[refactor] adopt yamux-only tunnel transport [BREAK]

- replace gRPC/DTLS tunnel paths with TLS + yamux transport
- remove legacy protocol, protobuf, DTLS, and proxy implementations
- simplify server and client entrypoints around yamux
- update configuration, metrics, API, and architecture documentation
- add bidirectional yamux session tests
- verify with go test ./...
This commit is contained in:
dalbodeule
2026-09-04 16:11:18 +09:00
parent 8e2f1e68cb
commit fe1018469d
37 changed files with 911 additions and 7102 deletions
+2 -2
View File
@@ -29,8 +29,8 @@ import (
// Manager 는 ACME 기반 인증서 관리를 추상화합니다. (ko)
// Manager abstracts ACME-based certificate management. (en)
type Manager interface {
// TLSConfig 는 HTTPS 및 DTLS 서버에 주입할 tls.Config 를 반환합니다. (ko)
// TLSConfig returns a tls.Config to be used by HTTPS and DTLS servers. (en)
// TLSConfig 는 HTTPS 및 TLS 터널 listener에 주입할 tls.Config 를 반환합니다. (ko)
// TLSConfig returns a tls.Config for the HTTPS and TLS tunnel listeners. (en)
TLSConfig() *tls.Config
}
+2 -2
View File
@@ -8,8 +8,8 @@ import (
"github.com/dalbodeule/hop-gate/ent"
entdomain "github.com/dalbodeule/hop-gate/ent/domain"
"github.com/dalbodeule/hop-gate/internal/dtls"
"github.com/dalbodeule/hop-gate/internal/logging"
"github.com/dalbodeule/hop-gate/internal/tunnel"
)
// entDomainValidator 는 ent.Client 를 사용해 Domain 테이블에서
@@ -22,7 +22,7 @@ type entDomainValidator struct {
// NewEntDomainValidator 는 ent 기반 DomainValidator 를 생성합니다.
// - domain 파라미터는 "host" 또는 "host:port" 형태 모두 허용하며,
// DB 조회 시에는 host 부분만 사용합니다.
func NewEntDomainValidator(logger logging.Logger, client *ent.Client) dtls.DomainValidator {
func NewEntDomainValidator(logger logging.Logger, client *ent.Client) tunnel.DomainValidator {
return &entDomainValidator{
logger: logger.With(logging.Fields{"component": "domain_validator"}),
client: client,
+4 -4
View File
@@ -30,7 +30,7 @@ type LokiConfig struct {
type ServerConfig struct {
HTTPListen string // 예: ":80"
HTTPSListen string // 예: ":443"
DTLSListen string // 예: ":443"
TunnelListen string // TLS + yamux tunnel listener, 예: ":7443"
Domain string // 메인 도메인
ProxyDomains []string // 프록시 서브도메인 또는 별도 도메인
Debug bool // true 이면 디버그 모드 (예: self-signed 인증서 신뢰, 검증 스킵 등)
@@ -40,7 +40,7 @@ type ServerConfig struct {
// ClientConfig 는 클라이언트 프로세스 설정을 담습니다.
// 현재 클라이언트는 다음 4가지 설정만 사용합니다.
// - ServerAddr : DTLS 서버 주소 (host:port)
// - ServerAddr : 터널 서버 주소 (host:port)
// - Domain : 서버에서 등록된 도메인 (예: api.example.com)
// - ClientAPIKey : 도메인에 매핑된 64자 클라이언트 API Key
// - LocalTarget : 로컬에서 요청할 서버 주소 (예: 127.0.0.1:8080)
@@ -48,7 +48,7 @@ type ServerConfig struct {
// 값은 .env/환경변수와 CLI 인자를 조합해 구성하며,
// CLI 인자가 우선, env 가 후순위로 적용됩니다.
type ClientConfig struct {
ServerAddr string // DTLS 서버 주소 (host:port)
ServerAddr string // 터널 서버 주소 (host:port)
Domain string // 서버에서 등록된 도메인 (예: api.example.com)
ClientAPIKey string // 도메인에 매핑된 64자 클라이언트 API Key
LocalTarget string // 로컬에서 요청할 서버 주소 (예: 127.0.0.1:8080)
@@ -224,7 +224,7 @@ func LoadServerConfigFromEnv() (*ServerConfig, error) {
cfg := &ServerConfig{
HTTPListen: getEnvOrDefault("HOP_SERVER_HTTP_LISTEN", ":80"),
HTTPSListen: getEnvOrDefault("HOP_SERVER_HTTPS_LISTEN", ":443"),
DTLSListen: getEnvOrDefault("HOP_SERVER_DTLS_LISTEN", ":443"),
TunnelListen: getEnvOrDefault("HOP_SERVER_TUNNEL_LISTEN", ":7443"),
Domain: os.Getenv("HOP_SERVER_DOMAIN"),
ProxyDomains: parseCSVEnv("HOP_SERVER_PROXY_DOMAINS"),
Debug: getEnvBool("HOP_SERVER_DEBUG", false),
-23
View File
@@ -1,23 +0,0 @@
package dtls
import "io"
// Session 은 DTLS 위의 양방향 스트림을 추상화합니다.
type Session interface {
io.ReadWriteCloser
ID() string
}
// Server 는 다중 클라이언트 DTLS 세션을 관리하는 추상 인터페이스입니다.
type Server interface {
Accept() (Session, error)
Close() error
}
// Client 는 단일 서버와의 DTLS 세션을 관리하는 추상 인터페이스입니다.
type Client interface {
Connect() (Session, error)
Close() error
}
// 실제 구현은 향후 pion/dtls 등을 사용해 추가합니다.
-210
View File
@@ -1,210 +0,0 @@
package dtls
import (
"bufio"
"context"
"encoding/json"
"fmt"
"strings"
"github.com/dalbodeule/hop-gate/internal/logging"
)
// DomainValidator 는 (domain, clientAPIKey) 조합이 유효한지 검증하는 인터페이스입니다.
// 실제 구현에서는 ent + PostgreSQL 을 사용해 Domain 테이블을 조회하면 됩니다.
type DomainValidator interface {
ValidateDomainAPIKey(ctx context.Context, domain, clientAPIKey string) error
}
// ServerHandshakeResult 는 서버 측에서 핸드셰이크가 완료된 후의 정보를 담습니다.
type ServerHandshakeResult struct {
Domain string
}
// ClientHandshakeResult 는 클라이언트 측에서 핸드셰이크가 완료된 후의 정보를 담습니다.
type ClientHandshakeResult struct {
Domain string
Message string
}
// handshakeRequest 는 클라이언트가 최초 DTLS 연결 후 서버로 보내는 메시지입니다.
// - Domain: 사용할 도메인 (예: api.example.com)
// - ClientAPIKey: 관리 plane 을 통해 발급받은 64자 API Key
type handshakeRequest struct {
Domain string `json:"domain"`
ClientAPIKey string `json:"client_api_key"`
}
// handshakeResponse 는 서버가 핸드셰이크 결과를 클라이언트로 돌려줄 때 사용하는 메시지입니다.
type handshakeResponse struct {
OK bool `json:"ok"`
Message string `json:"message"`
Domain string `json:"domain"`
}
// PerformServerHandshake 는 서버 측에서 DTLS 세션이 생성된 직후 호출되어
// 클라이언트가 보낸 (domain, client_api_key)를 검증합니다.
//
// 성공 시:
// - 서버 로그에 "어떤 도메인이 연결되었는지" 기록
// - 클라이언트로 OK 응답을 전송
// - ServerHandshakeResult 에 도메인 정보를 담아 반환
func PerformServerHandshake(
ctx context.Context,
sess Session,
validator DomainValidator,
logger logging.Logger,
) (*ServerHandshakeResult, error) {
log := logger.With(logging.Fields{"phase": "dtls_handshake", "side": "server"})
if err := ctx.Err(); err != nil {
return nil, err
}
var req handshakeRequest
// NOTE: pion/dtls 는 application plaintext 를 Caller's buffer 에 복호화하므로,
// JSON 디코더가 사용하는 버퍼 크기가 너무 작으면 "dtls: buffer too small" 이 발생할 수 있습니다.
// 이를 피하기 위해 충분히 큰 bufio.Reader(예: 64KiB)를 사용합니다. (ko)
// pion/dtls decrypts application data into the buffer provided by the caller.
// To avoid "dtls: buffer too small" errors when JSON payloads are larger than
// the default decoder buffer, we wrap the session in a bufio.Reader with a
// sufficiently large size (e.g. 64KiB). (en)
dec := json.NewDecoder(bufio.NewReaderSize(sess, 64*1024))
if err := dec.Decode(&req); err != nil {
log.Error("failed to read handshake request", logging.Fields{
"error": err.Error(),
})
return nil, fmt.Errorf("read handshake request: %w", err)
}
req.Domain = stringTrimSpace(req.Domain)
req.ClientAPIKey = stringTrimSpace(req.ClientAPIKey)
if req.Domain == "" || req.ClientAPIKey == "" {
_ = writeHandshakeResponse(sess, handshakeResponse{
OK: false,
Message: "domain and client_api_key are required",
Domain: req.Domain,
})
return nil, fmt.Errorf("invalid handshake parameters")
}
if err := validator.ValidateDomainAPIKey(ctx, req.Domain, req.ClientAPIKey); err != nil {
log.Warn("domain/api_key validation failed", logging.Fields{
"domain": req.Domain,
"error": err.Error(),
})
_ = writeHandshakeResponse(sess, handshakeResponse{
OK: false,
Message: "invalid domain or api key",
Domain: req.Domain,
})
return nil, fmt.Errorf("handshake validation failed: %w", err)
}
// 검증 성공
log.Info("dtls handshake success", logging.Fields{
"domain": req.Domain,
})
if err := writeHandshakeResponse(sess, handshakeResponse{
OK: true,
Message: "handshake ok",
Domain: req.Domain,
}); err != nil {
log.Error("failed to write handshake response", logging.Fields{
"domain": req.Domain,
"error": err.Error(),
})
return nil, fmt.Errorf("write handshake response: %w", err)
}
return &ServerHandshakeResult{
Domain: req.Domain,
}, nil
}
// PerformClientHandshake 는 클라이언트 측에서 DTLS 세션이 생성된 직후 호출되어
// 서버로 (domain, client_api_key)를 전송하고 결과를 검증합니다.
//
// localTarget 은 "로컬에서 요청할 서버 주소" (예: 127.0.0.1:8080) 로,
// 핸드셰이크 성공 시 로그에 함께 출력됩니다.
func PerformClientHandshake(
ctx context.Context,
sess Session,
logger logging.Logger,
domain string,
clientAPIKey string,
localTarget string,
) (*ClientHandshakeResult, error) {
log := logger.With(logging.Fields{"phase": "dtls_handshake", "side": "client"})
if err := ctx.Err(); err != nil {
return nil, err
}
req := handshakeRequest{
Domain: stringTrimSpace(domain),
ClientAPIKey: stringTrimSpace(clientAPIKey),
}
if req.Domain == "" || req.ClientAPIKey == "" {
return nil, fmt.Errorf("domain and client_api_key are required")
}
if err := writeHandshakeRequest(sess, req); err != nil {
log.Error("failed to write handshake request", logging.Fields{
"error": err.Error(),
})
return nil, fmt.Errorf("write handshake request: %w", err)
}
var resp handshakeResponse
// 클라이언트 측에서도 동일하게 큰 버퍼를 사용해 "buffer too small" 오류를 방지합니다. (ko)
// Use the same larger buffer on the client side as well. (en)
dec := json.NewDecoder(bufio.NewReaderSize(sess, 64*1024))
if err := dec.Decode(&resp); err != nil {
log.Error("failed to read handshake response", logging.Fields{
"error": err.Error(),
})
return nil, fmt.Errorf("read handshake response: %w", err)
}
if !resp.OK {
log.Error("dtls handshake failed", logging.Fields{
"domain": req.Domain,
"message": resp.Message,
})
return nil, fmt.Errorf("handshake failed: %s", resp.Message)
}
// 성공 로그: 연결 성공 메시지 + 도메인 + 로컬에서 요청할 서버 주소
log.Info("dtls handshake success", logging.Fields{
"domain": resp.Domain,
"message": resp.Message,
"local_target": localTarget,
})
return &ClientHandshakeResult{
Domain: resp.Domain,
Message: resp.Message,
}, nil
}
// writeHandshakeRequest 는 JSON 인코더를 사용해 handshakeRequest 를 세션으로 전송합니다.
func writeHandshakeRequest(sess Session, req handshakeRequest) error {
enc := json.NewEncoder(sess)
return enc.Encode(&req)
}
// writeHandshakeResponse 는 JSON 인코더를 사용해 handshakeResponse 를 세션으로 전송합니다.
func writeHandshakeResponse(sess Session, resp handshakeResponse) error {
enc := json.NewEncoder(sess)
return enc.Encode(&resp)
}
func stringTrimSpace(s string) string {
return strings.TrimSpace(s)
}
-69
View File
@@ -1,69 +0,0 @@
package dtls
import (
"crypto/rand"
"crypto/rsa"
"crypto/tls"
"crypto/x509"
"crypto/x509/pkix"
"math/big"
"net"
"time"
)
// NewSelfSignedLocalhostConfig 는 테스트용 self-signed TLS 설정을 생성합니다.
//
// - CN: "localhost"
// - DNS SAN: ["localhost"]
// - IP SAN: [127.0.0.1]
// - 유효기간: 생성 시점 기준 1년
//
// DTLS, 일반 TLS 서버 모두에서 사용할 수 있으며,
// 서버 측에서는 Certificates 에 이 인증서를 넣어주고,
// 클라이언트 측에서는 debug 모드에서 InsecureSkipVerify 를 true 로 두어
// 체인 검증을 스킵하는 방식으로 사용할 수 있습니다.
func NewSelfSignedLocalhostConfig() (*tls.Config, error) {
priv, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
return nil, err
}
serial, err := rand.Int(rand.Reader, big.NewInt(1<<62))
if err != nil {
return nil, err
}
notBefore := time.Now().Add(-1 * time.Hour)
notAfter := notBefore.Add(365 * 24 * time.Hour)
template := &x509.Certificate{
SerialNumber: serial,
Subject: pkix.Name{
CommonName: "localhost",
},
NotBefore: notBefore,
NotAfter: notAfter,
KeyUsage: x509.KeyUsageKeyEncipherment | x509.KeyUsageDigitalSignature,
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
BasicConstraintsValid: true,
DNSNames: []string{"localhost"},
IPAddresses: []net.IP{net.ParseIP("127.0.0.1")},
}
derBytes, err := x509.CreateCertificate(rand.Reader, template, template, &priv.PublicKey, priv)
if err != nil {
return nil, err
}
tlsCert := tls.Certificate{
Certificate: [][]byte{derBytes},
PrivateKey: priv,
}
return &tls.Config{
Certificates: []tls.Certificate{tlsCert},
MinVersion: tls.VersionTLS12,
}, nil
}
-58
View File
@@ -1,58 +0,0 @@
package dtls
import (
"crypto/tls"
"fmt"
"time"
)
// PionServerConfig 는 DTLS 서버 리스너 구성을 정의하는 기존 구조체를 그대로 유지합니다. (ko)
// PionServerConfig keeps the old DTLS server listener configuration shape for compatibility. (en)
type PionServerConfig struct {
Addr string
TLSConfig *tls.Config
}
// PionClientConfig 는 DTLS 클라이언트 구성을 정의하는 기존 구조체를 그대로 유지합니다. (ko)
// PionClientConfig keeps the old DTLS client configuration shape for compatibility. (en)
type PionClientConfig struct {
Addr string
TLSConfig *tls.Config
Timeout time.Duration
}
// disabledServer 는 DTLS 전송이 비활성화되었음을 나타내는 더미 구현입니다. (ko)
// disabledServer is a dummy Server implementation indicating that DTLS transport is disabled. (en)
type disabledServer struct{}
func (s *disabledServer) Accept() (Session, error) {
return nil, fmt.Errorf("dtls transport is disabled; use gRPC tunnel instead")
}
func (s *disabledServer) Close() error {
return nil
}
// disabledClient 는 DTLS 전송이 비활성화되었음을 나타내는 더미 구현입니다. (ko)
// disabledClient is a dummy Client implementation indicating that DTLS transport is disabled. (en)
type disabledClient struct{}
func (c *disabledClient) Connect() (Session, error) {
return nil, fmt.Errorf("dtls transport is disabled; use gRPC tunnel instead")
}
func (c *disabledClient) Close() error {
return nil
}
// NewPionServer 는 더 이상 실제 DTLS 서버를 생성하지 않고, 항상 에러를 반환합니다. (ko)
// NewPionServer no longer creates a real DTLS server and always returns an error. (en)
func NewPionServer(cfg PionServerConfig) (Server, error) {
return nil, fmt.Errorf("dtls transport is disabled; NewPionServer is no longer supported")
}
// NewPionClient 는 더 이상 실제 DTLS 클라이언트를 생성하지 않고, disabledClient 를 반환합니다. (ko)
// NewPionClient no longer creates a real DTLS client and instead returns a disabledClient. (en)
func NewPionClient(cfg PionClientConfig) Client {
return &disabledClient{}
}
-33
View File
@@ -1,33 +0,0 @@
package dtls
import (
"context"
"github.com/dalbodeule/hop-gate/internal/logging"
)
// DomainValidator 는 handshake.go 에 정의된 인터페이스를 재노출합니다.
// (동일 패키지이므로 별도 선언 없이 사용하지만, 여기에 더미 구현을 둡니다.)
// DummyDomainValidator 는 임시 개발용으로 모든 (domain, api_key) 조합을 허용하는 Validator 입니다.
// 실제 운영 환경에서는 ent + PostgreSQL 기반의 구현으로 교체해야 합니다.
type DummyDomainValidator struct {
Logger logging.Logger
}
func (d DummyDomainValidator) ValidateDomainAPIKey(ctx context.Context, domain, clientAPIKey string) error {
if d.Logger != nil {
d.Logger.Debug("dummy domain validator used (ALWAYS ALLOW)", logging.Fields{
"domain": domain,
"client_api_key_masked": maskKey(clientAPIKey),
})
}
return nil
}
func maskKey(key string) string {
if len(key) <= 8 {
return "***"
}
return key[:4] + "..." + key[len(key)-4:]
}
+2 -2
View File
@@ -1,2 +1,2 @@
/*! tailwindcss v4.1.17 | MIT License | https://tailwindcss.com */
@layer properties{@supports (((-webkit-hyphens:none)) and (not (margin-trim:inline))) or ((-moz-orient:inline) and (not (color:rgb(from red r g b)))){*,:before,:after,::backdrop{--tw-tracking:initial;--tw-blur:initial;--tw-brightness:initial;--tw-contrast:initial;--tw-grayscale:initial;--tw-hue-rotate:initial;--tw-invert:initial;--tw-opacity:initial;--tw-saturate:initial;--tw-sepia:initial;--tw-drop-shadow:initial;--tw-drop-shadow-color:initial;--tw-drop-shadow-alpha:100%;--tw-drop-shadow-size:initial}}}.visible{visibility:visible}.absolute{position:absolute}.fixed{position:fixed}.static{position:static}.container{width:100%}.contents{display:contents}.flex{display:flex}.inline-flex{display:inline-flex}.table{display:table}.min-h-screen{min-height:100vh}.w-\[240px\]{width:240px}.w-full{width:100%}.flex-col{flex-direction:column}.items-baseline{align-items:baseline}.items-center{align-items:center}.justify-center{justify-content:center}.text-center{text-align:center}.tracking-\[0\.25em\]{--tw-tracking:.25em;letter-spacing:.25em}.uppercase{text-transform:uppercase}.opacity-90{opacity:.9}.filter{filter:var(--tw-blur,)var(--tw-brightness,)var(--tw-contrast,)var(--tw-grayscale,)var(--tw-hue-rotate,)var(--tw-invert,)var(--tw-saturate,)var(--tw-sepia,)var(--tw-drop-shadow,)}@property --tw-tracking{syntax:"*";inherits:false}@property --tw-blur{syntax:"*";inherits:false}@property --tw-brightness{syntax:"*";inherits:false}@property --tw-contrast{syntax:"*";inherits:false}@property --tw-grayscale{syntax:"*";inherits:false}@property --tw-hue-rotate{syntax:"*";inherits:false}@property --tw-invert{syntax:"*";inherits:false}@property --tw-opacity{syntax:"*";inherits:false}@property --tw-saturate{syntax:"*";inherits:false}@property --tw-sepia{syntax:"*";inherits:false}@property --tw-drop-shadow{syntax:"*";inherits:false}@property --tw-drop-shadow-color{syntax:"*";inherits:false}@property --tw-drop-shadow-alpha{syntax:"<percentage>";inherits:false;initial-value:100%}@property --tw-drop-shadow-size{syntax:"*";inherits:false}
/*! tailwindcss v4.3.3 | MIT License | https://tailwindcss.com */
@layer properties{@supports (((-webkit-hyphens:none)) and (not (margin-trim:inline))) or ((-moz-orient:inline) and (not (color:rgb(from red r g b)))){*,:before,:after,::backdrop{--tw-tracking:initial;--tw-blur:initial;--tw-brightness:initial;--tw-contrast:initial;--tw-grayscale:initial;--tw-hue-rotate:initial;--tw-invert:initial;--tw-opacity:initial;--tw-saturate:initial;--tw-sepia:initial;--tw-drop-shadow:initial;--tw-drop-shadow-color:initial;--tw-drop-shadow-alpha:100%;--tw-drop-shadow-size:initial}}}.visible{visibility:visible}.absolute{position:absolute}.fixed{position:fixed}.static{position:static}.container{width:100%}.contents{display:contents}.flex{display:flex}.inline-flex{display:inline-flex}.table{display:table}.min-h-screen{min-height:100vh}.w-\[240px\]{width:240px}.w-full{width:100%}.flex-col{flex-direction:column}.items-baseline{align-items:baseline}.items-center{align-items:center}.justify-center{justify-content:center}.text-center{text-align:center}.tracking-\[0\.25em\]{--tw-tracking:.25em;letter-spacing:.25em}.uppercase{text-transform:uppercase}.opacity-90{opacity:.9}.filter{filter:var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,)}@property --tw-tracking{syntax:"*";inherits:false}@property --tw-blur{syntax:"*";inherits:false}@property --tw-brightness{syntax:"*";inherits:false}@property --tw-contrast{syntax:"*";inherits:false}@property --tw-grayscale{syntax:"*";inherits:false}@property --tw-hue-rotate{syntax:"*";inherits:false}@property --tw-invert{syntax:"*";inherits:false}@property --tw-opacity{syntax:"*";inherits:false}@property --tw-saturate{syntax:"*";inherits:false}@property --tw-sepia{syntax:"*";inherits:false}@property --tw-drop-shadow{syntax:"*";inherits:false}@property --tw-drop-shadow-color{syntax:"*";inherits:false}@property --tw-drop-shadow-alpha{syntax:"<percentage>";inherits:false;initial-value:100%}@property --tw-drop-shadow-size{syntax:"*";inherits:false}
+2 -2
View File
@@ -10,8 +10,8 @@ import (
)
// StatusTLSHandshakeFailed is an HTTP-style status code representing
// a TLS/DTLS handshake failure (similar to Cloudflare 525).
// TLS/DTLS 핸드셰이크 실패를 나타내는 HTTP 스타일 상태 코드입니다. (예: 525)
// a TLS tunnel handshake failure (similar to Cloudflare 525).
// TLS 터널 핸드셰이크 실패를 나타내는 HTTP 스타일 상태 코드입니다. (예: 525)
const StatusTLSHandshakeFailed = 525
// StatusGatewayTimeout is an HTTP-style status code representing
+1 -11
View File
@@ -8,15 +8,6 @@ import (
// Prometheus 기본 네임스페이스를 사용하며, 메트릭 이름에 hopgate_ 접두어를 붙입니다.
var (
// DTLS 핸드셰이크 총 횟수 (성공/실패 라벨 포함).
DTLSHandshakesTotal = prometheus.NewCounterVec(
prometheus.CounterOpts{
Name: "hopgate_dtls_handshakes_total",
Help: "Total number of DTLS handshakes, labeled by result.",
},
[]string{"result"}, // success, failure
)
// HTTP/Proxy 엔드포인트를 통해 들어온 요청 수 (메서드/상태 코드 라벨 포함).
HTTPRequestsTotal = prometheus.NewCounterVec(
prometheus.CounterOpts{
@@ -42,7 +33,7 @@ var (
Name: "hopgate_proxy_errors_total",
Help: "Total number of proxy-related errors, labeled by error type.",
},
[]string{"type"}, // e.g. no_dtls_session, dtls_forward_failed, acme_http01_error
[]string{"type"}, // e.g. no_tunnel_session, tunnel_forward_failed, acme_http01_error
)
)
@@ -50,7 +41,6 @@ var (
// 서버 시작 시 한 번만 호출해야 합니다.
func MustRegister() {
prometheus.MustRegister(
DTLSHandshakesTotal,
HTTPRequestsTotal,
HTTPRequestDurationSeconds,
ProxyErrorsTotal,
-427
View File
@@ -1,427 +0,0 @@
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)
}
}
-226
View File
@@ -1,226 +0,0 @@
package protocol
import (
"bufio"
"bytes"
"io"
"testing"
)
// mockDatagramConn simulates a datagram-based connection (like DTLS over UDP)
// where each Write sends a separate message and each Read receives a complete message.
// This mock verifies the FIXED behavior where the codec properly handles message boundaries.
type mockDatagramConn struct {
messages [][]byte
readIdx int
}
func newMockDatagramConn() *mockDatagramConn {
return &mockDatagramConn{
messages: make([][]byte, 0),
}
}
func (m *mockDatagramConn) Write(p []byte) (n int, err error) {
// Simulate datagram behavior: each Write is a separate message
msg := make([]byte, len(p))
copy(msg, p)
m.messages = append(m.messages, msg)
return len(p), nil
}
func (m *mockDatagramConn) Read(p []byte) (n int, err error) {
// Simulate datagram behavior: each Read returns a complete message
if m.readIdx >= len(m.messages) {
return 0, io.EOF
}
msg := m.messages[m.readIdx]
m.readIdx++
if len(p) < len(msg) {
return 0, io.ErrShortBuffer
}
copy(p, msg)
return len(msg), nil
}
// TestProtobufCodecDatagramBehavior tests that the protobuf codec works correctly
// with datagram-based transports (like DTLS over UDP) where message boundaries are preserved.
func TestProtobufCodecDatagramBehavior(t *testing.T) {
codec := protobufCodec{}
conn := newMockDatagramConn()
// Create a test envelope
testEnv := &Envelope{
Type: MessageTypeHTTP,
HTTPRequest: &Request{
RequestID: "test-req-123",
ClientID: "client-1",
ServiceName: "test-service",
Method: "GET",
URL: "/test/path",
Header: map[string][]string{
"User-Agent": {"test-client"},
},
Body: []byte("test body content"),
},
}
// Encode the envelope
if err := codec.Encode(conn, testEnv); err != nil {
t.Fatalf("Failed to encode envelope: %v", err)
}
// Verify that exactly one message was written (length prefix + data in single Write)
if len(conn.messages) != 1 {
t.Fatalf("Expected 1 message to be written, got %d", len(conn.messages))
}
// Verify the message structure: [4-byte length][protobuf data]
msg := conn.messages[0]
if len(msg) < 4 {
t.Fatalf("Message too short: %d bytes", len(msg))
}
// Decode the envelope using a buffered reader (as we do in actual code)
// to handle datagram-based reading properly
reader := bufio.NewReaderSize(conn, GetDTLSReadBufferSize())
var decodedEnv Envelope
if err := codec.Decode(reader, &decodedEnv); err != nil {
t.Fatalf("Failed to decode envelope: %v", err)
}
// Verify the decoded envelope matches the original
if decodedEnv.Type != testEnv.Type {
t.Errorf("Type mismatch: got %v, want %v", decodedEnv.Type, testEnv.Type)
}
if decodedEnv.HTTPRequest == nil {
t.Fatal("HTTPRequest is nil after decode")
}
if decodedEnv.HTTPRequest.RequestID != testEnv.HTTPRequest.RequestID {
t.Errorf("RequestID mismatch: got %v, want %v", decodedEnv.HTTPRequest.RequestID, testEnv.HTTPRequest.RequestID)
}
if decodedEnv.HTTPRequest.Method != testEnv.HTTPRequest.Method {
t.Errorf("Method mismatch: got %v, want %v", decodedEnv.HTTPRequest.Method, testEnv.HTTPRequest.Method)
}
if decodedEnv.HTTPRequest.URL != testEnv.HTTPRequest.URL {
t.Errorf("URL mismatch: got %v, want %v", decodedEnv.HTTPRequest.URL, testEnv.HTTPRequest.URL)
}
if !bytes.Equal(decodedEnv.HTTPRequest.Body, testEnv.HTTPRequest.Body) {
t.Errorf("Body mismatch: got %v, want %v", decodedEnv.HTTPRequest.Body, testEnv.HTTPRequest.Body)
}
}
// TestProtobufCodecStreamData tests encoding/decoding of StreamData messages
func TestProtobufCodecStreamData(t *testing.T) {
codec := protobufCodec{}
conn := newMockDatagramConn()
// Create a StreamData envelope
testEnv := &Envelope{
Type: MessageTypeStreamData,
StreamData: &StreamData{
ID: StreamID("stream-123"),
Seq: 42,
Data: []byte("stream data payload"),
},
}
// Encode
if err := codec.Encode(conn, testEnv); err != nil {
t.Fatalf("Failed to encode StreamData: %v", err)
}
// Verify single message
if len(conn.messages) != 1 {
t.Fatalf("Expected 1 message, got %d", len(conn.messages))
}
// Decode using a buffered reader (as we do in actual code)
reader := bufio.NewReaderSize(conn, GetDTLSReadBufferSize())
var decodedEnv Envelope
if err := codec.Decode(reader, &decodedEnv); err != nil {
t.Fatalf("Failed to decode StreamData: %v", err)
}
// Verify
if decodedEnv.Type != MessageTypeStreamData {
t.Errorf("Type mismatch: got %v, want %v", decodedEnv.Type, MessageTypeStreamData)
}
if decodedEnv.StreamData == nil {
t.Fatal("StreamData is nil")
}
if decodedEnv.StreamData.ID != testEnv.StreamData.ID {
t.Errorf("StreamID mismatch: got %v, want %v", decodedEnv.StreamData.ID, testEnv.StreamData.ID)
}
if decodedEnv.StreamData.Seq != testEnv.StreamData.Seq {
t.Errorf("Seq mismatch: got %v, want %v", decodedEnv.StreamData.Seq, testEnv.StreamData.Seq)
}
if !bytes.Equal(decodedEnv.StreamData.Data, testEnv.StreamData.Data) {
t.Errorf("Data mismatch: got %v, want %v", decodedEnv.StreamData.Data, testEnv.StreamData.Data)
}
}
// TestProtobufCodecMultipleMessages tests encoding/decoding multiple messages
func TestProtobufCodecMultipleMessages(t *testing.T) {
codec := protobufCodec{}
conn := newMockDatagramConn()
// Create multiple test envelopes
envelopes := []*Envelope{
{
Type: MessageTypeStreamOpen,
StreamOpen: &StreamOpen{
ID: StreamID("stream-1"),
Service: "test-service",
TargetAddr: "127.0.0.1:8080",
},
},
{
Type: MessageTypeStreamData,
StreamData: &StreamData{
ID: StreamID("stream-1"),
Seq: 1,
Data: []byte("first chunk"),
},
},
{
Type: MessageTypeStreamData,
StreamData: &StreamData{
ID: StreamID("stream-1"),
Seq: 2,
Data: []byte("second chunk"),
},
},
{
Type: MessageTypeStreamClose,
StreamClose: &StreamClose{
ID: StreamID("stream-1"),
Error: "",
},
},
}
// Encode all messages
for i, env := range envelopes {
if err := codec.Encode(conn, env); err != nil {
t.Fatalf("Failed to encode message %d: %v", i, err)
}
}
// Verify that each encode produced exactly one message
if len(conn.messages) != len(envelopes) {
t.Fatalf("Expected %d messages, got %d", len(envelopes), len(conn.messages))
}
// Decode and verify all messages using a buffered reader (as we do in actual code)
reader := bufio.NewReaderSize(conn, GetDTLSReadBufferSize())
for i := 0; i < len(envelopes); i++ {
var decoded Envelope
if err := codec.Decode(reader, &decoded); err != nil {
t.Fatalf("Failed to decode message %d: %v", i, err)
}
if decoded.Type != envelopes[i].Type {
t.Errorf("Message %d type mismatch: got %v, want %v", i, decoded.Type, envelopes[i].Type)
}
}
}
-103
View File
@@ -1,103 +0,0 @@
syntax = "proto3";
package hopgate.protocol.v1;
option go_package = "internal/protocol/pb;pb";
// HeaderValues 는 HTTP 헤더의 다중 값 표현을 위한 래퍼입니다.
// HeaderValues wraps multiple header values for a single HTTP header key.
message HeaderValues {
repeated string values = 1;
}
// Request 는 DTLS 터널 위에서 교환되는 HTTP 요청을 표현합니다.
// This mirrors internal/protocol.Request.
message Request {
string request_id = 1;
string client_id = 2; // optional client identifier
string service_name = 3; // logical service name on the client side
string method = 4;
string url = 5;
// HTTP header: map of key -> multiple values.
map<string, HeaderValues> header = 6;
// Raw HTTP body bytes.
bytes body = 7;
}
// Response 는 DTLS 터널 위에서 교환되는 HTTP 응답을 표현합니다.
// This mirrors internal/protocol.Response.
message Response {
string request_id = 1;
int32 status = 2;
// HTTP header.
map<string, HeaderValues> header = 3;
// Raw HTTP body bytes.
bytes body = 4;
// Optional error description when tunneling fails.
string error = 5;
}
// StreamOpen 은 새로운 스트림(HTTP 요청/응답, WebSocket 등)을 여는 메시지입니다.
// This represents opening a new stream (HTTP request/response, WebSocket, etc.).
message StreamOpen {
string id = 1; // StreamID (text form)
// Which logical service / local target to use on the client side.
string service_name = 2;
string target_addr = 3; // e.g. "127.0.0.1:8080"
// Initial HTTP-like headers (including Upgrade, etc.).
map<string, HeaderValues> header = 4;
}
// StreamData 는 이미 열린 스트림에 대한 단방향 데이터 프레임입니다.
// This is a unidirectional data frame on an already-open stream.
message StreamData {
string id = 1; // StreamID
uint64 seq = 2; // per-stream sequence number starting from 0
bytes data = 3;
}
// StreamAck 는 StreamData 에 대한 ACK/NACK 및 선택적 재전송 힌트를 전달합니다.
// This conveys ACK/NACK and optional retransmission hints for StreamData.
message StreamAck {
string id = 1;
// Last contiguously received sequence number (starting from 0).
uint64 ack_seq = 2;
// Additional missing sequence numbers beyond ack_seq (optional).
repeated uint64 lost_seqs = 3;
// Optional receive window size hint.
uint32 window_size = 4;
}
// StreamClose 는 스트림 종료(정상/에러)를 알립니다.
// This indicates normal or error termination of a stream.
message StreamClose {
string id = 1;
string error = 2; // empty means normal close
}
// Envelope 는 DTLS 세션 위에서 교환되는 상위 레벨 메시지 컨테이너입니다.
// 하나의 Envelope 에는 HTTP 요청/응답 또는 스트림 관련 메시지 중 하나만 포함됩니다.
// Envelope is the top-level container exchanged over the DTLS session.
// Exactly one payload (http_request/http_response/stream_*) is set per message.
message Envelope {
oneof payload {
Request http_request = 1;
Response http_response = 2;
StreamOpen stream_open = 3;
StreamData stream_data = 4;
StreamClose stream_close = 5;
StreamAck stream_ack = 6;
}
}
-799
View File
@@ -1,799 +0,0 @@
// Code generated by protoc-gen-go. DO NOT EDIT.
// versions:
// protoc-gen-go v1.36.10
// protoc v6.33.1
// source: internal/protocol/hopgate_stream.proto
package pb
import (
protoreflect "google.golang.org/protobuf/reflect/protoreflect"
protoimpl "google.golang.org/protobuf/runtime/protoimpl"
reflect "reflect"
sync "sync"
unsafe "unsafe"
)
const (
// Verify that this generated code is sufficiently up-to-date.
_ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion)
// Verify that runtime/protoimpl is sufficiently up-to-date.
_ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20)
)
// HeaderValues 는 HTTP 헤더의 다중 값 표현을 위한 래퍼입니다.
// HeaderValues wraps multiple header values for a single HTTP header key.
type HeaderValues struct {
state protoimpl.MessageState `protogen:"open.v1"`
Values []string `protobuf:"bytes,1,rep,name=values,proto3" json:"values,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *HeaderValues) Reset() {
*x = HeaderValues{}
mi := &file_internal_protocol_hopgate_stream_proto_msgTypes[0]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *HeaderValues) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*HeaderValues) ProtoMessage() {}
func (x *HeaderValues) ProtoReflect() protoreflect.Message {
mi := &file_internal_protocol_hopgate_stream_proto_msgTypes[0]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use HeaderValues.ProtoReflect.Descriptor instead.
func (*HeaderValues) Descriptor() ([]byte, []int) {
return file_internal_protocol_hopgate_stream_proto_rawDescGZIP(), []int{0}
}
func (x *HeaderValues) GetValues() []string {
if x != nil {
return x.Values
}
return nil
}
// Request 는 DTLS 터널 위에서 교환되는 HTTP 요청을 표현합니다.
// This mirrors internal/protocol.Request.
type Request struct {
state protoimpl.MessageState `protogen:"open.v1"`
RequestId string `protobuf:"bytes,1,opt,name=request_id,json=requestId,proto3" json:"request_id,omitempty"`
ClientId string `protobuf:"bytes,2,opt,name=client_id,json=clientId,proto3" json:"client_id,omitempty"` // optional client identifier
ServiceName string `protobuf:"bytes,3,opt,name=service_name,json=serviceName,proto3" json:"service_name,omitempty"` // logical service name on the client side
Method string `protobuf:"bytes,4,opt,name=method,proto3" json:"method,omitempty"`
Url string `protobuf:"bytes,5,opt,name=url,proto3" json:"url,omitempty"`
// HTTP header: map of key -> multiple values.
Header map[string]*HeaderValues `protobuf:"bytes,6,rep,name=header,proto3" json:"header,omitempty" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"bytes,2,opt,name=value"`
// Raw HTTP body bytes.
Body []byte `protobuf:"bytes,7,opt,name=body,proto3" json:"body,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *Request) Reset() {
*x = Request{}
mi := &file_internal_protocol_hopgate_stream_proto_msgTypes[1]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *Request) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*Request) ProtoMessage() {}
func (x *Request) ProtoReflect() protoreflect.Message {
mi := &file_internal_protocol_hopgate_stream_proto_msgTypes[1]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use Request.ProtoReflect.Descriptor instead.
func (*Request) Descriptor() ([]byte, []int) {
return file_internal_protocol_hopgate_stream_proto_rawDescGZIP(), []int{1}
}
func (x *Request) GetRequestId() string {
if x != nil {
return x.RequestId
}
return ""
}
func (x *Request) GetClientId() string {
if x != nil {
return x.ClientId
}
return ""
}
func (x *Request) GetServiceName() string {
if x != nil {
return x.ServiceName
}
return ""
}
func (x *Request) GetMethod() string {
if x != nil {
return x.Method
}
return ""
}
func (x *Request) GetUrl() string {
if x != nil {
return x.Url
}
return ""
}
func (x *Request) GetHeader() map[string]*HeaderValues {
if x != nil {
return x.Header
}
return nil
}
func (x *Request) GetBody() []byte {
if x != nil {
return x.Body
}
return nil
}
// Response 는 DTLS 터널 위에서 교환되는 HTTP 응답을 표현합니다.
// This mirrors internal/protocol.Response.
type Response struct {
state protoimpl.MessageState `protogen:"open.v1"`
RequestId string `protobuf:"bytes,1,opt,name=request_id,json=requestId,proto3" json:"request_id,omitempty"`
Status int32 `protobuf:"varint,2,opt,name=status,proto3" json:"status,omitempty"`
// HTTP header.
Header map[string]*HeaderValues `protobuf:"bytes,3,rep,name=header,proto3" json:"header,omitempty" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"bytes,2,opt,name=value"`
// Raw HTTP body bytes.
Body []byte `protobuf:"bytes,4,opt,name=body,proto3" json:"body,omitempty"`
// Optional error description when tunneling fails.
Error string `protobuf:"bytes,5,opt,name=error,proto3" json:"error,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *Response) Reset() {
*x = Response{}
mi := &file_internal_protocol_hopgate_stream_proto_msgTypes[2]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *Response) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*Response) ProtoMessage() {}
func (x *Response) ProtoReflect() protoreflect.Message {
mi := &file_internal_protocol_hopgate_stream_proto_msgTypes[2]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use Response.ProtoReflect.Descriptor instead.
func (*Response) Descriptor() ([]byte, []int) {
return file_internal_protocol_hopgate_stream_proto_rawDescGZIP(), []int{2}
}
func (x *Response) GetRequestId() string {
if x != nil {
return x.RequestId
}
return ""
}
func (x *Response) GetStatus() int32 {
if x != nil {
return x.Status
}
return 0
}
func (x *Response) GetHeader() map[string]*HeaderValues {
if x != nil {
return x.Header
}
return nil
}
func (x *Response) GetBody() []byte {
if x != nil {
return x.Body
}
return nil
}
func (x *Response) GetError() string {
if x != nil {
return x.Error
}
return ""
}
// StreamOpen 은 새로운 스트림(HTTP 요청/응답, WebSocket 등)을 여는 메시지입니다.
// This represents opening a new stream (HTTP request/response, WebSocket, etc.).
type StreamOpen struct {
state protoimpl.MessageState `protogen:"open.v1"`
Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"` // StreamID (text form)
// Which logical service / local target to use on the client side.
ServiceName string `protobuf:"bytes,2,opt,name=service_name,json=serviceName,proto3" json:"service_name,omitempty"`
TargetAddr string `protobuf:"bytes,3,opt,name=target_addr,json=targetAddr,proto3" json:"target_addr,omitempty"` // e.g. "127.0.0.1:8080"
// Initial HTTP-like headers (including Upgrade, etc.).
Header map[string]*HeaderValues `protobuf:"bytes,4,rep,name=header,proto3" json:"header,omitempty" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"bytes,2,opt,name=value"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *StreamOpen) Reset() {
*x = StreamOpen{}
mi := &file_internal_protocol_hopgate_stream_proto_msgTypes[3]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *StreamOpen) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*StreamOpen) ProtoMessage() {}
func (x *StreamOpen) ProtoReflect() protoreflect.Message {
mi := &file_internal_protocol_hopgate_stream_proto_msgTypes[3]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use StreamOpen.ProtoReflect.Descriptor instead.
func (*StreamOpen) Descriptor() ([]byte, []int) {
return file_internal_protocol_hopgate_stream_proto_rawDescGZIP(), []int{3}
}
func (x *StreamOpen) GetId() string {
if x != nil {
return x.Id
}
return ""
}
func (x *StreamOpen) GetServiceName() string {
if x != nil {
return x.ServiceName
}
return ""
}
func (x *StreamOpen) GetTargetAddr() string {
if x != nil {
return x.TargetAddr
}
return ""
}
func (x *StreamOpen) GetHeader() map[string]*HeaderValues {
if x != nil {
return x.Header
}
return nil
}
// StreamData 는 이미 열린 스트림에 대한 단방향 데이터 프레임입니다.
// This is a unidirectional data frame on an already-open stream.
type StreamData struct {
state protoimpl.MessageState `protogen:"open.v1"`
Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"` // StreamID
Seq uint64 `protobuf:"varint,2,opt,name=seq,proto3" json:"seq,omitempty"` // per-stream sequence number starting from 0
Data []byte `protobuf:"bytes,3,opt,name=data,proto3" json:"data,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *StreamData) Reset() {
*x = StreamData{}
mi := &file_internal_protocol_hopgate_stream_proto_msgTypes[4]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *StreamData) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*StreamData) ProtoMessage() {}
func (x *StreamData) ProtoReflect() protoreflect.Message {
mi := &file_internal_protocol_hopgate_stream_proto_msgTypes[4]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use StreamData.ProtoReflect.Descriptor instead.
func (*StreamData) Descriptor() ([]byte, []int) {
return file_internal_protocol_hopgate_stream_proto_rawDescGZIP(), []int{4}
}
func (x *StreamData) GetId() string {
if x != nil {
return x.Id
}
return ""
}
func (x *StreamData) GetSeq() uint64 {
if x != nil {
return x.Seq
}
return 0
}
func (x *StreamData) GetData() []byte {
if x != nil {
return x.Data
}
return nil
}
// StreamAck 는 StreamData 에 대한 ACK/NACK 및 선택적 재전송 힌트를 전달합니다.
// This conveys ACK/NACK and optional retransmission hints for StreamData.
type StreamAck struct {
state protoimpl.MessageState `protogen:"open.v1"`
Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"`
// Last contiguously received sequence number (starting from 0).
AckSeq uint64 `protobuf:"varint,2,opt,name=ack_seq,json=ackSeq,proto3" json:"ack_seq,omitempty"`
// Additional missing sequence numbers beyond ack_seq (optional).
LostSeqs []uint64 `protobuf:"varint,3,rep,packed,name=lost_seqs,json=lostSeqs,proto3" json:"lost_seqs,omitempty"`
// Optional receive window size hint.
WindowSize uint32 `protobuf:"varint,4,opt,name=window_size,json=windowSize,proto3" json:"window_size,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *StreamAck) Reset() {
*x = StreamAck{}
mi := &file_internal_protocol_hopgate_stream_proto_msgTypes[5]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *StreamAck) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*StreamAck) ProtoMessage() {}
func (x *StreamAck) ProtoReflect() protoreflect.Message {
mi := &file_internal_protocol_hopgate_stream_proto_msgTypes[5]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use StreamAck.ProtoReflect.Descriptor instead.
func (*StreamAck) Descriptor() ([]byte, []int) {
return file_internal_protocol_hopgate_stream_proto_rawDescGZIP(), []int{5}
}
func (x *StreamAck) GetId() string {
if x != nil {
return x.Id
}
return ""
}
func (x *StreamAck) GetAckSeq() uint64 {
if x != nil {
return x.AckSeq
}
return 0
}
func (x *StreamAck) GetLostSeqs() []uint64 {
if x != nil {
return x.LostSeqs
}
return nil
}
func (x *StreamAck) GetWindowSize() uint32 {
if x != nil {
return x.WindowSize
}
return 0
}
// StreamClose 는 스트림 종료(정상/에러)를 알립니다.
// This indicates normal or error termination of a stream.
type StreamClose struct {
state protoimpl.MessageState `protogen:"open.v1"`
Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"`
Error string `protobuf:"bytes,2,opt,name=error,proto3" json:"error,omitempty"` // empty means normal close
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *StreamClose) Reset() {
*x = StreamClose{}
mi := &file_internal_protocol_hopgate_stream_proto_msgTypes[6]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *StreamClose) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*StreamClose) ProtoMessage() {}
func (x *StreamClose) ProtoReflect() protoreflect.Message {
mi := &file_internal_protocol_hopgate_stream_proto_msgTypes[6]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use StreamClose.ProtoReflect.Descriptor instead.
func (*StreamClose) Descriptor() ([]byte, []int) {
return file_internal_protocol_hopgate_stream_proto_rawDescGZIP(), []int{6}
}
func (x *StreamClose) GetId() string {
if x != nil {
return x.Id
}
return ""
}
func (x *StreamClose) GetError() string {
if x != nil {
return x.Error
}
return ""
}
// Envelope 는 DTLS 세션 위에서 교환되는 상위 레벨 메시지 컨테이너입니다.
// 하나의 Envelope 에는 HTTP 요청/응답 또는 스트림 관련 메시지 중 하나만 포함됩니다.
// Envelope is the top-level container exchanged over the DTLS session.
// Exactly one payload (http_request/http_response/stream_*) is set per message.
type Envelope struct {
state protoimpl.MessageState `protogen:"open.v1"`
// Types that are valid to be assigned to Payload:
//
// *Envelope_HttpRequest
// *Envelope_HttpResponse
// *Envelope_StreamOpen
// *Envelope_StreamData
// *Envelope_StreamClose
// *Envelope_StreamAck
Payload isEnvelope_Payload `protobuf_oneof:"payload"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *Envelope) Reset() {
*x = Envelope{}
mi := &file_internal_protocol_hopgate_stream_proto_msgTypes[7]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *Envelope) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*Envelope) ProtoMessage() {}
func (x *Envelope) ProtoReflect() protoreflect.Message {
mi := &file_internal_protocol_hopgate_stream_proto_msgTypes[7]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use Envelope.ProtoReflect.Descriptor instead.
func (*Envelope) Descriptor() ([]byte, []int) {
return file_internal_protocol_hopgate_stream_proto_rawDescGZIP(), []int{7}
}
func (x *Envelope) GetPayload() isEnvelope_Payload {
if x != nil {
return x.Payload
}
return nil
}
func (x *Envelope) GetHttpRequest() *Request {
if x != nil {
if x, ok := x.Payload.(*Envelope_HttpRequest); ok {
return x.HttpRequest
}
}
return nil
}
func (x *Envelope) GetHttpResponse() *Response {
if x != nil {
if x, ok := x.Payload.(*Envelope_HttpResponse); ok {
return x.HttpResponse
}
}
return nil
}
func (x *Envelope) GetStreamOpen() *StreamOpen {
if x != nil {
if x, ok := x.Payload.(*Envelope_StreamOpen); ok {
return x.StreamOpen
}
}
return nil
}
func (x *Envelope) GetStreamData() *StreamData {
if x != nil {
if x, ok := x.Payload.(*Envelope_StreamData); ok {
return x.StreamData
}
}
return nil
}
func (x *Envelope) GetStreamClose() *StreamClose {
if x != nil {
if x, ok := x.Payload.(*Envelope_StreamClose); ok {
return x.StreamClose
}
}
return nil
}
func (x *Envelope) GetStreamAck() *StreamAck {
if x != nil {
if x, ok := x.Payload.(*Envelope_StreamAck); ok {
return x.StreamAck
}
}
return nil
}
type isEnvelope_Payload interface {
isEnvelope_Payload()
}
type Envelope_HttpRequest struct {
HttpRequest *Request `protobuf:"bytes,1,opt,name=http_request,json=httpRequest,proto3,oneof"`
}
type Envelope_HttpResponse struct {
HttpResponse *Response `protobuf:"bytes,2,opt,name=http_response,json=httpResponse,proto3,oneof"`
}
type Envelope_StreamOpen struct {
StreamOpen *StreamOpen `protobuf:"bytes,3,opt,name=stream_open,json=streamOpen,proto3,oneof"`
}
type Envelope_StreamData struct {
StreamData *StreamData `protobuf:"bytes,4,opt,name=stream_data,json=streamData,proto3,oneof"`
}
type Envelope_StreamClose struct {
StreamClose *StreamClose `protobuf:"bytes,5,opt,name=stream_close,json=streamClose,proto3,oneof"`
}
type Envelope_StreamAck struct {
StreamAck *StreamAck `protobuf:"bytes,6,opt,name=stream_ack,json=streamAck,proto3,oneof"`
}
func (*Envelope_HttpRequest) isEnvelope_Payload() {}
func (*Envelope_HttpResponse) isEnvelope_Payload() {}
func (*Envelope_StreamOpen) isEnvelope_Payload() {}
func (*Envelope_StreamData) isEnvelope_Payload() {}
func (*Envelope_StreamClose) isEnvelope_Payload() {}
func (*Envelope_StreamAck) isEnvelope_Payload() {}
var File_internal_protocol_hopgate_stream_proto protoreflect.FileDescriptor
const file_internal_protocol_hopgate_stream_proto_rawDesc = "" +
"\n" +
"&internal/protocol/hopgate_stream.proto\x12\x13hopgate.protocol.v1\"&\n" +
"\fHeaderValues\x12\x16\n" +
"\x06values\x18\x01 \x03(\tR\x06values\"\xc6\x02\n" +
"\aRequest\x12\x1d\n" +
"\n" +
"request_id\x18\x01 \x01(\tR\trequestId\x12\x1b\n" +
"\tclient_id\x18\x02 \x01(\tR\bclientId\x12!\n" +
"\fservice_name\x18\x03 \x01(\tR\vserviceName\x12\x16\n" +
"\x06method\x18\x04 \x01(\tR\x06method\x12\x10\n" +
"\x03url\x18\x05 \x01(\tR\x03url\x12@\n" +
"\x06header\x18\x06 \x03(\v2(.hopgate.protocol.v1.Request.HeaderEntryR\x06header\x12\x12\n" +
"\x04body\x18\a \x01(\fR\x04body\x1a\\\n" +
"\vHeaderEntry\x12\x10\n" +
"\x03key\x18\x01 \x01(\tR\x03key\x127\n" +
"\x05value\x18\x02 \x01(\v2!.hopgate.protocol.v1.HeaderValuesR\x05value:\x028\x01\"\x8c\x02\n" +
"\bResponse\x12\x1d\n" +
"\n" +
"request_id\x18\x01 \x01(\tR\trequestId\x12\x16\n" +
"\x06status\x18\x02 \x01(\x05R\x06status\x12A\n" +
"\x06header\x18\x03 \x03(\v2).hopgate.protocol.v1.Response.HeaderEntryR\x06header\x12\x12\n" +
"\x04body\x18\x04 \x01(\fR\x04body\x12\x14\n" +
"\x05error\x18\x05 \x01(\tR\x05error\x1a\\\n" +
"\vHeaderEntry\x12\x10\n" +
"\x03key\x18\x01 \x01(\tR\x03key\x127\n" +
"\x05value\x18\x02 \x01(\v2!.hopgate.protocol.v1.HeaderValuesR\x05value:\x028\x01\"\x83\x02\n" +
"\n" +
"StreamOpen\x12\x0e\n" +
"\x02id\x18\x01 \x01(\tR\x02id\x12!\n" +
"\fservice_name\x18\x02 \x01(\tR\vserviceName\x12\x1f\n" +
"\vtarget_addr\x18\x03 \x01(\tR\n" +
"targetAddr\x12C\n" +
"\x06header\x18\x04 \x03(\v2+.hopgate.protocol.v1.StreamOpen.HeaderEntryR\x06header\x1a\\\n" +
"\vHeaderEntry\x12\x10\n" +
"\x03key\x18\x01 \x01(\tR\x03key\x127\n" +
"\x05value\x18\x02 \x01(\v2!.hopgate.protocol.v1.HeaderValuesR\x05value:\x028\x01\"B\n" +
"\n" +
"StreamData\x12\x0e\n" +
"\x02id\x18\x01 \x01(\tR\x02id\x12\x10\n" +
"\x03seq\x18\x02 \x01(\x04R\x03seq\x12\x12\n" +
"\x04data\x18\x03 \x01(\fR\x04data\"r\n" +
"\tStreamAck\x12\x0e\n" +
"\x02id\x18\x01 \x01(\tR\x02id\x12\x17\n" +
"\aack_seq\x18\x02 \x01(\x04R\x06ackSeq\x12\x1b\n" +
"\tlost_seqs\x18\x03 \x03(\x04R\blostSeqs\x12\x1f\n" +
"\vwindow_size\x18\x04 \x01(\rR\n" +
"windowSize\"3\n" +
"\vStreamClose\x12\x0e\n" +
"\x02id\x18\x01 \x01(\tR\x02id\x12\x14\n" +
"\x05error\x18\x02 \x01(\tR\x05error\"\xae\x03\n" +
"\bEnvelope\x12A\n" +
"\fhttp_request\x18\x01 \x01(\v2\x1c.hopgate.protocol.v1.RequestH\x00R\vhttpRequest\x12D\n" +
"\rhttp_response\x18\x02 \x01(\v2\x1d.hopgate.protocol.v1.ResponseH\x00R\fhttpResponse\x12B\n" +
"\vstream_open\x18\x03 \x01(\v2\x1f.hopgate.protocol.v1.StreamOpenH\x00R\n" +
"streamOpen\x12B\n" +
"\vstream_data\x18\x04 \x01(\v2\x1f.hopgate.protocol.v1.StreamDataH\x00R\n" +
"streamData\x12E\n" +
"\fstream_close\x18\x05 \x01(\v2 .hopgate.protocol.v1.StreamCloseH\x00R\vstreamClose\x12?\n" +
"\n" +
"stream_ack\x18\x06 \x01(\v2\x1e.hopgate.protocol.v1.StreamAckH\x00R\tstreamAckB\t\n" +
"\apayloadB\x19Z\x17internal/protocol/pb;pbb\x06proto3"
var (
file_internal_protocol_hopgate_stream_proto_rawDescOnce sync.Once
file_internal_protocol_hopgate_stream_proto_rawDescData []byte
)
func file_internal_protocol_hopgate_stream_proto_rawDescGZIP() []byte {
file_internal_protocol_hopgate_stream_proto_rawDescOnce.Do(func() {
file_internal_protocol_hopgate_stream_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_internal_protocol_hopgate_stream_proto_rawDesc), len(file_internal_protocol_hopgate_stream_proto_rawDesc)))
})
return file_internal_protocol_hopgate_stream_proto_rawDescData
}
var file_internal_protocol_hopgate_stream_proto_msgTypes = make([]protoimpl.MessageInfo, 11)
var file_internal_protocol_hopgate_stream_proto_goTypes = []any{
(*HeaderValues)(nil), // 0: hopgate.protocol.v1.HeaderValues
(*Request)(nil), // 1: hopgate.protocol.v1.Request
(*Response)(nil), // 2: hopgate.protocol.v1.Response
(*StreamOpen)(nil), // 3: hopgate.protocol.v1.StreamOpen
(*StreamData)(nil), // 4: hopgate.protocol.v1.StreamData
(*StreamAck)(nil), // 5: hopgate.protocol.v1.StreamAck
(*StreamClose)(nil), // 6: hopgate.protocol.v1.StreamClose
(*Envelope)(nil), // 7: hopgate.protocol.v1.Envelope
nil, // 8: hopgate.protocol.v1.Request.HeaderEntry
nil, // 9: hopgate.protocol.v1.Response.HeaderEntry
nil, // 10: hopgate.protocol.v1.StreamOpen.HeaderEntry
}
var file_internal_protocol_hopgate_stream_proto_depIdxs = []int32{
8, // 0: hopgate.protocol.v1.Request.header:type_name -> hopgate.protocol.v1.Request.HeaderEntry
9, // 1: hopgate.protocol.v1.Response.header:type_name -> hopgate.protocol.v1.Response.HeaderEntry
10, // 2: hopgate.protocol.v1.StreamOpen.header:type_name -> hopgate.protocol.v1.StreamOpen.HeaderEntry
1, // 3: hopgate.protocol.v1.Envelope.http_request:type_name -> hopgate.protocol.v1.Request
2, // 4: hopgate.protocol.v1.Envelope.http_response:type_name -> hopgate.protocol.v1.Response
3, // 5: hopgate.protocol.v1.Envelope.stream_open:type_name -> hopgate.protocol.v1.StreamOpen
4, // 6: hopgate.protocol.v1.Envelope.stream_data:type_name -> hopgate.protocol.v1.StreamData
6, // 7: hopgate.protocol.v1.Envelope.stream_close:type_name -> hopgate.protocol.v1.StreamClose
5, // 8: hopgate.protocol.v1.Envelope.stream_ack:type_name -> hopgate.protocol.v1.StreamAck
0, // 9: hopgate.protocol.v1.Request.HeaderEntry.value:type_name -> hopgate.protocol.v1.HeaderValues
0, // 10: hopgate.protocol.v1.Response.HeaderEntry.value:type_name -> hopgate.protocol.v1.HeaderValues
0, // 11: hopgate.protocol.v1.StreamOpen.HeaderEntry.value:type_name -> hopgate.protocol.v1.HeaderValues
12, // [12:12] is the sub-list for method output_type
12, // [12:12] is the sub-list for method input_type
12, // [12:12] is the sub-list for extension type_name
12, // [12:12] is the sub-list for extension extendee
0, // [0:12] is the sub-list for field type_name
}
func init() { file_internal_protocol_hopgate_stream_proto_init() }
func file_internal_protocol_hopgate_stream_proto_init() {
if File_internal_protocol_hopgate_stream_proto != nil {
return
}
file_internal_protocol_hopgate_stream_proto_msgTypes[7].OneofWrappers = []any{
(*Envelope_HttpRequest)(nil),
(*Envelope_HttpResponse)(nil),
(*Envelope_StreamOpen)(nil),
(*Envelope_StreamData)(nil),
(*Envelope_StreamClose)(nil),
(*Envelope_StreamAck)(nil),
}
type x struct{}
out := protoimpl.TypeBuilder{
File: protoimpl.DescBuilder{
GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
RawDescriptor: unsafe.Slice(unsafe.StringData(file_internal_protocol_hopgate_stream_proto_rawDesc), len(file_internal_protocol_hopgate_stream_proto_rawDesc)),
NumEnums: 0,
NumMessages: 11,
NumExtensions: 0,
NumServices: 0,
},
GoTypes: file_internal_protocol_hopgate_stream_proto_goTypes,
DependencyIndexes: file_internal_protocol_hopgate_stream_proto_depIdxs,
MessageInfos: file_internal_protocol_hopgate_stream_proto_msgTypes,
}.Build()
File_internal_protocol_hopgate_stream_proto = out.File
file_internal_protocol_hopgate_stream_proto_goTypes = nil
file_internal_protocol_hopgate_stream_proto_depIdxs = nil
}
-119
View File
@@ -1,119 +0,0 @@
package pb
import (
"context"
"google.golang.org/grpc"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
)
// HopGateTunnelClient is the client API for the HopGateTunnel service.
type HopGateTunnelClient interface {
// OpenTunnel establishes a long-lived bi-directional stream between
// a HopGate client and the server. Both HTTP requests and responses
// are multiplexed as Envelope messages on this stream.
OpenTunnel(ctx context.Context, opts ...grpc.CallOption) (HopGateTunnel_OpenTunnelClient, error)
}
type hopGateTunnelClient struct {
cc grpc.ClientConnInterface
}
// NewHopGateTunnelClient creates a new HopGateTunnelClient.
func NewHopGateTunnelClient(cc grpc.ClientConnInterface) HopGateTunnelClient {
return &hopGateTunnelClient{cc: cc}
}
func (c *hopGateTunnelClient) OpenTunnel(ctx context.Context, opts ...grpc.CallOption) (HopGateTunnel_OpenTunnelClient, error) {
stream, err := c.cc.NewStream(ctx, &_HopGateTunnel_serviceDesc.Streams[0], "/hopgate.protocol.v1.HopGateTunnel/OpenTunnel", opts...)
if err != nil {
return nil, err
}
return &hopGateTunnelOpenTunnelClient{ClientStream: stream}, nil
}
// HopGateTunnel_OpenTunnelClient is the client-side stream for OpenTunnel.
type HopGateTunnel_OpenTunnelClient interface {
Send(*Envelope) error
Recv() (*Envelope, error)
grpc.ClientStream
}
type hopGateTunnelOpenTunnelClient struct {
grpc.ClientStream
}
func (x *hopGateTunnelOpenTunnelClient) Send(m *Envelope) error {
return x.ClientStream.SendMsg(m)
}
func (x *hopGateTunnelOpenTunnelClient) Recv() (*Envelope, error) {
m := new(Envelope)
if err := x.ClientStream.RecvMsg(m); err != nil {
return nil, err
}
return m, nil
}
// HopGateTunnelServer is the server API for the HopGateTunnel service.
type HopGateTunnelServer interface {
// OpenTunnel handles a long-lived bi-directional stream between the server
// and a HopGate client. Implementations are responsible for reading and
// writing Envelope messages on the stream.
OpenTunnel(HopGateTunnel_OpenTunnelServer) error
}
// UnimplementedHopGateTunnelServer can be embedded to have forward compatible implementations.
type UnimplementedHopGateTunnelServer struct{}
// OpenTunnel returns an Unimplemented error by default.
func (UnimplementedHopGateTunnelServer) OpenTunnel(HopGateTunnel_OpenTunnelServer) error {
return status.Errorf(codes.Unimplemented, "method OpenTunnel not implemented")
}
// RegisterHopGateTunnelServer registers the HopGateTunnel service with the given gRPC server.
func RegisterHopGateTunnelServer(s grpc.ServiceRegistrar, srv HopGateTunnelServer) {
s.RegisterService(&_HopGateTunnel_serviceDesc, srv)
}
// HopGateTunnel_OpenTunnelServer is the server-side stream for OpenTunnel.
type HopGateTunnel_OpenTunnelServer interface {
Send(*Envelope) error
Recv() (*Envelope, error)
grpc.ServerStream
}
func _HopGateTunnel_OpenTunnel_Handler(srv interface{}, stream grpc.ServerStream) error {
return srv.(HopGateTunnelServer).OpenTunnel(&hopGateTunnelOpenTunnelServer{ServerStream: stream})
}
type hopGateTunnelOpenTunnelServer struct {
grpc.ServerStream
}
func (x *hopGateTunnelOpenTunnelServer) Send(m *Envelope) error {
return x.ServerStream.SendMsg(m)
}
func (x *hopGateTunnelOpenTunnelServer) Recv() (*Envelope, error) {
m := new(Envelope)
if err := x.ServerStream.RecvMsg(m); err != nil {
return nil, err
}
return m, nil
}
var _HopGateTunnel_serviceDesc = grpc.ServiceDesc{
ServiceName: "hopgate.protocol.v1.HopGateTunnel",
HandlerType: (*HopGateTunnelServer)(nil),
Streams: []grpc.StreamDesc{
{
StreamName: "OpenTunnel",
Handler: _HopGateTunnel_OpenTunnel_Handler,
ServerStreams: true,
ClientStreams: true,
},
},
Metadata: "internal/protocol/hopgate_stream.proto",
}
-147
View File
@@ -1,147 +0,0 @@
package protocol
// Request 는 서버-클라이언트 간에 전달되는 HTTP 요청을 표현합니다.
// 기존 HTTP 터널링 경로에서는 이 구조체를 그대로 사용합니다.
type Request struct {
RequestID string
ClientID string // 대상 클라이언트 식별자
ServiceName string // 클라이언트 내부 서비스 이름
Method string
URL string
Header map[string][]string
Body []byte
}
// Response 는 서버-클라이언트 간에 전달되는 HTTP 응답을 표현합니다.
// 기존 HTTP 터널링 경로에서는 이 구조체를 그대로 사용합니다.
type Response struct {
RequestID string
Status int
Header map[string][]string
Body []byte
Error string // 에러 발생 시 설명 메시지
}
// --- 확장 가능 DTLS 메시지 Envelope 및 스트림 구조체 ---
//
// WebSocket/TCP 스트림 터널링을 지원하기 위해, 단일 HTTP 요청/응답 외에도
// 스트림 기반 메시지를 운반할 수 있는 Envelope 타입을 정의합니다.
// 현재 구현에서는 아직 사용하지 않으며, 향후 단계적으로 적용할 예정입니다.
// MessageType 은 DTLS 위에서 교환되는 상위 레벨 메시지 종류를 나타냅니다.
type MessageType string
// StreamChunkSize 는 스트림 터널링 시 단일 StreamData 프레임에 담을 최대 payload 크기입니다.
// 현재 구현에서는 4KiB 로 고정하여 DTLS/UDP MTU 한계를 여유 있게 피하도록 합니다.
// StreamChunkSize is the maximum payload size per StreamData frame (4KiB).
const StreamChunkSize = 4 * 1024
const (
// MessageTypeHTTP 는 기존 단일 HTTP 요청/응답 메시지를 의미합니다.
// 이 경우 HTTPRequest / HTTPResponse 필드를 사용합니다.
MessageTypeHTTP MessageType = "http"
// MessageTypeStreamOpen 은 새로운 스트림(TCP/WebSocket 등)의 오픈을 의미합니다.
MessageTypeStreamOpen MessageType = "stream_open"
// MessageTypeStreamData 는 열린 스트림에 대한 양방향 데이터 프레임을 의미합니다.
// HTTP 바디 chunk 를 비롯한 실제 payload 는 이 타입을 통해 전송됩니다.
// Stream data frames for an already-opened stream (HTTP body chunks, etc.).
MessageTypeStreamData MessageType = "stream_data"
// MessageTypeStreamClose 는 스트림 종료(정상/에러)를 의미합니다.
// Normal or error-termination of a stream.
MessageTypeStreamClose MessageType = "stream_close"
// MessageTypeStreamAck 는 스트림 데이터 프레임에 대한 ACK/NACK 및 재전송 힌트를 전달합니다.
// Stream-level ACK/NACK frames for selective retransmission hints.
MessageTypeStreamAck MessageType = "stream_ack"
)
// Envelope 는 DTLS 세션 위에서 교환되는 상위 레벨 메시지 컨테이너입니다.
// 하나의 Envelope 에는 HTTP 요청/응답 또는 스트림 관련 메시지 중 하나만 포함됩니다.
type Envelope struct {
Type MessageType `json:"type"`
// HTTP 1회성 요청/응답 (기존 터널링 경로)
HTTPRequest *Request `json:"http_request,omitempty"`
HTTPResponse *Response `json:"http_response,omitempty"`
// 스트림 기반 메시지 (WebSocket/TCP 터널용)
StreamOpen *StreamOpen `json:"stream_open,omitempty"`
StreamData *StreamData `json:"stream_data,omitempty"`
StreamClose *StreamClose `json:"stream_close,omitempty"`
// 스트림 제어 메시지 (ACK/NACK, 재전송 힌트 등)
// Stream-level control messages (ACK/NACK, retransmission hints, etc.).
StreamAck *StreamAck `json:"stream_ack,omitempty"`
}
// StreamID 는 스트림(예: 특정 WebSocket 연결 또는 TCP 커넥션)을 구분하기 위한 식별자입니다.
type StreamID string
// HTTP-over-stream 터널링에서 사용되는 pseudo-header 키 상수입니다.
// These pseudo-header keys are used when tunneling HTTP over the stream protocol.
const (
HeaderKeyMethod = "X-HopGate-Method"
HeaderKeyURL = "X-HopGate-URL"
HeaderKeyHost = "X-HopGate-Host"
HeaderKeyStatus = "X-HopGate-Status"
)
// StreamOpen 은 새로운 스트림을 여는 요청을 나타냅니다.
type StreamOpen struct {
ID StreamID `json:"id"`
// Service / TargetAddr 는 클라이언트 측에서 어느 로컬 서비스로 연결해야 하는지를 나타냅니다.
// 최소 구현에서는 LocalTarget 하나만 사용해도 되며, 추후 서비스별로 확장 가능합니다.
Service string `json:"service_name,omitempty"`
TargetAddr string `json:"target_addr,omitempty"` // 예: "127.0.0.1:8080"
Header map[string][]string `json:"header,omitempty"` // 초기 HTTP 헤더(Upgrade 포함) 전달용
}
// StreamData 는 이미 열린 스트림에 대해 한 방향으로 전송되는 데이터 프레임을 표현합니다.
// DTLS/UDP 특성상 손실/중복/순서 뒤바뀜을 감지하고 재전송할 수 있도록
// 각 스트림 내에서 0부터 시작하는 시퀀스 번호(Seq)를 포함합니다.
//
// StreamData represents a unidirectional data frame on an already-opened stream.
// To support loss/duplication/reordering detection and retransmission over DTLS/UDP,
// it carries a per-stream sequence number (Seq) starting from 0.
type StreamData struct {
ID StreamID `json:"id"`
Seq uint64 `json:"seq"`
Data []byte `json:"data"`
}
// StreamAck 는 스트림 데이터 프레임에 대한 ACK/NACK 및 선택적 재전송 요청 정보를 전달합니다.
// AckSeq 는 수신 측에서 "연속적으로" 수신 완료한 마지막 Seq 를 의미하며,
// LostSeqs 는 그 이후 구간에서 누락된 시퀀스 번호(선택적)를 나타냅니다.
//
// StreamAck conveys ACK/NACK and optional retransmission hints for stream data frames.
// AckSeq denotes the last sequence number received contiguously by the receiver,
// while LostSeqs can list additional missing sequence numbers beyond AckSeq.
type StreamAck struct {
ID StreamID `json:"id"`
// AckSeq 는 수신 측에서 0부터 시작해 연속으로 수신 완료한 마지막 Seq 입니다.
// AckSeq is the last contiguously received sequence number starting from 0.
AckSeq uint64 `json:"ack_seq"`
// LostSeqs 는 AckSeq 이후 구간에서 누락된 시퀀스 번호 목록입니다(선택).
// 이 필드는 선택적 selective retransmission 힌트를 제공하기 위해 사용됩니다.
//
// LostSeqs is an optional list of missing sequence numbers beyond AckSeq,
// used as a hint for selective retransmission.
LostSeqs []uint64 `json:"lost_seqs,omitempty"`
// WindowSize 는 수신 측이 허용 가능한 in-flight 프레임 수를 나타내는 선택적 힌트입니다.
// WindowSize is an optional hint for the allowed number of in-flight frames.
WindowSize uint32 `json:"window_size,omitempty"`
}
// StreamClose 는 스트림 종료를 알리는 메시지입니다.
type StreamClose struct {
ID StreamID `json:"id"`
Error string `json:"error,omitempty"` // 비워두면 정상 종료로 해석
}
File diff suppressed because it is too large Load Diff
-43
View File
@@ -1,43 +0,0 @@
package proxy
import (
"context"
"net/http"
"golang.org/x/net/http2"
)
// ServerProxy 는 공인 HTTP(S) 엔드포인트에서 들어오는 요청을
// 적절한 클라이언트로 라우팅하는 서버 측 프록시입니다.
type ServerProxy struct {
Router Router
HTTPServer *http.Server
}
// Router 는 도메인/패스 기준으로 어떤 클라이언트/서비스로 보낼지 결정하는 인터페이스입니다.
type Router interface {
Route(req *http.Request) (clientID string, serviceName string, err error)
}
// NewHTTPServer 는 H1/H2 를 지원하는 기본 HTTP 서버를 생성합니다.
func NewHTTPServer(addr string, handler http.Handler) *http.Server {
srv := &http.Server{
Addr: addr,
Handler: handler,
}
http2.ConfigureServer(srv, &http2.Server{})
return srv
}
// Start / Shutdown 등은 추후 구현합니다.
func (p *ServerProxy) Start(ctx context.Context) error {
// TODO: HTTP/HTTPS 리스너 시작 및 DTLS 연동
return nil
}
func (p *ServerProxy) Shutdown(ctx context.Context) error {
if p.HTTPServer != nil {
return p.HTTPServer.Shutdown(ctx)
}
return nil
}
+17
View File
@@ -0,0 +1,17 @@
package tunnel
import "context"
// DomainValidator validates the client credentials presented on the control stream.
type DomainValidator interface {
ValidateDomainAPIKey(ctx context.Context, domain, clientAPIKey string) error
}
// Response is the response returned by a tunnel transport to the public HTTP ingress.
type Response struct {
RequestID string
Status int
Header map[string][]string
Body []byte
Error string
}
+222
View File
@@ -0,0 +1,222 @@
// Package tunnel provides the transport-neutral session used by HopGate.
// The wire transport is TLS over TCP, while yamux supplies bidirectional
// logical streams on top of the connection.
package tunnel
import (
"context"
"crypto/tls"
"encoding/binary"
"encoding/json"
"errors"
"fmt"
"io"
"net"
"time"
"github.com/hashicorp/yamux"
)
const (
metadataHeaderSize = 4
maxMetadataSize = 64 << 10
)
// StreamMeta describes what a logical stream carries. HTTP and WebSocket
// streams use the same transport; only the metadata kind differs.
type StreamMeta struct {
Kind string `json:"kind"`
Domain string `json:"domain,omitempty"`
Service string `json:"service,omitempty"`
Method string `json:"method,omitempty"`
Path string `json:"path,omitempty"`
Host string `json:"host,omitempty"`
Headers map[string][]string `json:"headers,omitempty"`
Target string `json:"target,omitempty"`
}
// Stream is a yamux stream with one metadata record at its beginning.
type Stream struct {
net.Conn
Meta StreamMeta
}
// Session is a bidirectional multiplexed tunnel.
type Session struct {
inner *yamux.Session
}
func newSession(conn net.Conn, server bool) (*Session, error) {
if conn == nil {
return nil, errors.New("tunnel: nil connection")
}
config := yamux.DefaultConfig()
config.EnableKeepAlive = true
config.KeepAliveInterval = 30 * time.Second
config.ConnectionWriteTimeout = 10 * time.Second
var session *yamux.Session
var err error
if server {
session, err = yamux.Server(conn, config)
} else {
session, err = yamux.Client(conn, config)
}
if err != nil {
_ = conn.Close()
return nil, fmt.Errorf("tunnel: create yamux session: %w", err)
}
return &Session{inner: session}, nil
}
// NewClient wraps an already-established TLS or test connection.
func NewClient(conn net.Conn) (*Session, error) { return newSession(conn, false) }
// NewServer wraps an accepted TLS or test connection.
func NewServer(conn net.Conn) (*Session, error) { return newSession(conn, true) }
// DialTLS dials the server and establishes a TLS-protected yamux session.
func DialTLS(ctx context.Context, address string, config *tls.Config) (*Session, error) {
if config == nil {
return nil, errors.New("tunnel: nil TLS config")
}
dialer := &tls.Dialer{NetDialer: &net.Dialer{Timeout: 10 * time.Second}, Config: config}
conn, err := dialer.DialContext(ctx, "tcp", address)
if err != nil {
return nil, fmt.Errorf("tunnel: TLS dial: %w", err)
}
return NewClient(conn)
}
// Open creates a stream and writes its metadata before returning it.
func (s *Session) Open(ctx context.Context, meta StreamMeta) (*Stream, error) {
if s == nil || s.inner == nil {
return nil, errors.New("tunnel: session is closed")
}
if err := checkContext(ctx); err != nil {
return nil, err
}
conn, err := s.inner.Open()
if err != nil {
return nil, fmt.Errorf("tunnel: open stream: %w", err)
}
clearDeadline := true
defer func() {
if clearDeadline {
_ = conn.SetDeadline(time.Time{})
}
}()
if deadline, ok := ctxDeadline(ctx); ok {
if err := conn.SetDeadline(deadline); err != nil {
_ = conn.Close()
return nil, fmt.Errorf("tunnel: set stream deadline: %w", err)
}
}
if err := writeMeta(conn, meta); err != nil {
_ = conn.Close()
return nil, err
}
clearDeadline = false
_ = conn.SetDeadline(time.Time{})
return &Stream{Conn: conn, Meta: meta}, nil
}
// Accept waits for a peer-created stream and reads its metadata.
func (s *Session) Accept(ctx context.Context) (*Stream, error) {
if s == nil || s.inner == nil {
return nil, errors.New("tunnel: session is closed")
}
if err := checkContext(ctx); err != nil {
return nil, err
}
conn, err := s.inner.Accept()
if err != nil {
return nil, fmt.Errorf("tunnel: accept stream: %w", err)
}
if deadline, ok := ctxDeadline(ctx); ok {
if err := conn.SetDeadline(deadline); err != nil {
_ = conn.Close()
return nil, fmt.Errorf("tunnel: set stream deadline: %w", err)
}
}
meta, err := readMeta(conn)
if err != nil {
_ = conn.Close()
return nil, err
}
_ = conn.SetDeadline(time.Time{})
return &Stream{Conn: conn, Meta: meta}, nil
}
func (s *Session) Close() error {
if s == nil || s.inner == nil {
return nil
}
return s.inner.Close()
}
// IsClosed reports whether the underlying yamux session has terminated.
func (s *Session) IsClosed() bool {
return s == nil || s.inner == nil || s.inner.IsClosed()
}
func checkContext(ctx context.Context) error {
if ctx == nil {
return nil
}
select {
case <-ctx.Done():
return ctx.Err()
default:
}
return nil
}
func ctxDeadline(ctx context.Context) (time.Time, bool) {
if ctx == nil {
return time.Time{}, false
}
return ctx.Deadline()
}
func writeMeta(w io.Writer, meta StreamMeta) error {
data, err := json.Marshal(meta)
if err != nil {
return fmt.Errorf("tunnel: encode stream metadata: %w", err)
}
if len(data) > maxMetadataSize {
return fmt.Errorf("tunnel: stream metadata exceeds %d bytes", maxMetadataSize)
}
header := make([]byte, metadataHeaderSize)
binary.BigEndian.PutUint32(header, uint32(len(data)))
if _, err := w.Write(header); err != nil {
return fmt.Errorf("tunnel: write metadata length: %w", err)
}
if _, err := w.Write(data); err != nil {
return fmt.Errorf("tunnel: write metadata: %w", err)
}
return nil
}
func readMeta(r io.Reader) (StreamMeta, error) {
header := make([]byte, metadataHeaderSize)
if _, err := io.ReadFull(r, header); err != nil {
return StreamMeta{}, fmt.Errorf("tunnel: read metadata length: %w", err)
}
size := binary.BigEndian.Uint32(header)
if size == 0 || size > maxMetadataSize {
return StreamMeta{}, fmt.Errorf("tunnel: invalid metadata size %d", size)
}
data := make([]byte, size)
if _, err := io.ReadFull(r, data); err != nil {
return StreamMeta{}, fmt.Errorf("tunnel: read metadata: %w", err)
}
var meta StreamMeta
if err := json.Unmarshal(data, &meta); err != nil {
return StreamMeta{}, fmt.Errorf("tunnel: decode stream metadata: %w", err)
}
if meta.Kind == "" {
return StreamMeta{}, errors.New("tunnel: stream metadata kind is required")
}
return meta, nil
}
+84
View File
@@ -0,0 +1,84 @@
package tunnel
import (
"context"
"net"
"testing"
)
func TestSessionOpenAcceptAndBidirectionalData(t *testing.T) {
left, right := net.Pipe()
defer left.Close()
defer right.Close()
serverCh := make(chan *Session, 1)
serverErrCh := make(chan error, 1)
go func() {
session, err := NewServer(right)
if err != nil {
serverErrCh <- err
return
}
serverCh <- session
}()
client, err := NewClient(left)
if err != nil {
t.Fatalf("create client session: %v", err)
}
defer client.Close()
var server *Session
select {
case err := <-serverErrCh:
t.Fatalf("create server session: %v", err)
case server = <-serverCh:
}
defer server.Close()
meta := StreamMeta{
Kind: "http",
Method: "POST",
Path: "/upload",
Headers: map[string][]string{"Content-Type": {"application/octet-stream"}},
}
clientStream, err := client.Open(context.Background(), meta)
if err != nil {
t.Fatalf("open stream: %v", err)
}
defer clientStream.Close()
serverStream, err := server.Accept(context.Background())
if err != nil {
t.Fatalf("accept stream: %v", err)
}
defer serverStream.Close()
if serverStream.Meta.Kind != meta.Kind || serverStream.Meta.Path != meta.Path {
t.Fatalf("metadata mismatch: got %#v, want %#v", serverStream.Meta, meta)
}
const request = "request-body"
if _, err := clientStream.Write([]byte(request)); err != nil {
t.Fatalf("write request: %v", err)
}
buf := make([]byte, len(request))
if _, err := serverStream.Read(buf); err != nil {
t.Fatalf("read request: %v", err)
}
if string(buf) != request {
t.Fatalf("request mismatch: got %q, want %q", buf, request)
}
const response = "response-body"
if _, err := serverStream.Write([]byte(response)); err != nil {
t.Fatalf("write response: %v", err)
}
buf = make([]byte, len(response))
if _, err := clientStream.Read(buf); err != nil {
t.Fatalf("read response: %v", err)
}
if string(buf) != response {
t.Fatalf("response mismatch: got %q, want %q", buf, response)
}
}