Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
36 commits
Select commit Hold shift + click to select a range
799c039
add draft of implementation
mlsmaycon Feb 15, 2026
57a6d2a
fix mocks
mlsmaycon Feb 15, 2026
96023fc
Merge branch 'main' into feature/client-service-expose
mlsmaycon Feb 19, 2026
fbcb81e
replace stream protocol
mlsmaycon Feb 19, 2026
96086d0
update reverseproxy service with source type changes and last renewed…
mlsmaycon Feb 19, 2026
b50909b
add permission validation, expiration handling, and peer context to r…
mlsmaycon Feb 20, 2026
c9515c7
add auth metadata
mlsmaycon Feb 21, 2026
9ed8bb5
rename commands
mlsmaycon Feb 21, 2026
47119ec
enhance reverseproxy service with prefix validation, mutex for concur…
mlsmaycon Feb 21, 2026
692ee3f
add additional tests for reverseproxy and grpc modules, improve error…
mlsmaycon Feb 21, 2026
72b201e
fix mock behavior
mlsmaycon Feb 21, 2026
829fc8a
fix comments
mlsmaycon Feb 21, 2026
e5571aa
fix last seem timestamp
mlsmaycon Feb 21, 2026
0d98f22
fix mock
mlsmaycon Feb 21, 2026
492a716
use pointer type for ServiceMeta.CertificateIssuedAt to avoid MySQL z…
mlsmaycon Feb 21, 2026
40eaf6d
enhance reverseproxy module with dynamic protocol mapping, improve er…
mlsmaycon Feb 21, 2026
d30e25c
refactor expose service flow: introduce manager to simplify lifecycle…
mlsmaycon Feb 21, 2026
1175f1e
add pin validation to expose service and command, introduce comprehen…
mlsmaycon Feb 21, 2026
01725f9
improve expose command flag descriptions for clarity and add usage me…
mlsmaycon Feb 21, 2026
3c568d7
add validation for peer expose group settings
mlsmaycon Feb 21, 2026
1714c35
add support for tracking account peer expose settings changes
mlsmaycon Feb 21, 2026
fb61d2d
refactor expose command: extract flag validation into a dedicated fun…
mlsmaycon Feb 21, 2026
38feca6
refactor context handling in expose logic and remove redundant peer r…
mlsmaycon Feb 21, 2026
9621c10
update expose command description
mlsmaycon Feb 22, 2026
96e6dc2
introduce mgm client types
mlsmaycon Feb 23, 2026
e6ebc87
lock before return GetExposeManager
mlsmaycon Feb 23, 2026
dd93d98
use expose types and use int for pin and port
mlsmaycon Feb 23, 2026
12d6626
use string for pin and adjust code with new types
mlsmaycon Feb 23, 2026
c3ad534
move keepalive to expose manager
mlsmaycon Feb 23, 2026
0494f39
allow https exposure
mlsmaycon Feb 23, 2026
59f4fab
use account settings instead
mlsmaycon Feb 23, 2026
4206574
remove unused messages from proto
mlsmaycon Feb 23, 2026
b1488f4
Merge branch 'main' into feature/client-service-expose
mlsmaycon Feb 23, 2026
3d99476
refactor reverseproxy manager to use sendServiceUpdate function
mlsmaycon Feb 23, 2026
b0b4d52
use settings mock manager in reverseproxy manager tests
mlsmaycon Feb 23, 2026
c5c46bf
use status errors instead of fmt errors in reverseproxy manager
mlsmaycon Feb 23, 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
194 changes: 194 additions & 0 deletions client/cmd/expose.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,194 @@
package cmd

import (
"context"
"errors"
"fmt"
"io"
"os"
"os/signal"
"regexp"
"strconv"
"strings"
"syscall"

log "github.com/sirupsen/logrus"
"github.com/spf13/cobra"

"github.com/netbirdio/netbird/client/proto"
"github.com/netbirdio/netbird/util"
)

var pinRegexp = regexp.MustCompile(`^\d{6}$`)

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

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,
}

func init() {
exposeCmd.Flags().StringVar(&exposePin, "with-pin", "", "Protect the exposed service with a 6-digit PIN (e.g. --with-pin 123456)")
exposeCmd.Flags().StringVar(&exposePassword, "with-password", "", "Protect the exposed service with a password (e.g. --with-password my-secret)")
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)")
}

