create reverse proxy domain manager and api

This commit is contained in:
Alisdair MacLeod
2026-01-27 14:18:52 +00:00
parent c084386b88
commit 160b27bc60
6 changed files with 417 additions and 13 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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