Files
Abhishek Kondur c3c3ed1fb8 NM-254: add bulk delete apis, rm old acl code (#3937)
* NM-254: add bulk delete apis for users, hosts, nodes and optimise postgres connection settings

* NM-254: rm debug logs

* NM-254: add bulk delete apis, remove old acl code

* NM-254: rm unused flag

* NM-254: fix bulk delete bugs, add security and performance improvements

- Fix host delete notifying peers before confirming deletion from DB
- Fix self-delete vulnerability in bulk user delete
- Fix DissasociateNodeFromHost failing when host.Nodes is empty
- Fix AssociateNodeToHost/DissasociateNodeFromHost stale read race
- Hoist GetAllExtClients outside loop in bulk user delete/status
- Move initializeUUID outside master-pod guard for HA correctness

* NM-254: return 202 Accepted for async bulk APIs, fix relay allowedIPs and host association error handling

- Change all bulk endpoints (hosts, nodes, users, ext clients) from
  200 OK to 202 Accepted to correctly signal async processing
- Add ReturnAcceptedResponse helper in logic/errors.go
- Fix GetAllowedIpsForRelayed returning empty allowedIPs slice,
  restoring relay connectivity
- Make AssociateNodeToHost and DissasociateNodeFromHost return an
  error when the host DB re-fetch fails instead of silently using
  stale data
- Add bulk-apis.md documenting all five bulk endpoints

* NM-254: rm coredns container

* NM-254: add bulk apis for node,extclient status, add activity logs to bulk apis

* NM-254: add bulk api for connection toggle

* NM-254: add network check

* Update controllers/hosts.go

Co-authored-by: tenki-reviewer[bot] <262613592+tenki-reviewer[bot]@users.noreply.github.com>

* NM-254: optimise bulk extclient deletion

---------

Co-authored-by: tenki-reviewer[bot] <262613592+tenki-reviewer[bot]@users.noreply.github.com>
2026-03-26 10:15:07 +05:30

450 lines
14 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package controller
import (
"encoding/json"
"errors"
"fmt"
"net/http"
"slices"
"time"
"github.com/go-playground/validator/v10"
"github.com/google/uuid"
"github.com/gorilla/mux"
"github.com/gravitl/netmaker/schema"
"github.com/gravitl/netmaker/auth"
"github.com/gravitl/netmaker/logger"
"github.com/gravitl/netmaker/logic"
"github.com/gravitl/netmaker/models"
"github.com/gravitl/netmaker/mq"
"github.com/gravitl/netmaker/servercfg"
"golang.org/x/exp/slog"
)
func enrollmentKeyHandlers(r *mux.Router) {
r.HandleFunc("/api/v1/enrollment-keys", logic.SecurityCheck(true, http.HandlerFunc(createEnrollmentKey))).
Methods(http.MethodPost)
r.HandleFunc("/api/v1/enrollment-keys", logic.SecurityCheck(true, http.HandlerFunc(getEnrollmentKeys))).
Methods(http.MethodGet)
r.HandleFunc("/api/v1/enrollment-keys/{keyID}", logic.SecurityCheck(true, http.HandlerFunc(deleteEnrollmentKey))).
Methods(http.MethodDelete)
r.HandleFunc("/api/v1/host/register/{token}", http.HandlerFunc(handleHostRegister)).
Methods(http.MethodPost)
r.HandleFunc("/api/v1/enrollment-keys/{keyID}", logic.SecurityCheck(true, http.HandlerFunc(updateEnrollmentKey))).
Methods(http.MethodPut)
}
// @Summary Lists all EnrollmentKeys for admins
// @Router /api/v1/enrollment-keys [get]
// @Tags EnrollmentKeys
// @Security oauth
// @Produce json
// @Success 200 {array} models.EnrollmentKey
// @Failure 500 {object} models.ErrorResponse
func getEnrollmentKeys(w http.ResponseWriter, r *http.Request) {
keys, err := logic.GetAllEnrollmentKeys()
if err != nil {
logger.Log(0, r.Header.Get("user"), "failed to fetch enrollment keys: ", err.Error())
logic.ReturnErrorResponse(w, r, logic.FormatError(err, "internal"))
return
}
ret := []*models.EnrollmentKey{}
for _, key := range keys {
key := key
if err = logic.Tokenize(&key, servercfg.GetAPIHost()); err != nil {
logger.Log(0, r.Header.Get("user"), "failed to get token values for keys:", err.Error())
logic.ReturnErrorResponse(w, r, logic.FormatError(err, "internal"))
return
}
ret = append(ret, &key)
}
// return JSON/API formatted keys
logger.Log(2, r.Header.Get("user"), "fetched enrollment keys")
w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(ret)
}
// @Summary Deletes an EnrollmentKey from Netmaker server
// @Router /api/v1/enrollment-keys/{keyID} [delete]
// @Tags EnrollmentKeys
// @Security oauth
// @Param keyID path string true "Enrollment Key ID"
// @Success 200 {string} string
// @Failure 500 {object} models.ErrorResponse
func deleteEnrollmentKey(w http.ResponseWriter, r *http.Request) {
params := mux.Vars(r)
keyID := params["keyID"]
key, err := logic.GetEnrollmentKey(keyID)
if err != nil {
logic.ReturnErrorResponse(w, r, logic.FormatError(err, "badrequest"))
return
}
err = logic.DeleteEnrollmentKey(keyID, false)
if err != nil {
logger.Log(0, r.Header.Get("user"), "failed to remove enrollment key: ", err.Error())
logic.ReturnErrorResponse(w, r, logic.FormatError(err, "internal"))
return
}
logic.LogEvent(&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: keyID,
Name: key.Tags[0],
Type: schema.EnrollmentKeySub,
},
Origin: schema.Dashboard,
Diff: models.Diff{
Old: key,
New: nil,
},
})
logger.Log(2, r.Header.Get("user"), "deleted enrollment key", keyID)
w.WriteHeader(http.StatusOK)
}
// @Summary Creates an EnrollmentKey for hosts to register with server and join networks
// @Router /api/v1/enrollment-keys [post]
// @Tags EnrollmentKeys
// @Security oauth
// @Accept json
// @Produce json
// @Param body body models.APIEnrollmentKey true "Enrollment Key parameters"
// @Success 200 {object} models.EnrollmentKey
// @Failure 400 {object} models.ErrorResponse
// @Failure 500 {object} models.ErrorResponse
func createEnrollmentKey(w http.ResponseWriter, r *http.Request) {
var enrollmentKeyBody models.APIEnrollmentKey
err := json.NewDecoder(r.Body).Decode(&enrollmentKeyBody)
if err != nil {
logger.Log(0, r.Header.Get("user"), "error decoding request body: ",
err.Error())
logic.ReturnErrorResponse(w, r, logic.FormatError(err, "badrequest"))
return
}
var newTime time.Time
if enrollmentKeyBody.Expiration > 0 {
newTime = time.Unix(enrollmentKeyBody.Expiration, 0)
}
v := validator.New()
err = v.Struct(enrollmentKeyBody)
if err != nil {
logger.Log(0, r.Header.Get("user"), "error validating request body: ",
err.Error())
logic.ReturnErrorResponse(
w,
r,
logic.FormatError(
fmt.Errorf("validation error: name length must be between 3 and 32: %w", err),
"badrequest",
),
)
return
}
if existingKeys, err := logic.GetAllEnrollmentKeys(); err != nil {
logger.Log(0, r.Header.Get("user"), "error validating request body: ",
err.Error())
logic.ReturnErrorResponse(w, r, logic.FormatError(err, "internal"))
return
} else {
// check if any tags are duplicate
existingTags := make(map[string]struct{})
for _, existingKey := range existingKeys {
for _, t := range existingKey.Tags {
existingTags[t] = struct{}{}
}
}
for _, t := range enrollmentKeyBody.Tags {
if _, ok := existingTags[t]; ok {
logic.ReturnErrorResponse(w, r, logic.FormatError(fmt.Errorf("key names must be unique"), "badrequest"))
return
}
}
}
relayId := uuid.Nil
if enrollmentKeyBody.Relay != "" {
relayId, err = uuid.Parse(enrollmentKeyBody.Relay)
if err != nil {
logger.Log(0, r.Header.Get("user"), "error parsing relay id: ", err.Error())
logic.ReturnErrorResponse(w, r, logic.FormatError(err, "badrequest"))
return
}
}
newEnrollmentKey, err := logic.CreateEnrollmentKey(
enrollmentKeyBody.UsesRemaining,
newTime,
enrollmentKeyBody.Networks,
enrollmentKeyBody.Tags,
enrollmentKeyBody.Groups,
enrollmentKeyBody.Unlimited,
relayId,
false,
enrollmentKeyBody.AutoEgress,
enrollmentKeyBody.AutoAssignGateway,
)
if err != nil {
logger.Log(0, r.Header.Get("user"), "failed to create enrollment key:", err.Error())
logic.ReturnErrorResponse(w, r, logic.FormatError(err, "internal"))
return
}
if err = logic.Tokenize(newEnrollmentKey, servercfg.GetAPIHost()); err != nil {
logger.Log(0, r.Header.Get("user"), "failed to create enrollment key:", err.Error())
logic.ReturnErrorResponse(w, r, logic.FormatError(err, "internal"))
return
}
logic.LogEvent(&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: newEnrollmentKey.Value,
Name: newEnrollmentKey.Tags[0],
Type: schema.EnrollmentKeySub,
},
Origin: schema.Dashboard,
})
logger.Log(2, r.Header.Get("user"), "created enrollment key")
w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(newEnrollmentKey)
}
// @Summary Updates an EnrollmentKey
// @Router /api/v1/enrollment-keys/{keyID} [put]
// @Tags EnrollmentKeys
// @Security oauth
// @Accept json
// @Produce json
// @Param keyID path string true "Enrollment Key ID"
// @Param body body models.APIEnrollmentKey true "Enrollment Key parameters"
// @Success 200 {object} models.EnrollmentKey
// @Failure 400 {object} models.ErrorResponse
// @Failure 500 {object} models.ErrorResponse
func updateEnrollmentKey(w http.ResponseWriter, r *http.Request) {
var enrollmentKeyBody models.APIEnrollmentKey
params := mux.Vars(r)
keyId := params["keyID"]
err := json.NewDecoder(r.Body).Decode(&enrollmentKeyBody)
if err != nil {
slog.Error("error decoding request body", "error", err)
logic.ReturnErrorResponse(w, r, logic.FormatError(err, "badrequest"))
return
}
if enrollmentKeyBody.Relay != "" {
_, err = uuid.Parse(enrollmentKeyBody.Relay)
if err != nil {
slog.Error("error parsing relay id", "error", err)
logic.ReturnErrorResponse(w, r, logic.FormatError(err, "badrequest"))
return
}
}
currKey, _ := logic.GetEnrollmentKey(keyId)
newEnrollmentKey, err := logic.UpdateEnrollmentKey(keyId, &enrollmentKeyBody)
if err != nil {
slog.Error("failed to update enrollment key", "error", err)
logic.ReturnErrorResponse(w, r, logic.FormatError(err, "internal"))
return
}
if err = logic.Tokenize(newEnrollmentKey, servercfg.GetAPIHost()); err != nil {
slog.Error("failed to update enrollment key", "error", err)
logic.ReturnErrorResponse(w, r, logic.FormatError(err, "internal"))
return
}
logic.LogEvent(&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: newEnrollmentKey.Value,
Name: newEnrollmentKey.Tags[0],
Type: schema.EnrollmentKeySub,
},
Diff: models.Diff{
Old: currKey,
New: newEnrollmentKey,
},
Origin: schema.Dashboard,
})
slog.Info("updated enrollment key", "id", keyId)
w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(newEnrollmentKey)
}
// @Summary Handles a Netclient registration with server and add nodes accordingly
// @Router /api/v1/host/register/{token} [post]
// @Tags EnrollmentKeys
// @Accept json
// @Produce json
// @Param token path string true "Enrollment Key Token"
// @Param body body schema.Host true "Host registration parameters"
// @Success 200 {object} models.RegisterResponse
// @Failure 400 {object} models.ErrorResponse
// @Failure 500 {object} models.ErrorResponse
func handleHostRegister(w http.ResponseWriter, r *http.Request) {
params := mux.Vars(r)
token := params["token"]
logger.Log(0, "received registration attempt with token", token)
// check if token exists
enrollmentKey, err := logic.DeTokenize(token)
if err != nil {
logger.Log(0, "invalid enrollment key used", token, err.Error())
logic.ReturnErrorResponse(w, r, logic.FormatError(err, "badrequest"))
return
}
// get the host
var newHost schema.Host
if err = json.NewDecoder(r.Body).Decode(&newHost); err != nil {
logger.Log(0, r.Header.Get("user"), "error decoding request body: ",
err.Error())
logic.ReturnErrorResponse(w, r, logic.FormatError(err, "internal"))
return
}
// check if host already exists
hostExists := false
if hostExists = logic.HostExists(&newHost); hostExists && len(enrollmentKey.Networks) == 0 {
logger.Log(
0,
"host",
newHost.ID.String(),
newHost.Name,
"attempted to re-register with no networks",
)
logic.ReturnErrorResponse(
w,
r,
logic.FormatError(fmt.Errorf("host already exists"), "badrequest"),
)
return
}
// version check
if !logic.IsVersionCompatible(newHost.Version) {
err := fmt.Errorf("bad client version on register: %s", newHost.Version)
logic.ReturnErrorResponse(w, r, logic.FormatError(err, "badrequest"))
return
}
if newHost.TrafficKeyPublic == nil && newHost.OS != models.OS_Types.IoT {
err := fmt.Errorf("missing traffic key")
logic.ReturnErrorResponse(w, r, logic.FormatError(err, "badrequest"))
return
}
key, keyErr := logic.RetrievePublicTrafficKey()
if keyErr != nil {
logger.Log(0, "error retrieving key:", keyErr.Error())
logic.ReturnErrorResponse(w, r, logic.FormatError(keyErr, "internal"))
return
}
// use the token
if ok := logic.TryToUseEnrollmentKey(enrollmentKey); !ok {
logger.Log(0, "host", newHost.ID.String(), newHost.Name, "failed registration")
logic.ReturnErrorResponse(
w,
r,
logic.FormatError(fmt.Errorf("invalid enrollment key"), "badrequest"),
)
return
}
pcviolations := []models.Violation{}
skipViolatedNetworks := []string{}
keyTags := make(map[models.TagID]struct{})
if len(enrollmentKey.Groups) > 0 {
for _, tagI := range enrollmentKey.Groups {
keyTags[tagI] = struct{}{}
}
}
for _, netI := range enrollmentKey.Networks {
violations, _ := logic.CheckPostureViolations(models.PostureCheckDeviceInfo{
ClientLocation: newHost.CountryCode,
ClientVersion: newHost.Version,
OS: newHost.OS,
OSFamily: newHost.OSFamily,
OSVersion: newHost.OSVersion,
KernelVersion: newHost.KernelVersion,
SkipAutoUpdate: true,
Tags: keyTags,
}, schema.NetworkID(netI))
pcviolations = append(pcviolations, violations...)
if len(violations) > 0 {
skipViolatedNetworks = append(skipViolatedNetworks, netI)
}
}
if len(skipViolatedNetworks) == len(enrollmentKey.Networks) && len(pcviolations) > 0 {
logic.ReturnErrorResponse(w, r,
logic.FormatError(errors.New("access blocked: this device doesnt meet security requirements"), logic.Forbidden))
return
}
// need to remove the networks that were skipped from the enrollment key
enrollmentKey.Networks = slices.DeleteFunc(enrollmentKey.Networks, func(netI string) bool {
return slices.Contains(skipViolatedNetworks, netI)
})
var host *schema.Host
if !hostExists {
newHost.PersistentKeepalive = models.DefaultPersistentKeepAlive
// register host
_ = logic.CheckHostPorts(&newHost)
// create EMQX credentials and ACLs for host
if servercfg.GetBrokerType() == servercfg.EmqxBrokerType {
if err := mq.GetEmqxHandler().CreateEmqxUser(newHost.ID.String(), newHost.HostPass); err != nil {
logger.Log(0, "failed to create host credentials for EMQX: ", err.Error())
return
}
}
if err = logic.CreateHost(&newHost); err != nil {
logic.ReturnErrorResponse(w, r, logic.FormatError(err, "internal"))
return
}
host = &newHost
} else {
currHost := &schema.Host{
ID: newHost.ID,
}
err := currHost.Get(r.Context())
if err != nil {
logic.ReturnErrorResponse(w, r, logic.FormatError(err, "internal"))
return
}
logic.UpdateHostFromClient(&newHost, currHost)
if err = logic.UpsertHost(currHost); err != nil {
logic.ReturnErrorResponse(w, r, logic.FormatError(err, "internal"))
return
}
host = currHost
}
// ready the response
server := logic.GetServerInfo()
server.TrafficKey = key
response := models.RegisterResponse{
ServerConf: server,
RequestedHost: *host,
}
logger.Log(0, host.Name, host.ID.String(), "registered with Netmaker")
w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(&response)
// notify host of changes, peer and node updates
go auth.CheckNetRegAndHostUpdate(*enrollmentKey, host, r.Header.Get("user"))
}