func validateExposeFlags(cmd *cobra.Command, portStr string) (uint64, error) {
port, err := strconv.ParseUint(portStr, 10, 32)
if err != nil {
return 0, fmt.Errorf("invalid port number: %s", portStr)
}
if port == 0 || port > 65535 {
return 0, fmt.Errorf("invalid port number: must be between 1 and 65535")
}

if !isProtocolValid(exposeProtocol) {
return 0, fmt.Errorf("unsupported protocol %q: only 'http' or 'https' are supported", exposeProtocol)
}

if exposePin != "" && !pinRegexp.MatchString(exposePin) {
return 0, fmt.Errorf("invalid pin: must be exactly 6 digits")
}

if cmd.Flags().Changed("with-password") && exposePassword == "" {
return 0, fmt.Errorf("password cannot be empty")
}

if cmd.Flags().Changed("with-user-groups") && len(exposeUserGroups) == 0 {
return 0, fmt.Errorf("user groups cannot be empty")
}

return port, nil
}

func isProtocolValid(exposeProtocol string) bool {
return strings.ToLower(exposeProtocol) == "http" || strings.ToLower(exposeProtocol) == "https"
}

func exposeFn(cmd *cobra.Command, args []string) error {
SetFlagsFromEnvVars(rootCmd)

if err := util.InitLog(logLevel, util.LogConsole); err != nil {
log.Errorf("failed initializing log %v", err)
return err
}

cmd.Root().SilenceUsage = false

port, err := validateExposeFlags(cmd, args[0])
if err != nil {
return err
}

cmd.Root().SilenceUsage = true

ctx, cancel := context.WithCancel(cmd.Context())
defer cancel()

sigCh := make(chan os.Signal, 1)
signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
go func() {
<-sigCh
cancel()
}()

conn, err := DialClientGRPCServer(ctx, daemonAddr)
if err != nil {
return fmt.Errorf("connect to daemon: %w", err)
}
defer func() {
if err := conn.Close(); err != nil {
log.Debugf("failed to close daemon connection: %v", err)
}
}()

client := proto.NewDaemonServiceClient(conn)

protocol, err := toExposeProtocol(exposeProtocol)
if err != nil {
return err
}

stream, err := client.ExposeService(ctx, &proto.ExposeServiceRequest{
Port: uint32(port),
Protocol: protocol,
Pin: exposePin,
Password: exposePassword,
UserGroups: exposeUserGroups,
Domain: exposeDomain,
NamePrefix: exposeNamePrefix,
})
if err != nil {
return fmt.Errorf("expose service: %w", err)
}

if err := handleExposeReady(cmd, stream, port); err != nil {
return err
}

return waitForExposeEvents(cmd, ctx, stream)
}

