Files
Vishal Dalwadi 132aee0bb3 NM-341-v6: AMB Multi-tenancy Management (#4101)
* wip(go): return auth token with user on create org owner;

* wip(go): prevent use of default tenant slug ;

* wip(go): upsert settings for tenant in ctx;

* wip(go): use passed ctx when updating user; remove password validation from ValidateUser (already validated in UpdateUser);

* wip(go): qualify all tenant id usages with table name;

* wip(go): use passed ctx

* wip(go): return old enrollment key type on create;

* wip(go): prevent null networks return;

* wip(go): wire ctx in dns/nameserver functions; wire ctx in host registration functions;

* wip(go): wire ctx in node functions;

* wip(go): wire ctx in node functions;

* wip(go): wire ctx in extclient functions;

* wip(go): remove not needed field;

* wip(go): use passed ctx;

* wip(go): use unique index instead of primary key;

* wip(go): wire ctx in extclient apis;

* wip(go): wire ctx in extclient apis;

* wip(go): remove org id check from global scoped get tenant api;

* wip(go): add scope slug to discover response;

* wip(go): wire ctx in get users;

* wip(go): set tenant id in posture check violations;

* wip(go): upsert membership instead of user;

* wip(go): pass correct len;

* wip(go): optimize (single loop);

* wip(go): unify todos;

* wip(go): add todo;

* wip(go): wire context for network get;

* wip(go): wire context;

* wip(go): wire context;

* wip(go): wire ctx when getting tenant superadmin;

* wip(go): add network creator to network admin group;

* wip(go): check for user membership on sync;

* wip(go): add methods with strict membership check;

* wip(go): remove unwanted feature flags;

* wip(go): report per-tenant usage during license validation;

* wip(go): move email setting from tenant to org;

* wip(go): pass org scope to email init;

* wip(go): remove old tenant email settings usages; use org settings to get email settings

* wip(go): use get email settings from org settings;

* wip(go): remove email settings migration;

* wip(go): migrate email settings from server to org settings;

* wip(go): assign global network admin group membership only for super-admin and admins;

* wip(go): assign global network admin group membership only for super-admin and admins;

* wip(go): add membership alias to filter;

* wip(go): skip clearing local license validation response cache;

* wip(go): extend license validation response for multi-tenancy;

* wip(go): add functions to get sole tenant and org;

* wip(go): use tenant-specific feature flags;

* wip(go): add tenant status type;

* wip(go): remove old hook comment;

* wip(go): expect ctx during license validation;

* wip(go): always create default org and tenant for ce;

* wip(go): add tenant-specific check for license validation error;

* wip(go): cache license validation response in memory;

* wip(go): add todo to handle multiple-tenant for integration pull;

* wip(go): pass ctx during license validation;

* wip(go): add todo for making changes later; run hook separately for each tenant;

* wip(go): return specific errors when license validation response not in cache;

* wip(go): use retryable.httpclient for calling license validation api;

* wip(go): process license validation;

* wip(go): license validation function;

* wip(go): migration on license validation;

* wip(go): org settings not expected;

* wip(go): remove expiry from license validation response;

* wip(go): add limits and limits enforcement to license validation response;

* wip(go): remove expiry usages;

* wip(go): add arg to clear cache;

* wip(go): add arg to clear cache;

* wip(go): skip license validation if cached resp;

* wip(go): allow org_id and tenant_id query params;

* wip(go): upsert tenant membership on sync;

* wip(go): pass ctx to delete network roles;

* wip(go): create tenant membership on org owner creation on ce;

* wip(go): add tenant orchestrator;

* wip(go): use tenant orchestrator;

* wip(go): add default settings for tenant;

* wip(go): make org owner superadmin on all tenants;

* wip(go): simplify SyncOrgAndTenants;

* wip(go): retry license validation only 3 times

* wip(go): store invalid err;

* wip(go): add function to check if limits are to be enforced on a tenant;

* wip(go): pass tenant scope if possible when checking for license validation error;

* wip(go): remove merge errors;

* wip(go): add functions for limit checks;

* wip(go): wire host limit check;

* wip(go): wire ingress limit check;

* wip(go): wire egress limit check;

* wip(go): wire client limit check;

* wip(go): wire network limit check;

* wip(go): wire user limit check;

* wip(go): use correct errors;

* wip(go): skip telemetry for msps;

* wip(go): disable telemetry on msp tenants;

* wip(go): err check;

* wip(go): uncomment log event;

* wip(go): get sso state to get scope for non-headless oauth login;

* wip(go): inline delete pending user;

* wip(go): allow both org and tenant scope for pending users table;

* wip(go): add migration for pending user table during set tenant id and rekey org/tenant id;

* wip(go): update queries to use new scope fields in pending users table;

* wip(go): allow both org and tenant scope for user invites table;

* wip(go): update queries to use new scope fields;

* wip(go): add migration for rekey-ing org/tenant id and setting tenant id;

* wip(go): inline delete user invite;

* wip(go): set scope when creating invite;

* wip(go): get user count with membership is ctx is tenant scoped;

* wip(go): add methods to upsert and delete org memberships;

* wip(go): add global valid tenant and org roles map;

* wip(go): add scope query in user invite get by email;

* wip(go): add support for upsert and delete of org and tenant membership both;

* wip(go): infer scope for user invite apis;

* wip(go): wire ctx in get user invite by email;

* wip(go): allow no scope headers to be passed in infer scope apis;

* wip(go): add validation and checks for org-scoped invite api handlers;

* wip(go): wire ctx to is pending user check;

* wip(go): infer scope for pending user apis;

* wip(go): set default role based on ctx scope;

* wip(go): move mfa to tenant membership table; move account status to membership tables;

* wip(go): migrate account status and mfa settings;

* wip(go): update account status on sync;

* wip(go): use update mfa to update mfa setting;

* wip(go): set new membership settings on user creation;

* wip(go): use membership where required;

* wip(go): wire ctx on key unlink refs;

* wip(go): use r.Context directly without reinitializing db;

* wip(go): wire ctx in acls;

* wip(go): add tenant scoped primary key constructor;

* wip(go): use tenant scoped primary key for acls, dns, extclients and tags;

* wip(go): wire ctx for get groups in network;

* wip(go): wire ctx for get groups;

* wip(go): scope non-uuid group ids by tenant id;

* wip(go): migrate non-uuid group ids to scoped ids;

* wip(go): don't expect scope id in invite validation and signup apis;

* wip(go): wire ctx for get matching user; allow org scope when querying by external idp id;

* wip(go): add mfa to org;

* wip(go): scope preauth token;

* wip(go): add fallback for older auth tokens;

* wip(go): wire ctx for preauth token creation;

* wip(go): migrate mfa settings;

* wip(go): resolve mfa settings for inherited auth;

* wip(go): update mfa for org;

* wip(go): infer scope for totp apis;

* wip(go): use ctx when passed;

* wip(go): infer scope for list user apis;

* wip(go): ensure set primary keys don't cause errors during fetch;

* wip(go): make primary key for integrations uuid and store provider in separate key;

* wip(go): migrate integration ids;

* wip(go): use provider instead of id everywhere;

* wip(go): remove gorm column;

* wip(go): wire ctx for igw egress creation;

* wip(go): wire ctx for igw egress update;

* wip(go): wire ctx for host registration;

* wip(go): wire ctx for host registration;

* wip(go): merge errors;

* wip(go): merge errors;

* wip(go): wire ctx; remove default scope usages;

* wip(go): use get with membership;

* wip(go): org scope license validation;

* wip(go): remove debug log;

* wip(go): remove scoping on list roles;

* wip(go): ignore invite creation if already exists;

* wip(go): add is msp func to check if deployment is msp deployment;

* wip(go): ensure username is email for msp users;

* wip(go): add email validated check on user table;

* wip(go): mark email validated when oauth user;

* wip(go): handle gorm error;

* wip(go): add tenant id to siem grpc schema;

* wip(go): read tenant from scoped ctx;

* wip(go): pass scoped ctx to siem grpc client;

* wip(go): remove unused publisher;

* wip(go): publish all tenant exporter updates on pull;

* wip(go): fix wg usage;

* wip(go): tenant scope the ctx when getting server info;

* wip(go): add tenant id to enrollment key token;

* wip(go): make host register tenant scope;

* wip(go): add default settings;

* wip(go): use correct checks for is msp tenant;

* wip(go): add email validation extension;

* wip(go): add pages for validation check;

* wip(go): use extension for validating user;

* wip(go): add invite type;

* wip(go): create only one validation invite per user email;

* wip(go): add validate email handler;

* wip(go): add email builder;

* wip(go): add user invite type migration logic;

* wip(go): only log email send failures;

* wip(go): add tenant id to flows table;

* wip(go): add func to check if connected to clickhouse;

* wip(go): migrate clickhouse;

* wip(go): add tenant id to mdm and edr state;

* wip(go): add delete methods for tables;

* wip(go): add tenant teardown method;

* wip(go): handle tenant deletion;

* wip(go): clean up flow logs on tenant deletion;

* wip(go): add update org membership role id method;

* wip(go): add transfer org ownership api;

* wip(go): move audit logs retention period to org settings;

* wip(go):get retention period from org settings;

* wip(go): remove old audit logs usage;

* wip(go): migrate audit logs retention period;

* wip(go): unmarshal decodes nums as float64;

* wip(go): run flow logs cleanup for all tenants in one go;

* wip(go): add audit logs retention period and smtp settings to tenant settings table for non-msp tenants;

* wip(go): resolve retention period and smtp settings correctly;

* wip(go): handle retention period and smtp settings updates and fetches correctly;

* wip(go): overlay retention period and smtp settings on server settings;;

* wip(go): upsert overlayed settings;

* wip(go): add azure tenant id to org settings;

* wip(go): tenant scope list roles;

* wip(go): add noop creds;

* wip(go): use first instead of find;

* wip(go): use correct roles when transfering ownership;

* wip(go): delete access tokens only if tenant scoped;

* wip(go): make delete user work for both org and tenant scope;

* wip(go): delete extclients only if tenant scoped;

* wip(go): check if caller is allowed to delete user;

* wip(go): infer scope for update user;

* wip(go): prevent org-owner elevation;

* wip(go): permissions check for update;

* wip(go): delete user access tokens only if tenant scope;

* wip(go): delete extclients only if tenant scope;

* wip(go): move upsert membership in upsert user;

* wip(go): set api host when constructing server config;

* wip(go): fix static errors;

* wip(go): fix test cases;

* wip(go): use correct get ns order;

* wip(go): use correct get pending host order;

* wip(go): use correct get access token order;

* wip(go): use correct get event order;

* wip(go): return error response on emqx user creation error;

* wip(go): store correct value in cache;

* wip(go): return better error response;

* wip(go): return better error response;

* wip(go): use cache when listing egress acls;

* wip(go): wire ctx in UserHasNetworkGroupAccess;

* wip(go): remove double scope wrapping;

* wip(go): add security check for list and get org;

* wip(go): ratelimit discover api;

* wip(go): expect sole org id in get and create org owner;

* wip(go): wrap create org owner in tx;
2026-08-05 21:25:02 +08:00

611 lines
18 KiB
Go

package controller
import (
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"time"
"github.com/google/uuid"
"github.com/gorilla/mux"
"github.com/gravitl/netmaker/db"
"github.com/gravitl/netmaker/logger"
"github.com/gravitl/netmaker/logic"
"github.com/gravitl/netmaker/middleware"
"github.com/gravitl/netmaker/models"
"github.com/gravitl/netmaker/mq"
"github.com/gravitl/netmaker/schema"
"github.com/gravitl/netmaker/scope"
"github.com/gravitl/netmaker/servercfg"
"gorm.io/datatypes"
)
func egressHandlers(r *mux.Router) {
r.HandleFunc("/api/v1/egress/presets", middleware.Scope(scope.TenantScope, logic.SecurityCheck(true, http.HandlerFunc(getEgressPresets)))).Methods(http.MethodGet)
r.HandleFunc("/api/v1/egress", middleware.Scope(scope.TenantScope, logic.SecurityCheck(true, http.HandlerFunc(createEgress)))).Methods(http.MethodPost)
r.HandleFunc("/api/v1/egress", middleware.Scope(scope.TenantScope, logic.SecurityCheck(true, http.HandlerFunc(listEgress)))).Methods(http.MethodGet)
r.HandleFunc("/api/v1/egress", middleware.Scope(scope.TenantScope, logic.SecurityCheck(true, http.HandlerFunc(updateEgress)))).Methods(http.MethodPut)
r.HandleFunc("/api/v1/egress", middleware.Scope(scope.TenantScope, logic.SecurityCheck(true, http.HandlerFunc(deleteEgress)))).Methods(http.MethodDelete)
}
// @Summary List egress domain presets
// @Router /api/v1/egress/presets [get]
// @Tags Egress
// @Security oauth
// @Produce json
// @Success 200 {object} models.SuccessResponse
// @Failure 401 {object} models.ErrorResponse
func getEgressPresets(w http.ResponseWriter, r *http.Request) {
presets := []models.EgressPresetApp{}
if servercfg.IsPro {
presets = logic.ListEgressPresets()
}
logic.ReturnSuccessResponseWithJson(w, r, map[string]any{
"presetApps": presets,
}, "fetched egress preset catalog")
}
// @Summary Create Egress Resource
// @Router /api/v1/egress [post]
// @Tags Egress
// @Security oauth
// @Accept json
// @Produce json
// @Param body body models.EgressReq true "Egress request data"
// @Success 200 {object} schema.Egress
// @Failure 400 {object} models.ErrorResponse
// @Failure 401 {object} models.ErrorResponse
// @Failure 500 {object} models.ErrorResponse
func createEgress(w http.ResponseWriter, r *http.Request) {
var req models.EgressReq
err := json.NewDecoder(r.Body).Decode(&req)
if err != nil {
logger.Log(0, "error decoding request body: ",
err.Error())
logic.ReturnErrorResponse(w, r, logic.FormatError(err, "badrequest"))
return
}
if err := logic.ValidateEgressReqProLimits(&req); err != nil {
logic.ReturnErrorResponse(w, r, logic.FormatError(err, "badrequest"))
return
}
if req.PresetID != "" {
if err := logic.ApplyEgressPresetToEgressReq(&req); err != nil {
logic.ReturnErrorResponse(w, r, logic.FormatError(err, "badrequest"))
return
}
}
normDomains, err := logic.NormalizeEgressReqDomains(req.Domains)
if err != nil {
logic.ReturnErrorResponse(w, r, logic.FormatError(err, "badrequest"))
return
}
inetGw := logic.IsEgressReqInternetGateway(&req)
if inetGw {
normDomains = nil
}
req.Domains = normDomains
var resolvedCIDRs []string
if req.PresetID != "" {
if p, ok := logic.GetEgressPresetByID(req.PresetID); ok && logic.PresetYieldsAWSIPRanges(p) {
if c, err := logic.ResolveAWSEgressPresetCIDRs(http.DefaultClient, p); err == nil && len(c) > 0 {
resolvedCIDRs = c
} else if err != nil {
logger.Log(0, "aws preset ip fetch failed:", req.PresetID, err.Error())
}
}
}
var egressRange string
egressType := logic.InferEgressType(&req)
if inetGw {
egressType = schema.EgressTypeInternet
egressRange = "*"
req.Domains = nil
req.PresetID = ""
} else if len(normDomains) > 0 {
egressRange = ""
if egressType == "" || egressType == schema.EgressTypeCIDR {
if req.PresetID != "" {
egressType = schema.EgressTypeApp
} else {
egressType = schema.EgressTypeDomain
}
}
} else if req.Range != "" {
egressRange, err = logic.NormalizeCIDR(req.Range)
if err != nil {
logic.ReturnErrorResponse(w, r, logic.FormatError(err, "badrequest"))
return
}
}
network := &schema.Network{Name: req.Network}
err = network.Get(r.Context())
if err != nil {
logic.ReturnErrorResponse(w, r, logic.FormatError(err, "badrequest"))
return
}
e := schema.Egress{
ID: uuid.New().String(),
TenantID: scope.ID(r.Context()),
Name: req.Name,
Network: req.Network,
Description: req.Description,
Type: egressType,
Range: egressRange,
Nat: req.Nat,
Mode: req.Mode,
Nodes: make(datatypes.JSONMap),
Tags: make(datatypes.JSONMap),
PresetID: req.PresetID,
Status: true,
CreatedBy: r.Header.Get("user"),
CreatedAt: time.Now().UTC(),
}
logic.ApplyConfiguredDomainsToEgress(&e, normDomains)
if len(resolvedCIDRs) > 0 {
logic.SetEgressDomainAnsForDomains(&e, normDomains, resolvedCIDRs)
}
if len(req.Tags) > 0 {
for tagID, metric := range req.Tags {
e.Tags[tagID] = metric
}
e.Nodes = make(datatypes.JSONMap)
} else {
for nodeID, metric := range req.Nodes {
e.Nodes[nodeID] = metric
}
}
if logic.EgressLimitExceeded(r.Context()) {
logic.ReturnErrorResponse(w, r, logic.FormatError(logic.ErrEgressLimitExceeded, logic.Forbidden))
return
}
if err := logic.ValidateEgressReq(r.Context(), &e); err != nil {
logic.ReturnErrorResponse(w, r, logic.FormatError(err, "badrequest"))
return
}
if err := logic.AssignVirtualRangeToEgress(network, &e); err != nil {
logger.Log(0, "error assigning virtual range to egress: ", err.Error())
logic.ReturnErrorResponse(w, r, logic.FormatError(err, "badrequest"))
return
}
logger.Log(1, fmt.Sprintf("createEgress: after AssignVirtualRangeToEgress, e.VirtualRange = '%s', e.Mode = '%s', e.Nat = %v", e.VirtualRange, e.Mode, e.Nat))
err = e.Create(r.Context())
if err != nil {
logic.ReturnErrorResponse(
w,
r,
logic.FormatError(errors.New("error creating egress resource"+err.Error()), "internal"),
)
return
}
logic.LogEvent(r.Context(), &models.Event{
Action: schema.Create,
Source: models.Subject{
ID: r.Header.Get("user"),
Name: r.Header.Get("user"),
Type: schema.UserSub,
},
TriggeredBy: r.Header.Get("user"),
Target: models.Subject{
ID: e.ID,
Name: e.Name,
Type: schema.EgressSub,
},
NetworkID: schema.NetworkID(e.Network),
Origin: schema.Dashboard,
})
// for nodeID := range e.Nodes {
// node, err := logic.GetNodeByID(nodeID)
// if err != nil {
// logic.AddEgressInfoToNode(&node, e)
// logic.UpsertNode(&node)
// }
// }
if len(normDomains) > 0 && !logic.HasEgressDomainAns(e) {
if req.Nodes != nil {
for nodeID := range req.Nodes {
node, err := logic.GetNodeByID(nodeID)
if err != nil {
continue
}
host := &schema.Host{
ID: node.HostID,
}
err = host.Get(r.Context())
if err != nil {
continue
}
for _, dom := range normDomains {
mq.HostUpdate(&models.HostUpdate{
Action: models.EgressUpdate,
Host: *host,
EgressDomain: models.EgressDomain{
ID: e.ID,
Host: *host,
Node: node,
Domain: dom,
},
Node: node,
})
}
}
}
} else {
ctx := scope.WithContext(db.WithContext(context.Background()), scope.Level(r.Context()), scope.ID(r.Context()))
go mq.PublishPeerUpdate(ctx, false)
}
logic.ReturnSuccessResponseWithJson(w, r, e, "created egress resource")
}
// @Summary List Egress Resources
// @Router /api/v1/egress [get]
// @Tags Egress
// @Security oauth
// @Produce json
// @Param network query string true "Network identifier"
// @Success 200 {array} schema.Egress
// @Failure 400 {object} models.ErrorResponse
// @Failure 401 {object} models.ErrorResponse
// @Failure 500 {object} models.ErrorResponse
func listEgress(w http.ResponseWriter, r *http.Request) {
network := r.URL.Query().Get("network")
if network == "" {
logic.ReturnErrorResponse(w, r, logic.FormatError(errors.New("network is required"), "badrequest"))
return
}
e := schema.Egress{Network: network}
list, err := e.ListByNetwork(db.WithContext(r.Context()))
if err != nil {
logic.ReturnErrorResponse(
w,
r,
logic.FormatError(errors.New("error listing egress resource"+err.Error()), "internal"),
)
return
}
logic.ReturnSuccessResponseWithJson(w, r, list, "fetched egress resource list")
}
// @Summary Update Egress Resource
// @Router /api/v1/egress [put]
// @Tags Egress
// @Security oauth
// @Accept json
// @Produce json
// @Param body body models.EgressReq true "Egress request data"
// @Success 200 {object} schema.Egress
// @Failure 400 {object} models.ErrorResponse
// @Failure 401 {object} models.ErrorResponse
// @Failure 500 {object} models.ErrorResponse
func updateEgress(w http.ResponseWriter, r *http.Request) {
var req models.EgressReq
err := json.NewDecoder(r.Body).Decode(&req)
if err != nil {
logger.Log(0, "error decoding request body: ",
err.Error())
logic.ReturnErrorResponse(w, r, logic.FormatError(err, "badrequest"))
return
}
if err := logic.ValidateEgressReqProLimits(&req); err != nil {
logic.ReturnErrorResponse(w, r, logic.FormatError(err, "badrequest"))
return
}
if req.PresetID != "" {
if err := logic.ApplyEgressPresetToEgressReq(&req); err != nil {
logic.ReturnErrorResponse(w, r, logic.FormatError(err, "badrequest"))
return
}
}
normDomains, err := logic.NormalizeEgressReqDomains(req.Domains)
if err != nil {
logic.ReturnErrorResponse(w, r, logic.FormatError(err, "badrequest"))
return
}
inetGw := logic.IsEgressReqInternetGateway(&req)
if inetGw {
normDomains = nil
}
req.Domains = normDomains
network := &schema.Network{Name: req.Network}
err = network.Get(r.Context())
if err != nil {
logic.ReturnErrorResponse(w, r, logic.FormatError(err, "badrequest"))
return
}
var egressRange string
egressType := logic.InferEgressType(&req)
if inetGw {
egressType = schema.EgressTypeInternet
egressRange = "*"
req.Domains = nil
req.PresetID = ""
} else if len(normDomains) > 0 {
egressRange = ""
if egressType == "" || egressType == schema.EgressTypeCIDR {
if req.PresetID != "" {
egressType = schema.EgressTypeApp
} else {
egressType = schema.EgressTypeDomain
}
}
} else if req.Range != "" {
egressRange, err = logic.NormalizeCIDR(req.Range)
if err != nil {
logic.ReturnErrorResponse(w, r, logic.FormatError(err, "badrequest"))
return
}
}
e := schema.Egress{ID: req.ID}
err = e.Get(db.WithContext(r.Context()))
if err != nil {
logic.ReturnErrorResponse(w, r, logic.FormatError(err, "badrequest"))
return
}
oldConfigured := logic.ConfiguredDomainsForEgress(e)
oldPresetID := e.PresetID
oldMode := e.Mode
oldStatus := e.Status
oldRoutingNodes := make(map[string]struct{}, len(e.Nodes))
for nodeID := range e.Nodes {
oldRoutingNodes[nodeID] = struct{}{}
}
e.Range = egressRange
e.Type = egressType
event := &models.Event{
Action: schema.Update,
Source: models.Subject{
ID: r.Header.Get("user"),
Name: r.Header.Get("user"),
Type: schema.UserSub,
},
TriggeredBy: r.Header.Get("user"),
Target: models.Subject{
ID: e.ID,
Name: e.Name,
Type: schema.EgressSub,
},
Diff: models.Diff{
Old: e,
},
NetworkID: schema.NetworkID(e.Network),
Origin: schema.Dashboard,
}
e.Nodes = make(datatypes.JSONMap)
e.Tags = make(datatypes.JSONMap)
if len(req.Tags) > 0 {
for tagID, metric := range req.Tags {
e.Tags[tagID] = metric
}
e.Nodes = make(datatypes.JSONMap)
} else {
for nodeID, metric := range req.Nodes {
e.Nodes[nodeID] = metric
}
}
if !logic.EgressDomainsEqual(oldConfigured, normDomains) {
logic.ClearEgressDomainAns(&e)
}
e.Description = req.Description
e.Name = req.Name
logic.ApplyConfiguredDomainsToEgress(&e, normDomains)
e.Status = req.Status
if req.PresetID != "" {
e.PresetID = req.PresetID
}
if !req.Nat {
e.Nat = false
e.Mode = schema.DisabledNAT
e.VirtualRange = ""
} else if req.Mode == schema.VirtualNAT {
e.Nat = true
e.Mode = schema.VirtualNAT
} else {
e.Nat = true
e.Mode = schema.DirectNAT
e.VirtualRange = ""
}
presetChanged := req.PresetID != "" && req.PresetID != oldPresetID
domainsChanged := !logic.EgressDomainsEqual(oldConfigured, normDomains)
if req.PresetID != "" && (presetChanged || domainsChanged) {
if p, ok := logic.GetEgressPresetByID(req.PresetID); ok && logic.PresetYieldsAWSIPRanges(p) {
if c, err := logic.ResolveAWSEgressPresetCIDRs(http.DefaultClient, p); err == nil && len(c) > 0 {
logic.SetEgressDomainAnsForDomains(&e, normDomains, c)
} else if err != nil {
logger.Log(0, "aws preset ip fetch failed:", req.PresetID, err.Error())
}
}
}
e.UpdatedAt = time.Now().UTC()
if err := logic.ValidateEgressReq(r.Context(), &e); err != nil {
logic.ReturnErrorResponse(w, r, logic.FormatError(err, "badrequest"))
return
}
if e.Nat && e.Mode == schema.VirtualNAT {
if (oldMode != schema.VirtualNAT) || (e.VirtualRange == "") {
if err := logic.AssignVirtualRangeToEgress(network, &e); err != nil {
logger.Log(0, "error assigning virtual range to egress: ", err.Error())
logic.ReturnErrorResponse(w, r, logic.FormatError(err, "badrequest"))
return
}
}
}
// Build update map with all fields including zero values
// GORM's Updates(&e) doesn't update zero values, so we use a map explicitly
updateMap := map[string]any{
"name": e.Name,
"description": e.Description,
"egress_type": e.Type,
"range": e.Range,
"domains": e.Domains,
"nat": e.Nat,
"mode": e.Mode,
"status": e.Status,
"nodes": e.Nodes,
"tags": e.Tags,
"domain_ans_by_domain": e.DomainAnsByDomain,
"virtual_range": e.VirtualRange,
"preset_id": e.PresetID,
"updated_at": e.UpdatedAt,
}
// Perform single update with all fields including zero values
err = db.FromContext(r.Context()).Table(e.Table()).Where("id = ?", e.ID).Updates(updateMap).Error
if err != nil {
logic.ReturnErrorResponse(
w,
r,
logic.FormatError(errors.New("error updating egress resource: "+err.Error()), "internal"),
)
return
}
event.Diff.New = e
logic.LogEvent(r.Context(), event)
internetRoutingChanged := false
if logic.IsEgressInternetGateway(e) {
newRoutingNodes := make(map[string]struct{}, len(e.Nodes))
for nodeID := range e.Nodes {
newRoutingNodes[nodeID] = struct{}{}
}
internetRoutingChanged = len(oldRoutingNodes) != len(newRoutingNodes)
if !internetRoutingChanged {
for id := range oldRoutingNodes {
if _, ok := newRoutingNodes[id]; !ok {
internetRoutingChanged = true
break
}
}
}
if internetRoutingChanged {
go func(eg schema.Egress) {
logic.RebindInternetEgressClients(r.Context(), eg)
ctx := scope.WithContext(db.WithContext(context.Background()), scope.Level(r.Context()), scope.ID(r.Context()))
_ = mq.PublishPeerUpdate(ctx, false)
}(e)
}
}
if len(normDomains) > 0 && !logic.HasEgressDomainAns(e) {
if req.Nodes != nil {
for nodeID := range req.Nodes {
node, err := logic.GetNodeByID(nodeID)
if err != nil {
continue
}
host := &schema.Host{
ID: node.HostID,
}
err = host.Get(r.Context())
if err != nil {
continue
}
for _, dom := range normDomains {
mq.HostUpdate(&models.HostUpdate{
Action: models.EgressUpdate,
Host: *host,
EgressDomain: models.EgressDomain{
ID: e.ID,
Host: *host,
Node: node,
Domain: dom,
},
Node: node,
})
}
}
}
}
// Internet egress disabled: keep sticky selection, but push exit clients first so
// they fail open (drop full tunnel) before the global peer update.
ctx := scope.WithContext(db.WithContext(context.Background()), scope.Level(r.Context()), scope.ID(r.Context()))
if oldStatus && !e.Status && logic.IsEgressInternetGateway(e) {
clients := logic.ListNodesBySelectedInternetEgress(r.Context(), e.Network, e.ID)
go func(clients []models.Node) {
_ = mq.PublishPeerUpdatesForExitClientsFirst(ctx, clients)
}(clients)
} else if !internetRoutingChanged {
go mq.PublishPeerUpdate(ctx, false)
}
logic.ReturnSuccessResponseWithJson(w, r, e, "updated egress resource")
}
// @Summary Delete Egress Resource
// @Router /api/v1/egress [delete]
// @Tags Egress
// @Security oauth
// @Produce json
// @Param id query string true "Egress resource ID"
// @Success 200 {object} models.SuccessResponse
// @Failure 400 {object} models.ErrorResponse
// @Failure 401 {object} models.ErrorResponse
// @Failure 500 {object} models.ErrorResponse
func deleteEgress(w http.ResponseWriter, r *http.Request) {
id := r.URL.Query().Get("id")
if id == "" {
logic.ReturnErrorResponse(w, r, logic.FormatError(errors.New("id is required"), "badrequest"))
return
}
e := schema.Egress{ID: id}
err := e.Get(db.WithContext(r.Context()))
if err != nil {
logic.ReturnErrorResponse(w, r, logic.FormatError(err, logic.BadReq))
return
}
err = e.Delete(db.WithContext(r.Context()))
if err != nil {
logic.ReturnErrorResponse(w, r, logic.FormatError(err, logic.Internal))
return
}
if logic.IsEgressInternetGateway(e) {
logic.ClearNodesSelectedInternetEgress(r.Context(), e.ID, e.Network)
}
logic.LogEvent(r.Context(), &models.Event{
Action: schema.Delete,
Source: models.Subject{
ID: r.Header.Get("user"),
Name: r.Header.Get("user"),
Type: schema.UserSub,
},
TriggeredBy: r.Header.Get("user"),
Target: models.Subject{
ID: e.ID,
Name: e.Name,
Type: schema.EgressSub,
},
NetworkID: schema.NetworkID(e.Network),
Origin: schema.Dashboard,
Diff: models.Diff{
Old: e,
New: nil,
},
})
// delete related acl policies
acls := logic.ListAcls(r.Context())
for _, acl := range acls {
for i := len(acl.Dst) - 1; i >= 0; i-- {
if acl.Dst[i].ID == models.EgressID && acl.Dst[i].Value == id {
acl.Dst = append(acl.Dst[:i], acl.Dst[i+1:]...)
}
}
if len(acl.Dst) == 0 {
logic.DeleteAcl(r.Context(), acl)
} else {
logic.UpsertAcl(r.Context(), acl)
}
}
ctx := scope.WithContext(db.WithContext(context.Background()), scope.Level(r.Context()), scope.ID(r.Context()))
go mq.PublishPeerUpdate(ctx, false)
logic.ReturnSuccessResponseWithJson(w, r, nil, "deleted egress resource")
}