Skip to content
Open
Show file tree
Hide file tree
Changes from 10 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
83 changes: 83 additions & 0 deletions controllers/hosts.go
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,10 @@ func hostHandlers(r *mux.Router) {
Methods(http.MethodPut)
r.HandleFunc("/api/v1/host/{hostid}/peer_info", AuthorizeHost(http.HandlerFunc(getHostPeerInfo))).
Methods(http.MethodGet)
r.HandleFunc("/api/v1/host/{hostid}/posture_status", AuthorizeHost(http.HandlerFunc(getHostPostureStatus))).
Methods(http.MethodGet)
r.HandleFunc("/api/v1/host/{hostid}/posture_status/ui", logic.SecurityCheck(true, http.HandlerFunc(getHostPostureStatus))).
Methods(http.MethodGet)
Comment thread
abhishek9686 marked this conversation as resolved.
r.HandleFunc("/api/v1/pending_hosts", logic.SecurityCheck(true, http.HandlerFunc(getPendingHosts))).
Methods(http.MethodGet)
r.HandleFunc("/api/v1/pending_hosts/approve/{id}", logic.SecurityCheck(true, http.HandlerFunc(approvePendingHost))).
Expand Down Expand Up @@ -1643,6 +1647,85 @@ func getHostPeerInfo(w http.ResponseWriter, r *http.Request) {
logic.ReturnSuccessResponseWithJson(w, r, peerInfo, "fetched host peer info")
}

// @Summary Get the host's last-evaluated posture status
// @Router /api/v1/host/{hostid}/posture_status [get]
// @Tags Hosts
// @Security oauth
// @Produce json
// @Param hostid path string true "Host ID"
// @Success 200 {object} models.HostPostureStatus
// @Failure 400 {object} models.ErrorResponse
func getHostPostureStatus(w http.ResponseWriter, r *http.Request) {
hostIDStr := mux.Vars(r)["hostid"]
hostID, err := uuid.Parse(hostIDStr)
if err != nil {
logic.ReturnErrorResponse(w, r, logic.FormatError(fmt.Errorf("failed to parse host id: %w", err), logic.BadReq))
return
}

host := &schema.Host{ID: hostID}
if err := host.Get(r.Context()); err != nil {
logic.ReturnErrorResponse(w, r, models.ErrorResponse{Code: http.StatusBadRequest, Message: err.Error()})
return
}

resp := models.HostPostureStatus{
HostID: hostIDStr,
Networks: []models.NetworkPostureStatus{},
}

// MDM block - best-effort: only populated if an integration is configured and
// a sync row exists for the host.
mdmIntg := &schema.Integration{Type: "mdm"}
if mdmIntegrations, err := mdmIntg.ListByType(r.Context()); err == nil && len(mdmIntegrations) > 0 {
state := &schema.DeviceMDMState{HostID: hostIDStr, Provider: mdmIntegrations[0].ID}
if err := state.Get(r.Context()); err == nil {
resp.MDM = &models.HostMDMStatus{
Provider: state.Provider,
MatchedBy: state.MatchedBy,
Enrolled: state.Enrolled,
Compliant: state.Compliant,
LastSyncedAt: state.LastSyncedAt,
}
}
}

// Per-network status - copy from already-evaluated nodes belonging to the
// host. No new posture computation happens on this read path (v1).
nodes, err := logic.GetAllNodes()
if err != nil {
logic.ReturnErrorResponse(w, r, models.ErrorResponse{Code: http.StatusInternalServerError, Message: err.Error()})
return
}
var latest time.Time
for _, n := range nodes {
if n.HostID != hostID || n.IsStatic {
continue
}
entry := models.NetworkPostureStatus{
NetworkID: n.Network,
NodeID: n.ID.String(),
Severity: n.PostureCheckViolationSeverityLevel,
Violations: append([]models.Violation{}, n.PostureChecksViolations...),
}
switch {
case len(entry.Violations) == 0:
entry.Status = models.PostureStatusPass
case entry.Severity >= schema.SeverityHigh:
entry.Status = models.PostureStatusFail
default:
entry.Status = models.PostureStatusWarn
}
if n.LastEvaluatedAt.After(latest) {
latest = n.LastEvaluatedAt
}
resp.Networks = append(resp.Networks, entry)
}
resp.EvaluatedAt = latest

logic.ReturnSuccessResponseWithJson(w, r, resp, "fetched posture status")
}

// @Summary List pending hosts in a network
// @Router /api/v1/pending_hosts [get]
// @Tags Hosts
Expand Down
1 change: 0 additions & 1 deletion controllers/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -232,7 +232,6 @@ func getSettings(w http.ResponseWriter, r *http.Request) {
if scfg.OktaAPIToken != "" {
scfg.OktaAPIToken = logic.Mask()
}

logic.ReturnSuccessResponseWithJson(w, r, scfg, "fetched server settings successfully")
}

Expand Down
20 changes: 20 additions & 0 deletions logic/hosts.go
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,11 @@ var GetPostureCheckDeviceInfoByNode = func(node *models.Node) (d models.PostureC
return
}

// SyncHostMDMState refreshes MDM posture state for a host (no-op in community).
var SyncHostMDMState = func(ctx context.Context, hostID string) error {
return nil
}

const (
maxPort = 1<<16 - 1
minPort = 1025
Expand Down Expand Up @@ -245,6 +250,21 @@ func UpdateHostFromClient(newHost, currHost *schema.Host) (isEndpointChanged, se
if newHost.Interface != "" {
currHost.Interface = newHost.Interface
}
// MDM device-matching identifiers: only overwrite if the netclient reported
// a non-empty value, so we don't clobber a previously reported identifier
// when a later check-in omits the field.
if newHost.EntraDeviceID != "" {
currHost.EntraDeviceID = newHost.EntraDeviceID
}
if newHost.SerialNumber != "" {
currHost.SerialNumber = newHost.SerialNumber
}
if newHost.HardwareUUID != "" {
currHost.HardwareUUID = newHost.HardwareUUID
}
if newHost.UserEmail != "" {
currHost.UserEmail = newHost.UserEmail
}
if isEndpointChanged || currHost.Location == "" || currHost.CountryCode == "" {
var nodeIP net.IP
if currHost.EndpointIP != nil {
Expand Down
8 changes: 8 additions & 0 deletions models/host.go
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,14 @@ type Host struct {
Location string `json:"location"` // Format: "lat,lon"
CountryCode string `json:"country_code"`
EnableFlowLogs bool `json:"enable_flow_logs" yaml:"enable_flow_logs"`

// MDM device-matching identifiers. Reported by netclient on host check-in
// and consumed by the MDM sync worker to match a Netmaker host to its
// upstream MDM-managed device record.
EntraDeviceID string `json:"entra_device_id" yaml:"entra_device_id"`
SerialNumber string `json:"serial_number" yaml:"serial_number"`
HardwareUUID string `json:"hardware_uuid" yaml:"hardware_uuid"`
UserEmail string `json:"user_email" yaml:"user_email"`
}

// FormatBool converts a boolean to a [yes|no] string
Expand Down
42 changes: 42 additions & 0 deletions models/posture_status.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
package models

import (
"time"

"github.com/gravitl/netmaker/schema"
)

// HostPostureStatus is the netclient-facing summary of a host's last evaluated
// posture state. Returned by GET /api/v1/host/{hostid}/posture_status.
type HostPostureStatus struct {
HostID string `json:"host_id"`
EvaluatedAt time.Time `json:"evaluated_at"`
MDM *HostMDMStatus `json:"mdm,omitempty"`
Networks []NetworkPostureStatus `json:"networks"`
}

// HostMDMStatus is the current MDM sync snapshot for the host's configured
// MDM provider (if any).
type HostMDMStatus struct {
Provider string `json:"provider"`
MatchedBy string `json:"matched_by"`
Enrolled bool `json:"enrolled"`
Compliant bool `json:"compliant"`
LastSyncedAt time.Time `json:"last_synced_at"`
}

// NetworkPostureStatus describes posture state for a single (host, network).
type NetworkPostureStatus struct {
NetworkID string `json:"network_id"`
NodeID string `json:"node_id"`
Severity schema.Severity `json:"severity"`
Status string `json:"status"` // pass | warn | fail
Violations []Violation `json:"violations"`
}

// Network posture status values.
const (
PostureStatusPass = "pass"
PostureStatusWarn = "warn"
PostureStatusFail = "fail"
)
5 changes: 5 additions & 0 deletions models/structs.go
Original file line number Diff line number Diff line change
Expand Up @@ -476,6 +476,11 @@ type PostureCheckDeviceInfo struct {
Tags map[TagID]struct{}
IsUser bool
UserGroups map[schema.UserGroupID]struct{}
// HostID is the Netmaker host's UUID; used to look up MDM state.
HostID string
// MDMState is the most recent sync snapshot for the configured MDM
// provider; nil if MDM is not configured or the host hasn't synced yet.
MDMState *schema.DeviceMDMState
}

type Violation struct {
Expand Down
27 changes: 27 additions & 0 deletions mq/handlers.go
Original file line number Diff line number Diff line change
Expand Up @@ -414,6 +414,33 @@ func HandleHostCheckin(h, currentHost *schema.Host) bool {
slog.Info("updated host after check-in", "name", currentHost.Name, "id", currentHost.ID)
}

// Persist MDM device-matching identifiers if the netclient reported any
// new values. These don't affect peers, so don't roll into ifaceDelta.
mdmChanged := false
if h.EntraDeviceID != "" && h.EntraDeviceID != currentHost.EntraDeviceID {
currentHost.EntraDeviceID = h.EntraDeviceID
mdmChanged = true
}
if h.SerialNumber != "" && h.SerialNumber != currentHost.SerialNumber {
currentHost.SerialNumber = h.SerialNumber
mdmChanged = true
}
if h.HardwareUUID != "" && h.HardwareUUID != currentHost.HardwareUUID {
currentHost.HardwareUUID = h.HardwareUUID
mdmChanged = true
}
if h.UserEmail != "" && h.UserEmail != currentHost.UserEmail {
currentHost.UserEmail = h.UserEmail
mdmChanged = true
}
if mdmChanged {
if err := logic.UpsertHost(currentHost); err != nil {
slog.Error("failed to update mdm identifiers after check-in", "name", h.Name, "id", h.ID, "error", err)
} else if currentHost.EntraDeviceID != "" {
go logic.SyncHostMDMState(context.Background(), currentHost.ID.String())
}
}

slog.Info("check-in processed for host", "name", h.Name, "id", h.ID)
return ifaceDelta
}
Expand Down
Loading
Loading