mirror of
https://github.com/netbirdio/netbird.git
synced 2026-10-24 22:39:09 +02:00
[management,proxy,client] Add L4 capabilities (TLS/TCP/UDP) (#5530)
This commit is contained in:
+115
-29
@@ -22,20 +22,24 @@ import (
|
|||||||
var pinRegexp = regexp.MustCompile(`^\d{6}$`)
|
var pinRegexp = regexp.MustCompile(`^\d{6}$`)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
exposePin string
|
exposePin string
|
||||||
exposePassword string
|
exposePassword string
|
||||||
exposeUserGroups []string
|
exposeUserGroups []string
|
||||||
exposeDomain string
|
exposeDomain string
|
||||||
exposeNamePrefix string
|
exposeNamePrefix string
|
||||||
exposeProtocol string
|
exposeProtocol string
|
||||||
|
exposeExternalPort uint16
|
||||||
)
|
)
|
||||||
|
|
||||||
var exposeCmd = &cobra.Command{
|
var exposeCmd = &cobra.Command{
|
||||||
Use: "expose <port>",
|
Use: "expose <port>",
|
||||||
Short: "Expose a local port via the NetBird reverse proxy",
|
Short: "Expose a local port via the NetBird reverse proxy",
|
||||||
Args: cobra.ExactArgs(1),
|
Args: cobra.ExactArgs(1),
|
||||||
Example: "netbird expose --with-password safe-pass 8080",
|
Example: ` netbird expose --with-password safe-pass 8080
|
||||||
RunE: exposeFn,
|
netbird expose --protocol tcp 5432
|
||||||
|
netbird expose --protocol tcp --with-external-port 5433 5432
|
||||||
|
netbird expose --protocol tls --with-custom-domain tls.example.com 4443`,
|
||||||
|
RunE: exposeFn,
|
||||||
}
|
}
|
||||||
|
|
||||||
func init() {
|
func init() {
|
||||||
@@ -44,7 +48,52 @@ func init() {
|
|||||||
exposeCmd.Flags().StringSliceVar(&exposeUserGroups, "with-user-groups", nil, "Restrict access to specific user groups with SSO (e.g. --with-user-groups devops,Backend)")
|
exposeCmd.Flags().StringSliceVar(&exposeUserGroups, "with-user-groups", nil, "Restrict access to specific user groups with SSO (e.g. --with-user-groups devops,Backend)")
|
||||||
exposeCmd.Flags().StringVar(&exposeDomain, "with-custom-domain", "", "Custom domain for the exposed service, must be configured to your account (e.g. --with-custom-domain myapp.example.com)")
|
exposeCmd.Flags().StringVar(&exposeDomain, "with-custom-domain", "", "Custom domain for the exposed service, must be configured to your account (e.g. --with-custom-domain myapp.example.com)")
|
||||||
exposeCmd.Flags().StringVar(&exposeNamePrefix, "with-name-prefix", "", "Prefix for the generated service name (e.g. --with-name-prefix my-app)")
|
exposeCmd.Flags().StringVar(&exposeNamePrefix, "with-name-prefix", "", "Prefix for the generated service name (e.g. --with-name-prefix my-app)")
|
||||||
exposeCmd.Flags().StringVar(&exposeProtocol, "protocol", "http", "Protocol to use, http/https is supported (e.g. --protocol http)")
|
exposeCmd.Flags().StringVar(&exposeProtocol, "protocol", "http", "Protocol to use: http, https, tcp, udp, or tls (e.g. --protocol tcp)")
|
||||||
|
exposeCmd.Flags().Uint16Var(&exposeExternalPort, "with-external-port", 0, "Public-facing external port on the proxy cluster (defaults to the target port for L4)")
|
||||||
|
}
|
||||||
|
|
||||||
|
// isClusterProtocol returns true for L4/TLS protocols that reject HTTP-style auth flags.
|
||||||
|
func isClusterProtocol(protocol string) bool {
|
||||||
|
switch strings.ToLower(protocol) {
|
||||||
|
case "tcp", "udp", "tls":
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// isPortBasedProtocol returns true for pure port-based protocols (TCP/UDP)
|
||||||
|
// where domain display doesn't apply. TLS uses SNI so it has a domain.
|
||||||
|
func isPortBasedProtocol(protocol string) bool {
|
||||||
|
switch strings.ToLower(protocol) {
|
||||||
|
case "tcp", "udp":
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// extractPort returns the port portion of a URL like "tcp://host:12345", or
|
||||||
|
// falls back to the given default formatted as a string.
|
||||||
|
func extractPort(serviceURL string, fallback uint16) string {
|
||||||
|
u := serviceURL
|
||||||
|
if idx := strings.Index(u, "://"); idx != -1 {
|
||||||
|
u = u[idx+3:]
|
||||||
|
}
|
||||||
|
if i := strings.LastIndex(u, ":"); i != -1 {
|
||||||
|
if p := u[i+1:]; p != "" {
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return strconv.FormatUint(uint64(fallback), 10)
|
||||||
|
}
|
||||||
|
|
||||||
|
// resolveExternalPort returns the effective external port, defaulting to the target port.
|
||||||
|
func resolveExternalPort(targetPort uint64) uint16 {
|
||||||
|
if exposeExternalPort != 0 {
|
||||||
|
return exposeExternalPort
|
||||||
|
}
|
||||||
|
return uint16(targetPort)
|
||||||
}
|
}
|
||||||
|
|
||||||
func validateExposeFlags(cmd *cobra.Command, portStr string) (uint64, error) {
|
func validateExposeFlags(cmd *cobra.Command, portStr string) (uint64, error) {
|
||||||
@@ -57,7 +106,15 @@ func validateExposeFlags(cmd *cobra.Command, portStr string) (uint64, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if !isProtocolValid(exposeProtocol) {
|
if !isProtocolValid(exposeProtocol) {
|
||||||
return 0, fmt.Errorf("unsupported protocol %q: only 'http' or 'https' are supported", exposeProtocol)
|
return 0, fmt.Errorf("unsupported protocol %q: must be http, https, tcp, udp, or tls", exposeProtocol)
|
||||||
|
}
|
||||||
|
|
||||||
|
if isClusterProtocol(exposeProtocol) {
|
||||||
|
if exposePin != "" || exposePassword != "" || len(exposeUserGroups) > 0 {
|
||||||
|
return 0, fmt.Errorf("auth flags (--with-pin, --with-password, --with-user-groups) are not supported for %s protocol", exposeProtocol)
|
||||||
|
}
|
||||||
|
} else if cmd.Flags().Changed("with-external-port") {
|
||||||
|
return 0, fmt.Errorf("--with-external-port is not supported for %s protocol", exposeProtocol)
|
||||||
}
|
}
|
||||||
|
|
||||||
if exposePin != "" && !pinRegexp.MatchString(exposePin) {
|
if exposePin != "" && !pinRegexp.MatchString(exposePin) {
|
||||||
@@ -76,7 +133,12 @@ func validateExposeFlags(cmd *cobra.Command, portStr string) (uint64, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func isProtocolValid(exposeProtocol string) bool {
|
func isProtocolValid(exposeProtocol string) bool {
|
||||||
return strings.ToLower(exposeProtocol) == "http" || strings.ToLower(exposeProtocol) == "https"
|
switch strings.ToLower(exposeProtocol) {
|
||||||
|
case "http", "https", "tcp", "udp", "tls":
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func exposeFn(cmd *cobra.Command, args []string) error {
|
func exposeFn(cmd *cobra.Command, args []string) error {
|
||||||
@@ -123,7 +185,7 @@ func exposeFn(cmd *cobra.Command, args []string) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
stream, err := client.ExposeService(ctx, &proto.ExposeServiceRequest{
|
req := &proto.ExposeServiceRequest{
|
||||||
Port: uint32(port),
|
Port: uint32(port),
|
||||||
Protocol: protocol,
|
Protocol: protocol,
|
||||||
Pin: exposePin,
|
Pin: exposePin,
|
||||||
@@ -131,7 +193,12 @@ func exposeFn(cmd *cobra.Command, args []string) error {
|
|||||||
UserGroups: exposeUserGroups,
|
UserGroups: exposeUserGroups,
|
||||||
Domain: exposeDomain,
|
Domain: exposeDomain,
|
||||||
NamePrefix: exposeNamePrefix,
|
NamePrefix: exposeNamePrefix,
|
||||||
})
|
}
|
||||||
|
if isClusterProtocol(exposeProtocol) {
|
||||||
|
req.ListenPort = uint32(resolveExternalPort(port))
|
||||||
|
}
|
||||||
|
|
||||||
|
stream, err := client.ExposeService(ctx, req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("expose service: %w", err)
|
return fmt.Errorf("expose service: %w", err)
|
||||||
}
|
}
|
||||||
@@ -149,8 +216,14 @@ func toExposeProtocol(exposeProtocol string) (proto.ExposeProtocol, error) {
|
|||||||
return proto.ExposeProtocol_EXPOSE_HTTP, nil
|
return proto.ExposeProtocol_EXPOSE_HTTP, nil
|
||||||
case "https":
|
case "https":
|
||||||
return proto.ExposeProtocol_EXPOSE_HTTPS, nil
|
return proto.ExposeProtocol_EXPOSE_HTTPS, nil
|
||||||
|
case "tcp":
|
||||||
|
return proto.ExposeProtocol_EXPOSE_TCP, nil
|
||||||
|
case "udp":
|
||||||
|
return proto.ExposeProtocol_EXPOSE_UDP, nil
|
||||||
|
case "tls":
|
||||||
|
return proto.ExposeProtocol_EXPOSE_TLS, nil
|
||||||
default:
|
default:
|
||||||
return 0, fmt.Errorf("unsupported protocol %q: only 'http' or 'https' are supported", exposeProtocol)
|
return 0, fmt.Errorf("unsupported protocol %q: must be http, https, tcp, udp, or tls", exposeProtocol)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -160,20 +233,33 @@ func handleExposeReady(cmd *cobra.Command, stream proto.DaemonService_ExposeServ
|
|||||||
return fmt.Errorf("receive expose event: %w", err)
|
return fmt.Errorf("receive expose event: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
switch e := event.Event.(type) {
|
ready, ok := event.Event.(*proto.ExposeServiceEvent_Ready)
|
||||||
case *proto.ExposeServiceEvent_Ready:
|
if !ok {
|
||||||
cmd.Println("Service exposed successfully!")
|
|
||||||
cmd.Printf(" Name: %s\n", e.Ready.ServiceName)
|
|
||||||
cmd.Printf(" URL: %s\n", e.Ready.ServiceUrl)
|
|
||||||
cmd.Printf(" Domain: %s\n", e.Ready.Domain)
|
|
||||||
cmd.Printf(" Protocol: %s\n", exposeProtocol)
|
|
||||||
cmd.Printf(" Port: %d\n", port)
|
|
||||||
cmd.Println()
|
|
||||||
cmd.Println("Press Ctrl+C to stop exposing.")
|
|
||||||
return nil
|
|
||||||
default:
|
|
||||||
return fmt.Errorf("unexpected expose event: %T", event.Event)
|
return fmt.Errorf("unexpected expose event: %T", event.Event)
|
||||||
}
|
}
|
||||||
|
printExposeReady(cmd, ready.Ready, port)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func printExposeReady(cmd *cobra.Command, r *proto.ExposeServiceReady, port uint64) {
|
||||||
|
cmd.Println("Service exposed successfully!")
|
||||||
|
cmd.Printf(" Name: %s\n", r.ServiceName)
|
||||||
|
if r.ServiceUrl != "" {
|
||||||
|
cmd.Printf(" URL: %s\n", r.ServiceUrl)
|
||||||
|
}
|
||||||
|
if r.Domain != "" && !isPortBasedProtocol(exposeProtocol) {
|
||||||
|
cmd.Printf(" Domain: %s\n", r.Domain)
|
||||||
|
}
|
||||||
|
cmd.Printf(" Protocol: %s\n", exposeProtocol)
|
||||||
|
cmd.Printf(" Internal: %d\n", port)
|
||||||
|
if isClusterProtocol(exposeProtocol) {
|
||||||
|
cmd.Printf(" External: %s\n", extractPort(r.ServiceUrl, resolveExternalPort(port)))
|
||||||
|
}
|
||||||
|
if r.PortAutoAssigned && exposeExternalPort != 0 {
|
||||||
|
cmd.Printf("\n Note: requested port %d was reassigned\n", exposeExternalPort)
|
||||||
|
}
|
||||||
|
cmd.Println()
|
||||||
|
cmd.Println("Press Ctrl+C to stop exposing.")
|
||||||
}
|
}
|
||||||
|
|
||||||
func waitForExposeEvents(cmd *cobra.Command, ctx context.Context, stream proto.DaemonService_ExposeServiceClient) error {
|
func waitForExposeEvents(cmd *cobra.Command, ctx context.Context, stream proto.DaemonService_ExposeServiceClient) error {
|
||||||
|
|||||||
@@ -12,9 +12,10 @@ const renewTimeout = 10 * time.Second
|
|||||||
|
|
||||||
// Response holds the response from exposing a service.
|
// Response holds the response from exposing a service.
|
||||||
type Response struct {
|
type Response struct {
|
||||||
ServiceName string
|
ServiceName string
|
||||||
ServiceURL string
|
ServiceURL string
|
||||||
Domain string
|
Domain string
|
||||||
|
PortAutoAssigned bool
|
||||||
}
|
}
|
||||||
|
|
||||||
type Request struct {
|
type Request struct {
|
||||||
@@ -25,6 +26,7 @@ type Request struct {
|
|||||||
Pin string
|
Pin string
|
||||||
Password string
|
Password string
|
||||||
UserGroups []string
|
UserGroups []string
|
||||||
|
ListenPort uint16
|
||||||
}
|
}
|
||||||
|
|
||||||
type ManagementClient interface {
|
type ManagementClient interface {
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ func NewRequest(req *daemonProto.ExposeServiceRequest) *Request {
|
|||||||
UserGroups: req.UserGroups,
|
UserGroups: req.UserGroups,
|
||||||
Domain: req.Domain,
|
Domain: req.Domain,
|
||||||
NamePrefix: req.NamePrefix,
|
NamePrefix: req.NamePrefix,
|
||||||
|
ListenPort: uint16(req.ListenPort),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -27,13 +28,15 @@ func toClientExposeRequest(req Request) mgm.ExposeRequest {
|
|||||||
Pin: req.Pin,
|
Pin: req.Pin,
|
||||||
Password: req.Password,
|
Password: req.Password,
|
||||||
UserGroups: req.UserGroups,
|
UserGroups: req.UserGroups,
|
||||||
|
ListenPort: req.ListenPort,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func fromClientExposeResponse(response *mgm.ExposeResponse) *Response {
|
func fromClientExposeResponse(response *mgm.ExposeResponse) *Response {
|
||||||
return &Response{
|
return &Response{
|
||||||
ServiceName: response.ServiceName,
|
ServiceName: response.ServiceName,
|
||||||
Domain: response.Domain,
|
Domain: response.Domain,
|
||||||
ServiceURL: response.ServiceURL,
|
ServiceURL: response.ServiceURL,
|
||||||
|
PortAutoAssigned: response.PortAutoAssigned,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+36
-12
@@ -95,6 +95,7 @@ const (
|
|||||||
ExposeProtocol_EXPOSE_HTTPS ExposeProtocol = 1
|
ExposeProtocol_EXPOSE_HTTPS ExposeProtocol = 1
|
||||||
ExposeProtocol_EXPOSE_TCP ExposeProtocol = 2
|
ExposeProtocol_EXPOSE_TCP ExposeProtocol = 2
|
||||||
ExposeProtocol_EXPOSE_UDP ExposeProtocol = 3
|
ExposeProtocol_EXPOSE_UDP ExposeProtocol = 3
|
||||||
|
ExposeProtocol_EXPOSE_TLS ExposeProtocol = 4
|
||||||
)
|
)
|
||||||
|
|
||||||
// Enum value maps for ExposeProtocol.
|
// Enum value maps for ExposeProtocol.
|
||||||
@@ -104,12 +105,14 @@ var (
|
|||||||
1: "EXPOSE_HTTPS",
|
1: "EXPOSE_HTTPS",
|
||||||
2: "EXPOSE_TCP",
|
2: "EXPOSE_TCP",
|
||||||
3: "EXPOSE_UDP",
|
3: "EXPOSE_UDP",
|
||||||
|
4: "EXPOSE_TLS",
|
||||||
}
|
}
|
||||||
ExposeProtocol_value = map[string]int32{
|
ExposeProtocol_value = map[string]int32{
|
||||||
"EXPOSE_HTTP": 0,
|
"EXPOSE_HTTP": 0,
|
||||||
"EXPOSE_HTTPS": 1,
|
"EXPOSE_HTTPS": 1,
|
||||||
"EXPOSE_TCP": 2,
|
"EXPOSE_TCP": 2,
|
||||||
"EXPOSE_UDP": 3,
|
"EXPOSE_UDP": 3,
|
||||||
|
"EXPOSE_TLS": 4,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -5741,6 +5744,7 @@ type ExposeServiceRequest struct {
|
|||||||
UserGroups []string `protobuf:"bytes,5,rep,name=user_groups,json=userGroups,proto3" json:"user_groups,omitempty"`
|
UserGroups []string `protobuf:"bytes,5,rep,name=user_groups,json=userGroups,proto3" json:"user_groups,omitempty"`
|
||||||
Domain string `protobuf:"bytes,6,opt,name=domain,proto3" json:"domain,omitempty"`
|
Domain string `protobuf:"bytes,6,opt,name=domain,proto3" json:"domain,omitempty"`
|
||||||
NamePrefix string `protobuf:"bytes,7,opt,name=name_prefix,json=namePrefix,proto3" json:"name_prefix,omitempty"`
|
NamePrefix string `protobuf:"bytes,7,opt,name=name_prefix,json=namePrefix,proto3" json:"name_prefix,omitempty"`
|
||||||
|
ListenPort uint32 `protobuf:"varint,8,opt,name=listen_port,json=listenPort,proto3" json:"listen_port,omitempty"`
|
||||||
unknownFields protoimpl.UnknownFields
|
unknownFields protoimpl.UnknownFields
|
||||||
sizeCache protoimpl.SizeCache
|
sizeCache protoimpl.SizeCache
|
||||||
}
|
}
|
||||||
@@ -5824,6 +5828,13 @@ func (x *ExposeServiceRequest) GetNamePrefix() string {
|
|||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (x *ExposeServiceRequest) GetListenPort() uint32 {
|
||||||
|
if x != nil {
|
||||||
|
return x.ListenPort
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
type ExposeServiceEvent struct {
|
type ExposeServiceEvent struct {
|
||||||
state protoimpl.MessageState `protogen:"open.v1"`
|
state protoimpl.MessageState `protogen:"open.v1"`
|
||||||
// Types that are valid to be assigned to Event:
|
// Types that are valid to be assigned to Event:
|
||||||
@@ -5891,12 +5902,13 @@ type ExposeServiceEvent_Ready struct {
|
|||||||
func (*ExposeServiceEvent_Ready) isExposeServiceEvent_Event() {}
|
func (*ExposeServiceEvent_Ready) isExposeServiceEvent_Event() {}
|
||||||
|
|
||||||
type ExposeServiceReady struct {
|
type ExposeServiceReady struct {
|
||||||
state protoimpl.MessageState `protogen:"open.v1"`
|
state protoimpl.MessageState `protogen:"open.v1"`
|
||||||
ServiceName string `protobuf:"bytes,1,opt,name=service_name,json=serviceName,proto3" json:"service_name,omitempty"`
|
ServiceName string `protobuf:"bytes,1,opt,name=service_name,json=serviceName,proto3" json:"service_name,omitempty"`
|
||||||
ServiceUrl string `protobuf:"bytes,2,opt,name=service_url,json=serviceUrl,proto3" json:"service_url,omitempty"`
|
ServiceUrl string `protobuf:"bytes,2,opt,name=service_url,json=serviceUrl,proto3" json:"service_url,omitempty"`
|
||||||
Domain string `protobuf:"bytes,3,opt,name=domain,proto3" json:"domain,omitempty"`
|
Domain string `protobuf:"bytes,3,opt,name=domain,proto3" json:"domain,omitempty"`
|
||||||
unknownFields protoimpl.UnknownFields
|
PortAutoAssigned bool `protobuf:"varint,4,opt,name=port_auto_assigned,json=portAutoAssigned,proto3" json:"port_auto_assigned,omitempty"`
|
||||||
sizeCache protoimpl.SizeCache
|
unknownFields protoimpl.UnknownFields
|
||||||
|
sizeCache protoimpl.SizeCache
|
||||||
}
|
}
|
||||||
|
|
||||||
func (x *ExposeServiceReady) Reset() {
|
func (x *ExposeServiceReady) Reset() {
|
||||||
@@ -5950,6 +5962,13 @@ func (x *ExposeServiceReady) GetDomain() string {
|
|||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (x *ExposeServiceReady) GetPortAutoAssigned() bool {
|
||||||
|
if x != nil {
|
||||||
|
return x.PortAutoAssigned
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
type PortInfo_Range struct {
|
type PortInfo_Range struct {
|
||||||
state protoimpl.MessageState `protogen:"open.v1"`
|
state protoimpl.MessageState `protogen:"open.v1"`
|
||||||
Start uint32 `protobuf:"varint,1,opt,name=start,proto3" json:"start,omitempty"`
|
Start uint32 `protobuf:"varint,1,opt,name=start,proto3" json:"start,omitempty"`
|
||||||
@@ -6499,7 +6518,7 @@ const file_daemon_proto_rawDesc = "" +
|
|||||||
"\x16InstallerResultRequest\"O\n" +
|
"\x16InstallerResultRequest\"O\n" +
|
||||||
"\x17InstallerResultResponse\x12\x18\n" +
|
"\x17InstallerResultResponse\x12\x18\n" +
|
||||||
"\asuccess\x18\x01 \x01(\bR\asuccess\x12\x1a\n" +
|
"\asuccess\x18\x01 \x01(\bR\asuccess\x12\x1a\n" +
|
||||||
"\berrorMsg\x18\x02 \x01(\tR\berrorMsg\"\xe6\x01\n" +
|
"\berrorMsg\x18\x02 \x01(\tR\berrorMsg\"\x87\x02\n" +
|
||||||
"\x14ExposeServiceRequest\x12\x12\n" +
|
"\x14ExposeServiceRequest\x12\x12\n" +
|
||||||
"\x04port\x18\x01 \x01(\rR\x04port\x122\n" +
|
"\x04port\x18\x01 \x01(\rR\x04port\x122\n" +
|
||||||
"\bprotocol\x18\x02 \x01(\x0e2\x16.daemon.ExposeProtocolR\bprotocol\x12\x10\n" +
|
"\bprotocol\x18\x02 \x01(\x0e2\x16.daemon.ExposeProtocolR\bprotocol\x12\x10\n" +
|
||||||
@@ -6509,15 +6528,18 @@ const file_daemon_proto_rawDesc = "" +
|
|||||||
"userGroups\x12\x16\n" +
|
"userGroups\x12\x16\n" +
|
||||||
"\x06domain\x18\x06 \x01(\tR\x06domain\x12\x1f\n" +
|
"\x06domain\x18\x06 \x01(\tR\x06domain\x12\x1f\n" +
|
||||||
"\vname_prefix\x18\a \x01(\tR\n" +
|
"\vname_prefix\x18\a \x01(\tR\n" +
|
||||||
"namePrefix\"Q\n" +
|
"namePrefix\x12\x1f\n" +
|
||||||
|
"\vlisten_port\x18\b \x01(\rR\n" +
|
||||||
|
"listenPort\"Q\n" +
|
||||||
"\x12ExposeServiceEvent\x122\n" +
|
"\x12ExposeServiceEvent\x122\n" +
|
||||||
"\x05ready\x18\x01 \x01(\v2\x1a.daemon.ExposeServiceReadyH\x00R\x05readyB\a\n" +
|
"\x05ready\x18\x01 \x01(\v2\x1a.daemon.ExposeServiceReadyH\x00R\x05readyB\a\n" +
|
||||||
"\x05event\"p\n" +
|
"\x05event\"\x9e\x01\n" +
|
||||||
"\x12ExposeServiceReady\x12!\n" +
|
"\x12ExposeServiceReady\x12!\n" +
|
||||||
"\fservice_name\x18\x01 \x01(\tR\vserviceName\x12\x1f\n" +
|
"\fservice_name\x18\x01 \x01(\tR\vserviceName\x12\x1f\n" +
|
||||||
"\vservice_url\x18\x02 \x01(\tR\n" +
|
"\vservice_url\x18\x02 \x01(\tR\n" +
|
||||||
"serviceUrl\x12\x16\n" +
|
"serviceUrl\x12\x16\n" +
|
||||||
"\x06domain\x18\x03 \x01(\tR\x06domain*b\n" +
|
"\x06domain\x18\x03 \x01(\tR\x06domain\x12,\n" +
|
||||||
|
"\x12port_auto_assigned\x18\x04 \x01(\bR\x10portAutoAssigned*b\n" +
|
||||||
"\bLogLevel\x12\v\n" +
|
"\bLogLevel\x12\v\n" +
|
||||||
"\aUNKNOWN\x10\x00\x12\t\n" +
|
"\aUNKNOWN\x10\x00\x12\t\n" +
|
||||||
"\x05PANIC\x10\x01\x12\t\n" +
|
"\x05PANIC\x10\x01\x12\t\n" +
|
||||||
@@ -6526,14 +6548,16 @@ const file_daemon_proto_rawDesc = "" +
|
|||||||
"\x04WARN\x10\x04\x12\b\n" +
|
"\x04WARN\x10\x04\x12\b\n" +
|
||||||
"\x04INFO\x10\x05\x12\t\n" +
|
"\x04INFO\x10\x05\x12\t\n" +
|
||||||
"\x05DEBUG\x10\x06\x12\t\n" +
|
"\x05DEBUG\x10\x06\x12\t\n" +
|
||||||
"\x05TRACE\x10\a*S\n" +
|
"\x05TRACE\x10\a*c\n" +
|
||||||
"\x0eExposeProtocol\x12\x0f\n" +
|
"\x0eExposeProtocol\x12\x0f\n" +
|
||||||
"\vEXPOSE_HTTP\x10\x00\x12\x10\n" +
|
"\vEXPOSE_HTTP\x10\x00\x12\x10\n" +
|
||||||
"\fEXPOSE_HTTPS\x10\x01\x12\x0e\n" +
|
"\fEXPOSE_HTTPS\x10\x01\x12\x0e\n" +
|
||||||
"\n" +
|
"\n" +
|
||||||
"EXPOSE_TCP\x10\x02\x12\x0e\n" +
|
"EXPOSE_TCP\x10\x02\x12\x0e\n" +
|
||||||
"\n" +
|
"\n" +
|
||||||
"EXPOSE_UDP\x10\x032\xfc\x15\n" +
|
"EXPOSE_UDP\x10\x03\x12\x0e\n" +
|
||||||
|
"\n" +
|
||||||
|
"EXPOSE_TLS\x10\x042\xfc\x15\n" +
|
||||||
"\rDaemonService\x126\n" +
|
"\rDaemonService\x126\n" +
|
||||||
"\x05Login\x12\x14.daemon.LoginRequest\x1a\x15.daemon.LoginResponse\"\x00\x12K\n" +
|
"\x05Login\x12\x14.daemon.LoginRequest\x1a\x15.daemon.LoginResponse\"\x00\x12K\n" +
|
||||||
"\fWaitSSOLogin\x12\x1b.daemon.WaitSSOLoginRequest\x1a\x1c.daemon.WaitSSOLoginResponse\"\x00\x12-\n" +
|
"\fWaitSSOLogin\x12\x1b.daemon.WaitSSOLoginRequest\x1a\x1c.daemon.WaitSSOLoginResponse\"\x00\x12-\n" +
|
||||||
|
|||||||
@@ -821,6 +821,7 @@ enum ExposeProtocol {
|
|||||||
EXPOSE_HTTPS = 1;
|
EXPOSE_HTTPS = 1;
|
||||||
EXPOSE_TCP = 2;
|
EXPOSE_TCP = 2;
|
||||||
EXPOSE_UDP = 3;
|
EXPOSE_UDP = 3;
|
||||||
|
EXPOSE_TLS = 4;
|
||||||
}
|
}
|
||||||
|
|
||||||
message ExposeServiceRequest {
|
message ExposeServiceRequest {
|
||||||
@@ -831,6 +832,7 @@ message ExposeServiceRequest {
|
|||||||
repeated string user_groups = 5;
|
repeated string user_groups = 5;
|
||||||
string domain = 6;
|
string domain = 6;
|
||||||
string name_prefix = 7;
|
string name_prefix = 7;
|
||||||
|
uint32 listen_port = 8;
|
||||||
}
|
}
|
||||||
|
|
||||||
message ExposeServiceEvent {
|
message ExposeServiceEvent {
|
||||||
@@ -843,4 +845,5 @@ message ExposeServiceReady {
|
|||||||
string service_name = 1;
|
string service_name = 1;
|
||||||
string service_url = 2;
|
string service_url = 2;
|
||||||
string domain = 3;
|
string domain = 3;
|
||||||
|
bool port_auto_assigned = 4;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1378,9 +1378,10 @@ func (s *Server) ExposeService(req *proto.ExposeServiceRequest, srv proto.Daemon
|
|||||||
if err := srv.Send(&proto.ExposeServiceEvent{
|
if err := srv.Send(&proto.ExposeServiceEvent{
|
||||||
Event: &proto.ExposeServiceEvent_Ready{
|
Event: &proto.ExposeServiceEvent_Ready{
|
||||||
Ready: &proto.ExposeServiceReady{
|
Ready: &proto.ExposeServiceReady{
|
||||||
ServiceName: result.ServiceName,
|
ServiceName: result.ServiceName,
|
||||||
ServiceUrl: result.ServiceURL,
|
ServiceUrl: result.ServiceURL,
|
||||||
Domain: result.Domain,
|
Domain: result.Domain,
|
||||||
|
PortAutoAssigned: result.PortAutoAssigned,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}); err != nil {
|
}); err != nil {
|
||||||
|
|||||||
@@ -10,6 +10,15 @@ import (
|
|||||||
"github.com/netbirdio/netbird/shared/management/proto"
|
"github.com/netbirdio/netbird/shared/management/proto"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// AccessLogProtocol identifies the transport protocol of an access log entry.
|
||||||
|
type AccessLogProtocol string
|
||||||
|
|
||||||
|
const (
|
||||||
|
AccessLogProtocolHTTP AccessLogProtocol = "http"
|
||||||
|
AccessLogProtocolTCP AccessLogProtocol = "tcp"
|
||||||
|
AccessLogProtocolUDP AccessLogProtocol = "udp"
|
||||||
|
)
|
||||||
|
|
||||||
type AccessLogEntry struct {
|
type AccessLogEntry struct {
|
||||||
ID string `gorm:"primaryKey"`
|
ID string `gorm:"primaryKey"`
|
||||||
AccountID string `gorm:"index"`
|
AccountID string `gorm:"index"`
|
||||||
@@ -22,10 +31,11 @@ type AccessLogEntry struct {
|
|||||||
Duration time.Duration `gorm:"index"`
|
Duration time.Duration `gorm:"index"`
|
||||||
StatusCode int `gorm:"index"`
|
StatusCode int `gorm:"index"`
|
||||||
Reason string
|
Reason string
|
||||||
UserId string `gorm:"index"`
|
UserId string `gorm:"index"`
|
||||||
AuthMethodUsed string `gorm:"index"`
|
AuthMethodUsed string `gorm:"index"`
|
||||||
BytesUpload int64 `gorm:"index"`
|
BytesUpload int64 `gorm:"index"`
|
||||||
BytesDownload int64 `gorm:"index"`
|
BytesDownload int64 `gorm:"index"`
|
||||||
|
Protocol AccessLogProtocol `gorm:"index"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// FromProto creates an AccessLogEntry from a proto.AccessLog
|
// FromProto creates an AccessLogEntry from a proto.AccessLog
|
||||||
@@ -43,17 +53,22 @@ func (a *AccessLogEntry) FromProto(serviceLog *proto.AccessLog) {
|
|||||||
a.AccountID = serviceLog.GetAccountId()
|
a.AccountID = serviceLog.GetAccountId()
|
||||||
a.BytesUpload = serviceLog.GetBytesUpload()
|
a.BytesUpload = serviceLog.GetBytesUpload()
|
||||||
a.BytesDownload = serviceLog.GetBytesDownload()
|
a.BytesDownload = serviceLog.GetBytesDownload()
|
||||||
|
a.Protocol = AccessLogProtocol(serviceLog.GetProtocol())
|
||||||
|
|
||||||
if sourceIP := serviceLog.GetSourceIp(); sourceIP != "" {
|
if sourceIP := serviceLog.GetSourceIp(); sourceIP != "" {
|
||||||
if ip, err := netip.ParseAddr(sourceIP); err == nil {
|
if addr, err := netip.ParseAddr(sourceIP); err == nil {
|
||||||
a.GeoLocation.ConnectionIP = net.IP(ip.AsSlice())
|
addr = addr.Unmap()
|
||||||
|
a.GeoLocation.ConnectionIP = net.IP(addr.AsSlice())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if !serviceLog.GetAuthSuccess() {
|
// Only set reason for HTTP entries. L4 entries have no auth or status code.
|
||||||
a.Reason = "Authentication failed"
|
if a.Protocol == "" || a.Protocol == AccessLogProtocolHTTP {
|
||||||
} else if serviceLog.GetResponseCode() >= 400 {
|
if !serviceLog.GetAuthSuccess() {
|
||||||
a.Reason = "Request failed"
|
a.Reason = "Authentication failed"
|
||||||
|
} else if serviceLog.GetResponseCode() >= 400 {
|
||||||
|
a.Reason = "Request failed"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -90,6 +105,12 @@ func (a *AccessLogEntry) ToAPIResponse() *api.ProxyAccessLog {
|
|||||||
cityName = &a.GeoLocation.CityName
|
cityName = &a.GeoLocation.CityName
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var protocol *string
|
||||||
|
if a.Protocol != "" {
|
||||||
|
p := string(a.Protocol)
|
||||||
|
protocol = &p
|
||||||
|
}
|
||||||
|
|
||||||
return &api.ProxyAccessLog{
|
return &api.ProxyAccessLog{
|
||||||
Id: a.ID,
|
Id: a.ID,
|
||||||
ServiceId: a.ServiceID,
|
ServiceId: a.ServiceID,
|
||||||
@@ -107,5 +128,6 @@ func (a *AccessLogEntry) ToAPIResponse() *api.ProxyAccessLog {
|
|||||||
CityName: cityName,
|
CityName: cityName,
|
||||||
BytesUpload: a.BytesUpload,
|
BytesUpload: a.BytesUpload,
|
||||||
BytesDownload: a.BytesDownload,
|
BytesDownload: a.BytesDownload,
|
||||||
|
Protocol: protocol,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,6 +14,9 @@ type Domain struct {
|
|||||||
TargetCluster string // The proxy cluster this domain should be validated against
|
TargetCluster string // The proxy cluster this domain should be validated against
|
||||||
Type Type `gorm:"-"`
|
Type Type `gorm:"-"`
|
||||||
Validated bool
|
Validated bool
|
||||||
|
// SupportsCustomPorts is populated at query time for free domains from the
|
||||||
|
// proxy cluster capabilities. Not persisted.
|
||||||
|
SupportsCustomPorts *bool `gorm:"-"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// EventMeta returns activity event metadata for a domain
|
// EventMeta returns activity event metadata for a domain
|
||||||
|
|||||||
@@ -42,10 +42,11 @@ func domainTypeToApi(t domain.Type) api.ReverseProxyDomainType {
|
|||||||
|
|
||||||
func domainToApi(d *domain.Domain) api.ReverseProxyDomain {
|
func domainToApi(d *domain.Domain) api.ReverseProxyDomain {
|
||||||
resp := api.ReverseProxyDomain{
|
resp := api.ReverseProxyDomain{
|
||||||
Domain: d.Domain,
|
Domain: d.Domain,
|
||||||
Id: d.ID,
|
Id: d.ID,
|
||||||
Type: domainTypeToApi(d.Type),
|
Type: domainTypeToApi(d.Type),
|
||||||
Validated: d.Validated,
|
Validated: d.Validated,
|
||||||
|
SupportsCustomPorts: d.SupportsCustomPorts,
|
||||||
}
|
}
|
||||||
if d.TargetCluster != "" {
|
if d.TargetCluster != "" {
|
||||||
resp.TargetCluster = &d.TargetCluster
|
resp.TargetCluster = &d.TargetCluster
|
||||||
|
|||||||
@@ -33,11 +33,16 @@ type proxyManager interface {
|
|||||||
GetActiveClusterAddresses(ctx context.Context) ([]string, error)
|
GetActiveClusterAddresses(ctx context.Context) ([]string, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type clusterCapabilities interface {
|
||||||
|
ClusterSupportsCustomPorts(clusterAddr string) *bool
|
||||||
|
}
|
||||||
|
|
||||||
type Manager struct {
|
type Manager struct {
|
||||||
store store
|
store store
|
||||||
validator domain.Validator
|
validator domain.Validator
|
||||||
proxyManager proxyManager
|
proxyManager proxyManager
|
||||||
permissionsManager permissions.Manager
|
clusterCapabilities clusterCapabilities
|
||||||
|
permissionsManager permissions.Manager
|
||||||
accountManager account.Manager
|
accountManager account.Manager
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -51,6 +56,11 @@ func NewManager(store store, proxyMgr proxyManager, permissionsManager permissio
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SetClusterCapabilities sets the cluster capabilities provider for domain queries.
|
||||||
|
func (m *Manager) SetClusterCapabilities(caps clusterCapabilities) {
|
||||||
|
m.clusterCapabilities = caps
|
||||||
|
}
|
||||||
|
|
||||||
func (m Manager) GetDomains(ctx context.Context, accountID, userID string) ([]*domain.Domain, error) {
|
func (m Manager) GetDomains(ctx context.Context, accountID, userID string) ([]*domain.Domain, error) {
|
||||||
ok, err := m.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Services, operations.Read)
|
ok, err := m.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Services, operations.Read)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -80,24 +90,32 @@ func (m Manager) GetDomains(ctx context.Context, accountID, userID string) ([]*d
|
|||||||
}).Debug("getting domains with proxy allow list")
|
}).Debug("getting domains with proxy allow list")
|
||||||
|
|
||||||
for _, cluster := range allowList {
|
for _, cluster := range allowList {
|
||||||
ret = append(ret, &domain.Domain{
|
d := &domain.Domain{
|
||||||
Domain: cluster,
|
Domain: cluster,
|
||||||
AccountID: accountID,
|
AccountID: accountID,
|
||||||
Type: domain.TypeFree,
|
Type: domain.TypeFree,
|
||||||
Validated: true,
|
Validated: true,
|
||||||
})
|
}
|
||||||
|
if m.clusterCapabilities != nil {
|
||||||
|
d.SupportsCustomPorts = m.clusterCapabilities.ClusterSupportsCustomPorts(cluster)
|
||||||
|
}
|
||||||
|
ret = append(ret, d)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add custom domains.
|
// Add custom domains.
|
||||||
for _, d := range domains {
|
for _, d := range domains {
|
||||||
ret = append(ret, &domain.Domain{
|
cd := &domain.Domain{
|
||||||
ID: d.ID,
|
ID: d.ID,
|
||||||
Domain: d.Domain,
|
Domain: d.Domain,
|
||||||
AccountID: accountID,
|
AccountID: accountID,
|
||||||
TargetCluster: d.TargetCluster,
|
TargetCluster: d.TargetCluster,
|
||||||
Type: domain.TypeCustom,
|
Type: domain.TypeCustom,
|
||||||
Validated: d.Validated,
|
Validated: d.Validated,
|
||||||
})
|
}
|
||||||
|
if m.clusterCapabilities != nil && d.TargetCluster != "" {
|
||||||
|
cd.SupportsCustomPorts = m.clusterCapabilities.ClusterSupportsCustomPorts(d.TargetCluster)
|
||||||
|
}
|
||||||
|
ret = append(ret, cd)
|
||||||
}
|
}
|
||||||
|
|
||||||
return ret, nil
|
return ret, nil
|
||||||
@@ -298,7 +316,7 @@ func extractClusterFromCustomDomains(domain string, customDomains []*domain.Doma
|
|||||||
// It matches the domain suffix against available clusters and returns the matching cluster.
|
// It matches the domain suffix against available clusters and returns the matching cluster.
|
||||||
func ExtractClusterFromFreeDomain(domain string, availableClusters []string) (string, bool) {
|
func ExtractClusterFromFreeDomain(domain string, availableClusters []string) (string, bool) {
|
||||||
for _, cluster := range availableClusters {
|
for _, cluster := range availableClusters {
|
||||||
if strings.HasSuffix(domain, "."+cluster) {
|
if domain == cluster || strings.HasSuffix(domain, "."+cluster) {
|
||||||
return cluster, true
|
return cluster, true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -33,4 +33,5 @@ type Controller interface {
|
|||||||
RegisterProxyToCluster(ctx context.Context, clusterAddr, proxyID string) error
|
RegisterProxyToCluster(ctx context.Context, clusterAddr, proxyID string) error
|
||||||
UnregisterProxyFromCluster(ctx context.Context, clusterAddr, proxyID string) error
|
UnregisterProxyFromCluster(ctx context.Context, clusterAddr, proxyID string) error
|
||||||
GetProxiesForCluster(clusterAddr string) []string
|
GetProxiesForCluster(clusterAddr string) []string
|
||||||
|
ClusterSupportsCustomPorts(clusterAddr string) *bool
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -72,6 +72,11 @@ func (c *GRPCController) UnregisterProxyFromCluster(ctx context.Context, cluster
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ClusterSupportsCustomPorts returns whether any proxy in the cluster supports custom ports.
|
||||||
|
func (c *GRPCController) ClusterSupportsCustomPorts(clusterAddr string) *bool {
|
||||||
|
return c.proxyGRPCServer.ClusterSupportsCustomPorts(clusterAddr)
|
||||||
|
}
|
||||||
|
|
||||||
// GetProxiesForCluster returns all proxy IDs registered for a specific cluster.
|
// GetProxiesForCluster returns all proxy IDs registered for a specific cluster.
|
||||||
func (c *GRPCController) GetProxiesForCluster(clusterAddr string) []string {
|
func (c *GRPCController) GetProxiesForCluster(clusterAddr string) []string {
|
||||||
proxySet, ok := c.clusterProxies.Load(clusterAddr)
|
proxySet, ok := c.clusterProxies.Load(clusterAddr)
|
||||||
|
|||||||
@@ -144,6 +144,20 @@ func (mr *MockControllerMockRecorder) GetOIDCValidationConfig() *gomock.Call {
|
|||||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetOIDCValidationConfig", reflect.TypeOf((*MockController)(nil).GetOIDCValidationConfig))
|
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetOIDCValidationConfig", reflect.TypeOf((*MockController)(nil).GetOIDCValidationConfig))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ClusterSupportsCustomPorts mocks base method.
|
||||||
|
func (m *MockController) ClusterSupportsCustomPorts(clusterAddr string) *bool {
|
||||||
|
m.ctrl.T.Helper()
|
||||||
|
ret := m.ctrl.Call(m, "ClusterSupportsCustomPorts", clusterAddr)
|
||||||
|
ret0, _ := ret[0].(*bool)
|
||||||
|
return ret0
|
||||||
|
}
|
||||||
|
|
||||||
|
// ClusterSupportsCustomPorts indicates an expected call of ClusterSupportsCustomPorts.
|
||||||
|
func (mr *MockControllerMockRecorder) ClusterSupportsCustomPorts(clusterAddr interface{}) *gomock.Call {
|
||||||
|
mr.mock.ctrl.T.Helper()
|
||||||
|
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ClusterSupportsCustomPorts", reflect.TypeOf((*MockController)(nil).ClusterSupportsCustomPorts), clusterAddr)
|
||||||
|
}
|
||||||
|
|
||||||
// GetProxiesForCluster mocks base method.
|
// GetProxiesForCluster mocks base method.
|
||||||
func (m *MockController) GetProxiesForCluster(clusterAddr string) []string {
|
func (m *MockController) GetProxiesForCluster(clusterAddr string) []string {
|
||||||
m.ctrl.T.Helper()
|
m.ctrl.T.Helper()
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ type Manager interface {
|
|||||||
GetAccountServices(ctx context.Context, accountID string) ([]*Service, error)
|
GetAccountServices(ctx context.Context, accountID string) ([]*Service, error)
|
||||||
GetServiceIDByTargetID(ctx context.Context, accountID string, resourceID string) (string, error)
|
GetServiceIDByTargetID(ctx context.Context, accountID string, resourceID string) (string, error)
|
||||||
CreateServiceFromPeer(ctx context.Context, accountID, peerID string, req *ExposeServiceRequest) (*ExposeServiceResponse, error)
|
CreateServiceFromPeer(ctx context.Context, accountID, peerID string, req *ExposeServiceRequest) (*ExposeServiceResponse, error)
|
||||||
RenewServiceFromPeer(ctx context.Context, accountID, peerID, domain string) error
|
RenewServiceFromPeer(ctx context.Context, accountID, peerID, serviceID string) error
|
||||||
StopServiceFromPeer(ctx context.Context, accountID, peerID, domain string) error
|
StopServiceFromPeer(ctx context.Context, accountID, peerID, serviceID string) error
|
||||||
StartExposeReaper(ctx context.Context)
|
StartExposeReaper(ctx context.Context)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -211,17 +211,17 @@ func (mr *MockManagerMockRecorder) ReloadService(ctx, accountID, serviceID inter
|
|||||||
}
|
}
|
||||||
|
|
||||||
// RenewServiceFromPeer mocks base method.
|
// RenewServiceFromPeer mocks base method.
|
||||||
func (m *MockManager) RenewServiceFromPeer(ctx context.Context, accountID, peerID, domain string) error {
|
func (m *MockManager) RenewServiceFromPeer(ctx context.Context, accountID, peerID, serviceID string) error {
|
||||||
m.ctrl.T.Helper()
|
m.ctrl.T.Helper()
|
||||||
ret := m.ctrl.Call(m, "RenewServiceFromPeer", ctx, accountID, peerID, domain)
|
ret := m.ctrl.Call(m, "RenewServiceFromPeer", ctx, accountID, peerID, serviceID)
|
||||||
ret0, _ := ret[0].(error)
|
ret0, _ := ret[0].(error)
|
||||||
return ret0
|
return ret0
|
||||||
}
|
}
|
||||||
|
|
||||||
// RenewServiceFromPeer indicates an expected call of RenewServiceFromPeer.
|
// RenewServiceFromPeer indicates an expected call of RenewServiceFromPeer.
|
||||||
func (mr *MockManagerMockRecorder) RenewServiceFromPeer(ctx, accountID, peerID, domain interface{}) *gomock.Call {
|
func (mr *MockManagerMockRecorder) RenewServiceFromPeer(ctx, accountID, peerID, serviceID interface{}) *gomock.Call {
|
||||||
mr.mock.ctrl.T.Helper()
|
mr.mock.ctrl.T.Helper()
|
||||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "RenewServiceFromPeer", reflect.TypeOf((*MockManager)(nil).RenewServiceFromPeer), ctx, accountID, peerID, domain)
|
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "RenewServiceFromPeer", reflect.TypeOf((*MockManager)(nil).RenewServiceFromPeer), ctx, accountID, peerID, serviceID)
|
||||||
}
|
}
|
||||||
|
|
||||||
// SetCertificateIssuedAt mocks base method.
|
// SetCertificateIssuedAt mocks base method.
|
||||||
@@ -265,17 +265,17 @@ func (mr *MockManagerMockRecorder) StartExposeReaper(ctx interface{}) *gomock.Ca
|
|||||||
}
|
}
|
||||||
|
|
||||||
// StopServiceFromPeer mocks base method.
|
// StopServiceFromPeer mocks base method.
|
||||||
func (m *MockManager) StopServiceFromPeer(ctx context.Context, accountID, peerID, domain string) error {
|
func (m *MockManager) StopServiceFromPeer(ctx context.Context, accountID, peerID, serviceID string) error {
|
||||||
m.ctrl.T.Helper()
|
m.ctrl.T.Helper()
|
||||||
ret := m.ctrl.Call(m, "StopServiceFromPeer", ctx, accountID, peerID, domain)
|
ret := m.ctrl.Call(m, "StopServiceFromPeer", ctx, accountID, peerID, serviceID)
|
||||||
ret0, _ := ret[0].(error)
|
ret0, _ := ret[0].(error)
|
||||||
return ret0
|
return ret0
|
||||||
}
|
}
|
||||||
|
|
||||||
// StopServiceFromPeer indicates an expected call of StopServiceFromPeer.
|
// StopServiceFromPeer indicates an expected call of StopServiceFromPeer.
|
||||||
func (mr *MockManagerMockRecorder) StopServiceFromPeer(ctx, accountID, peerID, domain interface{}) *gomock.Call {
|
func (mr *MockManagerMockRecorder) StopServiceFromPeer(ctx, accountID, peerID, serviceID interface{}) *gomock.Call {
|
||||||
mr.mock.ctrl.T.Helper()
|
mr.mock.ctrl.T.Helper()
|
||||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "StopServiceFromPeer", reflect.TypeOf((*MockManager)(nil).StopServiceFromPeer), ctx, accountID, peerID, domain)
|
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "StopServiceFromPeer", reflect.TypeOf((*MockManager)(nil).StopServiceFromPeer), ctx, accountID, peerID, serviceID)
|
||||||
}
|
}
|
||||||
|
|
||||||
// UpdateService mocks base method.
|
// UpdateService mocks base method.
|
||||||
|
|||||||
@@ -11,19 +11,22 @@ import (
|
|||||||
domainmanager "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/domain/manager"
|
domainmanager "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/domain/manager"
|
||||||
rpservice "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/service"
|
rpservice "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/service"
|
||||||
nbcontext "github.com/netbirdio/netbird/management/server/context"
|
nbcontext "github.com/netbirdio/netbird/management/server/context"
|
||||||
|
"github.com/netbirdio/netbird/management/server/permissions"
|
||||||
"github.com/netbirdio/netbird/shared/management/http/api"
|
"github.com/netbirdio/netbird/shared/management/http/api"
|
||||||
"github.com/netbirdio/netbird/shared/management/http/util"
|
"github.com/netbirdio/netbird/shared/management/http/util"
|
||||||
"github.com/netbirdio/netbird/shared/management/status"
|
"github.com/netbirdio/netbird/shared/management/status"
|
||||||
)
|
)
|
||||||
|
|
||||||
type handler struct {
|
type handler struct {
|
||||||
manager rpservice.Manager
|
manager rpservice.Manager
|
||||||
|
permissionsManager permissions.Manager
|
||||||
}
|
}
|
||||||
|
|
||||||
// RegisterEndpoints registers all service HTTP endpoints.
|
// RegisterEndpoints registers all service HTTP endpoints.
|
||||||
func RegisterEndpoints(manager rpservice.Manager, domainManager domainmanager.Manager, accessLogsManager accesslogs.Manager, router *mux.Router) {
|
func RegisterEndpoints(manager rpservice.Manager, domainManager domainmanager.Manager, accessLogsManager accesslogs.Manager, permissionsManager permissions.Manager, router *mux.Router) {
|
||||||
h := &handler{
|
h := &handler{
|
||||||
manager: manager,
|
manager: manager,
|
||||||
|
permissionsManager: permissionsManager,
|
||||||
}
|
}
|
||||||
|
|
||||||
domainRouter := router.PathPrefix("/reverse-proxies").Subrouter()
|
domainRouter := router.PathPrefix("/reverse-proxies").Subrouter()
|
||||||
|
|||||||
@@ -18,8 +18,8 @@ func TestReapExpiredExposes(t *testing.T) {
|
|||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|
||||||
resp, err := mgr.CreateServiceFromPeer(ctx, testAccountID, testPeerID, &rpservice.ExposeServiceRequest{
|
resp, err := mgr.CreateServiceFromPeer(ctx, testAccountID, testPeerID, &rpservice.ExposeServiceRequest{
|
||||||
Port: 8080,
|
Port: 8080,
|
||||||
Protocol: "http",
|
Mode: "http",
|
||||||
})
|
})
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
@@ -28,8 +28,8 @@ func TestReapExpiredExposes(t *testing.T) {
|
|||||||
|
|
||||||
// Create a non-expired service
|
// Create a non-expired service
|
||||||
resp2, err := mgr.CreateServiceFromPeer(ctx, testAccountID, testPeerID, &rpservice.ExposeServiceRequest{
|
resp2, err := mgr.CreateServiceFromPeer(ctx, testAccountID, testPeerID, &rpservice.ExposeServiceRequest{
|
||||||
Port: 8081,
|
Port: 8081,
|
||||||
Protocol: "http",
|
Mode: "http",
|
||||||
})
|
})
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
@@ -49,15 +49,16 @@ func TestReapAlreadyDeletedService(t *testing.T) {
|
|||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|
||||||
resp, err := mgr.CreateServiceFromPeer(ctx, testAccountID, testPeerID, &rpservice.ExposeServiceRequest{
|
resp, err := mgr.CreateServiceFromPeer(ctx, testAccountID, testPeerID, &rpservice.ExposeServiceRequest{
|
||||||
Port: 8080,
|
Port: 8080,
|
||||||
Protocol: "http",
|
Mode: "http",
|
||||||
})
|
})
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
expireEphemeralService(t, testStore, testAccountID, resp.Domain)
|
expireEphemeralService(t, testStore, testAccountID, resp.Domain)
|
||||||
|
|
||||||
// Delete the service before reaping
|
// Delete the service before reaping
|
||||||
err = mgr.StopServiceFromPeer(ctx, testAccountID, testPeerID, resp.Domain)
|
svcID := resolveServiceIDByDomain(t, testStore, resp.Domain)
|
||||||
|
err = mgr.StopServiceFromPeer(ctx, testAccountID, testPeerID, svcID)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
// Reaping should handle the already-deleted service gracefully
|
// Reaping should handle the already-deleted service gracefully
|
||||||
@@ -70,8 +71,8 @@ func TestConcurrentReapAndRenew(t *testing.T) {
|
|||||||
|
|
||||||
for i := range 5 {
|
for i := range 5 {
|
||||||
_, err := mgr.CreateServiceFromPeer(ctx, testAccountID, testPeerID, &rpservice.ExposeServiceRequest{
|
_, err := mgr.CreateServiceFromPeer(ctx, testAccountID, testPeerID, &rpservice.ExposeServiceRequest{
|
||||||
Port: 8080 + i,
|
Port: uint16(8080 + i),
|
||||||
Protocol: "http",
|
Mode: "http",
|
||||||
})
|
})
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
}
|
}
|
||||||
@@ -108,17 +109,19 @@ func TestRenewEphemeralService(t *testing.T) {
|
|||||||
|
|
||||||
t.Run("renew succeeds for active service", func(t *testing.T) {
|
t.Run("renew succeeds for active service", func(t *testing.T) {
|
||||||
resp, err := mgr.CreateServiceFromPeer(ctx, testAccountID, testPeerID, &rpservice.ExposeServiceRequest{
|
resp, err := mgr.CreateServiceFromPeer(ctx, testAccountID, testPeerID, &rpservice.ExposeServiceRequest{
|
||||||
Port: 8082,
|
Port: 8082,
|
||||||
Protocol: "http",
|
Mode: "http",
|
||||||
})
|
})
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
err = mgr.RenewServiceFromPeer(ctx, testAccountID, testPeerID, resp.Domain)
|
svc, lookupErr := mgr.store.GetServiceByDomain(ctx, resp.Domain)
|
||||||
|
require.NoError(t, lookupErr)
|
||||||
|
err = mgr.RenewServiceFromPeer(ctx, testAccountID, testPeerID, svc.ID)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
})
|
})
|
||||||
|
|
||||||
t.Run("renew fails for nonexistent domain", func(t *testing.T) {
|
t.Run("renew fails for nonexistent domain", func(t *testing.T) {
|
||||||
err := mgr.RenewServiceFromPeer(ctx, testAccountID, testPeerID, "nonexistent.com")
|
err := mgr.RenewServiceFromPeer(ctx, testAccountID, testPeerID, "nonexistent-service-id")
|
||||||
require.Error(t, err)
|
require.Error(t, err)
|
||||||
assert.Contains(t, err.Error(), "no active expose session")
|
assert.Contains(t, err.Error(), "no active expose session")
|
||||||
})
|
})
|
||||||
@@ -133,8 +136,8 @@ func TestCountAndExistsEphemeralServices(t *testing.T) {
|
|||||||
assert.Equal(t, int64(0), count)
|
assert.Equal(t, int64(0), count)
|
||||||
|
|
||||||
resp, err := mgr.CreateServiceFromPeer(ctx, testAccountID, testPeerID, &rpservice.ExposeServiceRequest{
|
resp, err := mgr.CreateServiceFromPeer(ctx, testAccountID, testPeerID, &rpservice.ExposeServiceRequest{
|
||||||
Port: 8083,
|
Port: 8083,
|
||||||
Protocol: "http",
|
Mode: "http",
|
||||||
})
|
})
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
@@ -157,15 +160,15 @@ func TestMaxExposesPerPeerEnforced(t *testing.T) {
|
|||||||
|
|
||||||
for i := range maxExposesPerPeer {
|
for i := range maxExposesPerPeer {
|
||||||
_, err := mgr.CreateServiceFromPeer(ctx, testAccountID, testPeerID, &rpservice.ExposeServiceRequest{
|
_, err := mgr.CreateServiceFromPeer(ctx, testAccountID, testPeerID, &rpservice.ExposeServiceRequest{
|
||||||
Port: 8090 + i,
|
Port: uint16(8090 + i),
|
||||||
Protocol: "http",
|
Mode: "http",
|
||||||
})
|
})
|
||||||
require.NoError(t, err, "expose %d should succeed", i)
|
require.NoError(t, err, "expose %d should succeed", i)
|
||||||
}
|
}
|
||||||
|
|
||||||
_, err := mgr.CreateServiceFromPeer(ctx, testAccountID, testPeerID, &rpservice.ExposeServiceRequest{
|
_, err := mgr.CreateServiceFromPeer(ctx, testAccountID, testPeerID, &rpservice.ExposeServiceRequest{
|
||||||
Port: 9999,
|
Port: 9999,
|
||||||
Protocol: "http",
|
Mode: "http",
|
||||||
})
|
})
|
||||||
require.Error(t, err)
|
require.Error(t, err)
|
||||||
assert.Contains(t, err.Error(), "maximum number of active expose sessions")
|
assert.Contains(t, err.Error(), "maximum number of active expose sessions")
|
||||||
@@ -176,8 +179,8 @@ func TestReapSkipsRenewedService(t *testing.T) {
|
|||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|
||||||
resp, err := mgr.CreateServiceFromPeer(ctx, testAccountID, testPeerID, &rpservice.ExposeServiceRequest{
|
resp, err := mgr.CreateServiceFromPeer(ctx, testAccountID, testPeerID, &rpservice.ExposeServiceRequest{
|
||||||
Port: 8086,
|
Port: 8086,
|
||||||
Protocol: "http",
|
Mode: "http",
|
||||||
})
|
})
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
@@ -185,7 +188,9 @@ func TestReapSkipsRenewedService(t *testing.T) {
|
|||||||
expireEphemeralService(t, testStore, testAccountID, resp.Domain)
|
expireEphemeralService(t, testStore, testAccountID, resp.Domain)
|
||||||
|
|
||||||
// Renew it before the reaper runs
|
// Renew it before the reaper runs
|
||||||
err = mgr.RenewServiceFromPeer(ctx, testAccountID, testPeerID, resp.Domain)
|
svc, err := testStore.GetServiceByDomain(ctx, resp.Domain)
|
||||||
|
require.NoError(t, err)
|
||||||
|
err = mgr.RenewServiceFromPeer(ctx, testAccountID, testPeerID, svc.ID)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
// Reaper should skip it because the re-check sees a fresh timestamp
|
// Reaper should skip it because the re-check sees a fresh timestamp
|
||||||
@@ -195,6 +200,14 @@ func TestReapSkipsRenewedService(t *testing.T) {
|
|||||||
require.NoError(t, err, "renewed service should survive reaping")
|
require.NoError(t, err, "renewed service should survive reaping")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// resolveServiceIDByDomain looks up a service ID by domain in tests.
|
||||||
|
func resolveServiceIDByDomain(t *testing.T, s store.Store, domain string) string {
|
||||||
|
t.Helper()
|
||||||
|
svc, err := s.GetServiceByDomain(context.Background(), domain)
|
||||||
|
require.NoError(t, err)
|
||||||
|
return svc.ID
|
||||||
|
}
|
||||||
|
|
||||||
// expireEphemeralService backdates meta_last_renewed_at to force expiration.
|
// expireEphemeralService backdates meta_last_renewed_at to force expiration.
|
||||||
func expireEphemeralService(t *testing.T, s store.Store, accountID, domain string) {
|
func expireEphemeralService(t *testing.T, s store.Store, accountID, domain string) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|||||||
@@ -0,0 +1,582 @@
|
|||||||
|
package manager
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/golang/mock/gomock"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
|
||||||
|
"github.com/netbirdio/netbird/management/internals/modules/reverseproxy/proxy"
|
||||||
|
rpservice "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/service"
|
||||||
|
"github.com/netbirdio/netbird/management/server/activity"
|
||||||
|
"github.com/netbirdio/netbird/management/server/mock_server"
|
||||||
|
nbpeer "github.com/netbirdio/netbird/management/server/peer"
|
||||||
|
"github.com/netbirdio/netbird/management/server/permissions"
|
||||||
|
"github.com/netbirdio/netbird/management/server/store"
|
||||||
|
"github.com/netbirdio/netbird/management/server/types"
|
||||||
|
)
|
||||||
|
|
||||||
|
const testCluster = "test-cluster"
|
||||||
|
|
||||||
|
func boolPtr(v bool) *bool { return &v }
|
||||||
|
|
||||||
|
// setupL4Test creates a manager with a mock proxy controller for L4 port tests.
|
||||||
|
func setupL4Test(t *testing.T, customPortsSupported *bool) (*Manager, store.Store, *proxy.MockController) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
ctrl := gomock.NewController(t)
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
testStore, cleanup, err := store.NewTestStoreFromSQL(ctx, "", t.TempDir())
|
||||||
|
require.NoError(t, err)
|
||||||
|
t.Cleanup(cleanup)
|
||||||
|
|
||||||
|
err = testStore.SaveAccount(ctx, &types.Account{
|
||||||
|
Id: testAccountID,
|
||||||
|
CreatedBy: testUserID,
|
||||||
|
Settings: &types.Settings{
|
||||||
|
PeerExposeEnabled: true,
|
||||||
|
PeerExposeGroups: []string{testGroupID},
|
||||||
|
},
|
||||||
|
Users: map[string]*types.User{
|
||||||
|
testUserID: {
|
||||||
|
Id: testUserID,
|
||||||
|
AccountID: testAccountID,
|
||||||
|
Role: types.UserRoleAdmin,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Peers: map[string]*nbpeer.Peer{
|
||||||
|
testPeerID: {
|
||||||
|
ID: testPeerID,
|
||||||
|
AccountID: testAccountID,
|
||||||
|
Key: "test-key",
|
||||||
|
DNSLabel: "test-peer",
|
||||||
|
Name: "test-peer",
|
||||||
|
IP: net.ParseIP("100.64.0.1"),
|
||||||
|
Status: &nbpeer.PeerStatus{Connected: true, LastSeen: time.Now()},
|
||||||
|
Meta: nbpeer.PeerSystemMeta{Hostname: "test-peer"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Groups: map[string]*types.Group{
|
||||||
|
testGroupID: {
|
||||||
|
ID: testGroupID,
|
||||||
|
AccountID: testAccountID,
|
||||||
|
Name: "Expose Group",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
err = testStore.AddPeerToGroup(ctx, testAccountID, testPeerID, testGroupID)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
mockCtrl := proxy.NewMockController(ctrl)
|
||||||
|
mockCtrl.EXPECT().ClusterSupportsCustomPorts(gomock.Any()).Return(customPortsSupported).AnyTimes()
|
||||||
|
mockCtrl.EXPECT().SendServiceUpdateToCluster(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).AnyTimes()
|
||||||
|
mockCtrl.EXPECT().GetOIDCValidationConfig().Return(proxy.OIDCValidationConfig{}).AnyTimes()
|
||||||
|
|
||||||
|
accountMgr := &mock_server.MockAccountManager{
|
||||||
|
StoreEventFunc: func(_ context.Context, _, _, _ string, _ activity.ActivityDescriber, _ map[string]any) {},
|
||||||
|
UpdateAccountPeersFunc: func(_ context.Context, _ string) {},
|
||||||
|
GetGroupByNameFunc: func(ctx context.Context, accountID, groupName string) (*types.Group, error) {
|
||||||
|
return testStore.GetGroupByName(ctx, store.LockingStrengthNone, groupName, accountID)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
mgr := &Manager{
|
||||||
|
store: testStore,
|
||||||
|
accountManager: accountMgr,
|
||||||
|
permissionsManager: permissions.NewManager(testStore),
|
||||||
|
proxyController: mockCtrl,
|
||||||
|
clusterDeriver: &testClusterDeriver{domains: []string{"test.netbird.io"}},
|
||||||
|
}
|
||||||
|
mgr.exposeReaper = &exposeReaper{manager: mgr}
|
||||||
|
|
||||||
|
return mgr, testStore, mockCtrl
|
||||||
|
}
|
||||||
|
|
||||||
|
// seedService creates a service directly in the store for test setup.
|
||||||
|
func seedService(t *testing.T, s store.Store, name, protocol, domain, cluster string, port uint16) *rpservice.Service {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
svc := &rpservice.Service{
|
||||||
|
AccountID: testAccountID,
|
||||||
|
Name: name,
|
||||||
|
Mode: protocol,
|
||||||
|
Domain: domain,
|
||||||
|
ProxyCluster: cluster,
|
||||||
|
ListenPort: port,
|
||||||
|
Enabled: true,
|
||||||
|
Source: "permanent",
|
||||||
|
Targets: []*rpservice.Target{
|
||||||
|
{AccountID: testAccountID, TargetId: testPeerID, TargetType: rpservice.TargetTypePeer, Protocol: protocol, Port: 8080, Enabled: true},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
svc.InitNewRecord()
|
||||||
|
err := s.CreateService(context.Background(), svc)
|
||||||
|
require.NoError(t, err)
|
||||||
|
return svc
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPortConflict_TCPSamePortCluster(t *testing.T) {
|
||||||
|
mgr, testStore, _ := setupL4Test(t, boolPtr(true))
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
seedService(t, testStore, "existing-tcp", "tcp", testCluster, testCluster, 5432)
|
||||||
|
|
||||||
|
svc := &rpservice.Service{
|
||||||
|
AccountID: testAccountID,
|
||||||
|
Name: "conflicting-tcp",
|
||||||
|
Mode: "tcp",
|
||||||
|
Domain: "conflicting-tcp." + testCluster,
|
||||||
|
ProxyCluster: testCluster,
|
||||||
|
ListenPort: 5432,
|
||||||
|
Enabled: true,
|
||||||
|
Source: "permanent",
|
||||||
|
Targets: []*rpservice.Target{
|
||||||
|
{AccountID: testAccountID, TargetId: testPeerID, TargetType: rpservice.TargetTypePeer, Protocol: "tcp", Port: 9090, Enabled: true},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
svc.InitNewRecord()
|
||||||
|
|
||||||
|
err := mgr.persistNewService(ctx, testAccountID, svc)
|
||||||
|
require.Error(t, err, "TCP+TCP on same port/cluster should be rejected")
|
||||||
|
assert.Contains(t, err.Error(), "already in use")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPortConflict_UDPSamePortCluster(t *testing.T) {
|
||||||
|
mgr, testStore, _ := setupL4Test(t, boolPtr(true))
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
seedService(t, testStore, "existing-udp", "udp", testCluster, testCluster, 5432)
|
||||||
|
|
||||||
|
svc := &rpservice.Service{
|
||||||
|
AccountID: testAccountID,
|
||||||
|
Name: "conflicting-udp",
|
||||||
|
Mode: "udp",
|
||||||
|
Domain: "conflicting-udp." + testCluster,
|
||||||
|
ProxyCluster: testCluster,
|
||||||
|
ListenPort: 5432,
|
||||||
|
Enabled: true,
|
||||||
|
Source: "permanent",
|
||||||
|
Targets: []*rpservice.Target{
|
||||||
|
{AccountID: testAccountID, TargetId: testPeerID, TargetType: rpservice.TargetTypePeer, Protocol: "udp", Port: 9090, Enabled: true},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
svc.InitNewRecord()
|
||||||
|
|
||||||
|
err := mgr.persistNewService(ctx, testAccountID, svc)
|
||||||
|
require.Error(t, err, "UDP+UDP on same port/cluster should be rejected")
|
||||||
|
assert.Contains(t, err.Error(), "already in use")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPortConflict_TLSSamePortDifferentDomain(t *testing.T) {
|
||||||
|
mgr, testStore, _ := setupL4Test(t, boolPtr(true))
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
seedService(t, testStore, "existing-tls", "tls", "app1.example.com", testCluster, 443)
|
||||||
|
|
||||||
|
svc := &rpservice.Service{
|
||||||
|
AccountID: testAccountID,
|
||||||
|
Name: "new-tls",
|
||||||
|
Mode: "tls",
|
||||||
|
Domain: "app2.example.com",
|
||||||
|
ProxyCluster: testCluster,
|
||||||
|
ListenPort: 443,
|
||||||
|
Enabled: true,
|
||||||
|
Source: "permanent",
|
||||||
|
Targets: []*rpservice.Target{
|
||||||
|
{AccountID: testAccountID, TargetId: testPeerID, TargetType: rpservice.TargetTypePeer, Protocol: "tcp", Port: 8443, Enabled: true},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
svc.InitNewRecord()
|
||||||
|
|
||||||
|
err := mgr.persistNewService(ctx, testAccountID, svc)
|
||||||
|
assert.NoError(t, err, "TLS+TLS on same port with different domains should be allowed (SNI routing)")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPortConflict_TLSSamePortSameDomain(t *testing.T) {
|
||||||
|
mgr, testStore, _ := setupL4Test(t, boolPtr(true))
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
seedService(t, testStore, "existing-tls", "tls", "app.example.com", testCluster, 443)
|
||||||
|
|
||||||
|
svc := &rpservice.Service{
|
||||||
|
AccountID: testAccountID,
|
||||||
|
Name: "duplicate-tls",
|
||||||
|
Mode: "tls",
|
||||||
|
Domain: "app.example.com",
|
||||||
|
ProxyCluster: testCluster,
|
||||||
|
ListenPort: 443,
|
||||||
|
Enabled: true,
|
||||||
|
Source: "permanent",
|
||||||
|
Targets: []*rpservice.Target{
|
||||||
|
{AccountID: testAccountID, TargetId: testPeerID, TargetType: rpservice.TargetTypePeer, Protocol: "tcp", Port: 8443, Enabled: true},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
svc.InitNewRecord()
|
||||||
|
|
||||||
|
err := mgr.persistNewService(ctx, testAccountID, svc)
|
||||||
|
require.Error(t, err, "TLS+TLS on same domain should be rejected")
|
||||||
|
assert.Contains(t, err.Error(), "domain already taken")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPortConflict_TLSAndTCPSamePort(t *testing.T) {
|
||||||
|
mgr, testStore, _ := setupL4Test(t, boolPtr(true))
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
seedService(t, testStore, "existing-tls", "tls", "app.example.com", testCluster, 443)
|
||||||
|
|
||||||
|
svc := &rpservice.Service{
|
||||||
|
AccountID: testAccountID,
|
||||||
|
Name: "new-tcp",
|
||||||
|
Mode: "tcp",
|
||||||
|
Domain: testCluster,
|
||||||
|
ProxyCluster: testCluster,
|
||||||
|
ListenPort: 443,
|
||||||
|
Enabled: true,
|
||||||
|
Source: "permanent",
|
||||||
|
Targets: []*rpservice.Target{
|
||||||
|
{AccountID: testAccountID, TargetId: testPeerID, TargetType: rpservice.TargetTypePeer, Protocol: "tcp", Port: 8080, Enabled: true},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
svc.InitNewRecord()
|
||||||
|
|
||||||
|
err := mgr.persistNewService(ctx, testAccountID, svc)
|
||||||
|
assert.NoError(t, err, "TLS+TCP on same port should be allowed (multiplexed)")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAutoAssign_TCPNoListenPort(t *testing.T) {
|
||||||
|
mgr, _, _ := setupL4Test(t, boolPtr(false))
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
svc := &rpservice.Service{
|
||||||
|
AccountID: testAccountID,
|
||||||
|
Name: "auto-tcp",
|
||||||
|
Mode: "tcp",
|
||||||
|
Domain: testCluster,
|
||||||
|
ProxyCluster: testCluster,
|
||||||
|
ListenPort: 0,
|
||||||
|
Enabled: true,
|
||||||
|
Source: "permanent",
|
||||||
|
Targets: []*rpservice.Target{
|
||||||
|
{AccountID: testAccountID, TargetId: testPeerID, TargetType: rpservice.TargetTypePeer, Protocol: "tcp", Port: 8080, Enabled: true},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
svc.InitNewRecord()
|
||||||
|
|
||||||
|
err := mgr.persistNewService(ctx, testAccountID, svc)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.True(t, svc.ListenPort >= autoAssignPortMin && svc.ListenPort <= autoAssignPortMax,
|
||||||
|
"auto-assigned port %d should be in range [%d, %d]", svc.ListenPort, autoAssignPortMin, autoAssignPortMax)
|
||||||
|
assert.True(t, svc.PortAutoAssigned, "PortAutoAssigned should be set")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAutoAssign_TCPCustomPortRejectedWhenNotSupported(t *testing.T) {
|
||||||
|
mgr, _, _ := setupL4Test(t, boolPtr(false))
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
svc := &rpservice.Service{
|
||||||
|
AccountID: testAccountID,
|
||||||
|
Name: "custom-tcp",
|
||||||
|
Mode: "tcp",
|
||||||
|
Domain: testCluster,
|
||||||
|
ProxyCluster: testCluster,
|
||||||
|
ListenPort: 5555,
|
||||||
|
Enabled: true,
|
||||||
|
Source: "permanent",
|
||||||
|
Targets: []*rpservice.Target{
|
||||||
|
{AccountID: testAccountID, TargetId: testPeerID, TargetType: rpservice.TargetTypePeer, Protocol: "tcp", Port: 8080, Enabled: true},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
svc.InitNewRecord()
|
||||||
|
|
||||||
|
err := mgr.persistNewService(ctx, testAccountID, svc)
|
||||||
|
require.Error(t, err, "TCP with custom port should be rejected when cluster doesn't support it")
|
||||||
|
assert.Contains(t, err.Error(), "custom ports")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAutoAssign_TLSCustomPortAlwaysAllowed(t *testing.T) {
|
||||||
|
mgr, _, _ := setupL4Test(t, boolPtr(false))
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
svc := &rpservice.Service{
|
||||||
|
AccountID: testAccountID,
|
||||||
|
Name: "custom-tls",
|
||||||
|
Mode: "tls",
|
||||||
|
Domain: "app.example.com",
|
||||||
|
ProxyCluster: testCluster,
|
||||||
|
ListenPort: 9999,
|
||||||
|
Enabled: true,
|
||||||
|
Source: "permanent",
|
||||||
|
Targets: []*rpservice.Target{
|
||||||
|
{AccountID: testAccountID, TargetId: testPeerID, TargetType: rpservice.TargetTypePeer, Protocol: "tcp", Port: 8443, Enabled: true},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
svc.InitNewRecord()
|
||||||
|
|
||||||
|
err := mgr.persistNewService(ctx, testAccountID, svc)
|
||||||
|
assert.NoError(t, err, "TLS with custom port should always be allowed regardless of cluster capability")
|
||||||
|
assert.Equal(t, uint16(9999), svc.ListenPort, "TLS listen port should not be overridden")
|
||||||
|
assert.False(t, svc.PortAutoAssigned, "PortAutoAssigned should not be set for TLS")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAutoAssign_EphemeralOverridesPortWhenNotSupported(t *testing.T) {
|
||||||
|
mgr, _, _ := setupL4Test(t, boolPtr(false))
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
svc := &rpservice.Service{
|
||||||
|
AccountID: testAccountID,
|
||||||
|
Name: "ephemeral-tcp",
|
||||||
|
Mode: "tcp",
|
||||||
|
Domain: testCluster,
|
||||||
|
ProxyCluster: testCluster,
|
||||||
|
ListenPort: 5555,
|
||||||
|
Enabled: true,
|
||||||
|
Source: "ephemeral",
|
||||||
|
SourcePeer: testPeerID,
|
||||||
|
Targets: []*rpservice.Target{
|
||||||
|
{AccountID: testAccountID, TargetId: testPeerID, TargetType: rpservice.TargetTypePeer, Protocol: "tcp", Port: 8080, Enabled: true},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
svc.InitNewRecord()
|
||||||
|
|
||||||
|
err := mgr.persistNewEphemeralService(ctx, testAccountID, testPeerID, svc)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.NotEqual(t, uint16(5555), svc.ListenPort, "requested port should be overridden")
|
||||||
|
assert.True(t, svc.ListenPort >= autoAssignPortMin && svc.ListenPort <= autoAssignPortMax,
|
||||||
|
"auto-assigned port %d should be in range", svc.ListenPort)
|
||||||
|
assert.True(t, svc.PortAutoAssigned)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAutoAssign_EphemeralTLSKeepsCustomPort(t *testing.T) {
|
||||||
|
mgr, _, _ := setupL4Test(t, boolPtr(false))
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
svc := &rpservice.Service{
|
||||||
|
AccountID: testAccountID,
|
||||||
|
Name: "ephemeral-tls",
|
||||||
|
Mode: "tls",
|
||||||
|
Domain: "app.example.com",
|
||||||
|
ProxyCluster: testCluster,
|
||||||
|
ListenPort: 9999,
|
||||||
|
Enabled: true,
|
||||||
|
Source: "ephemeral",
|
||||||
|
SourcePeer: testPeerID,
|
||||||
|
Targets: []*rpservice.Target{
|
||||||
|
{AccountID: testAccountID, TargetId: testPeerID, TargetType: rpservice.TargetTypePeer, Protocol: "tcp", Port: 8443, Enabled: true},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
svc.InitNewRecord()
|
||||||
|
|
||||||
|
err := mgr.persistNewEphemeralService(ctx, testAccountID, testPeerID, svc)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, uint16(9999), svc.ListenPort, "TLS listen port should not be overridden")
|
||||||
|
assert.False(t, svc.PortAutoAssigned)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAutoAssign_AvoidsExistingPorts(t *testing.T) {
|
||||||
|
mgr, testStore, _ := setupL4Test(t, boolPtr(true))
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
existingPort := uint16(20000)
|
||||||
|
seedService(t, testStore, "existing", "tcp", testCluster, testCluster, existingPort)
|
||||||
|
|
||||||
|
svc := &rpservice.Service{
|
||||||
|
AccountID: testAccountID,
|
||||||
|
Name: "auto-tcp",
|
||||||
|
Mode: "tcp",
|
||||||
|
Domain: "auto-tcp." + testCluster,
|
||||||
|
ProxyCluster: testCluster,
|
||||||
|
ListenPort: 0,
|
||||||
|
Enabled: true,
|
||||||
|
Source: "permanent",
|
||||||
|
Targets: []*rpservice.Target{
|
||||||
|
{AccountID: testAccountID, TargetId: testPeerID, TargetType: rpservice.TargetTypePeer, Protocol: "tcp", Port: 8080, Enabled: true},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
svc.InitNewRecord()
|
||||||
|
|
||||||
|
err := mgr.persistNewService(ctx, testAccountID, svc)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.NotEqual(t, existingPort, svc.ListenPort, "auto-assigned port should not collide with existing")
|
||||||
|
assert.True(t, svc.PortAutoAssigned)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAutoAssign_TCPCustomPortAllowedWhenSupported(t *testing.T) {
|
||||||
|
mgr, _, _ := setupL4Test(t, boolPtr(true))
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
svc := &rpservice.Service{
|
||||||
|
AccountID: testAccountID,
|
||||||
|
Name: "custom-tcp",
|
||||||
|
Mode: "tcp",
|
||||||
|
Domain: testCluster,
|
||||||
|
ProxyCluster: testCluster,
|
||||||
|
ListenPort: 5555,
|
||||||
|
Enabled: true,
|
||||||
|
Source: "permanent",
|
||||||
|
Targets: []*rpservice.Target{
|
||||||
|
{AccountID: testAccountID, TargetId: testPeerID, TargetType: rpservice.TargetTypePeer, Protocol: "tcp", Port: 8080, Enabled: true},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
svc.InitNewRecord()
|
||||||
|
|
||||||
|
err := mgr.persistNewService(ctx, testAccountID, svc)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, uint16(5555), svc.ListenPort, "custom port should be preserved when supported")
|
||||||
|
assert.False(t, svc.PortAutoAssigned)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUpdate_PreservesExistingListenPort(t *testing.T) {
|
||||||
|
mgr, testStore, _ := setupL4Test(t, boolPtr(true))
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
existing := seedService(t, testStore, "tcp-svc", "tcp", testCluster, testCluster, 12345)
|
||||||
|
|
||||||
|
updated := &rpservice.Service{
|
||||||
|
ID: existing.ID,
|
||||||
|
AccountID: testAccountID,
|
||||||
|
Name: "tcp-svc-renamed",
|
||||||
|
Mode: "tcp",
|
||||||
|
Domain: testCluster,
|
||||||
|
ProxyCluster: testCluster,
|
||||||
|
ListenPort: 0,
|
||||||
|
Enabled: true,
|
||||||
|
Targets: []*rpservice.Target{
|
||||||
|
{AccountID: testAccountID, TargetId: testPeerID, TargetType: rpservice.TargetTypePeer, Protocol: "tcp", Port: 9090, Enabled: true},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := mgr.persistServiceUpdate(ctx, testAccountID, updated)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, uint16(12345), updated.ListenPort, "existing listen port should be preserved when update sends 0")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUpdate_AllowsPortChange(t *testing.T) {
|
||||||
|
mgr, testStore, _ := setupL4Test(t, boolPtr(true))
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
existing := seedService(t, testStore, "tcp-svc", "tcp", testCluster, testCluster, 12345)
|
||||||
|
|
||||||
|
updated := &rpservice.Service{
|
||||||
|
ID: existing.ID,
|
||||||
|
AccountID: testAccountID,
|
||||||
|
Name: "tcp-svc",
|
||||||
|
Mode: "tcp",
|
||||||
|
Domain: testCluster,
|
||||||
|
ProxyCluster: testCluster,
|
||||||
|
ListenPort: 54321,
|
||||||
|
Enabled: true,
|
||||||
|
Targets: []*rpservice.Target{
|
||||||
|
{AccountID: testAccountID, TargetId: testPeerID, TargetType: rpservice.TargetTypePeer, Protocol: "tcp", Port: 9090, Enabled: true},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := mgr.persistServiceUpdate(ctx, testAccountID, updated)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, uint16(54321), updated.ListenPort, "explicit port change should be applied")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCreateServiceFromPeer_TCP(t *testing.T) {
|
||||||
|
mgr, _, _ := setupL4Test(t, boolPtr(false))
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
resp, err := mgr.CreateServiceFromPeer(ctx, testAccountID, testPeerID, &rpservice.ExposeServiceRequest{
|
||||||
|
Port: 5432,
|
||||||
|
Mode: "tcp",
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
assert.NotEmpty(t, resp.ServiceName)
|
||||||
|
assert.Contains(t, resp.Domain, ".test.netbird.io", "TCP uses unique subdomain")
|
||||||
|
assert.True(t, resp.PortAutoAssigned, "port should be auto-assigned when cluster doesn't support custom ports")
|
||||||
|
assert.Contains(t, resp.ServiceURL, "tcp://")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCreateServiceFromPeer_TCP_CustomPort(t *testing.T) {
|
||||||
|
mgr, _, _ := setupL4Test(t, boolPtr(true))
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
resp, err := mgr.CreateServiceFromPeer(ctx, testAccountID, testPeerID, &rpservice.ExposeServiceRequest{
|
||||||
|
Port: 5432,
|
||||||
|
Mode: "tcp",
|
||||||
|
ListenPort: 15432,
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
assert.False(t, resp.PortAutoAssigned)
|
||||||
|
assert.Contains(t, resp.ServiceURL, ":15432")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCreateServiceFromPeer_TCP_DefaultListenPort(t *testing.T) {
|
||||||
|
mgr, _, _ := setupL4Test(t, boolPtr(true))
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
resp, err := mgr.CreateServiceFromPeer(ctx, testAccountID, testPeerID, &rpservice.ExposeServiceRequest{
|
||||||
|
Port: 5432,
|
||||||
|
Mode: "tcp",
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// When no explicit listen port, defaults to target port
|
||||||
|
assert.Contains(t, resp.ServiceURL, ":5432")
|
||||||
|
assert.False(t, resp.PortAutoAssigned)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCreateServiceFromPeer_TLS(t *testing.T) {
|
||||||
|
mgr, _, _ := setupL4Test(t, boolPtr(false))
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
resp, err := mgr.CreateServiceFromPeer(ctx, testAccountID, testPeerID, &rpservice.ExposeServiceRequest{
|
||||||
|
Port: 443,
|
||||||
|
Mode: "tls",
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
assert.Contains(t, resp.Domain, ".test.netbird.io", "TLS uses subdomain")
|
||||||
|
assert.Contains(t, resp.ServiceURL, "tls://")
|
||||||
|
assert.Contains(t, resp.ServiceURL, ":443")
|
||||||
|
// TLS always keeps its port (not port-based protocol for auto-assign)
|
||||||
|
assert.False(t, resp.PortAutoAssigned)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCreateServiceFromPeer_TCP_StopAndRenew(t *testing.T) {
|
||||||
|
mgr, testStore, _ := setupL4Test(t, boolPtr(true))
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
resp, err := mgr.CreateServiceFromPeer(ctx, testAccountID, testPeerID, &rpservice.ExposeServiceRequest{
|
||||||
|
Port: 8080,
|
||||||
|
Mode: "tcp",
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
svcID := resolveServiceIDByDomain(t, testStore, resp.Domain)
|
||||||
|
|
||||||
|
err = mgr.RenewServiceFromPeer(ctx, testAccountID, testPeerID, svcID)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
err = mgr.StopServiceFromPeer(ctx, testAccountID, testPeerID, svcID)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// Renew after stop should fail
|
||||||
|
err = mgr.RenewServiceFromPeer(ctx, testAccountID, testPeerID, svcID)
|
||||||
|
require.Error(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCreateServiceFromPeer_L4_RejectsAuth(t *testing.T) {
|
||||||
|
mgr, _, _ := setupL4Test(t, boolPtr(true))
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
_, err := mgr.CreateServiceFromPeer(ctx, testAccountID, testPeerID, &rpservice.ExposeServiceRequest{
|
||||||
|
Port: 8080,
|
||||||
|
Mode: "tcp",
|
||||||
|
Pin: "123456",
|
||||||
|
})
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.Contains(t, err.Error(), "authentication is not supported")
|
||||||
|
}
|
||||||
@@ -4,7 +4,9 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"math/rand/v2"
|
"math/rand/v2"
|
||||||
|
"os"
|
||||||
"slices"
|
"slices"
|
||||||
|
"strconv"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
log "github.com/sirupsen/logrus"
|
log "github.com/sirupsen/logrus"
|
||||||
@@ -23,6 +25,45 @@ import (
|
|||||||
"github.com/netbirdio/netbird/shared/management/status"
|
"github.com/netbirdio/netbird/shared/management/status"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
defaultAutoAssignPortMin uint16 = 10000
|
||||||
|
defaultAutoAssignPortMax uint16 = 49151
|
||||||
|
|
||||||
|
// EnvAutoAssignPortMin overrides the lower bound for auto-assigned L4 listen ports.
|
||||||
|
EnvAutoAssignPortMin = "NB_PROXY_PORT_MIN"
|
||||||
|
// EnvAutoAssignPortMax overrides the upper bound for auto-assigned L4 listen ports.
|
||||||
|
EnvAutoAssignPortMax = "NB_PROXY_PORT_MAX"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
autoAssignPortMin = defaultAutoAssignPortMin
|
||||||
|
autoAssignPortMax = defaultAutoAssignPortMax
|
||||||
|
)
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
autoAssignPortMin = portFromEnv(EnvAutoAssignPortMin, defaultAutoAssignPortMin)
|
||||||
|
autoAssignPortMax = portFromEnv(EnvAutoAssignPortMax, defaultAutoAssignPortMax)
|
||||||
|
if autoAssignPortMin > autoAssignPortMax {
|
||||||
|
log.Warnf("port range invalid: %s (%d) > %s (%d), using defaults",
|
||||||
|
EnvAutoAssignPortMin, autoAssignPortMin, EnvAutoAssignPortMax, autoAssignPortMax)
|
||||||
|
autoAssignPortMin = defaultAutoAssignPortMin
|
||||||
|
autoAssignPortMax = defaultAutoAssignPortMax
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func portFromEnv(key string, fallback uint16) uint16 {
|
||||||
|
val := os.Getenv(key)
|
||||||
|
if val == "" {
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
n, err := strconv.ParseUint(val, 10, 16)
|
||||||
|
if err != nil {
|
||||||
|
log.Warnf("invalid %s value %q, using default %d: %v", key, val, fallback, err)
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
return uint16(n)
|
||||||
|
}
|
||||||
|
|
||||||
const unknownHostPlaceholder = "unknown"
|
const unknownHostPlaceholder = "unknown"
|
||||||
|
|
||||||
// ClusterDeriver derives the proxy cluster from a domain.
|
// ClusterDeriver derives the proxy cluster from a domain.
|
||||||
@@ -115,6 +156,7 @@ func (m *Manager) replaceHostByLookup(ctx context.Context, accountID string, s *
|
|||||||
return fmt.Errorf("unknown target type: %s", target.TargetType)
|
return fmt.Errorf("unknown target type: %s", target.TargetType)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -197,55 +239,19 @@ func (m *Manager) initializeServiceForCreate(ctx context.Context, accountID stri
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *Manager) persistNewService(ctx context.Context, accountID string, service *service.Service) error {
|
func (m *Manager) persistNewService(ctx context.Context, accountID string, svc *service.Service) error {
|
||||||
return m.store.ExecuteInTransaction(ctx, func(transaction store.Store) error {
|
return m.store.ExecuteInTransaction(ctx, func(transaction store.Store) error {
|
||||||
if err := m.checkDomainAvailable(ctx, transaction, service.Domain, ""); err != nil {
|
if svc.Domain != "" {
|
||||||
|
if err := m.checkDomainAvailable(ctx, transaction, svc.Domain, ""); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := m.ensureL4Port(ctx, transaction, svc); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := validateTargetReferences(ctx, transaction, accountID, service.Targets); err != nil {
|
if err := m.checkPortConflict(ctx, transaction, svc); err != nil {
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := transaction.CreateService(ctx, service); err != nil {
|
|
||||||
return fmt.Errorf("failed to create service: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// persistNewEphemeralService creates an ephemeral service inside a single transaction
|
|
||||||
// that also enforces the duplicate and per-peer limit checks atomically.
|
|
||||||
// The count and exists queries use FOR UPDATE locking to serialize concurrent creates
|
|
||||||
// for the same peer, preventing the per-peer limit from being bypassed.
|
|
||||||
func (m *Manager) persistNewEphemeralService(ctx context.Context, accountID, peerID string, svc *service.Service) error {
|
|
||||||
return m.store.ExecuteInTransaction(ctx, func(transaction store.Store) error {
|
|
||||||
// Lock the peer row to serialize concurrent creates for the same peer.
|
|
||||||
// Without this, when no ephemeral rows exist yet, FOR UPDATE on the services
|
|
||||||
// table returns no rows and acquires no locks, allowing concurrent inserts
|
|
||||||
// to bypass the per-peer limit.
|
|
||||||
if _, err := transaction.GetPeerByID(ctx, store.LockingStrengthUpdate, accountID, peerID); err != nil {
|
|
||||||
return fmt.Errorf("lock peer row: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
exists, err := transaction.EphemeralServiceExists(ctx, store.LockingStrengthUpdate, accountID, peerID, svc.Domain)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("check existing expose: %w", err)
|
|
||||||
}
|
|
||||||
if exists {
|
|
||||||
return status.Errorf(status.AlreadyExists, "peer already has an active expose session for this domain")
|
|
||||||
}
|
|
||||||
|
|
||||||
count, err := transaction.CountEphemeralServicesByPeer(ctx, store.LockingStrengthUpdate, accountID, peerID)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("count peer exposes: %w", err)
|
|
||||||
}
|
|
||||||
if count >= int64(maxExposesPerPeer) {
|
|
||||||
return status.Errorf(status.PreconditionFailed, "peer has reached the maximum number of active expose sessions (%d)", maxExposesPerPeer)
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := m.checkDomainAvailable(ctx, transaction, svc.Domain, ""); err != nil {
|
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -261,11 +267,155 @@ func (m *Manager) persistNewEphemeralService(ctx context.Context, accountID, pee
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ensureL4Port auto-assigns a listen port when needed and validates cluster support.
|
||||||
|
func (m *Manager) ensureL4Port(ctx context.Context, tx store.Store, svc *service.Service) error {
|
||||||
|
if !service.IsL4Protocol(svc.Mode) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
customPorts := m.proxyController.ClusterSupportsCustomPorts(svc.ProxyCluster)
|
||||||
|
if service.IsPortBasedProtocol(svc.Mode) && svc.ListenPort > 0 && (customPorts == nil || !*customPorts) {
|
||||||
|
if svc.Source != service.SourceEphemeral {
|
||||||
|
return status.Errorf(status.InvalidArgument, "custom ports not supported on cluster %s", svc.ProxyCluster)
|
||||||
|
}
|
||||||
|
svc.ListenPort = 0
|
||||||
|
}
|
||||||
|
if svc.ListenPort == 0 {
|
||||||
|
port, err := m.assignPort(ctx, tx, svc.ProxyCluster)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
svc.ListenPort = port
|
||||||
|
svc.PortAutoAssigned = true
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// checkPortConflict rejects L4 services that would conflict on the same listener.
|
||||||
|
// For TCP/UDP: unique per cluster+protocol+port.
|
||||||
|
// For TLS: unique per cluster+port+domain (SNI routing allows sharing ports).
|
||||||
|
// Cross-protocol conflicts (TLS vs raw TCP) are intentionally not checked:
|
||||||
|
// the proxy router multiplexes TLS (via SNI) and raw TCP (via fallback) on the same listener.
|
||||||
|
func (m *Manager) checkPortConflict(ctx context.Context, transaction store.Store, svc *service.Service) error {
|
||||||
|
if !service.IsL4Protocol(svc.Mode) || svc.ListenPort == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
existing, err := transaction.GetServicesByClusterAndPort(ctx, store.LockingStrengthUpdate, svc.ProxyCluster, svc.Mode, svc.ListenPort)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("query port conflicts: %w", err)
|
||||||
|
}
|
||||||
|
for _, s := range existing {
|
||||||
|
if s.ID == svc.ID {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// TLS services on the same port are allowed if they have different domains (SNI routing)
|
||||||
|
if svc.Mode == service.ModeTLS && s.Domain != svc.Domain {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
return status.Errorf(status.AlreadyExists,
|
||||||
|
"%s port %d is already in use by service %q on cluster %s",
|
||||||
|
svc.Mode, svc.ListenPort, s.Name, svc.ProxyCluster)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// assignPort picks a random available port on the cluster within the auto-assign range.
|
||||||
|
func (m *Manager) assignPort(ctx context.Context, tx store.Store, cluster string) (uint16, error) {
|
||||||
|
services, err := tx.GetServicesByCluster(ctx, store.LockingStrengthUpdate, cluster)
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf("query cluster ports: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
occupied := make(map[uint16]struct{}, len(services))
|
||||||
|
for _, s := range services {
|
||||||
|
if s.ListenPort > 0 {
|
||||||
|
occupied[s.ListenPort] = struct{}{}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
portRange := int(autoAssignPortMax-autoAssignPortMin) + 1
|
||||||
|
for range 100 {
|
||||||
|
port := autoAssignPortMin + uint16(rand.IntN(portRange))
|
||||||
|
if _, taken := occupied[port]; !taken {
|
||||||
|
return port, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for port := autoAssignPortMin; port <= autoAssignPortMax; port++ {
|
||||||
|
if _, taken := occupied[port]; !taken {
|
||||||
|
return port, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return 0, status.Errorf(status.PreconditionFailed, "no available ports on cluster %s", cluster)
|
||||||
|
}
|
||||||
|
|
||||||
|
// persistNewEphemeralService creates an ephemeral service inside a single transaction
|
||||||
|
// that also enforces the duplicate and per-peer limit checks atomically.
|
||||||
|
// The count and exists queries use FOR UPDATE locking to serialize concurrent creates
|
||||||
|
// for the same peer, preventing the per-peer limit from being bypassed.
|
||||||
|
func (m *Manager) persistNewEphemeralService(ctx context.Context, accountID, peerID string, svc *service.Service) error {
|
||||||
|
return m.store.ExecuteInTransaction(ctx, func(transaction store.Store) error {
|
||||||
|
if err := m.validateEphemeralPreconditions(ctx, transaction, accountID, peerID, svc); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := m.ensureL4Port(ctx, transaction, svc); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := m.checkPortConflict(ctx, transaction, svc); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := validateTargetReferences(ctx, transaction, accountID, svc.Targets); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := transaction.CreateService(ctx, svc); err != nil {
|
||||||
|
return fmt.Errorf("create service: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Manager) validateEphemeralPreconditions(ctx context.Context, transaction store.Store, accountID, peerID string, svc *service.Service) error {
|
||||||
|
// Lock the peer row to serialize concurrent creates for the same peer.
|
||||||
|
if _, err := transaction.GetPeerByID(ctx, store.LockingStrengthUpdate, accountID, peerID); err != nil {
|
||||||
|
return fmt.Errorf("lock peer row: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
exists, err := transaction.EphemeralServiceExists(ctx, store.LockingStrengthUpdate, accountID, peerID, svc.Domain)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("check existing expose: %w", err)
|
||||||
|
}
|
||||||
|
if exists {
|
||||||
|
return status.Errorf(status.AlreadyExists, "peer already has an active expose session for this domain")
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := m.checkDomainAvailable(ctx, transaction, svc.Domain, ""); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
count, err := transaction.CountEphemeralServicesByPeer(ctx, store.LockingStrengthUpdate, accountID, peerID)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("count peer exposes: %w", err)
|
||||||
|
}
|
||||||
|
if count >= int64(maxExposesPerPeer) {
|
||||||
|
return status.Errorf(status.PreconditionFailed, "peer has reached the maximum number of active expose sessions (%d)", maxExposesPerPeer)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// checkDomainAvailable checks that no other service already uses this domain.
|
||||||
func (m *Manager) checkDomainAvailable(ctx context.Context, transaction store.Store, domain, excludeServiceID string) error {
|
func (m *Manager) checkDomainAvailable(ctx context.Context, transaction store.Store, domain, excludeServiceID string) error {
|
||||||
existingService, err := transaction.GetServiceByDomain(ctx, domain)
|
existingService, err := transaction.GetServiceByDomain(ctx, domain)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if sErr, ok := status.FromError(err); !ok || sErr.Type() != status.NotFound {
|
if sErr, ok := status.FromError(err); !ok || sErr.Type() != status.NotFound {
|
||||||
return fmt.Errorf("failed to check existing service: %w", err)
|
return fmt.Errorf("check existing service: %w", err)
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -322,6 +472,10 @@ func (m *Manager) persistServiceUpdate(ctx context.Context, accountID string, se
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if err := validateProtocolChange(existingService.Mode, service.Mode); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
updateInfo.oldCluster = existingService.ProxyCluster
|
updateInfo.oldCluster = existingService.ProxyCluster
|
||||||
updateInfo.domainChanged = existingService.Domain != service.Domain
|
updateInfo.domainChanged = existingService.Domain != service.Domain
|
||||||
|
|
||||||
@@ -335,12 +489,18 @@ func (m *Manager) persistServiceUpdate(ctx context.Context, accountID string, se
|
|||||||
|
|
||||||
m.preserveExistingAuthSecrets(service, existingService)
|
m.preserveExistingAuthSecrets(service, existingService)
|
||||||
m.preserveServiceMetadata(service, existingService)
|
m.preserveServiceMetadata(service, existingService)
|
||||||
|
m.preserveListenPort(service, existingService)
|
||||||
updateInfo.serviceEnabledChanged = existingService.Enabled != service.Enabled
|
updateInfo.serviceEnabledChanged = existingService.Enabled != service.Enabled
|
||||||
|
|
||||||
|
if err := m.ensureL4Port(ctx, transaction, service); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := m.checkPortConflict(ctx, transaction, service); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
if err := validateTargetReferences(ctx, transaction, accountID, service.Targets); err != nil {
|
if err := validateTargetReferences(ctx, transaction, accountID, service.Targets); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := transaction.UpdateService(ctx, service); err != nil {
|
if err := transaction.UpdateService(ctx, service); err != nil {
|
||||||
return fmt.Errorf("update service: %w", err)
|
return fmt.Errorf("update service: %w", err)
|
||||||
}
|
}
|
||||||
@@ -351,23 +511,39 @@ func (m *Manager) persistServiceUpdate(ctx context.Context, accountID string, se
|
|||||||
return &updateInfo, err
|
return &updateInfo, err
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *Manager) handleDomainChange(ctx context.Context, transaction store.Store, accountID string, service *service.Service) error {
|
func (m *Manager) handleDomainChange(ctx context.Context, transaction store.Store, accountID string, svc *service.Service) error {
|
||||||
if err := m.checkDomainAvailable(ctx, transaction, service.Domain, service.ID); err != nil {
|
if err := m.checkDomainAvailable(ctx, transaction, svc.Domain, svc.ID); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
if m.clusterDeriver != nil {
|
if m.clusterDeriver != nil {
|
||||||
newCluster, err := m.clusterDeriver.DeriveClusterFromDomain(ctx, accountID, service.Domain)
|
newCluster, err := m.clusterDeriver.DeriveClusterFromDomain(ctx, accountID, svc.Domain)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.WithError(err).Warnf("could not derive cluster from domain %s", service.Domain)
|
log.WithError(err).Warnf("could not derive cluster from domain %s", svc.Domain)
|
||||||
} else {
|
} else {
|
||||||
service.ProxyCluster = newCluster
|
svc.ProxyCluster = newCluster
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// validateProtocolChange rejects mode changes on update.
|
||||||
|
// Only empty<->HTTP is allowed; all other transitions are rejected.
|
||||||
|
func validateProtocolChange(oldMode, newMode string) error {
|
||||||
|
if newMode == "" || newMode == oldMode {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if isHTTPFamily(oldMode) && isHTTPFamily(newMode) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return status.Errorf(status.InvalidArgument, "cannot change mode from %q to %q", oldMode, newMode)
|
||||||
|
}
|
||||||
|
|
||||||
|
func isHTTPFamily(mode string) bool {
|
||||||
|
return mode == "" || mode == "http"
|
||||||
|
}
|
||||||
|
|
||||||
func (m *Manager) preserveExistingAuthSecrets(service, existingService *service.Service) {
|
func (m *Manager) preserveExistingAuthSecrets(service, existingService *service.Service) {
|
||||||
if service.Auth.PasswordAuth != nil && service.Auth.PasswordAuth.Enabled &&
|
if service.Auth.PasswordAuth != nil && service.Auth.PasswordAuth.Enabled &&
|
||||||
existingService.Auth.PasswordAuth != nil && existingService.Auth.PasswordAuth.Enabled &&
|
existingService.Auth.PasswordAuth != nil && existingService.Auth.PasswordAuth.Enabled &&
|
||||||
@@ -388,6 +564,13 @@ func (m *Manager) preserveServiceMetadata(service, existingService *service.Serv
|
|||||||
service.SessionPublicKey = existingService.SessionPublicKey
|
service.SessionPublicKey = existingService.SessionPublicKey
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (m *Manager) preserveListenPort(svc, existing *service.Service) {
|
||||||
|
if existing.ListenPort > 0 && svc.ListenPort == 0 {
|
||||||
|
svc.ListenPort = existing.ListenPort
|
||||||
|
svc.PortAutoAssigned = existing.PortAutoAssigned
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func (m *Manager) sendServiceUpdateNotifications(ctx context.Context, accountID string, s *service.Service, updateInfo *serviceUpdateInfo) {
|
func (m *Manager) sendServiceUpdateNotifications(ctx context.Context, accountID string, s *service.Service, updateInfo *serviceUpdateInfo) {
|
||||||
oidcCfg := m.proxyController.GetOIDCValidationConfig()
|
oidcCfg := m.proxyController.GetOIDCValidationConfig()
|
||||||
|
|
||||||
@@ -675,6 +858,10 @@ func (m *Manager) validateExposePermission(ctx context.Context, accountID, peerI
|
|||||||
return status.Errorf(status.PermissionDenied, "peer is not in an allowed expose group")
|
return status.Errorf(status.PermissionDenied, "peer is not in an allowed expose group")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (m *Manager) resolveDefaultDomain(serviceName string) (string, error) {
|
||||||
|
return m.buildRandomDomain(serviceName)
|
||||||
|
}
|
||||||
|
|
||||||
// CreateServiceFromPeer creates a service initiated by a peer expose request.
|
// CreateServiceFromPeer creates a service initiated by a peer expose request.
|
||||||
// It validates the request, checks expose permissions, enforces the per-peer limit,
|
// It validates the request, checks expose permissions, enforces the per-peer limit,
|
||||||
// creates the service, and tracks it for TTL-based reaping.
|
// creates the service, and tracks it for TTL-based reaping.
|
||||||
@@ -696,9 +883,9 @@ func (m *Manager) CreateServiceFromPeer(ctx context.Context, accountID, peerID s
|
|||||||
svc.Source = service.SourceEphemeral
|
svc.Source = service.SourceEphemeral
|
||||||
|
|
||||||
if svc.Domain == "" {
|
if svc.Domain == "" {
|
||||||
domain, err := m.buildRandomDomain(svc.Name)
|
domain, err := m.resolveDefaultDomain(svc.Name)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("build random domain for service %s: %w", svc.Name, err)
|
return nil, err
|
||||||
}
|
}
|
||||||
svc.Domain = domain
|
svc.Domain = domain
|
||||||
}
|
}
|
||||||
@@ -739,10 +926,16 @@ func (m *Manager) CreateServiceFromPeer(ctx context.Context, accountID, peerID s
|
|||||||
m.proxyController.SendServiceUpdateToCluster(ctx, accountID, svc.ToProtoMapping(service.Create, "", m.proxyController.GetOIDCValidationConfig()), svc.ProxyCluster)
|
m.proxyController.SendServiceUpdateToCluster(ctx, accountID, svc.ToProtoMapping(service.Create, "", m.proxyController.GetOIDCValidationConfig()), svc.ProxyCluster)
|
||||||
m.accountManager.UpdateAccountPeers(ctx, accountID)
|
m.accountManager.UpdateAccountPeers(ctx, accountID)
|
||||||
|
|
||||||
|
serviceURL := "https://" + svc.Domain
|
||||||
|
if service.IsL4Protocol(svc.Mode) {
|
||||||
|
serviceURL = fmt.Sprintf("%s://%s:%d", svc.Mode, svc.Domain, svc.ListenPort)
|
||||||
|
}
|
||||||
|
|
||||||
return &service.ExposeServiceResponse{
|
return &service.ExposeServiceResponse{
|
||||||
ServiceName: svc.Name,
|
ServiceName: svc.Name,
|
||||||
ServiceURL: "https://" + svc.Domain,
|
ServiceURL: serviceURL,
|
||||||
Domain: svc.Domain,
|
Domain: svc.Domain,
|
||||||
|
PortAutoAssigned: svc.PortAutoAssigned,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -761,64 +954,47 @@ func (m *Manager) getGroupIDsFromNames(ctx context.Context, accountID string, gr
|
|||||||
return groupIDs, nil
|
return groupIDs, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *Manager) buildRandomDomain(name string) (string, error) {
|
func (m *Manager) getDefaultClusterDomain() (string, error) {
|
||||||
if m.clusterDeriver == nil {
|
if m.clusterDeriver == nil {
|
||||||
return "", fmt.Errorf("unable to get random domain")
|
return "", fmt.Errorf("unable to get cluster domain")
|
||||||
}
|
}
|
||||||
clusterDomains := m.clusterDeriver.GetClusterDomains()
|
clusterDomains := m.clusterDeriver.GetClusterDomains()
|
||||||
if len(clusterDomains) == 0 {
|
if len(clusterDomains) == 0 {
|
||||||
return "", fmt.Errorf("no cluster domains found for service %s", name)
|
return "", fmt.Errorf("no cluster domains available")
|
||||||
}
|
}
|
||||||
index := rand.IntN(len(clusterDomains))
|
return clusterDomains[rand.IntN(len(clusterDomains))], nil
|
||||||
domain := name + "." + clusterDomains[index]
|
}
|
||||||
return domain, nil
|
|
||||||
|
func (m *Manager) buildRandomDomain(name string) (string, error) {
|
||||||
|
domain, err := m.getDefaultClusterDomain()
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return name + "." + domain, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// RenewServiceFromPeer updates the DB timestamp for the peer's ephemeral service.
|
// RenewServiceFromPeer updates the DB timestamp for the peer's ephemeral service.
|
||||||
func (m *Manager) RenewServiceFromPeer(ctx context.Context, accountID, peerID, domain string) error {
|
func (m *Manager) RenewServiceFromPeer(ctx context.Context, accountID, peerID, serviceID string) error {
|
||||||
return m.store.RenewEphemeralService(ctx, accountID, peerID, domain)
|
return m.store.RenewEphemeralService(ctx, accountID, peerID, serviceID)
|
||||||
}
|
}
|
||||||
|
|
||||||
// StopServiceFromPeer stops a peer's active expose session by deleting the service from the DB.
|
// StopServiceFromPeer stops a peer's active expose session by deleting the service from the DB.
|
||||||
func (m *Manager) StopServiceFromPeer(ctx context.Context, accountID, peerID, domain string) error {
|
func (m *Manager) StopServiceFromPeer(ctx context.Context, accountID, peerID, serviceID string) error {
|
||||||
if err := m.deleteServiceFromPeer(ctx, accountID, peerID, domain, false); err != nil {
|
if err := m.deleteServiceFromPeer(ctx, accountID, peerID, serviceID, false); err != nil {
|
||||||
log.WithContext(ctx).Errorf("failed to delete peer-exposed service for domain %s: %v", domain, err)
|
log.WithContext(ctx).Errorf("failed to delete peer-exposed service %s: %v", serviceID, err)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// deleteServiceFromPeer deletes a peer-initiated service identified by domain.
|
// deleteServiceFromPeer deletes a peer-initiated service identified by service ID.
|
||||||
// When expired is true, the activity is recorded as PeerServiceExposeExpired instead of PeerServiceUnexposed.
|
// When expired is true, the activity is recorded as PeerServiceExposeExpired instead of PeerServiceUnexposed.
|
||||||
func (m *Manager) deleteServiceFromPeer(ctx context.Context, accountID, peerID, domain string, expired bool) error {
|
func (m *Manager) deleteServiceFromPeer(ctx context.Context, accountID, peerID, serviceID string, expired bool) error {
|
||||||
svc, err := m.lookupPeerService(ctx, accountID, peerID, domain)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
activityCode := activity.PeerServiceUnexposed
|
activityCode := activity.PeerServiceUnexposed
|
||||||
if expired {
|
if expired {
|
||||||
activityCode = activity.PeerServiceExposeExpired
|
activityCode = activity.PeerServiceExposeExpired
|
||||||
}
|
}
|
||||||
return m.deletePeerService(ctx, accountID, peerID, svc.ID, activityCode)
|
return m.deletePeerService(ctx, accountID, peerID, serviceID, activityCode)
|
||||||
}
|
|
||||||
|
|
||||||
// lookupPeerService finds a peer-initiated service by domain and validates ownership.
|
|
||||||
func (m *Manager) lookupPeerService(ctx context.Context, accountID, peerID, domain string) (*service.Service, error) {
|
|
||||||
svc, err := m.store.GetServiceByDomain(ctx, domain)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
if svc.Source != service.SourceEphemeral {
|
|
||||||
return nil, status.Errorf(status.PermissionDenied, "cannot operate on API-created service via peer expose")
|
|
||||||
}
|
|
||||||
|
|
||||||
if svc.SourcePeer != peerID {
|
|
||||||
return nil, status.Errorf(status.PermissionDenied, "cannot operate on service exposed by another peer")
|
|
||||||
}
|
|
||||||
|
|
||||||
return svc, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *Manager) deletePeerService(ctx context.Context, accountID, peerID, serviceID string, activityCode activity.Activity) error {
|
func (m *Manager) deletePeerService(ctx context.Context, accountID, peerID, serviceID string, activityCode activity.Activity) error {
|
||||||
|
|||||||
@@ -803,8 +803,8 @@ func TestCreateServiceFromPeer(t *testing.T) {
|
|||||||
mgr, testStore := setupIntegrationTest(t)
|
mgr, testStore := setupIntegrationTest(t)
|
||||||
|
|
||||||
req := &rpservice.ExposeServiceRequest{
|
req := &rpservice.ExposeServiceRequest{
|
||||||
Port: 8080,
|
Port: 8080,
|
||||||
Protocol: "http",
|
Mode: "http",
|
||||||
}
|
}
|
||||||
|
|
||||||
resp, err := mgr.CreateServiceFromPeer(ctx, testAccountID, testPeerID, req)
|
resp, err := mgr.CreateServiceFromPeer(ctx, testAccountID, testPeerID, req)
|
||||||
@@ -826,9 +826,9 @@ func TestCreateServiceFromPeer(t *testing.T) {
|
|||||||
mgr, _ := setupIntegrationTest(t)
|
mgr, _ := setupIntegrationTest(t)
|
||||||
|
|
||||||
req := &rpservice.ExposeServiceRequest{
|
req := &rpservice.ExposeServiceRequest{
|
||||||
Port: 80,
|
Port: 80,
|
||||||
Protocol: "http",
|
Mode: "http",
|
||||||
Domain: "example.com",
|
Domain: "example.com",
|
||||||
}
|
}
|
||||||
|
|
||||||
resp, err := mgr.CreateServiceFromPeer(ctx, testAccountID, testPeerID, req)
|
resp, err := mgr.CreateServiceFromPeer(ctx, testAccountID, testPeerID, req)
|
||||||
@@ -847,8 +847,8 @@ func TestCreateServiceFromPeer(t *testing.T) {
|
|||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
req := &rpservice.ExposeServiceRequest{
|
req := &rpservice.ExposeServiceRequest{
|
||||||
Port: 8080,
|
Port: 8080,
|
||||||
Protocol: "http",
|
Mode: "http",
|
||||||
}
|
}
|
||||||
|
|
||||||
_, err = mgr.CreateServiceFromPeer(ctx, testAccountID, testPeerID, req)
|
_, err = mgr.CreateServiceFromPeer(ctx, testAccountID, testPeerID, req)
|
||||||
@@ -860,8 +860,8 @@ func TestCreateServiceFromPeer(t *testing.T) {
|
|||||||
mgr, _ := setupIntegrationTest(t)
|
mgr, _ := setupIntegrationTest(t)
|
||||||
|
|
||||||
req := &rpservice.ExposeServiceRequest{
|
req := &rpservice.ExposeServiceRequest{
|
||||||
Port: 0,
|
Port: 0,
|
||||||
Protocol: "http",
|
Mode: "http",
|
||||||
}
|
}
|
||||||
|
|
||||||
_, err := mgr.CreateServiceFromPeer(ctx, testAccountID, testPeerID, req)
|
_, err := mgr.CreateServiceFromPeer(ctx, testAccountID, testPeerID, req)
|
||||||
@@ -878,62 +878,52 @@ func TestExposeServiceRequestValidate(t *testing.T) {
|
|||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
name: "valid http request",
|
name: "valid http request",
|
||||||
req: rpservice.ExposeServiceRequest{Port: 8080, Protocol: "http"},
|
req: rpservice.ExposeServiceRequest{Port: 8080, Mode: "http"},
|
||||||
wantErr: "",
|
wantErr: "",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "valid https request with pin",
|
name: "https mode rejected",
|
||||||
req: rpservice.ExposeServiceRequest{Port: 443, Protocol: "https", Pin: "123456"},
|
req: rpservice.ExposeServiceRequest{Port: 443, Mode: "https", Pin: "123456"},
|
||||||
wantErr: "",
|
wantErr: "unsupported mode",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "port zero rejected",
|
name: "port zero rejected",
|
||||||
req: rpservice.ExposeServiceRequest{Port: 0, Protocol: "http"},
|
req: rpservice.ExposeServiceRequest{Port: 0, Mode: "http"},
|
||||||
wantErr: "port must be between 1 and 65535",
|
wantErr: "port must be between 1 and 65535",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "negative port rejected",
|
name: "unsupported mode",
|
||||||
req: rpservice.ExposeServiceRequest{Port: -1, Protocol: "http"},
|
req: rpservice.ExposeServiceRequest{Port: 80, Mode: "ftp"},
|
||||||
wantErr: "port must be between 1 and 65535",
|
wantErr: "unsupported mode",
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "port above 65535 rejected",
|
|
||||||
req: rpservice.ExposeServiceRequest{Port: 65536, Protocol: "http"},
|
|
||||||
wantErr: "port must be between 1 and 65535",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "unsupported protocol",
|
|
||||||
req: rpservice.ExposeServiceRequest{Port: 80, Protocol: "tcp"},
|
|
||||||
wantErr: "unsupported protocol",
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "invalid pin format",
|
name: "invalid pin format",
|
||||||
req: rpservice.ExposeServiceRequest{Port: 80, Protocol: "http", Pin: "abc"},
|
req: rpservice.ExposeServiceRequest{Port: 80, Mode: "http", Pin: "abc"},
|
||||||
wantErr: "invalid pin",
|
wantErr: "invalid pin",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "pin too short",
|
name: "pin too short",
|
||||||
req: rpservice.ExposeServiceRequest{Port: 80, Protocol: "http", Pin: "12345"},
|
req: rpservice.ExposeServiceRequest{Port: 80, Mode: "http", Pin: "12345"},
|
||||||
wantErr: "invalid pin",
|
wantErr: "invalid pin",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "valid 6-digit pin",
|
name: "valid 6-digit pin",
|
||||||
req: rpservice.ExposeServiceRequest{Port: 80, Protocol: "http", Pin: "000000"},
|
req: rpservice.ExposeServiceRequest{Port: 80, Mode: "http", Pin: "000000"},
|
||||||
wantErr: "",
|
wantErr: "",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "empty user group name",
|
name: "empty user group name",
|
||||||
req: rpservice.ExposeServiceRequest{Port: 80, Protocol: "http", UserGroups: []string{"valid", ""}},
|
req: rpservice.ExposeServiceRequest{Port: 80, Mode: "http", UserGroups: []string{"valid", ""}},
|
||||||
wantErr: "user group name cannot be empty",
|
wantErr: "user group name cannot be empty",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "invalid name prefix",
|
name: "invalid name prefix",
|
||||||
req: rpservice.ExposeServiceRequest{Port: 80, Protocol: "http", NamePrefix: "INVALID"},
|
req: rpservice.ExposeServiceRequest{Port: 80, Mode: "http", NamePrefix: "INVALID"},
|
||||||
wantErr: "invalid name prefix",
|
wantErr: "invalid name prefix",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "valid name prefix",
|
name: "valid name prefix",
|
||||||
req: rpservice.ExposeServiceRequest{Port: 80, Protocol: "http", NamePrefix: "my-service"},
|
req: rpservice.ExposeServiceRequest{Port: 80, Mode: "http", NamePrefix: "my-service"},
|
||||||
wantErr: "",
|
wantErr: "",
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -966,14 +956,14 @@ func TestDeleteServiceFromPeer_ByDomain(t *testing.T) {
|
|||||||
|
|
||||||
// First create a service
|
// First create a service
|
||||||
req := &rpservice.ExposeServiceRequest{
|
req := &rpservice.ExposeServiceRequest{
|
||||||
Port: 8080,
|
Port: 8080,
|
||||||
Protocol: "http",
|
Mode: "http",
|
||||||
}
|
}
|
||||||
resp, err := mgr.CreateServiceFromPeer(ctx, testAccountID, testPeerID, req)
|
resp, err := mgr.CreateServiceFromPeer(ctx, testAccountID, testPeerID, req)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
// Delete by domain using unexported method
|
svcID := resolveServiceIDByDomain(t, testStore, resp.Domain)
|
||||||
err = mgr.deleteServiceFromPeer(ctx, testAccountID, testPeerID, resp.Domain, false)
|
err = mgr.deleteServiceFromPeer(ctx, testAccountID, testPeerID, svcID, false)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
// Verify service is deleted
|
// Verify service is deleted
|
||||||
@@ -982,16 +972,17 @@ func TestDeleteServiceFromPeer_ByDomain(t *testing.T) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
t.Run("expire uses correct activity", func(t *testing.T) {
|
t.Run("expire uses correct activity", func(t *testing.T) {
|
||||||
mgr, _ := setupIntegrationTest(t)
|
mgr, testStore := setupIntegrationTest(t)
|
||||||
|
|
||||||
req := &rpservice.ExposeServiceRequest{
|
req := &rpservice.ExposeServiceRequest{
|
||||||
Port: 8080,
|
Port: 8080,
|
||||||
Protocol: "http",
|
Mode: "http",
|
||||||
}
|
}
|
||||||
resp, err := mgr.CreateServiceFromPeer(ctx, testAccountID, testPeerID, req)
|
resp, err := mgr.CreateServiceFromPeer(ctx, testAccountID, testPeerID, req)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
err = mgr.deleteServiceFromPeer(ctx, testAccountID, testPeerID, resp.Domain, true)
|
svcID := resolveServiceIDByDomain(t, testStore, resp.Domain)
|
||||||
|
err = mgr.deleteServiceFromPeer(ctx, testAccountID, testPeerID, svcID, true)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -1003,13 +994,14 @@ func TestStopServiceFromPeer(t *testing.T) {
|
|||||||
mgr, testStore := setupIntegrationTest(t)
|
mgr, testStore := setupIntegrationTest(t)
|
||||||
|
|
||||||
req := &rpservice.ExposeServiceRequest{
|
req := &rpservice.ExposeServiceRequest{
|
||||||
Port: 8080,
|
Port: 8080,
|
||||||
Protocol: "http",
|
Mode: "http",
|
||||||
}
|
}
|
||||||
resp, err := mgr.CreateServiceFromPeer(ctx, testAccountID, testPeerID, req)
|
resp, err := mgr.CreateServiceFromPeer(ctx, testAccountID, testPeerID, req)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
err = mgr.StopServiceFromPeer(ctx, testAccountID, testPeerID, resp.Domain)
|
svcID := resolveServiceIDByDomain(t, testStore, resp.Domain)
|
||||||
|
err = mgr.StopServiceFromPeer(ctx, testAccountID, testPeerID, svcID)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
_, err = testStore.GetServiceByDomain(ctx, resp.Domain)
|
_, err = testStore.GetServiceByDomain(ctx, resp.Domain)
|
||||||
@@ -1022,8 +1014,8 @@ func TestDeleteService_DeletesEphemeralExpose(t *testing.T) {
|
|||||||
mgr, testStore := setupIntegrationTest(t)
|
mgr, testStore := setupIntegrationTest(t)
|
||||||
|
|
||||||
resp, err := mgr.CreateServiceFromPeer(ctx, testAccountID, testPeerID, &rpservice.ExposeServiceRequest{
|
resp, err := mgr.CreateServiceFromPeer(ctx, testAccountID, testPeerID, &rpservice.ExposeServiceRequest{
|
||||||
Port: 8080,
|
Port: 8080,
|
||||||
Protocol: "http",
|
Mode: "http",
|
||||||
})
|
})
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
@@ -1042,8 +1034,8 @@ func TestDeleteService_DeletesEphemeralExpose(t *testing.T) {
|
|||||||
assert.Equal(t, int64(0), count, "ephemeral service should be deleted after API delete")
|
assert.Equal(t, int64(0), count, "ephemeral service should be deleted after API delete")
|
||||||
|
|
||||||
_, err = mgr.CreateServiceFromPeer(ctx, testAccountID, testPeerID, &rpservice.ExposeServiceRequest{
|
_, err = mgr.CreateServiceFromPeer(ctx, testAccountID, testPeerID, &rpservice.ExposeServiceRequest{
|
||||||
Port: 9090,
|
Port: 9090,
|
||||||
Protocol: "http",
|
Mode: "http",
|
||||||
})
|
})
|
||||||
assert.NoError(t, err, "new expose should succeed after API delete")
|
assert.NoError(t, err, "new expose should succeed after API delete")
|
||||||
}
|
}
|
||||||
@@ -1054,8 +1046,8 @@ func TestDeleteAllServices_DeletesEphemeralExposes(t *testing.T) {
|
|||||||
|
|
||||||
for i := range 3 {
|
for i := range 3 {
|
||||||
_, err := mgr.CreateServiceFromPeer(ctx, testAccountID, testPeerID, &rpservice.ExposeServiceRequest{
|
_, err := mgr.CreateServiceFromPeer(ctx, testAccountID, testPeerID, &rpservice.ExposeServiceRequest{
|
||||||
Port: 8080 + i,
|
Port: uint16(8080 + i),
|
||||||
Protocol: "http",
|
Mode: "http",
|
||||||
})
|
})
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
}
|
}
|
||||||
@@ -1076,21 +1068,22 @@ func TestRenewServiceFromPeer(t *testing.T) {
|
|||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|
||||||
t.Run("renews tracked expose", func(t *testing.T) {
|
t.Run("renews tracked expose", func(t *testing.T) {
|
||||||
mgr, _ := setupIntegrationTest(t)
|
mgr, testStore := setupIntegrationTest(t)
|
||||||
|
|
||||||
resp, err := mgr.CreateServiceFromPeer(ctx, testAccountID, testPeerID, &rpservice.ExposeServiceRequest{
|
resp, err := mgr.CreateServiceFromPeer(ctx, testAccountID, testPeerID, &rpservice.ExposeServiceRequest{
|
||||||
Port: 8080,
|
Port: 8080,
|
||||||
Protocol: "http",
|
Mode: "http",
|
||||||
})
|
})
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
err = mgr.RenewServiceFromPeer(ctx, testAccountID, testPeerID, resp.Domain)
|
svcID := resolveServiceIDByDomain(t, testStore, resp.Domain)
|
||||||
|
err = mgr.RenewServiceFromPeer(ctx, testAccountID, testPeerID, svcID)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
})
|
})
|
||||||
|
|
||||||
t.Run("fails for untracked domain", func(t *testing.T) {
|
t.Run("fails for untracked domain", func(t *testing.T) {
|
||||||
mgr, _ := setupIntegrationTest(t)
|
mgr, _ := setupIntegrationTest(t)
|
||||||
err := mgr.RenewServiceFromPeer(ctx, testAccountID, testPeerID, "nonexistent.com")
|
err := mgr.RenewServiceFromPeer(ctx, testAccountID, testPeerID, "nonexistent-service-id")
|
||||||
require.Error(t, err)
|
require.Error(t, err)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -1191,3 +1184,33 @@ func TestDeleteService_DeletesTargets(t *testing.T) {
|
|||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
assert.Len(t, targets, 0, "All targets should be deleted when service is deleted")
|
assert.Len(t, targets, 0, "All targets should be deleted when service is deleted")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestValidateProtocolChange(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
oldP string
|
||||||
|
newP string
|
||||||
|
wantErr bool
|
||||||
|
}{
|
||||||
|
{"empty to http", "", "http", false},
|
||||||
|
{"http to http", "http", "http", false},
|
||||||
|
{"same protocol", "tcp", "tcp", false},
|
||||||
|
{"empty new proto", "tcp", "", false},
|
||||||
|
{"http to tcp", "http", "tcp", true},
|
||||||
|
{"tcp to udp", "tcp", "udp", true},
|
||||||
|
{"tls to http", "tls", "http", true},
|
||||||
|
{"udp to tls", "udp", "tls", true},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
err := validateProtocolChange(tt.oldP, tt.newP)
|
||||||
|
if tt.wantErr {
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.Contains(t, err.Error(), "cannot change mode")
|
||||||
|
} else {
|
||||||
|
require.NoError(t, err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ const (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type Status string
|
type Status string
|
||||||
|
type TargetType string
|
||||||
|
|
||||||
const (
|
const (
|
||||||
StatusPending Status = "pending"
|
StatusPending Status = "pending"
|
||||||
@@ -43,34 +44,36 @@ const (
|
|||||||
StatusCertificateFailed Status = "certificate_failed"
|
StatusCertificateFailed Status = "certificate_failed"
|
||||||
StatusError Status = "error"
|
StatusError Status = "error"
|
||||||
|
|
||||||
TargetTypePeer = "peer"
|
TargetTypePeer TargetType = "peer"
|
||||||
TargetTypeHost = "host"
|
TargetTypeHost TargetType = "host"
|
||||||
TargetTypeDomain = "domain"
|
TargetTypeDomain TargetType = "domain"
|
||||||
TargetTypeSubnet = "subnet"
|
TargetTypeSubnet TargetType = "subnet"
|
||||||
|
|
||||||
SourcePermanent = "permanent"
|
SourcePermanent = "permanent"
|
||||||
SourceEphemeral = "ephemeral"
|
SourceEphemeral = "ephemeral"
|
||||||
)
|
)
|
||||||
|
|
||||||
type TargetOptions struct {
|
type TargetOptions struct {
|
||||||
SkipTLSVerify bool `json:"skip_tls_verify"`
|
SkipTLSVerify bool `json:"skip_tls_verify"`
|
||||||
RequestTimeout time.Duration `json:"request_timeout,omitempty"`
|
RequestTimeout time.Duration `json:"request_timeout,omitempty"`
|
||||||
PathRewrite PathRewriteMode `json:"path_rewrite,omitempty"`
|
SessionIdleTimeout time.Duration `json:"session_idle_timeout,omitempty"`
|
||||||
CustomHeaders map[string]string `gorm:"serializer:json" json:"custom_headers,omitempty"`
|
PathRewrite PathRewriteMode `json:"path_rewrite,omitempty"`
|
||||||
|
CustomHeaders map[string]string `gorm:"serializer:json" json:"custom_headers,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type Target struct {
|
type Target struct {
|
||||||
ID uint `gorm:"primaryKey" json:"-"`
|
ID uint `gorm:"primaryKey" json:"-"`
|
||||||
AccountID string `gorm:"index:idx_target_account;not null" json:"-"`
|
AccountID string `gorm:"index:idx_target_account;not null" json:"-"`
|
||||||
ServiceID string `gorm:"index:idx_service_targets;not null" json:"-"`
|
ServiceID string `gorm:"index:idx_service_targets;not null" json:"-"`
|
||||||
Path *string `json:"path,omitempty"`
|
Path *string `json:"path,omitempty"`
|
||||||
Host string `json:"host"` // the Host field is only used for subnet targets, otherwise ignored
|
Host string `json:"host"` // the Host field is only used for subnet targets, otherwise ignored
|
||||||
Port int `gorm:"index:idx_target_port" json:"port"`
|
Port uint16 `gorm:"index:idx_target_port" json:"port"`
|
||||||
Protocol string `gorm:"index:idx_target_protocol" json:"protocol"`
|
Protocol string `gorm:"index:idx_target_protocol" json:"protocol"`
|
||||||
TargetId string `gorm:"index:idx_target_id" json:"target_id"`
|
TargetId string `gorm:"index:idx_target_id" json:"target_id"`
|
||||||
TargetType string `gorm:"index:idx_target_type" json:"target_type"`
|
TargetType TargetType `gorm:"index:idx_target_type" json:"target_type"`
|
||||||
Enabled bool `gorm:"index:idx_target_enabled" json:"enabled"`
|
Enabled bool `gorm:"index:idx_target_enabled" json:"enabled"`
|
||||||
Options TargetOptions `gorm:"embedded" json:"options"`
|
Options TargetOptions `gorm:"embedded" json:"options"`
|
||||||
|
ProxyProtocol bool `json:"proxy_protocol"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type PasswordAuthConfig struct {
|
type PasswordAuthConfig struct {
|
||||||
@@ -146,23 +149,10 @@ type Service struct {
|
|||||||
SessionPublicKey string `gorm:"column:session_public_key"`
|
SessionPublicKey string `gorm:"column:session_public_key"`
|
||||||
Source string `gorm:"default:'permanent';index:idx_service_source_peer"`
|
Source string `gorm:"default:'permanent';index:idx_service_source_peer"`
|
||||||
SourcePeer string `gorm:"index:idx_service_source_peer"`
|
SourcePeer string `gorm:"index:idx_service_source_peer"`
|
||||||
}
|
// Mode determines the service type: "http", "tcp", "udp", or "tls".
|
||||||
|
Mode string `gorm:"default:'http'"`
|
||||||
func NewService(accountID, name, domain, proxyCluster string, targets []*Target, enabled bool) *Service {
|
ListenPort uint16
|
||||||
for _, target := range targets {
|
PortAutoAssigned bool
|
||||||
target.AccountID = accountID
|
|
||||||
}
|
|
||||||
|
|
||||||
s := &Service{
|
|
||||||
AccountID: accountID,
|
|
||||||
Name: name,
|
|
||||||
Domain: domain,
|
|
||||||
ProxyCluster: proxyCluster,
|
|
||||||
Targets: targets,
|
|
||||||
Enabled: enabled,
|
|
||||||
}
|
|
||||||
s.InitNewRecord()
|
|
||||||
return s
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// InitNewRecord generates a new unique ID and resets metadata for a newly created
|
// InitNewRecord generates a new unique ID and resets metadata for a newly created
|
||||||
@@ -177,21 +167,17 @@ func (s *Service) InitNewRecord() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *Service) ToAPIResponse() *api.Service {
|
func (s *Service) ToAPIResponse() *api.Service {
|
||||||
s.Auth.ClearSecrets()
|
|
||||||
|
|
||||||
authConfig := api.ServiceAuthConfig{}
|
authConfig := api.ServiceAuthConfig{}
|
||||||
|
|
||||||
if s.Auth.PasswordAuth != nil {
|
if s.Auth.PasswordAuth != nil {
|
||||||
authConfig.PasswordAuth = &api.PasswordAuthConfig{
|
authConfig.PasswordAuth = &api.PasswordAuthConfig{
|
||||||
Enabled: s.Auth.PasswordAuth.Enabled,
|
Enabled: s.Auth.PasswordAuth.Enabled,
|
||||||
Password: s.Auth.PasswordAuth.Password,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if s.Auth.PinAuth != nil {
|
if s.Auth.PinAuth != nil {
|
||||||
authConfig.PinAuth = &api.PINAuthConfig{
|
authConfig.PinAuth = &api.PINAuthConfig{
|
||||||
Enabled: s.Auth.PinAuth.Enabled,
|
Enabled: s.Auth.PinAuth.Enabled,
|
||||||
Pin: s.Auth.PinAuth.Pin,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -208,13 +194,18 @@ func (s *Service) ToAPIResponse() *api.Service {
|
|||||||
st := api.ServiceTarget{
|
st := api.ServiceTarget{
|
||||||
Path: target.Path,
|
Path: target.Path,
|
||||||
Host: &target.Host,
|
Host: &target.Host,
|
||||||
Port: target.Port,
|
Port: int(target.Port),
|
||||||
Protocol: api.ServiceTargetProtocol(target.Protocol),
|
Protocol: api.ServiceTargetProtocol(target.Protocol),
|
||||||
TargetId: target.TargetId,
|
TargetId: target.TargetId,
|
||||||
TargetType: api.ServiceTargetTargetType(target.TargetType),
|
TargetType: api.ServiceTargetTargetType(target.TargetType),
|
||||||
Enabled: target.Enabled,
|
Enabled: target.Enabled,
|
||||||
}
|
}
|
||||||
st.Options = targetOptionsToAPI(target.Options)
|
opts := targetOptionsToAPI(target.Options)
|
||||||
|
if opts == nil {
|
||||||
|
opts = &api.ServiceTargetOptions{}
|
||||||
|
}
|
||||||
|
opts.ProxyProtocol = &target.ProxyProtocol
|
||||||
|
st.Options = opts
|
||||||
apiTargets = append(apiTargets, st)
|
apiTargets = append(apiTargets, st)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -227,6 +218,9 @@ func (s *Service) ToAPIResponse() *api.Service {
|
|||||||
meta.CertificateIssuedAt = s.Meta.CertificateIssuedAt
|
meta.CertificateIssuedAt = s.Meta.CertificateIssuedAt
|
||||||
}
|
}
|
||||||
|
|
||||||
|
mode := api.ServiceMode(s.Mode)
|
||||||
|
listenPort := int(s.ListenPort)
|
||||||
|
|
||||||
resp := &api.Service{
|
resp := &api.Service{
|
||||||
Id: s.ID,
|
Id: s.ID,
|
||||||
Name: s.Name,
|
Name: s.Name,
|
||||||
@@ -237,6 +231,9 @@ func (s *Service) ToAPIResponse() *api.Service {
|
|||||||
RewriteRedirects: &s.RewriteRedirects,
|
RewriteRedirects: &s.RewriteRedirects,
|
||||||
Auth: authConfig,
|
Auth: authConfig,
|
||||||
Meta: meta,
|
Meta: meta,
|
||||||
|
Mode: &mode,
|
||||||
|
ListenPort: &listenPort,
|
||||||
|
PortAutoAssigned: &s.PortAutoAssigned,
|
||||||
}
|
}
|
||||||
|
|
||||||
if s.ProxyCluster != "" {
|
if s.ProxyCluster != "" {
|
||||||
@@ -247,37 +244,7 @@ func (s *Service) ToAPIResponse() *api.Service {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *Service) ToProtoMapping(operation Operation, authToken string, oidcConfig proxy.OIDCValidationConfig) *proto.ProxyMapping {
|
func (s *Service) ToProtoMapping(operation Operation, authToken string, oidcConfig proxy.OIDCValidationConfig) *proto.ProxyMapping {
|
||||||
pathMappings := make([]*proto.PathMapping, 0, len(s.Targets))
|
pathMappings := s.buildPathMappings()
|
||||||
for _, target := range s.Targets {
|
|
||||||
if !target.Enabled {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
// TODO: Make path prefix stripping configurable per-target.
|
|
||||||
// Currently the matching prefix is baked into the target URL path,
|
|
||||||
// so the proxy strips-then-re-adds it (effectively a no-op).
|
|
||||||
targetURL := url.URL{
|
|
||||||
Scheme: target.Protocol,
|
|
||||||
Host: target.Host,
|
|
||||||
Path: "/", // TODO: support service path
|
|
||||||
}
|
|
||||||
if target.Port > 0 && !isDefaultPort(target.Protocol, target.Port) {
|
|
||||||
targetURL.Host = net.JoinHostPort(targetURL.Host, strconv.Itoa(target.Port))
|
|
||||||
}
|
|
||||||
|
|
||||||
path := "/"
|
|
||||||
if target.Path != nil {
|
|
||||||
path = *target.Path
|
|
||||||
}
|
|
||||||
|
|
||||||
pm := &proto.PathMapping{
|
|
||||||
Path: path,
|
|
||||||
Target: targetURL.String(),
|
|
||||||
}
|
|
||||||
|
|
||||||
pm.Options = targetOptionsToProto(target.Options)
|
|
||||||
pathMappings = append(pathMappings, pm)
|
|
||||||
}
|
|
||||||
|
|
||||||
auth := &proto.Authentication{
|
auth := &proto.Authentication{
|
||||||
SessionKey: s.SessionPublicKey,
|
SessionKey: s.SessionPublicKey,
|
||||||
@@ -306,9 +273,58 @@ func (s *Service) ToProtoMapping(operation Operation, authToken string, oidcConf
|
|||||||
AccountId: s.AccountID,
|
AccountId: s.AccountID,
|
||||||
PassHostHeader: s.PassHostHeader,
|
PassHostHeader: s.PassHostHeader,
|
||||||
RewriteRedirects: s.RewriteRedirects,
|
RewriteRedirects: s.RewriteRedirects,
|
||||||
|
Mode: s.Mode,
|
||||||
|
ListenPort: int32(s.ListenPort), //nolint:gosec
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// buildPathMappings constructs PathMapping entries from targets.
|
||||||
|
// For HTTP/HTTPS, each target becomes a path-based route with a full URL.
|
||||||
|
// For L4/TLS, a single target maps to a host:port address.
|
||||||
|
func (s *Service) buildPathMappings() []*proto.PathMapping {
|
||||||
|
pathMappings := make([]*proto.PathMapping, 0, len(s.Targets))
|
||||||
|
for _, target := range s.Targets {
|
||||||
|
if !target.Enabled {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if IsL4Protocol(s.Mode) {
|
||||||
|
pm := &proto.PathMapping{
|
||||||
|
Target: net.JoinHostPort(target.Host, strconv.FormatUint(uint64(target.Port), 10)),
|
||||||
|
}
|
||||||
|
opts := l4TargetOptionsToProto(target)
|
||||||
|
if opts != nil {
|
||||||
|
pm.Options = opts
|
||||||
|
}
|
||||||
|
pathMappings = append(pathMappings, pm)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// HTTP/HTTPS: build full URL
|
||||||
|
targetURL := url.URL{
|
||||||
|
Scheme: target.Protocol,
|
||||||
|
Host: target.Host,
|
||||||
|
Path: "/",
|
||||||
|
}
|
||||||
|
if target.Port > 0 && !isDefaultPort(target.Protocol, target.Port) {
|
||||||
|
targetURL.Host = net.JoinHostPort(targetURL.Host, strconv.FormatUint(uint64(target.Port), 10))
|
||||||
|
}
|
||||||
|
|
||||||
|
path := "/"
|
||||||
|
if target.Path != nil {
|
||||||
|
path = *target.Path
|
||||||
|
}
|
||||||
|
|
||||||
|
pm := &proto.PathMapping{
|
||||||
|
Path: path,
|
||||||
|
Target: targetURL.String(),
|
||||||
|
}
|
||||||
|
pm.Options = targetOptionsToProto(target.Options)
|
||||||
|
pathMappings = append(pathMappings, pm)
|
||||||
|
}
|
||||||
|
return pathMappings
|
||||||
|
}
|
||||||
|
|
||||||
func operationToProtoType(op Operation) proto.ProxyMappingUpdateType {
|
func operationToProtoType(op Operation) proto.ProxyMappingUpdateType {
|
||||||
switch op {
|
switch op {
|
||||||
case Create:
|
case Create:
|
||||||
@@ -325,8 +341,8 @@ func operationToProtoType(op Operation) proto.ProxyMappingUpdateType {
|
|||||||
|
|
||||||
// isDefaultPort reports whether port is the standard default for the given scheme
|
// isDefaultPort reports whether port is the standard default for the given scheme
|
||||||
// (443 for https, 80 for http).
|
// (443 for https, 80 for http).
|
||||||
func isDefaultPort(scheme string, port int) bool {
|
func isDefaultPort(scheme string, port uint16) bool {
|
||||||
return (scheme == "https" && port == 443) || (scheme == "http" && port == 80)
|
return (scheme == TargetProtoHTTPS && port == 443) || (scheme == TargetProtoHTTP && port == 80)
|
||||||
}
|
}
|
||||||
|
|
||||||
// PathRewriteMode controls how the request path is rewritten before forwarding.
|
// PathRewriteMode controls how the request path is rewritten before forwarding.
|
||||||
@@ -346,7 +362,7 @@ func pathRewriteToProto(mode PathRewriteMode) proto.PathRewriteMode {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func targetOptionsToAPI(opts TargetOptions) *api.ServiceTargetOptions {
|
func targetOptionsToAPI(opts TargetOptions) *api.ServiceTargetOptions {
|
||||||
if !opts.SkipTLSVerify && opts.RequestTimeout == 0 && opts.PathRewrite == "" && len(opts.CustomHeaders) == 0 {
|
if !opts.SkipTLSVerify && opts.RequestTimeout == 0 && opts.SessionIdleTimeout == 0 && opts.PathRewrite == "" && len(opts.CustomHeaders) == 0 {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
apiOpts := &api.ServiceTargetOptions{}
|
apiOpts := &api.ServiceTargetOptions{}
|
||||||
@@ -357,6 +373,10 @@ func targetOptionsToAPI(opts TargetOptions) *api.ServiceTargetOptions {
|
|||||||
s := opts.RequestTimeout.String()
|
s := opts.RequestTimeout.String()
|
||||||
apiOpts.RequestTimeout = &s
|
apiOpts.RequestTimeout = &s
|
||||||
}
|
}
|
||||||
|
if opts.SessionIdleTimeout != 0 {
|
||||||
|
s := opts.SessionIdleTimeout.String()
|
||||||
|
apiOpts.SessionIdleTimeout = &s
|
||||||
|
}
|
||||||
if opts.PathRewrite != "" {
|
if opts.PathRewrite != "" {
|
||||||
pr := api.ServiceTargetOptionsPathRewrite(opts.PathRewrite)
|
pr := api.ServiceTargetOptionsPathRewrite(opts.PathRewrite)
|
||||||
apiOpts.PathRewrite = &pr
|
apiOpts.PathRewrite = &pr
|
||||||
@@ -382,6 +402,23 @@ func targetOptionsToProto(opts TargetOptions) *proto.PathTargetOptions {
|
|||||||
return popts
|
return popts
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// l4TargetOptionsToProto converts L4-relevant target options to proto.
|
||||||
|
func l4TargetOptionsToProto(target *Target) *proto.PathTargetOptions {
|
||||||
|
if !target.ProxyProtocol && target.Options.RequestTimeout == 0 && target.Options.SessionIdleTimeout == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
opts := &proto.PathTargetOptions{
|
||||||
|
ProxyProtocol: target.ProxyProtocol,
|
||||||
|
}
|
||||||
|
if target.Options.RequestTimeout > 0 {
|
||||||
|
opts.RequestTimeout = durationpb.New(target.Options.RequestTimeout)
|
||||||
|
}
|
||||||
|
if target.Options.SessionIdleTimeout > 0 {
|
||||||
|
opts.SessionIdleTimeout = durationpb.New(target.Options.SessionIdleTimeout)
|
||||||
|
}
|
||||||
|
return opts
|
||||||
|
}
|
||||||
|
|
||||||
func targetOptionsFromAPI(idx int, o *api.ServiceTargetOptions) (TargetOptions, error) {
|
func targetOptionsFromAPI(idx int, o *api.ServiceTargetOptions) (TargetOptions, error) {
|
||||||
var opts TargetOptions
|
var opts TargetOptions
|
||||||
if o.SkipTlsVerify != nil {
|
if o.SkipTlsVerify != nil {
|
||||||
@@ -394,6 +431,13 @@ func targetOptionsFromAPI(idx int, o *api.ServiceTargetOptions) (TargetOptions,
|
|||||||
}
|
}
|
||||||
opts.RequestTimeout = d
|
opts.RequestTimeout = d
|
||||||
}
|
}
|
||||||
|
if o.SessionIdleTimeout != nil {
|
||||||
|
d, err := time.ParseDuration(*o.SessionIdleTimeout)
|
||||||
|
if err != nil {
|
||||||
|
return opts, fmt.Errorf("target %d: parse session_idle_timeout %q: %w", idx, *o.SessionIdleTimeout, err)
|
||||||
|
}
|
||||||
|
opts.SessionIdleTimeout = d
|
||||||
|
}
|
||||||
if o.PathRewrite != nil {
|
if o.PathRewrite != nil {
|
||||||
opts.PathRewrite = PathRewriteMode(*o.PathRewrite)
|
opts.PathRewrite = PathRewriteMode(*o.PathRewrite)
|
||||||
}
|
}
|
||||||
@@ -408,15 +452,49 @@ func (s *Service) FromAPIRequest(req *api.ServiceRequest, accountID string) erro
|
|||||||
s.Domain = req.Domain
|
s.Domain = req.Domain
|
||||||
s.AccountID = accountID
|
s.AccountID = accountID
|
||||||
|
|
||||||
targets := make([]*Target, 0, len(req.Targets))
|
if req.Mode != nil {
|
||||||
for i, apiTarget := range req.Targets {
|
s.Mode = string(*req.Mode)
|
||||||
|
}
|
||||||
|
if req.ListenPort != nil {
|
||||||
|
s.ListenPort = uint16(*req.ListenPort) //nolint:gosec
|
||||||
|
}
|
||||||
|
|
||||||
|
targets, err := targetsFromAPI(accountID, req.Targets)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
s.Targets = targets
|
||||||
|
s.Enabled = req.Enabled
|
||||||
|
|
||||||
|
if req.PassHostHeader != nil {
|
||||||
|
s.PassHostHeader = *req.PassHostHeader
|
||||||
|
}
|
||||||
|
if req.RewriteRedirects != nil {
|
||||||
|
s.RewriteRedirects = *req.RewriteRedirects
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.Auth != nil {
|
||||||
|
s.Auth = authFromAPI(req.Auth)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func targetsFromAPI(accountID string, apiTargetsPtr *[]api.ServiceTarget) ([]*Target, error) {
|
||||||
|
var apiTargets []api.ServiceTarget
|
||||||
|
if apiTargetsPtr != nil {
|
||||||
|
apiTargets = *apiTargetsPtr
|
||||||
|
}
|
||||||
|
|
||||||
|
targets := make([]*Target, 0, len(apiTargets))
|
||||||
|
for i, apiTarget := range apiTargets {
|
||||||
target := &Target{
|
target := &Target{
|
||||||
AccountID: accountID,
|
AccountID: accountID,
|
||||||
Path: apiTarget.Path,
|
Path: apiTarget.Path,
|
||||||
Port: apiTarget.Port,
|
Port: uint16(apiTarget.Port), //nolint:gosec // validated by API layer
|
||||||
Protocol: string(apiTarget.Protocol),
|
Protocol: string(apiTarget.Protocol),
|
||||||
TargetId: apiTarget.TargetId,
|
TargetId: apiTarget.TargetId,
|
||||||
TargetType: string(apiTarget.TargetType),
|
TargetType: TargetType(apiTarget.TargetType),
|
||||||
Enabled: apiTarget.Enabled,
|
Enabled: apiTarget.Enabled,
|
||||||
}
|
}
|
||||||
if apiTarget.Host != nil {
|
if apiTarget.Host != nil {
|
||||||
@@ -425,49 +503,42 @@ func (s *Service) FromAPIRequest(req *api.ServiceRequest, accountID string) erro
|
|||||||
if apiTarget.Options != nil {
|
if apiTarget.Options != nil {
|
||||||
opts, err := targetOptionsFromAPI(i, apiTarget.Options)
|
opts, err := targetOptionsFromAPI(i, apiTarget.Options)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return nil, err
|
||||||
}
|
}
|
||||||
target.Options = opts
|
target.Options = opts
|
||||||
|
if apiTarget.Options.ProxyProtocol != nil {
|
||||||
|
target.ProxyProtocol = *apiTarget.Options.ProxyProtocol
|
||||||
|
}
|
||||||
}
|
}
|
||||||
targets = append(targets, target)
|
targets = append(targets, target)
|
||||||
}
|
}
|
||||||
s.Targets = targets
|
return targets, nil
|
||||||
|
}
|
||||||
|
|
||||||
s.Enabled = req.Enabled
|
func authFromAPI(reqAuth *api.ServiceAuthConfig) AuthConfig {
|
||||||
|
var auth AuthConfig
|
||||||
if req.PassHostHeader != nil {
|
if reqAuth.PasswordAuth != nil {
|
||||||
s.PassHostHeader = *req.PassHostHeader
|
auth.PasswordAuth = &PasswordAuthConfig{
|
||||||
}
|
Enabled: reqAuth.PasswordAuth.Enabled,
|
||||||
|
Password: reqAuth.PasswordAuth.Password,
|
||||||
if req.RewriteRedirects != nil {
|
|
||||||
s.RewriteRedirects = *req.RewriteRedirects
|
|
||||||
}
|
|
||||||
|
|
||||||
if req.Auth.PasswordAuth != nil {
|
|
||||||
s.Auth.PasswordAuth = &PasswordAuthConfig{
|
|
||||||
Enabled: req.Auth.PasswordAuth.Enabled,
|
|
||||||
Password: req.Auth.PasswordAuth.Password,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if reqAuth.PinAuth != nil {
|
||||||
if req.Auth.PinAuth != nil {
|
auth.PinAuth = &PINAuthConfig{
|
||||||
s.Auth.PinAuth = &PINAuthConfig{
|
Enabled: reqAuth.PinAuth.Enabled,
|
||||||
Enabled: req.Auth.PinAuth.Enabled,
|
Pin: reqAuth.PinAuth.Pin,
|
||||||
Pin: req.Auth.PinAuth.Pin,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if reqAuth.BearerAuth != nil {
|
||||||
if req.Auth.BearerAuth != nil {
|
|
||||||
bearerAuth := &BearerAuthConfig{
|
bearerAuth := &BearerAuthConfig{
|
||||||
Enabled: req.Auth.BearerAuth.Enabled,
|
Enabled: reqAuth.BearerAuth.Enabled,
|
||||||
}
|
}
|
||||||
if req.Auth.BearerAuth.DistributionGroups != nil {
|
if reqAuth.BearerAuth.DistributionGroups != nil {
|
||||||
bearerAuth.DistributionGroups = *req.Auth.BearerAuth.DistributionGroups
|
bearerAuth.DistributionGroups = *reqAuth.BearerAuth.DistributionGroups
|
||||||
}
|
}
|
||||||
s.Auth.BearerAuth = bearerAuth
|
auth.BearerAuth = bearerAuth
|
||||||
}
|
}
|
||||||
|
return auth
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Service) Validate() error {
|
func (s *Service) Validate() error {
|
||||||
@@ -478,14 +549,69 @@ func (s *Service) Validate() error {
|
|||||||
return errors.New("service name exceeds maximum length of 255 characters")
|
return errors.New("service name exceeds maximum length of 255 characters")
|
||||||
}
|
}
|
||||||
|
|
||||||
if s.Domain == "" {
|
|
||||||
return errors.New("service domain is required")
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(s.Targets) == 0 {
|
if len(s.Targets) == 0 {
|
||||||
return errors.New("at least one target is required")
|
return errors.New("at least one target is required")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if s.Mode == "" {
|
||||||
|
s.Mode = ModeHTTP
|
||||||
|
}
|
||||||
|
|
||||||
|
switch s.Mode {
|
||||||
|
case ModeHTTP:
|
||||||
|
return s.validateHTTPMode()
|
||||||
|
case ModeTCP, ModeUDP:
|
||||||
|
return s.validateTCPUDPMode()
|
||||||
|
case ModeTLS:
|
||||||
|
return s.validateTLSMode()
|
||||||
|
default:
|
||||||
|
return fmt.Errorf("unsupported mode %q", s.Mode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) validateHTTPMode() error {
|
||||||
|
if s.Domain == "" {
|
||||||
|
return errors.New("service domain is required")
|
||||||
|
}
|
||||||
|
if s.ListenPort != 0 {
|
||||||
|
return errors.New("listen_port is not supported for HTTP services")
|
||||||
|
}
|
||||||
|
return s.validateHTTPTargets()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) validateTCPUDPMode() error {
|
||||||
|
if s.Domain == "" {
|
||||||
|
return errors.New("domain is required for TCP/UDP services (used for cluster derivation)")
|
||||||
|
}
|
||||||
|
if s.isAuthEnabled() {
|
||||||
|
return errors.New("auth is not supported for TCP/UDP services")
|
||||||
|
}
|
||||||
|
if len(s.Targets) != 1 {
|
||||||
|
return errors.New("TCP/UDP services must have exactly one target")
|
||||||
|
}
|
||||||
|
if s.Mode == ModeUDP && s.Targets[0].ProxyProtocol {
|
||||||
|
return errors.New("proxy_protocol is not supported for UDP services")
|
||||||
|
}
|
||||||
|
return s.validateL4Target(s.Targets[0])
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) validateTLSMode() error {
|
||||||
|
if s.Domain == "" {
|
||||||
|
return errors.New("domain is required for TLS services (used for SNI matching)")
|
||||||
|
}
|
||||||
|
if s.isAuthEnabled() {
|
||||||
|
return errors.New("auth is not supported for TLS services")
|
||||||
|
}
|
||||||
|
if s.ListenPort == 0 {
|
||||||
|
return errors.New("listen_port is required for TLS services")
|
||||||
|
}
|
||||||
|
if len(s.Targets) != 1 {
|
||||||
|
return errors.New("TLS services must have exactly one target")
|
||||||
|
}
|
||||||
|
return s.validateL4Target(s.Targets[0])
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) validateHTTPTargets() error {
|
||||||
for i, target := range s.Targets {
|
for i, target := range s.Targets {
|
||||||
switch target.TargetType {
|
switch target.TargetType {
|
||||||
case TargetTypePeer, TargetTypeHost, TargetTypeDomain:
|
case TargetTypePeer, TargetTypeHost, TargetTypeDomain:
|
||||||
@@ -500,6 +626,9 @@ func (s *Service) Validate() error {
|
|||||||
if target.TargetId == "" {
|
if target.TargetId == "" {
|
||||||
return fmt.Errorf("target %d has empty target_id", i)
|
return fmt.Errorf("target %d has empty target_id", i)
|
||||||
}
|
}
|
||||||
|
if target.ProxyProtocol {
|
||||||
|
return fmt.Errorf("target %d: proxy_protocol is not supported for HTTP services", i)
|
||||||
|
}
|
||||||
if err := validateTargetOptions(i, &target.Options); err != nil {
|
if err := validateTargetOptions(i, &target.Options); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -508,11 +637,62 @@ func (s *Service) Validate() error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Service) validateL4Target(target *Target) error {
|
||||||
|
if target.Port == 0 {
|
||||||
|
return errors.New("target port is required for L4 services")
|
||||||
|
}
|
||||||
|
if target.TargetId == "" {
|
||||||
|
return errors.New("target_id is required for L4 services")
|
||||||
|
}
|
||||||
|
switch target.TargetType {
|
||||||
|
case TargetTypePeer, TargetTypeHost:
|
||||||
|
// OK
|
||||||
|
case TargetTypeSubnet:
|
||||||
|
if target.Host == "" {
|
||||||
|
return errors.New("target host is required for subnet targets")
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return fmt.Errorf("invalid target_type %q for L4 service", target.TargetType)
|
||||||
|
}
|
||||||
|
if target.Path != nil && *target.Path != "" && *target.Path != "/" {
|
||||||
|
return errors.New("path is not supported for L4 services")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Service mode constants.
|
||||||
const (
|
const (
|
||||||
maxRequestTimeout = 5 * time.Minute
|
ModeHTTP = "http"
|
||||||
maxCustomHeaders = 16
|
ModeTCP = "tcp"
|
||||||
maxHeaderKeyLen = 128
|
ModeUDP = "udp"
|
||||||
maxHeaderValueLen = 4096
|
ModeTLS = "tls"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Target protocol constants (URL scheme for backend connections).
|
||||||
|
const (
|
||||||
|
TargetProtoHTTP = "http"
|
||||||
|
TargetProtoHTTPS = "https"
|
||||||
|
TargetProtoTCP = "tcp"
|
||||||
|
TargetProtoUDP = "udp"
|
||||||
|
)
|
||||||
|
|
||||||
|
// IsL4Protocol returns true if the mode requires port-based routing (TCP, UDP, or TLS).
|
||||||
|
func IsL4Protocol(mode string) bool {
|
||||||
|
return mode == ModeTCP || mode == ModeUDP || mode == ModeTLS
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsPortBasedProtocol returns true if the mode relies on dedicated port allocation.
|
||||||
|
// TLS is excluded because it uses SNI routing and can share ports with other TLS services.
|
||||||
|
func IsPortBasedProtocol(mode string) bool {
|
||||||
|
return mode == ModeTCP || mode == ModeUDP
|
||||||
|
}
|
||||||
|
|
||||||
|
const (
|
||||||
|
maxRequestTimeout = 5 * time.Minute
|
||||||
|
maxSessionIdleTimeout = 10 * time.Minute
|
||||||
|
maxCustomHeaders = 16
|
||||||
|
maxHeaderKeyLen = 128
|
||||||
|
maxHeaderValueLen = 4096
|
||||||
)
|
)
|
||||||
|
|
||||||
// httpHeaderNameRe matches valid HTTP header field names per RFC 7230 token definition.
|
// httpHeaderNameRe matches valid HTTP header field names per RFC 7230 token definition.
|
||||||
@@ -560,6 +740,15 @@ func validateTargetOptions(idx int, opts *TargetOptions) error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if opts.SessionIdleTimeout != 0 {
|
||||||
|
if opts.SessionIdleTimeout <= 0 {
|
||||||
|
return fmt.Errorf("target %d: session_idle_timeout must be positive", idx)
|
||||||
|
}
|
||||||
|
if opts.SessionIdleTimeout > maxSessionIdleTimeout {
|
||||||
|
return fmt.Errorf("target %d: session_idle_timeout exceeds maximum of %s", idx, maxSessionIdleTimeout)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if err := validateCustomHeaders(idx, opts.CustomHeaders); err != nil {
|
if err := validateCustomHeaders(idx, opts.CustomHeaders); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -608,17 +797,49 @@ func containsCRLF(s string) bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *Service) EventMeta() map[string]any {
|
func (s *Service) EventMeta() map[string]any {
|
||||||
return map[string]any{"name": s.Name, "domain": s.Domain, "proxy_cluster": s.ProxyCluster, "source": s.Source, "auth": s.isAuthEnabled()}
|
meta := map[string]any{
|
||||||
|
"name": s.Name,
|
||||||
|
"domain": s.Domain,
|
||||||
|
"proxy_cluster": s.ProxyCluster,
|
||||||
|
"source": s.Source,
|
||||||
|
"auth": s.isAuthEnabled(),
|
||||||
|
"mode": s.Mode,
|
||||||
|
}
|
||||||
|
|
||||||
|
if s.ListenPort != 0 {
|
||||||
|
meta["listen_port"] = s.ListenPort
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(s.Targets) > 0 {
|
||||||
|
t := s.Targets[0]
|
||||||
|
if t.ProxyProtocol {
|
||||||
|
meta["proxy_protocol"] = true
|
||||||
|
}
|
||||||
|
if t.Options.RequestTimeout != 0 {
|
||||||
|
meta["request_timeout"] = t.Options.RequestTimeout.String()
|
||||||
|
}
|
||||||
|
if t.Options.SessionIdleTimeout != 0 {
|
||||||
|
meta["session_idle_timeout"] = t.Options.SessionIdleTimeout.String()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return meta
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Service) isAuthEnabled() bool {
|
func (s *Service) isAuthEnabled() bool {
|
||||||
return s.Auth.PasswordAuth != nil || s.Auth.PinAuth != nil || s.Auth.BearerAuth != nil
|
return (s.Auth.PasswordAuth != nil && s.Auth.PasswordAuth.Enabled) ||
|
||||||
|
(s.Auth.PinAuth != nil && s.Auth.PinAuth.Enabled) ||
|
||||||
|
(s.Auth.BearerAuth != nil && s.Auth.BearerAuth.Enabled)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Service) Copy() *Service {
|
func (s *Service) Copy() *Service {
|
||||||
targets := make([]*Target, len(s.Targets))
|
targets := make([]*Target, len(s.Targets))
|
||||||
for i, target := range s.Targets {
|
for i, target := range s.Targets {
|
||||||
targetCopy := *target
|
targetCopy := *target
|
||||||
|
if target.Path != nil {
|
||||||
|
p := *target.Path
|
||||||
|
targetCopy.Path = &p
|
||||||
|
}
|
||||||
if len(target.Options.CustomHeaders) > 0 {
|
if len(target.Options.CustomHeaders) > 0 {
|
||||||
targetCopy.Options.CustomHeaders = make(map[string]string, len(target.Options.CustomHeaders))
|
targetCopy.Options.CustomHeaders = make(map[string]string, len(target.Options.CustomHeaders))
|
||||||
for k, v := range target.Options.CustomHeaders {
|
for k, v := range target.Options.CustomHeaders {
|
||||||
@@ -628,6 +849,24 @@ func (s *Service) Copy() *Service {
|
|||||||
targets[i] = &targetCopy
|
targets[i] = &targetCopy
|
||||||
}
|
}
|
||||||
|
|
||||||
|
authCopy := s.Auth
|
||||||
|
if s.Auth.PasswordAuth != nil {
|
||||||
|
pa := *s.Auth.PasswordAuth
|
||||||
|
authCopy.PasswordAuth = &pa
|
||||||
|
}
|
||||||
|
if s.Auth.PinAuth != nil {
|
||||||
|
pa := *s.Auth.PinAuth
|
||||||
|
authCopy.PinAuth = &pa
|
||||||
|
}
|
||||||
|
if s.Auth.BearerAuth != nil {
|
||||||
|
ba := *s.Auth.BearerAuth
|
||||||
|
if len(s.Auth.BearerAuth.DistributionGroups) > 0 {
|
||||||
|
ba.DistributionGroups = make([]string, len(s.Auth.BearerAuth.DistributionGroups))
|
||||||
|
copy(ba.DistributionGroups, s.Auth.BearerAuth.DistributionGroups)
|
||||||
|
}
|
||||||
|
authCopy.BearerAuth = &ba
|
||||||
|
}
|
||||||
|
|
||||||
return &Service{
|
return &Service{
|
||||||
ID: s.ID,
|
ID: s.ID,
|
||||||
AccountID: s.AccountID,
|
AccountID: s.AccountID,
|
||||||
@@ -638,12 +877,15 @@ func (s *Service) Copy() *Service {
|
|||||||
Enabled: s.Enabled,
|
Enabled: s.Enabled,
|
||||||
PassHostHeader: s.PassHostHeader,
|
PassHostHeader: s.PassHostHeader,
|
||||||
RewriteRedirects: s.RewriteRedirects,
|
RewriteRedirects: s.RewriteRedirects,
|
||||||
Auth: s.Auth,
|
Auth: authCopy,
|
||||||
Meta: s.Meta,
|
Meta: s.Meta,
|
||||||
SessionPrivateKey: s.SessionPrivateKey,
|
SessionPrivateKey: s.SessionPrivateKey,
|
||||||
SessionPublicKey: s.SessionPublicKey,
|
SessionPublicKey: s.SessionPublicKey,
|
||||||
Source: s.Source,
|
Source: s.Source,
|
||||||
SourcePeer: s.SourcePeer,
|
SourcePeer: s.SourcePeer,
|
||||||
|
Mode: s.Mode,
|
||||||
|
ListenPort: s.ListenPort,
|
||||||
|
PortAutoAssigned: s.PortAutoAssigned,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -688,12 +930,16 @@ var validNamePrefix = regexp.MustCompile(`^[a-z0-9]([a-z0-9-]{0,30}[a-z0-9])?$`)
|
|||||||
// ExposeServiceRequest contains the parameters for creating a peer-initiated expose service.
|
// ExposeServiceRequest contains the parameters for creating a peer-initiated expose service.
|
||||||
type ExposeServiceRequest struct {
|
type ExposeServiceRequest struct {
|
||||||
NamePrefix string
|
NamePrefix string
|
||||||
Port int
|
Port uint16
|
||||||
Protocol string
|
Mode string
|
||||||
Domain string
|
// TargetProtocol is the protocol used to connect to the peer backend.
|
||||||
Pin string
|
// For HTTP mode: "http" (default) or "https". For L4 modes: "tcp" or "udp".
|
||||||
Password string
|
TargetProtocol string
|
||||||
UserGroups []string
|
Domain string
|
||||||
|
Pin string
|
||||||
|
Password string
|
||||||
|
UserGroups []string
|
||||||
|
ListenPort uint16
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate checks all fields of the expose request.
|
// Validate checks all fields of the expose request.
|
||||||
@@ -702,12 +948,20 @@ func (r *ExposeServiceRequest) Validate() error {
|
|||||||
return errors.New("request cannot be nil")
|
return errors.New("request cannot be nil")
|
||||||
}
|
}
|
||||||
|
|
||||||
if r.Port < 1 || r.Port > 65535 {
|
if r.Port == 0 {
|
||||||
return fmt.Errorf("port must be between 1 and 65535, got %d", r.Port)
|
return fmt.Errorf("port must be between 1 and 65535, got %d", r.Port)
|
||||||
}
|
}
|
||||||
|
|
||||||
if r.Protocol != "http" && r.Protocol != "https" {
|
switch r.Mode {
|
||||||
return fmt.Errorf("unsupported protocol %q: must be http or https", r.Protocol)
|
case ModeHTTP, ModeTCP, ModeUDP, ModeTLS:
|
||||||
|
default:
|
||||||
|
return fmt.Errorf("unsupported mode %q", r.Mode)
|
||||||
|
}
|
||||||
|
|
||||||
|
if IsL4Protocol(r.Mode) {
|
||||||
|
if r.Pin != "" || r.Password != "" || len(r.UserGroups) > 0 {
|
||||||
|
return fmt.Errorf("authentication is not supported for %s mode", r.Mode)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if r.Pin != "" && !pinRegexp.MatchString(r.Pin) {
|
if r.Pin != "" && !pinRegexp.MatchString(r.Pin) {
|
||||||
@@ -729,55 +983,79 @@ func (r *ExposeServiceRequest) Validate() error {
|
|||||||
|
|
||||||
// ToService builds a Service from the expose request.
|
// ToService builds a Service from the expose request.
|
||||||
func (r *ExposeServiceRequest) ToService(accountID, peerID, serviceName string) *Service {
|
func (r *ExposeServiceRequest) ToService(accountID, peerID, serviceName string) *Service {
|
||||||
service := &Service{
|
svc := &Service{
|
||||||
AccountID: accountID,
|
AccountID: accountID,
|
||||||
Name: serviceName,
|
Name: serviceName,
|
||||||
|
Mode: r.Mode,
|
||||||
Enabled: true,
|
Enabled: true,
|
||||||
Targets: []*Target{
|
}
|
||||||
{
|
|
||||||
AccountID: accountID,
|
// If domain is empty, CreateServiceFromPeer generates a unique subdomain.
|
||||||
Port: r.Port,
|
// When explicitly provided, the service name is prepended as a subdomain.
|
||||||
Protocol: r.Protocol,
|
if r.Domain != "" {
|
||||||
TargetId: peerID,
|
svc.Domain = serviceName + "." + r.Domain
|
||||||
TargetType: TargetTypePeer,
|
}
|
||||||
Enabled: true,
|
|
||||||
},
|
if IsL4Protocol(r.Mode) {
|
||||||
|
svc.ListenPort = r.Port
|
||||||
|
if r.ListenPort > 0 {
|
||||||
|
svc.ListenPort = r.ListenPort
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var targetProto string
|
||||||
|
switch {
|
||||||
|
case !IsL4Protocol(r.Mode):
|
||||||
|
targetProto = TargetProtoHTTP
|
||||||
|
if r.TargetProtocol != "" {
|
||||||
|
targetProto = r.TargetProtocol
|
||||||
|
}
|
||||||
|
case r.Mode == ModeUDP:
|
||||||
|
targetProto = TargetProtoUDP
|
||||||
|
default:
|
||||||
|
targetProto = TargetProtoTCP
|
||||||
|
}
|
||||||
|
svc.Targets = []*Target{
|
||||||
|
{
|
||||||
|
AccountID: accountID,
|
||||||
|
Port: r.Port,
|
||||||
|
Protocol: targetProto,
|
||||||
|
TargetId: peerID,
|
||||||
|
TargetType: TargetTypePeer,
|
||||||
|
Enabled: true,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
if r.Domain != "" {
|
|
||||||
service.Domain = serviceName + "." + r.Domain
|
|
||||||
}
|
|
||||||
|
|
||||||
if r.Pin != "" {
|
if r.Pin != "" {
|
||||||
service.Auth.PinAuth = &PINAuthConfig{
|
svc.Auth.PinAuth = &PINAuthConfig{
|
||||||
Enabled: true,
|
Enabled: true,
|
||||||
Pin: r.Pin,
|
Pin: r.Pin,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if r.Password != "" {
|
if r.Password != "" {
|
||||||
service.Auth.PasswordAuth = &PasswordAuthConfig{
|
svc.Auth.PasswordAuth = &PasswordAuthConfig{
|
||||||
Enabled: true,
|
Enabled: true,
|
||||||
Password: r.Password,
|
Password: r.Password,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(r.UserGroups) > 0 {
|
if len(r.UserGroups) > 0 {
|
||||||
service.Auth.BearerAuth = &BearerAuthConfig{
|
svc.Auth.BearerAuth = &BearerAuthConfig{
|
||||||
Enabled: true,
|
Enabled: true,
|
||||||
DistributionGroups: r.UserGroups,
|
DistributionGroups: r.UserGroups,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return service
|
return svc
|
||||||
}
|
}
|
||||||
|
|
||||||
// ExposeServiceResponse contains the result of a successful peer expose creation.
|
// ExposeServiceResponse contains the result of a successful peer expose creation.
|
||||||
type ExposeServiceResponse struct {
|
type ExposeServiceResponse struct {
|
||||||
ServiceName string
|
ServiceName string
|
||||||
ServiceURL string
|
ServiceURL string
|
||||||
Domain string
|
Domain string
|
||||||
|
PortAutoAssigned bool
|
||||||
}
|
}
|
||||||
|
|
||||||
// GenerateExposeName generates a random service name for peer-exposed services.
|
// GenerateExposeName generates a random service name for peer-exposed services.
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ func TestValidate_EmptyDomain(t *testing.T) {
|
|||||||
func TestValidate_NoTargets(t *testing.T) {
|
func TestValidate_NoTargets(t *testing.T) {
|
||||||
rp := validProxy()
|
rp := validProxy()
|
||||||
rp.Targets = nil
|
rp.Targets = nil
|
||||||
assert.ErrorContains(t, rp.Validate(), "at least one target")
|
assert.ErrorContains(t, rp.Validate(), "at least one target is required")
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestValidate_EmptyTargetId(t *testing.T) {
|
func TestValidate_EmptyTargetId(t *testing.T) {
|
||||||
@@ -273,7 +273,7 @@ func TestToProtoMapping_NoOptionsWhenDefault(t *testing.T) {
|
|||||||
func TestIsDefaultPort(t *testing.T) {
|
func TestIsDefaultPort(t *testing.T) {
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
scheme string
|
scheme string
|
||||||
port int
|
port uint16
|
||||||
want bool
|
want bool
|
||||||
}{
|
}{
|
||||||
{"http", 80, true},
|
{"http", 80, true},
|
||||||
@@ -299,7 +299,7 @@ func TestToProtoMapping_PortInTargetURL(t *testing.T) {
|
|||||||
name string
|
name string
|
||||||
protocol string
|
protocol string
|
||||||
host string
|
host string
|
||||||
port int
|
port uint16
|
||||||
wantTarget string
|
wantTarget string
|
||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
@@ -645,8 +645,8 @@ func TestGenerateExposeName(t *testing.T) {
|
|||||||
func TestExposeServiceRequest_ToService(t *testing.T) {
|
func TestExposeServiceRequest_ToService(t *testing.T) {
|
||||||
t.Run("basic HTTP service", func(t *testing.T) {
|
t.Run("basic HTTP service", func(t *testing.T) {
|
||||||
req := &ExposeServiceRequest{
|
req := &ExposeServiceRequest{
|
||||||
Port: 8080,
|
Port: 8080,
|
||||||
Protocol: "http",
|
Mode: "http",
|
||||||
}
|
}
|
||||||
|
|
||||||
service := req.ToService("account-1", "peer-1", "mysvc")
|
service := req.ToService("account-1", "peer-1", "mysvc")
|
||||||
@@ -658,7 +658,7 @@ func TestExposeServiceRequest_ToService(t *testing.T) {
|
|||||||
require.Len(t, service.Targets, 1)
|
require.Len(t, service.Targets, 1)
|
||||||
|
|
||||||
target := service.Targets[0]
|
target := service.Targets[0]
|
||||||
assert.Equal(t, 8080, target.Port)
|
assert.Equal(t, uint16(8080), target.Port)
|
||||||
assert.Equal(t, "http", target.Protocol)
|
assert.Equal(t, "http", target.Protocol)
|
||||||
assert.Equal(t, "peer-1", target.TargetId)
|
assert.Equal(t, "peer-1", target.TargetId)
|
||||||
assert.Equal(t, TargetTypePeer, target.TargetType)
|
assert.Equal(t, TargetTypePeer, target.TargetType)
|
||||||
@@ -730,3 +730,182 @@ func TestExposeServiceRequest_ToService(t *testing.T) {
|
|||||||
require.NotNil(t, service.Auth.BearerAuth)
|
require.NotNil(t, service.Auth.BearerAuth)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestValidate_TLSOnly(t *testing.T) {
|
||||||
|
rp := &Service{
|
||||||
|
Name: "tls-svc",
|
||||||
|
Mode: "tls",
|
||||||
|
Domain: "example.com",
|
||||||
|
ListenPort: 8443,
|
||||||
|
Targets: []*Target{
|
||||||
|
{TargetId: "peer-1", TargetType: TargetTypePeer, Protocol: "tcp", Port: 443, Enabled: true},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
require.NoError(t, rp.Validate())
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidate_TLSMissingListenPort(t *testing.T) {
|
||||||
|
rp := &Service{
|
||||||
|
Name: "tls-svc",
|
||||||
|
Mode: "tls",
|
||||||
|
Domain: "example.com",
|
||||||
|
ListenPort: 0,
|
||||||
|
Targets: []*Target{
|
||||||
|
{TargetId: "peer-1", TargetType: TargetTypePeer, Protocol: "tcp", Port: 443, Enabled: true},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
assert.ErrorContains(t, rp.Validate(), "listen_port is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidate_TLSMissingDomain(t *testing.T) {
|
||||||
|
rp := &Service{
|
||||||
|
Name: "tls-svc",
|
||||||
|
Mode: "tls",
|
||||||
|
ListenPort: 8443,
|
||||||
|
Targets: []*Target{
|
||||||
|
{TargetId: "peer-1", TargetType: TargetTypePeer, Protocol: "tcp", Port: 443, Enabled: true},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
assert.ErrorContains(t, rp.Validate(), "domain is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidate_TCPValid(t *testing.T) {
|
||||||
|
rp := &Service{
|
||||||
|
Name: "tcp-svc",
|
||||||
|
Mode: "tcp",
|
||||||
|
Domain: "cluster.test",
|
||||||
|
ListenPort: 5432,
|
||||||
|
Targets: []*Target{
|
||||||
|
{TargetId: "peer-1", TargetType: TargetTypePeer, Protocol: "tcp", Port: 5432, Enabled: true},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
require.NoError(t, rp.Validate())
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidate_TCPMissingListenPort(t *testing.T) {
|
||||||
|
rp := &Service{
|
||||||
|
Name: "tcp-svc",
|
||||||
|
Mode: "tcp",
|
||||||
|
Domain: "cluster.test",
|
||||||
|
Targets: []*Target{
|
||||||
|
{TargetId: "peer-1", TargetType: TargetTypePeer, Protocol: "tcp", Port: 5432, Enabled: true},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
require.NoError(t, rp.Validate(), "TCP with listen_port=0 is valid (auto-assigned by manager)")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidate_L4MultipleTargets(t *testing.T) {
|
||||||
|
rp := &Service{
|
||||||
|
Name: "tcp-svc",
|
||||||
|
Mode: "tcp",
|
||||||
|
Domain: "cluster.test",
|
||||||
|
ListenPort: 5432,
|
||||||
|
Targets: []*Target{
|
||||||
|
{TargetId: "peer-1", TargetType: TargetTypePeer, Protocol: "tcp", Port: 5432, Enabled: true},
|
||||||
|
{TargetId: "peer-2", TargetType: TargetTypePeer, Protocol: "tcp", Port: 5432, Enabled: true},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
assert.ErrorContains(t, rp.Validate(), "exactly one target")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidate_L4TargetMissingPort(t *testing.T) {
|
||||||
|
rp := &Service{
|
||||||
|
Name: "tcp-svc",
|
||||||
|
Mode: "tcp",
|
||||||
|
Domain: "cluster.test",
|
||||||
|
ListenPort: 5432,
|
||||||
|
Targets: []*Target{
|
||||||
|
{TargetId: "peer-1", TargetType: TargetTypePeer, Protocol: "tcp", Port: 0, Enabled: true},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
assert.ErrorContains(t, rp.Validate(), "port is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidate_TLSInvalidTargetType(t *testing.T) {
|
||||||
|
rp := &Service{
|
||||||
|
Name: "tls-svc",
|
||||||
|
Mode: "tls",
|
||||||
|
Domain: "example.com",
|
||||||
|
ListenPort: 443,
|
||||||
|
Targets: []*Target{
|
||||||
|
{TargetId: "peer-1", TargetType: "invalid", Protocol: "tcp", Port: 443, Enabled: true},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
assert.Error(t, rp.Validate())
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidate_TLSSubnetValid(t *testing.T) {
|
||||||
|
rp := &Service{
|
||||||
|
Name: "tls-subnet",
|
||||||
|
Mode: "tls",
|
||||||
|
Domain: "example.com",
|
||||||
|
ListenPort: 8443,
|
||||||
|
Targets: []*Target{
|
||||||
|
{TargetId: "subnet-1", TargetType: TargetTypeSubnet, Protocol: "tcp", Port: 443, Host: "10.0.0.5", Enabled: true},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
require.NoError(t, rp.Validate())
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidate_HTTPProxyProtocolRejected(t *testing.T) {
|
||||||
|
rp := validProxy()
|
||||||
|
rp.Targets[0].ProxyProtocol = true
|
||||||
|
assert.ErrorContains(t, rp.Validate(), "proxy_protocol is not supported for HTTP")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidate_UDPProxyProtocolRejected(t *testing.T) {
|
||||||
|
rp := &Service{
|
||||||
|
Name: "udp-svc",
|
||||||
|
Mode: "udp",
|
||||||
|
Domain: "cluster.test",
|
||||||
|
Targets: []*Target{
|
||||||
|
{TargetId: "peer-1", TargetType: TargetTypePeer, Protocol: "udp", Port: 5432, Enabled: true, ProxyProtocol: true},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
assert.ErrorContains(t, rp.Validate(), "proxy_protocol is not supported for UDP")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidate_TCPProxyProtocolAllowed(t *testing.T) {
|
||||||
|
rp := &Service{
|
||||||
|
Name: "tcp-svc",
|
||||||
|
Mode: "tcp",
|
||||||
|
Domain: "cluster.test",
|
||||||
|
ListenPort: 5432,
|
||||||
|
Targets: []*Target{
|
||||||
|
{TargetId: "peer-1", TargetType: TargetTypePeer, Protocol: "tcp", Port: 5432, Enabled: true, ProxyProtocol: true},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
require.NoError(t, rp.Validate())
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExposeServiceRequest_Validate_L4RejectsAuth(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
req ExposeServiceRequest
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "tcp with pin",
|
||||||
|
req: ExposeServiceRequest{Port: 8080, Mode: "tcp", Pin: "123456"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "udp with password",
|
||||||
|
req: ExposeServiceRequest{Port: 8080, Mode: "udp", Password: "secret"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "tls with user groups",
|
||||||
|
req: ExposeServiceRequest{Port: 443, Mode: "tls", UserGroups: []string{"admins"}},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
err := tt.req.Validate()
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.Contains(t, err.Error(), "authentication is not supported")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExposeServiceRequest_Validate_HTTPAllowsAuth(t *testing.T) {
|
||||||
|
req := ExposeServiceRequest{Port: 8080, Mode: "http", Pin: "123456"}
|
||||||
|
require.NoError(t, req.Validate())
|
||||||
|
}
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import (
|
|||||||
"google.golang.org/grpc/keepalive"
|
"google.golang.org/grpc/keepalive"
|
||||||
|
|
||||||
"github.com/netbirdio/management-integrations/integrations"
|
"github.com/netbirdio/management-integrations/integrations"
|
||||||
|
|
||||||
"github.com/netbirdio/netbird/encryption"
|
"github.com/netbirdio/netbird/encryption"
|
||||||
"github.com/netbirdio/netbird/formatter/hook"
|
"github.com/netbirdio/netbird/formatter/hook"
|
||||||
"github.com/netbirdio/netbird/management/internals/modules/reverseproxy/accesslogs"
|
"github.com/netbirdio/netbird/management/internals/modules/reverseproxy/accesslogs"
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import (
|
|||||||
log "github.com/sirupsen/logrus"
|
log "github.com/sirupsen/logrus"
|
||||||
|
|
||||||
"github.com/netbirdio/management-integrations/integrations"
|
"github.com/netbirdio/management-integrations/integrations"
|
||||||
|
|
||||||
"github.com/netbirdio/netbird/management/internals/modules/peers"
|
"github.com/netbirdio/netbird/management/internals/modules/peers"
|
||||||
"github.com/netbirdio/netbird/management/internals/modules/reverseproxy/domain/manager"
|
"github.com/netbirdio/netbird/management/internals/modules/reverseproxy/domain/manager"
|
||||||
"github.com/netbirdio/netbird/management/internals/modules/reverseproxy/proxy"
|
"github.com/netbirdio/netbird/management/internals/modules/reverseproxy/proxy"
|
||||||
@@ -211,6 +212,9 @@ func (s *BaseServer) ProxyManager() proxy.Manager {
|
|||||||
func (s *BaseServer) ReverseProxyDomainManager() *manager.Manager {
|
func (s *BaseServer) ReverseProxyDomainManager() *manager.Manager {
|
||||||
return Create(s, func() *manager.Manager {
|
return Create(s, func() *manager.Manager {
|
||||||
m := manager.NewManager(s.Store(), s.ProxyManager(), s.PermissionsManager(), s.AccountManager())
|
m := manager.NewManager(s.Store(), s.ProxyManager(), s.PermissionsManager(), s.AccountManager())
|
||||||
|
s.AfterInit(func(s *BaseServer) {
|
||||||
|
m.SetClusterCapabilities(s.ServiceProxyController())
|
||||||
|
})
|
||||||
return &m
|
return &m
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package grpc
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
pb "github.com/golang/protobuf/proto" // nolint
|
pb "github.com/golang/protobuf/proto" // nolint
|
||||||
log "github.com/sirupsen/logrus"
|
log "github.com/sirupsen/logrus"
|
||||||
@@ -39,23 +40,38 @@ func (s *Server) CreateExpose(ctx context.Context, req *proto.EncryptedMessage)
|
|||||||
return nil, status.Errorf(codes.Internal, "reverse proxy manager not available")
|
return nil, status.Errorf(codes.Internal, "reverse proxy manager not available")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if exposeReq.Port > 65535 {
|
||||||
|
return nil, status.Errorf(codes.InvalidArgument, "port out of range: %d", exposeReq.Port)
|
||||||
|
}
|
||||||
|
if exposeReq.ListenPort > 65535 {
|
||||||
|
return nil, status.Errorf(codes.InvalidArgument, "listen_port out of range: %d", exposeReq.ListenPort)
|
||||||
|
}
|
||||||
|
|
||||||
|
mode, err := exposeProtocolToString(exposeReq.Protocol)
|
||||||
|
if err != nil {
|
||||||
|
return nil, status.Errorf(codes.InvalidArgument, "%v", err)
|
||||||
|
}
|
||||||
|
|
||||||
created, err := reverseProxyMgr.CreateServiceFromPeer(ctx, accountID, peer.ID, &rpservice.ExposeServiceRequest{
|
created, err := reverseProxyMgr.CreateServiceFromPeer(ctx, accountID, peer.ID, &rpservice.ExposeServiceRequest{
|
||||||
NamePrefix: exposeReq.NamePrefix,
|
NamePrefix: exposeReq.NamePrefix,
|
||||||
Port: int(exposeReq.Port),
|
Port: uint16(exposeReq.Port), //nolint:gosec // validated above
|
||||||
Protocol: exposeProtocolToString(exposeReq.Protocol),
|
Mode: mode,
|
||||||
Domain: exposeReq.Domain,
|
TargetProtocol: exposeTargetProtocol(exposeReq.Protocol),
|
||||||
Pin: exposeReq.Pin,
|
Domain: exposeReq.Domain,
|
||||||
Password: exposeReq.Password,
|
Pin: exposeReq.Pin,
|
||||||
UserGroups: exposeReq.UserGroups,
|
Password: exposeReq.Password,
|
||||||
|
UserGroups: exposeReq.UserGroups,
|
||||||
|
ListenPort: uint16(exposeReq.ListenPort), //nolint:gosec // validated above
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, mapExposeError(ctx, err)
|
return nil, mapExposeError(ctx, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return s.encryptResponse(peerKey, &proto.ExposeServiceResponse{
|
return s.encryptResponse(peerKey, &proto.ExposeServiceResponse{
|
||||||
ServiceName: created.ServiceName,
|
ServiceName: created.ServiceName,
|
||||||
ServiceUrl: created.ServiceURL,
|
ServiceUrl: created.ServiceURL,
|
||||||
Domain: created.Domain,
|
Domain: created.Domain,
|
||||||
|
PortAutoAssigned: created.PortAutoAssigned,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -77,7 +93,12 @@ func (s *Server) RenewExpose(ctx context.Context, req *proto.EncryptedMessage) (
|
|||||||
return nil, status.Errorf(codes.Internal, "reverse proxy manager not available")
|
return nil, status.Errorf(codes.Internal, "reverse proxy manager not available")
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := reverseProxyMgr.RenewServiceFromPeer(ctx, accountID, peer.ID, renewReq.Domain); err != nil {
|
serviceID, err := s.resolveServiceID(ctx, renewReq.Domain)
|
||||||
|
if err != nil {
|
||||||
|
return nil, mapExposeError(ctx, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := reverseProxyMgr.RenewServiceFromPeer(ctx, accountID, peer.ID, serviceID); err != nil {
|
||||||
return nil, mapExposeError(ctx, err)
|
return nil, mapExposeError(ctx, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -102,7 +123,12 @@ func (s *Server) StopExpose(ctx context.Context, req *proto.EncryptedMessage) (*
|
|||||||
return nil, status.Errorf(codes.Internal, "reverse proxy manager not available")
|
return nil, status.Errorf(codes.Internal, "reverse proxy manager not available")
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := reverseProxyMgr.StopServiceFromPeer(ctx, accountID, peer.ID, stopReq.Domain); err != nil {
|
serviceID, err := s.resolveServiceID(ctx, stopReq.Domain)
|
||||||
|
if err != nil {
|
||||||
|
return nil, mapExposeError(ctx, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := reverseProxyMgr.StopServiceFromPeer(ctx, accountID, peer.ID, serviceID); err != nil {
|
||||||
return nil, mapExposeError(ctx, err)
|
return nil, mapExposeError(ctx, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -180,13 +206,46 @@ func (s *Server) SetReverseProxyManager(mgr rpservice.Manager) {
|
|||||||
s.reverseProxyManager = mgr
|
s.reverseProxyManager = mgr
|
||||||
}
|
}
|
||||||
|
|
||||||
func exposeProtocolToString(p proto.ExposeProtocol) string {
|
// resolveServiceID looks up the service by its globally unique domain.
|
||||||
|
func (s *Server) resolveServiceID(ctx context.Context, domain string) (string, error) {
|
||||||
|
if domain == "" {
|
||||||
|
return "", status.Errorf(codes.InvalidArgument, "domain is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
svc, err := s.accountManager.GetStore().GetServiceByDomain(ctx, domain)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return svc.ID, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func exposeProtocolToString(p proto.ExposeProtocol) (string, error) {
|
||||||
switch p {
|
switch p {
|
||||||
case proto.ExposeProtocol_EXPOSE_HTTP:
|
case proto.ExposeProtocol_EXPOSE_HTTP, proto.ExposeProtocol_EXPOSE_HTTPS:
|
||||||
return "http"
|
return "http", nil
|
||||||
case proto.ExposeProtocol_EXPOSE_HTTPS:
|
case proto.ExposeProtocol_EXPOSE_TCP:
|
||||||
return "https"
|
return "tcp", nil
|
||||||
|
case proto.ExposeProtocol_EXPOSE_UDP:
|
||||||
|
return "udp", nil
|
||||||
|
case proto.ExposeProtocol_EXPOSE_TLS:
|
||||||
|
return "tls", nil
|
||||||
default:
|
default:
|
||||||
return "http"
|
return "", fmt.Errorf("unsupported expose protocol: %v", p)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// exposeTargetProtocol returns the target protocol for the given expose protocol.
|
||||||
|
// For HTTP mode, this is http or https (the scheme used to connect to the backend).
|
||||||
|
// For L4 modes, this is tcp or udp (the transport used to connect to the backend).
|
||||||
|
func exposeTargetProtocol(p proto.ExposeProtocol) string {
|
||||||
|
switch p {
|
||||||
|
case proto.ExposeProtocol_EXPOSE_HTTPS:
|
||||||
|
return rpservice.TargetProtoHTTPS
|
||||||
|
case proto.ExposeProtocol_EXPOSE_TCP, proto.ExposeProtocol_EXPOSE_TLS:
|
||||||
|
return rpservice.TargetProtoTCP
|
||||||
|
case proto.ExposeProtocol_EXPOSE_UDP:
|
||||||
|
return rpservice.TargetProtoUDP
|
||||||
|
default:
|
||||||
|
return rpservice.TargetProtoHTTP
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ import (
|
|||||||
proxyauth "github.com/netbirdio/netbird/proxy/auth"
|
proxyauth "github.com/netbirdio/netbird/proxy/auth"
|
||||||
"github.com/netbirdio/netbird/shared/hash/argon2id"
|
"github.com/netbirdio/netbird/shared/hash/argon2id"
|
||||||
"github.com/netbirdio/netbird/shared/management/proto"
|
"github.com/netbirdio/netbird/shared/management/proto"
|
||||||
|
nbstatus "github.com/netbirdio/netbird/shared/management/status"
|
||||||
)
|
)
|
||||||
|
|
||||||
type ProxyOIDCConfig struct {
|
type ProxyOIDCConfig struct {
|
||||||
@@ -45,12 +46,6 @@ type ProxyOIDCConfig struct {
|
|||||||
KeysLocation string
|
KeysLocation string
|
||||||
}
|
}
|
||||||
|
|
||||||
// ClusterInfo contains information about a proxy cluster.
|
|
||||||
type ClusterInfo struct {
|
|
||||||
Address string
|
|
||||||
ConnectedProxies int
|
|
||||||
}
|
|
||||||
|
|
||||||
// ProxyServiceServer implements the ProxyService gRPC server
|
// ProxyServiceServer implements the ProxyService gRPC server
|
||||||
type ProxyServiceServer struct {
|
type ProxyServiceServer struct {
|
||||||
proto.UnimplementedProxyServiceServer
|
proto.UnimplementedProxyServiceServer
|
||||||
@@ -61,9 +56,9 @@ type ProxyServiceServer struct {
|
|||||||
// Manager for access logs
|
// Manager for access logs
|
||||||
accessLogManager accesslogs.Manager
|
accessLogManager accesslogs.Manager
|
||||||
|
|
||||||
|
mu sync.RWMutex
|
||||||
// Manager for reverse proxy operations
|
// Manager for reverse proxy operations
|
||||||
serviceManager rpservice.Manager
|
serviceManager rpservice.Manager
|
||||||
|
|
||||||
// ProxyController for service updates and cluster management
|
// ProxyController for service updates and cluster management
|
||||||
proxyController proxy.Controller
|
proxyController proxy.Controller
|
||||||
|
|
||||||
@@ -84,23 +79,26 @@ type ProxyServiceServer struct {
|
|||||||
|
|
||||||
// Store for PKCE verifiers
|
// Store for PKCE verifiers
|
||||||
pkceVerifierStore *PKCEVerifierStore
|
pkceVerifierStore *PKCEVerifierStore
|
||||||
|
|
||||||
|
cancel context.CancelFunc
|
||||||
}
|
}
|
||||||
|
|
||||||
const pkceVerifierTTL = 10 * time.Minute
|
const pkceVerifierTTL = 10 * time.Minute
|
||||||
|
|
||||||
// proxyConnection represents a connected proxy
|
// proxyConnection represents a connected proxy
|
||||||
type proxyConnection struct {
|
type proxyConnection struct {
|
||||||
proxyID string
|
proxyID string
|
||||||
address string
|
address string
|
||||||
stream proto.ProxyService_GetMappingUpdateServer
|
capabilities *proto.ProxyCapabilities
|
||||||
sendChan chan *proto.GetMappingUpdateResponse
|
stream proto.ProxyService_GetMappingUpdateServer
|
||||||
ctx context.Context
|
sendChan chan *proto.GetMappingUpdateResponse
|
||||||
cancel context.CancelFunc
|
ctx context.Context
|
||||||
|
cancel context.CancelFunc
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewProxyServiceServer creates a new proxy service server.
|
// NewProxyServiceServer creates a new proxy service server.
|
||||||
func NewProxyServiceServer(accessLogMgr accesslogs.Manager, tokenStore *OneTimeTokenStore, pkceStore *PKCEVerifierStore, oidcConfig ProxyOIDCConfig, peersManager peers.Manager, usersManager users.Manager, proxyMgr proxy.Manager) *ProxyServiceServer {
|
func NewProxyServiceServer(accessLogMgr accesslogs.Manager, tokenStore *OneTimeTokenStore, pkceStore *PKCEVerifierStore, oidcConfig ProxyOIDCConfig, peersManager peers.Manager, usersManager users.Manager, proxyMgr proxy.Manager) *ProxyServiceServer {
|
||||||
ctx := context.Background()
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
s := &ProxyServiceServer{
|
s := &ProxyServiceServer{
|
||||||
accessLogManager: accessLogMgr,
|
accessLogManager: accessLogMgr,
|
||||||
oidcConfig: oidcConfig,
|
oidcConfig: oidcConfig,
|
||||||
@@ -109,6 +107,7 @@ func NewProxyServiceServer(accessLogMgr accesslogs.Manager, tokenStore *OneTimeT
|
|||||||
peersManager: peersManager,
|
peersManager: peersManager,
|
||||||
usersManager: usersManager,
|
usersManager: usersManager,
|
||||||
proxyManager: proxyMgr,
|
proxyManager: proxyMgr,
|
||||||
|
cancel: cancel,
|
||||||
}
|
}
|
||||||
go s.cleanupStaleProxies(ctx)
|
go s.cleanupStaleProxies(ctx)
|
||||||
return s
|
return s
|
||||||
@@ -130,11 +129,22 @@ func (s *ProxyServiceServer) cleanupStaleProxies(ctx context.Context) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Close stops background goroutines.
|
||||||
|
func (s *ProxyServiceServer) Close() {
|
||||||
|
s.cancel()
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetServiceManager sets the service manager. Must be called before serving.
|
||||||
func (s *ProxyServiceServer) SetServiceManager(manager rpservice.Manager) {
|
func (s *ProxyServiceServer) SetServiceManager(manager rpservice.Manager) {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
s.serviceManager = manager
|
s.serviceManager = manager
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SetProxyController sets the proxy controller. Must be called before serving.
|
||||||
func (s *ProxyServiceServer) SetProxyController(proxyController proxy.Controller) {
|
func (s *ProxyServiceServer) SetProxyController(proxyController proxy.Controller) {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
s.proxyController = proxyController
|
s.proxyController = proxyController
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -157,12 +167,13 @@ func (s *ProxyServiceServer) GetMappingUpdate(req *proto.GetMappingUpdateRequest
|
|||||||
|
|
||||||
connCtx, cancel := context.WithCancel(ctx)
|
connCtx, cancel := context.WithCancel(ctx)
|
||||||
conn := &proxyConnection{
|
conn := &proxyConnection{
|
||||||
proxyID: proxyID,
|
proxyID: proxyID,
|
||||||
address: proxyAddress,
|
address: proxyAddress,
|
||||||
stream: stream,
|
capabilities: req.GetCapabilities(),
|
||||||
sendChan: make(chan *proto.GetMappingUpdateResponse, 100),
|
stream: stream,
|
||||||
ctx: connCtx,
|
sendChan: make(chan *proto.GetMappingUpdateResponse, 100),
|
||||||
cancel: cancel,
|
ctx: connCtx,
|
||||||
|
cancel: cancel,
|
||||||
}
|
}
|
||||||
|
|
||||||
s.connectedProxies.Store(proxyID, conn)
|
s.connectedProxies.Store(proxyID, conn)
|
||||||
@@ -231,29 +242,18 @@ func (s *ProxyServiceServer) heartbeat(ctx context.Context, proxyID string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// sendSnapshot sends the initial snapshot of services to the connecting proxy.
|
// sendSnapshot sends the initial snapshot of services to the connecting proxy.
|
||||||
// Only services matching the proxy's cluster address are sent.
|
// Only entries matching the proxy's cluster address are sent.
|
||||||
func (s *ProxyServiceServer) sendSnapshot(ctx context.Context, conn *proxyConnection) error {
|
func (s *ProxyServiceServer) sendSnapshot(ctx context.Context, conn *proxyConnection) error {
|
||||||
services, err := s.serviceManager.GetGlobalServices(ctx)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("get services from store: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if !isProxyAddressValid(conn.address) {
|
if !isProxyAddressValid(conn.address) {
|
||||||
return fmt.Errorf("proxy address is invalid")
|
return fmt.Errorf("proxy address is invalid")
|
||||||
}
|
}
|
||||||
|
|
||||||
var filtered []*rpservice.Service
|
mappings, err := s.snapshotServiceMappings(ctx, conn)
|
||||||
for _, service := range services {
|
if err != nil {
|
||||||
if !service.Enabled {
|
return err
|
||||||
continue
|
|
||||||
}
|
|
||||||
if service.ProxyCluster == "" || service.ProxyCluster != conn.address {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
filtered = append(filtered, service)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(filtered) == 0 {
|
if len(mappings) == 0 {
|
||||||
if err := conn.stream.Send(&proto.GetMappingUpdateResponse{
|
if err := conn.stream.Send(&proto.GetMappingUpdateResponse{
|
||||||
InitialSyncComplete: true,
|
InitialSyncComplete: true,
|
||||||
}); err != nil {
|
}); err != nil {
|
||||||
@@ -262,9 +262,30 @@ func (s *ProxyServiceServer) sendSnapshot(ctx context.Context, conn *proxyConnec
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
for i, service := range filtered {
|
for i, m := range mappings {
|
||||||
// Generate one-time authentication token for each service in the snapshot
|
if err := conn.stream.Send(&proto.GetMappingUpdateResponse{
|
||||||
// Tokens are not persistent on the proxy, so we need to generate new ones on reconnection
|
Mapping: []*proto.ProxyMapping{m},
|
||||||
|
InitialSyncComplete: i == len(mappings)-1,
|
||||||
|
}); err != nil {
|
||||||
|
return fmt.Errorf("send proxy mapping: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *ProxyServiceServer) snapshotServiceMappings(ctx context.Context, conn *proxyConnection) ([]*proto.ProxyMapping, error) {
|
||||||
|
services, err := s.serviceManager.GetGlobalServices(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("get services from store: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var mappings []*proto.ProxyMapping
|
||||||
|
for _, service := range services {
|
||||||
|
if !service.Enabled || service.ProxyCluster == "" || service.ProxyCluster != conn.address {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
token, err := s.tokenStore.GenerateToken(service.AccountID, service.ID, 5*time.Minute)
|
token, err := s.tokenStore.GenerateToken(service.AccountID, service.ID, 5*time.Minute)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.WithFields(log.Fields{
|
log.WithFields(log.Fields{
|
||||||
@@ -274,25 +295,10 @@ func (s *ProxyServiceServer) sendSnapshot(ctx context.Context, conn *proxyConnec
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := conn.stream.Send(&proto.GetMappingUpdateResponse{
|
m := service.ToProtoMapping(rpservice.Create, token, s.GetOIDCValidationConfig())
|
||||||
Mapping: []*proto.ProxyMapping{
|
mappings = append(mappings, m)
|
||||||
service.ToProtoMapping(
|
|
||||||
rpservice.Create, // Initial snapshot, all records are "new" for the proxy.
|
|
||||||
token,
|
|
||||||
s.GetOIDCValidationConfig(),
|
|
||||||
),
|
|
||||||
},
|
|
||||||
InitialSyncComplete: i == len(filtered)-1,
|
|
||||||
}); err != nil {
|
|
||||||
log.WithFields(log.Fields{
|
|
||||||
"domain": service.Domain,
|
|
||||||
"account": service.AccountID,
|
|
||||||
}).WithError(err).Error("failed to send proxy mapping")
|
|
||||||
return fmt.Errorf("send proxy mapping: %w", err)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
return mappings, nil
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// isProxyAddressValid validates a proxy address
|
// isProxyAddressValid validates a proxy address
|
||||||
@@ -305,8 +311,8 @@ func isProxyAddressValid(addr string) bool {
|
|||||||
func (s *ProxyServiceServer) sender(conn *proxyConnection, errChan chan<- error) {
|
func (s *ProxyServiceServer) sender(conn *proxyConnection, errChan chan<- error) {
|
||||||
for {
|
for {
|
||||||
select {
|
select {
|
||||||
case msg := <-conn.sendChan:
|
case resp := <-conn.sendChan:
|
||||||
if err := conn.stream.Send(msg); err != nil {
|
if err := conn.stream.Send(resp); err != nil {
|
||||||
errChan <- err
|
errChan <- err
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -361,12 +367,12 @@ func (s *ProxyServiceServer) SendServiceUpdate(update *proto.GetMappingUpdateRes
|
|||||||
log.Debugf("Broadcasting service update to all connected proxy servers")
|
log.Debugf("Broadcasting service update to all connected proxy servers")
|
||||||
s.connectedProxies.Range(func(key, value interface{}) bool {
|
s.connectedProxies.Range(func(key, value interface{}) bool {
|
||||||
conn := value.(*proxyConnection)
|
conn := value.(*proxyConnection)
|
||||||
msg := s.perProxyMessage(update, conn.proxyID)
|
resp := s.perProxyMessage(update, conn.proxyID)
|
||||||
if msg == nil {
|
if resp == nil {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
select {
|
select {
|
||||||
case conn.sendChan <- msg:
|
case conn.sendChan <- resp:
|
||||||
log.Debugf("Sent service update to proxy server %s", conn.proxyID)
|
log.Debugf("Sent service update to proxy server %s", conn.proxyID)
|
||||||
default:
|
default:
|
||||||
log.Warnf("Failed to send service update to proxy server %s (channel full)", conn.proxyID)
|
log.Warnf("Failed to send service update to proxy server %s (channel full)", conn.proxyID)
|
||||||
@@ -495,9 +501,40 @@ func shallowCloneMapping(m *proto.ProxyMapping) *proto.ProxyMapping {
|
|||||||
Auth: m.Auth,
|
Auth: m.Auth,
|
||||||
PassHostHeader: m.PassHostHeader,
|
PassHostHeader: m.PassHostHeader,
|
||||||
RewriteRedirects: m.RewriteRedirects,
|
RewriteRedirects: m.RewriteRedirects,
|
||||||
|
Mode: m.Mode,
|
||||||
|
ListenPort: m.ListenPort,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ClusterSupportsCustomPorts returns whether any connected proxy in the given
|
||||||
|
// cluster reports custom port support. Returns nil if no proxy has reported
|
||||||
|
// capabilities (old proxies that predate the field).
|
||||||
|
func (s *ProxyServiceServer) ClusterSupportsCustomPorts(clusterAddr string) *bool {
|
||||||
|
if s.proxyController == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var hasCapabilities bool
|
||||||
|
for _, pid := range s.proxyController.GetProxiesForCluster(clusterAddr) {
|
||||||
|
connVal, ok := s.connectedProxies.Load(pid)
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
conn := connVal.(*proxyConnection)
|
||||||
|
if conn.capabilities == nil || conn.capabilities.SupportsCustomPorts == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if *conn.capabilities.SupportsCustomPorts {
|
||||||
|
return ptr(true)
|
||||||
|
}
|
||||||
|
hasCapabilities = true
|
||||||
|
}
|
||||||
|
if hasCapabilities {
|
||||||
|
return ptr(false)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func (s *ProxyServiceServer) Authenticate(ctx context.Context, req *proto.AuthenticateRequest) (*proto.AuthenticateResponse, error) {
|
func (s *ProxyServiceServer) Authenticate(ctx context.Context, req *proto.AuthenticateRequest) (*proto.AuthenticateResponse, error) {
|
||||||
service, err := s.serviceManager.GetServiceByID(ctx, req.GetAccountId(), req.GetId())
|
service, err := s.serviceManager.GetServiceByID(ctx, req.GetAccountId(), req.GetId())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -585,7 +622,7 @@ func (s *ProxyServiceServer) generateSessionToken(ctx context.Context, authentic
|
|||||||
return token, nil
|
return token, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// SendStatusUpdate handles status updates from proxy clients
|
// SendStatusUpdate handles status updates from proxy clients.
|
||||||
func (s *ProxyServiceServer) SendStatusUpdate(ctx context.Context, req *proto.SendStatusUpdateRequest) (*proto.SendStatusUpdateResponse, error) {
|
func (s *ProxyServiceServer) SendStatusUpdate(ctx context.Context, req *proto.SendStatusUpdateRequest) (*proto.SendStatusUpdateResponse, error) {
|
||||||
accountID := req.GetAccountId()
|
accountID := req.GetAccountId()
|
||||||
serviceID := req.GetServiceId()
|
serviceID := req.GetServiceId()
|
||||||
@@ -604,6 +641,17 @@ func (s *ProxyServiceServer) SendStatusUpdate(ctx context.Context, req *proto.Se
|
|||||||
return nil, status.Errorf(codes.InvalidArgument, "service_id and account_id are required")
|
return nil, status.Errorf(codes.InvalidArgument, "service_id and account_id are required")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
internalStatus := protoStatusToInternal(protoStatus)
|
||||||
|
|
||||||
|
if err := s.serviceManager.SetStatus(ctx, accountID, serviceID, internalStatus); err != nil {
|
||||||
|
sErr, isNbErr := nbstatus.FromError(err)
|
||||||
|
if isNbErr && sErr.Type() == nbstatus.NotFound {
|
||||||
|
return nil, status.Errorf(codes.NotFound, "service %s not found", serviceID)
|
||||||
|
}
|
||||||
|
log.WithContext(ctx).WithError(err).Error("failed to update service status")
|
||||||
|
return nil, status.Errorf(codes.Internal, "update service status: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
if certificateIssued {
|
if certificateIssued {
|
||||||
if err := s.serviceManager.SetCertificateIssuedAt(ctx, accountID, serviceID); err != nil {
|
if err := s.serviceManager.SetCertificateIssuedAt(ctx, accountID, serviceID); err != nil {
|
||||||
log.WithContext(ctx).WithError(err).Error("failed to set certificate issued timestamp")
|
log.WithContext(ctx).WithError(err).Error("failed to set certificate issued timestamp")
|
||||||
@@ -615,13 +663,6 @@ func (s *ProxyServiceServer) SendStatusUpdate(ctx context.Context, req *proto.Se
|
|||||||
}).Info("Certificate issued timestamp updated")
|
}).Info("Certificate issued timestamp updated")
|
||||||
}
|
}
|
||||||
|
|
||||||
internalStatus := protoStatusToInternal(protoStatus)
|
|
||||||
|
|
||||||
if err := s.serviceManager.SetStatus(ctx, accountID, serviceID, internalStatus); err != nil {
|
|
||||||
log.WithContext(ctx).WithError(err).Error("failed to update service status")
|
|
||||||
return nil, status.Errorf(codes.Internal, "update service status: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
log.WithFields(log.Fields{
|
log.WithFields(log.Fields{
|
||||||
"service_id": serviceID,
|
"service_id": serviceID,
|
||||||
"account_id": accountID,
|
"account_id": accountID,
|
||||||
@@ -631,7 +672,7 @@ func (s *ProxyServiceServer) SendStatusUpdate(ctx context.Context, req *proto.Se
|
|||||||
return &proto.SendStatusUpdateResponse{}, nil
|
return &proto.SendStatusUpdateResponse{}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// protoStatusToInternal maps proto status to internal status
|
// protoStatusToInternal maps proto status to internal service status.
|
||||||
func protoStatusToInternal(protoStatus proto.ProxyStatus) rpservice.Status {
|
func protoStatusToInternal(protoStatus proto.ProxyStatus) rpservice.Status {
|
||||||
switch protoStatus {
|
switch protoStatus {
|
||||||
case proto.ProxyStatus_PROXY_STATUS_PENDING:
|
case proto.ProxyStatus_PROXY_STATUS_PENDING:
|
||||||
@@ -1061,3 +1102,5 @@ func (s *ProxyServiceServer) checkGroupAccess(service *rpservice.Service, user *
|
|||||||
|
|
||||||
return fmt.Errorf("user not in allowed groups")
|
return fmt.Errorf("user not in allowed groups")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func ptr[T any](v T) *T { return &v }
|
||||||
|
|||||||
@@ -53,6 +53,10 @@ func (c *testProxyController) UnregisterProxyFromCluster(_ context.Context, clus
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (c *testProxyController) ClusterSupportsCustomPorts(_ string) *bool {
|
||||||
|
return ptr(true)
|
||||||
|
}
|
||||||
|
|
||||||
func (c *testProxyController) GetProxiesForCluster(clusterAddr string) []string {
|
func (c *testProxyController) GetProxiesForCluster(clusterAddr string) []string {
|
||||||
c.mu.Lock()
|
c.mu.Lock()
|
||||||
defer c.mu.Unlock()
|
defer c.mu.Unlock()
|
||||||
@@ -70,11 +74,17 @@ func (c *testProxyController) GetProxiesForCluster(clusterAddr string) []string
|
|||||||
// registerFakeProxy adds a fake proxy connection to the server's internal maps
|
// registerFakeProxy adds a fake proxy connection to the server's internal maps
|
||||||
// and returns the channel where messages will be received.
|
// and returns the channel where messages will be received.
|
||||||
func registerFakeProxy(s *ProxyServiceServer, proxyID, clusterAddr string) chan *proto.GetMappingUpdateResponse {
|
func registerFakeProxy(s *ProxyServiceServer, proxyID, clusterAddr string) chan *proto.GetMappingUpdateResponse {
|
||||||
|
return registerFakeProxyWithCaps(s, proxyID, clusterAddr, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// registerFakeProxyWithCaps adds a fake proxy connection with explicit capabilities.
|
||||||
|
func registerFakeProxyWithCaps(s *ProxyServiceServer, proxyID, clusterAddr string, caps *proto.ProxyCapabilities) chan *proto.GetMappingUpdateResponse {
|
||||||
ch := make(chan *proto.GetMappingUpdateResponse, 10)
|
ch := make(chan *proto.GetMappingUpdateResponse, 10)
|
||||||
conn := &proxyConnection{
|
conn := &proxyConnection{
|
||||||
proxyID: proxyID,
|
proxyID: proxyID,
|
||||||
address: clusterAddr,
|
address: clusterAddr,
|
||||||
sendChan: ch,
|
capabilities: caps,
|
||||||
|
sendChan: ch,
|
||||||
}
|
}
|
||||||
s.connectedProxies.Store(proxyID, conn)
|
s.connectedProxies.Store(proxyID, conn)
|
||||||
|
|
||||||
@@ -83,15 +93,29 @@ func registerFakeProxy(s *ProxyServiceServer, proxyID, clusterAddr string) chan
|
|||||||
return ch
|
return ch
|
||||||
}
|
}
|
||||||
|
|
||||||
func drainChannel(ch chan *proto.GetMappingUpdateResponse) *proto.GetMappingUpdateResponse {
|
// drainMapping drains a single ProxyMapping from the channel.
|
||||||
|
func drainMapping(ch chan *proto.GetMappingUpdateResponse) *proto.ProxyMapping {
|
||||||
select {
|
select {
|
||||||
case msg := <-ch:
|
case resp := <-ch:
|
||||||
return msg
|
if len(resp.Mapping) > 0 {
|
||||||
|
return resp.Mapping[0]
|
||||||
|
}
|
||||||
|
return nil
|
||||||
case <-time.After(time.Second):
|
case <-time.After(time.Second):
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// drainEmpty checks if a channel has no message within timeout.
|
||||||
|
func drainEmpty(ch chan *proto.GetMappingUpdateResponse) bool {
|
||||||
|
select {
|
||||||
|
case <-ch:
|
||||||
|
return false
|
||||||
|
case <-time.After(100 * time.Millisecond):
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestSendServiceUpdateToCluster_UniqueTokensPerProxy(t *testing.T) {
|
func TestSendServiceUpdateToCluster_UniqueTokensPerProxy(t *testing.T) {
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
tokenStore, err := NewOneTimeTokenStore(ctx, time.Hour, 10*time.Minute, 100)
|
tokenStore, err := NewOneTimeTokenStore(ctx, time.Hour, 10*time.Minute, 100)
|
||||||
@@ -129,10 +153,8 @@ func TestSendServiceUpdateToCluster_UniqueTokensPerProxy(t *testing.T) {
|
|||||||
|
|
||||||
tokens := make([]string, numProxies)
|
tokens := make([]string, numProxies)
|
||||||
for i, ch := range channels {
|
for i, ch := range channels {
|
||||||
resp := drainChannel(ch)
|
msg := drainMapping(ch)
|
||||||
require.NotNil(t, resp, "proxy %d should receive a message", i)
|
require.NotNil(t, msg, "proxy %d should receive a message", i)
|
||||||
require.Len(t, resp.Mapping, 1, "proxy %d should receive exactly one mapping", i)
|
|
||||||
msg := resp.Mapping[0]
|
|
||||||
assert.Equal(t, mapping.Domain, msg.Domain)
|
assert.Equal(t, mapping.Domain, msg.Domain)
|
||||||
assert.Equal(t, mapping.Id, msg.Id)
|
assert.Equal(t, mapping.Id, msg.Id)
|
||||||
assert.NotEmpty(t, msg.AuthToken, "proxy %d should have a non-empty token", i)
|
assert.NotEmpty(t, msg.AuthToken, "proxy %d should have a non-empty token", i)
|
||||||
@@ -181,16 +203,14 @@ func TestSendServiceUpdateToCluster_DeleteNoToken(t *testing.T) {
|
|||||||
|
|
||||||
s.SendServiceUpdateToCluster(context.Background(), mapping, cluster)
|
s.SendServiceUpdateToCluster(context.Background(), mapping, cluster)
|
||||||
|
|
||||||
resp1 := drainChannel(ch1)
|
msg1 := drainMapping(ch1)
|
||||||
resp2 := drainChannel(ch2)
|
msg2 := drainMapping(ch2)
|
||||||
require.NotNil(t, resp1)
|
require.NotNil(t, msg1)
|
||||||
require.NotNil(t, resp2)
|
require.NotNil(t, msg2)
|
||||||
require.Len(t, resp1.Mapping, 1)
|
|
||||||
require.Len(t, resp2.Mapping, 1)
|
|
||||||
|
|
||||||
// Delete operations should not generate tokens
|
// Delete operations should not generate tokens
|
||||||
assert.Empty(t, resp1.Mapping[0].AuthToken)
|
assert.Empty(t, msg1.AuthToken)
|
||||||
assert.Empty(t, resp2.Mapping[0].AuthToken)
|
assert.Empty(t, msg2.AuthToken)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestSendServiceUpdate_UniqueTokensPerProxy(t *testing.T) {
|
func TestSendServiceUpdate_UniqueTokensPerProxy(t *testing.T) {
|
||||||
@@ -224,15 +244,10 @@ func TestSendServiceUpdate_UniqueTokensPerProxy(t *testing.T) {
|
|||||||
|
|
||||||
s.SendServiceUpdate(update)
|
s.SendServiceUpdate(update)
|
||||||
|
|
||||||
resp1 := drainChannel(ch1)
|
msg1 := drainMapping(ch1)
|
||||||
resp2 := drainChannel(ch2)
|
msg2 := drainMapping(ch2)
|
||||||
require.NotNil(t, resp1)
|
require.NotNil(t, msg1)
|
||||||
require.NotNil(t, resp2)
|
require.NotNil(t, msg2)
|
||||||
require.Len(t, resp1.Mapping, 1)
|
|
||||||
require.Len(t, resp2.Mapping, 1)
|
|
||||||
|
|
||||||
msg1 := resp1.Mapping[0]
|
|
||||||
msg2 := resp2.Mapping[0]
|
|
||||||
|
|
||||||
assert.NotEmpty(t, msg1.AuthToken)
|
assert.NotEmpty(t, msg1.AuthToken)
|
||||||
assert.NotEmpty(t, msg2.AuthToken)
|
assert.NotEmpty(t, msg2.AuthToken)
|
||||||
@@ -324,3 +339,314 @@ func TestValidateState_RejectsInvalidHMAC(t *testing.T) {
|
|||||||
require.Error(t, err)
|
require.Error(t, err)
|
||||||
assert.Contains(t, err.Error(), "invalid state signature")
|
assert.Contains(t, err.Error(), "invalid state signature")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestSendServiceUpdateToCluster_FiltersOnCapability(t *testing.T) {
|
||||||
|
tokenStore, err := NewOneTimeTokenStore(context.Background(), time.Hour, 10*time.Minute, 100)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
s := &ProxyServiceServer{
|
||||||
|
tokenStore: tokenStore,
|
||||||
|
}
|
||||||
|
s.SetProxyController(newTestProxyController())
|
||||||
|
|
||||||
|
const cluster = "proxy.example.com"
|
||||||
|
|
||||||
|
// Proxy A supports custom ports.
|
||||||
|
chA := registerFakeProxyWithCaps(s, "proxy-a", cluster, &proto.ProxyCapabilities{SupportsCustomPorts: ptr(true)})
|
||||||
|
// Proxy B does NOT support custom ports (shared cloud proxy).
|
||||||
|
chB := registerFakeProxyWithCaps(s, "proxy-b", cluster, &proto.ProxyCapabilities{SupportsCustomPorts: ptr(false)})
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
// TLS passthrough works on all proxies regardless of custom port support.
|
||||||
|
tlsMapping := &proto.ProxyMapping{
|
||||||
|
Type: proto.ProxyMappingUpdateType_UPDATE_TYPE_CREATED,
|
||||||
|
Id: "service-tls",
|
||||||
|
AccountId: "account-1",
|
||||||
|
Domain: "db.example.com",
|
||||||
|
Mode: "tls",
|
||||||
|
ListenPort: 8443,
|
||||||
|
Path: []*proto.PathMapping{{Target: "10.0.0.5:5432"}},
|
||||||
|
}
|
||||||
|
|
||||||
|
s.SendServiceUpdateToCluster(ctx, tlsMapping, cluster)
|
||||||
|
|
||||||
|
msgA := drainMapping(chA)
|
||||||
|
msgB := drainMapping(chB)
|
||||||
|
assert.NotNil(t, msgA, "proxy-a should receive TLS mapping")
|
||||||
|
assert.NotNil(t, msgB, "proxy-b should receive TLS mapping (passthrough works on all proxies)")
|
||||||
|
|
||||||
|
// Send an HTTP mapping: both should receive it.
|
||||||
|
httpMapping := &proto.ProxyMapping{
|
||||||
|
Type: proto.ProxyMappingUpdateType_UPDATE_TYPE_CREATED,
|
||||||
|
Id: "service-http",
|
||||||
|
AccountId: "account-1",
|
||||||
|
Domain: "app.example.com",
|
||||||
|
Path: []*proto.PathMapping{{Path: "/", Target: "http://10.0.0.1:80"}},
|
||||||
|
}
|
||||||
|
|
||||||
|
s.SendServiceUpdateToCluster(ctx, httpMapping, cluster)
|
||||||
|
|
||||||
|
msgA = drainMapping(chA)
|
||||||
|
msgB = drainMapping(chB)
|
||||||
|
assert.NotNil(t, msgA, "proxy-a should receive HTTP mapping")
|
||||||
|
assert.NotNil(t, msgB, "proxy-b should receive HTTP mapping")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSendServiceUpdateToCluster_TLSNotFiltered(t *testing.T) {
|
||||||
|
tokenStore, err := NewOneTimeTokenStore(context.Background(), time.Hour, 10*time.Minute, 100)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
s := &ProxyServiceServer{
|
||||||
|
tokenStore: tokenStore,
|
||||||
|
}
|
||||||
|
s.SetProxyController(newTestProxyController())
|
||||||
|
|
||||||
|
const cluster = "proxy.example.com"
|
||||||
|
|
||||||
|
chShared := registerFakeProxyWithCaps(s, "proxy-shared", cluster, &proto.ProxyCapabilities{SupportsCustomPorts: ptr(false)})
|
||||||
|
|
||||||
|
tlsMapping := &proto.ProxyMapping{
|
||||||
|
Type: proto.ProxyMappingUpdateType_UPDATE_TYPE_CREATED,
|
||||||
|
Id: "service-tls",
|
||||||
|
AccountId: "account-1",
|
||||||
|
Domain: "db.example.com",
|
||||||
|
Mode: "tls",
|
||||||
|
Path: []*proto.PathMapping{{Target: "10.0.0.5:5432"}},
|
||||||
|
}
|
||||||
|
|
||||||
|
s.SendServiceUpdateToCluster(context.Background(), tlsMapping, cluster)
|
||||||
|
|
||||||
|
msg := drainMapping(chShared)
|
||||||
|
assert.NotNil(t, msg, "shared proxy should receive TLS mapping even without custom port support")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestServiceModifyNotifications exercises every possible modification
|
||||||
|
// scenario for an existing service, verifying the correct update types
|
||||||
|
// reach the correct clusters.
|
||||||
|
func TestServiceModifyNotifications(t *testing.T) {
|
||||||
|
tokenStore, err := NewOneTimeTokenStore(context.Background(), time.Hour, 10*time.Minute, 100)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
newServer := func() (*ProxyServiceServer, map[string]chan *proto.GetMappingUpdateResponse) {
|
||||||
|
s := &ProxyServiceServer{
|
||||||
|
tokenStore: tokenStore,
|
||||||
|
}
|
||||||
|
s.SetProxyController(newTestProxyController())
|
||||||
|
chs := map[string]chan *proto.GetMappingUpdateResponse{
|
||||||
|
"cluster-a": registerFakeProxyWithCaps(s, "proxy-a", "cluster-a", &proto.ProxyCapabilities{SupportsCustomPorts: ptr(true)}),
|
||||||
|
"cluster-b": registerFakeProxyWithCaps(s, "proxy-b", "cluster-b", &proto.ProxyCapabilities{SupportsCustomPorts: ptr(true)}),
|
||||||
|
}
|
||||||
|
return s, chs
|
||||||
|
}
|
||||||
|
|
||||||
|
httpMapping := func(updateType proto.ProxyMappingUpdateType) *proto.ProxyMapping {
|
||||||
|
return &proto.ProxyMapping{
|
||||||
|
Type: updateType,
|
||||||
|
Id: "svc-1",
|
||||||
|
AccountId: "acct-1",
|
||||||
|
Domain: "app.example.com",
|
||||||
|
Path: []*proto.PathMapping{{Path: "/", Target: "http://10.0.0.1:8080"}},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
tlsOnlyMapping := func(updateType proto.ProxyMappingUpdateType) *proto.ProxyMapping {
|
||||||
|
return &proto.ProxyMapping{
|
||||||
|
Type: updateType,
|
||||||
|
Id: "svc-1",
|
||||||
|
AccountId: "acct-1",
|
||||||
|
Domain: "app.example.com",
|
||||||
|
Mode: "tls",
|
||||||
|
ListenPort: 8443,
|
||||||
|
Path: []*proto.PathMapping{{Target: "10.0.0.1:443"}},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
t.Run("targets changed sends MODIFIED to same cluster", func(t *testing.T) {
|
||||||
|
s, chs := newServer()
|
||||||
|
s.SendServiceUpdateToCluster(ctx, httpMapping(proto.ProxyMappingUpdateType_UPDATE_TYPE_MODIFIED), "cluster-a")
|
||||||
|
|
||||||
|
msg := drainMapping(chs["cluster-a"])
|
||||||
|
require.NotNil(t, msg, "cluster-a should receive update")
|
||||||
|
assert.Equal(t, proto.ProxyMappingUpdateType_UPDATE_TYPE_MODIFIED, msg.Type)
|
||||||
|
assert.NotEmpty(t, msg.AuthToken, "MODIFIED should include token")
|
||||||
|
assert.True(t, drainEmpty(chs["cluster-b"]), "cluster-b should not receive update")
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("auth config changed sends MODIFIED", func(t *testing.T) {
|
||||||
|
s, chs := newServer()
|
||||||
|
mapping := httpMapping(proto.ProxyMappingUpdateType_UPDATE_TYPE_MODIFIED)
|
||||||
|
mapping.Auth = &proto.Authentication{Password: true, Pin: true}
|
||||||
|
s.SendServiceUpdateToCluster(ctx, mapping, "cluster-a")
|
||||||
|
|
||||||
|
msg := drainMapping(chs["cluster-a"])
|
||||||
|
require.NotNil(t, msg)
|
||||||
|
assert.Equal(t, proto.ProxyMappingUpdateType_UPDATE_TYPE_MODIFIED, msg.Type)
|
||||||
|
assert.True(t, msg.Auth.Password)
|
||||||
|
assert.True(t, msg.Auth.Pin)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("HTTP to TLS transition sends MODIFIED with TLS config", func(t *testing.T) {
|
||||||
|
s, chs := newServer()
|
||||||
|
s.SendServiceUpdateToCluster(ctx, tlsOnlyMapping(proto.ProxyMappingUpdateType_UPDATE_TYPE_MODIFIED), "cluster-a")
|
||||||
|
|
||||||
|
msg := drainMapping(chs["cluster-a"])
|
||||||
|
require.NotNil(t, msg)
|
||||||
|
assert.Equal(t, proto.ProxyMappingUpdateType_UPDATE_TYPE_MODIFIED, msg.Type)
|
||||||
|
assert.Equal(t, "tls", msg.Mode, "mode should be tls")
|
||||||
|
assert.Equal(t, int32(8443), msg.ListenPort)
|
||||||
|
assert.Len(t, msg.Path, 1, "should have one path entry with target address")
|
||||||
|
assert.Equal(t, "10.0.0.1:443", msg.Path[0].Target)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("TLS to HTTP transition sends MODIFIED without TLS", func(t *testing.T) {
|
||||||
|
s, chs := newServer()
|
||||||
|
s.SendServiceUpdateToCluster(ctx, httpMapping(proto.ProxyMappingUpdateType_UPDATE_TYPE_MODIFIED), "cluster-a")
|
||||||
|
|
||||||
|
msg := drainMapping(chs["cluster-a"])
|
||||||
|
require.NotNil(t, msg)
|
||||||
|
assert.Equal(t, proto.ProxyMappingUpdateType_UPDATE_TYPE_MODIFIED, msg.Type)
|
||||||
|
assert.Empty(t, msg.Mode, "mode should be empty for HTTP")
|
||||||
|
assert.True(t, len(msg.Path) > 0)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("TLS port changed sends MODIFIED with new port", func(t *testing.T) {
|
||||||
|
s, chs := newServer()
|
||||||
|
mapping := tlsOnlyMapping(proto.ProxyMappingUpdateType_UPDATE_TYPE_MODIFIED)
|
||||||
|
mapping.ListenPort = 9443
|
||||||
|
s.SendServiceUpdateToCluster(ctx, mapping, "cluster-a")
|
||||||
|
|
||||||
|
msg := drainMapping(chs["cluster-a"])
|
||||||
|
require.NotNil(t, msg)
|
||||||
|
assert.Equal(t, int32(9443), msg.ListenPort)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("disable sends REMOVED to cluster", func(t *testing.T) {
|
||||||
|
s, chs := newServer()
|
||||||
|
// Manager sends Delete when service is disabled
|
||||||
|
s.SendServiceUpdateToCluster(ctx, httpMapping(proto.ProxyMappingUpdateType_UPDATE_TYPE_REMOVED), "cluster-a")
|
||||||
|
|
||||||
|
msg := drainMapping(chs["cluster-a"])
|
||||||
|
require.NotNil(t, msg)
|
||||||
|
assert.Equal(t, proto.ProxyMappingUpdateType_UPDATE_TYPE_REMOVED, msg.Type)
|
||||||
|
assert.Empty(t, msg.AuthToken, "DELETE should not have token")
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("enable sends CREATED to cluster", func(t *testing.T) {
|
||||||
|
s, chs := newServer()
|
||||||
|
s.SendServiceUpdateToCluster(ctx, httpMapping(proto.ProxyMappingUpdateType_UPDATE_TYPE_CREATED), "cluster-a")
|
||||||
|
|
||||||
|
msg := drainMapping(chs["cluster-a"])
|
||||||
|
require.NotNil(t, msg)
|
||||||
|
assert.Equal(t, proto.ProxyMappingUpdateType_UPDATE_TYPE_CREATED, msg.Type)
|
||||||
|
assert.NotEmpty(t, msg.AuthToken)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("domain change with cluster change sends DELETE to old CREATE to new", func(t *testing.T) {
|
||||||
|
s, chs := newServer()
|
||||||
|
// This is the pattern the manager produces:
|
||||||
|
// 1. DELETE on old cluster
|
||||||
|
s.SendServiceUpdateToCluster(ctx, httpMapping(proto.ProxyMappingUpdateType_UPDATE_TYPE_REMOVED), "cluster-a")
|
||||||
|
// 2. CREATE on new cluster
|
||||||
|
s.SendServiceUpdateToCluster(ctx, httpMapping(proto.ProxyMappingUpdateType_UPDATE_TYPE_CREATED), "cluster-b")
|
||||||
|
|
||||||
|
msgA := drainMapping(chs["cluster-a"])
|
||||||
|
require.NotNil(t, msgA, "old cluster should receive DELETE")
|
||||||
|
assert.Equal(t, proto.ProxyMappingUpdateType_UPDATE_TYPE_REMOVED, msgA.Type)
|
||||||
|
|
||||||
|
msgB := drainMapping(chs["cluster-b"])
|
||||||
|
require.NotNil(t, msgB, "new cluster should receive CREATE")
|
||||||
|
assert.Equal(t, proto.ProxyMappingUpdateType_UPDATE_TYPE_CREATED, msgB.Type)
|
||||||
|
assert.NotEmpty(t, msgB.AuthToken)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("domain change same cluster sends DELETE then CREATE", func(t *testing.T) {
|
||||||
|
s, chs := newServer()
|
||||||
|
// Domain changes within same cluster: manager sends DELETE (old domain) + CREATE (new domain).
|
||||||
|
s.SendServiceUpdateToCluster(ctx, httpMapping(proto.ProxyMappingUpdateType_UPDATE_TYPE_REMOVED), "cluster-a")
|
||||||
|
s.SendServiceUpdateToCluster(ctx, httpMapping(proto.ProxyMappingUpdateType_UPDATE_TYPE_CREATED), "cluster-a")
|
||||||
|
|
||||||
|
msgDel := drainMapping(chs["cluster-a"])
|
||||||
|
require.NotNil(t, msgDel, "same cluster should receive DELETE")
|
||||||
|
assert.Equal(t, proto.ProxyMappingUpdateType_UPDATE_TYPE_REMOVED, msgDel.Type)
|
||||||
|
|
||||||
|
msgCreate := drainMapping(chs["cluster-a"])
|
||||||
|
require.NotNil(t, msgCreate, "same cluster should receive CREATE")
|
||||||
|
assert.Equal(t, proto.ProxyMappingUpdateType_UPDATE_TYPE_CREATED, msgCreate.Type)
|
||||||
|
assert.NotEmpty(t, msgCreate.AuthToken)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("TLS passthrough sent to all proxies", func(t *testing.T) {
|
||||||
|
s := &ProxyServiceServer{
|
||||||
|
tokenStore: tokenStore,
|
||||||
|
}
|
||||||
|
s.SetProxyController(newTestProxyController())
|
||||||
|
const cluster = "proxy.example.com"
|
||||||
|
chModern := registerFakeProxyWithCaps(s, "modern", cluster, &proto.ProxyCapabilities{SupportsCustomPorts: ptr(true)})
|
||||||
|
chLegacy := registerFakeProxyWithCaps(s, "legacy", cluster, &proto.ProxyCapabilities{SupportsCustomPorts: ptr(false)})
|
||||||
|
|
||||||
|
// TLS passthrough works on all proxies regardless of custom port support
|
||||||
|
s.SendServiceUpdateToCluster(ctx, tlsOnlyMapping(proto.ProxyMappingUpdateType_UPDATE_TYPE_MODIFIED), cluster)
|
||||||
|
|
||||||
|
msgModern := drainMapping(chModern)
|
||||||
|
require.NotNil(t, msgModern, "modern proxy receives TLS update")
|
||||||
|
assert.Equal(t, "tls", msgModern.Mode)
|
||||||
|
|
||||||
|
msgLegacy := drainMapping(chLegacy)
|
||||||
|
assert.NotNil(t, msgLegacy, "legacy proxy should also receive TLS passthrough")
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("TLS on default port NOT filtered for legacy proxy", func(t *testing.T) {
|
||||||
|
s := &ProxyServiceServer{
|
||||||
|
tokenStore: tokenStore,
|
||||||
|
}
|
||||||
|
s.SetProxyController(newTestProxyController())
|
||||||
|
const cluster = "proxy.example.com"
|
||||||
|
chLegacy := registerFakeProxyWithCaps(s, "legacy", cluster, &proto.ProxyCapabilities{SupportsCustomPorts: ptr(false)})
|
||||||
|
|
||||||
|
mapping := tlsOnlyMapping(proto.ProxyMappingUpdateType_UPDATE_TYPE_MODIFIED)
|
||||||
|
mapping.ListenPort = 0 // default port
|
||||||
|
s.SendServiceUpdateToCluster(ctx, mapping, cluster)
|
||||||
|
|
||||||
|
msgLegacy := drainMapping(chLegacy)
|
||||||
|
assert.NotNil(t, msgLegacy, "legacy proxy should receive TLS on default port")
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("passthrough and rewrite flags propagated", func(t *testing.T) {
|
||||||
|
s, chs := newServer()
|
||||||
|
mapping := httpMapping(proto.ProxyMappingUpdateType_UPDATE_TYPE_MODIFIED)
|
||||||
|
mapping.PassHostHeader = true
|
||||||
|
mapping.RewriteRedirects = true
|
||||||
|
s.SendServiceUpdateToCluster(ctx, mapping, "cluster-a")
|
||||||
|
|
||||||
|
msg := drainMapping(chs["cluster-a"])
|
||||||
|
require.NotNil(t, msg)
|
||||||
|
assert.True(t, msg.PassHostHeader)
|
||||||
|
assert.True(t, msg.RewriteRedirects)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("multiple paths propagated in MODIFIED", func(t *testing.T) {
|
||||||
|
s, chs := newServer()
|
||||||
|
mapping := &proto.ProxyMapping{
|
||||||
|
Type: proto.ProxyMappingUpdateType_UPDATE_TYPE_MODIFIED,
|
||||||
|
Id: "svc-multi",
|
||||||
|
AccountId: "acct-1",
|
||||||
|
Domain: "multi.example.com",
|
||||||
|
Path: []*proto.PathMapping{
|
||||||
|
{Path: "/", Target: "http://10.0.0.1:8080"},
|
||||||
|
{Path: "/api", Target: "http://10.0.0.2:9090"},
|
||||||
|
{Path: "/ws", Target: "http://10.0.0.3:3000"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
s.SendServiceUpdateToCluster(ctx, mapping, "cluster-a")
|
||||||
|
|
||||||
|
msg := drainMapping(chs["cluster-a"])
|
||||||
|
require.NotNil(t, msg)
|
||||||
|
require.Len(t, msg.Path, 3, "all paths should be present")
|
||||||
|
assert.Equal(t, "/", msg.Path[0].Path)
|
||||||
|
assert.Equal(t, "/api", msg.Path[1].Path)
|
||||||
|
assert.Equal(t, "/ws", msg.Path[2].Path)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
@@ -174,9 +174,8 @@ func NewAPIHandler(ctx context.Context, accountManager account.Manager, networks
|
|||||||
instance.AddEndpoints(instanceManager, router)
|
instance.AddEndpoints(instanceManager, router)
|
||||||
instance.AddVersionEndpoint(instanceManager, router)
|
instance.AddVersionEndpoint(instanceManager, router)
|
||||||
if serviceManager != nil && reverseProxyDomainManager != nil {
|
if serviceManager != nil && reverseProxyDomainManager != nil {
|
||||||
reverseproxymanager.RegisterEndpoints(serviceManager, *reverseProxyDomainManager, reverseProxyAccessLogsManager, router)
|
reverseproxymanager.RegisterEndpoints(serviceManager, *reverseProxyDomainManager, reverseProxyAccessLogsManager, permissionsManager, router)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Register OAuth callback handler for proxy authentication
|
// Register OAuth callback handler for proxy authentication
|
||||||
if proxyGRPCServer != nil {
|
if proxyGRPCServer != nil {
|
||||||
oauthHandler := proxy.NewAuthCallbackHandler(proxyGRPCServer, trustedHTTPProxies)
|
oauthHandler := proxy.NewAuthCallbackHandler(proxyGRPCServer, trustedHTTPProxies)
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import (
|
|||||||
"go.opentelemetry.io/otel/metric/noop"
|
"go.opentelemetry.io/otel/metric/noop"
|
||||||
|
|
||||||
"github.com/netbirdio/management-integrations/integrations"
|
"github.com/netbirdio/management-integrations/integrations"
|
||||||
|
|
||||||
accesslogsmanager "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/accesslogs/manager"
|
accesslogsmanager "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/accesslogs/manager"
|
||||||
"github.com/netbirdio/netbird/management/internals/modules/reverseproxy/domain/manager"
|
"github.com/netbirdio/netbird/management/internals/modules/reverseproxy/domain/manager"
|
||||||
proxymanager "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/proxy/manager"
|
proxymanager "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/proxy/manager"
|
||||||
@@ -113,6 +114,7 @@ func BuildApiBlackBoxWithDBState(t testing_tools.TB, sqlFile string, expectedPee
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Failed to create proxy controller: %v", err)
|
t.Fatalf("Failed to create proxy controller: %v", err)
|
||||||
}
|
}
|
||||||
|
domainManager.SetClusterCapabilities(serviceProxyController)
|
||||||
serviceManager := reverseproxymanager.NewManager(store, am, permissionsManager, serviceProxyController, domainManager)
|
serviceManager := reverseproxymanager.NewManager(store, am, permissionsManager, serviceProxyController, domainManager)
|
||||||
proxyServiceServer.SetServiceManager(serviceManager)
|
proxyServiceServer.SetServiceManager(serviceManager)
|
||||||
am.SetServiceManager(serviceManager)
|
am.SetServiceManager(serviceManager)
|
||||||
|
|||||||
@@ -219,7 +219,7 @@ func (w *Worker) generateProperties(ctx context.Context) properties {
|
|||||||
servicesStatusActive int
|
servicesStatusActive int
|
||||||
servicesStatusPending int
|
servicesStatusPending int
|
||||||
servicesStatusError int
|
servicesStatusError int
|
||||||
servicesTargetType map[string]int
|
servicesTargetType map[rpservice.TargetType]int
|
||||||
servicesAuthPassword int
|
servicesAuthPassword int
|
||||||
servicesAuthPin int
|
servicesAuthPin int
|
||||||
servicesAuthOIDC int
|
servicesAuthOIDC int
|
||||||
@@ -232,7 +232,7 @@ func (w *Worker) generateProperties(ctx context.Context) properties {
|
|||||||
rulesDirection = make(map[string]int)
|
rulesDirection = make(map[string]int)
|
||||||
activeUsersLastDay = make(map[string]struct{})
|
activeUsersLastDay = make(map[string]struct{})
|
||||||
embeddedIdpTypes = make(map[string]int)
|
embeddedIdpTypes = make(map[string]int)
|
||||||
servicesTargetType = make(map[string]int)
|
servicesTargetType = make(map[rpservice.TargetType]int)
|
||||||
uptime = time.Since(w.startupTime).Seconds()
|
uptime = time.Since(w.startupTime).Seconds()
|
||||||
connections := w.connManager.GetAllConnectedPeers()
|
connections := w.connManager.GetAllConnectedPeers()
|
||||||
version = nbversion.NetbirdVersion()
|
version = nbversion.NetbirdVersion()
|
||||||
@@ -434,7 +434,7 @@ func (w *Worker) generateProperties(ctx context.Context) properties {
|
|||||||
metricsProperties["custom_domains_validated"] = customDomainsValidated
|
metricsProperties["custom_domains_validated"] = customDomainsValidated
|
||||||
|
|
||||||
for targetType, count := range servicesTargetType {
|
for targetType, count := range servicesTargetType {
|
||||||
metricsProperties["services_target_type_"+targetType] = count
|
metricsProperties["services_target_type_"+string(targetType)] = count
|
||||||
}
|
}
|
||||||
|
|
||||||
for idpType, count := range embeddedIdpTypes {
|
for idpType, count := range embeddedIdpTypes {
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ import (
|
|||||||
nbdns "github.com/netbirdio/netbird/dns"
|
nbdns "github.com/netbirdio/netbird/dns"
|
||||||
"github.com/netbirdio/netbird/management/internals/modules/reverseproxy/accesslogs"
|
"github.com/netbirdio/netbird/management/internals/modules/reverseproxy/accesslogs"
|
||||||
"github.com/netbirdio/netbird/management/internals/modules/reverseproxy/domain"
|
"github.com/netbirdio/netbird/management/internals/modules/reverseproxy/domain"
|
||||||
|
|
||||||
"github.com/netbirdio/netbird/management/internals/modules/reverseproxy/proxy"
|
"github.com/netbirdio/netbird/management/internals/modules/reverseproxy/proxy"
|
||||||
rpservice "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/service"
|
rpservice "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/service"
|
||||||
"github.com/netbirdio/netbird/management/internals/modules/zones"
|
"github.com/netbirdio/netbird/management/internals/modules/zones"
|
||||||
@@ -4996,6 +4997,7 @@ func (s *SqlStore) GetServiceByDomain(ctx context.Context, domain string) (*rpse
|
|||||||
return service, nil
|
return service, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
func (s *SqlStore) GetServices(ctx context.Context, lockStrength LockingStrength) ([]*rpservice.Service, error) {
|
func (s *SqlStore) GetServices(ctx context.Context, lockStrength LockingStrength) ([]*rpservice.Service, error) {
|
||||||
tx := s.db.Preload("Targets")
|
tx := s.db.Preload("Targets")
|
||||||
if lockStrength != LockingStrengthNone {
|
if lockStrength != LockingStrengthNone {
|
||||||
@@ -5041,16 +5043,16 @@ func (s *SqlStore) GetAccountServices(ctx context.Context, lockStrength LockingS
|
|||||||
}
|
}
|
||||||
|
|
||||||
// RenewEphemeralService updates the last_renewed_at timestamp for an ephemeral service.
|
// RenewEphemeralService updates the last_renewed_at timestamp for an ephemeral service.
|
||||||
func (s *SqlStore) RenewEphemeralService(ctx context.Context, accountID, peerID, domain string) error {
|
func (s *SqlStore) RenewEphemeralService(ctx context.Context, accountID, peerID, serviceID string) error {
|
||||||
result := s.db.Model(&rpservice.Service{}).
|
result := s.db.Model(&rpservice.Service{}).
|
||||||
Where("account_id = ? AND source_peer = ? AND domain = ? AND source = ?", accountID, peerID, domain, rpservice.SourceEphemeral).
|
Where("id = ? AND account_id = ? AND source_peer = ? AND source = ?", serviceID, accountID, peerID, rpservice.SourceEphemeral).
|
||||||
Update("meta_last_renewed_at", time.Now())
|
Update("meta_last_renewed_at", time.Now())
|
||||||
if result.Error != nil {
|
if result.Error != nil {
|
||||||
log.WithContext(ctx).Errorf("failed to renew ephemeral service: %v", result.Error)
|
log.WithContext(ctx).Errorf("failed to renew ephemeral service: %v", result.Error)
|
||||||
return status.Errorf(status.Internal, "renew ephemeral service")
|
return status.Errorf(status.Internal, "renew ephemeral service")
|
||||||
}
|
}
|
||||||
if result.RowsAffected == 0 {
|
if result.RowsAffected == 0 {
|
||||||
return status.Errorf(status.NotFound, "no active expose session for domain %s", domain)
|
return status.Errorf(status.NotFound, "no active expose session for service %s", serviceID)
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -5133,6 +5135,37 @@ func (s *SqlStore) EphemeralServiceExists(ctx context.Context, lockStrength Lock
|
|||||||
return id != "", nil
|
return id != "", nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetServicesByClusterAndPort returns services matching the given proxy cluster, mode, and listen port.
|
||||||
|
func (s *SqlStore) GetServicesByClusterAndPort(ctx context.Context, lockStrength LockingStrength, proxyCluster string, mode string, listenPort uint16) ([]*rpservice.Service, error) {
|
||||||
|
tx := s.db.WithContext(ctx)
|
||||||
|
if lockStrength != LockingStrengthNone {
|
||||||
|
tx = tx.Clauses(clause.Locking{Strength: string(lockStrength)})
|
||||||
|
}
|
||||||
|
|
||||||
|
var services []*rpservice.Service
|
||||||
|
result := tx.Where("proxy_cluster = ? AND mode = ? AND listen_port = ?", proxyCluster, mode, listenPort).Find(&services)
|
||||||
|
if result.Error != nil {
|
||||||
|
return nil, status.Errorf(status.Internal, "query services by cluster and port")
|
||||||
|
}
|
||||||
|
|
||||||
|
return services, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetServicesByCluster returns all services for the given proxy cluster.
|
||||||
|
func (s *SqlStore) GetServicesByCluster(ctx context.Context, lockStrength LockingStrength, proxyCluster string) ([]*rpservice.Service, error) {
|
||||||
|
tx := s.db.WithContext(ctx)
|
||||||
|
if lockStrength != LockingStrengthNone {
|
||||||
|
tx = tx.Clauses(clause.Locking{Strength: string(lockStrength)})
|
||||||
|
}
|
||||||
|
|
||||||
|
var services []*rpservice.Service
|
||||||
|
result := tx.Where("proxy_cluster = ?", proxyCluster).Find(&services)
|
||||||
|
if result.Error != nil {
|
||||||
|
return nil, status.Errorf(status.Internal, "query services by cluster")
|
||||||
|
}
|
||||||
|
return services, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (s *SqlStore) GetCustomDomain(ctx context.Context, accountID string, domainID string) (*domain.Domain, error) {
|
func (s *SqlStore) GetCustomDomain(ctx context.Context, accountID string, domainID string) (*domain.Domain, error) {
|
||||||
tx := s.db
|
tx := s.db
|
||||||
|
|
||||||
|
|||||||
@@ -261,10 +261,12 @@ type Store interface {
|
|||||||
GetServices(ctx context.Context, lockStrength LockingStrength) ([]*rpservice.Service, error)
|
GetServices(ctx context.Context, lockStrength LockingStrength) ([]*rpservice.Service, error)
|
||||||
GetAccountServices(ctx context.Context, lockStrength LockingStrength, accountID string) ([]*rpservice.Service, error)
|
GetAccountServices(ctx context.Context, lockStrength LockingStrength, accountID string) ([]*rpservice.Service, error)
|
||||||
|
|
||||||
RenewEphemeralService(ctx context.Context, accountID, peerID, domain string) error
|
RenewEphemeralService(ctx context.Context, accountID, peerID, serviceID string) error
|
||||||
GetExpiredEphemeralServices(ctx context.Context, ttl time.Duration, limit int) ([]*rpservice.Service, error)
|
GetExpiredEphemeralServices(ctx context.Context, ttl time.Duration, limit int) ([]*rpservice.Service, error)
|
||||||
CountEphemeralServicesByPeer(ctx context.Context, lockStrength LockingStrength, accountID, peerID string) (int64, error)
|
CountEphemeralServicesByPeer(ctx context.Context, lockStrength LockingStrength, accountID, peerID string) (int64, error)
|
||||||
EphemeralServiceExists(ctx context.Context, lockStrength LockingStrength, accountID, peerID, domain string) (bool, error)
|
EphemeralServiceExists(ctx context.Context, lockStrength LockingStrength, accountID, peerID, domain string) (bool, error)
|
||||||
|
GetServicesByClusterAndPort(ctx context.Context, lockStrength LockingStrength, proxyCluster string, mode string, listenPort uint16) ([]*rpservice.Service, error)
|
||||||
|
GetServicesByCluster(ctx context.Context, lockStrength LockingStrength, proxyCluster string) ([]*rpservice.Service, error)
|
||||||
|
|
||||||
GetCustomDomain(ctx context.Context, accountID string, domainID string) (*domain.Domain, error)
|
GetCustomDomain(ctx context.Context, accountID string, domainID string) (*domain.Domain, error)
|
||||||
ListFreeDomains(ctx context.Context, accountID string) ([]string, error)
|
ListFreeDomains(ctx context.Context, accountID string) ([]string, error)
|
||||||
|
|||||||
@@ -1991,6 +1991,36 @@ func (mr *MockStoreMockRecorder) GetServices(ctx, lockStrength interface{}) *gom
|
|||||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetServices", reflect.TypeOf((*MockStore)(nil).GetServices), ctx, lockStrength)
|
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetServices", reflect.TypeOf((*MockStore)(nil).GetServices), ctx, lockStrength)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetServicesByCluster mocks base method.
|
||||||
|
func (m *MockStore) GetServicesByCluster(ctx context.Context, lockStrength LockingStrength, proxyCluster string) ([]*service.Service, error) {
|
||||||
|
m.ctrl.T.Helper()
|
||||||
|
ret := m.ctrl.Call(m, "GetServicesByCluster", ctx, lockStrength, proxyCluster)
|
||||||
|
ret0, _ := ret[0].([]*service.Service)
|
||||||
|
ret1, _ := ret[1].(error)
|
||||||
|
return ret0, ret1
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetServicesByCluster indicates an expected call of GetServicesByCluster.
|
||||||
|
func (mr *MockStoreMockRecorder) GetServicesByCluster(ctx, lockStrength, proxyCluster interface{}) *gomock.Call {
|
||||||
|
mr.mock.ctrl.T.Helper()
|
||||||
|
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetServicesByCluster", reflect.TypeOf((*MockStore)(nil).GetServicesByCluster), ctx, lockStrength, proxyCluster)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetServicesByClusterAndPort mocks base method.
|
||||||
|
func (m *MockStore) GetServicesByClusterAndPort(ctx context.Context, lockStrength LockingStrength, proxyCluster, mode string, listenPort uint16) ([]*service.Service, error) {
|
||||||
|
m.ctrl.T.Helper()
|
||||||
|
ret := m.ctrl.Call(m, "GetServicesByClusterAndPort", ctx, lockStrength, proxyCluster, mode, listenPort)
|
||||||
|
ret0, _ := ret[0].([]*service.Service)
|
||||||
|
ret1, _ := ret[1].(error)
|
||||||
|
return ret0, ret1
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetServicesByClusterAndPort indicates an expected call of GetServicesByClusterAndPort.
|
||||||
|
func (mr *MockStoreMockRecorder) GetServicesByClusterAndPort(ctx, lockStrength, proxyCluster, mode, listenPort interface{}) *gomock.Call {
|
||||||
|
mr.mock.ctrl.T.Helper()
|
||||||
|
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetServicesByClusterAndPort", reflect.TypeOf((*MockStore)(nil).GetServicesByClusterAndPort), ctx, lockStrength, proxyCluster, mode, listenPort)
|
||||||
|
}
|
||||||
|
|
||||||
// GetSetupKeyByID mocks base method.
|
// GetSetupKeyByID mocks base method.
|
||||||
func (m *MockStore) GetSetupKeyByID(ctx context.Context, lockStrength LockingStrength, accountID, setupKeyID string) (*types2.SetupKey, error) {
|
func (m *MockStore) GetSetupKeyByID(ctx context.Context, lockStrength LockingStrength, accountID, setupKeyID string) (*types2.SetupKey, error) {
|
||||||
m.ctrl.T.Helper()
|
m.ctrl.T.Helper()
|
||||||
@@ -2447,17 +2477,17 @@ func (mr *MockStoreMockRecorder) RemoveResourceFromGroup(ctx, accountId, groupID
|
|||||||
}
|
}
|
||||||
|
|
||||||
// RenewEphemeralService mocks base method.
|
// RenewEphemeralService mocks base method.
|
||||||
func (m *MockStore) RenewEphemeralService(ctx context.Context, accountID, peerID, domain string) error {
|
func (m *MockStore) RenewEphemeralService(ctx context.Context, accountID, peerID, serviceID string) error {
|
||||||
m.ctrl.T.Helper()
|
m.ctrl.T.Helper()
|
||||||
ret := m.ctrl.Call(m, "RenewEphemeralService", ctx, accountID, peerID, domain)
|
ret := m.ctrl.Call(m, "RenewEphemeralService", ctx, accountID, peerID, serviceID)
|
||||||
ret0, _ := ret[0].(error)
|
ret0, _ := ret[0].(error)
|
||||||
return ret0
|
return ret0
|
||||||
}
|
}
|
||||||
|
|
||||||
// RenewEphemeralService indicates an expected call of RenewEphemeralService.
|
// RenewEphemeralService indicates an expected call of RenewEphemeralService.
|
||||||
func (mr *MockStoreMockRecorder) RenewEphemeralService(ctx, accountID, peerID, domain interface{}) *gomock.Call {
|
func (mr *MockStoreMockRecorder) RenewEphemeralService(ctx, accountID, peerID, serviceID interface{}) *gomock.Call {
|
||||||
mr.mock.ctrl.T.Helper()
|
mr.mock.ctrl.T.Helper()
|
||||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "RenewEphemeralService", reflect.TypeOf((*MockStore)(nil).RenewEphemeralService), ctx, accountID, peerID, domain)
|
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "RenewEphemeralService", reflect.TypeOf((*MockStore)(nil).RenewEphemeralService), ctx, accountID, peerID, serviceID)
|
||||||
}
|
}
|
||||||
|
|
||||||
// RevokeProxyAccessToken mocks base method.
|
// RevokeProxyAccessToken mocks base method.
|
||||||
|
|||||||
@@ -907,8 +907,8 @@ func (a *Account) Copy() *Account {
|
|||||||
}
|
}
|
||||||
|
|
||||||
services := []*service.Service{}
|
services := []*service.Service{}
|
||||||
for _, service := range a.Services {
|
for _, svc := range a.Services {
|
||||||
services = append(services, service.Copy())
|
services = append(services, svc.Copy())
|
||||||
}
|
}
|
||||||
|
|
||||||
return &Account{
|
return &Account{
|
||||||
@@ -1605,12 +1605,12 @@ func (a *Account) GetPoliciesForNetworkResource(resourceId string) []*Policy {
|
|||||||
networkResourceGroups := a.getNetworkResourceGroups(resourceId)
|
networkResourceGroups := a.getNetworkResourceGroups(resourceId)
|
||||||
|
|
||||||
for _, policy := range a.Policies {
|
for _, policy := range a.Policies {
|
||||||
if !policy.Enabled {
|
if policy == nil || !policy.Enabled {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, rule := range policy.Rules {
|
for _, rule := range policy.Rules {
|
||||||
if !rule.Enabled {
|
if rule == nil || !rule.Enabled {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1812,15 +1812,18 @@ func (a *Account) InjectProxyPolicies(ctx context.Context) {
|
|||||||
}
|
}
|
||||||
a.injectServiceProxyPolicies(ctx, service, proxyPeersByCluster)
|
a.injectServiceProxyPolicies(ctx, service, proxyPeersByCluster)
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *Account) injectServiceProxyPolicies(ctx context.Context, service *service.Service, proxyPeersByCluster map[string][]*nbpeer.Peer) {
|
func (a *Account) injectServiceProxyPolicies(ctx context.Context, service *service.Service, proxyPeersByCluster map[string][]*nbpeer.Peer) {
|
||||||
|
proxyPeers := proxyPeersByCluster[service.ProxyCluster]
|
||||||
for _, target := range service.Targets {
|
for _, target := range service.Targets {
|
||||||
if !target.Enabled {
|
if !target.Enabled {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
a.injectTargetProxyPolicies(ctx, service, target, proxyPeersByCluster[service.ProxyCluster])
|
a.injectTargetProxyPolicies(ctx, service, target, proxyPeers)
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *Account) injectTargetProxyPolicies(ctx context.Context, service *service.Service, target *service.Target, proxyPeers []*nbpeer.Peer) {
|
func (a *Account) injectTargetProxyPolicies(ctx context.Context, service *service.Service, target *service.Target, proxyPeers []*nbpeer.Peer) {
|
||||||
@@ -1840,13 +1843,13 @@ func (a *Account) injectTargetProxyPolicies(ctx context.Context, service *servic
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *Account) resolveTargetPort(ctx context.Context, target *service.Target) (int, bool) {
|
func (a *Account) resolveTargetPort(ctx context.Context, target *service.Target) (uint16, bool) {
|
||||||
if target.Port != 0 {
|
if target.Port != 0 {
|
||||||
return target.Port, true
|
return target.Port, true
|
||||||
}
|
}
|
||||||
|
|
||||||
switch target.Protocol {
|
switch target.Protocol {
|
||||||
case "https":
|
case "https", "tls":
|
||||||
return 443, true
|
return 443, true
|
||||||
case "http":
|
case "http":
|
||||||
return 80, true
|
return 80, true
|
||||||
@@ -1856,17 +1859,23 @@ func (a *Account) resolveTargetPort(ctx context.Context, target *service.Target)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *Account) createProxyPolicy(service *service.Service, target *service.Target, proxyPeer *nbpeer.Peer, port int, path string) *Policy {
|
func (a *Account) createProxyPolicy(svc *service.Service, target *service.Target, proxyPeer *nbpeer.Peer, port uint16, path string) *Policy {
|
||||||
policyID := fmt.Sprintf("proxy-access-%s-%s-%s", service.ID, proxyPeer.ID, path)
|
policyID := fmt.Sprintf("proxy-access-%s-%s-%s", svc.ID, proxyPeer.ID, path)
|
||||||
|
|
||||||
|
protocol := PolicyRuleProtocolTCP
|
||||||
|
if svc.Mode == service.ModeUDP {
|
||||||
|
protocol = PolicyRuleProtocolUDP
|
||||||
|
}
|
||||||
|
|
||||||
return &Policy{
|
return &Policy{
|
||||||
ID: policyID,
|
ID: policyID,
|
||||||
Name: fmt.Sprintf("Proxy Access to %s", service.Name),
|
Name: fmt.Sprintf("Proxy Access to %s", svc.Name),
|
||||||
Enabled: true,
|
Enabled: true,
|
||||||
Rules: []*PolicyRule{
|
Rules: []*PolicyRule{
|
||||||
{
|
{
|
||||||
ID: policyID,
|
ID: policyID,
|
||||||
PolicyID: policyID,
|
PolicyID: policyID,
|
||||||
Name: fmt.Sprintf("Allow access to %s", service.Name),
|
Name: fmt.Sprintf("Allow access to %s", svc.Name),
|
||||||
Enabled: true,
|
Enabled: true,
|
||||||
SourceResource: Resource{
|
SourceResource: Resource{
|
||||||
ID: proxyPeer.ID,
|
ID: proxyPeer.ID,
|
||||||
@@ -1877,12 +1886,12 @@ func (a *Account) createProxyPolicy(service *service.Service, target *service.Ta
|
|||||||
Type: ResourceType(target.TargetType),
|
Type: ResourceType(target.TargetType),
|
||||||
},
|
},
|
||||||
Bidirectional: false,
|
Bidirectional: false,
|
||||||
Protocol: PolicyRuleProtocolTCP,
|
Protocol: protocol,
|
||||||
Action: PolicyTrafficActionAccept,
|
Action: PolicyTrafficActionAccept,
|
||||||
PortRanges: []RulePortRange{
|
PortRanges: []RulePortRange{
|
||||||
{
|
{
|
||||||
Start: uint16(port),
|
Start: port,
|
||||||
End: uint16(port),
|
End: port,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
+46
-27
@@ -7,6 +7,7 @@ import (
|
|||||||
"os/signal"
|
"os/signal"
|
||||||
"strconv"
|
"strconv"
|
||||||
"syscall"
|
"syscall"
|
||||||
|
"time"
|
||||||
|
|
||||||
log "github.com/sirupsen/logrus"
|
log "github.com/sirupsen/logrus"
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
@@ -34,30 +35,32 @@ var (
|
|||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
logLevel string
|
logLevel string
|
||||||
debugLogs bool
|
debugLogs bool
|
||||||
mgmtAddr string
|
mgmtAddr string
|
||||||
addr string
|
addr string
|
||||||
proxyDomain string
|
proxyDomain string
|
||||||
certDir string
|
defaultDialTimeout time.Duration
|
||||||
acmeCerts bool
|
certDir string
|
||||||
acmeAddr string
|
acmeCerts bool
|
||||||
acmeDir string
|
acmeAddr string
|
||||||
acmeEABKID string
|
acmeDir string
|
||||||
acmeEABHMACKey string
|
acmeEABKID string
|
||||||
acmeChallengeType string
|
acmeEABHMACKey string
|
||||||
debugEndpoint bool
|
acmeChallengeType string
|
||||||
debugEndpointAddr string
|
debugEndpoint bool
|
||||||
healthAddr string
|
debugEndpointAddr string
|
||||||
forwardedProto string
|
healthAddr string
|
||||||
trustedProxies string
|
forwardedProto string
|
||||||
certFile string
|
trustedProxies string
|
||||||
certKeyFile string
|
certFile string
|
||||||
certLockMethod string
|
certKeyFile string
|
||||||
wildcardCertDir string
|
certLockMethod string
|
||||||
wgPort int
|
wildcardCertDir string
|
||||||
proxyProtocol bool
|
wgPort uint16
|
||||||
preSharedKey string
|
proxyProtocol bool
|
||||||
|
preSharedKey string
|
||||||
|
supportsCustomPorts bool
|
||||||
)
|
)
|
||||||
|
|
||||||
var rootCmd = &cobra.Command{
|
var rootCmd = &cobra.Command{
|
||||||
@@ -92,9 +95,11 @@ func init() {
|
|||||||
rootCmd.Flags().StringVar(&certKeyFile, "cert-key-file", envStringOrDefault("NB_PROXY_CERTIFICATE_KEY_FILE", "tls.key"), "TLS certificate key filename within the certificate directory")
|
rootCmd.Flags().StringVar(&certKeyFile, "cert-key-file", envStringOrDefault("NB_PROXY_CERTIFICATE_KEY_FILE", "tls.key"), "TLS certificate key filename within the certificate directory")
|
||||||
rootCmd.Flags().StringVar(&certLockMethod, "cert-lock-method", envStringOrDefault("NB_PROXY_CERT_LOCK_METHOD", "auto"), "Certificate lock method for cross-replica coordination: auto, flock, or k8s-lease")
|
rootCmd.Flags().StringVar(&certLockMethod, "cert-lock-method", envStringOrDefault("NB_PROXY_CERT_LOCK_METHOD", "auto"), "Certificate lock method for cross-replica coordination: auto, flock, or k8s-lease")
|
||||||
rootCmd.Flags().StringVar(&wildcardCertDir, "wildcard-cert-dir", envStringOrDefault("NB_PROXY_WILDCARD_CERT_DIR", ""), "Directory containing wildcard certificate pairs (<name>.crt/<name>.key). Wildcard patterns are extracted from SANs automatically")
|
rootCmd.Flags().StringVar(&wildcardCertDir, "wildcard-cert-dir", envStringOrDefault("NB_PROXY_WILDCARD_CERT_DIR", ""), "Directory containing wildcard certificate pairs (<name>.crt/<name>.key). Wildcard patterns are extracted from SANs automatically")
|
||||||
rootCmd.Flags().IntVar(&wgPort, "wg-port", envIntOrDefault("NB_PROXY_WG_PORT", 0), "WireGuard listen port (0 = random). Fixed port only works with single-account deployments")
|
rootCmd.Flags().Uint16Var(&wgPort, "wg-port", envUint16OrDefault("NB_PROXY_WG_PORT", 0), "WireGuard listen port (0 = random). Fixed port only works with single-account deployments")
|
||||||
rootCmd.Flags().BoolVar(&proxyProtocol, "proxy-protocol", envBoolOrDefault("NB_PROXY_PROXY_PROTOCOL", false), "Enable PROXY protocol on TCP listeners to preserve client IPs behind L4 proxies")
|
rootCmd.Flags().BoolVar(&proxyProtocol, "proxy-protocol", envBoolOrDefault("NB_PROXY_PROXY_PROTOCOL", false), "Enable PROXY protocol on TCP listeners to preserve client IPs behind L4 proxies")
|
||||||
rootCmd.Flags().StringVar(&preSharedKey, "preshared-key", envStringOrDefault("NB_PROXY_PRESHARED_KEY", ""), "Define a pre-shared key for the tunnel between proxy and peers")
|
rootCmd.Flags().StringVar(&preSharedKey, "preshared-key", envStringOrDefault("NB_PROXY_PRESHARED_KEY", ""), "Define a pre-shared key for the tunnel between proxy and peers")
|
||||||
|
rootCmd.Flags().BoolVar(&supportsCustomPorts, "supports-custom-ports", envBoolOrDefault("NB_PROXY_SUPPORTS_CUSTOM_PORTS", true), "Whether the proxy can bind arbitrary ports for UDP/TCP passthrough")
|
||||||
|
rootCmd.Flags().DurationVar(&defaultDialTimeout, "default-dial-timeout", envDurationOrDefault("NB_PROXY_DEFAULT_DIAL_TIMEOUT", 0), "Default backend dial timeout when no per-service timeout is set (e.g. 30s)")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Execute runs the root command.
|
// Execute runs the root command.
|
||||||
@@ -171,6 +176,8 @@ func runServer(cmd *cobra.Command, args []string) error {
|
|||||||
WireguardPort: wgPort,
|
WireguardPort: wgPort,
|
||||||
ProxyProtocol: proxyProtocol,
|
ProxyProtocol: proxyProtocol,
|
||||||
PreSharedKey: preSharedKey,
|
PreSharedKey: preSharedKey,
|
||||||
|
SupportsCustomPorts: supportsCustomPorts,
|
||||||
|
DefaultDialTimeout: defaultDialTimeout,
|
||||||
}
|
}
|
||||||
|
|
||||||
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGTERM, syscall.SIGINT)
|
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGTERM, syscall.SIGINT)
|
||||||
@@ -203,12 +210,24 @@ func envStringOrDefault(key string, def string) string {
|
|||||||
return v
|
return v
|
||||||
}
|
}
|
||||||
|
|
||||||
func envIntOrDefault(key string, def int) int {
|
func envUint16OrDefault(key string, def uint16) uint16 {
|
||||||
v, exists := os.LookupEnv(key)
|
v, exists := os.LookupEnv(key)
|
||||||
if !exists {
|
if !exists {
|
||||||
return def
|
return def
|
||||||
}
|
}
|
||||||
parsed, err := strconv.Atoi(v)
|
parsed, err := strconv.ParseUint(v, 10, 16)
|
||||||
|
if err != nil {
|
||||||
|
return def
|
||||||
|
}
|
||||||
|
return uint16(parsed)
|
||||||
|
}
|
||||||
|
|
||||||
|
func envDurationOrDefault(key string, def time.Duration) time.Duration {
|
||||||
|
v, exists := os.LookupEnv(key)
|
||||||
|
if !exists {
|
||||||
|
return def
|
||||||
|
}
|
||||||
|
parsed, err := time.ParseDuration(v)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return def
|
return def
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -38,11 +38,18 @@ func (m *mockMappingStream) Context() context.Context { return context.Backgroun
|
|||||||
func (m *mockMappingStream) SendMsg(any) error { return nil }
|
func (m *mockMappingStream) SendMsg(any) error { return nil }
|
||||||
func (m *mockMappingStream) RecvMsg(any) error { return nil }
|
func (m *mockMappingStream) RecvMsg(any) error { return nil }
|
||||||
|
|
||||||
|
func closedChan() chan struct{} {
|
||||||
|
ch := make(chan struct{})
|
||||||
|
close(ch)
|
||||||
|
return ch
|
||||||
|
}
|
||||||
|
|
||||||
func TestHandleMappingStream_SyncCompleteFlag(t *testing.T) {
|
func TestHandleMappingStream_SyncCompleteFlag(t *testing.T) {
|
||||||
checker := health.NewChecker(nil, nil)
|
checker := health.NewChecker(nil, nil)
|
||||||
s := &Server{
|
s := &Server{
|
||||||
Logger: log.StandardLogger(),
|
Logger: log.StandardLogger(),
|
||||||
healthChecker: checker,
|
healthChecker: checker,
|
||||||
|
routerReady: closedChan(),
|
||||||
}
|
}
|
||||||
|
|
||||||
stream := &mockMappingStream{
|
stream := &mockMappingStream{
|
||||||
@@ -62,6 +69,7 @@ func TestHandleMappingStream_NoSyncFlagDoesNotMarkDone(t *testing.T) {
|
|||||||
s := &Server{
|
s := &Server{
|
||||||
Logger: log.StandardLogger(),
|
Logger: log.StandardLogger(),
|
||||||
healthChecker: checker,
|
healthChecker: checker,
|
||||||
|
routerReady: closedChan(),
|
||||||
}
|
}
|
||||||
|
|
||||||
stream := &mockMappingStream{
|
stream := &mockMappingStream{
|
||||||
@@ -78,7 +86,8 @@ func TestHandleMappingStream_NoSyncFlagDoesNotMarkDone(t *testing.T) {
|
|||||||
|
|
||||||
func TestHandleMappingStream_NilHealthChecker(t *testing.T) {
|
func TestHandleMappingStream_NilHealthChecker(t *testing.T) {
|
||||||
s := &Server{
|
s := &Server{
|
||||||
Logger: log.StandardLogger(),
|
Logger: log.StandardLogger(),
|
||||||
|
routerReady: closedChan(),
|
||||||
}
|
}
|
||||||
|
|
||||||
stream := &mockMappingStream{
|
stream := &mockMappingStream{
|
||||||
|
|||||||
@@ -6,11 +6,13 @@ import (
|
|||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/rs/xid"
|
||||||
log "github.com/sirupsen/logrus"
|
log "github.com/sirupsen/logrus"
|
||||||
"google.golang.org/grpc"
|
"google.golang.org/grpc"
|
||||||
"google.golang.org/protobuf/types/known/timestamppb"
|
"google.golang.org/protobuf/types/known/timestamppb"
|
||||||
|
|
||||||
"github.com/netbirdio/netbird/proxy/auth"
|
"github.com/netbirdio/netbird/proxy/auth"
|
||||||
|
"github.com/netbirdio/netbird/proxy/internal/types"
|
||||||
"github.com/netbirdio/netbird/shared/management/proto"
|
"github.com/netbirdio/netbird/shared/management/proto"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -19,6 +21,7 @@ const (
|
|||||||
bytesThreshold = 1024 * 1024 * 1024 // Log every 1GB
|
bytesThreshold = 1024 * 1024 * 1024 // Log every 1GB
|
||||||
usageCleanupPeriod = 1 * time.Hour // Clean up stale counters every hour
|
usageCleanupPeriod = 1 * time.Hour // Clean up stale counters every hour
|
||||||
usageInactiveWindow = 24 * time.Hour // Consider domain inactive if no traffic for 24 hours
|
usageInactiveWindow = 24 * time.Hour // Consider domain inactive if no traffic for 24 hours
|
||||||
|
logSendTimeout = 10 * time.Second
|
||||||
)
|
)
|
||||||
|
|
||||||
type domainUsage struct {
|
type domainUsage struct {
|
||||||
@@ -79,22 +82,63 @@ func (l *Logger) Close() {
|
|||||||
|
|
||||||
type logEntry struct {
|
type logEntry struct {
|
||||||
ID string
|
ID string
|
||||||
AccountID string
|
AccountID types.AccountID
|
||||||
ServiceId string
|
ServiceId types.ServiceID
|
||||||
Host string
|
Host string
|
||||||
Path string
|
Path string
|
||||||
DurationMs int64
|
DurationMs int64
|
||||||
Method string
|
Method string
|
||||||
ResponseCode int32
|
ResponseCode int32
|
||||||
SourceIp string
|
SourceIP netip.Addr
|
||||||
AuthMechanism string
|
AuthMechanism string
|
||||||
UserId string
|
UserId string
|
||||||
AuthSuccess bool
|
AuthSuccess bool
|
||||||
BytesUpload int64
|
BytesUpload int64
|
||||||
BytesDownload int64
|
BytesDownload int64
|
||||||
|
Protocol Protocol
|
||||||
}
|
}
|
||||||
|
|
||||||
func (l *Logger) log(ctx context.Context, entry logEntry) {
|
// Protocol identifies the transport protocol of an access log entry.
|
||||||
|
type Protocol string
|
||||||
|
|
||||||
|
const (
|
||||||
|
ProtocolHTTP Protocol = "http"
|
||||||
|
ProtocolTCP Protocol = "tcp"
|
||||||
|
ProtocolUDP Protocol = "udp"
|
||||||
|
ProtocolTLS Protocol = "tls"
|
||||||
|
)
|
||||||
|
|
||||||
|
// L4Entry holds the data for a layer-4 (TCP/UDP) access log entry.
|
||||||
|
type L4Entry struct {
|
||||||
|
AccountID types.AccountID
|
||||||
|
ServiceID types.ServiceID
|
||||||
|
Protocol Protocol
|
||||||
|
Host string // SNI hostname or listen address
|
||||||
|
SourceIP netip.Addr
|
||||||
|
DurationMs int64
|
||||||
|
BytesUpload int64
|
||||||
|
BytesDownload int64
|
||||||
|
}
|
||||||
|
|
||||||
|
// LogL4 sends an access log entry for a layer-4 connection (TCP or UDP).
|
||||||
|
// The call is non-blocking: the gRPC send happens in a background goroutine.
|
||||||
|
func (l *Logger) LogL4(entry L4Entry) {
|
||||||
|
le := logEntry{
|
||||||
|
ID: xid.New().String(),
|
||||||
|
AccountID: entry.AccountID,
|
||||||
|
ServiceId: entry.ServiceID,
|
||||||
|
Protocol: entry.Protocol,
|
||||||
|
Host: entry.Host,
|
||||||
|
SourceIP: entry.SourceIP,
|
||||||
|
DurationMs: entry.DurationMs,
|
||||||
|
BytesUpload: entry.BytesUpload,
|
||||||
|
BytesDownload: entry.BytesDownload,
|
||||||
|
}
|
||||||
|
l.log(le)
|
||||||
|
l.trackUsage(entry.Host, entry.BytesUpload+entry.BytesDownload)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *Logger) log(entry logEntry) {
|
||||||
// Fire off the log request in a separate routine.
|
// Fire off the log request in a separate routine.
|
||||||
// This increases the possibility of losing a log message
|
// This increases the possibility of losing a log message
|
||||||
// (although it should still get logged in the event of an error),
|
// (although it should still get logged in the event of an error),
|
||||||
@@ -105,31 +149,37 @@ func (l *Logger) log(ctx context.Context, entry logEntry) {
|
|||||||
// allow for resolving that on the server.
|
// allow for resolving that on the server.
|
||||||
now := timestamppb.Now() // Grab the timestamp before launching the goroutine to try to prevent weird timing issues. This is probably unnecessary.
|
now := timestamppb.Now() // Grab the timestamp before launching the goroutine to try to prevent weird timing issues. This is probably unnecessary.
|
||||||
go func() {
|
go func() {
|
||||||
logCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
logCtx, cancel := context.WithTimeout(context.Background(), logSendTimeout)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
if entry.AuthMechanism != auth.MethodOIDC.String() {
|
if entry.AuthMechanism != auth.MethodOIDC.String() {
|
||||||
entry.UserId = ""
|
entry.UserId = ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var sourceIP string
|
||||||
|
if entry.SourceIP.IsValid() {
|
||||||
|
sourceIP = entry.SourceIP.String()
|
||||||
|
}
|
||||||
|
|
||||||
if _, err := l.client.SendAccessLog(logCtx, &proto.SendAccessLogRequest{
|
if _, err := l.client.SendAccessLog(logCtx, &proto.SendAccessLogRequest{
|
||||||
Log: &proto.AccessLog{
|
Log: &proto.AccessLog{
|
||||||
LogId: entry.ID,
|
LogId: entry.ID,
|
||||||
AccountId: entry.AccountID,
|
AccountId: string(entry.AccountID),
|
||||||
Timestamp: now,
|
Timestamp: now,
|
||||||
ServiceId: entry.ServiceId,
|
ServiceId: string(entry.ServiceId),
|
||||||
Host: entry.Host,
|
Host: entry.Host,
|
||||||
Path: entry.Path,
|
Path: entry.Path,
|
||||||
DurationMs: entry.DurationMs,
|
DurationMs: entry.DurationMs,
|
||||||
Method: entry.Method,
|
Method: entry.Method,
|
||||||
ResponseCode: entry.ResponseCode,
|
ResponseCode: entry.ResponseCode,
|
||||||
SourceIp: entry.SourceIp,
|
SourceIp: sourceIP,
|
||||||
AuthMechanism: entry.AuthMechanism,
|
AuthMechanism: entry.AuthMechanism,
|
||||||
UserId: entry.UserId,
|
UserId: entry.UserId,
|
||||||
AuthSuccess: entry.AuthSuccess,
|
AuthSuccess: entry.AuthSuccess,
|
||||||
BytesUpload: entry.BytesUpload,
|
BytesUpload: entry.BytesUpload,
|
||||||
BytesDownload: entry.BytesDownload,
|
BytesDownload: entry.BytesDownload,
|
||||||
|
Protocol: string(entry.Protocol),
|
||||||
},
|
},
|
||||||
}); err != nil {
|
}); err != nil {
|
||||||
// If it fails to send on the gRPC connection, then at least log it to the error log.
|
|
||||||
l.logger.WithFields(log.Fields{
|
l.logger.WithFields(log.Fields{
|
||||||
"service_id": entry.ServiceId,
|
"service_id": entry.ServiceId,
|
||||||
"host": entry.Host,
|
"host": entry.Host,
|
||||||
@@ -137,7 +187,7 @@ func (l *Logger) log(ctx context.Context, entry logEntry) {
|
|||||||
"duration": entry.DurationMs,
|
"duration": entry.DurationMs,
|
||||||
"method": entry.Method,
|
"method": entry.Method,
|
||||||
"response_code": entry.ResponseCode,
|
"response_code": entry.ResponseCode,
|
||||||
"source_ip": entry.SourceIp,
|
"source_ip": sourceIP,
|
||||||
"auth_mechanism": entry.AuthMechanism,
|
"auth_mechanism": entry.AuthMechanism,
|
||||||
"user_id": entry.UserId,
|
"user_id": entry.UserId,
|
||||||
"auth_success": entry.AuthSuccess,
|
"auth_success": entry.AuthSuccess,
|
||||||
|
|||||||
@@ -67,23 +67,24 @@ func (l *Logger) Middleware(next http.Handler) http.Handler {
|
|||||||
entry := logEntry{
|
entry := logEntry{
|
||||||
ID: requestID,
|
ID: requestID,
|
||||||
ServiceId: capturedData.GetServiceId(),
|
ServiceId: capturedData.GetServiceId(),
|
||||||
AccountID: string(capturedData.GetAccountId()),
|
AccountID: capturedData.GetAccountId(),
|
||||||
Host: host,
|
Host: host,
|
||||||
Path: r.URL.Path,
|
Path: r.URL.Path,
|
||||||
DurationMs: duration.Milliseconds(),
|
DurationMs: duration.Milliseconds(),
|
||||||
Method: r.Method,
|
Method: r.Method,
|
||||||
ResponseCode: int32(sw.status),
|
ResponseCode: int32(sw.status),
|
||||||
SourceIp: sourceIp,
|
SourceIP: sourceIp,
|
||||||
AuthMechanism: capturedData.GetAuthMethod(),
|
AuthMechanism: capturedData.GetAuthMethod(),
|
||||||
UserId: capturedData.GetUserID(),
|
UserId: capturedData.GetUserID(),
|
||||||
AuthSuccess: sw.status != http.StatusUnauthorized && sw.status != http.StatusForbidden,
|
AuthSuccess: sw.status != http.StatusUnauthorized && sw.status != http.StatusForbidden,
|
||||||
BytesUpload: bytesUpload,
|
BytesUpload: bytesUpload,
|
||||||
BytesDownload: bytesDownload,
|
BytesDownload: bytesDownload,
|
||||||
|
Protocol: ProtocolHTTP,
|
||||||
}
|
}
|
||||||
l.logger.Debugf("response: request_id=%s method=%s host=%s path=%s status=%d duration=%dms source=%s origin=%s service=%s account=%s",
|
l.logger.Debugf("response: request_id=%s method=%s host=%s path=%s status=%d duration=%dms source=%s origin=%s service=%s account=%s",
|
||||||
requestID, r.Method, host, r.URL.Path, sw.status, duration.Milliseconds(), sourceIp, capturedData.GetOrigin(), capturedData.GetServiceId(), capturedData.GetAccountId())
|
requestID, r.Method, host, r.URL.Path, sw.status, duration.Milliseconds(), sourceIp, capturedData.GetOrigin(), capturedData.GetServiceId(), capturedData.GetAccountId())
|
||||||
|
|
||||||
l.log(r.Context(), entry)
|
l.log(entry)
|
||||||
|
|
||||||
// Track usage for cost monitoring (upload + download) by domain
|
// Track usage for cost monitoring (upload + download) by domain
|
||||||
l.trackUsage(host, bytesUpload+bytesDownload)
|
l.trackUsage(host, bytesUpload+bytesDownload)
|
||||||
|
|||||||
@@ -11,6 +11,6 @@ import (
|
|||||||
// proxy configuration. When trustedProxies is non-empty and the direct
|
// proxy configuration. When trustedProxies is non-empty and the direct
|
||||||
// connection is from a trusted source, it walks X-Forwarded-For right-to-left
|
// connection is from a trusted source, it walks X-Forwarded-For right-to-left
|
||||||
// skipping trusted IPs. Otherwise it returns RemoteAddr directly.
|
// skipping trusted IPs. Otherwise it returns RemoteAddr directly.
|
||||||
func extractSourceIP(r *http.Request, trustedProxies []netip.Prefix) string {
|
func extractSourceIP(r *http.Request, trustedProxies []netip.Prefix) netip.Addr {
|
||||||
return proxy.ResolveClientIP(r.RemoteAddr, r.Header.Get("X-Forwarded-For"), trustedProxies)
|
return proxy.ResolveClientIP(r.RemoteAddr, r.Header.Get("X-Forwarded-For"), trustedProxies)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import (
|
|||||||
"golang.org/x/crypto/acme/autocert"
|
"golang.org/x/crypto/acme/autocert"
|
||||||
|
|
||||||
"github.com/netbirdio/netbird/proxy/internal/certwatch"
|
"github.com/netbirdio/netbird/proxy/internal/certwatch"
|
||||||
|
"github.com/netbirdio/netbird/proxy/internal/types"
|
||||||
"github.com/netbirdio/netbird/shared/management/domain"
|
"github.com/netbirdio/netbird/shared/management/domain"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -30,7 +31,7 @@ import (
|
|||||||
var oidSCTList = asn1.ObjectIdentifier{1, 3, 6, 1, 4, 1, 11129, 2, 4, 2}
|
var oidSCTList = asn1.ObjectIdentifier{1, 3, 6, 1, 4, 1, 11129, 2, 4, 2}
|
||||||
|
|
||||||
type certificateNotifier interface {
|
type certificateNotifier interface {
|
||||||
NotifyCertificateIssued(ctx context.Context, accountID, serviceID, domain string) error
|
NotifyCertificateIssued(ctx context.Context, accountID types.AccountID, serviceID types.ServiceID, domain string) error
|
||||||
}
|
}
|
||||||
|
|
||||||
type domainState int
|
type domainState int
|
||||||
@@ -42,8 +43,8 @@ const (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type domainInfo struct {
|
type domainInfo struct {
|
||||||
accountID string
|
accountID types.AccountID
|
||||||
serviceID string
|
serviceID types.ServiceID
|
||||||
state domainState
|
state domainState
|
||||||
err string
|
err string
|
||||||
}
|
}
|
||||||
@@ -301,7 +302,7 @@ func (mgr *Manager) GetCertificate(hello *tls.ClientHelloInfo) (*tls.Certificate
|
|||||||
// When AddDomain returns true the caller is responsible for sending any
|
// When AddDomain returns true the caller is responsible for sending any
|
||||||
// certificate-ready notifications after the surrounding operation (e.g.
|
// certificate-ready notifications after the surrounding operation (e.g.
|
||||||
// mapping update) has committed successfully.
|
// mapping update) has committed successfully.
|
||||||
func (mgr *Manager) AddDomain(d domain.Domain, accountID, serviceID string) (wildcardHit bool) {
|
func (mgr *Manager) AddDomain(d domain.Domain, accountID types.AccountID, serviceID types.ServiceID) (wildcardHit bool) {
|
||||||
name := d.PunycodeString()
|
name := d.PunycodeString()
|
||||||
if e := mgr.findWildcardEntry(name); e != nil {
|
if e := mgr.findWildcardEntry(name); e != nil {
|
||||||
mgr.mu.Lock()
|
mgr.mu.Lock()
|
||||||
|
|||||||
@@ -17,12 +17,14 @@ import (
|
|||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
|
|
||||||
|
"github.com/netbirdio/netbird/proxy/internal/types"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestHostPolicy(t *testing.T) {
|
func TestHostPolicy(t *testing.T) {
|
||||||
mgr, err := NewManager(ManagerConfig{CertDir: t.TempDir(), ACMEURL: "https://acme.example.com/directory"}, nil, nil, nil)
|
mgr, err := NewManager(ManagerConfig{CertDir: t.TempDir(), ACMEURL: "https://acme.example.com/directory"}, nil, nil, nil)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
mgr.AddDomain("example.com", "acc1", "rp1")
|
mgr.AddDomain("example.com", types.AccountID("acc1"), types.ServiceID("rp1"))
|
||||||
|
|
||||||
// Wait for the background prefetch goroutine to finish so the temp dir
|
// Wait for the background prefetch goroutine to finish so the temp dir
|
||||||
// can be cleaned up without a race.
|
// can be cleaned up without a race.
|
||||||
@@ -92,8 +94,8 @@ func TestDomainStates(t *testing.T) {
|
|||||||
|
|
||||||
// AddDomain starts as pending, then the prefetch goroutine will fail
|
// AddDomain starts as pending, then the prefetch goroutine will fail
|
||||||
// (no real ACME server) and transition to failed.
|
// (no real ACME server) and transition to failed.
|
||||||
mgr.AddDomain("a.example.com", "acc1", "rp1")
|
mgr.AddDomain("a.example.com", types.AccountID("acc1"), types.ServiceID("rp1"))
|
||||||
mgr.AddDomain("b.example.com", "acc1", "rp1")
|
mgr.AddDomain("b.example.com", types.AccountID("acc1"), types.ServiceID("rp1"))
|
||||||
|
|
||||||
assert.Equal(t, 2, mgr.TotalDomains(), "two domains registered")
|
assert.Equal(t, 2, mgr.TotalDomains(), "two domains registered")
|
||||||
|
|
||||||
@@ -209,12 +211,12 @@ func TestWildcardAddDomainSkipsACME(t *testing.T) {
|
|||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
// Add a wildcard-matching domain — should be immediately ready.
|
// Add a wildcard-matching domain — should be immediately ready.
|
||||||
mgr.AddDomain("foo.example.com", "acc1", "svc1")
|
mgr.AddDomain("foo.example.com", types.AccountID("acc1"), types.ServiceID("svc1"))
|
||||||
assert.Equal(t, 0, mgr.PendingCerts(), "wildcard domain should not be pending")
|
assert.Equal(t, 0, mgr.PendingCerts(), "wildcard domain should not be pending")
|
||||||
assert.Equal(t, []string{"foo.example.com"}, mgr.ReadyDomains())
|
assert.Equal(t, []string{"foo.example.com"}, mgr.ReadyDomains())
|
||||||
|
|
||||||
// Add a non-wildcard domain — should go through ACME (pending then failed).
|
// Add a non-wildcard domain — should go through ACME (pending then failed).
|
||||||
mgr.AddDomain("other.net", "acc2", "svc2")
|
mgr.AddDomain("other.net", types.AccountID("acc2"), types.ServiceID("svc2"))
|
||||||
assert.Equal(t, 2, mgr.TotalDomains())
|
assert.Equal(t, 2, mgr.TotalDomains())
|
||||||
|
|
||||||
// Wait for the ACME prefetch to fail.
|
// Wait for the ACME prefetch to fail.
|
||||||
@@ -234,7 +236,7 @@ func TestWildcardGetCertificate(t *testing.T) {
|
|||||||
mgr, err := NewManager(ManagerConfig{CertDir: acmeDir, ACMEURL: "https://acme.example.com/directory", WildcardDir: wcDir}, nil, nil, nil)
|
mgr, err := NewManager(ManagerConfig{CertDir: acmeDir, ACMEURL: "https://acme.example.com/directory", WildcardDir: wcDir}, nil, nil, nil)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
mgr.AddDomain("foo.example.com", "acc1", "svc1")
|
mgr.AddDomain("foo.example.com", types.AccountID("acc1"), types.ServiceID("svc1"))
|
||||||
|
|
||||||
// GetCertificate for a wildcard-matching domain should return the static cert.
|
// GetCertificate for a wildcard-matching domain should return the static cert.
|
||||||
cert, err := mgr.GetCertificate(&tls.ClientHelloInfo{ServerName: "foo.example.com"})
|
cert, err := mgr.GetCertificate(&tls.ClientHelloInfo{ServerName: "foo.example.com"})
|
||||||
@@ -255,8 +257,8 @@ func TestMultipleWildcards(t *testing.T) {
|
|||||||
assert.ElementsMatch(t, []string{"*.example.com", "*.other.org"}, mgr.WildcardPatterns())
|
assert.ElementsMatch(t, []string{"*.example.com", "*.other.org"}, mgr.WildcardPatterns())
|
||||||
|
|
||||||
// Both wildcards should resolve.
|
// Both wildcards should resolve.
|
||||||
mgr.AddDomain("foo.example.com", "acc1", "svc1")
|
mgr.AddDomain("foo.example.com", types.AccountID("acc1"), types.ServiceID("svc1"))
|
||||||
mgr.AddDomain("bar.other.org", "acc2", "svc2")
|
mgr.AddDomain("bar.other.org", types.AccountID("acc2"), types.ServiceID("svc2"))
|
||||||
|
|
||||||
assert.Equal(t, 0, mgr.PendingCerts())
|
assert.Equal(t, 0, mgr.PendingCerts())
|
||||||
assert.ElementsMatch(t, []string{"foo.example.com", "bar.other.org"}, mgr.ReadyDomains())
|
assert.ElementsMatch(t, []string{"foo.example.com", "bar.other.org"}, mgr.ReadyDomains())
|
||||||
@@ -271,7 +273,7 @@ func TestMultipleWildcards(t *testing.T) {
|
|||||||
assert.Contains(t, cert2.Leaf.DNSNames, "*.other.org")
|
assert.Contains(t, cert2.Leaf.DNSNames, "*.other.org")
|
||||||
|
|
||||||
// Non-matching domain falls through to ACME.
|
// Non-matching domain falls through to ACME.
|
||||||
mgr.AddDomain("custom.net", "acc3", "svc3")
|
mgr.AddDomain("custom.net", types.AccountID("acc3"), types.ServiceID("svc3"))
|
||||||
assert.Eventually(t, func() bool {
|
assert.Eventually(t, func() bool {
|
||||||
return mgr.PendingCerts() == 0
|
return mgr.PendingCerts() == 0
|
||||||
}, 30*time.Second, 100*time.Millisecond)
|
}, 30*time.Second, 100*time.Millisecond)
|
||||||
|
|||||||
@@ -44,8 +44,8 @@ type DomainConfig struct {
|
|||||||
Schemes []Scheme
|
Schemes []Scheme
|
||||||
SessionPublicKey ed25519.PublicKey
|
SessionPublicKey ed25519.PublicKey
|
||||||
SessionExpiration time.Duration
|
SessionExpiration time.Duration
|
||||||
AccountID string
|
AccountID types.AccountID
|
||||||
ServiceID string
|
ServiceID types.ServiceID
|
||||||
}
|
}
|
||||||
|
|
||||||
type validationResult struct {
|
type validationResult struct {
|
||||||
@@ -124,7 +124,7 @@ func (mw *Middleware) getDomainConfig(host string) (DomainConfig, bool) {
|
|||||||
|
|
||||||
func setCapturedIDs(r *http.Request, config DomainConfig) {
|
func setCapturedIDs(r *http.Request, config DomainConfig) {
|
||||||
if cd := proxy.CapturedDataFromContext(r.Context()); cd != nil {
|
if cd := proxy.CapturedDataFromContext(r.Context()); cd != nil {
|
||||||
cd.SetAccountId(types.AccountID(config.AccountID))
|
cd.SetAccountId(config.AccountID)
|
||||||
cd.SetServiceId(config.ServiceID)
|
cd.SetServiceId(config.ServiceID)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -275,7 +275,7 @@ func wasCredentialSubmitted(r *http.Request, method auth.Method) bool {
|
|||||||
// session JWTs. Returns an error if the key is missing or invalid.
|
// session JWTs. Returns an error if the key is missing or invalid.
|
||||||
// Callers must not serve the domain if this returns an error, to avoid
|
// Callers must not serve the domain if this returns an error, to avoid
|
||||||
// exposing an unauthenticated service.
|
// exposing an unauthenticated service.
|
||||||
func (mw *Middleware) AddDomain(domain string, schemes []Scheme, publicKeyB64 string, expiration time.Duration, accountID, serviceID string) error {
|
func (mw *Middleware) AddDomain(domain string, schemes []Scheme, publicKeyB64 string, expiration time.Duration, accountID types.AccountID, serviceID types.ServiceID) error {
|
||||||
if len(schemes) == 0 {
|
if len(schemes) == 0 {
|
||||||
mw.domainsMux.Lock()
|
mw.domainsMux.Lock()
|
||||||
defer mw.domainsMux.Unlock()
|
defer mw.domainsMux.Unlock()
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import (
|
|||||||
"google.golang.org/grpc"
|
"google.golang.org/grpc"
|
||||||
|
|
||||||
"github.com/netbirdio/netbird/proxy/auth"
|
"github.com/netbirdio/netbird/proxy/auth"
|
||||||
|
"github.com/netbirdio/netbird/proxy/internal/types"
|
||||||
"github.com/netbirdio/netbird/shared/management/proto"
|
"github.com/netbirdio/netbird/shared/management/proto"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -17,14 +18,14 @@ type urlGenerator interface {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type OIDC struct {
|
type OIDC struct {
|
||||||
id string
|
id types.ServiceID
|
||||||
accountId string
|
accountId types.AccountID
|
||||||
forwardedProto string
|
forwardedProto string
|
||||||
client urlGenerator
|
client urlGenerator
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewOIDC creates a new OIDC authentication scheme
|
// NewOIDC creates a new OIDC authentication scheme
|
||||||
func NewOIDC(client urlGenerator, id, accountId, forwardedProto string) OIDC {
|
func NewOIDC(client urlGenerator, id types.ServiceID, accountId types.AccountID, forwardedProto string) OIDC {
|
||||||
return OIDC{
|
return OIDC{
|
||||||
id: id,
|
id: id,
|
||||||
accountId: accountId,
|
accountId: accountId,
|
||||||
@@ -53,8 +54,8 @@ func (o OIDC) Authenticate(r *http.Request) (string, string, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
res, err := o.client.GetOIDCURL(r.Context(), &proto.GetOIDCURLRequest{
|
res, err := o.client.GetOIDCURL(r.Context(), &proto.GetOIDCURLRequest{
|
||||||
Id: o.id,
|
Id: string(o.id),
|
||||||
AccountId: o.accountId,
|
AccountId: string(o.accountId),
|
||||||
RedirectUrl: redirectURL.String(),
|
RedirectUrl: redirectURL.String(),
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -5,17 +5,19 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"github.com/netbirdio/netbird/proxy/auth"
|
"github.com/netbirdio/netbird/proxy/auth"
|
||||||
|
"github.com/netbirdio/netbird/proxy/internal/types"
|
||||||
"github.com/netbirdio/netbird/shared/management/proto"
|
"github.com/netbirdio/netbird/shared/management/proto"
|
||||||
)
|
)
|
||||||
|
|
||||||
const passwordFormId = "password"
|
const passwordFormId = "password"
|
||||||
|
|
||||||
type Password struct {
|
type Password struct {
|
||||||
id, accountId string
|
id types.ServiceID
|
||||||
client authenticator
|
accountId types.AccountID
|
||||||
|
client authenticator
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewPassword(client authenticator, id, accountId string) Password {
|
func NewPassword(client authenticator, id types.ServiceID, accountId types.AccountID) Password {
|
||||||
return Password{
|
return Password{
|
||||||
id: id,
|
id: id,
|
||||||
accountId: accountId,
|
accountId: accountId,
|
||||||
@@ -41,8 +43,8 @@ func (p Password) Authenticate(r *http.Request) (string, string, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
res, err := p.client.Authenticate(r.Context(), &proto.AuthenticateRequest{
|
res, err := p.client.Authenticate(r.Context(), &proto.AuthenticateRequest{
|
||||||
Id: p.id,
|
Id: string(p.id),
|
||||||
AccountId: p.accountId,
|
AccountId: string(p.accountId),
|
||||||
Request: &proto.AuthenticateRequest_Password{
|
Request: &proto.AuthenticateRequest_Password{
|
||||||
Password: &proto.PasswordRequest{
|
Password: &proto.PasswordRequest{
|
||||||
Password: password,
|
Password: password,
|
||||||
|
|||||||
@@ -5,17 +5,19 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"github.com/netbirdio/netbird/proxy/auth"
|
"github.com/netbirdio/netbird/proxy/auth"
|
||||||
|
"github.com/netbirdio/netbird/proxy/internal/types"
|
||||||
"github.com/netbirdio/netbird/shared/management/proto"
|
"github.com/netbirdio/netbird/shared/management/proto"
|
||||||
)
|
)
|
||||||
|
|
||||||
const pinFormId = "pin"
|
const pinFormId = "pin"
|
||||||
|
|
||||||
type Pin struct {
|
type Pin struct {
|
||||||
id, accountId string
|
id types.ServiceID
|
||||||
client authenticator
|
accountId types.AccountID
|
||||||
|
client authenticator
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewPin(client authenticator, id, accountId string) Pin {
|
func NewPin(client authenticator, id types.ServiceID, accountId types.AccountID) Pin {
|
||||||
return Pin{
|
return Pin{
|
||||||
id: id,
|
id: id,
|
||||||
accountId: accountId,
|
accountId: accountId,
|
||||||
@@ -41,8 +43,8 @@ func (p Pin) Authenticate(r *http.Request) (string, string, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
res, err := p.client.Authenticate(r.Context(), &proto.AuthenticateRequest{
|
res, err := p.client.Authenticate(r.Context(), &proto.AuthenticateRequest{
|
||||||
Id: p.id,
|
Id: string(p.id),
|
||||||
AccountId: p.accountId,
|
AccountId: string(p.accountId),
|
||||||
Request: &proto.AuthenticateRequest_Pin{
|
Request: &proto.AuthenticateRequest_Pin{
|
||||||
Pin: &proto.PinRequest{
|
Pin: &proto.PinRequest{
|
||||||
Pin: pin,
|
Pin: pin,
|
||||||
|
|||||||
@@ -10,10 +10,11 @@ import (
|
|||||||
type trackedConn struct {
|
type trackedConn struct {
|
||||||
net.Conn
|
net.Conn
|
||||||
tracker *HijackTracker
|
tracker *HijackTracker
|
||||||
|
host string
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *trackedConn) Close() error {
|
func (c *trackedConn) Close() error {
|
||||||
c.tracker.conns.Delete(c)
|
c.tracker.remove(c)
|
||||||
return c.Conn.Close()
|
return c.Conn.Close()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -22,6 +23,7 @@ func (c *trackedConn) Close() error {
|
|||||||
type trackingWriter struct {
|
type trackingWriter struct {
|
||||||
http.ResponseWriter
|
http.ResponseWriter
|
||||||
tracker *HijackTracker
|
tracker *HijackTracker
|
||||||
|
host string
|
||||||
}
|
}
|
||||||
|
|
||||||
func (w *trackingWriter) Hijack() (net.Conn, *bufio.ReadWriter, error) {
|
func (w *trackingWriter) Hijack() (net.Conn, *bufio.ReadWriter, error) {
|
||||||
@@ -33,8 +35,8 @@ func (w *trackingWriter) Hijack() (net.Conn, *bufio.ReadWriter, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, nil, err
|
return nil, nil, err
|
||||||
}
|
}
|
||||||
tc := &trackedConn{Conn: conn, tracker: w.tracker}
|
tc := &trackedConn{Conn: conn, tracker: w.tracker, host: w.host}
|
||||||
w.tracker.conns.Store(tc, struct{}{})
|
w.tracker.add(tc)
|
||||||
return tc, buf, nil
|
return tc, buf, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
package conntrack
|
package conntrack
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"net"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
"sync"
|
"sync"
|
||||||
)
|
)
|
||||||
@@ -10,10 +9,14 @@ import (
|
|||||||
// upgrades). http.Server.Shutdown does not close hijacked connections, so
|
// upgrades). http.Server.Shutdown does not close hijacked connections, so
|
||||||
// they must be tracked and closed explicitly during graceful shutdown.
|
// they must be tracked and closed explicitly during graceful shutdown.
|
||||||
//
|
//
|
||||||
|
// Connections are indexed by the request Host so they can be closed
|
||||||
|
// per-domain when a service mapping is removed.
|
||||||
|
//
|
||||||
// Use Middleware as the outermost HTTP middleware to ensure hijacked
|
// Use Middleware as the outermost HTTP middleware to ensure hijacked
|
||||||
// connections are tracked and automatically deregistered when closed.
|
// connections are tracked and automatically deregistered when closed.
|
||||||
type HijackTracker struct {
|
type HijackTracker struct {
|
||||||
conns sync.Map // net.Conn → struct{}
|
mu sync.Mutex
|
||||||
|
conns map[*trackedConn]struct{}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Middleware returns an HTTP middleware that wraps the ResponseWriter so that
|
// Middleware returns an HTTP middleware that wraps the ResponseWriter so that
|
||||||
@@ -21,21 +24,73 @@ type HijackTracker struct {
|
|||||||
// tracker when closed. This should be the outermost middleware in the chain.
|
// tracker when closed. This should be the outermost middleware in the chain.
|
||||||
func (t *HijackTracker) Middleware(next http.Handler) http.Handler {
|
func (t *HijackTracker) Middleware(next http.Handler) http.Handler {
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
next.ServeHTTP(&trackingWriter{ResponseWriter: w, tracker: t}, r)
|
next.ServeHTTP(&trackingWriter{
|
||||||
|
ResponseWriter: w,
|
||||||
|
tracker: t,
|
||||||
|
host: hostOnly(r.Host),
|
||||||
|
}, r)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// CloseAll closes all tracked hijacked connections and returns the number
|
// CloseAll closes all tracked hijacked connections and returns the count.
|
||||||
// of connections that were closed.
|
|
||||||
func (t *HijackTracker) CloseAll() int {
|
func (t *HijackTracker) CloseAll() int {
|
||||||
var count int
|
t.mu.Lock()
|
||||||
t.conns.Range(func(key, _ any) bool {
|
conns := t.conns
|
||||||
if conn, ok := key.(net.Conn); ok {
|
t.conns = nil
|
||||||
_ = conn.Close()
|
t.mu.Unlock()
|
||||||
count++
|
|
||||||
|
for tc := range conns {
|
||||||
|
_ = tc.Conn.Close()
|
||||||
|
}
|
||||||
|
return len(conns)
|
||||||
|
}
|
||||||
|
|
||||||
|
// CloseByHost closes all tracked hijacked connections for the given host
|
||||||
|
// and returns the number of connections closed.
|
||||||
|
func (t *HijackTracker) CloseByHost(host string) int {
|
||||||
|
host = hostOnly(host)
|
||||||
|
t.mu.Lock()
|
||||||
|
var toClose []*trackedConn
|
||||||
|
for tc := range t.conns {
|
||||||
|
if tc.host == host {
|
||||||
|
toClose = append(toClose, tc)
|
||||||
}
|
}
|
||||||
t.conns.Delete(key)
|
}
|
||||||
return true
|
for _, tc := range toClose {
|
||||||
})
|
delete(t.conns, tc)
|
||||||
return count
|
}
|
||||||
|
t.mu.Unlock()
|
||||||
|
|
||||||
|
for _, tc := range toClose {
|
||||||
|
_ = tc.Conn.Close()
|
||||||
|
}
|
||||||
|
return len(toClose)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *HijackTracker) add(tc *trackedConn) {
|
||||||
|
t.mu.Lock()
|
||||||
|
if t.conns == nil {
|
||||||
|
t.conns = make(map[*trackedConn]struct{})
|
||||||
|
}
|
||||||
|
t.conns[tc] = struct{}{}
|
||||||
|
t.mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *HijackTracker) remove(tc *trackedConn) {
|
||||||
|
t.mu.Lock()
|
||||||
|
delete(t.conns, tc)
|
||||||
|
t.mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
// hostOnly strips the port from a host:port string.
|
||||||
|
func hostOnly(hostport string) string {
|
||||||
|
for i := len(hostport) - 1; i >= 0; i-- {
|
||||||
|
if hostport[i] == ':' {
|
||||||
|
return hostport[:i]
|
||||||
|
}
|
||||||
|
if hostport[i] < '0' || hostport[i] > '9' {
|
||||||
|
return hostport
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return hostport
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,142 @@
|
|||||||
|
package conntrack
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
// fakeHijackWriter implements http.ResponseWriter and http.Hijacker for testing.
|
||||||
|
type fakeHijackWriter struct {
|
||||||
|
http.ResponseWriter
|
||||||
|
conn net.Conn
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeHijackWriter) Hijack() (net.Conn, *bufio.ReadWriter, error) {
|
||||||
|
rw := bufio.NewReadWriter(bufio.NewReader(f.conn), bufio.NewWriter(f.conn))
|
||||||
|
return f.conn, rw, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCloseByHost(t *testing.T) {
|
||||||
|
var tracker HijackTracker
|
||||||
|
|
||||||
|
// Simulate hijacking two connections for different hosts.
|
||||||
|
connA1, connA2 := net.Pipe()
|
||||||
|
defer connA2.Close()
|
||||||
|
connB1, connB2 := net.Pipe()
|
||||||
|
defer connB2.Close()
|
||||||
|
|
||||||
|
twA := &trackingWriter{
|
||||||
|
ResponseWriter: httptest.NewRecorder(),
|
||||||
|
tracker: &tracker,
|
||||||
|
host: "a.example.com",
|
||||||
|
}
|
||||||
|
twB := &trackingWriter{
|
||||||
|
ResponseWriter: httptest.NewRecorder(),
|
||||||
|
tracker: &tracker,
|
||||||
|
host: "b.example.com",
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use fakeHijackWriter to provide the Hijack method.
|
||||||
|
twA.ResponseWriter = &fakeHijackWriter{ResponseWriter: twA.ResponseWriter, conn: connA1}
|
||||||
|
twB.ResponseWriter = &fakeHijackWriter{ResponseWriter: twB.ResponseWriter, conn: connB1}
|
||||||
|
|
||||||
|
_, _, err := twA.Hijack()
|
||||||
|
require.NoError(t, err)
|
||||||
|
_, _, err = twB.Hijack()
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
tracker.mu.Lock()
|
||||||
|
assert.Equal(t, 2, len(tracker.conns), "should track 2 connections")
|
||||||
|
tracker.mu.Unlock()
|
||||||
|
|
||||||
|
// Close only host A.
|
||||||
|
n := tracker.CloseByHost("a.example.com")
|
||||||
|
assert.Equal(t, 1, n, "should close 1 connection for host A")
|
||||||
|
|
||||||
|
tracker.mu.Lock()
|
||||||
|
assert.Equal(t, 1, len(tracker.conns), "should have 1 remaining connection")
|
||||||
|
tracker.mu.Unlock()
|
||||||
|
|
||||||
|
// Verify host A's conn is actually closed.
|
||||||
|
buf := make([]byte, 1)
|
||||||
|
_, err = connA2.Read(buf)
|
||||||
|
assert.Error(t, err, "host A pipe should be closed")
|
||||||
|
|
||||||
|
// Host B should still be alive.
|
||||||
|
go func() { _, _ = connB1.Write([]byte("x")) }()
|
||||||
|
|
||||||
|
// Close all remaining.
|
||||||
|
n = tracker.CloseAll()
|
||||||
|
assert.Equal(t, 1, n, "should close remaining 1 connection")
|
||||||
|
|
||||||
|
tracker.mu.Lock()
|
||||||
|
assert.Equal(t, 0, len(tracker.conns), "should have 0 connections after CloseAll")
|
||||||
|
tracker.mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCloseAll(t *testing.T) {
|
||||||
|
var tracker HijackTracker
|
||||||
|
|
||||||
|
for range 5 {
|
||||||
|
c1, c2 := net.Pipe()
|
||||||
|
defer c2.Close()
|
||||||
|
tc := &trackedConn{Conn: c1, tracker: &tracker, host: "test.com"}
|
||||||
|
tracker.add(tc)
|
||||||
|
}
|
||||||
|
|
||||||
|
tracker.mu.Lock()
|
||||||
|
assert.Equal(t, 5, len(tracker.conns))
|
||||||
|
tracker.mu.Unlock()
|
||||||
|
|
||||||
|
n := tracker.CloseAll()
|
||||||
|
assert.Equal(t, 5, n)
|
||||||
|
|
||||||
|
// Double CloseAll is safe.
|
||||||
|
n = tracker.CloseAll()
|
||||||
|
assert.Equal(t, 0, n)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTrackedConn_AutoDeregister(t *testing.T) {
|
||||||
|
var tracker HijackTracker
|
||||||
|
|
||||||
|
c1, c2 := net.Pipe()
|
||||||
|
defer c2.Close()
|
||||||
|
|
||||||
|
tc := &trackedConn{Conn: c1, tracker: &tracker, host: "auto.com"}
|
||||||
|
tracker.add(tc)
|
||||||
|
|
||||||
|
tracker.mu.Lock()
|
||||||
|
assert.Equal(t, 1, len(tracker.conns))
|
||||||
|
tracker.mu.Unlock()
|
||||||
|
|
||||||
|
// Close the tracked conn: should auto-deregister.
|
||||||
|
require.NoError(t, tc.Close())
|
||||||
|
|
||||||
|
tracker.mu.Lock()
|
||||||
|
assert.Equal(t, 0, len(tracker.conns), "should auto-deregister on close")
|
||||||
|
tracker.mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHostOnly(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
input string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{"example.com:443", "example.com"},
|
||||||
|
{"example.com", "example.com"},
|
||||||
|
{"127.0.0.1:8080", "127.0.0.1"},
|
||||||
|
{"[::1]:443", "[::1]"},
|
||||||
|
{"", ""},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.input, func(t *testing.T) {
|
||||||
|
assert.Equal(t, tt.want, hostOnly(tt.input))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -152,7 +152,7 @@ func (c *Client) printClients(data map[string]any) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
_, _ = fmt.Fprintf(c.out, "%-38s %-12s %-40s %s\n", "ACCOUNT ID", "AGE", "DOMAINS", "HAS CLIENT")
|
_, _ = fmt.Fprintf(c.out, "%-38s %-12s %-40s %s\n", "ACCOUNT ID", "AGE", "SERVICES", "HAS CLIENT")
|
||||||
_, _ = fmt.Fprintln(c.out, strings.Repeat("-", 110))
|
_, _ = fmt.Fprintln(c.out, strings.Repeat("-", 110))
|
||||||
|
|
||||||
for _, item := range clients {
|
for _, item := range clients {
|
||||||
@@ -166,7 +166,7 @@ func (c *Client) printClientRow(item any) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
domains := c.extractDomains(client)
|
services := c.extractServiceKeys(client)
|
||||||
hasClient := "no"
|
hasClient := "no"
|
||||||
if hc, ok := client["has_client"].(bool); ok && hc {
|
if hc, ok := client["has_client"].(bool); ok && hc {
|
||||||
hasClient = "yes"
|
hasClient = "yes"
|
||||||
@@ -175,20 +175,20 @@ func (c *Client) printClientRow(item any) {
|
|||||||
_, _ = fmt.Fprintf(c.out, "%-38s %-12v %s %s\n",
|
_, _ = fmt.Fprintf(c.out, "%-38s %-12v %s %s\n",
|
||||||
client["account_id"],
|
client["account_id"],
|
||||||
client["age"],
|
client["age"],
|
||||||
domains,
|
services,
|
||||||
hasClient,
|
hasClient,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *Client) extractDomains(client map[string]any) string {
|
func (c *Client) extractServiceKeys(client map[string]any) string {
|
||||||
d, ok := client["domains"].([]any)
|
d, ok := client["service_keys"].([]any)
|
||||||
if !ok || len(d) == 0 {
|
if !ok || len(d) == 0 {
|
||||||
return "-"
|
return "-"
|
||||||
}
|
}
|
||||||
|
|
||||||
parts := make([]string, len(d))
|
parts := make([]string, len(d))
|
||||||
for i, domain := range d {
|
for i, key := range d {
|
||||||
parts[i] = fmt.Sprint(domain)
|
parts[i] = fmt.Sprint(key)
|
||||||
}
|
}
|
||||||
return strings.Join(parts, ", ")
|
return strings.Join(parts, ", ")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -189,7 +189,7 @@ type indexData struct {
|
|||||||
Version string
|
Version string
|
||||||
Uptime string
|
Uptime string
|
||||||
ClientCount int
|
ClientCount int
|
||||||
TotalDomains int
|
TotalServices int
|
||||||
CertsTotal int
|
CertsTotal int
|
||||||
CertsReady int
|
CertsReady int
|
||||||
CertsPending int
|
CertsPending int
|
||||||
@@ -202,7 +202,7 @@ type indexData struct {
|
|||||||
|
|
||||||
type clientData struct {
|
type clientData struct {
|
||||||
AccountID string
|
AccountID string
|
||||||
Domains string
|
Services string
|
||||||
Age string
|
Age string
|
||||||
Status string
|
Status string
|
||||||
}
|
}
|
||||||
@@ -211,9 +211,9 @@ func (h *Handler) handleIndex(w http.ResponseWriter, _ *http.Request, wantJSON b
|
|||||||
clients := h.provider.ListClientsForDebug()
|
clients := h.provider.ListClientsForDebug()
|
||||||
sortedIDs := sortedAccountIDs(clients)
|
sortedIDs := sortedAccountIDs(clients)
|
||||||
|
|
||||||
totalDomains := 0
|
totalServices := 0
|
||||||
for _, info := range clients {
|
for _, info := range clients {
|
||||||
totalDomains += info.DomainCount
|
totalServices += info.ServiceCount
|
||||||
}
|
}
|
||||||
|
|
||||||
var certsTotal, certsReady, certsPending, certsFailed int
|
var certsTotal, certsReady, certsPending, certsFailed int
|
||||||
@@ -234,24 +234,24 @@ func (h *Handler) handleIndex(w http.ResponseWriter, _ *http.Request, wantJSON b
|
|||||||
for _, id := range sortedIDs {
|
for _, id := range sortedIDs {
|
||||||
info := clients[id]
|
info := clients[id]
|
||||||
clientsJSON = append(clientsJSON, map[string]interface{}{
|
clientsJSON = append(clientsJSON, map[string]interface{}{
|
||||||
"account_id": info.AccountID,
|
"account_id": info.AccountID,
|
||||||
"domain_count": info.DomainCount,
|
"service_count": info.ServiceCount,
|
||||||
"domains": info.Domains,
|
"service_keys": info.ServiceKeys,
|
||||||
"has_client": info.HasClient,
|
"has_client": info.HasClient,
|
||||||
"created_at": info.CreatedAt,
|
"created_at": info.CreatedAt,
|
||||||
"age": time.Since(info.CreatedAt).Round(time.Second).String(),
|
"age": time.Since(info.CreatedAt).Round(time.Second).String(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
resp := map[string]interface{}{
|
resp := map[string]interface{}{
|
||||||
"version": version.NetbirdVersion(),
|
"version": version.NetbirdVersion(),
|
||||||
"uptime": time.Since(h.startTime).Round(time.Second).String(),
|
"uptime": time.Since(h.startTime).Round(time.Second).String(),
|
||||||
"client_count": len(clients),
|
"client_count": len(clients),
|
||||||
"total_domains": totalDomains,
|
"total_services": totalServices,
|
||||||
"certs_total": certsTotal,
|
"certs_total": certsTotal,
|
||||||
"certs_ready": certsReady,
|
"certs_ready": certsReady,
|
||||||
"certs_pending": certsPending,
|
"certs_pending": certsPending,
|
||||||
"certs_failed": certsFailed,
|
"certs_failed": certsFailed,
|
||||||
"clients": clientsJSON,
|
"clients": clientsJSON,
|
||||||
}
|
}
|
||||||
if len(certsPendingDomains) > 0 {
|
if len(certsPendingDomains) > 0 {
|
||||||
resp["certs_pending_domains"] = certsPendingDomains
|
resp["certs_pending_domains"] = certsPendingDomains
|
||||||
@@ -278,7 +278,7 @@ func (h *Handler) handleIndex(w http.ResponseWriter, _ *http.Request, wantJSON b
|
|||||||
Version: version.NetbirdVersion(),
|
Version: version.NetbirdVersion(),
|
||||||
Uptime: time.Since(h.startTime).Round(time.Second).String(),
|
Uptime: time.Since(h.startTime).Round(time.Second).String(),
|
||||||
ClientCount: len(clients),
|
ClientCount: len(clients),
|
||||||
TotalDomains: totalDomains,
|
TotalServices: totalServices,
|
||||||
CertsTotal: certsTotal,
|
CertsTotal: certsTotal,
|
||||||
CertsReady: certsReady,
|
CertsReady: certsReady,
|
||||||
CertsPending: certsPending,
|
CertsPending: certsPending,
|
||||||
@@ -291,9 +291,9 @@ func (h *Handler) handleIndex(w http.ResponseWriter, _ *http.Request, wantJSON b
|
|||||||
|
|
||||||
for _, id := range sortedIDs {
|
for _, id := range sortedIDs {
|
||||||
info := clients[id]
|
info := clients[id]
|
||||||
domains := info.Domains.SafeString()
|
services := strings.Join(info.ServiceKeys, ", ")
|
||||||
if domains == "" {
|
if services == "" {
|
||||||
domains = "-"
|
services = "-"
|
||||||
}
|
}
|
||||||
status := "No client"
|
status := "No client"
|
||||||
if info.HasClient {
|
if info.HasClient {
|
||||||
@@ -301,7 +301,7 @@ func (h *Handler) handleIndex(w http.ResponseWriter, _ *http.Request, wantJSON b
|
|||||||
}
|
}
|
||||||
data.Clients = append(data.Clients, clientData{
|
data.Clients = append(data.Clients, clientData{
|
||||||
AccountID: string(info.AccountID),
|
AccountID: string(info.AccountID),
|
||||||
Domains: domains,
|
Services: services,
|
||||||
Age: time.Since(info.CreatedAt).Round(time.Second).String(),
|
Age: time.Since(info.CreatedAt).Round(time.Second).String(),
|
||||||
Status: status,
|
Status: status,
|
||||||
})
|
})
|
||||||
@@ -324,12 +324,12 @@ func (h *Handler) handleListClients(w http.ResponseWriter, _ *http.Request, want
|
|||||||
for _, id := range sortedIDs {
|
for _, id := range sortedIDs {
|
||||||
info := clients[id]
|
info := clients[id]
|
||||||
clientsJSON = append(clientsJSON, map[string]interface{}{
|
clientsJSON = append(clientsJSON, map[string]interface{}{
|
||||||
"account_id": info.AccountID,
|
"account_id": info.AccountID,
|
||||||
"domain_count": info.DomainCount,
|
"service_count": info.ServiceCount,
|
||||||
"domains": info.Domains,
|
"service_keys": info.ServiceKeys,
|
||||||
"has_client": info.HasClient,
|
"has_client": info.HasClient,
|
||||||
"created_at": info.CreatedAt,
|
"created_at": info.CreatedAt,
|
||||||
"age": time.Since(info.CreatedAt).Round(time.Second).String(),
|
"age": time.Since(info.CreatedAt).Round(time.Second).String(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
h.writeJSON(w, map[string]interface{}{
|
h.writeJSON(w, map[string]interface{}{
|
||||||
@@ -347,9 +347,9 @@ func (h *Handler) handleListClients(w http.ResponseWriter, _ *http.Request, want
|
|||||||
|
|
||||||
for _, id := range sortedIDs {
|
for _, id := range sortedIDs {
|
||||||
info := clients[id]
|
info := clients[id]
|
||||||
domains := info.Domains.SafeString()
|
services := strings.Join(info.ServiceKeys, ", ")
|
||||||
if domains == "" {
|
if services == "" {
|
||||||
domains = "-"
|
services = "-"
|
||||||
}
|
}
|
||||||
status := "No client"
|
status := "No client"
|
||||||
if info.HasClient {
|
if info.HasClient {
|
||||||
@@ -357,7 +357,7 @@ func (h *Handler) handleListClients(w http.ResponseWriter, _ *http.Request, want
|
|||||||
}
|
}
|
||||||
data.Clients = append(data.Clients, clientData{
|
data.Clients = append(data.Clients, clientData{
|
||||||
AccountID: string(info.AccountID),
|
AccountID: string(info.AccountID),
|
||||||
Domains: domains,
|
Services: services,
|
||||||
Age: time.Since(info.CreatedAt).Round(time.Second).String(),
|
Age: time.Since(info.CreatedAt).Round(time.Second).String(),
|
||||||
Status: status,
|
Status: status,
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -12,14 +12,14 @@
|
|||||||
<table>
|
<table>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Account ID</th>
|
<th>Account ID</th>
|
||||||
<th>Domains</th>
|
<th>Services</th>
|
||||||
<th>Age</th>
|
<th>Age</th>
|
||||||
<th>Status</th>
|
<th>Status</th>
|
||||||
</tr>
|
</tr>
|
||||||
{{range .Clients}}
|
{{range .Clients}}
|
||||||
<tr>
|
<tr>
|
||||||
<td><a href="/debug/clients/{{.AccountID}}/tools">{{.AccountID}}</a></td>
|
<td><a href="/debug/clients/{{.AccountID}}/tools">{{.AccountID}}</a></td>
|
||||||
<td>{{.Domains}}</td>
|
<td>{{.Services}}</td>
|
||||||
<td>{{.Age}}</td>
|
<td>{{.Age}}</td>
|
||||||
<td>{{.Status}}</td>
|
<td>{{.Status}}</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|||||||
@@ -27,19 +27,19 @@
|
|||||||
<ul>{{range .CertsFailedDomains}}<li>{{.Domain}}: {{.Error}}</li>{{end}}</ul>
|
<ul>{{range .CertsFailedDomains}}<li>{{.Domain}}: {{.Error}}</li>{{end}}</ul>
|
||||||
</details>
|
</details>
|
||||||
{{end}}
|
{{end}}
|
||||||
<h2>Clients ({{.ClientCount}}) | Domains ({{.TotalDomains}})</h2>
|
<h2>Clients ({{.ClientCount}}) | Services ({{.TotalServices}})</h2>
|
||||||
{{if .Clients}}
|
{{if .Clients}}
|
||||||
<table>
|
<table>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Account ID</th>
|
<th>Account ID</th>
|
||||||
<th>Domains</th>
|
<th>Services</th>
|
||||||
<th>Age</th>
|
<th>Age</th>
|
||||||
<th>Status</th>
|
<th>Status</th>
|
||||||
</tr>
|
</tr>
|
||||||
{{range .Clients}}
|
{{range .Clients}}
|
||||||
<tr>
|
<tr>
|
||||||
<td><a href="/debug/clients/{{.AccountID}}/tools">{{.AccountID}}</a></td>
|
<td><a href="/debug/clients/{{.AccountID}}/tools">{{.AccountID}}</a></td>
|
||||||
<td>{{.Domains}}</td>
|
<td>{{.Services}}</td>
|
||||||
<td>{{.Age}}</td>
|
<td>{{.Age}}</td>
|
||||||
<td>{{.Status}}</td>
|
<td>{{.Status}}</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|||||||
@@ -0,0 +1,69 @@
|
|||||||
|
package metrics_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"reflect"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
promexporter "go.opentelemetry.io/otel/exporters/prometheus"
|
||||||
|
sdkmetric "go.opentelemetry.io/otel/sdk/metric"
|
||||||
|
|
||||||
|
"github.com/netbirdio/netbird/proxy/internal/metrics"
|
||||||
|
"github.com/netbirdio/netbird/proxy/internal/types"
|
||||||
|
)
|
||||||
|
|
||||||
|
func newTestMetrics(t *testing.T) *metrics.Metrics {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
exporter, err := promexporter.New()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create prometheus exporter: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
provider := sdkmetric.NewMeterProvider(sdkmetric.WithReader(exporter))
|
||||||
|
pkg := reflect.TypeOf(metrics.Metrics{}).PkgPath()
|
||||||
|
meter := provider.Meter(pkg)
|
||||||
|
|
||||||
|
m, err := metrics.New(context.Background(), meter)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create metrics: %v", err)
|
||||||
|
}
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestL4ServiceGauge(t *testing.T) {
|
||||||
|
m := newTestMetrics(t)
|
||||||
|
|
||||||
|
m.L4ServiceAdded(types.ServiceModeTCP)
|
||||||
|
m.L4ServiceAdded(types.ServiceModeTCP)
|
||||||
|
m.L4ServiceAdded(types.ServiceModeUDP)
|
||||||
|
m.L4ServiceRemoved(types.ServiceModeTCP)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTCPRelayMetrics(t *testing.T) {
|
||||||
|
m := newTestMetrics(t)
|
||||||
|
|
||||||
|
acct := types.AccountID("acct-1")
|
||||||
|
|
||||||
|
m.TCPRelayStarted(acct)
|
||||||
|
m.TCPRelayStarted(acct)
|
||||||
|
m.TCPRelayEnded(acct, 10*time.Second, 1000, 500)
|
||||||
|
m.TCPRelayDialError(acct)
|
||||||
|
m.TCPRelayRejected(acct)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUDPSessionMetrics(t *testing.T) {
|
||||||
|
m := newTestMetrics(t)
|
||||||
|
|
||||||
|
acct := types.AccountID("acct-2")
|
||||||
|
|
||||||
|
m.UDPSessionStarted(acct)
|
||||||
|
m.UDPSessionStarted(acct)
|
||||||
|
m.UDPSessionEnded(acct)
|
||||||
|
m.UDPSessionDialError(acct)
|
||||||
|
m.UDPSessionRejected(acct)
|
||||||
|
m.UDPPacketRelayed(types.RelayDirectionClientToBackend, 100)
|
||||||
|
m.UDPPacketRelayed(types.RelayDirectionClientToBackend, 200)
|
||||||
|
m.UDPPacketRelayed(types.RelayDirectionBackendToClient, 150)
|
||||||
|
}
|
||||||
@@ -6,12 +6,15 @@ import (
|
|||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"go.opentelemetry.io/otel/attribute"
|
||||||
"go.opentelemetry.io/otel/metric"
|
"go.opentelemetry.io/otel/metric"
|
||||||
|
|
||||||
"github.com/netbirdio/netbird/proxy/internal/proxy"
|
"github.com/netbirdio/netbird/proxy/internal/proxy"
|
||||||
"github.com/netbirdio/netbird/proxy/internal/responsewriter"
|
"github.com/netbirdio/netbird/proxy/internal/responsewriter"
|
||||||
|
"github.com/netbirdio/netbird/proxy/internal/types"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Metrics collects OpenTelemetry metrics for the proxy.
|
||||||
type Metrics struct {
|
type Metrics struct {
|
||||||
ctx context.Context
|
ctx context.Context
|
||||||
requestsTotal metric.Int64Counter
|
requestsTotal metric.Int64Counter
|
||||||
@@ -22,85 +25,188 @@ type Metrics struct {
|
|||||||
backendDuration metric.Int64Histogram
|
backendDuration metric.Int64Histogram
|
||||||
certificateIssueDuration metric.Int64Histogram
|
certificateIssueDuration metric.Int64Histogram
|
||||||
|
|
||||||
|
// L4 service-level metrics.
|
||||||
|
l4Services metric.Int64UpDownCounter
|
||||||
|
|
||||||
|
// L4 TCP connection-level metrics.
|
||||||
|
tcpActiveConns metric.Int64UpDownCounter
|
||||||
|
tcpConnsTotal metric.Int64Counter
|
||||||
|
tcpConnDuration metric.Int64Histogram
|
||||||
|
tcpBytesTotal metric.Int64Counter
|
||||||
|
|
||||||
|
// L4 UDP session-level metrics.
|
||||||
|
udpActiveSess metric.Int64UpDownCounter
|
||||||
|
udpSessionsTotal metric.Int64Counter
|
||||||
|
udpPacketsTotal metric.Int64Counter
|
||||||
|
udpBytesTotal metric.Int64Counter
|
||||||
|
|
||||||
mappingsMux sync.Mutex
|
mappingsMux sync.Mutex
|
||||||
mappingPaths map[string]int
|
mappingPaths map[string]int
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// New creates a Metrics instance using the given OpenTelemetry meter.
|
||||||
func New(ctx context.Context, meter metric.Meter) (*Metrics, error) {
|
func New(ctx context.Context, meter metric.Meter) (*Metrics, error) {
|
||||||
requestsTotal, err := meter.Int64Counter(
|
m := &Metrics{
|
||||||
|
ctx: ctx,
|
||||||
|
mappingPaths: make(map[string]int),
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := m.initHTTPMetrics(meter); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := m.initL4Metrics(meter); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Metrics) initHTTPMetrics(meter metric.Meter) error {
|
||||||
|
var err error
|
||||||
|
|
||||||
|
m.requestsTotal, err = meter.Int64Counter(
|
||||||
"proxy.http.request.counter",
|
"proxy.http.request.counter",
|
||||||
metric.WithUnit("1"),
|
metric.WithUnit("1"),
|
||||||
metric.WithDescription("Total number of requests made to the netbird proxy"),
|
metric.WithDescription("Total number of requests made to the netbird proxy"),
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
activeRequests, err := meter.Int64UpDownCounter(
|
m.activeRequests, err = meter.Int64UpDownCounter(
|
||||||
"proxy.http.active_requests",
|
"proxy.http.active_requests",
|
||||||
metric.WithUnit("1"),
|
metric.WithUnit("1"),
|
||||||
metric.WithDescription("Current in-flight requests handled by the netbird proxy"),
|
metric.WithDescription("Current in-flight requests handled by the netbird proxy"),
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
configuredDomains, err := meter.Int64UpDownCounter(
|
m.configuredDomains, err = meter.Int64UpDownCounter(
|
||||||
"proxy.domains.count",
|
"proxy.domains.count",
|
||||||
metric.WithUnit("1"),
|
metric.WithUnit("1"),
|
||||||
metric.WithDescription("Current number of domains configured on the netbird proxy"),
|
metric.WithDescription("Current number of domains configured on the netbird proxy"),
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
totalPaths, err := meter.Int64UpDownCounter(
|
m.totalPaths, err = meter.Int64UpDownCounter(
|
||||||
"proxy.paths.count",
|
"proxy.paths.count",
|
||||||
metric.WithUnit("1"),
|
metric.WithUnit("1"),
|
||||||
metric.WithDescription("Total number of paths configured on the netbird proxy"),
|
metric.WithDescription("Total number of paths configured on the netbird proxy"),
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
requestDuration, err := meter.Int64Histogram(
|
m.requestDuration, err = meter.Int64Histogram(
|
||||||
"proxy.http.request.duration.ms",
|
"proxy.http.request.duration.ms",
|
||||||
metric.WithUnit("milliseconds"),
|
metric.WithUnit("milliseconds"),
|
||||||
metric.WithDescription("Duration of requests made to the netbird proxy"),
|
metric.WithDescription("Duration of requests made to the netbird proxy"),
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
backendDuration, err := meter.Int64Histogram(
|
m.backendDuration, err = meter.Int64Histogram(
|
||||||
"proxy.backend.duration.ms",
|
"proxy.backend.duration.ms",
|
||||||
metric.WithUnit("milliseconds"),
|
metric.WithUnit("milliseconds"),
|
||||||
metric.WithDescription("Duration of peer round trip time from the netbird proxy"),
|
metric.WithDescription("Duration of peer round trip time from the netbird proxy"),
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
certificateIssueDuration, err := meter.Int64Histogram(
|
m.certificateIssueDuration, err = meter.Int64Histogram(
|
||||||
"proxy.certificate.issue.duration.ms",
|
"proxy.certificate.issue.duration.ms",
|
||||||
metric.WithUnit("milliseconds"),
|
metric.WithUnit("milliseconds"),
|
||||||
metric.WithDescription("Duration of ACME certificate issuance"),
|
metric.WithDescription("Duration of ACME certificate issuance"),
|
||||||
)
|
)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Metrics) initL4Metrics(meter metric.Meter) error {
|
||||||
|
var err error
|
||||||
|
|
||||||
|
m.l4Services, err = meter.Int64UpDownCounter(
|
||||||
|
"proxy.l4.services.count",
|
||||||
|
metric.WithUnit("1"),
|
||||||
|
metric.WithDescription("Current number of configured L4 services (TCP/TLS/UDP) by mode"),
|
||||||
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
return &Metrics{
|
m.tcpActiveConns, err = meter.Int64UpDownCounter(
|
||||||
ctx: ctx,
|
"proxy.tcp.active_connections",
|
||||||
requestsTotal: requestsTotal,
|
metric.WithUnit("1"),
|
||||||
activeRequests: activeRequests,
|
metric.WithDescription("Current number of active TCP/TLS relay connections"),
|
||||||
configuredDomains: configuredDomains,
|
)
|
||||||
totalPaths: totalPaths,
|
if err != nil {
|
||||||
requestDuration: requestDuration,
|
return err
|
||||||
backendDuration: backendDuration,
|
}
|
||||||
certificateIssueDuration: certificateIssueDuration,
|
|
||||||
mappingPaths: make(map[string]int),
|
m.tcpConnsTotal, err = meter.Int64Counter(
|
||||||
}, nil
|
"proxy.tcp.connections.total",
|
||||||
|
metric.WithUnit("1"),
|
||||||
|
metric.WithDescription("Total TCP/TLS relay connections by result and account"),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
m.tcpConnDuration, err = meter.Int64Histogram(
|
||||||
|
"proxy.tcp.connection.duration.ms",
|
||||||
|
metric.WithUnit("milliseconds"),
|
||||||
|
metric.WithDescription("Duration of TCP/TLS relay connections"),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
m.tcpBytesTotal, err = meter.Int64Counter(
|
||||||
|
"proxy.tcp.bytes.total",
|
||||||
|
metric.WithUnit("bytes"),
|
||||||
|
metric.WithDescription("Total bytes transferred through TCP/TLS relay by direction"),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
m.udpActiveSess, err = meter.Int64UpDownCounter(
|
||||||
|
"proxy.udp.active_sessions",
|
||||||
|
metric.WithUnit("1"),
|
||||||
|
metric.WithDescription("Current number of active UDP relay sessions"),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
m.udpSessionsTotal, err = meter.Int64Counter(
|
||||||
|
"proxy.udp.sessions.total",
|
||||||
|
metric.WithUnit("1"),
|
||||||
|
metric.WithDescription("Total UDP relay sessions by result and account"),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
m.udpPacketsTotal, err = meter.Int64Counter(
|
||||||
|
"proxy.udp.packets.total",
|
||||||
|
metric.WithUnit("1"),
|
||||||
|
metric.WithDescription("Total UDP packets relayed by direction"),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
m.udpBytesTotal, err = meter.Int64Counter(
|
||||||
|
"proxy.udp.bytes.total",
|
||||||
|
metric.WithUnit("bytes"),
|
||||||
|
metric.WithDescription("Total bytes transferred through UDP relay by direction"),
|
||||||
|
)
|
||||||
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
type responseInterceptor struct {
|
type responseInterceptor struct {
|
||||||
@@ -120,6 +226,13 @@ func (w *responseInterceptor) Write(b []byte) (int, error) {
|
|||||||
return size, err
|
return size, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Unwrap returns the underlying ResponseWriter so http.ResponseController
|
||||||
|
// can reach through to the original writer for Hijack/Flush operations.
|
||||||
|
func (w *responseInterceptor) Unwrap() http.ResponseWriter {
|
||||||
|
return w.PassthroughWriter
|
||||||
|
}
|
||||||
|
|
||||||
|
// Middleware wraps an HTTP handler with request metrics.
|
||||||
func (m *Metrics) Middleware(next http.Handler) http.Handler {
|
func (m *Metrics) Middleware(next http.Handler) http.Handler {
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
m.requestsTotal.Add(m.ctx, 1)
|
m.requestsTotal.Add(m.ctx, 1)
|
||||||
@@ -144,6 +257,7 @@ func (f roundTripperFunc) RoundTrip(r *http.Request) (*http.Response, error) {
|
|||||||
return f(r)
|
return f(r)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// RoundTripper wraps an http.RoundTripper with backend duration metrics.
|
||||||
func (m *Metrics) RoundTripper(next http.RoundTripper) http.RoundTripper {
|
func (m *Metrics) RoundTripper(next http.RoundTripper) http.RoundTripper {
|
||||||
return roundTripperFunc(func(req *http.Request) (*http.Response, error) {
|
return roundTripperFunc(func(req *http.Request) (*http.Response, error) {
|
||||||
start := time.Now()
|
start := time.Now()
|
||||||
@@ -156,6 +270,7 @@ func (m *Metrics) RoundTripper(next http.RoundTripper) http.RoundTripper {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// AddMapping records that a domain mapping was added.
|
||||||
func (m *Metrics) AddMapping(mapping proxy.Mapping) {
|
func (m *Metrics) AddMapping(mapping proxy.Mapping) {
|
||||||
m.mappingsMux.Lock()
|
m.mappingsMux.Lock()
|
||||||
defer m.mappingsMux.Unlock()
|
defer m.mappingsMux.Unlock()
|
||||||
@@ -175,13 +290,13 @@ func (m *Metrics) AddMapping(mapping proxy.Mapping) {
|
|||||||
m.mappingPaths[mapping.Host] = newPathCount
|
m.mappingPaths[mapping.Host] = newPathCount
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// RemoveMapping records that a domain mapping was removed.
|
||||||
func (m *Metrics) RemoveMapping(mapping proxy.Mapping) {
|
func (m *Metrics) RemoveMapping(mapping proxy.Mapping) {
|
||||||
m.mappingsMux.Lock()
|
m.mappingsMux.Lock()
|
||||||
defer m.mappingsMux.Unlock()
|
defer m.mappingsMux.Unlock()
|
||||||
|
|
||||||
oldPathCount, exists := m.mappingPaths[mapping.Host]
|
oldPathCount, exists := m.mappingPaths[mapping.Host]
|
||||||
if !exists {
|
if !exists {
|
||||||
// Nothing to remove
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -195,3 +310,80 @@ func (m *Metrics) RemoveMapping(mapping proxy.Mapping) {
|
|||||||
func (m *Metrics) RecordCertificateIssuance(duration time.Duration) {
|
func (m *Metrics) RecordCertificateIssuance(duration time.Duration) {
|
||||||
m.certificateIssueDuration.Record(m.ctx, duration.Milliseconds())
|
m.certificateIssueDuration.Record(m.ctx, duration.Milliseconds())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// L4ServiceAdded increments the L4 service gauge for the given mode.
|
||||||
|
func (m *Metrics) L4ServiceAdded(mode types.ServiceMode) {
|
||||||
|
m.l4Services.Add(m.ctx, 1, metric.WithAttributes(attribute.String("mode", string(mode))))
|
||||||
|
}
|
||||||
|
|
||||||
|
// L4ServiceRemoved decrements the L4 service gauge for the given mode.
|
||||||
|
func (m *Metrics) L4ServiceRemoved(mode types.ServiceMode) {
|
||||||
|
m.l4Services.Add(m.ctx, -1, metric.WithAttributes(attribute.String("mode", string(mode))))
|
||||||
|
}
|
||||||
|
|
||||||
|
// TCPRelayStarted records a new TCP relay connection starting.
|
||||||
|
func (m *Metrics) TCPRelayStarted(accountID types.AccountID) {
|
||||||
|
acct := attribute.String("account_id", string(accountID))
|
||||||
|
m.tcpActiveConns.Add(m.ctx, 1, metric.WithAttributes(acct))
|
||||||
|
m.tcpConnsTotal.Add(m.ctx, 1, metric.WithAttributes(acct, attribute.String("result", "success")))
|
||||||
|
}
|
||||||
|
|
||||||
|
// TCPRelayEnded records a TCP relay connection ending and accumulates bytes and duration.
|
||||||
|
func (m *Metrics) TCPRelayEnded(accountID types.AccountID, duration time.Duration, srcToDst, dstToSrc int64) {
|
||||||
|
acct := attribute.String("account_id", string(accountID))
|
||||||
|
m.tcpActiveConns.Add(m.ctx, -1, metric.WithAttributes(acct))
|
||||||
|
m.tcpConnDuration.Record(m.ctx, duration.Milliseconds(), metric.WithAttributes(acct))
|
||||||
|
m.tcpBytesTotal.Add(m.ctx, srcToDst, metric.WithAttributes(attribute.String("direction", "client_to_backend")))
|
||||||
|
m.tcpBytesTotal.Add(m.ctx, dstToSrc, metric.WithAttributes(attribute.String("direction", "backend_to_client")))
|
||||||
|
}
|
||||||
|
|
||||||
|
// TCPRelayDialError records a dial failure for a TCP relay.
|
||||||
|
func (m *Metrics) TCPRelayDialError(accountID types.AccountID) {
|
||||||
|
m.tcpConnsTotal.Add(m.ctx, 1, metric.WithAttributes(
|
||||||
|
attribute.String("account_id", string(accountID)),
|
||||||
|
attribute.String("result", "dial_error"),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
// TCPRelayRejected records a rejected TCP relay (semaphore full).
|
||||||
|
func (m *Metrics) TCPRelayRejected(accountID types.AccountID) {
|
||||||
|
m.tcpConnsTotal.Add(m.ctx, 1, metric.WithAttributes(
|
||||||
|
attribute.String("account_id", string(accountID)),
|
||||||
|
attribute.String("result", "rejected"),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
// UDPSessionStarted records a new UDP session starting.
|
||||||
|
func (m *Metrics) UDPSessionStarted(accountID types.AccountID) {
|
||||||
|
acct := attribute.String("account_id", string(accountID))
|
||||||
|
m.udpActiveSess.Add(m.ctx, 1, metric.WithAttributes(acct))
|
||||||
|
m.udpSessionsTotal.Add(m.ctx, 1, metric.WithAttributes(acct, attribute.String("result", "success")))
|
||||||
|
}
|
||||||
|
|
||||||
|
// UDPSessionEnded records a UDP session ending.
|
||||||
|
func (m *Metrics) UDPSessionEnded(accountID types.AccountID) {
|
||||||
|
m.udpActiveSess.Add(m.ctx, -1, metric.WithAttributes(attribute.String("account_id", string(accountID))))
|
||||||
|
}
|
||||||
|
|
||||||
|
// UDPSessionDialError records a dial failure for a UDP session.
|
||||||
|
func (m *Metrics) UDPSessionDialError(accountID types.AccountID) {
|
||||||
|
m.udpSessionsTotal.Add(m.ctx, 1, metric.WithAttributes(
|
||||||
|
attribute.String("account_id", string(accountID)),
|
||||||
|
attribute.String("result", "dial_error"),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
// UDPSessionRejected records a rejected UDP session (limit or rate limited).
|
||||||
|
func (m *Metrics) UDPSessionRejected(accountID types.AccountID) {
|
||||||
|
m.udpSessionsTotal.Add(m.ctx, 1, metric.WithAttributes(
|
||||||
|
attribute.String("account_id", string(accountID)),
|
||||||
|
attribute.String("result", "rejected"),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
// UDPPacketRelayed records a packet relayed in the given direction with its size in bytes.
|
||||||
|
func (m *Metrics) UDPPacketRelayed(direction types.RelayDirection, bytes int) {
|
||||||
|
dir := attribute.String("direction", string(direction))
|
||||||
|
m.udpPacketsTotal.Add(m.ctx, 1, metric.WithAttributes(dir))
|
||||||
|
m.udpBytesTotal.Add(m.ctx, int64(bytes), metric.WithAttributes(dir))
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
package netutil
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"math"
|
||||||
|
"net"
|
||||||
|
"syscall"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ValidatePort converts an int32 proto port to uint16, returning an error
|
||||||
|
// if the value is out of the valid 1–65535 range.
|
||||||
|
func ValidatePort(port int32) (uint16, error) {
|
||||||
|
if port <= 0 || port > math.MaxUint16 {
|
||||||
|
return 0, fmt.Errorf("invalid port %d: must be 1–65535", port)
|
||||||
|
}
|
||||||
|
return uint16(port), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsExpectedError returns true for errors that are normal during
|
||||||
|
// connection teardown and should not be logged as warnings.
|
||||||
|
func IsExpectedError(err error) bool {
|
||||||
|
return errors.Is(err, net.ErrClosed) ||
|
||||||
|
errors.Is(err, context.Canceled) ||
|
||||||
|
errors.Is(err, io.EOF) ||
|
||||||
|
errors.Is(err, syscall.ECONNRESET) ||
|
||||||
|
errors.Is(err, syscall.EPIPE) ||
|
||||||
|
errors.Is(err, syscall.ECONNABORTED)
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsTimeout checks whether the error is a network timeout.
|
||||||
|
func IsTimeout(err error) bool {
|
||||||
|
var netErr net.Error
|
||||||
|
if errors.As(err, &netErr) {
|
||||||
|
return netErr.Timeout()
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
package netutil
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net"
|
||||||
|
"syscall"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestValidatePort(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
port int32
|
||||||
|
want uint16
|
||||||
|
wantErr bool
|
||||||
|
}{
|
||||||
|
{"valid min", 1, 1, false},
|
||||||
|
{"valid mid", 8080, 8080, false},
|
||||||
|
{"valid max", 65535, 65535, false},
|
||||||
|
{"zero", 0, 0, true},
|
||||||
|
{"negative", -1, 0, true},
|
||||||
|
{"too large", 65536, 0, true},
|
||||||
|
{"way too large", 100000, 0, true},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
got, err := ValidatePort(tt.port)
|
||||||
|
if tt.wantErr {
|
||||||
|
assert.Error(t, err)
|
||||||
|
assert.Zero(t, got)
|
||||||
|
} else {
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Equal(t, tt.want, got)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIsExpectedError(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
err error
|
||||||
|
want bool
|
||||||
|
}{
|
||||||
|
{"net.ErrClosed", net.ErrClosed, true},
|
||||||
|
{"context.Canceled", context.Canceled, true},
|
||||||
|
{"io.EOF", io.EOF, true},
|
||||||
|
{"ECONNRESET", syscall.ECONNRESET, true},
|
||||||
|
{"EPIPE", syscall.EPIPE, true},
|
||||||
|
{"ECONNABORTED", syscall.ECONNABORTED, true},
|
||||||
|
{"wrapped expected", fmt.Errorf("wrap: %w", net.ErrClosed), true},
|
||||||
|
{"unexpected EOF", io.ErrUnexpectedEOF, false},
|
||||||
|
{"generic error", errors.New("something"), false},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
assert.Equal(t, tt.want, IsExpectedError(tt.err))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type timeoutErr struct{ timeout bool }
|
||||||
|
|
||||||
|
func (e *timeoutErr) Error() string { return "timeout" }
|
||||||
|
func (e *timeoutErr) Timeout() bool { return e.timeout }
|
||||||
|
func (e *timeoutErr) Temporary() bool { return false }
|
||||||
|
|
||||||
|
func TestIsTimeout(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
err error
|
||||||
|
want bool
|
||||||
|
}{
|
||||||
|
{"net timeout", &timeoutErr{timeout: true}, true},
|
||||||
|
{"net non-timeout", &timeoutErr{timeout: false}, false},
|
||||||
|
{"wrapped timeout", fmt.Errorf("wrap: %w", &timeoutErr{timeout: true}), true},
|
||||||
|
{"generic error", errors.New("not a timeout"), false},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
assert.Equal(t, tt.want, IsTimeout(tt.err))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ package proxy
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"net/netip"
|
||||||
"sync"
|
"sync"
|
||||||
|
|
||||||
"github.com/netbirdio/netbird/proxy/internal/types"
|
"github.com/netbirdio/netbird/proxy/internal/types"
|
||||||
@@ -47,10 +48,10 @@ func (o ResponseOrigin) String() string {
|
|||||||
type CapturedData struct {
|
type CapturedData struct {
|
||||||
mu sync.RWMutex
|
mu sync.RWMutex
|
||||||
RequestID string
|
RequestID string
|
||||||
ServiceId string
|
ServiceId types.ServiceID
|
||||||
AccountId types.AccountID
|
AccountId types.AccountID
|
||||||
Origin ResponseOrigin
|
Origin ResponseOrigin
|
||||||
ClientIP string
|
ClientIP netip.Addr
|
||||||
UserID string
|
UserID string
|
||||||
AuthMethod string
|
AuthMethod string
|
||||||
}
|
}
|
||||||
@@ -63,14 +64,14 @@ func (c *CapturedData) GetRequestID() string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// SetServiceId safely sets the service ID
|
// SetServiceId safely sets the service ID
|
||||||
func (c *CapturedData) SetServiceId(serviceId string) {
|
func (c *CapturedData) SetServiceId(serviceId types.ServiceID) {
|
||||||
c.mu.Lock()
|
c.mu.Lock()
|
||||||
defer c.mu.Unlock()
|
defer c.mu.Unlock()
|
||||||
c.ServiceId = serviceId
|
c.ServiceId = serviceId
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetServiceId safely gets the service ID
|
// GetServiceId safely gets the service ID
|
||||||
func (c *CapturedData) GetServiceId() string {
|
func (c *CapturedData) GetServiceId() types.ServiceID {
|
||||||
c.mu.RLock()
|
c.mu.RLock()
|
||||||
defer c.mu.RUnlock()
|
defer c.mu.RUnlock()
|
||||||
return c.ServiceId
|
return c.ServiceId
|
||||||
@@ -105,14 +106,14 @@ func (c *CapturedData) GetOrigin() ResponseOrigin {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// SetClientIP safely sets the resolved client IP.
|
// SetClientIP safely sets the resolved client IP.
|
||||||
func (c *CapturedData) SetClientIP(ip string) {
|
func (c *CapturedData) SetClientIP(ip netip.Addr) {
|
||||||
c.mu.Lock()
|
c.mu.Lock()
|
||||||
defer c.mu.Unlock()
|
defer c.mu.Unlock()
|
||||||
c.ClientIP = ip
|
c.ClientIP = ip
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetClientIP safely gets the resolved client IP.
|
// GetClientIP safely gets the resolved client IP.
|
||||||
func (c *CapturedData) GetClientIP() string {
|
func (c *CapturedData) GetClientIP() netip.Addr {
|
||||||
c.mu.RLock()
|
c.mu.RLock()
|
||||||
defer c.mu.RUnlock()
|
defer c.mu.RUnlock()
|
||||||
return c.ClientIP
|
return c.ClientIP
|
||||||
@@ -161,13 +162,13 @@ func CapturedDataFromContext(ctx context.Context) *CapturedData {
|
|||||||
return data
|
return data
|
||||||
}
|
}
|
||||||
|
|
||||||
func withServiceId(ctx context.Context, serviceId string) context.Context {
|
func withServiceId(ctx context.Context, serviceId types.ServiceID) context.Context {
|
||||||
return context.WithValue(ctx, serviceIdKey, serviceId)
|
return context.WithValue(ctx, serviceIdKey, serviceId)
|
||||||
}
|
}
|
||||||
|
|
||||||
func ServiceIdFromContext(ctx context.Context) string {
|
func ServiceIdFromContext(ctx context.Context) types.ServiceID {
|
||||||
v := ctx.Value(serviceIdKey)
|
v := ctx.Value(serviceIdKey)
|
||||||
serviceId, ok := v.(string)
|
serviceId, ok := v.(types.ServiceID)
|
||||||
if !ok {
|
if !ok {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ func (nopTransport) RoundTrip(*http.Request) (*http.Response, error) {
|
|||||||
func BenchmarkServeHTTP(b *testing.B) {
|
func BenchmarkServeHTTP(b *testing.B) {
|
||||||
rp := proxy.NewReverseProxy(nopTransport{}, "http", nil, nil)
|
rp := proxy.NewReverseProxy(nopTransport{}, "http", nil, nil)
|
||||||
rp.AddMapping(proxy.Mapping{
|
rp.AddMapping(proxy.Mapping{
|
||||||
ID: rand.Text(),
|
ID: types.ServiceID(rand.Text()),
|
||||||
AccountID: types.AccountID(rand.Text()),
|
AccountID: types.AccountID(rand.Text()),
|
||||||
Host: "app.example.com",
|
Host: "app.example.com",
|
||||||
Paths: map[string]*proxy.PathTarget{
|
Paths: map[string]*proxy.PathTarget{
|
||||||
@@ -66,7 +66,7 @@ func BenchmarkServeHTTPHostCount(b *testing.B) {
|
|||||||
target = id
|
target = id
|
||||||
}
|
}
|
||||||
rp.AddMapping(proxy.Mapping{
|
rp.AddMapping(proxy.Mapping{
|
||||||
ID: id,
|
ID: types.ServiceID(id),
|
||||||
AccountID: types.AccountID(rand.Text()),
|
AccountID: types.AccountID(rand.Text()),
|
||||||
Host: host,
|
Host: host,
|
||||||
Paths: map[string]*proxy.PathTarget{
|
Paths: map[string]*proxy.PathTarget{
|
||||||
@@ -118,7 +118,7 @@ func BenchmarkServeHTTPPathCount(b *testing.B) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
rp.AddMapping(proxy.Mapping{
|
rp.AddMapping(proxy.Mapping{
|
||||||
ID: rand.Text(),
|
ID: types.ServiceID(rand.Text()),
|
||||||
AccountID: types.AccountID(rand.Text()),
|
AccountID: types.AccountID(rand.Text()),
|
||||||
Host: "app.example.com",
|
Host: "app.example.com",
|
||||||
Paths: paths,
|
Paths: paths,
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import (
|
|||||||
|
|
||||||
"github.com/netbirdio/netbird/proxy/auth"
|
"github.com/netbirdio/netbird/proxy/auth"
|
||||||
"github.com/netbirdio/netbird/proxy/internal/roundtrip"
|
"github.com/netbirdio/netbird/proxy/internal/roundtrip"
|
||||||
|
"github.com/netbirdio/netbird/proxy/internal/types"
|
||||||
"github.com/netbirdio/netbird/proxy/web"
|
"github.com/netbirdio/netbird/proxy/web"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -86,9 +87,7 @@ func (p *ReverseProxy) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
|||||||
ctx = roundtrip.WithSkipTLSVerify(ctx)
|
ctx = roundtrip.WithSkipTLSVerify(ctx)
|
||||||
}
|
}
|
||||||
if pt.RequestTimeout > 0 {
|
if pt.RequestTimeout > 0 {
|
||||||
var cancel context.CancelFunc
|
ctx = types.WithDialTimeout(ctx, pt.RequestTimeout)
|
||||||
ctx, cancel = context.WithTimeout(ctx, pt.RequestTimeout)
|
|
||||||
defer cancel()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
rewriteMatchedPath := result.matchedPath
|
rewriteMatchedPath := result.matchedPath
|
||||||
@@ -142,9 +141,9 @@ func (p *ReverseProxy) rewriteFunc(target *url.URL, matchedPath string, passHost
|
|||||||
r.Out.Header.Set(k, v)
|
r.Out.Header.Set(k, v)
|
||||||
}
|
}
|
||||||
|
|
||||||
clientIP := extractClientIP(r.In.RemoteAddr)
|
clientIP := extractHostIP(r.In.RemoteAddr)
|
||||||
|
|
||||||
if IsTrustedProxy(clientIP, p.trustedProxies) {
|
if isTrustedAddr(clientIP, p.trustedProxies) {
|
||||||
p.setTrustedForwardingHeaders(r, clientIP)
|
p.setTrustedForwardingHeaders(r, clientIP)
|
||||||
} else {
|
} else {
|
||||||
p.setUntrustedForwardingHeaders(r, clientIP)
|
p.setUntrustedForwardingHeaders(r, clientIP)
|
||||||
@@ -214,12 +213,14 @@ func normalizeHost(u *url.URL) string {
|
|||||||
// setTrustedForwardingHeaders appends to the existing forwarding header chain
|
// setTrustedForwardingHeaders appends to the existing forwarding header chain
|
||||||
// and preserves upstream-provided headers when the direct connection is from
|
// and preserves upstream-provided headers when the direct connection is from
|
||||||
// a trusted proxy.
|
// a trusted proxy.
|
||||||
func (p *ReverseProxy) setTrustedForwardingHeaders(r *httputil.ProxyRequest, clientIP string) {
|
func (p *ReverseProxy) setTrustedForwardingHeaders(r *httputil.ProxyRequest, clientIP netip.Addr) {
|
||||||
|
ipStr := clientIP.String()
|
||||||
|
|
||||||
// Append the direct connection IP to the existing X-Forwarded-For chain.
|
// Append the direct connection IP to the existing X-Forwarded-For chain.
|
||||||
if existing := r.In.Header.Get("X-Forwarded-For"); existing != "" {
|
if existing := r.In.Header.Get("X-Forwarded-For"); existing != "" {
|
||||||
r.Out.Header.Set("X-Forwarded-For", existing+", "+clientIP)
|
r.Out.Header.Set("X-Forwarded-For", existing+", "+ipStr)
|
||||||
} else {
|
} else {
|
||||||
r.Out.Header.Set("X-Forwarded-For", clientIP)
|
r.Out.Header.Set("X-Forwarded-For", ipStr)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Preserve upstream X-Real-IP if present; otherwise resolve through the chain.
|
// Preserve upstream X-Real-IP if present; otherwise resolve through the chain.
|
||||||
@@ -227,7 +228,7 @@ func (p *ReverseProxy) setTrustedForwardingHeaders(r *httputil.ProxyRequest, cli
|
|||||||
r.Out.Header.Set("X-Real-IP", realIP)
|
r.Out.Header.Set("X-Real-IP", realIP)
|
||||||
} else {
|
} else {
|
||||||
resolved := ResolveClientIP(r.In.RemoteAddr, r.In.Header.Get("X-Forwarded-For"), p.trustedProxies)
|
resolved := ResolveClientIP(r.In.RemoteAddr, r.In.Header.Get("X-Forwarded-For"), p.trustedProxies)
|
||||||
r.Out.Header.Set("X-Real-IP", resolved)
|
r.Out.Header.Set("X-Real-IP", resolved.String())
|
||||||
}
|
}
|
||||||
|
|
||||||
// Preserve upstream X-Forwarded-Host if present.
|
// Preserve upstream X-Forwarded-Host if present.
|
||||||
@@ -257,10 +258,11 @@ func (p *ReverseProxy) setTrustedForwardingHeaders(r *httputil.ProxyRequest, cli
|
|||||||
// sets them fresh based on the direct connection. This is the default
|
// sets them fresh based on the direct connection. This is the default
|
||||||
// behavior when no trusted proxies are configured or the direct connection
|
// behavior when no trusted proxies are configured or the direct connection
|
||||||
// is from an untrusted source.
|
// is from an untrusted source.
|
||||||
func (p *ReverseProxy) setUntrustedForwardingHeaders(r *httputil.ProxyRequest, clientIP string) {
|
func (p *ReverseProxy) setUntrustedForwardingHeaders(r *httputil.ProxyRequest, clientIP netip.Addr) {
|
||||||
|
ipStr := clientIP.String()
|
||||||
proto := auth.ResolveProto(p.forwardedProto, r.In.TLS)
|
proto := auth.ResolveProto(p.forwardedProto, r.In.TLS)
|
||||||
r.Out.Header.Set("X-Forwarded-For", clientIP)
|
r.Out.Header.Set("X-Forwarded-For", ipStr)
|
||||||
r.Out.Header.Set("X-Real-IP", clientIP)
|
r.Out.Header.Set("X-Real-IP", ipStr)
|
||||||
r.Out.Header.Set("X-Forwarded-Host", r.In.Host)
|
r.Out.Header.Set("X-Forwarded-Host", r.In.Host)
|
||||||
r.Out.Header.Set("X-Forwarded-Proto", proto)
|
r.Out.Header.Set("X-Forwarded-Proto", proto)
|
||||||
r.Out.Header.Set("X-Forwarded-Port", extractForwardedPort(r.In.Host, proto))
|
r.Out.Header.Set("X-Forwarded-Port", extractForwardedPort(r.In.Host, proto))
|
||||||
@@ -288,16 +290,6 @@ func stripSessionTokenQuery(r *httputil.ProxyRequest) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// extractClientIP extracts the IP address from an http.Request.RemoteAddr
|
|
||||||
// which is always in host:port format.
|
|
||||||
func extractClientIP(remoteAddr string) string {
|
|
||||||
ip, _, err := net.SplitHostPort(remoteAddr)
|
|
||||||
if err != nil {
|
|
||||||
return remoteAddr
|
|
||||||
}
|
|
||||||
return ip
|
|
||||||
}
|
|
||||||
|
|
||||||
// extractForwardedPort returns the port from the Host header if present,
|
// extractForwardedPort returns the port from the Host header if present,
|
||||||
// otherwise defaults to the standard port for the resolved protocol.
|
// otherwise defaults to the standard port for the resolved protocol.
|
||||||
func extractForwardedPort(host, resolvedProto string) string {
|
func extractForwardedPort(host, resolvedProto string) string {
|
||||||
@@ -327,10 +319,12 @@ func proxyErrorHandler(w http.ResponseWriter, r *http.Request, err error) {
|
|||||||
web.ServeErrorPage(w, r, code, title, message, requestID, status)
|
web.ServeErrorPage(w, r, code, title, message, requestID, status)
|
||||||
}
|
}
|
||||||
|
|
||||||
// getClientIP retrieves the resolved client IP from context.
|
// getClientIP retrieves the resolved client IP string from context.
|
||||||
func getClientIP(r *http.Request) string {
|
func getClientIP(r *http.Request) string {
|
||||||
if capturedData := CapturedDataFromContext(r.Context()); capturedData != nil {
|
if capturedData := CapturedDataFromContext(r.Context()); capturedData != nil {
|
||||||
return capturedData.GetClientIP()
|
if ip := capturedData.GetClientIP(); ip.IsValid() {
|
||||||
|
return ip.String()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -284,23 +284,23 @@ func TestRewriteFunc_URLRewriting(t *testing.T) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestExtractClientIP(t *testing.T) {
|
func TestExtractHostIP(t *testing.T) {
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
remoteAddr string
|
remoteAddr string
|
||||||
expected string
|
expected netip.Addr
|
||||||
}{
|
}{
|
||||||
{"IPv4 with port", "192.168.1.1:12345", "192.168.1.1"},
|
{"IPv4 with port", "192.168.1.1:12345", netip.MustParseAddr("192.168.1.1")},
|
||||||
{"IPv6 with port", "[::1]:12345", "::1"},
|
{"IPv6 with port", "[::1]:12345", netip.MustParseAddr("::1")},
|
||||||
{"IPv6 full with port", "[2001:db8::1]:443", "2001:db8::1"},
|
{"IPv6 full with port", "[2001:db8::1]:443", netip.MustParseAddr("2001:db8::1")},
|
||||||
{"IPv4 without port fallback", "192.168.1.1", "192.168.1.1"},
|
{"IPv4 without port fallback", "192.168.1.1", netip.MustParseAddr("192.168.1.1")},
|
||||||
{"IPv6 without brackets fallback", "::1", "::1"},
|
{"IPv6 without brackets fallback", "::1", netip.MustParseAddr("::1")},
|
||||||
{"empty string fallback", "", ""},
|
{"empty string fallback", "", netip.Addr{}},
|
||||||
{"public IP", "203.0.113.50:9999", "203.0.113.50"},
|
{"public IP", "203.0.113.50:9999", netip.MustParseAddr("203.0.113.50")},
|
||||||
}
|
}
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
assert.Equal(t, tt.expected, extractClientIP(tt.remoteAddr))
|
assert.Equal(t, tt.expected, extractHostIP(tt.remoteAddr))
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,8 +30,9 @@ type PathTarget struct {
|
|||||||
CustomHeaders map[string]string
|
CustomHeaders map[string]string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Mapping describes how a domain is routed by the HTTP reverse proxy.
|
||||||
type Mapping struct {
|
type Mapping struct {
|
||||||
ID string
|
ID types.ServiceID
|
||||||
AccountID types.AccountID
|
AccountID types.AccountID
|
||||||
Host string
|
Host string
|
||||||
Paths map[string]*PathTarget
|
Paths map[string]*PathTarget
|
||||||
@@ -42,7 +43,7 @@ type Mapping struct {
|
|||||||
type targetResult struct {
|
type targetResult struct {
|
||||||
target *PathTarget
|
target *PathTarget
|
||||||
matchedPath string
|
matchedPath string
|
||||||
serviceID string
|
serviceID types.ServiceID
|
||||||
accountID types.AccountID
|
accountID types.AccountID
|
||||||
passHostHeader bool
|
passHostHeader bool
|
||||||
rewriteRedirects bool
|
rewriteRedirects bool
|
||||||
@@ -101,8 +102,13 @@ func (p *ReverseProxy) AddMapping(m Mapping) {
|
|||||||
p.mappings[m.Host] = m
|
p.mappings[m.Host] = m
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *ReverseProxy) RemoveMapping(m Mapping) {
|
// RemoveMapping removes the mapping for the given host and reports whether it existed.
|
||||||
|
func (p *ReverseProxy) RemoveMapping(m Mapping) bool {
|
||||||
p.mappingsMux.Lock()
|
p.mappingsMux.Lock()
|
||||||
defer p.mappingsMux.Unlock()
|
defer p.mappingsMux.Unlock()
|
||||||
|
if _, ok := p.mappings[m.Host]; !ok {
|
||||||
|
return false
|
||||||
|
}
|
||||||
delete(p.mappings, m.Host)
|
delete(p.mappings, m.Host)
|
||||||
|
return true
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,21 +7,11 @@ import (
|
|||||||
|
|
||||||
// IsTrustedProxy checks if the given IP string falls within any of the trusted prefixes.
|
// IsTrustedProxy checks if the given IP string falls within any of the trusted prefixes.
|
||||||
func IsTrustedProxy(ipStr string, trusted []netip.Prefix) bool {
|
func IsTrustedProxy(ipStr string, trusted []netip.Prefix) bool {
|
||||||
if len(trusted) == 0 {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
addr, err := netip.ParseAddr(ipStr)
|
addr, err := netip.ParseAddr(ipStr)
|
||||||
if err != nil {
|
if err != nil || len(trusted) == 0 {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
return isTrustedAddr(addr.Unmap(), trusted)
|
||||||
for _, prefix := range trusted {
|
|
||||||
if prefix.Contains(addr) {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ResolveClientIP extracts the real client IP from X-Forwarded-For using the trusted proxy list.
|
// ResolveClientIP extracts the real client IP from X-Forwarded-For using the trusted proxy list.
|
||||||
@@ -30,10 +20,10 @@ func IsTrustedProxy(ipStr string, trusted []netip.Prefix) bool {
|
|||||||
//
|
//
|
||||||
// If the trusted list is empty or remoteAddr is not trusted, it returns the
|
// If the trusted list is empty or remoteAddr is not trusted, it returns the
|
||||||
// remoteAddr IP directly (ignoring any forwarding headers).
|
// remoteAddr IP directly (ignoring any forwarding headers).
|
||||||
func ResolveClientIP(remoteAddr, xff string, trusted []netip.Prefix) string {
|
func ResolveClientIP(remoteAddr, xff string, trusted []netip.Prefix) netip.Addr {
|
||||||
remoteIP := extractClientIP(remoteAddr)
|
remoteIP := extractHostIP(remoteAddr)
|
||||||
|
|
||||||
if len(trusted) == 0 || !IsTrustedProxy(remoteIP, trusted) {
|
if len(trusted) == 0 || !isTrustedAddr(remoteIP, trusted) {
|
||||||
return remoteIP
|
return remoteIP
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -47,14 +37,45 @@ func ResolveClientIP(remoteAddr, xff string, trusted []netip.Prefix) string {
|
|||||||
if ip == "" {
|
if ip == "" {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if !IsTrustedProxy(ip, trusted) {
|
addr, err := netip.ParseAddr(ip)
|
||||||
return ip
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
addr = addr.Unmap()
|
||||||
|
if !isTrustedAddr(addr, trusted) {
|
||||||
|
return addr
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// All IPs in XFF are trusted; return the leftmost as best guess.
|
// All IPs in XFF are trusted; return the leftmost as best guess.
|
||||||
if first := strings.TrimSpace(parts[0]); first != "" {
|
if first := strings.TrimSpace(parts[0]); first != "" {
|
||||||
return first
|
if addr, err := netip.ParseAddr(first); err == nil {
|
||||||
|
return addr.Unmap()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return remoteIP
|
return remoteIP
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// extractHostIP parses the IP from a host:port string and returns it unmapped.
|
||||||
|
func extractHostIP(hostPort string) netip.Addr {
|
||||||
|
if ap, err := netip.ParseAddrPort(hostPort); err == nil {
|
||||||
|
return ap.Addr().Unmap()
|
||||||
|
}
|
||||||
|
if addr, err := netip.ParseAddr(hostPort); err == nil {
|
||||||
|
return addr.Unmap()
|
||||||
|
}
|
||||||
|
return netip.Addr{}
|
||||||
|
}
|
||||||
|
|
||||||
|
// isTrustedAddr checks if the given address falls within any of the trusted prefixes.
|
||||||
|
func isTrustedAddr(addr netip.Addr, trusted []netip.Prefix) bool {
|
||||||
|
if !addr.IsValid() {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for _, prefix := range trusted {
|
||||||
|
if prefix.Contains(addr) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|||||||
@@ -48,77 +48,77 @@ func TestResolveClientIP(t *testing.T) {
|
|||||||
remoteAddr string
|
remoteAddr string
|
||||||
xff string
|
xff string
|
||||||
trusted []netip.Prefix
|
trusted []netip.Prefix
|
||||||
want string
|
want netip.Addr
|
||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
name: "empty trusted list returns RemoteAddr",
|
name: "empty trusted list returns RemoteAddr",
|
||||||
remoteAddr: "203.0.113.50:9999",
|
remoteAddr: "203.0.113.50:9999",
|
||||||
xff: "1.2.3.4",
|
xff: "1.2.3.4",
|
||||||
trusted: nil,
|
trusted: nil,
|
||||||
want: "203.0.113.50",
|
want: netip.MustParseAddr("203.0.113.50"),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "untrusted RemoteAddr ignores XFF",
|
name: "untrusted RemoteAddr ignores XFF",
|
||||||
remoteAddr: "203.0.113.50:9999",
|
remoteAddr: "203.0.113.50:9999",
|
||||||
xff: "1.2.3.4, 10.0.0.1",
|
xff: "1.2.3.4, 10.0.0.1",
|
||||||
trusted: trusted,
|
trusted: trusted,
|
||||||
want: "203.0.113.50",
|
want: netip.MustParseAddr("203.0.113.50"),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "trusted RemoteAddr with single client in XFF",
|
name: "trusted RemoteAddr with single client in XFF",
|
||||||
remoteAddr: "10.0.0.1:5000",
|
remoteAddr: "10.0.0.1:5000",
|
||||||
xff: "203.0.113.50",
|
xff: "203.0.113.50",
|
||||||
trusted: trusted,
|
trusted: trusted,
|
||||||
want: "203.0.113.50",
|
want: netip.MustParseAddr("203.0.113.50"),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "trusted RemoteAddr walks past trusted entries in XFF",
|
name: "trusted RemoteAddr walks past trusted entries in XFF",
|
||||||
remoteAddr: "10.0.0.1:5000",
|
remoteAddr: "10.0.0.1:5000",
|
||||||
xff: "203.0.113.50, 10.0.0.2, 172.16.0.5",
|
xff: "203.0.113.50, 10.0.0.2, 172.16.0.5",
|
||||||
trusted: trusted,
|
trusted: trusted,
|
||||||
want: "203.0.113.50",
|
want: netip.MustParseAddr("203.0.113.50"),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "trusted RemoteAddr with empty XFF falls back to RemoteAddr",
|
name: "trusted RemoteAddr with empty XFF falls back to RemoteAddr",
|
||||||
remoteAddr: "10.0.0.1:5000",
|
remoteAddr: "10.0.0.1:5000",
|
||||||
xff: "",
|
xff: "",
|
||||||
trusted: trusted,
|
trusted: trusted,
|
||||||
want: "10.0.0.1",
|
want: netip.MustParseAddr("10.0.0.1"),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "all XFF IPs trusted returns leftmost",
|
name: "all XFF IPs trusted returns leftmost",
|
||||||
remoteAddr: "10.0.0.1:5000",
|
remoteAddr: "10.0.0.1:5000",
|
||||||
xff: "10.0.0.2, 172.16.0.1, 10.0.0.3",
|
xff: "10.0.0.2, 172.16.0.1, 10.0.0.3",
|
||||||
trusted: trusted,
|
trusted: trusted,
|
||||||
want: "10.0.0.2",
|
want: netip.MustParseAddr("10.0.0.2"),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "XFF with whitespace",
|
name: "XFF with whitespace",
|
||||||
remoteAddr: "10.0.0.1:5000",
|
remoteAddr: "10.0.0.1:5000",
|
||||||
xff: " 203.0.113.50 , 10.0.0.2 ",
|
xff: " 203.0.113.50 , 10.0.0.2 ",
|
||||||
trusted: trusted,
|
trusted: trusted,
|
||||||
want: "203.0.113.50",
|
want: netip.MustParseAddr("203.0.113.50"),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "XFF with empty segments",
|
name: "XFF with empty segments",
|
||||||
remoteAddr: "10.0.0.1:5000",
|
remoteAddr: "10.0.0.1:5000",
|
||||||
xff: "203.0.113.50,,10.0.0.2",
|
xff: "203.0.113.50,,10.0.0.2",
|
||||||
trusted: trusted,
|
trusted: trusted,
|
||||||
want: "203.0.113.50",
|
want: netip.MustParseAddr("203.0.113.50"),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "multi-hop with mixed trust",
|
name: "multi-hop with mixed trust",
|
||||||
remoteAddr: "10.0.0.1:5000",
|
remoteAddr: "10.0.0.1:5000",
|
||||||
xff: "8.8.8.8, 203.0.113.50, 172.16.0.1",
|
xff: "8.8.8.8, 203.0.113.50, 172.16.0.1",
|
||||||
trusted: trusted,
|
trusted: trusted,
|
||||||
want: "203.0.113.50",
|
want: netip.MustParseAddr("203.0.113.50"),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "RemoteAddr without port",
|
name: "RemoteAddr without port",
|
||||||
remoteAddr: "10.0.0.1",
|
remoteAddr: "10.0.0.1",
|
||||||
xff: "203.0.113.50",
|
xff: "203.0.113.50",
|
||||||
trusted: trusted,
|
trusted: trusted,
|
||||||
want: "203.0.113.50",
|
want: netip.MustParseAddr("203.0.113.50"),
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
|
|||||||
+140
-116
@@ -5,6 +5,7 @@ import (
|
|||||||
"crypto/tls"
|
"crypto/tls"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
@@ -14,11 +15,12 @@ import (
|
|||||||
"golang.org/x/exp/maps"
|
"golang.org/x/exp/maps"
|
||||||
"golang.zx2c4.com/wireguard/wgctrl/wgtypes"
|
"golang.zx2c4.com/wireguard/wgctrl/wgtypes"
|
||||||
"google.golang.org/grpc"
|
"google.golang.org/grpc"
|
||||||
|
"google.golang.org/grpc/codes"
|
||||||
|
grpcstatus "google.golang.org/grpc/status"
|
||||||
|
|
||||||
"github.com/netbirdio/netbird/client/embed"
|
"github.com/netbirdio/netbird/client/embed"
|
||||||
nberrors "github.com/netbirdio/netbird/client/errors"
|
nberrors "github.com/netbirdio/netbird/client/errors"
|
||||||
"github.com/netbirdio/netbird/proxy/internal/types"
|
"github.com/netbirdio/netbird/proxy/internal/types"
|
||||||
"github.com/netbirdio/netbird/shared/management/domain"
|
|
||||||
"github.com/netbirdio/netbird/shared/management/proto"
|
"github.com/netbirdio/netbird/shared/management/proto"
|
||||||
"github.com/netbirdio/netbird/util"
|
"github.com/netbirdio/netbird/util"
|
||||||
)
|
)
|
||||||
@@ -26,7 +28,22 @@ import (
|
|||||||
const deviceNamePrefix = "ingress-proxy-"
|
const deviceNamePrefix = "ingress-proxy-"
|
||||||
|
|
||||||
// backendKey identifies a backend by its host:port from the target URL.
|
// backendKey identifies a backend by its host:port from the target URL.
|
||||||
type backendKey = string
|
type backendKey string
|
||||||
|
|
||||||
|
// ServiceKey uniquely identifies a service (HTTP reverse proxy or L4 service)
|
||||||
|
// that holds a reference to an embedded NetBird client. Callers should use the
|
||||||
|
// DomainServiceKey and L4ServiceKey constructors to avoid namespace collisions.
|
||||||
|
type ServiceKey string
|
||||||
|
|
||||||
|
// DomainServiceKey returns a ServiceKey for an HTTP/TLS domain-based service.
|
||||||
|
func DomainServiceKey(domain string) ServiceKey {
|
||||||
|
return ServiceKey("domain:" + domain)
|
||||||
|
}
|
||||||
|
|
||||||
|
// L4ServiceKey returns a ServiceKey for an L4 service (TCP/UDP).
|
||||||
|
func L4ServiceKey(id types.ServiceID) ServiceKey {
|
||||||
|
return ServiceKey("l4:" + id)
|
||||||
|
}
|
||||||
|
|
||||||
var (
|
var (
|
||||||
// ErrNoAccountID is returned when a request context is missing the account ID.
|
// ErrNoAccountID is returned when a request context is missing the account ID.
|
||||||
@@ -39,24 +56,24 @@ var (
|
|||||||
ErrTooManyInflight = errors.New("too many in-flight requests")
|
ErrTooManyInflight = errors.New("too many in-flight requests")
|
||||||
)
|
)
|
||||||
|
|
||||||
// domainInfo holds metadata about a registered domain.
|
// serviceInfo holds metadata about a registered service.
|
||||||
type domainInfo struct {
|
type serviceInfo struct {
|
||||||
serviceID string
|
serviceID types.ServiceID
|
||||||
}
|
}
|
||||||
|
|
||||||
type domainNotification struct {
|
type serviceNotification struct {
|
||||||
domain domain.Domain
|
key ServiceKey
|
||||||
serviceID string
|
serviceID types.ServiceID
|
||||||
}
|
}
|
||||||
|
|
||||||
// clientEntry holds an embedded NetBird client and tracks which domains use it.
|
// clientEntry holds an embedded NetBird client and tracks which services use it.
|
||||||
type clientEntry struct {
|
type clientEntry struct {
|
||||||
client *embed.Client
|
client *embed.Client
|
||||||
transport *http.Transport
|
transport *http.Transport
|
||||||
// insecureTransport is a clone of transport with TLS verification disabled,
|
// insecureTransport is a clone of transport with TLS verification disabled,
|
||||||
// used when per-target skip_tls_verify is set.
|
// used when per-target skip_tls_verify is set.
|
||||||
insecureTransport *http.Transport
|
insecureTransport *http.Transport
|
||||||
domains map[domain.Domain]domainInfo
|
services map[ServiceKey]serviceInfo
|
||||||
createdAt time.Time
|
createdAt time.Time
|
||||||
started bool
|
started bool
|
||||||
// Per-backend in-flight limiting keyed by target host:port.
|
// Per-backend in-flight limiting keyed by target host:port.
|
||||||
@@ -93,12 +110,12 @@ func (e *clientEntry) acquireInflight(backend backendKey) (release func(), ok bo
|
|||||||
// ClientConfig holds configuration for the embedded NetBird client.
|
// ClientConfig holds configuration for the embedded NetBird client.
|
||||||
type ClientConfig struct {
|
type ClientConfig struct {
|
||||||
MgmtAddr string
|
MgmtAddr string
|
||||||
WGPort int
|
WGPort uint16
|
||||||
PreSharedKey string
|
PreSharedKey string
|
||||||
}
|
}
|
||||||
|
|
||||||
type statusNotifier interface {
|
type statusNotifier interface {
|
||||||
NotifyStatus(ctx context.Context, accountID, serviceID, domain string, connected bool) error
|
NotifyStatus(ctx context.Context, accountID types.AccountID, serviceID types.ServiceID, connected bool) error
|
||||||
}
|
}
|
||||||
|
|
||||||
type managementClient interface {
|
type managementClient interface {
|
||||||
@@ -107,7 +124,7 @@ type managementClient interface {
|
|||||||
|
|
||||||
// NetBird provides an http.RoundTripper implementation
|
// NetBird provides an http.RoundTripper implementation
|
||||||
// backed by underlying NetBird connections.
|
// backed by underlying NetBird connections.
|
||||||
// Clients are keyed by AccountID, allowing multiple domains to share the same connection.
|
// Clients are keyed by AccountID, allowing multiple services to share the same connection.
|
||||||
type NetBird struct {
|
type NetBird struct {
|
||||||
proxyID string
|
proxyID string
|
||||||
proxyAddr string
|
proxyAddr string
|
||||||
@@ -124,11 +141,11 @@ type NetBird struct {
|
|||||||
|
|
||||||
// ClientDebugInfo contains debug information about a client.
|
// ClientDebugInfo contains debug information about a client.
|
||||||
type ClientDebugInfo struct {
|
type ClientDebugInfo struct {
|
||||||
AccountID types.AccountID
|
AccountID types.AccountID
|
||||||
DomainCount int
|
ServiceCount int
|
||||||
Domains domain.List
|
ServiceKeys []string
|
||||||
HasClient bool
|
HasClient bool
|
||||||
CreatedAt time.Time
|
CreatedAt time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
// accountIDContextKey is the context key for storing the account ID.
|
// accountIDContextKey is the context key for storing the account ID.
|
||||||
@@ -137,37 +154,37 @@ type accountIDContextKey struct{}
|
|||||||
// skipTLSVerifyContextKey is the context key for requesting insecure TLS.
|
// skipTLSVerifyContextKey is the context key for requesting insecure TLS.
|
||||||
type skipTLSVerifyContextKey struct{}
|
type skipTLSVerifyContextKey struct{}
|
||||||
|
|
||||||
// AddPeer registers a domain for an account. If the account doesn't have a client yet,
|
// AddPeer registers a service for an account. If the account doesn't have a client yet,
|
||||||
// one is created by authenticating with the management server using the provided token.
|
// one is created by authenticating with the management server using the provided token.
|
||||||
// Multiple domains can share the same client.
|
// Multiple services can share the same client.
|
||||||
func (n *NetBird) AddPeer(ctx context.Context, accountID types.AccountID, d domain.Domain, authToken, serviceID string) error {
|
func (n *NetBird) AddPeer(ctx context.Context, accountID types.AccountID, key ServiceKey, authToken string, serviceID types.ServiceID) error {
|
||||||
|
si := serviceInfo{serviceID: serviceID}
|
||||||
|
|
||||||
n.clientsMux.Lock()
|
n.clientsMux.Lock()
|
||||||
|
|
||||||
entry, exists := n.clients[accountID]
|
entry, exists := n.clients[accountID]
|
||||||
if exists {
|
if exists {
|
||||||
// Client already exists for this account, just register the domain
|
entry.services[key] = si
|
||||||
entry.domains[d] = domainInfo{serviceID: serviceID}
|
|
||||||
started := entry.started
|
started := entry.started
|
||||||
n.clientsMux.Unlock()
|
n.clientsMux.Unlock()
|
||||||
|
|
||||||
n.logger.WithFields(log.Fields{
|
n.logger.WithFields(log.Fields{
|
||||||
"account_id": accountID,
|
"account_id": accountID,
|
||||||
"domain": d,
|
"service_key": key,
|
||||||
}).Debug("registered domain with existing client")
|
}).Debug("registered service with existing client")
|
||||||
|
|
||||||
// If client is already started, notify this domain as connected immediately
|
|
||||||
if started && n.statusNotifier != nil {
|
if started && n.statusNotifier != nil {
|
||||||
if err := n.statusNotifier.NotifyStatus(ctx, string(accountID), serviceID, string(d), true); err != nil {
|
if err := n.statusNotifier.NotifyStatus(ctx, accountID, serviceID, true); err != nil {
|
||||||
n.logger.WithFields(log.Fields{
|
n.logger.WithFields(log.Fields{
|
||||||
"account_id": accountID,
|
"account_id": accountID,
|
||||||
"domain": d,
|
"service_key": key,
|
||||||
}).WithError(err).Warn("failed to notify status for existing client")
|
}).WithError(err).Warn("failed to notify status for existing client")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
entry, err := n.createClientEntry(ctx, accountID, d, authToken, serviceID)
|
entry, err := n.createClientEntry(ctx, accountID, key, authToken, si)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
n.clientsMux.Unlock()
|
n.clientsMux.Unlock()
|
||||||
return err
|
return err
|
||||||
@@ -177,8 +194,8 @@ func (n *NetBird) AddPeer(ctx context.Context, accountID types.AccountID, d doma
|
|||||||
n.clientsMux.Unlock()
|
n.clientsMux.Unlock()
|
||||||
|
|
||||||
n.logger.WithFields(log.Fields{
|
n.logger.WithFields(log.Fields{
|
||||||
"account_id": accountID,
|
"account_id": accountID,
|
||||||
"domain": d,
|
"service_key": key,
|
||||||
}).Info("created new client for account")
|
}).Info("created new client for account")
|
||||||
|
|
||||||
// Attempt to start the client in the background; if this fails we will
|
// Attempt to start the client in the background; if this fails we will
|
||||||
@@ -190,7 +207,8 @@ func (n *NetBird) AddPeer(ctx context.Context, accountID types.AccountID, d doma
|
|||||||
|
|
||||||
// createClientEntry generates a WireGuard keypair, authenticates with management,
|
// createClientEntry generates a WireGuard keypair, authenticates with management,
|
||||||
// and creates an embedded NetBird client. Must be called with clientsMux held.
|
// and creates an embedded NetBird client. Must be called with clientsMux held.
|
||||||
func (n *NetBird) createClientEntry(ctx context.Context, accountID types.AccountID, d domain.Domain, authToken, serviceID string) (*clientEntry, error) {
|
func (n *NetBird) createClientEntry(ctx context.Context, accountID types.AccountID, key ServiceKey, authToken string, si serviceInfo) (*clientEntry, error) {
|
||||||
|
serviceID := si.serviceID
|
||||||
n.logger.WithFields(log.Fields{
|
n.logger.WithFields(log.Fields{
|
||||||
"account_id": accountID,
|
"account_id": accountID,
|
||||||
"service_id": serviceID,
|
"service_id": serviceID,
|
||||||
@@ -209,7 +227,7 @@ func (n *NetBird) createClientEntry(ctx context.Context, accountID types.Account
|
|||||||
}).Debug("authenticating new proxy peer with management")
|
}).Debug("authenticating new proxy peer with management")
|
||||||
|
|
||||||
resp, err := n.mgmtClient.CreateProxyPeer(ctx, &proto.CreateProxyPeerRequest{
|
resp, err := n.mgmtClient.CreateProxyPeer(ctx, &proto.CreateProxyPeerRequest{
|
||||||
ServiceId: serviceID,
|
ServiceId: string(serviceID),
|
||||||
AccountId: string(accountID),
|
AccountId: string(accountID),
|
||||||
Token: authToken,
|
Token: authToken,
|
||||||
WireguardPublicKey: publicKey.String(),
|
WireguardPublicKey: publicKey.String(),
|
||||||
@@ -240,13 +258,14 @@ func (n *NetBird) createClientEntry(ctx context.Context, accountID types.Account
|
|||||||
|
|
||||||
// Create embedded NetBird client with the generated private key.
|
// Create embedded NetBird client with the generated private key.
|
||||||
// The peer has already been created via CreateProxyPeer RPC with the public key.
|
// The peer has already been created via CreateProxyPeer RPC with the public key.
|
||||||
|
wgPort := int(n.clientCfg.WGPort)
|
||||||
client, err := embed.New(embed.Options{
|
client, err := embed.New(embed.Options{
|
||||||
DeviceName: deviceNamePrefix + n.proxyID,
|
DeviceName: deviceNamePrefix + n.proxyID,
|
||||||
ManagementURL: n.clientCfg.MgmtAddr,
|
ManagementURL: n.clientCfg.MgmtAddr,
|
||||||
PrivateKey: privateKey.String(),
|
PrivateKey: privateKey.String(),
|
||||||
LogLevel: log.WarnLevel.String(),
|
LogLevel: log.WarnLevel.String(),
|
||||||
BlockInbound: true,
|
BlockInbound: true,
|
||||||
WireguardPort: &n.clientCfg.WGPort,
|
WireguardPort: &wgPort,
|
||||||
PreSharedKey: n.clientCfg.PreSharedKey,
|
PreSharedKey: n.clientCfg.PreSharedKey,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -257,7 +276,7 @@ func (n *NetBird) createClientEntry(ctx context.Context, accountID types.Account
|
|||||||
// the client's HTTPClient to avoid issues with request validation that do
|
// the client's HTTPClient to avoid issues with request validation that do
|
||||||
// not work with reverse proxied requests.
|
// not work with reverse proxied requests.
|
||||||
transport := &http.Transport{
|
transport := &http.Transport{
|
||||||
DialContext: client.DialContext,
|
DialContext: dialWithTimeout(client.DialContext),
|
||||||
ForceAttemptHTTP2: true,
|
ForceAttemptHTTP2: true,
|
||||||
MaxIdleConns: n.transportCfg.maxIdleConns,
|
MaxIdleConns: n.transportCfg.maxIdleConns,
|
||||||
MaxIdleConnsPerHost: n.transportCfg.maxIdleConnsPerHost,
|
MaxIdleConnsPerHost: n.transportCfg.maxIdleConnsPerHost,
|
||||||
@@ -276,7 +295,7 @@ func (n *NetBird) createClientEntry(ctx context.Context, accountID types.Account
|
|||||||
|
|
||||||
return &clientEntry{
|
return &clientEntry{
|
||||||
client: client,
|
client: client,
|
||||||
domains: map[domain.Domain]domainInfo{d: {serviceID: serviceID}},
|
services: map[ServiceKey]serviceInfo{key: si},
|
||||||
transport: transport,
|
transport: transport,
|
||||||
insecureTransport: insecureTransport,
|
insecureTransport: insecureTransport,
|
||||||
createdAt: time.Now(),
|
createdAt: time.Now(),
|
||||||
@@ -286,7 +305,7 @@ func (n *NetBird) createClientEntry(ctx context.Context, accountID types.Account
|
|||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// runClientStartup starts the client and notifies registered domains on success.
|
// runClientStartup starts the client and notifies registered services on success.
|
||||||
func (n *NetBird) runClientStartup(ctx context.Context, accountID types.AccountID, client *embed.Client) {
|
func (n *NetBird) runClientStartup(ctx context.Context, accountID types.AccountID, client *embed.Client) {
|
||||||
startCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
startCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
@@ -300,16 +319,16 @@ func (n *NetBird) runClientStartup(ctx context.Context, accountID types.AccountI
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Mark client as started and collect domains to notify outside the lock.
|
// Mark client as started and collect services to notify outside the lock.
|
||||||
n.clientsMux.Lock()
|
n.clientsMux.Lock()
|
||||||
entry, exists := n.clients[accountID]
|
entry, exists := n.clients[accountID]
|
||||||
if exists {
|
if exists {
|
||||||
entry.started = true
|
entry.started = true
|
||||||
}
|
}
|
||||||
var domainsToNotify []domainNotification
|
var toNotify []serviceNotification
|
||||||
if exists {
|
if exists {
|
||||||
for dom, info := range entry.domains {
|
for key, info := range entry.services {
|
||||||
domainsToNotify = append(domainsToNotify, domainNotification{domain: dom, serviceID: info.serviceID})
|
toNotify = append(toNotify, serviceNotification{key: key, serviceID: info.serviceID})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
n.clientsMux.Unlock()
|
n.clientsMux.Unlock()
|
||||||
@@ -317,24 +336,24 @@ func (n *NetBird) runClientStartup(ctx context.Context, accountID types.AccountI
|
|||||||
if n.statusNotifier == nil {
|
if n.statusNotifier == nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
for _, dn := range domainsToNotify {
|
for _, sn := range toNotify {
|
||||||
if err := n.statusNotifier.NotifyStatus(ctx, string(accountID), dn.serviceID, string(dn.domain), true); err != nil {
|
if err := n.statusNotifier.NotifyStatus(ctx, accountID, sn.serviceID, true); err != nil {
|
||||||
n.logger.WithFields(log.Fields{
|
n.logger.WithFields(log.Fields{
|
||||||
"account_id": accountID,
|
"account_id": accountID,
|
||||||
"domain": dn.domain,
|
"service_key": sn.key,
|
||||||
}).WithError(err).Warn("failed to notify tunnel connection status")
|
}).WithError(err).Warn("failed to notify tunnel connection status")
|
||||||
} else {
|
} else {
|
||||||
n.logger.WithFields(log.Fields{
|
n.logger.WithFields(log.Fields{
|
||||||
"account_id": accountID,
|
"account_id": accountID,
|
||||||
"domain": dn.domain,
|
"service_key": sn.key,
|
||||||
}).Info("notified management about tunnel connection")
|
}).Info("notified management about tunnel connection")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// RemovePeer unregisters a domain from an account. The client is only stopped
|
// RemovePeer unregisters a service from an account. The client is only stopped
|
||||||
// when no domains are using it anymore.
|
// when no services are using it anymore.
|
||||||
func (n *NetBird) RemovePeer(ctx context.Context, accountID types.AccountID, d domain.Domain) error {
|
func (n *NetBird) RemovePeer(ctx context.Context, accountID types.AccountID, key ServiceKey) error {
|
||||||
n.clientsMux.Lock()
|
n.clientsMux.Lock()
|
||||||
|
|
||||||
entry, exists := n.clients[accountID]
|
entry, exists := n.clients[accountID]
|
||||||
@@ -344,74 +363,65 @@ func (n *NetBird) RemovePeer(ctx context.Context, accountID types.AccountID, d d
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get domain info before deleting
|
si, svcExists := entry.services[key]
|
||||||
domInfo, domainExists := entry.domains[d]
|
if !svcExists {
|
||||||
if !domainExists {
|
|
||||||
n.clientsMux.Unlock()
|
n.clientsMux.Unlock()
|
||||||
n.logger.WithFields(log.Fields{
|
n.logger.WithFields(log.Fields{
|
||||||
"account_id": accountID,
|
"account_id": accountID,
|
||||||
"domain": d,
|
"service_key": key,
|
||||||
}).Debug("remove peer: domain not registered")
|
}).Debug("remove peer: service not registered")
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
delete(entry.domains, d)
|
delete(entry.services, key)
|
||||||
|
|
||||||
// If there are still domains using this client, keep it running
|
|
||||||
if len(entry.domains) > 0 {
|
|
||||||
n.clientsMux.Unlock()
|
|
||||||
|
|
||||||
|
stopClient := len(entry.services) == 0
|
||||||
|
var client *embed.Client
|
||||||
|
var transport, insecureTransport *http.Transport
|
||||||
|
if stopClient {
|
||||||
|
n.logger.WithField("account_id", accountID).Info("stopping client, no more services")
|
||||||
|
client = entry.client
|
||||||
|
transport = entry.transport
|
||||||
|
insecureTransport = entry.insecureTransport
|
||||||
|
delete(n.clients, accountID)
|
||||||
|
} else {
|
||||||
n.logger.WithFields(log.Fields{
|
n.logger.WithFields(log.Fields{
|
||||||
"account_id": accountID,
|
"account_id": accountID,
|
||||||
"domain": d,
|
"service_key": key,
|
||||||
"remaining_domains": len(entry.domains),
|
"remaining_services": len(entry.services),
|
||||||
}).Debug("unregistered domain, client still in use")
|
}).Debug("unregistered service, client still in use")
|
||||||
|
|
||||||
// Notify this domain as disconnected
|
|
||||||
if n.statusNotifier != nil {
|
|
||||||
if err := n.statusNotifier.NotifyStatus(ctx, string(accountID), domInfo.serviceID, string(d), false); err != nil {
|
|
||||||
n.logger.WithFields(log.Fields{
|
|
||||||
"account_id": accountID,
|
|
||||||
"domain": d,
|
|
||||||
}).WithError(err).Warn("failed to notify tunnel disconnection status")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// No more domains using this client, stop it
|
|
||||||
n.logger.WithFields(log.Fields{
|
|
||||||
"account_id": accountID,
|
|
||||||
}).Info("stopping client, no more domains")
|
|
||||||
|
|
||||||
client := entry.client
|
|
||||||
transport := entry.transport
|
|
||||||
insecureTransport := entry.insecureTransport
|
|
||||||
delete(n.clients, accountID)
|
|
||||||
n.clientsMux.Unlock()
|
n.clientsMux.Unlock()
|
||||||
|
|
||||||
// Notify disconnection before stopping
|
n.notifyDisconnect(ctx, accountID, key, si.serviceID)
|
||||||
if n.statusNotifier != nil {
|
|
||||||
if err := n.statusNotifier.NotifyStatus(ctx, string(accountID), domInfo.serviceID, string(d), false); err != nil {
|
if stopClient {
|
||||||
n.logger.WithFields(log.Fields{
|
transport.CloseIdleConnections()
|
||||||
"account_id": accountID,
|
insecureTransport.CloseIdleConnections()
|
||||||
"domain": d,
|
if err := client.Stop(ctx); err != nil {
|
||||||
}).WithError(err).Warn("failed to notify tunnel disconnection status")
|
n.logger.WithField("account_id", accountID).WithError(err).Warn("failed to stop netbird client")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
transport.CloseIdleConnections()
|
|
||||||
insecureTransport.CloseIdleConnections()
|
|
||||||
|
|
||||||
if err := client.Stop(ctx); err != nil {
|
|
||||||
n.logger.WithFields(log.Fields{
|
|
||||||
"account_id": accountID,
|
|
||||||
}).WithError(err).Warn("failed to stop netbird client")
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (n *NetBird) notifyDisconnect(ctx context.Context, accountID types.AccountID, key ServiceKey, serviceID types.ServiceID) {
|
||||||
|
if n.statusNotifier == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := n.statusNotifier.NotifyStatus(ctx, accountID, serviceID, false); err != nil {
|
||||||
|
if s, ok := grpcstatus.FromError(err); ok && s.Code() == codes.NotFound {
|
||||||
|
n.logger.WithField("service_key", key).Debug("service already removed, skipping disconnect notification")
|
||||||
|
} else {
|
||||||
|
n.logger.WithFields(log.Fields{
|
||||||
|
"account_id": accountID,
|
||||||
|
"service_key": key,
|
||||||
|
}).WithError(err).Warn("failed to notify tunnel disconnection status")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// RoundTrip implements http.RoundTripper. It looks up the client for the account
|
// RoundTrip implements http.RoundTripper. It looks up the client for the account
|
||||||
// specified in the request context and uses it to dial the backend.
|
// specified in the request context and uses it to dial the backend.
|
||||||
func (n *NetBird) RoundTrip(req *http.Request) (*http.Response, error) {
|
func (n *NetBird) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||||
@@ -435,7 +445,7 @@ func (n *NetBird) RoundTrip(req *http.Request) (*http.Response, error) {
|
|||||||
}
|
}
|
||||||
n.clientsMux.RUnlock()
|
n.clientsMux.RUnlock()
|
||||||
|
|
||||||
release, ok := entry.acquireInflight(req.URL.Host)
|
release, ok := entry.acquireInflight(backendKey(req.URL.Host))
|
||||||
defer release()
|
defer release()
|
||||||
if !ok {
|
if !ok {
|
||||||
return nil, ErrTooManyInflight
|
return nil, ErrTooManyInflight
|
||||||
@@ -496,16 +506,16 @@ func (n *NetBird) HasClient(accountID types.AccountID) bool {
|
|||||||
return exists
|
return exists
|
||||||
}
|
}
|
||||||
|
|
||||||
// DomainCount returns the number of domains registered for the given account.
|
// ServiceCount returns the number of services registered for the given account.
|
||||||
// Returns 0 if the account has no client.
|
// Returns 0 if the account has no client.
|
||||||
func (n *NetBird) DomainCount(accountID types.AccountID) int {
|
func (n *NetBird) ServiceCount(accountID types.AccountID) int {
|
||||||
n.clientsMux.RLock()
|
n.clientsMux.RLock()
|
||||||
defer n.clientsMux.RUnlock()
|
defer n.clientsMux.RUnlock()
|
||||||
entry, exists := n.clients[accountID]
|
entry, exists := n.clients[accountID]
|
||||||
if !exists {
|
if !exists {
|
||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
return len(entry.domains)
|
return len(entry.services)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ClientCount returns the total number of active clients.
|
// ClientCount returns the total number of active clients.
|
||||||
@@ -533,16 +543,16 @@ func (n *NetBird) ListClientsForDebug() map[types.AccountID]ClientDebugInfo {
|
|||||||
|
|
||||||
result := make(map[types.AccountID]ClientDebugInfo)
|
result := make(map[types.AccountID]ClientDebugInfo)
|
||||||
for accountID, entry := range n.clients {
|
for accountID, entry := range n.clients {
|
||||||
domains := make(domain.List, 0, len(entry.domains))
|
keys := make([]string, 0, len(entry.services))
|
||||||
for d := range entry.domains {
|
for k := range entry.services {
|
||||||
domains = append(domains, d)
|
keys = append(keys, string(k))
|
||||||
}
|
}
|
||||||
result[accountID] = ClientDebugInfo{
|
result[accountID] = ClientDebugInfo{
|
||||||
AccountID: accountID,
|
AccountID: accountID,
|
||||||
DomainCount: len(entry.domains),
|
ServiceCount: len(entry.services),
|
||||||
Domains: domains,
|
ServiceKeys: keys,
|
||||||
HasClient: entry.client != nil,
|
HasClient: entry.client != nil,
|
||||||
CreatedAt: entry.createdAt,
|
CreatedAt: entry.createdAt,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return result
|
return result
|
||||||
@@ -581,6 +591,20 @@ func NewNetBird(proxyID, proxyAddr string, clientCfg ClientConfig, logger *log.L
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// dialWithTimeout wraps a DialContext function so that any dial timeout
|
||||||
|
// stored in the context (via types.WithDialTimeout) is applied only to
|
||||||
|
// the connection establishment phase, not the full request lifetime.
|
||||||
|
func dialWithTimeout(dial func(ctx context.Context, network, addr string) (net.Conn, error)) func(ctx context.Context, network, addr string) (net.Conn, error) {
|
||||||
|
return func(ctx context.Context, network, addr string) (net.Conn, error) {
|
||||||
|
if d, ok := types.DialTimeoutFromContext(ctx); ok {
|
||||||
|
var cancel context.CancelFunc
|
||||||
|
ctx, cancel = context.WithTimeout(ctx, d)
|
||||||
|
defer cancel()
|
||||||
|
}
|
||||||
|
return dial(ctx, network, addr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// WithAccountID adds the account ID to the context.
|
// WithAccountID adds the account ID to the context.
|
||||||
func WithAccountID(ctx context.Context, accountID types.AccountID) context.Context {
|
func WithAccountID(ctx context.Context, accountID types.AccountID) context.Context {
|
||||||
return context.WithValue(ctx, accountIDContextKey{}, accountID)
|
return context.WithValue(ctx, accountIDContextKey{}, accountID)
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package roundtrip
|
package roundtrip
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"crypto/rand"
|
"crypto/rand"
|
||||||
"math/big"
|
"math/big"
|
||||||
"sync"
|
"sync"
|
||||||
@@ -8,7 +9,6 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/netbirdio/netbird/proxy/internal/types"
|
"github.com/netbirdio/netbird/proxy/internal/types"
|
||||||
"github.com/netbirdio/netbird/shared/management/domain"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// Simple benchmark for comparison with AddPeer contention.
|
// Simple benchmark for comparison with AddPeer contention.
|
||||||
@@ -29,9 +29,9 @@ func BenchmarkHasClient(b *testing.B) {
|
|||||||
target = id
|
target = id
|
||||||
}
|
}
|
||||||
nb.clients[id] = &clientEntry{
|
nb.clients[id] = &clientEntry{
|
||||||
domains: map[domain.Domain]domainInfo{
|
services: map[ServiceKey]serviceInfo{
|
||||||
domain.Domain(rand.Text()): {
|
ServiceKey(rand.Text()): {
|
||||||
serviceID: rand.Text(),
|
serviceID: types.ServiceID(rand.Text()),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
createdAt: time.Now(),
|
createdAt: time.Now(),
|
||||||
@@ -70,9 +70,9 @@ func BenchmarkHasClientDuringAddPeer(b *testing.B) {
|
|||||||
target = id
|
target = id
|
||||||
}
|
}
|
||||||
nb.clients[id] = &clientEntry{
|
nb.clients[id] = &clientEntry{
|
||||||
domains: map[domain.Domain]domainInfo{
|
services: map[ServiceKey]serviceInfo{
|
||||||
domain.Domain(rand.Text()): {
|
ServiceKey(rand.Text()): {
|
||||||
serviceID: rand.Text(),
|
serviceID: types.ServiceID(rand.Text()),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
createdAt: time.Now(),
|
createdAt: time.Now(),
|
||||||
@@ -81,19 +81,22 @@ func BenchmarkHasClientDuringAddPeer(b *testing.B) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Launch workers that continuously call AddPeer with new random accountIDs.
|
// Launch workers that continuously call AddPeer with new random accountIDs.
|
||||||
|
ctx, cancel := context.WithCancel(b.Context())
|
||||||
var wg sync.WaitGroup
|
var wg sync.WaitGroup
|
||||||
for range addPeerWorkers {
|
for range addPeerWorkers {
|
||||||
wg.Go(func() {
|
wg.Add(1)
|
||||||
for {
|
go func() {
|
||||||
if err := nb.AddPeer(b.Context(),
|
defer wg.Done()
|
||||||
|
for ctx.Err() == nil {
|
||||||
|
if err := nb.AddPeer(ctx,
|
||||||
types.AccountID(rand.Text()),
|
types.AccountID(rand.Text()),
|
||||||
domain.Domain(rand.Text()),
|
ServiceKey(rand.Text()),
|
||||||
rand.Text(),
|
rand.Text(),
|
||||||
rand.Text()); err != nil {
|
types.ServiceID(rand.Text())); err != nil {
|
||||||
b.Log(err)
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
}()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Benchmark calling HasClient during AddPeer contention.
|
// Benchmark calling HasClient during AddPeer contention.
|
||||||
@@ -104,4 +107,6 @@ func BenchmarkHasClientDuringAddPeer(b *testing.B) {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
b.StopTimer()
|
b.StopTimer()
|
||||||
|
cancel()
|
||||||
|
wg.Wait()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ import (
|
|||||||
"google.golang.org/grpc"
|
"google.golang.org/grpc"
|
||||||
|
|
||||||
"github.com/netbirdio/netbird/proxy/internal/types"
|
"github.com/netbirdio/netbird/proxy/internal/types"
|
||||||
"github.com/netbirdio/netbird/shared/management/domain"
|
|
||||||
"github.com/netbirdio/netbird/shared/management/proto"
|
"github.com/netbirdio/netbird/shared/management/proto"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -27,16 +26,15 @@ type mockStatusNotifier struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type statusCall struct {
|
type statusCall struct {
|
||||||
accountID string
|
accountID types.AccountID
|
||||||
serviceID string
|
serviceID types.ServiceID
|
||||||
domain string
|
|
||||||
connected bool
|
connected bool
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *mockStatusNotifier) NotifyStatus(_ context.Context, accountID, serviceID, domain string, connected bool) error {
|
func (m *mockStatusNotifier) NotifyStatus(_ context.Context, accountID types.AccountID, serviceID types.ServiceID, connected bool) error {
|
||||||
m.mu.Lock()
|
m.mu.Lock()
|
||||||
defer m.mu.Unlock()
|
defer m.mu.Unlock()
|
||||||
m.statuses = append(m.statuses, statusCall{accountID, serviceID, domain, connected})
|
m.statuses = append(m.statuses, statusCall{accountID, serviceID, connected})
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -62,36 +60,34 @@ func TestNetBird_AddPeer_CreatesClientForNewAccount(t *testing.T) {
|
|||||||
|
|
||||||
// Initially no client exists.
|
// Initially no client exists.
|
||||||
assert.False(t, nb.HasClient(accountID), "should not have client before AddPeer")
|
assert.False(t, nb.HasClient(accountID), "should not have client before AddPeer")
|
||||||
assert.Equal(t, 0, nb.DomainCount(accountID), "domain count should be 0")
|
assert.Equal(t, 0, nb.ServiceCount(accountID), "service count should be 0")
|
||||||
|
|
||||||
// Add first domain - this should create a new client.
|
// Add first service - this should create a new client.
|
||||||
// Note: This will fail to actually connect since we use an invalid URL,
|
err := nb.AddPeer(context.Background(), accountID, "domain1.test", "setup-key-1", types.ServiceID("proxy-1"))
|
||||||
// but the client entry should still be created.
|
|
||||||
err := nb.AddPeer(context.Background(), accountID, domain.Domain("domain1.test"), "setup-key-1", "proxy-1")
|
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
assert.True(t, nb.HasClient(accountID), "should have client after AddPeer")
|
assert.True(t, nb.HasClient(accountID), "should have client after AddPeer")
|
||||||
assert.Equal(t, 1, nb.DomainCount(accountID), "domain count should be 1")
|
assert.Equal(t, 1, nb.ServiceCount(accountID), "service count should be 1")
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestNetBird_AddPeer_ReuseClientForSameAccount(t *testing.T) {
|
func TestNetBird_AddPeer_ReuseClientForSameAccount(t *testing.T) {
|
||||||
nb := mockNetBird()
|
nb := mockNetBird()
|
||||||
accountID := types.AccountID("account-1")
|
accountID := types.AccountID("account-1")
|
||||||
|
|
||||||
// Add first domain.
|
// Add first service.
|
||||||
err := nb.AddPeer(context.Background(), accountID, domain.Domain("domain1.test"), "setup-key-1", "proxy-1")
|
err := nb.AddPeer(context.Background(), accountID, "domain1.test", "setup-key-1", types.ServiceID("proxy-1"))
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
assert.Equal(t, 1, nb.DomainCount(accountID))
|
assert.Equal(t, 1, nb.ServiceCount(accountID))
|
||||||
|
|
||||||
// Add second domain for the same account - should reuse existing client.
|
// Add second service for the same account - should reuse existing client.
|
||||||
err = nb.AddPeer(context.Background(), accountID, domain.Domain("domain2.test"), "setup-key-1", "proxy-2")
|
err = nb.AddPeer(context.Background(), accountID, "domain2.test", "setup-key-1", types.ServiceID("proxy-2"))
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
assert.Equal(t, 2, nb.DomainCount(accountID), "domain count should be 2 after adding second domain")
|
assert.Equal(t, 2, nb.ServiceCount(accountID), "service count should be 2 after adding second service")
|
||||||
|
|
||||||
// Add third domain.
|
// Add third service.
|
||||||
err = nb.AddPeer(context.Background(), accountID, domain.Domain("domain3.test"), "setup-key-1", "proxy-3")
|
err = nb.AddPeer(context.Background(), accountID, "domain3.test", "setup-key-1", types.ServiceID("proxy-3"))
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
assert.Equal(t, 3, nb.DomainCount(accountID), "domain count should be 3 after adding third domain")
|
assert.Equal(t, 3, nb.ServiceCount(accountID), "service count should be 3 after adding third service")
|
||||||
|
|
||||||
// Still only one client.
|
// Still only one client.
|
||||||
assert.True(t, nb.HasClient(accountID))
|
assert.True(t, nb.HasClient(accountID))
|
||||||
@@ -102,64 +98,62 @@ func TestNetBird_AddPeer_SeparateClientsForDifferentAccounts(t *testing.T) {
|
|||||||
account1 := types.AccountID("account-1")
|
account1 := types.AccountID("account-1")
|
||||||
account2 := types.AccountID("account-2")
|
account2 := types.AccountID("account-2")
|
||||||
|
|
||||||
// Add domain for account 1.
|
// Add service for account 1.
|
||||||
err := nb.AddPeer(context.Background(), account1, domain.Domain("domain1.test"), "setup-key-1", "proxy-1")
|
err := nb.AddPeer(context.Background(), account1, "domain1.test", "setup-key-1", types.ServiceID("proxy-1"))
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
// Add domain for account 2.
|
// Add service for account 2.
|
||||||
err = nb.AddPeer(context.Background(), account2, domain.Domain("domain2.test"), "setup-key-2", "proxy-2")
|
err = nb.AddPeer(context.Background(), account2, "domain2.test", "setup-key-2", types.ServiceID("proxy-2"))
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
// Both accounts should have their own clients.
|
// Both accounts should have their own clients.
|
||||||
assert.True(t, nb.HasClient(account1), "account1 should have client")
|
assert.True(t, nb.HasClient(account1), "account1 should have client")
|
||||||
assert.True(t, nb.HasClient(account2), "account2 should have client")
|
assert.True(t, nb.HasClient(account2), "account2 should have client")
|
||||||
assert.Equal(t, 1, nb.DomainCount(account1), "account1 domain count should be 1")
|
assert.Equal(t, 1, nb.ServiceCount(account1), "account1 service count should be 1")
|
||||||
assert.Equal(t, 1, nb.DomainCount(account2), "account2 domain count should be 1")
|
assert.Equal(t, 1, nb.ServiceCount(account2), "account2 service count should be 1")
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestNetBird_RemovePeer_KeepsClientWhenDomainsRemain(t *testing.T) {
|
func TestNetBird_RemovePeer_KeepsClientWhenServicesRemain(t *testing.T) {
|
||||||
nb := mockNetBird()
|
nb := mockNetBird()
|
||||||
accountID := types.AccountID("account-1")
|
accountID := types.AccountID("account-1")
|
||||||
|
|
||||||
// Add multiple domains.
|
// Add multiple services.
|
||||||
err := nb.AddPeer(context.Background(), accountID, domain.Domain("domain1.test"), "setup-key-1", "proxy-1")
|
err := nb.AddPeer(context.Background(), accountID, "domain1.test", "setup-key-1", types.ServiceID("proxy-1"))
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
err = nb.AddPeer(context.Background(), accountID, domain.Domain("domain2.test"), "setup-key-1", "proxy-2")
|
err = nb.AddPeer(context.Background(), accountID, "domain2.test", "setup-key-1", types.ServiceID("proxy-2"))
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
err = nb.AddPeer(context.Background(), accountID, domain.Domain("domain3.test"), "setup-key-1", "proxy-3")
|
err = nb.AddPeer(context.Background(), accountID, "domain3.test", "setup-key-1", types.ServiceID("proxy-3"))
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
assert.Equal(t, 3, nb.DomainCount(accountID))
|
assert.Equal(t, 3, nb.ServiceCount(accountID))
|
||||||
|
|
||||||
// Remove one domain - client should remain.
|
// Remove one service - client should remain.
|
||||||
err = nb.RemovePeer(context.Background(), accountID, "domain1.test")
|
err = nb.RemovePeer(context.Background(), accountID, "domain1.test")
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
assert.True(t, nb.HasClient(accountID), "client should remain after removing one domain")
|
assert.True(t, nb.HasClient(accountID), "client should remain after removing one service")
|
||||||
assert.Equal(t, 2, nb.DomainCount(accountID), "domain count should be 2")
|
assert.Equal(t, 2, nb.ServiceCount(accountID), "service count should be 2")
|
||||||
|
|
||||||
// Remove another domain - client should still remain.
|
// Remove another service - client should still remain.
|
||||||
err = nb.RemovePeer(context.Background(), accountID, "domain2.test")
|
err = nb.RemovePeer(context.Background(), accountID, "domain2.test")
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
assert.True(t, nb.HasClient(accountID), "client should remain after removing second domain")
|
assert.True(t, nb.HasClient(accountID), "client should remain after removing second service")
|
||||||
assert.Equal(t, 1, nb.DomainCount(accountID), "domain count should be 1")
|
assert.Equal(t, 1, nb.ServiceCount(accountID), "service count should be 1")
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestNetBird_RemovePeer_RemovesClientWhenLastDomainRemoved(t *testing.T) {
|
func TestNetBird_RemovePeer_RemovesClientWhenLastServiceRemoved(t *testing.T) {
|
||||||
nb := mockNetBird()
|
nb := mockNetBird()
|
||||||
accountID := types.AccountID("account-1")
|
accountID := types.AccountID("account-1")
|
||||||
|
|
||||||
// Add single domain.
|
// Add single service.
|
||||||
err := nb.AddPeer(context.Background(), accountID, domain.Domain("domain1.test"), "setup-key-1", "proxy-1")
|
err := nb.AddPeer(context.Background(), accountID, "domain1.test", "setup-key-1", types.ServiceID("proxy-1"))
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
assert.True(t, nb.HasClient(accountID))
|
assert.True(t, nb.HasClient(accountID))
|
||||||
|
|
||||||
// Remove the only domain - client should be removed.
|
// Remove the only service - client should be removed.
|
||||||
// Note: Stop() may fail since the client never actually connected,
|
|
||||||
// but the entry should still be removed from the map.
|
|
||||||
_ = nb.RemovePeer(context.Background(), accountID, "domain1.test")
|
_ = nb.RemovePeer(context.Background(), accountID, "domain1.test")
|
||||||
|
|
||||||
// After removing all domains, client should be gone.
|
// After removing all services, client should be gone.
|
||||||
assert.False(t, nb.HasClient(accountID), "client should be removed after removing last domain")
|
assert.False(t, nb.HasClient(accountID), "client should be removed after removing last service")
|
||||||
assert.Equal(t, 0, nb.DomainCount(accountID), "domain count should be 0")
|
assert.Equal(t, 0, nb.ServiceCount(accountID), "service count should be 0")
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestNetBird_RemovePeer_NonExistentAccountIsNoop(t *testing.T) {
|
func TestNetBird_RemovePeer_NonExistentAccountIsNoop(t *testing.T) {
|
||||||
@@ -171,21 +165,21 @@ func TestNetBird_RemovePeer_NonExistentAccountIsNoop(t *testing.T) {
|
|||||||
assert.NoError(t, err, "removing from non-existent account should not error")
|
assert.NoError(t, err, "removing from non-existent account should not error")
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestNetBird_RemovePeer_NonExistentDomainIsNoop(t *testing.T) {
|
func TestNetBird_RemovePeer_NonExistentServiceIsNoop(t *testing.T) {
|
||||||
nb := mockNetBird()
|
nb := mockNetBird()
|
||||||
accountID := types.AccountID("account-1")
|
accountID := types.AccountID("account-1")
|
||||||
|
|
||||||
// Add one domain.
|
// Add one service.
|
||||||
err := nb.AddPeer(context.Background(), accountID, domain.Domain("domain1.test"), "setup-key-1", "proxy-1")
|
err := nb.AddPeer(context.Background(), accountID, "domain1.test", "setup-key-1", types.ServiceID("proxy-1"))
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
// Remove non-existent domain - should not affect existing domain.
|
// Remove non-existent service - should not affect existing service.
|
||||||
err = nb.RemovePeer(context.Background(), accountID, domain.Domain("nonexistent.test"))
|
err = nb.RemovePeer(context.Background(), accountID, "nonexistent.test")
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
// Original domain should still be registered.
|
// Original service should still be registered.
|
||||||
assert.True(t, nb.HasClient(accountID))
|
assert.True(t, nb.HasClient(accountID))
|
||||||
assert.Equal(t, 1, nb.DomainCount(accountID), "original domain should remain")
|
assert.Equal(t, 1, nb.ServiceCount(accountID), "original service should remain")
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestWithAccountID_AndAccountIDFromContext(t *testing.T) {
|
func TestWithAccountID_AndAccountIDFromContext(t *testing.T) {
|
||||||
@@ -216,19 +210,17 @@ func TestNetBird_StopAll_StopsAllClients(t *testing.T) {
|
|||||||
account2 := types.AccountID("account-2")
|
account2 := types.AccountID("account-2")
|
||||||
account3 := types.AccountID("account-3")
|
account3 := types.AccountID("account-3")
|
||||||
|
|
||||||
// Add domains for multiple accounts.
|
// Add services for multiple accounts.
|
||||||
err := nb.AddPeer(context.Background(), account1, domain.Domain("domain1.test"), "key-1", "proxy-1")
|
err := nb.AddPeer(context.Background(), account1, "domain1.test", "key-1", types.ServiceID("proxy-1"))
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
err = nb.AddPeer(context.Background(), account2, domain.Domain("domain2.test"), "key-2", "proxy-2")
|
err = nb.AddPeer(context.Background(), account2, "domain2.test", "key-2", types.ServiceID("proxy-2"))
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
err = nb.AddPeer(context.Background(), account3, domain.Domain("domain3.test"), "key-3", "proxy-3")
|
err = nb.AddPeer(context.Background(), account3, "domain3.test", "key-3", types.ServiceID("proxy-3"))
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
assert.Equal(t, 3, nb.ClientCount(), "should have 3 clients")
|
assert.Equal(t, 3, nb.ClientCount(), "should have 3 clients")
|
||||||
|
|
||||||
// Stop all clients.
|
// Stop all clients.
|
||||||
// Note: StopAll may return errors since clients never actually connected,
|
|
||||||
// but the clients should still be removed from the map.
|
|
||||||
_ = nb.StopAll(context.Background())
|
_ = nb.StopAll(context.Background())
|
||||||
|
|
||||||
assert.Equal(t, 0, nb.ClientCount(), "should have 0 clients after StopAll")
|
assert.Equal(t, 0, nb.ClientCount(), "should have 0 clients after StopAll")
|
||||||
@@ -243,18 +235,18 @@ func TestNetBird_ClientCount(t *testing.T) {
|
|||||||
assert.Equal(t, 0, nb.ClientCount(), "should start with 0 clients")
|
assert.Equal(t, 0, nb.ClientCount(), "should start with 0 clients")
|
||||||
|
|
||||||
// Add clients for different accounts.
|
// Add clients for different accounts.
|
||||||
err := nb.AddPeer(context.Background(), types.AccountID("account-1"), domain.Domain("domain1.test"), "key-1", "proxy-1")
|
err := nb.AddPeer(context.Background(), types.AccountID("account-1"), "domain1.test", "key-1", types.ServiceID("proxy-1"))
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
assert.Equal(t, 1, nb.ClientCount())
|
assert.Equal(t, 1, nb.ClientCount())
|
||||||
|
|
||||||
err = nb.AddPeer(context.Background(), types.AccountID("account-2"), domain.Domain("domain2.test"), "key-2", "proxy-2")
|
err = nb.AddPeer(context.Background(), types.AccountID("account-2"), "domain2.test", "key-2", types.ServiceID("proxy-2"))
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
assert.Equal(t, 2, nb.ClientCount())
|
assert.Equal(t, 2, nb.ClientCount())
|
||||||
|
|
||||||
// Adding domain to existing account should not increase count.
|
// Adding service to existing account should not increase count.
|
||||||
err = nb.AddPeer(context.Background(), types.AccountID("account-1"), domain.Domain("domain1b.test"), "key-1", "proxy-1b")
|
err = nb.AddPeer(context.Background(), types.AccountID("account-1"), "domain1b.test", "key-1", types.ServiceID("proxy-1b"))
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
assert.Equal(t, 2, nb.ClientCount(), "adding domain to existing account should not increase client count")
|
assert.Equal(t, 2, nb.ClientCount(), "adding service to existing account should not increase client count")
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestNetBird_RoundTrip_RequiresAccountIDInContext(t *testing.T) {
|
func TestNetBird_RoundTrip_RequiresAccountIDInContext(t *testing.T) {
|
||||||
@@ -293,8 +285,8 @@ func TestNetBird_AddPeer_ExistingStartedClient_NotifiesStatus(t *testing.T) {
|
|||||||
}, nil, notifier, &mockMgmtClient{})
|
}, nil, notifier, &mockMgmtClient{})
|
||||||
accountID := types.AccountID("account-1")
|
accountID := types.AccountID("account-1")
|
||||||
|
|
||||||
// Add first domain — creates a new client entry.
|
// Add first service — creates a new client entry.
|
||||||
err := nb.AddPeer(context.Background(), accountID, domain.Domain("domain1.test"), "key-1", "svc-1")
|
err := nb.AddPeer(context.Background(), accountID, "domain1.test", "key-1", types.ServiceID("svc-1"))
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
// Manually mark client as started to simulate background startup completing.
|
// Manually mark client as started to simulate background startup completing.
|
||||||
@@ -302,15 +294,14 @@ func TestNetBird_AddPeer_ExistingStartedClient_NotifiesStatus(t *testing.T) {
|
|||||||
nb.clients[accountID].started = true
|
nb.clients[accountID].started = true
|
||||||
nb.clientsMux.Unlock()
|
nb.clientsMux.Unlock()
|
||||||
|
|
||||||
// Add second domain — should notify immediately since client is already started.
|
// Add second service — should notify immediately since client is already started.
|
||||||
err = nb.AddPeer(context.Background(), accountID, domain.Domain("domain2.test"), "key-1", "svc-2")
|
err = nb.AddPeer(context.Background(), accountID, "domain2.test", "key-1", types.ServiceID("svc-2"))
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
calls := notifier.calls()
|
calls := notifier.calls()
|
||||||
require.Len(t, calls, 1)
|
require.Len(t, calls, 1)
|
||||||
assert.Equal(t, string(accountID), calls[0].accountID)
|
assert.Equal(t, accountID, calls[0].accountID)
|
||||||
assert.Equal(t, "svc-2", calls[0].serviceID)
|
assert.Equal(t, types.ServiceID("svc-2"), calls[0].serviceID)
|
||||||
assert.Equal(t, "domain2.test", calls[0].domain)
|
|
||||||
assert.True(t, calls[0].connected)
|
assert.True(t, calls[0].connected)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -323,18 +314,18 @@ func TestNetBird_RemovePeer_NotifiesDisconnection(t *testing.T) {
|
|||||||
}, nil, notifier, &mockMgmtClient{})
|
}, nil, notifier, &mockMgmtClient{})
|
||||||
accountID := types.AccountID("account-1")
|
accountID := types.AccountID("account-1")
|
||||||
|
|
||||||
err := nb.AddPeer(context.Background(), accountID, domain.Domain("domain1.test"), "key-1", "svc-1")
|
err := nb.AddPeer(context.Background(), accountID, "domain1.test", "key-1", types.ServiceID("svc-1"))
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
err = nb.AddPeer(context.Background(), accountID, domain.Domain("domain2.test"), "key-1", "svc-2")
|
err = nb.AddPeer(context.Background(), accountID, "domain2.test", "key-1", types.ServiceID("svc-2"))
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
// Remove one domain — client stays, but disconnection notification fires.
|
// Remove one service — client stays, but disconnection notification fires.
|
||||||
err = nb.RemovePeer(context.Background(), accountID, "domain1.test")
|
err = nb.RemovePeer(context.Background(), accountID, "domain1.test")
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
assert.True(t, nb.HasClient(accountID))
|
assert.True(t, nb.HasClient(accountID))
|
||||||
|
|
||||||
calls := notifier.calls()
|
calls := notifier.calls()
|
||||||
require.Len(t, calls, 1)
|
require.Len(t, calls, 1)
|
||||||
assert.Equal(t, "domain1.test", calls[0].domain)
|
assert.Equal(t, types.ServiceID("svc-1"), calls[0].serviceID)
|
||||||
assert.False(t, calls[0].connected)
|
assert.False(t, calls[0].connected)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,133 @@
|
|||||||
|
package tcp
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"crypto/tls"
|
||||||
|
"io"
|
||||||
|
"net"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// BenchmarkPeekClientHello_TLS measures the overhead of peeking at a real
|
||||||
|
// TLS ClientHello and extracting the SNI. This is the per-connection cost
|
||||||
|
// added to every TLS connection on the main listener.
|
||||||
|
func BenchmarkPeekClientHello_TLS(b *testing.B) {
|
||||||
|
// Pre-generate a ClientHello by capturing what crypto/tls sends.
|
||||||
|
clientConn, serverConn := net.Pipe()
|
||||||
|
go func() {
|
||||||
|
tlsConn := tls.Client(clientConn, &tls.Config{
|
||||||
|
ServerName: "app.example.com",
|
||||||
|
InsecureSkipVerify: true, //nolint:gosec
|
||||||
|
})
|
||||||
|
_ = tlsConn.Handshake()
|
||||||
|
}()
|
||||||
|
|
||||||
|
var hello []byte
|
||||||
|
buf := make([]byte, 16384)
|
||||||
|
n, _ := serverConn.Read(buf)
|
||||||
|
hello = make([]byte, n)
|
||||||
|
copy(hello, buf[:n])
|
||||||
|
clientConn.Close()
|
||||||
|
serverConn.Close()
|
||||||
|
|
||||||
|
b.ResetTimer()
|
||||||
|
b.ReportAllocs()
|
||||||
|
|
||||||
|
for b.Loop() {
|
||||||
|
r := bytes.NewReader(hello)
|
||||||
|
conn := &readerConn{Reader: r}
|
||||||
|
sni, wrapped, err := PeekClientHello(conn)
|
||||||
|
if err != nil {
|
||||||
|
b.Fatal(err)
|
||||||
|
}
|
||||||
|
if sni != "app.example.com" {
|
||||||
|
b.Fatalf("unexpected SNI: %q", sni)
|
||||||
|
}
|
||||||
|
// Simulate draining the peeked bytes (what the HTTP server would do).
|
||||||
|
_, _ = io.Copy(io.Discard, wrapped)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// BenchmarkPeekClientHello_NonTLS measures peek overhead for non-TLS
|
||||||
|
// connections that hit the fast non-handshake exit path.
|
||||||
|
func BenchmarkPeekClientHello_NonTLS(b *testing.B) {
|
||||||
|
httpReq := []byte("GET / HTTP/1.1\r\nHost: example.com\r\n\r\n")
|
||||||
|
|
||||||
|
b.ResetTimer()
|
||||||
|
b.ReportAllocs()
|
||||||
|
|
||||||
|
for b.Loop() {
|
||||||
|
r := bytes.NewReader(httpReq)
|
||||||
|
conn := &readerConn{Reader: r}
|
||||||
|
_, wrapped, err := PeekClientHello(conn)
|
||||||
|
if err != nil {
|
||||||
|
b.Fatal(err)
|
||||||
|
}
|
||||||
|
_, _ = io.Copy(io.Discard, wrapped)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// BenchmarkPeekedConn_Read measures the read overhead of the peekedConn
|
||||||
|
// wrapper compared to a plain connection read. The peeked bytes use
|
||||||
|
// io.MultiReader which adds one indirection per Read call.
|
||||||
|
func BenchmarkPeekedConn_Read(b *testing.B) {
|
||||||
|
data := make([]byte, 4096)
|
||||||
|
peeked := make([]byte, 512)
|
||||||
|
buf := make([]byte, 1024)
|
||||||
|
|
||||||
|
b.ResetTimer()
|
||||||
|
b.ReportAllocs()
|
||||||
|
|
||||||
|
for b.Loop() {
|
||||||
|
r := bytes.NewReader(data)
|
||||||
|
conn := &readerConn{Reader: r}
|
||||||
|
pc := newPeekedConn(conn, peeked)
|
||||||
|
for {
|
||||||
|
_, err := pc.Read(buf)
|
||||||
|
if err != nil {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// BenchmarkExtractSNI measures just the in-memory SNI parsing cost,
|
||||||
|
// excluding I/O.
|
||||||
|
func BenchmarkExtractSNI(b *testing.B) {
|
||||||
|
clientConn, serverConn := net.Pipe()
|
||||||
|
go func() {
|
||||||
|
tlsConn := tls.Client(clientConn, &tls.Config{
|
||||||
|
ServerName: "app.example.com",
|
||||||
|
InsecureSkipVerify: true, //nolint:gosec
|
||||||
|
})
|
||||||
|
_ = tlsConn.Handshake()
|
||||||
|
}()
|
||||||
|
|
||||||
|
buf := make([]byte, 16384)
|
||||||
|
n, _ := serverConn.Read(buf)
|
||||||
|
payload := make([]byte, n-tlsRecordHeaderLen)
|
||||||
|
copy(payload, buf[tlsRecordHeaderLen:n])
|
||||||
|
clientConn.Close()
|
||||||
|
serverConn.Close()
|
||||||
|
|
||||||
|
b.ResetTimer()
|
||||||
|
b.ReportAllocs()
|
||||||
|
|
||||||
|
for b.Loop() {
|
||||||
|
sni := extractSNI(payload)
|
||||||
|
if sni != "app.example.com" {
|
||||||
|
b.Fatalf("unexpected SNI: %q", sni)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// readerConn wraps an io.Reader as a net.Conn for benchmarking.
|
||||||
|
// Only Read is functional; all other methods are no-ops.
|
||||||
|
type readerConn struct {
|
||||||
|
io.Reader
|
||||||
|
net.Conn
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *readerConn) Read(b []byte) (int, error) {
|
||||||
|
return c.Reader.Read(b)
|
||||||
|
}
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
package tcp
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net"
|
||||||
|
"sync"
|
||||||
|
)
|
||||||
|
|
||||||
|
// chanListener implements net.Listener by reading connections from a channel.
|
||||||
|
// It allows the SNI router to feed HTTP connections to http.Server.ServeTLS.
|
||||||
|
type chanListener struct {
|
||||||
|
ch chan net.Conn
|
||||||
|
addr net.Addr
|
||||||
|
once sync.Once
|
||||||
|
closed chan struct{}
|
||||||
|
}
|
||||||
|
|
||||||
|
func newChanListener(ch chan net.Conn, addr net.Addr) *chanListener {
|
||||||
|
return &chanListener{
|
||||||
|
ch: ch,
|
||||||
|
addr: addr,
|
||||||
|
closed: make(chan struct{}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Accept waits for and returns the next connection from the channel.
|
||||||
|
func (l *chanListener) Accept() (net.Conn, error) {
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case conn, ok := <-l.ch:
|
||||||
|
if !ok {
|
||||||
|
return nil, net.ErrClosed
|
||||||
|
}
|
||||||
|
return conn, nil
|
||||||
|
case <-l.closed:
|
||||||
|
// Drain buffered connections before returning.
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case conn, ok := <-l.ch:
|
||||||
|
if !ok {
|
||||||
|
return nil, net.ErrClosed
|
||||||
|
}
|
||||||
|
_ = conn.Close()
|
||||||
|
default:
|
||||||
|
return nil, net.ErrClosed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close signals the listener to stop accepting connections and drains
|
||||||
|
// any buffered connections that have not yet been accepted.
|
||||||
|
func (l *chanListener) Close() error {
|
||||||
|
l.once.Do(func() {
|
||||||
|
close(l.closed)
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case conn, ok := <-l.ch:
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_ = conn.Close()
|
||||||
|
default:
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Addr returns the listener's network address.
|
||||||
|
func (l *chanListener) Addr() net.Addr {
|
||||||
|
return l.addr
|
||||||
|
}
|
||||||
|
|
||||||
|
var _ net.Listener = (*chanListener)(nil)
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
package tcp
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"io"
|
||||||
|
"net"
|
||||||
|
)
|
||||||
|
|
||||||
|
// peekedConn wraps a net.Conn and prepends previously peeked bytes
|
||||||
|
// so that readers see the full original stream transparently.
|
||||||
|
type peekedConn struct {
|
||||||
|
net.Conn
|
||||||
|
reader io.Reader
|
||||||
|
}
|
||||||
|
|
||||||
|
func newPeekedConn(conn net.Conn, peeked []byte) *peekedConn {
|
||||||
|
return &peekedConn{
|
||||||
|
Conn: conn,
|
||||||
|
reader: io.MultiReader(bytes.NewReader(peeked), conn),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read replays the peeked bytes first, then reads from the underlying conn.
|
||||||
|
func (c *peekedConn) Read(b []byte) (int, error) {
|
||||||
|
return c.reader.Read(b)
|
||||||
|
}
|
||||||
|
|
||||||
|
// CloseWrite delegates to the underlying connection if it supports
|
||||||
|
// half-close (e.g. *net.TCPConn). Without this, embedding net.Conn
|
||||||
|
// as an interface hides the concrete type's CloseWrite method, making
|
||||||
|
// half-close a silent no-op for all SNI-routed connections.
|
||||||
|
func (c *peekedConn) CloseWrite() error {
|
||||||
|
if hc, ok := c.Conn.(halfCloser); ok {
|
||||||
|
return hc.CloseWrite()
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var _ halfCloser = (*peekedConn)(nil)
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
package tcp
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net"
|
||||||
|
|
||||||
|
"github.com/pires/go-proxyproto"
|
||||||
|
)
|
||||||
|
|
||||||
|
// writeProxyProtoV2 sends a PROXY protocol v2 header to the backend connection,
|
||||||
|
// conveying the real client address.
|
||||||
|
func writeProxyProtoV2(client, backend net.Conn) error {
|
||||||
|
tp := proxyproto.TCPv4
|
||||||
|
if addr, ok := client.RemoteAddr().(*net.TCPAddr); ok && addr.IP.To4() == nil {
|
||||||
|
tp = proxyproto.TCPv6
|
||||||
|
}
|
||||||
|
|
||||||
|
header := &proxyproto.Header{
|
||||||
|
Version: 2,
|
||||||
|
Command: proxyproto.PROXY,
|
||||||
|
TransportProtocol: tp,
|
||||||
|
SourceAddr: client.RemoteAddr(),
|
||||||
|
DestinationAddr: client.LocalAddr(),
|
||||||
|
}
|
||||||
|
if _, err := header.WriteTo(backend); err != nil {
|
||||||
|
return fmt.Errorf("write PROXY protocol v2 header: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
package tcp
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"net"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/pires/go-proxyproto"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestWriteProxyProtoV2_IPv4(t *testing.T) {
|
||||||
|
// Set up a real TCP listener and dial to get connections with real addresses.
|
||||||
|
ln, err := net.Listen("tcp4", "127.0.0.1:0")
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer ln.Close()
|
||||||
|
|
||||||
|
var serverConn net.Conn
|
||||||
|
accepted := make(chan struct{})
|
||||||
|
go func() {
|
||||||
|
var err error
|
||||||
|
serverConn, err = ln.Accept()
|
||||||
|
if err != nil {
|
||||||
|
t.Error("accept failed:", err)
|
||||||
|
}
|
||||||
|
close(accepted)
|
||||||
|
}()
|
||||||
|
|
||||||
|
clientConn, err := net.Dial("tcp4", ln.Addr().String())
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer clientConn.Close()
|
||||||
|
|
||||||
|
<-accepted
|
||||||
|
defer serverConn.Close()
|
||||||
|
|
||||||
|
// Use a pipe as the backend: write the header to one end, read from the other.
|
||||||
|
backendRead, backendWrite := net.Pipe()
|
||||||
|
defer backendRead.Close()
|
||||||
|
defer backendWrite.Close()
|
||||||
|
|
||||||
|
// serverConn is the "client" arg: RemoteAddr is the source, LocalAddr is the destination.
|
||||||
|
writeDone := make(chan error, 1)
|
||||||
|
go func() {
|
||||||
|
writeDone <- writeProxyProtoV2(serverConn, backendWrite)
|
||||||
|
}()
|
||||||
|
|
||||||
|
// Read the PROXY protocol header from the backend read side.
|
||||||
|
header, err := proxyproto.Read(bufio.NewReader(backendRead))
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotNil(t, header, "should have received a proxy protocol header")
|
||||||
|
|
||||||
|
writeErr := <-writeDone
|
||||||
|
require.NoError(t, writeErr)
|
||||||
|
|
||||||
|
assert.Equal(t, byte(2), header.Version, "version should be 2")
|
||||||
|
assert.Equal(t, proxyproto.PROXY, header.Command, "command should be PROXY")
|
||||||
|
assert.Equal(t, proxyproto.TCPv4, header.TransportProtocol, "transport should be TCPv4")
|
||||||
|
|
||||||
|
// serverConn.RemoteAddr() is the client's address (source in the header).
|
||||||
|
expectedSrc := serverConn.RemoteAddr().(*net.TCPAddr)
|
||||||
|
actualSrc := header.SourceAddr.(*net.TCPAddr)
|
||||||
|
assert.Equal(t, expectedSrc.IP.String(), actualSrc.IP.String(), "source IP should match client remote addr")
|
||||||
|
assert.Equal(t, expectedSrc.Port, actualSrc.Port, "source port should match client remote addr")
|
||||||
|
|
||||||
|
// serverConn.LocalAddr() is the server's address (destination in the header).
|
||||||
|
expectedDst := serverConn.LocalAddr().(*net.TCPAddr)
|
||||||
|
actualDst := header.DestinationAddr.(*net.TCPAddr)
|
||||||
|
assert.Equal(t, expectedDst.IP.String(), actualDst.IP.String(), "destination IP should match server local addr")
|
||||||
|
assert.Equal(t, expectedDst.Port, actualDst.Port, "destination port should match server local addr")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWriteProxyProtoV2_IPv6(t *testing.T) {
|
||||||
|
// Set up a real TCP6 listener on loopback.
|
||||||
|
ln, err := net.Listen("tcp6", "[::1]:0")
|
||||||
|
if err != nil {
|
||||||
|
t.Skip("IPv6 not available:", err)
|
||||||
|
}
|
||||||
|
defer ln.Close()
|
||||||
|
|
||||||
|
var serverConn net.Conn
|
||||||
|
accepted := make(chan struct{})
|
||||||
|
go func() {
|
||||||
|
var err error
|
||||||
|
serverConn, err = ln.Accept()
|
||||||
|
if err != nil {
|
||||||
|
t.Error("accept failed:", err)
|
||||||
|
}
|
||||||
|
close(accepted)
|
||||||
|
}()
|
||||||
|
|
||||||
|
clientConn, err := net.Dial("tcp6", ln.Addr().String())
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer clientConn.Close()
|
||||||
|
|
||||||
|
<-accepted
|
||||||
|
defer serverConn.Close()
|
||||||
|
|
||||||
|
backendRead, backendWrite := net.Pipe()
|
||||||
|
defer backendRead.Close()
|
||||||
|
defer backendWrite.Close()
|
||||||
|
|
||||||
|
writeDone := make(chan error, 1)
|
||||||
|
go func() {
|
||||||
|
writeDone <- writeProxyProtoV2(serverConn, backendWrite)
|
||||||
|
}()
|
||||||
|
|
||||||
|
header, err := proxyproto.Read(bufio.NewReader(backendRead))
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotNil(t, header, "should have received a proxy protocol header")
|
||||||
|
|
||||||
|
writeErr := <-writeDone
|
||||||
|
require.NoError(t, writeErr)
|
||||||
|
|
||||||
|
assert.Equal(t, byte(2), header.Version, "version should be 2")
|
||||||
|
assert.Equal(t, proxyproto.PROXY, header.Command, "command should be PROXY")
|
||||||
|
assert.Equal(t, proxyproto.TCPv6, header.TransportProtocol, "transport should be TCPv6")
|
||||||
|
|
||||||
|
expectedSrc := serverConn.RemoteAddr().(*net.TCPAddr)
|
||||||
|
actualSrc := header.SourceAddr.(*net.TCPAddr)
|
||||||
|
assert.Equal(t, expectedSrc.IP.String(), actualSrc.IP.String(), "source IP should match client remote addr")
|
||||||
|
assert.Equal(t, expectedSrc.Port, actualSrc.Port, "source port should match client remote addr")
|
||||||
|
|
||||||
|
expectedDst := serverConn.LocalAddr().(*net.TCPAddr)
|
||||||
|
actualDst := header.DestinationAddr.(*net.TCPAddr)
|
||||||
|
assert.Equal(t, expectedDst.IP.String(), actualDst.IP.String(), "destination IP should match server local addr")
|
||||||
|
assert.Equal(t, expectedDst.Port, actualDst.Port, "destination port should match server local addr")
|
||||||
|
}
|
||||||
@@ -0,0 +1,156 @@
|
|||||||
|
package tcp
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"io"
|
||||||
|
"net"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
log "github.com/sirupsen/logrus"
|
||||||
|
|
||||||
|
"github.com/netbirdio/netbird/proxy/internal/netutil"
|
||||||
|
)
|
||||||
|
|
||||||
|
// errIdleTimeout is returned when a relay connection is closed due to inactivity.
|
||||||
|
var errIdleTimeout = errors.New("idle timeout")
|
||||||
|
|
||||||
|
// DefaultIdleTimeout is the default idle timeout for TCP relay connections.
|
||||||
|
// A zero value disables idle timeout checking.
|
||||||
|
const DefaultIdleTimeout = 5 * time.Minute
|
||||||
|
|
||||||
|
// halfCloser is implemented by connections that support half-close
|
||||||
|
// (e.g. *net.TCPConn). When one copy direction finishes, we signal
|
||||||
|
// EOF to the remote by closing the write side while keeping the read
|
||||||
|
// side open so the other direction can drain.
|
||||||
|
type halfCloser interface {
|
||||||
|
CloseWrite() error
|
||||||
|
}
|
||||||
|
|
||||||
|
// copyBufPool avoids allocating a new 32KB buffer per io.Copy call.
|
||||||
|
var copyBufPool = sync.Pool{
|
||||||
|
New: func() any {
|
||||||
|
buf := make([]byte, 32*1024)
|
||||||
|
return &buf
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// Relay copies data bidirectionally between src and dst until both
|
||||||
|
// sides are done or the context is canceled. When idleTimeout is
|
||||||
|
// non-zero, each direction's read is deadline-guarded; if no data
|
||||||
|
// flows within the timeout the connection is torn down. When one
|
||||||
|
// direction finishes, it half-closes the write side of the
|
||||||
|
// destination (if supported) to signal EOF, allowing the other
|
||||||
|
// direction to drain gracefully before the full connection teardown.
|
||||||
|
func Relay(ctx context.Context, logger *log.Entry, src, dst net.Conn, idleTimeout time.Duration) (srcToDst, dstToSrc int64) {
|
||||||
|
ctx, cancel := context.WithCancel(ctx)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
<-ctx.Done()
|
||||||
|
_ = src.Close()
|
||||||
|
_ = dst.Close()
|
||||||
|
}()
|
||||||
|
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
wg.Add(2)
|
||||||
|
|
||||||
|
var errSrcToDst, errDstToSrc error
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
srcToDst, errSrcToDst = copyWithIdleTimeout(dst, src, idleTimeout)
|
||||||
|
halfClose(dst)
|
||||||
|
cancel()
|
||||||
|
}()
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
dstToSrc, errDstToSrc = copyWithIdleTimeout(src, dst, idleTimeout)
|
||||||
|
halfClose(src)
|
||||||
|
cancel()
|
||||||
|
}()
|
||||||
|
|
||||||
|
wg.Wait()
|
||||||
|
|
||||||
|
if errors.Is(errSrcToDst, errIdleTimeout) || errors.Is(errDstToSrc, errIdleTimeout) {
|
||||||
|
logger.Debug("relay closed due to idle timeout")
|
||||||
|
}
|
||||||
|
if errSrcToDst != nil && !isExpectedCopyError(errSrcToDst) {
|
||||||
|
logger.Debugf("relay copy error (src→dst): %v", errSrcToDst)
|
||||||
|
}
|
||||||
|
if errDstToSrc != nil && !isExpectedCopyError(errDstToSrc) {
|
||||||
|
logger.Debugf("relay copy error (dst→src): %v", errDstToSrc)
|
||||||
|
}
|
||||||
|
|
||||||
|
return srcToDst, dstToSrc
|
||||||
|
}
|
||||||
|
|
||||||
|
// copyWithIdleTimeout copies from src to dst using a pooled buffer.
|
||||||
|
// When idleTimeout > 0 it sets a read deadline on src before each
|
||||||
|
// read and treats a timeout as an idle-triggered close.
|
||||||
|
func copyWithIdleTimeout(dst io.Writer, src io.Reader, idleTimeout time.Duration) (int64, error) {
|
||||||
|
bufp := copyBufPool.Get().(*[]byte)
|
||||||
|
defer copyBufPool.Put(bufp)
|
||||||
|
|
||||||
|
if idleTimeout <= 0 {
|
||||||
|
return io.CopyBuffer(dst, src, *bufp)
|
||||||
|
}
|
||||||
|
|
||||||
|
conn, ok := src.(net.Conn)
|
||||||
|
if !ok {
|
||||||
|
return io.CopyBuffer(dst, src, *bufp)
|
||||||
|
}
|
||||||
|
|
||||||
|
buf := *bufp
|
||||||
|
var total int64
|
||||||
|
for {
|
||||||
|
if err := conn.SetReadDeadline(time.Now().Add(idleTimeout)); err != nil {
|
||||||
|
return total, err
|
||||||
|
}
|
||||||
|
nr, readErr := src.Read(buf)
|
||||||
|
if nr > 0 {
|
||||||
|
n, err := checkedWrite(dst, buf[:nr])
|
||||||
|
total += n
|
||||||
|
if err != nil {
|
||||||
|
return total, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if readErr != nil {
|
||||||
|
if netutil.IsTimeout(readErr) {
|
||||||
|
return total, errIdleTimeout
|
||||||
|
}
|
||||||
|
return total, readErr
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// checkedWrite writes buf to dst and returns the number of bytes written.
|
||||||
|
// It guards against short writes and negative counts per io.Copy convention.
|
||||||
|
func checkedWrite(dst io.Writer, buf []byte) (int64, error) {
|
||||||
|
nw, err := dst.Write(buf)
|
||||||
|
if nw < 0 || nw > len(buf) {
|
||||||
|
nw = 0
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return int64(nw), err
|
||||||
|
}
|
||||||
|
if nw != len(buf) {
|
||||||
|
return int64(nw), io.ErrShortWrite
|
||||||
|
}
|
||||||
|
return int64(nw), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func isExpectedCopyError(err error) bool {
|
||||||
|
return errors.Is(err, errIdleTimeout) || netutil.IsExpectedError(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// halfClose attempts to half-close the write side of the connection.
|
||||||
|
// If the connection does not support half-close, this is a no-op.
|
||||||
|
func halfClose(conn net.Conn) {
|
||||||
|
if hc, ok := conn.(halfCloser); ok {
|
||||||
|
// Best-effort; the full close will follow shortly.
|
||||||
|
_ = hc.CloseWrite()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,210 @@
|
|||||||
|
package tcp
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
log "github.com/sirupsen/logrus"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
|
||||||
|
"github.com/netbirdio/netbird/proxy/internal/netutil"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestRelay_BidirectionalCopy(t *testing.T) {
|
||||||
|
srcClient, srcServer := net.Pipe()
|
||||||
|
dstClient, dstServer := net.Pipe()
|
||||||
|
|
||||||
|
logger := log.NewEntry(log.StandardLogger())
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
srcData := []byte("hello from src")
|
||||||
|
dstData := []byte("hello from dst")
|
||||||
|
|
||||||
|
// dst side: write response first, then read + close.
|
||||||
|
go func() {
|
||||||
|
_, _ = dstClient.Write(dstData)
|
||||||
|
buf := make([]byte, 256)
|
||||||
|
_, _ = dstClient.Read(buf)
|
||||||
|
dstClient.Close()
|
||||||
|
}()
|
||||||
|
|
||||||
|
// src side: read the response, then send data + close.
|
||||||
|
go func() {
|
||||||
|
buf := make([]byte, 256)
|
||||||
|
_, _ = srcClient.Read(buf)
|
||||||
|
_, _ = srcClient.Write(srcData)
|
||||||
|
srcClient.Close()
|
||||||
|
}()
|
||||||
|
|
||||||
|
s2d, d2s := Relay(ctx, logger, srcServer, dstServer, 0)
|
||||||
|
|
||||||
|
assert.Equal(t, int64(len(srcData)), s2d, "bytes src→dst")
|
||||||
|
assert.Equal(t, int64(len(dstData)), d2s, "bytes dst→src")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRelay_ContextCancellation(t *testing.T) {
|
||||||
|
srcClient, srcServer := net.Pipe()
|
||||||
|
dstClient, dstServer := net.Pipe()
|
||||||
|
defer srcClient.Close()
|
||||||
|
defer dstClient.Close()
|
||||||
|
|
||||||
|
logger := log.NewEntry(log.StandardLogger())
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
|
||||||
|
done := make(chan struct{})
|
||||||
|
go func() {
|
||||||
|
Relay(ctx, logger, srcServer, dstServer, 0)
|
||||||
|
close(done)
|
||||||
|
}()
|
||||||
|
|
||||||
|
// Cancel should cause Relay to return.
|
||||||
|
cancel()
|
||||||
|
|
||||||
|
select {
|
||||||
|
case <-done:
|
||||||
|
case <-time.After(5 * time.Second):
|
||||||
|
t.Fatal("Relay did not return after context cancellation")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRelay_OneSideClosed(t *testing.T) {
|
||||||
|
srcClient, srcServer := net.Pipe()
|
||||||
|
dstClient, dstServer := net.Pipe()
|
||||||
|
defer dstClient.Close()
|
||||||
|
|
||||||
|
logger := log.NewEntry(log.StandardLogger())
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
// Close src immediately. Relay should complete without hanging.
|
||||||
|
srcClient.Close()
|
||||||
|
|
||||||
|
done := make(chan struct{})
|
||||||
|
go func() {
|
||||||
|
Relay(ctx, logger, srcServer, dstServer, 0)
|
||||||
|
close(done)
|
||||||
|
}()
|
||||||
|
|
||||||
|
select {
|
||||||
|
case <-done:
|
||||||
|
case <-time.After(5 * time.Second):
|
||||||
|
t.Fatal("Relay did not return after one side closed")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRelay_LargeTransfer(t *testing.T) {
|
||||||
|
srcClient, srcServer := net.Pipe()
|
||||||
|
dstClient, dstServer := net.Pipe()
|
||||||
|
|
||||||
|
logger := log.NewEntry(log.StandardLogger())
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
// 1MB of data.
|
||||||
|
data := make([]byte, 1<<20)
|
||||||
|
for i := range data {
|
||||||
|
data[i] = byte(i % 256)
|
||||||
|
}
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
_, _ = srcClient.Write(data)
|
||||||
|
srcClient.Close()
|
||||||
|
}()
|
||||||
|
|
||||||
|
errCh := make(chan error, 1)
|
||||||
|
go func() {
|
||||||
|
received, err := io.ReadAll(dstClient)
|
||||||
|
if err != nil {
|
||||||
|
errCh <- err
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if len(received) != len(data) {
|
||||||
|
errCh <- fmt.Errorf("expected %d bytes, got %d", len(data), len(received))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
errCh <- nil
|
||||||
|
dstClient.Close()
|
||||||
|
}()
|
||||||
|
|
||||||
|
s2d, _ := Relay(ctx, logger, srcServer, dstServer, 0)
|
||||||
|
assert.Equal(t, int64(len(data)), s2d, "should transfer all bytes")
|
||||||
|
require.NoError(t, <-errCh)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRelay_IdleTimeout(t *testing.T) {
|
||||||
|
// Use real TCP connections so SetReadDeadline works (net.Pipe
|
||||||
|
// does not support deadlines).
|
||||||
|
srcLn, err := net.Listen("tcp", "127.0.0.1:0")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer srcLn.Close()
|
||||||
|
|
||||||
|
dstLn, err := net.Listen("tcp", "127.0.0.1:0")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer dstLn.Close()
|
||||||
|
|
||||||
|
srcClient, err := net.Dial("tcp", srcLn.Addr().String())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer srcClient.Close()
|
||||||
|
|
||||||
|
srcServer, err := srcLn.Accept()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
dstClient, err := net.Dial("tcp", dstLn.Addr().String())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer dstClient.Close()
|
||||||
|
|
||||||
|
dstServer, err := dstLn.Accept()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
logger := log.NewEntry(log.StandardLogger())
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
// Send initial data to prove the relay works.
|
||||||
|
go func() {
|
||||||
|
_, _ = srcClient.Write([]byte("ping"))
|
||||||
|
}()
|
||||||
|
|
||||||
|
done := make(chan struct{})
|
||||||
|
var s2d, d2s int64
|
||||||
|
go func() {
|
||||||
|
s2d, d2s = Relay(ctx, logger, srcServer, dstServer, 200*time.Millisecond)
|
||||||
|
close(done)
|
||||||
|
}()
|
||||||
|
|
||||||
|
// Read the forwarded data on the dst side.
|
||||||
|
buf := make([]byte, 64)
|
||||||
|
n, err := dstClient.Read(buf)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Equal(t, "ping", string(buf[:n]))
|
||||||
|
|
||||||
|
// Now stop sending. The relay should close after the idle timeout.
|
||||||
|
select {
|
||||||
|
case <-done:
|
||||||
|
assert.Greater(t, s2d, int64(0), "should have transferred initial data")
|
||||||
|
_ = d2s
|
||||||
|
case <-time.After(5 * time.Second):
|
||||||
|
t.Fatal("Relay did not exit after idle timeout")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIsExpectedError(t *testing.T) {
|
||||||
|
assert.True(t, netutil.IsExpectedError(net.ErrClosed))
|
||||||
|
assert.True(t, netutil.IsExpectedError(context.Canceled))
|
||||||
|
assert.True(t, netutil.IsExpectedError(io.EOF))
|
||||||
|
assert.False(t, netutil.IsExpectedError(io.ErrUnexpectedEOF))
|
||||||
|
}
|
||||||
@@ -0,0 +1,570 @@
|
|||||||
|
package tcp
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"net"
|
||||||
|
"net/netip"
|
||||||
|
"slices"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
log "github.com/sirupsen/logrus"
|
||||||
|
|
||||||
|
"github.com/netbirdio/netbird/proxy/internal/accesslog"
|
||||||
|
"github.com/netbirdio/netbird/proxy/internal/types"
|
||||||
|
)
|
||||||
|
|
||||||
|
// defaultDialTimeout is the fallback dial timeout when no per-route
|
||||||
|
// timeout is configured.
|
||||||
|
const defaultDialTimeout = 30 * time.Second
|
||||||
|
|
||||||
|
// SNIHost is a typed key for SNI hostname lookups.
|
||||||
|
type SNIHost string
|
||||||
|
|
||||||
|
// RouteType specifies how a connection should be handled.
|
||||||
|
type RouteType int
|
||||||
|
|
||||||
|
const (
|
||||||
|
// RouteHTTP routes the connection through the HTTP reverse proxy.
|
||||||
|
RouteHTTP RouteType = iota
|
||||||
|
// RouteTCP relays the connection directly to the backend (TLS passthrough).
|
||||||
|
RouteTCP
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
// sniPeekTimeout is the deadline for reading the TLS ClientHello.
|
||||||
|
sniPeekTimeout = 5 * time.Second
|
||||||
|
// DefaultDrainTimeout is the default grace period for in-flight relay
|
||||||
|
// connections to finish during shutdown.
|
||||||
|
DefaultDrainTimeout = 30 * time.Second
|
||||||
|
// DefaultMaxRelayConns is the default cap on concurrent TCP relay connections per router.
|
||||||
|
DefaultMaxRelayConns = 4096
|
||||||
|
// httpChannelBuffer is the capacity of the channel feeding HTTP connections.
|
||||||
|
httpChannelBuffer = 4096
|
||||||
|
)
|
||||||
|
|
||||||
|
// DialResolver returns a DialContextFunc for the given account.
|
||||||
|
type DialResolver func(accountID types.AccountID) (types.DialContextFunc, error)
|
||||||
|
|
||||||
|
// Route describes where a connection for a given SNI should be sent.
|
||||||
|
type Route struct {
|
||||||
|
Type RouteType
|
||||||
|
AccountID types.AccountID
|
||||||
|
ServiceID types.ServiceID
|
||||||
|
// Domain is the service's configured domain, used for access log entries.
|
||||||
|
Domain string
|
||||||
|
// Protocol is the frontend protocol (tcp, tls), used for access log entries.
|
||||||
|
Protocol accesslog.Protocol
|
||||||
|
// Target is the backend address for TCP relay (e.g. "10.0.0.5:5432").
|
||||||
|
Target string
|
||||||
|
// ProxyProtocol enables sending a PROXY protocol v2 header to the backend.
|
||||||
|
ProxyProtocol bool
|
||||||
|
// DialTimeout overrides the default dial timeout for this route.
|
||||||
|
// Zero uses defaultDialTimeout.
|
||||||
|
DialTimeout time.Duration
|
||||||
|
}
|
||||||
|
|
||||||
|
// l4Logger sends layer-4 access log entries to the management server.
|
||||||
|
type l4Logger interface {
|
||||||
|
LogL4(entry accesslog.L4Entry)
|
||||||
|
}
|
||||||
|
|
||||||
|
// RelayObserver receives callbacks for TCP relay lifecycle events.
|
||||||
|
// All methods must be safe for concurrent use.
|
||||||
|
type RelayObserver interface {
|
||||||
|
TCPRelayStarted(accountID types.AccountID)
|
||||||
|
TCPRelayEnded(accountID types.AccountID, duration time.Duration, srcToDst, dstToSrc int64)
|
||||||
|
TCPRelayDialError(accountID types.AccountID)
|
||||||
|
TCPRelayRejected(accountID types.AccountID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Router accepts raw TCP connections on a shared listener, peeks at
|
||||||
|
// the TLS ClientHello to extract the SNI, and routes the connection
|
||||||
|
// to either the HTTP reverse proxy or a direct TCP relay.
|
||||||
|
type Router struct {
|
||||||
|
logger *log.Logger
|
||||||
|
// httpCh is immutable after construction: set only in NewRouter, nil in NewPortRouter.
|
||||||
|
httpCh chan net.Conn
|
||||||
|
httpListener *chanListener
|
||||||
|
mu sync.RWMutex
|
||||||
|
routes map[SNIHost][]Route
|
||||||
|
fallback *Route
|
||||||
|
draining bool
|
||||||
|
dialResolve DialResolver
|
||||||
|
activeConns sync.WaitGroup
|
||||||
|
activeRelays sync.WaitGroup
|
||||||
|
relaySem chan struct{}
|
||||||
|
drainDone chan struct{}
|
||||||
|
observer RelayObserver
|
||||||
|
accessLog l4Logger
|
||||||
|
// svcCtxs tracks a context per service ID. All relay goroutines for a
|
||||||
|
// service derive from its context; canceling it kills them immediately.
|
||||||
|
svcCtxs map[types.ServiceID]context.Context
|
||||||
|
svcCancels map[types.ServiceID]context.CancelFunc
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewRouter creates a new SNI-based connection router.
|
||||||
|
func NewRouter(logger *log.Logger, dialResolve DialResolver, addr net.Addr) *Router {
|
||||||
|
httpCh := make(chan net.Conn, httpChannelBuffer)
|
||||||
|
return &Router{
|
||||||
|
logger: logger,
|
||||||
|
httpCh: httpCh,
|
||||||
|
httpListener: newChanListener(httpCh, addr),
|
||||||
|
routes: make(map[SNIHost][]Route),
|
||||||
|
dialResolve: dialResolve,
|
||||||
|
relaySem: make(chan struct{}, DefaultMaxRelayConns),
|
||||||
|
svcCtxs: make(map[types.ServiceID]context.Context),
|
||||||
|
svcCancels: make(map[types.ServiceID]context.CancelFunc),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewPortRouter creates a Router for a dedicated port without an HTTP
|
||||||
|
// channel. Connections that don't match any SNI route fall through to
|
||||||
|
// the fallback relay (if set) or are closed.
|
||||||
|
func NewPortRouter(logger *log.Logger, dialResolve DialResolver) *Router {
|
||||||
|
return &Router{
|
||||||
|
logger: logger,
|
||||||
|
routes: make(map[SNIHost][]Route),
|
||||||
|
dialResolve: dialResolve,
|
||||||
|
relaySem: make(chan struct{}, DefaultMaxRelayConns),
|
||||||
|
svcCtxs: make(map[types.ServiceID]context.Context),
|
||||||
|
svcCancels: make(map[types.ServiceID]context.CancelFunc),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// HTTPListener returns a net.Listener that yields connections routed
|
||||||
|
// to the HTTP handler. Use this with http.Server.ServeTLS.
|
||||||
|
func (r *Router) HTTPListener() net.Listener {
|
||||||
|
return r.httpListener
|
||||||
|
}
|
||||||
|
|
||||||
|
// AddRoute registers an SNI route. Multiple routes for the same host are
|
||||||
|
// stored and resolved by priority at lookup time (HTTP > TCP).
|
||||||
|
// Empty host is ignored to prevent conflicts with ECH/ESNI fallback.
|
||||||
|
func (r *Router) AddRoute(host SNIHost, route Route) {
|
||||||
|
if host == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
r.mu.Lock()
|
||||||
|
defer r.mu.Unlock()
|
||||||
|
|
||||||
|
routes := r.routes[host]
|
||||||
|
for i, existing := range routes {
|
||||||
|
if existing.ServiceID == route.ServiceID {
|
||||||
|
r.cancelServiceLocked(route.ServiceID)
|
||||||
|
routes[i] = route
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
r.routes[host] = append(routes, route)
|
||||||
|
}
|
||||||
|
|
||||||
|
// RemoveRoute removes the route for the given host and service ID.
|
||||||
|
// Active relay connections for the service are closed immediately.
|
||||||
|
// If other routes remain for the host, they are preserved.
|
||||||
|
func (r *Router) RemoveRoute(host SNIHost, svcID types.ServiceID) {
|
||||||
|
r.mu.Lock()
|
||||||
|
defer r.mu.Unlock()
|
||||||
|
|
||||||
|
r.routes[host] = slices.DeleteFunc(r.routes[host], func(route Route) bool {
|
||||||
|
return route.ServiceID == svcID
|
||||||
|
})
|
||||||
|
if len(r.routes[host]) == 0 {
|
||||||
|
delete(r.routes, host)
|
||||||
|
}
|
||||||
|
r.cancelServiceLocked(svcID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetFallback registers a catch-all route for connections that don't
|
||||||
|
// match any SNI route. On a port router this handles plain TCP relay;
|
||||||
|
// on the main router it takes priority over the HTTP channel.
|
||||||
|
func (r *Router) SetFallback(route Route) {
|
||||||
|
r.mu.Lock()
|
||||||
|
defer r.mu.Unlock()
|
||||||
|
r.fallback = &route
|
||||||
|
}
|
||||||
|
|
||||||
|
// RemoveFallback clears the catch-all fallback route and closes any
|
||||||
|
// active relay connections for the given service.
|
||||||
|
func (r *Router) RemoveFallback(svcID types.ServiceID) {
|
||||||
|
r.mu.Lock()
|
||||||
|
defer r.mu.Unlock()
|
||||||
|
r.fallback = nil
|
||||||
|
r.cancelServiceLocked(svcID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetObserver sets the relay lifecycle observer. Must be called before Serve.
|
||||||
|
func (r *Router) SetObserver(obs RelayObserver) {
|
||||||
|
r.mu.Lock()
|
||||||
|
defer r.mu.Unlock()
|
||||||
|
r.observer = obs
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetAccessLogger sets the L4 access logger. Must be called before Serve.
|
||||||
|
func (r *Router) SetAccessLogger(l l4Logger) {
|
||||||
|
r.mu.Lock()
|
||||||
|
defer r.mu.Unlock()
|
||||||
|
r.accessLog = l
|
||||||
|
}
|
||||||
|
|
||||||
|
// getObserver returns the current relay observer under the read lock.
|
||||||
|
func (r *Router) getObserver() RelayObserver {
|
||||||
|
r.mu.RLock()
|
||||||
|
defer r.mu.RUnlock()
|
||||||
|
return r.observer
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsEmpty returns true when the router has no SNI routes and no fallback.
|
||||||
|
func (r *Router) IsEmpty() bool {
|
||||||
|
r.mu.RLock()
|
||||||
|
defer r.mu.RUnlock()
|
||||||
|
return len(r.routes) == 0 && r.fallback == nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Serve accepts connections from ln and routes them based on SNI.
|
||||||
|
// It blocks until ctx is canceled or ln is closed, then drains
|
||||||
|
// active relay connections up to DefaultDrainTimeout.
|
||||||
|
func (r *Router) Serve(ctx context.Context, ln net.Listener) error {
|
||||||
|
done := make(chan struct{})
|
||||||
|
defer close(done)
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
_ = ln.Close()
|
||||||
|
if r.httpListener != nil {
|
||||||
|
r.httpListener.Close()
|
||||||
|
}
|
||||||
|
case <-done:
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
for {
|
||||||
|
conn, err := ln.Accept()
|
||||||
|
if err != nil {
|
||||||
|
if ctx.Err() != nil || errors.Is(err, net.ErrClosed) {
|
||||||
|
if ok := r.Drain(DefaultDrainTimeout); !ok {
|
||||||
|
r.logger.Warn("timed out waiting for connections to drain")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
r.logger.Debugf("SNI router accept: %v", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
r.activeConns.Add(1)
|
||||||
|
go func() {
|
||||||
|
defer r.activeConns.Done()
|
||||||
|
r.handleConn(ctx, conn)
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleConn peeks at the TLS ClientHello and routes the connection.
|
||||||
|
func (r *Router) handleConn(ctx context.Context, conn net.Conn) {
|
||||||
|
// Fast path: when no SNI routes and no HTTP channel exist (pure TCP
|
||||||
|
// fallback port), skip the TLS peek entirely to avoid read errors on
|
||||||
|
// non-TLS connections and reduce latency.
|
||||||
|
if r.isFallbackOnly() {
|
||||||
|
r.handleUnmatched(ctx, conn)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := conn.SetReadDeadline(time.Now().Add(sniPeekTimeout)); err != nil {
|
||||||
|
r.logger.Debugf("set SNI peek deadline: %v", err)
|
||||||
|
_ = conn.Close()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
sni, wrapped, err := PeekClientHello(conn)
|
||||||
|
if err != nil {
|
||||||
|
r.logger.Debugf("SNI peek: %v", err)
|
||||||
|
if wrapped != nil {
|
||||||
|
r.handleUnmatched(ctx, wrapped)
|
||||||
|
} else {
|
||||||
|
_ = conn.Close()
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := wrapped.SetReadDeadline(time.Time{}); err != nil {
|
||||||
|
r.logger.Debugf("clear SNI peek deadline: %v", err)
|
||||||
|
_ = wrapped.Close()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
host := SNIHost(sni)
|
||||||
|
route, ok := r.lookupRoute(host)
|
||||||
|
if !ok {
|
||||||
|
r.handleUnmatched(ctx, wrapped)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if route.Type == RouteHTTP {
|
||||||
|
r.sendToHTTP(wrapped)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := r.relayTCP(ctx, wrapped, host, route); err != nil {
|
||||||
|
r.logger.WithFields(log.Fields{
|
||||||
|
"sni": host,
|
||||||
|
"service_id": route.ServiceID,
|
||||||
|
"target": route.Target,
|
||||||
|
}).Warnf("TCP relay: %v", err)
|
||||||
|
_ = wrapped.Close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// isFallbackOnly returns true when the router has no SNI routes and no HTTP
|
||||||
|
// channel, meaning all connections should go directly to the fallback relay.
|
||||||
|
func (r *Router) isFallbackOnly() bool {
|
||||||
|
r.mu.RLock()
|
||||||
|
defer r.mu.RUnlock()
|
||||||
|
return len(r.routes) == 0 && r.httpCh == nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleUnmatched routes a connection that didn't match any SNI route.
|
||||||
|
// This includes ECH/ESNI connections where the cleartext SNI is empty.
|
||||||
|
// It tries the fallback relay first, then the HTTP channel, and closes
|
||||||
|
// the connection if neither is available.
|
||||||
|
func (r *Router) handleUnmatched(ctx context.Context, conn net.Conn) {
|
||||||
|
r.mu.RLock()
|
||||||
|
fb := r.fallback
|
||||||
|
r.mu.RUnlock()
|
||||||
|
|
||||||
|
if fb != nil {
|
||||||
|
if err := r.relayTCP(ctx, conn, SNIHost("fallback"), *fb); err != nil {
|
||||||
|
r.logger.WithFields(log.Fields{
|
||||||
|
"service_id": fb.ServiceID,
|
||||||
|
"target": fb.Target,
|
||||||
|
}).Warnf("TCP relay (fallback): %v", err)
|
||||||
|
_ = conn.Close()
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
r.sendToHTTP(conn)
|
||||||
|
}
|
||||||
|
|
||||||
|
// lookupRoute returns the highest-priority route for the given SNI host.
|
||||||
|
// HTTP routes take precedence over TCP routes.
|
||||||
|
func (r *Router) lookupRoute(host SNIHost) (Route, bool) {
|
||||||
|
r.mu.RLock()
|
||||||
|
defer r.mu.RUnlock()
|
||||||
|
routes, ok := r.routes[host]
|
||||||
|
if !ok || len(routes) == 0 {
|
||||||
|
return Route{}, false
|
||||||
|
}
|
||||||
|
best := routes[0]
|
||||||
|
for _, route := range routes[1:] {
|
||||||
|
if route.Type < best.Type {
|
||||||
|
best = route
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return best, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// sendToHTTP feeds the connection to the HTTP handler via the channel.
|
||||||
|
// If no HTTP channel is configured (port router), the router is
|
||||||
|
// draining, or the channel is full, the connection is closed.
|
||||||
|
func (r *Router) sendToHTTP(conn net.Conn) {
|
||||||
|
if r.httpCh == nil {
|
||||||
|
_ = conn.Close()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
r.mu.RLock()
|
||||||
|
draining := r.draining
|
||||||
|
r.mu.RUnlock()
|
||||||
|
|
||||||
|
if draining {
|
||||||
|
_ = conn.Close()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
select {
|
||||||
|
case r.httpCh <- conn:
|
||||||
|
default:
|
||||||
|
r.logger.Warnf("HTTP channel full, dropping connection from %s", conn.RemoteAddr())
|
||||||
|
_ = conn.Close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Drain prevents new relay connections from starting and waits for all
|
||||||
|
// in-flight connection handlers and active relays to finish, up to the
|
||||||
|
// given timeout. Returns true if all completed, false on timeout.
|
||||||
|
func (r *Router) Drain(timeout time.Duration) bool {
|
||||||
|
r.mu.Lock()
|
||||||
|
r.draining = true
|
||||||
|
if r.drainDone == nil {
|
||||||
|
done := make(chan struct{})
|
||||||
|
go func() {
|
||||||
|
r.activeConns.Wait()
|
||||||
|
r.activeRelays.Wait()
|
||||||
|
close(done)
|
||||||
|
}()
|
||||||
|
r.drainDone = done
|
||||||
|
}
|
||||||
|
done := r.drainDone
|
||||||
|
r.mu.Unlock()
|
||||||
|
|
||||||
|
select {
|
||||||
|
case <-done:
|
||||||
|
return true
|
||||||
|
case <-time.After(timeout):
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// cancelServiceLocked cancels and removes the context for the given service,
|
||||||
|
// closing all its active relay connections. Must be called with mu held.
|
||||||
|
func (r *Router) cancelServiceLocked(svcID types.ServiceID) {
|
||||||
|
if cancel, ok := r.svcCancels[svcID]; ok {
|
||||||
|
cancel()
|
||||||
|
delete(r.svcCtxs, svcID)
|
||||||
|
delete(r.svcCancels, svcID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// relayTCP sets up and runs a bidirectional TCP relay.
|
||||||
|
// The caller owns conn and must close it if this method returns an error.
|
||||||
|
// On success (nil error), both conn and backend are closed by the relay.
|
||||||
|
func (r *Router) relayTCP(ctx context.Context, conn net.Conn, sni SNIHost, route Route) error {
|
||||||
|
svcCtx, err := r.acquireRelay(ctx, route)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer func() {
|
||||||
|
<-r.relaySem
|
||||||
|
r.activeRelays.Done()
|
||||||
|
}()
|
||||||
|
|
||||||
|
backend, err := r.dialBackend(svcCtx, route)
|
||||||
|
if err != nil {
|
||||||
|
obs := r.getObserver()
|
||||||
|
if obs != nil {
|
||||||
|
obs.TCPRelayDialError(route.AccountID)
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if route.ProxyProtocol {
|
||||||
|
if err := writeProxyProtoV2(conn, backend); err != nil {
|
||||||
|
_ = backend.Close()
|
||||||
|
return fmt.Errorf("write PROXY protocol header: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
obs := r.getObserver()
|
||||||
|
if obs != nil {
|
||||||
|
obs.TCPRelayStarted(route.AccountID)
|
||||||
|
}
|
||||||
|
|
||||||
|
entry := r.logger.WithFields(log.Fields{
|
||||||
|
"sni": sni,
|
||||||
|
"service_id": route.ServiceID,
|
||||||
|
"target": route.Target,
|
||||||
|
})
|
||||||
|
entry.Debug("TCP relay started")
|
||||||
|
|
||||||
|
start := time.Now()
|
||||||
|
s2d, d2s := Relay(svcCtx, entry, conn, backend, DefaultIdleTimeout)
|
||||||
|
elapsed := time.Since(start)
|
||||||
|
|
||||||
|
if obs != nil {
|
||||||
|
obs.TCPRelayEnded(route.AccountID, elapsed, s2d, d2s)
|
||||||
|
}
|
||||||
|
entry.Debugf("TCP relay ended (client→backend: %d bytes, backend→client: %d bytes)", s2d, d2s)
|
||||||
|
|
||||||
|
r.logL4Entry(route, conn, elapsed, s2d, d2s)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// acquireRelay checks draining state, increments activeRelays, and acquires
|
||||||
|
// a semaphore slot. Returns the per-service context on success.
|
||||||
|
// The caller must release the semaphore and call activeRelays.Done() when done.
|
||||||
|
func (r *Router) acquireRelay(ctx context.Context, route Route) (context.Context, error) {
|
||||||
|
r.mu.Lock()
|
||||||
|
if r.draining {
|
||||||
|
r.mu.Unlock()
|
||||||
|
return nil, errors.New("router is draining")
|
||||||
|
}
|
||||||
|
r.activeRelays.Add(1)
|
||||||
|
svcCtx := r.getOrCreateServiceCtxLocked(ctx, route.ServiceID)
|
||||||
|
r.mu.Unlock()
|
||||||
|
|
||||||
|
select {
|
||||||
|
case r.relaySem <- struct{}{}:
|
||||||
|
return svcCtx, nil
|
||||||
|
default:
|
||||||
|
r.activeRelays.Done()
|
||||||
|
obs := r.getObserver()
|
||||||
|
if obs != nil {
|
||||||
|
obs.TCPRelayRejected(route.AccountID)
|
||||||
|
}
|
||||||
|
return nil, errors.New("TCP relay connection limit reached")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// dialBackend resolves the dialer for the route's account and dials the backend.
|
||||||
|
func (r *Router) dialBackend(svcCtx context.Context, route Route) (net.Conn, error) {
|
||||||
|
dialFn, err := r.dialResolve(route.AccountID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("resolve dialer: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
dialTimeout := route.DialTimeout
|
||||||
|
if dialTimeout <= 0 {
|
||||||
|
dialTimeout = defaultDialTimeout
|
||||||
|
}
|
||||||
|
dialCtx, dialCancel := context.WithTimeout(svcCtx, dialTimeout)
|
||||||
|
backend, err := dialFn(dialCtx, "tcp", route.Target)
|
||||||
|
dialCancel()
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("dial backend %s: %w", route.Target, err)
|
||||||
|
}
|
||||||
|
return backend, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// logL4Entry sends a TCP relay access log entry if an access logger is configured.
|
||||||
|
func (r *Router) logL4Entry(route Route, conn net.Conn, duration time.Duration, bytesUp, bytesDown int64) {
|
||||||
|
r.mu.RLock()
|
||||||
|
al := r.accessLog
|
||||||
|
r.mu.RUnlock()
|
||||||
|
|
||||||
|
if al == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var sourceIP netip.Addr
|
||||||
|
if remote := conn.RemoteAddr(); remote != nil {
|
||||||
|
if ap, err := netip.ParseAddrPort(remote.String()); err == nil {
|
||||||
|
sourceIP = ap.Addr().Unmap()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
al.LogL4(accesslog.L4Entry{
|
||||||
|
AccountID: route.AccountID,
|
||||||
|
ServiceID: route.ServiceID,
|
||||||
|
Protocol: route.Protocol,
|
||||||
|
Host: route.Domain,
|
||||||
|
SourceIP: sourceIP,
|
||||||
|
DurationMs: duration.Milliseconds(),
|
||||||
|
BytesUpload: bytesUp,
|
||||||
|
BytesDownload: bytesDown,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// getOrCreateServiceCtxLocked returns the context for a service, creating one
|
||||||
|
// if it doesn't exist yet. The context is a child of the server context.
|
||||||
|
// Must be called with mu held.
|
||||||
|
func (r *Router) getOrCreateServiceCtxLocked(parent context.Context, svcID types.ServiceID) context.Context {
|
||||||
|
if ctx, ok := r.svcCtxs[svcID]; ok {
|
||||||
|
return ctx
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithCancel(parent)
|
||||||
|
r.svcCtxs[svcID] = ctx
|
||||||
|
r.svcCancels[svcID] = cancel
|
||||||
|
return ctx
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,191 @@
|
|||||||
|
package tcp
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/binary"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
// TLS record header is 5 bytes: ContentType(1) + Version(2) + Length(2).
|
||||||
|
tlsRecordHeaderLen = 5
|
||||||
|
// TLS handshake type for ClientHello.
|
||||||
|
handshakeTypeClientHello = 1
|
||||||
|
// TLS ContentType for handshake messages.
|
||||||
|
contentTypeHandshake = 22
|
||||||
|
// SNI extension type (RFC 6066).
|
||||||
|
extensionServerName = 0
|
||||||
|
// SNI host name type.
|
||||||
|
sniHostNameType = 0
|
||||||
|
// maxClientHelloLen caps the ClientHello size we're willing to buffer.
|
||||||
|
maxClientHelloLen = 16384
|
||||||
|
// maxSNILen is the maximum valid DNS hostname length per RFC 1035.
|
||||||
|
maxSNILen = 253
|
||||||
|
)
|
||||||
|
|
||||||
|
// PeekClientHello reads the TLS ClientHello from conn, extracts the SNI
|
||||||
|
// server name, and returns a wrapped connection that replays the peeked
|
||||||
|
// bytes transparently. If the data is not a valid TLS ClientHello or
|
||||||
|
// contains no SNI extension, sni is empty and err is nil.
|
||||||
|
//
|
||||||
|
// ECH/ESNI: When the client uses Encrypted Client Hello (TLS 1.3), the
|
||||||
|
// real server name is encrypted inside the encrypted_client_hello
|
||||||
|
// extension. This parser only reads the cleartext server_name extension
|
||||||
|
// (type 0x0000), so ECH connections return sni="" and are routed through
|
||||||
|
// the fallback path (or HTTP channel), which is the correct behavior
|
||||||
|
// for a transparent proxy that does not terminate TLS.
|
||||||
|
func PeekClientHello(conn net.Conn) (sni string, wrapped net.Conn, err error) {
|
||||||
|
// Read the 5-byte TLS record header into a small stack-friendly buffer.
|
||||||
|
var header [tlsRecordHeaderLen]byte
|
||||||
|
if _, err := io.ReadFull(conn, header[:]); err != nil {
|
||||||
|
return "", nil, fmt.Errorf("read TLS record header: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if header[0] != contentTypeHandshake {
|
||||||
|
return "", newPeekedConn(conn, header[:]), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
recordLen := int(binary.BigEndian.Uint16(header[3:5]))
|
||||||
|
if recordLen == 0 || recordLen > maxClientHelloLen {
|
||||||
|
return "", newPeekedConn(conn, header[:]), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Single allocation for header + payload. The peekedConn takes
|
||||||
|
// ownership of this buffer, so no further copies are needed.
|
||||||
|
buf := make([]byte, tlsRecordHeaderLen+recordLen)
|
||||||
|
copy(buf, header[:])
|
||||||
|
|
||||||
|
n, err := io.ReadFull(conn, buf[tlsRecordHeaderLen:])
|
||||||
|
if err != nil {
|
||||||
|
return "", newPeekedConn(conn, buf[:tlsRecordHeaderLen+n]), fmt.Errorf("read TLS handshake payload: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
sni = extractSNI(buf[tlsRecordHeaderLen:])
|
||||||
|
return sni, newPeekedConn(conn, buf), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// extractSNI parses a TLS handshake payload to find the SNI extension.
|
||||||
|
// Returns empty string if the payload is not a ClientHello or has no SNI.
|
||||||
|
func extractSNI(payload []byte) string {
|
||||||
|
if len(payload) < 4 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
if payload[0] != handshakeTypeClientHello {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handshake length (3 bytes, big-endian).
|
||||||
|
handshakeLen := int(payload[1])<<16 | int(payload[2])<<8 | int(payload[3])
|
||||||
|
if handshakeLen > len(payload)-4 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
return parseSNIFromClientHello(payload[4 : 4+handshakeLen])
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseSNIFromClientHello walks the ClientHello message fields to reach
|
||||||
|
// the extensions block and extract the server_name extension value.
|
||||||
|
func parseSNIFromClientHello(msg []byte) string {
|
||||||
|
// ClientHello layout:
|
||||||
|
// ProtocolVersion(2) + Random(32) = 34 bytes minimum before session_id
|
||||||
|
if len(msg) < 34 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
pos := 34
|
||||||
|
|
||||||
|
// Session ID (variable, 1 byte length prefix).
|
||||||
|
if pos >= len(msg) {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
sessionIDLen := int(msg[pos])
|
||||||
|
pos++
|
||||||
|
pos += sessionIDLen
|
||||||
|
|
||||||
|
// Cipher suites (variable, 2 byte length prefix).
|
||||||
|
if pos+2 > len(msg) {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
cipherSuitesLen := int(binary.BigEndian.Uint16(msg[pos : pos+2]))
|
||||||
|
pos += 2 + cipherSuitesLen
|
||||||
|
|
||||||
|
// Compression methods (variable, 1 byte length prefix).
|
||||||
|
if pos >= len(msg) {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
compMethodsLen := int(msg[pos])
|
||||||
|
pos++
|
||||||
|
pos += compMethodsLen
|
||||||
|
|
||||||
|
// Extensions (variable, 2 byte length prefix).
|
||||||
|
if pos+2 > len(msg) {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
extensionsLen := int(binary.BigEndian.Uint16(msg[pos : pos+2]))
|
||||||
|
pos += 2
|
||||||
|
|
||||||
|
extensionsEnd := pos + extensionsLen
|
||||||
|
if extensionsEnd > len(msg) {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
return findSNIExtension(msg[pos:extensionsEnd])
|
||||||
|
}
|
||||||
|
|
||||||
|
// findSNIExtension iterates over TLS extensions and returns the host
|
||||||
|
// name from the server_name extension, if present.
|
||||||
|
func findSNIExtension(extensions []byte) string {
|
||||||
|
pos := 0
|
||||||
|
for pos+4 <= len(extensions) {
|
||||||
|
extType := binary.BigEndian.Uint16(extensions[pos : pos+2])
|
||||||
|
extLen := int(binary.BigEndian.Uint16(extensions[pos+2 : pos+4]))
|
||||||
|
pos += 4
|
||||||
|
|
||||||
|
if pos+extLen > len(extensions) {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
if extType == extensionServerName {
|
||||||
|
return parseSNIExtensionData(extensions[pos : pos+extLen])
|
||||||
|
}
|
||||||
|
pos += extLen
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseSNIExtensionData parses the ServerNameList structure inside an
|
||||||
|
// SNI extension to extract the host name.
|
||||||
|
func parseSNIExtensionData(data []byte) string {
|
||||||
|
if len(data) < 2 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
listLen := int(binary.BigEndian.Uint16(data[0:2]))
|
||||||
|
if listLen > len(data)-2 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
list := data[2 : 2+listLen]
|
||||||
|
pos := 0
|
||||||
|
for pos+3 <= len(list) {
|
||||||
|
nameType := list[pos]
|
||||||
|
nameLen := int(binary.BigEndian.Uint16(list[pos+1 : pos+3]))
|
||||||
|
pos += 3
|
||||||
|
|
||||||
|
if pos+nameLen > len(list) {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
if nameType == sniHostNameType {
|
||||||
|
name := list[pos : pos+nameLen]
|
||||||
|
if nameLen > maxSNILen || bytes.ContainsRune(name, 0) {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return string(name)
|
||||||
|
}
|
||||||
|
pos += nameLen
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
@@ -0,0 +1,251 @@
|
|||||||
|
package tcp
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/tls"
|
||||||
|
"io"
|
||||||
|
"net"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestPeekClientHello_ValidSNI(t *testing.T) {
|
||||||
|
clientConn, serverConn := net.Pipe()
|
||||||
|
defer clientConn.Close()
|
||||||
|
defer serverConn.Close()
|
||||||
|
|
||||||
|
const expectedSNI = "example.com"
|
||||||
|
trailingData := []byte("trailing data after handshake")
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
tlsConn := tls.Client(clientConn, &tls.Config{
|
||||||
|
ServerName: expectedSNI,
|
||||||
|
InsecureSkipVerify: true, //nolint:gosec
|
||||||
|
})
|
||||||
|
// The Handshake will send the ClientHello. It will fail because
|
||||||
|
// our server side isn't doing a real TLS handshake, but that's
|
||||||
|
// fine: we only need the ClientHello to be sent.
|
||||||
|
_ = tlsConn.Handshake()
|
||||||
|
}()
|
||||||
|
|
||||||
|
sni, wrapped, err := PeekClientHello(serverConn)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, expectedSNI, sni, "should extract SNI from ClientHello")
|
||||||
|
assert.NotNil(t, wrapped, "wrapped connection should not be nil")
|
||||||
|
|
||||||
|
// Verify the wrapped connection replays the peeked bytes.
|
||||||
|
// Read the first 5 bytes (TLS record header) to confirm replay.
|
||||||
|
buf := make([]byte, 5)
|
||||||
|
n, err := wrapped.Read(buf)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, 5, n)
|
||||||
|
assert.Equal(t, byte(contentTypeHandshake), buf[0], "first byte should be TLS handshake content type")
|
||||||
|
|
||||||
|
// Write trailing data from the client side and verify it arrives
|
||||||
|
// through the wrapped connection after the peeked bytes.
|
||||||
|
go func() {
|
||||||
|
_, _ = clientConn.Write(trailingData)
|
||||||
|
}()
|
||||||
|
|
||||||
|
// Drain the rest of the peeked ClientHello first.
|
||||||
|
peekedRest := make([]byte, 16384)
|
||||||
|
_, _ = wrapped.Read(peekedRest)
|
||||||
|
|
||||||
|
got := make([]byte, len(trailingData))
|
||||||
|
n, err = io.ReadFull(wrapped, got)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, trailingData, got[:n])
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPeekClientHello_MultipleSNIs(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
serverName string
|
||||||
|
expectedSNI string
|
||||||
|
}{
|
||||||
|
{"simple domain", "example.com", "example.com"},
|
||||||
|
{"subdomain", "sub.example.com", "sub.example.com"},
|
||||||
|
{"deep subdomain", "a.b.c.example.com", "a.b.c.example.com"},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
clientConn, serverConn := net.Pipe()
|
||||||
|
defer clientConn.Close()
|
||||||
|
defer serverConn.Close()
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
tlsConn := tls.Client(clientConn, &tls.Config{
|
||||||
|
ServerName: tt.serverName,
|
||||||
|
InsecureSkipVerify: true, //nolint:gosec
|
||||||
|
})
|
||||||
|
_ = tlsConn.Handshake()
|
||||||
|
}()
|
||||||
|
|
||||||
|
sni, wrapped, err := PeekClientHello(serverConn)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, tt.expectedSNI, sni)
|
||||||
|
assert.NotNil(t, wrapped)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPeekClientHello_NonTLSData(t *testing.T) {
|
||||||
|
clientConn, serverConn := net.Pipe()
|
||||||
|
defer clientConn.Close()
|
||||||
|
defer serverConn.Close()
|
||||||
|
|
||||||
|
// Send plain HTTP data (not TLS).
|
||||||
|
httpData := []byte("GET / HTTP/1.1\r\nHost: example.com\r\n\r\n")
|
||||||
|
go func() {
|
||||||
|
_, _ = clientConn.Write(httpData)
|
||||||
|
}()
|
||||||
|
|
||||||
|
sni, wrapped, err := PeekClientHello(serverConn)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Empty(t, sni, "should return empty SNI for non-TLS data")
|
||||||
|
assert.NotNil(t, wrapped)
|
||||||
|
|
||||||
|
// Verify the wrapped connection still provides the original data.
|
||||||
|
buf := make([]byte, len(httpData))
|
||||||
|
n, err := io.ReadFull(wrapped, buf)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, httpData, buf[:n], "wrapped connection should replay original data")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPeekClientHello_TruncatedHeader(t *testing.T) {
|
||||||
|
clientConn, serverConn := net.Pipe()
|
||||||
|
defer serverConn.Close()
|
||||||
|
|
||||||
|
// Write only 3 bytes then close, fewer than the 5-byte TLS header.
|
||||||
|
go func() {
|
||||||
|
_, _ = clientConn.Write([]byte{0x16, 0x03, 0x01})
|
||||||
|
clientConn.Close()
|
||||||
|
}()
|
||||||
|
|
||||||
|
_, _, err := PeekClientHello(serverConn)
|
||||||
|
assert.Error(t, err, "should error on truncated header")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPeekClientHello_TruncatedPayload(t *testing.T) {
|
||||||
|
clientConn, serverConn := net.Pipe()
|
||||||
|
defer serverConn.Close()
|
||||||
|
|
||||||
|
// Write a valid TLS header claiming 100 bytes, but only send 10.
|
||||||
|
go func() {
|
||||||
|
header := []byte{0x16, 0x03, 0x01, 0x00, 0x64} // 100 bytes claimed
|
||||||
|
_, _ = clientConn.Write(header)
|
||||||
|
_, _ = clientConn.Write(make([]byte, 10))
|
||||||
|
clientConn.Close()
|
||||||
|
}()
|
||||||
|
|
||||||
|
_, _, err := PeekClientHello(serverConn)
|
||||||
|
assert.Error(t, err, "should error on truncated payload")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPeekClientHello_ZeroLengthRecord(t *testing.T) {
|
||||||
|
clientConn, serverConn := net.Pipe()
|
||||||
|
defer clientConn.Close()
|
||||||
|
defer serverConn.Close()
|
||||||
|
|
||||||
|
// TLS handshake header with zero-length payload.
|
||||||
|
go func() {
|
||||||
|
_, _ = clientConn.Write([]byte{0x16, 0x03, 0x01, 0x00, 0x00})
|
||||||
|
}()
|
||||||
|
|
||||||
|
sni, wrapped, err := PeekClientHello(serverConn)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Empty(t, sni)
|
||||||
|
assert.NotNil(t, wrapped)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExtractSNI_InvalidPayload(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
payload []byte
|
||||||
|
}{
|
||||||
|
{"nil", nil},
|
||||||
|
{"empty", []byte{}},
|
||||||
|
{"too short", []byte{0x01, 0x00}},
|
||||||
|
{"wrong handshake type", []byte{0x02, 0x00, 0x00, 0x05, 0x03, 0x03, 0x00, 0x00, 0x00}},
|
||||||
|
{"truncated client hello", []byte{0x01, 0x00, 0x00, 0x20}}, // claims 32 bytes but has none
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
assert.Empty(t, extractSNI(tt.payload))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPeekedConn_CloseWrite(t *testing.T) {
|
||||||
|
t.Run("delegates to underlying TCPConn", func(t *testing.T) {
|
||||||
|
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer ln.Close()
|
||||||
|
|
||||||
|
accepted := make(chan net.Conn, 1)
|
||||||
|
go func() {
|
||||||
|
c, err := ln.Accept()
|
||||||
|
if err == nil {
|
||||||
|
accepted <- c
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
client, err := net.Dial("tcp", ln.Addr().String())
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer client.Close()
|
||||||
|
|
||||||
|
server := <-accepted
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
wrapped := newPeekedConn(server, []byte("peeked"))
|
||||||
|
|
||||||
|
// CloseWrite should succeed on a real TCP connection.
|
||||||
|
err = wrapped.CloseWrite()
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
// The client should see EOF on reads after CloseWrite.
|
||||||
|
buf := make([]byte, 1)
|
||||||
|
_, err = client.Read(buf)
|
||||||
|
assert.Equal(t, io.EOF, err, "client should see EOF after half-close")
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("no-op on non-halfcloser", func(t *testing.T) {
|
||||||
|
// net.Pipe does not implement CloseWrite.
|
||||||
|
_, server := net.Pipe()
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
wrapped := newPeekedConn(server, []byte("peeked"))
|
||||||
|
err := wrapped.CloseWrite()
|
||||||
|
assert.NoError(t, err, "should be no-op on non-halfcloser")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPeekedConn_ReplayAndPassthrough(t *testing.T) {
|
||||||
|
clientConn, serverConn := net.Pipe()
|
||||||
|
defer clientConn.Close()
|
||||||
|
defer serverConn.Close()
|
||||||
|
|
||||||
|
peeked := []byte("peeked-data")
|
||||||
|
subsequent := []byte("subsequent-data")
|
||||||
|
|
||||||
|
wrapped := newPeekedConn(serverConn, peeked)
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
_, _ = clientConn.Write(subsequent)
|
||||||
|
}()
|
||||||
|
|
||||||
|
// Read should return peeked data first.
|
||||||
|
buf := make([]byte, len(peeked))
|
||||||
|
n, err := io.ReadFull(wrapped, buf)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, peeked, buf[:n])
|
||||||
|
|
||||||
|
// Then subsequent data from the real connection.
|
||||||
|
buf = make([]byte, len(subsequent))
|
||||||
|
n, err = io.ReadFull(wrapped, buf)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, subsequent, buf[:n])
|
||||||
|
}
|
||||||
@@ -1,5 +1,56 @@
|
|||||||
// Package types defines common types used across the proxy package.
|
// Package types defines common types used across the proxy package.
|
||||||
package types
|
package types
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
// AccountID represents a unique identifier for a NetBird account.
|
// AccountID represents a unique identifier for a NetBird account.
|
||||||
type AccountID string
|
type AccountID string
|
||||||
|
|
||||||
|
// ServiceID represents a unique identifier for a proxy service.
|
||||||
|
type ServiceID string
|
||||||
|
|
||||||
|
// ServiceMode describes how a reverse proxy service is exposed.
|
||||||
|
type ServiceMode string
|
||||||
|
|
||||||
|
const (
|
||||||
|
ServiceModeHTTP ServiceMode = "http"
|
||||||
|
ServiceModeTCP ServiceMode = "tcp"
|
||||||
|
ServiceModeUDP ServiceMode = "udp"
|
||||||
|
ServiceModeTLS ServiceMode = "tls"
|
||||||
|
)
|
||||||
|
|
||||||
|
// IsL4 returns true for TCP, UDP, and TLS modes.
|
||||||
|
func (m ServiceMode) IsL4() bool {
|
||||||
|
return m == ServiceModeTCP || m == ServiceModeUDP || m == ServiceModeTLS
|
||||||
|
}
|
||||||
|
|
||||||
|
// RelayDirection indicates the direction of a relayed packet.
|
||||||
|
type RelayDirection string
|
||||||
|
|
||||||
|
const (
|
||||||
|
RelayDirectionClientToBackend RelayDirection = "client_to_backend"
|
||||||
|
RelayDirectionBackendToClient RelayDirection = "backend_to_client"
|
||||||
|
)
|
||||||
|
|
||||||
|
// DialContextFunc dials a backend through the WireGuard tunnel.
|
||||||
|
type DialContextFunc func(ctx context.Context, network, address string) (net.Conn, error)
|
||||||
|
|
||||||
|
// dialTimeoutKey is the context key for a per-request dial timeout.
|
||||||
|
type dialTimeoutKey struct{}
|
||||||
|
|
||||||
|
// WithDialTimeout returns a context carrying a dial timeout that
|
||||||
|
// DialContext wrappers can use to scope the timeout to just the
|
||||||
|
// connection establishment phase.
|
||||||
|
func WithDialTimeout(ctx context.Context, d time.Duration) context.Context {
|
||||||
|
return context.WithValue(ctx, dialTimeoutKey{}, d)
|
||||||
|
}
|
||||||
|
|
||||||
|
// DialTimeoutFromContext returns the dial timeout from the context, if set.
|
||||||
|
func DialTimeoutFromContext(ctx context.Context) (time.Duration, bool) {
|
||||||
|
d, ok := ctx.Value(dialTimeoutKey{}).(time.Duration)
|
||||||
|
return d, ok && d > 0
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
package types
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestServiceMode_IsL4(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
mode ServiceMode
|
||||||
|
want bool
|
||||||
|
}{
|
||||||
|
{ServiceModeHTTP, false},
|
||||||
|
{ServiceModeTCP, true},
|
||||||
|
{ServiceModeUDP, true},
|
||||||
|
{ServiceModeTLS, true},
|
||||||
|
{ServiceMode("unknown"), false},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(string(tt.mode), func(t *testing.T) {
|
||||||
|
assert.Equal(t, tt.want, tt.mode.IsL4())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDialTimeoutContext(t *testing.T) {
|
||||||
|
t.Run("round trip", func(t *testing.T) {
|
||||||
|
ctx := WithDialTimeout(context.Background(), 5*time.Second)
|
||||||
|
d, ok := DialTimeoutFromContext(ctx)
|
||||||
|
assert.True(t, ok)
|
||||||
|
assert.Equal(t, 5*time.Second, d)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("missing", func(t *testing.T) {
|
||||||
|
_, ok := DialTimeoutFromContext(context.Background())
|
||||||
|
assert.False(t, ok)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("zero returns false", func(t *testing.T) {
|
||||||
|
ctx := WithDialTimeout(context.Background(), 0)
|
||||||
|
_, ok := DialTimeoutFromContext(ctx)
|
||||||
|
assert.False(t, ok, "zero duration should return ok=false")
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("negative returns false", func(t *testing.T) {
|
||||||
|
ctx := WithDialTimeout(context.Background(), -1*time.Second)
|
||||||
|
_, ok := DialTimeoutFromContext(ctx)
|
||||||
|
assert.False(t, ok, "negative duration should return ok=false")
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,496 @@
|
|||||||
|
package udp
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"net"
|
||||||
|
"net/netip"
|
||||||
|
"sync"
|
||||||
|
"sync/atomic"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
log "github.com/sirupsen/logrus"
|
||||||
|
"golang.org/x/time/rate"
|
||||||
|
|
||||||
|
"github.com/netbirdio/netbird/proxy/internal/accesslog"
|
||||||
|
"github.com/netbirdio/netbird/proxy/internal/netutil"
|
||||||
|
"github.com/netbirdio/netbird/proxy/internal/types"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
// DefaultSessionTTL is the default idle timeout for UDP sessions before cleanup.
|
||||||
|
DefaultSessionTTL = 30 * time.Second
|
||||||
|
// cleanupInterval is how often the cleaner goroutine runs.
|
||||||
|
cleanupInterval = time.Minute
|
||||||
|
// maxPacketSize is the maximum UDP packet size we'll handle.
|
||||||
|
maxPacketSize = 65535
|
||||||
|
// DefaultMaxSessions is the default cap on concurrent UDP sessions per relay.
|
||||||
|
DefaultMaxSessions = 1024
|
||||||
|
// sessionCreateRate limits new session creation per second.
|
||||||
|
sessionCreateRate = 50
|
||||||
|
// sessionCreateBurst is the burst allowance for session creation.
|
||||||
|
sessionCreateBurst = 100
|
||||||
|
// defaultDialTimeout is the fallback dial timeout for backend connections.
|
||||||
|
defaultDialTimeout = 30 * time.Second
|
||||||
|
)
|
||||||
|
|
||||||
|
// l4Logger sends layer-4 access log entries to the management server.
|
||||||
|
type l4Logger interface {
|
||||||
|
LogL4(entry accesslog.L4Entry)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SessionObserver receives callbacks for UDP session lifecycle events.
|
||||||
|
// All methods must be safe for concurrent use.
|
||||||
|
type SessionObserver interface {
|
||||||
|
UDPSessionStarted(accountID types.AccountID)
|
||||||
|
UDPSessionEnded(accountID types.AccountID)
|
||||||
|
UDPSessionDialError(accountID types.AccountID)
|
||||||
|
UDPSessionRejected(accountID types.AccountID)
|
||||||
|
UDPPacketRelayed(direction types.RelayDirection, bytes int)
|
||||||
|
}
|
||||||
|
|
||||||
|
// clientAddr is a typed key for UDP session lookups.
|
||||||
|
type clientAddr string
|
||||||
|
|
||||||
|
// Relay listens for incoming UDP packets on a dedicated port and
|
||||||
|
// maintains per-client sessions that relay packets to a backend
|
||||||
|
// through the WireGuard tunnel.
|
||||||
|
type Relay struct {
|
||||||
|
logger *log.Entry
|
||||||
|
listener net.PacketConn
|
||||||
|
target string
|
||||||
|
domain string
|
||||||
|
accountID types.AccountID
|
||||||
|
serviceID types.ServiceID
|
||||||
|
dialFunc types.DialContextFunc
|
||||||
|
dialTimeout time.Duration
|
||||||
|
sessionTTL time.Duration
|
||||||
|
maxSessions int
|
||||||
|
|
||||||
|
mu sync.RWMutex
|
||||||
|
sessions map[clientAddr]*session
|
||||||
|
|
||||||
|
bufPool sync.Pool
|
||||||
|
sessLimiter *rate.Limiter
|
||||||
|
sessWg sync.WaitGroup
|
||||||
|
ctx context.Context
|
||||||
|
cancel context.CancelFunc
|
||||||
|
observer SessionObserver
|
||||||
|
accessLog l4Logger
|
||||||
|
}
|
||||||
|
|
||||||
|
type session struct {
|
||||||
|
backend net.Conn
|
||||||
|
addr net.Addr
|
||||||
|
createdAt time.Time
|
||||||
|
// lastSeen stores the last activity timestamp as unix nanoseconds.
|
||||||
|
lastSeen atomic.Int64
|
||||||
|
cancel context.CancelFunc
|
||||||
|
// bytesIn tracks total bytes received from the client.
|
||||||
|
bytesIn atomic.Int64
|
||||||
|
// bytesOut tracks total bytes sent back to the client.
|
||||||
|
bytesOut atomic.Int64
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *session) updateLastSeen() {
|
||||||
|
s.lastSeen.Store(time.Now().UnixNano())
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *session) idleDuration() time.Duration {
|
||||||
|
return time.Since(time.Unix(0, s.lastSeen.Load()))
|
||||||
|
}
|
||||||
|
|
||||||
|
// RelayConfig holds the configuration for a UDP relay.
|
||||||
|
type RelayConfig struct {
|
||||||
|
Logger *log.Entry
|
||||||
|
Listener net.PacketConn
|
||||||
|
Target string
|
||||||
|
Domain string
|
||||||
|
AccountID types.AccountID
|
||||||
|
ServiceID types.ServiceID
|
||||||
|
DialFunc types.DialContextFunc
|
||||||
|
DialTimeout time.Duration
|
||||||
|
SessionTTL time.Duration
|
||||||
|
MaxSessions int
|
||||||
|
AccessLog l4Logger
|
||||||
|
}
|
||||||
|
|
||||||
|
// New creates a UDP relay for the given listener and backend target.
|
||||||
|
// MaxSessions caps the number of concurrent sessions; use 0 for DefaultMaxSessions.
|
||||||
|
// DialTimeout controls how long to wait for backend connections; use 0 for default.
|
||||||
|
// SessionTTL is the idle timeout before a session is reaped; use 0 for DefaultSessionTTL.
|
||||||
|
func New(parentCtx context.Context, cfg RelayConfig) *Relay {
|
||||||
|
maxSessions := cfg.MaxSessions
|
||||||
|
dialTimeout := cfg.DialTimeout
|
||||||
|
sessionTTL := cfg.SessionTTL
|
||||||
|
if maxSessions <= 0 {
|
||||||
|
maxSessions = DefaultMaxSessions
|
||||||
|
}
|
||||||
|
if dialTimeout <= 0 {
|
||||||
|
dialTimeout = defaultDialTimeout
|
||||||
|
}
|
||||||
|
if sessionTTL <= 0 {
|
||||||
|
sessionTTL = DefaultSessionTTL
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithCancel(parentCtx)
|
||||||
|
return &Relay{
|
||||||
|
logger: cfg.Logger,
|
||||||
|
listener: cfg.Listener,
|
||||||
|
target: cfg.Target,
|
||||||
|
domain: cfg.Domain,
|
||||||
|
accountID: cfg.AccountID,
|
||||||
|
serviceID: cfg.ServiceID,
|
||||||
|
accessLog: cfg.AccessLog,
|
||||||
|
dialFunc: cfg.DialFunc,
|
||||||
|
dialTimeout: dialTimeout,
|
||||||
|
sessionTTL: sessionTTL,
|
||||||
|
maxSessions: maxSessions,
|
||||||
|
sessions: make(map[clientAddr]*session),
|
||||||
|
bufPool: sync.Pool{
|
||||||
|
New: func() any {
|
||||||
|
buf := make([]byte, maxPacketSize)
|
||||||
|
return &buf
|
||||||
|
},
|
||||||
|
},
|
||||||
|
sessLimiter: rate.NewLimiter(sessionCreateRate, sessionCreateBurst),
|
||||||
|
ctx: ctx,
|
||||||
|
cancel: cancel,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ServiceID returns the service ID associated with this relay.
|
||||||
|
func (r *Relay) ServiceID() types.ServiceID {
|
||||||
|
return r.serviceID
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetObserver sets the session lifecycle observer. Must be called before Serve.
|
||||||
|
func (r *Relay) SetObserver(obs SessionObserver) {
|
||||||
|
r.observer = obs
|
||||||
|
}
|
||||||
|
|
||||||
|
// Serve starts the relay loop. It blocks until the context is canceled
|
||||||
|
// or the listener is closed.
|
||||||
|
func (r *Relay) Serve() {
|
||||||
|
go r.cleanupLoop()
|
||||||
|
|
||||||
|
for {
|
||||||
|
bufp := r.bufPool.Get().(*[]byte)
|
||||||
|
buf := *bufp
|
||||||
|
|
||||||
|
n, addr, err := r.listener.ReadFrom(buf)
|
||||||
|
if err != nil {
|
||||||
|
r.bufPool.Put(bufp)
|
||||||
|
if r.ctx.Err() != nil || errors.Is(err, net.ErrClosed) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
r.logger.Debugf("UDP read: %v", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
data := buf[:n]
|
||||||
|
sess, err := r.getOrCreateSession(addr)
|
||||||
|
if err != nil {
|
||||||
|
r.bufPool.Put(bufp)
|
||||||
|
r.logger.Debugf("create UDP session for %s: %v", addr, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
sess.updateLastSeen()
|
||||||
|
|
||||||
|
nw, err := sess.backend.Write(data)
|
||||||
|
if err != nil {
|
||||||
|
r.bufPool.Put(bufp)
|
||||||
|
if !netutil.IsExpectedError(err) {
|
||||||
|
r.logger.Debugf("UDP write to backend for %s: %v", addr, err)
|
||||||
|
}
|
||||||
|
r.removeSession(sess)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
sess.bytesIn.Add(int64(nw))
|
||||||
|
|
||||||
|
if r.observer != nil {
|
||||||
|
r.observer.UDPPacketRelayed(types.RelayDirectionClientToBackend, nw)
|
||||||
|
}
|
||||||
|
r.bufPool.Put(bufp)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// getOrCreateSession returns an existing session or creates a new one.
|
||||||
|
func (r *Relay) getOrCreateSession(addr net.Addr) (*session, error) {
|
||||||
|
key := clientAddr(addr.String())
|
||||||
|
|
||||||
|
r.mu.RLock()
|
||||||
|
sess, ok := r.sessions[key]
|
||||||
|
r.mu.RUnlock()
|
||||||
|
if ok && sess != nil {
|
||||||
|
return sess, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check before taking the write lock: if the relay is shutting down,
|
||||||
|
// don't create new sessions. This prevents orphaned goroutines when
|
||||||
|
// Serve() processes a packet that was already read before Close().
|
||||||
|
if r.ctx.Err() != nil {
|
||||||
|
return nil, r.ctx.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
r.mu.Lock()
|
||||||
|
|
||||||
|
if sess, ok = r.sessions[key]; ok && sess != nil {
|
||||||
|
r.mu.Unlock()
|
||||||
|
return sess, nil
|
||||||
|
}
|
||||||
|
if ok {
|
||||||
|
// Another goroutine is dialing for this key, skip.
|
||||||
|
r.mu.Unlock()
|
||||||
|
return nil, fmt.Errorf("session dial in progress for %s", key)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(r.sessions) >= r.maxSessions {
|
||||||
|
r.mu.Unlock()
|
||||||
|
if r.observer != nil {
|
||||||
|
r.observer.UDPSessionRejected(r.accountID)
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("session limit reached (%d)", r.maxSessions)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !r.sessLimiter.Allow() {
|
||||||
|
r.mu.Unlock()
|
||||||
|
if r.observer != nil {
|
||||||
|
r.observer.UDPSessionRejected(r.accountID)
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("session creation rate limited")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reserve the slot with a nil session so concurrent callers for the same
|
||||||
|
// key see it exists and wait. Release the lock before dialing.
|
||||||
|
r.sessions[key] = nil
|
||||||
|
r.mu.Unlock()
|
||||||
|
|
||||||
|
dialCtx, dialCancel := context.WithTimeout(r.ctx, r.dialTimeout)
|
||||||
|
backend, err := r.dialFunc(dialCtx, "udp", r.target)
|
||||||
|
dialCancel()
|
||||||
|
if err != nil {
|
||||||
|
r.mu.Lock()
|
||||||
|
delete(r.sessions, key)
|
||||||
|
r.mu.Unlock()
|
||||||
|
if r.observer != nil {
|
||||||
|
r.observer.UDPSessionDialError(r.accountID)
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("dial backend %s: %w", r.target, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
sessCtx, sessCancel := context.WithCancel(r.ctx)
|
||||||
|
sess = &session{
|
||||||
|
backend: backend,
|
||||||
|
addr: addr,
|
||||||
|
createdAt: time.Now(),
|
||||||
|
cancel: sessCancel,
|
||||||
|
}
|
||||||
|
sess.updateLastSeen()
|
||||||
|
|
||||||
|
r.mu.Lock()
|
||||||
|
r.sessions[key] = sess
|
||||||
|
r.mu.Unlock()
|
||||||
|
|
||||||
|
if r.observer != nil {
|
||||||
|
r.observer.UDPSessionStarted(r.accountID)
|
||||||
|
}
|
||||||
|
|
||||||
|
r.sessWg.Go(func() {
|
||||||
|
r.relayBackendToClient(sessCtx, sess)
|
||||||
|
})
|
||||||
|
|
||||||
|
r.logger.Debugf("UDP session created for %s", addr)
|
||||||
|
return sess, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// relayBackendToClient reads packets from the backend and writes them
|
||||||
|
// back to the client through the public-facing listener.
|
||||||
|
func (r *Relay) relayBackendToClient(ctx context.Context, sess *session) {
|
||||||
|
bufp := r.bufPool.Get().(*[]byte)
|
||||||
|
defer r.bufPool.Put(bufp)
|
||||||
|
defer r.removeSession(sess)
|
||||||
|
|
||||||
|
for ctx.Err() == nil {
|
||||||
|
data, ok := r.readBackendPacket(sess, *bufp)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if data == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
sess.updateLastSeen()
|
||||||
|
|
||||||
|
nw, err := r.listener.WriteTo(data, sess.addr)
|
||||||
|
if err != nil {
|
||||||
|
if !netutil.IsExpectedError(err) {
|
||||||
|
r.logger.Debugf("UDP write to client %s: %v", sess.addr, err)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
sess.bytesOut.Add(int64(nw))
|
||||||
|
|
||||||
|
if r.observer != nil {
|
||||||
|
r.observer.UDPPacketRelayed(types.RelayDirectionBackendToClient, nw)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// readBackendPacket reads one packet from the backend with an idle deadline.
|
||||||
|
// Returns (data, true) on success, (nil, true) on idle timeout that should
|
||||||
|
// retry, or (nil, false) when the session should be torn down.
|
||||||
|
func (r *Relay) readBackendPacket(sess *session, buf []byte) ([]byte, bool) {
|
||||||
|
if err := sess.backend.SetReadDeadline(time.Now().Add(r.sessionTTL)); err != nil {
|
||||||
|
r.logger.Debugf("set backend read deadline for %s: %v", sess.addr, err)
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
|
||||||
|
n, err := sess.backend.Read(buf)
|
||||||
|
if err != nil {
|
||||||
|
if netutil.IsTimeout(err) {
|
||||||
|
if sess.idleDuration() > r.sessionTTL {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
return nil, true
|
||||||
|
}
|
||||||
|
if !netutil.IsExpectedError(err) {
|
||||||
|
r.logger.Debugf("UDP read from backend for %s: %v", sess.addr, err)
|
||||||
|
}
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
|
||||||
|
return buf[:n], true
|
||||||
|
}
|
||||||
|
|
||||||
|
// cleanupLoop periodically removes idle sessions.
|
||||||
|
func (r *Relay) cleanupLoop() {
|
||||||
|
ticker := time.NewTicker(cleanupInterval)
|
||||||
|
defer ticker.Stop()
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-r.ctx.Done():
|
||||||
|
return
|
||||||
|
case <-ticker.C:
|
||||||
|
r.cleanupIdleSessions()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// cleanupIdleSessions closes sessions that have been idle for too long.
|
||||||
|
func (r *Relay) cleanupIdleSessions() {
|
||||||
|
var expired []*session
|
||||||
|
|
||||||
|
r.mu.Lock()
|
||||||
|
for key, sess := range r.sessions {
|
||||||
|
if sess == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
idle := sess.idleDuration()
|
||||||
|
if idle > r.sessionTTL {
|
||||||
|
r.logger.Debugf("UDP session %s idle for %s, closing (client→backend: %d bytes, backend→client: %d bytes)",
|
||||||
|
sess.addr, idle, sess.bytesIn.Load(), sess.bytesOut.Load())
|
||||||
|
delete(r.sessions, key)
|
||||||
|
sess.cancel()
|
||||||
|
if err := sess.backend.Close(); err != nil {
|
||||||
|
r.logger.Debugf("close idle session %s backend: %v", sess.addr, err)
|
||||||
|
}
|
||||||
|
expired = append(expired, sess)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
r.mu.Unlock()
|
||||||
|
|
||||||
|
for _, sess := range expired {
|
||||||
|
if r.observer != nil {
|
||||||
|
r.observer.UDPSessionEnded(r.accountID)
|
||||||
|
}
|
||||||
|
r.logSessionEnd(sess)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// removeSession removes a session from the map if it still matches the
|
||||||
|
// given pointer. This is safe to call concurrently with cleanupIdleSessions
|
||||||
|
// because the identity check prevents double-close when both paths race.
|
||||||
|
func (r *Relay) removeSession(sess *session) {
|
||||||
|
r.mu.Lock()
|
||||||
|
key := clientAddr(sess.addr.String())
|
||||||
|
removed := r.sessions[key] == sess
|
||||||
|
if removed {
|
||||||
|
delete(r.sessions, key)
|
||||||
|
sess.cancel()
|
||||||
|
if err := sess.backend.Close(); err != nil {
|
||||||
|
r.logger.Debugf("close session %s backend: %v", sess.addr, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
r.mu.Unlock()
|
||||||
|
|
||||||
|
if removed {
|
||||||
|
r.logger.Debugf("UDP session %s ended (client→backend: %d bytes, backend→client: %d bytes)",
|
||||||
|
sess.addr, sess.bytesIn.Load(), sess.bytesOut.Load())
|
||||||
|
if r.observer != nil {
|
||||||
|
r.observer.UDPSessionEnded(r.accountID)
|
||||||
|
}
|
||||||
|
r.logSessionEnd(sess)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// logSessionEnd sends an access log entry for a completed UDP session.
|
||||||
|
func (r *Relay) logSessionEnd(sess *session) {
|
||||||
|
if r.accessLog == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var sourceIP netip.Addr
|
||||||
|
if ap, err := netip.ParseAddrPort(sess.addr.String()); err == nil {
|
||||||
|
sourceIP = ap.Addr().Unmap()
|
||||||
|
}
|
||||||
|
|
||||||
|
r.accessLog.LogL4(accesslog.L4Entry{
|
||||||
|
AccountID: r.accountID,
|
||||||
|
ServiceID: r.serviceID,
|
||||||
|
Protocol: accesslog.ProtocolUDP,
|
||||||
|
Host: r.domain,
|
||||||
|
SourceIP: sourceIP,
|
||||||
|
DurationMs: time.Unix(0, sess.lastSeen.Load()).Sub(sess.createdAt).Milliseconds(),
|
||||||
|
BytesUpload: sess.bytesIn.Load(),
|
||||||
|
BytesDownload: sess.bytesOut.Load(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close stops the relay, waits for all session goroutines to exit,
|
||||||
|
// and cleans up remaining sessions.
|
||||||
|
func (r *Relay) Close() {
|
||||||
|
r.cancel()
|
||||||
|
if err := r.listener.Close(); err != nil {
|
||||||
|
r.logger.Debugf("close UDP listener: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var closedSessions []*session
|
||||||
|
r.mu.Lock()
|
||||||
|
for key, sess := range r.sessions {
|
||||||
|
if sess == nil {
|
||||||
|
delete(r.sessions, key)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
r.logger.Debugf("UDP session %s closed (client→backend: %d bytes, backend→client: %d bytes)",
|
||||||
|
sess.addr, sess.bytesIn.Load(), sess.bytesOut.Load())
|
||||||
|
sess.cancel()
|
||||||
|
if err := sess.backend.Close(); err != nil {
|
||||||
|
r.logger.Debugf("close session %s backend: %v", sess.addr, err)
|
||||||
|
}
|
||||||
|
delete(r.sessions, key)
|
||||||
|
closedSessions = append(closedSessions, sess)
|
||||||
|
}
|
||||||
|
r.mu.Unlock()
|
||||||
|
|
||||||
|
for _, sess := range closedSessions {
|
||||||
|
if r.observer != nil {
|
||||||
|
r.observer.UDPSessionEnded(r.accountID)
|
||||||
|
}
|
||||||
|
r.logSessionEnd(sess)
|
||||||
|
}
|
||||||
|
|
||||||
|
r.sessWg.Wait()
|
||||||
|
}
|
||||||
@@ -0,0 +1,493 @@
|
|||||||
|
package udp
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"net"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
log "github.com/sirupsen/logrus"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
|
||||||
|
"github.com/netbirdio/netbird/proxy/internal/types"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestRelay_BasicPacketExchange(t *testing.T) {
|
||||||
|
// Set up a UDP backend that echoes packets.
|
||||||
|
backend, err := net.ListenPacket("udp", "127.0.0.1:0")
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer backend.Close()
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
buf := make([]byte, 65535)
|
||||||
|
for {
|
||||||
|
n, addr, err := backend.ReadFrom(buf)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_, _ = backend.WriteTo(buf[:n], addr)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
// Set up the relay's public-facing listener.
|
||||||
|
listener, err := net.ListenPacket("udp", "127.0.0.1:0")
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer listener.Close()
|
||||||
|
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
logger := log.NewEntry(log.StandardLogger())
|
||||||
|
backendAddr := backend.LocalAddr().String()
|
||||||
|
|
||||||
|
dialFunc := func(ctx context.Context, network, address string) (net.Conn, error) {
|
||||||
|
return net.Dial(network, address)
|
||||||
|
}
|
||||||
|
|
||||||
|
relay := New(ctx, RelayConfig{Logger: logger, Listener: listener, Target: backendAddr, DialFunc: dialFunc})
|
||||||
|
go relay.Serve()
|
||||||
|
defer relay.Close()
|
||||||
|
|
||||||
|
// Create a client and send a packet to the relay.
|
||||||
|
client, err := net.Dial("udp", listener.LocalAddr().String())
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer client.Close()
|
||||||
|
|
||||||
|
testData := []byte("hello UDP relay")
|
||||||
|
_, err = client.Write(testData)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// Read the echoed response.
|
||||||
|
if err := client.SetReadDeadline(time.Now().Add(2 * time.Second)); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
buf := make([]byte, 1024)
|
||||||
|
n, err := client.Read(buf)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, testData, buf[:n], "should receive echoed packet")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRelay_MultipleClients(t *testing.T) {
|
||||||
|
backend, err := net.ListenPacket("udp", "127.0.0.1:0")
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer backend.Close()
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
buf := make([]byte, 65535)
|
||||||
|
for {
|
||||||
|
n, addr, err := backend.ReadFrom(buf)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_, _ = backend.WriteTo(buf[:n], addr)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
listener, err := net.ListenPacket("udp", "127.0.0.1:0")
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer listener.Close()
|
||||||
|
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
logger := log.NewEntry(log.StandardLogger())
|
||||||
|
dialFunc := func(ctx context.Context, network, address string) (net.Conn, error) {
|
||||||
|
return net.Dial(network, address)
|
||||||
|
}
|
||||||
|
|
||||||
|
relay := New(ctx, RelayConfig{Logger: logger, Listener: listener, Target: backend.LocalAddr().String(), DialFunc: dialFunc})
|
||||||
|
go relay.Serve()
|
||||||
|
defer relay.Close()
|
||||||
|
|
||||||
|
// Two clients, each should get their own session.
|
||||||
|
for i, msg := range []string{"client-1", "client-2"} {
|
||||||
|
client, err := net.Dial("udp", listener.LocalAddr().String())
|
||||||
|
require.NoError(t, err, "client %d", i)
|
||||||
|
defer client.Close()
|
||||||
|
|
||||||
|
_, err = client.Write([]byte(msg))
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
if err := client.SetReadDeadline(time.Now().Add(2 * time.Second)); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
buf := make([]byte, 1024)
|
||||||
|
n, err := client.Read(buf)
|
||||||
|
require.NoError(t, err, "client %d read", i)
|
||||||
|
assert.Equal(t, msg, string(buf[:n]), "client %d should get own echo", i)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify two sessions were created.
|
||||||
|
relay.mu.RLock()
|
||||||
|
sessionCount := len(relay.sessions)
|
||||||
|
relay.mu.RUnlock()
|
||||||
|
assert.Equal(t, 2, sessionCount, "should have two sessions")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRelay_Close(t *testing.T) {
|
||||||
|
listener, err := net.ListenPacket("udp", "127.0.0.1:0")
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
logger := log.NewEntry(log.StandardLogger())
|
||||||
|
dialFunc := func(ctx context.Context, network, address string) (net.Conn, error) {
|
||||||
|
return net.Dial(network, address)
|
||||||
|
}
|
||||||
|
|
||||||
|
relay := New(ctx, RelayConfig{Logger: logger, Listener: listener, Target: "127.0.0.1:9999", DialFunc: dialFunc})
|
||||||
|
|
||||||
|
done := make(chan struct{})
|
||||||
|
go func() {
|
||||||
|
relay.Serve()
|
||||||
|
close(done)
|
||||||
|
}()
|
||||||
|
|
||||||
|
relay.Close()
|
||||||
|
|
||||||
|
select {
|
||||||
|
case <-done:
|
||||||
|
case <-time.After(5 * time.Second):
|
||||||
|
t.Fatal("Serve did not return after Close")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRelay_SessionCleanup(t *testing.T) {
|
||||||
|
backend, err := net.ListenPacket("udp", "127.0.0.1:0")
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer backend.Close()
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
buf := make([]byte, 65535)
|
||||||
|
for {
|
||||||
|
n, addr, err := backend.ReadFrom(buf)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_, _ = backend.WriteTo(buf[:n], addr)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
listener, err := net.ListenPacket("udp", "127.0.0.1:0")
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer listener.Close()
|
||||||
|
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
logger := log.NewEntry(log.StandardLogger())
|
||||||
|
dialFunc := func(ctx context.Context, network, address string) (net.Conn, error) {
|
||||||
|
return net.Dial(network, address)
|
||||||
|
}
|
||||||
|
|
||||||
|
relay := New(ctx, RelayConfig{Logger: logger, Listener: listener, Target: backend.LocalAddr().String(), DialFunc: dialFunc})
|
||||||
|
go relay.Serve()
|
||||||
|
defer relay.Close()
|
||||||
|
|
||||||
|
// Create a session.
|
||||||
|
client, err := net.Dial("udp", listener.LocalAddr().String())
|
||||||
|
require.NoError(t, err)
|
||||||
|
_, err = client.Write([]byte("hello"))
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
if err := client.SetReadDeadline(time.Now().Add(2 * time.Second)); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
buf := make([]byte, 1024)
|
||||||
|
_, err = client.Read(buf)
|
||||||
|
require.NoError(t, err)
|
||||||
|
client.Close()
|
||||||
|
|
||||||
|
// Verify session exists.
|
||||||
|
relay.mu.RLock()
|
||||||
|
assert.Equal(t, 1, len(relay.sessions))
|
||||||
|
relay.mu.RUnlock()
|
||||||
|
|
||||||
|
// Make session appear idle by setting lastSeen to the past.
|
||||||
|
relay.mu.Lock()
|
||||||
|
for _, sess := range relay.sessions {
|
||||||
|
sess.lastSeen.Store(time.Now().Add(-2 * DefaultSessionTTL).UnixNano())
|
||||||
|
}
|
||||||
|
relay.mu.Unlock()
|
||||||
|
|
||||||
|
// Trigger cleanup manually.
|
||||||
|
relay.cleanupIdleSessions()
|
||||||
|
|
||||||
|
relay.mu.RLock()
|
||||||
|
assert.Equal(t, 0, len(relay.sessions), "idle sessions should be cleaned up")
|
||||||
|
relay.mu.RUnlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRelay_CloseAndRecreate verifies that closing a relay and creating a new
|
||||||
|
// one on the same port works cleanly (simulates port mapping modify cycle).
|
||||||
|
func TestRelay_CloseAndRecreate(t *testing.T) {
|
||||||
|
backend, err := net.ListenPacket("udp", "127.0.0.1:0")
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer backend.Close()
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
buf := make([]byte, 65535)
|
||||||
|
for {
|
||||||
|
n, addr, err := backend.ReadFrom(buf)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_, _ = backend.WriteTo(buf[:n], addr)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
logger := log.NewEntry(log.StandardLogger())
|
||||||
|
dialFunc := func(ctx context.Context, network, address string) (net.Conn, error) {
|
||||||
|
return net.Dial(network, address)
|
||||||
|
}
|
||||||
|
|
||||||
|
// First relay.
|
||||||
|
ln1, err := net.ListenPacket("udp", "127.0.0.1:0")
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
relay1 := New(ctx, RelayConfig{Logger: logger, Listener: ln1, Target: backend.LocalAddr().String(), DialFunc: dialFunc})
|
||||||
|
go relay1.Serve()
|
||||||
|
|
||||||
|
client1, err := net.Dial("udp", ln1.LocalAddr().String())
|
||||||
|
require.NoError(t, err)
|
||||||
|
_, err = client1.Write([]byte("relay1"))
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NoError(t, client1.SetReadDeadline(time.Now().Add(2*time.Second)))
|
||||||
|
buf := make([]byte, 1024)
|
||||||
|
n, err := client1.Read(buf)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, "relay1", string(buf[:n]))
|
||||||
|
client1.Close()
|
||||||
|
|
||||||
|
// Close first relay.
|
||||||
|
relay1.Close()
|
||||||
|
|
||||||
|
// Second relay on same port.
|
||||||
|
port := ln1.LocalAddr().(*net.UDPAddr).Port
|
||||||
|
ln2, err := net.ListenPacket("udp", fmt.Sprintf("127.0.0.1:%d", port))
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
relay2 := New(ctx, RelayConfig{Logger: logger, Listener: ln2, Target: backend.LocalAddr().String(), DialFunc: dialFunc})
|
||||||
|
go relay2.Serve()
|
||||||
|
defer relay2.Close()
|
||||||
|
|
||||||
|
client2, err := net.Dial("udp", ln2.LocalAddr().String())
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer client2.Close()
|
||||||
|
_, err = client2.Write([]byte("relay2"))
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NoError(t, client2.SetReadDeadline(time.Now().Add(2*time.Second)))
|
||||||
|
n, err = client2.Read(buf)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, "relay2", string(buf[:n]), "second relay should work on same port")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRelay_SessionLimit(t *testing.T) {
|
||||||
|
backend, err := net.ListenPacket("udp", "127.0.0.1:0")
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer backend.Close()
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
buf := make([]byte, 65535)
|
||||||
|
for {
|
||||||
|
n, addr, err := backend.ReadFrom(buf)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_, _ = backend.WriteTo(buf[:n], addr)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
listener, err := net.ListenPacket("udp", "127.0.0.1:0")
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer listener.Close()
|
||||||
|
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
logger := log.NewEntry(log.StandardLogger())
|
||||||
|
dialFunc := func(ctx context.Context, network, address string) (net.Conn, error) {
|
||||||
|
return net.Dial(network, address)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a relay with a max of 2 sessions.
|
||||||
|
relay := New(ctx, RelayConfig{Logger: logger, Listener: listener, Target: backend.LocalAddr().String(), DialFunc: dialFunc, MaxSessions: 2})
|
||||||
|
go relay.Serve()
|
||||||
|
defer relay.Close()
|
||||||
|
|
||||||
|
// Create 2 clients to fill up the session limit.
|
||||||
|
for i := range 2 {
|
||||||
|
client, err := net.Dial("udp", listener.LocalAddr().String())
|
||||||
|
require.NoError(t, err, "client %d", i)
|
||||||
|
defer client.Close()
|
||||||
|
|
||||||
|
_, err = client.Write([]byte("hello"))
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
require.NoError(t, client.SetReadDeadline(time.Now().Add(2*time.Second)))
|
||||||
|
buf := make([]byte, 1024)
|
||||||
|
_, err = client.Read(buf)
|
||||||
|
require.NoError(t, err, "client %d should get response", i)
|
||||||
|
}
|
||||||
|
|
||||||
|
relay.mu.RLock()
|
||||||
|
assert.Equal(t, 2, len(relay.sessions), "should have exactly 2 sessions")
|
||||||
|
relay.mu.RUnlock()
|
||||||
|
|
||||||
|
// Third client should get its packet dropped (session creation fails).
|
||||||
|
client3, err := net.Dial("udp", listener.LocalAddr().String())
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer client3.Close()
|
||||||
|
|
||||||
|
_, err = client3.Write([]byte("should be dropped"))
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
require.NoError(t, client3.SetReadDeadline(time.Now().Add(500*time.Millisecond)))
|
||||||
|
buf := make([]byte, 1024)
|
||||||
|
_, err = client3.Read(buf)
|
||||||
|
assert.Error(t, err, "third client should time out because session was rejected")
|
||||||
|
|
||||||
|
relay.mu.RLock()
|
||||||
|
assert.Equal(t, 2, len(relay.sessions), "session count should not exceed limit")
|
||||||
|
relay.mu.RUnlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
// testObserver records UDP session lifecycle events for test assertions.
|
||||||
|
type testObserver struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
started int
|
||||||
|
ended int
|
||||||
|
rejected int
|
||||||
|
dialErr int
|
||||||
|
packets int
|
||||||
|
bytes int
|
||||||
|
}
|
||||||
|
|
||||||
|
func (o *testObserver) UDPSessionStarted(types.AccountID) { o.mu.Lock(); o.started++; o.mu.Unlock() }
|
||||||
|
func (o *testObserver) UDPSessionEnded(types.AccountID) { o.mu.Lock(); o.ended++; o.mu.Unlock() }
|
||||||
|
func (o *testObserver) UDPSessionDialError(types.AccountID) { o.mu.Lock(); o.dialErr++; o.mu.Unlock() }
|
||||||
|
func (o *testObserver) UDPSessionRejected(types.AccountID) { o.mu.Lock(); o.rejected++; o.mu.Unlock() }
|
||||||
|
func (o *testObserver) UDPPacketRelayed(_ types.RelayDirection, b int) {
|
||||||
|
o.mu.Lock()
|
||||||
|
o.packets++
|
||||||
|
o.bytes += b
|
||||||
|
o.mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRelay_CloseFiresObserverEnded(t *testing.T) {
|
||||||
|
backend, err := net.ListenPacket("udp", "127.0.0.1:0")
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer backend.Close()
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
buf := make([]byte, 65535)
|
||||||
|
for {
|
||||||
|
n, addr, err := backend.ReadFrom(buf)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_, _ = backend.WriteTo(buf[:n], addr)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
listener, err := net.ListenPacket("udp", "127.0.0.1:0")
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer listener.Close()
|
||||||
|
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
logger := log.NewEntry(log.StandardLogger())
|
||||||
|
dialFunc := func(ctx context.Context, network, address string) (net.Conn, error) {
|
||||||
|
return net.Dial(network, address)
|
||||||
|
}
|
||||||
|
|
||||||
|
obs := &testObserver{}
|
||||||
|
relay := New(ctx, RelayConfig{Logger: logger, Listener: listener, Target: backend.LocalAddr().String(), AccountID: "test-acct", DialFunc: dialFunc})
|
||||||
|
relay.SetObserver(obs)
|
||||||
|
go relay.Serve()
|
||||||
|
|
||||||
|
// Create two sessions.
|
||||||
|
for i := range 2 {
|
||||||
|
client, err := net.Dial("udp", listener.LocalAddr().String())
|
||||||
|
require.NoError(t, err, "client %d", i)
|
||||||
|
|
||||||
|
_, err = client.Write([]byte("hello"))
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
require.NoError(t, client.SetReadDeadline(time.Now().Add(2*time.Second)))
|
||||||
|
buf := make([]byte, 1024)
|
||||||
|
_, err = client.Read(buf)
|
||||||
|
require.NoError(t, err)
|
||||||
|
client.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
obs.mu.Lock()
|
||||||
|
assert.Equal(t, 2, obs.started, "should have 2 started events")
|
||||||
|
obs.mu.Unlock()
|
||||||
|
|
||||||
|
// Close should fire UDPSessionEnded for all remaining sessions.
|
||||||
|
relay.Close()
|
||||||
|
|
||||||
|
obs.mu.Lock()
|
||||||
|
assert.Equal(t, 2, obs.ended, "Close should fire UDPSessionEnded for each session")
|
||||||
|
obs.mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRelay_SessionRateLimit(t *testing.T) {
|
||||||
|
backend, err := net.ListenPacket("udp", "127.0.0.1:0")
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer backend.Close()
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
buf := make([]byte, 65535)
|
||||||
|
for {
|
||||||
|
n, addr, err := backend.ReadFrom(buf)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_, _ = backend.WriteTo(buf[:n], addr)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
listener, err := net.ListenPacket("udp", "127.0.0.1:0")
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer listener.Close()
|
||||||
|
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
logger := log.NewEntry(log.StandardLogger())
|
||||||
|
dialFunc := func(ctx context.Context, network, address string) (net.Conn, error) {
|
||||||
|
return net.Dial(network, address)
|
||||||
|
}
|
||||||
|
|
||||||
|
obs := &testObserver{}
|
||||||
|
// High max sessions (1000) but the relay uses a rate limiter internally
|
||||||
|
// (default: 50/s burst 100). We exhaust the burst by creating sessions
|
||||||
|
// rapidly, then verify that subsequent creates are rejected.
|
||||||
|
relay := New(ctx, RelayConfig{Logger: logger, Listener: listener, Target: backend.LocalAddr().String(), AccountID: "test-acct", DialFunc: dialFunc, MaxSessions: 1000})
|
||||||
|
relay.SetObserver(obs)
|
||||||
|
go relay.Serve()
|
||||||
|
defer relay.Close()
|
||||||
|
|
||||||
|
// Exhaust the burst by calling getOrCreateSession directly with
|
||||||
|
// synthetic addresses. This is faster than real UDP round-trips.
|
||||||
|
for i := range sessionCreateBurst + 20 {
|
||||||
|
addr := &net.UDPAddr{IP: net.IPv4(10, 0, byte(i/256), byte(i%256)), Port: 10000 + i}
|
||||||
|
_, _ = relay.getOrCreateSession(addr)
|
||||||
|
}
|
||||||
|
|
||||||
|
obs.mu.Lock()
|
||||||
|
rejected := obs.rejected
|
||||||
|
obs.mu.Unlock()
|
||||||
|
|
||||||
|
assert.Greater(t, rejected, 0, "some sessions should be rate-limited")
|
||||||
|
}
|
||||||
@@ -243,6 +243,10 @@ func (c *testProxyController) GetProxiesForCluster(_ string) []string {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (c *testProxyController) ClusterSupportsCustomPorts(_ string) *bool {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// storeBackedServiceManager reads directly from the real store.
|
// storeBackedServiceManager reads directly from the real store.
|
||||||
type storeBackedServiceManager struct {
|
type storeBackedServiceManager struct {
|
||||||
store store.Store
|
store store.Store
|
||||||
@@ -505,15 +509,15 @@ func TestIntegration_ProxyConnection_ReconnectDoesNotDuplicateState(t *testing.T
|
|||||||
nil,
|
nil,
|
||||||
"",
|
"",
|
||||||
0,
|
0,
|
||||||
mapping.GetAccountId(),
|
proxytypes.AccountID(mapping.GetAccountId()),
|
||||||
mapping.GetId(),
|
proxytypes.ServiceID(mapping.GetId()),
|
||||||
)
|
)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
// Apply to real proxy (idempotent)
|
// Apply to real proxy (idempotent)
|
||||||
proxyHandler.AddMapping(proxy.Mapping{
|
proxyHandler.AddMapping(proxy.Mapping{
|
||||||
Host: mapping.GetDomain(),
|
Host: mapping.GetDomain(),
|
||||||
ID: mapping.GetId(),
|
ID: proxytypes.ServiceID(mapping.GetId()),
|
||||||
AccountID: proxytypes.AccountID(mapping.GetAccountId()),
|
AccountID: proxytypes.AccountID(mapping.GetAccountId()),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
+723
-59
File diff suppressed because it is too large
Load Diff
@@ -56,12 +56,14 @@ type ExposeRequest struct {
|
|||||||
Pin string
|
Pin string
|
||||||
Password string
|
Password string
|
||||||
UserGroups []string
|
UserGroups []string
|
||||||
|
ListenPort uint16
|
||||||
}
|
}
|
||||||
|
|
||||||
type ExposeResponse struct {
|
type ExposeResponse struct {
|
||||||
ServiceName string
|
ServiceName string
|
||||||
Domain string
|
Domain string
|
||||||
ServiceURL string
|
ServiceURL string
|
||||||
|
PortAutoAssigned bool
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewClient creates a new client to Management service
|
// NewClient creates a new client to Management service
|
||||||
@@ -790,9 +792,10 @@ func (c *GrpcClient) StopExpose(ctx context.Context, domain string) error {
|
|||||||
|
|
||||||
func fromProtoExposeResponse(resp *proto.ExposeServiceResponse) *ExposeResponse {
|
func fromProtoExposeResponse(resp *proto.ExposeServiceResponse) *ExposeResponse {
|
||||||
return &ExposeResponse{
|
return &ExposeResponse{
|
||||||
ServiceName: resp.ServiceName,
|
ServiceName: resp.ServiceName,
|
||||||
Domain: resp.Domain,
|
Domain: resp.Domain,
|
||||||
ServiceURL: resp.ServiceUrl,
|
ServiceURL: resp.ServiceUrl,
|
||||||
|
PortAutoAssigned: resp.PortAutoAssigned,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -808,6 +811,8 @@ func toProtoExposeServiceRequest(req ExposeRequest) (*proto.ExposeServiceRequest
|
|||||||
protocol = proto.ExposeProtocol_EXPOSE_TCP
|
protocol = proto.ExposeProtocol_EXPOSE_TCP
|
||||||
case int(proto.ExposeProtocol_EXPOSE_UDP):
|
case int(proto.ExposeProtocol_EXPOSE_UDP):
|
||||||
protocol = proto.ExposeProtocol_EXPOSE_UDP
|
protocol = proto.ExposeProtocol_EXPOSE_UDP
|
||||||
|
case int(proto.ExposeProtocol_EXPOSE_TLS):
|
||||||
|
protocol = proto.ExposeProtocol_EXPOSE_TLS
|
||||||
default:
|
default:
|
||||||
return nil, fmt.Errorf("invalid expose protocol: %d", req.Protocol)
|
return nil, fmt.Errorf("invalid expose protocol: %d", req.Protocol)
|
||||||
}
|
}
|
||||||
@@ -820,6 +825,7 @@ func toProtoExposeServiceRequest(req ExposeRequest) (*proto.ExposeServiceRequest
|
|||||||
Pin: req.Pin,
|
Pin: req.Pin,
|
||||||
Password: req.Password,
|
Password: req.Password,
|
||||||
UserGroups: req.UserGroups,
|
UserGroups: req.UserGroups,
|
||||||
|
ListenPort: uint32(req.ListenPort),
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2836,6 +2836,10 @@ components:
|
|||||||
format: int64
|
format: int64
|
||||||
description: "Bytes downloaded (response body size)"
|
description: "Bytes downloaded (response body size)"
|
||||||
example: 8192
|
example: 8192
|
||||||
|
protocol:
|
||||||
|
type: string
|
||||||
|
description: "Protocol type: http, tcp, or udp"
|
||||||
|
example: "http"
|
||||||
required:
|
required:
|
||||||
- id
|
- id
|
||||||
- service_id
|
- service_id
|
||||||
@@ -2954,6 +2958,20 @@ components:
|
|||||||
domain:
|
domain:
|
||||||
type: string
|
type: string
|
||||||
description: Domain for the service
|
description: Domain for the service
|
||||||
|
mode:
|
||||||
|
type: string
|
||||||
|
description: Service mode. "http" for L7 reverse proxy, "tcp"/"udp"/"tls" for L4 passthrough.
|
||||||
|
enum: [http, tcp, udp, tls]
|
||||||
|
default: http
|
||||||
|
listen_port:
|
||||||
|
type: integer
|
||||||
|
minimum: 0
|
||||||
|
maximum: 65535
|
||||||
|
description: Port the proxy listens on (L4/TLS only)
|
||||||
|
port_auto_assigned:
|
||||||
|
type: boolean
|
||||||
|
description: Whether the listen port was auto-assigned
|
||||||
|
readOnly: true
|
||||||
proxy_cluster:
|
proxy_cluster:
|
||||||
type: string
|
type: string
|
||||||
description: The proxy cluster handling this service (derived from domain)
|
description: The proxy cluster handling this service (derived from domain)
|
||||||
@@ -3020,6 +3038,16 @@ components:
|
|||||||
domain:
|
domain:
|
||||||
type: string
|
type: string
|
||||||
description: Domain for the service
|
description: Domain for the service
|
||||||
|
mode:
|
||||||
|
type: string
|
||||||
|
description: Service mode. "http" for L7 reverse proxy, "tcp"/"udp"/"tls" for L4 passthrough.
|
||||||
|
enum: [http, tcp, udp, tls]
|
||||||
|
default: http
|
||||||
|
listen_port:
|
||||||
|
type: integer
|
||||||
|
minimum: 0
|
||||||
|
maximum: 65535
|
||||||
|
description: Port the proxy listens on (L4/TLS only). Set to 0 for auto-assignment.
|
||||||
targets:
|
targets:
|
||||||
type: array
|
type: array
|
||||||
items:
|
items:
|
||||||
@@ -3040,8 +3068,6 @@ components:
|
|||||||
required:
|
required:
|
||||||
- name
|
- name
|
||||||
- domain
|
- domain
|
||||||
- targets
|
|
||||||
- auth
|
|
||||||
- enabled
|
- enabled
|
||||||
ServiceTargetOptions:
|
ServiceTargetOptions:
|
||||||
type: object
|
type: object
|
||||||
@@ -3065,6 +3091,12 @@ components:
|
|||||||
additionalProperties:
|
additionalProperties:
|
||||||
type: string
|
type: string
|
||||||
pattern: '^[^\r\n]*$'
|
pattern: '^[^\r\n]*$'
|
||||||
|
proxy_protocol:
|
||||||
|
type: boolean
|
||||||
|
description: Send PROXY Protocol v2 header to this backend (TCP/TLS only)
|
||||||
|
session_idle_timeout:
|
||||||
|
type: string
|
||||||
|
description: Idle timeout before a UDP session is reaped, as a Go duration string (e.g. "30s", "2m"). Maximum 10m.
|
||||||
ServiceTarget:
|
ServiceTarget:
|
||||||
type: object
|
type: object
|
||||||
properties:
|
properties:
|
||||||
@@ -3073,21 +3105,23 @@ components:
|
|||||||
description: Target ID
|
description: Target ID
|
||||||
target_type:
|
target_type:
|
||||||
type: string
|
type: string
|
||||||
description: Target type (e.g., "peer", "resource")
|
description: Target type
|
||||||
enum: [peer, resource]
|
enum: [peer, host, domain, subnet]
|
||||||
path:
|
path:
|
||||||
type: string
|
type: string
|
||||||
description: URL path prefix for this target
|
description: URL path prefix for this target (HTTP only)
|
||||||
protocol:
|
protocol:
|
||||||
type: string
|
type: string
|
||||||
description: Protocol to use when connecting to the backend
|
description: Protocol to use when connecting to the backend
|
||||||
enum: [http, https]
|
enum: [http, https, tcp, udp]
|
||||||
host:
|
host:
|
||||||
type: string
|
type: string
|
||||||
description: Backend ip or domain for this target
|
description: Backend ip or domain for this target
|
||||||
port:
|
port:
|
||||||
type: integer
|
type: integer
|
||||||
description: Backend port for this target. Use 0 or omit to use the scheme default (80 for http, 443 for https).
|
minimum: 1
|
||||||
|
maximum: 65535
|
||||||
|
description: Backend port for this target
|
||||||
enabled:
|
enabled:
|
||||||
type: boolean
|
type: boolean
|
||||||
description: Whether this target is enabled
|
description: Whether this target is enabled
|
||||||
@@ -3194,6 +3228,9 @@ components:
|
|||||||
target_cluster:
|
target_cluster:
|
||||||
type: string
|
type: string
|
||||||
description: The proxy cluster this domain is validated against (only for custom domains)
|
description: The proxy cluster this domain is validated against (only for custom domains)
|
||||||
|
supports_custom_ports:
|
||||||
|
type: boolean
|
||||||
|
description: Whether the cluster supports binding arbitrary TCP/UDP ports
|
||||||
required:
|
required:
|
||||||
- id
|
- id
|
||||||
- domain
|
- domain
|
||||||
@@ -4277,6 +4314,12 @@ components:
|
|||||||
requires_authentication:
|
requires_authentication:
|
||||||
description: Requires authentication
|
description: Requires authentication
|
||||||
content: { }
|
content: { }
|
||||||
|
conflict:
|
||||||
|
description: Conflict
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/ErrorResponse'
|
||||||
securitySchemes:
|
securitySchemes:
|
||||||
BearerAuth:
|
BearerAuth:
|
||||||
type: http
|
type: http
|
||||||
@@ -9621,6 +9664,29 @@ paths:
|
|||||||
application/json:
|
application/json:
|
||||||
schema:
|
schema:
|
||||||
$ref: '#/components/schemas/ErrorResponse'
|
$ref: '#/components/schemas/ErrorResponse'
|
||||||
|
/api/reverse-proxies/clusters:
|
||||||
|
get:
|
||||||
|
summary: List available proxy clusters
|
||||||
|
description: Returns a list of available proxy clusters with their connection status
|
||||||
|
tags: [ Services ]
|
||||||
|
security:
|
||||||
|
- BearerAuth: [ ]
|
||||||
|
- TokenAuth: [ ]
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: A JSON Array of proxy clusters
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
$ref: '#/components/schemas/ProxyCluster'
|
||||||
|
'401':
|
||||||
|
"$ref": "#/components/responses/requires_authentication"
|
||||||
|
'403':
|
||||||
|
"$ref": "#/components/responses/forbidden"
|
||||||
|
'500':
|
||||||
|
"$ref": "#/components/responses/internal_error"
|
||||||
/api/reverse-proxies/services:
|
/api/reverse-proxies/services:
|
||||||
get:
|
get:
|
||||||
summary: List all Services
|
summary: List all Services
|
||||||
@@ -9670,29 +9736,8 @@ paths:
|
|||||||
"$ref": "#/components/responses/requires_authentication"
|
"$ref": "#/components/responses/requires_authentication"
|
||||||
'403':
|
'403':
|
||||||
"$ref": "#/components/responses/forbidden"
|
"$ref": "#/components/responses/forbidden"
|
||||||
'500':
|
'409':
|
||||||
"$ref": "#/components/responses/internal_error"
|
"$ref": "#/components/responses/conflict"
|
||||||
/api/reverse-proxies/clusters:
|
|
||||||
get:
|
|
||||||
summary: List available proxy clusters
|
|
||||||
description: Returns a list of available proxy clusters with their connection status
|
|
||||||
tags: [ Services ]
|
|
||||||
security:
|
|
||||||
- BearerAuth: [ ]
|
|
||||||
- TokenAuth: [ ]
|
|
||||||
responses:
|
|
||||||
'200':
|
|
||||||
description: A JSON Array of proxy clusters
|
|
||||||
content:
|
|
||||||
application/json:
|
|
||||||
schema:
|
|
||||||
type: array
|
|
||||||
items:
|
|
||||||
$ref: '#/components/schemas/ProxyCluster'
|
|
||||||
'401':
|
|
||||||
"$ref": "#/components/responses/requires_authentication"
|
|
||||||
'403':
|
|
||||||
"$ref": "#/components/responses/forbidden"
|
|
||||||
'500':
|
'500':
|
||||||
"$ref": "#/components/responses/internal_error"
|
"$ref": "#/components/responses/internal_error"
|
||||||
/api/reverse-proxies/services/{serviceId}:
|
/api/reverse-proxies/services/{serviceId}:
|
||||||
@@ -9762,6 +9807,8 @@ paths:
|
|||||||
"$ref": "#/components/responses/forbidden"
|
"$ref": "#/components/responses/forbidden"
|
||||||
'404':
|
'404':
|
||||||
"$ref": "#/components/responses/not_found"
|
"$ref": "#/components/responses/not_found"
|
||||||
|
'409':
|
||||||
|
"$ref": "#/components/responses/conflict"
|
||||||
'500':
|
'500':
|
||||||
"$ref": "#/components/responses/internal_error"
|
"$ref": "#/components/responses/internal_error"
|
||||||
delete:
|
delete:
|
||||||
|
|||||||
@@ -880,6 +880,30 @@ func (e SentinelOneMatchAttributesNetworkStatus) Valid() bool {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Defines values for ServiceMode.
|
||||||
|
const (
|
||||||
|
ServiceModeHttp ServiceMode = "http"
|
||||||
|
ServiceModeTcp ServiceMode = "tcp"
|
||||||
|
ServiceModeTls ServiceMode = "tls"
|
||||||
|
ServiceModeUdp ServiceMode = "udp"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Valid indicates whether the value is a known member of the ServiceMode enum.
|
||||||
|
func (e ServiceMode) Valid() bool {
|
||||||
|
switch e {
|
||||||
|
case ServiceModeHttp:
|
||||||
|
return true
|
||||||
|
case ServiceModeTcp:
|
||||||
|
return true
|
||||||
|
case ServiceModeTls:
|
||||||
|
return true
|
||||||
|
case ServiceModeUdp:
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Defines values for ServiceMetaStatus.
|
// Defines values for ServiceMetaStatus.
|
||||||
const (
|
const (
|
||||||
ServiceMetaStatusActive ServiceMetaStatus = "active"
|
ServiceMetaStatusActive ServiceMetaStatus = "active"
|
||||||
@@ -910,10 +934,36 @@ func (e ServiceMetaStatus) Valid() bool {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Defines values for ServiceRequestMode.
|
||||||
|
const (
|
||||||
|
ServiceRequestModeHttp ServiceRequestMode = "http"
|
||||||
|
ServiceRequestModeTcp ServiceRequestMode = "tcp"
|
||||||
|
ServiceRequestModeTls ServiceRequestMode = "tls"
|
||||||
|
ServiceRequestModeUdp ServiceRequestMode = "udp"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Valid indicates whether the value is a known member of the ServiceRequestMode enum.
|
||||||
|
func (e ServiceRequestMode) Valid() bool {
|
||||||
|
switch e {
|
||||||
|
case ServiceRequestModeHttp:
|
||||||
|
return true
|
||||||
|
case ServiceRequestModeTcp:
|
||||||
|
return true
|
||||||
|
case ServiceRequestModeTls:
|
||||||
|
return true
|
||||||
|
case ServiceRequestModeUdp:
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Defines values for ServiceTargetProtocol.
|
// Defines values for ServiceTargetProtocol.
|
||||||
const (
|
const (
|
||||||
ServiceTargetProtocolHttp ServiceTargetProtocol = "http"
|
ServiceTargetProtocolHttp ServiceTargetProtocol = "http"
|
||||||
ServiceTargetProtocolHttps ServiceTargetProtocol = "https"
|
ServiceTargetProtocolHttps ServiceTargetProtocol = "https"
|
||||||
|
ServiceTargetProtocolTcp ServiceTargetProtocol = "tcp"
|
||||||
|
ServiceTargetProtocolUdp ServiceTargetProtocol = "udp"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Valid indicates whether the value is a known member of the ServiceTargetProtocol enum.
|
// Valid indicates whether the value is a known member of the ServiceTargetProtocol enum.
|
||||||
@@ -923,6 +973,10 @@ func (e ServiceTargetProtocol) Valid() bool {
|
|||||||
return true
|
return true
|
||||||
case ServiceTargetProtocolHttps:
|
case ServiceTargetProtocolHttps:
|
||||||
return true
|
return true
|
||||||
|
case ServiceTargetProtocolTcp:
|
||||||
|
return true
|
||||||
|
case ServiceTargetProtocolUdp:
|
||||||
|
return true
|
||||||
default:
|
default:
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
@@ -930,16 +984,22 @@ func (e ServiceTargetProtocol) Valid() bool {
|
|||||||
|
|
||||||
// Defines values for ServiceTargetTargetType.
|
// Defines values for ServiceTargetTargetType.
|
||||||
const (
|
const (
|
||||||
ServiceTargetTargetTypePeer ServiceTargetTargetType = "peer"
|
ServiceTargetTargetTypeDomain ServiceTargetTargetType = "domain"
|
||||||
ServiceTargetTargetTypeResource ServiceTargetTargetType = "resource"
|
ServiceTargetTargetTypeHost ServiceTargetTargetType = "host"
|
||||||
|
ServiceTargetTargetTypePeer ServiceTargetTargetType = "peer"
|
||||||
|
ServiceTargetTargetTypeSubnet ServiceTargetTargetType = "subnet"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Valid indicates whether the value is a known member of the ServiceTargetTargetType enum.
|
// Valid indicates whether the value is a known member of the ServiceTargetTargetType enum.
|
||||||
func (e ServiceTargetTargetType) Valid() bool {
|
func (e ServiceTargetTargetType) Valid() bool {
|
||||||
switch e {
|
switch e {
|
||||||
|
case ServiceTargetTargetTypeDomain:
|
||||||
|
return true
|
||||||
|
case ServiceTargetTargetTypeHost:
|
||||||
|
return true
|
||||||
case ServiceTargetTargetTypePeer:
|
case ServiceTargetTargetTypePeer:
|
||||||
return true
|
return true
|
||||||
case ServiceTargetTargetTypeResource:
|
case ServiceTargetTargetTypeSubnet:
|
||||||
return true
|
return true
|
||||||
default:
|
default:
|
||||||
return false
|
return false
|
||||||
@@ -3249,6 +3309,9 @@ type ProxyAccessLog struct {
|
|||||||
// Path Path of the request
|
// Path Path of the request
|
||||||
Path string `json:"path"`
|
Path string `json:"path"`
|
||||||
|
|
||||||
|
// Protocol Protocol type: http, tcp, or udp
|
||||||
|
Protocol *string `json:"protocol,omitempty"`
|
||||||
|
|
||||||
// Reason Reason for the request result (e.g., authentication failure)
|
// Reason Reason for the request result (e.g., authentication failure)
|
||||||
Reason *string `json:"reason,omitempty"`
|
Reason *string `json:"reason,omitempty"`
|
||||||
|
|
||||||
@@ -3313,6 +3376,9 @@ type ReverseProxyDomain struct {
|
|||||||
// Id Domain ID
|
// Id Domain ID
|
||||||
Id string `json:"id"`
|
Id string `json:"id"`
|
||||||
|
|
||||||
|
// SupportsCustomPorts Whether the cluster supports binding arbitrary TCP/UDP ports
|
||||||
|
SupportsCustomPorts *bool `json:"supports_custom_ports,omitempty"`
|
||||||
|
|
||||||
// TargetCluster The proxy cluster this domain is validated against (only for custom domains)
|
// TargetCluster The proxy cluster this domain is validated against (only for custom domains)
|
||||||
TargetCluster *string `json:"target_cluster,omitempty"`
|
TargetCluster *string `json:"target_cluster,omitempty"`
|
||||||
|
|
||||||
@@ -3505,8 +3571,14 @@ type Service struct {
|
|||||||
Enabled bool `json:"enabled"`
|
Enabled bool `json:"enabled"`
|
||||||
|
|
||||||
// Id Service ID
|
// Id Service ID
|
||||||
Id string `json:"id"`
|
Id string `json:"id"`
|
||||||
Meta ServiceMeta `json:"meta"`
|
|
||||||
|
// ListenPort Port the proxy listens on (L4/TLS only)
|
||||||
|
ListenPort *int `json:"listen_port,omitempty"`
|
||||||
|
Meta ServiceMeta `json:"meta"`
|
||||||
|
|
||||||
|
// Mode Service mode. "http" for L7 reverse proxy, "tcp"/"udp"/"tls" for L4 passthrough.
|
||||||
|
Mode *ServiceMode `json:"mode,omitempty"`
|
||||||
|
|
||||||
// Name Service name
|
// Name Service name
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
@@ -3514,6 +3586,9 @@ type Service struct {
|
|||||||
// PassHostHeader When true, the original client Host header is passed through to the backend instead of being rewritten to the backend's address
|
// PassHostHeader When true, the original client Host header is passed through to the backend instead of being rewritten to the backend's address
|
||||||
PassHostHeader *bool `json:"pass_host_header,omitempty"`
|
PassHostHeader *bool `json:"pass_host_header,omitempty"`
|
||||||
|
|
||||||
|
// PortAutoAssigned Whether the listen port was auto-assigned
|
||||||
|
PortAutoAssigned *bool `json:"port_auto_assigned,omitempty"`
|
||||||
|
|
||||||
// ProxyCluster The proxy cluster handling this service (derived from domain)
|
// ProxyCluster The proxy cluster handling this service (derived from domain)
|
||||||
ProxyCluster *string `json:"proxy_cluster,omitempty"`
|
ProxyCluster *string `json:"proxy_cluster,omitempty"`
|
||||||
|
|
||||||
@@ -3524,6 +3599,9 @@ type Service struct {
|
|||||||
Targets []ServiceTarget `json:"targets"`
|
Targets []ServiceTarget `json:"targets"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ServiceMode Service mode. "http" for L7 reverse proxy, "tcp"/"udp"/"tls" for L4 passthrough.
|
||||||
|
type ServiceMode string
|
||||||
|
|
||||||
// ServiceAuthConfig defines model for ServiceAuthConfig.
|
// ServiceAuthConfig defines model for ServiceAuthConfig.
|
||||||
type ServiceAuthConfig struct {
|
type ServiceAuthConfig struct {
|
||||||
BearerAuth *BearerAuthConfig `json:"bearer_auth,omitempty"`
|
BearerAuth *BearerAuthConfig `json:"bearer_auth,omitempty"`
|
||||||
@@ -3549,7 +3627,7 @@ type ServiceMetaStatus string
|
|||||||
|
|
||||||
// ServiceRequest defines model for ServiceRequest.
|
// ServiceRequest defines model for ServiceRequest.
|
||||||
type ServiceRequest struct {
|
type ServiceRequest struct {
|
||||||
Auth ServiceAuthConfig `json:"auth"`
|
Auth *ServiceAuthConfig `json:"auth,omitempty"`
|
||||||
|
|
||||||
// Domain Domain for the service
|
// Domain Domain for the service
|
||||||
Domain string `json:"domain"`
|
Domain string `json:"domain"`
|
||||||
@@ -3557,6 +3635,12 @@ type ServiceRequest struct {
|
|||||||
// Enabled Whether the service is enabled
|
// Enabled Whether the service is enabled
|
||||||
Enabled bool `json:"enabled"`
|
Enabled bool `json:"enabled"`
|
||||||
|
|
||||||
|
// ListenPort Port the proxy listens on (L4/TLS only). Set to 0 for auto-assignment.
|
||||||
|
ListenPort *int `json:"listen_port,omitempty"`
|
||||||
|
|
||||||
|
// Mode Service mode. "http" for L7 reverse proxy, "tcp"/"udp"/"tls" for L4 passthrough.
|
||||||
|
Mode *ServiceRequestMode `json:"mode,omitempty"`
|
||||||
|
|
||||||
// Name Service name
|
// Name Service name
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
|
|
||||||
@@ -3567,9 +3651,12 @@ type ServiceRequest struct {
|
|||||||
RewriteRedirects *bool `json:"rewrite_redirects,omitempty"`
|
RewriteRedirects *bool `json:"rewrite_redirects,omitempty"`
|
||||||
|
|
||||||
// Targets List of target backends for this service
|
// Targets List of target backends for this service
|
||||||
Targets []ServiceTarget `json:"targets"`
|
Targets *[]ServiceTarget `json:"targets,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ServiceRequestMode Service mode. "http" for L7 reverse proxy, "tcp"/"udp"/"tls" for L4 passthrough.
|
||||||
|
type ServiceRequestMode string
|
||||||
|
|
||||||
// ServiceTarget defines model for ServiceTarget.
|
// ServiceTarget defines model for ServiceTarget.
|
||||||
type ServiceTarget struct {
|
type ServiceTarget struct {
|
||||||
// Enabled Whether this target is enabled
|
// Enabled Whether this target is enabled
|
||||||
@@ -3579,10 +3666,10 @@ type ServiceTarget struct {
|
|||||||
Host *string `json:"host,omitempty"`
|
Host *string `json:"host,omitempty"`
|
||||||
Options *ServiceTargetOptions `json:"options,omitempty"`
|
Options *ServiceTargetOptions `json:"options,omitempty"`
|
||||||
|
|
||||||
// Path URL path prefix for this target
|
// Path URL path prefix for this target (HTTP only)
|
||||||
Path *string `json:"path,omitempty"`
|
Path *string `json:"path,omitempty"`
|
||||||
|
|
||||||
// Port Backend port for this target. Use 0 or omit to use the scheme default (80 for http, 443 for https).
|
// Port Backend port for this target
|
||||||
Port int `json:"port"`
|
Port int `json:"port"`
|
||||||
|
|
||||||
// Protocol Protocol to use when connecting to the backend
|
// Protocol Protocol to use when connecting to the backend
|
||||||
@@ -3591,14 +3678,14 @@ type ServiceTarget struct {
|
|||||||
// TargetId Target ID
|
// TargetId Target ID
|
||||||
TargetId string `json:"target_id"`
|
TargetId string `json:"target_id"`
|
||||||
|
|
||||||
// TargetType Target type (e.g., "peer", "resource")
|
// TargetType Target type
|
||||||
TargetType ServiceTargetTargetType `json:"target_type"`
|
TargetType ServiceTargetTargetType `json:"target_type"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// ServiceTargetProtocol Protocol to use when connecting to the backend
|
// ServiceTargetProtocol Protocol to use when connecting to the backend
|
||||||
type ServiceTargetProtocol string
|
type ServiceTargetProtocol string
|
||||||
|
|
||||||
// ServiceTargetTargetType Target type (e.g., "peer", "resource")
|
// ServiceTargetTargetType Target type
|
||||||
type ServiceTargetTargetType string
|
type ServiceTargetTargetType string
|
||||||
|
|
||||||
// ServiceTargetOptions defines model for ServiceTargetOptions.
|
// ServiceTargetOptions defines model for ServiceTargetOptions.
|
||||||
@@ -3609,9 +3696,15 @@ type ServiceTargetOptions struct {
|
|||||||
// PathRewrite Controls how the request path is rewritten before forwarding to the backend. Default strips the matched prefix. "preserve" keeps the full original request path.
|
// PathRewrite Controls how the request path is rewritten before forwarding to the backend. Default strips the matched prefix. "preserve" keeps the full original request path.
|
||||||
PathRewrite *ServiceTargetOptionsPathRewrite `json:"path_rewrite,omitempty"`
|
PathRewrite *ServiceTargetOptionsPathRewrite `json:"path_rewrite,omitempty"`
|
||||||
|
|
||||||
|
// ProxyProtocol Send PROXY Protocol v2 header to this backend (TCP/TLS only)
|
||||||
|
ProxyProtocol *bool `json:"proxy_protocol,omitempty"`
|
||||||
|
|
||||||
// RequestTimeout Per-target response timeout as a Go duration string (e.g. "30s", "2m")
|
// RequestTimeout Per-target response timeout as a Go duration string (e.g. "30s", "2m")
|
||||||
RequestTimeout *string `json:"request_timeout,omitempty"`
|
RequestTimeout *string `json:"request_timeout,omitempty"`
|
||||||
|
|
||||||
|
// SessionIdleTimeout Idle timeout before a UDP session is reaped, as a Go duration string (e.g. "30s", "2m"). Maximum 10m.
|
||||||
|
SessionIdleTimeout *string `json:"session_idle_timeout,omitempty"`
|
||||||
|
|
||||||
// SkipTlsVerify Skip TLS certificate verification for this backend
|
// SkipTlsVerify Skip TLS certificate verification for this backend
|
||||||
SkipTlsVerify *bool `json:"skip_tls_verify,omitempty"`
|
SkipTlsVerify *bool `json:"skip_tls_verify,omitempty"`
|
||||||
}
|
}
|
||||||
@@ -4136,6 +4229,9 @@ type ZoneRequest struct {
|
|||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Conflict Standard error response. Note: The exact structure of this error response is inferred from `util.WriteErrorResponse` and `util.WriteError` usage in the provided Go code, as a specific Go struct for errors was not provided.
|
||||||
|
type Conflict = ErrorResponse
|
||||||
|
|
||||||
// GetApiEventsNetworkTrafficParams defines parameters for GetApiEventsNetworkTraffic.
|
// GetApiEventsNetworkTrafficParams defines parameters for GetApiEventsNetworkTraffic.
|
||||||
type GetApiEventsNetworkTrafficParams struct {
|
type GetApiEventsNetworkTrafficParams struct {
|
||||||
// Page Page number
|
// Page Page number
|
||||||
|
|||||||
@@ -228,6 +228,7 @@ const (
|
|||||||
ExposeProtocol_EXPOSE_HTTPS ExposeProtocol = 1
|
ExposeProtocol_EXPOSE_HTTPS ExposeProtocol = 1
|
||||||
ExposeProtocol_EXPOSE_TCP ExposeProtocol = 2
|
ExposeProtocol_EXPOSE_TCP ExposeProtocol = 2
|
||||||
ExposeProtocol_EXPOSE_UDP ExposeProtocol = 3
|
ExposeProtocol_EXPOSE_UDP ExposeProtocol = 3
|
||||||
|
ExposeProtocol_EXPOSE_TLS ExposeProtocol = 4
|
||||||
)
|
)
|
||||||
|
|
||||||
// Enum value maps for ExposeProtocol.
|
// Enum value maps for ExposeProtocol.
|
||||||
@@ -237,12 +238,14 @@ var (
|
|||||||
1: "EXPOSE_HTTPS",
|
1: "EXPOSE_HTTPS",
|
||||||
2: "EXPOSE_TCP",
|
2: "EXPOSE_TCP",
|
||||||
3: "EXPOSE_UDP",
|
3: "EXPOSE_UDP",
|
||||||
|
4: "EXPOSE_TLS",
|
||||||
}
|
}
|
||||||
ExposeProtocol_value = map[string]int32{
|
ExposeProtocol_value = map[string]int32{
|
||||||
"EXPOSE_HTTP": 0,
|
"EXPOSE_HTTP": 0,
|
||||||
"EXPOSE_HTTPS": 1,
|
"EXPOSE_HTTPS": 1,
|
||||||
"EXPOSE_TCP": 2,
|
"EXPOSE_TCP": 2,
|
||||||
"EXPOSE_UDP": 3,
|
"EXPOSE_UDP": 3,
|
||||||
|
"EXPOSE_TLS": 4,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -4047,6 +4050,7 @@ type ExposeServiceRequest struct {
|
|||||||
UserGroups []string `protobuf:"bytes,5,rep,name=user_groups,json=userGroups,proto3" json:"user_groups,omitempty"`
|
UserGroups []string `protobuf:"bytes,5,rep,name=user_groups,json=userGroups,proto3" json:"user_groups,omitempty"`
|
||||||
Domain string `protobuf:"bytes,6,opt,name=domain,proto3" json:"domain,omitempty"`
|
Domain string `protobuf:"bytes,6,opt,name=domain,proto3" json:"domain,omitempty"`
|
||||||
NamePrefix string `protobuf:"bytes,7,opt,name=name_prefix,json=namePrefix,proto3" json:"name_prefix,omitempty"`
|
NamePrefix string `protobuf:"bytes,7,opt,name=name_prefix,json=namePrefix,proto3" json:"name_prefix,omitempty"`
|
||||||
|
ListenPort uint32 `protobuf:"varint,8,opt,name=listen_port,json=listenPort,proto3" json:"listen_port,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (x *ExposeServiceRequest) Reset() {
|
func (x *ExposeServiceRequest) Reset() {
|
||||||
@@ -4130,14 +4134,22 @@ func (x *ExposeServiceRequest) GetNamePrefix() string {
|
|||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (x *ExposeServiceRequest) GetListenPort() uint32 {
|
||||||
|
if x != nil {
|
||||||
|
return x.ListenPort
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
type ExposeServiceResponse struct {
|
type ExposeServiceResponse struct {
|
||||||
state protoimpl.MessageState
|
state protoimpl.MessageState
|
||||||
sizeCache protoimpl.SizeCache
|
sizeCache protoimpl.SizeCache
|
||||||
unknownFields protoimpl.UnknownFields
|
unknownFields protoimpl.UnknownFields
|
||||||
|
|
||||||
ServiceName string `protobuf:"bytes,1,opt,name=service_name,json=serviceName,proto3" json:"service_name,omitempty"`
|
ServiceName string `protobuf:"bytes,1,opt,name=service_name,json=serviceName,proto3" json:"service_name,omitempty"`
|
||||||
ServiceUrl string `protobuf:"bytes,2,opt,name=service_url,json=serviceUrl,proto3" json:"service_url,omitempty"`
|
ServiceUrl string `protobuf:"bytes,2,opt,name=service_url,json=serviceUrl,proto3" json:"service_url,omitempty"`
|
||||||
Domain string `protobuf:"bytes,3,opt,name=domain,proto3" json:"domain,omitempty"`
|
Domain string `protobuf:"bytes,3,opt,name=domain,proto3" json:"domain,omitempty"`
|
||||||
|
PortAutoAssigned bool `protobuf:"varint,4,opt,name=port_auto_assigned,json=portAutoAssigned,proto3" json:"port_auto_assigned,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (x *ExposeServiceResponse) Reset() {
|
func (x *ExposeServiceResponse) Reset() {
|
||||||
@@ -4193,6 +4205,13 @@ func (x *ExposeServiceResponse) GetDomain() string {
|
|||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (x *ExposeServiceResponse) GetPortAutoAssigned() bool {
|
||||||
|
if x != nil {
|
||||||
|
return x.PortAutoAssigned
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
type RenewExposeRequest struct {
|
type RenewExposeRequest struct {
|
||||||
state protoimpl.MessageState
|
state protoimpl.MessageState
|
||||||
sizeCache protoimpl.SizeCache
|
sizeCache protoimpl.SizeCache
|
||||||
@@ -4996,7 +5015,7 @@ var file_management_proto_rawDesc = []byte{
|
|||||||
0x64, 0x50, 0x6f, 0x72, 0x74, 0x18, 0x04, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x14, 0x2e, 0x6d, 0x61,
|
0x64, 0x50, 0x6f, 0x72, 0x74, 0x18, 0x04, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x14, 0x2e, 0x6d, 0x61,
|
||||||
0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x50, 0x6f, 0x72, 0x74, 0x49, 0x6e, 0x66,
|
0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x50, 0x6f, 0x72, 0x74, 0x49, 0x6e, 0x66,
|
||||||
0x6f, 0x52, 0x0e, 0x74, 0x72, 0x61, 0x6e, 0x73, 0x6c, 0x61, 0x74, 0x65, 0x64, 0x50, 0x6f, 0x72,
|
0x6f, 0x52, 0x0e, 0x74, 0x72, 0x61, 0x6e, 0x73, 0x6c, 0x61, 0x74, 0x65, 0x64, 0x50, 0x6f, 0x72,
|
||||||
0x74, 0x22, 0xea, 0x01, 0x0a, 0x14, 0x45, 0x78, 0x70, 0x6f, 0x73, 0x65, 0x53, 0x65, 0x72, 0x76,
|
0x74, 0x22, 0x8b, 0x02, 0x0a, 0x14, 0x45, 0x78, 0x70, 0x6f, 0x73, 0x65, 0x53, 0x65, 0x72, 0x76,
|
||||||
0x69, 0x63, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x12, 0x0a, 0x04, 0x70, 0x6f,
|
0x69, 0x63, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x12, 0x0a, 0x04, 0x70, 0x6f,
|
||||||
0x72, 0x74, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0d, 0x52, 0x04, 0x70, 0x6f, 0x72, 0x74, 0x12, 0x36,
|
0x72, 0x74, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0d, 0x52, 0x04, 0x70, 0x6f, 0x72, 0x74, 0x12, 0x36,
|
||||||
0x0a, 0x08, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x63, 0x6f, 0x6c, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0e,
|
0x0a, 0x08, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x63, 0x6f, 0x6c, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0e,
|
||||||
@@ -5010,15 +5029,20 @@ var file_management_proto_rawDesc = []byte{
|
|||||||
0x72, 0x6f, 0x75, 0x70, 0x73, 0x12, 0x16, 0x0a, 0x06, 0x64, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x18,
|
0x72, 0x6f, 0x75, 0x70, 0x73, 0x12, 0x16, 0x0a, 0x06, 0x64, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x18,
|
||||||
0x06, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x64, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x12, 0x1f, 0x0a,
|
0x06, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x64, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x12, 0x1f, 0x0a,
|
||||||
0x0b, 0x6e, 0x61, 0x6d, 0x65, 0x5f, 0x70, 0x72, 0x65, 0x66, 0x69, 0x78, 0x18, 0x07, 0x20, 0x01,
|
0x0b, 0x6e, 0x61, 0x6d, 0x65, 0x5f, 0x70, 0x72, 0x65, 0x66, 0x69, 0x78, 0x18, 0x07, 0x20, 0x01,
|
||||||
0x28, 0x09, 0x52, 0x0a, 0x6e, 0x61, 0x6d, 0x65, 0x50, 0x72, 0x65, 0x66, 0x69, 0x78, 0x22, 0x73,
|
0x28, 0x09, 0x52, 0x0a, 0x6e, 0x61, 0x6d, 0x65, 0x50, 0x72, 0x65, 0x66, 0x69, 0x78, 0x12, 0x1f,
|
||||||
0x0a, 0x15, 0x45, 0x78, 0x70, 0x6f, 0x73, 0x65, 0x53, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x52,
|
0x0a, 0x0b, 0x6c, 0x69, 0x73, 0x74, 0x65, 0x6e, 0x5f, 0x70, 0x6f, 0x72, 0x74, 0x18, 0x08, 0x20,
|
||||||
0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x21, 0x0a, 0x0c, 0x73, 0x65, 0x72, 0x76, 0x69,
|
0x01, 0x28, 0x0d, 0x52, 0x0a, 0x6c, 0x69, 0x73, 0x74, 0x65, 0x6e, 0x50, 0x6f, 0x72, 0x74, 0x22,
|
||||||
0x63, 0x65, 0x5f, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0b, 0x73,
|
0xa1, 0x01, 0x0a, 0x15, 0x45, 0x78, 0x70, 0x6f, 0x73, 0x65, 0x53, 0x65, 0x72, 0x76, 0x69, 0x63,
|
||||||
0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x4e, 0x61, 0x6d, 0x65, 0x12, 0x1f, 0x0a, 0x0b, 0x73, 0x65,
|
0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x21, 0x0a, 0x0c, 0x73, 0x65, 0x72,
|
||||||
0x72, 0x76, 0x69, 0x63, 0x65, 0x5f, 0x75, 0x72, 0x6c, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52,
|
0x76, 0x69, 0x63, 0x65, 0x5f, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52,
|
||||||
0x0a, 0x73, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x55, 0x72, 0x6c, 0x12, 0x16, 0x0a, 0x06, 0x64,
|
0x0b, 0x73, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x4e, 0x61, 0x6d, 0x65, 0x12, 0x1f, 0x0a, 0x0b,
|
||||||
0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x64, 0x6f, 0x6d,
|
0x73, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x5f, 0x75, 0x72, 0x6c, 0x18, 0x02, 0x20, 0x01, 0x28,
|
||||||
0x61, 0x69, 0x6e, 0x22, 0x2c, 0x0a, 0x12, 0x52, 0x65, 0x6e, 0x65, 0x77, 0x45, 0x78, 0x70, 0x6f,
|
0x09, 0x52, 0x0a, 0x73, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x55, 0x72, 0x6c, 0x12, 0x16, 0x0a,
|
||||||
|
0x06, 0x64, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x64,
|
||||||
|
0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x12, 0x2c, 0x0a, 0x12, 0x70, 0x6f, 0x72, 0x74, 0x5f, 0x61, 0x75,
|
||||||
|
0x74, 0x6f, 0x5f, 0x61, 0x73, 0x73, 0x69, 0x67, 0x6e, 0x65, 0x64, 0x18, 0x04, 0x20, 0x01, 0x28,
|
||||||
|
0x08, 0x52, 0x10, 0x70, 0x6f, 0x72, 0x74, 0x41, 0x75, 0x74, 0x6f, 0x41, 0x73, 0x73, 0x69, 0x67,
|
||||||
|
0x6e, 0x65, 0x64, 0x22, 0x2c, 0x0a, 0x12, 0x52, 0x65, 0x6e, 0x65, 0x77, 0x45, 0x78, 0x70, 0x6f,
|
||||||
0x73, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x16, 0x0a, 0x06, 0x64, 0x6f, 0x6d,
|
0x73, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x16, 0x0a, 0x06, 0x64, 0x6f, 0x6d,
|
||||||
0x61, 0x69, 0x6e, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x64, 0x6f, 0x6d, 0x61, 0x69,
|
0x61, 0x69, 0x6e, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x64, 0x6f, 0x6d, 0x61, 0x69,
|
||||||
0x6e, 0x22, 0x15, 0x0a, 0x13, 0x52, 0x65, 0x6e, 0x65, 0x77, 0x45, 0x78, 0x70, 0x6f, 0x73, 0x65,
|
0x6e, 0x22, 0x15, 0x0a, 0x13, 0x52, 0x65, 0x6e, 0x65, 0x77, 0x45, 0x78, 0x70, 0x6f, 0x73, 0x65,
|
||||||
@@ -5039,12 +5063,13 @@ var file_management_proto_rawDesc = []byte{
|
|||||||
0x65, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x06, 0x0a, 0x02, 0x49, 0x4e, 0x10, 0x00, 0x12, 0x07,
|
0x65, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x06, 0x0a, 0x02, 0x49, 0x4e, 0x10, 0x00, 0x12, 0x07,
|
||||||
0x0a, 0x03, 0x4f, 0x55, 0x54, 0x10, 0x01, 0x2a, 0x22, 0x0a, 0x0a, 0x52, 0x75, 0x6c, 0x65, 0x41,
|
0x0a, 0x03, 0x4f, 0x55, 0x54, 0x10, 0x01, 0x2a, 0x22, 0x0a, 0x0a, 0x52, 0x75, 0x6c, 0x65, 0x41,
|
||||||
0x63, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x0a, 0x0a, 0x06, 0x41, 0x43, 0x43, 0x45, 0x50, 0x54, 0x10,
|
0x63, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x0a, 0x0a, 0x06, 0x41, 0x43, 0x43, 0x45, 0x50, 0x54, 0x10,
|
||||||
0x00, 0x12, 0x08, 0x0a, 0x04, 0x44, 0x52, 0x4f, 0x50, 0x10, 0x01, 0x2a, 0x53, 0x0a, 0x0e, 0x45,
|
0x00, 0x12, 0x08, 0x0a, 0x04, 0x44, 0x52, 0x4f, 0x50, 0x10, 0x01, 0x2a, 0x63, 0x0a, 0x0e, 0x45,
|
||||||
0x78, 0x70, 0x6f, 0x73, 0x65, 0x50, 0x72, 0x6f, 0x74, 0x6f, 0x63, 0x6f, 0x6c, 0x12, 0x0f, 0x0a,
|
0x78, 0x70, 0x6f, 0x73, 0x65, 0x50, 0x72, 0x6f, 0x74, 0x6f, 0x63, 0x6f, 0x6c, 0x12, 0x0f, 0x0a,
|
||||||
0x0b, 0x45, 0x58, 0x50, 0x4f, 0x53, 0x45, 0x5f, 0x48, 0x54, 0x54, 0x50, 0x10, 0x00, 0x12, 0x10,
|
0x0b, 0x45, 0x58, 0x50, 0x4f, 0x53, 0x45, 0x5f, 0x48, 0x54, 0x54, 0x50, 0x10, 0x00, 0x12, 0x10,
|
||||||
0x0a, 0x0c, 0x45, 0x58, 0x50, 0x4f, 0x53, 0x45, 0x5f, 0x48, 0x54, 0x54, 0x50, 0x53, 0x10, 0x01,
|
0x0a, 0x0c, 0x45, 0x58, 0x50, 0x4f, 0x53, 0x45, 0x5f, 0x48, 0x54, 0x54, 0x50, 0x53, 0x10, 0x01,
|
||||||
0x12, 0x0e, 0x0a, 0x0a, 0x45, 0x58, 0x50, 0x4f, 0x53, 0x45, 0x5f, 0x54, 0x43, 0x50, 0x10, 0x02,
|
0x12, 0x0e, 0x0a, 0x0a, 0x45, 0x58, 0x50, 0x4f, 0x53, 0x45, 0x5f, 0x54, 0x43, 0x50, 0x10, 0x02,
|
||||||
0x12, 0x0e, 0x0a, 0x0a, 0x45, 0x58, 0x50, 0x4f, 0x53, 0x45, 0x5f, 0x55, 0x44, 0x50, 0x10, 0x03,
|
0x12, 0x0e, 0x0a, 0x0a, 0x45, 0x58, 0x50, 0x4f, 0x53, 0x45, 0x5f, 0x55, 0x44, 0x50, 0x10, 0x03,
|
||||||
|
0x12, 0x0e, 0x0a, 0x0a, 0x45, 0x58, 0x50, 0x4f, 0x53, 0x45, 0x5f, 0x54, 0x4c, 0x53, 0x10, 0x04,
|
||||||
0x32, 0xfd, 0x06, 0x0a, 0x11, 0x4d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x53,
|
0x32, 0xfd, 0x06, 0x0a, 0x11, 0x4d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x53,
|
||||||
0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x12, 0x45, 0x0a, 0x05, 0x4c, 0x6f, 0x67, 0x69, 0x6e, 0x12,
|
0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x12, 0x45, 0x0a, 0x05, 0x4c, 0x6f, 0x67, 0x69, 0x6e, 0x12,
|
||||||
0x1c, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x45, 0x6e, 0x63,
|
0x1c, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x45, 0x6e, 0x63,
|
||||||
|
|||||||
@@ -652,6 +652,7 @@ enum ExposeProtocol {
|
|||||||
EXPOSE_HTTPS = 1;
|
EXPOSE_HTTPS = 1;
|
||||||
EXPOSE_TCP = 2;
|
EXPOSE_TCP = 2;
|
||||||
EXPOSE_UDP = 3;
|
EXPOSE_UDP = 3;
|
||||||
|
EXPOSE_TLS = 4;
|
||||||
}
|
}
|
||||||
|
|
||||||
message ExposeServiceRequest {
|
message ExposeServiceRequest {
|
||||||
@@ -662,12 +663,14 @@ message ExposeServiceRequest {
|
|||||||
repeated string user_groups = 5;
|
repeated string user_groups = 5;
|
||||||
string domain = 6;
|
string domain = 6;
|
||||||
string name_prefix = 7;
|
string name_prefix = 7;
|
||||||
|
uint32 listen_port = 8;
|
||||||
}
|
}
|
||||||
|
|
||||||
message ExposeServiceResponse {
|
message ExposeServiceResponse {
|
||||||
string service_name = 1;
|
string service_name = 1;
|
||||||
string service_url = 2;
|
string service_url = 2;
|
||||||
string domain = 3;
|
string domain = 3;
|
||||||
|
bool port_auto_assigned = 4;
|
||||||
}
|
}
|
||||||
|
|
||||||
message RenewExposeRequest {
|
message RenewExposeRequest {
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -27,12 +27,19 @@ service ProxyService {
|
|||||||
rpc ValidateSession(ValidateSessionRequest) returns (ValidateSessionResponse);
|
rpc ValidateSession(ValidateSessionRequest) returns (ValidateSessionResponse);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ProxyCapabilities describes what a proxy can handle.
|
||||||
|
message ProxyCapabilities {
|
||||||
|
// Whether the proxy can bind arbitrary ports for TCP/UDP/TLS services.
|
||||||
|
optional bool supports_custom_ports = 1;
|
||||||
|
}
|
||||||
|
|
||||||
// GetMappingUpdateRequest is sent to initialise a mapping stream.
|
// GetMappingUpdateRequest is sent to initialise a mapping stream.
|
||||||
message GetMappingUpdateRequest {
|
message GetMappingUpdateRequest {
|
||||||
string proxy_id = 1;
|
string proxy_id = 1;
|
||||||
string version = 2;
|
string version = 2;
|
||||||
google.protobuf.Timestamp started_at = 3;
|
google.protobuf.Timestamp started_at = 3;
|
||||||
string address = 4;
|
string address = 4;
|
||||||
|
ProxyCapabilities capabilities = 5;
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetMappingUpdateResponse contains zero or more ProxyMappings.
|
// GetMappingUpdateResponse contains zero or more ProxyMappings.
|
||||||
@@ -61,6 +68,10 @@ message PathTargetOptions {
|
|||||||
google.protobuf.Duration request_timeout = 2;
|
google.protobuf.Duration request_timeout = 2;
|
||||||
PathRewriteMode path_rewrite = 3;
|
PathRewriteMode path_rewrite = 3;
|
||||||
map<string, string> custom_headers = 4;
|
map<string, string> custom_headers = 4;
|
||||||
|
// Send PROXY protocol v2 header to this backend.
|
||||||
|
bool proxy_protocol = 5;
|
||||||
|
// Idle timeout before a UDP session is reaped.
|
||||||
|
google.protobuf.Duration session_idle_timeout = 6;
|
||||||
}
|
}
|
||||||
|
|
||||||
message PathMapping {
|
message PathMapping {
|
||||||
@@ -91,6 +102,10 @@ message ProxyMapping {
|
|||||||
// When true, Location headers in backend responses are rewritten to replace
|
// When true, Location headers in backend responses are rewritten to replace
|
||||||
// the backend address with the public-facing domain.
|
// the backend address with the public-facing domain.
|
||||||
bool rewrite_redirects = 9;
|
bool rewrite_redirects = 9;
|
||||||
|
// Service mode: "http", "tcp", "udp", or "tls".
|
||||||
|
string mode = 10;
|
||||||
|
// For L4/TLS: the port the proxy listens on.
|
||||||
|
int32 listen_port = 11;
|
||||||
}
|
}
|
||||||
|
|
||||||
// SendAccessLogRequest consists of one or more AccessLogs from a Proxy.
|
// SendAccessLogRequest consists of one or more AccessLogs from a Proxy.
|
||||||
@@ -117,6 +132,7 @@ message AccessLog {
|
|||||||
bool auth_success = 13;
|
bool auth_success = 13;
|
||||||
int64 bytes_upload = 14;
|
int64 bytes_upload = 14;
|
||||||
int64 bytes_download = 15;
|
int64 bytes_download = 15;
|
||||||
|
string protocol = 16;
|
||||||
}
|
}
|
||||||
|
|
||||||
message AuthenticateRequest {
|
message AuthenticateRequest {
|
||||||
|
|||||||
Reference in New Issue
Block a user