mirror of
https://github.com/dalbodeule/hop-gate.git
synced 2026-09-21 08:11:06 +09:00
[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:
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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 등을 사용해 추가합니다.
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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{}
|
||||
}
|
||||
@@ -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:]
|
||||
}
|
||||
@@ -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}
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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",
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user