diff --git a/ROADMAP2.md b/ROADMAP2.md index 53037de..3181eec 100644 --- a/ROADMAP2.md +++ b/ROADMAP2.md @@ -260,9 +260,10 @@ and wired refs, one click or one CLI call. served at `/api/agents/download/{linux-amd64,linux-arm64}` (install script falls back to GH releases). `cli/commands/nodes_add.go`, `api/assets/install-agent.sh`, `agents.go:ServeInstallScript`/`ServeAgentBinary`. -- [ ] **Mesh awareness** — agent detects + reports tailscale0/netbird0/ - zt0 IPs at register; backend stores + displays. Optional - `TAILSCALE_AUTH_KEY` enroll-at-provision later. +- [x] **Mesh awareness** — agent reports tailscale/netbird/wireguard/ + zerotier interface IPs at register (`mesh` field, authoritative on + re-register), stored in `node_agents.metadata.mesh`, shown on the node + detail page. `TAILSCALE_AUTH_KEY` enroll-at-provision still open. - [x] **Node detail page** — `/nodes/:id` (admin): heartbeat telemetry (cpu/mem sparklines over 1h/24h/7d via `GET /agents/:id/metrics`), containers, command history, rename, auto-prune toggle, prune-now, diff --git a/app/backend/cmd/agent/main.go b/app/backend/cmd/agent/main.go index a9fa547..1dd53e2 100644 --- a/app/backend/cmd/agent/main.go +++ b/app/backend/cmd/agent/main.go @@ -167,6 +167,8 @@ func register(ctx context.Context, client *http.Client, cfg agentConfig) (string "ip_address": cfg.IP, "port": cfg.Port, "capabilities": detectCapabilities(), + // Always send mesh so a removed interface clears stored state. + "mesh": detectMeshInterfaces(), } var response registerResponse if err := postJSON(ctx, client, cfg, "/api/agents/register", payload, &response); err != nil { @@ -372,6 +374,42 @@ func detectCapabilities() capabilities { } } +// detectMeshInterfaces reports overlay-network interface IPs (tailscale, +// netbird, zerotier, wireguard) so the backend can display reachable +// mesh addresses alongside the primary IP. +func detectMeshInterfaces() map[string]string { + ifaces, err := net.Interfaces() + if err != nil { + return nil + } + mesh := map[string]string{} + for _, iface := range ifaces { + name := strings.ToLower(iface.Name) + isMesh := strings.HasPrefix(name, "tailscale") || strings.HasPrefix(name, "ts.") || + strings.HasPrefix(name, "netbird") || strings.HasPrefix(name, "wt") || + strings.HasPrefix(name, "zt") || strings.HasPrefix(name, "wg") + if !isMesh || iface.Flags&net.FlagUp == 0 { + continue + } + addrs, err := iface.Addrs() + if err != nil { + continue + } + for _, addr := range addrs { + ip, _, err := net.ParseCIDR(addr.String()) + if err != nil || ip.IsLoopback() { + continue + } + if v4 := ip.To4(); v4 != nil { + mesh[iface.Name] = v4.String() + } else if _, exists := mesh[iface.Name]; !exists { + mesh[iface.Name] = ip.String() + } + } + } + return mesh +} + func collectResources(ip string) nodeResources { mem := readMemory() storage := readStorage("/") diff --git a/app/backend/internal/api/agents.go b/app/backend/internal/api/agents.go index 76dc3c3..25e994f 100644 --- a/app/backend/internal/api/agents.go +++ b/app/backend/internal/api/agents.go @@ -331,6 +331,7 @@ func (h *NodeAgentHandler) RegisterAgent(c *gin.Context) { Port int `json:"port" binding:"required"` Capabilities AgentCapabilities `json:"capabilities" binding:"required"` AuthToken string `json:"auth_token"` + Mesh map[string]string `json:"mesh"` } if err := c.ShouldBindJSON(&req); err != nil { @@ -345,12 +346,28 @@ func (h *NodeAgentHandler) RegisterAgent(c *gin.Context) { } ctx := c.Request.Context() + metadata := map[string]interface{}{} + // req.Mesh is authoritative when present (newer agents always send it, + // possibly empty); nil means an older agent — keep any stored value. + if req.Mesh != nil { + metadata["mesh"] = req.Mesh + } // Re-registering an existing host updates it in place. if existing, err := h.q.GetAgentByHostAndIP(ctx, sqlcdb.GetAgentByHostAndIPParams{ Hostname: req.Hostname, IpAddress: req.IPAddress, }); err == nil { + if existing.Metadata.Valid && len(existing.Metadata.RawMessage) > 0 { + var prev map[string]interface{} + if err := json.Unmarshal(existing.Metadata.RawMessage, &prev); err == nil { + for k, v := range prev { + if _, ok := metadata[k]; !ok { + metadata[k] = v + } + } + } + } updated, err := h.q.UpdateAgent(ctx, sqlcdb.UpdateAgentParams{ ID: existing.ID, Name: req.Name, @@ -362,7 +379,7 @@ func (h *NodeAgentHandler) RegisterAgent(c *gin.Context) { Capabilities: rawJSON(req.Capabilities), Resources: existing.Resources, LastHeartbeat: sql.NullTime{Time: time.Now(), Valid: true}, - Metadata: existing.Metadata, + Metadata: rawJSON(metadata), }) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update agent"}) @@ -412,7 +429,7 @@ func (h *NodeAgentHandler) RegisterAgent(c *gin.Context) { Capabilities: rawJSON(req.Capabilities), Resources: rawJSON(resources), LastHeartbeat: sql.NullTime{Time: time.Now(), Valid: true}, - Metadata: rawJSON(map[string]interface{}{}), + Metadata: rawJSON(metadata), }) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create agent"}) diff --git a/app/frontend/src/features/nodes/NodeDetailPage.tsx b/app/frontend/src/features/nodes/NodeDetailPage.tsx index 8a94939..ef45c45 100644 --- a/app/frontend/src/features/nodes/NodeDetailPage.tsx +++ b/app/frontend/src/features/nodes/NodeDetailPage.tsx @@ -156,6 +156,11 @@ export function NodeDetailPage() { {agent.version ? ` · v${agent.version}` : ''} {agent.lastHeartbeat ? ` · heartbeat ${formatRelative(agent.lastHeartbeat)}` : ''}

+ {meshAddresses(agent).length > 0 && ( +

+ mesh: {meshAddresses(agent).map(([iface, ip]) => `${iface}=${ip}`).join(' · ')} +

+ )}
}): [string, string][] { + const mesh = agent.metadata?.mesh; + if (!mesh || typeof mesh !== 'object' || Array.isArray(mesh)) return []; + return Object.entries(mesh as Record) + .filter((entry): entry is [string, string] => typeof entry[1] === 'string' && entry[1] !== ''); +} + function Stat({ label, value, sub }: { label: string; value: string; sub?: string }) { return (
diff --git a/app/frontend/src/generated/api-types.ts b/app/frontend/src/generated/api-types.ts index baf70af..d105a18 100644 --- a/app/frontend/src/generated/api-types.ts +++ b/app/frontend/src/generated/api-types.ts @@ -12026,6 +12026,10 @@ export interface components { capabilities: components["schemas"]["AgentCapabilities"]; /** @description May also be sent via the X-Agent-Token header */ auth_token?: string; + /** @description Overlay-network interface IPs (tailscale/netbird/zerotier/wireguard). Stored under agent metadata.mesh; authoritative — send an empty object when no mesh interfaces exist. */ + mesh?: { + [key: string]: string; + }; }; AgentHeartbeat: { agent_id?: string; diff --git a/app/frontend/src/lib/api-client.ts b/app/frontend/src/lib/api-client.ts index c61d420..625a8e7 100644 --- a/app/frontend/src/lib/api-client.ts +++ b/app/frontend/src/lib/api-client.ts @@ -342,6 +342,7 @@ export type NodeAgentEntity = { capabilities: AgentCapabilities; resources: NodeResources; autoPrune: boolean; + metadata: Record; lastHeartbeat?: string; createdAt?: string; updatedAt?: string; @@ -1078,6 +1079,7 @@ function normalizeAgent(agent: RawNodeAgent): NodeAgentEntity | null { }, lastHeartbeat: agent.last_heartbeat, autoPrune: agent.auto_prune ?? false, + metadata: (agent.metadata ?? {}) as Record, createdAt: agent.created_at, updatedAt: agent.updated_at, }; diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 57ed2e6..db83288 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -9130,6 +9130,11 @@ components: auth_token: type: string description: May also be sent via the X-Agent-Token header + mesh: + type: object + additionalProperties: + type: string + description: Overlay-network interface IPs (tailscale/netbird/zerotier/wireguard). Stored under agent metadata.mesh; authoritative — send an empty object when no mesh interfaces exist. AgentHeartbeat: type: object