From 160b27bc60decac981e4edcdaa3c1aac882f5ef3 Mon Sep 17 00:00:00 2001 From: Alisdair MacLeod Date: Tue, 27 Jan 2026 14:18:52 +0000 Subject: [PATCH] create reverse proxy domain manager and api --- .../internals/modules/services/domains/api.go | 124 +++++++++++++++ .../modules/services/domains/manager.go | 109 +++++++++++++ .../validator.go | 11 +- .../validator_test.go | 6 +- shared/management/http/api/openapi.yml | 147 ++++++++++++++++++ shared/management/http/api/types.gen.go | 33 ++++ 6 files changed, 417 insertions(+), 13 deletions(-) create mode 100644 management/internals/modules/services/domains/api.go create mode 100644 management/internals/modules/services/domains/manager.go rename management/internals/modules/services/{domainvalidation => domains}/validator.go (76%) rename management/internals/modules/services/{domainvalidation => domains}/validator_test.go (91%) diff --git a/management/internals/modules/services/domains/api.go b/management/internals/modules/services/domains/api.go new file mode 100644 index 000000000..b810f1df8 --- /dev/null +++ b/management/internals/modules/services/domains/api.go @@ -0,0 +1,124 @@ +package domains + +import ( + "encoding/json" + "net/http" + + "github.com/gorilla/mux" + 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 manager +} + +func RegisterEndpoints(router *mux.Router) { + h := &handler{} + + router.HandleFunc("/domains", h.getAllDomains).Methods("GET", "OPTIONS") + router.HandleFunc("/domains", h.createCustomDomain).Methods("POST", "OPTIONS") + router.HandleFunc("/domains/{domainId}", h.deleteCustomDomain).Methods("DELETE", "OPTIONS") + router.HandleFunc("/domains/{domainId}/validate", h.triggerCustomDomainValidation).Methods("GET", "OPTIONS") +} + +func domainTypeToApi(t domainType) api.ReverseProxyDomainType { + switch t { + case domainTypeCustom: + return api.ReverseProxyDomainTypeCustom + case domainTypeFree: + return api.ReverseProxyDomainTypeFree + } + // By default return as a "free" domain as that is more restrictive. + // TODO: is this correct? + return api.ReverseProxyDomainTypeFree +} + +func domainToApi(d domain) api.ReverseProxyDomain { + return api.ReverseProxyDomain{ + Domain: d.Domain, + Id: d.ID, + Type: domainTypeToApi(d.Type), + Validated: d.Validated, + } +} + +func (h *handler) getAllDomains(w http.ResponseWriter, r *http.Request) { + userAuth, err := nbcontext.GetUserAuthFromContext(r.Context()) + if err != nil { + util.WriteError(r.Context(), err, w) + return + } + + domains, err := h.manager.GetDomains(r.Context(), userAuth.AccountId) + + var ret []api.ReverseProxyDomain + for _, d := range domains { + ret = append(ret, domainToApi(d)) + } + + util.WriteJSONObject(r.Context(), w, ret) +} + +func (h *handler) createCustomDomain(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.PostApiReverseProxyDomainsJSONRequestBody + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + util.WriteErrorResponse("couldn't parse JSON request", http.StatusBadRequest, w) + return + } + + domain, err := h.manager.CreateDomain(r.Context(), userAuth.AccountId, req.Domain) + if err != nil { + util.WriteError(r.Context(), err, w) + return + } + + util.WriteJSONObject(r.Context(), w, domainToApi(domain)) +} + +func (h *handler) deleteCustomDomain(w http.ResponseWriter, r *http.Request) { + userAuth, err := nbcontext.GetUserAuthFromContext(r.Context()) + if err != nil { + util.WriteError(r.Context(), err, w) + return + } + + domainID := mux.Vars(r)["domainId"] + if domainID == "" { + util.WriteError(r.Context(), status.Errorf(status.InvalidArgument, "domain ID is required"), w) + return + } + + if err := h.manager.DeleteDomain(r.Context(), userAuth.AccountId, domainID); err != nil { + util.WriteError(r.Context(), err, w) + return + } + + w.WriteHeader(http.StatusNoContent) +} + +func (h *handler) triggerCustomDomainValidation(w http.ResponseWriter, r *http.Request) { + userAuth, err := nbcontext.GetUserAuthFromContext(r.Context()) + if err != nil { + util.WriteError(r.Context(), err, w) + return + } + + domainID := mux.Vars(r)["domainId"] + if domainID == "" { + util.WriteError(r.Context(), status.Errorf(status.InvalidArgument, "domain ID is required"), w) + return + } + + go h.manager.ValidateDomain(userAuth.AccountId, domainID) + + w.WriteHeader(http.StatusAccepted) +} diff --git a/management/internals/modules/services/domains/manager.go b/management/internals/modules/services/domains/manager.go new file mode 100644 index 000000000..1d6fc13c9 --- /dev/null +++ b/management/internals/modules/services/domains/manager.go @@ -0,0 +1,109 @@ +package domains + +import ( + "context" + "fmt" +) + +type domainType string + +const ( + domainTypeFree domainType = "free" + domainTypeCustom domainType = "custom" +) + +type domain struct { + ID string + Domain string + Type domainType + Validated bool +} + +type store interface { + GetAccountDomainNonce(ctx context.Context, accountID string) (nonce string, err error) + GetCustomDomain(ctx context.Context, accountID string, domainID string) (domain, error) + ListFreeDomains(ctx context.Context, accountID string) ([]string, error) + ListCustomDomains(ctx context.Context, accountID string) ([]domain, error) + CreateCustomDomain(ctx context.Context, accountID string, domainName string, validated bool) (domain, error) + UpdateCustomDomain(ctx context.Context, accountID string, d domain) (domain, error) + DeleteCustomDomain(ctx context.Context, accountID string, domainID string) error +} + +type manager struct { + store store + validator Validator +} + +func (m manager) GetDomains(ctx context.Context, accountID string) ([]domain, error) { + nonce, err := m.store.GetAccountDomainNonce(ctx, accountID) + if err != nil { + return nil, fmt.Errorf("get account domain nonce: %w", err) + } + free, err := m.store.ListFreeDomains(ctx, accountID) + if err != nil { + return nil, fmt.Errorf("list free domains: %w", err) + } + domains, err := m.store.ListCustomDomains(ctx, accountID) + if err != nil { + // TODO: check for "no records" type error. Because that is a success condition. + return nil, fmt.Errorf("list custom domains: %w", err) + } + + // Prepend each free domain with the account nonce and then add it to the domain + // array to be returned. + // This account nonce is added to free domains to prevent users being able to + // query free domain usage across accounts and simplifies tracking free domain + // usage across accounts. + for _, name := range free { + domains = append(domains, domain{ + Domain: nonce + "." + name, + Type: domainTypeFree, + Validated: true, + }) + } + return domains, nil +} + +func (m manager) CreateDomain(ctx context.Context, accountID, domainName string) (domain, error) { + // Attempt an initial validation; however, a failure is still acceptable for creation + // because the user may not yet have configured their DNS records, or the DNS update + // has not yet reached the servers that are queried by the validation resolver. + var validated bool + // TODO: retrieve in use reverse proxy addresses from somewhere! + var reverseProxyAddresses []string + if m.validator.IsValid(ctx, domainName, reverseProxyAddresses) { + validated = true + } + + d, err := m.store.CreateCustomDomain(ctx, accountID, domainName, validated) + if err != nil { + return d, fmt.Errorf("create domain in store: %w", err) + } + + return d, nil +} + +func (m manager) DeleteDomain(ctx context.Context, accountID, domainID string) error { + if err := m.store.DeleteCustomDomain(ctx, accountID, domainID); err != nil { + // TODO: check for "no records" type error. Because that is a success condition. + return fmt.Errorf("delete domain from store: %w", err) + } + return nil +} + +func (m manager) ValidateDomain(accountID, domainID string) { + d, err := m.store.GetCustomDomain(context.Background(), accountID, domainID) + if err != nil { + // TODO: something? Log? + return + } + // TODO: retrieve in use reverse proxy addresses from somewhere! + var reverseProxyAddresses []string + if m.validator.IsValid(context.Background(), d.Domain, reverseProxyAddresses) { + d.Validated = true + if _, err := m.store.UpdateCustomDomain(context.Background(), accountID, d); err != nil { + // TODO: something? Log? + return + } + } +} diff --git a/management/internals/modules/services/domainvalidation/validator.go b/management/internals/modules/services/domains/validator.go similarity index 76% rename from management/internals/modules/services/domainvalidation/validator.go rename to management/internals/modules/services/domains/validator.go index 682c4417f..bc58e9b07 100644 --- a/management/internals/modules/services/domainvalidation/validator.go +++ b/management/internals/modules/services/domains/validator.go @@ -1,13 +1,4 @@ -// Package domainvalidation provides a mechanism for verifying ownership of -// a domain. -// It is intended to be used before custom domains can be assigned to reverse -// proxy services. -// Acceptable domains should be set to the known domains that reverse proxy -// servers are hosted at. -// -// After a custom domain is validated, it should be pinned to a single account -// to prevent domain abuse across accounts. -package domainvalidation +package domains import ( "context" diff --git a/management/internals/modules/services/domainvalidation/validator_test.go b/management/internals/modules/services/domains/validator_test.go similarity index 91% rename from management/internals/modules/services/domainvalidation/validator_test.go rename to management/internals/modules/services/domains/validator_test.go index c9e7d0419..9a7dec79c 100644 --- a/management/internals/modules/services/domainvalidation/validator_test.go +++ b/management/internals/modules/services/domains/validator_test.go @@ -1,10 +1,10 @@ -package domainvalidation_test +package domains_test import ( "context" "testing" - "github.com/netbirdio/netbird/management/internals/modules/services/domainvalidation" + "github.com/netbirdio/netbird/management/internals/modules/services/domains" ) type resolver struct { @@ -46,7 +46,7 @@ func TestIsValid(t *testing.T) { for name, test := range tests { t.Run(name, func(t *testing.T) { - validator := domainvalidation.NewValidator(test.resolver) + validator := domains.NewValidator(test.resolver) actual := validator.IsValid(t.Context(), test.domain, test.accept) if test.expect != actual { t.Errorf("Incorrect return value:\nexpect: %v\nactual: %v", test.expect, actual) diff --git a/shared/management/http/api/openapi.yml b/shared/management/http/api/openapi.yml index a61281ab6..747ca1217 100644 --- a/shared/management/http/api/openapi.yml +++ b/shared/management/http/api/openapi.yml @@ -2581,6 +2581,40 @@ components: description: Whether link auth is enabled required: - enabled + ReverseProxyDomainType: + type: string + description: Type of Reverse Proxy Domain + enum: + - free + - custom + example: free + ReverseProxyDomain: + type: object + properties: + id: + type: string + description: Domain ID + domain: + type: string + description: Domain name + validated: + type: boolean + description: Whether the domain has been validated + type: + $ref: '#/components/schemas/ReverseProxyDomainType' + required: + - id + - domain + - validated + - type + ReverseProxyDomainRequest: + type: object + properties: + domain: + type: string + description: Domain name + required: + - domain InstanceStatus: type: object description: Instance status information @@ -5929,3 +5963,116 @@ paths: "$ref": "#/components/responses/not_found" '500': "$ref": "#/components/responses/internal_error" + /api/reverse-proxy/domains: + get: + summary: Retrieve Reverse Proxy Domains + description: Get information about domains that can be used for Reverse Proxy endpoints. + tags: [ Reverse Proxy ] + security: + - BearerAuth: [ ] + - TokenAuth: [ ] + responses: + '200': + description: A JSON Array of ReverseProxyDomains + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/ReverseProxyDomain' + '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" + post: + summary: Create a Custom domain + description: Create a new Custom domain for use with Reverse Proxy endpoints, this will trigger an initial validation check + tags: [ Reverse Proxy ] + security: + - BearerAuth: [ ] + - TokenAuth: [ ] + requestBody: + description: Custom domain creation request + content: + application/json: + schema: + $ref: '#/components/schemas/ReverseProxyDomainRequest' + responses: + '200': + description: Reverse proxy created + content: + application/json: + schema: + $ref: '#/components/schemas/ReverseProxy' + '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" + /api/reverse-proxy/domains/{domainId}: + delete: + summary: Delete a Custom domain + description: Delete an existing reverse proxy custom domain + tags: [ Reverse Proxy ] + security: + - BearerAuth: [ ] + - TokenAuth: [ ] + parameters: + - in: path + name: domainId + required: true + schema: + type: string + description: The custom domain ID + responses: + '204': + description: Reverse proxy custom domain 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" + /api/reverse-proxy/domains/{domainId}/validate: + get: + summary: Validate a custom domain + description: Trigger domain ownership validation for a custom domain + tags: [ Reverse Proxy ] + security: + - BearerAuth: [ ] + - TokenAuth: [ ] + parameters: + - in: path + name: domainId + required: true + schema: + type: string + description: The custom domain ID + responses: + '202': + description: Reverse proxy custom domain validation triggered + '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 6d95a8720..fc84d0eed 100644 --- a/shared/management/http/api/types.gen.go +++ b/shared/management/http/api/types.gen.go @@ -204,6 +204,12 @@ const ( ReverseProxyAuthConfigTypePin ReverseProxyAuthConfigType = "pin" ) +// Defines values for ReverseProxyDomainType. +const ( + ReverseProxyDomainTypeCustom ReverseProxyDomainType = "custom" + ReverseProxyDomainTypeFree ReverseProxyDomainType = "free" +) + // Defines values for ReverseProxyTargetProtocol. const ( ReverseProxyTargetProtocolHttp ReverseProxyTargetProtocol = "http" @@ -1776,6 +1782,30 @@ type ReverseProxyAuthConfig struct { // ReverseProxyAuthConfigType Authentication type type ReverseProxyAuthConfigType string +// ReverseProxyDomain defines model for ReverseProxyDomain. +type ReverseProxyDomain struct { + // Domain Domain name + Domain string `json:"domain"` + + // Id Domain ID + Id string `json:"id"` + + // Type Type of Reverse Proxy Domain + Type ReverseProxyDomainType `json:"type"` + + // Validated Whether the domain has been validated + Validated bool `json:"validated"` +} + +// ReverseProxyDomainRequest defines model for ReverseProxyDomainRequest. +type ReverseProxyDomainRequest struct { + // Domain Domain name + Domain string `json:"domain"` +} + +// ReverseProxyDomainType Type of Reverse Proxy Domain +type ReverseProxyDomainType string + // ReverseProxyRequest defines model for ReverseProxyRequest. type ReverseProxyRequest struct { Auth ReverseProxyAuthConfig `json:"auth"` @@ -2380,6 +2410,9 @@ type PutApiPostureChecksPostureCheckIdJSONRequestBody = PostureCheckUpdate // PostApiReverseProxyJSONRequestBody defines body for PostApiReverseProxy for application/json ContentType. type PostApiReverseProxyJSONRequestBody = ReverseProxyRequest +// PostApiReverseProxyDomainsJSONRequestBody defines body for PostApiReverseProxyDomains for application/json ContentType. +type PostApiReverseProxyDomainsJSONRequestBody = ReverseProxyDomainRequest + // PutApiReverseProxyProxyIdJSONRequestBody defines body for PutApiReverseProxyProxyId for application/json ContentType. type PutApiReverseProxyProxyIdJSONRequestBody = ReverseProxyRequest