func toExposeProtocol(exposeProtocol string) (proto.ExposeProtocol, error) {
switch strings.ToLower(exposeProtocol) {
case "http":
return proto.ExposeProtocol_EXPOSE_HTTP, nil
case "https":
return proto.ExposeProtocol_EXPOSE_HTTPS, nil
default:
return 0, fmt.Errorf("unsupported protocol %q: only 'http' or 'https' are supported", exposeProtocol)
}
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

func handleExposeReady(cmd *cobra.Command, stream proto.DaemonService_ExposeServiceClient, port uint64) error {
event, err := stream.Recv()
if err != nil {
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:
return fmt.Errorf("unexpected expose event: %T", event.Event)
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

func waitForExposeEvents(cmd *cobra.Command, ctx context.Context, stream proto.DaemonService_ExposeServiceClient) error {
for {
_, err := stream.Recv()
if err != nil {
if ctx.Err() != nil {
cmd.Println("\nService stopped.")
//nolint:nilerr
return nil
}
if errors.Is(err, io.EOF) {
return fmt.Errorf("connection to daemon closed unexpectedly")
}
return fmt.Errorf("stream error: %w", err)
}
}
}
1 change: 1 addition & 0 deletions client/cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,7 @@ func init() {
rootCmd.AddCommand(forwardingRulesCmd)
rootCmd.AddCommand(debugCmd)
rootCmd.AddCommand(profileCmd)
rootCmd.AddCommand(exposeCmd)

networksCMD.AddCommand(routesListCmd)
networksCMD.AddCommand(routesSelectCmd, routesDeselectCmd)
Expand Down
15 changes: 13 additions & 2 deletions client/internal/engine.go
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ import (
"github.com/netbirdio/netbird/client/internal/dns"
dnsconfig "github.com/netbirdio/netbird/client/internal/dns/config"
"github.com/netbirdio/netbird/client/internal/dnsfwd"
"github.com/netbirdio/netbird/client/internal/expose"
"github.com/netbirdio/netbird/client/internal/ingressgw"
"github.com/netbirdio/netbird/client/internal/netflow"
nftypes "github.com/netbirdio/netbird/client/internal/netflow/types"
Expand Down Expand Up @@ -224,6 +225,8 @@ type Engine struct {

jobExecutor *jobexec.Executor
jobExecutorWG sync.WaitGroup

exposeManager *expose.Manager
}

// Peer is an instance of the Connection Peer
Expand Down Expand Up @@ -419,6 +422,7 @@ func (e *Engine) Start(netbirdConfig *mgmProto.NetbirdConfig, mgmtURL *url.URL)
e.cancel()
}
e.ctx, e.cancel = context.WithCancel(e.clientCtx)
e.exposeManager = expose.NewManager(e.ctx, e.mgmClient)

wgIface, err := e.newWgIface()
if err != nil {
Expand Down Expand Up @@ -801,7 +805,7 @@ func (e *Engine) handleAutoUpdateVersion(autoUpdateSettings *mgmProto.AutoUpdate

disabled := autoUpdateSettings.Version == disableAutoUpdate

// Stop and cleanup if disabled
// stop and cleanup if disabled
if e.updateManager != nil && disabled {
log.Infof("auto-update is disabled, stopping update manager")
e.updateManager.Stop()
Expand Down Expand Up @@ -1824,11 +1828,18 @@ func (e *Engine) GetRouteManager() routemanager.Manager {
return e.routeManager
}

// GetFirewallManager returns the firewall manager
// GetFirewallManager returns the firewall manager.
func (e *Engine) GetFirewallManager() firewallManager.Manager {
return e.firewall
}

// GetExposeManager returns the expose session manager.
func (e *Engine) GetExposeManager() *expose.Manager {
e.syncMsgMux.Lock()
defer e.syncMsgMux.Unlock()
return e.exposeManager
Comment thread
mlsmaycon marked this conversation as resolved.
}

func findIPFromInterfaceName(ifaceName string) (net.IP, error) {
iface, err := net.InterfaceByName(ifaceName)
if err != nil {
Expand Down
95 changes: 95 additions & 0 deletions client/internal/expose/manager.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
package expose

import (
"context"
"time"

mgm "github.com/netbirdio/netbird/shared/management/client"
log "github.com/sirupsen/logrus"
)

const renewTimeout = 10 * time.Second

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

type Request struct {
NamePrefix string
Domain string
Port uint16
Protocol int
Pin string
Password string
UserGroups []string
}

type ManagementClient interface {
CreateExpose(ctx context.Context, req mgm.ExposeRequest) (*mgm.ExposeResponse, error)
RenewExpose(ctx context.Context, domain string) error
StopExpose(ctx context.Context, domain string) error
}

// Manager handles expose session lifecycle via the management client.
type Manager struct {
Comment thread
mlsmaycon marked this conversation as resolved.
mgmClient ManagementClient
ctx context.Context
}

// NewManager creates a new expose Manager using the given management client.
func NewManager(ctx context.Context, mgmClient ManagementClient) *Manager {
return &Manager{mgmClient: mgmClient, ctx: ctx}
}

// Expose creates a new expose session via the management server.
func (m *Manager) Expose(ctx context.Context, req Request) (*Response, error) {
log.Infof("exposing service on port %d", req.Port)
resp, err := m.mgmClient.CreateExpose(ctx, toClientExposeRequest(req))
if err != nil {
return nil, err
}

log.Infof("expose session created for %s", resp.Domain)

return fromClientExposeResponse(resp), nil
}

func (m *Manager) KeepAlive(ctx context.Context, domain string) error {
ticker := time.NewTicker(10 * time.Second)
defer ticker.Stop()
defer m.stop(domain)

for {
select {
case <-ctx.Done():
log.Infof("context canceled, stopping keep alive for %s", domain)

return nil
case <-ticker.C:
if err := m.renew(ctx, domain); err != nil {
log.Errorf("renewing expose session for %s: %v", domain, err)
return err
}
}
}
}

// renew extends the TTL of an active expose session.
func (m *Manager) renew(ctx context.Context, domain string) error {
renewCtx, cancel := context.WithTimeout(ctx, renewTimeout)
defer cancel()
return m.mgmClient.RenewExpose(renewCtx, domain)
}

// stop terminates an active expose session.
func (m *Manager) stop(domain string) {
stopCtx, cancel := context.WithTimeout(m.ctx, renewTimeout)
defer cancel()
err := m.mgmClient.StopExpose(stopCtx, domain)
if err != nil {
log.Warnf("Failed stopping expose session for %s: %v", domain, err)
}
}
Loading
Loading