diff --git a/management/internals/modules/services/interface.go b/management/internals/modules/services/interface.go new file mode 100644 index 000000000..ee1ab539b --- /dev/null +++ b/management/internals/modules/services/interface.go @@ -0,0 +1,13 @@ +package services + +import ( + "context" +) + +type Manager interface { + GetAllServices(ctx context.Context, accountID, userID string) ([]*Service, error) + GetService(ctx context.Context, accountID, userID, serviceID string) (*Service, error) + CreateService(ctx context.Context, accountID, userID string, service *Service) (*Service, error) + UpdateService(ctx context.Context, accountID, userID string, service *Service) (*Service, error) + DeleteService(ctx context.Context, accountID, userID, serviceID string) error +} diff --git a/management/internals/modules/services/manager/api.go b/management/internals/modules/services/manager/api.go new file mode 100644 index 000000000..d49935c76 --- /dev/null +++ b/management/internals/modules/services/manager/api.go @@ -0,0 +1,161 @@ +package manager + +import ( + "encoding/json" + "net/http" + + "github.com/gorilla/mux" + + "github.com/netbirdio/netbird/management/internals/modules/services" + nbcontext "github.com/netbirdio/netbird/management/server/context" + "github.com/netbirdio/netbird/shared/management/http/api" + "github.com/netbirdio/netbird/shared/management/http/util" + "github.com/netbirdio/netbird/shared/management/status" +) + +type handler struct { + manager services.Manager +} + +func RegisterEndpoints(router *mux.Router, manager services.Manager) { + h := &handler{ + manager: manager, + } + + router.HandleFunc("/services", h.getAllServices).Methods("GET", "OPTIONS") + router.HandleFunc("/services", h.createService).Methods("POST", "OPTIONS") + router.HandleFunc("/services/{serviceId}", h.getService).Methods("GET", "OPTIONS") + router.HandleFunc("/services/{serviceId}", h.updateService).Methods("PUT", "OPTIONS") + router.HandleFunc("/services/{serviceId}", h.deleteService).Methods("DELETE", "OPTIONS") +} + +func (h *handler) getAllServices(w http.ResponseWriter, r *http.Request) { + userAuth, err := nbcontext.GetUserAuthFromContext(r.Context()) + if err != nil { + util.WriteError(r.Context(), err, w) + return + } + + allServices, err := h.manager.GetAllServices(r.Context(), userAuth.AccountId, userAuth.UserId) + if err != nil { + util.WriteError(r.Context(), err, w) + return + } + + apiServices := make([]*api.Service, 0, len(allServices)) + for _, service := range allServices { + apiServices = append(apiServices, service.ToAPIResponse()) + } + + util.WriteJSONObject(r.Context(), w, apiServices) +} + +func (h *handler) createService(w http.ResponseWriter, r *http.Request) { + userAuth, err := nbcontext.GetUserAuthFromContext(r.Context()) + if err != nil { + util.WriteError(r.Context(), err, w) + return + } + + var req api.PostApiServicesJSONRequestBody + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + util.WriteErrorResponse("couldn't parse JSON request", http.StatusBadRequest, w) + return + } + + service := new(services.Service) + service.FromAPIRequest(&req) + + if err = service.Validate(); err != nil { + util.WriteError(r.Context(), status.Errorf(status.InvalidArgument, "%s", err.Error()), w) + return + } + + createdService, err := h.manager.CreateService(r.Context(), userAuth.AccountId, userAuth.UserId, service) + if err != nil { + util.WriteError(r.Context(), err, w) + return + } + + util.WriteJSONObject(r.Context(), w, createdService.ToAPIResponse()) +} + +func (h *handler) getService(w http.ResponseWriter, r *http.Request) { + userAuth, err := nbcontext.GetUserAuthFromContext(r.Context()) + if err != nil { + util.WriteError(r.Context(), err, w) + return + } + + serviceID := mux.Vars(r)["serviceId"] + if serviceID == "" { + util.WriteError(r.Context(), status.Errorf(status.InvalidArgument, "service ID is required"), w) + return + } + + service, err := h.manager.GetService(r.Context(), userAuth.AccountId, userAuth.UserId, serviceID) + if err != nil { + util.WriteError(r.Context(), err, w) + return + } + + util.WriteJSONObject(r.Context(), w, service.ToAPIResponse()) +} + +func (h *handler) updateService(w http.ResponseWriter, r *http.Request) { + userAuth, err := nbcontext.GetUserAuthFromContext(r.Context()) + if err != nil { + util.WriteError(r.Context(), err, w) + return + } + + serviceID := mux.Vars(r)["serviceId"] + if serviceID == "" { + util.WriteError(r.Context(), status.Errorf(status.InvalidArgument, "service ID is required"), w) + return + } + + var req api.PutApiServicesServiceIdJSONRequestBody + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + util.WriteErrorResponse("couldn't parse JSON request", http.StatusBadRequest, w) + return + } + + service := new(services.Service) + service.ID = serviceID + service.FromAPIRequest(&req) + + if err = service.Validate(); err != nil { + util.WriteError(r.Context(), status.Errorf(status.InvalidArgument, "%s", err.Error()), w) + return + } + + updatedService, err := h.manager.UpdateService(r.Context(), userAuth.AccountId, userAuth.UserId, service) + if err != nil { + util.WriteError(r.Context(), err, w) + return + } + + util.WriteJSONObject(r.Context(), w, updatedService.ToAPIResponse()) +} + +func (h *handler) deleteService(w http.ResponseWriter, r *http.Request) { + userAuth, err := nbcontext.GetUserAuthFromContext(r.Context()) + if err != nil { + util.WriteError(r.Context(), err, w) + return + } + + serviceID := mux.Vars(r)["serviceId"] + if serviceID == "" { + util.WriteError(r.Context(), status.Errorf(status.InvalidArgument, "service ID is required"), w) + return + } + + if err := h.manager.DeleteService(r.Context(), userAuth.AccountId, userAuth.UserId, serviceID); err != nil { + util.WriteError(r.Context(), err, w) + return + } + + util.WriteJSONObject(r.Context(), w, util.EmptyObject{}) +} diff --git a/management/internals/modules/services/manager/manager.go b/management/internals/modules/services/manager/manager.go new file mode 100644 index 000000000..7ae380997 --- /dev/null +++ b/management/internals/modules/services/manager/manager.go @@ -0,0 +1,199 @@ +package manager + +import ( + "context" + "fmt" + + "github.com/netbirdio/netbird/management/internals/modules/services" + "github.com/netbirdio/netbird/management/server/account" + "github.com/netbirdio/netbird/management/server/activity" + "github.com/netbirdio/netbird/management/server/permissions" + "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/shared/management/status" +) + +type managerImpl struct { + store store.Store + accountManager account.Manager + permissionsManager permissions.Manager +} + +func NewManager(store store.Store, accountManager account.Manager, permissionsManager permissions.Manager) services.Manager { + return &managerImpl{ + store: store, + accountManager: accountManager, + permissionsManager: permissionsManager, + } +} + +func (m *managerImpl) GetAllServices(ctx context.Context, accountID, userID string) ([]*services.Service, error) { + ok, err := m.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Services, operations.Read) + if err != nil { + return nil, status.NewPermissionValidationError(err) + } + if !ok { + return nil, status.NewPermissionDeniedError() + } + + return m.store.GetAccountServices(ctx, store.LockingStrengthNone, accountID) +} + +func (m *managerImpl) GetService(ctx context.Context, accountID, userID, serviceID string) (*services.Service, error) { + ok, err := m.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Services, operations.Read) + if err != nil { + return nil, status.NewPermissionValidationError(err) + } + if !ok { + return nil, status.NewPermissionDeniedError() + } + + return m.store.GetServiceByID(ctx, store.LockingStrengthNone, accountID, serviceID) +} + +func (m *managerImpl) CreateService(ctx context.Context, accountID, userID string, service *services.Service) (*services.Service, error) { + ok, err := m.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Services, operations.Create) + if err != nil { + return nil, status.NewPermissionValidationError(err) + } + if !ok { + return nil, status.NewPermissionDeniedError() + } + + // Store auth config before creating new service + authType := service.AuthType + authBasicUsername := service.AuthBasicUsername + authBasicPassword := service.AuthBasicPassword + authPINValue := service.AuthPINValue + authPINHeader := service.AuthPINHeader + authBearerEnabled := service.AuthBearerEnabled + + service = services.NewService(accountID, service.Name, service.Description, service.Domain, service.Targets, service.DistributionGroups, service.Enabled, service.Exposed) + + // Restore auth config + service.AuthType = authType + service.AuthBasicUsername = authBasicUsername + service.AuthBasicPassword = authBasicPassword + service.AuthPINValue = authPINValue + service.AuthPINHeader = authPINHeader + service.AuthBearerEnabled = authBearerEnabled + + err = m.store.ExecuteInTransaction(ctx, func(transaction store.Store) error { + // Check for duplicate domain + existingService, err := transaction.GetServiceByDomain(ctx, accountID, service.Domain) + if err != nil { + if sErr, ok := status.FromError(err); !ok || sErr.Type() != status.NotFound { + return fmt.Errorf("failed to check existing service: %w", err) + } + } + if existingService != nil { + return status.Errorf(status.AlreadyExists, "service with domain %s already exists", service.Domain) + } + + // Validate distribution groups exist + for _, groupID := range service.DistributionGroups { + _, err = transaction.GetGroupByID(ctx, store.LockingStrengthNone, accountID, groupID) + if err != nil { + return status.Errorf(status.InvalidArgument, "%s", err.Error()) + } + } + + if err = transaction.CreateService(ctx, service); err != nil { + return fmt.Errorf("failed to create service: %w", err) + } + + return nil + }) + if err != nil { + return nil, err + } + + m.accountManager.StoreEvent(ctx, userID, service.ID, accountID, activity.ServiceCreated, service.EventMeta()) + + return service, nil +} + +func (m *managerImpl) UpdateService(ctx context.Context, accountID, userID string, service *services.Service) (*services.Service, error) { + ok, err := m.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Services, operations.Update) + if err != nil { + return nil, status.NewPermissionValidationError(err) + } + if !ok { + return nil, status.NewPermissionDeniedError() + } + + err = m.store.ExecuteInTransaction(ctx, func(transaction store.Store) error { + // Get existing service + existingService, err := transaction.GetServiceByID(ctx, store.LockingStrengthUpdate, accountID, service.ID) + if err != nil { + return err + } + + // Check if domain changed and if it conflicts + if existingService.Domain != service.Domain { + conflictService, err := transaction.GetServiceByDomain(ctx, accountID, service.Domain) + if err != nil { + if sErr, ok := status.FromError(err); !ok || sErr.Type() != status.NotFound { + return fmt.Errorf("failed to check existing service: %w", err) + } + } + if conflictService != nil && conflictService.ID != service.ID { + return status.Errorf(status.AlreadyExists, "service with domain %s already exists", service.Domain) + } + } + + // Validate distribution groups exist + for _, groupID := range service.DistributionGroups { + _, err = transaction.GetGroupByID(ctx, store.LockingStrengthNone, accountID, groupID) + if err != nil { + return status.Errorf(status.InvalidArgument, "%s", err.Error()) + } + } + + if err = transaction.UpdateService(ctx, service); err != nil { + return fmt.Errorf("failed to update service: %w", err) + } + + return nil + }) + if err != nil { + return nil, err + } + + m.accountManager.StoreEvent(ctx, userID, service.ID, accountID, activity.ServiceUpdated, service.EventMeta()) + + return service, nil +} + +func (m *managerImpl) DeleteService(ctx context.Context, accountID, userID, serviceID string) error { + ok, err := m.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Services, operations.Delete) + if err != nil { + return status.NewPermissionValidationError(err) + } + if !ok { + return status.NewPermissionDeniedError() + } + + var service *services.Service + err = m.store.ExecuteInTransaction(ctx, func(transaction store.Store) error { + var err error + service, err = transaction.GetServiceByID(ctx, store.LockingStrengthUpdate, accountID, serviceID) + if err != nil { + return err + } + + if err = transaction.DeleteService(ctx, accountID, serviceID); err != nil { + return fmt.Errorf("failed to delete service: %w", err) + } + + return nil + }) + if err != nil { + return err + } + + m.accountManager.StoreEvent(ctx, userID, serviceID, accountID, activity.ServiceDeleted, service.EventMeta()) + + return nil +} diff --git a/management/internals/modules/services/service.go b/management/internals/modules/services/service.go new file mode 100644 index 000000000..cc685f7a4 --- /dev/null +++ b/management/internals/modules/services/service.go @@ -0,0 +1,184 @@ +package services + +import ( + "errors" + + "github.com/rs/xid" + + "github.com/netbirdio/netbird/shared/management/http/api" +) + +type Target struct { + Path string `json:"path"` + Host string `json:"host"` + Enabled bool `json:"enabled"` +} + +type Service struct { + ID string `gorm:"primaryKey"` + AccountID string `gorm:"index"` + Name string + Description string + Domain string `gorm:"index"` + Targets []Target `gorm:"serializer:json"` + DistributionGroups []string `gorm:"serializer:json"` + Enabled bool + Exposed bool + + // Authentication configuration + AuthType string + AuthBasicUsername string + AuthBasicPassword string + AuthPINValue string + AuthPINHeader string + AuthBearerEnabled bool +} + +func NewService(accountID, name, description, domain string, targets []Target, distributionGroups []string, enabled, exposed bool) *Service { + return &Service{ + ID: xid.New().String(), + AccountID: accountID, + Name: name, + Description: description, + Domain: domain, + Targets: targets, + DistributionGroups: distributionGroups, + Enabled: enabled, + Exposed: exposed, + } +} + +func (s *Service) ToAPIResponse() *api.Service { + var authConfig *api.ServiceAuthConfig + + switch s.AuthType { + case "basic": + authConfig = &api.ServiceAuthConfig{ + Type: "basic", + BasicAuth: &api.BasicAuthConfig{ + Username: s.AuthBasicUsername, + Password: s.AuthBasicPassword, + }, + } + case "pin": + authConfig = &api.ServiceAuthConfig{ + Type: "pin", + PinAuth: &api.PINAuthConfig{ + Pin: s.AuthPINValue, + Header: s.AuthPINHeader, + }, + } + case "bearer": + authConfig = &api.ServiceAuthConfig{ + Type: "bearer", + BearerAuth: &api.BearerAuthConfig{ + Enabled: s.AuthBearerEnabled, + }, + } + } + + // Convert internal targets to API targets + apiTargets := make([]api.ServiceTarget, 0, len(s.Targets)) + for _, target := range s.Targets { + apiTargets = append(apiTargets, api.ServiceTarget{ + Path: target.Path, + Host: target.Host, + Enabled: target.Enabled, + }) + } + + return &api.Service{ + Id: s.ID, + Name: s.Name, + Description: &s.Description, + Domain: s.Domain, + Targets: apiTargets, + DistributionGroups: s.DistributionGroups, + Enabled: s.Enabled, + Exposed: s.Exposed, + Auth: authConfig, + } +} + +func (s *Service) FromAPIRequest(req *api.ServiceRequest) { + s.Name = req.Name + s.Domain = req.Domain + + // Convert API targets to internal targets + targets := make([]Target, 0, len(req.Targets)) + for _, apiTarget := range req.Targets { + targets = append(targets, Target{ + Path: apiTarget.Path, + Host: apiTarget.Host, + Enabled: apiTarget.Enabled, + }) + } + s.Targets = targets + + s.DistributionGroups = req.DistributionGroups + + if req.Description != nil { + s.Description = *req.Description + } + + enabled := true + if req.Enabled != nil { + enabled = *req.Enabled + } + s.Enabled = enabled + + exposed := false + if req.Exposed != nil { + exposed = *req.Exposed + } + s.Exposed = exposed + + // Handle auth config + if req.Auth != nil { + s.AuthType = string(req.Auth.Type) + + switch req.Auth.Type { + case "basic": + if req.Auth.BasicAuth != nil { + s.AuthBasicUsername = req.Auth.BasicAuth.Username + s.AuthBasicPassword = req.Auth.BasicAuth.Password + } + case "pin": + if req.Auth.PinAuth != nil { + s.AuthPINValue = req.Auth.PinAuth.Pin + s.AuthPINHeader = req.Auth.PinAuth.Header + } + case "bearer": + if req.Auth.BearerAuth != nil { + s.AuthBearerEnabled = req.Auth.BearerAuth.Enabled + } + } + } +} + +func (s *Service) Validate() error { + if s.Name == "" { + return errors.New("service name is required") + } + if len(s.Name) > 255 { + return errors.New("service name exceeds maximum length of 255 characters") + } + + if s.Domain == "" { + return errors.New("service domain is required") + } + + if len(s.Targets) == 0 { + return errors.New("at least one target is required") + } + + if len(s.DistributionGroups) == 0 { + return errors.New("at least one distribution group is required") + } + + return nil +} + +func (s *Service) EventMeta() map[string]any { + return map[string]any{"name": s.Name, "domain": s.Domain} +} diff --git a/management/internals/server/boot.go b/management/internals/server/boot.go index d1220e387..9f89512ff 100644 --- a/management/internals/server/boot.go +++ b/management/internals/server/boot.go @@ -92,7 +92,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()) + 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.ServiceManager()) if err != nil { log.Fatalf("failed to create API handler: %v", err) } diff --git a/management/internals/server/modules.go b/management/internals/server/modules.go index 9649caead..adcfb1af0 100644 --- a/management/internals/server/modules.go +++ b/management/internals/server/modules.go @@ -8,6 +8,8 @@ import ( "github.com/netbirdio/management-integrations/integrations" "github.com/netbirdio/netbird/management/internals/modules/peers" + "github.com/netbirdio/netbird/management/internals/modules/services" + nbservices "github.com/netbirdio/netbird/management/internals/modules/services/manager" "github.com/netbirdio/netbird/management/internals/modules/zones" zonesManager "github.com/netbirdio/netbird/management/internals/modules/zones/manager" "github.com/netbirdio/netbird/management/internals/modules/zones/records" @@ -174,3 +176,9 @@ func (s *BaseServer) RecordsManager() records.Manager { return recordsManager.NewManager(s.Store(), s.AccountManager(), s.PermissionsManager()) }) } + +func (s *BaseServer) ServiceManager() services.Manager { + return Create(s, func() services.Manager { + return nbservices.NewManager(s.Store(), s.AccountManager(), s.PermissionsManager()) + }) +} diff --git a/management/server/activity/codes.go b/management/server/activity/codes.go index 7593e1230..6254c02db 100644 --- a/management/server/activity/codes.go +++ b/management/server/activity/codes.go @@ -195,6 +195,10 @@ const ( DNSRecordUpdated Activity = 100 DNSRecordDeleted Activity = 101 + ServiceCreated Activity = 102 + ServiceUpdated Activity = 103 + ServiceDeleted Activity = 104 + AccountDeleted Activity = 99999 ) @@ -319,6 +323,10 @@ var activityMap = map[Activity]Code{ DNSRecordCreated: {"DNS zone record created", "dns.zone.record.create"}, DNSRecordUpdated: {"DNS zone record updated", "dns.zone.record.update"}, DNSRecordDeleted: {"DNS zone record deleted", "dns.zone.record.delete"}, + + ServiceCreated: {"Service created", "service.create"}, + ServiceUpdated: {"Service updated", "service.update"}, + ServiceDeleted: {"Service deleted", "service.delete"}, } // StringCode returns a string code of the activity diff --git a/management/server/http/handler.go b/management/server/http/handler.go index 64f914afe..ceb3d931c 100644 --- a/management/server/http/handler.go +++ b/management/server/http/handler.go @@ -9,10 +9,13 @@ import ( "time" "github.com/gorilla/mux" - idpmanager "github.com/netbirdio/netbird/management/server/idp" "github.com/rs/cors" log "github.com/sirupsen/logrus" + nbservices "github.com/netbirdio/netbird/management/internals/modules/services" + services "github.com/netbirdio/netbird/management/internals/modules/services/manager" + idpmanager "github.com/netbirdio/netbird/management/server/idp" + "github.com/netbirdio/management-integrations/integrations" "github.com/netbirdio/netbird/management/internals/controllers/network_map" "github.com/netbirdio/netbird/management/internals/modules/zones" @@ -59,7 +62,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) (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, serviceManager nbservices.Manager) (http.Handler, error) { // Register bypass paths for unauthenticated endpoints if err := bypass.AddBypassPath("/api/instance"); err != nil { @@ -145,6 +148,7 @@ func NewAPIHandler(ctx context.Context, accountManager account.Manager, networks recordsManager.RegisterEndpoints(router, rManager) idp.AddEndpoints(accountManager, router) instance.AddEndpoints(instanceManager, router) + services.RegisterEndpoints(router, serviceManager) // Mount embedded IdP handler at /oauth2 path if configured if embeddedIdpEnabled { diff --git a/management/server/permissions/modules/module.go b/management/server/permissions/modules/module.go index 0ae10d521..67e491087 100644 --- a/management/server/permissions/modules/module.go +++ b/management/server/permissions/modules/module.go @@ -17,6 +17,7 @@ const ( SetupKeys Module = "setup_keys" Pats Module = "pats" IdentityProviders Module = "identity_providers" + Services Module = "services" ) var All = map[Module]struct{}{ diff --git a/management/server/store/sql_store.go b/management/server/store/sql_store.go index 7d71030eb..64f8f4711 100644 --- a/management/server/store/sql_store.go +++ b/management/server/store/sql_store.go @@ -27,6 +27,7 @@ import ( "gorm.io/gorm/logger" nbdns "github.com/netbirdio/netbird/dns" + "github.com/netbirdio/netbird/management/internals/modules/services" "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" @@ -125,7 +126,7 @@ func NewSqlStore(ctx context.Context, db *gorm.DB, storeEngine types.Engine, met &types.Account{}, &types.Policy{}, &types.PolicyRule{}, &route.Route{}, &nbdns.NameServerGroup{}, &installation{}, &types.ExtraSettings{}, &posture.Checks{}, &nbpeer.NetworkAddress{}, &networkTypes.Network{}, &routerTypes.NetworkRouter{}, &resourceTypes.NetworkResource{}, &types.AccountOnboarding{}, - &zones.Zone{}, &records.Record{}, + &zones.Zone{}, &records.Record{}, &services.Service{}, ) if err != nil { return nil, fmt.Errorf("auto migratePreAuto: %w", err) @@ -4363,3 +4364,88 @@ func (s *SqlStore) DeleteZoneDNSRecords(ctx context.Context, accountID, zoneID s return nil } + +func (s *SqlStore) CreateService(ctx context.Context, service *services.Service) error { + result := s.db.Create(service) + if result.Error != nil { + log.WithContext(ctx).Errorf("failed to create service to store: %v", result.Error) + return status.Errorf(status.Internal, "failed to create service to store") + } + + return nil +} + +func (s *SqlStore) UpdateService(ctx context.Context, service *services.Service) error { + result := s.db.Select("*").Save(service) + if result.Error != nil { + log.WithContext(ctx).Errorf("failed to update service to store: %v", result.Error) + return status.Errorf(status.Internal, "failed to update service to store") + } + + return nil +} + +func (s *SqlStore) DeleteService(ctx context.Context, accountID, serviceID string) error { + result := s.db.Delete(&services.Service{}, accountAndIDQueryCondition, accountID, serviceID) + if result.Error != nil { + log.WithContext(ctx).Errorf("failed to delete service from store: %v", result.Error) + return status.Errorf(status.Internal, "failed to delete service from store") + } + + if result.RowsAffected == 0 { + return status.Errorf(status.NotFound, "service %s not found", serviceID) + } + + return nil +} + +func (s *SqlStore) GetServiceByID(ctx context.Context, lockStrength LockingStrength, accountID, serviceID string) (*services.Service, error) { + tx := s.db + if lockStrength != LockingStrengthNone { + tx = tx.Clauses(clause.Locking{Strength: string(lockStrength)}) + } + + var service *services.Service + result := tx.Take(&service, accountAndIDQueryCondition, accountID, serviceID) + if result.Error != nil { + if errors.Is(result.Error, gorm.ErrRecordNotFound) { + return nil, status.Errorf(status.NotFound, "service %s not found", serviceID) + } + + log.WithContext(ctx).Errorf("failed to get service from store: %v", result.Error) + return nil, status.Errorf(status.Internal, "failed to get service from store") + } + + return service, nil +} + +func (s *SqlStore) GetServiceByDomain(ctx context.Context, accountID, domain string) (*services.Service, error) { + var service *services.Service + result := s.db.Where("account_id = ? AND domain = ?", accountID, domain).First(&service) + if result.Error != nil { + if errors.Is(result.Error, gorm.ErrRecordNotFound) { + return nil, status.Errorf(status.NotFound, "service with domain %s not found", domain) + } + + log.WithContext(ctx).Errorf("failed to get service by domain from store: %v", result.Error) + return nil, status.Errorf(status.Internal, "failed to get service by domain from store") + } + + return service, nil +} + +func (s *SqlStore) GetAccountServices(ctx context.Context, lockStrength LockingStrength, accountID string) ([]*services.Service, error) { + tx := s.db + if lockStrength != LockingStrengthNone { + tx = tx.Clauses(clause.Locking{Strength: string(lockStrength)}) + } + + var servicesList []*services.Service + result := tx.Find(&servicesList, accountIDCondition, accountID) + if result.Error != nil { + log.WithContext(ctx).Errorf("failed to get services from the store: %s", result.Error) + return nil, status.Errorf(status.Internal, "failed to get services from store") + } + + return servicesList, nil +} diff --git a/management/server/store/store.go b/management/server/store/store.go index 3838b235e..33d529740 100644 --- a/management/server/store/store.go +++ b/management/server/store/store.go @@ -23,6 +23,7 @@ import ( "gorm.io/gorm" "github.com/netbirdio/netbird/dns" + "github.com/netbirdio/netbird/management/internals/modules/services" "github.com/netbirdio/netbird/management/internals/modules/zones" "github.com/netbirdio/netbird/management/internals/modules/zones/records" "github.com/netbirdio/netbird/management/server/telemetry" @@ -226,6 +227,13 @@ type Store interface { GetZoneDNSRecords(ctx context.Context, lockStrength LockingStrength, accountID, zoneID string) ([]*records.Record, error) GetZoneDNSRecordsByName(ctx context.Context, lockStrength LockingStrength, accountID, zoneID, name string) ([]*records.Record, error) DeleteZoneDNSRecords(ctx context.Context, accountID, zoneID string) error + + CreateService(ctx context.Context, service *services.Service) error + UpdateService(ctx context.Context, service *services.Service) error + DeleteService(ctx context.Context, accountID, serviceID string) error + GetServiceByID(ctx context.Context, lockStrength LockingStrength, accountID, serviceID string) (*services.Service, error) + GetServiceByDomain(ctx context.Context, accountID, domain string) (*services.Service, error) + GetAccountServices(ctx context.Context, lockStrength LockingStrength, accountID string) ([]*services.Service, error) } const ( diff --git a/shared/management/http/api/openapi.yml b/shared/management/http/api/openapi.yml index 7b9451b15..635255645 100644 --- a/shared/management/http/api/openapi.yml +++ b/shared/management/http/api/openapi.yml @@ -36,6 +36,8 @@ tags: x-cloud-only: true - name: Identity Providers description: Interact with and view information about identity providers. + - name: Services + description: Interact with and view information about exposed services. - name: Instance description: Instance setup and status endpoints for initial configuration. components: @@ -1905,7 +1907,8 @@ components: "route.add", "route.delete", "route.update", "nameserver.group.add", "nameserver.group.delete", "nameserver.group.update", "peer.ssh.disable", "peer.ssh.enable", "peer.rename", "peer.login.expiration.disable", "peer.login.expiration.enable", "peer.login.expire", - "service.user.create", "personal.access.token.create", "service.user.delete", "personal.access.token.delete" ] + "service.user.create", "personal.access.token.create", "service.user.delete", "personal.access.token.delete", + "service.create", "service.update", "service.delete" ] example: route.add initiator_id: description: The ID of the initiator of the event. E.g., an ID of a user that triggered the event. @@ -2428,6 +2431,147 @@ components: - issuer - client_id - client_secret + Service: + type: object + properties: + id: + type: string + description: Service ID + name: + type: string + description: Service name + description: + type: string + description: Service description + domain: + type: string + description: Domain for the service + targets: + type: array + items: + $ref: '#/components/schemas/ServiceTarget' + description: List of target backends for this service + distribution_groups: + type: array + items: + type: string + description: List of group IDs that can access this service + enabled: + type: boolean + description: Whether the service is enabled + exposed: + type: boolean + description: Whether the service is exposed + auth: + $ref: '#/components/schemas/ServiceAuthConfig' + required: + - id + - name + - domain + - targets + - distribution_groups + - enabled + - exposed + ServiceRequest: + type: object + properties: + name: + type: string + description: Service name + description: + type: string + description: Service description + domain: + type: string + description: Domain for the service + targets: + type: array + items: + $ref: '#/components/schemas/ServiceTarget' + description: List of target backends for this service + distribution_groups: + type: array + items: + type: string + description: List of group IDs that can access this service + enabled: + type: boolean + description: Whether the service is enabled + default: true + exposed: + type: boolean + description: Whether the service is exposed + default: false + auth: + $ref: '#/components/schemas/ServiceAuthConfig' + required: + - name + - domain + - targets + - distribution_groups + ServiceTarget: + type: object + properties: + path: + type: string + description: URL path prefix for this target + host: + type: string + description: Backend host:port for this target + enabled: + type: boolean + description: Whether this target is enabled + required: + - path + - host + - enabled + ServiceAuthConfig: + type: object + properties: + type: + type: string + enum: [basic, pin, bearer] + description: Authentication type + basic_auth: + $ref: '#/components/schemas/BasicAuthConfig' + pin_auth: + $ref: '#/components/schemas/PINAuthConfig' + bearer_auth: + $ref: '#/components/schemas/BearerAuthConfig' + required: + - type + BasicAuthConfig: + type: object + properties: + username: + type: string + description: Basic auth username + password: + type: string + description: Basic auth password + required: + - username + - password + PINAuthConfig: + type: object + properties: + pin: + type: string + description: PIN value + header: + type: string + description: HTTP header name for PIN + required: + - pin + - header + BearerAuthConfig: + type: object + properties: + enabled: + type: boolean + description: Whether bearer auth is enabled + required: + - enabled InstanceStatus: type: object description: Instance status information @@ -5629,3 +5773,150 @@ paths: "$ref": "#/components/responses/forbidden" '500': "$ref": "#/components/responses/internal_error" + /api/services: + get: + summary: List all Services + description: Returns a list of all exposed services + tags: [ Services ] + security: + - BearerAuth: [ ] + - TokenAuth: [ ] + responses: + '200': + description: A JSON Array of Services + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/Service' + '401': + "$ref": "#/components/responses/requires_authentication" + '403': + "$ref": "#/components/responses/forbidden" + '500': + "$ref": "#/components/responses/internal_error" + post: + summary: Create a Service + description: Creates a new exposed service + tags: [ Services ] + security: + - BearerAuth: [ ] + - TokenAuth: [ ] + requestBody: + description: New service request + content: + application/json: + schema: + $ref: '#/components/schemas/ServiceRequest' + responses: + '200': + description: Service created + content: + application/json: + schema: + $ref: '#/components/schemas/Service' + '400': + "$ref": "#/components/responses/bad_request" + '401': + "$ref": "#/components/responses/requires_authentication" + '403': + "$ref": "#/components/responses/forbidden" + '500': + "$ref": "#/components/responses/internal_error" + /api/services/{serviceId}: + get: + summary: Retrieve a Service + description: Get information about a specific service + tags: [ Services ] + security: + - BearerAuth: [ ] + - TokenAuth: [ ] + parameters: + - in: path + name: serviceId + required: true + schema: + type: string + description: The unique identifier of a service + responses: + '200': + description: A Service object + content: + application/json: + schema: + $ref: '#/components/schemas/Service' + '400': + "$ref": "#/components/responses/bad_request" + '401': + "$ref": "#/components/responses/requires_authentication" + '403': + "$ref": "#/components/responses/forbidden" + '404': + "$ref": "#/components/responses/not_found" + '500': + "$ref": "#/components/responses/internal_error" + put: + summary: Update a Service + description: Update an existing service configuration + tags: [ Services ] + security: + - BearerAuth: [ ] + - TokenAuth: [ ] + parameters: + - in: path + name: serviceId + required: true + schema: + type: string + description: The unique identifier of a service + requestBody: + description: Service update request + content: + application/json: + schema: + $ref: '#/components/schemas/ServiceRequest' + responses: + '200': + description: Service updated + content: + application/json: + schema: + $ref: '#/components/schemas/Service' + '400': + "$ref": "#/components/responses/bad_request" + '401': + "$ref": "#/components/responses/requires_authentication" + '403': + "$ref": "#/components/responses/forbidden" + '404': + "$ref": "#/components/responses/not_found" + '500': + "$ref": "#/components/responses/internal_error" + delete: + summary: Delete a Service + description: Delete an existing service + tags: [ Services ] + security: + - BearerAuth: [ ] + - TokenAuth: [ ] + parameters: + - in: path + name: serviceId + required: true + schema: + type: string + description: The unique identifier of a service + responses: + '200': + description: Service deleted + '400': + "$ref": "#/components/responses/bad_request" + '401': + "$ref": "#/components/responses/requires_authentication" + '403': + "$ref": "#/components/responses/forbidden" + '404': + "$ref": "#/components/responses/not_found" + '500': + "$ref": "#/components/responses/internal_error" diff --git a/shared/management/http/api/types.gen.go b/shared/management/http/api/types.gen.go index 94a52b6e1..e8162d874 100644 --- a/shared/management/http/api/types.gen.go +++ b/shared/management/http/api/types.gen.go @@ -193,6 +193,13 @@ const ( ResourceTypeSubnet ResourceType = "subnet" ) +// Defines values for ServiceAuthConfigType. +const ( + ServiceAuthConfigTypeBasic ServiceAuthConfigType = "basic" + ServiceAuthConfigTypeBearer ServiceAuthConfigType = "bearer" + ServiceAuthConfigTypePin ServiceAuthConfigType = "pin" +) + // Defines values for UserStatus. const ( UserStatusActive UserStatus = "active" @@ -368,6 +375,21 @@ type AvailablePorts struct { Udp int `json:"udp"` } +// BasicAuthConfig defines model for BasicAuthConfig. +type BasicAuthConfig struct { + // Password Basic auth password + Password string `json:"password"` + + // Username Basic auth username + Username string `json:"username"` +} + +// BearerAuthConfig defines model for BearerAuthConfig. +type BearerAuthConfig struct { + // Enabled Whether bearer auth is enabled + Enabled bool `json:"enabled"` +} + // Checks List of objects that perform the actual checks type Checks struct { // GeoLocationCheck Posture check for geo location @@ -1125,6 +1147,15 @@ type OSVersionCheck struct { Windows *MinKernelVersionCheck `json:"windows,omitempty"` } +// PINAuthConfig defines model for PINAuthConfig. +type PINAuthConfig struct { + // Header HTTP header name for PIN + Header string `json:"header"` + + // Pin PIN value + Pin string `json:"pin"` +} + // Peer defines model for Peer. type Peer struct { // ApprovalRequired (Cloud only) Indicates whether peer needs approval @@ -1785,6 +1816,86 @@ type RulePortRange struct { Start int `json:"start"` } +// Service defines model for Service. +type Service struct { + Auth *ServiceAuthConfig `json:"auth,omitempty"` + + // Description Service description + Description *string `json:"description,omitempty"` + + // DistributionGroups List of group IDs that can access this service + DistributionGroups []string `json:"distribution_groups"` + + // Domain Domain for the service + Domain string `json:"domain"` + + // Enabled Whether the service is enabled + Enabled bool `json:"enabled"` + + // Exposed Whether the service is exposed + Exposed bool `json:"exposed"` + + // Id Service ID + Id string `json:"id"` + + // Name Service name + Name string `json:"name"` + + // Targets List of target backends for this service + Targets []ServiceTarget `json:"targets"` +} + +// ServiceAuthConfig defines model for ServiceAuthConfig. +type ServiceAuthConfig struct { + BasicAuth *BasicAuthConfig `json:"basic_auth,omitempty"` + BearerAuth *BearerAuthConfig `json:"bearer_auth,omitempty"` + PinAuth *PINAuthConfig `json:"pin_auth,omitempty"` + + // Type Authentication type + Type ServiceAuthConfigType `json:"type"` +} + +// ServiceAuthConfigType Authentication type +type ServiceAuthConfigType string + +// ServiceRequest defines model for ServiceRequest. +type ServiceRequest struct { + Auth *ServiceAuthConfig `json:"auth,omitempty"` + + // Description Service description + Description *string `json:"description,omitempty"` + + // DistributionGroups List of group IDs that can access this service + DistributionGroups []string `json:"distribution_groups"` + + // Domain Domain for the service + Domain string `json:"domain"` + + // Enabled Whether the service is enabled + Enabled *bool `json:"enabled,omitempty"` + + // Exposed Whether the service is exposed + Exposed *bool `json:"exposed,omitempty"` + + // Name Service name + Name string `json:"name"` + + // Targets List of target backends for this service + Targets []ServiceTarget `json:"targets"` +} + +// ServiceTarget defines model for ServiceTarget. +type ServiceTarget struct { + // Enabled Whether this target is enabled + Enabled bool `json:"enabled"` + + // Host Backend host:port for this target + Host string `json:"host"` + + // Path URL path prefix for this target + Path string `json:"path"` +} + // SetupKey defines model for SetupKey. type SetupKey struct { // AllowExtraDnsLabels Allow extra DNS labels to be added to the peer @@ -2246,6 +2357,12 @@ type PostApiRoutesJSONRequestBody = RouteRequest // PutApiRoutesRouteIdJSONRequestBody defines body for PutApiRoutesRouteId for application/json ContentType. type PutApiRoutesRouteIdJSONRequestBody = RouteRequest +// PostApiServicesJSONRequestBody defines body for PostApiServices for application/json ContentType. +type PostApiServicesJSONRequestBody = ServiceRequest + +// PutApiServicesServiceIdJSONRequestBody defines body for PutApiServicesServiceId for application/json ContentType. +type PutApiServicesServiceIdJSONRequestBody = ServiceRequest + // PostApiSetupJSONRequestBody defines body for PostApiSetup for application/json ContentType. type PostApiSetupJSONRequestBody = SetupRequest