add management API to store

This commit is contained in:
pascal
2026-01-16 16:16:29 +01:00
parent 51261fe7a9
commit 2851e38a1f
13 changed files with 1085 additions and 5 deletions

View File

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

View File

@@ -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{})
}

View File

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

View File

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

View File

@@ -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)
}

View File

@@ -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())
})
}

View File

@@ -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

View File

@@ -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 {

View File

@@ -17,6 +17,7 @@ const (
SetupKeys Module = "setup_keys"
Pats Module = "pats"
IdentityProviders Module = "identity_providers"
Services Module = "services"
)
var All = map[Module]struct{}{

View File

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

View File

@@ -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 (

View File

@@ -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"

View File

@@ -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