mirror of
https://github.com/netbirdio/netbird.git
synced 2026-10-14 01:19:09 +02:00
With the validator derived and the helper in place, wire both together: reads and writes hand back an ETag, and PUT and DELETE honour an If-Match against it. A client that plans against one read and writes the whole object back now gets refused instead of silently reverting whatever changed in between. The comparison happens inside the write's own transaction, under the row lock the fetch already takes, rather than in the handler. Comparing before the transaction merely narrows the race: two writers read the same row, both find their precondition satisfied, and both then write. That is the lost update this is meant to prevent, and the concurrency test added here fails against that shape while passing against this one. It also runs ahead of each operation's other guards. A caller stale enough to be holding an old validator is stale in its identity echo too, so answering "you are working from an old read" is more useful than "the endpoint is immutable"; on delete it matters more still, because the provider guard raises the same 412 and would send a stale client hunting for providers it may not know exist. If-Match stays optional. The dashboard is human-driven, where last-write-wins is acceptable and a surprise 412 is worse than a benign overwrite; Terraform is machine-driven and can opt in. Making it mandatory would cost a dashboard change and should use 428 when that day comes. GET emits a validator for the pre-bootstrap defaults as well: they are a representation like any other, and validating them means an If-Match taken before bootstrap cannot match the row that appeared since.
146 lines
5.5 KiB
Go
146 lines
5.5 KiB
Go
package handlers
|
|
|
|
import (
|
|
"encoding/json"
|
|
"net/http"
|
|
|
|
"github.com/gorilla/mux"
|
|
|
|
"github.com/netbirdio/netbird/management/internals/modules/agentnetwork/types"
|
|
nbcontext "github.com/netbirdio/netbird/management/server/context"
|
|
"github.com/netbirdio/netbird/shared/management/http/api"
|
|
"github.com/netbirdio/netbird/shared/management/http/util"
|
|
)
|
|
|
|
// addSettingsEndpoints registers the Agent Network settings routes. POST
|
|
// bootstraps the settings row, assigning the account's immutable endpoint;
|
|
// GET reads it (defaults with an empty endpoint before bootstrap); PUT
|
|
// carries every field, replacing the mutable collection toggles and rejecting
|
|
// any change to the identity fields; DELETE removes the row — guarded so it
|
|
// stays a bootstrap-repair operation — releasing the endpoint for a fresh
|
|
// bootstrap.
|
|
func (h *handler) addSettingsEndpoints(router *mux.Router) {
|
|
router.HandleFunc("/agent-network/settings", h.getSettings).Methods("GET", "OPTIONS")
|
|
router.HandleFunc("/agent-network/settings", h.createSettings).Methods("POST", "OPTIONS")
|
|
router.HandleFunc("/agent-network/settings", h.updateSettings).Methods("PUT", "OPTIONS")
|
|
router.HandleFunc("/agent-network/settings", h.deleteSettings).Methods("DELETE", "OPTIONS")
|
|
}
|
|
|
|
// createSettings bootstraps the account's settings row. Exactly one of
|
|
// proxy_address (labeled endpoint; the server allocates the label) and
|
|
// endpoint (self-addressed, claimed verbatim) must be provided; optional
|
|
// collection toggles ride along with defaults for omitted fields.
|
|
func (h *handler) createSettings(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.AgentNetworkSettingsCreateRequest
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
util.WriteErrorResponse("couldn't parse JSON request", http.StatusBadRequest, w)
|
|
return
|
|
}
|
|
|
|
settings := types.DefaultSettings(userAuth.AccountId)
|
|
settings.FromAPICreateRequest(&req)
|
|
|
|
proxyAddress := ""
|
|
if req.ProxyAddress != nil {
|
|
proxyAddress = *req.ProxyAddress
|
|
}
|
|
endpoint := ""
|
|
if req.Endpoint != nil {
|
|
endpoint = *req.Endpoint
|
|
}
|
|
|
|
created, err := h.manager.CreateSettings(r.Context(), userAuth.UserId, settings, proxyAddress, endpoint)
|
|
if err != nil {
|
|
util.WriteError(r.Context(), err, w)
|
|
return
|
|
}
|
|
// Emitting the validator here lets a client that just bootstrapped issue a
|
|
// conditional PUT without an intervening GET.
|
|
util.SetETag(w, created.ETag())
|
|
util.WriteJSONObject(r.Context(), w, created.ToAPIResponse())
|
|
}
|
|
|
|
// updateSettings replaces the mutable settings fields on the account's row.
|
|
// A request carrying a cluster bootstraps the row when the account doesn't
|
|
// have one yet.
|
|
//
|
|
// An If-Match header makes the update conditional: it is honoured against the
|
|
// stored row inside the write's transaction, and a stale validator is refused
|
|
// with 412 rather than overwriting what changed since the client read. Omitting
|
|
// the header keeps the pre-existing last-write-wins behaviour.
|
|
func (h *handler) updateSettings(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.AgentNetworkSettingsRequest
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
util.WriteErrorResponse("couldn't parse JSON request", http.StatusBadRequest, w)
|
|
return
|
|
}
|
|
|
|
settings := &types.Settings{AccountID: userAuth.AccountId}
|
|
settings.FromAPIRequest(&req)
|
|
|
|
updated, err := h.manager.UpdateSettings(r.Context(), userAuth.UserId, settings, util.IfMatch(r))
|
|
if err != nil {
|
|
util.WriteError(r.Context(), err, w)
|
|
return
|
|
}
|
|
util.SetETag(w, updated.ETag())
|
|
util.WriteJSONObject(r.Context(), w, updated.ToAPIResponse())
|
|
}
|
|
|
|
// deleteSettings removes the account's settings row, releasing the endpoint.
|
|
// The manager refuses (412) while providers exist or a proxy is actively
|
|
// serving the endpoint; a later POST bootstraps fresh, allocating a new
|
|
// endpoint.
|
|
//
|
|
// An If-Match header makes the delete conditional, and is worth sending here
|
|
// even more than on update: both existing guards are about state rather than
|
|
// staleness, so nothing else stops a client from deleting a row that was
|
|
// replaced since it read one.
|
|
func (h *handler) deleteSettings(w http.ResponseWriter, r *http.Request) {
|
|
userAuth, err := nbcontext.GetUserAuthFromContext(r.Context())
|
|
if err != nil {
|
|
util.WriteError(r.Context(), err, w)
|
|
return
|
|
}
|
|
|
|
if err := h.manager.DeleteSettings(r.Context(), userAuth.AccountId, userAuth.UserId, util.IfMatch(r)); err != nil {
|
|
util.WriteError(r.Context(), err, w)
|
|
return
|
|
}
|
|
util.WriteJSONObject(r.Context(), w, util.EmptyObject{})
|
|
}
|
|
|
|
// getSettings returns the account's agent-network settings. Accounts that
|
|
// haven't been bootstrapped yet read as the defaults with an empty cluster,
|
|
// subdomain and endpoint; the manager synthesises that view.
|
|
func (h *handler) getSettings(w http.ResponseWriter, r *http.Request) {
|
|
userAuth, err := nbcontext.GetUserAuthFromContext(r.Context())
|
|
if err != nil {
|
|
util.WriteError(r.Context(), err, w)
|
|
return
|
|
}
|
|
|
|
settings, err := h.manager.GetSettings(r.Context(), userAuth.AccountId, userAuth.UserId)
|
|
if err != nil {
|
|
util.WriteError(r.Context(), err, w)
|
|
return
|
|
}
|
|
// The pre-bootstrap defaults are a representation like any other and carry
|
|
// a validator too, so an If-Match taken before bootstrap cannot silently
|
|
// match the row that appeared since.
|
|
util.SetETag(w, settings.ETag())
|
|
util.WriteJSONObject(r.Context(), w, settings.ToAPIResponse())
|
|
}
|