diff --git a/client/embed/embed.go b/client/embed/embed.go index e73f37e35..515d78d51 100644 --- a/client/embed/embed.go +++ b/client/embed/embed.go @@ -71,6 +71,8 @@ type Options struct { DisableClientRoutes bool // BlockInbound blocks all inbound connections from peers BlockInbound bool + // WireguardPort is the port for the WireGuard interface. Use 0 for a random port. + WireguardPort *int } // validateCredentials checks that exactly one credential type is provided @@ -140,6 +142,7 @@ func New(opts Options) (*Client, error) { DisableServerRoutes: &t, DisableClientRoutes: &opts.DisableClientRoutes, BlockInbound: &opts.BlockInbound, + WireguardPort: opts.WireguardPort, } if opts.ConfigPath != "" { config, err = profilemanager.UpdateOrCreateConfig(input) @@ -159,6 +162,7 @@ func New(opts Options) (*Client, error) { setupKey: opts.SetupKey, jwtToken: opts.JWTToken, config: config, + recorder: peer.NewRecorder(config.ManagementURL.String()), }, nil } @@ -180,6 +184,7 @@ func (c *Client) Start(startCtx context.Context) error { // nolint:staticcheck ctx = context.WithValue(ctx, system.DeviceNameCtxKey, c.deviceName) + authClient, err := auth.NewAuth(ctx, c.config.PrivateKey, c.config.ManagementURL, c.config) if err != nil { return fmt.Errorf("create auth client: %w", err) @@ -189,10 +194,7 @@ func (c *Client) Start(startCtx context.Context) error { if err, _ := authClient.Login(ctx, c.setupKey, c.jwtToken); err != nil { return fmt.Errorf("login: %w", err) } - - recorder := peer.NewRecorder(c.config.ManagementURL.String()) - c.recorder = recorder - client := internal.NewConnectClient(ctx, c.config, recorder, false) + client := internal.NewConnectClient(ctx, c.config, c.recorder, false) client.SetSyncResponsePersistence(true) // either startup error (permanent backoff err) or nil err (successful engine up) @@ -345,14 +347,9 @@ func (c *Client) NewHTTPClient() *http.Client { // Status returns the current status of the client. func (c *Client) Status() (peer.FullStatus, error) { c.mu.Lock() - recorder := c.recorder connect := c.connect c.mu.Unlock() - if recorder == nil { - return peer.FullStatus{}, errors.New("client not started") - } - if connect != nil { engine := connect.Engine() if engine != nil { @@ -360,7 +357,7 @@ func (c *Client) Status() (peer.FullStatus, error) { } } - return recorder.GetFullStatus(), nil + return c.recorder.GetFullStatus(), nil } // GetLatestSyncResponse returns the latest sync response from the management server. diff --git a/client/iface/iface.go b/client/iface/iface.go index e5623c979..9b331d68c 100644 --- a/client/iface/iface.go +++ b/client/iface/iface.go @@ -18,6 +18,7 @@ import ( "github.com/netbirdio/netbird/client/errors" "github.com/netbirdio/netbird/client/iface/configurer" "github.com/netbirdio/netbird/client/iface/device" + nbnetstack "github.com/netbirdio/netbird/client/iface/netstack" "github.com/netbirdio/netbird/client/iface/udpmux" "github.com/netbirdio/netbird/client/iface/wgaddr" "github.com/netbirdio/netbird/client/iface/wgproxy" @@ -228,6 +229,10 @@ func (w *WGIface) Close() error { result = multierror.Append(result, fmt.Errorf("failed to close wireguard interface %s: %w", w.Name(), err)) } + if nbnetstack.IsEnabled() { + return errors.FormatErrorOrNil(result) + } + if err := w.waitUntilRemoved(); err != nil { log.Warnf("failed to remove WireGuard interface %s: %v", w.Name(), err) if err := w.Destroy(); err != nil { diff --git a/client/internal/connect.go b/client/internal/connect.go index 7fc3c9a96..17fc20c42 100644 --- a/client/internal/connect.go +++ b/client/internal/connect.go @@ -20,6 +20,7 @@ import ( "github.com/netbirdio/netbird/client/iface" "github.com/netbirdio/netbird/client/iface/device" + "github.com/netbirdio/netbird/client/iface/netstack" "github.com/netbirdio/netbird/client/internal/dns" "github.com/netbirdio/netbird/client/internal/listener" "github.com/netbirdio/netbird/client/internal/peer" @@ -244,7 +245,7 @@ func (c *ConnectClient) run(mobileDependency MobileDependency, runningChan chan localPeerState := peer.LocalPeerState{ IP: loginResp.GetPeerConfig().GetAddress(), PubKey: myPrivateKey.PublicKey().String(), - KernelInterface: device.WireGuardModuleIsLoaded(), + KernelInterface: device.WireGuardModuleIsLoaded() && !netstack.IsEnabled(), FQDN: loginResp.GetPeerConfig().GetFqdn(), } c.statusRecorder.UpdateLocalPeerState(localPeerState) diff --git a/client/internal/engine.go b/client/internal/engine.go index 63ba1c9f2..597ac7c2d 100644 --- a/client/internal/engine.go +++ b/client/internal/engine.go @@ -1017,7 +1017,7 @@ func (e *Engine) updateConfig(conf *mgmProto.PeerConfig) error { state := e.statusRecorder.GetLocalPeerState() state.IP = e.wgInterface.Address().String() state.PubKey = e.config.WgPrivateKey.PublicKey().String() - state.KernelInterface = device.WireGuardModuleIsLoaded() + state.KernelInterface = !e.wgInterface.IsUserspaceBind() state.FQDN = conf.GetFqdn() e.statusRecorder.UpdateLocalPeerState(state) diff --git a/client/internal/engine_ssh.go b/client/internal/engine_ssh.go index a8c05fe0a..1419bc262 100644 --- a/client/internal/engine_ssh.go +++ b/client/internal/engine_ssh.go @@ -10,6 +10,7 @@ import ( log "github.com/sirupsen/logrus" firewallManager "github.com/netbirdio/netbird/client/firewall/manager" + "github.com/netbirdio/netbird/client/iface/netstack" nftypes "github.com/netbirdio/netbird/client/internal/netflow/types" sshauth "github.com/netbirdio/netbird/client/ssh/auth" sshconfig "github.com/netbirdio/netbird/client/ssh/config" @@ -94,6 +95,10 @@ func (e *Engine) updateSSH(sshConf *mgmProto.SSHConfig) error { // updateSSHClientConfig updates the SSH client configuration with peer information func (e *Engine) updateSSHClientConfig(remotePeers []*mgmProto.RemotePeerConfig) error { + if netstack.IsEnabled() { + return nil + } + peerInfo := e.extractPeerSSHInfo(remotePeers) if len(peerInfo) == 0 { log.Debug("no SSH-enabled peers found, skipping SSH config update") @@ -216,6 +221,10 @@ func (e *Engine) GetPeerSSHKey(peerAddress string) ([]byte, bool) { // cleanupSSHConfig removes NetBird SSH client configuration on shutdown func (e *Engine) cleanupSSHConfig() { + if netstack.IsEnabled() { + return + } + configMgr := sshconfig.New() if err := configMgr.RemoveSSHClientConfig(); err != nil { diff --git a/client/internal/lazyconn/activity/manager.go b/client/internal/lazyconn/activity/manager.go index db283ec9a..1c11378c8 100644 --- a/client/internal/lazyconn/activity/manager.go +++ b/client/internal/lazyconn/activity/manager.go @@ -11,6 +11,7 @@ import ( log "github.com/sirupsen/logrus" "golang.zx2c4.com/wireguard/wgctrl/wgtypes" + "github.com/netbirdio/netbird/client/iface/netstack" "github.com/netbirdio/netbird/client/iface/wgaddr" "github.com/netbirdio/netbird/client/internal/lazyconn" peerid "github.com/netbirdio/netbird/client/internal/peer/id" @@ -74,12 +75,13 @@ func (m *Manager) createListener(peerCfg lazyconn.PeerConfig) (listener, error) return NewUDPListener(m.wgIface, peerCfg) } - // BindListener is only used on Windows and JS platforms: + // BindListener is used on Windows, JS, and netstack platforms: // - JS: Cannot listen to UDP sockets // - Windows: IP_UNICAST_IF socket option forces packets out the interface the default // gateway points to, preventing them from reaching the loopback interface. - // BindListener bypasses this by passing data directly through the bind. - if runtime.GOOS != "windows" && runtime.GOOS != "js" { + // - Netstack: Allows multiple instances on the same host without port conflicts. + // BindListener bypasses these issues by passing data directly through the bind. + if runtime.GOOS != "windows" && runtime.GOOS != "js" && !netstack.IsEnabled() { return NewUDPListener(m.wgIface, peerCfg) } diff --git a/idp/dex/provider.go b/idp/dex/provider.go index 657674eba..6c608dbf5 100644 --- a/idp/dex/provider.go +++ b/idp/dex/provider.go @@ -398,7 +398,7 @@ func (p *Provider) Stop(ctx context.Context) error { // EnsureDefaultClients creates dashboard and CLI OAuth clients // Uses Dex's storage.Client directly - no custom wrappers -func (p *Provider) EnsureDefaultClients(ctx context.Context, dashboardURIs, cliURIs, proxyURIs []string) error { +func (p *Provider) EnsureDefaultClients(ctx context.Context, dashboardURIs, cliURIs []string) error { clients := []storage.Client{ { ID: "netbird-dashboard", @@ -412,12 +412,6 @@ func (p *Provider) EnsureDefaultClients(ctx context.Context, dashboardURIs, cliU RedirectURIs: cliURIs, Public: true, }, - { - ID: "netbird-proxy", - Name: "NetBird Proxy", - RedirectURIs: proxyURIs, - Public: true, - }, } for _, client := range clients { diff --git a/idp/sdk/sdk.go b/idp/sdk/sdk.go index aaa892b46..d2189135b 100644 --- a/idp/sdk/sdk.go +++ b/idp/sdk/sdk.go @@ -95,8 +95,8 @@ func (d *DexIdP) Stop(ctx context.Context) error { } // EnsureDefaultClients creates the default NetBird OAuth clients -func (d *DexIdP) EnsureDefaultClients(ctx context.Context, dashboardURIs, cliURIs, proxyURIs []string) error { - return d.provider.EnsureDefaultClients(ctx, dashboardURIs, cliURIs, proxyURIs) +func (d *DexIdP) EnsureDefaultClients(ctx context.Context, dashboardURIs, cliURIs []string) error { + return d.provider.EnsureDefaultClients(ctx, dashboardURIs, cliURIs) } // Storage exposes Dex storage for direct user/client/connector management diff --git a/management/internals/controllers/network_map/controller/controller.go b/management/internals/controllers/network_map/controller/controller.go index 5ae64e9f1..064a13573 100644 --- a/management/internals/controllers/network_map/controller/controller.go +++ b/management/internals/controllers/network_map/controller/controller.go @@ -180,6 +180,8 @@ func (c *Controller) sendUpdateAccountPeers(ctx context.Context, accountID strin resourcePolicies := account.GetResourcePoliciesMap() routers := account.GetResourceRoutersMap() groupIDToUserIDs := account.GetActiveGroupUsers() + exposedServices := account.GetExposedServicesMap() + proxyPeers := account.GetProxyPeers() if c.experimentalNetworkMap(accountID) { c.initNetworkMapBuilderIfNeeded(account, approvedPeersMap) @@ -232,7 +234,7 @@ func (c *Controller) sendUpdateAccountPeers(ctx context.Context, accountID strin if c.experimentalNetworkMap(accountID) { remotePeerNetworkMap = c.getPeerNetworkMapExp(ctx, p.AccountID, p.ID, approvedPeersMap, peersCustomZone, accountZones, c.accountManagerMetrics) } else { - remotePeerNetworkMap = account.GetPeerNetworkMap(ctx, p.ID, peersCustomZone, accountZones, approvedPeersMap, resourcePolicies, routers, c.accountManagerMetrics, groupIDToUserIDs) + remotePeerNetworkMap = account.GetPeerNetworkMap(ctx, p.ID, peersCustomZone, accountZones, approvedPeersMap, resourcePolicies, routers, c.accountManagerMetrics, groupIDToUserIDs, exposedServices, proxyPeers) } c.metrics.CountCalcPeerNetworkMapDuration(time.Since(start)) @@ -353,7 +355,7 @@ func (c *Controller) UpdateAccountPeer(ctx context.Context, accountId string, pe if c.experimentalNetworkMap(accountId) { remotePeerNetworkMap = c.getPeerNetworkMapExp(ctx, peer.AccountID, peer.ID, approvedPeersMap, peersCustomZone, accountZones, c.accountManagerMetrics) } else { - remotePeerNetworkMap = account.GetPeerNetworkMap(ctx, peerId, peersCustomZone, accountZones, approvedPeersMap, resourcePolicies, routers, c.accountManagerMetrics, groupIDToUserIDs) + remotePeerNetworkMap = account.GetPeerNetworkMap(ctx, peerId, peersCustomZone, accountZones, approvedPeersMap, resourcePolicies, routers, c.accountManagerMetrics, groupIDToUserIDs, account.GetExposedServicesMap(), account.GetProxyPeers()) } proxyNetworkMap, ok := proxyNetworkMaps[peer.ID] @@ -469,7 +471,7 @@ func (c *Controller) GetValidatedPeerWithMap(ctx context.Context, isRequiresAppr } else { resourcePolicies := account.GetResourcePoliciesMap() routers := account.GetResourceRoutersMap() - networkMap = account.GetPeerNetworkMap(ctx, peer.ID, peersCustomZone, accountZones, approvedPeersMap, resourcePolicies, routers, c.accountManagerMetrics, account.GetActiveGroupUsers()) + networkMap = account.GetPeerNetworkMap(ctx, peer.ID, peersCustomZone, accountZones, approvedPeersMap, resourcePolicies, routers, c.accountManagerMetrics, account.GetActiveGroupUsers(), account.GetExposedServicesMap(), account.GetProxyPeers()) } proxyNetworkMap, ok := proxyNetworkMaps[peer.ID] @@ -842,7 +844,7 @@ func (c *Controller) GetNetworkMap(ctx context.Context, peerID string) (*types.N } else { resourcePolicies := account.GetResourcePoliciesMap() routers := account.GetResourceRoutersMap() - networkMap = account.GetPeerNetworkMap(ctx, peer.ID, peersCustomZone, accountZones, validatedPeers, resourcePolicies, routers, nil, account.GetActiveGroupUsers()) + networkMap = account.GetPeerNetworkMap(ctx, peer.ID, peersCustomZone, accountZones, validatedPeers, resourcePolicies, routers, nil, account.GetActiveGroupUsers(), account.GetExposedServicesMap(), account.GetProxyPeers()) } proxyNetworkMap, ok := proxyNetworkMaps[peer.ID] diff --git a/management/internals/modules/peers/manager.go b/management/internals/modules/peers/manager.go index 7ac2e379f..049cbb7ab 100644 --- a/management/internals/modules/peers/manager.go +++ b/management/internals/modules/peers/manager.go @@ -7,6 +7,7 @@ import ( "fmt" "time" + "github.com/rs/xid" log "github.com/sirupsen/logrus" "github.com/netbirdio/netbird/management/internals/controllers/network_map" @@ -32,6 +33,7 @@ type Manager interface { SetIntegratedPeerValidator(integratedPeerValidator integrated_validator.IntegratedValidator) SetAccountManager(accountManager account.Manager) GetPeerID(ctx context.Context, peerKey string) (string, error) + CreateProxyPeer(ctx context.Context, accountID string, peerKey string) error } type managerImpl struct { @@ -182,3 +184,33 @@ func (m *managerImpl) DeletePeers(ctx context.Context, accountID string, peerIDs func (m *managerImpl) GetPeerID(ctx context.Context, peerKey string) (string, error) { return m.store.GetPeerIDByKey(ctx, store.LockingStrengthNone, peerKey) } + +func (m *managerImpl) CreateProxyPeer(ctx context.Context, accountID string, peerKey string) error { + existingPeerID, err := m.store.GetPeerIDByKey(ctx, store.LockingStrengthNone, peerKey) + if err == nil && existingPeerID != "" { + // Peer already exists + return nil + } + + name := fmt.Sprintf("proxy-%s", xid.New().String()) + peer := &peer.Peer{ + Ephemeral: true, + ProxyEmbedded: true, + Name: name, + Key: peerKey, + LoginExpirationEnabled: false, + InactivityExpirationEnabled: false, + Meta: peer.PeerSystemMeta{ + Hostname: name, + GoOS: "proxy", + OS: "proxy", + }, + } + + _, _, _, err = m.accountManager.AddPeer(ctx, accountID, "", "", peer, false) + if err != nil { + return fmt.Errorf("failed to create proxy peer: %w", err) + } + + return nil +} diff --git a/management/internals/modules/reverseproxy/manager/manager.go b/management/internals/modules/reverseproxy/manager/manager.go index 0bafc80fa..b413713a9 100644 --- a/management/internals/modules/reverseproxy/manager/manager.go +++ b/management/internals/modules/reverseproxy/manager/manager.go @@ -5,11 +5,10 @@ import ( "fmt" "time" - "github.com/google/uuid" - "github.com/rs/xid" log "github.com/sirupsen/logrus" "github.com/netbirdio/netbird/management/internals/modules/reverseproxy" + "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/sessionkey" nbgrpc "github.com/netbirdio/netbird/management/internals/shared/grpc" "github.com/netbirdio/netbird/management/server/account" "github.com/netbirdio/netbird/management/server/activity" @@ -17,7 +16,6 @@ import ( "github.com/netbirdio/netbird/management/server/permissions/modules" "github.com/netbirdio/netbird/management/server/permissions/operations" "github.com/netbirdio/netbird/management/server/store" - "github.com/netbirdio/netbird/management/server/types" "github.com/netbirdio/netbird/shared/management/status" ) @@ -32,16 +30,18 @@ type managerImpl struct { permissionsManager permissions.Manager proxyGRPCServer *nbgrpc.ProxyServiceServer clusterDeriver ClusterDeriver + tokenStore *nbgrpc.OneTimeTokenStore } // NewManager creates a new reverse proxy manager. -func NewManager(store store.Store, accountManager account.Manager, permissionsManager permissions.Manager, proxyGRPCServer *nbgrpc.ProxyServiceServer, clusterDeriver ClusterDeriver) reverseproxy.Manager { +func NewManager(store store.Store, accountManager account.Manager, permissionsManager permissions.Manager, proxyGRPCServer *nbgrpc.ProxyServiceServer, clusterDeriver ClusterDeriver, tokenStore *nbgrpc.OneTimeTokenStore) reverseproxy.Manager { return &managerImpl{ store: store, accountManager: accountManager, permissionsManager: permissionsManager, proxyGRPCServer: proxyGRPCServer, clusterDeriver: clusterDeriver, + tokenStore: tokenStore, } } @@ -92,6 +92,14 @@ func (m *managerImpl) CreateReverseProxy(ctx context.Context, accountID, userID reverseProxy.Auth = authConfig + // Generate session JWT signing keys + keyPair, err := sessionkey.GenerateKeyPair() + if err != nil { + return nil, fmt.Errorf("generate session keys: %w", err) + } + reverseProxy.SessionPrivateKey = keyPair.PrivateKey + reverseProxy.SessionPublicKey = keyPair.PublicKey + err = m.store.ExecuteInTransaction(ctx, func(transaction store.Store) error { // Check for duplicate domain existingReverseProxy, err := transaction.GetReverseProxyByDomain(ctx, accountID, reverseProxy.Domain) @@ -114,56 +122,14 @@ func (m *managerImpl) CreateReverseProxy(ctx context.Context, accountID, userID return nil, err } + token, err := m.tokenStore.GenerateToken(accountID, reverseProxy.ID, 5*time.Minute) + if err != nil { + return nil, fmt.Errorf("failed to generate authentication token: %w", err) + } + m.accountManager.StoreEvent(ctx, userID, reverseProxy.ID, accountID, activity.ReverseProxyCreated, reverseProxy.EventMeta()) - // TODO: refactor to avoid policy and group creation here - group := &types.Group{ - ID: xid.New().String(), - Name: reverseProxy.Name, - Issued: types.GroupIssuedAPI, - } - err = m.accountManager.CreateGroup(ctx, accountID, activity.SystemInitiator, group) - if err != nil { - return nil, fmt.Errorf("failed to create default group for reverse proxy: %w", err) - } - - for _, target := range reverseProxy.Targets { - policyID := uuid.New().String() - // TODO: support other resource types in the future - targetType := types.ResourceTypePeer - if target.TargetType == "resource" { - targetType = types.ResourceTypeHost - } - policyRule := &types.PolicyRule{ - ID: policyID, - PolicyID: policyID, - Name: reverseProxy.Name, - Enabled: true, - Action: types.PolicyTrafficActionAccept, - Protocol: types.PolicyRuleProtocolALL, - Sources: []string{group.ID}, - DestinationResource: types.Resource{Type: targetType, ID: target.TargetId}, - Bidirectional: false, - } - - policy := &types.Policy{ - AccountID: accountID, - Name: reverseProxy.Name, - Enabled: true, - Rules: []*types.PolicyRule{policyRule}, - } - _, err = m.accountManager.SavePolicy(ctx, accountID, activity.SystemInitiator, policy, true) - if err != nil { - return nil, fmt.Errorf("failed to create default policy for reverse proxy: %w", err) - } - } - - key, err := m.accountManager.CreateSetupKey(ctx, accountID, reverseProxy.Name, types.SetupKeyReusable, 0, []string{group.ID}, 0, activity.SystemInitiator, true, false) - if err != nil { - return nil, fmt.Errorf("failed to create setup key for reverse proxy: %w", err) - } - - m.proxyGRPCServer.SendReverseProxyUpdateToCluster(reverseProxy.ToProtoMapping(reverseproxy.Create, key.Key), reverseProxy.ProxyCluster) + m.proxyGRPCServer.SendReverseProxyUpdateToCluster(reverseProxy.ToProtoMapping(reverseproxy.Create, token, m.proxyGRPCServer.GetOIDCValidationConfig()), reverseProxy.ProxyCluster) return reverseProxy, nil } @@ -225,11 +191,12 @@ func (m *managerImpl) UpdateReverseProxy(ctx context.Context, accountID, userID m.accountManager.StoreEvent(ctx, userID, reverseProxy.ID, accountID, activity.ReverseProxyUpdated, reverseProxy.EventMeta()) + oidcConfig := m.proxyGRPCServer.GetOIDCValidationConfig() if domainChanged && oldCluster != reverseProxy.ProxyCluster { - m.proxyGRPCServer.SendReverseProxyUpdateToCluster(reverseProxy.ToProtoMapping(reverseproxy.Delete, ""), oldCluster) - m.proxyGRPCServer.SendReverseProxyUpdateToCluster(reverseProxy.ToProtoMapping(reverseproxy.Create, ""), reverseProxy.ProxyCluster) + m.proxyGRPCServer.SendReverseProxyUpdateToCluster(reverseProxy.ToProtoMapping(reverseproxy.Delete, "", oidcConfig), oldCluster) + m.proxyGRPCServer.SendReverseProxyUpdateToCluster(reverseProxy.ToProtoMapping(reverseproxy.Create, "", oidcConfig), reverseProxy.ProxyCluster) } else { - m.proxyGRPCServer.SendReverseProxyUpdateToCluster(reverseProxy.ToProtoMapping(reverseproxy.Update, ""), reverseProxy.ProxyCluster) + m.proxyGRPCServer.SendReverseProxyUpdateToCluster(reverseProxy.ToProtoMapping(reverseproxy.Update, "", oidcConfig), reverseProxy.ProxyCluster) } return reverseProxy, nil @@ -264,7 +231,7 @@ func (m *managerImpl) DeleteReverseProxy(ctx context.Context, accountID, userID, m.accountManager.StoreEvent(ctx, userID, reverseProxyID, accountID, activity.ReverseProxyDeleted, reverseProxy.EventMeta()) - m.proxyGRPCServer.SendReverseProxyUpdateToCluster(reverseProxy.ToProtoMapping(reverseproxy.Delete, ""), reverseProxy.ProxyCluster) + m.proxyGRPCServer.SendReverseProxyUpdateToCluster(reverseProxy.ToProtoMapping(reverseproxy.Delete, "", m.proxyGRPCServer.GetOIDCValidationConfig()), reverseProxy.ProxyCluster) return nil } diff --git a/management/internals/modules/reverseproxy/reverseproxy.go b/management/internals/modules/reverseproxy/reverseproxy.go index edb541410..8bbf5af5f 100644 --- a/management/internals/modules/reverseproxy/reverseproxy.go +++ b/management/internals/modules/reverseproxy/reverseproxy.go @@ -7,6 +7,7 @@ import ( "strconv" "time" + "github.com/netbirdio/netbird/util/crypt" "github.com/rs/xid" log "github.com/sirupsen/logrus" @@ -31,6 +32,9 @@ const ( StatusCertificatePending ProxyStatus = "certificate_pending" StatusCertificateFailed ProxyStatus = "certificate_failed" StatusError ProxyStatus = "error" + + TargetTypePeer = "peer" + TargetTypeResource = "resource" ) type Target struct { @@ -58,15 +62,17 @@ type BearerAuthConfig struct { DistributionGroups []string `json:"distribution_groups,omitempty" gorm:"serializer:json"` } -type LinkAuthConfig struct { - Enabled bool `json:"enabled"` -} - type AuthConfig struct { PasswordAuth *PasswordAuthConfig `json:"password_auth,omitempty" gorm:"serializer:json"` PinAuth *PINAuthConfig `json:"pin_auth,omitempty" gorm:"serializer:json"` BearerAuth *BearerAuthConfig `json:"bearer_auth,omitempty" gorm:"serializer:json"` - LinkAuth *LinkAuthConfig `json:"link_auth,omitempty" gorm:"serializer:json"` +} + +type OIDCValidationConfig struct { + Issuer string + Audiences []string + KeysLocation string + MaxTokenAgeSeconds int64 } type ReverseProxyMeta struct { @@ -76,15 +82,17 @@ type ReverseProxyMeta struct { } type ReverseProxy struct { - ID string `gorm:"primaryKey"` - AccountID string `gorm:"index"` - Name string - Domain string `gorm:"index"` - ProxyCluster string `gorm:"index"` - Targets []Target `gorm:"serializer:json"` - Enabled bool - Auth AuthConfig `gorm:"serializer:json"` - Meta ReverseProxyMeta `gorm:"embedded;embeddedPrefix:meta_"` + ID string `gorm:"primaryKey"` + AccountID string `gorm:"index"` + Name string + Domain string `gorm:"index"` + ProxyCluster string `gorm:"index"` + Targets []Target `gorm:"serializer:json"` + Enabled bool + Auth AuthConfig `gorm:"serializer:json"` + Meta ReverseProxyMeta `gorm:"embedded;embeddedPrefix:meta_"` + SessionPrivateKey string `gorm:"column:session_private_key"` + SessionPublicKey string `gorm:"column:session_public_key"` } func NewReverseProxy(accountID, name, domain, proxyCluster string, targets []Target, enabled bool) *ReverseProxy { @@ -127,12 +135,6 @@ func (r *ReverseProxy) ToAPIResponse() *api.ReverseProxy { } } - if r.Auth.LinkAuth != nil { - authConfig.LinkAuth = &api.LinkAuthConfig{ - Enabled: r.Auth.LinkAuth.Enabled, - } - } - // Convert internal targets to API targets apiTargets := make([]api.ReverseProxyTarget, 0, len(r.Targets)) for _, target := range r.Targets { @@ -173,7 +175,7 @@ func (r *ReverseProxy) ToAPIResponse() *api.ReverseProxy { return resp } -func (r *ReverseProxy) ToProtoMapping(operation Operation, setupKey string) *proto.ProxyMapping { +func (r *ReverseProxy) ToProtoMapping(operation Operation, authToken string, oidcConfig OIDCValidationConfig) *proto.ProxyMapping { pathMappings := make([]*proto.PathMapping, 0, len(r.Targets)) for _, target := range r.Targets { if !target.Enabled { @@ -200,7 +202,10 @@ func (r *ReverseProxy) ToProtoMapping(operation Operation, setupKey string) *pro }) } - auth := &proto.Authentication{} + auth := &proto.Authentication{ + SessionKey: r.SessionPublicKey, + MaxSessionAgeSeconds: int64((time.Hour * 24).Seconds()), + } if r.Auth.PasswordAuth != nil && r.Auth.PasswordAuth.Enabled { auth.Password = true @@ -211,13 +216,7 @@ func (r *ReverseProxy) ToProtoMapping(operation Operation, setupKey string) *pro } if r.Auth.BearerAuth != nil && r.Auth.BearerAuth.Enabled { - auth.Oidc = &proto.OIDC{ - DistributionGroups: r.Auth.BearerAuth.DistributionGroups, - } - } - - if r.Auth.LinkAuth != nil && r.Auth.LinkAuth.Enabled { - auth.Link = true + auth.Oidc = true } return &proto.ProxyMapping{ @@ -225,7 +224,7 @@ func (r *ReverseProxy) ToProtoMapping(operation Operation, setupKey string) *pro Id: r.ID, Domain: r.Domain, Path: pathMappings, - SetupKey: setupKey, + AuthToken: authToken, Auth: auth, AccountId: r.AccountID, } @@ -289,13 +288,6 @@ func (r *ReverseProxy) FromAPIRequest(req *api.ReverseProxyRequest, accountID st } r.Auth.BearerAuth = bearerAuth } - - if req.Auth.LinkAuth != nil { - r.Auth.LinkAuth = &LinkAuthConfig{ - Enabled: req.Auth.LinkAuth.Enabled, - } - } - } func (r *ReverseProxy) Validate() error { @@ -320,3 +312,54 @@ func (r *ReverseProxy) Validate() error { func (r *ReverseProxy) EventMeta() map[string]any { return map[string]any{"name": r.Name, "domain": r.Domain, "proxy_cluster": r.ProxyCluster} } + +func (r *ReverseProxy) Copy() *ReverseProxy { + targets := make([]Target, len(r.Targets)) + copy(targets, r.Targets) + + return &ReverseProxy{ + ID: r.ID, + AccountID: r.AccountID, + Name: r.Name, + Domain: r.Domain, + ProxyCluster: r.ProxyCluster, + Targets: targets, + Enabled: r.Enabled, + Auth: r.Auth, + Meta: r.Meta, + SessionPrivateKey: r.SessionPrivateKey, + SessionPublicKey: r.SessionPublicKey, + } +} + +func (r *ReverseProxy) EncryptSensitiveData(enc *crypt.FieldEncrypt) error { + if enc == nil { + return nil + } + + if r.SessionPrivateKey != "" { + var err error + r.SessionPrivateKey, err = enc.Encrypt(r.SessionPrivateKey) + if err != nil { + return err + } + } + + return nil +} + +func (r *ReverseProxy) DecryptSensitiveData(enc *crypt.FieldEncrypt) error { + if enc == nil { + return nil + } + + if r.SessionPrivateKey != "" { + var err error + r.SessionPrivateKey, err = enc.Decrypt(r.SessionPrivateKey) + if err != nil { + return err + } + } + + return nil +} diff --git a/management/internals/modules/reverseproxy/sessionkey/sessionkey.go b/management/internals/modules/reverseproxy/sessionkey/sessionkey.go new file mode 100644 index 000000000..aacbe5dca --- /dev/null +++ b/management/internals/modules/reverseproxy/sessionkey/sessionkey.go @@ -0,0 +1,69 @@ +package sessionkey + +import ( + "crypto/ed25519" + "crypto/rand" + "encoding/base64" + "fmt" + "time" + + "github.com/golang-jwt/jwt/v5" + + "github.com/netbirdio/netbird/proxy/auth" +) + +type KeyPair struct { + PrivateKey string + PublicKey string +} + +type Claims struct { + jwt.RegisteredClaims + Method auth.Method `json:"method"` +} + +func GenerateKeyPair() (*KeyPair, error) { + pub, priv, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + return nil, fmt.Errorf("generate ed25519 key: %w", err) + } + + return &KeyPair{ + PrivateKey: base64.StdEncoding.EncodeToString(priv), + PublicKey: base64.StdEncoding.EncodeToString(pub), + }, nil +} + +func SignToken(privKeyB64, userID, domain string, method auth.Method, expiration time.Duration) (string, error) { + privKeyBytes, err := base64.StdEncoding.DecodeString(privKeyB64) + if err != nil { + return "", fmt.Errorf("decode private key: %w", err) + } + + if len(privKeyBytes) != ed25519.PrivateKeySize { + return "", fmt.Errorf("invalid private key size: got %d, want %d", len(privKeyBytes), ed25519.PrivateKeySize) + } + + privKey := ed25519.PrivateKey(privKeyBytes) + + now := time.Now() + claims := Claims{ + RegisteredClaims: jwt.RegisteredClaims{ + Issuer: auth.SessionJWTIssuer, + Subject: userID, + Audience: jwt.ClaimStrings{domain}, + ExpiresAt: jwt.NewNumericDate(now.Add(expiration)), + IssuedAt: jwt.NewNumericDate(now), + NotBefore: jwt.NewNumericDate(now), + }, + Method: method, + } + + token := jwt.NewWithClaims(jwt.SigningMethodEdDSA, claims) + signedToken, err := token.SignedString(privKey) + if err != nil { + return "", fmt.Errorf("sign token: %w", err) + } + + return signedToken, nil +} diff --git a/management/internals/server/boot.go b/management/internals/server/boot.go index de86ad22a..a0753a592 100644 --- a/management/internals/server/boot.go +++ b/management/internals/server/boot.go @@ -8,6 +8,7 @@ import ( "net/http" "net/netip" "slices" + "strings" "time" grpcMiddleware "github.com/grpc-ecosystem/go-grpc-middleware/v2" @@ -94,7 +95,7 @@ func (s *BaseServer) EventStore() activity.Store { func (s *BaseServer) APIHandler() http.Handler { return Create(s, func() http.Handler { - httpAPIHandler, err := nbhttp.NewAPIHandler(context.Background(), s.AccountManager(), s.NetworksManager(), s.ResourcesManager(), s.RoutesManager(), s.GroupsManager(), s.GeoLocationManager(), s.AuthManager(), s.Metrics(), s.IntegratedValidator(), s.ProxyController(), s.PermissionsManager(), s.PeersManager(), s.SettingsManager(), s.ZonesManager(), s.RecordsManager(), s.NetworkMapController(), s.IdpManager(), s.ReverseProxyManager(), s.ReverseProxyDomainManager(), s.AccessLogsManager()) + httpAPIHandler, err := nbhttp.NewAPIHandler(context.Background(), s.AccountManager(), s.NetworksManager(), s.ResourcesManager(), s.RoutesManager(), s.GroupsManager(), s.GeoLocationManager(), s.AuthManager(), s.Metrics(), s.IntegratedValidator(), s.ProxyController(), s.PermissionsManager(), s.PeersManager(), s.SettingsManager(), s.ZonesManager(), s.RecordsManager(), s.NetworkMapController(), s.IdpManager(), s.ReverseProxyManager(), s.ReverseProxyDomainManager(), s.AccessLogsManager(), s.ReverseProxyGRPCServer()) if err != nil { log.Fatalf("failed to create API handler: %v", err) } @@ -161,7 +162,7 @@ func (s *BaseServer) GRPCServer() *grpc.Server { func (s *BaseServer) ReverseProxyGRPCServer() *nbgrpc.ProxyServiceServer { return Create(s, func() *nbgrpc.ProxyServiceServer { - proxyService := nbgrpc.NewProxyServiceServer(s.Store(), s.AccountManager(), s.AccessLogsManager()) + proxyService := nbgrpc.NewProxyServiceServer(s.Store(), s.AccessLogsManager(), s.ProxyTokenStore(), s.proxyOIDCConfig(), s.PeersManager()) s.AfterInit(func(s *BaseServer) { proxyService.SetProxyManager(s.ReverseProxyManager()) }) @@ -169,6 +170,35 @@ func (s *BaseServer) ReverseProxyGRPCServer() *nbgrpc.ProxyServiceServer { }) } +func (s *BaseServer) proxyOIDCConfig() nbgrpc.ProxyOIDCConfig { + return Create(s, func() nbgrpc.ProxyOIDCConfig { + // TODO: this is weird, double check + // Build callback URL - this should be the management server's callback endpoint + // For embedded IdP, derive from issuer. For external, use a configured value or derive from issuer. + // The callback URL should be registered in the IdP's allowed redirect URIs for the dashboard client. + callbackURL := strings.TrimSuffix(s.Config.HttpConfig.AuthIssuer, "/oauth2") + callbackURL = callbackURL + "/api/oauth/callback" + + return nbgrpc.ProxyOIDCConfig{ + Issuer: s.Config.HttpConfig.AuthIssuer, + ClientID: "netbird-dashboard", // Reuse dashboard client + Scopes: []string{"openid", "profile", "email"}, + CallbackURL: callbackURL, + HMACKey: []byte(s.Config.DataStoreEncryptionKey), // Use the datastore encryption key for OIDC state HMACs, this should ensure all management instances are using the same key. + Audience: s.Config.HttpConfig.AuthAudience, + KeysLocation: s.Config.HttpConfig.AuthKeysLocation, + } + }) +} + +func (s *BaseServer) ProxyTokenStore() *nbgrpc.OneTimeTokenStore { + return Create(s, func() *nbgrpc.OneTimeTokenStore { + tokenStore := nbgrpc.NewOneTimeTokenStore(1 * time.Minute) + log.Info("One-time token store initialized for proxy authentication") + return tokenStore + }) +} + func (s *BaseServer) AccessLogsManager() accesslogs.Manager { return Create(s, func() accesslogs.Manager { accessLogManager := accesslogsmanager.NewManager(s.Store(), s.PermissionsManager(), s.GeoLocationManager()) diff --git a/management/internals/server/modules.go b/management/internals/server/modules.go index 02a4330f7..bcb19429b 100644 --- a/management/internals/server/modules.go +++ b/management/internals/server/modules.go @@ -181,7 +181,7 @@ func (s *BaseServer) RecordsManager() records.Manager { func (s *BaseServer) ReverseProxyManager() reverseproxy.Manager { return Create(s, func() reverseproxy.Manager { domainMgr := s.ReverseProxyDomainManager() - return nbreverseproxy.NewManager(s.Store(), s.AccountManager(), s.PermissionsManager(), s.ReverseProxyGRPCServer(), domainMgr) + return nbreverseproxy.NewManager(s.Store(), s.AccountManager(), s.PermissionsManager(), s.ReverseProxyGRPCServer(), domainMgr, s.ProxyTokenStore()) }) } diff --git a/management/internals/shared/grpc/onetime_token.go b/management/internals/shared/grpc/onetime_token.go new file mode 100644 index 000000000..5dfc8848a --- /dev/null +++ b/management/internals/shared/grpc/onetime_token.go @@ -0,0 +1,167 @@ +package grpc + +import ( + "crypto/rand" + "crypto/subtle" + "encoding/base64" + "fmt" + "sync" + "time" + + log "github.com/sirupsen/logrus" +) + +// OneTimeTokenStore manages short-lived, single-use authentication tokens +// for proxy-to-management RPC authentication. Tokens are generated when +// a reverse proxy is created and must be used exactly once by the proxy +// to authenticate a subsequent RPC call. +type OneTimeTokenStore struct { + tokens map[string]*tokenMetadata + mu sync.RWMutex + cleanup *time.Ticker + cleanupDone chan struct{} +} + +// tokenMetadata stores information about a one-time token +type tokenMetadata struct { + ReverseProxyID string + AccountID string + ExpiresAt time.Time + CreatedAt time.Time +} + +// NewOneTimeTokenStore creates a new token store with automatic cleanup +// of expired tokens. The cleanupInterval determines how often expired +// tokens are removed from memory. +func NewOneTimeTokenStore(cleanupInterval time.Duration) *OneTimeTokenStore { + store := &OneTimeTokenStore{ + tokens: make(map[string]*tokenMetadata), + cleanup: time.NewTicker(cleanupInterval), + cleanupDone: make(chan struct{}), + } + + // Start background cleanup goroutine + go store.cleanupExpired() + + return store +} + +// GenerateToken creates a new cryptographically secure one-time token +// with the specified TTL. The token is associated with a specific +// accountID and reverseProxyID for validation purposes. +// +// Returns the generated token string or an error if random generation fails. +func (s *OneTimeTokenStore) GenerateToken(accountID, reverseProxyID string, ttl time.Duration) (string, error) { + // Generate 32 bytes (256 bits) of cryptographically secure random data + randomBytes := make([]byte, 32) + if _, err := rand.Read(randomBytes); err != nil { + return "", fmt.Errorf("failed to generate random token: %w", err) + } + + // Encode as URL-safe base64 for easy transmission in gRPC + token := base64.URLEncoding.EncodeToString(randomBytes) + + s.mu.Lock() + defer s.mu.Unlock() + + s.tokens[token] = &tokenMetadata{ + ReverseProxyID: reverseProxyID, + AccountID: accountID, + ExpiresAt: time.Now().Add(ttl), + CreatedAt: time.Now(), + } + + log.Debugf("Generated one-time token for proxy %s in account %s (expires in %s)", + reverseProxyID, accountID, ttl) + + return token, nil +} + +// ValidateAndConsume verifies the token against the provided accountID and +// reverseProxyID, checks expiration, and then deletes it to enforce single-use. +// +// This method uses constant-time comparison to prevent timing attacks. +// +// Returns nil on success, or an error if: +// - Token doesn't exist +// - Token has expired +// - Account ID doesn't match +// - Reverse proxy ID doesn't match +func (s *OneTimeTokenStore) ValidateAndConsume(token, accountID, reverseProxyID string) error { + s.mu.Lock() + defer s.mu.Unlock() + + metadata, exists := s.tokens[token] + if !exists { + log.Warnf("Token validation failed: token not found (proxy: %s, account: %s)", + reverseProxyID, accountID) + return fmt.Errorf("invalid token") + } + + // Check expiration + if time.Now().After(metadata.ExpiresAt) { + delete(s.tokens, token) + log.Warnf("Token validation failed: token expired (proxy: %s, account: %s)", + reverseProxyID, accountID) + return fmt.Errorf("token expired") + } + + // Validate account ID using constant-time comparison (prevents timing attacks) + if subtle.ConstantTimeCompare([]byte(metadata.AccountID), []byte(accountID)) != 1 { + log.Warnf("Token validation failed: account ID mismatch (expected: %s, got: %s)", + metadata.AccountID, accountID) + return fmt.Errorf("account ID mismatch") + } + + // Validate reverse proxy ID using constant-time comparison + if subtle.ConstantTimeCompare([]byte(metadata.ReverseProxyID), []byte(reverseProxyID)) != 1 { + log.Warnf("Token validation failed: reverse proxy ID mismatch (expected: %s, got: %s)", + metadata.ReverseProxyID, reverseProxyID) + return fmt.Errorf("reverse proxy ID mismatch") + } + + // Delete token immediately to enforce single-use + delete(s.tokens, token) + + log.Infof("Token validated and consumed for proxy %s in account %s", + reverseProxyID, accountID) + + return nil +} + +// cleanupExpired removes expired tokens in the background to prevent memory leaks +func (s *OneTimeTokenStore) cleanupExpired() { + for { + select { + case <-s.cleanup.C: + s.mu.Lock() + now := time.Now() + removed := 0 + for token, metadata := range s.tokens { + if now.After(metadata.ExpiresAt) { + delete(s.tokens, token) + removed++ + } + } + if removed > 0 { + log.Debugf("Cleaned up %d expired one-time tokens", removed) + } + s.mu.Unlock() + case <-s.cleanupDone: + return + } + } +} + +// Close stops the cleanup goroutine and releases resources +func (s *OneTimeTokenStore) Close() { + s.cleanup.Stop() + close(s.cleanupDone) +} + +// GetTokenCount returns the current number of tokens in the store (for debugging/metrics) +func (s *OneTimeTokenStore) GetTokenCount() int { + s.mu.RLock() + defer s.mu.RUnlock() + return len(s.tokens) +} diff --git a/management/internals/shared/grpc/proxy.go b/management/internals/shared/grpc/proxy.go index 51d2f8499..b770cbe66 100644 --- a/management/internals/shared/grpc/proxy.go +++ b/management/internals/shared/grpc/proxy.go @@ -2,27 +2,46 @@ package grpc import ( "context" + "crypto/hmac" + "crypto/sha256" "crypto/subtle" + "encoding/base64" + "encoding/hex" + "errors" "fmt" "net" "net/url" + "strings" "sync" "time" + "github.com/coreos/go-oidc/v3/oidc" log "github.com/sirupsen/logrus" + "golang.org/x/oauth2" "google.golang.org/grpc/codes" "google.golang.org/grpc/peer" "google.golang.org/grpc/status" - "github.com/netbirdio/netbird/management/server/activity" - + "github.com/netbirdio/netbird/management/internals/modules/peers" "github.com/netbirdio/netbird/management/internals/modules/reverseproxy" "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/accesslogs" + "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/sessionkey" "github.com/netbirdio/netbird/management/server/store" - "github.com/netbirdio/netbird/management/server/types" + proxyauth "github.com/netbirdio/netbird/proxy/auth" "github.com/netbirdio/netbird/shared/management/proto" ) +type ProxyOIDCConfig struct { + Issuer string + ClientID string + Scopes []string + CallbackURL string + HMACKey []byte + + Audience string + KeysLocation string +} + type reverseProxyStore interface { GetReverseProxies(ctx context.Context, lockStrength store.LockingStrength) ([]*reverseproxy.ReverseProxy, error) GetAccountReverseProxies(ctx context.Context, lockStrength store.LockingStrength, accountID string) ([]*reverseproxy.ReverseProxy, error) @@ -34,11 +53,6 @@ type reverseProxyManager interface { SetStatus(ctx context.Context, accountID, reverseProxyID string, status reverseproxy.ProxyStatus) error } -type keyStore interface { - GetGroupByName(ctx context.Context, groupName string, accountID string) (*types.Group, error) - CreateSetupKey(ctx context.Context, accountID string, keyName string, keyType types.SetupKeyType, expiresIn time.Duration, autoGroups []string, usageLimit int, userID string, ephemeral bool, allowExtraDNSLabels bool) (*types.SetupKey, error) -} - // ClusterInfo contains information about a proxy cluster. type ClusterInfo struct { Address string @@ -61,14 +75,23 @@ type ProxyServiceServer struct { // Store of reverse proxies reverseProxyStore reverseProxyStore - // Store for client setup keys - keyStore keyStore - // Manager for access logs accessLogManager accesslogs.Manager // Manager for reverse proxy operations reverseProxyManager reverseProxyManager + + // Manager for peers + peersManager peers.Manager + + // Store for one-time authentication tokens + tokenStore *OneTimeTokenStore + + // OIDC configuration for proxy authentication + oidcConfig ProxyOIDCConfig + + // TODO: use database to store these instead? + pkceVerifiers sync.Map } // proxyConnection represents a connected proxy @@ -83,12 +106,14 @@ type proxyConnection struct { } // NewProxyServiceServer creates a new proxy service server -func NewProxyServiceServer(store reverseProxyStore, keys keyStore, accessLogMgr accesslogs.Manager) *ProxyServiceServer { +func NewProxyServiceServer(store reverseProxyStore, accessLogMgr accesslogs.Manager, tokenStore *OneTimeTokenStore, oidcConfig ProxyOIDCConfig, peersManager peers.Manager) *ProxyServiceServer { return &ProxyServiceServer{ updatesChan: make(chan *proto.ProxyMapping, 100), reverseProxyStore: store, - keyStore: keys, accessLogManager: accessLogMgr, + oidcConfig: oidcConfig, + tokenStore: tokenStore, + peersManager: peersManager, } } @@ -180,32 +205,14 @@ func (s *ProxyServiceServer) sendSnapshot(ctx context.Context, conn *proxyConnec continue } - group, err := s.keyStore.GetGroupByName(ctx, rp.Name, rp.AccountID) + // Generate one-time authentication token for each proxy in the snapshot + // Tokens are not persistent on the proxy, so we need to generate new ones on reconnection + token, err := s.tokenStore.GenerateToken(rp.AccountID, rp.ID, 5*time.Minute) if err != nil { log.WithFields(log.Fields{ "proxy": rp.Name, "account": rp.AccountID, - }).WithError(err).Error("Failed to get group by name") - continue - } - - key, err := s.keyStore.CreateSetupKey(ctx, - rp.AccountID, - rp.Name, - types.SetupKeyReusable, - 0, - []string{group.ID}, - 0, - activity.SystemInitiator, - true, - false, - ) - if err != nil { - log.WithFields(log.Fields{ - "proxy": rp.Name, - "account": rp.AccountID, - "group": group.ID, - }).WithError(err).Error("Failed to create setup key") + }).WithError(err).Error("Failed to generate auth token for snapshot") continue } @@ -213,7 +220,8 @@ func (s *ProxyServiceServer) sendSnapshot(ctx context.Context, conn *proxyConnec Mapping: []*proto.ProxyMapping{ rp.ToProtoMapping( reverseproxy.Create, - key.Key, + token, + s.GetOIDCValidationConfig(), ), }, }); err != nil { @@ -423,7 +431,10 @@ func (s *ProxyServiceServer) Authenticate(ctx context.Context, req *proto.Authen // TODO: log the error return nil, status.Errorf(codes.FailedPrecondition, "failed to get reverse proxy from store: %v", err) } + var authenticated bool + var userId string + var method proxyauth.Method switch v := req.GetRequest().(type) { case *proto.AuthenticateRequest_Pin: auth := proxy.Auth.PinAuth @@ -433,6 +444,8 @@ func (s *ProxyServiceServer) Authenticate(ctx context.Context, req *proto.Authen break } authenticated = subtle.ConstantTimeCompare([]byte(auth.Pin), []byte(v.Pin.GetPin())) == 1 + userId = "pin-user" + method = proxyauth.MethodPIN case *proto.AuthenticateRequest_Password: auth := proxy.Auth.PasswordAuth if auth == nil || !auth.Enabled { @@ -441,9 +454,28 @@ func (s *ProxyServiceServer) Authenticate(ctx context.Context, req *proto.Authen break } authenticated = subtle.ConstantTimeCompare([]byte(auth.Password), []byte(v.Password.GetPassword())) == 1 + userId = "password-user" + method = proxyauth.MethodPassword } + + var token string + if authenticated && proxy.SessionPrivateKey != "" { + token, err = sessionkey.SignToken( + proxy.SessionPrivateKey, + userId, + proxy.Domain, + method, + proxyauth.DefaultSessionExpiry, + ) + if err != nil { + log.WithError(err).Error("Failed to sign session token") + authenticated = false + } + } + return &proto.AuthenticateResponse{ - Success: authenticated, + Success: authenticated, + SessionToken: token, }, nil } @@ -512,3 +544,198 @@ func protoStatusToInternal(protoStatus proto.ProxyStatus) reverseproxy.ProxyStat return reverseproxy.StatusError } } + +// CreateProxyPeer handles proxy peer creation with one-time token authentication +func (s *ProxyServiceServer) CreateProxyPeer(ctx context.Context, req *proto.CreateProxyPeerRequest) (*proto.CreateProxyPeerResponse, error) { + reverseProxyID := req.GetReverseProxyId() + accountID := req.GetAccountId() + token := req.GetToken() + key := req.WireguardPublicKey + + log.WithFields(log.Fields{ + "reverse_proxy_id": reverseProxyID, + "account_id": accountID, + }).Debug("CreateProxyPeer request received") + + if reverseProxyID == "" || accountID == "" || token == "" { + log.Warn("CreateProxyPeer: missing required fields") + return &proto.CreateProxyPeerResponse{ + Success: false, + ErrorMessage: strPtr("missing required fields: reverse_proxy_id, account_id, and token are required"), + }, nil + } + + if err := s.tokenStore.ValidateAndConsume(token, accountID, reverseProxyID); err != nil { + log.WithFields(log.Fields{ + "reverse_proxy_id": reverseProxyID, + "account_id": accountID, + }).WithError(err).Warn("CreateProxyPeer: token validation failed") + return &proto.CreateProxyPeerResponse{ + Success: false, + ErrorMessage: strPtr("authentication failed: invalid or expired token"), + }, status.Errorf(codes.Unauthenticated, "token validation failed: %v", err) + } + + err := s.peersManager.CreateProxyPeer(ctx, accountID, key) + if err != nil { + log.WithFields(log.Fields{ + "reverse_proxy_id": reverseProxyID, + "account_id": accountID, + }).WithError(err).Error("CreateProxyPeer: failed to create proxy peer") + return &proto.CreateProxyPeerResponse{ + Success: false, + ErrorMessage: strPtr(fmt.Sprintf("failed to create proxy peer: %v", err)), + }, status.Errorf(codes.Internal, "failed to create proxy peer: %v", err) + } + + return &proto.CreateProxyPeerResponse{ + Success: true, + }, nil +} + +// strPtr is a helper to create a string pointer for optional proto fields +func strPtr(s string) *string { + return &s +} + +func (s *ProxyServiceServer) GetOIDCURL(ctx context.Context, req *proto.GetOIDCURLRequest) (*proto.GetOIDCURLResponse, error) { + redirectURL, err := url.Parse(req.GetRedirectUrl()) + if err != nil { + // TODO: log + return nil, status.Errorf(codes.InvalidArgument, "failed to parse redirect url: %v", err) + } + // Validate redirectURL against known proxy endpoints to avoid abuse of OIDC redirection. + proxies, err := s.reverseProxyStore.GetAccountReverseProxies(ctx, store.LockingStrengthNone, req.GetAccountId()) + if err != nil { + // TODO: log + return nil, status.Errorf(codes.FailedPrecondition, "failed to get reverse proxy from store: %v", err) + } + var found bool + for _, proxy := range proxies { + if proxy.Domain == redirectURL.Hostname() { + found = true + break + } + } + if !found { + // TODO: log + return nil, status.Errorf(codes.FailedPrecondition, "reverse proxy not found in store") + } + + provider, err := oidc.NewProvider(ctx, s.oidcConfig.Issuer) + if err != nil { + // TODO: log + return nil, status.Errorf(codes.FailedPrecondition, "failed to create OIDC provider: %v", err) + } + + scopes := s.oidcConfig.Scopes + if len(scopes) == 0 { + scopes = []string{oidc.ScopeOpenID, "profile", "email"} + } + + // Using an HMAC here to avoid redirection state being modified. + // State format: base64(redirectURL)|hmac + hmacSum := s.generateHMAC(redirectURL.String()) + state := fmt.Sprintf("%s|%s", base64.URLEncoding.EncodeToString([]byte(redirectURL.String())), hmacSum) + + codeVerifier := oauth2.GenerateVerifier() + s.pkceVerifiers.Store(state, codeVerifier) + + return &proto.GetOIDCURLResponse{ + Url: (&oauth2.Config{ + ClientID: s.oidcConfig.ClientID, + Endpoint: provider.Endpoint(), + RedirectURL: s.oidcConfig.CallbackURL, + Scopes: scopes, + }).AuthCodeURL(state, oauth2.S256ChallengeOption(codeVerifier)), + }, nil +} + +// GetOIDCConfig returns the OIDC configuration for token validation. +func (s *ProxyServiceServer) GetOIDCConfig() ProxyOIDCConfig { + return s.oidcConfig +} + +// GetOIDCValidationConfig returns the OIDC configuration for token validation +// in the format needed by ToProtoMapping. +func (s *ProxyServiceServer) GetOIDCValidationConfig() reverseproxy.OIDCValidationConfig { + return reverseproxy.OIDCValidationConfig{ + Issuer: s.oidcConfig.Issuer, + Audiences: []string{s.oidcConfig.Audience}, + KeysLocation: s.oidcConfig.KeysLocation, + MaxTokenAgeSeconds: 0, // No max token age by default + } +} + +func (s *ProxyServiceServer) generateHMAC(input string) string { + mac := hmac.New(sha256.New, s.oidcConfig.HMACKey) + mac.Write([]byte(input)) + return hex.EncodeToString(mac.Sum(nil)) +} + +// ValidateState validates the state parameter from an OAuth callback. +// Returns the original redirect URL if valid, or an error if invalid. +func (s *ProxyServiceServer) ValidateState(state string) (verifier, redirectURL string, err error) { + v, ok := s.pkceVerifiers.LoadAndDelete(state) + if !ok { + return "", "", errors.New("no verifier for state") + } + verifier, ok = v.(string) + if !ok { + return "", "", errors.New("invalid verifier for state") + } + + parts := strings.Split(state, "|") + if len(parts) != 2 { + return "", "", errors.New("invalid state format") + } + + encodedURL := parts[0] + providedHMAC := parts[1] + + redirectURLBytes, err := base64.URLEncoding.DecodeString(encodedURL) + if err != nil { + return "", "", fmt.Errorf("invalid state encoding: %w", err) + } + redirectURL = string(redirectURLBytes) + + expectedHMAC := s.generateHMAC(redirectURL) + + if !hmac.Equal([]byte(providedHMAC), []byte(expectedHMAC)) { + return "", "", fmt.Errorf("invalid state signature") + } + + return verifier, redirectURL, nil +} + +// GenerateSessionToken creates a signed session JWT for the given domain and user. +func (s *ProxyServiceServer) GenerateSessionToken(ctx context.Context, domain, userID string, method proxyauth.Method) (string, error) { + // Find the proxy by domain to get its signing key + proxies, err := s.reverseProxyStore.GetReverseProxies(ctx, store.LockingStrengthNone) + if err != nil { + return "", fmt.Errorf("get reverse proxies: %w", err) + } + + var proxy *reverseproxy.ReverseProxy + for _, p := range proxies { + if p.Domain == domain { + proxy = p + break + } + } + if proxy == nil { + return "", fmt.Errorf("reverse proxy not found for domain: %s", domain) + } + + if proxy.SessionPrivateKey == "" { + return "", fmt.Errorf("no session key configured for domain: %s", domain) + } + + return sessionkey.SignToken( + proxy.SessionPrivateKey, + userID, + domain, + method, + proxyauth.DefaultSessionExpiry, + ) +} diff --git a/management/server/http/handler.go b/management/server/http/handler.go index 011055dd4..9d8dc3fe2 100644 --- a/management/server/http/handler.go +++ b/management/server/http/handler.go @@ -16,6 +16,7 @@ import ( "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/accesslogs" "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/domain" reverseproxymanager "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/manager" + nbgrpc "github.com/netbirdio/netbird/management/internals/shared/grpc" idpmanager "github.com/netbirdio/netbird/management/server/idp" "github.com/netbirdio/management-integrations/integrations" @@ -43,6 +44,7 @@ import ( "github.com/netbirdio/netbird/management/server/http/handlers/networks" "github.com/netbirdio/netbird/management/server/http/handlers/peers" "github.com/netbirdio/netbird/management/server/http/handlers/policies" + "github.com/netbirdio/netbird/management/server/http/handlers/proxy" "github.com/netbirdio/netbird/management/server/http/handlers/routes" "github.com/netbirdio/netbird/management/server/http/handlers/setup_keys" "github.com/netbirdio/netbird/management/server/http/handlers/users" @@ -64,7 +66,7 @@ const ( ) // NewAPIHandler creates the Management service HTTP API handler registering all the available endpoints. -func NewAPIHandler(ctx context.Context, accountManager account.Manager, networksManager nbnetworks.Manager, resourceManager resources.Manager, routerManager routers.Manager, groupsManager nbgroups.Manager, LocationManager geolocation.Geolocation, authManager auth.Manager, appMetrics telemetry.AppMetrics, integratedValidator integrated_validator.IntegratedValidator, proxyController port_forwarding.Controller, permissionsManager permissions.Manager, peersManager nbpeers.Manager, settingsManager settings.Manager, zManager zones.Manager, rManager records.Manager, networkMapController network_map.Controller, idpManager idpmanager.Manager, reverseProxyManager reverseproxy.Manager, reverseProxyDomainManager *domain.Manager, reverseProxyAccessLogsManager accesslogs.Manager) (http.Handler, error) { +func NewAPIHandler(ctx context.Context, accountManager account.Manager, networksManager nbnetworks.Manager, resourceManager resources.Manager, routerManager routers.Manager, groupsManager nbgroups.Manager, LocationManager geolocation.Geolocation, authManager auth.Manager, appMetrics telemetry.AppMetrics, integratedValidator integrated_validator.IntegratedValidator, proxyController port_forwarding.Controller, permissionsManager permissions.Manager, peersManager nbpeers.Manager, settingsManager settings.Manager, zManager zones.Manager, rManager records.Manager, networkMapController network_map.Controller, idpManager idpmanager.Manager, reverseProxyManager reverseproxy.Manager, reverseProxyDomainManager *domain.Manager, reverseProxyAccessLogsManager accesslogs.Manager, proxyGRPCServer *nbgrpc.ProxyServiceServer) (http.Handler, error) { // Register bypass paths for unauthenticated endpoints if err := bypass.AddBypassPath("/api/instance"); err != nil { @@ -80,6 +82,10 @@ func NewAPIHandler(ctx context.Context, accountManager account.Manager, networks if err := bypass.AddBypassPath("/api/users/invites/nbi_*/accept"); err != nil { return nil, fmt.Errorf("failed to add bypass path: %w", err) } + // OAuth callback for proxy authentication + if err := bypass.AddBypassPath("/api/oauth/callback"); err != nil { + return nil, fmt.Errorf("failed to add bypass path: %w", err) + } var rateLimitingConfig *middleware.RateLimiterConfig if os.Getenv(rateLimitingEnabledKey) == "true" { @@ -164,6 +170,12 @@ func NewAPIHandler(ctx context.Context, accountManager account.Manager, networks reverseproxymanager.RegisterEndpoints(reverseProxyManager, *reverseProxyDomainManager, reverseProxyAccessLogsManager, router) } + // Register OAuth callback handler for proxy authentication + if proxyGRPCServer != nil { + oauthHandler := proxy.NewAuthCallbackHandler(proxyGRPCServer) + oauthHandler.RegisterEndpoints(router) + } + // Mount embedded IdP handler at /oauth2 path if configured if embeddedIdpEnabled { rootRouter.PathPrefix("/oauth2").Handler(corsMiddleware.Handler(embeddedIdP.Handler())) diff --git a/management/server/http/handlers/peers/peers_handler.go b/management/server/http/handlers/peers/peers_handler.go index 53d8ab055..976ef1f64 100644 --- a/management/server/http/handlers/peers/peers_handler.go +++ b/management/server/http/handlers/peers/peers_handler.go @@ -395,7 +395,7 @@ func (h *Handler) GetAccessiblePeers(w http.ResponseWriter, r *http.Request) { dnsDomain := h.networkMapController.GetDNSDomain(account.Settings) - netMap := account.GetPeerNetworkMap(r.Context(), peerID, dns.CustomZone{}, nil, validPeers, account.GetResourcePoliciesMap(), account.GetResourceRoutersMap(), nil, account.GetActiveGroupUsers()) + netMap := account.GetPeerNetworkMap(r.Context(), peerID, dns.CustomZone{}, nil, validPeers, account.GetResourcePoliciesMap(), account.GetResourceRoutersMap(), nil, account.GetActiveGroupUsers(), account.GetExposedServicesMap(), account.GetProxyPeers()) util.WriteJSONObject(r.Context(), w, toAccessiblePeers(netMap, dnsDomain)) } diff --git a/management/server/http/handlers/proxy/auth.go b/management/server/http/handlers/proxy/auth.go new file mode 100644 index 000000000..17ed1772a --- /dev/null +++ b/management/server/http/handlers/proxy/auth.go @@ -0,0 +1,143 @@ +package proxy + +import ( + "context" + "net/http" + "net/url" + + "github.com/coreos/go-oidc/v3/oidc" + "github.com/gorilla/mux" + log "github.com/sirupsen/logrus" + "golang.org/x/oauth2" + + nbgrpc "github.com/netbirdio/netbird/management/internals/shared/grpc" + "github.com/netbirdio/netbird/proxy/auth" +) + +type AuthCallbackHandler struct { + proxyService *nbgrpc.ProxyServiceServer +} + +func NewAuthCallbackHandler(proxyService *nbgrpc.ProxyServiceServer) *AuthCallbackHandler { + return &AuthCallbackHandler{ + proxyService: proxyService, + } +} + +func (h *AuthCallbackHandler) RegisterEndpoints(router *mux.Router) { + router.HandleFunc("/oauth/callback", h.handleCallback).Methods(http.MethodGet) +} + +func (h *AuthCallbackHandler) handleCallback(w http.ResponseWriter, r *http.Request) { + state := r.URL.Query().Get("state") + + codeVerifier, originalURL, err := h.proxyService.ValidateState(state) + if err != nil { + log.WithError(err).Error("OAuth callback state validation failed") + http.Error(w, "Invalid state parameter", http.StatusBadRequest) + return + } + + redirectURL, err := url.Parse(originalURL) + if err != nil { + log.WithError(err).Error("Failed to parse redirect URL") + http.Error(w, "Invalid redirect URL", http.StatusBadRequest) + return + } + + // Get OIDC configuration + oidcConfig := h.proxyService.GetOIDCConfig() + + // Create OIDC provider to discover endpoints + provider, err := oidc.NewProvider(r.Context(), oidcConfig.Issuer) + if err != nil { + log.WithError(err).Error("Failed to create OIDC provider") + http.Error(w, "Failed to create OIDC provider", http.StatusInternalServerError) + return + } + + token, err := (&oauth2.Config{ + ClientID: oidcConfig.ClientID, + Endpoint: provider.Endpoint(), + RedirectURL: oidcConfig.CallbackURL, + }).Exchange(r.Context(), r.URL.Query().Get("code"), oauth2.VerifierOption(codeVerifier)) + if err != nil { + log.WithError(err).Error("Failed to exchange code for token") + http.Error(w, "Failed to exchange code for token", http.StatusInternalServerError) + return + } + + // Extract user ID from the OIDC token + userID := extractUserIDFromToken(r.Context(), provider, oidcConfig, token) + if userID == "" { + log.Error("Failed to extract user ID from OIDC token") + http.Error(w, "Failed to validate token", http.StatusUnauthorized) + return + } + + // Generate session JWT instead of passing OIDC access_token + sessionToken, err := h.proxyService.GenerateSessionToken(r.Context(), redirectURL.Hostname(), userID, auth.MethodOIDC) + if err != nil { + log.WithError(err).Error("Failed to create session token") + http.Error(w, "Failed to create session", http.StatusInternalServerError) + return + } + + // Redirect must be HTTPS, regardless of what was originally intended (which should always be HTTPS but better to double-check here). + redirectURL.Scheme = "https" + + // Pass the session token in the URL query parameter. The proxy middleware will + // extract it, validate it, set its own cookie, and redirect to remove the token from the URL. + // We cannot set the cookie here because cookies are domain-scoped (RFC 6265) and the + // management server cannot set cookies for the proxy's domain. + query := redirectURL.Query() + query.Set("session_token", sessionToken) + redirectURL.RawQuery = query.Encode() + + log.WithField("redirect", redirectURL.Host).Debug("OAuth callback: redirecting user with session token") + http.Redirect(w, r, redirectURL.String(), http.StatusFound) +} + +// extractUserIDFromToken extracts the user ID from an OIDC token. +func extractUserIDFromToken(ctx context.Context, provider *oidc.Provider, config nbgrpc.ProxyOIDCConfig, token *oauth2.Token) string { + // Try to get ID token from the oauth2 token extras + rawIDToken, ok := token.Extra("id_token").(string) + if !ok { + log.Warn("No id_token in OIDC response") + return "" + } + + verifier := provider.Verifier(&oidc.Config{ + ClientID: config.ClientID, + }) + + idToken, err := verifier.Verify(ctx, rawIDToken) + if err != nil { + log.WithError(err).Warn("Failed to verify ID token") + return "" + } + + // Extract claims + var claims struct { + Subject string `json:"sub"` + Email string `json:"email"` + UserID string `json:"user_id"` + } + if err := idToken.Claims(&claims); err != nil { + log.WithError(err).Warn("Failed to extract claims from ID token") + return "" + } + + // Prefer subject, fall back to user_id or email + if claims.Subject != "" { + return claims.Subject + } + if claims.UserID != "" { + return claims.UserID + } + if claims.Email != "" { + return claims.Email + } + + return "" +} diff --git a/management/server/http/testing/testing_tools/channel/channel.go b/management/server/http/testing/testing_tools/channel/channel.go index 315d1222f..bdc68e85f 100644 --- a/management/server/http/testing/testing_tools/channel/channel.go +++ b/management/server/http/testing/testing_tools/channel/channel.go @@ -102,7 +102,7 @@ func BuildApiBlackBoxWithDBState(t testing_tools.TB, sqlFile string, expectedPee customZonesManager := zonesManager.NewManager(store, am, permissionsManager, "") zoneRecordsManager := recordsManager.NewManager(store, am, permissionsManager) - apiHandler, err := http2.NewAPIHandler(context.Background(), am, networksManagerMock, resourcesManagerMock, routersManagerMock, groupsManagerMock, geoMock, authManagerMock, metrics, validatorMock, proxyController, permissionsManager, peersManager, settingsManager, customZonesManager, zoneRecordsManager, networkMapController, nil, nil, nil, nil) + apiHandler, err := http2.NewAPIHandler(context.Background(), am, networksManagerMock, resourcesManagerMock, routersManagerMock, groupsManagerMock, geoMock, authManagerMock, metrics, validatorMock, proxyController, permissionsManager, peersManager, settingsManager, customZonesManager, zoneRecordsManager, networkMapController, nil, nil, nil, nil, nil) if err != nil { t.Fatalf("Failed to create API handler: %v", err) } diff --git a/management/server/idp/embedded.go b/management/server/idp/embedded.go index 1b9d04061..e03ef0c27 100644 --- a/management/server/idp/embedded.go +++ b/management/server/idp/embedded.go @@ -18,7 +18,6 @@ import ( const ( staticClientDashboard = "netbird-dashboard" staticClientCLI = "netbird-cli" - staticClientProxy = "netbird-proxy" defaultCLIRedirectURL1 = "http://localhost:53000/" defaultCLIRedirectURL2 = "http://localhost:54000/" defaultScopes = "openid profile email groups" @@ -38,10 +37,8 @@ type EmbeddedIdPConfig struct { Storage EmbeddedStorageConfig // DashboardRedirectURIs are the OAuth2 redirect URIs for the dashboard client DashboardRedirectURIs []string - // CLIRedirectURIs are the OAuth2 redirect URIs for the CLI client + // DashboardRedirectURIs are the OAuth2 redirect URIs for the dashboard client CLIRedirectURIs []string - // ProxyRedirectURIs are the OAuth2 redirect URIs for the Proxy client - ProxyRedirectURIs []string // Owner is the initial owner/admin user (optional, can be nil) Owner *OwnerConfig // SignKeyRefreshEnabled enables automatic key rotation for signing keys @@ -89,6 +86,11 @@ func (c *EmbeddedIdPConfig) ToYAMLConfig() (*dex.YAMLConfig, error) { cliRedirectURIs = append(cliRedirectURIs, "/device/callback") cliRedirectURIs = append(cliRedirectURIs, c.Issuer+"/device/callback") + // Build dashboard redirect URIs including the OAuth callback for proxy authentication + dashboardRedirectURIs := c.DashboardRedirectURIs + baseURL := strings.TrimSuffix(c.Issuer, "/oauth2") + dashboardRedirectURIs = append(dashboardRedirectURIs, baseURL+"/api/oauth/callback") + cfg := &dex.YAMLConfig{ Issuer: c.Issuer, Storage: dex.Storage{ @@ -114,7 +116,7 @@ func (c *EmbeddedIdPConfig) ToYAMLConfig() (*dex.YAMLConfig, error) { ID: staticClientDashboard, Name: "NetBird Dashboard", Public: true, - RedirectURIs: c.DashboardRedirectURIs, + RedirectURIs: dashboardRedirectURIs, }, { ID: staticClientCLI, @@ -122,12 +124,6 @@ func (c *EmbeddedIdPConfig) ToYAMLConfig() (*dex.YAMLConfig, error) { Public: true, RedirectURIs: cliRedirectURIs, }, - { - ID: staticClientProxy, - Name: "NetBird Proxy", - Public: true, - RedirectURIs: c.ProxyRedirectURIs, - }, }, } @@ -555,7 +551,7 @@ func (m *EmbeddedIdPManager) GetLocalKeysLocation() string { // GetClientIDs returns the OAuth2 client IDs configured for this provider. func (m *EmbeddedIdPManager) GetClientIDs() []string { - return []string{staticClientDashboard, staticClientCLI, staticClientProxy} + return []string{staticClientDashboard, staticClientCLI} } // GetUserIDClaim returns the JWT claim name used for user identification. diff --git a/management/server/peer.go b/management/server/peer.go index ab72d3051..e056f871a 100644 --- a/management/server/peer.go +++ b/management/server/peer.go @@ -545,7 +545,7 @@ func (am *DefaultAccountManager) GetPeerNetwork(ctx context.Context, peerID stri // Each new Peer will be assigned a new next net.IP from the Account.Network and Account.Network.LastIP will be updated (IP's are not reused). // The peer property is just a placeholder for the Peer properties to pass further func (am *DefaultAccountManager) AddPeer(ctx context.Context, accountID, setupKey, userID string, peer *nbpeer.Peer, temporary bool) (*nbpeer.Peer, *types.NetworkMap, []*posture.Checks, error) { - if setupKey == "" && userID == "" { + if setupKey == "" && userID == "" && !peer.ProxyEmbedded { // no auth method provided => reject access return nil, nil, nil, status.Errorf(status.Unauthenticated, "no peer auth method provided, please use a setup key or interactive SSO login") } @@ -554,6 +554,7 @@ func (am *DefaultAccountManager) AddPeer(ctx context.Context, accountID, setupKe hashedKey := sha256.Sum256([]byte(upperKey)) encodedHashedKey := b64.StdEncoding.EncodeToString(hashedKey[:]) addedByUser := len(userID) > 0 + addedBySetupKey := len(setupKey) > 0 // This is a handling for the case when the same machine (with the same WireGuard pub key) tries to register twice. // Such case is possible when AddPeer function takes long time to finish after AcquireWriteLockByUID (e.g., database is slow) @@ -576,7 +577,8 @@ func (am *DefaultAccountManager) AddPeer(ctx context.Context, accountID, setupKe var ephemeral bool var groupsToAdd []string var allowExtraDNSLabels bool - if addedByUser { + switch { + case addedByUser: user, err := am.Store.GetUserByUserID(ctx, store.LockingStrengthNone, userID) if err != nil { return nil, nil, nil, status.Errorf(status.NotFound, "failed adding new peer: user not found") @@ -599,7 +601,7 @@ func (am *DefaultAccountManager) AddPeer(ctx context.Context, accountID, setupKe } opEvent.InitiatorID = userID opEvent.Activity = activity.PeerAddedByUser - } else { + case addedBySetupKey: // Validate the setup key sk, err := am.Store.GetSetupKeyBySecret(ctx, store.LockingStrengthNone, encodedHashedKey) if err != nil { @@ -622,6 +624,12 @@ func (am *DefaultAccountManager) AddPeer(ctx context.Context, accountID, setupKe if !sk.AllowExtraDNSLabels && len(peer.ExtraDNSLabels) > 0 { return nil, nil, nil, status.Errorf(status.PreconditionFailed, "couldn't add peer: setup key doesn't allow extra DNS labels") } + default: + if peer.ProxyEmbedded { + log.WithContext(ctx).Debugf("adding peer for proxy embedded, accountID: %s", accountID) + } else { + log.WithContext(ctx).Warnf("adding peer without setup key or userID, accountID: %s", accountID) + } } opEvent.AccountID = accountID @@ -657,6 +665,7 @@ func (am *DefaultAccountManager) AddPeer(ctx context.Context, accountID, setupKe CreatedAt: registrationTime, LoginExpirationEnabled: addedByUser && !temporary, Ephemeral: ephemeral, + ProxyEmbedded: peer.ProxyEmbedded, Location: peer.Location, InactivityExpirationEnabled: addedByUser && !temporary, ExtraDNSLabels: peer.ExtraDNSLabels, @@ -728,12 +737,13 @@ func (am *DefaultAccountManager) AddPeer(ctx context.Context, accountID, setupKe return fmt.Errorf("failed adding peer to All group: %w", err) } - if addedByUser { + switch { + case addedByUser: err := transaction.SaveUserLastLogin(ctx, accountID, userID, newPeer.GetLastLogin()) if err != nil { log.WithContext(ctx).Debugf("failed to update user last login: %v", err) } - } else { + case addedBySetupKey: sk, err := transaction.GetSetupKeyBySecret(ctx, store.LockingStrengthUpdate, encodedHashedKey) if err != nil { return fmt.Errorf("failed to get setup key: %w", err) diff --git a/management/server/peer/peer.go b/management/server/peer/peer.go index 2439e8a22..48157476f 100644 --- a/management/server/peer/peer.go +++ b/management/server/peer/peer.go @@ -48,6 +48,8 @@ type Peer struct { CreatedAt time.Time // Indicate ephemeral peer attribute Ephemeral bool `gorm:"index"` + // ProxyEmbedded indicates whether the peer is embedded in a reverse proxy + ProxyEmbedded bool `gorm:"index"` // Geo location based on connection IP Location Location `gorm:"embedded;embeddedPrefix:location_"` @@ -224,6 +226,7 @@ func (p *Peer) Copy() *Peer { LastLogin: p.LastLogin, CreatedAt: p.CreatedAt, Ephemeral: p.Ephemeral, + ProxyEmbedded: p.ProxyEmbedded, Location: p.Location, InactivityExpirationEnabled: p.InactivityExpirationEnabled, ExtraDNSLabels: slices.Clone(p.ExtraDNSLabels), diff --git a/management/server/store/sql_store.go b/management/server/store/sql_store.go index d84abe3cf..0dadb6921 100644 --- a/management/server/store/sql_store.go +++ b/management/server/store/sql_store.go @@ -1099,6 +1099,7 @@ func (s *SqlStore) getAccountGorm(ctx context.Context, accountID string) (*types Preload("NetworkRouters"). Preload("NetworkResources"). Preload("Onboarding"). + Preload("ReverseProxies"). Take(&account, idQueryCondition, accountID) if result.Error != nil { log.WithContext(ctx).Errorf("error when getting account %s from the store: %s", accountID, result.Error) @@ -1276,6 +1277,17 @@ func (s *SqlStore) getAccountPgx(ctx context.Context, accountID string) (*types. account.PostureChecks = checks }() + wg.Add(1) + go func() { + defer wg.Done() + proxies, err := s.getProxies(ctx, accountID) + if err != nil { + errChan <- err + return + } + account.ReverseProxies = proxies + }() + wg.Add(1) go func() { defer wg.Done() @@ -1677,7 +1689,7 @@ func (s *SqlStore) getPeers(ctx context.Context, accountID string) ([]nbpeer.Pee meta_kernel_version, meta_network_addresses, meta_system_serial_number, meta_system_product_name, meta_system_manufacturer, meta_environment, meta_flags, meta_files, peer_status_last_seen, peer_status_connected, peer_status_login_expired, peer_status_requires_approval, location_connection_ip, location_country_code, location_city_name, - location_geo_name_id FROM peers WHERE account_id = $1` + location_geo_name_id, proxy_embedded FROM peers WHERE account_id = $1` rows, err := s.pool.Query(ctx, query, accountID) if err != nil { return nil, err @@ -1690,7 +1702,7 @@ func (s *SqlStore) getPeers(ctx context.Context, accountID string) ([]nbpeer.Pee lastLogin, createdAt sql.NullTime sshEnabled, loginExpirationEnabled, inactivityExpirationEnabled, ephemeral, allowExtraDNSLabels sql.NullBool peerStatusLastSeen sql.NullTime - peerStatusConnected, peerStatusLoginExpired, peerStatusRequiresApproval sql.NullBool + peerStatusConnected, peerStatusLoginExpired, peerStatusRequiresApproval, proxyEmbedded sql.NullBool ip, extraDNS, netAddr, env, flags, files, connIP []byte metaHostname, metaGoOS, metaKernel, metaCore, metaPlatform sql.NullString metaOS, metaOSVersion, metaWtVersion, metaUIVersion, metaKernelVersion sql.NullString @@ -1705,7 +1717,7 @@ func (s *SqlStore) getPeers(ctx context.Context, accountID string) ([]nbpeer.Pee &metaOS, &metaOSVersion, &metaWtVersion, &metaUIVersion, &metaKernelVersion, &netAddr, &metaSystemSerialNumber, &metaSystemProductName, &metaSystemManufacturer, &env, &flags, &files, &peerStatusLastSeen, &peerStatusConnected, &peerStatusLoginExpired, &peerStatusRequiresApproval, &connIP, - &locationCountryCode, &locationCityName, &locationGeoNameID) + &locationCountryCode, &locationCityName, &locationGeoNameID, &proxyEmbedded) if err == nil { if lastLogin.Valid { @@ -1789,6 +1801,9 @@ func (s *SqlStore) getPeers(ctx context.Context, accountID string) ([]nbpeer.Pee if locationGeoNameID.Valid { p.Location.GeoNameID = uint(locationGeoNameID.Int64) } + if proxyEmbedded.Valid { + p.ProxyEmbedded = proxyEmbedded.Bool + } if ip != nil { _ = json.Unmarshal(ip, &p.IP) } @@ -2044,6 +2059,65 @@ func (s *SqlStore) getPostureChecks(ctx context.Context, accountID string) ([]*p return checks, nil } +func (s *SqlStore) getProxies(ctx context.Context, accountID string) ([]*reverseproxy.ReverseProxy, error) { + const query = `SELECT id, account_id, name, domain, targets, enabled, auth, + meta_created_at, meta_certificate_issued_at, meta_status + FROM reverse_proxies WHERE account_id = $1` + rows, err := s.pool.Query(ctx, query, accountID) + if err != nil { + return nil, err + } + proxies, err := pgx.CollectRows(rows, func(row pgx.CollectableRow) (*reverseproxy.ReverseProxy, error) { + var p reverseproxy.ReverseProxy + var auth []byte + var targets []byte + var createdAt, certIssuedAt sql.NullTime + var status sql.NullString + err := row.Scan( + &p.ID, + &p.AccountID, + &p.Name, + &p.Domain, + &targets, + &p.Enabled, + &auth, + &createdAt, + &certIssuedAt, + &status, + ) + if err != nil { + return nil, err + } + // Unmarshal JSON fields + if auth != nil { + if err := json.Unmarshal(auth, &p.Auth); err != nil { + return nil, err + } + } + if targets != nil { + if err := json.Unmarshal(targets, &p.Targets); err != nil { + return nil, err + } + } + p.Meta = reverseproxy.ReverseProxyMeta{} + if createdAt.Valid { + p.Meta.CreatedAt = createdAt.Time + } + if certIssuedAt.Valid { + p.Meta.CertificateIssuedAt = certIssuedAt.Time + } + if status.Valid { + p.Meta.Status = status.String + } + + return &p, nil + }) + if err != nil { + return nil, err + } + return proxies, nil +} + func (s *SqlStore) getNetworks(ctx context.Context, accountID string) ([]*networkTypes.Network, error) { const query = `SELECT id, account_id, name, description FROM networks WHERE account_id = $1` rows, err := s.pool.Query(ctx, query, accountID) @@ -4609,7 +4683,11 @@ func (s *SqlStore) GetPeerIDByKey(ctx context.Context, lockStrength LockingStren } func (s *SqlStore) CreateReverseProxy(ctx context.Context, proxy *reverseproxy.ReverseProxy) error { - result := s.db.Create(proxy) + proxyCopy := proxy.Copy() + if err := proxyCopy.EncryptSensitiveData(s.fieldEncrypt); err != nil { + return fmt.Errorf("encrypt reverse proxy data: %w", err) + } + result := s.db.Create(proxyCopy) if result.Error != nil { log.WithContext(ctx).Errorf("failed to create reverse proxy to store: %v", result.Error) return status.Errorf(status.Internal, "failed to create reverse proxy to store") @@ -4619,7 +4697,11 @@ func (s *SqlStore) CreateReverseProxy(ctx context.Context, proxy *reverseproxy.R } func (s *SqlStore) UpdateReverseProxy(ctx context.Context, proxy *reverseproxy.ReverseProxy) error { - result := s.db.Select("*").Save(proxy) + proxyCopy := proxy.Copy() + if err := proxyCopy.EncryptSensitiveData(s.fieldEncrypt); err != nil { + return fmt.Errorf("encrypt reverse proxy data: %w", err) + } + result := s.db.Select("*").Save(proxyCopy) if result.Error != nil { log.WithContext(ctx).Errorf("failed to update reverse proxy to store: %v", result.Error) return status.Errorf(status.Internal, "failed to update reverse proxy to store") @@ -4659,6 +4741,10 @@ func (s *SqlStore) GetReverseProxyByID(ctx context.Context, lockStrength Locking return nil, status.Errorf(status.Internal, "failed to get reverse proxy from store") } + if err := proxy.DecryptSensitiveData(s.fieldEncrypt); err != nil { + return nil, fmt.Errorf("decrypt reverse proxy data: %w", err) + } + return proxy, nil } @@ -4674,6 +4760,10 @@ func (s *SqlStore) GetReverseProxyByDomain(ctx context.Context, accountID, domai return nil, status.Errorf(status.Internal, "failed to get reverse proxy by domain from store") } + if err := proxy.DecryptSensitiveData(s.fieldEncrypt); err != nil { + return nil, fmt.Errorf("decrypt reverse proxy data: %w", err) + } + return proxy, nil } @@ -4690,6 +4780,12 @@ func (s *SqlStore) GetReverseProxies(ctx context.Context, lockStrength LockingSt return nil, status.Errorf(status.Internal, "failed to get reverse proxy from store") } + for _, proxy := range proxyList { + if err := proxy.DecryptSensitiveData(s.fieldEncrypt); err != nil { + return nil, fmt.Errorf("decrypt reverse proxy data: %w", err) + } + } + return proxyList, nil } @@ -4706,6 +4802,12 @@ func (s *SqlStore) GetAccountReverseProxies(ctx context.Context, lockStrength Lo return nil, status.Errorf(status.Internal, "failed to get reverse proxy from store") } + for _, proxy := range proxyList { + if err := proxy.DecryptSensitiveData(s.fieldEncrypt); err != nil { + return nil, fmt.Errorf("decrypt reverse proxy data: %w", err) + } + } + return proxyList, nil } diff --git a/management/server/types/account.go b/management/server/types/account.go index 959173ab8..5ad7bdab9 100644 --- a/management/server/types/account.go +++ b/management/server/types/account.go @@ -18,6 +18,7 @@ import ( "github.com/netbirdio/netbird/client/ssh/auth" nbdns "github.com/netbirdio/netbird/dns" + "github.com/netbirdio/netbird/management/internals/modules/reverseproxy" "github.com/netbirdio/netbird/management/internals/modules/zones" "github.com/netbirdio/netbird/management/internals/modules/zones/records" resourceTypes "github.com/netbirdio/netbird/management/server/networks/resources/types" @@ -99,6 +100,7 @@ type Account struct { NameServerGroupsG []nbdns.NameServerGroup `json:"-" gorm:"foreignKey:AccountID;references:id"` DNSSettings DNSSettings `gorm:"embedded;embeddedPrefix:dns_settings_"` PostureChecks []*posture.Checks `gorm:"foreignKey:AccountID;references:id"` + ReverseProxies []*reverseproxy.ReverseProxy `gorm:"foreignKey:AccountID;references:id"` // Settings is a dictionary of Account settings Settings *Settings `gorm:"embedded;embeddedPrefix:settings_"` Networks []*networkTypes.Network `gorm:"foreignKey:AccountID;references:id"` @@ -283,6 +285,8 @@ func (a *Account) GetPeerNetworkMap( routers map[string]map[string]*routerTypes.NetworkRouter, metrics *telemetry.AccountManagerMetrics, groupIDToUserIDs map[string][]string, + exposedServices map[string][]*reverseproxy.ReverseProxy, // routerPeer -> list of exposed services + proxyPeers []*nbpeer.Peer, ) *NetworkMap { start := time.Now() peer := a.Peers[peerID] @@ -300,10 +304,21 @@ func (a *Account) GetPeerNetworkMap( peerGroups := a.GetPeerGroups(peerID) - aclPeers, firewallRules, authorizedUsers, enableSSH := a.GetPeerConnectionResources(ctx, peer, validatedPeersMap, groupIDToUserIDs) + var aclPeers []*nbpeer.Peer + var firewallRules []*FirewallRule + var authorizedUsers map[string]map[string]struct{} + var enableSSH bool + if peer.ProxyEmbedded { + aclPeers, firewallRules = a.GetProxyConnectionResources(exposedServices) + } else { + aclPeers, firewallRules, authorizedUsers, enableSSH = a.GetPeerConnectionResources(ctx, peer, validatedPeersMap, groupIDToUserIDs) + proxyAclPeers, proxyFirewallRules := a.GetPeerProxyResources(exposedServices[peerID], proxyPeers) + aclPeers = append(aclPeers, proxyAclPeers...) + firewallRules = append(firewallRules, proxyFirewallRules...) + } + + var peersToConnect, expiredPeers []*nbpeer.Peer // exclude expired peers - var peersToConnect []*nbpeer.Peer - var expiredPeers []*nbpeer.Peer for _, p := range aclPeers { expired, _ := p.LoginExpired(a.Settings.PeerLoginExpiration) if a.Settings.PeerLoginExpirationEnabled && expired { @@ -372,6 +387,74 @@ func (a *Account) GetPeerNetworkMap( return nm } +func (a *Account) GetProxyConnectionResources(exposedServices map[string][]*reverseproxy.ReverseProxy) ([]*nbpeer.Peer, []*FirewallRule) { + var aclPeers []*nbpeer.Peer + var firewallRules []*FirewallRule + + for _, peerServices := range exposedServices { + for _, service := range peerServices { + if !service.Enabled { + continue + } + for _, target := range service.Targets { + if !target.Enabled { + continue + } + switch target.TargetType { + case reverseproxy.TargetTypePeer: + tpeer := a.GetPeer(target.TargetId) + if tpeer == nil { + continue + } + aclPeers = append(aclPeers, tpeer) + firewallRules = append(firewallRules, &FirewallRule{ + PolicyID: "proxy-" + service.ID, + PeerIP: tpeer.IP.String(), + Direction: FirewallRuleDirectionOUT, + Action: "allow", + Protocol: string(PolicyRuleProtocolTCP), + PortRange: RulePortRange{Start: uint16(target.Port), End: uint16(target.Port)}, + }) + case reverseproxy.TargetTypeResource: + // TODO: handle resource type targets + } + } + } + } + + return aclPeers, firewallRules +} + +func (a *Account) GetPeerProxyResources(services []*reverseproxy.ReverseProxy, proxyPeers []*nbpeer.Peer) ([]*nbpeer.Peer, []*FirewallRule) { + var aclPeers []*nbpeer.Peer + var firewallRules []*FirewallRule + + for _, service := range services { + if !service.Enabled { + continue + } + for _, target := range service.Targets { + if !target.Enabled { + continue + } + aclPeers = proxyPeers + for _, peer := range aclPeers { + firewallRules = append(firewallRules, &FirewallRule{ + PolicyID: "proxy-" + service.ID, + PeerIP: peer.IP.String(), + Direction: FirewallRuleDirectionIN, + Action: "allow", + Protocol: string(PolicyRuleProtocolTCP), + PortRange: RulePortRange{Start: uint16(target.Port), End: uint16(target.Port)}, + }) + } + // TODO: handle routes + } + } + + return aclPeers, firewallRules +} + func (a *Account) addNetworksRoutingPeers( networkResourcesRoutes []*route.Route, peer *nbpeer.Peer, @@ -1215,7 +1298,7 @@ func (a *Account) getAllPeersFromGroups(ctx context.Context, groups []string, pe filteredPeers := make([]*nbpeer.Peer, 0, len(uniquePeerIDs)) for _, p := range uniquePeerIDs { peer, ok := a.Peers[p] - if !ok || peer == nil { + if !ok || peer == nil || peer.ProxyEmbedded { continue } @@ -1242,7 +1325,7 @@ func (a *Account) getAllPeersFromGroups(ctx context.Context, groups []string, pe func (a *Account) getPeerFromResource(resource Resource, peerID string) ([]*nbpeer.Peer, bool) { peer := a.GetPeer(resource.ID) - if peer == nil { + if peer == nil || peer.ProxyEmbedded { return []*nbpeer.Peer{}, false } @@ -1778,6 +1861,40 @@ func (a *Account) GetActiveGroupUsers() map[string][]string { return groups } +func (a *Account) GetProxyPeers() []*nbpeer.Peer { + var proxyPeers []*nbpeer.Peer + for _, peer := range a.Peers { + if peer.ProxyEmbedded { + proxyPeers = append(proxyPeers, peer) + } + } + return proxyPeers +} + +func (a *Account) GetExposedServicesMap() map[string][]*reverseproxy.ReverseProxy { + services := make(map[string][]*reverseproxy.ReverseProxy) + resourcesMap := make(map[string]*resourceTypes.NetworkResource) + for _, resource := range a.NetworkResources { + resourcesMap[resource.ID] = resource + } + routersMap := a.GetResourceRoutersMap() + for _, proxy := range a.ReverseProxies { + for _, target := range proxy.Targets { + switch target.TargetType { + case reverseproxy.TargetTypePeer: + services[target.TargetId] = append(services[target.TargetId], proxy) + case reverseproxy.TargetTypeResource: + resource := resourcesMap[target.TargetId] + routers := routersMap[resource.NetworkID] + for peerID := range routers { + services[peerID] = append(services[peerID], proxy) + } + } + } + } + return services +} + // expandPortsAndRanges expands Ports and PortRanges of a rule into individual firewall rules func expandPortsAndRanges(base FirewallRule, rule *PolicyRule, peer *nbpeer.Peer) []*FirewallRule { features := peerSupportedFirewallFeatures(peer.Meta.WtVersion) diff --git a/proxy/Dockerfile b/proxy/Dockerfile index da5182ad1..543e45f35 100644 --- a/proxy/Dockerfile +++ b/proxy/Dockerfile @@ -1,5 +1,25 @@ -FROM ubuntu:24.04 -RUN apt update && apt install -y ca-certificates && rm -fr /var/cache/apt -ENTRYPOINT [ "/go/bin/netbird-proxy"] -CMD [] -COPY netbird-proxy /go/bin/netbird-proxy +FROM golang:1.25-alpine AS builder +WORKDIR /app + +COPY go.mod go.sum ./ +RUN go mod download + +COPY . . +RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o netbird-proxy ./proxy/cmd/proxy + +RUN echo "netbird:x:1000:1000:netbird:/var/lib/netbird:/sbin/nologin" > /tmp/passwd && \ + echo "netbird:x:1000:netbird" > /tmp/group && \ + mkdir -p /tmp/var/lib/netbird && \ + mkdir -p /tmp/cert + +FROM gcr.io/distroless/base:debug +COPY --from=builder /app/netbird-proxy /usr/bin/netbird-proxy +COPY --from=builder /tmp/passwd /etc/passwd +COPY --from=builder /tmp/group /etc/group +COPY --from=builder --chown=1000:1000 /tmp/var/lib/netbird /var/lib/netbird +COPY --from=builder --chown=1000:1000 /tmp/cert /cert +USER netbird:netbird +ENV HOME=/var/lib/netbird +ENV NB_PROXY_ADDRESS=":8443" +EXPOSE 8443 +ENTRYPOINT ["/usr/bin/netbird-proxy"] diff --git a/proxy/README.md b/proxy/README.md index 9cd131340..e458c34b0 100644 --- a/proxy/README.md +++ b/proxy/README.md @@ -14,14 +14,18 @@ Proxy Authentication methods supported are: - Simple PIN - HTTP Basic Auth Username and Password -## Management Connection +## Management Connection and Authentication The Proxy communicates with the Management server over a gRPC connection. Proxies act as clients to the Management server, the following RPCs are used: - Server-side streaming for proxied service updates. - Client-side streaming for proxy logs. -## Authentication +To authenticate with the Management server, the proxy server uses Machine-to-Machine OAuth2. +If you are using the embedded IdP //TODO: explain how to get credentials. +Otherwise, create a new machine-to-machine profile in your IdP for proxy servers and set the relevant settings in the proxy's environment or flags (see below). + +## User Authentication When a request hits the Proxy, it looks up the permitted authentication methods for the Host domain. If no authentication methods are registered for the Host domain, then no authentication will be applied (for fully public resources). diff --git a/proxy/auth/auth.go b/proxy/auth/auth.go new file mode 100644 index 000000000..a151e5508 --- /dev/null +++ b/proxy/auth/auth.go @@ -0,0 +1,60 @@ +// Package auth contains exported proxy auth values. +// These are used to ensure coherent usage across management and proxy implementations. +package auth + +import ( + "crypto/ed25519" + "fmt" + "time" + + "github.com/golang-jwt/jwt/v5" +) + +type Method string + +var ( + MethodPassword Method = "password" + MethodPIN Method = "pin" + MethodOIDC Method = "oidc" +) + +func (m Method) String() string { + return string(m) +} + +const ( + SessionCookieName = "nb_session" + DefaultSessionExpiry = 24 * time.Hour + SessionJWTIssuer = "netbird-management" +) + +// ValidateSessionJWT validates a session JWT and returns the user ID and method. +func ValidateSessionJWT(tokenString, domain string, publicKey ed25519.PublicKey) (userID, method string, err error) { + if publicKey == nil { + return "", "", fmt.Errorf("no public key configured for domain") + } + + token, err := jwt.Parse(tokenString, func(t *jwt.Token) (interface{}, error) { + if _, ok := t.Method.(*jwt.SigningMethodEd25519); !ok { + return nil, fmt.Errorf("unexpected signing method: %v", t.Header["alg"]) + } + return publicKey, nil + }, jwt.WithAudience(domain), jwt.WithIssuer(SessionJWTIssuer)) + if err != nil { + return "", "", fmt.Errorf("parse token: %w", err) + } + + claims, ok := token.Claims.(jwt.MapClaims) + if !ok || !token.Valid { + return "", "", fmt.Errorf("invalid token claims") + } + + sub, _ := claims.GetSubject() + if sub == "" { + return "", "", fmt.Errorf("missing subject claim") + } + + methodClaim, _ := claims["method"].(string) + + return sub, methodClaim, nil +} diff --git a/proxy/cmd/proxy/cmd/debug.go b/proxy/cmd/proxy/cmd/debug.go new file mode 100644 index 000000000..86172d78b --- /dev/null +++ b/proxy/cmd/proxy/cmd/debug.go @@ -0,0 +1,166 @@ +package cmd + +import ( + "fmt" + "strconv" + + "github.com/spf13/cobra" + + "github.com/netbirdio/netbird/proxy/internal/debug" +) + +var ( + debugAddr string + jsonOutput bool + + // status filters + statusFilterByIPs []string + statusFilterByNames []string + statusFilterByStatus string + statusFilterByConnectionType string +) + +var debugCmd = &cobra.Command{ + Use: "debug", + Short: "Debug commands for inspecting proxy state", + Long: "Debug commands for inspecting the reverse proxy state via the debug HTTP endpoint.", +} + +var debugHealthCmd = &cobra.Command{ + Use: "health", + Short: "Show proxy health status", + RunE: runDebugHealth, + SilenceUsage: true, +} + +var debugClientsCmd = &cobra.Command{ + Use: "clients", + Aliases: []string{"list"}, + Short: "List all connected clients", + RunE: runDebugClients, + SilenceUsage: true, +} + +var debugStatusCmd = &cobra.Command{ + Use: "status ", + Short: "Show client status", + Args: cobra.ExactArgs(1), + RunE: runDebugStatus, + SilenceUsage: true, +} + +var debugSyncCmd = &cobra.Command{ + Use: "sync-response ", + Short: "Show client sync response", + Args: cobra.ExactArgs(1), + RunE: runDebugSync, + SilenceUsage: true, +} + +var pingTimeout string + +var debugPingCmd = &cobra.Command{ + Use: "ping [port]", + Short: "TCP ping through a client", + Long: "Perform a TCP ping through a client's network to test connectivity.\nPort defaults to 80 if not specified.", + Args: cobra.RangeArgs(2, 3), + RunE: runDebugPing, + SilenceUsage: true, +} + +var debugLogLevelCmd = &cobra.Command{ + Use: "loglevel ", + Short: "Set client log level", + Long: "Set the log level for a client (trace, debug, info, warn, error).", + Args: cobra.ExactArgs(2), + RunE: runDebugLogLevel, + SilenceUsage: true, +} + +var debugStartCmd = &cobra.Command{ + Use: "start ", + Short: "Start a client", + Args: cobra.ExactArgs(1), + RunE: runDebugStart, + SilenceUsage: true, +} + +var debugStopCmd = &cobra.Command{ + Use: "stop ", + Short: "Stop a client", + Args: cobra.ExactArgs(1), + RunE: runDebugStop, + SilenceUsage: true, +} + +func init() { + debugCmd.PersistentFlags().StringVar(&debugAddr, "addr", envStringOrDefault("NB_PROXY_DEBUG_ADDRESS", "localhost:8444"), "Debug endpoint address") + debugCmd.PersistentFlags().BoolVar(&jsonOutput, "json", false, "Output JSON instead of pretty format") + + debugStatusCmd.Flags().StringSliceVar(&statusFilterByIPs, "filter-by-ips", nil, "Filter by peer IPs (comma-separated)") + debugStatusCmd.Flags().StringSliceVar(&statusFilterByNames, "filter-by-names", nil, "Filter by peer names (comma-separated)") + debugStatusCmd.Flags().StringVar(&statusFilterByStatus, "filter-by-status", "", "Filter by status (idle|connecting|connected)") + debugStatusCmd.Flags().StringVar(&statusFilterByConnectionType, "filter-by-connection-type", "", "Filter by connection type (P2P|Relayed)") + + debugPingCmd.Flags().StringVar(&pingTimeout, "timeout", "", "Ping timeout (e.g., 10s)") + + debugCmd.AddCommand(debugHealthCmd) + debugCmd.AddCommand(debugClientsCmd) + debugCmd.AddCommand(debugStatusCmd) + debugCmd.AddCommand(debugSyncCmd) + debugCmd.AddCommand(debugPingCmd) + debugCmd.AddCommand(debugLogLevelCmd) + debugCmd.AddCommand(debugStartCmd) + debugCmd.AddCommand(debugStopCmd) + + rootCmd.AddCommand(debugCmd) +} + +func getDebugClient(cmd *cobra.Command) *debug.Client { + return debug.NewClient(debugAddr, jsonOutput, cmd.OutOrStdout()) +} + +func runDebugHealth(cmd *cobra.Command, _ []string) error { + return getDebugClient(cmd).Health(cmd.Context()) +} + +func runDebugClients(cmd *cobra.Command, _ []string) error { + return getDebugClient(cmd).ListClients(cmd.Context()) +} + +func runDebugStatus(cmd *cobra.Command, args []string) error { + return getDebugClient(cmd).ClientStatus(cmd.Context(), args[0], debug.StatusFilters{ + IPs: statusFilterByIPs, + Names: statusFilterByNames, + Status: statusFilterByStatus, + ConnectionType: statusFilterByConnectionType, + }) +} + +func runDebugSync(cmd *cobra.Command, args []string) error { + return getDebugClient(cmd).ClientSyncResponse(cmd.Context(), args[0]) +} + +func runDebugPing(cmd *cobra.Command, args []string) error { + port := 80 + if len(args) > 2 { + p, err := strconv.Atoi(args[2]) + if err != nil { + return fmt.Errorf("invalid port: %w", err) + } + port = p + } + return getDebugClient(cmd).PingTCP(cmd.Context(), args[0], args[1], port, pingTimeout) +} + +func runDebugLogLevel(cmd *cobra.Command, args []string) error { + return getDebugClient(cmd).SetLogLevel(cmd.Context(), args[0], args[1]) +} + +func runDebugStart(cmd *cobra.Command, args []string) error { + return getDebugClient(cmd).StartClient(cmd.Context(), args[0]) +} + +func runDebugStop(cmd *cobra.Command, args []string) error { + return getDebugClient(cmd).StopClient(cmd.Context(), args[0]) +} diff --git a/proxy/cmd/proxy/cmd/root.go b/proxy/cmd/proxy/cmd/root.go new file mode 100644 index 000000000..0a8cd6de5 --- /dev/null +++ b/proxy/cmd/proxy/cmd/root.go @@ -0,0 +1,140 @@ +package cmd + +import ( + "context" + "os" + "strconv" + "strings" + + log "github.com/sirupsen/logrus" + "github.com/spf13/cobra" + "golang.org/x/crypto/acme" + + "github.com/netbirdio/netbird/proxy" + "github.com/netbirdio/netbird/util" +) + +const DefaultManagementURL = "https://api.netbird.io:443" + +var ( + Version = "dev" + Commit = "unknown" + BuildDate = "unknown" + GoVersion = "unknown" +) + +var ( + debugLogs bool + mgmtAddr string + addr string + proxyURL string + certDir string + acmeCerts bool + acmeAddr string + acmeDir string + debugEndpoint bool + debugEndpointAddr string + healthAddr string + oidcClientID string + oidcClientSecret string + oidcEndpoint string + oidcScopes string +) + +var rootCmd = &cobra.Command{ + Use: "proxy", + Short: "NetBird reverse proxy server", + Long: "NetBird reverse proxy server for proxying traffic to NetBird networks.", + Version: Version, + RunE: runServer, +} + +func init() { + rootCmd.PersistentFlags().BoolVar(&debugLogs, "debug", envBoolOrDefault("NB_PROXY_DEBUG_LOGS", false), "Enable debug logs") + rootCmd.Flags().StringVar(&mgmtAddr, "mgmt", envStringOrDefault("NB_PROXY_MANAGEMENT_ADDRESS", DefaultManagementURL), "Management address to connect to") + rootCmd.Flags().StringVar(&addr, "addr", envStringOrDefault("NB_PROXY_ADDRESS", ":443"), "Reverse proxy address to listen on") + rootCmd.Flags().StringVar(&proxyURL, "url", envStringOrDefault("NB_PROXY_URL", ""), "The URL at which this proxy will be reached") + rootCmd.Flags().StringVar(&certDir, "cert-dir", envStringOrDefault("NB_PROXY_CERTIFICATE_DIRECTORY", "./certs"), "Directory to store certificates") + rootCmd.Flags().BoolVar(&acmeCerts, "acme-certs", envBoolOrDefault("NB_PROXY_ACME_CERTIFICATES", false), "Generate ACME certificates using HTTP-01 challenges") + rootCmd.Flags().StringVar(&acmeAddr, "acme-addr", envStringOrDefault("NB_PROXY_ACME_ADDRESS", ":80"), "HTTP address for ACME HTTP-01 challenges") + rootCmd.Flags().StringVar(&acmeDir, "acme-dir", envStringOrDefault("NB_PROXY_ACME_DIRECTORY", acme.LetsEncryptURL), "URL of ACME challenge directory") + rootCmd.Flags().BoolVar(&debugEndpoint, "debug-endpoint", envBoolOrDefault("NB_PROXY_DEBUG_ENDPOINT", false), "Enable debug HTTP endpoint") + rootCmd.Flags().StringVar(&debugEndpointAddr, "debug-endpoint-addr", envStringOrDefault("NB_PROXY_DEBUG_ENDPOINT_ADDRESS", "localhost:8444"), "Address for the debug HTTP endpoint") + rootCmd.Flags().StringVar(&healthAddr, "health-addr", envStringOrDefault("NB_PROXY_HEALTH_ADDRESS", "localhost:8080"), "Address for the health probe endpoint (liveness/readiness/startup)") + rootCmd.Flags().StringVar(&oidcClientID, "oidc-id", envStringOrDefault("NB_PROXY_OIDC_CLIENT_ID", "netbird-proxy"), "The OAuth2 Client ID for OIDC User Authentication") + rootCmd.Flags().StringVar(&oidcClientSecret, "oidc-secret", envStringOrDefault("NB_PROXY_OIDC_CLIENT_SECRET", ""), "The OAuth2 Client Secret for OIDC User Authentication") + rootCmd.Flags().StringVar(&oidcEndpoint, "oidc-endpoint", envStringOrDefault("NB_PROXY_OIDC_ENDPOINT", ""), "The OIDC Endpoint for OIDC User Authentication") + rootCmd.Flags().StringVar(&oidcScopes, "oidc-scopes", envStringOrDefault("NB_PROXY_OIDC_SCOPES", "openid,profile,email"), "The OAuth2 scopes for OIDC User Authentication, comma separated") +} + +// Execute runs the root command. +func Execute() { + if err := rootCmd.Execute(); err != nil { + os.Exit(1) + } +} + +// SetVersionInfo sets version information for the CLI. +func SetVersionInfo(version, commit, buildDate, goVersion string) { + Version = version + Commit = commit + BuildDate = buildDate + GoVersion = goVersion + rootCmd.Version = version + rootCmd.SetVersionTemplate("Version: {{.Version}}, Commit: " + Commit + ", BuildDate: " + BuildDate + ", Go: " + GoVersion + "\n") +} + +func runServer(cmd *cobra.Command, args []string) error { + level := "error" + if debugLogs { + level = "debug" + } + logger := log.New() + + _ = util.InitLogger(logger, level, util.LogConsole) + + log.Infof("configured log level: %s", level) + + srv := proxy.Server{ + Logger: logger, + Version: Version, + ManagementAddress: mgmtAddr, + ProxyURL: proxyURL, + CertificateDirectory: certDir, + GenerateACMECertificates: acmeCerts, + ACMEChallengeAddress: acmeAddr, + ACMEDirectory: acmeDir, + DebugEndpointEnabled: debugEndpoint, + DebugEndpointAddress: debugEndpointAddr, + HealthAddress: healthAddr, + OIDCClientId: oidcClientID, + OIDCClientSecret: oidcClientSecret, + OIDCEndpoint: oidcEndpoint, + OIDCScopes: strings.Split(oidcScopes, ","), + } + + if err := srv.ListenAndServe(context.TODO(), addr); err != nil { + log.Fatal(err) + } + return nil +} + +func envBoolOrDefault(key string, def bool) bool { + v, exists := os.LookupEnv(key) + if !exists { + return def + } + parsed, err := strconv.ParseBool(v) + if err != nil { + return def + } + return parsed +} + +func envStringOrDefault(key string, def string) string { + v, exists := os.LookupEnv(key) + if !exists { + return def + } + return v +} diff --git a/proxy/cmd/proxy/main.go b/proxy/cmd/proxy/main.go index 2eb0ac01d..14e540a2e 100644 --- a/proxy/cmd/proxy/main.go +++ b/proxy/cmd/proxy/main.go @@ -1,22 +1,11 @@ package main import ( - "context" - "flag" - "fmt" - "os" "runtime" - "strings" - "github.com/netbirdio/netbird/util" - log "github.com/sirupsen/logrus" - "golang.org/x/crypto/acme" - - "github.com/netbirdio/netbird/proxy" + "github.com/netbirdio/netbird/proxy/cmd/proxy/cmd" ) -const DefaultManagementURL = "https://api.netbird.io:443" - var ( // Version is the application version (set via ldflags during build) Version = "dev" @@ -31,78 +20,7 @@ var ( GoVersion = runtime.Version() ) -func envBoolOrDefault(key string, def bool) bool { - v, exists := os.LookupEnv(key) - if !exists { - return def - } - return v == strings.ToLower("true") -} - -func envStringOrDefault(key string, def string) string { - v, exists := os.LookupEnv(key) - if !exists { - return def - } - return v -} - func main() { - var ( - version, debug bool - mgmtAddr, addr, url, certDir string - acmeCerts bool - acmeAddr, acmeDir string - oidcId, oidcSecret, oidcEndpoint, oidcScopes string - ) - - flag.BoolVar(&version, "v", false, "Print version and exit") - flag.BoolVar(&debug, "debug", envBoolOrDefault("NB_PROXY_DEBUG_LOGS", false), "Enable debug logs") - flag.StringVar(&mgmtAddr, "mgmt", envStringOrDefault("NB_PROXY_MANAGEMENT_ADDRESS", DefaultManagementURL), "Management address to connect to.") - flag.StringVar(&addr, "addr", envStringOrDefault("NB_PROXY_ADDRESS", ":443"), "Reverse proxy address to listen on.") - flag.StringVar(&url, "url", envStringOrDefault("NB_PROXY_URL", "proxy.netbird.io"), "The URL at which this proxy will be reached, where CNAME records for proxied endpoints will be directed.") - flag.StringVar(&certDir, "cert-dir", envStringOrDefault("NB_PROXY_CERTIFICATE_DIRECTORY", "./certs"), "Directory to store ") - flag.BoolVar(&acmeCerts, "acme-certs", envBoolOrDefault("NB_PROXY_ACME_CERTIFICATES", false), "Generate ACME certificates using HTTP-01 challenges.") - flag.StringVar(&acmeAddr, "acme-addr", envStringOrDefault("NB_PROXY_ACME_ADDRESS", ":80"), "HTTP address to listen on, used for ACME HTTP-01 certificate generation.") - flag.StringVar(&acmeDir, "acme-dir", envStringOrDefault("NB_PROXY_ACME_DIRECTORY", acme.LetsEncryptURL), "URL of ACME challenge directory.") - flag.StringVar(&oidcId, "oidc-id", envStringOrDefault("NB_PROXY_OIDC_CLIENT_ID", "netbird-proxy"), "The OAuth2 Client ID for OIDC User Authentication") - flag.StringVar(&oidcSecret, "oidc-secret", envStringOrDefault("NB_PROXY_OIDC_CLIENT_SECRET", ""), "The OAuth2 Client Secret for OIDC User Authentication") - flag.StringVar(&oidcEndpoint, "oidc-endpoint", envStringOrDefault("NB_PROXY_OIDC_ENDPOINT", ""), "The OIDC Endpoint for OIDC User Authentication") - flag.StringVar(&oidcScopes, "oidc-scopes", envStringOrDefault("NB_PROXY_OIDC_SCOPES", "openid,profile,email"), "The OAuth2 scopes for OIDC User Authentication, comma separated") - flag.Parse() - - if version { - fmt.Printf("Version: %s, Commit: %s, BuildDate: %s, Go: %s", Version, Commit, BuildDate, GoVersion) - os.Exit(0) - } - - // Configure logrus. - level := "error" - if debug { - level = "debug" - } - logger := log.New() - - _ = util.InitLogger(logger, level, util.LogConsole) - - log.Infof("configured log level: %s", level) - - srv := proxy.Server{ - Logger: logger, - Version: Version, - ManagementAddress: mgmtAddr, - ProxyURL: url, - CertificateDirectory: certDir, - GenerateACMECertificates: acmeCerts, - ACMEChallengeAddress: acmeAddr, - ACMEDirectory: acmeDir, - OIDCClientId: oidcId, - OIDCClientSecret: oidcSecret, - OIDCEndpoint: oidcEndpoint, - OIDCScopes: strings.Split(oidcScopes, ","), - } - - if err := srv.ListenAndServe(context.TODO(), addr); err != nil { - log.Fatal(err) - } + cmd.SetVersionInfo(Version, Commit, BuildDate, GoVersion) + cmd.Execute() } diff --git a/proxy/deploy/k8s/deployment.yaml b/proxy/deploy/k8s/deployment.yaml new file mode 100644 index 000000000..94b1e4e9e --- /dev/null +++ b/proxy/deploy/k8s/deployment.yaml @@ -0,0 +1,108 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: netbird-proxy + labels: + app: netbird-proxy +spec: + replicas: 1 + selector: + matchLabels: + app: netbird-proxy + template: + metadata: + labels: + app: netbird-proxy + spec: + hostAliases: + - ip: "192.168.100.1" + hostnames: + - "host.docker.internal" + containers: + - name: proxy + image: netbird-proxy + ports: + - containerPort: 8443 + name: https + - containerPort: 8080 + name: health + - containerPort: 8444 + name: debug + env: + - name: USER + value: "netbird" + - name: HOME + value: "/tmp" + - name: NB_PROXY_DEBUG_LOGS + value: "true" + - name: NB_PROXY_MANAGEMENT_ADDRESS + value: "http://host.docker.internal:8080" + - name: NB_PROXY_ADDRESS + value: ":8443" + - name: NB_PROXY_HEALTH_ADDRESS + value: ":8080" + - name: NB_PROXY_DEBUG_ENDPOINT + value: "true" + - name: NB_PROXY_DEBUG_ENDPOINT_ADDRESS + value: ":8444" + - name: NB_PROXY_URL + value: "https://proxy.local" + - name: NB_PROXY_CERTIFICATE_DIRECTORY + value: "/certs" + volumeMounts: + - name: tls-certs + mountPath: /certs + readOnly: true + livenessProbe: + httpGet: + path: /healthz/live + port: health + initialDelaySeconds: 5 + periodSeconds: 10 + timeoutSeconds: 5 + failureThreshold: 3 + readinessProbe: + httpGet: + path: /healthz/ready + port: health + initialDelaySeconds: 5 + periodSeconds: 10 + timeoutSeconds: 5 + failureThreshold: 3 + startupProbe: + httpGet: + path: /healthz/startup + port: health + periodSeconds: 2 + timeoutSeconds: 10 + failureThreshold: 60 + resources: + requests: + memory: "64Mi" + cpu: "100m" + limits: + memory: "256Mi" + cpu: "500m" + volumes: + - name: tls-certs + secret: + secretName: netbird-proxy-tls +--- +apiVersion: v1 +kind: Service +metadata: + name: netbird-proxy +spec: + selector: + app: netbird-proxy + ports: + - name: https + port: 8443 + targetPort: 8443 + - name: health + port: 8080 + targetPort: 8080 + - name: debug + port: 8444 + targetPort: 8444 + type: ClusterIP diff --git a/proxy/deploy/kind-config.yaml b/proxy/deploy/kind-config.yaml new file mode 100644 index 000000000..d40f1eb36 --- /dev/null +++ b/proxy/deploy/kind-config.yaml @@ -0,0 +1,11 @@ +kind: Cluster +apiVersion: kind.x-k8s.io/v1alpha4 +nodes: +- role: control-plane + extraPortMappings: + - containerPort: 30080 + hostPort: 30080 + protocol: TCP + - containerPort: 30443 + hostPort: 30443 + protocol: TCP diff --git a/proxy/internal/accesslog/middleware.go b/proxy/internal/accesslog/middleware.go index c298b7f79..706de7d3c 100644 --- a/proxy/internal/accesslog/middleware.go +++ b/proxy/internal/accesslog/middleware.go @@ -13,6 +13,7 @@ import ( func (l *Logger) Middleware(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + l.logger.Debugf("access log middleware invoked for %s %s", r.Method, r.URL.Path) // Use a response writer wrapper so we can access the status code later. sw := &statusWriter{ w: w, @@ -42,7 +43,7 @@ func (l *Logger) Middleware(next http.Handler) http.Handler { entry := logEntry{ ID: xid.New().String(), ServiceId: capturedData.GetServiceId(), - AccountID: capturedData.GetAccountId(), + AccountID: string(capturedData.GetAccountId()), Host: host, Path: r.URL.Path, DurationMs: duration.Milliseconds(), diff --git a/proxy/internal/auth/context.go b/proxy/internal/auth/context.go index 277d58057..a4ac40f74 100644 --- a/proxy/internal/auth/context.go +++ b/proxy/internal/auth/context.go @@ -2,6 +2,8 @@ package auth import ( "context" + + "github.com/netbirdio/netbird/proxy/auth" ) type requestContextKey string @@ -11,13 +13,13 @@ const ( authUserKey requestContextKey = "authUser" ) -func withAuthMethod(ctx context.Context, method Method) context.Context { +func withAuthMethod(ctx context.Context, method auth.Method) context.Context { return context.WithValue(ctx, authMethodKey, method) } -func MethodFromContext(ctx context.Context) Method { +func MethodFromContext(ctx context.Context) auth.Method { v := ctx.Value(authMethodKey) - method, ok := v.(Method) + method, ok := v.(auth.Method) if !ok { return "" } diff --git a/proxy/internal/auth/link.go b/proxy/internal/auth/link.go deleted file mode 100644 index 9059622e1..000000000 --- a/proxy/internal/auth/link.go +++ /dev/null @@ -1,57 +0,0 @@ -package auth - -import ( - "net/http" - - "github.com/netbirdio/netbird/shared/management/proto" -) - -const linkFormId = "email" - -type Link struct { - id, accountId string - client authenticator -} - -func NewLink(client authenticator, id, accountId string) Link { - return Link{ - id: id, - accountId: accountId, - client: client, - } -} - -func (Link) Type() Method { - return MethodLink -} - -func (l Link) Authenticate(r *http.Request) (string, string) { - email := r.FormValue(linkFormId) - - res, err := l.client.Authenticate(r.Context(), &proto.AuthenticateRequest{ - Id: l.id, - AccountId: l.accountId, - Request: &proto.AuthenticateRequest_Link{ - Link: &proto.LinkRequest{ - Email: email, - Redirect: "", // TODO: calculate this. - }, - }, - }) - if err != nil { - // TODO: log error here - return "", linkFormId - } - - if res.GetSuccess() { - // Use the email address as the user identifier. - return email, "" - } - - return "", linkFormId -} - -func (l Link) Middleware(next http.Handler) http.Handler { - // TODO: handle magic link redirects, should be similar to OIDC. - return next -} diff --git a/proxy/internal/auth/middleware.go b/proxy/internal/auth/middleware.go index 8462e79a4..917672c2d 100644 --- a/proxy/internal/auth/middleware.go +++ b/proxy/internal/auth/middleware.go @@ -2,79 +2,57 @@ package auth import ( "context" - "crypto/rand" + "crypto/ed25519" "encoding/base64" "net" "net/http" "sync" "time" + log "github.com/sirupsen/logrus" "google.golang.org/grpc" + "github.com/netbirdio/netbird/proxy/auth" + "github.com/netbirdio/netbird/proxy/web" "github.com/netbirdio/netbird/shared/management/proto" ) -type Method string - -var ( - MethodPassword Method = "password" - MethodPIN Method = "pin" - MethodOIDC Method = "oidc" - MethodLink Method = "link" -) - -func (m Method) String() string { - return string(m) -} - -const ( - sessionCookieName = "nb_session" - sessionExpiration = 24 * time.Hour -) - -type session struct { - UserID string - Method Method - CreatedAt time.Time -} - type authenticator interface { Authenticate(ctx context.Context, in *proto.AuthenticateRequest, opts ...grpc.CallOption) (*proto.AuthenticateResponse, error) } type Scheme interface { - Type() Method + Type() auth.Method // Authenticate should check the passed request and determine whether // it represents an authenticated user request. If it does not, then // an empty string should indicate an unauthenticated request which // will be rejected; optionally, it can also return any data that should // be included in a UI template when prompting the user to authenticate. - // If the request is authenticated, then a user id should be returned. - Authenticate(*http.Request) (userid string, promptData string) - // Middleware is applied within the outer auth middleware, but they will - // be applied after authentication if no scheme has authenticated a - // request. - // If no scheme Middleware blocks the request processing, then the auth - // middleware will then present the user with the auth UI. - Middleware(http.Handler) http.Handler + // If the request is authenticated, then a session token should be returned. + Authenticate(*http.Request) (token string, promptData string) +} + +type DomainConfig struct { + Schemes []Scheme + SessionPublicKey ed25519.PublicKey + SessionExpiration time.Duration } type Middleware struct { - domainsMux sync.RWMutex - domains map[string][]Scheme - sessionsMux sync.RWMutex - sessions map[string]*session + domainsMux sync.RWMutex + domains map[string]DomainConfig + logger *log.Logger } -func NewMiddleware() *Middleware { - mw := &Middleware{ - domains: make(map[string][]Scheme), - sessions: make(map[string]*session), +func NewMiddleware(logger *log.Logger) *Middleware { + if logger == nil { + logger = log.StandardLogger() + } + return &Middleware{ + domains: make(map[string]DomainConfig), + logger: logger, } - // TODO: goroutine is leaked here. - go mw.cleanupSessions() - return mw } // Protect applies authentication middleware to the passed handler. @@ -93,24 +71,22 @@ func (mw *Middleware) Protect(next http.Handler) http.Handler { host = r.Host } mw.domainsMux.RLock() - schemes, exists := mw.domains[host] + config, exists := mw.domains[host] mw.domainsMux.RUnlock() + mw.logger.Debugf("checking authentication for host: %s, exists: %t", host, exists) + // Domains that are not configured here or have no authentication schemes applied should simply pass through. - if !exists || len(schemes) == 0 { + if !exists || len(config.Schemes) == 0 { next.ServeHTTP(w, r) return } - // Check for an existing session to avoid users having to authenticate for every request. - // TODO: This does not work if you are load balancing across multiple proxy servers. - if cookie, err := r.Cookie(sessionCookieName); err == nil { - mw.sessionsMux.RLock() - sess, ok := mw.sessions[cookie.Value] - mw.sessionsMux.RUnlock() - if ok { - ctx := withAuthMethod(r.Context(), sess.Method) - ctx = withAuthUser(ctx, sess.UserID) + // Check for an existing session cookie (contains JWT) + if cookie, err := r.Cookie(auth.SessionCookieName); err == nil { + if userID, method, err := auth.ValidateSessionJWT(cookie.Value, host, config.SessionPublicKey); err == nil { + ctx := withAuthMethod(r.Context(), auth.Method(method)) + ctx = withAuthUser(ctx, userID) next.ServeHTTP(w, r.WithContext(ctx)) return } @@ -118,41 +94,59 @@ func (mw *Middleware) Protect(next http.Handler) http.Handler { // Try to authenticate with each scheme. methods := make(map[string]string) - for _, s := range schemes { - userid, promptData := s.Authenticate(r) - if userid != "" { - mw.createSession(w, r, userid, s.Type()) - // Clean the path and redirect to the naked URL. - // This is intended to prevent leaking potentially - // sensitive query parameters for authentication - // methods. - http.Redirect(w, r, r.URL.Path, http.StatusFound) + for _, scheme := range config.Schemes { + token, promptData := scheme.Authenticate(r) + if token != "" { + userid, _, err := auth.ValidateSessionJWT(token, host, config.SessionPublicKey) + if err != nil { + // TODO: log, this should never fail. + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + expiration := config.SessionExpiration + if expiration == 0 { + expiration = auth.DefaultSessionExpiry + } + http.SetCookie(w, &http.Cookie{ + Name: auth.SessionCookieName, + Value: token, + HttpOnly: true, + Secure: true, + SameSite: http.SameSiteLaxMode, + MaxAge: int(expiration.Seconds()), + }) + + ctx := withAuthMethod(r.Context(), scheme.Type()) + ctx = withAuthUser(ctx, userid) + next.ServeHTTP(w, r.WithContext(ctx)) return } - methods[s.Type().String()] = promptData + methods[scheme.Type().String()] = promptData } - // The handler is passed through the scheme middlewares, - // if none of them intercept the request, then this handler will - // be called and present the user with the authentication page. - handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - web.ServeHTTP(w, r, map[string]any{"methods": methods}) - })) - - // No authentication succeeded. Apply the scheme handlers. - for _, s := range schemes { - handler = s.Middleware(handler) - } - - // Run the unauthenticated request against the scheme handlers and the final UI handler. - handler.ServeHTTP(w, r) + web.ServeHTTP(w, r, map[string]any{"methods": methods}) }) } -func (mw *Middleware) AddDomain(domain string, schemes []Scheme) { +func (mw *Middleware) AddDomain(domain string, schemes []Scheme, publicKeyB64 string, expiration time.Duration) { + pubKeyBytes, err := base64.StdEncoding.DecodeString(publicKeyB64) + if err != nil { + // TODO: log + return + } + if len(pubKeyBytes) != ed25519.PublicKeySize { + // TODO: log + return + } + mw.domainsMux.Lock() defer mw.domainsMux.Unlock() - mw.domains[domain] = schemes + mw.domains[domain] = DomainConfig{ + Schemes: schemes, + SessionPublicKey: pubKeyBytes, + SessionExpiration: expiration, + } } func (mw *Middleware) RemoveDomain(domain string) { @@ -160,39 +154,3 @@ func (mw *Middleware) RemoveDomain(domain string) { defer mw.domainsMux.Unlock() delete(mw.domains, domain) } - -func (mw *Middleware) createSession(w http.ResponseWriter, r *http.Request, userID string, method Method) { - // Generate a random sessionID - b := make([]byte, 32) - _, _ = rand.Read(b) - sessionID := base64.URLEncoding.EncodeToString(b) - - mw.sessionsMux.Lock() - mw.sessions[sessionID] = &session{ - UserID: userID, - Method: method, - CreatedAt: time.Now(), - } - mw.sessionsMux.Unlock() - - http.SetCookie(w, &http.Cookie{ - Name: sessionCookieName, - Value: sessionID, - HttpOnly: true, // This cookie is only for proxy access, so no scripts should touch it. - Secure: true, // The proxy only accepts TLS traffic regardless of the service proxied behind. - SameSite: http.SameSiteLaxMode, // TODO: might this actually be strict mode? - }) -} - -func (mw *Middleware) cleanupSessions() { - for range time.Tick(time.Minute) { - cutoff := time.Now().Add(-sessionExpiration) - mw.sessionsMux.Lock() - for id, sess := range mw.sessions { - if sess.CreatedAt.Before(cutoff) { - delete(mw.sessions, id) - } - } - mw.sessionsMux.Unlock() - } -} diff --git a/proxy/internal/auth/oidc.go b/proxy/internal/auth/oidc.go index a9293214e..5cf203354 100644 --- a/proxy/internal/auth/oidc.go +++ b/proxy/internal/auth/oidc.go @@ -2,238 +2,60 @@ package auth import ( "context" - "crypto/rand" - "encoding/base64" - "fmt" "net/http" "net/url" - "strings" - "sync" - "time" - "github.com/coreos/go-oidc/v3/oidc" - "golang.org/x/oauth2" + "google.golang.org/grpc" + + "github.com/netbirdio/netbird/proxy/auth" + "github.com/netbirdio/netbird/shared/management/proto" ) -const stateExpiration = 10 * time.Minute - -const callbackPath = "/oauth/callback" - -// OIDCConfig holds configuration for OIDC authentication -type OIDCConfig struct { - OIDCProviderURL string - OIDCClientID string - OIDCClientSecret string - OIDCScopes []string - DistributionGroups []string +type urlGenerator interface { + GetOIDCURL(context.Context, *proto.GetOIDCURLRequest, ...grpc.CallOption) (*proto.GetOIDCURLResponse, error) } -// oidcState stores CSRF state with expiration -type oidcState struct { - OriginalURL string - CreatedAt time.Time -} - -// OIDC implements the Scheme interface for JWT/OIDC authentication type OIDC struct { - id, accountId, proxyURL string - verifier *oidc.IDTokenVerifier - oauthConfig *oauth2.Config - states map[string]*oidcState - statesMux sync.RWMutex - distributionGroups []string + id, accountId string + client urlGenerator } // NewOIDC creates a new OIDC authentication scheme -func NewOIDC(ctx context.Context, id, accountId, proxyURL string, cfg OIDCConfig) (*OIDC, error) { - if cfg.OIDCProviderURL == "" || cfg.OIDCClientID == "" { - return nil, fmt.Errorf("OIDC provider URL and client ID are required") - } - - scopes := cfg.OIDCScopes - if len(scopes) == 0 { - scopes = []string{oidc.ScopeOpenID, "profile", "email"} - } - - provider, err := oidc.NewProvider(ctx, cfg.OIDCProviderURL) - if err != nil { - return nil, fmt.Errorf("failed to create OIDC provider: %w", err) - } - - o := &OIDC{ +func NewOIDC(client urlGenerator, id, accountId string) OIDC { + return OIDC{ id: id, accountId: accountId, - proxyURL: proxyURL, - verifier: provider.Verifier(&oidc.Config{ - ClientID: cfg.OIDCClientID, - }), - oauthConfig: &oauth2.Config{ - ClientID: cfg.OIDCClientID, - ClientSecret: cfg.OIDCClientSecret, - Endpoint: provider.Endpoint(), - Scopes: scopes, - }, - states: make(map[string]*oidcState), - distributionGroups: cfg.DistributionGroups, + client: client, } - - go o.cleanupStates() - - return o, nil } -func (*OIDC) Type() Method { - return MethodOIDC +func (OIDC) Type() auth.Method { + return auth.MethodOIDC } -func (o *OIDC) Authenticate(r *http.Request) (string, string) { - // Try Authorization: Bearer header - if auth := r.Header.Get("Authorization"); strings.HasPrefix(auth, "Bearer ") { - if userID := o.validateToken(r.Context(), strings.TrimPrefix(auth, "Bearer ")); userID != "" { - return userID, "" - } +func (o OIDC) Authenticate(r *http.Request) (string, string) { + // Check for the session_token query param (from OIDC redirects). + // The management server passes the token in the URL because it cannot set + // cookies for the proxy's domain (cookies are domain-scoped per RFC 6265). + if token := r.URL.Query().Get("session_token"); token != "" { + return token, "" } - // Try _auth_token query parameter (from OIDC callback redirect) - if token := r.URL.Query().Get("_auth_token"); token != "" { - if userID := o.validateToken(r.Context(), token); userID != "" { - return userID, "" - } + redirectURL := &url.URL{ + Scheme: "https", + Host: r.Host, + Path: r.URL.Path, } - // If the request is not authenticated, return a redirect URL for the UI to - // route the user through if they select OIDC login. - b := make([]byte, 32) - _, _ = rand.Read(b) - state := base64.URLEncoding.EncodeToString(b) - - // TODO: this does not work if you are load balancing across multiple proxy servers. - o.statesMux.Lock() - o.states[state] = &oidcState{OriginalURL: fmt.Sprintf("https://%s%s", r.Host, r.URL), CreatedAt: time.Now()} - o.statesMux.Unlock() - - return "", (&oauth2.Config{ - ClientID: o.oauthConfig.ClientID, - ClientSecret: o.oauthConfig.ClientSecret, - Endpoint: o.oauthConfig.Endpoint, - RedirectURL: o.proxyURL + callbackPath, - Scopes: o.oauthConfig.Scopes, - }).AuthCodeURL(state) -} - -// Middleware returns an http.Handler that handles OIDC callback and flow initiation. -func (o *OIDC) Middleware(next http.Handler) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - // Handle OIDC callback - if r.URL.Path == callbackPath { - o.handleCallback(w, r) - return - } - - next.ServeHTTP(w, r) + res, err := o.client.GetOIDCURL(r.Context(), &proto.GetOIDCURLRequest{ + Id: o.id, + AccountId: o.accountId, + RedirectUrl: redirectURL.String(), }) -} - -// validateToken validates a JWT ID token and returns the user ID (subject) -// Returns empty string if token is invalid or user's groups don't appear -// in the distributionGroups. -func (o *OIDC) validateToken(ctx context.Context, token string) string { - if o.verifier == nil { - return "" - } - - idToken, err := o.verifier.Verify(ctx, token) if err != nil { - // TODO: log or return? - return "" + // TODO: log + return "", "" } - // If distribution groups are configured, check if user has access - if len(o.distributionGroups) > 0 { - var claims struct { - Groups []string `json:"groups"` - } - if err := idToken.Claims(&claims); err != nil { - // TODO: log or return? - return "" - } - - allowed := make(map[string]struct{}, len(o.distributionGroups)) - for _, g := range o.distributionGroups { - allowed[g] = struct{}{} - } - - for _, g := range claims.Groups { - if _, ok := allowed[g]; ok { - return idToken.Subject - } - } - } - - // Default deny - return "" -} - -// handleCallback processes the OIDC callback -func (o *OIDC) handleCallback(w http.ResponseWriter, r *http.Request) { - code := r.URL.Query().Get("code") - state := r.URL.Query().Get("state") - - if code == "" || state == "" { - http.Error(w, "Invalid callback parameters", http.StatusBadRequest) - return - } - - // Verify and consume state - o.statesMux.Lock() - st, ok := o.states[state] - if ok { - delete(o.states, state) - } - o.statesMux.Unlock() - - if !ok { - http.Error(w, "Invalid or expired state", http.StatusBadRequest) - return - } - - // Exchange code for token - token, err := o.oauthConfig.Exchange(r.Context(), code) - if err != nil { - http.Error(w, "Authentication failed", http.StatusUnauthorized) - return - } - - // Prefer ID token if available - idToken := token.AccessToken - if id, ok := token.Extra("id_token").(string); ok && id != "" { - idToken = id - } - - // Redirect back to original URL with token - origURL, err := url.Parse(st.OriginalURL) - if err != nil { - http.Error(w, "Internal Server Error", http.StatusInternalServerError) - return - } - - q := origURL.Query() - q.Set("_auth_token", idToken) - origURL.RawQuery = q.Encode() - - http.Redirect(w, r, origURL.String(), http.StatusFound) -} - -// cleanupStates periodically removes expired states -func (o *OIDC) cleanupStates() { - for range time.Tick(time.Minute) { - cutoff := time.Now().Add(-stateExpiration) - o.statesMux.Lock() - for k, v := range o.states { - if v.CreatedAt.Before(cutoff) { - delete(o.states, k) - } - } - o.statesMux.Unlock() - } + return "", res.GetUrl() } diff --git a/proxy/internal/auth/password.go b/proxy/internal/auth/password.go index ffc52c1c0..e8f6c49fa 100644 --- a/proxy/internal/auth/password.go +++ b/proxy/internal/auth/password.go @@ -3,13 +3,11 @@ package auth import ( "net/http" + "github.com/netbirdio/netbird/proxy/auth" "github.com/netbirdio/netbird/shared/management/proto" ) -const ( - passwordUserId = "password-user" - passwordFormId = "password" -) +const passwordFormId = "password" type Password struct { id, accountId string @@ -24,8 +22,8 @@ func NewPassword(client authenticator, id, accountId string) Password { } } -func (Password) Type() Method { - return MethodPassword +func (Password) Type() auth.Method { + return auth.MethodPassword } // Authenticate attempts to authenticate the request using a form @@ -36,6 +34,11 @@ func (Password) Type() Method { func (p Password) Authenticate(r *http.Request) (string, string) { password := r.FormValue(passwordFormId) + if password == "" { + // This cannot be authenticated, so not worth wasting time sending the request. + return "", passwordFormId + } + res, err := p.client.Authenticate(r.Context(), &proto.AuthenticateRequest{ Id: p.id, AccountId: p.accountId, @@ -51,12 +54,8 @@ func (p Password) Authenticate(r *http.Request) (string, string) { } if res.GetSuccess() { - return passwordUserId, "" + return res.GetSessionToken(), "" } return "", passwordFormId } - -func (p Password) Middleware(next http.Handler) http.Handler { - return next -} diff --git a/proxy/internal/auth/pin.go b/proxy/internal/auth/pin.go index dd6d5346b..5f51b9466 100644 --- a/proxy/internal/auth/pin.go +++ b/proxy/internal/auth/pin.go @@ -3,13 +3,11 @@ package auth import ( "net/http" + "github.com/netbirdio/netbird/proxy/auth" "github.com/netbirdio/netbird/shared/management/proto" ) -const ( - pinUserId = "pin-user" - pinFormId = "pin" -) +const pinFormId = "pin" type Pin struct { id, accountId string @@ -24,8 +22,8 @@ func NewPin(client authenticator, id, accountId string) Pin { } } -func (Pin) Type() Method { - return MethodPIN +func (Pin) Type() auth.Method { + return auth.MethodPIN } // Authenticate attempts to authenticate the request using a form @@ -36,6 +34,11 @@ func (Pin) Type() Method { func (p Pin) Authenticate(r *http.Request) (string, string) { pin := r.FormValue(pinFormId) + if pin == "" { + // This cannot be authenticated, so not worth wasting time sending the request. + return "", pinFormId + } + res, err := p.client.Authenticate(r.Context(), &proto.AuthenticateRequest{ Id: p.id, AccountId: p.accountId, @@ -51,12 +54,8 @@ func (p Pin) Authenticate(r *http.Request) (string, string) { } if res.GetSuccess() { - return pinUserId, "" + return res.GetSessionToken(), "" } return "", pinFormId } - -func (p Pin) Middleware(next http.Handler) http.Handler { - return next -} diff --git a/proxy/internal/debug/client.go b/proxy/internal/debug/client.go new file mode 100644 index 000000000..6b78a9b8a --- /dev/null +++ b/proxy/internal/debug/client.go @@ -0,0 +1,307 @@ +// Package debug provides HTTP debug endpoints and CLI client for the proxy server. +package debug + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strings" + "time" +) + +// StatusFilters contains filter options for status queries. +type StatusFilters struct { + IPs []string + Names []string + Status string + ConnectionType string +} + +// Client provides CLI access to debug endpoints. +type Client struct { + baseURL string + jsonOutput bool + httpClient *http.Client + out io.Writer +} + +// NewClient creates a new debug client. +func NewClient(baseURL string, jsonOutput bool, out io.Writer) *Client { + if !strings.HasPrefix(baseURL, "http://") && !strings.HasPrefix(baseURL, "https://") { + baseURL = "http://" + baseURL + } + baseURL = strings.TrimSuffix(baseURL, "/") + + return &Client{ + baseURL: baseURL, + jsonOutput: jsonOutput, + out: out, + httpClient: &http.Client{ + Timeout: 30 * time.Second, + }, + } +} + +// Health fetches the health status. +func (c *Client) Health(ctx context.Context) error { + return c.fetchAndPrint(ctx, "/debug/health", c.printHealth) +} + +func (c *Client) printHealth(data map[string]any) { + _, _ = fmt.Fprintf(c.out, "Status: %v\n", data["status"]) + _, _ = fmt.Fprintf(c.out, "Uptime: %v\n", data["uptime"]) +} + +// ListClients fetches the list of all clients. +func (c *Client) ListClients(ctx context.Context) error { + return c.fetchAndPrint(ctx, "/debug/clients", c.printClients) +} + +func (c *Client) printClients(data map[string]any) { + _, _ = fmt.Fprintf(c.out, "Uptime: %v\n", data["uptime"]) + _, _ = fmt.Fprintf(c.out, "Clients: %v\n\n", data["client_count"]) + + clients, ok := data["clients"].([]any) + if !ok || len(clients) == 0 { + _, _ = fmt.Fprintln(c.out, "No clients connected.") + return + } + + _, _ = fmt.Fprintf(c.out, "%-38s %-12s %-40s %s\n", "ACCOUNT ID", "AGE", "DOMAINS", "HAS CLIENT") + _, _ = fmt.Fprintln(c.out, strings.Repeat("-", 110)) + + for _, item := range clients { + c.printClientRow(item) + } +} + +func (c *Client) printClientRow(item any) { + client, ok := item.(map[string]any) + if !ok { + return + } + + domains := c.extractDomains(client) + hasClient := "no" + if hc, ok := client["has_client"].(bool); ok && hc { + hasClient = "yes" + } + + _, _ = fmt.Fprintf(c.out, "%-38s %-12v %s %s\n", + client["account_id"], + client["age"], + domains, + hasClient, + ) +} + +func (c *Client) extractDomains(client map[string]any) string { + d, ok := client["domains"].([]any) + if !ok || len(d) == 0 { + return "-" + } + + parts := make([]string, len(d)) + for i, domain := range d { + parts[i] = fmt.Sprint(domain) + } + return strings.Join(parts, ", ") +} + +// ClientStatus fetches the status of a specific client. +func (c *Client) ClientStatus(ctx context.Context, accountID string, filters StatusFilters) error { + params := url.Values{} + if len(filters.IPs) > 0 { + params.Set("filter-by-ips", strings.Join(filters.IPs, ",")) + } + if len(filters.Names) > 0 { + params.Set("filter-by-names", strings.Join(filters.Names, ",")) + } + if filters.Status != "" { + params.Set("filter-by-status", filters.Status) + } + if filters.ConnectionType != "" { + params.Set("filter-by-connection-type", filters.ConnectionType) + } + + path := "/debug/clients/" + url.PathEscape(accountID) + if len(params) > 0 { + path += "?" + params.Encode() + } + return c.fetchAndPrint(ctx, path, c.printClientStatus) +} + +func (c *Client) printClientStatus(data map[string]any) { + _, _ = fmt.Fprintf(c.out, "Account: %v\n\n", data["account_id"]) + if status, ok := data["status"].(string); ok { + _, _ = fmt.Fprint(c.out, status) + } +} + +// ClientSyncResponse fetches the sync response of a specific client. +func (c *Client) ClientSyncResponse(ctx context.Context, accountID string) error { + path := "/debug/clients/" + url.PathEscape(accountID) + "/syncresponse" + return c.fetchAndPrintJSON(ctx, path) +} + +// PingTCP performs a TCP ping through a client. +func (c *Client) PingTCP(ctx context.Context, accountID, host string, port int, timeout string) error { + params := url.Values{} + params.Set("host", host) + params.Set("port", fmt.Sprintf("%d", port)) + if timeout != "" { + params.Set("timeout", timeout) + } + + path := fmt.Sprintf("/debug/clients/%s/pingtcp?%s", url.PathEscape(accountID), params.Encode()) + return c.fetchAndPrint(ctx, path, c.printPingResult) +} + +func (c *Client) printPingResult(data map[string]any) { + success, _ := data["success"].(bool) + if success { + _, _ = fmt.Fprintf(c.out, "Success: %v:%v\n", data["host"], data["port"]) + _, _ = fmt.Fprintf(c.out, "Latency: %v\n", data["latency"]) + } else { + _, _ = fmt.Fprintf(c.out, "Failed: %v:%v\n", data["host"], data["port"]) + c.printError(data) + } +} + +// SetLogLevel sets the log level of a specific client. +func (c *Client) SetLogLevel(ctx context.Context, accountID, level string) error { + params := url.Values{} + params.Set("level", level) + + path := fmt.Sprintf("/debug/clients/%s/loglevel?%s", url.PathEscape(accountID), params.Encode()) + return c.fetchAndPrint(ctx, path, c.printLogLevelResult) +} + +func (c *Client) printLogLevelResult(data map[string]any) { + success, _ := data["success"].(bool) + if success { + _, _ = fmt.Fprintf(c.out, "Log level set to: %v\n", data["level"]) + } else { + _, _ = fmt.Fprintln(c.out, "Failed to set log level") + c.printError(data) + } +} + +// StartClient starts a specific client. +func (c *Client) StartClient(ctx context.Context, accountID string) error { + path := "/debug/clients/" + url.PathEscape(accountID) + "/start" + return c.fetchAndPrint(ctx, path, c.printStartResult) +} + +func (c *Client) printStartResult(data map[string]any) { + success, _ := data["success"].(bool) + if success { + _, _ = fmt.Fprintln(c.out, "Client started") + } else { + _, _ = fmt.Fprintln(c.out, "Failed to start client") + c.printError(data) + } +} + +// StopClient stops a specific client. +func (c *Client) StopClient(ctx context.Context, accountID string) error { + path := "/debug/clients/" + url.PathEscape(accountID) + "/stop" + return c.fetchAndPrint(ctx, path, c.printStopResult) +} + +func (c *Client) printStopResult(data map[string]any) { + success, _ := data["success"].(bool) + if success { + _, _ = fmt.Fprintln(c.out, "Client stopped") + } else { + _, _ = fmt.Fprintln(c.out, "Failed to stop client") + c.printError(data) + } +} + +func (c *Client) printError(data map[string]any) { + if errMsg, ok := data["error"].(string); ok { + _, _ = fmt.Fprintf(c.out, "Error: %s\n", errMsg) + } +} + +func (c *Client) fetchAndPrint(ctx context.Context, path string, printer func(map[string]any)) error { + data, raw, err := c.fetch(ctx, path) + if err != nil { + return err + } + + if c.jsonOutput { + return c.writeJSON(data) + } + + if data != nil { + printer(data) + return nil + } + + _, _ = fmt.Fprintln(c.out, string(raw)) + return nil +} + +func (c *Client) fetchAndPrintJSON(ctx context.Context, path string) error { + data, raw, err := c.fetch(ctx, path) + if err != nil { + return err + } + + if data != nil { + return c.writeJSON(data) + } + + _, _ = fmt.Fprintln(c.out, string(raw)) + return nil +} + +func (c *Client) writeJSON(data map[string]any) error { + enc := json.NewEncoder(c.out) + enc.SetIndent("", " ") + return enc.Encode(data) +} + +func (c *Client) fetch(ctx context.Context, path string) (map[string]any, []byte, error) { + fullURL := c.baseURL + path + if !strings.Contains(path, "format=json") { + if strings.Contains(path, "?") { + fullURL += "&format=json" + } else { + fullURL += "?format=json" + } + } + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, fullURL, nil) + if err != nil { + return nil, nil, fmt.Errorf("create request: %w", err) + } + + resp, err := c.httpClient.Do(req) + if err != nil { + return nil, nil, fmt.Errorf("request failed: %w", err) + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, nil, fmt.Errorf("read response: %w", err) + } + + if resp.StatusCode >= 400 { + return nil, nil, fmt.Errorf("server error (%d): %s", resp.StatusCode, strings.TrimSpace(string(body))) + } + + var data map[string]any + if err := json.Unmarshal(body, &data); err != nil { + return nil, body, nil + } + + return data, body, nil +} + diff --git a/proxy/internal/debug/handler.go b/proxy/internal/debug/handler.go new file mode 100644 index 000000000..f7b1fa87c --- /dev/null +++ b/proxy/internal/debug/handler.go @@ -0,0 +1,589 @@ +// Package debug provides HTTP debug endpoints for the proxy server. +package debug + +import ( + "context" + "embed" + "encoding/json" + "fmt" + "html/template" + "net/http" + "strconv" + "strings" + "sync" + "time" + + log "github.com/sirupsen/logrus" + "google.golang.org/protobuf/encoding/protojson" + + nbembed "github.com/netbirdio/netbird/client/embed" + nbstatus "github.com/netbirdio/netbird/client/status" + "github.com/netbirdio/netbird/proxy/internal/roundtrip" + "github.com/netbirdio/netbird/proxy/internal/types" + "github.com/netbirdio/netbird/version" +) + +//go:embed templates/*.html +var templateFS embed.FS + +const defaultPingTimeout = 10 * time.Second + +// formatDuration formats a duration with 2 decimal places using appropriate units. +func formatDuration(d time.Duration) string { + switch { + case d >= time.Hour: + return fmt.Sprintf("%.2fh", d.Hours()) + case d >= time.Minute: + return fmt.Sprintf("%.2fm", d.Minutes()) + case d >= time.Second: + return fmt.Sprintf("%.2fs", d.Seconds()) + case d >= time.Millisecond: + return fmt.Sprintf("%.2fms", float64(d.Microseconds())/1000) + case d >= time.Microsecond: + return fmt.Sprintf("%.2fµs", float64(d.Nanoseconds())/1000) + default: + return fmt.Sprintf("%dns", d.Nanoseconds()) + } +} + +// clientProvider provides access to NetBird clients. +type clientProvider interface { + GetClient(accountID types.AccountID) (*nbembed.Client, bool) + ListClientsForDebug() map[types.AccountID]roundtrip.ClientDebugInfo +} + +// Handler provides HTTP debug endpoints. +type Handler struct { + provider clientProvider + logger *log.Logger + startTime time.Time + templates *template.Template + templateMu sync.RWMutex +} + +// NewHandler creates a new debug handler. +func NewHandler(provider clientProvider, logger *log.Logger) *Handler { + if logger == nil { + logger = log.StandardLogger() + } + h := &Handler{ + provider: provider, + logger: logger, + startTime: time.Now(), + } + if err := h.loadTemplates(); err != nil { + logger.Errorf("failed to load embedded templates: %v", err) + } + return h +} + +func (h *Handler) loadTemplates() error { + tmpl, err := template.ParseFS(templateFS, "templates/*.html") + if err != nil { + return fmt.Errorf("parse embedded templates: %w", err) + } + + h.templateMu.Lock() + h.templates = tmpl + h.templateMu.Unlock() + + return nil +} + +func (h *Handler) getTemplates() *template.Template { + h.templateMu.RLock() + defer h.templateMu.RUnlock() + return h.templates +} + +// ServeHTTP handles debug requests. +func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + path := r.URL.Path + wantJSON := r.URL.Query().Get("format") == "json" || strings.HasSuffix(path, "/json") + path = strings.TrimSuffix(path, "/json") + + switch path { + case "/debug", "/debug/": + h.handleIndex(w, r, wantJSON) + case "/debug/clients": + h.handleListClients(w, r, wantJSON) + case "/debug/health": + h.handleHealth(w, r, wantJSON) + default: + if h.handleClientRoutes(w, r, path, wantJSON) { + return + } + http.NotFound(w, r) + } +} + +func (h *Handler) handleClientRoutes(w http.ResponseWriter, r *http.Request, path string, wantJSON bool) bool { + if !strings.HasPrefix(path, "/debug/clients/") { + return false + } + + rest := strings.TrimPrefix(path, "/debug/clients/") + parts := strings.SplitN(rest, "/", 2) + accountID := types.AccountID(parts[0]) + + if len(parts) == 1 { + h.handleClientStatus(w, r, accountID, wantJSON) + return true + } + + switch parts[1] { + case "syncresponse": + h.handleClientSyncResponse(w, r, accountID, wantJSON) + case "tools": + h.handleClientTools(w, r, accountID) + case "pingtcp": + h.handlePingTCP(w, r, accountID) + case "loglevel": + h.handleLogLevel(w, r, accountID) + case "start": + h.handleClientStart(w, r, accountID) + case "stop": + h.handleClientStop(w, r, accountID) + default: + return false + } + return true +} + +type indexData struct { + Version string + Uptime string + ClientCount int + TotalDomains int + Clients []clientData +} + +type clientData struct { + AccountID string + Domains string + Age string + Status string +} + +func (h *Handler) handleIndex(w http.ResponseWriter, _ *http.Request, wantJSON bool) { + clients := h.provider.ListClientsForDebug() + + totalDomains := 0 + for _, info := range clients { + totalDomains += info.DomainCount + } + + if wantJSON { + clientsJSON := make([]map[string]interface{}, 0, len(clients)) + for _, info := range clients { + clientsJSON = append(clientsJSON, map[string]interface{}{ + "account_id": info.AccountID, + "domain_count": info.DomainCount, + "domains": info.Domains, + "has_client": info.HasClient, + "created_at": info.CreatedAt, + "age": time.Since(info.CreatedAt).Round(time.Second).String(), + }) + } + h.writeJSON(w, map[string]interface{}{ + "version": version.NetbirdVersion(), + "uptime": time.Since(h.startTime).Round(time.Second).String(), + "client_count": len(clients), + "total_domains": totalDomains, + "clients": clientsJSON, + }) + return + } + + data := indexData{ + Version: version.NetbirdVersion(), + Uptime: time.Since(h.startTime).Round(time.Second).String(), + ClientCount: len(clients), + TotalDomains: totalDomains, + Clients: make([]clientData, 0, len(clients)), + } + + for _, info := range clients { + domains := info.Domains.SafeString() + if domains == "" { + domains = "-" + } + status := "No client" + if info.HasClient { + status = "Active" + } + data.Clients = append(data.Clients, clientData{ + AccountID: string(info.AccountID), + Domains: domains, + Age: time.Since(info.CreatedAt).Round(time.Second).String(), + Status: status, + }) + } + + h.renderTemplate(w, "index", data) +} + +type clientsData struct { + Uptime string + Clients []clientData +} + +func (h *Handler) handleListClients(w http.ResponseWriter, _ *http.Request, wantJSON bool) { + clients := h.provider.ListClientsForDebug() + + if wantJSON { + clientsJSON := make([]map[string]interface{}, 0, len(clients)) + for _, info := range clients { + clientsJSON = append(clientsJSON, map[string]interface{}{ + "account_id": info.AccountID, + "domain_count": info.DomainCount, + "domains": info.Domains, + "has_client": info.HasClient, + "created_at": info.CreatedAt, + "age": time.Since(info.CreatedAt).Round(time.Second).String(), + }) + } + h.writeJSON(w, map[string]interface{}{ + "uptime": time.Since(h.startTime).Round(time.Second).String(), + "client_count": len(clients), + "clients": clientsJSON, + }) + return + } + + data := clientsData{ + Uptime: time.Since(h.startTime).Round(time.Second).String(), + Clients: make([]clientData, 0, len(clients)), + } + + for _, info := range clients { + domains := info.Domains.SafeString() + if domains == "" { + domains = "-" + } + status := "No client" + if info.HasClient { + status = "Active" + } + data.Clients = append(data.Clients, clientData{ + AccountID: string(info.AccountID), + Domains: domains, + Age: time.Since(info.CreatedAt).Round(time.Second).String(), + Status: status, + }) + } + + h.renderTemplate(w, "clients", data) +} + +type clientDetailData struct { + AccountID string + ActiveTab string + Content string +} + +func (h *Handler) handleClientStatus(w http.ResponseWriter, r *http.Request, accountID types.AccountID, wantJSON bool) { + client, ok := h.provider.GetClient(accountID) + if !ok { + http.Error(w, "Client not found: "+string(accountID), http.StatusNotFound) + return + } + + fullStatus, err := client.Status() + if err != nil { + http.Error(w, "Error getting status: "+err.Error(), http.StatusInternalServerError) + return + } + + // Parse filter parameters + query := r.URL.Query() + statusFilter := query.Get("filter-by-status") + connectionTypeFilter := query.Get("filter-by-connection-type") + + var prefixNamesFilter []string + var prefixNamesFilterMap map[string]struct{} + if names := query.Get("filter-by-names"); names != "" { + prefixNamesFilter = strings.Split(names, ",") + prefixNamesFilterMap = make(map[string]struct{}) + for _, name := range prefixNamesFilter { + prefixNamesFilterMap[strings.ToLower(strings.TrimSpace(name))] = struct{}{} + } + } + + var ipsFilterMap map[string]struct{} + if ips := query.Get("filter-by-ips"); ips != "" { + ipsFilterMap = make(map[string]struct{}) + for _, ip := range strings.Split(ips, ",") { + ipsFilterMap[strings.TrimSpace(ip)] = struct{}{} + } + } + + pbStatus := nbstatus.ToProtoFullStatus(fullStatus) + overview := nbstatus.ConvertToStatusOutputOverview( + pbStatus, + false, + version.NetbirdVersion(), + statusFilter, + prefixNamesFilter, + prefixNamesFilterMap, + ipsFilterMap, + connectionTypeFilter, + "", + ) + + if wantJSON { + h.writeJSON(w, map[string]interface{}{ + "account_id": accountID, + "status": overview.FullDetailSummary(), + }) + return + } + + data := clientDetailData{ + AccountID: string(accountID), + ActiveTab: "status", + Content: overview.FullDetailSummary(), + } + + h.renderTemplate(w, "clientDetail", data) +} + +func (h *Handler) handleClientSyncResponse(w http.ResponseWriter, _ *http.Request, accountID types.AccountID, wantJSON bool) { + client, ok := h.provider.GetClient(accountID) + if !ok { + http.Error(w, "Client not found: "+string(accountID), http.StatusNotFound) + return + } + + syncResp, err := client.GetLatestSyncResponse() + if err != nil { + http.Error(w, "Error getting sync response: "+err.Error(), http.StatusInternalServerError) + return + } + + if syncResp == nil { + http.Error(w, "No sync response available for client: "+string(accountID), http.StatusNotFound) + return + } + + opts := protojson.MarshalOptions{ + EmitUnpopulated: true, + UseProtoNames: true, + Indent: " ", + AllowPartial: true, + } + + jsonBytes, err := opts.Marshal(syncResp) + if err != nil { + http.Error(w, "Error marshaling sync response: "+err.Error(), http.StatusInternalServerError) + return + } + + if wantJSON { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write(jsonBytes) + return + } + + data := clientDetailData{ + AccountID: string(accountID), + ActiveTab: "syncresponse", + Content: string(jsonBytes), + } + + h.renderTemplate(w, "clientDetail", data) +} + +type toolsData struct { + AccountID string +} + +func (h *Handler) handleClientTools(w http.ResponseWriter, _ *http.Request, accountID types.AccountID) { + _, ok := h.provider.GetClient(accountID) + if !ok { + http.Error(w, "Client not found: "+string(accountID), http.StatusNotFound) + return + } + + data := toolsData{ + AccountID: string(accountID), + } + + h.renderTemplate(w, "tools", data) +} + +func (h *Handler) handlePingTCP(w http.ResponseWriter, r *http.Request, accountID types.AccountID) { + client, ok := h.provider.GetClient(accountID) + if !ok { + h.writeJSON(w, map[string]interface{}{"error": "client not found"}) + return + } + + host := r.URL.Query().Get("host") + portStr := r.URL.Query().Get("port") + if host == "" || portStr == "" { + h.writeJSON(w, map[string]interface{}{"error": "host and port parameters required"}) + return + } + + port, err := strconv.Atoi(portStr) + if err != nil || port < 1 || port > 65535 { + h.writeJSON(w, map[string]interface{}{"error": "invalid port"}) + return + } + + timeout := defaultPingTimeout + if t := r.URL.Query().Get("timeout"); t != "" { + if d, err := time.ParseDuration(t); err == nil { + timeout = d + } + } + + ctx, cancel := context.WithTimeout(r.Context(), timeout) + defer cancel() + + address := fmt.Sprintf("%s:%d", host, port) + start := time.Now() + + conn, err := client.Dial(ctx, "tcp", address) + if err != nil { + h.writeJSON(w, map[string]interface{}{ + "success": false, + "host": host, + "port": port, + "error": err.Error(), + }) + return + } + if err := conn.Close(); err != nil { + h.logger.Debugf("close tcp ping connection: %v", err) + } + + latency := time.Since(start) + h.writeJSON(w, map[string]interface{}{ + "success": true, + "host": host, + "port": port, + "latency_ms": latency.Milliseconds(), + "latency": formatDuration(latency), + }) +} + +func (h *Handler) handleLogLevel(w http.ResponseWriter, r *http.Request, accountID types.AccountID) { + client, ok := h.provider.GetClient(accountID) + if !ok { + h.writeJSON(w, map[string]interface{}{"error": "client not found"}) + return + } + + level := r.URL.Query().Get("level") + if level == "" { + h.writeJSON(w, map[string]interface{}{"error": "level parameter required (trace, debug, info, warn, error)"}) + return + } + + if err := client.SetLogLevel(level); err != nil { + h.writeJSON(w, map[string]interface{}{ + "success": false, + "error": err.Error(), + }) + return + } + + h.writeJSON(w, map[string]interface{}{ + "success": true, + "level": level, + }) +} + +const clientActionTimeout = 30 * time.Second + +func (h *Handler) handleClientStart(w http.ResponseWriter, r *http.Request, accountID types.AccountID) { + client, ok := h.provider.GetClient(accountID) + if !ok { + h.writeJSON(w, map[string]interface{}{"error": "client not found"}) + return + } + + ctx, cancel := context.WithTimeout(r.Context(), clientActionTimeout) + defer cancel() + + if err := client.Start(ctx); err != nil { + h.writeJSON(w, map[string]interface{}{ + "success": false, + "error": err.Error(), + }) + return + } + + h.writeJSON(w, map[string]interface{}{ + "success": true, + "message": "client started", + }) +} + +func (h *Handler) handleClientStop(w http.ResponseWriter, r *http.Request, accountID types.AccountID) { + client, ok := h.provider.GetClient(accountID) + if !ok { + h.writeJSON(w, map[string]interface{}{"error": "client not found"}) + return + } + + ctx, cancel := context.WithTimeout(r.Context(), clientActionTimeout) + defer cancel() + + if err := client.Stop(ctx); err != nil { + h.writeJSON(w, map[string]interface{}{ + "success": false, + "error": err.Error(), + }) + return + } + + h.writeJSON(w, map[string]interface{}{ + "success": true, + "message": "client stopped", + }) +} + +type healthData struct { + Uptime string +} + +func (h *Handler) handleHealth(w http.ResponseWriter, _ *http.Request, wantJSON bool) { + if wantJSON { + h.writeJSON(w, map[string]interface{}{ + "status": "ok", + "uptime": time.Since(h.startTime).Round(10 * time.Millisecond).String(), + }) + return + } + + data := healthData{ + Uptime: time.Since(h.startTime).Round(time.Second).String(), + } + + h.renderTemplate(w, "health", data) +} + +func (h *Handler) renderTemplate(w http.ResponseWriter, name string, data interface{}) { + w.Header().Set("Content-Type", "text/html; charset=utf-8") + tmpl := h.getTemplates() + if tmpl == nil { + http.Error(w, "Templates not loaded", http.StatusInternalServerError) + return + } + if err := tmpl.ExecuteTemplate(w, name, data); err != nil { + h.logger.Errorf("execute template %s: %v", name, err) + http.Error(w, "Template error", http.StatusInternalServerError) + } +} + +func (h *Handler) writeJSON(w http.ResponseWriter, v interface{}) { + w.Header().Set("Content-Type", "application/json") + enc := json.NewEncoder(w) + enc.SetIndent("", " ") + if err := enc.Encode(v); err != nil { + h.logger.Errorf("encode JSON response: %v", err) + } +} diff --git a/proxy/internal/debug/templates/base.html b/proxy/internal/debug/templates/base.html new file mode 100644 index 000000000..737bd5b85 --- /dev/null +++ b/proxy/internal/debug/templates/base.html @@ -0,0 +1,101 @@ +{{define "style"}} +body { + font-family: monospace; + margin: 20px; + background: #1a1a1a; + color: #eee; +} +a { + color: #6cf; +} +h1, h2, h3 { + color: #fff; +} +.info { + color: #aaa; +} +table { + border-collapse: collapse; + margin: 10px 0; +} +th, td { + border: 1px solid #444; + padding: 8px; + text-align: left; +} +th { + background: #333; +} +.nav { + margin-bottom: 20px; +} +.nav a { + margin-right: 15px; + padding: 8px 16px; + background: #333; + text-decoration: none; + border-radius: 4px; +} +.nav a.active { + background: #6cf; + color: #000; +} +pre { + background: #222; + padding: 15px; + border-radius: 4px; + overflow-x: auto; + white-space: pre-wrap; +} +input, select, textarea { + background: #333; + color: #eee; + border: 1px solid #555; + padding: 8px; + border-radius: 4px; + font-family: monospace; +} +input:focus, select:focus, textarea:focus { + outline: none; + border-color: #6cf; +} +button { + background: #6cf; + color: #000; + border: none; + padding: 8px 16px; + border-radius: 4px; + cursor: pointer; + font-family: monospace; +} +button:hover { + background: #5be; +} +button:disabled { + background: #555; + color: #888; + cursor: not-allowed; +} +.form-group { + margin-bottom: 15px; +} +.form-group label { + display: block; + margin-bottom: 5px; + color: #aaa; +} +.form-row { + display: flex; + gap: 10px; + align-items: flex-end; +} +.result { + margin-top: 20px; +} +.success { + color: #5f5; +} +.error { + color: #f55; +} +{{end}} diff --git a/proxy/internal/debug/templates/client_detail.html b/proxy/internal/debug/templates/client_detail.html new file mode 100644 index 000000000..c58e26f6c --- /dev/null +++ b/proxy/internal/debug/templates/client_detail.html @@ -0,0 +1,19 @@ +{{define "clientDetail"}} + + + + Client {{.AccountID}} + + + +

Client: {{.AccountID}}

+ +
{{.Content}}
+ + +{{end}} diff --git a/proxy/internal/debug/templates/clients.html b/proxy/internal/debug/templates/clients.html new file mode 100644 index 000000000..68f286272 --- /dev/null +++ b/proxy/internal/debug/templates/clients.html @@ -0,0 +1,33 @@ +{{define "clients"}} + + + + Clients + + + +

All Clients

+

Uptime: {{.Uptime}} | ← Back

+ {{if .Clients}} + + + + + + + + {{range .Clients}} + + + + + + + {{end}} +
Account IDDomainsAgeStatus
{{.AccountID}}{{.Domains}}{{.Age}}{{.Status}}
+ {{else}} +

No clients connected

+ {{end}} + + +{{end}} diff --git a/proxy/internal/debug/templates/health.html b/proxy/internal/debug/templates/health.html new file mode 100644 index 000000000..f584f8357 --- /dev/null +++ b/proxy/internal/debug/templates/health.html @@ -0,0 +1,14 @@ +{{define "health"}} + + + + Health + + + +

OK

+

Uptime: {{.Uptime}}

+

← Back

+ + +{{end}} diff --git a/proxy/internal/debug/templates/index.html b/proxy/internal/debug/templates/index.html new file mode 100644 index 000000000..ac01e12e9 --- /dev/null +++ b/proxy/internal/debug/templates/index.html @@ -0,0 +1,40 @@ +{{define "index"}} + + + + NetBird Proxy Debug + + + +

NetBird Proxy Debug

+

Version: {{.Version}} | Uptime: {{.Uptime}}

+

Clients ({{.ClientCount}}) | Domains ({{.TotalDomains}})

+ {{if .Clients}} + + + + + + + + {{range .Clients}} + + + + + + + {{end}} +
Account IDDomainsAgeStatus
{{.AccountID}}{{.Domains}}{{.Age}}{{.Status}}
+ {{else}} +

No clients connected

+ {{end}} +

Endpoints

+ +

Add ?format=json or /json suffix for JSON output

+ + +{{end}} diff --git a/proxy/internal/debug/templates/tools.html b/proxy/internal/debug/templates/tools.html new file mode 100644 index 000000000..091b3e0a1 --- /dev/null +++ b/proxy/internal/debug/templates/tools.html @@ -0,0 +1,142 @@ +{{define "tools"}} + + + + Client {{.AccountID}} - Tools + + + +

Client: {{.AccountID}}

+ + +

Client Control

+
+
+ + +
+
+ + +
+
+
+ +

Log Level

+
+
+ + +
+
+ + +
+
+
+ +

TCP Ping

+
+
+ + +
+
+ + +
+
+ + +
+
+
+ + + + +{{end}} diff --git a/proxy/internal/health/health.go b/proxy/internal/health/health.go new file mode 100644 index 000000000..36ed51674 --- /dev/null +++ b/proxy/internal/health/health.go @@ -0,0 +1,340 @@ +// Package health provides health probes for the proxy server. +package health + +import ( + "context" + "encoding/json" + "net" + "net/http" + "strings" + "sync" + "time" + + log "github.com/sirupsen/logrus" + + "github.com/netbirdio/netbird/client/embed" + "github.com/netbirdio/netbird/proxy/internal/types" +) + +const ( + maxConcurrentChecks = 3 + maxClientCheckTimeout = 5 * time.Minute +) + +// clientProvider provides access to NetBird clients for health checks. +type clientProvider interface { + ListClientsForStartup() map[types.AccountID]*embed.Client +} + +// Checker tracks health state and provides probe endpoints. +type Checker struct { + logger *log.Logger + provider clientProvider + + mu sync.RWMutex + managementConnected bool + initialSyncComplete bool + + // checkSem limits concurrent client health checks. + checkSem chan struct{} +} + +// ClientHealth represents the health status of a single NetBird client. +type ClientHealth struct { + Healthy bool `json:"healthy"` + ManagementConnected bool `json:"management_connected"` + SignalConnected bool `json:"signal_connected"` + RelaysConnected int `json:"relays_connected"` + RelaysTotal int `json:"relays_total"` + Error string `json:"error,omitempty"` +} + +// ProbeResponse represents the JSON response for health probes. +type ProbeResponse struct { + Status string `json:"status"` + Checks map[string]bool `json:"checks,omitempty"` + Clients map[types.AccountID]ClientHealth `json:"clients,omitempty"` +} + +// Server runs the health probe HTTP server on a dedicated port. +type Server struct { + server *http.Server + logger *log.Logger + checker *Checker +} + +// SetManagementConnected updates the management connection state. +func (c *Checker) SetManagementConnected(connected bool) { + c.mu.Lock() + defer c.mu.Unlock() + c.managementConnected = connected +} + +// SetInitialSyncComplete marks that the initial mapping sync has completed. +func (c *Checker) SetInitialSyncComplete() { + c.mu.Lock() + defer c.mu.Unlock() + c.initialSyncComplete = true +} + +// CheckClientsConnected verifies all clients are connected to management/signal/relay. +// Uses the provided context for timeout/cancellation, with a maximum bound of maxClientCheckTimeout. +// Limits concurrent checks via semaphore. +func (c *Checker) CheckClientsConnected(ctx context.Context) (bool, map[types.AccountID]ClientHealth) { + // Apply upper bound timeout in case parent context has no deadline + ctx, cancel := context.WithTimeout(ctx, maxClientCheckTimeout) + defer cancel() + + clients := c.provider.ListClientsForStartup() + + // No clients yet means not ready + if len(clients) == 0 { + return false, make(map[types.AccountID]ClientHealth) + } + + type result struct { + accountID types.AccountID + health ClientHealth + } + + resultsCh := make(chan result, len(clients)) + var wg sync.WaitGroup + + for accountID, client := range clients { + wg.Add(1) + go func(id types.AccountID, cl *embed.Client) { + defer wg.Done() + + // Acquire semaphore + select { + case c.checkSem <- struct{}{}: + defer func() { <-c.checkSem }() + case <-ctx.Done(): + resultsCh <- result{id, ClientHealth{Healthy: false, Error: ctx.Err().Error()}} + return + } + + resultsCh <- result{id, checkClientHealth(cl)} + }(accountID, client) + } + + go func() { + wg.Wait() + close(resultsCh) + }() + + results := make(map[types.AccountID]ClientHealth) + allHealthy := true + for r := range resultsCh { + results[r.accountID] = r.health + if !r.health.Healthy { + allHealthy = false + } + } + + return allHealthy, results +} + +// LivenessProbe returns true if the process is alive. +// This should always return true if we can respond. +func (c *Checker) LivenessProbe() bool { + return true +} + +// ReadinessProbe returns true if the server can accept traffic. +func (c *Checker) ReadinessProbe() bool { + c.mu.RLock() + defer c.mu.RUnlock() + return c.managementConnected +} + +// StartupProbe checks if initial startup is complete. +// Checks management connection, initial sync, and all client health directly. +// Uses the provided context for timeout/cancellation. +func (c *Checker) StartupProbe(ctx context.Context) bool { + c.mu.RLock() + mgmt := c.managementConnected + sync := c.initialSyncComplete + c.mu.RUnlock() + + if !mgmt || !sync { + return false + } + + // Check all clients are connected to management/signal/relay + allHealthy, _ := c.CheckClientsConnected(ctx) + return allHealthy +} + +// Handler returns an http.Handler for health probe endpoints. +func (c *Checker) Handler() http.Handler { + mux := http.NewServeMux() + mux.HandleFunc("/healthz/live", c.handleLiveness) + mux.HandleFunc("/healthz/ready", c.handleReadiness) + mux.HandleFunc("/healthz/startup", c.handleStartup) + mux.HandleFunc("/healthz", c.handleFull) + return mux +} + +func (c *Checker) handleLiveness(w http.ResponseWriter, r *http.Request) { + if c.LivenessProbe() { + c.writeProbeResponse(w, http.StatusOK, "ok", nil, nil) + return + } + c.writeProbeResponse(w, http.StatusServiceUnavailable, "fail", nil, nil) +} + +func (c *Checker) handleReadiness(w http.ResponseWriter, r *http.Request) { + c.mu.RLock() + checks := map[string]bool{ + "management_connected": c.managementConnected, + } + c.mu.RUnlock() + + if c.ReadinessProbe() { + c.writeProbeResponse(w, http.StatusOK, "ok", checks, nil) + return + } + c.writeProbeResponse(w, http.StatusServiceUnavailable, "fail", checks, nil) +} + +func (c *Checker) handleStartup(w http.ResponseWriter, r *http.Request) { + c.mu.RLock() + mgmt := c.managementConnected + sync := c.initialSyncComplete + c.mu.RUnlock() + + // Check clients directly using request context + allClientsHealthy, clientHealth := c.CheckClientsConnected(r.Context()) + + checks := map[string]bool{ + "management_connected": mgmt, + "initial_sync_complete": sync, + "all_clients_healthy": allClientsHealthy, + } + + if c.StartupProbe(r.Context()) { + c.writeProbeResponse(w, http.StatusOK, "ok", checks, clientHealth) + return + } + c.writeProbeResponse(w, http.StatusServiceUnavailable, "fail", checks, clientHealth) +} + +func (c *Checker) handleFull(w http.ResponseWriter, r *http.Request) { + c.mu.RLock() + mgmt := c.managementConnected + sync := c.initialSyncComplete + c.mu.RUnlock() + + allClientsHealthy, clientHealth := c.CheckClientsConnected(r.Context()) + + checks := map[string]bool{ + "management_connected": mgmt, + "initial_sync_complete": sync, + "all_clients_healthy": allClientsHealthy, + } + + status := "ok" + statusCode := http.StatusOK + if !c.ReadinessProbe() { + status = "fail" + statusCode = http.StatusServiceUnavailable + } + + c.writeProbeResponse(w, statusCode, status, checks, clientHealth) +} + +func (c *Checker) writeProbeResponse(w http.ResponseWriter, statusCode int, status string, checks map[string]bool, clients map[types.AccountID]ClientHealth) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(statusCode) + + resp := ProbeResponse{ + Status: status, + Checks: checks, + Clients: clients, + } + if err := json.NewEncoder(w).Encode(resp); err != nil { + c.logger.Debugf("write health response: %v", err) + } +} + +// ListenAndServe starts the health probe server. +func (s *Server) ListenAndServe() error { + s.logger.Infof("starting health probe server on %s", s.server.Addr) + return s.server.ListenAndServe() +} + +// Serve starts the health probe server on the given listener. +func (s *Server) Serve(l net.Listener) error { + s.logger.Infof("starting health probe server on %s", l.Addr()) + return s.server.Serve(l) +} + +// Shutdown gracefully shuts down the health probe server. +func (s *Server) Shutdown(ctx context.Context) error { + return s.server.Shutdown(ctx) +} + +// NewChecker creates a new health checker. +func NewChecker(logger *log.Logger, provider clientProvider) *Checker { + if logger == nil { + logger = log.StandardLogger() + } + return &Checker{ + logger: logger, + provider: provider, + checkSem: make(chan struct{}, maxConcurrentChecks), + } +} + +// NewServer creates a new health probe server. +func NewServer(addr string, checker *Checker, logger *log.Logger) *Server { + if logger == nil { + logger = log.StandardLogger() + } + return &Server{ + server: &http.Server{ + Addr: addr, + Handler: checker.Handler(), + ReadTimeout: 5 * time.Second, + WriteTimeout: 5 * time.Second, + }, + logger: logger, + checker: checker, + } +} + +func checkClientHealth(client *embed.Client) ClientHealth { + status, err := client.Status() + if err != nil { + return ClientHealth{ + Healthy: false, + Error: err.Error(), + } + } + + // Count only rel:// and rels:// relays (not stun/turn) + var relayCount, relaysConnected int + for _, relay := range status.Relays { + if !strings.HasPrefix(relay.URI, "rel://") && !strings.HasPrefix(relay.URI, "rels://") { + continue + } + relayCount++ + if relay.Err == nil { + relaysConnected++ + } + } + + // Client is healthy if connected to management, signal, and at least one relay (if any are defined) + healthy := status.ManagementState.Connected && + status.SignalState.Connected && + (relayCount == 0 || relaysConnected > 0) + + return ClientHealth{ + Healthy: healthy, + ManagementConnected: status.ManagementState.Connected, + SignalConnected: status.SignalState.Connected, + RelaysConnected: relaysConnected, + RelaysTotal: relayCount, + } +} diff --git a/proxy/internal/health/health_test.go b/proxy/internal/health/health_test.go new file mode 100644 index 000000000..d824bcf90 --- /dev/null +++ b/proxy/internal/health/health_test.go @@ -0,0 +1,155 @@ +package health + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/netbirdio/netbird/client/embed" + "github.com/netbirdio/netbird/proxy/internal/types" +) + +type mockClientProvider struct { + clients map[types.AccountID]*embed.Client +} + +func (m *mockClientProvider) ListClientsForStartup() map[types.AccountID]*embed.Client { + return m.clients +} + +func TestChecker_LivenessProbe(t *testing.T) { + checker := NewChecker(nil, &mockClientProvider{}) + + // Liveness should always return true if we can respond. + assert.True(t, checker.LivenessProbe()) +} + +func TestChecker_ReadinessProbe(t *testing.T) { + checker := NewChecker(nil, &mockClientProvider{}) + + // Initially not ready (management not connected). + assert.False(t, checker.ReadinessProbe()) + + // After management connects, should be ready. + checker.SetManagementConnected(true) + assert.True(t, checker.ReadinessProbe()) + + // If management disconnects, should not be ready. + checker.SetManagementConnected(false) + assert.False(t, checker.ReadinessProbe()) +} + +func TestChecker_StartupProbe_NoClients(t *testing.T) { + checker := NewChecker(nil, &mockClientProvider{}) + + // Initially startup not complete. + assert.False(t, checker.StartupProbe(context.Background())) + + // Just management connected is not enough. + checker.SetManagementConnected(true) + assert.False(t, checker.StartupProbe(context.Background())) + + // Management + initial sync but no clients = not ready + checker.SetInitialSyncComplete() + assert.False(t, checker.StartupProbe(context.Background())) +} + +func TestChecker_Handler_Liveness(t *testing.T) { + checker := NewChecker(nil, &mockClientProvider{}) + handler := checker.Handler() + + req := httptest.NewRequest(http.MethodGet, "/healthz/live", nil) + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + + assert.Equal(t, http.StatusOK, rec.Code) + + var resp ProbeResponse + require.NoError(t, json.NewDecoder(rec.Body).Decode(&resp)) + assert.Equal(t, "ok", resp.Status) +} + +func TestChecker_Handler_Readiness_NotReady(t *testing.T) { + checker := NewChecker(nil, &mockClientProvider{}) + handler := checker.Handler() + + req := httptest.NewRequest(http.MethodGet, "/healthz/ready", nil) + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + + assert.Equal(t, http.StatusServiceUnavailable, rec.Code) + + var resp ProbeResponse + require.NoError(t, json.NewDecoder(rec.Body).Decode(&resp)) + assert.Equal(t, "fail", resp.Status) + assert.False(t, resp.Checks["management_connected"]) +} + +func TestChecker_Handler_Readiness_Ready(t *testing.T) { + checker := NewChecker(nil, &mockClientProvider{}) + checker.SetManagementConnected(true) + handler := checker.Handler() + + req := httptest.NewRequest(http.MethodGet, "/healthz/ready", nil) + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + + assert.Equal(t, http.StatusOK, rec.Code) + + var resp ProbeResponse + require.NoError(t, json.NewDecoder(rec.Body).Decode(&resp)) + assert.Equal(t, "ok", resp.Status) + assert.True(t, resp.Checks["management_connected"]) +} + +func TestChecker_Handler_Startup_NotComplete(t *testing.T) { + checker := NewChecker(nil, &mockClientProvider{}) + handler := checker.Handler() + + req := httptest.NewRequest(http.MethodGet, "/healthz/startup", nil) + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + + assert.Equal(t, http.StatusServiceUnavailable, rec.Code) + + var resp ProbeResponse + require.NoError(t, json.NewDecoder(rec.Body).Decode(&resp)) + assert.Equal(t, "fail", resp.Status) +} + +func TestChecker_Handler_Full(t *testing.T) { + checker := NewChecker(nil, &mockClientProvider{}) + checker.SetManagementConnected(true) + handler := checker.Handler() + + req := httptest.NewRequest(http.MethodGet, "/healthz", nil) + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + + assert.Equal(t, http.StatusOK, rec.Code) + + var resp ProbeResponse + require.NoError(t, json.NewDecoder(rec.Body).Decode(&resp)) + assert.Equal(t, "ok", resp.Status) + assert.NotNil(t, resp.Checks) + // Clients may be empty map when no clients exist. + assert.Empty(t, resp.Clients) +} + +func TestChecker_StartupProbe_RespectsContext(t *testing.T) { + checker := NewChecker(nil, &mockClientProvider{}) + checker.SetManagementConnected(true) + checker.SetInitialSyncComplete() + + // Cancelled context should return false quickly + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + result := checker.StartupProbe(ctx) + assert.False(t, result) +} diff --git a/proxy/internal/proxy/context.go b/proxy/internal/proxy/context.go index b437a9610..36da03d30 100644 --- a/proxy/internal/proxy/context.go +++ b/proxy/internal/proxy/context.go @@ -3,6 +3,8 @@ package proxy import ( "context" "sync" + + "github.com/netbirdio/netbird/proxy/internal/types" ) type requestContextKey string @@ -18,7 +20,7 @@ const ( type CapturedData struct { mu sync.RWMutex ServiceId string - AccountId string + AccountId types.AccountID } // SetServiceId safely sets the service ID @@ -36,14 +38,14 @@ func (c *CapturedData) GetServiceId() string { } // SetAccountId safely sets the account ID -func (c *CapturedData) SetAccountId(accountId string) { +func (c *CapturedData) SetAccountId(accountId types.AccountID) { c.mu.Lock() defer c.mu.Unlock() c.AccountId = accountId } // GetAccountId safely gets the account ID -func (c *CapturedData) GetAccountId() string { +func (c *CapturedData) GetAccountId() types.AccountID { c.mu.RLock() defer c.mu.RUnlock() return c.AccountId @@ -76,13 +78,13 @@ func ServiceIdFromContext(ctx context.Context) string { } return serviceId } -func withAccountId(ctx context.Context, accountId string) context.Context { +func withAccountId(ctx context.Context, accountId types.AccountID) context.Context { return context.WithValue(ctx, accountIdKey, accountId) } -func AccountIdFromContext(ctx context.Context) string { +func AccountIdFromContext(ctx context.Context) types.AccountID { v := ctx.Value(accountIdKey) - accountId, ok := v.(string) + accountId, ok := v.(types.AccountID) if !ok { return "" } diff --git a/proxy/internal/proxy/reverseproxy.go b/proxy/internal/proxy/reverseproxy.go index e90850961..d64073941 100644 --- a/proxy/internal/proxy/reverseproxy.go +++ b/proxy/internal/proxy/reverseproxy.go @@ -1,15 +1,24 @@ package proxy import ( + "context" + "errors" "net/http" "net/http/httputil" + "strings" "sync" + + log "github.com/sirupsen/logrus" + + "github.com/netbirdio/netbird/proxy/internal/roundtrip" + "github.com/netbirdio/netbird/proxy/web" ) type ReverseProxy struct { transport http.RoundTripper mappingsMux sync.RWMutex mappings map[string]Mapping + logger *log.Logger } // NewReverseProxy configures a new NetBird ReverseProxy. @@ -18,26 +27,31 @@ type ReverseProxy struct { // between requested URLs and targets. // The internal mappings can be modified using the AddMapping // and RemoveMapping functions. -func NewReverseProxy(transport http.RoundTripper) *ReverseProxy { +func NewReverseProxy(transport http.RoundTripper, logger *log.Logger) *ReverseProxy { + if logger == nil { + logger = log.StandardLogger() + } return &ReverseProxy{ transport: transport, mappings: make(map[string]Mapping), + logger: logger, } } func (p *ReverseProxy) ServeHTTP(w http.ResponseWriter, r *http.Request) { target, serviceId, accountID, exists := p.findTargetForRequest(r) if !exists { - // No mapping found so return an error here. - // TODO: prettier error page. - http.Error(w, http.StatusText(http.StatusNotFound), http.StatusNotFound) + web.ServeErrorPage(w, r, http.StatusNotFound, "Service Not Found", + "The requested service could not be found. Please check the URL, try refreshing, or check if the peer is running. If that doesn't work, see our documentation for help.") return } // Set the serviceId in the context for later retrieval. ctx := withServiceId(r.Context(), serviceId) - // Set the accountId in the context for later retrieval. + // Set the accountId in the context for later retrieval (for middleware). ctx = withAccountId(ctx, accountID) + // Set the accountId in the context for the roundtripper to use. + ctx = roundtrip.WithAccountID(ctx, accountID) // Also populate captured data if it exists (allows middleware to read after handler completes). // This solves the problem of passing data UP the middleware chain: we put a mutable struct @@ -50,5 +64,44 @@ func (p *ReverseProxy) ServeHTTP(w http.ResponseWriter, r *http.Request) { // Set up a reverse proxy using the transport and then use it to serve the request. proxy := httputil.NewSingleHostReverseProxy(target) proxy.Transport = p.transport + proxy.ErrorHandler = proxyErrorHandler proxy.ServeHTTP(w, r.WithContext(ctx)) } + +// proxyErrorHandler handles errors from the reverse proxy and serves +// user-friendly error pages instead of raw error responses. +func proxyErrorHandler(w http.ResponseWriter, r *http.Request, err error) { + title, message, code := classifyProxyError(err) + web.ServeErrorPage(w, r, code, title, message) +} + +// classifyProxyError determines the appropriate error title, message, and HTTP +// status code based on the error type. +func classifyProxyError(err error) (title, message string, code int) { + switch { + case errors.Is(err, context.DeadlineExceeded): + return "Request Timeout", + "The request timed out while trying to reach the service. Please refresh the page and try again.", + http.StatusGatewayTimeout + + case errors.Is(err, context.Canceled): + return "Request Canceled", + "The request was canceled before it could be completed. Please refresh the page and try again.", + http.StatusBadGateway + + case errors.Is(err, roundtrip.ErrNoAccountID): + return "Configuration Error", + "The request could not be processed due to a configuration issue. Please refresh the page and try again.", + http.StatusInternalServerError + + case strings.Contains(err.Error(), "connection refused"): + return "Service Unavailable", + "The connection to the service was refused. Please verify that the service is running and try again.", + http.StatusBadGateway + + default: + return "Peer Not Connected", + "The connection to the peer could not be established. Please ensure the peer is running and connected to the NetBird network.", + http.StatusBadGateway + } +} diff --git a/proxy/internal/proxy/servicemapping.go b/proxy/internal/proxy/servicemapping.go index be9c0ed29..e345bb622 100644 --- a/proxy/internal/proxy/servicemapping.go +++ b/proxy/internal/proxy/servicemapping.go @@ -6,16 +6,18 @@ import ( "net/url" "sort" "strings" + + "github.com/netbirdio/netbird/proxy/internal/types" ) type Mapping struct { ID string - AccountID string + AccountID types.AccountID Host string Paths map[string]*url.URL } -func (p *ReverseProxy) findTargetForRequest(req *http.Request) (*url.URL, string, string, bool) { +func (p *ReverseProxy) findTargetForRequest(req *http.Request) (*url.URL, string, types.AccountID, bool) { p.mappingsMux.RLock() if p.mappings == nil { p.mappingsMux.RUnlock() @@ -27,10 +29,13 @@ func (p *ReverseProxy) findTargetForRequest(req *http.Request) (*url.URL, string } defer p.mappingsMux.RUnlock() - host, _, err := net.SplitHostPort(req.Host) - if err != nil { - host = req.Host + // Strip port from host if present (e.g., "external.test:8443" -> "external.test") + host := req.Host + if h, _, err := net.SplitHostPort(host); err == nil { + host = h } + + p.logger.Debugf("looking for mapping for host: %s, path: %s", host, req.URL.Path) m, exists := p.mappings[host] if !exists { return nil, "", "", false diff --git a/proxy/internal/roundtrip/netbird.go b/proxy/internal/roundtrip/netbird.go index 5e591f75d..500801a97 100644 --- a/proxy/internal/roundtrip/netbird.go +++ b/proxy/internal/roundtrip/netbird.go @@ -4,166 +4,499 @@ import ( "context" "errors" "fmt" - "io" - "net" "net/http" "sync" "time" + "github.com/hashicorp/go-multierror" log "github.com/sirupsen/logrus" + "golang.org/x/exp/maps" + "golang.zx2c4.com/wireguard/wgctrl/wgtypes" + "google.golang.org/grpc" "github.com/netbirdio/netbird/client/embed" + nberrors "github.com/netbirdio/netbird/client/errors" + "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/util" ) -const deviceNamePrefix = "ingress-" +const deviceNamePrefix = "ingress-proxy-" + +// ErrNoAccountID is returned when a request context is missing the account ID. +var ErrNoAccountID = errors.New("no account ID in request context") + +// domainInfo holds metadata about a registered domain. +type domainInfo struct { + reverseProxyID string +} + +// clientEntry holds an embedded NetBird client and tracks which domains use it. +type clientEntry struct { + client *embed.Client + transport *http.Transport + domains map[domain.Domain]domainInfo + createdAt time.Time + started bool +} type statusNotifier interface { NotifyStatus(ctx context.Context, accountID, reverseProxyID, domain string, connected bool) error } +type managementClient interface { + CreateProxyPeer(ctx context.Context, req *proto.CreateProxyPeerRequest, opts ...grpc.CallOption) (*proto.CreateProxyPeerResponse, error) +} + // NetBird provides an http.RoundTripper implementation // backed by underlying NetBird connections. +// Clients are keyed by AccountID, allowing multiple domains to share the same connection. type NetBird struct { - mgmtAddr string - logger *log.Logger - - clientsMux sync.RWMutex - clients map[string]*embed.Client + mgmtAddr string + proxyID string + logger *log.Logger + mgmtClient managementClient + clientsMux sync.RWMutex + clients map[types.AccountID]*clientEntry + initLogOnce sync.Once statusNotifier statusNotifier } -func NewNetBird(mgmtAddr string, logger *log.Logger, notifier statusNotifier) *NetBird { - if logger == nil { - logger = log.StandardLogger() - } - return &NetBird{ - mgmtAddr: mgmtAddr, - logger: logger, - clients: make(map[string]*embed.Client), - statusNotifier: notifier, - } +// ClientDebugInfo contains debug information about a client. +type ClientDebugInfo struct { + AccountID types.AccountID + DomainCount int + Domains domain.List + HasClient bool + CreatedAt time.Time } -func (n *NetBird) AddPeer(ctx context.Context, domain, key, accountID, reverseProxyID string) error { - client, err := embed.New(embed.Options{ - DeviceName: deviceNamePrefix + domain, - ManagementURL: n.mgmtAddr, - SetupKey: key, - LogOutput: io.Discard, - BlockInbound: true, +// accountIDContextKey is the context key for storing the account ID. +type accountIDContextKey struct{} + +// AddPeer registers a domain 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. +// Multiple domains can share the same client. +func (n *NetBird) AddPeer(ctx context.Context, accountID types.AccountID, d domain.Domain, authToken, reverseProxyID string) error { + n.clientsMux.Lock() + + entry, exists := n.clients[accountID] + if exists { + // Client already exists for this account, just register the domain + entry.domains[d] = domainInfo{reverseProxyID: reverseProxyID} + started := entry.started + n.clientsMux.Unlock() + + n.logger.WithFields(log.Fields{ + "account_id": accountID, + "domain": d, + }).Debug("registered domain with existing client") + + // If client is already started, notify this domain as connected immediately + if started && n.statusNotifier != nil { + if err := n.statusNotifier.NotifyStatus(ctx, string(accountID), reverseProxyID, string(d), true); err != nil { + n.logger.WithFields(log.Fields{ + "account_id": accountID, + "domain": d, + }).WithError(err).Warn("failed to notify status for existing client") + } + } + return nil + } + + n.logger.WithFields(log.Fields{ + "account_id": accountID, + "reverse_proxy_id": reverseProxyID, + }).Debug("generating WireGuard keypair for new peer") + + privateKey, err := wgtypes.GeneratePrivateKey() + if err != nil { + n.clientsMux.Unlock() + return fmt.Errorf("generate wireguard private key: %w", err) + } + publicKey := privateKey.PublicKey() + + n.logger.WithFields(log.Fields{ + "account_id": accountID, + "reverse_proxy_id": reverseProxyID, + "public_key": publicKey.String(), + }).Debug("authenticating new proxy peer with management") + + // Authenticate with management using the one-time token and send public key + resp, err := n.mgmtClient.CreateProxyPeer(ctx, &proto.CreateProxyPeerRequest{ + ReverseProxyId: reverseProxyID, + AccountId: string(accountID), + Token: authToken, + WireguardPublicKey: publicKey.String(), }) if err != nil { + n.clientsMux.Unlock() + return fmt.Errorf("authenticate proxy peer with management: %w", err) + } + if resp != nil && !resp.GetSuccess() { + n.clientsMux.Unlock() + errMsg := "unknown error" + if resp.ErrorMessage != nil { + errMsg = *resp.ErrorMessage + } + return fmt.Errorf("proxy peer authentication failed: %s", errMsg) + } + + n.logger.WithFields(log.Fields{ + "account_id": accountID, + "reverse_proxy_id": reverseProxyID, + "public_key": publicKey.String(), + }).Info("proxy peer authenticated successfully with management") + + n.initLogOnce.Do(func() { + if err := util.InitLog(log.WarnLevel.String(), util.LogConsole); err != nil { + n.logger.WithField("account_id", accountID).Warnf("failed to initialize embedded client logging: %v", err) + } + }) + + // Create embedded NetBird client with the generated private key + // The peer has already been created via CreateProxyPeer RPC with the public key + wgPort := 0 + client, err := embed.New(embed.Options{ + DeviceName: deviceNamePrefix + n.proxyID, + ManagementURL: n.mgmtAddr, + PrivateKey: privateKey.String(), + LogLevel: log.WarnLevel.String(), + BlockInbound: true, + WireguardPort: &wgPort, + }) + if err != nil { + n.clientsMux.Unlock() return fmt.Errorf("create netbird client: %w", err) } + // Create a transport using the client dialer. We do this instead of using + // the client's HTTPClient to avoid issues with request validation that do + // not work with reverse proxied requests. + entry = &clientEntry{ + client: client, + domains: map[domain.Domain]domainInfo{d: {reverseProxyID: reverseProxyID}}, + transport: &http.Transport{ + DialContext: client.DialContext, + ForceAttemptHTTP2: true, + MaxIdleConns: 100, + IdleConnTimeout: 90 * time.Second, + TLSHandshakeTimeout: 10 * time.Second, + ExpectContinueTimeout: 1 * time.Second, + }, + createdAt: time.Now(), + started: false, + } + n.clients[accountID] = entry + n.clientsMux.Unlock() + + n.logger.WithFields(log.Fields{ + "account_id": accountID, + "domain": d, + }).Info("created new client for account") + // Attempt to start the client in the background, if this fails // then it is not ideal, but it isn't the end of the world because // we will try to start the client again before we use it. go func() { - startCtx, cancel := context.WithTimeout(ctx, 3*time.Second) + startCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() - err = client.Start(startCtx) - switch { - case errors.Is(err, context.DeadlineExceeded): - n.logger.Debug("netbird client timed out") - // This is not ideal, but we will try again later. - return - case err != nil: - n.logger.WithField("domain", domain).WithError(err).Error("Unable to start netbird client, will try again later.") + + if err := client.Start(startCtx); err != nil { + if errors.Is(err, context.DeadlineExceeded) { + n.logger.WithFields(log.Fields{ + "account_id": accountID, + }).Debug("netbird client start timed out, will retry on first request") + } else { + n.logger.WithFields(log.Fields{ + "account_id": accountID, + }).WithError(err).Error("failed to start netbird client") + } return } - // Notify management that tunnel is now active + // Mark client as started and notify all registered domains + n.clientsMux.Lock() + entry, exists := n.clients[accountID] + if exists { + entry.started = true + } + // Copy domain info while holding lock + var domainsToNotify []struct { + domain domain.Domain + reverseProxyID string + } + if exists { + for dom, info := range entry.domains { + domainsToNotify = append(domainsToNotify, struct { + domain domain.Domain + reverseProxyID string + }{domain: dom, reverseProxyID: info.reverseProxyID}) + } + } + n.clientsMux.Unlock() + + // Notify all domains that they're connected if n.statusNotifier != nil { - if err := n.statusNotifier.NotifyStatus(ctx, accountID, reverseProxyID, domain, true); err != nil { - n.logger.WithField("domain", domain).WithError(err).Warn("Failed to notify management about tunnel connection") - } else { - n.logger.WithField("domain", domain).Info("Successfully notified management about tunnel connection") + for _, domInfo := range domainsToNotify { + if err := n.statusNotifier.NotifyStatus(ctx, string(accountID), domInfo.reverseProxyID, string(domInfo.domain), true); err != nil { + n.logger.WithFields(log.Fields{ + "account_id": accountID, + "domain": domInfo.domain, + }).WithError(err).Warn("failed to notify tunnel connection status") + } else { + n.logger.WithFields(log.Fields{ + "account_id": accountID, + "domain": domInfo.domain, + }).Info("notified management about tunnel connection") + } } } }() - n.clientsMux.Lock() - defer n.clientsMux.Unlock() - n.clients[domain] = client return nil } -func (n *NetBird) RemovePeer(ctx context.Context, domain, accountID, reverseProxyID string) error { - n.clientsMux.RLock() - client, exists := n.clients[domain] - n.clientsMux.RUnlock() +// RemovePeer unregisters a domain from an account. The client is only stopped +// when no domains are using it anymore. +func (n *NetBird) RemovePeer(ctx context.Context, accountID types.AccountID, d domain.Domain) error { + n.clientsMux.Lock() + + entry, exists := n.clients[accountID] if !exists { - // Mission failed successfully! + n.clientsMux.Unlock() return nil } - if err := client.Stop(ctx); err != nil { - return fmt.Errorf("stop netbird client: %w", err) + + // Get domain info before deleting + domInfo, domainExists := entry.domains[d] + if !domainExists { + n.clientsMux.Unlock() + return nil } - // Notify management that tunnel is disconnected + delete(entry.domains, d) + + // If there are still domains using this client, keep it running + if len(entry.domains) > 0 { + n.clientsMux.Unlock() + + n.logger.WithFields(log.Fields{ + "account_id": accountID, + "domain": d, + "remaining_domains": len(entry.domains), + }).Debug("unregistered domain, client still in use") + + // Notify this domain as disconnected + if n.statusNotifier != nil { + if err := n.statusNotifier.NotifyStatus(ctx, string(accountID), domInfo.reverseProxyID, 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 + delete(n.clients, accountID) + n.clientsMux.Unlock() + + // Notify disconnection before stopping if n.statusNotifier != nil { - if err := n.statusNotifier.NotifyStatus(ctx, accountID, reverseProxyID, domain, false); err != nil { - n.logger.WithField("domain", domain).WithError(err).Warn("Failed to notify management about tunnel disconnection") - } else { - n.logger.WithField("domain", domain).Info("Successfully notified management about tunnel disconnection") + if err := n.statusNotifier.NotifyStatus(ctx, string(accountID), domInfo.reverseProxyID, string(d), false); err != nil { + n.logger.WithFields(log.Fields{ + "account_id": accountID, + "domain": d, + }).WithError(err).Warn("failed to notify tunnel disconnection status") } } - n.clientsMux.Lock() - defer n.clientsMux.Unlock() - delete(n.clients, domain) + transport.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 } +// RoundTrip implements http.RoundTripper. It looks up the client for the account +// specified in the request context and uses it to dial the backend. func (n *NetBird) RoundTrip(req *http.Request) (*http.Response, error) { - host, _, err := net.SplitHostPort(req.Host) - if err != nil { - host = req.Host + accountID := AccountIDFromContext(req.Context()) + if accountID == "" { + return nil, ErrNoAccountID } + + // Copy references while holding lock, then unlock early to avoid blocking + // other requests during the potentially slow RoundTrip. n.clientsMux.RLock() - client, exists := n.clients[host] - // Immediately unlock after retrieval here rather than defer to avoid - // the call to client.Do blocking other clients being used whilst one - // is in use. - n.clientsMux.RUnlock() + entry, exists := n.clients[accountID] if !exists { - return nil, fmt.Errorf("no peer connection found for host: %s", host) + n.clientsMux.RUnlock() + return nil, fmt.Errorf("no peer connection found for account: %s", accountID) } + client := entry.client + transport := entry.transport + n.clientsMux.RUnlock() // Attempt to start the client, if the client is already running then // it will return an error that we ignore, if this hits a timeout then // this request is unprocessable. - startCtx, cancel := context.WithTimeout(req.Context(), 3*time.Second) + startCtx, cancel := context.WithTimeout(req.Context(), 10*time.Second) defer cancel() - err = client.Start(startCtx) - switch { - case errors.Is(err, embed.ErrClientAlreadyStarted): - break - case err != nil: - return nil, fmt.Errorf("start netbird client: %w", err) + if err := client.Start(startCtx); err != nil { + if !errors.Is(err, embed.ErrClientAlreadyStarted) { + return nil, fmt.Errorf("start netbird client: %w", err) + } } n.logger.WithFields(log.Fields{ - "host": host, + "account_id": accountID, + "host": req.Host, "url": req.URL.String(), "requestURI": req.RequestURI, "method": req.Method, }).Debug("running roundtrip for peer connection") - // Create a new transport using the client dialer and perform the roundtrip. - // We do this instead of using the client HTTPClient to avoid issues around - // client request validation that do not work with the reverse proxied - // requests. - // Other values are simply copied from the http.DefaultTransport which the - // standard reverse proxy implementation would have used. - // TODO: tune this transport for our needs. - return (&http.Transport{ - DialContext: client.DialContext, - MaxIdleConns: 100, - IdleConnTimeout: 90 * time.Second, - TLSHandshakeTimeout: 10 * time.Second, - ExpectContinueTimeout: 1 * time.Second, - }).RoundTrip(req) + return transport.RoundTrip(req) +} + +// StopAll stops all clients. +func (n *NetBird) StopAll(ctx context.Context) error { + n.clientsMux.Lock() + defer n.clientsMux.Unlock() + + var merr *multierror.Error + for accountID, entry := range n.clients { + entry.transport.CloseIdleConnections() + if err := entry.client.Stop(ctx); err != nil { + n.logger.WithFields(log.Fields{ + "account_id": accountID, + }).WithError(err).Warn("failed to stop netbird client during shutdown") + merr = multierror.Append(merr, err) + } + } + maps.Clear(n.clients) + + return nberrors.FormatErrorOrNil(merr) +} + +// HasClient returns true if there is a client for the given account. +func (n *NetBird) HasClient(accountID types.AccountID) bool { + n.clientsMux.RLock() + defer n.clientsMux.RUnlock() + _, exists := n.clients[accountID] + return exists +} + +// DomainCount returns the number of domains registered for the given account. +// Returns 0 if the account has no client. +func (n *NetBird) DomainCount(accountID types.AccountID) int { + n.clientsMux.RLock() + defer n.clientsMux.RUnlock() + entry, exists := n.clients[accountID] + if !exists { + return 0 + } + return len(entry.domains) +} + +// ClientCount returns the total number of active clients. +func (n *NetBird) ClientCount() int { + n.clientsMux.RLock() + defer n.clientsMux.RUnlock() + return len(n.clients) +} + +// GetClient returns the embed.Client for the given account ID. +func (n *NetBird) GetClient(accountID types.AccountID) (*embed.Client, bool) { + n.clientsMux.RLock() + defer n.clientsMux.RUnlock() + entry, exists := n.clients[accountID] + if !exists { + return nil, false + } + return entry.client, true +} + +// ListClientsForDebug returns information about all clients for debug purposes. +func (n *NetBird) ListClientsForDebug() map[types.AccountID]ClientDebugInfo { + n.clientsMux.RLock() + defer n.clientsMux.RUnlock() + + result := make(map[types.AccountID]ClientDebugInfo) + for accountID, entry := range n.clients { + domains := make(domain.List, 0, len(entry.domains)) + for d := range entry.domains { + domains = append(domains, d) + } + result[accountID] = ClientDebugInfo{ + AccountID: accountID, + DomainCount: len(entry.domains), + Domains: domains, + HasClient: entry.client != nil, + CreatedAt: entry.createdAt, + } + } + return result +} + +// ListClientsForStartup returns all embed.Client instances for health checks. +func (n *NetBird) ListClientsForStartup() map[types.AccountID]*embed.Client { + n.clientsMux.RLock() + defer n.clientsMux.RUnlock() + + result := make(map[types.AccountID]*embed.Client) + for accountID, entry := range n.clients { + if entry.client != nil { + result[accountID] = entry.client + } + } + return result +} + +// NewNetBird creates a new NetBird transport. +func NewNetBird(mgmtAddr, proxyID string, logger *log.Logger, notifier statusNotifier, mgmtClient managementClient) *NetBird { + if logger == nil { + logger = log.StandardLogger() + } + return &NetBird{ + mgmtAddr: mgmtAddr, + proxyID: proxyID, + logger: logger, + clients: make(map[types.AccountID]*clientEntry), + statusNotifier: notifier, + mgmtClient: mgmtClient, + } +} + +// WithAccountID adds the account ID to the context. +func WithAccountID(ctx context.Context, accountID types.AccountID) context.Context { + return context.WithValue(ctx, accountIDContextKey{}, accountID) +} + +// AccountIDFromContext retrieves the account ID from the context. +func AccountIDFromContext(ctx context.Context) types.AccountID { + v := ctx.Value(accountIDContextKey{}) + if v == nil { + return "" + } + accountID, ok := v.(types.AccountID) + if !ok { + return "" + } + return accountID } diff --git a/proxy/internal/roundtrip/netbird_test.go b/proxy/internal/roundtrip/netbird_test.go new file mode 100644 index 000000000..b04600e4a --- /dev/null +++ b/proxy/internal/roundtrip/netbird_test.go @@ -0,0 +1,247 @@ +package roundtrip + +import ( + "context" + "net/http" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/netbirdio/netbird/proxy/internal/types" + "github.com/netbirdio/netbird/shared/management/domain" +) + +// mockNetBird creates a NetBird instance for testing without actually connecting. +// It uses an invalid management URL to prevent real connections. +func mockNetBird() *NetBird { + return NewNetBird("http://invalid.test:9999", "test-proxy", nil, nil) +} + +func TestNetBird_AddPeer_CreatesClientForNewAccount(t *testing.T) { + nb := mockNetBird() + accountID := types.AccountID("account-1") + + // Initially no client exists. + assert.False(t, nb.HasClient(accountID), "should not have client before AddPeer") + assert.Equal(t, 0, nb.DomainCount(accountID), "domain count should be 0") + + // Add first domain - this should create a new client. + // Note: This will fail to actually connect since we use an invalid URL, + // 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) + + assert.True(t, nb.HasClient(accountID), "should have client after AddPeer") + assert.Equal(t, 1, nb.DomainCount(accountID), "domain count should be 1") +} + +func TestNetBird_AddPeer_ReuseClientForSameAccount(t *testing.T) { + nb := mockNetBird() + accountID := types.AccountID("account-1") + + // Add first domain. + err := nb.AddPeer(context.Background(), accountID, domain.Domain("domain1.test"), "setup-key-1", "proxy-1") + require.NoError(t, err) + assert.Equal(t, 1, nb.DomainCount(accountID)) + + // Add second domain for the same account - should reuse existing client. + err = nb.AddPeer(context.Background(), accountID, domain.Domain("domain2.test"), "setup-key-1", "proxy-2") + require.NoError(t, err) + assert.Equal(t, 2, nb.DomainCount(accountID), "domain count should be 2 after adding second domain") + + // Add third domain. + err = nb.AddPeer(context.Background(), accountID, domain.Domain("domain3.test"), "setup-key-1", "proxy-3") + require.NoError(t, err) + assert.Equal(t, 3, nb.DomainCount(accountID), "domain count should be 3 after adding third domain") + + // Still only one client. + assert.True(t, nb.HasClient(accountID)) +} + +func TestNetBird_AddPeer_SeparateClientsForDifferentAccounts(t *testing.T) { + nb := mockNetBird() + account1 := types.AccountID("account-1") + account2 := types.AccountID("account-2") + + // Add domain for account 1. + err := nb.AddPeer(context.Background(), account1, domain.Domain("domain1.test"), "setup-key-1", "proxy-1") + require.NoError(t, err) + + // Add domain for account 2. + err = nb.AddPeer(context.Background(), account2, domain.Domain("domain2.test"), "setup-key-2", "proxy-2") + require.NoError(t, err) + + // Both accounts should have their own clients. + assert.True(t, nb.HasClient(account1), "account1 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.DomainCount(account2), "account2 domain count should be 1") +} + +func TestNetBird_RemovePeer_KeepsClientWhenDomainsRemain(t *testing.T) { + nb := mockNetBird() + accountID := types.AccountID("account-1") + + // Add multiple domains. + err := nb.AddPeer(context.Background(), accountID, domain.Domain("domain1.test"), "setup-key-1", "proxy-1") + require.NoError(t, err) + err = nb.AddPeer(context.Background(), accountID, domain.Domain("domain2.test"), "setup-key-1", "proxy-2") + require.NoError(t, err) + err = nb.AddPeer(context.Background(), accountID, domain.Domain("domain3.test"), "setup-key-1", "proxy-3") + require.NoError(t, err) + assert.Equal(t, 3, nb.DomainCount(accountID)) + + // Remove one domain - client should remain. + err = nb.RemovePeer(context.Background(), accountID, "domain1.test") + require.NoError(t, err) + assert.True(t, nb.HasClient(accountID), "client should remain after removing one domain") + assert.Equal(t, 2, nb.DomainCount(accountID), "domain count should be 2") + + // Remove another domain - client should still remain. + err = nb.RemovePeer(context.Background(), accountID, "domain2.test") + require.NoError(t, err) + assert.True(t, nb.HasClient(accountID), "client should remain after removing second domain") + assert.Equal(t, 1, nb.DomainCount(accountID), "domain count should be 1") +} + +func TestNetBird_RemovePeer_RemovesClientWhenLastDomainRemoved(t *testing.T) { + nb := mockNetBird() + accountID := types.AccountID("account-1") + + // Add single domain. + err := nb.AddPeer(context.Background(), accountID, domain.Domain("domain1.test"), "setup-key-1", "proxy-1") + require.NoError(t, err) + assert.True(t, nb.HasClient(accountID)) + + // Remove the only domain - 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") + + // After removing all domains, client should be gone. + assert.False(t, nb.HasClient(accountID), "client should be removed after removing last domain") + assert.Equal(t, 0, nb.DomainCount(accountID), "domain count should be 0") +} + +func TestNetBird_RemovePeer_NonExistentAccountIsNoop(t *testing.T) { + nb := mockNetBird() + accountID := types.AccountID("nonexistent-account") + + // Removing from non-existent account should not error. + err := nb.RemovePeer(context.Background(), accountID, "domain1.test") + assert.NoError(t, err, "removing from non-existent account should not error") +} + +func TestNetBird_RemovePeer_NonExistentDomainIsNoop(t *testing.T) { + nb := mockNetBird() + accountID := types.AccountID("account-1") + + // Add one domain. + err := nb.AddPeer(context.Background(), accountID, domain.Domain("domain1.test"), "setup-key-1", "proxy-1") + require.NoError(t, err) + + // Remove non-existent domain - should not affect existing domain. + err = nb.RemovePeer(context.Background(), accountID, domain.Domain("nonexistent.test")) + require.NoError(t, err) + + // Original domain should still be registered. + assert.True(t, nb.HasClient(accountID)) + assert.Equal(t, 1, nb.DomainCount(accountID), "original domain should remain") +} + +func TestWithAccountID_AndAccountIDFromContext(t *testing.T) { + ctx := context.Background() + accountID := types.AccountID("test-account") + + // Initially no account ID in context. + retrieved := AccountIDFromContext(ctx) + assert.True(t, retrieved == "", "should be empty when not set") + + // Add account ID to context. + ctx = WithAccountID(ctx, accountID) + retrieved = AccountIDFromContext(ctx) + assert.Equal(t, accountID, retrieved, "should retrieve the same account ID") +} + +func TestAccountIDFromContext_ReturnsEmptyForWrongType(t *testing.T) { + // Create context with wrong type for account ID key. + ctx := context.WithValue(context.Background(), accountIDContextKey{}, "wrong-type-string") + + retrieved := AccountIDFromContext(ctx) + assert.True(t, retrieved == "", "should return empty for wrong type") +} + +func TestNetBird_StopAll_StopsAllClients(t *testing.T) { + nb := mockNetBird() + account1 := types.AccountID("account-1") + account2 := types.AccountID("account-2") + account3 := types.AccountID("account-3") + + // Add domains for multiple accounts. + err := nb.AddPeer(context.Background(), account1, domain.Domain("domain1.test"), "key-1", "proxy-1") + require.NoError(t, err) + err = nb.AddPeer(context.Background(), account2, domain.Domain("domain2.test"), "key-2", "proxy-2") + require.NoError(t, err) + err = nb.AddPeer(context.Background(), account3, domain.Domain("domain3.test"), "key-3", "proxy-3") + require.NoError(t, err) + + assert.Equal(t, 3, nb.ClientCount(), "should have 3 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()) + + assert.Equal(t, 0, nb.ClientCount(), "should have 0 clients after StopAll") + assert.False(t, nb.HasClient(account1), "account1 should not have client") + assert.False(t, nb.HasClient(account2), "account2 should not have client") + assert.False(t, nb.HasClient(account3), "account3 should not have client") +} + +func TestNetBird_ClientCount(t *testing.T) { + nb := mockNetBird() + + assert.Equal(t, 0, nb.ClientCount(), "should start with 0 clients") + + // Add clients for different accounts. + err := nb.AddPeer(context.Background(), types.AccountID("account-1"), domain.Domain("domain1.test"), "key-1", "proxy-1") + require.NoError(t, err) + assert.Equal(t, 1, nb.ClientCount()) + + err = nb.AddPeer(context.Background(), types.AccountID("account-2"), domain.Domain("domain2.test"), "key-2", "proxy-2") + require.NoError(t, err) + assert.Equal(t, 2, nb.ClientCount()) + + // Adding domain to existing account should not increase count. + err = nb.AddPeer(context.Background(), types.AccountID("account-1"), domain.Domain("domain1b.test"), "key-1", "proxy-1b") + require.NoError(t, err) + assert.Equal(t, 2, nb.ClientCount(), "adding domain to existing account should not increase client count") +} + +func TestNetBird_RoundTrip_RequiresAccountIDInContext(t *testing.T) { + nb := mockNetBird() + + // Create a request without account ID in context. + req, err := http.NewRequest("GET", "http://example.com/", nil) + require.NoError(t, err) + + // RoundTrip should fail because no account ID in context. + _, err = nb.RoundTrip(req) + require.ErrorIs(t, err, ErrNoAccountID) +} + +func TestNetBird_RoundTrip_RequiresExistingClient(t *testing.T) { + nb := mockNetBird() + accountID := types.AccountID("nonexistent-account") + + // Create a request with account ID but no client exists. + req, err := http.NewRequest("GET", "http://example.com/", nil) + require.NoError(t, err) + req = req.WithContext(WithAccountID(req.Context(), accountID)) + + // RoundTrip should fail because no client for this account. + _, err = nb.RoundTrip(req) + assert.Error(t, err) + assert.Contains(t, err.Error(), "no peer connection found for account") +} diff --git a/proxy/internal/types/types.go b/proxy/internal/types/types.go new file mode 100644 index 000000000..41acfef40 --- /dev/null +++ b/proxy/internal/types/types.go @@ -0,0 +1,5 @@ +// Package types defines common types used across the proxy package. +package types + +// AccountID represents a unique identifier for a NetBird account. +type AccountID string diff --git a/proxy/server.go b/proxy/server.go index 5bfdbe629..cda6211d2 100644 --- a/proxy/server.go +++ b/proxy/server.go @@ -31,20 +31,27 @@ import ( "github.com/netbirdio/netbird/proxy/internal/accesslog" "github.com/netbirdio/netbird/proxy/internal/acme" "github.com/netbirdio/netbird/proxy/internal/auth" + "github.com/netbirdio/netbird/proxy/internal/debug" + "github.com/netbirdio/netbird/proxy/internal/health" "github.com/netbirdio/netbird/proxy/internal/proxy" "github.com/netbirdio/netbird/proxy/internal/roundtrip" + "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/util/embeddedroots" ) type Server struct { - mgmtClient proto.ProxyServiceClient - proxy *proxy.ReverseProxy - netbird *roundtrip.NetBird - acme *acme.Manager - auth *auth.Middleware - http *http.Server - https *http.Server + mgmtClient proto.ProxyServiceClient + proxy *proxy.ReverseProxy + netbird *roundtrip.NetBird + acme *acme.Manager + auth *auth.Middleware + http *http.Server + https *http.Server + debug *http.Server + healthServer *health.Server + healthChecker *health.Checker // Mostly used for debugging on management. startTime time.Time @@ -62,6 +69,13 @@ type Server struct { OIDCClientSecret string OIDCEndpoint string OIDCScopes []string + + // DebugEndpointEnabled enables the debug HTTP endpoint. + DebugEndpointEnabled bool + // DebugEndpointAddress is the address for the debug HTTP endpoint (default: ":8444"). + DebugEndpointAddress string + // HealthAddress is the address for the health probe endpoint (default: "localhost:8080"). + HealthAddress string } // NotifyStatus sends a status update to management about tunnel connectivity @@ -148,7 +162,7 @@ func (s *Server) ListenAndServe(ctx context.Context, addr string) (err error) { // Initialize the netbird client, this is required to build peer connections // to proxy over. - s.netbird = roundtrip.NewNetBird(s.ManagementAddress, s.Logger, s) + s.netbird = roundtrip.NewNetBird(s.ManagementAddress, s.ID, s.Logger, s, s.mgmtClient) // When generating ACME certificates, start a challenge server. tlsConfig := &tls.Config{} @@ -196,27 +210,87 @@ func (s *Server) ListenAndServe(ctx context.Context, addr string) (err error) { } // Configure the reverse proxy using NetBird's HTTP Client Transport for proxying. - s.proxy = proxy.NewReverseProxy(s.netbird) + s.proxy = proxy.NewReverseProxy(s.netbird, s.Logger) // Configure the authentication middleware. - s.auth = auth.NewMiddleware() + s.auth = auth.NewMiddleware(s.Logger) // Configure Access logs to management server. accessLog := accesslog.NewLogger(s.mgmtClient, s.Logger) + if s.DebugEndpointEnabled { + debugAddr := debugEndpointAddr(s.DebugEndpointAddress) + debugHandler := debug.NewHandler(s.netbird, s.Logger) + s.debug = &http.Server{ + Addr: debugAddr, + Handler: debugHandler, + } + go func() { + s.Logger.Infof("starting debug endpoint on %s", debugAddr) + if err := s.debug.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { + s.Logger.Errorf("debug endpoint error: %v", err) + } + }() + defer func() { + if err := s.debug.Close(); err != nil { + s.Logger.Debugf("debug endpoint close: %v", err) + } + }() + } + + // Start health probe server on separate port for Kubernetes probes. + healthAddr := s.HealthAddress + if healthAddr == "" { + healthAddr = "localhost:8080" + } + s.healthChecker = health.NewChecker(s.Logger, s.netbird) + s.healthServer = health.NewServer(healthAddr, s.healthChecker, s.Logger) + healthListener, err := net.Listen("tcp", healthAddr) + if err != nil { + return fmt.Errorf("health probe server listen on %s: %w", healthAddr, err) + } + go func() { + if err := s.healthServer.Serve(healthListener); err != nil && !errors.Is(err, http.ErrServerClosed) { + s.Logger.Errorf("health probe server: %v", err) + } + }() + defer func() { + shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + if err := s.healthServer.Shutdown(shutdownCtx); err != nil { + s.Logger.Debugf("health probe server shutdown: %v", err) + } + }() + + defer func() { + stopCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + if err := s.netbird.StopAll(stopCtx); err != nil { + s.Logger.Warnf("failed to stop all netbird clients: %v", err) + } + }() + // Finally, start the reverse proxy. s.https = &http.Server{ Addr: addr, Handler: accessLog.Middleware(s.auth.Protect(s.proxy)), TLSConfig: tlsConfig, } + s.Logger.Debugf("starting listening on reverse proxy server address %s", addr) return s.https.ListenAndServeTLS("", "") } func (s *Server) newManagementMappingWorker(ctx context.Context, client proto.ProxyServiceClient) { b := backoff.New(0, 0) + initialSyncDone := false for { s.Logger.Debug("Getting mapping updates from management server") + + // Mark management as disconnected while we're attempting to reconnect. + if s.healthChecker != nil { + s.healthChecker.SetManagementConnected(false) + } + mappingClient, err := client.GetMappingUpdate(ctx, &proto.GetMappingUpdateRequest{ ProxyId: s.ID, Version: s.Version, @@ -233,8 +307,19 @@ func (s *Server) newManagementMappingWorker(ctx context.Context, client proto.Pr time.Sleep(backoffDuration) continue } + + // Mark management as connected once stream is established. + if s.healthChecker != nil { + s.healthChecker.SetManagementConnected(true) + } s.Logger.Debug("Got mapping updates client from management server") - err = s.handleMappingStream(ctx, mappingClient) + + err = s.handleMappingStream(ctx, mappingClient, &initialSyncDone) + + if s.healthChecker != nil { + s.healthChecker.SetManagementConnected(false) + } + backoffDuration := b.Duration() switch { case errors.Is(err, context.Canceled), @@ -257,7 +342,7 @@ func (s *Server) newManagementMappingWorker(ctx context.Context, client proto.Pr } } -func (s *Server) handleMappingStream(ctx context.Context, mappingClient proto.ProxyService_GetMappingUpdateClient) error { +func (s *Server) handleMappingStream(ctx context.Context, mappingClient proto.ProxyService_GetMappingUpdateClient, initialSyncDone *bool) error { for { // Check for context completion to gracefully shutdown. select { @@ -301,20 +386,29 @@ func (s *Server) handleMappingStream(ctx context.Context, mappingClient proto.Pr } } s.Logger.Debug("Processing mapping update completed") + + // After the first mapping sync, mark the initial sync complete. + // Client health is checked directly in the startup probe. + if !*initialSyncDone && s.healthChecker != nil { + s.healthChecker.SetInitialSyncComplete() + *initialSyncDone = true + s.Logger.Info("Initial mapping sync complete") + } } } } func (s *Server) addMapping(ctx context.Context, mapping *proto.ProxyMapping) error { - domain := mapping.GetDomain() - accountID := mapping.GetAccountId() + d := domain.Domain(mapping.GetDomain()) + accountID := types.AccountID(mapping.GetAccountId()) reverseProxyID := mapping.GetId() + authToken := mapping.GetAuthToken() - if err := s.netbird.AddPeer(ctx, domain, mapping.GetSetupKey(), accountID, reverseProxyID); err != nil { - return fmt.Errorf("create peer for domain %q: %w", domain, err) + if err := s.netbird.AddPeer(ctx, accountID, d, authToken, reverseProxyID); err != nil { + return fmt.Errorf("create peer for domain %q: %w", d, err) } if s.acme != nil { - s.acme.AddDomain(domain, accountID, reverseProxyID) + s.acme.AddDomain(string(d), string(accountID), reverseProxyID) } // Pass the mapping through to the update function to avoid duplicating the @@ -337,33 +431,23 @@ func (s *Server) updateMapping(ctx context.Context, mapping *proto.ProxyMapping) if mapping.GetAuth().GetPin() { schemes = append(schemes, auth.NewPin(s.mgmtClient, mapping.GetId(), mapping.GetAccountId())) } - if mapping.GetAuth().GetOidc() != nil { - oidc := mapping.GetAuth().GetOidc() - scheme, err := auth.NewOIDC(ctx, mapping.GetId(), mapping.GetAccountId(), s.ProxyURL, auth.OIDCConfig{ - OIDCProviderURL: s.OIDCEndpoint, - OIDCClientID: s.OIDCClientId, - OIDCClientSecret: s.OIDCClientSecret, - OIDCScopes: s.OIDCScopes, - DistributionGroups: oidc.GetDistributionGroups(), - }) - if err != nil { - s.Logger.WithError(err).Error("Failed to create OIDC scheme") - } else { - schemes = append(schemes, scheme) - } + if mapping.GetAuth().GetOidc() { + schemes = append(schemes, auth.NewOIDC(s.mgmtClient, mapping.GetId(), mapping.GetAccountId())) } - if mapping.GetAuth().GetLink() { - schemes = append(schemes, auth.NewLink(s.mgmtClient, mapping.GetId(), mapping.GetAccountId())) - } - s.auth.AddDomain(mapping.GetDomain(), schemes) + + maxSessionAge := time.Duration(mapping.GetAuth().GetMaxSessionAgeSeconds()) * time.Second + s.auth.AddDomain(mapping.GetDomain(), schemes, mapping.GetAuth().GetSessionKey(), maxSessionAge) s.proxy.AddMapping(s.protoToMapping(mapping)) } func (s *Server) removeMapping(ctx context.Context, mapping *proto.ProxyMapping) { - if err := s.netbird.RemovePeer(ctx, mapping.GetDomain(), mapping.GetAccountId(), mapping.GetId()); err != nil { + d := domain.Domain(mapping.GetDomain()) + accountID := types.AccountID(mapping.GetAccountId()) + if err := s.netbird.RemovePeer(ctx, accountID, d); err != nil { s.Logger.WithFields(log.Fields{ - "domain": mapping.GetDomain(), - "error": err, + "account_id": accountID, + "domain": d, + "error": err, }).Error("Error removing NetBird peer connection for domain, continuing additional domain cleanup but peer connection may still exist") } if s.acme != nil { @@ -392,8 +476,17 @@ func (s *Server) protoToMapping(mapping *proto.ProxyMapping) proxy.Mapping { } return proxy.Mapping{ ID: mapping.GetId(), - AccountID: mapping.AccountId, + AccountID: types.AccountID(mapping.GetAccountId()), Host: mapping.GetDomain(), Paths: paths, } } + +// debugEndpointAddr returns the address for the debug endpoint. +// If addr is empty, it defaults to localhost:8444 for security. +func debugEndpointAddr(addr string) string { + if addr == "" { + return "localhost:8444" + } + return addr +} diff --git a/proxy/server_test.go b/proxy/server_test.go new file mode 100644 index 000000000..b4fb4f8ba --- /dev/null +++ b/proxy/server_test.go @@ -0,0 +1,48 @@ +package proxy + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestDebugEndpointDisabledByDefault(t *testing.T) { + s := &Server{} + assert.False(t, s.DebugEndpointEnabled, "debug endpoint should be disabled by default") +} + +func TestDebugEndpointAddr(t *testing.T) { + tests := []struct { + name string + input string + expected string + }{ + { + name: "empty defaults to localhost", + input: "", + expected: "localhost:8444", + }, + { + name: "explicit localhost preserved", + input: "localhost:9999", + expected: "localhost:9999", + }, + { + name: "explicit address preserved", + input: "0.0.0.0:8444", + expected: "0.0.0.0:8444", + }, + { + name: "127.0.0.1 preserved", + input: "127.0.0.1:8444", + expected: "127.0.0.1:8444", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + result := debugEndpointAddr(tc.input) + assert.Equal(t, tc.expected, result) + }) + } +} diff --git a/proxy/web/dist/assets/index-BQ7jeUNq.js b/proxy/web/dist/assets/index-BQ7jeUNq.js deleted file mode 100644 index dee83eb6f..000000000 --- a/proxy/web/dist/assets/index-BQ7jeUNq.js +++ /dev/null @@ -1,9 +0,0 @@ -(function(){const b=document.createElement("link").relList;if(b&&b.supports&&b.supports("modulepreload"))return;for(const _ of document.querySelectorAll('link[rel="modulepreload"]'))f(_);new MutationObserver(_=>{for(const M of _)if(M.type==="childList")for(const D of M.addedNodes)D.tagName==="LINK"&&D.rel==="modulepreload"&&f(D)}).observe(document,{childList:!0,subtree:!0});function T(_){const M={};return _.integrity&&(M.integrity=_.integrity),_.referrerPolicy&&(M.referrerPolicy=_.referrerPolicy),_.crossOrigin==="use-credentials"?M.credentials="include":_.crossOrigin==="anonymous"?M.credentials="omit":M.credentials="same-origin",M}function f(_){if(_.ep)return;_.ep=!0;const M=T(_);fetch(_.href,M)}})();var pf={exports:{}},Uu={};var Rd;function Em(){if(Rd)return Uu;Rd=1;var o=Symbol.for("react.transitional.element"),b=Symbol.for("react.fragment");function T(f,_,M){var D=null;if(M!==void 0&&(D=""+M),_.key!==void 0&&(D=""+_.key),"key"in _){M={};for(var U in _)U!=="key"&&(M[U]=_[U])}else M=_;return _=M.ref,{$$typeof:o,type:f,key:D,ref:_!==void 0?_:null,props:M}}return Uu.Fragment=b,Uu.jsx=T,Uu.jsxs=T,Uu}var Hd;function Mm(){return Hd||(Hd=1,pf.exports=Em()),pf.exports}var R=Mm(),Sf={exports:{}},K={};var jd;function _m(){if(jd)return K;jd=1;var o=Symbol.for("react.transitional.element"),b=Symbol.for("react.portal"),T=Symbol.for("react.fragment"),f=Symbol.for("react.strict_mode"),_=Symbol.for("react.profiler"),M=Symbol.for("react.consumer"),D=Symbol.for("react.context"),U=Symbol.for("react.forward_ref"),O=Symbol.for("react.suspense"),p=Symbol.for("react.memo"),H=Symbol.for("react.lazy"),j=Symbol.for("react.activity"),w=Symbol.iterator;function st(s){return s===null||typeof s!="object"?null:(s=w&&s[w]||s["@@iterator"],typeof s=="function"?s:null)}var L={isMounted:function(){return!1},enqueueForceUpdate:function(){},enqueueReplaceState:function(){},enqueueSetState:function(){}},X=Object.assign,V={};function tt(s,E,C){this.props=s,this.context=E,this.refs=V,this.updater=C||L}tt.prototype.isReactComponent={},tt.prototype.setState=function(s,E){if(typeof s!="object"&&typeof s!="function"&&s!=null)throw Error("takes an object of state variables to update or a function which returns an object of state variables.");this.updater.enqueueSetState(this,s,E,"setState")},tt.prototype.forceUpdate=function(s){this.updater.enqueueForceUpdate(this,s,"forceUpdate")};function St(){}St.prototype=tt.prototype;function zt(s,E,C){this.props=s,this.context=E,this.refs=V,this.updater=C||L}var P=zt.prototype=new St;P.constructor=zt,X(P,tt.prototype),P.isPureReactComponent=!0;var Rt=Array.isArray;function Dt(){}var W={H:null,A:null,T:null,S:null},Zt=Object.prototype.hasOwnProperty;function It(s,E,C){var q=C.ref;return{$$typeof:o,type:s,key:E,ref:q!==void 0?q:null,props:C}}function Cl(s,E){return It(s.type,E,s.props)}function Pt(s){return typeof s=="object"&&s!==null&&s.$$typeof===o}function I(s){var E={"=":"=0",":":"=2"};return"$"+s.replace(/[=:]/g,function(C){return E[C]})}var Rl=/\/+/g;function tl(s,E){return typeof s=="object"&&s!==null&&s.key!=null?I(""+s.key):E.toString(36)}function ll(s){switch(s.status){case"fulfilled":return s.value;case"rejected":throw s.reason;default:switch(typeof s.status=="string"?s.then(Dt,Dt):(s.status="pending",s.then(function(E){s.status==="pending"&&(s.status="fulfilled",s.value=E)},function(E){s.status==="pending"&&(s.status="rejected",s.reason=E)})),s.status){case"fulfilled":return s.value;case"rejected":throw s.reason}}throw s}function S(s,E,C,q,J){var lt=typeof s;(lt==="undefined"||lt==="boolean")&&(s=null);var yt=!1;if(s===null)yt=!0;else switch(lt){case"bigint":case"string":case"number":yt=!0;break;case"object":switch(s.$$typeof){case o:case b:yt=!0;break;case H:return yt=s._init,S(yt(s._payload),E,C,q,J)}}if(yt)return J=J(s),yt=q===""?"."+tl(s,0):q,Rt(J)?(C="",yt!=null&&(C=yt.replace(Rl,"$&/")+"/"),S(J,E,C,"",function(qa){return qa})):J!=null&&(Pt(J)&&(J=Cl(J,C+(J.key==null||s&&s.key===J.key?"":(""+J.key).replace(Rl,"$&/")+"/")+yt)),E.push(J)),1;yt=0;var Wt=q===""?".":q+":";if(Rt(s))for(var Ut=0;Ut>>1,dt=S[ct];if(0<_(dt,N))S[ct]=N,S[Q]=dt,Q=ct;else break t}}function T(S){return S.length===0?null:S[0]}function f(S){if(S.length===0)return null;var N=S[0],Q=S.pop();if(Q!==N){S[0]=Q;t:for(var ct=0,dt=S.length,s=dt>>>1;ct_(C,Q))q_(J,C)?(S[ct]=J,S[q]=Q,ct=q):(S[ct]=C,S[E]=Q,ct=E);else if(q_(J,Q))S[ct]=J,S[q]=Q,ct=q;else break t}}return N}function _(S,N){var Q=S.sortIndex-N.sortIndex;return Q!==0?Q:S.id-N.id}if(o.unstable_now=void 0,typeof performance=="object"&&typeof performance.now=="function"){var M=performance;o.unstable_now=function(){return M.now()}}else{var D=Date,U=D.now();o.unstable_now=function(){return D.now()-U}}var O=[],p=[],H=1,j=null,w=3,st=!1,L=!1,X=!1,V=!1,tt=typeof setTimeout=="function"?setTimeout:null,St=typeof clearTimeout=="function"?clearTimeout:null,zt=typeof setImmediate<"u"?setImmediate:null;function P(S){for(var N=T(p);N!==null;){if(N.callback===null)f(p);else if(N.startTime<=S)f(p),N.sortIndex=N.expirationTime,b(O,N);else break;N=T(p)}}function Rt(S){if(X=!1,P(S),!L)if(T(O)!==null)L=!0,Dt||(Dt=!0,I());else{var N=T(p);N!==null&&ll(Rt,N.startTime-S)}}var Dt=!1,W=-1,Zt=5,It=-1;function Cl(){return V?!0:!(o.unstable_now()-ItS&&Cl());){var ct=j.callback;if(typeof ct=="function"){j.callback=null,w=j.priorityLevel;var dt=ct(j.expirationTime<=S);if(S=o.unstable_now(),typeof dt=="function"){j.callback=dt,P(S),N=!0;break l}j===T(O)&&f(O),P(S)}else f(O);j=T(O)}if(j!==null)N=!0;else{var s=T(p);s!==null&&ll(Rt,s.startTime-S),N=!1}}break t}finally{j=null,w=Q,st=!1}N=void 0}}finally{N?I():Dt=!1}}}var I;if(typeof zt=="function")I=function(){zt(Pt)};else if(typeof MessageChannel<"u"){var Rl=new MessageChannel,tl=Rl.port2;Rl.port1.onmessage=Pt,I=function(){tl.postMessage(null)}}else I=function(){tt(Pt,0)};function ll(S,N){W=tt(function(){S(o.unstable_now())},N)}o.unstable_IdlePriority=5,o.unstable_ImmediatePriority=1,o.unstable_LowPriority=4,o.unstable_NormalPriority=3,o.unstable_Profiling=null,o.unstable_UserBlockingPriority=2,o.unstable_cancelCallback=function(S){S.callback=null},o.unstable_forceFrameRate=function(S){0>S||125ct?(S.sortIndex=Q,b(p,S),T(O)===null&&S===T(p)&&(X?(St(W),W=-1):X=!0,ll(Rt,Q-ct))):(S.sortIndex=dt,b(O,S),L||st||(L=!0,Dt||(Dt=!0,I()))),S},o.unstable_shouldYield=Cl,o.unstable_wrapCallback=function(S){var N=w;return function(){var Q=w;w=N;try{return S.apply(this,arguments)}finally{w=Q}}}})(xf)),xf}var Yd;function Dm(){return Yd||(Yd=1,Tf.exports=Om()),Tf.exports}var Af={exports:{}},kt={};var Gd;function Um(){if(Gd)return kt;Gd=1;var o=Df();function b(O){var p="https://react.dev/errors/"+O;if(1"u"||typeof __REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE!="function"))try{__REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE(o)}catch(b){console.error(b)}}return o(),Af.exports=Um(),Af.exports}var Qd;function Cm(){if(Qd)return Nu;Qd=1;var o=Dm(),b=Df(),T=Nm();function f(t){var l="https://react.dev/errors/"+t;if(1dt||(t.current=ct[dt],ct[dt]=null,dt--)}function C(t,l){dt++,ct[dt]=t.current,t.current=l}var q=s(null),J=s(null),lt=s(null),yt=s(null);function Wt(t,l){switch(C(lt,l),C(J,t),C(q,null),l.nodeType){case 9:case 11:t=(t=l.documentElement)&&(t=t.namespaceURI)?ed(t):0;break;default:if(t=l.tagName,l=l.namespaceURI)l=ed(l),t=ad(l,t);else switch(t){case"svg":t=1;break;case"math":t=2;break;default:t=0}}E(q),C(q,t)}function Ut(){E(q),E(J),E(lt)}function qa(t){t.memoizedState!==null&&C(yt,t);var l=q.current,e=ad(l,t.type);l!==e&&(C(J,t),C(q,e))}function Hu(t){J.current===t&&(E(q),E(J)),yt.current===t&&(E(yt),Mu._currentValue=Q)}var ti,Nf;function Ue(t){if(ti===void 0)try{throw Error()}catch(e){var l=e.stack.trim().match(/\n( *(at )?)/);ti=l&&l[1]||"",Nf=-1)":-1u||r[a]!==g[u]){var z=` -`+r[a].replace(" at new "," at ");return t.displayName&&z.includes("")&&(z=z.replace("",t.displayName)),z}while(1<=a&&0<=u);break}}}finally{li=!1,Error.prepareStackTrace=e}return(e=t?t.displayName||t.name:"")?Ue(e):""}function e0(t,l){switch(t.tag){case 26:case 27:case 5:return Ue(t.type);case 16:return Ue("Lazy");case 13:return t.child!==l&&l!==null?Ue("Suspense Fallback"):Ue("Suspense");case 19:return Ue("SuspenseList");case 0:case 15:return ei(t.type,!1);case 11:return ei(t.type.render,!1);case 1:return ei(t.type,!0);case 31:return Ue("Activity");default:return""}}function Cf(t){try{var l="",e=null;do l+=e0(t,e),e=t,t=t.return;while(t);return l}catch(a){return` -Error generating stack: `+a.message+` -`+a.stack}}var ai=Object.prototype.hasOwnProperty,ui=o.unstable_scheduleCallback,ni=o.unstable_cancelCallback,a0=o.unstable_shouldYield,u0=o.unstable_requestPaint,rl=o.unstable_now,n0=o.unstable_getCurrentPriorityLevel,Rf=o.unstable_ImmediatePriority,Hf=o.unstable_UserBlockingPriority,ju=o.unstable_NormalPriority,i0=o.unstable_LowPriority,jf=o.unstable_IdlePriority,c0=o.log,f0=o.unstable_setDisableYieldValue,Ya=null,ol=null;function ae(t){if(typeof c0=="function"&&f0(t),ol&&typeof ol.setStrictMode=="function")try{ol.setStrictMode(Ya,t)}catch{}}var sl=Math.clz32?Math.clz32:s0,r0=Math.log,o0=Math.LN2;function s0(t){return t>>>=0,t===0?32:31-(r0(t)/o0|0)|0}var Bu=256,qu=262144,Yu=4194304;function Ne(t){var l=t&42;if(l!==0)return l;switch(t&-t){case 1:return 1;case 2:return 2;case 4:return 4;case 8:return 8;case 16:return 16;case 32:return 32;case 64:return 64;case 128:return 128;case 256:case 512:case 1024:case 2048:case 4096:case 8192:case 16384:case 32768:case 65536:case 131072:return t&261888;case 262144:case 524288:case 1048576:case 2097152:return t&3932160;case 4194304:case 8388608:case 16777216:case 33554432:return t&62914560;case 67108864:return 67108864;case 134217728:return 134217728;case 268435456:return 268435456;case 536870912:return 536870912;case 1073741824:return 0;default:return t}}function Gu(t,l,e){var a=t.pendingLanes;if(a===0)return 0;var u=0,n=t.suspendedLanes,i=t.pingedLanes;t=t.warmLanes;var c=a&134217727;return c!==0?(a=c&~n,a!==0?u=Ne(a):(i&=c,i!==0?u=Ne(i):e||(e=c&~t,e!==0&&(u=Ne(e))))):(c=a&~n,c!==0?u=Ne(c):i!==0?u=Ne(i):e||(e=a&~t,e!==0&&(u=Ne(e)))),u===0?0:l!==0&&l!==u&&(l&n)===0&&(n=u&-u,e=l&-l,n>=e||n===32&&(e&4194048)!==0)?l:u}function Ga(t,l){return(t.pendingLanes&~(t.suspendedLanes&~t.pingedLanes)&l)===0}function d0(t,l){switch(t){case 1:case 2:case 4:case 8:case 64:return l+250;case 16:case 32:case 128:case 256:case 512:case 1024:case 2048:case 4096:case 8192:case 16384:case 32768:case 65536:case 131072:case 262144:case 524288:case 1048576:case 2097152:return l+5e3;case 4194304:case 8388608:case 16777216:case 33554432:return-1;case 67108864:case 134217728:case 268435456:case 536870912:case 1073741824:return-1;default:return-1}}function Bf(){var t=Yu;return Yu<<=1,(Yu&62914560)===0&&(Yu=4194304),t}function ii(t){for(var l=[],e=0;31>e;e++)l.push(t);return l}function Xa(t,l){t.pendingLanes|=l,l!==268435456&&(t.suspendedLanes=0,t.pingedLanes=0,t.warmLanes=0)}function y0(t,l,e,a,u,n){var i=t.pendingLanes;t.pendingLanes=e,t.suspendedLanes=0,t.pingedLanes=0,t.warmLanes=0,t.expiredLanes&=e,t.entangledLanes&=e,t.errorRecoveryDisabledLanes&=e,t.shellSuspendCounter=0;var c=t.entanglements,r=t.expirationTimes,g=t.hiddenUpdates;for(e=i&~e;0"u")return null;try{return t.activeElement||t.body}catch{return t.body}}var p0=/[\n"\\]/g;function Sl(t){return t.replace(p0,function(l){return"\\"+l.charCodeAt(0).toString(16)+" "})}function di(t,l,e,a,u,n,i,c){t.name="",i!=null&&typeof i!="function"&&typeof i!="symbol"&&typeof i!="boolean"?t.type=i:t.removeAttribute("type"),l!=null?i==="number"?(l===0&&t.value===""||t.value!=l)&&(t.value=""+pl(l)):t.value!==""+pl(l)&&(t.value=""+pl(l)):i!=="submit"&&i!=="reset"||t.removeAttribute("value"),l!=null?yi(t,i,pl(l)):e!=null?yi(t,i,pl(e)):a!=null&&t.removeAttribute("value"),u==null&&n!=null&&(t.defaultChecked=!!n),u!=null&&(t.checked=u&&typeof u!="function"&&typeof u!="symbol"),c!=null&&typeof c!="function"&&typeof c!="symbol"&&typeof c!="boolean"?t.name=""+pl(c):t.removeAttribute("name")}function Wf(t,l,e,a,u,n,i,c){if(n!=null&&typeof n!="function"&&typeof n!="symbol"&&typeof n!="boolean"&&(t.type=n),l!=null||e!=null){if(!(n!=="submit"&&n!=="reset"||l!=null)){si(t);return}e=e!=null?""+pl(e):"",l=l!=null?""+pl(l):e,c||l===t.value||(t.value=l),t.defaultValue=l}a=a??u,a=typeof a!="function"&&typeof a!="symbol"&&!!a,t.checked=c?t.checked:!!a,t.defaultChecked=!!a,i!=null&&typeof i!="function"&&typeof i!="symbol"&&typeof i!="boolean"&&(t.name=i),si(t)}function yi(t,l,e){l==="number"&&Zu(t.ownerDocument)===t||t.defaultValue===""+e||(t.defaultValue=""+e)}function la(t,l,e,a){if(t=t.options,l){l={};for(var u=0;u"u"||typeof window.document>"u"||typeof window.document.createElement>"u"),bi=!1;if(Xl)try{var wa={};Object.defineProperty(wa,"passive",{get:function(){bi=!0}}),window.addEventListener("test",wa,wa),window.removeEventListener("test",wa,wa)}catch{bi=!1}var ne=null,pi=null,wu=null;function er(){if(wu)return wu;var t,l=pi,e=l.length,a,u="value"in ne?ne.value:ne.textContent,n=u.length;for(t=0;t=Ja),fr=" ",rr=!1;function or(t,l){switch(t){case"keyup":return J0.indexOf(l.keyCode)!==-1;case"keydown":return l.keyCode!==229;case"keypress":case"mousedown":case"focusout":return!0;default:return!1}}function sr(t){return t=t.detail,typeof t=="object"&&"data"in t?t.data:null}var na=!1;function W0(t,l){switch(t){case"compositionend":return sr(l);case"keypress":return l.which!==32?null:(rr=!0,fr);case"textInput":return t=l.data,t===fr&&rr?null:t;default:return null}}function $0(t,l){if(na)return t==="compositionend"||!Ai&&or(t,l)?(t=er(),wu=pi=ne=null,na=!1,t):null;switch(t){case"paste":return null;case"keypress":if(!(l.ctrlKey||l.altKey||l.metaKey)||l.ctrlKey&&l.altKey){if(l.char&&1=l)return{node:e,offset:l-t};t=a}t:{for(;e;){if(e.nextSibling){e=e.nextSibling;break t}e=e.parentNode}e=void 0}e=pr(e)}}function zr(t,l){return t&&l?t===l?!0:t&&t.nodeType===3?!1:l&&l.nodeType===3?zr(t,l.parentNode):"contains"in t?t.contains(l):t.compareDocumentPosition?!!(t.compareDocumentPosition(l)&16):!1:!1}function Tr(t){t=t!=null&&t.ownerDocument!=null&&t.ownerDocument.defaultView!=null?t.ownerDocument.defaultView:window;for(var l=Zu(t.document);l instanceof t.HTMLIFrameElement;){try{var e=typeof l.contentWindow.location.href=="string"}catch{e=!1}if(e)t=l.contentWindow;else break;l=Zu(t.document)}return l}function _i(t){var l=t&&t.nodeName&&t.nodeName.toLowerCase();return l&&(l==="input"&&(t.type==="text"||t.type==="search"||t.type==="tel"||t.type==="url"||t.type==="password")||l==="textarea"||t.contentEditable==="true")}var uy=Xl&&"documentMode"in document&&11>=document.documentMode,ia=null,Oi=null,Fa=null,Di=!1;function xr(t,l,e){var a=e.window===e?e.document:e.nodeType===9?e:e.ownerDocument;Di||ia==null||ia!==Zu(a)||(a=ia,"selectionStart"in a&&_i(a)?a={start:a.selectionStart,end:a.selectionEnd}:(a=(a.ownerDocument&&a.ownerDocument.defaultView||window).getSelection(),a={anchorNode:a.anchorNode,anchorOffset:a.anchorOffset,focusNode:a.focusNode,focusOffset:a.focusOffset}),Fa&&$a(Fa,a)||(Fa=a,a=Yn(Oi,"onSelect"),0>=i,u-=i,Hl=1<<32-sl(l)+u|e<$?(nt=Y,Y=null):nt=Y.sibling;var rt=h(y,Y,m[$],x);if(rt===null){Y===null&&(Y=nt);break}t&&Y&&rt.alternate===null&&l(y,Y),d=n(rt,d,$),ft===null?G=rt:ft.sibling=rt,ft=rt,Y=nt}if($===m.length)return e(y,Y),it&&Zl(y,$),G;if(Y===null){for(;$$?(nt=Y,Y=null):nt=Y.sibling;var _e=h(y,Y,rt.value,x);if(_e===null){Y===null&&(Y=nt);break}t&&Y&&_e.alternate===null&&l(y,Y),d=n(_e,d,$),ft===null?G=_e:ft.sibling=_e,ft=_e,Y=nt}if(rt.done)return e(y,Y),it&&Zl(y,$),G;if(Y===null){for(;!rt.done;$++,rt=m.next())rt=A(y,rt.value,x),rt!==null&&(d=n(rt,d,$),ft===null?G=rt:ft.sibling=rt,ft=rt);return it&&Zl(y,$),G}for(Y=a(Y);!rt.done;$++,rt=m.next())rt=v(Y,y,$,rt.value,x),rt!==null&&(t&&rt.alternate!==null&&Y.delete(rt.key===null?$:rt.key),d=n(rt,d,$),ft===null?G=rt:ft.sibling=rt,ft=rt);return t&&Y.forEach(function(Am){return l(y,Am)}),it&&Zl(y,$),G}function bt(y,d,m,x){if(typeof m=="object"&&m!==null&&m.type===X&&m.key===null&&(m=m.props.children),typeof m=="object"&&m!==null){switch(m.$$typeof){case st:t:{for(var G=m.key;d!==null;){if(d.key===G){if(G=m.type,G===X){if(d.tag===7){e(y,d.sibling),x=u(d,m.props.children),x.return=y,y=x;break t}}else if(d.elementType===G||typeof G=="object"&&G!==null&&G.$$typeof===Zt&&Ze(G)===d.type){e(y,d.sibling),x=u(d,m.props),au(x,m),x.return=y,y=x;break t}e(y,d);break}else l(y,d);d=d.sibling}m.type===X?(x=qe(m.props.children,y.mode,x,m.key),x.return=y,y=x):(x=tn(m.type,m.key,m.props,null,y.mode,x),au(x,m),x.return=y,y=x)}return i(y);case L:t:{for(G=m.key;d!==null;){if(d.key===G)if(d.tag===4&&d.stateNode.containerInfo===m.containerInfo&&d.stateNode.implementation===m.implementation){e(y,d.sibling),x=u(d,m.children||[]),x.return=y,y=x;break t}else{e(y,d);break}else l(y,d);d=d.sibling}x=Bi(m,y.mode,x),x.return=y,y=x}return i(y);case Zt:return m=Ze(m),bt(y,d,m,x)}if(ll(m))return B(y,d,m,x);if(I(m)){if(G=I(m),typeof G!="function")throw Error(f(150));return m=G.call(m),Z(y,d,m,x)}if(typeof m.then=="function")return bt(y,d,fn(m),x);if(m.$$typeof===zt)return bt(y,d,an(y,m),x);rn(y,m)}return typeof m=="string"&&m!==""||typeof m=="number"||typeof m=="bigint"?(m=""+m,d!==null&&d.tag===6?(e(y,d.sibling),x=u(d,m),x.return=y,y=x):(e(y,d),x=ji(m,y.mode,x),x.return=y,y=x),i(y)):e(y,d)}return function(y,d,m,x){try{eu=0;var G=bt(y,d,m,x);return va=null,G}catch(Y){if(Y===ha||Y===nn)throw Y;var ft=yl(29,Y,null,y.mode);return ft.lanes=x,ft.return=y,ft}}}var we=Kr(!0),Jr=Kr(!1),oe=!1;function ki(t){t.updateQueue={baseState:t.memoizedState,firstBaseUpdate:null,lastBaseUpdate:null,shared:{pending:null,lanes:0,hiddenCallbacks:null},callbacks:null}}function Wi(t,l){t=t.updateQueue,l.updateQueue===t&&(l.updateQueue={baseState:t.baseState,firstBaseUpdate:t.firstBaseUpdate,lastBaseUpdate:t.lastBaseUpdate,shared:t.shared,callbacks:null})}function se(t){return{lane:t,tag:0,payload:null,callback:null,next:null}}function de(t,l,e){var a=t.updateQueue;if(a===null)return null;if(a=a.shared,(ot&2)!==0){var u=a.pending;return u===null?l.next=l:(l.next=u.next,u.next=l),a.pending=l,l=Pu(t),Ur(t,null,e),l}return Iu(t,a,l,e),Pu(t)}function uu(t,l,e){if(l=l.updateQueue,l!==null&&(l=l.shared,(e&4194048)!==0)){var a=l.lanes;a&=t.pendingLanes,e|=a,l.lanes=e,Yf(t,e)}}function $i(t,l){var e=t.updateQueue,a=t.alternate;if(a!==null&&(a=a.updateQueue,e===a)){var u=null,n=null;if(e=e.firstBaseUpdate,e!==null){do{var i={lane:e.lane,tag:e.tag,payload:e.payload,callback:null,next:null};n===null?u=n=i:n=n.next=i,e=e.next}while(e!==null);n===null?u=n=l:n=n.next=l}else u=n=l;e={baseState:a.baseState,firstBaseUpdate:u,lastBaseUpdate:n,shared:a.shared,callbacks:a.callbacks},t.updateQueue=e;return}t=e.lastBaseUpdate,t===null?e.firstBaseUpdate=l:t.next=l,e.lastBaseUpdate=l}var Fi=!1;function nu(){if(Fi){var t=ga;if(t!==null)throw t}}function iu(t,l,e,a){Fi=!1;var u=t.updateQueue;oe=!1;var n=u.firstBaseUpdate,i=u.lastBaseUpdate,c=u.shared.pending;if(c!==null){u.shared.pending=null;var r=c,g=r.next;r.next=null,i===null?n=g:i.next=g,i=r;var z=t.alternate;z!==null&&(z=z.updateQueue,c=z.lastBaseUpdate,c!==i&&(c===null?z.firstBaseUpdate=g:c.next=g,z.lastBaseUpdate=r))}if(n!==null){var A=u.baseState;i=0,z=g=r=null,c=n;do{var h=c.lane&-536870913,v=h!==c.lane;if(v?(ut&h)===h:(a&h)===h){h!==0&&h===ma&&(Fi=!0),z!==null&&(z=z.next={lane:0,tag:c.tag,payload:c.payload,callback:null,next:null});t:{var B=t,Z=c;h=l;var bt=e;switch(Z.tag){case 1:if(B=Z.payload,typeof B=="function"){A=B.call(bt,A,h);break t}A=B;break t;case 3:B.flags=B.flags&-65537|128;case 0:if(B=Z.payload,h=typeof B=="function"?B.call(bt,A,h):B,h==null)break t;A=j({},A,h);break t;case 2:oe=!0}}h=c.callback,h!==null&&(t.flags|=64,v&&(t.flags|=8192),v=u.callbacks,v===null?u.callbacks=[h]:v.push(h))}else v={lane:h,tag:c.tag,payload:c.payload,callback:c.callback,next:null},z===null?(g=z=v,r=A):z=z.next=v,i|=h;if(c=c.next,c===null){if(c=u.shared.pending,c===null)break;v=c,c=v.next,v.next=null,u.lastBaseUpdate=v,u.shared.pending=null}}while(!0);z===null&&(r=A),u.baseState=r,u.firstBaseUpdate=g,u.lastBaseUpdate=z,n===null&&(u.shared.lanes=0),ve|=i,t.lanes=i,t.memoizedState=A}}function kr(t,l){if(typeof t!="function")throw Error(f(191,t));t.call(l)}function Wr(t,l){var e=t.callbacks;if(e!==null)for(t.callbacks=null,t=0;tn?n:8;var i=S.T,c={};S.T=c,hc(t,!1,l,e);try{var r=u(),g=S.S;if(g!==null&&g(c,r),r!==null&&typeof r=="object"&&typeof r.then=="function"){var z=yy(r,a);ru(t,l,z,bl(t))}else ru(t,l,a,bl(t))}catch(A){ru(t,l,{then:function(){},status:"rejected",reason:A},bl())}finally{N.p=n,i!==null&&c.types!==null&&(i.types=c.types),S.T=i}}function py(){}function mc(t,l,e,a){if(t.tag!==5)throw Error(f(476));var u=Oo(t).queue;_o(t,u,l,Q,e===null?py:function(){return Do(t),e(a)})}function Oo(t){var l=t.memoizedState;if(l!==null)return l;l={memoizedState:Q,baseState:Q,baseQueue:null,queue:{pending:null,lanes:0,dispatch:null,lastRenderedReducer:Kl,lastRenderedState:Q},next:null};var e={};return l.next={memoizedState:e,baseState:e,baseQueue:null,queue:{pending:null,lanes:0,dispatch:null,lastRenderedReducer:Kl,lastRenderedState:e},next:null},t.memoizedState=l,t=t.alternate,t!==null&&(t.memoizedState=l),l}function Do(t){var l=Oo(t);l.next===null&&(l=t.alternate.memoizedState),ru(t,l.next.queue,{},bl())}function gc(){return Vt(Mu)}function Uo(){return Ct().memoizedState}function No(){return Ct().memoizedState}function Sy(t){for(var l=t.return;l!==null;){switch(l.tag){case 24:case 3:var e=bl();t=se(e);var a=de(l,t,e);a!==null&&(fl(a,l,e),uu(a,l,e)),l={cache:wi()},t.payload=l;return}l=l.return}}function zy(t,l,e){var a=bl();e={lane:a,revertLane:0,gesture:null,action:e,hasEagerState:!1,eagerState:null,next:null},pn(t)?Ro(l,e):(e=Ri(t,l,e,a),e!==null&&(fl(e,t,a),Ho(e,l,a)))}function Co(t,l,e){var a=bl();ru(t,l,e,a)}function ru(t,l,e,a){var u={lane:a,revertLane:0,gesture:null,action:e,hasEagerState:!1,eagerState:null,next:null};if(pn(t))Ro(l,u);else{var n=t.alternate;if(t.lanes===0&&(n===null||n.lanes===0)&&(n=l.lastRenderedReducer,n!==null))try{var i=l.lastRenderedState,c=n(i,e);if(u.hasEagerState=!0,u.eagerState=c,dl(c,i))return Iu(t,l,u,0),pt===null&&Fu(),!1}catch{}if(e=Ri(t,l,u,a),e!==null)return fl(e,t,a),Ho(e,l,a),!0}return!1}function hc(t,l,e,a){if(a={lane:2,revertLane:kc(),gesture:null,action:a,hasEagerState:!1,eagerState:null,next:null},pn(t)){if(l)throw Error(f(479))}else l=Ri(t,e,a,2),l!==null&&fl(l,t,2)}function pn(t){var l=t.alternate;return t===k||l!==null&&l===k}function Ro(t,l){pa=dn=!0;var e=t.pending;e===null?l.next=l:(l.next=e.next,e.next=l),t.pending=l}function Ho(t,l,e){if((e&4194048)!==0){var a=l.lanes;a&=t.pendingLanes,e|=a,l.lanes=e,Yf(t,e)}}var ou={readContext:Vt,use:gn,useCallback:Mt,useContext:Mt,useEffect:Mt,useImperativeHandle:Mt,useLayoutEffect:Mt,useInsertionEffect:Mt,useMemo:Mt,useReducer:Mt,useRef:Mt,useState:Mt,useDebugValue:Mt,useDeferredValue:Mt,useTransition:Mt,useSyncExternalStore:Mt,useId:Mt,useHostTransitionStatus:Mt,useFormState:Mt,useActionState:Mt,useOptimistic:Mt,useMemoCache:Mt,useCacheRefresh:Mt};ou.useEffectEvent=Mt;var jo={readContext:Vt,use:gn,useCallback:function(t,l){return $t().memoizedState=[t,l===void 0?null:l],t},useContext:Vt,useEffect:bo,useImperativeHandle:function(t,l,e){e=e!=null?e.concat([t]):null,vn(4194308,4,To.bind(null,l,t),e)},useLayoutEffect:function(t,l){return vn(4194308,4,t,l)},useInsertionEffect:function(t,l){vn(4,2,t,l)},useMemo:function(t,l){var e=$t();l=l===void 0?null:l;var a=t();if(Ve){ae(!0);try{t()}finally{ae(!1)}}return e.memoizedState=[a,l],a},useReducer:function(t,l,e){var a=$t();if(e!==void 0){var u=e(l);if(Ve){ae(!0);try{e(l)}finally{ae(!1)}}}else u=l;return a.memoizedState=a.baseState=u,t={pending:null,lanes:0,dispatch:null,lastRenderedReducer:t,lastRenderedState:u},a.queue=t,t=t.dispatch=zy.bind(null,k,t),[a.memoizedState,t]},useRef:function(t){var l=$t();return t={current:t},l.memoizedState=t},useState:function(t){t=rc(t);var l=t.queue,e=Co.bind(null,k,l);return l.dispatch=e,[t.memoizedState,e]},useDebugValue:dc,useDeferredValue:function(t,l){var e=$t();return yc(e,t,l)},useTransition:function(){var t=rc(!1);return t=_o.bind(null,k,t.queue,!0,!1),$t().memoizedState=t,[!1,t]},useSyncExternalStore:function(t,l,e){var a=k,u=$t();if(it){if(e===void 0)throw Error(f(407));e=e()}else{if(e=l(),pt===null)throw Error(f(349));(ut&127)!==0||lo(a,l,e)}u.memoizedState=e;var n={value:e,getSnapshot:l};return u.queue=n,bo(ao.bind(null,a,n,t),[t]),a.flags|=2048,za(9,{destroy:void 0},eo.bind(null,a,n,e,l),null),e},useId:function(){var t=$t(),l=pt.identifierPrefix;if(it){var e=jl,a=Hl;e=(a&~(1<<32-sl(a)-1)).toString(32)+e,l="_"+l+"R_"+e,e=yn++,0<\/script>",n=n.removeChild(n.firstChild);break;case"select":n=typeof a.is=="string"?i.createElement("select",{is:a.is}):i.createElement("select"),a.multiple?n.multiple=!0:a.size&&(n.size=a.size);break;default:n=typeof a.is=="string"?i.createElement(u,{is:a.is}):i.createElement(u)}}n[Lt]=l,n[el]=a;t:for(i=l.child;i!==null;){if(i.tag===5||i.tag===6)n.appendChild(i.stateNode);else if(i.tag!==4&&i.tag!==27&&i.child!==null){i.child.return=i,i=i.child;continue}if(i===l)break t;for(;i.sibling===null;){if(i.return===null||i.return===l)break t;i=i.return}i.sibling.return=i.return,i=i.sibling}l.stateNode=n;t:switch(Jt(n,u,a),u){case"button":case"input":case"select":case"textarea":a=!!a.autoFocus;break t;case"img":a=!0;break t;default:a=!1}a&&kl(l)}}return xt(l),Uc(l,l.type,t===null?null:t.memoizedProps,l.pendingProps,e),null;case 6:if(t&&l.stateNode!=null)t.memoizedProps!==a&&kl(l);else{if(typeof a!="string"&&l.stateNode===null)throw Error(f(166));if(t=lt.current,da(l)){if(t=l.stateNode,e=l.memoizedProps,a=null,u=wt,u!==null)switch(u.tag){case 27:case 5:a=u.memoizedProps}t[Lt]=l,t=!!(t.nodeValue===e||a!==null&&a.suppressHydrationWarning===!0||td(t.nodeValue,e)),t||fe(l,!0)}else t=Gn(t).createTextNode(a),t[Lt]=l,l.stateNode=t}return xt(l),null;case 31:if(e=l.memoizedState,t===null||t.memoizedState!==null){if(a=da(l),e!==null){if(t===null){if(!a)throw Error(f(318));if(t=l.memoizedState,t=t!==null?t.dehydrated:null,!t)throw Error(f(557));t[Lt]=l}else Ye(),(l.flags&128)===0&&(l.memoizedState=null),l.flags|=4;xt(l),t=!1}else e=Xi(),t!==null&&t.memoizedState!==null&&(t.memoizedState.hydrationErrors=e),t=!0;if(!t)return l.flags&256?(gl(l),l):(gl(l),null);if((l.flags&128)!==0)throw Error(f(558))}return xt(l),null;case 13:if(a=l.memoizedState,t===null||t.memoizedState!==null&&t.memoizedState.dehydrated!==null){if(u=da(l),a!==null&&a.dehydrated!==null){if(t===null){if(!u)throw Error(f(318));if(u=l.memoizedState,u=u!==null?u.dehydrated:null,!u)throw Error(f(317));u[Lt]=l}else Ye(),(l.flags&128)===0&&(l.memoizedState=null),l.flags|=4;xt(l),u=!1}else u=Xi(),t!==null&&t.memoizedState!==null&&(t.memoizedState.hydrationErrors=u),u=!0;if(!u)return l.flags&256?(gl(l),l):(gl(l),null)}return gl(l),(l.flags&128)!==0?(l.lanes=e,l):(e=a!==null,t=t!==null&&t.memoizedState!==null,e&&(a=l.child,u=null,a.alternate!==null&&a.alternate.memoizedState!==null&&a.alternate.memoizedState.cachePool!==null&&(u=a.alternate.memoizedState.cachePool.pool),n=null,a.memoizedState!==null&&a.memoizedState.cachePool!==null&&(n=a.memoizedState.cachePool.pool),n!==u&&(a.flags|=2048)),e!==t&&e&&(l.child.flags|=8192),An(l,l.updateQueue),xt(l),null);case 4:return Ut(),t===null&&Ic(l.stateNode.containerInfo),xt(l),null;case 10:return wl(l.type),xt(l),null;case 19:if(E(Nt),a=l.memoizedState,a===null)return xt(l),null;if(u=(l.flags&128)!==0,n=a.rendering,n===null)if(u)du(a,!1);else{if(_t!==0||t!==null&&(t.flags&128)!==0)for(t=l.child;t!==null;){if(n=sn(t),n!==null){for(l.flags|=128,du(a,!1),t=n.updateQueue,l.updateQueue=t,An(l,t),l.subtreeFlags=0,t=e,e=l.child;e!==null;)Nr(e,t),e=e.sibling;return C(Nt,Nt.current&1|2),it&&Zl(l,a.treeForkCount),l.child}t=t.sibling}a.tail!==null&&rl()>Dn&&(l.flags|=128,u=!0,du(a,!1),l.lanes=4194304)}else{if(!u)if(t=sn(n),t!==null){if(l.flags|=128,u=!0,t=t.updateQueue,l.updateQueue=t,An(l,t),du(a,!0),a.tail===null&&a.tailMode==="hidden"&&!n.alternate&&!it)return xt(l),null}else 2*rl()-a.renderingStartTime>Dn&&e!==536870912&&(l.flags|=128,u=!0,du(a,!1),l.lanes=4194304);a.isBackwards?(n.sibling=l.child,l.child=n):(t=a.last,t!==null?t.sibling=n:l.child=n,a.last=n)}return a.tail!==null?(t=a.tail,a.rendering=t,a.tail=t.sibling,a.renderingStartTime=rl(),t.sibling=null,e=Nt.current,C(Nt,u?e&1|2:e&1),it&&Zl(l,a.treeForkCount),t):(xt(l),null);case 22:case 23:return gl(l),Pi(),a=l.memoizedState!==null,t!==null?t.memoizedState!==null!==a&&(l.flags|=8192):a&&(l.flags|=8192),a?(e&536870912)!==0&&(l.flags&128)===0&&(xt(l),l.subtreeFlags&6&&(l.flags|=8192)):xt(l),e=l.updateQueue,e!==null&&An(l,e.retryQueue),e=null,t!==null&&t.memoizedState!==null&&t.memoizedState.cachePool!==null&&(e=t.memoizedState.cachePool.pool),a=null,l.memoizedState!==null&&l.memoizedState.cachePool!==null&&(a=l.memoizedState.cachePool.pool),a!==e&&(l.flags|=2048),t!==null&&E(Qe),null;case 24:return e=null,t!==null&&(e=t.memoizedState.cache),l.memoizedState.cache!==e&&(l.flags|=2048),wl(Ht),xt(l),null;case 25:return null;case 30:return null}throw Error(f(156,l.tag))}function My(t,l){switch(Yi(l),l.tag){case 1:return t=l.flags,t&65536?(l.flags=t&-65537|128,l):null;case 3:return wl(Ht),Ut(),t=l.flags,(t&65536)!==0&&(t&128)===0?(l.flags=t&-65537|128,l):null;case 26:case 27:case 5:return Hu(l),null;case 31:if(l.memoizedState!==null){if(gl(l),l.alternate===null)throw Error(f(340));Ye()}return t=l.flags,t&65536?(l.flags=t&-65537|128,l):null;case 13:if(gl(l),t=l.memoizedState,t!==null&&t.dehydrated!==null){if(l.alternate===null)throw Error(f(340));Ye()}return t=l.flags,t&65536?(l.flags=t&-65537|128,l):null;case 19:return E(Nt),null;case 4:return Ut(),null;case 10:return wl(l.type),null;case 22:case 23:return gl(l),Pi(),t!==null&&E(Qe),t=l.flags,t&65536?(l.flags=t&-65537|128,l):null;case 24:return wl(Ht),null;case 25:return null;default:return null}}function ns(t,l){switch(Yi(l),l.tag){case 3:wl(Ht),Ut();break;case 26:case 27:case 5:Hu(l);break;case 4:Ut();break;case 31:l.memoizedState!==null&&gl(l);break;case 13:gl(l);break;case 19:E(Nt);break;case 10:wl(l.type);break;case 22:case 23:gl(l),Pi(),t!==null&&E(Qe);break;case 24:wl(Ht)}}function yu(t,l){try{var e=l.updateQueue,a=e!==null?e.lastEffect:null;if(a!==null){var u=a.next;e=u;do{if((e.tag&t)===t){a=void 0;var n=e.create,i=e.inst;a=n(),i.destroy=a}e=e.next}while(e!==u)}}catch(c){gt(l,l.return,c)}}function ge(t,l,e){try{var a=l.updateQueue,u=a!==null?a.lastEffect:null;if(u!==null){var n=u.next;a=n;do{if((a.tag&t)===t){var i=a.inst,c=i.destroy;if(c!==void 0){i.destroy=void 0,u=l;var r=e,g=c;try{g()}catch(z){gt(u,r,z)}}}a=a.next}while(a!==n)}}catch(z){gt(l,l.return,z)}}function is(t){var l=t.updateQueue;if(l!==null){var e=t.stateNode;try{Wr(l,e)}catch(a){gt(t,t.return,a)}}}function cs(t,l,e){e.props=Ke(t.type,t.memoizedProps),e.state=t.memoizedState;try{e.componentWillUnmount()}catch(a){gt(t,l,a)}}function mu(t,l){try{var e=t.ref;if(e!==null){switch(t.tag){case 26:case 27:case 5:var a=t.stateNode;break;case 30:a=t.stateNode;break;default:a=t.stateNode}typeof e=="function"?t.refCleanup=e(a):e.current=a}}catch(u){gt(t,l,u)}}function Bl(t,l){var e=t.ref,a=t.refCleanup;if(e!==null)if(typeof a=="function")try{a()}catch(u){gt(t,l,u)}finally{t.refCleanup=null,t=t.alternate,t!=null&&(t.refCleanup=null)}else if(typeof e=="function")try{e(null)}catch(u){gt(t,l,u)}else e.current=null}function fs(t){var l=t.type,e=t.memoizedProps,a=t.stateNode;try{t:switch(l){case"button":case"input":case"select":case"textarea":e.autoFocus&&a.focus();break t;case"img":e.src?a.src=e.src:e.srcSet&&(a.srcset=e.srcSet)}}catch(u){gt(t,t.return,u)}}function Nc(t,l,e){try{var a=t.stateNode;ky(a,t.type,e,l),a[el]=l}catch(u){gt(t,t.return,u)}}function rs(t){return t.tag===5||t.tag===3||t.tag===26||t.tag===27&&Te(t.type)||t.tag===4}function Cc(t){t:for(;;){for(;t.sibling===null;){if(t.return===null||rs(t.return))return null;t=t.return}for(t.sibling.return=t.return,t=t.sibling;t.tag!==5&&t.tag!==6&&t.tag!==18;){if(t.tag===27&&Te(t.type)||t.flags&2||t.child===null||t.tag===4)continue t;t.child.return=t,t=t.child}if(!(t.flags&2))return t.stateNode}}function Rc(t,l,e){var a=t.tag;if(a===5||a===6)t=t.stateNode,l?(e.nodeType===9?e.body:e.nodeName==="HTML"?e.ownerDocument.body:e).insertBefore(t,l):(l=e.nodeType===9?e.body:e.nodeName==="HTML"?e.ownerDocument.body:e,l.appendChild(t),e=e._reactRootContainer,e!=null||l.onclick!==null||(l.onclick=Gl));else if(a!==4&&(a===27&&Te(t.type)&&(e=t.stateNode,l=null),t=t.child,t!==null))for(Rc(t,l,e),t=t.sibling;t!==null;)Rc(t,l,e),t=t.sibling}function En(t,l,e){var a=t.tag;if(a===5||a===6)t=t.stateNode,l?e.insertBefore(t,l):e.appendChild(t);else if(a!==4&&(a===27&&Te(t.type)&&(e=t.stateNode),t=t.child,t!==null))for(En(t,l,e),t=t.sibling;t!==null;)En(t,l,e),t=t.sibling}function os(t){var l=t.stateNode,e=t.memoizedProps;try{for(var a=t.type,u=l.attributes;u.length;)l.removeAttributeNode(u[0]);Jt(l,a,e),l[Lt]=t,l[el]=e}catch(n){gt(t,t.return,n)}}var Wl=!1,qt=!1,Hc=!1,ss=typeof WeakSet=="function"?WeakSet:Set,Xt=null;function _y(t,l){if(t=t.containerInfo,lf=Kn,t=Tr(t),_i(t)){if("selectionStart"in t)var e={start:t.selectionStart,end:t.selectionEnd};else t:{e=(e=t.ownerDocument)&&e.defaultView||window;var a=e.getSelection&&e.getSelection();if(a&&a.rangeCount!==0){e=a.anchorNode;var u=a.anchorOffset,n=a.focusNode;a=a.focusOffset;try{e.nodeType,n.nodeType}catch{e=null;break t}var i=0,c=-1,r=-1,g=0,z=0,A=t,h=null;l:for(;;){for(var v;A!==e||u!==0&&A.nodeType!==3||(c=i+u),A!==n||a!==0&&A.nodeType!==3||(r=i+a),A.nodeType===3&&(i+=A.nodeValue.length),(v=A.firstChild)!==null;)h=A,A=v;for(;;){if(A===t)break l;if(h===e&&++g===u&&(c=i),h===n&&++z===a&&(r=i),(v=A.nextSibling)!==null)break;A=h,h=A.parentNode}A=v}e=c===-1||r===-1?null:{start:c,end:r}}else e=null}e=e||{start:0,end:0}}else e=null;for(ef={focusedElem:t,selectionRange:e},Kn=!1,Xt=l;Xt!==null;)if(l=Xt,t=l.child,(l.subtreeFlags&1028)!==0&&t!==null)t.return=l,Xt=t;else for(;Xt!==null;){switch(l=Xt,n=l.alternate,t=l.flags,l.tag){case 0:if((t&4)!==0&&(t=l.updateQueue,t=t!==null?t.events:null,t!==null))for(e=0;e title"))),Jt(n,a,e),n[Lt]=t,Gt(n),a=n;break t;case"link":var i=vd("link","href",u).get(a+(e.href||""));if(i){for(var c=0;cbt&&(i=bt,bt=Z,Z=i);var y=Sr(c,Z),d=Sr(c,bt);if(y&&d&&(v.rangeCount!==1||v.anchorNode!==y.node||v.anchorOffset!==y.offset||v.focusNode!==d.node||v.focusOffset!==d.offset)){var m=A.createRange();m.setStart(y.node,y.offset),v.removeAllRanges(),Z>bt?(v.addRange(m),v.extend(d.node,d.offset)):(m.setEnd(d.node,d.offset),v.addRange(m))}}}}for(A=[],v=c;v=v.parentNode;)v.nodeType===1&&A.push({element:v,left:v.scrollLeft,top:v.scrollTop});for(typeof c.focus=="function"&&c.focus(),c=0;ce?32:e,S.T=null,e=Qc,Qc=null;var n=pe,i=te;if(Yt=0,Ma=pe=null,te=0,(ot&6)!==0)throw Error(f(331));var c=ot;if(ot|=4,Ts(n.current),ps(n,n.current,i,e),ot=c,Su(0,!1),ol&&typeof ol.onPostCommitFiberRoot=="function")try{ol.onPostCommitFiberRoot(Ya,n)}catch{}return!0}finally{N.p=u,S.T=a,Xs(t,l)}}function Zs(t,l,e){l=Tl(e,l),l=Sc(t.stateNode,l,2),t=de(t,l,2),t!==null&&(Xa(t,2),ql(t))}function gt(t,l,e){if(t.tag===3)Zs(t,t,e);else for(;l!==null;){if(l.tag===3){Zs(l,t,e);break}else if(l.tag===1){var a=l.stateNode;if(typeof l.type.getDerivedStateFromError=="function"||typeof a.componentDidCatch=="function"&&(be===null||!be.has(a))){t=Tl(e,t),e=Lo(2),a=de(l,e,2),a!==null&&(wo(e,a,l,t),Xa(a,2),ql(a));break}}l=l.return}}function Vc(t,l,e){var a=t.pingCache;if(a===null){a=t.pingCache=new Uy;var u=new Set;a.set(l,u)}else u=a.get(l),u===void 0&&(u=new Set,a.set(l,u));u.has(e)||(qc=!0,u.add(e),t=jy.bind(null,t,l,e),l.then(t,t))}function jy(t,l,e){var a=t.pingCache;a!==null&&a.delete(l),t.pingedLanes|=t.suspendedLanes&e,t.warmLanes&=~e,pt===t&&(ut&e)===e&&(_t===4||_t===3&&(ut&62914560)===ut&&300>rl()-On?(ot&2)===0&&_a(t,0):Yc|=e,Ea===ut&&(Ea=0)),ql(t)}function Ls(t,l){l===0&&(l=Bf()),t=Be(t,l),t!==null&&(Xa(t,l),ql(t))}function By(t){var l=t.memoizedState,e=0;l!==null&&(e=l.retryLane),Ls(t,e)}function qy(t,l){var e=0;switch(t.tag){case 31:case 13:var a=t.stateNode,u=t.memoizedState;u!==null&&(e=u.retryLane);break;case 19:a=t.stateNode;break;case 22:a=t.stateNode._retryCache;break;default:throw Error(f(314))}a!==null&&a.delete(l),Ls(t,e)}function Yy(t,l){return ui(t,l)}var jn=null,Da=null,Kc=!1,Bn=!1,Jc=!1,ze=0;function ql(t){t!==Da&&t.next===null&&(Da===null?jn=Da=t:Da=Da.next=t),Bn=!0,Kc||(Kc=!0,Xy())}function Su(t,l){if(!Jc&&Bn){Jc=!0;do for(var e=!1,a=jn;a!==null;){if(t!==0){var u=a.pendingLanes;if(u===0)var n=0;else{var i=a.suspendedLanes,c=a.pingedLanes;n=(1<<31-sl(42|t)+1)-1,n&=u&~(i&~c),n=n&201326741?n&201326741|1:n?n|2:0}n!==0&&(e=!0,Js(a,n))}else n=ut,n=Gu(a,a===pt?n:0,a.cancelPendingCommit!==null||a.timeoutHandle!==-1),(n&3)===0||Ga(a,n)||(e=!0,Js(a,n));a=a.next}while(e);Jc=!1}}function Gy(){ws()}function ws(){Bn=Kc=!1;var t=0;ze!==0&&$y()&&(t=ze);for(var l=rl(),e=null,a=jn;a!==null;){var u=a.next,n=Vs(a,l);n===0?(a.next=null,e===null?jn=u:e.next=u,u===null&&(Da=e)):(e=a,(t!==0||(n&3)!==0)&&(Bn=!0)),a=u}Yt!==0&&Yt!==5||Su(t),ze!==0&&(ze=0)}function Vs(t,l){for(var e=t.suspendedLanes,a=t.pingedLanes,u=t.expirationTimes,n=t.pendingLanes&-62914561;0c)break;var z=r.transferSize,A=r.initiatorType;z&&ld(A)&&(r=r.responseEnd,i+=z*(r"u"?null:document;function yd(t,l,e){var a=Ua;if(a&&typeof l=="string"&&l){var u=Sl(l);u='link[rel="'+t+'"][href="'+u+'"]',typeof e=="string"&&(u+='[crossorigin="'+e+'"]'),dd.has(u)||(dd.add(u),t={rel:t,crossOrigin:e,href:l},a.querySelector(u)===null&&(l=a.createElement("link"),Jt(l,"link",t),Gt(l),a.head.appendChild(l)))}}function nm(t){le.D(t),yd("dns-prefetch",t,null)}function im(t,l){le.C(t,l),yd("preconnect",t,l)}function cm(t,l,e){le.L(t,l,e);var a=Ua;if(a&&t&&l){var u='link[rel="preload"][as="'+Sl(l)+'"]';l==="image"&&e&&e.imageSrcSet?(u+='[imagesrcset="'+Sl(e.imageSrcSet)+'"]',typeof e.imageSizes=="string"&&(u+='[imagesizes="'+Sl(e.imageSizes)+'"]')):u+='[href="'+Sl(t)+'"]';var n=u;switch(l){case"style":n=Na(t);break;case"script":n=Ca(t)}Ol.has(n)||(t=j({rel:"preload",href:l==="image"&&e&&e.imageSrcSet?void 0:t,as:l},e),Ol.set(n,t),a.querySelector(u)!==null||l==="style"&&a.querySelector(Au(n))||l==="script"&&a.querySelector(Eu(n))||(l=a.createElement("link"),Jt(l,"link",t),Gt(l),a.head.appendChild(l)))}}function fm(t,l){le.m(t,l);var e=Ua;if(e&&t){var a=l&&typeof l.as=="string"?l.as:"script",u='link[rel="modulepreload"][as="'+Sl(a)+'"][href="'+Sl(t)+'"]',n=u;switch(a){case"audioworklet":case"paintworklet":case"serviceworker":case"sharedworker":case"worker":case"script":n=Ca(t)}if(!Ol.has(n)&&(t=j({rel:"modulepreload",href:t},l),Ol.set(n,t),e.querySelector(u)===null)){switch(a){case"audioworklet":case"paintworklet":case"serviceworker":case"sharedworker":case"worker":case"script":if(e.querySelector(Eu(n)))return}a=e.createElement("link"),Jt(a,"link",t),Gt(a),e.head.appendChild(a)}}}function rm(t,l,e){le.S(t,l,e);var a=Ua;if(a&&t){var u=Pe(a).hoistableStyles,n=Na(t);l=l||"default";var i=u.get(n);if(!i){var c={loading:0,preload:null};if(i=a.querySelector(Au(n)))c.loading=5;else{t=j({rel:"stylesheet",href:t,"data-precedence":l},e),(e=Ol.get(n))&&of(t,e);var r=i=a.createElement("link");Gt(r),Jt(r,"link",t),r._p=new Promise(function(g,z){r.onload=g,r.onerror=z}),r.addEventListener("load",function(){c.loading|=1}),r.addEventListener("error",function(){c.loading|=2}),c.loading|=4,Qn(i,l,a)}i={type:"stylesheet",instance:i,count:1,state:c},u.set(n,i)}}}function om(t,l){le.X(t,l);var e=Ua;if(e&&t){var a=Pe(e).hoistableScripts,u=Ca(t),n=a.get(u);n||(n=e.querySelector(Eu(u)),n||(t=j({src:t,async:!0},l),(l=Ol.get(u))&&sf(t,l),n=e.createElement("script"),Gt(n),Jt(n,"link",t),e.head.appendChild(n)),n={type:"script",instance:n,count:1,state:null},a.set(u,n))}}function sm(t,l){le.M(t,l);var e=Ua;if(e&&t){var a=Pe(e).hoistableScripts,u=Ca(t),n=a.get(u);n||(n=e.querySelector(Eu(u)),n||(t=j({src:t,async:!0,type:"module"},l),(l=Ol.get(u))&&sf(t,l),n=e.createElement("script"),Gt(n),Jt(n,"link",t),e.head.appendChild(n)),n={type:"script",instance:n,count:1,state:null},a.set(u,n))}}function md(t,l,e,a){var u=(u=lt.current)?Xn(u):null;if(!u)throw Error(f(446));switch(t){case"meta":case"title":return null;case"style":return typeof e.precedence=="string"&&typeof e.href=="string"?(l=Na(e.href),e=Pe(u).hoistableStyles,a=e.get(l),a||(a={type:"style",instance:null,count:0,state:null},e.set(l,a)),a):{type:"void",instance:null,count:0,state:null};case"link":if(e.rel==="stylesheet"&&typeof e.href=="string"&&typeof e.precedence=="string"){t=Na(e.href);var n=Pe(u).hoistableStyles,i=n.get(t);if(i||(u=u.ownerDocument||u,i={type:"stylesheet",instance:null,count:0,state:{loading:0,preload:null}},n.set(t,i),(n=u.querySelector(Au(t)))&&!n._p&&(i.instance=n,i.state.loading=5),Ol.has(t)||(e={rel:"preload",as:"style",href:e.href,crossOrigin:e.crossOrigin,integrity:e.integrity,media:e.media,hrefLang:e.hrefLang,referrerPolicy:e.referrerPolicy},Ol.set(t,e),n||dm(u,t,e,i.state))),l&&a===null)throw Error(f(528,""));return i}if(l&&a!==null)throw Error(f(529,""));return null;case"script":return l=e.async,e=e.src,typeof e=="string"&&l&&typeof l!="function"&&typeof l!="symbol"?(l=Ca(e),e=Pe(u).hoistableScripts,a=e.get(l),a||(a={type:"script",instance:null,count:0,state:null},e.set(l,a)),a):{type:"void",instance:null,count:0,state:null};default:throw Error(f(444,t))}}function Na(t){return'href="'+Sl(t)+'"'}function Au(t){return'link[rel="stylesheet"]['+t+"]"}function gd(t){return j({},t,{"data-precedence":t.precedence,precedence:null})}function dm(t,l,e,a){t.querySelector('link[rel="preload"][as="style"]['+l+"]")?a.loading=1:(l=t.createElement("link"),a.preload=l,l.addEventListener("load",function(){return a.loading|=1}),l.addEventListener("error",function(){return a.loading|=2}),Jt(l,"link",e),Gt(l),t.head.appendChild(l))}function Ca(t){return'[src="'+Sl(t)+'"]'}function Eu(t){return"script[async]"+t}function hd(t,l,e){if(l.count++,l.instance===null)switch(l.type){case"style":var a=t.querySelector('style[data-href~="'+Sl(e.href)+'"]');if(a)return l.instance=a,Gt(a),a;var u=j({},e,{"data-href":e.href,"data-precedence":e.precedence,href:null,precedence:null});return a=(t.ownerDocument||t).createElement("style"),Gt(a),Jt(a,"style",u),Qn(a,e.precedence,t),l.instance=a;case"stylesheet":u=Na(e.href);var n=t.querySelector(Au(u));if(n)return l.state.loading|=4,l.instance=n,Gt(n),n;a=gd(e),(u=Ol.get(u))&&of(a,u),n=(t.ownerDocument||t).createElement("link"),Gt(n);var i=n;return i._p=new Promise(function(c,r){i.onload=c,i.onerror=r}),Jt(n,"link",a),l.state.loading|=4,Qn(n,e.precedence,t),l.instance=n;case"script":return n=Ca(e.src),(u=t.querySelector(Eu(n)))?(l.instance=u,Gt(u),u):(a=e,(u=Ol.get(n))&&(a=j({},e),sf(a,u)),t=t.ownerDocument||t,u=t.createElement("script"),Gt(u),Jt(u,"link",a),t.head.appendChild(u),l.instance=u);case"void":return null;default:throw Error(f(443,l.type))}else l.type==="stylesheet"&&(l.state.loading&4)===0&&(a=l.instance,l.state.loading|=4,Qn(a,e.precedence,t));return l.instance}function Qn(t,l,e){for(var a=e.querySelectorAll('link[rel="stylesheet"][data-precedence],style[data-precedence]'),u=a.length?a[a.length-1]:null,n=u,i=0;i title"):null)}function ym(t,l,e){if(e===1||l.itemProp!=null)return!1;switch(t){case"meta":case"title":return!0;case"style":if(typeof l.precedence!="string"||typeof l.href!="string"||l.href==="")break;return!0;case"link":if(typeof l.rel!="string"||typeof l.href!="string"||l.href===""||l.onLoad||l.onError)break;return l.rel==="stylesheet"?(t=l.disabled,typeof l.precedence=="string"&&t==null):!0;case"script":if(l.async&&typeof l.async!="function"&&typeof l.async!="symbol"&&!l.onLoad&&!l.onError&&l.src&&typeof l.src=="string")return!0}return!1}function pd(t){return!(t.type==="stylesheet"&&(t.state.loading&3)===0)}function mm(t,l,e,a){if(e.type==="stylesheet"&&(typeof a.media!="string"||matchMedia(a.media).matches!==!1)&&(e.state.loading&4)===0){if(e.instance===null){var u=Na(a.href),n=l.querySelector(Au(u));if(n){l=n._p,l!==null&&typeof l=="object"&&typeof l.then=="function"&&(t.count++,t=Ln.bind(t),l.then(t,t)),e.state.loading|=4,e.instance=n,Gt(n);return}n=l.ownerDocument||l,a=gd(a),(u=Ol.get(u))&&of(a,u),n=n.createElement("link"),Gt(n);var i=n;i._p=new Promise(function(c,r){i.onload=c,i.onerror=r}),Jt(n,"link",a),e.instance=n}t.stylesheets===null&&(t.stylesheets=new Map),t.stylesheets.set(e,l),(l=e.state.preload)&&(e.state.loading&3)===0&&(t.count++,e=Ln.bind(t),l.addEventListener("load",e),l.addEventListener("error",e))}}var df=0;function gm(t,l){return t.stylesheets&&t.count===0&&Vn(t,t.stylesheets),0df?50:800)+l);return t.unsuspend=e,function(){t.unsuspend=null,clearTimeout(a),clearTimeout(u)}}:null}function Ln(){if(this.count--,this.count===0&&(this.imgCount===0||!this.waitingForImages)){if(this.stylesheets)Vn(this,this.stylesheets);else if(this.unsuspend){var t=this.unsuspend;this.unsuspend=null,t()}}}var wn=null;function Vn(t,l){t.stylesheets=null,t.unsuspend!==null&&(t.count++,wn=new Map,l.forEach(hm,t),wn=null,Ln.call(t))}function hm(t,l){if(!(l.state.loading&4)){var e=wn.get(t);if(e)var a=e.get(null);else{e=new Map,wn.set(t,e);for(var u=t.querySelectorAll("link[data-precedence],style[data-precedence]"),n=0;n"u"||typeof __REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE!="function"))try{__REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE(o)}catch(b){console.error(b)}}return o(),zf.exports=Cm(),zf.exports}var Hm=Rm();const jm=o=>o.replace(/([a-z0-9])([A-Z])/g,"$1-$2").toLowerCase(),Kd=(...o)=>o.filter((b,T,f)=>!!b&&b.trim()!==""&&f.indexOf(b)===T).join(" ").trim();var Bm={xmlns:"http://www.w3.org/2000/svg",width:24,height:24,viewBox:"0 0 24 24",fill:"none",stroke:"currentColor",strokeWidth:2,strokeLinecap:"round",strokeLinejoin:"round"};const qm=Ot.forwardRef(({color:o="currentColor",size:b=24,strokeWidth:T=2,absoluteStrokeWidth:f,className:_="",children:M,iconNode:D,...U},O)=>Ot.createElement("svg",{ref:O,...Bm,width:b,height:b,stroke:o,strokeWidth:f?Number(T)*24/Number(b):T,className:Kd("lucide",_),...U},[...D.map(([p,H])=>Ot.createElement(p,H)),...Array.isArray(M)?M:[M]]));const ja=(o,b)=>{const T=Ot.forwardRef(({className:f,..._},M)=>Ot.createElement(qm,{ref:M,iconNode:b,className:Kd(`lucide-${jm(o)}`,f),..._}));return T.displayName=`${o}`,T};const Ym=ja("Binary",[["rect",{x:"14",y:"14",width:"4",height:"6",rx:"2",key:"p02svl"}],["rect",{x:"6",y:"4",width:"4",height:"6",rx:"2",key:"xm4xkj"}],["path",{d:"M6 20h4",key:"1i6q5t"}],["path",{d:"M14 10h4",key:"ru81e7"}],["path",{d:"M6 14h2v6",key:"16z9wg"}],["path",{d:"M14 4h2v6",key:"1idq9u"}]]);const Gm=ja("EyeOff",[["path",{d:"M10.733 5.076a10.744 10.744 0 0 1 11.205 6.575 1 1 0 0 1 0 .696 10.747 10.747 0 0 1-1.444 2.49",key:"ct8e1f"}],["path",{d:"M14.084 14.158a3 3 0 0 1-4.242-4.242",key:"151rxh"}],["path",{d:"M17.479 17.499a10.75 10.75 0 0 1-15.417-5.151 1 1 0 0 1 0-.696 10.75 10.75 0 0 1 4.446-5.143",key:"13bj9a"}],["path",{d:"m2 2 20 20",key:"1ooewy"}]]);const Xm=ja("Eye",[["path",{d:"M2.062 12.348a1 1 0 0 1 0-.696 10.75 10.75 0 0 1 19.876 0 1 1 0 0 1 0 .696 10.75 10.75 0 0 1-19.876 0",key:"1nclc0"}],["circle",{cx:"12",cy:"12",r:"3",key:"1v7zrd"}]]);const Qm=ja("LoaderCircle",[["path",{d:"M21 12a9 9 0 1 1-6.219-8.56",key:"13zald"}]]);const Zm=ja("Lock",[["rect",{width:"18",height:"11",x:"3",y:"11",rx:"2",ry:"2",key:"1w4ew1"}],["path",{d:"M7 11V7a5 5 0 0 1 10 0v4",key:"fwvmzm"}]]);const Lm=ja("LogIn",[["path",{d:"M15 3h4a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2h-4",key:"u53s6r"}],["polyline",{points:"10 17 15 12 10 7",key:"1ail0h"}],["line",{x1:"15",x2:"3",y1:"12",y2:"12",key:"v6grx8"}]]);function wm(){return window.__DATA__??{}}function Jd(o){var b,T,f="";if(typeof o=="string"||typeof o=="number")f+=o;else if(typeof o=="object")if(Array.isArray(o)){var _=o.length;for(b=0;b<_;b++)o[b]&&(T=Jd(o[b]))&&(f&&(f+=" "),f+=T)}else for(T in o)o[T]&&(f&&(f+=" "),f+=T);return f}function Vm(){for(var o,b,T=0,f="",_=arguments.length;T<_;T++)(o=arguments[T])&&(b=Jd(o))&&(f&&(f+=" "),f+=b);return f}const Uf="-",Km=o=>{const b=km(o),{conflictingClassGroups:T,conflictingClassGroupModifiers:f}=o;return{getClassGroupId:D=>{const U=D.split(Uf);return U[0]===""&&U.length!==1&&U.shift(),kd(U,b)||Jm(D)},getConflictingClassGroupIds:(D,U)=>{const O=T[D]||[];return U&&f[D]?[...O,...f[D]]:O}}},kd=(o,b)=>{if(o.length===0)return b.classGroupId;const T=o[0],f=b.nextPart.get(T),_=f?kd(o.slice(1),f):void 0;if(_)return _;if(b.validators.length===0)return;const M=o.join(Uf);return b.validators.find(({validator:D})=>D(M))?.classGroupId},Ld=/^\[(.+)\]$/,Jm=o=>{if(Ld.test(o)){const b=Ld.exec(o)[1],T=b?.substring(0,b.indexOf(":"));if(T)return"arbitrary.."+T}},km=o=>{const{theme:b,prefix:T}=o,f={nextPart:new Map,validators:[]};return $m(Object.entries(o.classGroups),T).forEach(([M,D])=>{_f(D,f,M,b)}),f},_f=(o,b,T,f)=>{o.forEach(_=>{if(typeof _=="string"){const M=_===""?b:wd(b,_);M.classGroupId=T;return}if(typeof _=="function"){if(Wm(_)){_f(_(f),b,T,f);return}b.validators.push({validator:_,classGroupId:T});return}Object.entries(_).forEach(([M,D])=>{_f(D,wd(b,M),T,f)})})},wd=(o,b)=>{let T=o;return b.split(Uf).forEach(f=>{T.nextPart.has(f)||T.nextPart.set(f,{nextPart:new Map,validators:[]}),T=T.nextPart.get(f)}),T},Wm=o=>o.isThemeGetter,$m=(o,b)=>b?o.map(([T,f])=>{const _=f.map(M=>typeof M=="string"?b+M:typeof M=="object"?Object.fromEntries(Object.entries(M).map(([D,U])=>[b+D,U])):M);return[T,_]}):o,Fm=o=>{if(o<1)return{get:()=>{},set:()=>{}};let b=0,T=new Map,f=new Map;const _=(M,D)=>{T.set(M,D),b++,b>o&&(b=0,f=T,T=new Map)};return{get(M){let D=T.get(M);if(D!==void 0)return D;if((D=f.get(M))!==void 0)return _(M,D),D},set(M,D){T.has(M)?T.set(M,D):_(M,D)}}},Wd="!",Im=o=>{const{separator:b,experimentalParseClassName:T}=o,f=b.length===1,_=b[0],M=b.length,D=U=>{const O=[];let p=0,H=0,j;for(let V=0;VH?j-H:void 0;return{modifiers:O,hasImportantModifier:st,baseClassName:L,maybePostfixModifierPosition:X}};return T?U=>T({className:U,parseClassName:D}):D},Pm=o=>{if(o.length<=1)return o;const b=[];let T=[];return o.forEach(f=>{f[0]==="["?(b.push(...T.sort(),f),T=[]):T.push(f)}),b.push(...T.sort()),b},tg=o=>({cache:Fm(o.cacheSize),parseClassName:Im(o),...Km(o)}),lg=/\s+/,eg=(o,b)=>{const{parseClassName:T,getClassGroupId:f,getConflictingClassGroupIds:_}=b,M=[],D=o.trim().split(lg);let U="";for(let O=D.length-1;O>=0;O-=1){const p=D[O],{modifiers:H,hasImportantModifier:j,baseClassName:w,maybePostfixModifierPosition:st}=T(p);let L=!!st,X=f(L?w.substring(0,st):w);if(!X){if(!L){U=p+(U.length>0?" "+U:U);continue}if(X=f(w),!X){U=p+(U.length>0?" "+U:U);continue}L=!1}const V=Pm(H).join(":"),tt=j?V+Wd:V,St=tt+X;if(M.includes(St))continue;M.push(St);const zt=_(X,L);for(let P=0;P0?" "+U:U)}return U};function ag(){let o=0,b,T,f="";for(;o{if(typeof o=="string")return o;let b,T="";for(let f=0;fj(H),o());return T=tg(p),f=T.cache.get,_=T.cache.set,M=U,U(O)}function U(O){const p=f(O);if(p)return p;const H=eg(O,T);return _(O,H),H}return function(){return M(ag.apply(null,arguments))}}const At=o=>{const b=T=>T[o]||[];return b.isThemeGetter=!0,b},Fd=/^\[(?:([a-z-]+):)?(.+)\]$/i,ng=/^\d+\/\d+$/,ig=new Set(["px","full","screen"]),cg=/^(\d+(\.\d+)?)?(xs|sm|md|lg|xl)$/,fg=/\d+(%|px|r?em|[sdl]?v([hwib]|min|max)|pt|pc|in|cm|mm|cap|ch|ex|r?lh|cq(w|h|i|b|min|max))|\b(calc|min|max|clamp)\(.+\)|^0$/,rg=/^(rgba?|hsla?|hwb|(ok)?(lab|lch)|color-mix)\(.+\)$/,og=/^(inset_)?-?((\d+)?\.?(\d+)[a-z]+|0)_-?((\d+)?\.?(\d+)[a-z]+|0)/,sg=/^(url|image|image-set|cross-fade|element|(repeating-)?(linear|radial|conic)-gradient)\(.+\)$/,ee=o=>Ha(o)||ig.has(o)||ng.test(o),Oe=o=>Ba(o,"length",pg),Ha=o=>!!o&&!Number.isNaN(Number(o)),Ef=o=>Ba(o,"number",Ha),Cu=o=>!!o&&Number.isInteger(Number(o)),dg=o=>o.endsWith("%")&&Ha(o.slice(0,-1)),F=o=>Fd.test(o),De=o=>cg.test(o),yg=new Set(["length","size","percentage"]),mg=o=>Ba(o,yg,Id),gg=o=>Ba(o,"position",Id),hg=new Set(["image","url"]),vg=o=>Ba(o,hg,zg),bg=o=>Ba(o,"",Sg),Ru=()=>!0,Ba=(o,b,T)=>{const f=Fd.exec(o);return f?f[1]?typeof b=="string"?f[1]===b:b.has(f[1]):T(f[2]):!1},pg=o=>fg.test(o)&&!rg.test(o),Id=()=>!1,Sg=o=>og.test(o),zg=o=>sg.test(o),Tg=()=>{const o=At("colors"),b=At("spacing"),T=At("blur"),f=At("brightness"),_=At("borderColor"),M=At("borderRadius"),D=At("borderSpacing"),U=At("borderWidth"),O=At("contrast"),p=At("grayscale"),H=At("hueRotate"),j=At("invert"),w=At("gap"),st=At("gradientColorStops"),L=At("gradientColorStopPositions"),X=At("inset"),V=At("margin"),tt=At("opacity"),St=At("padding"),zt=At("saturate"),P=At("scale"),Rt=At("sepia"),Dt=At("skew"),W=At("space"),Zt=At("translate"),It=()=>["auto","contain","none"],Cl=()=>["auto","hidden","clip","visible","scroll"],Pt=()=>["auto",F,b],I=()=>[F,b],Rl=()=>["",ee,Oe],tl=()=>["auto",Ha,F],ll=()=>["bottom","center","left","left-bottom","left-top","right","right-bottom","right-top","top"],S=()=>["solid","dashed","dotted","double","none"],N=()=>["normal","multiply","screen","overlay","darken","lighten","color-dodge","color-burn","hard-light","soft-light","difference","exclusion","hue","saturation","color","luminosity"],Q=()=>["start","end","center","between","around","evenly","stretch"],ct=()=>["","0",F],dt=()=>["auto","avoid","all","avoid-page","page","left","right","column"],s=()=>[Ha,F];return{cacheSize:500,separator:":",theme:{colors:[Ru],spacing:[ee,Oe],blur:["none","",De,F],brightness:s(),borderColor:[o],borderRadius:["none","","full",De,F],borderSpacing:I(),borderWidth:Rl(),contrast:s(),grayscale:ct(),hueRotate:s(),invert:ct(),gap:I(),gradientColorStops:[o],gradientColorStopPositions:[dg,Oe],inset:Pt(),margin:Pt(),opacity:s(),padding:I(),saturate:s(),scale:s(),sepia:ct(),skew:s(),space:I(),translate:I()},classGroups:{aspect:[{aspect:["auto","square","video",F]}],container:["container"],columns:[{columns:[De]}],"break-after":[{"break-after":dt()}],"break-before":[{"break-before":dt()}],"break-inside":[{"break-inside":["auto","avoid","avoid-page","avoid-column"]}],"box-decoration":[{"box-decoration":["slice","clone"]}],box:[{box:["border","content"]}],display:["block","inline-block","inline","flex","inline-flex","table","inline-table","table-caption","table-cell","table-column","table-column-group","table-footer-group","table-header-group","table-row-group","table-row","flow-root","grid","inline-grid","contents","list-item","hidden"],float:[{float:["right","left","none","start","end"]}],clear:[{clear:["left","right","both","none","start","end"]}],isolation:["isolate","isolation-auto"],"object-fit":[{object:["contain","cover","fill","none","scale-down"]}],"object-position":[{object:[...ll(),F]}],overflow:[{overflow:Cl()}],"overflow-x":[{"overflow-x":Cl()}],"overflow-y":[{"overflow-y":Cl()}],overscroll:[{overscroll:It()}],"overscroll-x":[{"overscroll-x":It()}],"overscroll-y":[{"overscroll-y":It()}],position:["static","fixed","absolute","relative","sticky"],inset:[{inset:[X]}],"inset-x":[{"inset-x":[X]}],"inset-y":[{"inset-y":[X]}],start:[{start:[X]}],end:[{end:[X]}],top:[{top:[X]}],right:[{right:[X]}],bottom:[{bottom:[X]}],left:[{left:[X]}],visibility:["visible","invisible","collapse"],z:[{z:["auto",Cu,F]}],basis:[{basis:Pt()}],"flex-direction":[{flex:["row","row-reverse","col","col-reverse"]}],"flex-wrap":[{flex:["wrap","wrap-reverse","nowrap"]}],flex:[{flex:["1","auto","initial","none",F]}],grow:[{grow:ct()}],shrink:[{shrink:ct()}],order:[{order:["first","last","none",Cu,F]}],"grid-cols":[{"grid-cols":[Ru]}],"col-start-end":[{col:["auto",{span:["full",Cu,F]},F]}],"col-start":[{"col-start":tl()}],"col-end":[{"col-end":tl()}],"grid-rows":[{"grid-rows":[Ru]}],"row-start-end":[{row:["auto",{span:[Cu,F]},F]}],"row-start":[{"row-start":tl()}],"row-end":[{"row-end":tl()}],"grid-flow":[{"grid-flow":["row","col","dense","row-dense","col-dense"]}],"auto-cols":[{"auto-cols":["auto","min","max","fr",F]}],"auto-rows":[{"auto-rows":["auto","min","max","fr",F]}],gap:[{gap:[w]}],"gap-x":[{"gap-x":[w]}],"gap-y":[{"gap-y":[w]}],"justify-content":[{justify:["normal",...Q()]}],"justify-items":[{"justify-items":["start","end","center","stretch"]}],"justify-self":[{"justify-self":["auto","start","end","center","stretch"]}],"align-content":[{content:["normal",...Q(),"baseline"]}],"align-items":[{items:["start","end","center","baseline","stretch"]}],"align-self":[{self:["auto","start","end","center","stretch","baseline"]}],"place-content":[{"place-content":[...Q(),"baseline"]}],"place-items":[{"place-items":["start","end","center","baseline","stretch"]}],"place-self":[{"place-self":["auto","start","end","center","stretch"]}],p:[{p:[St]}],px:[{px:[St]}],py:[{py:[St]}],ps:[{ps:[St]}],pe:[{pe:[St]}],pt:[{pt:[St]}],pr:[{pr:[St]}],pb:[{pb:[St]}],pl:[{pl:[St]}],m:[{m:[V]}],mx:[{mx:[V]}],my:[{my:[V]}],ms:[{ms:[V]}],me:[{me:[V]}],mt:[{mt:[V]}],mr:[{mr:[V]}],mb:[{mb:[V]}],ml:[{ml:[V]}],"space-x":[{"space-x":[W]}],"space-x-reverse":["space-x-reverse"],"space-y":[{"space-y":[W]}],"space-y-reverse":["space-y-reverse"],w:[{w:["auto","min","max","fit","svw","lvw","dvw",F,b]}],"min-w":[{"min-w":[F,b,"min","max","fit"]}],"max-w":[{"max-w":[F,b,"none","full","min","max","fit","prose",{screen:[De]},De]}],h:[{h:[F,b,"auto","min","max","fit","svh","lvh","dvh"]}],"min-h":[{"min-h":[F,b,"min","max","fit","svh","lvh","dvh"]}],"max-h":[{"max-h":[F,b,"min","max","fit","svh","lvh","dvh"]}],size:[{size:[F,b,"auto","min","max","fit"]}],"font-size":[{text:["base",De,Oe]}],"font-smoothing":["antialiased","subpixel-antialiased"],"font-style":["italic","not-italic"],"font-weight":[{font:["thin","extralight","light","normal","medium","semibold","bold","extrabold","black",Ef]}],"font-family":[{font:[Ru]}],"fvn-normal":["normal-nums"],"fvn-ordinal":["ordinal"],"fvn-slashed-zero":["slashed-zero"],"fvn-figure":["lining-nums","oldstyle-nums"],"fvn-spacing":["proportional-nums","tabular-nums"],"fvn-fraction":["diagonal-fractions","stacked-fractions"],tracking:[{tracking:["tighter","tight","normal","wide","wider","widest",F]}],"line-clamp":[{"line-clamp":["none",Ha,Ef]}],leading:[{leading:["none","tight","snug","normal","relaxed","loose",ee,F]}],"list-image":[{"list-image":["none",F]}],"list-style-type":[{list:["none","disc","decimal",F]}],"list-style-position":[{list:["inside","outside"]}],"placeholder-color":[{placeholder:[o]}],"placeholder-opacity":[{"placeholder-opacity":[tt]}],"text-alignment":[{text:["left","center","right","justify","start","end"]}],"text-color":[{text:[o]}],"text-opacity":[{"text-opacity":[tt]}],"text-decoration":["underline","overline","line-through","no-underline"],"text-decoration-style":[{decoration:[...S(),"wavy"]}],"text-decoration-thickness":[{decoration:["auto","from-font",ee,Oe]}],"underline-offset":[{"underline-offset":["auto",ee,F]}],"text-decoration-color":[{decoration:[o]}],"text-transform":["uppercase","lowercase","capitalize","normal-case"],"text-overflow":["truncate","text-ellipsis","text-clip"],"text-wrap":[{text:["wrap","nowrap","balance","pretty"]}],indent:[{indent:I()}],"vertical-align":[{align:["baseline","top","middle","bottom","text-top","text-bottom","sub","super",F]}],whitespace:[{whitespace:["normal","nowrap","pre","pre-line","pre-wrap","break-spaces"]}],break:[{break:["normal","words","all","keep"]}],hyphens:[{hyphens:["none","manual","auto"]}],content:[{content:["none",F]}],"bg-attachment":[{bg:["fixed","local","scroll"]}],"bg-clip":[{"bg-clip":["border","padding","content","text"]}],"bg-opacity":[{"bg-opacity":[tt]}],"bg-origin":[{"bg-origin":["border","padding","content"]}],"bg-position":[{bg:[...ll(),gg]}],"bg-repeat":[{bg:["no-repeat",{repeat:["","x","y","round","space"]}]}],"bg-size":[{bg:["auto","cover","contain",mg]}],"bg-image":[{bg:["none",{"gradient-to":["t","tr","r","br","b","bl","l","tl"]},vg]}],"bg-color":[{bg:[o]}],"gradient-from-pos":[{from:[L]}],"gradient-via-pos":[{via:[L]}],"gradient-to-pos":[{to:[L]}],"gradient-from":[{from:[st]}],"gradient-via":[{via:[st]}],"gradient-to":[{to:[st]}],rounded:[{rounded:[M]}],"rounded-s":[{"rounded-s":[M]}],"rounded-e":[{"rounded-e":[M]}],"rounded-t":[{"rounded-t":[M]}],"rounded-r":[{"rounded-r":[M]}],"rounded-b":[{"rounded-b":[M]}],"rounded-l":[{"rounded-l":[M]}],"rounded-ss":[{"rounded-ss":[M]}],"rounded-se":[{"rounded-se":[M]}],"rounded-ee":[{"rounded-ee":[M]}],"rounded-es":[{"rounded-es":[M]}],"rounded-tl":[{"rounded-tl":[M]}],"rounded-tr":[{"rounded-tr":[M]}],"rounded-br":[{"rounded-br":[M]}],"rounded-bl":[{"rounded-bl":[M]}],"border-w":[{border:[U]}],"border-w-x":[{"border-x":[U]}],"border-w-y":[{"border-y":[U]}],"border-w-s":[{"border-s":[U]}],"border-w-e":[{"border-e":[U]}],"border-w-t":[{"border-t":[U]}],"border-w-r":[{"border-r":[U]}],"border-w-b":[{"border-b":[U]}],"border-w-l":[{"border-l":[U]}],"border-opacity":[{"border-opacity":[tt]}],"border-style":[{border:[...S(),"hidden"]}],"divide-x":[{"divide-x":[U]}],"divide-x-reverse":["divide-x-reverse"],"divide-y":[{"divide-y":[U]}],"divide-y-reverse":["divide-y-reverse"],"divide-opacity":[{"divide-opacity":[tt]}],"divide-style":[{divide:S()}],"border-color":[{border:[_]}],"border-color-x":[{"border-x":[_]}],"border-color-y":[{"border-y":[_]}],"border-color-s":[{"border-s":[_]}],"border-color-e":[{"border-e":[_]}],"border-color-t":[{"border-t":[_]}],"border-color-r":[{"border-r":[_]}],"border-color-b":[{"border-b":[_]}],"border-color-l":[{"border-l":[_]}],"divide-color":[{divide:[_]}],"outline-style":[{outline:["",...S()]}],"outline-offset":[{"outline-offset":[ee,F]}],"outline-w":[{outline:[ee,Oe]}],"outline-color":[{outline:[o]}],"ring-w":[{ring:Rl()}],"ring-w-inset":["ring-inset"],"ring-color":[{ring:[o]}],"ring-opacity":[{"ring-opacity":[tt]}],"ring-offset-w":[{"ring-offset":[ee,Oe]}],"ring-offset-color":[{"ring-offset":[o]}],shadow:[{shadow:["","inner","none",De,bg]}],"shadow-color":[{shadow:[Ru]}],opacity:[{opacity:[tt]}],"mix-blend":[{"mix-blend":[...N(),"plus-lighter","plus-darker"]}],"bg-blend":[{"bg-blend":N()}],filter:[{filter:["","none"]}],blur:[{blur:[T]}],brightness:[{brightness:[f]}],contrast:[{contrast:[O]}],"drop-shadow":[{"drop-shadow":["","none",De,F]}],grayscale:[{grayscale:[p]}],"hue-rotate":[{"hue-rotate":[H]}],invert:[{invert:[j]}],saturate:[{saturate:[zt]}],sepia:[{sepia:[Rt]}],"backdrop-filter":[{"backdrop-filter":["","none"]}],"backdrop-blur":[{"backdrop-blur":[T]}],"backdrop-brightness":[{"backdrop-brightness":[f]}],"backdrop-contrast":[{"backdrop-contrast":[O]}],"backdrop-grayscale":[{"backdrop-grayscale":[p]}],"backdrop-hue-rotate":[{"backdrop-hue-rotate":[H]}],"backdrop-invert":[{"backdrop-invert":[j]}],"backdrop-opacity":[{"backdrop-opacity":[tt]}],"backdrop-saturate":[{"backdrop-saturate":[zt]}],"backdrop-sepia":[{"backdrop-sepia":[Rt]}],"border-collapse":[{border:["collapse","separate"]}],"border-spacing":[{"border-spacing":[D]}],"border-spacing-x":[{"border-spacing-x":[D]}],"border-spacing-y":[{"border-spacing-y":[D]}],"table-layout":[{table:["auto","fixed"]}],caption:[{caption:["top","bottom"]}],transition:[{transition:["none","all","","colors","opacity","shadow","transform",F]}],duration:[{duration:s()}],ease:[{ease:["linear","in","out","in-out",F]}],delay:[{delay:s()}],animate:[{animate:["none","spin","ping","pulse","bounce",F]}],transform:[{transform:["","gpu","none"]}],scale:[{scale:[P]}],"scale-x":[{"scale-x":[P]}],"scale-y":[{"scale-y":[P]}],rotate:[{rotate:[Cu,F]}],"translate-x":[{"translate-x":[Zt]}],"translate-y":[{"translate-y":[Zt]}],"skew-x":[{"skew-x":[Dt]}],"skew-y":[{"skew-y":[Dt]}],"transform-origin":[{origin:["center","top","top-right","right","bottom-right","bottom","bottom-left","left","top-left",F]}],accent:[{accent:["auto",o]}],appearance:[{appearance:["none","auto"]}],cursor:[{cursor:["auto","default","pointer","wait","text","move","help","not-allowed","none","context-menu","progress","cell","crosshair","vertical-text","alias","copy","no-drop","grab","grabbing","all-scroll","col-resize","row-resize","n-resize","e-resize","s-resize","w-resize","ne-resize","nw-resize","se-resize","sw-resize","ew-resize","ns-resize","nesw-resize","nwse-resize","zoom-in","zoom-out",F]}],"caret-color":[{caret:[o]}],"pointer-events":[{"pointer-events":["none","auto"]}],resize:[{resize:["none","y","x",""]}],"scroll-behavior":[{scroll:["auto","smooth"]}],"scroll-m":[{"scroll-m":I()}],"scroll-mx":[{"scroll-mx":I()}],"scroll-my":[{"scroll-my":I()}],"scroll-ms":[{"scroll-ms":I()}],"scroll-me":[{"scroll-me":I()}],"scroll-mt":[{"scroll-mt":I()}],"scroll-mr":[{"scroll-mr":I()}],"scroll-mb":[{"scroll-mb":I()}],"scroll-ml":[{"scroll-ml":I()}],"scroll-p":[{"scroll-p":I()}],"scroll-px":[{"scroll-px":I()}],"scroll-py":[{"scroll-py":I()}],"scroll-ps":[{"scroll-ps":I()}],"scroll-pe":[{"scroll-pe":I()}],"scroll-pt":[{"scroll-pt":I()}],"scroll-pr":[{"scroll-pr":I()}],"scroll-pb":[{"scroll-pb":I()}],"scroll-pl":[{"scroll-pl":I()}],"snap-align":[{snap:["start","end","center","align-none"]}],"snap-stop":[{snap:["normal","always"]}],"snap-type":[{snap:["none","x","y","both"]}],"snap-strictness":[{snap:["mandatory","proximity"]}],touch:[{touch:["auto","none","manipulation"]}],"touch-x":[{"touch-pan":["x","left","right"]}],"touch-y":[{"touch-pan":["y","up","down"]}],"touch-pz":["touch-pinch-zoom"],select:[{select:["none","text","all","auto"]}],"will-change":[{"will-change":["auto","scroll","contents","transform",F]}],fill:[{fill:[o,"none"]}],"stroke-w":[{stroke:[ee,Oe,Ef]}],stroke:[{stroke:[o,"none"]}],sr:["sr-only","not-sr-only"],"forced-color-adjust":[{"forced-color-adjust":["auto","none"]}]},conflictingClassGroups:{overflow:["overflow-x","overflow-y"],overscroll:["overscroll-x","overscroll-y"],inset:["inset-x","inset-y","start","end","top","right","bottom","left"],"inset-x":["right","left"],"inset-y":["top","bottom"],flex:["basis","grow","shrink"],gap:["gap-x","gap-y"],p:["px","py","ps","pe","pt","pr","pb","pl"],px:["pr","pl"],py:["pt","pb"],m:["mx","my","ms","me","mt","mr","mb","ml"],mx:["mr","ml"],my:["mt","mb"],size:["w","h"],"font-size":["leading"],"fvn-normal":["fvn-ordinal","fvn-slashed-zero","fvn-figure","fvn-spacing","fvn-fraction"],"fvn-ordinal":["fvn-normal"],"fvn-slashed-zero":["fvn-normal"],"fvn-figure":["fvn-normal"],"fvn-spacing":["fvn-normal"],"fvn-fraction":["fvn-normal"],"line-clamp":["display","overflow"],rounded:["rounded-s","rounded-e","rounded-t","rounded-r","rounded-b","rounded-l","rounded-ss","rounded-se","rounded-ee","rounded-es","rounded-tl","rounded-tr","rounded-br","rounded-bl"],"rounded-s":["rounded-ss","rounded-es"],"rounded-e":["rounded-se","rounded-ee"],"rounded-t":["rounded-tl","rounded-tr"],"rounded-r":["rounded-tr","rounded-br"],"rounded-b":["rounded-br","rounded-bl"],"rounded-l":["rounded-tl","rounded-bl"],"border-spacing":["border-spacing-x","border-spacing-y"],"border-w":["border-w-s","border-w-e","border-w-t","border-w-r","border-w-b","border-w-l"],"border-w-x":["border-w-r","border-w-l"],"border-w-y":["border-w-t","border-w-b"],"border-color":["border-color-s","border-color-e","border-color-t","border-color-r","border-color-b","border-color-l"],"border-color-x":["border-color-r","border-color-l"],"border-color-y":["border-color-t","border-color-b"],"scroll-m":["scroll-mx","scroll-my","scroll-ms","scroll-me","scroll-mt","scroll-mr","scroll-mb","scroll-ml"],"scroll-mx":["scroll-mr","scroll-ml"],"scroll-my":["scroll-mt","scroll-mb"],"scroll-p":["scroll-px","scroll-py","scroll-ps","scroll-pe","scroll-pt","scroll-pr","scroll-pb","scroll-pl"],"scroll-px":["scroll-pr","scroll-pl"],"scroll-py":["scroll-pt","scroll-pb"],touch:["touch-x","touch-y","touch-pz"],"touch-x":["touch"],"touch-y":["touch"],"touch-pz":["touch"]},conflictingClassGroupModifiers:{"font-size":["leading"]}}},xg=ug(Tg);function Qt(...o){return xg(Vm(o))}const Ag=["relative cursor-pointer","text-sm focus:z-10 focus:ring-2 font-medium focus:outline-none whitespace-nowrap shadow-sm","inline-flex gap-2 items-center justify-center transition-colors focus:ring-offset-1","disabled:opacity-40 disabled:cursor-not-allowed disabled:text-nb-gray-300 ring-offset-neutral-950/50"],Eg={default:["bg-white hover:text-black focus:ring-zinc-200/50 hover:bg-gray-100 border-gray-200 text-gray-900","dark:focus:ring-zinc-800/50 dark:bg-nb-gray dark:text-gray-400 dark:border-gray-700/30 dark:hover:text-white dark:hover:bg-zinc-800/50"],primary:["dark:focus:ring-netbird-600/50 dark:ring-offset-neutral-950/50 enabled:dark:bg-netbird disabled:dark:bg-nb-gray-910 dark:text-gray-100 enabled:dark:hover:text-white enabled:dark:hover:bg-netbird-500/80","enabled:bg-netbird enabled:text-white enabled:focus:ring-netbird-400/50 enabled:hover:bg-netbird-500"],secondary:["bg-white hover:text-black focus:ring-zinc-200/50 hover:bg-gray-100 border-gray-200 text-gray-900","dark:ring-offset-neutral-950/50 dark:focus:ring-neutral-500/20","dark:bg-nb-gray-920 dark:text-gray-400 dark:border-gray-700/40 dark:hover:text-white dark:hover:bg-nb-gray-910"],secondaryLighter:["bg-white hover:text-black focus:ring-zinc-200/50 hover:bg-gray-100 border-gray-200 text-gray-900","dark:ring-offset-neutral-950/50 dark:focus:ring-neutral-500/20","dark:bg-nb-gray-900/70 dark:text-gray-400 dark:border-gray-700/70 dark:hover:text-white dark:hover:bg-nb-gray-800/60"],input:["bg-white hover:text-black focus:ring-zinc-200/50 hover:bg-gray-100 border-neutral-200 text-gray-900","dark:ring-offset-neutral-950/50 dark:focus:ring-neutral-500/20","dark:bg-nb-gray-900 dark:text-gray-400 dark:border-nb-gray-700 dark:hover:bg-nb-gray-900/80"],dropdown:["bg-white hover:text-black focus:ring-zinc-200/50 hover:bg-gray-100 border-neutral-200 text-gray-900","dark:ring-offset-neutral-950/50 dark:focus:ring-neutral-500/20","dark:bg-nb-gray-900/40 dark:text-gray-400 dark:border-nb-gray-900 dark:hover:bg-nb-gray-900/50"],dotted:["bg-white hover:text-black focus:ring-zinc-200/50 hover:bg-gray-100 border-gray-200 text-gray-900 border-dashed","dark:ring-offset-neutral-950/50 dark:focus:ring-neutral-500/20","dark:bg-nb-gray-900/30 dark:text-gray-400 dark:border-gray-500/40 dark:hover:text-white dark:hover:bg-zinc-800/50"],tertiary:["bg-white hover:text-black focus:ring-zinc-200/50 hover:bg-gray-100 border-gray-200 text-gray-900","dark:focus:ring-zinc-800/50 dark:bg-white dark:text-gray-800 dark:border-gray-700/40 dark:hover:bg-neutral-200 disabled:dark:bg-nb-gray-920 disabled:dark:text-nb-gray-300"],white:["focus:ring-white/50 bg-white text-gray-800 border-white outline-none hover:bg-neutral-200 disabled:dark:bg-nb-gray-920 disabled:dark:text-nb-gray-300","disabled:dark:bg-nb-gray-900 disabled:dark:text-nb-gray-300 disabled:dark:border-nb-gray-900"],outline:["bg-white hover:text-black focus:ring-zinc-200/50 hover:bg-gray-100 border-gray-200 text-gray-900","dark:focus:ring-zinc-800/50 dark:bg-transparent dark:text-netbird dark:border-netbird dark:hover:bg-nb-gray-900/30"],"danger-outline":["enabled:dark:focus:ring-red-800/20 enabled:dark:focus:bg-red-950/40 enabled:hover:dark:bg-red-950/50 enabled:dark:hover:border-red-800/50 dark:bg-transparent dark:text-red-500"],"danger-text":["dark:bg-transparent dark:text-red-500 dark:hover:text-red-600 dark:border-transparent !px-0 !shadow-none !py-0 focus:ring-red-500/30 dark:ring-offset-neutral-950/50"],"default-outline":["dark:ring-offset-nb-gray-950/50 dark:focus:ring-nb-gray-500/20","dark:bg-transparent dark:text-nb-gray-400 dark:border-transparent dark:hover:text-white dark:hover:bg-nb-gray-900/30 dark:hover:border-nb-gray-800/50","data-[state=open]:dark:text-white data-[state=open]:dark:bg-nb-gray-900/30 data-[state=open]:dark:border-nb-gray-800/50"],danger:["dark:focus:ring-red-700/20 dark:focus:bg-red-700 hover:dark:bg-red-700 dark:hover:border-red-800/50 dark:bg-red-600 dark:text-red-100"]},Mg={xs:"text-xs py-2 px-4",xs2:"text-[0.78rem] py-2 px-4",sm:"text-sm py-2.5 px-4",md:"text-sm py-2.5 px-4",lg:"text-base py-2.5 px-4"},_g={0:"border",1:"border border-transparent",2:"border border-t-0 border-b-0"},Of=Ot.forwardRef(({variant:o="default",rounded:b=!0,border:T=1,size:f="md",stopPropagation:_=!0,className:M,onClick:D,children:U,...O},p)=>R.jsx("button",{type:"button",...O,ref:p,className:Qt(Ag,Eg[o],Mg[f],_g[T?1:0],b&&"rounded-md",M),onClick:H=>{_&&H.stopPropagation(),D?.(H)},children:U}));Of.displayName="Button";const Og={default:["bg-nb-gray-900 placeholder:text-neutral-400/70 border-nb-gray-700","ring-offset-neutral-950/50 focus-visible:ring-neutral-500/20"],darker:["bg-nb-gray-920 placeholder:text-neutral-400/70 border-nb-gray-800","ring-offset-neutral-950/50 focus-visible:ring-neutral-500/20"],error:["bg-nb-gray-900 placeholder:text-neutral-400/70 border-red-500 text-red-500","ring-offset-red-500/10 focus-visible:ring-red-500/10"]},Dg={default:"bg-nb-gray-900 border-nb-gray-700 text-nb-gray-300",error:"bg-nb-gray-900 border-red-500 text-nb-gray-300 text-red-500"},Pd=Ot.forwardRef(({className:o,type:b,customSuffix:T,customPrefix:f,icon:_,maxWidthClass:M="",error:D,variant:U="default",prefixClassName:O,showPasswordToggle:p=!1,...H},j)=>{const[w,st]=Ot.useState(!1),L=b==="password",X=L&&w?"text":b,tt=(L&&p?R.jsx("button",{type:"button",onClick:()=>st(!w),className:"hover:text-white transition-all","aria-label":"Toggle password visibility",children:w?R.jsx(Gm,{size:18}):R.jsx(Xm,{size:18})}):null)||T,St=D?"error":U;return R.jsxs(R.Fragment,{children:[R.jsxs("div",{className:Qt("flex relative h-[42px]",M),children:[f&&R.jsx("div",{className:Qt(Dg[D?"error":"default"],"flex h-[42px] w-auto rounded-l-md px-3 py-2 text-sm","border items-center whitespace-nowrap",H.disabled&&"opacity-40",O),children:f}),R.jsx("div",{className:Qt("absolute left-0 top-0 h-full flex items-center text-xs text-nb-gray-300 pl-3 leading-[0]",H.disabled&&"opacity-40"),children:_}),R.jsx("input",{type:X,ref:j,...H,className:Qt(Og[St],"flex h-[42px] w-full rounded-md px-3 py-2 text-sm","file:bg-transparent file:text-sm file:font-medium file:border-0","focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2","disabled:cursor-not-allowed disabled:opacity-40","border",f&&"!border-l-0 !rounded-l-none",tt&&"!pr-16",_&&"!pl-10",o)}),R.jsx("div",{className:Qt("absolute right-0 top-0 h-full flex items-center text-xs text-nb-gray-300 pr-4 leading-[0] select-none",H.disabled&&"opacity-30"),children:tt})]}),D&&R.jsx("p",{className:"text-xs text-red-500 mt-2",children:D})]})});Pd.displayName="Input";const Ug=Ot.forwardRef(function({value:b,onChange:T,length:f=6,disabled:_=!1,className:M,autoFocus:D=!1},U){const O=Ot.useRef([]);Ot.useImperativeHandle(U,()=>({focus:()=>{O.current[0]?.focus()}}));const p=b.split("").concat(Array(f).fill("")).slice(0,f),H=(L,X)=>{if(!/^\d*$/.test(X))return;const V=[...p];V[L]=X.slice(-1);const tt=V.join("").replace(/\s/g,"");T(tt),X&&L{X.key==="Backspace"&&!p[L]&&L>0&&O.current[L-1]?.focus(),X.key==="ArrowLeft"&&L>0&&O.current[L-1]?.focus(),X.key==="ArrowRight"&&L{L.preventDefault();const X=L.clipboardData.getData("text").replace(/\D/g,"").slice(0,f);T(X);const V=Math.min(X.length,f-1);O.current[V]?.focus()},st=L=>{L.target.select()};return R.jsx("div",{className:Qt("flex gap-2 w-full min-w-0",M),children:p.map((L,X)=>R.jsx("input",{ref:V=>{O.current[X]=V},type:"text",inputMode:"numeric",maxLength:1,value:L,onChange:V=>H(X,V.target.value),onKeyDown:V=>j(X,V),onPaste:w,onFocus:st,disabled:_,autoFocus:D&&X===0,className:Qt("flex-1 min-w-0 h-[42px] text-center text-sm rounded-md","dark:bg-nb-gray-900 border dark:border-nb-gray-700","dark:placeholder:text-neutral-400/70","focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2","ring-offset-neutral-200/20 dark:ring-offset-neutral-950/50 dark:focus-visible:ring-neutral-500/20","disabled:cursor-not-allowed disabled:opacity-40")},X))})}),t0=Ot.createContext({value:"",onChange:()=>{}}),l0=()=>Ot.useContext(t0);function We({value:o,defaultValue:b,onChange:T,children:f}){const[_,M]=Ot.useState(b||""),D=o!==void 0?o:_,U=O=>{o===void 0&&M(O),T?.(O)};return R.jsx(t0.Provider,{value:{value:D,onChange:U},children:R.jsx("div",{children:typeof f=="function"?f({value:D,onChange:U}):f})})}function Ng({children:o,className:b}){return R.jsx("div",{role:"tablist",className:Qt("bg-nb-gray-930/70 p-1.5 flex justify-center gap-1 border-nb-gray-900",b),children:o})}function Cg({children:o,value:b,disabled:T=!1,className:f,selected:_,onClick:M}){const D=l0(),U=_!==void 0?_:b===D.value,O=()=>{D.onChange(b),M?.()};return R.jsx("button",{role:"tab",type:"button",disabled:T,"aria-selected":U,onClick:O,className:Qt("px-4 py-2 text-sm rounded-md w-full transition-all cursor-pointer",T&&"opacity-30 cursor-not-allowed",U?"bg-nb-gray-900 text-white":T?"":"text-nb-gray-400 hover:bg-nb-gray-900/50",f),children:R.jsx("div",{className:"flex items-center w-full justify-center gap-2",children:o})})}function Rg({children:o,value:b,className:T,visible:f}){const _=l0();return(f!==void 0?f:b===_.value)?R.jsx("div",{role:"tabpanel",className:Qt("bg-nb-gray-930/70 px-4 pt-4 pb-5 rounded-b-md border border-t-0 border-nb-gray-900",T),children:o}):null}We.List=Ng;We.Trigger=Cg;We.Content=Rg;const Hg="/assets/netbird-full-4AdtrUIK.svg",jg="data:image/svg+xml,%3csvg%20width='31'%20height='23'%20viewBox='0%200%2031%2023'%20fill='none'%20xmlns='http://www.w3.org/2000/svg'%3e%3cpath%20d='M21.4631%200.523438C17.8173%200.857913%2016.0028%202.95675%2015.3171%204.01871L4.66406%2022.4734H17.5163L30.1929%200.523438H21.4631Z'%20fill='%23F68330'/%3e%3cpath%20d='M17.5265%2022.4737L0%203.88525C0%203.88525%2019.8177%20-1.44128%2021.7493%2015.1738L17.5265%2022.4737Z'%20fill='%23F68330'/%3e%3cpath%20d='M14.9236%204.70563L9.54688%2014.0208L17.5158%2022.4747L21.7385%2015.158C21.0696%209.44682%2018.2851%206.32784%2014.9236%204.69727'%20fill='%23F05252'/%3e%3c/svg%3e",Pn={small:{desktop:14,mobile:20},default:{desktop:22,mobile:30},large:{desktop:24,mobile:40}},Bg=({size:o="default",mobile:b=!0})=>R.jsxs(R.Fragment,{children:[R.jsx("img",{src:Hg,height:Pn[o].desktop,style:{height:Pn[o].desktop},alt:"NetBird Logo",className:Qt(b&&"hidden md:block","group-hover:opacity-80 transition-all")}),b&&R.jsx("img",{src:jg,width:Pn[o].mobile,style:{width:Pn[o].mobile},alt:"NetBird Logo",className:Qt(b&&"md:hidden ml-4")})]});function qg(){return R.jsxs("div",{className:"flex items-center justify-center mt-8 gap-2 group cursor-pointer",children:[R.jsx("span",{className:"text-sm text-nb-gray-400 font-light text-center group-hover:opacity-80 transition-all",children:"Powered by"}),R.jsx(Bg,{size:"small",mobile:!1})]})}const Yg=({className:o})=>R.jsx("div",{className:Qt("h-full w-full absolute left-0 top-0 rounded-md overflow-hidden z-0 pointer-events-none",o),children:R.jsx("div",{className:"bg-linear-to-b from-nb-gray-900/10 via-transparent to-transparent w-full h-full rounded-md"})}),Gg=({children:o,className:b})=>R.jsxs("div",{className:Qt("px-6 sm:px-10 py-10 pt-8","bg-nb-gray-940 border border-nb-gray-910 rounded-lg relative",b),children:[R.jsx(Yg,{}),o]});function Xg({children:o,className:b}){return R.jsx("h1",{className:Qt("text-xl! text-center z-10 relative",b),children:o})}function Qg({children:o,className:b}){return R.jsx("div",{className:Qt("text-sm text-nb-gray-300 font-light mt-2 block text-center z-10 relative",b),children:o})}const Zg=()=>R.jsxs("div",{className:"flex items-center justify-center relative my-4",children:[R.jsx("span",{className:"bg-nb-gray-940 relative z-10 px-4 text-xs text-nb-gray-400 font-medium",children:"OR"}),R.jsx("span",{className:"h-px bg-nb-gray-900 w-full absolute z-0"})]}),Lg=({error:o})=>R.jsx("div",{className:"text-red-400 bg-red-800/20 border border-red-800/50 rounded-lg px-4 py-3 whitespace-break-spaces text-sm",children:o});function Vd({className:o,...b}){return R.jsx("label",{className:Qt("text-sm font-medium tracking-wider leading-none","peer-disabled:cursor-not-allowed peer-disabled:opacity-70","mb-2.5 inline-block text-nb-gray-200","flex items-center gap-2 select-none",o),...b})}const Mf=wm(),Ft=Mf.methods&&Object.keys(Mf.methods).length>0?Mf.methods:{password:"password",pin:"pin",oidc:"/auth/oidc"};function wg(){const[o,b]=Ot.useState(null),[T,f]=Ot.useState(null),[_,M]=Ot.useState(""),[D,U]=Ot.useState(""),O=Ot.useRef(null),p=Ot.useRef(null),[H,j]=Ot.useState(Ft.password?"password":"pin"),w=(P,Rt)=>{b(Rt),f(null),P==="password"?(U(""),setTimeout(()=>O.current?.focus(),200)):(M(""),setTimeout(()=>p.current?.focus(),200))},st=(P,Rt)=>{b(null),f(P);const Dt=new FormData;P==="password"?Dt.append(Ft.password,Rt):Dt.append(Ft.pin,Rt),fetch(window.location.href,{method:"POST",body:Dt,redirect:"follow"}).then(W=>{W.ok||W.redirected?window.location.reload():w(P,"Authentication failed. Please try again.")}).catch(()=>{w(P,"An error occurred. Please try again.")})},L=P=>{M(P),P.length===6&&st("pin",P)},X=_.length===6,V=D.length>0,tt=T!==null||H==="password"&&!V||H==="pin"&&!X,St=Ft.password||Ft.pin,zt=Ft.password&&Ft.pin;return R.jsxs("main",{className:"mt-20",children:[R.jsxs(Gg,{className:"max-w-105 mx-auto",children:[R.jsx(Xg,{children:"Authentication Required"}),R.jsx(Qg,{children:"The service you are trying to access is protected. Please authenticate to continue."}),R.jsxs("div",{className:"flex flex-col gap-4 mt-7 z-10 relative",children:[o&&R.jsx(Lg,{error:o}),Ft.oidc&&R.jsxs(Of,{variant:"primary",className:"w-full",onClick:()=>window.location.href=Ft.oidc,children:[R.jsx(Lm,{size:16}),"Sign in with SSO"]}),Ft.oidc&&St&&R.jsx(Zg,{}),St&&R.jsxs("form",{onSubmit:P=>{P.preventDefault(),st(H,H==="password"?D:_)},children:[zt&&R.jsx(We,{value:H,onChange:P=>{j(P),setTimeout(()=>{P==="password"?O.current?.focus():p.current?.focus()},0)},children:R.jsxs(We.List,{className:"rounded-lg border mb-4",children:[R.jsxs(We.Trigger,{value:"password",children:[R.jsx(Zm,{size:14}),"Password"]}),R.jsxs(We.Trigger,{value:"pin",children:[R.jsx(Ym,{size:14}),"PIN"]})]})}),R.jsxs("div",{className:"mb-4",children:[Ft.password&&(H==="password"||!Ft.pin)&&R.jsxs(R.Fragment,{children:[!zt&&R.jsx(Vd,{children:"Password"}),R.jsx(Pd,{ref:O,type:"password",id:"password",placeholder:"Enter password",disabled:T!==null,showPasswordToggle:!0,autoFocus:!0,value:D,onChange:P=>U(P.target.value)})]}),Ft.pin&&(H==="pin"||!Ft.password)&&R.jsxs(R.Fragment,{children:[!zt&&R.jsx(Vd,{children:"Enter PIN Code"}),R.jsx(Ug,{ref:p,value:_,onChange:L,disabled:T!==null,autoFocus:!Ft.password})]})]}),R.jsx(Of,{type:"submit",disabled:tt,variant:"secondary",className:"w-full",children:T!==null?R.jsxs(R.Fragment,{children:[R.jsx(Qm,{className:"animate-spin",size:16}),"Verifying..."]}):H==="password"?"Sign in":"Submit"})]})]})]}),R.jsx(qg,{})]})}Hm.createRoot(document.getElementById("root")).render(R.jsx(Ot.StrictMode,{children:R.jsx(wg,{})})); diff --git a/proxy/web/dist/assets/style-B08XFatU.css b/proxy/web/dist/assets/style-B08XFatU.css deleted file mode 100644 index 71db8aa92..000000000 --- a/proxy/web/dist/assets/style-B08XFatU.css +++ /dev/null @@ -1 +0,0 @@ -@layer properties{@supports (((-webkit-hyphens:none)) and (not (margin-trim:inline))) or ((-moz-orient:inline) and (not (color:rgb(from red r g b)))){*,:before,:after,::backdrop{--tw-border-style:solid;--tw-gradient-position:initial;--tw-gradient-from:#0000;--tw-gradient-via:#0000;--tw-gradient-to:#0000;--tw-gradient-stops:initial;--tw-gradient-via-stops:initial;--tw-gradient-from-position:0%;--tw-gradient-via-position:50%;--tw-gradient-to-position:100%;--tw-leading:initial;--tw-font-weight:initial;--tw-tracking:initial;--tw-shadow:0 0 #0000;--tw-shadow-color:initial;--tw-shadow-alpha:100%;--tw-inset-shadow:0 0 #0000;--tw-inset-shadow-color:initial;--tw-inset-shadow-alpha:100%;--tw-ring-color:initial;--tw-ring-shadow:0 0 #0000;--tw-inset-ring-color:initial;--tw-inset-ring-shadow:0 0 #0000;--tw-ring-inset:initial;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-offset-shadow:0 0 #0000;--tw-outline-style:solid}}}@layer theme{:root,:host{--font-sans:ui-sans-serif,system-ui,sans-serif,"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol","Noto Color Emoji";--font-mono:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,"Liberation Mono","Courier New",monospace;--color-red-100:#fde8e8;--color-red-400:#f98080;--color-red-500:#f05252;--color-red-600:#e02424;--color-red-700:#c81e1e;--color-red-800:#9b1c1c;--color-red-950:oklch(25.8% .092 26.042);--color-gray-100:#f3f4f6;--color-gray-200:#e5e7eb;--color-gray-400:#9ca3af;--color-gray-500:#6b7280;--color-gray-700:#374151;--color-gray-800:#1f2937;--color-gray-900:#111827;--color-zinc-50:oklch(98.5% 0 0);--color-zinc-200:oklch(92% .004 286.32);--color-zinc-800:oklch(27.4% .006 286.033);--color-neutral-200:oklch(92.2% 0 0);--color-neutral-400:oklch(70.8% 0 0);--color-neutral-500:oklch(55.6% 0 0);--color-neutral-950:oklch(14.5% 0 0);--color-black:#000;--color-white:#fff;--spacing:.25rem;--text-xs:.75rem;--text-xs--line-height:calc(1/.75);--text-sm:.875rem;--text-sm--line-height:calc(1.25/.875);--text-base:1rem;--text-base--line-height: 1.5 ;--text-xl:1.25rem;--text-xl--line-height:calc(1.75/1.25);--text-2xl:1.5rem;--text-2xl--line-height:calc(2/1.5);--font-weight-light:300;--font-weight-medium:500;--tracking-wide:.025em;--tracking-wider:.05em;--radius-md:.375rem;--radius-lg:.5rem;--animate-spin:spin 1s linear infinite;--default-transition-duration:.15s;--default-transition-timing-function:cubic-bezier(.4,0,.2,1);--default-font-family:var(--font-sans);--default-mono-font-family:var(--font-mono);--color-nb-gray:#181a1d;--color-nb-gray-100:#e4e7e9;--color-nb-gray-200:#cbd2d6;--color-nb-gray-300:#aab4bd;--color-nb-gray-400:#7c8994;--color-nb-gray-500:#616e79;--color-nb-gray-700:#474e57;--color-nb-gray-800:#3f444b;--color-nb-gray-900:#32363d;--color-nb-gray-910:#2b2f33;--color-nb-gray-920:#25282d;--color-nb-gray-930:#25282c;--color-nb-gray-940:#1c1e21;--color-nb-gray-950:#181a1d;--color-netbird:#f68330;--color-netbird-400:#f68330;--color-netbird-500:#f46d1b;--color-netbird-600:#e55311}}@layer base{*,:after,:before,::backdrop{box-sizing:border-box;border:0 solid;margin:0;padding:0}::file-selector-button{box-sizing:border-box;border:0 solid;margin:0;padding:0}html,:host{-webkit-text-size-adjust:100%;tab-size:4;line-height:1.5;font-family:var(--default-font-family,ui-sans-serif,system-ui,sans-serif,"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol","Noto Color Emoji");font-feature-settings:var(--default-font-feature-settings,normal);font-variation-settings:var(--default-font-variation-settings,normal);-webkit-tap-highlight-color:transparent}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;-webkit-text-decoration:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-family:var(--default-mono-font-family,ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,"Liberation Mono","Courier New",monospace);font-feature-settings:var(--default-mono-font-feature-settings,normal);font-variation-settings:var(--default-mono-font-variation-settings,normal);font-size:1em}small{font-size:80%}sub,sup{vertical-align:baseline;font-size:75%;line-height:0;position:relative}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}:-moz-focusring{outline:auto}progress{vertical-align:baseline}summary{display:list-item}ol,ul,menu{list-style:none}img,svg,video,canvas,audio,iframe,embed,object{vertical-align:middle;display:block}img,video{max-width:100%;height:auto}button,input,select,optgroup,textarea{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}::file-selector-button{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}:where(select:is([multiple],[size])) optgroup{font-weight:bolder}:where(select:is([multiple],[size])) optgroup option{padding-inline-start:20px}::file-selector-button{margin-inline-end:4px}::placeholder{opacity:1}@supports (not ((-webkit-appearance:-apple-pay-button))) or (contain-intrinsic-size:1px){::placeholder{color:currentColor}@supports (color:color-mix(in lab,red,red)){::placeholder{color:color-mix(in oklab,currentcolor 50%,transparent)}}}textarea{resize:vertical}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-date-and-time-value{min-height:1lh;text-align:inherit}::-webkit-datetime-edit{display:inline-flex}::-webkit-datetime-edit-fields-wrapper{padding:0}::-webkit-datetime-edit{padding-block:0}::-webkit-datetime-edit-year-field{padding-block:0}::-webkit-datetime-edit-month-field{padding-block:0}::-webkit-datetime-edit-day-field{padding-block:0}::-webkit-datetime-edit-hour-field{padding-block:0}::-webkit-datetime-edit-minute-field{padding-block:0}::-webkit-datetime-edit-second-field{padding-block:0}::-webkit-datetime-edit-millisecond-field{padding-block:0}::-webkit-datetime-edit-meridiem-field{padding-block:0}::-webkit-calendar-picker-indicator{line-height:1}:-moz-ui-invalid{box-shadow:none}button,input:where([type=button],[type=reset],[type=submit]){appearance:button}::file-selector-button{appearance:button}::-webkit-inner-spin-button{height:auto}::-webkit-outer-spin-button{height:auto}[hidden]:where(:not([hidden=until-found])){display:none!important}}@layer components;@layer utilities{.pointer-events-none{pointer-events:none}.visible{visibility:visible}.absolute{position:absolute}.relative{position:relative}.static{position:static}.top-0{top:calc(var(--spacing)*0)}.right-0{right:calc(var(--spacing)*0)}.left-0{left:calc(var(--spacing)*0)}.z-0{z-index:0}.z-10{z-index:10}.mx-auto{margin-inline:auto}.my-4{margin-block:calc(var(--spacing)*4)}.mt-2{margin-top:calc(var(--spacing)*2)}.mt-7{margin-top:calc(var(--spacing)*7)}.mt-8{margin-top:calc(var(--spacing)*8)}.mt-20{margin-top:calc(var(--spacing)*20)}.mb-2\.5{margin-bottom:calc(var(--spacing)*2.5)}.mb-4{margin-bottom:calc(var(--spacing)*4)}.ml-4{margin-left:calc(var(--spacing)*4)}.block{display:block}.flex{display:flex}.hidden{display:none}.inline-block{display:inline-block}.inline-flex{display:inline-flex}.h-\[42px\]{height:42px}.h-full{height:100%}.h-px{height:1px}.w-auto{width:auto}.w-full{width:100%}.max-w-105{max-width:calc(var(--spacing)*105)}.min-w-0{min-width:calc(var(--spacing)*0)}.flex-1{flex:1}.animate-spin{animation:var(--animate-spin)}.cursor-not-allowed{cursor:not-allowed}.cursor-pointer{cursor:pointer}.flex-col{flex-direction:column}.items-center{align-items:center}.justify-center{justify-content:center}.gap-1{gap:calc(var(--spacing)*1)}.gap-2{gap:calc(var(--spacing)*2)}.gap-4{gap:calc(var(--spacing)*4)}.overflow-hidden{overflow:hidden}.rounded{border-radius:.25rem}.rounded-lg{border-radius:var(--radius-lg)}.rounded-md{border-radius:var(--radius-md)}.\!rounded-l-none{border-top-left-radius:0!important;border-bottom-left-radius:0!important}.rounded-l-md{border-top-left-radius:var(--radius-md);border-bottom-left-radius:var(--radius-md)}.rounded-b-md{border-bottom-right-radius:var(--radius-md);border-bottom-left-radius:var(--radius-md)}.border{border-style:var(--tw-border-style);border-width:1px}.border-t-0{border-top-style:var(--tw-border-style);border-top-width:0}.border-b-0{border-bottom-style:var(--tw-border-style);border-bottom-width:0}.\!border-l-0{border-left-style:var(--tw-border-style)!important;border-left-width:0!important}.border-dashed{--tw-border-style:dashed;border-style:dashed}.border-gray-200{border-color:var(--color-gray-200)}.border-nb-gray-700{border-color:var(--color-nb-gray-700)}.border-nb-gray-800{border-color:var(--color-nb-gray-800)}.border-nb-gray-900{border-color:var(--color-nb-gray-900)}.border-nb-gray-910{border-color:var(--color-nb-gray-910)}.border-neutral-200{border-color:var(--color-neutral-200)}.border-red-500{border-color:var(--color-red-500)}.border-red-800\/50{border-color:#9b1c1c80}@supports (color:color-mix(in lab,red,red)){.border-red-800\/50{border-color:color-mix(in oklab,var(--color-red-800)50%,transparent)}}.border-transparent{border-color:#0000}.border-white{border-color:var(--color-white)}.bg-nb-gray-900{background-color:var(--color-nb-gray-900)}.bg-nb-gray-920{background-color:var(--color-nb-gray-920)}.bg-nb-gray-930\/70{background-color:#25282cb3}@supports (color:color-mix(in lab,red,red)){.bg-nb-gray-930\/70{background-color:color-mix(in oklab,var(--color-nb-gray-930)70%,transparent)}}.bg-nb-gray-940{background-color:var(--color-nb-gray-940)}.bg-red-800\/20{background-color:#9b1c1c33}@supports (color:color-mix(in lab,red,red)){.bg-red-800\/20{background-color:color-mix(in oklab,var(--color-red-800)20%,transparent)}}.bg-white{background-color:var(--color-white)}.bg-linear-to-b{--tw-gradient-position:to bottom}@supports (background-image:linear-gradient(in lab,red,red)){.bg-linear-to-b{--tw-gradient-position:to bottom in oklab}}.bg-linear-to-b{background-image:linear-gradient(var(--tw-gradient-stops))}.from-nb-gray-900\/10{--tw-gradient-from:#32363d1a}@supports (color:color-mix(in lab,red,red)){.from-nb-gray-900\/10{--tw-gradient-from:color-mix(in oklab,var(--color-nb-gray-900)10%,transparent)}}.from-nb-gray-900\/10{--tw-gradient-stops:var(--tw-gradient-via-stops,var(--tw-gradient-position),var(--tw-gradient-from)var(--tw-gradient-from-position),var(--tw-gradient-to)var(--tw-gradient-to-position))}.via-transparent{--tw-gradient-via:transparent;--tw-gradient-via-stops:var(--tw-gradient-position),var(--tw-gradient-from)var(--tw-gradient-from-position),var(--tw-gradient-via)var(--tw-gradient-via-position),var(--tw-gradient-to)var(--tw-gradient-to-position);--tw-gradient-stops:var(--tw-gradient-via-stops)}.to-transparent{--tw-gradient-to:transparent;--tw-gradient-stops:var(--tw-gradient-via-stops,var(--tw-gradient-position),var(--tw-gradient-from)var(--tw-gradient-from-position),var(--tw-gradient-to)var(--tw-gradient-to-position))}.p-1\.5{padding:calc(var(--spacing)*1.5)}.\!px-0{padding-inline:calc(var(--spacing)*0)!important}.px-3{padding-inline:calc(var(--spacing)*3)}.px-4{padding-inline:calc(var(--spacing)*4)}.px-6{padding-inline:calc(var(--spacing)*6)}.\!py-0{padding-block:calc(var(--spacing)*0)!important}.py-2{padding-block:calc(var(--spacing)*2)}.py-2\.5{padding-block:calc(var(--spacing)*2.5)}.py-3{padding-block:calc(var(--spacing)*3)}.py-10{padding-block:calc(var(--spacing)*10)}.pt-4{padding-top:calc(var(--spacing)*4)}.pt-8{padding-top:calc(var(--spacing)*8)}.\!pr-16{padding-right:calc(var(--spacing)*16)!important}.pr-4{padding-right:calc(var(--spacing)*4)}.pb-5{padding-bottom:calc(var(--spacing)*5)}.\!pl-10{padding-left:calc(var(--spacing)*10)!important}.pl-3{padding-left:calc(var(--spacing)*3)}.text-center{text-align:center}.text-base{font-size:var(--text-base);line-height:var(--tw-leading,var(--text-base--line-height))}.text-sm{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height))}.text-xl\!{font-size:var(--text-xl)!important;line-height:var(--tw-leading,var(--text-xl--line-height))!important}.text-xs{font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height))}.text-\[\.8rem\]{font-size:.8rem}.text-\[0\.78rem\]{font-size:.78rem}.leading-\[0\]{--tw-leading:0;line-height:0}.leading-none{--tw-leading:1;line-height:1}.font-light{--tw-font-weight:var(--font-weight-light);font-weight:var(--font-weight-light)}.font-medium{--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium)}.tracking-wide{--tw-tracking:var(--tracking-wide);letter-spacing:var(--tracking-wide)}.tracking-wider{--tw-tracking:var(--tracking-wider);letter-spacing:var(--tracking-wider)}.whitespace-break-spaces{white-space:break-spaces}.whitespace-nowrap{white-space:nowrap}.text-gray-800{color:var(--color-gray-800)}.text-gray-900{color:var(--color-gray-900)}.text-nb-gray-200{color:var(--color-nb-gray-200)}.text-nb-gray-300{color:var(--color-nb-gray-300)}.text-nb-gray-400{color:var(--color-nb-gray-400)}.text-red-400{color:var(--color-red-400)}.text-red-500{color:var(--color-red-500)}.text-white{color:var(--color-white)}.opacity-30{opacity:.3}.opacity-40{opacity:.4}.\!shadow-none{--tw-shadow:0 0 #0000!important;box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)!important}.shadow-sm{--tw-shadow:0 1px 3px 0 var(--tw-shadow-color,#0000001a),0 1px 2px -1px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.ring-offset-neutral-200\/20{--tw-ring-offset-color:#e5e5e533}@supports (color:color-mix(in lab,red,red)){.ring-offset-neutral-200\/20{--tw-ring-offset-color:color-mix(in oklab,var(--color-neutral-200)20%,transparent)}}.ring-offset-neutral-950\/50{--tw-ring-offset-color:#0a0a0a80}@supports (color:color-mix(in lab,red,red)){.ring-offset-neutral-950\/50{--tw-ring-offset-color:color-mix(in oklab,var(--color-neutral-950)50%,transparent)}}.ring-offset-red-500\/10{--tw-ring-offset-color:#f052521a}@supports (color:color-mix(in lab,red,red)){.ring-offset-red-500\/10{--tw-ring-offset-color:color-mix(in oklab,var(--color-red-500)10%,transparent)}}.outline{outline-style:var(--tw-outline-style);outline-width:1px}.transition-all{transition-property:all;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-colors{transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.outline-none{--tw-outline-style:none;outline-style:none}.select-none{-webkit-user-select:none;user-select:none}@media(hover:hover){.group-hover\:opacity-80:is(:where(.group):hover *){opacity:.8}}.peer-disabled\:cursor-not-allowed:is(:where(.peer):disabled~*){cursor:not-allowed}.peer-disabled\:opacity-70:is(:where(.peer):disabled~*){opacity:.7}.file\:border-0::file-selector-button{border-style:var(--tw-border-style);border-width:0}.file\:bg-transparent::file-selector-button{background-color:#0000}.file\:text-sm::file-selector-button{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height))}.file\:font-medium::file-selector-button{--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium)}.placeholder\:text-neutral-400\/70::placeholder{color:#a1a1a1b3}@supports (color:color-mix(in lab,red,red)){.placeholder\:text-neutral-400\/70::placeholder{color:color-mix(in oklab,var(--color-neutral-400)70%,transparent)}}@media(hover:hover){.hover\:bg-gray-100:hover{background-color:var(--color-gray-100)}.hover\:bg-nb-gray-900\/50:hover{background-color:#32363d80}@supports (color:color-mix(in lab,red,red)){.hover\:bg-nb-gray-900\/50:hover{background-color:color-mix(in oklab,var(--color-nb-gray-900)50%,transparent)}}.hover\:bg-neutral-200:hover{background-color:var(--color-neutral-200)}.hover\:text-black:hover{color:var(--color-black)}.hover\:text-white:hover{color:var(--color-white)}}.focus\:z-10:focus{z-index:10}.focus\:ring-2:focus{--tw-ring-shadow:var(--tw-ring-inset,)0 0 0 calc(2px + var(--tw-ring-offset-width))var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.focus\:ring-red-500\/30:focus{--tw-ring-color:#f052524d}@supports (color:color-mix(in lab,red,red)){.focus\:ring-red-500\/30:focus{--tw-ring-color:color-mix(in oklab,var(--color-red-500)30%,transparent)}}.focus\:ring-white\/50:focus{--tw-ring-color:#ffffff80}@supports (color:color-mix(in lab,red,red)){.focus\:ring-white\/50:focus{--tw-ring-color:color-mix(in oklab,var(--color-white)50%,transparent)}}.focus\:ring-zinc-200\/50:focus{--tw-ring-color:#e4e4e780}@supports (color:color-mix(in lab,red,red)){.focus\:ring-zinc-200\/50:focus{--tw-ring-color:color-mix(in oklab,var(--color-zinc-200)50%,transparent)}}.focus\:ring-offset-1:focus{--tw-ring-offset-width:1px;--tw-ring-offset-shadow:var(--tw-ring-inset,)0 0 0 var(--tw-ring-offset-width)var(--tw-ring-offset-color)}.focus\:outline-none:focus{--tw-outline-style:none;outline-style:none}.focus-visible\:ring-2:focus-visible{--tw-ring-shadow:var(--tw-ring-inset,)0 0 0 calc(2px + var(--tw-ring-offset-width))var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.focus-visible\:ring-neutral-500\/20:focus-visible{--tw-ring-color:#73737333}@supports (color:color-mix(in lab,red,red)){.focus-visible\:ring-neutral-500\/20:focus-visible{--tw-ring-color:color-mix(in oklab,var(--color-neutral-500)20%,transparent)}}.focus-visible\:ring-red-500\/10:focus-visible{--tw-ring-color:#f052521a}@supports (color:color-mix(in lab,red,red)){.focus-visible\:ring-red-500\/10:focus-visible{--tw-ring-color:color-mix(in oklab,var(--color-red-500)10%,transparent)}}.focus-visible\:ring-offset-2:focus-visible{--tw-ring-offset-width:2px;--tw-ring-offset-shadow:var(--tw-ring-inset,)0 0 0 var(--tw-ring-offset-width)var(--tw-ring-offset-color)}.focus-visible\:outline-none:focus-visible{--tw-outline-style:none;outline-style:none}.enabled\:bg-netbird:enabled{background-color:var(--color-netbird)}.enabled\:text-white:enabled{color:var(--color-white)}@media(hover:hover){.enabled\:hover\:bg-netbird-500:enabled:hover{background-color:var(--color-netbird-500)}}.enabled\:focus\:ring-netbird-400\/50:enabled:focus{--tw-ring-color:#f6833080}@supports (color:color-mix(in lab,red,red)){.enabled\:focus\:ring-netbird-400\/50:enabled:focus{--tw-ring-color:color-mix(in oklab,var(--color-netbird-400)50%,transparent)}}.disabled\:cursor-not-allowed:disabled{cursor:not-allowed}.disabled\:text-nb-gray-300:disabled{color:var(--color-nb-gray-300)}.disabled\:opacity-40:disabled{opacity:.4}@media(min-width:40rem){.sm\:px-10{padding-inline:calc(var(--spacing)*10)}}@media(min-width:48rem){.md\:block{display:block}.md\:hidden{display:none}}@media(prefers-color-scheme:dark){.dark\:border-gray-500\/40{border-color:#6b728066}@supports (color:color-mix(in lab,red,red)){.dark\:border-gray-500\/40{border-color:color-mix(in oklab,var(--color-gray-500)40%,transparent)}}.dark\:border-gray-700\/30{border-color:#3741514d}@supports (color:color-mix(in lab,red,red)){.dark\:border-gray-700\/30{border-color:color-mix(in oklab,var(--color-gray-700)30%,transparent)}}.dark\:border-gray-700\/40{border-color:#37415166}@supports (color:color-mix(in lab,red,red)){.dark\:border-gray-700\/40{border-color:color-mix(in oklab,var(--color-gray-700)40%,transparent)}}.dark\:border-gray-700\/70{border-color:#374151b3}@supports (color:color-mix(in lab,red,red)){.dark\:border-gray-700\/70{border-color:color-mix(in oklab,var(--color-gray-700)70%,transparent)}}.dark\:border-nb-gray-700{border-color:var(--color-nb-gray-700)}.dark\:border-nb-gray-900{border-color:var(--color-nb-gray-900)}.dark\:border-netbird{border-color:var(--color-netbird)}.dark\:border-transparent{border-color:#0000}.dark\:bg-nb-gray{background-color:var(--color-nb-gray)}.dark\:bg-nb-gray-900{background-color:var(--color-nb-gray-900)}.dark\:bg-nb-gray-900\/30{background-color:#32363d4d}@supports (color:color-mix(in lab,red,red)){.dark\:bg-nb-gray-900\/30{background-color:color-mix(in oklab,var(--color-nb-gray-900)30%,transparent)}}.dark\:bg-nb-gray-900\/40{background-color:#32363d66}@supports (color:color-mix(in lab,red,red)){.dark\:bg-nb-gray-900\/40{background-color:color-mix(in oklab,var(--color-nb-gray-900)40%,transparent)}}.dark\:bg-nb-gray-900\/70{background-color:#32363db3}@supports (color:color-mix(in lab,red,red)){.dark\:bg-nb-gray-900\/70{background-color:color-mix(in oklab,var(--color-nb-gray-900)70%,transparent)}}.dark\:bg-nb-gray-920{background-color:var(--color-nb-gray-920)}.dark\:bg-red-600{background-color:var(--color-red-600)}.dark\:bg-transparent{background-color:#0000}.dark\:bg-white{background-color:var(--color-white)}.dark\:text-gray-100{color:var(--color-gray-100)}.dark\:text-gray-400{color:var(--color-gray-400)}.dark\:text-gray-800{color:var(--color-gray-800)}.dark\:text-nb-gray-400{color:var(--color-nb-gray-400)}.dark\:text-netbird{color:var(--color-netbird)}.dark\:text-red-100{color:var(--color-red-100)}.dark\:text-red-500{color:var(--color-red-500)}.dark\:ring-offset-nb-gray-950\/50{--tw-ring-offset-color:#181a1d80}@supports (color:color-mix(in lab,red,red)){.dark\:ring-offset-nb-gray-950\/50{--tw-ring-offset-color:color-mix(in oklab,var(--color-nb-gray-950)50%,transparent)}}.dark\:ring-offset-neutral-950\/50{--tw-ring-offset-color:#0a0a0a80}@supports (color:color-mix(in lab,red,red)){.dark\:ring-offset-neutral-950\/50{--tw-ring-offset-color:color-mix(in oklab,var(--color-neutral-950)50%,transparent)}}.dark\:placeholder\:text-neutral-400\/70::placeholder{color:#a1a1a1b3}@supports (color:color-mix(in lab,red,red)){.dark\:placeholder\:text-neutral-400\/70::placeholder{color:color-mix(in oklab,var(--color-neutral-400)70%,transparent)}}@media(hover:hover){.dark\:hover\:border-nb-gray-800\/50:hover{border-color:#3f444b80}@supports (color:color-mix(in lab,red,red)){.dark\:hover\:border-nb-gray-800\/50:hover{border-color:color-mix(in oklab,var(--color-nb-gray-800)50%,transparent)}}.dark\:hover\:border-red-800\/50:hover{border-color:#9b1c1c80}@supports (color:color-mix(in lab,red,red)){.dark\:hover\:border-red-800\/50:hover{border-color:color-mix(in oklab,var(--color-red-800)50%,transparent)}}.dark\:hover\:bg-nb-gray-800\/60:hover{background-color:#3f444b99}@supports (color:color-mix(in lab,red,red)){.dark\:hover\:bg-nb-gray-800\/60:hover{background-color:color-mix(in oklab,var(--color-nb-gray-800)60%,transparent)}}.dark\:hover\:bg-nb-gray-900\/30:hover{background-color:#32363d4d}@supports (color:color-mix(in lab,red,red)){.dark\:hover\:bg-nb-gray-900\/30:hover{background-color:color-mix(in oklab,var(--color-nb-gray-900)30%,transparent)}}.dark\:hover\:bg-nb-gray-900\/50:hover{background-color:#32363d80}@supports (color:color-mix(in lab,red,red)){.dark\:hover\:bg-nb-gray-900\/50:hover{background-color:color-mix(in oklab,var(--color-nb-gray-900)50%,transparent)}}.dark\:hover\:bg-nb-gray-900\/80:hover{background-color:#32363dcc}@supports (color:color-mix(in lab,red,red)){.dark\:hover\:bg-nb-gray-900\/80:hover{background-color:color-mix(in oklab,var(--color-nb-gray-900)80%,transparent)}}.dark\:hover\:bg-nb-gray-910:hover{background-color:var(--color-nb-gray-910)}.dark\:hover\:bg-neutral-200:hover{background-color:var(--color-neutral-200)}.dark\:hover\:bg-zinc-800\/50:hover{background-color:#27272a80}@supports (color:color-mix(in lab,red,red)){.dark\:hover\:bg-zinc-800\/50:hover{background-color:color-mix(in oklab,var(--color-zinc-800)50%,transparent)}}}}@media(hover:hover){@media(prefers-color-scheme:dark){.hover\:dark\:bg-red-700:hover{background-color:var(--color-red-700)}}}@media(prefers-color-scheme:dark){@media(hover:hover){.dark\:hover\:text-red-600:hover{color:var(--color-red-600)}.dark\:hover\:text-white:hover{color:var(--color-white)}}.dark\:focus\:bg-red-700:focus{background-color:var(--color-red-700)}.dark\:focus\:ring-nb-gray-500\/20:focus{--tw-ring-color:#616e7933}@supports (color:color-mix(in lab,red,red)){.dark\:focus\:ring-nb-gray-500\/20:focus{--tw-ring-color:color-mix(in oklab,var(--color-nb-gray-500)20%,transparent)}}.dark\:focus\:ring-netbird-600\/50:focus{--tw-ring-color:#e5531180}@supports (color:color-mix(in lab,red,red)){.dark\:focus\:ring-netbird-600\/50:focus{--tw-ring-color:color-mix(in oklab,var(--color-netbird-600)50%,transparent)}}.dark\:focus\:ring-neutral-500\/20:focus{--tw-ring-color:#73737333}@supports (color:color-mix(in lab,red,red)){.dark\:focus\:ring-neutral-500\/20:focus{--tw-ring-color:color-mix(in oklab,var(--color-neutral-500)20%,transparent)}}.dark\:focus\:ring-red-700\/20:focus{--tw-ring-color:#c81e1e33}@supports (color:color-mix(in lab,red,red)){.dark\:focus\:ring-red-700\/20:focus{--tw-ring-color:color-mix(in oklab,var(--color-red-700)20%,transparent)}}.dark\:focus\:ring-zinc-800\/50:focus{--tw-ring-color:#27272a80}@supports (color:color-mix(in lab,red,red)){.dark\:focus\:ring-zinc-800\/50:focus{--tw-ring-color:color-mix(in oklab,var(--color-zinc-800)50%,transparent)}}.dark\:focus-visible\:ring-neutral-500\/20:focus-visible{--tw-ring-color:#73737333}@supports (color:color-mix(in lab,red,red)){.dark\:focus-visible\:ring-neutral-500\/20:focus-visible{--tw-ring-color:color-mix(in oklab,var(--color-neutral-500)20%,transparent)}}.enabled\:dark\:bg-netbird:enabled{background-color:var(--color-netbird)}@media(hover:hover){.enabled\:dark\:hover\:border-red-800\/50:enabled:hover{border-color:#9b1c1c80}@supports (color:color-mix(in lab,red,red)){.enabled\:dark\:hover\:border-red-800\/50:enabled:hover{border-color:color-mix(in oklab,var(--color-red-800)50%,transparent)}}.enabled\:dark\:hover\:bg-netbird-500\/80:enabled:hover{background-color:#f46d1bcc}@supports (color:color-mix(in lab,red,red)){.enabled\:dark\:hover\:bg-netbird-500\/80:enabled:hover{background-color:color-mix(in oklab,var(--color-netbird-500)80%,transparent)}}}}@media(hover:hover){@media(prefers-color-scheme:dark){.enabled\:hover\:dark\:bg-red-950\/50:enabled:hover{background-color:#46080980}@supports (color:color-mix(in lab,red,red)){.enabled\:hover\:dark\:bg-red-950\/50:enabled:hover{background-color:color-mix(in oklab,var(--color-red-950)50%,transparent)}}}}@media(prefers-color-scheme:dark){@media(hover:hover){.enabled\:dark\:hover\:text-white:enabled:hover{color:var(--color-white)}}.enabled\:dark\:focus\:bg-red-950\/40:enabled:focus{background-color:#46080966}@supports (color:color-mix(in lab,red,red)){.enabled\:dark\:focus\:bg-red-950\/40:enabled:focus{background-color:color-mix(in oklab,var(--color-red-950)40%,transparent)}}.enabled\:dark\:focus\:ring-red-800\/20:enabled:focus{--tw-ring-color:#9b1c1c33}@supports (color:color-mix(in lab,red,red)){.enabled\:dark\:focus\:ring-red-800\/20:enabled:focus{--tw-ring-color:color-mix(in oklab,var(--color-red-800)20%,transparent)}}.disabled\:dark\:border-nb-gray-900:disabled{border-color:var(--color-nb-gray-900)}.disabled\:dark\:bg-nb-gray-900:disabled{background-color:var(--color-nb-gray-900)}.disabled\:dark\:bg-nb-gray-910:disabled{background-color:var(--color-nb-gray-910)}.disabled\:dark\:bg-nb-gray-920:disabled{background-color:var(--color-nb-gray-920)}.disabled\:dark\:text-nb-gray-300:disabled{color:var(--color-nb-gray-300)}.data-\[state\=open\]\:dark\:border-nb-gray-800\/50[data-state=open]{border-color:#3f444b80}@supports (color:color-mix(in lab,red,red)){.data-\[state\=open\]\:dark\:border-nb-gray-800\/50[data-state=open]{border-color:color-mix(in oklab,var(--color-nb-gray-800)50%,transparent)}}.data-\[state\=open\]\:dark\:bg-nb-gray-900\/30[data-state=open]{background-color:#32363d4d}@supports (color:color-mix(in lab,red,red)){.data-\[state\=open\]\:dark\:bg-nb-gray-900\/30[data-state=open]{background-color:color-mix(in oklab,var(--color-nb-gray-900)30%,transparent)}}.data-\[state\=open\]\:dark\:text-white[data-state=open]{color:var(--color-white)}}}@font-face{font-family:Inter;font-style:normal;font-weight:100 900;font-display:swap;src:url(/assets/Inter-VariableFont_opsz_wght-c8O0ljhh.ttf)format("truetype")}@font-face{font-family:Inter;font-style:italic;font-weight:100 900;font-display:swap;src:url(/assets/Inter-Italic-VariableFont_opsz_wght-B-9PvMw6.ttf)format("truetype")}:root{--nb-bg:#18191d;--nb-card-bg:#1b1f22;--nb-border:#32363d80;--nb-text:#e4e7e9;--nb-text-muted:#a7b1b9cc;--nb-primary:#f68330;--nb-primary-hover:#e5722a;--nb-input-bg:#3f444b80;--nb-input-border:#3f444bcc;--nb-error-bg:#991b1b33;--nb-error-border:#991b1b80;--nb-error-text:#f87171}html{color-scheme:dark;background-color:var(--color-nb-gray)}html.dark,:root{color-scheme:dark}body{font-family:Inter,ui-sans-serif,system-ui,sans-serif,Apple Color Emoji,Segoe UI Emoji}h1{margin-block:calc(var(--spacing)*1);font-size:var(--text-2xl);line-height:var(--tw-leading,var(--text-2xl--line-height));--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium);color:var(--color-gray-700)}@media(prefers-color-scheme:dark){h1{color:var(--color-nb-gray-100)}}h2{margin-block:calc(var(--spacing)*1);font-size:var(--text-xl);line-height:var(--tw-leading,var(--text-xl--line-height));--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium);color:var(--color-gray-700)}@media(prefers-color-scheme:dark){h2{color:var(--color-nb-gray-100)}}p{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));--tw-font-weight:var(--font-weight-light);font-weight:var(--font-weight-light);--tw-tracking:var(--tracking-wide);letter-spacing:var(--tracking-wide);color:var(--color-gray-700)}@media(prefers-color-scheme:dark){p{color:var(--color-zinc-50)}}[placeholder]{text-overflow:ellipsis}@property --tw-border-style{syntax:"*";inherits:false;initial-value:solid}@property --tw-gradient-position{syntax:"*";inherits:false}@property --tw-gradient-from{syntax:"";inherits:false;initial-value:#0000}@property --tw-gradient-via{syntax:"";inherits:false;initial-value:#0000}@property --tw-gradient-to{syntax:"";inherits:false;initial-value:#0000}@property --tw-gradient-stops{syntax:"*";inherits:false}@property --tw-gradient-via-stops{syntax:"*";inherits:false}@property --tw-gradient-from-position{syntax:"";inherits:false;initial-value:0%}@property --tw-gradient-via-position{syntax:"";inherits:false;initial-value:50%}@property --tw-gradient-to-position{syntax:"";inherits:false;initial-value:100%}@property --tw-leading{syntax:"*";inherits:false}@property --tw-font-weight{syntax:"*";inherits:false}@property --tw-tracking{syntax:"*";inherits:false}@property --tw-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-shadow-color{syntax:"*";inherits:false}@property --tw-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-inset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-shadow-color{syntax:"*";inherits:false}@property --tw-inset-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-ring-color{syntax:"*";inherits:false}@property --tw-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-ring-color{syntax:"*";inherits:false}@property --tw-inset-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-ring-inset{syntax:"*";inherits:false}@property --tw-ring-offset-width{syntax:"";inherits:false;initial-value:0}@property --tw-ring-offset-color{syntax:"*";inherits:false;initial-value:#fff}@property --tw-ring-offset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-outline-style{syntax:"*";inherits:false;initial-value:solid}@keyframes spin{to{transform:rotate(360deg)}} diff --git a/proxy/web/dist/index.html b/proxy/web/dist/index.html index 60dd7fc2f..a57ba7ea5 100644 --- a/proxy/web/dist/index.html +++ b/proxy/web/dist/index.html @@ -4,10 +4,10 @@ - Authentication Required + NetBird Service - - + + diff --git a/proxy/web/index.html b/proxy/web/index.html index db9b290cf..e41f24f38 100644 --- a/proxy/web/index.html +++ b/proxy/web/index.html @@ -4,7 +4,7 @@ - Authentication Required + NetBird Service diff --git a/proxy/web/package-lock.json b/proxy/web/package-lock.json index 8fec50b5d..d16196d77 100644 --- a/proxy/web/package-lock.json +++ b/proxy/web/package-lock.json @@ -63,6 +63,7 @@ "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", @@ -1709,6 +1710,7 @@ "integrity": "sha512-+0/4J266CBGPUq/ELg7QUHhN25WYjE0wYTPSQJn1xeu8DOlIOPxXxrNGiLmfAWl7HMMgWFWXpt9IDjMWrF5Iow==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "undici-types": "~7.16.0" } @@ -1719,6 +1721,7 @@ "integrity": "sha512-WPigyYuGhgZ/cTPRXB2EwUw+XvsRA3GqHlsP4qteqrnnjDrApbS7MxcGr/hke5iUoeB7E/gQtrs9I37zAJ0Vjw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "csstype": "^3.2.2" } @@ -1778,6 +1781,7 @@ "integrity": "sha512-BtE0k6cjwjLZoZixN0t5AKP0kSzlGu7FctRXYuPAm//aaiZhmfq1JwdYpYr1brzEspYyFeF+8XF5j2VK6oalrA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.54.0", "@typescript-eslint/types": "8.54.0", @@ -2029,6 +2033,7 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -2134,6 +2139,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -2388,6 +2394,7 @@ "integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -3384,6 +3391,7 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -3445,6 +3453,7 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz", "integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==", "license": "MIT", + "peer": true, "engines": { "node": ">=0.10.0" } @@ -3678,6 +3687,7 @@ "integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "~0.27.0", "get-tsconfig": "^4.7.5" @@ -3711,6 +3721,7 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -3797,6 +3808,7 @@ "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", @@ -3918,6 +3930,7 @@ "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", "dev": true, "license": "MIT", + "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/proxy/web/src/App.tsx b/proxy/web/src/App.tsx index 4404e13ac..bd3295cb2 100644 --- a/proxy/web/src/App.tsx +++ b/proxy/web/src/App.tsx @@ -1,4 +1,4 @@ -import { useState, useRef } from "react"; +import { useState, useRef, useEffect } from "react"; import {Loader2, Lock, Binary, LogIn} from "lucide-react"; import { getData, type Data } from "@/data"; import Button from "@/components/Button"; @@ -22,6 +22,10 @@ const methods: NonNullable = : { password:"password", pin: "pin", oidc: "/auth/oidc" }; function App() { + useEffect(() => { + document.title = "Authentication Required - NetBird Service"; + }, []); + const [error, setError] = useState(null); const [submitting, setSubmitting] = useState(null); const [pin, setPin] = useState(""); diff --git a/proxy/web/src/components/PoweredByNetBird.tsx b/proxy/web/src/components/PoweredByNetBird.tsx index af31c7944..555d8a79f 100644 --- a/proxy/web/src/components/PoweredByNetBird.tsx +++ b/proxy/web/src/components/PoweredByNetBird.tsx @@ -2,11 +2,16 @@ import { NetBirdLogo } from "./NetBirdLogo"; export function PoweredByNetBird() { return ( - + ); } \ No newline at end of file diff --git a/proxy/web/src/data.ts b/proxy/web/src/data.ts index 3df76edfa..7b03e731f 100644 --- a/proxy/web/src/data.ts +++ b/proxy/web/src/data.ts @@ -1,9 +1,21 @@ // Auth method types matching Go export type AuthMethod = 'pin' | 'password' | 'oidc' | "link" +// Page types +export type PageType = 'auth' | 'error' + +// Error data structure +export interface ErrorData { + code: number + title: string + message: string +} + // Data injected by Go templates export interface Data { + page?: PageType methods?: Partial> + error?: ErrorData } declare global { diff --git a/proxy/web/src/main.tsx b/proxy/web/src/main.tsx index bef5202a3..328b31140 100644 --- a/proxy/web/src/main.tsx +++ b/proxy/web/src/main.tsx @@ -2,9 +2,17 @@ import { StrictMode } from 'react' import { createRoot } from 'react-dom/client' import './index.css' import App from './App.tsx' +import { ErrorPage } from './pages/ErrorPage.tsx' +import { getData } from '@/data' + +const data = getData() createRoot(document.getElementById('root')!).render( - + {data.page === 'error' && data.error ? ( + + ) : ( + + )} , ) diff --git a/proxy/web/src/pages/ErrorPage.tsx b/proxy/web/src/pages/ErrorPage.tsx new file mode 100644 index 000000000..a52abe1dd --- /dev/null +++ b/proxy/web/src/pages/ErrorPage.tsx @@ -0,0 +1,42 @@ +import { useEffect } from "react"; +import { BookText, RotateCw } from "lucide-react"; +import { Title } from "@/components/Title"; +import { Description } from "@/components/Description"; +import { PoweredByNetBird } from "@/components/PoweredByNetBird"; +import { Card } from "@/components/Card"; +import Button from "@/components/Button"; +import type { ErrorData } from "@/data"; + +export function ErrorPage({ code, title, message }: ErrorData) { + useEffect(() => { + document.title = `${title} - NetBird Service`; + }, [title]); + + return ( +
+ +
{code}
+ {title} + {message} +
+ + +
+
+ + +
+ ); +} diff --git a/proxy/web/web.go b/proxy/web/web.go index e0d59b121..4959369d1 100644 --- a/proxy/web/web.go +++ b/proxy/web/web.go @@ -37,7 +37,8 @@ func init() { // ServeHTTP serves the web UI. For static assets it serves them directly, // for other paths it renders the page with the provided data. -func ServeHTTP(w http.ResponseWriter, r *http.Request, data any) { +// Optional statusCode can be passed to set a custom HTTP status code (default 200). +func ServeHTTP(w http.ResponseWriter, r *http.Request, data any, statusCode ...int) { if initErr != nil { http.Error(w, initErr.Error(), http.StatusInternalServerError) return @@ -101,5 +102,20 @@ func ServeHTTP(w http.ResponseWriter, r *http.Request, data any) { } w.Header().Set("Content-Type", "text/html") + if len(statusCode) > 0 { + w.WriteHeader(statusCode[0]) + } w.Write(buf.Bytes()) } + +// ServeErrorPage renders a user-friendly error page with the given details. +func ServeErrorPage(w http.ResponseWriter, r *http.Request, code int, title, message string) { + ServeHTTP(w, r, map[string]any{ + "page": "error", + "error": map[string]any{ + "code": code, + "title": title, + "message": message, + }, + }, code) +} diff --git a/shared/management/proto/proxy_service.pb.go b/shared/management/proto/proxy_service.pb.go index 36c4cf4f0..f82eb5fe1 100644 --- a/shared/management/proto/proxy_service.pb.go +++ b/shared/management/proto/proxy_service.pb.go @@ -310,10 +310,11 @@ type Authentication struct { sizeCache protoimpl.SizeCache unknownFields protoimpl.UnknownFields - Password bool `protobuf:"varint,1,opt,name=password,proto3" json:"password,omitempty"` - Pin bool `protobuf:"varint,2,opt,name=pin,proto3" json:"pin,omitempty"` - Oidc *OIDC `protobuf:"bytes,3,opt,name=oidc,proto3,oneof" json:"oidc,omitempty"` - Link bool `protobuf:"varint,4,opt,name=link,proto3" json:"link,omitempty"` + SessionKey string `protobuf:"bytes,1,opt,name=session_key,json=sessionKey,proto3" json:"session_key,omitempty"` + MaxSessionAgeSeconds int64 `protobuf:"varint,2,opt,name=max_session_age_seconds,json=maxSessionAgeSeconds,proto3" json:"max_session_age_seconds,omitempty"` + Password bool `protobuf:"varint,3,opt,name=password,proto3" json:"password,omitempty"` + Pin bool `protobuf:"varint,4,opt,name=pin,proto3" json:"pin,omitempty"` + Oidc bool `protobuf:"varint,5,opt,name=oidc,proto3" json:"oidc,omitempty"` } func (x *Authentication) Reset() { @@ -348,6 +349,20 @@ func (*Authentication) Descriptor() ([]byte, []int) { return file_proxy_service_proto_rawDescGZIP(), []int{3} } +func (x *Authentication) GetSessionKey() string { + if x != nil { + return x.SessionKey + } + return "" +} + +func (x *Authentication) GetMaxSessionAgeSeconds() int64 { + if x != nil { + return x.MaxSessionAgeSeconds + } + return 0 +} + func (x *Authentication) GetPassword() bool { if x != nil { return x.Password @@ -362,67 +377,13 @@ func (x *Authentication) GetPin() bool { return false } -func (x *Authentication) GetOidc() *OIDC { +func (x *Authentication) GetOidc() bool { if x != nil { return x.Oidc } - return nil -} - -func (x *Authentication) GetLink() bool { - if x != nil { - return x.Link - } return false } -type OIDC struct { - state protoimpl.MessageState - sizeCache protoimpl.SizeCache - unknownFields protoimpl.UnknownFields - - DistributionGroups []string `protobuf:"bytes,1,rep,name=distribution_groups,json=distributionGroups,proto3" json:"distribution_groups,omitempty"` -} - -func (x *OIDC) Reset() { - *x = OIDC{} - if protoimpl.UnsafeEnabled { - mi := &file_proxy_service_proto_msgTypes[4] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) - } -} - -func (x *OIDC) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*OIDC) ProtoMessage() {} - -func (x *OIDC) ProtoReflect() protoreflect.Message { - mi := &file_proxy_service_proto_msgTypes[4] - if protoimpl.UnsafeEnabled && x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use OIDC.ProtoReflect.Descriptor instead. -func (*OIDC) Descriptor() ([]byte, []int) { - return file_proxy_service_proto_rawDescGZIP(), []int{4} -} - -func (x *OIDC) GetDistributionGroups() []string { - if x != nil { - return x.DistributionGroups - } - return nil -} - type ProxyMapping struct { state protoimpl.MessageState sizeCache protoimpl.SizeCache @@ -433,14 +394,14 @@ type ProxyMapping struct { AccountId string `protobuf:"bytes,3,opt,name=account_id,json=accountId,proto3" json:"account_id,omitempty"` Domain string `protobuf:"bytes,4,opt,name=domain,proto3" json:"domain,omitempty"` Path []*PathMapping `protobuf:"bytes,5,rep,name=path,proto3" json:"path,omitempty"` - SetupKey string `protobuf:"bytes,6,opt,name=setup_key,json=setupKey,proto3" json:"setup_key,omitempty"` + AuthToken string `protobuf:"bytes,6,opt,name=auth_token,json=authToken,proto3" json:"auth_token,omitempty"` Auth *Authentication `protobuf:"bytes,7,opt,name=auth,proto3" json:"auth,omitempty"` } func (x *ProxyMapping) Reset() { *x = ProxyMapping{} if protoimpl.UnsafeEnabled { - mi := &file_proxy_service_proto_msgTypes[5] + mi := &file_proxy_service_proto_msgTypes[4] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -453,7 +414,7 @@ func (x *ProxyMapping) String() string { func (*ProxyMapping) ProtoMessage() {} func (x *ProxyMapping) ProtoReflect() protoreflect.Message { - mi := &file_proxy_service_proto_msgTypes[5] + mi := &file_proxy_service_proto_msgTypes[4] if protoimpl.UnsafeEnabled && x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -466,7 +427,7 @@ func (x *ProxyMapping) ProtoReflect() protoreflect.Message { // Deprecated: Use ProxyMapping.ProtoReflect.Descriptor instead. func (*ProxyMapping) Descriptor() ([]byte, []int) { - return file_proxy_service_proto_rawDescGZIP(), []int{5} + return file_proxy_service_proto_rawDescGZIP(), []int{4} } func (x *ProxyMapping) GetType() ProxyMappingUpdateType { @@ -504,9 +465,9 @@ func (x *ProxyMapping) GetPath() []*PathMapping { return nil } -func (x *ProxyMapping) GetSetupKey() string { +func (x *ProxyMapping) GetAuthToken() string { if x != nil { - return x.SetupKey + return x.AuthToken } return "" } @@ -530,7 +491,7 @@ type SendAccessLogRequest struct { func (x *SendAccessLogRequest) Reset() { *x = SendAccessLogRequest{} if protoimpl.UnsafeEnabled { - mi := &file_proxy_service_proto_msgTypes[6] + mi := &file_proxy_service_proto_msgTypes[5] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -543,7 +504,7 @@ func (x *SendAccessLogRequest) String() string { func (*SendAccessLogRequest) ProtoMessage() {} func (x *SendAccessLogRequest) ProtoReflect() protoreflect.Message { - mi := &file_proxy_service_proto_msgTypes[6] + mi := &file_proxy_service_proto_msgTypes[5] if protoimpl.UnsafeEnabled && x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -556,7 +517,7 @@ func (x *SendAccessLogRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use SendAccessLogRequest.ProtoReflect.Descriptor instead. func (*SendAccessLogRequest) Descriptor() ([]byte, []int) { - return file_proxy_service_proto_rawDescGZIP(), []int{6} + return file_proxy_service_proto_rawDescGZIP(), []int{5} } func (x *SendAccessLogRequest) GetLog() *AccessLog { @@ -576,7 +537,7 @@ type SendAccessLogResponse struct { func (x *SendAccessLogResponse) Reset() { *x = SendAccessLogResponse{} if protoimpl.UnsafeEnabled { - mi := &file_proxy_service_proto_msgTypes[7] + mi := &file_proxy_service_proto_msgTypes[6] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -589,7 +550,7 @@ func (x *SendAccessLogResponse) String() string { func (*SendAccessLogResponse) ProtoMessage() {} func (x *SendAccessLogResponse) ProtoReflect() protoreflect.Message { - mi := &file_proxy_service_proto_msgTypes[7] + mi := &file_proxy_service_proto_msgTypes[6] if protoimpl.UnsafeEnabled && x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -602,7 +563,7 @@ func (x *SendAccessLogResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use SendAccessLogResponse.ProtoReflect.Descriptor instead. func (*SendAccessLogResponse) Descriptor() ([]byte, []int) { - return file_proxy_service_proto_rawDescGZIP(), []int{7} + return file_proxy_service_proto_rawDescGZIP(), []int{6} } type AccessLog struct { @@ -628,7 +589,7 @@ type AccessLog struct { func (x *AccessLog) Reset() { *x = AccessLog{} if protoimpl.UnsafeEnabled { - mi := &file_proxy_service_proto_msgTypes[8] + mi := &file_proxy_service_proto_msgTypes[7] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -641,7 +602,7 @@ func (x *AccessLog) String() string { func (*AccessLog) ProtoMessage() {} func (x *AccessLog) ProtoReflect() protoreflect.Message { - mi := &file_proxy_service_proto_msgTypes[8] + mi := &file_proxy_service_proto_msgTypes[7] if protoimpl.UnsafeEnabled && x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -654,7 +615,7 @@ func (x *AccessLog) ProtoReflect() protoreflect.Message { // Deprecated: Use AccessLog.ProtoReflect.Descriptor instead. func (*AccessLog) Descriptor() ([]byte, []int) { - return file_proxy_service_proto_rawDescGZIP(), []int{8} + return file_proxy_service_proto_rawDescGZIP(), []int{7} } func (x *AccessLog) GetTimestamp() *timestamppb.Timestamp { @@ -759,14 +720,13 @@ type AuthenticateRequest struct { // // *AuthenticateRequest_Password // *AuthenticateRequest_Pin - // *AuthenticateRequest_Link Request isAuthenticateRequest_Request `protobuf_oneof:"request"` } func (x *AuthenticateRequest) Reset() { *x = AuthenticateRequest{} if protoimpl.UnsafeEnabled { - mi := &file_proxy_service_proto_msgTypes[9] + mi := &file_proxy_service_proto_msgTypes[8] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -779,7 +739,7 @@ func (x *AuthenticateRequest) String() string { func (*AuthenticateRequest) ProtoMessage() {} func (x *AuthenticateRequest) ProtoReflect() protoreflect.Message { - mi := &file_proxy_service_proto_msgTypes[9] + mi := &file_proxy_service_proto_msgTypes[8] if protoimpl.UnsafeEnabled && x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -792,7 +752,7 @@ func (x *AuthenticateRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use AuthenticateRequest.ProtoReflect.Descriptor instead. func (*AuthenticateRequest) Descriptor() ([]byte, []int) { - return file_proxy_service_proto_rawDescGZIP(), []int{9} + return file_proxy_service_proto_rawDescGZIP(), []int{8} } func (x *AuthenticateRequest) GetId() string { @@ -830,13 +790,6 @@ func (x *AuthenticateRequest) GetPin() *PinRequest { return nil } -func (x *AuthenticateRequest) GetLink() *LinkRequest { - if x, ok := x.GetRequest().(*AuthenticateRequest_Link); ok { - return x.Link - } - return nil -} - type isAuthenticateRequest_Request interface { isAuthenticateRequest_Request() } @@ -849,16 +802,10 @@ type AuthenticateRequest_Pin struct { Pin *PinRequest `protobuf:"bytes,4,opt,name=pin,proto3,oneof"` } -type AuthenticateRequest_Link struct { - Link *LinkRequest `protobuf:"bytes,5,opt,name=link,proto3,oneof"` -} - func (*AuthenticateRequest_Password) isAuthenticateRequest_Request() {} func (*AuthenticateRequest_Pin) isAuthenticateRequest_Request() {} -func (*AuthenticateRequest_Link) isAuthenticateRequest_Request() {} - type PasswordRequest struct { state protoimpl.MessageState sizeCache protoimpl.SizeCache @@ -870,7 +817,7 @@ type PasswordRequest struct { func (x *PasswordRequest) Reset() { *x = PasswordRequest{} if protoimpl.UnsafeEnabled { - mi := &file_proxy_service_proto_msgTypes[10] + mi := &file_proxy_service_proto_msgTypes[9] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -883,7 +830,7 @@ func (x *PasswordRequest) String() string { func (*PasswordRequest) ProtoMessage() {} func (x *PasswordRequest) ProtoReflect() protoreflect.Message { - mi := &file_proxy_service_proto_msgTypes[10] + mi := &file_proxy_service_proto_msgTypes[9] if protoimpl.UnsafeEnabled && x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -896,7 +843,7 @@ func (x *PasswordRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use PasswordRequest.ProtoReflect.Descriptor instead. func (*PasswordRequest) Descriptor() ([]byte, []int) { - return file_proxy_service_proto_rawDescGZIP(), []int{10} + return file_proxy_service_proto_rawDescGZIP(), []int{9} } func (x *PasswordRequest) GetPassword() string { @@ -917,7 +864,7 @@ type PinRequest struct { func (x *PinRequest) Reset() { *x = PinRequest{} if protoimpl.UnsafeEnabled { - mi := &file_proxy_service_proto_msgTypes[11] + mi := &file_proxy_service_proto_msgTypes[10] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -930,7 +877,7 @@ func (x *PinRequest) String() string { func (*PinRequest) ProtoMessage() {} func (x *PinRequest) ProtoReflect() protoreflect.Message { - mi := &file_proxy_service_proto_msgTypes[11] + mi := &file_proxy_service_proto_msgTypes[10] if protoimpl.UnsafeEnabled && x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -943,7 +890,7 @@ func (x *PinRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use PinRequest.ProtoReflect.Descriptor instead. func (*PinRequest) Descriptor() ([]byte, []int) { - return file_proxy_service_proto_rawDescGZIP(), []int{11} + return file_proxy_service_proto_rawDescGZIP(), []int{10} } func (x *PinRequest) GetPin() string { @@ -953,73 +900,19 @@ func (x *PinRequest) GetPin() string { return "" } -type LinkRequest struct { - state protoimpl.MessageState - sizeCache protoimpl.SizeCache - unknownFields protoimpl.UnknownFields - - Email string `protobuf:"bytes,1,opt,name=email,proto3" json:"email,omitempty"` - Redirect string `protobuf:"bytes,2,opt,name=redirect,proto3" json:"redirect,omitempty"` -} - -func (x *LinkRequest) Reset() { - *x = LinkRequest{} - if protoimpl.UnsafeEnabled { - mi := &file_proxy_service_proto_msgTypes[12] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) - } -} - -func (x *LinkRequest) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*LinkRequest) ProtoMessage() {} - -func (x *LinkRequest) ProtoReflect() protoreflect.Message { - mi := &file_proxy_service_proto_msgTypes[12] - if protoimpl.UnsafeEnabled && x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use LinkRequest.ProtoReflect.Descriptor instead. -func (*LinkRequest) Descriptor() ([]byte, []int) { - return file_proxy_service_proto_rawDescGZIP(), []int{12} -} - -func (x *LinkRequest) GetEmail() string { - if x != nil { - return x.Email - } - return "" -} - -func (x *LinkRequest) GetRedirect() string { - if x != nil { - return x.Redirect - } - return "" -} - type AuthenticateResponse struct { state protoimpl.MessageState sizeCache protoimpl.SizeCache unknownFields protoimpl.UnknownFields - Success bool `protobuf:"varint,1,opt,name=success,proto3" json:"success,omitempty"` + Success bool `protobuf:"varint,1,opt,name=success,proto3" json:"success,omitempty"` + SessionToken string `protobuf:"bytes,2,opt,name=session_token,json=sessionToken,proto3" json:"session_token,omitempty"` } func (x *AuthenticateResponse) Reset() { *x = AuthenticateResponse{} if protoimpl.UnsafeEnabled { - mi := &file_proxy_service_proto_msgTypes[13] + mi := &file_proxy_service_proto_msgTypes[11] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1032,7 +925,7 @@ func (x *AuthenticateResponse) String() string { func (*AuthenticateResponse) ProtoMessage() {} func (x *AuthenticateResponse) ProtoReflect() protoreflect.Message { - mi := &file_proxy_service_proto_msgTypes[13] + mi := &file_proxy_service_proto_msgTypes[11] if protoimpl.UnsafeEnabled && x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1045,7 +938,7 @@ func (x *AuthenticateResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use AuthenticateResponse.ProtoReflect.Descriptor instead. func (*AuthenticateResponse) Descriptor() ([]byte, []int) { - return file_proxy_service_proto_rawDescGZIP(), []int{13} + return file_proxy_service_proto_rawDescGZIP(), []int{11} } func (x *AuthenticateResponse) GetSuccess() bool { @@ -1055,6 +948,13 @@ func (x *AuthenticateResponse) GetSuccess() bool { return false } +func (x *AuthenticateResponse) GetSessionToken() string { + if x != nil { + return x.SessionToken + } + return "" +} + // SendStatusUpdateRequest is sent by the proxy to update its status type SendStatusUpdateRequest struct { state protoimpl.MessageState @@ -1071,7 +971,7 @@ type SendStatusUpdateRequest struct { func (x *SendStatusUpdateRequest) Reset() { *x = SendStatusUpdateRequest{} if protoimpl.UnsafeEnabled { - mi := &file_proxy_service_proto_msgTypes[14] + mi := &file_proxy_service_proto_msgTypes[12] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1084,7 +984,7 @@ func (x *SendStatusUpdateRequest) String() string { func (*SendStatusUpdateRequest) ProtoMessage() {} func (x *SendStatusUpdateRequest) ProtoReflect() protoreflect.Message { - mi := &file_proxy_service_proto_msgTypes[14] + mi := &file_proxy_service_proto_msgTypes[12] if protoimpl.UnsafeEnabled && x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1097,7 +997,7 @@ func (x *SendStatusUpdateRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use SendStatusUpdateRequest.ProtoReflect.Descriptor instead. func (*SendStatusUpdateRequest) Descriptor() ([]byte, []int) { - return file_proxy_service_proto_rawDescGZIP(), []int{14} + return file_proxy_service_proto_rawDescGZIP(), []int{12} } func (x *SendStatusUpdateRequest) GetReverseProxyId() string { @@ -1145,7 +1045,7 @@ type SendStatusUpdateResponse struct { func (x *SendStatusUpdateResponse) Reset() { *x = SendStatusUpdateResponse{} if protoimpl.UnsafeEnabled { - mi := &file_proxy_service_proto_msgTypes[15] + mi := &file_proxy_service_proto_msgTypes[13] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1158,7 +1058,7 @@ func (x *SendStatusUpdateResponse) String() string { func (*SendStatusUpdateResponse) ProtoMessage() {} func (x *SendStatusUpdateResponse) ProtoReflect() protoreflect.Message { - mi := &file_proxy_service_proto_msgTypes[15] + mi := &file_proxy_service_proto_msgTypes[13] if protoimpl.UnsafeEnabled && x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1171,9 +1071,248 @@ func (x *SendStatusUpdateResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use SendStatusUpdateResponse.ProtoReflect.Descriptor instead. func (*SendStatusUpdateResponse) Descriptor() ([]byte, []int) { + return file_proxy_service_proto_rawDescGZIP(), []int{13} +} + +// CreateProxyPeerRequest is sent by the proxy to create a peer connection +// The token is a one-time authentication token sent via ProxyMapping +type CreateProxyPeerRequest struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + ReverseProxyId string `protobuf:"bytes,1,opt,name=reverse_proxy_id,json=reverseProxyId,proto3" json:"reverse_proxy_id,omitempty"` + AccountId string `protobuf:"bytes,2,opt,name=account_id,json=accountId,proto3" json:"account_id,omitempty"` + Token string `protobuf:"bytes,3,opt,name=token,proto3" json:"token,omitempty"` + WireguardPublicKey string `protobuf:"bytes,4,opt,name=wireguard_public_key,json=wireguardPublicKey,proto3" json:"wireguard_public_key,omitempty"` +} + +func (x *CreateProxyPeerRequest) Reset() { + *x = CreateProxyPeerRequest{} + if protoimpl.UnsafeEnabled { + mi := &file_proxy_service_proto_msgTypes[14] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *CreateProxyPeerRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*CreateProxyPeerRequest) ProtoMessage() {} + +func (x *CreateProxyPeerRequest) ProtoReflect() protoreflect.Message { + mi := &file_proxy_service_proto_msgTypes[14] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use CreateProxyPeerRequest.ProtoReflect.Descriptor instead. +func (*CreateProxyPeerRequest) Descriptor() ([]byte, []int) { + return file_proxy_service_proto_rawDescGZIP(), []int{14} +} + +func (x *CreateProxyPeerRequest) GetReverseProxyId() string { + if x != nil { + return x.ReverseProxyId + } + return "" +} + +func (x *CreateProxyPeerRequest) GetAccountId() string { + if x != nil { + return x.AccountId + } + return "" +} + +func (x *CreateProxyPeerRequest) GetToken() string { + if x != nil { + return x.Token + } + return "" +} + +func (x *CreateProxyPeerRequest) GetWireguardPublicKey() string { + if x != nil { + return x.WireguardPublicKey + } + return "" +} + +// CreateProxyPeerResponse contains the result of peer creation +type CreateProxyPeerResponse struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Success bool `protobuf:"varint,1,opt,name=success,proto3" json:"success,omitempty"` + ErrorMessage *string `protobuf:"bytes,2,opt,name=error_message,json=errorMessage,proto3,oneof" json:"error_message,omitempty"` +} + +func (x *CreateProxyPeerResponse) Reset() { + *x = CreateProxyPeerResponse{} + if protoimpl.UnsafeEnabled { + mi := &file_proxy_service_proto_msgTypes[15] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *CreateProxyPeerResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*CreateProxyPeerResponse) ProtoMessage() {} + +func (x *CreateProxyPeerResponse) ProtoReflect() protoreflect.Message { + mi := &file_proxy_service_proto_msgTypes[15] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use CreateProxyPeerResponse.ProtoReflect.Descriptor instead. +func (*CreateProxyPeerResponse) Descriptor() ([]byte, []int) { return file_proxy_service_proto_rawDescGZIP(), []int{15} } +func (x *CreateProxyPeerResponse) GetSuccess() bool { + if x != nil { + return x.Success + } + return false +} + +func (x *CreateProxyPeerResponse) GetErrorMessage() string { + if x != nil && x.ErrorMessage != nil { + return *x.ErrorMessage + } + return "" +} + +type GetOIDCURLRequest struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"` + AccountId string `protobuf:"bytes,2,opt,name=account_id,json=accountId,proto3" json:"account_id,omitempty"` + RedirectUrl string `protobuf:"bytes,3,opt,name=redirect_url,json=redirectUrl,proto3" json:"redirect_url,omitempty"` +} + +func (x *GetOIDCURLRequest) Reset() { + *x = GetOIDCURLRequest{} + if protoimpl.UnsafeEnabled { + mi := &file_proxy_service_proto_msgTypes[16] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *GetOIDCURLRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*GetOIDCURLRequest) ProtoMessage() {} + +func (x *GetOIDCURLRequest) ProtoReflect() protoreflect.Message { + mi := &file_proxy_service_proto_msgTypes[16] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use GetOIDCURLRequest.ProtoReflect.Descriptor instead. +func (*GetOIDCURLRequest) Descriptor() ([]byte, []int) { + return file_proxy_service_proto_rawDescGZIP(), []int{16} +} + +func (x *GetOIDCURLRequest) GetId() string { + if x != nil { + return x.Id + } + return "" +} + +func (x *GetOIDCURLRequest) GetAccountId() string { + if x != nil { + return x.AccountId + } + return "" +} + +func (x *GetOIDCURLRequest) GetRedirectUrl() string { + if x != nil { + return x.RedirectUrl + } + return "" +} + +type GetOIDCURLResponse struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Url string `protobuf:"bytes,1,opt,name=url,proto3" json:"url,omitempty"` +} + +func (x *GetOIDCURLResponse) Reset() { + *x = GetOIDCURLResponse{} + if protoimpl.UnsafeEnabled { + mi := &file_proxy_service_proto_msgTypes[17] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *GetOIDCURLResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*GetOIDCURLResponse) ProtoMessage() {} + +func (x *GetOIDCURLResponse) ProtoReflect() protoreflect.Message { + mi := &file_proxy_service_proto_msgTypes[17] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use GetOIDCURLResponse.ProtoReflect.Descriptor instead. +func (*GetOIDCURLResponse) Descriptor() ([]byte, []int) { + return file_proxy_service_proto_rawDescGZIP(), []int{17} +} + +func (x *GetOIDCURLResponse) GetUrl() string { + if x != nil { + return x.Url + } + return "" +} + var File_proxy_service_proto protoreflect.FileDescriptor var file_proxy_service_proto_rawDesc = []byte{ @@ -1200,155 +1339,187 @@ var file_proxy_service_proto_rawDesc = []byte{ 0x4d, 0x61, 0x70, 0x70, 0x69, 0x6e, 0x67, 0x12, 0x12, 0x0a, 0x04, 0x70, 0x61, 0x74, 0x68, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x70, 0x61, 0x74, 0x68, 0x12, 0x16, 0x0a, 0x06, 0x74, 0x61, 0x72, 0x67, 0x65, 0x74, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x74, 0x61, 0x72, - 0x67, 0x65, 0x74, 0x22, 0x86, 0x01, 0x0a, 0x0e, 0x41, 0x75, 0x74, 0x68, 0x65, 0x6e, 0x74, 0x69, - 0x63, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x1a, 0x0a, 0x08, 0x70, 0x61, 0x73, 0x73, 0x77, 0x6f, - 0x72, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x08, 0x52, 0x08, 0x70, 0x61, 0x73, 0x73, 0x77, 0x6f, - 0x72, 0x64, 0x12, 0x10, 0x0a, 0x03, 0x70, 0x69, 0x6e, 0x18, 0x02, 0x20, 0x01, 0x28, 0x08, 0x52, - 0x03, 0x70, 0x69, 0x6e, 0x12, 0x29, 0x0a, 0x04, 0x6f, 0x69, 0x64, 0x63, 0x18, 0x03, 0x20, 0x01, - 0x28, 0x0b, 0x32, 0x10, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, - 0x4f, 0x49, 0x44, 0x43, 0x48, 0x00, 0x52, 0x04, 0x6f, 0x69, 0x64, 0x63, 0x88, 0x01, 0x01, 0x12, - 0x12, 0x0a, 0x04, 0x6c, 0x69, 0x6e, 0x6b, 0x18, 0x04, 0x20, 0x01, 0x28, 0x08, 0x52, 0x04, 0x6c, - 0x69, 0x6e, 0x6b, 0x42, 0x07, 0x0a, 0x05, 0x5f, 0x6f, 0x69, 0x64, 0x63, 0x22, 0x37, 0x0a, 0x04, - 0x4f, 0x49, 0x44, 0x43, 0x12, 0x2f, 0x0a, 0x13, 0x64, 0x69, 0x73, 0x74, 0x72, 0x69, 0x62, 0x75, - 0x74, 0x69, 0x6f, 0x6e, 0x5f, 0x67, 0x72, 0x6f, 0x75, 0x70, 0x73, 0x18, 0x01, 0x20, 0x03, 0x28, - 0x09, 0x52, 0x12, 0x64, 0x69, 0x73, 0x74, 0x72, 0x69, 0x62, 0x75, 0x74, 0x69, 0x6f, 0x6e, 0x47, - 0x72, 0x6f, 0x75, 0x70, 0x73, 0x22, 0x87, 0x02, 0x0a, 0x0c, 0x50, 0x72, 0x6f, 0x78, 0x79, 0x4d, - 0x61, 0x70, 0x70, 0x69, 0x6e, 0x67, 0x12, 0x36, 0x0a, 0x04, 0x74, 0x79, 0x70, 0x65, 0x18, 0x01, - 0x20, 0x01, 0x28, 0x0e, 0x32, 0x22, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, - 0x74, 0x2e, 0x50, 0x72, 0x6f, 0x78, 0x79, 0x4d, 0x61, 0x70, 0x70, 0x69, 0x6e, 0x67, 0x55, 0x70, - 0x64, 0x61, 0x74, 0x65, 0x54, 0x79, 0x70, 0x65, 0x52, 0x04, 0x74, 0x79, 0x70, 0x65, 0x12, 0x0e, - 0x0a, 0x02, 0x69, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x02, 0x69, 0x64, 0x12, 0x1d, - 0x0a, 0x0a, 0x61, 0x63, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x5f, 0x69, 0x64, 0x18, 0x03, 0x20, 0x01, - 0x28, 0x09, 0x52, 0x09, 0x61, 0x63, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x49, 0x64, 0x12, 0x16, 0x0a, - 0x06, 0x64, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x64, - 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x12, 0x2b, 0x0a, 0x04, 0x70, 0x61, 0x74, 0x68, 0x18, 0x05, 0x20, - 0x03, 0x28, 0x0b, 0x32, 0x17, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, - 0x2e, 0x50, 0x61, 0x74, 0x68, 0x4d, 0x61, 0x70, 0x70, 0x69, 0x6e, 0x67, 0x52, 0x04, 0x70, 0x61, - 0x74, 0x68, 0x12, 0x1b, 0x0a, 0x09, 0x73, 0x65, 0x74, 0x75, 0x70, 0x5f, 0x6b, 0x65, 0x79, 0x18, - 0x06, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x73, 0x65, 0x74, 0x75, 0x70, 0x4b, 0x65, 0x79, 0x12, - 0x2e, 0x0a, 0x04, 0x61, 0x75, 0x74, 0x68, 0x18, 0x07, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1a, 0x2e, - 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x41, 0x75, 0x74, 0x68, 0x65, - 0x6e, 0x74, 0x69, 0x63, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x52, 0x04, 0x61, 0x75, 0x74, 0x68, 0x22, - 0x3f, 0x0a, 0x14, 0x53, 0x65, 0x6e, 0x64, 0x41, 0x63, 0x63, 0x65, 0x73, 0x73, 0x4c, 0x6f, 0x67, - 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x27, 0x0a, 0x03, 0x6c, 0x6f, 0x67, 0x18, 0x01, - 0x20, 0x01, 0x28, 0x0b, 0x32, 0x15, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, - 0x74, 0x2e, 0x41, 0x63, 0x63, 0x65, 0x73, 0x73, 0x4c, 0x6f, 0x67, 0x52, 0x03, 0x6c, 0x6f, 0x67, - 0x22, 0x17, 0x0a, 0x15, 0x53, 0x65, 0x6e, 0x64, 0x41, 0x63, 0x63, 0x65, 0x73, 0x73, 0x4c, 0x6f, - 0x67, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0xa0, 0x03, 0x0a, 0x09, 0x41, 0x63, - 0x63, 0x65, 0x73, 0x73, 0x4c, 0x6f, 0x67, 0x12, 0x38, 0x0a, 0x09, 0x74, 0x69, 0x6d, 0x65, 0x73, - 0x74, 0x61, 0x6d, 0x70, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x67, 0x6f, 0x6f, - 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x54, 0x69, 0x6d, - 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, 0x52, 0x09, 0x74, 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, - 0x70, 0x12, 0x15, 0x0a, 0x06, 0x6c, 0x6f, 0x67, 0x5f, 0x69, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, - 0x09, 0x52, 0x05, 0x6c, 0x6f, 0x67, 0x49, 0x64, 0x12, 0x1d, 0x0a, 0x0a, 0x61, 0x63, 0x63, 0x6f, - 0x75, 0x6e, 0x74, 0x5f, 0x69, 0x64, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x61, 0x63, - 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x49, 0x64, 0x12, 0x1d, 0x0a, 0x0a, 0x73, 0x65, 0x72, 0x76, 0x69, - 0x63, 0x65, 0x5f, 0x69, 0x64, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x73, 0x65, 0x72, - 0x76, 0x69, 0x63, 0x65, 0x49, 0x64, 0x12, 0x12, 0x0a, 0x04, 0x68, 0x6f, 0x73, 0x74, 0x18, 0x05, - 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x68, 0x6f, 0x73, 0x74, 0x12, 0x12, 0x0a, 0x04, 0x70, 0x61, - 0x74, 0x68, 0x18, 0x06, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x70, 0x61, 0x74, 0x68, 0x12, 0x1f, - 0x0a, 0x0b, 0x64, 0x75, 0x72, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x5f, 0x6d, 0x73, 0x18, 0x07, 0x20, - 0x01, 0x28, 0x03, 0x52, 0x0a, 0x64, 0x75, 0x72, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x4d, 0x73, 0x12, - 0x16, 0x0a, 0x06, 0x6d, 0x65, 0x74, 0x68, 0x6f, 0x64, 0x18, 0x08, 0x20, 0x01, 0x28, 0x09, 0x52, - 0x06, 0x6d, 0x65, 0x74, 0x68, 0x6f, 0x64, 0x12, 0x23, 0x0a, 0x0d, 0x72, 0x65, 0x73, 0x70, 0x6f, - 0x6e, 0x73, 0x65, 0x5f, 0x63, 0x6f, 0x64, 0x65, 0x18, 0x09, 0x20, 0x01, 0x28, 0x05, 0x52, 0x0c, - 0x72, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x43, 0x6f, 0x64, 0x65, 0x12, 0x1b, 0x0a, 0x09, - 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x5f, 0x69, 0x70, 0x18, 0x0a, 0x20, 0x01, 0x28, 0x09, 0x52, - 0x08, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x49, 0x70, 0x12, 0x25, 0x0a, 0x0e, 0x61, 0x75, 0x74, - 0x68, 0x5f, 0x6d, 0x65, 0x63, 0x68, 0x61, 0x6e, 0x69, 0x73, 0x6d, 0x18, 0x0b, 0x20, 0x01, 0x28, - 0x09, 0x52, 0x0d, 0x61, 0x75, 0x74, 0x68, 0x4d, 0x65, 0x63, 0x68, 0x61, 0x6e, 0x69, 0x73, 0x6d, - 0x12, 0x17, 0x0a, 0x07, 0x75, 0x73, 0x65, 0x72, 0x5f, 0x69, 0x64, 0x18, 0x0c, 0x20, 0x01, 0x28, - 0x09, 0x52, 0x06, 0x75, 0x73, 0x65, 0x72, 0x49, 0x64, 0x12, 0x21, 0x0a, 0x0c, 0x61, 0x75, 0x74, - 0x68, 0x5f, 0x73, 0x75, 0x63, 0x63, 0x65, 0x73, 0x73, 0x18, 0x0d, 0x20, 0x01, 0x28, 0x08, 0x52, - 0x0b, 0x61, 0x75, 0x74, 0x68, 0x53, 0x75, 0x63, 0x63, 0x65, 0x73, 0x73, 0x22, 0xe5, 0x01, 0x0a, - 0x13, 0x41, 0x75, 0x74, 0x68, 0x65, 0x6e, 0x74, 0x69, 0x63, 0x61, 0x74, 0x65, 0x52, 0x65, 0x71, - 0x75, 0x65, 0x73, 0x74, 0x12, 0x0e, 0x0a, 0x02, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, - 0x52, 0x02, 0x69, 0x64, 0x12, 0x1d, 0x0a, 0x0a, 0x61, 0x63, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x5f, - 0x69, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x61, 0x63, 0x63, 0x6f, 0x75, 0x6e, - 0x74, 0x49, 0x64, 0x12, 0x39, 0x0a, 0x08, 0x70, 0x61, 0x73, 0x73, 0x77, 0x6f, 0x72, 0x64, 0x18, - 0x03, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1b, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, - 0x6e, 0x74, 0x2e, 0x50, 0x61, 0x73, 0x73, 0x77, 0x6f, 0x72, 0x64, 0x52, 0x65, 0x71, 0x75, 0x65, - 0x73, 0x74, 0x48, 0x00, 0x52, 0x08, 0x70, 0x61, 0x73, 0x73, 0x77, 0x6f, 0x72, 0x64, 0x12, 0x2a, - 0x0a, 0x03, 0x70, 0x69, 0x6e, 0x18, 0x04, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x16, 0x2e, 0x6d, 0x61, - 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x50, 0x69, 0x6e, 0x52, 0x65, 0x71, 0x75, - 0x65, 0x73, 0x74, 0x48, 0x00, 0x52, 0x03, 0x70, 0x69, 0x6e, 0x12, 0x2d, 0x0a, 0x04, 0x6c, 0x69, - 0x6e, 0x6b, 0x18, 0x05, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x17, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, - 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x4c, 0x69, 0x6e, 0x6b, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, - 0x74, 0x48, 0x00, 0x52, 0x04, 0x6c, 0x69, 0x6e, 0x6b, 0x42, 0x09, 0x0a, 0x07, 0x72, 0x65, 0x71, - 0x75, 0x65, 0x73, 0x74, 0x22, 0x2d, 0x0a, 0x0f, 0x50, 0x61, 0x73, 0x73, 0x77, 0x6f, 0x72, 0x64, - 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x1a, 0x0a, 0x08, 0x70, 0x61, 0x73, 0x73, 0x77, - 0x6f, 0x72, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x70, 0x61, 0x73, 0x73, 0x77, - 0x6f, 0x72, 0x64, 0x22, 0x1e, 0x0a, 0x0a, 0x50, 0x69, 0x6e, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, - 0x74, 0x12, 0x10, 0x0a, 0x03, 0x70, 0x69, 0x6e, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, - 0x70, 0x69, 0x6e, 0x22, 0x3f, 0x0a, 0x0b, 0x4c, 0x69, 0x6e, 0x6b, 0x52, 0x65, 0x71, 0x75, 0x65, - 0x73, 0x74, 0x12, 0x14, 0x0a, 0x05, 0x65, 0x6d, 0x61, 0x69, 0x6c, 0x18, 0x01, 0x20, 0x01, 0x28, - 0x09, 0x52, 0x05, 0x65, 0x6d, 0x61, 0x69, 0x6c, 0x12, 0x1a, 0x0a, 0x08, 0x72, 0x65, 0x64, 0x69, - 0x72, 0x65, 0x63, 0x74, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x72, 0x65, 0x64, 0x69, - 0x72, 0x65, 0x63, 0x74, 0x22, 0x30, 0x0a, 0x14, 0x41, 0x75, 0x74, 0x68, 0x65, 0x6e, 0x74, 0x69, - 0x63, 0x61, 0x74, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x18, 0x0a, 0x07, + 0x67, 0x65, 0x74, 0x22, 0xaa, 0x01, 0x0a, 0x0e, 0x41, 0x75, 0x74, 0x68, 0x65, 0x6e, 0x74, 0x69, + 0x63, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x1f, 0x0a, 0x0b, 0x73, 0x65, 0x73, 0x73, 0x69, 0x6f, + 0x6e, 0x5f, 0x6b, 0x65, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0a, 0x73, 0x65, 0x73, + 0x73, 0x69, 0x6f, 0x6e, 0x4b, 0x65, 0x79, 0x12, 0x35, 0x0a, 0x17, 0x6d, 0x61, 0x78, 0x5f, 0x73, + 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x5f, 0x61, 0x67, 0x65, 0x5f, 0x73, 0x65, 0x63, 0x6f, 0x6e, + 0x64, 0x73, 0x18, 0x02, 0x20, 0x01, 0x28, 0x03, 0x52, 0x14, 0x6d, 0x61, 0x78, 0x53, 0x65, 0x73, + 0x73, 0x69, 0x6f, 0x6e, 0x41, 0x67, 0x65, 0x53, 0x65, 0x63, 0x6f, 0x6e, 0x64, 0x73, 0x12, 0x1a, + 0x0a, 0x08, 0x70, 0x61, 0x73, 0x73, 0x77, 0x6f, 0x72, 0x64, 0x18, 0x03, 0x20, 0x01, 0x28, 0x08, + 0x52, 0x08, 0x70, 0x61, 0x73, 0x73, 0x77, 0x6f, 0x72, 0x64, 0x12, 0x10, 0x0a, 0x03, 0x70, 0x69, + 0x6e, 0x18, 0x04, 0x20, 0x01, 0x28, 0x08, 0x52, 0x03, 0x70, 0x69, 0x6e, 0x12, 0x12, 0x0a, 0x04, + 0x6f, 0x69, 0x64, 0x63, 0x18, 0x05, 0x20, 0x01, 0x28, 0x08, 0x52, 0x04, 0x6f, 0x69, 0x64, 0x63, + 0x22, 0x89, 0x02, 0x0a, 0x0c, 0x50, 0x72, 0x6f, 0x78, 0x79, 0x4d, 0x61, 0x70, 0x70, 0x69, 0x6e, + 0x67, 0x12, 0x36, 0x0a, 0x04, 0x74, 0x79, 0x70, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0e, 0x32, + 0x22, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x50, 0x72, 0x6f, + 0x78, 0x79, 0x4d, 0x61, 0x70, 0x70, 0x69, 0x6e, 0x67, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x54, + 0x79, 0x70, 0x65, 0x52, 0x04, 0x74, 0x79, 0x70, 0x65, 0x12, 0x0e, 0x0a, 0x02, 0x69, 0x64, 0x18, + 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x02, 0x69, 0x64, 0x12, 0x1d, 0x0a, 0x0a, 0x61, 0x63, 0x63, + 0x6f, 0x75, 0x6e, 0x74, 0x5f, 0x69, 0x64, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x61, + 0x63, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x49, 0x64, 0x12, 0x16, 0x0a, 0x06, 0x64, 0x6f, 0x6d, 0x61, + 0x69, 0x6e, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x64, 0x6f, 0x6d, 0x61, 0x69, 0x6e, + 0x12, 0x2b, 0x0a, 0x04, 0x70, 0x61, 0x74, 0x68, 0x18, 0x05, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x17, + 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x50, 0x61, 0x74, 0x68, + 0x4d, 0x61, 0x70, 0x70, 0x69, 0x6e, 0x67, 0x52, 0x04, 0x70, 0x61, 0x74, 0x68, 0x12, 0x1d, 0x0a, + 0x0a, 0x61, 0x75, 0x74, 0x68, 0x5f, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x18, 0x06, 0x20, 0x01, 0x28, + 0x09, 0x52, 0x09, 0x61, 0x75, 0x74, 0x68, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x12, 0x2e, 0x0a, 0x04, + 0x61, 0x75, 0x74, 0x68, 0x18, 0x07, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x6d, 0x61, 0x6e, + 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x41, 0x75, 0x74, 0x68, 0x65, 0x6e, 0x74, 0x69, + 0x63, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x52, 0x04, 0x61, 0x75, 0x74, 0x68, 0x22, 0x3f, 0x0a, 0x14, + 0x53, 0x65, 0x6e, 0x64, 0x41, 0x63, 0x63, 0x65, 0x73, 0x73, 0x4c, 0x6f, 0x67, 0x52, 0x65, 0x71, + 0x75, 0x65, 0x73, 0x74, 0x12, 0x27, 0x0a, 0x03, 0x6c, 0x6f, 0x67, 0x18, 0x01, 0x20, 0x01, 0x28, + 0x0b, 0x32, 0x15, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x41, + 0x63, 0x63, 0x65, 0x73, 0x73, 0x4c, 0x6f, 0x67, 0x52, 0x03, 0x6c, 0x6f, 0x67, 0x22, 0x17, 0x0a, + 0x15, 0x53, 0x65, 0x6e, 0x64, 0x41, 0x63, 0x63, 0x65, 0x73, 0x73, 0x4c, 0x6f, 0x67, 0x52, 0x65, + 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0xa0, 0x03, 0x0a, 0x09, 0x41, 0x63, 0x63, 0x65, 0x73, + 0x73, 0x4c, 0x6f, 0x67, 0x12, 0x38, 0x0a, 0x09, 0x74, 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, + 0x70, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, + 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x54, 0x69, 0x6d, 0x65, 0x73, 0x74, + 0x61, 0x6d, 0x70, 0x52, 0x09, 0x74, 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, 0x12, 0x15, + 0x0a, 0x06, 0x6c, 0x6f, 0x67, 0x5f, 0x69, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, + 0x6c, 0x6f, 0x67, 0x49, 0x64, 0x12, 0x1d, 0x0a, 0x0a, 0x61, 0x63, 0x63, 0x6f, 0x75, 0x6e, 0x74, + 0x5f, 0x69, 0x64, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x61, 0x63, 0x63, 0x6f, 0x75, + 0x6e, 0x74, 0x49, 0x64, 0x12, 0x1d, 0x0a, 0x0a, 0x73, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x5f, + 0x69, 0x64, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x73, 0x65, 0x72, 0x76, 0x69, 0x63, + 0x65, 0x49, 0x64, 0x12, 0x12, 0x0a, 0x04, 0x68, 0x6f, 0x73, 0x74, 0x18, 0x05, 0x20, 0x01, 0x28, + 0x09, 0x52, 0x04, 0x68, 0x6f, 0x73, 0x74, 0x12, 0x12, 0x0a, 0x04, 0x70, 0x61, 0x74, 0x68, 0x18, + 0x06, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x70, 0x61, 0x74, 0x68, 0x12, 0x1f, 0x0a, 0x0b, 0x64, + 0x75, 0x72, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x5f, 0x6d, 0x73, 0x18, 0x07, 0x20, 0x01, 0x28, 0x03, + 0x52, 0x0a, 0x64, 0x75, 0x72, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x4d, 0x73, 0x12, 0x16, 0x0a, 0x06, + 0x6d, 0x65, 0x74, 0x68, 0x6f, 0x64, 0x18, 0x08, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x6d, 0x65, + 0x74, 0x68, 0x6f, 0x64, 0x12, 0x23, 0x0a, 0x0d, 0x72, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, + 0x5f, 0x63, 0x6f, 0x64, 0x65, 0x18, 0x09, 0x20, 0x01, 0x28, 0x05, 0x52, 0x0c, 0x72, 0x65, 0x73, + 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x43, 0x6f, 0x64, 0x65, 0x12, 0x1b, 0x0a, 0x09, 0x73, 0x6f, 0x75, + 0x72, 0x63, 0x65, 0x5f, 0x69, 0x70, 0x18, 0x0a, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x73, 0x6f, + 0x75, 0x72, 0x63, 0x65, 0x49, 0x70, 0x12, 0x25, 0x0a, 0x0e, 0x61, 0x75, 0x74, 0x68, 0x5f, 0x6d, + 0x65, 0x63, 0x68, 0x61, 0x6e, 0x69, 0x73, 0x6d, 0x18, 0x0b, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0d, + 0x61, 0x75, 0x74, 0x68, 0x4d, 0x65, 0x63, 0x68, 0x61, 0x6e, 0x69, 0x73, 0x6d, 0x12, 0x17, 0x0a, + 0x07, 0x75, 0x73, 0x65, 0x72, 0x5f, 0x69, 0x64, 0x18, 0x0c, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, + 0x75, 0x73, 0x65, 0x72, 0x49, 0x64, 0x12, 0x21, 0x0a, 0x0c, 0x61, 0x75, 0x74, 0x68, 0x5f, 0x73, + 0x75, 0x63, 0x63, 0x65, 0x73, 0x73, 0x18, 0x0d, 0x20, 0x01, 0x28, 0x08, 0x52, 0x0b, 0x61, 0x75, + 0x74, 0x68, 0x53, 0x75, 0x63, 0x63, 0x65, 0x73, 0x73, 0x22, 0xb6, 0x01, 0x0a, 0x13, 0x41, 0x75, + 0x74, 0x68, 0x65, 0x6e, 0x74, 0x69, 0x63, 0x61, 0x74, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, + 0x74, 0x12, 0x0e, 0x0a, 0x02, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x02, 0x69, + 0x64, 0x12, 0x1d, 0x0a, 0x0a, 0x61, 0x63, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x5f, 0x69, 0x64, 0x18, + 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x61, 0x63, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x49, 0x64, + 0x12, 0x39, 0x0a, 0x08, 0x70, 0x61, 0x73, 0x73, 0x77, 0x6f, 0x72, 0x64, 0x18, 0x03, 0x20, 0x01, + 0x28, 0x0b, 0x32, 0x1b, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, + 0x50, 0x61, 0x73, 0x73, 0x77, 0x6f, 0x72, 0x64, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x48, + 0x00, 0x52, 0x08, 0x70, 0x61, 0x73, 0x73, 0x77, 0x6f, 0x72, 0x64, 0x12, 0x2a, 0x0a, 0x03, 0x70, + 0x69, 0x6e, 0x18, 0x04, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x16, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, + 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x50, 0x69, 0x6e, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, + 0x48, 0x00, 0x52, 0x03, 0x70, 0x69, 0x6e, 0x42, 0x09, 0x0a, 0x07, 0x72, 0x65, 0x71, 0x75, 0x65, + 0x73, 0x74, 0x22, 0x2d, 0x0a, 0x0f, 0x50, 0x61, 0x73, 0x73, 0x77, 0x6f, 0x72, 0x64, 0x52, 0x65, + 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x1a, 0x0a, 0x08, 0x70, 0x61, 0x73, 0x73, 0x77, 0x6f, 0x72, + 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x70, 0x61, 0x73, 0x73, 0x77, 0x6f, 0x72, + 0x64, 0x22, 0x1e, 0x0a, 0x0a, 0x50, 0x69, 0x6e, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, + 0x10, 0x0a, 0x03, 0x70, 0x69, 0x6e, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x70, 0x69, + 0x6e, 0x22, 0x55, 0x0a, 0x14, 0x41, 0x75, 0x74, 0x68, 0x65, 0x6e, 0x74, 0x69, 0x63, 0x61, 0x74, + 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x18, 0x0a, 0x07, 0x73, 0x75, 0x63, + 0x63, 0x65, 0x73, 0x73, 0x18, 0x01, 0x20, 0x01, 0x28, 0x08, 0x52, 0x07, 0x73, 0x75, 0x63, 0x63, + 0x65, 0x73, 0x73, 0x12, 0x23, 0x0a, 0x0d, 0x73, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x5f, 0x74, + 0x6f, 0x6b, 0x65, 0x6e, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0c, 0x73, 0x65, 0x73, 0x73, + 0x69, 0x6f, 0x6e, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x22, 0xfe, 0x01, 0x0a, 0x17, 0x53, 0x65, 0x6e, + 0x64, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x52, 0x65, 0x71, + 0x75, 0x65, 0x73, 0x74, 0x12, 0x28, 0x0a, 0x10, 0x72, 0x65, 0x76, 0x65, 0x72, 0x73, 0x65, 0x5f, + 0x70, 0x72, 0x6f, 0x78, 0x79, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0e, + 0x72, 0x65, 0x76, 0x65, 0x72, 0x73, 0x65, 0x50, 0x72, 0x6f, 0x78, 0x79, 0x49, 0x64, 0x12, 0x1d, + 0x0a, 0x0a, 0x61, 0x63, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x5f, 0x69, 0x64, 0x18, 0x02, 0x20, 0x01, + 0x28, 0x09, 0x52, 0x09, 0x61, 0x63, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x49, 0x64, 0x12, 0x2f, 0x0a, + 0x06, 0x73, 0x74, 0x61, 0x74, 0x75, 0x73, 0x18, 0x03, 0x20, 0x01, 0x28, 0x0e, 0x32, 0x17, 0x2e, + 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x50, 0x72, 0x6f, 0x78, 0x79, + 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x52, 0x06, 0x73, 0x74, 0x61, 0x74, 0x75, 0x73, 0x12, 0x2d, + 0x0a, 0x12, 0x63, 0x65, 0x72, 0x74, 0x69, 0x66, 0x69, 0x63, 0x61, 0x74, 0x65, 0x5f, 0x69, 0x73, + 0x73, 0x75, 0x65, 0x64, 0x18, 0x04, 0x20, 0x01, 0x28, 0x08, 0x52, 0x11, 0x63, 0x65, 0x72, 0x74, + 0x69, 0x66, 0x69, 0x63, 0x61, 0x74, 0x65, 0x49, 0x73, 0x73, 0x75, 0x65, 0x64, 0x12, 0x28, 0x0a, + 0x0d, 0x65, 0x72, 0x72, 0x6f, 0x72, 0x5f, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x18, 0x05, + 0x20, 0x01, 0x28, 0x09, 0x48, 0x00, 0x52, 0x0c, 0x65, 0x72, 0x72, 0x6f, 0x72, 0x4d, 0x65, 0x73, + 0x73, 0x61, 0x67, 0x65, 0x88, 0x01, 0x01, 0x42, 0x10, 0x0a, 0x0e, 0x5f, 0x65, 0x72, 0x72, 0x6f, + 0x72, 0x5f, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x22, 0x1a, 0x0a, 0x18, 0x53, 0x65, 0x6e, + 0x64, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x52, 0x65, 0x73, + 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0xa9, 0x01, 0x0a, 0x16, 0x43, 0x72, 0x65, 0x61, 0x74, 0x65, + 0x50, 0x72, 0x6f, 0x78, 0x79, 0x50, 0x65, 0x65, 0x72, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, + 0x12, 0x28, 0x0a, 0x10, 0x72, 0x65, 0x76, 0x65, 0x72, 0x73, 0x65, 0x5f, 0x70, 0x72, 0x6f, 0x78, + 0x79, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0e, 0x72, 0x65, 0x76, 0x65, + 0x72, 0x73, 0x65, 0x50, 0x72, 0x6f, 0x78, 0x79, 0x49, 0x64, 0x12, 0x1d, 0x0a, 0x0a, 0x61, 0x63, + 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x5f, 0x69, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, + 0x61, 0x63, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x49, 0x64, 0x12, 0x14, 0x0a, 0x05, 0x74, 0x6f, 0x6b, + 0x65, 0x6e, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x12, + 0x30, 0x0a, 0x14, 0x77, 0x69, 0x72, 0x65, 0x67, 0x75, 0x61, 0x72, 0x64, 0x5f, 0x70, 0x75, 0x62, + 0x6c, 0x69, 0x63, 0x5f, 0x6b, 0x65, 0x79, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x12, 0x77, + 0x69, 0x72, 0x65, 0x67, 0x75, 0x61, 0x72, 0x64, 0x50, 0x75, 0x62, 0x6c, 0x69, 0x63, 0x4b, 0x65, + 0x79, 0x22, 0x6f, 0x0a, 0x17, 0x43, 0x72, 0x65, 0x61, 0x74, 0x65, 0x50, 0x72, 0x6f, 0x78, 0x79, + 0x50, 0x65, 0x65, 0x72, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x18, 0x0a, 0x07, 0x73, 0x75, 0x63, 0x63, 0x65, 0x73, 0x73, 0x18, 0x01, 0x20, 0x01, 0x28, 0x08, 0x52, 0x07, 0x73, - 0x75, 0x63, 0x63, 0x65, 0x73, 0x73, 0x22, 0xfe, 0x01, 0x0a, 0x17, 0x53, 0x65, 0x6e, 0x64, 0x53, - 0x74, 0x61, 0x74, 0x75, 0x73, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, - 0x73, 0x74, 0x12, 0x28, 0x0a, 0x10, 0x72, 0x65, 0x76, 0x65, 0x72, 0x73, 0x65, 0x5f, 0x70, 0x72, - 0x6f, 0x78, 0x79, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0e, 0x72, 0x65, - 0x76, 0x65, 0x72, 0x73, 0x65, 0x50, 0x72, 0x6f, 0x78, 0x79, 0x49, 0x64, 0x12, 0x1d, 0x0a, 0x0a, - 0x61, 0x63, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x5f, 0x69, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, - 0x52, 0x09, 0x61, 0x63, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x49, 0x64, 0x12, 0x2f, 0x0a, 0x06, 0x73, - 0x74, 0x61, 0x74, 0x75, 0x73, 0x18, 0x03, 0x20, 0x01, 0x28, 0x0e, 0x32, 0x17, 0x2e, 0x6d, 0x61, - 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x50, 0x72, 0x6f, 0x78, 0x79, 0x53, 0x74, - 0x61, 0x74, 0x75, 0x73, 0x52, 0x06, 0x73, 0x74, 0x61, 0x74, 0x75, 0x73, 0x12, 0x2d, 0x0a, 0x12, - 0x63, 0x65, 0x72, 0x74, 0x69, 0x66, 0x69, 0x63, 0x61, 0x74, 0x65, 0x5f, 0x69, 0x73, 0x73, 0x75, - 0x65, 0x64, 0x18, 0x04, 0x20, 0x01, 0x28, 0x08, 0x52, 0x11, 0x63, 0x65, 0x72, 0x74, 0x69, 0x66, - 0x69, 0x63, 0x61, 0x74, 0x65, 0x49, 0x73, 0x73, 0x75, 0x65, 0x64, 0x12, 0x28, 0x0a, 0x0d, 0x65, - 0x72, 0x72, 0x6f, 0x72, 0x5f, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x18, 0x05, 0x20, 0x01, - 0x28, 0x09, 0x48, 0x00, 0x52, 0x0c, 0x65, 0x72, 0x72, 0x6f, 0x72, 0x4d, 0x65, 0x73, 0x73, 0x61, - 0x67, 0x65, 0x88, 0x01, 0x01, 0x42, 0x10, 0x0a, 0x0e, 0x5f, 0x65, 0x72, 0x72, 0x6f, 0x72, 0x5f, - 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x22, 0x1a, 0x0a, 0x18, 0x53, 0x65, 0x6e, 0x64, 0x53, - 0x74, 0x61, 0x74, 0x75, 0x73, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, - 0x6e, 0x73, 0x65, 0x2a, 0x64, 0x0a, 0x16, 0x50, 0x72, 0x6f, 0x78, 0x79, 0x4d, 0x61, 0x70, 0x70, - 0x69, 0x6e, 0x67, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x54, 0x79, 0x70, 0x65, 0x12, 0x17, 0x0a, - 0x13, 0x55, 0x50, 0x44, 0x41, 0x54, 0x45, 0x5f, 0x54, 0x59, 0x50, 0x45, 0x5f, 0x43, 0x52, 0x45, - 0x41, 0x54, 0x45, 0x44, 0x10, 0x00, 0x12, 0x18, 0x0a, 0x14, 0x55, 0x50, 0x44, 0x41, 0x54, 0x45, - 0x5f, 0x54, 0x59, 0x50, 0x45, 0x5f, 0x4d, 0x4f, 0x44, 0x49, 0x46, 0x49, 0x45, 0x44, 0x10, 0x01, - 0x12, 0x17, 0x0a, 0x13, 0x55, 0x50, 0x44, 0x41, 0x54, 0x45, 0x5f, 0x54, 0x59, 0x50, 0x45, 0x5f, - 0x52, 0x45, 0x4d, 0x4f, 0x56, 0x45, 0x44, 0x10, 0x02, 0x2a, 0xc8, 0x01, 0x0a, 0x0b, 0x50, 0x72, - 0x6f, 0x78, 0x79, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x12, 0x18, 0x0a, 0x14, 0x50, 0x52, 0x4f, - 0x58, 0x59, 0x5f, 0x53, 0x54, 0x41, 0x54, 0x55, 0x53, 0x5f, 0x50, 0x45, 0x4e, 0x44, 0x49, 0x4e, - 0x47, 0x10, 0x00, 0x12, 0x17, 0x0a, 0x13, 0x50, 0x52, 0x4f, 0x58, 0x59, 0x5f, 0x53, 0x54, 0x41, - 0x54, 0x55, 0x53, 0x5f, 0x41, 0x43, 0x54, 0x49, 0x56, 0x45, 0x10, 0x01, 0x12, 0x23, 0x0a, 0x1f, - 0x50, 0x52, 0x4f, 0x58, 0x59, 0x5f, 0x53, 0x54, 0x41, 0x54, 0x55, 0x53, 0x5f, 0x54, 0x55, 0x4e, - 0x4e, 0x45, 0x4c, 0x5f, 0x4e, 0x4f, 0x54, 0x5f, 0x43, 0x52, 0x45, 0x41, 0x54, 0x45, 0x44, 0x10, - 0x02, 0x12, 0x24, 0x0a, 0x20, 0x50, 0x52, 0x4f, 0x58, 0x59, 0x5f, 0x53, 0x54, 0x41, 0x54, 0x55, - 0x53, 0x5f, 0x43, 0x45, 0x52, 0x54, 0x49, 0x46, 0x49, 0x43, 0x41, 0x54, 0x45, 0x5f, 0x50, 0x45, - 0x4e, 0x44, 0x49, 0x4e, 0x47, 0x10, 0x03, 0x12, 0x23, 0x0a, 0x1f, 0x50, 0x52, 0x4f, 0x58, 0x59, - 0x5f, 0x53, 0x54, 0x41, 0x54, 0x55, 0x53, 0x5f, 0x43, 0x45, 0x52, 0x54, 0x49, 0x46, 0x49, 0x43, - 0x41, 0x54, 0x45, 0x5f, 0x46, 0x41, 0x49, 0x4c, 0x45, 0x44, 0x10, 0x04, 0x12, 0x16, 0x0a, 0x12, - 0x50, 0x52, 0x4f, 0x58, 0x59, 0x5f, 0x53, 0x54, 0x41, 0x54, 0x55, 0x53, 0x5f, 0x45, 0x52, 0x52, - 0x4f, 0x52, 0x10, 0x05, 0x32, 0xf7, 0x02, 0x0a, 0x0c, 0x50, 0x72, 0x6f, 0x78, 0x79, 0x53, 0x65, - 0x72, 0x76, 0x69, 0x63, 0x65, 0x12, 0x5f, 0x0a, 0x10, 0x47, 0x65, 0x74, 0x4d, 0x61, 0x70, 0x70, - 0x69, 0x6e, 0x67, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x12, 0x23, 0x2e, 0x6d, 0x61, 0x6e, 0x61, - 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x47, 0x65, 0x74, 0x4d, 0x61, 0x70, 0x70, 0x69, 0x6e, - 0x67, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x24, - 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x47, 0x65, 0x74, 0x4d, - 0x61, 0x70, 0x70, 0x69, 0x6e, 0x67, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x52, 0x65, 0x73, 0x70, - 0x6f, 0x6e, 0x73, 0x65, 0x30, 0x01, 0x12, 0x54, 0x0a, 0x0d, 0x53, 0x65, 0x6e, 0x64, 0x41, 0x63, - 0x63, 0x65, 0x73, 0x73, 0x4c, 0x6f, 0x67, 0x12, 0x20, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, + 0x75, 0x63, 0x63, 0x65, 0x73, 0x73, 0x12, 0x28, 0x0a, 0x0d, 0x65, 0x72, 0x72, 0x6f, 0x72, 0x5f, + 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x48, 0x00, 0x52, + 0x0c, 0x65, 0x72, 0x72, 0x6f, 0x72, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x88, 0x01, 0x01, + 0x42, 0x10, 0x0a, 0x0e, 0x5f, 0x65, 0x72, 0x72, 0x6f, 0x72, 0x5f, 0x6d, 0x65, 0x73, 0x73, 0x61, + 0x67, 0x65, 0x22, 0x65, 0x0a, 0x11, 0x47, 0x65, 0x74, 0x4f, 0x49, 0x44, 0x43, 0x55, 0x52, 0x4c, + 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x0e, 0x0a, 0x02, 0x69, 0x64, 0x18, 0x01, 0x20, + 0x01, 0x28, 0x09, 0x52, 0x02, 0x69, 0x64, 0x12, 0x1d, 0x0a, 0x0a, 0x61, 0x63, 0x63, 0x6f, 0x75, + 0x6e, 0x74, 0x5f, 0x69, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x61, 0x63, 0x63, + 0x6f, 0x75, 0x6e, 0x74, 0x49, 0x64, 0x12, 0x21, 0x0a, 0x0c, 0x72, 0x65, 0x64, 0x69, 0x72, 0x65, + 0x63, 0x74, 0x5f, 0x75, 0x72, 0x6c, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0b, 0x72, 0x65, + 0x64, 0x69, 0x72, 0x65, 0x63, 0x74, 0x55, 0x72, 0x6c, 0x22, 0x26, 0x0a, 0x12, 0x47, 0x65, 0x74, + 0x4f, 0x49, 0x44, 0x43, 0x55, 0x52, 0x4c, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, + 0x10, 0x0a, 0x03, 0x75, 0x72, 0x6c, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x75, 0x72, + 0x6c, 0x2a, 0x64, 0x0a, 0x16, 0x50, 0x72, 0x6f, 0x78, 0x79, 0x4d, 0x61, 0x70, 0x70, 0x69, 0x6e, + 0x67, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x54, 0x79, 0x70, 0x65, 0x12, 0x17, 0x0a, 0x13, 0x55, + 0x50, 0x44, 0x41, 0x54, 0x45, 0x5f, 0x54, 0x59, 0x50, 0x45, 0x5f, 0x43, 0x52, 0x45, 0x41, 0x54, + 0x45, 0x44, 0x10, 0x00, 0x12, 0x18, 0x0a, 0x14, 0x55, 0x50, 0x44, 0x41, 0x54, 0x45, 0x5f, 0x54, + 0x59, 0x50, 0x45, 0x5f, 0x4d, 0x4f, 0x44, 0x49, 0x46, 0x49, 0x45, 0x44, 0x10, 0x01, 0x12, 0x17, + 0x0a, 0x13, 0x55, 0x50, 0x44, 0x41, 0x54, 0x45, 0x5f, 0x54, 0x59, 0x50, 0x45, 0x5f, 0x52, 0x45, + 0x4d, 0x4f, 0x56, 0x45, 0x44, 0x10, 0x02, 0x2a, 0xc8, 0x01, 0x0a, 0x0b, 0x50, 0x72, 0x6f, 0x78, + 0x79, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x12, 0x18, 0x0a, 0x14, 0x50, 0x52, 0x4f, 0x58, 0x59, + 0x5f, 0x53, 0x54, 0x41, 0x54, 0x55, 0x53, 0x5f, 0x50, 0x45, 0x4e, 0x44, 0x49, 0x4e, 0x47, 0x10, + 0x00, 0x12, 0x17, 0x0a, 0x13, 0x50, 0x52, 0x4f, 0x58, 0x59, 0x5f, 0x53, 0x54, 0x41, 0x54, 0x55, + 0x53, 0x5f, 0x41, 0x43, 0x54, 0x49, 0x56, 0x45, 0x10, 0x01, 0x12, 0x23, 0x0a, 0x1f, 0x50, 0x52, + 0x4f, 0x58, 0x59, 0x5f, 0x53, 0x54, 0x41, 0x54, 0x55, 0x53, 0x5f, 0x54, 0x55, 0x4e, 0x4e, 0x45, + 0x4c, 0x5f, 0x4e, 0x4f, 0x54, 0x5f, 0x43, 0x52, 0x45, 0x41, 0x54, 0x45, 0x44, 0x10, 0x02, 0x12, + 0x24, 0x0a, 0x20, 0x50, 0x52, 0x4f, 0x58, 0x59, 0x5f, 0x53, 0x54, 0x41, 0x54, 0x55, 0x53, 0x5f, + 0x43, 0x45, 0x52, 0x54, 0x49, 0x46, 0x49, 0x43, 0x41, 0x54, 0x45, 0x5f, 0x50, 0x45, 0x4e, 0x44, + 0x49, 0x4e, 0x47, 0x10, 0x03, 0x12, 0x23, 0x0a, 0x1f, 0x50, 0x52, 0x4f, 0x58, 0x59, 0x5f, 0x53, + 0x54, 0x41, 0x54, 0x55, 0x53, 0x5f, 0x43, 0x45, 0x52, 0x54, 0x49, 0x46, 0x49, 0x43, 0x41, 0x54, + 0x45, 0x5f, 0x46, 0x41, 0x49, 0x4c, 0x45, 0x44, 0x10, 0x04, 0x12, 0x16, 0x0a, 0x12, 0x50, 0x52, + 0x4f, 0x58, 0x59, 0x5f, 0x53, 0x54, 0x41, 0x54, 0x55, 0x53, 0x5f, 0x45, 0x52, 0x52, 0x4f, 0x52, + 0x10, 0x05, 0x32, 0xa0, 0x04, 0x0a, 0x0c, 0x50, 0x72, 0x6f, 0x78, 0x79, 0x53, 0x65, 0x72, 0x76, + 0x69, 0x63, 0x65, 0x12, 0x5f, 0x0a, 0x10, 0x47, 0x65, 0x74, 0x4d, 0x61, 0x70, 0x70, 0x69, 0x6e, + 0x67, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x12, 0x23, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, + 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x47, 0x65, 0x74, 0x4d, 0x61, 0x70, 0x70, 0x69, 0x6e, 0x67, 0x55, + 0x70, 0x64, 0x61, 0x74, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x24, 0x2e, 0x6d, + 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x47, 0x65, 0x74, 0x4d, 0x61, 0x70, + 0x70, 0x69, 0x6e, 0x67, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, + 0x73, 0x65, 0x30, 0x01, 0x12, 0x54, 0x0a, 0x0d, 0x53, 0x65, 0x6e, 0x64, 0x41, 0x63, 0x63, 0x65, + 0x73, 0x73, 0x4c, 0x6f, 0x67, 0x12, 0x20, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, + 0x6e, 0x74, 0x2e, 0x53, 0x65, 0x6e, 0x64, 0x41, 0x63, 0x63, 0x65, 0x73, 0x73, 0x4c, 0x6f, 0x67, + 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x21, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x53, 0x65, 0x6e, 0x64, 0x41, 0x63, 0x63, 0x65, 0x73, 0x73, 0x4c, - 0x6f, 0x67, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x21, 0x2e, 0x6d, 0x61, 0x6e, 0x61, - 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x53, 0x65, 0x6e, 0x64, 0x41, 0x63, 0x63, 0x65, 0x73, - 0x73, 0x4c, 0x6f, 0x67, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x51, 0x0a, 0x0c, - 0x41, 0x75, 0x74, 0x68, 0x65, 0x6e, 0x74, 0x69, 0x63, 0x61, 0x74, 0x65, 0x12, 0x1f, 0x2e, 0x6d, - 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x41, 0x75, 0x74, 0x68, 0x65, 0x6e, - 0x74, 0x69, 0x63, 0x61, 0x74, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x20, 0x2e, - 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x41, 0x75, 0x74, 0x68, 0x65, - 0x6e, 0x74, 0x69, 0x63, 0x61, 0x74, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, - 0x5d, 0x0a, 0x10, 0x53, 0x65, 0x6e, 0x64, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x55, 0x70, 0x64, - 0x61, 0x74, 0x65, 0x12, 0x23, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, - 0x2e, 0x53, 0x65, 0x6e, 0x64, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x55, 0x70, 0x64, 0x61, 0x74, - 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x24, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, - 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x53, 0x65, 0x6e, 0x64, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, - 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x42, 0x08, - 0x5a, 0x06, 0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33, + 0x6f, 0x67, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x51, 0x0a, 0x0c, 0x41, 0x75, + 0x74, 0x68, 0x65, 0x6e, 0x74, 0x69, 0x63, 0x61, 0x74, 0x65, 0x12, 0x1f, 0x2e, 0x6d, 0x61, 0x6e, + 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x41, 0x75, 0x74, 0x68, 0x65, 0x6e, 0x74, 0x69, + 0x63, 0x61, 0x74, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x20, 0x2e, 0x6d, 0x61, + 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x41, 0x75, 0x74, 0x68, 0x65, 0x6e, 0x74, + 0x69, 0x63, 0x61, 0x74, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x5d, 0x0a, + 0x10, 0x53, 0x65, 0x6e, 0x64, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x55, 0x70, 0x64, 0x61, 0x74, + 0x65, 0x12, 0x23, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x53, + 0x65, 0x6e, 0x64, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x52, + 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x24, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, + 0x65, 0x6e, 0x74, 0x2e, 0x53, 0x65, 0x6e, 0x64, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x55, 0x70, + 0x64, 0x61, 0x74, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x5a, 0x0a, 0x0f, + 0x43, 0x72, 0x65, 0x61, 0x74, 0x65, 0x50, 0x72, 0x6f, 0x78, 0x79, 0x50, 0x65, 0x65, 0x72, 0x12, + 0x22, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x43, 0x72, 0x65, + 0x61, 0x74, 0x65, 0x50, 0x72, 0x6f, 0x78, 0x79, 0x50, 0x65, 0x65, 0x72, 0x52, 0x65, 0x71, 0x75, + 0x65, 0x73, 0x74, 0x1a, 0x23, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, + 0x2e, 0x43, 0x72, 0x65, 0x61, 0x74, 0x65, 0x50, 0x72, 0x6f, 0x78, 0x79, 0x50, 0x65, 0x65, 0x72, + 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x4b, 0x0a, 0x0a, 0x47, 0x65, 0x74, 0x4f, + 0x49, 0x44, 0x43, 0x55, 0x52, 0x4c, 0x12, 0x1d, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, + 0x65, 0x6e, 0x74, 0x2e, 0x47, 0x65, 0x74, 0x4f, 0x49, 0x44, 0x43, 0x55, 0x52, 0x4c, 0x52, 0x65, + 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x1e, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, + 0x6e, 0x74, 0x2e, 0x47, 0x65, 0x74, 0x4f, 0x49, 0x44, 0x43, 0x55, 0x52, 0x4c, 0x52, 0x65, 0x73, + 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x42, 0x08, 0x5a, 0x06, 0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, + 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33, } var ( @@ -1364,7 +1535,7 @@ func file_proxy_service_proto_rawDescGZIP() []byte { } var file_proxy_service_proto_enumTypes = make([]protoimpl.EnumInfo, 2) -var file_proxy_service_proto_msgTypes = make([]protoimpl.MessageInfo, 16) +var file_proxy_service_proto_msgTypes = make([]protoimpl.MessageInfo, 18) var file_proxy_service_proto_goTypes = []interface{}{ (ProxyMappingUpdateType)(0), // 0: management.ProxyMappingUpdateType (ProxyStatus)(0), // 1: management.ProxyStatus @@ -1372,46 +1543,50 @@ var file_proxy_service_proto_goTypes = []interface{}{ (*GetMappingUpdateResponse)(nil), // 3: management.GetMappingUpdateResponse (*PathMapping)(nil), // 4: management.PathMapping (*Authentication)(nil), // 5: management.Authentication - (*OIDC)(nil), // 6: management.OIDC - (*ProxyMapping)(nil), // 7: management.ProxyMapping - (*SendAccessLogRequest)(nil), // 8: management.SendAccessLogRequest - (*SendAccessLogResponse)(nil), // 9: management.SendAccessLogResponse - (*AccessLog)(nil), // 10: management.AccessLog - (*AuthenticateRequest)(nil), // 11: management.AuthenticateRequest - (*PasswordRequest)(nil), // 12: management.PasswordRequest - (*PinRequest)(nil), // 13: management.PinRequest - (*LinkRequest)(nil), // 14: management.LinkRequest - (*AuthenticateResponse)(nil), // 15: management.AuthenticateResponse - (*SendStatusUpdateRequest)(nil), // 16: management.SendStatusUpdateRequest - (*SendStatusUpdateResponse)(nil), // 17: management.SendStatusUpdateResponse - (*timestamppb.Timestamp)(nil), // 18: google.protobuf.Timestamp + (*ProxyMapping)(nil), // 6: management.ProxyMapping + (*SendAccessLogRequest)(nil), // 7: management.SendAccessLogRequest + (*SendAccessLogResponse)(nil), // 8: management.SendAccessLogResponse + (*AccessLog)(nil), // 9: management.AccessLog + (*AuthenticateRequest)(nil), // 10: management.AuthenticateRequest + (*PasswordRequest)(nil), // 11: management.PasswordRequest + (*PinRequest)(nil), // 12: management.PinRequest + (*AuthenticateResponse)(nil), // 13: management.AuthenticateResponse + (*SendStatusUpdateRequest)(nil), // 14: management.SendStatusUpdateRequest + (*SendStatusUpdateResponse)(nil), // 15: management.SendStatusUpdateResponse + (*CreateProxyPeerRequest)(nil), // 16: management.CreateProxyPeerRequest + (*CreateProxyPeerResponse)(nil), // 17: management.CreateProxyPeerResponse + (*GetOIDCURLRequest)(nil), // 18: management.GetOIDCURLRequest + (*GetOIDCURLResponse)(nil), // 19: management.GetOIDCURLResponse + (*timestamppb.Timestamp)(nil), // 20: google.protobuf.Timestamp } var file_proxy_service_proto_depIdxs = []int32{ - 18, // 0: management.GetMappingUpdateRequest.started_at:type_name -> google.protobuf.Timestamp - 7, // 1: management.GetMappingUpdateResponse.mapping:type_name -> management.ProxyMapping - 6, // 2: management.Authentication.oidc:type_name -> management.OIDC - 0, // 3: management.ProxyMapping.type:type_name -> management.ProxyMappingUpdateType - 4, // 4: management.ProxyMapping.path:type_name -> management.PathMapping - 5, // 5: management.ProxyMapping.auth:type_name -> management.Authentication - 10, // 6: management.SendAccessLogRequest.log:type_name -> management.AccessLog - 18, // 7: management.AccessLog.timestamp:type_name -> google.protobuf.Timestamp - 12, // 8: management.AuthenticateRequest.password:type_name -> management.PasswordRequest - 13, // 9: management.AuthenticateRequest.pin:type_name -> management.PinRequest - 14, // 10: management.AuthenticateRequest.link:type_name -> management.LinkRequest - 1, // 11: management.SendStatusUpdateRequest.status:type_name -> management.ProxyStatus - 2, // 12: management.ProxyService.GetMappingUpdate:input_type -> management.GetMappingUpdateRequest - 8, // 13: management.ProxyService.SendAccessLog:input_type -> management.SendAccessLogRequest - 11, // 14: management.ProxyService.Authenticate:input_type -> management.AuthenticateRequest - 16, // 15: management.ProxyService.SendStatusUpdate:input_type -> management.SendStatusUpdateRequest + 20, // 0: management.GetMappingUpdateRequest.started_at:type_name -> google.protobuf.Timestamp + 6, // 1: management.GetMappingUpdateResponse.mapping:type_name -> management.ProxyMapping + 0, // 2: management.ProxyMapping.type:type_name -> management.ProxyMappingUpdateType + 4, // 3: management.ProxyMapping.path:type_name -> management.PathMapping + 5, // 4: management.ProxyMapping.auth:type_name -> management.Authentication + 9, // 5: management.SendAccessLogRequest.log:type_name -> management.AccessLog + 20, // 6: management.AccessLog.timestamp:type_name -> google.protobuf.Timestamp + 11, // 7: management.AuthenticateRequest.password:type_name -> management.PasswordRequest + 12, // 8: management.AuthenticateRequest.pin:type_name -> management.PinRequest + 1, // 9: management.SendStatusUpdateRequest.status:type_name -> management.ProxyStatus + 2, // 10: management.ProxyService.GetMappingUpdate:input_type -> management.GetMappingUpdateRequest + 7, // 11: management.ProxyService.SendAccessLog:input_type -> management.SendAccessLogRequest + 10, // 12: management.ProxyService.Authenticate:input_type -> management.AuthenticateRequest + 14, // 13: management.ProxyService.SendStatusUpdate:input_type -> management.SendStatusUpdateRequest + 16, // 14: management.ProxyService.CreateProxyPeer:input_type -> management.CreateProxyPeerRequest + 18, // 15: management.ProxyService.GetOIDCURL:input_type -> management.GetOIDCURLRequest 3, // 16: management.ProxyService.GetMappingUpdate:output_type -> management.GetMappingUpdateResponse - 9, // 17: management.ProxyService.SendAccessLog:output_type -> management.SendAccessLogResponse - 15, // 18: management.ProxyService.Authenticate:output_type -> management.AuthenticateResponse - 17, // 19: management.ProxyService.SendStatusUpdate:output_type -> management.SendStatusUpdateResponse - 16, // [16:20] is the sub-list for method output_type - 12, // [12:16] is the sub-list for method input_type - 12, // [12:12] is the sub-list for extension type_name - 12, // [12:12] is the sub-list for extension extendee - 0, // [0:12] is the sub-list for field type_name + 8, // 17: management.ProxyService.SendAccessLog:output_type -> management.SendAccessLogResponse + 13, // 18: management.ProxyService.Authenticate:output_type -> management.AuthenticateResponse + 15, // 19: management.ProxyService.SendStatusUpdate:output_type -> management.SendStatusUpdateResponse + 17, // 20: management.ProxyService.CreateProxyPeer:output_type -> management.CreateProxyPeerResponse + 19, // 21: management.ProxyService.GetOIDCURL:output_type -> management.GetOIDCURLResponse + 16, // [16:22] is the sub-list for method output_type + 10, // [10:16] is the sub-list for method input_type + 10, // [10:10] is the sub-list for extension type_name + 10, // [10:10] is the sub-list for extension extendee + 0, // [0:10] is the sub-list for field type_name } func init() { file_proxy_service_proto_init() } @@ -1469,18 +1644,6 @@ func file_proxy_service_proto_init() { } } file_proxy_service_proto_msgTypes[4].Exporter = func(v interface{}, i int) interface{} { - switch v := v.(*OIDC); i { - case 0: - return &v.state - case 1: - return &v.sizeCache - case 2: - return &v.unknownFields - default: - return nil - } - } - file_proxy_service_proto_msgTypes[5].Exporter = func(v interface{}, i int) interface{} { switch v := v.(*ProxyMapping); i { case 0: return &v.state @@ -1492,7 +1655,7 @@ func file_proxy_service_proto_init() { return nil } } - file_proxy_service_proto_msgTypes[6].Exporter = func(v interface{}, i int) interface{} { + file_proxy_service_proto_msgTypes[5].Exporter = func(v interface{}, i int) interface{} { switch v := v.(*SendAccessLogRequest); i { case 0: return &v.state @@ -1504,7 +1667,7 @@ func file_proxy_service_proto_init() { return nil } } - file_proxy_service_proto_msgTypes[7].Exporter = func(v interface{}, i int) interface{} { + file_proxy_service_proto_msgTypes[6].Exporter = func(v interface{}, i int) interface{} { switch v := v.(*SendAccessLogResponse); i { case 0: return &v.state @@ -1516,7 +1679,7 @@ func file_proxy_service_proto_init() { return nil } } - file_proxy_service_proto_msgTypes[8].Exporter = func(v interface{}, i int) interface{} { + file_proxy_service_proto_msgTypes[7].Exporter = func(v interface{}, i int) interface{} { switch v := v.(*AccessLog); i { case 0: return &v.state @@ -1528,7 +1691,7 @@ func file_proxy_service_proto_init() { return nil } } - file_proxy_service_proto_msgTypes[9].Exporter = func(v interface{}, i int) interface{} { + file_proxy_service_proto_msgTypes[8].Exporter = func(v interface{}, i int) interface{} { switch v := v.(*AuthenticateRequest); i { case 0: return &v.state @@ -1540,7 +1703,7 @@ func file_proxy_service_proto_init() { return nil } } - file_proxy_service_proto_msgTypes[10].Exporter = func(v interface{}, i int) interface{} { + file_proxy_service_proto_msgTypes[9].Exporter = func(v interface{}, i int) interface{} { switch v := v.(*PasswordRequest); i { case 0: return &v.state @@ -1552,7 +1715,7 @@ func file_proxy_service_proto_init() { return nil } } - file_proxy_service_proto_msgTypes[11].Exporter = func(v interface{}, i int) interface{} { + file_proxy_service_proto_msgTypes[10].Exporter = func(v interface{}, i int) interface{} { switch v := v.(*PinRequest); i { case 0: return &v.state @@ -1564,19 +1727,7 @@ func file_proxy_service_proto_init() { return nil } } - file_proxy_service_proto_msgTypes[12].Exporter = func(v interface{}, i int) interface{} { - switch v := v.(*LinkRequest); i { - case 0: - return &v.state - case 1: - return &v.sizeCache - case 2: - return &v.unknownFields - default: - return nil - } - } - file_proxy_service_proto_msgTypes[13].Exporter = func(v interface{}, i int) interface{} { + file_proxy_service_proto_msgTypes[11].Exporter = func(v interface{}, i int) interface{} { switch v := v.(*AuthenticateResponse); i { case 0: return &v.state @@ -1588,7 +1739,7 @@ func file_proxy_service_proto_init() { return nil } } - file_proxy_service_proto_msgTypes[14].Exporter = func(v interface{}, i int) interface{} { + file_proxy_service_proto_msgTypes[12].Exporter = func(v interface{}, i int) interface{} { switch v := v.(*SendStatusUpdateRequest); i { case 0: return &v.state @@ -1600,7 +1751,7 @@ func file_proxy_service_proto_init() { return nil } } - file_proxy_service_proto_msgTypes[15].Exporter = func(v interface{}, i int) interface{} { + file_proxy_service_proto_msgTypes[13].Exporter = func(v interface{}, i int) interface{} { switch v := v.(*SendStatusUpdateResponse); i { case 0: return &v.state @@ -1612,21 +1763,68 @@ func file_proxy_service_proto_init() { return nil } } + file_proxy_service_proto_msgTypes[14].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*CreateProxyPeerRequest); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_proxy_service_proto_msgTypes[15].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*CreateProxyPeerResponse); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_proxy_service_proto_msgTypes[16].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*GetOIDCURLRequest); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_proxy_service_proto_msgTypes[17].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*GetOIDCURLResponse); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } } - file_proxy_service_proto_msgTypes[3].OneofWrappers = []interface{}{} - file_proxy_service_proto_msgTypes[9].OneofWrappers = []interface{}{ + file_proxy_service_proto_msgTypes[8].OneofWrappers = []interface{}{ (*AuthenticateRequest_Password)(nil), (*AuthenticateRequest_Pin)(nil), - (*AuthenticateRequest_Link)(nil), } - file_proxy_service_proto_msgTypes[14].OneofWrappers = []interface{}{} + file_proxy_service_proto_msgTypes[12].OneofWrappers = []interface{}{} + file_proxy_service_proto_msgTypes[15].OneofWrappers = []interface{}{} type x struct{} out := protoimpl.TypeBuilder{ File: protoimpl.DescBuilder{ GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: file_proxy_service_proto_rawDesc, NumEnums: 2, - NumMessages: 16, + NumMessages: 18, NumExtensions: 0, NumServices: 1, }, diff --git a/shared/management/proto/proxy_service.proto b/shared/management/proto/proxy_service.proto index 471198267..13cc015f5 100644 --- a/shared/management/proto/proxy_service.proto +++ b/shared/management/proto/proxy_service.proto @@ -16,6 +16,10 @@ service ProxyService { rpc Authenticate(AuthenticateRequest) returns (AuthenticateResponse); rpc SendStatusUpdate(SendStatusUpdateRequest) returns (SendStatusUpdateResponse); + + rpc CreateProxyPeer(CreateProxyPeerRequest) returns (CreateProxyPeerResponse); + + rpc GetOIDCURL(GetOIDCURLRequest) returns (GetOIDCURLResponse); } // GetMappingUpdateRequest is sent to initialise a mapping stream. @@ -45,14 +49,11 @@ message PathMapping { } message Authentication { - bool password = 1; - bool pin = 2; - optional OIDC oidc = 3; - bool link = 4; -} - -message OIDC { - repeated string distribution_groups = 1; + string session_key = 1; + int64 max_session_age_seconds = 2; + bool password = 3; + bool pin = 4; + bool oidc = 5; } message ProxyMapping { @@ -61,7 +62,7 @@ message ProxyMapping { string account_id = 3; string domain = 4; repeated PathMapping path = 5; - string setup_key = 6; + string auth_token = 6; Authentication auth = 7; } @@ -95,7 +96,6 @@ message AuthenticateRequest { oneof request { PasswordRequest password = 3; PinRequest pin = 4; - LinkRequest link = 5; } } @@ -107,13 +107,9 @@ message PinRequest { string pin = 1; } -message LinkRequest { - string email = 1; - string redirect = 2; -} - message AuthenticateResponse { bool success = 1; + string session_token = 2; } enum ProxyStatus { @@ -136,3 +132,28 @@ message SendStatusUpdateRequest { // SendStatusUpdateResponse is intentionally empty to allow for future expansion message SendStatusUpdateResponse {} + +// CreateProxyPeerRequest is sent by the proxy to create a peer connection +// The token is a one-time authentication token sent via ProxyMapping +message CreateProxyPeerRequest { + string reverse_proxy_id = 1; + string account_id = 2; + string token = 3; + string wireguard_public_key = 4; +} + +// CreateProxyPeerResponse contains the result of peer creation +message CreateProxyPeerResponse { + bool success = 1; + optional string error_message = 2; +} + +message GetOIDCURLRequest { + string id = 1; + string account_id = 2; + string redirect_url = 3; +} + +message GetOIDCURLResponse { + string url = 1; +} diff --git a/shared/management/proto/proxy_service_grpc.pb.go b/shared/management/proto/proxy_service_grpc.pb.go index f8e501ae2..9abeaf219 100644 --- a/shared/management/proto/proxy_service_grpc.pb.go +++ b/shared/management/proto/proxy_service_grpc.pb.go @@ -22,6 +22,8 @@ type ProxyServiceClient interface { SendAccessLog(ctx context.Context, in *SendAccessLogRequest, opts ...grpc.CallOption) (*SendAccessLogResponse, error) Authenticate(ctx context.Context, in *AuthenticateRequest, opts ...grpc.CallOption) (*AuthenticateResponse, error) SendStatusUpdate(ctx context.Context, in *SendStatusUpdateRequest, opts ...grpc.CallOption) (*SendStatusUpdateResponse, error) + CreateProxyPeer(ctx context.Context, in *CreateProxyPeerRequest, opts ...grpc.CallOption) (*CreateProxyPeerResponse, error) + GetOIDCURL(ctx context.Context, in *GetOIDCURLRequest, opts ...grpc.CallOption) (*GetOIDCURLResponse, error) } type proxyServiceClient struct { @@ -91,6 +93,24 @@ func (c *proxyServiceClient) SendStatusUpdate(ctx context.Context, in *SendStatu return out, nil } +func (c *proxyServiceClient) CreateProxyPeer(ctx context.Context, in *CreateProxyPeerRequest, opts ...grpc.CallOption) (*CreateProxyPeerResponse, error) { + out := new(CreateProxyPeerResponse) + err := c.cc.Invoke(ctx, "/management.ProxyService/CreateProxyPeer", in, out, opts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *proxyServiceClient) GetOIDCURL(ctx context.Context, in *GetOIDCURLRequest, opts ...grpc.CallOption) (*GetOIDCURLResponse, error) { + out := new(GetOIDCURLResponse) + err := c.cc.Invoke(ctx, "/management.ProxyService/GetOIDCURL", in, out, opts...) + if err != nil { + return nil, err + } + return out, nil +} + // ProxyServiceServer is the server API for ProxyService service. // All implementations must embed UnimplementedProxyServiceServer // for forward compatibility @@ -99,6 +119,8 @@ type ProxyServiceServer interface { SendAccessLog(context.Context, *SendAccessLogRequest) (*SendAccessLogResponse, error) Authenticate(context.Context, *AuthenticateRequest) (*AuthenticateResponse, error) SendStatusUpdate(context.Context, *SendStatusUpdateRequest) (*SendStatusUpdateResponse, error) + CreateProxyPeer(context.Context, *CreateProxyPeerRequest) (*CreateProxyPeerResponse, error) + GetOIDCURL(context.Context, *GetOIDCURLRequest) (*GetOIDCURLResponse, error) mustEmbedUnimplementedProxyServiceServer() } @@ -118,6 +140,12 @@ func (UnimplementedProxyServiceServer) Authenticate(context.Context, *Authentica func (UnimplementedProxyServiceServer) SendStatusUpdate(context.Context, *SendStatusUpdateRequest) (*SendStatusUpdateResponse, error) { return nil, status.Errorf(codes.Unimplemented, "method SendStatusUpdate not implemented") } +func (UnimplementedProxyServiceServer) CreateProxyPeer(context.Context, *CreateProxyPeerRequest) (*CreateProxyPeerResponse, error) { + return nil, status.Errorf(codes.Unimplemented, "method CreateProxyPeer not implemented") +} +func (UnimplementedProxyServiceServer) GetOIDCURL(context.Context, *GetOIDCURLRequest) (*GetOIDCURLResponse, error) { + return nil, status.Errorf(codes.Unimplemented, "method GetOIDCURL not implemented") +} func (UnimplementedProxyServiceServer) mustEmbedUnimplementedProxyServiceServer() {} // UnsafeProxyServiceServer may be embedded to opt out of forward compatibility for this service. @@ -206,6 +234,42 @@ func _ProxyService_SendStatusUpdate_Handler(srv interface{}, ctx context.Context return interceptor(ctx, in, info, handler) } +func _ProxyService_CreateProxyPeer_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(CreateProxyPeerRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(ProxyServiceServer).CreateProxyPeer(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: "/management.ProxyService/CreateProxyPeer", + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(ProxyServiceServer).CreateProxyPeer(ctx, req.(*CreateProxyPeerRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _ProxyService_GetOIDCURL_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(GetOIDCURLRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(ProxyServiceServer).GetOIDCURL(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: "/management.ProxyService/GetOIDCURL", + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(ProxyServiceServer).GetOIDCURL(ctx, req.(*GetOIDCURLRequest)) + } + return interceptor(ctx, in, info, handler) +} + // ProxyService_ServiceDesc is the grpc.ServiceDesc for ProxyService service. // It's only intended for direct use with grpc.RegisterService, // and not to be introspected or modified (even as a copy) @@ -225,6 +289,14 @@ var ProxyService_ServiceDesc = grpc.ServiceDesc{ MethodName: "SendStatusUpdate", Handler: _ProxyService_SendStatusUpdate_Handler, }, + { + MethodName: "CreateProxyPeer", + Handler: _ProxyService_CreateProxyPeer_Handler, + }, + { + MethodName: "GetOIDCURL", + Handler: _ProxyService_GetOIDCURL_Handler, + }, }, Streams: []grpc.StreamDesc{ {