mirror of
https://github.com/yusing/godoxy.git
synced 2026-04-18 23:20:05 +02:00
feat(agent): agent stream tunneling with TLS and dTLS (UDP) (#188)
* **New Features** * Multiplexed TLS port: HTTP API and a custom stream protocol can share one port via ALPN. * Agent-side TCP and DTLS/UDP stream tunneling with health-check support and runtime capability detection. * Agents now advertise per-agent stream support (TCP/UDP). * **Documentation** * Added comprehensive stream protocol documentation. * **Tests** * Extended integration and concurrency tests covering multiplexing, TCP/UDP streams, and health checks. * **Chores** * Compose/template updated to expose both TCP and UDP ports.
This commit is contained in:
@@ -4,6 +4,8 @@ import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"encoding/json"
|
||||
"encoding/pem"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
@@ -16,31 +18,51 @@ import (
|
||||
|
||||
"github.com/rs/zerolog"
|
||||
"github.com/rs/zerolog/log"
|
||||
"github.com/yusing/godoxy/agent/pkg/agent/common"
|
||||
agentstream "github.com/yusing/godoxy/agent/pkg/agent/stream"
|
||||
"github.com/yusing/godoxy/agent/pkg/certs"
|
||||
gperr "github.com/yusing/goutils/errs"
|
||||
httputils "github.com/yusing/goutils/http"
|
||||
"github.com/yusing/goutils/version"
|
||||
)
|
||||
|
||||
type AgentConfig struct {
|
||||
Addr string `json:"addr"`
|
||||
Name string `json:"name"`
|
||||
Version version.Version `json:"version" swaggertype:"string"`
|
||||
Runtime ContainerRuntime `json:"runtime"`
|
||||
AgentInfo
|
||||
|
||||
Addr string `json:"addr"`
|
||||
IsTCPStreamSupported bool `json:"supports_tcp_stream"`
|
||||
IsUDPStreamSupported bool `json:"supports_udp_stream"`
|
||||
|
||||
// for stream
|
||||
caCert *x509.Certificate
|
||||
clientCert *tls.Certificate
|
||||
|
||||
tlsConfig tls.Config
|
||||
l zerolog.Logger
|
||||
|
||||
l zerolog.Logger
|
||||
} // @name Agent
|
||||
|
||||
type AgentInfo struct {
|
||||
Version version.Version `json:"version" swaggertype:"string"`
|
||||
Name string `json:"name"`
|
||||
Runtime ContainerRuntime `json:"runtime"`
|
||||
}
|
||||
|
||||
// Deprecated. Replaced by EndpointInfo
|
||||
const (
|
||||
EndpointVersion = "/version"
|
||||
EndpointName = "/name"
|
||||
EndpointRuntime = "/runtime"
|
||||
EndpointVersion = "/version"
|
||||
EndpointName = "/name"
|
||||
EndpointRuntime = "/runtime"
|
||||
)
|
||||
|
||||
const (
|
||||
EndpointInfo = "/info"
|
||||
EndpointProxyHTTP = "/proxy/http"
|
||||
EndpointHealth = "/health"
|
||||
EndpointLogs = "/logs"
|
||||
EndpointSystemInfo = "/system_info"
|
||||
|
||||
AgentHost = CertsDNSName
|
||||
AgentHost = common.CertsDNSName
|
||||
|
||||
APIEndpointBase = "/godoxy/agent"
|
||||
APIBaseURL = "https://" + AgentHost + APIEndpointBase
|
||||
@@ -90,6 +112,7 @@ func (cfg *AgentConfig) InitWithCerts(ctx context.Context, ca, crt, key []byte)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
cfg.clientCert = &clientCert
|
||||
|
||||
// create tls config
|
||||
caCertPool := x509.NewCertPool()
|
||||
@@ -97,58 +120,105 @@ func (cfg *AgentConfig) InitWithCerts(ctx context.Context, ca, crt, key []byte)
|
||||
if !ok {
|
||||
return errors.New("invalid ca certificate")
|
||||
}
|
||||
// Keep the CA leaf for stream client dialing.
|
||||
if block, _ := pem.Decode(ca); block == nil || block.Type != "CERTIFICATE" {
|
||||
return errors.New("invalid ca certificate")
|
||||
} else if cert, err := x509.ParseCertificate(block.Bytes); err != nil {
|
||||
return err
|
||||
} else {
|
||||
cfg.caCert = cert
|
||||
}
|
||||
|
||||
cfg.tlsConfig = tls.Config{
|
||||
Certificates: []tls.Certificate{clientCert},
|
||||
RootCAs: caCertPool,
|
||||
ServerName: CertsDNSName,
|
||||
ServerName: common.CertsDNSName,
|
||||
MinVersion: tls.VersionTLS12,
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
// get agent name
|
||||
name, _, err := cfg.fetchString(ctx, EndpointName)
|
||||
status, err := cfg.fetchJSON(ctx, EndpointInfo, &cfg.AgentInfo)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
cfg.Name = name
|
||||
var streamUnsupportedErrs gperr.Builder
|
||||
|
||||
if status == http.StatusOK {
|
||||
// test stream server connection
|
||||
const fakeAddress = "localhost:8080" // it won't be used, just for testing
|
||||
// test TCP stream support
|
||||
err := agentstream.TCPHealthCheck(cfg.Addr, cfg.caCert, cfg.clientCert)
|
||||
if err != nil {
|
||||
streamUnsupportedErrs.Addf("failed to connect to stream server via TCP: %w", err)
|
||||
} else {
|
||||
cfg.IsTCPStreamSupported = true
|
||||
}
|
||||
|
||||
// test UDP stream support
|
||||
err = agentstream.UDPHealthCheck(cfg.Addr, cfg.caCert, cfg.clientCert)
|
||||
if err != nil {
|
||||
streamUnsupportedErrs.Addf("failed to connect to stream server via UDP: %w", err)
|
||||
} else {
|
||||
cfg.IsUDPStreamSupported = true
|
||||
}
|
||||
} else {
|
||||
// old agent does not support EndpointInfo
|
||||
// fallback with old logic
|
||||
cfg.IsTCPStreamSupported = false
|
||||
cfg.IsUDPStreamSupported = false
|
||||
streamUnsupportedErrs.Adds("agent version is too old, does not support stream tunneling")
|
||||
|
||||
// get agent name
|
||||
name, _, err := cfg.fetchString(ctx, EndpointName)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
cfg.Name = name
|
||||
|
||||
// check agent version
|
||||
agentVersion, _, err := cfg.fetchString(ctx, EndpointVersion)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
cfg.Version = version.Parse(agentVersion)
|
||||
|
||||
// check agent runtime
|
||||
runtime, status, err := cfg.fetchString(ctx, EndpointRuntime)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
switch status {
|
||||
case http.StatusOK:
|
||||
switch runtime {
|
||||
case "docker":
|
||||
cfg.Runtime = ContainerRuntimeDocker
|
||||
// case "nerdctl":
|
||||
// cfg.Runtime = ContainerRuntimeNerdctl
|
||||
case "podman":
|
||||
cfg.Runtime = ContainerRuntimePodman
|
||||
default:
|
||||
return fmt.Errorf("invalid agent runtime: %s", runtime)
|
||||
}
|
||||
case http.StatusNotFound:
|
||||
// backward compatibility, old agent does not have runtime endpoint
|
||||
cfg.Runtime = ContainerRuntimeDocker
|
||||
default:
|
||||
return fmt.Errorf("failed to get agent runtime: HTTP %d %s", status, runtime)
|
||||
}
|
||||
}
|
||||
|
||||
cfg.l = log.With().Str("agent", cfg.Name).Logger()
|
||||
|
||||
// check agent version
|
||||
agentVersion, _, err := cfg.fetchString(ctx, EndpointVersion)
|
||||
if err != nil {
|
||||
return err
|
||||
if err := streamUnsupportedErrs.Error(); err != nil {
|
||||
gperr.LogWarn("agent has limited/no stream tunneling support, TCP and UDP routes via agent will not work", err, &cfg.l)
|
||||
}
|
||||
|
||||
// check agent runtime
|
||||
runtime, status, err := cfg.fetchString(ctx, EndpointRuntime)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
switch status {
|
||||
case http.StatusOK:
|
||||
switch runtime {
|
||||
case "docker":
|
||||
cfg.Runtime = ContainerRuntimeDocker
|
||||
// case "nerdctl":
|
||||
// cfg.Runtime = ContainerRuntimeNerdctl
|
||||
case "podman":
|
||||
cfg.Runtime = ContainerRuntimePodman
|
||||
default:
|
||||
return fmt.Errorf("invalid agent runtime: %s", runtime)
|
||||
}
|
||||
case http.StatusNotFound:
|
||||
// backward compatibility, old agent does not have runtime endpoint
|
||||
cfg.Runtime = ContainerRuntimeDocker
|
||||
default:
|
||||
return fmt.Errorf("failed to get agent runtime: HTTP %d %s", status, runtime)
|
||||
}
|
||||
|
||||
cfg.Version = version.Parse(agentVersion)
|
||||
|
||||
if serverVersion.IsNewerThanMajor(cfg.Version) {
|
||||
log.Warn().Msgf("agent %s major version mismatch: server: %s, agent: %s", cfg.Name, serverVersion, cfg.Version)
|
||||
}
|
||||
@@ -177,6 +247,38 @@ func (cfg *AgentConfig) Init(ctx context.Context) error {
|
||||
return cfg.InitWithCerts(ctx, ca, crt, key)
|
||||
}
|
||||
|
||||
// NewTCPClient creates a new TCP client for the agent.
|
||||
//
|
||||
// It returns an error if
|
||||
// - the agent is not initialized
|
||||
// - the agent does not support TCP stream tunneling
|
||||
// - the agent stream server address is not initialized
|
||||
func (cfg *AgentConfig) NewTCPClient(targetAddress string) (net.Conn, error) {
|
||||
if cfg.caCert == nil || cfg.clientCert == nil {
|
||||
return nil, errors.New("agent is not initialized")
|
||||
}
|
||||
if !cfg.IsTCPStreamSupported {
|
||||
return nil, errors.New("agent does not support TCP stream tunneling")
|
||||
}
|
||||
return agentstream.NewTCPClient(cfg.Addr, targetAddress, cfg.caCert, cfg.clientCert)
|
||||
}
|
||||
|
||||
// NewUDPClient creates a new UDP client for the agent.
|
||||
//
|
||||
// It returns an error if
|
||||
// - the agent is not initialized
|
||||
// - the agent does not support UDP stream tunneling
|
||||
// - the agent stream server address is not initialized
|
||||
func (cfg *AgentConfig) NewUDPClient(targetAddress string) (net.Conn, error) {
|
||||
if cfg.caCert == nil || cfg.clientCert == nil {
|
||||
return nil, errors.New("agent is not initialized")
|
||||
}
|
||||
if !cfg.IsUDPStreamSupported {
|
||||
return nil, errors.New("agent does not support UDP stream tunneling")
|
||||
}
|
||||
return agentstream.NewUDPClient(cfg.Addr, targetAddress, cfg.caCert, cfg.clientCert)
|
||||
}
|
||||
|
||||
func (cfg *AgentConfig) Transport() *http.Transport {
|
||||
return &http.Transport{
|
||||
DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) {
|
||||
@@ -232,3 +334,31 @@ func (cfg *AgentConfig) fetchString(ctx context.Context, endpoint string) (strin
|
||||
release(data)
|
||||
return ret, resp.StatusCode, nil
|
||||
}
|
||||
|
||||
// fetchJSON fetches a JSON response from the agent and unmarshals it into the provided struct
|
||||
//
|
||||
// It will return the status code of the response, and error if any.
|
||||
// If the status code is not http.StatusOK, out will be unchanged but error will still be nil.
|
||||
func (cfg *AgentConfig) fetchJSON(ctx context.Context, endpoint string, out any) (int, error) {
|
||||
resp, err := cfg.do(ctx, "GET", endpoint, nil)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
data, release, err := httputils.ReadAllBody(resp)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
defer release(data)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return resp.StatusCode, nil
|
||||
}
|
||||
|
||||
err = json.Unmarshal(data, out)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return resp.StatusCode, nil
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user