From 7cb5e10eb6d520001dbb78fc04a091441aeea447 Mon Sep 17 00:00:00 2001
From: Tomas Dvorak
Date: Fri, 9 Oct 2026 12:24:28 +0200
Subject: [PATCH] feat(nodes): mesh awareness + node detail page
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- agent reports tailscale/netbird/zerotier/wireguard interface IPs at
register (authoritative — absent interfaces clear stored state)
- backend stores under node_agents.metadata.mesh, merged on re-register
- node detail page shows mesh addresses under the primary IP
Generated with [Devin](https://devin.ai)
Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
---
ROADMAP2.md | 7 ++--
app/backend/cmd/agent/main.go | 38 +++++++++++++++++++
app/backend/internal/api/agents.go | 21 +++++++++-
.../src/features/nodes/NodeDetailPage.tsx | 12 ++++++
app/frontend/src/generated/api-types.ts | 4 ++
app/frontend/src/lib/api-client.ts | 2 +
docs/api/openapi.yaml | 5 +++
7 files changed, 84 insertions(+), 5 deletions(-)
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