Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
000ae70
Add L4 capabilities (TLS/TCP/UDP)
lixmal Feb 22, 2026
6f94ead
Fix SonarCloud code smells in L4 feature
lixmal Mar 6, 2026
d66b6d3
Remove unused testProxyManager type
lixmal Mar 6, 2026
f39e428
Address CodeRabbit review feedback
lixmal Mar 7, 2026
99fcbe4
Fix active request gauge leak and addMapping rollback
lixmal Mar 7, 2026
90f0c30
Address CodeRabbit nits
lixmal Mar 7, 2026
5d59da8
Address remaining CodeRabbit feedback
lixmal Mar 7, 2026
6936322
Merge branch 'main' into reverse-proxy-l4
lixmal Mar 7, 2026
8652a43
Address second-wave CodeRabbit review feedback
lixmal Mar 7, 2026
305f1d2
Fix dead UDP session on write failure and EXPOSE_HTTPS mode mapping
lixmal Mar 7, 2026
999cccc
Cancel mapping worker on early server exit
lixmal Mar 7, 2026
1c7ba35
Fix review findings: data race, fail-open protocol, ToAPIResponse mut…
lixmal Mar 9, 2026
9e7e19a
Enforce domain uniqueness across all service modes and remove service…
lixmal Mar 9, 2026
2476d8b
Reinstate ProxyCluster API removed during L4 merge
lixmal Mar 9, 2026
92e6959
Protect TCP router observer field with mutex for thread safety
lixmal Mar 9, 2026
3219b33
Track handleConn goroutines in Drain to wait for in-flight connections
lixmal Mar 9, 2026
ac100ac
Use types.AccountID in observer interfaces instead of string
lixmal Mar 9, 2026
6841d69
Restore custom domain warning in expose flag description
lixmal Mar 9, 2026
e8d14c3
Merge branch 'main' into reverse-proxy-l4
lixmal Mar 9, 2026
7e3576a
Fix pkceCleanupCancel build error from merge with main
lixmal Mar 9, 2026
1aaab1d
Expand ServiceTarget protocol enum to include tcp and udp for L4 modes
lixmal Mar 9, 2026
74c100f
Fix ServiceTarget target_type enum: replace "resource" with "domain"
lixmal Mar 9, 2026
151247e
Make domain required in Service response and fix target_type enum
lixmal Mar 9, 2026
f00cd89
Merge branch 'main' into reverse-proxy-l4
lixmal Mar 10, 2026
cb920d4
Fix CI: run go mod tidy and split metrics New() to satisfy Sonar S138
lixmal Mar 10, 2026
a191723
Add L4 (TCP/UDP) access log reporting to management
lixmal Mar 11, 2026
c0ad23f
Reduce addUDPRelay params to satisfy Sonar max-params rule
lixmal Mar 11, 2026
47d44be
Fix startup race: wait for router init before processing mappings
lixmal Mar 11, 2026
70b766c
Fix mapping stream tests: initialize routerReady channel
lixmal Mar 11, 2026
c6d4085
Merge branch 'main' into reverse-proxy-l4
lixmal Mar 12, 2026
17fb01e
Make supports_custom_ports optional so old proxies return nil
lixmal Mar 13, 2026
611eb0e
Merge branch 'main' into reverse-proxy-l4
lixmal Mar 13, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
144 changes: 115 additions & 29 deletions client/cmd/expose.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,20 +22,24 @@ import (
var pinRegexp = regexp.MustCompile(`^\d{6}$`)

var (
exposePin string
exposePassword string
exposeUserGroups []string
exposeDomain string
exposeNamePrefix string
exposeProtocol string
exposePin string
exposePassword string
exposeUserGroups []string
exposeDomain string
exposeNamePrefix string
exposeProtocol string
exposeExternalPort uint16
)

var exposeCmd = &cobra.Command{
Use: "expose <port>",
Short: "Expose a local port via the NetBird reverse proxy",
Args: cobra.ExactArgs(1),
Example: "netbird expose --with-password safe-pass 8080",
RunE: exposeFn,
Use: "expose <port>",
Short: "Expose a local port via the NetBird reverse proxy",
Args: cobra.ExactArgs(1),
Example: ` netbird expose --with-password safe-pass 8080
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() {
Expand All @@ -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().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(&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)
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

// 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) {
Expand All @@ -57,7 +106,15 @@ func validateExposeFlags(cmd *cobra.Command, portStr string) (uint64, error) {
}

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)
}
Comment thread
lixmal marked this conversation as resolved.

if exposePin != "" && !pinRegexp.MatchString(exposePin) {
Expand All @@ -76,7 +133,12 @@ func validateExposeFlags(cmd *cobra.Command, portStr string) (uint64, error) {
}

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 {
Expand Down Expand Up @@ -123,15 +185,20 @@ func exposeFn(cmd *cobra.Command, args []string) error {
return err
}

stream, err := client.ExposeService(ctx, &proto.ExposeServiceRequest{
req := &proto.ExposeServiceRequest{
Port: uint32(port),
Protocol: protocol,
Pin: exposePin,
Password: exposePassword,
UserGroups: exposeUserGroups,
Domain: exposeDomain,
NamePrefix: exposeNamePrefix,
})
}
if isClusterProtocol(exposeProtocol) {
req.ListenPort = uint32(resolveExternalPort(port))
}

stream, err := client.ExposeService(ctx, req)
if err != nil {
return fmt.Errorf("expose service: %w", err)
}
Expand All @@ -149,8 +216,14 @@ func toExposeProtocol(exposeProtocol string) (proto.ExposeProtocol, error) {
return proto.ExposeProtocol_EXPOSE_HTTP, nil
case "https":
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:
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)
}
}

Expand All @@ -160,20 +233,33 @@ func handleExposeReady(cmd *cobra.Command, stream proto.DaemonService_ExposeServ
return fmt.Errorf("receive expose event: %w", err)
}

switch e := event.Event.(type) {
case *proto.ExposeServiceEvent_Ready:
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:
ready, ok := event.Event.(*proto.ExposeServiceEvent_Ready)
if !ok {
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 {
Expand Down
8 changes: 5 additions & 3 deletions client/internal/expose/manager.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,10 @@ const renewTimeout = 10 * time.Second

// Response holds the response from exposing a service.
type Response struct {
ServiceName string
ServiceURL string
Domain string
ServiceName string
ServiceURL string
Domain string
PortAutoAssigned bool
}

type Request struct {
Expand All @@ -25,6 +26,7 @@ type Request struct {
Pin string
Password string
UserGroups []string
ListenPort uint16
}

type ManagementClient interface {
Expand Down
9 changes: 6 additions & 3 deletions client/internal/expose/request.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ func NewRequest(req *daemonProto.ExposeServiceRequest) *Request {
UserGroups: req.UserGroups,
Domain: req.Domain,
NamePrefix: req.NamePrefix,
ListenPort: uint16(req.ListenPort),
}
}

Expand All @@ -27,13 +28,15 @@ func toClientExposeRequest(req Request) mgm.ExposeRequest {
Pin: req.Pin,
Password: req.Password,
UserGroups: req.UserGroups,
ListenPort: req.ListenPort,
}
}

func fromClientExposeResponse(response *mgm.ExposeResponse) *Response {
return &Response{
ServiceName: response.ServiceName,
Domain: response.Domain,
ServiceURL: response.ServiceURL,
ServiceName: response.ServiceName,
Domain: response.Domain,
ServiceURL: response.ServiceURL,
PortAutoAssigned: response.PortAutoAssigned,
}
}
48 changes: 36 additions & 12 deletions client/proto/daemon.pb.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading
Loading