Compare commits

...
36 Commits
Author SHA1 Message Date
nessi 2509c4fa28 feat: add timestamp tracking to flows with first_seen_at/last_seen_at/observed_at fields across all collectors
Add FIREWALL_LOG_TS_RE regex to parse timestamps from firewall log lines, implement firewall_log_seen_at to extract and convert log timestamps to UTC ISO format, add first_seen_at/last_seen_at/observed_at fields to flows in collect_packet_flows (AF_PACKET collector) with timestamp updates on flow aggregation, add timestamp fields to collect_flows (conntrack collector) and parse_firewall_log_line (
2026-07-10 14:44:42 +02:00
nessi 757fecc686 refactor: fix htons return type to uint16 and add htonsInt wrapper for syscall.Socket compatibility
Split htons to return uint16 instead of int for correct byte order conversion, add htonsInt wrapper that converts uint16 to int for syscall.Socket protocol parameter, update openSocket to use htonsInt for ethPAll protocol value
2026-07-10 14:24:09 +02:00
nessi 3aa7ae0c65 feat: add Go-based eBPF helper using AF_PACKET raw sockets with automatic build in agent installer
Add nexafabric-ebpf.go implementing flow collection via Linux raw packet sockets (AF_PACKET) instead of tc/eBPF to enable immediate Proxmox deployment without kernel dependencies, implement packet parsing with VLAN/IP/TCP/UDP/ICMP support and flow aggregation by 5-tuple with vmid/nic/interface/direction metadata extraction from tap/fwbr interface names, add /agents/download/nexafabric-ebpf.go endpoint
2026-07-10 14:19:28 +02:00
nessi e6c9a92ff0 feat: add eBPF flow collector with helper binary contract and agent integration
Add EBPF_HELPER_CONTRACT.md documenting helper binary invocation with --json/--limit/--duration/--interfaces parameters and expected JSON output format with flows/diagnostics, implement collect_ebpf_flows to invoke helper binary with configurable timeout/window/interfaces and normalize flow fields (source_ip/destination_ip/protocol/ports/packets/bytes/state), add executable_exists and flow_int helpers for binary validation
2026-07-10 14:15:15 +02:00
nessi af68949e07 feat: change workload traffic sorting to prioritize recent flows over volume with fallback to bytes
Update summarizeTraffic to sort flows by observedAt timestamp (newest first) instead of bytes, add fallback to bytes when timestamps are equal/invalid/missing, modify TrafficBars to re-sort top 5 flows by bytes for volume-based visualization while preserving timestamp-based ordering in main traffic list
2026-07-10 14:02:46 +02:00
nessi b9b0d4ae39 feat: add security group membership with workload assignment, sg: prefix resolution in policy rules, and searchable select component
Add SecurityGroupMember model with security_group_id/workload_id foreign keys and unique constraint, implement security_group_members table with timestamps, add SecurityGroupMemberCreate/SecurityGroupMemberRead schemas with workload_name/workload_external_id fields, implement workload_provider_targets helper to expand sg: prefix into multiple workload targets with
2026-07-10 12:46:56 +02:00
nessi 0f4734c85c feat: reduce agent payload size with flow truncation and increase nginx body size limit to 16MB
Reduce agent flow_limit from 2000 to 1500, truncate log_excerpt from 500 to 180 characters in firewall log parsing, increase firewall_log_lines_from_files limit from default to max(limit*2, 1000), add compact_agent_payload to truncate flows array to 50 entries with flow_count/flows_truncated metadata, update agent_heartbeat to store compacted payload instead of full dump, add agentFlowCount helper to
2026-07-10 08:42:48 +02:00
nessi 07cd534254 feat: add include_rules and include_flow_context query parameters to workload insights endpoint for conditional rule/policy matching
Add include_rules boolean parameter to conditionally fetch active firewall rules (defaults to false), add include_flow_context boolean parameter to conditionally compute matching firewall rules and policies for each flow (defaults to false), rename traffic parameter to traffic_mode with alias for backward compatibility, update flow decision logic to return "observed" when
2026-07-10 08:39:43 +02:00
nessi 65bc7fad67 feat: optimize dashboard and workload queries with database indexes and SQL aggregation for improved performance
Add database indexes on ip_addresses.address and traffic_flows columns (node_id/source_ip/destination_ip/destination_port/state with updated_at) to accelerate query performance, implement ensure_runtime_indexes to create indexes on startup with IF NOT EXISTS guards, rewrite dashboard_top_talkers to use SQL aggregation with JOIN on IpAddress/TrafficFlow instead of Python loops over all
2026-07-10 08:23:38 +02:00
nessi 9af60945cf feat: remove traffic flow query limits to show complete flow history in dashboard and workload insights
Remove 500-flow limit from dashboard_suspicious_traffic query and 1000-flow limit from workload_insights query to display all traffic flows instead of truncated results, enabling full visibility of suspicious traffic events and workload communication patterns
2026-07-10 08:16:53 +02:00
nessi 67eee0662a feat: add automatic cluster sync and IPAM discovery with configurable intervals, firewall log file parsing, and enhanced flow prioritization
Add RuntimeSettingsRead/RuntimeSettingsUpdate schemas with auto_node_sync_enabled/auto_node_sync_interval_minutes/auto_ipam_sync_enabled/auto_ipam_sync_interval_minutes/last_node_auto_sync_at/last_ipam_auto_sync_at fields, implement firewall_log_lines_from_files to parse /var/log/pve-firewall.log with max_lines limit and error collection, extend collect_firewall_log
2026-07-10 08:13:40 +02:00
nessi 1531b7ea47 feat: add configurable flow retention settings with super admin controls and pagination for workload flows
Add RuntimeSettingsRead/RuntimeSettingsUpdate schemas with flow_retention_hours field (1-8760 hours), implement runtime_setting helper to initialize/fetch runtime system setting with 24h default, add require_super_admin guard to validate * permission, update agent_heartbeat to use configurable retention_cutoff from runtime settings instead of hardcoded 24h, replace /settings GET endpoint to return RuntimeSettingsRead with flow_retention_hours,
2026-07-09 22:59:31 +02:00
nessi 8c59ab32d5 feat: add dedicated flow analytics page with filtering, aggregation charts, and enhanced traffic table
Add WorkloadFlows component with search/protocol/decision/port filters, implement FlowStatCards showing total traffic/flows/allowed/blocked/protocols, add TopFlowChart component for top conversations/destinations/protocols/packets with horizontal bars, implement aggregateBy helper to sum traffic by label function, extend TrafficSummary with sourceIp/destinationIp/sourcePort/collector/observedAt
2026-07-09 22:52:14 +02:00
nessi d651a11472 feat: add automatic policy rule cleanup when disabling or switching to audit mode with provider-level rule deletion
Add cluster_provider_targets helper to build target list from all cluster workloads, implement cleanup_policy_provider_rules to delete policy rules across all clusters with per-cluster result tracking, add delete_policy_rules method to ProxmoxProvider that removes rules matching policy ID marker with error collection, extend policy_id_marker and rule_comment_matches_marker helpers for
2026-07-09 21:31:20 +02:00
nessi 0d07349de0 feat: add traffic flow deduplication with 24h retention and update-in-place for existing flows
Add traffic_flow_key helper to generate unique flow identifier from node/IPs/protocol/ports/decision, implement 24-hour retention cutoff to delete old flows instead of all flows on heartbeat, build existing_flows lookup map from database with composite key matching, update agent_heartbeat to check for existing flows and update bytes/packets/state/observed_at/raw in-place instead of creating duplicates, extend
2026-07-09 21:19:26 +02:00
nessi da60155710 feat: add firewall enforcement status checking to policy apply with datacenter/node/guest/interface validation and warning collection
Add cluster_firewall_options_url and node_firewall_options_url helpers to build firewall options endpoint paths, implement config_interface_status to parse network interface firewall flags from VM/LXC config with bridge/firewall/raw fields, add firewall_enforcement_status to check enable status across datacenter/node/guest levels and validate interface firewall flags with warning collection for disabled settings, extend apply_rules response with enforcement_
2026-07-09 21:16:13 +02:00
nessi 35ffcb6768 feat: add kernel firewall log parsing to agent with blocked traffic detection and dashboard suspicious traffic enhancement
Add parse_firewall_log_line to extract SRC/DST/PROTO/SPT/DPT/LEN from kernel log lines with drop/reject/accept decision classification, implement collect_firewall_log_flows to parse journalctl -k output from last 5 minutes with flow aggregation by 5-tuple+decision, add merge_flow_sources to combine packet flows and firewall log flows with deduplication, extend agent config with
2026-07-09 21:07:03 +02:00
nessi 68c11eba57 feat: add human-readable endpoint labels to policy table with workload/network/security group prefix detection
Add endpointLabel helper to format policy endpoints with type-specific prefixes (VM/LXC, Network, Security Group) by parsing workload:/network:/sg: prefixes and resolving workload IDs to names, implement policyEndpoint wrapper to apply endpoint labeling to policy definition fields, fetch workloads query in Policies component for endpoint resolution, update DataTable source/destination columns
2026-07-09 20:23:18 +02:00
nessi 2f7da934ea feat: add custom IP/CIDR input option to policy source and destination fields with dynamic text input and placeholder examples
Add customTargetValue constant for custom IP/CIDR selection, implement endpointSelectValue helper to detect custom values not in target list, add isCustomEndpoint predicate to show/hide custom input fields, extend PolicyDesigner with "Custom IP/CIDR" dropdown option that reveals text input for manual IP/CIDR entry with trim on change, add placeholder examples "172.16.0.50 or 172.16.0.0/16" to guide user
2026-07-09 20:19:52 +02:00
nessi 1802c2cbee feat: add policy deployment status tracking with cluster-level rule state monitoring and live firewall rule version comparison
Add policy_read_payload helper to build policy response with deployment status, implement nexafabric_rule_version to parse policy ID and version from rule comments, add policy_deployment_status to check active/stale/partial/unresolved states by comparing expected rules from preview against live firewall rules per cluster with version matching, extend PolicyRead schema with
2026-07-09 19:52:10 +02:00
nessi 6d5dc310df feat: add flow-level firewall rule and policy matching with decision classification and audit mode visualization
Add firewall_rule_matches_flow to check if active rules match traffic flows using protocol/port/IP/direction matching with enable status validation, implement policy_matches_flow to evaluate policy definitions against flows with workload/network endpoint resolution and protocol/port matching, add flow_policy_decision to determine final decision from active rules and policies with audit
2026-07-09 19:47:39 +02:00
nessi b12ac38c6c feat: add active firewall rules display to workload insights with managed rule detection and enable status
Add active_firewall_rules_for_workload helper to fetch live firewall rules from provider with NexaFabric policy comment detection and enable status, implement list_firewall_rules method in ProxmoxProvider to retrieve rules via firewall API endpoint, extend WorkloadInsight schema with active_firewall_rules field, add ActiveRulesList component showing rule type/action/protocol/port with enable status and
2026-07-09 19:42:51 +02:00
nessi 1b81847fc6 feat: add automatic guest network interface firewall enablement during policy apply operations
Add network_firewall_enabled_value helper to parse and inject firewall=1 into Proxmox network interface config strings, implement enable_guest_firewall_interfaces to update VM/LXC config with firewall=1 on all netX interfaces before writing rules, extend workload_config_url to build config endpoint paths for qemu/lxc guests, add interfaces_enabled field to apply_rules response showing which interfaces were
2026-07-09 19:40:31 +02:00
nessi 5302a8bc82 feat: add policy enforcement mode normalization and audit mode protection for firewall apply operations
Add normalized_policy_definition helper to validate and default enforcement_mode to "enforced" or "audit" when creating/updating policies, extend firewall_apply to block live apply when policy is in audit mode with explanatory message, add policy_mode field to all firewall apply response paths, update FirewallPreview UI to show enforcement mode in policy dropdown with version number, display audit mode warning
2026-07-09 15:57:23 +02:00
nessi 571d1513e7 feat: add suspicious traffic detection dashboard widget with sensitive port monitoring and security posture indicator
Add dashboard_suspicious_traffic to detect external connections to sensitive ports (SSH/RDP/SMB/VNC/PostgreSQL/MySQL/Redis) from outside IPAM subnets with severity classification, extend Dashboard type with security_posture/suspicious_traffic/last_syncs fields, implement BarList component for traffic visualization with percentage bars and byte formatting, add security posture card
2026-07-09 15:50:42 +02:00
nessi 32906bca1e feat: add IP-based flow labels with internal/external classification and redesign workload summary sidebar with compact flow visualization
Add flow_ip_label helper to format flow endpoints with IP addresses and internal/external classification based on subnet membership, extend workload_insights response with source_label/destination_label fields showing IP addresses with context, implement endpointText helper to display formatted flow labels in UI, add CompactFlowList component showing top 3 flows with protocol
2026-07-09 15:42:56 +02:00
nessi baa0d24eb4 feat: add network: endpoint resolver, VM.Config.Options privilege requirement, top talkers dashboard widget, and automatic guest firewall enablement
Add network: prefix support in endpoint_values to resolve network names to IPAM subnet CIDRs for policy matching, extend workload_provider_target to accept vmid: prefix and bare workload names as fallback resolution methods, implement dashboard_top_talkers to aggregate traffic flows by workload with interface_traffic fallback when flows unavailable, add
2026-07-09 15:36:42 +02:00
nessi a16b56614c feat: add subnet edit functionality with internal subnet labeling in workload traffic insights
Add SubnetUpdate schema with optional fields for PATCH operations, implement update_subnet endpoint with validation and audit logging, add subnet_label_for_ip helper to match IPs against known subnets using longest prefix matching, update flow_endpoint_label to show "internal (CIDR)" for traffic within known subnets instead of "external", add DNS servers and DHCP toggle to subnet form UI, implement edit
2026-07-09 15:29:36 +02:00
nessi 10e9406510 feat: add workload detail page with traffic visualization, protocol distribution, and flow aggregation
Add WorkloadDetail component with dedicated route for per-workload traffic analysis, implement summarizeTraffic to aggregate flows by 5-tuple with byte/packet totals and IP address collection, add TrafficBars component showing top 5 flows with horizontal bar charts, implement ProtocolChart with color-coded protocol distribution and percentage breakdown, add TrafficTable with scrollable flow list showing
2026-07-09 15:26:15 +02:00
nessi 4ceb4489c5 feat: add AF_PACKET flow collector to agent for real VM traffic visibility with IPv4 TCP/UDP/ICMP flow extraction
Add packet flow collector in agent v0.2.0 using Linux AF_PACKET sockets to capture and aggregate IPv4 TCP/UDP/ICMP flows from VM interfaces (tap/fwln) with configurable window/limit, implement parse_packet_flow to extract 5-tuple from raw Ethernet frames with VLAN tag handling, add selected_flow_interfaces to choose best interface per VM NIC for packet capture, include packet collector
2026-07-09 15:18:35 +02:00
nessi 3bfd77a74a feat: add interface traffic counters as fallback telemetry when conntrack flows unavailable
Add interface_traffic collection in agent to aggregate VM/LXC network counters by vmid/nic with tap/fwln/fwpr/fwbr interface ranking, implement collect_interface_traffic to select best interface per VM NIC and format as flow-like records with rx/tx bytes/packets, add collect_flow_diagnostics to capture conntrack binary path and kernel bridge/netfilter settings for debugging, update workload_insights endpoint
2026-07-09 15:12:31 +02:00
nessi dbd7fc6f95 fix: escape arrow operator in conntrack flow display and remove push trigger from CI workflow
Remove push event trigger from GitHub Actions CI workflow to run only on pull requests, wrap arrow operator in curly braces to prevent JSX parsing issues in conntrack flow source/destination display
2026-07-09 15:05:51 +02:00
nessi fc719800f9 feat: add LoadingOverlay component with contextual busy messages for async operations across cluster sync, firewall preview/apply, IPAM discovery/export, node agent installer, and policy compilation
CI / backend (push) Failing after 3s
CI / frontend (push) Failing after 27s
Add LoadingOverlay component with spinner, customizable title/message, and backdrop blur styling, implement busy state tracking with setBusyMessage in Clusters/FirewallPreview/Ipam/Nodes/Policies pages, wrap async operations (cluster test/sync, firewall preview/apply, IPAM discover/export CSV, node agent installer
2026-07-09 15:04:27 +02:00
nessi 8536014666 feat: add agent data detail modal with interface telemetry, conntrack flows, and raw payload viewer
CI / backend (push) Failing after 3s
CI / frontend (push) Failing after 30s
Add ScrollText icon button to nodes table to open agent data modal, implement agentFlows/agentInterfaces/agentConntrack helpers to safely extract telemetry from last_payload with type guards, add Modal with status/flows/conntrack summary cards, display interface list with operstate/vmid/rx/tx stats in scrollable container, show top 50 conntrack flows with source/destination/protocol/bytes/packets/state details
2026-07-09 15:02:16 +02:00
nessi 5040ac2f16 feat: change agent installer to use restart instead of enable --now for systemd service activation
CI / backend (push) Failing after 2s
CI / frontend (push) Failing after 27s
Replace `systemctl enable --now` with separate `systemctl enable` and `systemctl restart` commands to ensure agent service restarts on reinstall rather than silently failing when service already exists
2026-07-09 14:58:22 +02:00
nessi 926a1b8165 feat: redesign sidebar navigation with grouped sections, improved visual hierarchy, and enhanced active state indicators
Restructure navigation into three logical groups (Operate, Network, Security) with uppercase section labels, move system-related items (Jobs, Audit, Users, Settings) to separate bottom section with border separator, add NF logo badge next to app title in header, extract SidebarLink component with refined styling including left accent bar for active items, update active state to
2026-07-09 14:58:15 +02:00
30 changed files with 4330 additions and 259 deletions
-1
View File
@@ -1,7 +1,6 @@
name: CI name: CI
on: on:
push:
pull_request: pull_request:
jobs: jobs:
+19 -7
View File
@@ -77,7 +77,8 @@ For write-enabled firewall orchestration, create a separate token or role and do
Minimum practical write privileges for VM/LXC-level firewall rules: Minimum practical write privileges for VM/LXC-level firewall rules:
- `VM.Audit` so NexaFabric can resolve guests and inspect existing rules. - `VM.Audit` so NexaFabric can resolve guests and inspect existing rules.
- `VM.Config.Network` on `/vms` or on the narrow VM/LXC paths you want NexaFabric to manage. - `VM.Config.Options` so NexaFabric can enable the guest firewall option before writing rules.
- `VM.Config.Network` on `/vms` or on the narrow VM/LXC paths you want NexaFabric to manage, so NexaFabric can set `firewall=1` on guest network interfaces before writing rules.
NexaFabric writes only rules that carry a `NexaFabric policy=...` comment marker. During apply it removes and replaces its own marked rules for the selected policy, leaving manually created Proxmox firewall rules untouched. NexaFabric writes only rules that carry a `NexaFabric policy=...` comment marker. During apply it removes and replaces its own marked rules for the selected policy, leaving manually created Proxmox firewall rules untouched.
@@ -156,16 +157,15 @@ Proxmox inventory and guest agent data are enough for:
- Static LXC IP discovery. - Static LXC IP discovery.
- Policy matching and firewall previews. - Policy matching and firewall previews.
Actual traffic flow visibility, top talkers, byte counters, and per-workload traffic history require an additional telemetry source. Proxmox VE does not provide full flow telemetry for every VM through the basic inventory API. Actual traffic flow visibility, top talkers, byte counters, and per-workload traffic history require the NexaFabric node agent or another telemetry source. Proxmox VE does not provide full flow telemetry for every VM through the basic inventory API.
Supported or planned options: Supported options:
- NexaFabric node agent on Proxmox nodes to read host interface counters, VM/LXC interface hints, conntrack flows, nftables ruleset state, and pve-firewall status. - NexaFabric node agent on Proxmox nodes to read VM/LXC interface hints, host interface counters, real IPv4 TCP/UDP/ICMP flows from Linux VM interfaces, conntrack flows when available, nftables ruleset state, and pve-firewall status.
- Open vSwitch with sFlow/NetFlow/IPFIX exported to a collector. - Open vSwitch with sFlow/NetFlow/IPFIX exported to a collector.
- Router/firewall flow exports from pfSense, OPNsense, FRR/BGP edge devices, or physical switches. - Router/firewall flow exports from pfSense, OPNsense, FRR/BGP edge devices, or physical switches.
- eBPF or host-level telemetry in future agent builds.
Until such a source is configured, NexaFabric will show `No flow telemetry collected yet` instead of fake traffic. Until such a source is configured, NexaFabric will show `No flow telemetry collected yet` instead of fake traffic. If the node agent can see VM interface counters but no packet flows, NexaFabric displays the counters as an explicitly marked fallback.
### 8. Install The Node Agent ### 8. Install The Node Agent
@@ -192,7 +192,19 @@ journalctl -u nexafabric-agent -f
systemctl restart nexafabric-agent systemctl restart nexafabric-agent
``` ```
The agent reports host/interface counters, VMID hints from Proxmox interface names, conntrack flow records, pve-firewall status, and an nftables ruleset hash. NexaFabric maps flow source/destination IPs back to workloads through IPAM, so VM/LXC details can show observed traffic once guest IPs have been discovered. The agent does not enforce policies itself; Proxmox firewall rule apply remains API-driven through NexaFabric. Agent version `0.2.1` reports host/interface counters, VMID hints from Proxmox interface names, real packet-derived IPv4 TCP/UDP/ICMP flows from VM interfaces, conntrack flow records when available, recent kernel firewall log drops/rejects, pve-firewall status, and an nftables ruleset hash. NexaFabric maps flow source/destination IPs back to workloads through IPAM, so VM/LXC details can show observed and blocked traffic once guest IPs have been discovered. Blocked traffic visibility depends on Proxmox/kernel firewall logging being enabled for the rule or default drop that rejected the packet. The agent does not enforce policies itself; Proxmox firewall rule apply remains API-driven through NexaFabric.
The default agent config enables the packet flow collector:
```json
{
"packet_flow_collector": true,
"packet_flow_window_seconds": 10,
"flow_limit": 500
}
```
The collector runs as root through the Linux `AF_PACKET` interface and attaches to Proxmox VM interfaces such as `tap100i0` and `fwln100i0`. It aggregates locally before sending data to NexaFabric; packet payloads are not stored or uploaded.
### 9. Troubleshooting Proxmox Integration ### 9. Troubleshooting Proxmox Integration
@@ -0,0 +1,40 @@
# NexaFabric eBPF helper contract
Agent 0.3.0 can call an optional helper binary at `/opt/nexafabric-agent/nexafabric-ebpf`.
The repository includes a dependency-free Go helper source at `nexafabric-ebpf.go`.
The first implementation uses Linux raw packet sockets on the selected VM/LXC interfaces and prints the same JSON contract that a tc/eBPF implementation should print. This keeps the helper installable on Proxmox immediately while preserving the agent integration point for a later kernel eBPF loader.
The helper is invoked as:
```sh
nexafabric-ebpf --json --limit 1500 --duration 10 --interfaces tap100i0,fwln100i0
```
It must print JSON to stdout:
```json
{
"flows": [
{
"source_ip": "172.16.0.10",
"destination_ip": "172.16.0.20",
"protocol": "tcp",
"source_port": 443,
"destination_port": 53020,
"packets": 10,
"bytes": 14800,
"vmid": "100",
"interface": "tap100i0",
"direction": "ingress",
"state": "observed"
}
],
"diagnostics": {
"attach_mode": "af_packet_raw_socket",
"interfaces_attached": ["tap100i0"]
}
}
```
The Python agent merges these flows with firewall-log, packet, and conntrack fallback collectors.
+515 -7
View File
@@ -5,8 +5,10 @@ import json
import os import os
import platform import platform
import re import re
import select
import socket import socket
import ssl import ssl
import struct
import subprocess import subprocess
import time import time
import urllib.error import urllib.error
@@ -16,8 +18,16 @@ from pathlib import Path
from typing import Any from typing import Any
VERSION = "0.1.0" VERSION = "0.3.0"
VM_INTERFACE_RE = re.compile(r"(?:tap|fwbr|fwln|fwpr)(\d+)") VM_INTERFACE_RE = re.compile(r"(?:tap|fwbr|fwln|fwpr)(\d+)")
VM_INTERFACE_DETAIL_RE = re.compile(r"(?:tap|fwbr|fwln|fwpr)(\d+)i(\d+)")
LOG_FIELD_RE = re.compile(r"\b([A-Z]+)=([^\s]+)")
FIREWALL_LOG_TS_RE = re.compile(r"\b(\d{2}/[A-Za-z]{3}/\d{4}:\d{2}:\d{2}:\d{2} [+-]\d{4})\b")
ETH_P_IP = 0x0800
ETH_P_ALL = 0x0003
ETH_P_8021Q = 0x8100
ETH_P_8021AD = 0x88A8
IP_PROTOCOLS = {1: "icmp", 6: "tcp", 17: "udp"}
def read_text(path: str) -> str | None: def read_text(path: str) -> str | None:
@@ -30,11 +40,23 @@ def read_text(path: str) -> str | None:
def run_command(args: list[str], timeout: int = 5) -> tuple[int, str]: def run_command(args: list[str], timeout: int = 5) -> tuple[int, str]:
try: try:
result = subprocess.run(args, capture_output=True, text=True, timeout=timeout, check=False) result = subprocess.run(args, capture_output=True, text=True, timeout=timeout, check=False)
return result.returncode, result.stdout.strip() output = result.stdout.strip() or result.stderr.strip()
return result.returncode, output
except (OSError, subprocess.SubprocessError): except (OSError, subprocess.SubprocessError):
return 127, "" return 127, ""
def executable_exists(path: str) -> bool:
return Path(path).exists() and os.access(path, os.X_OK)
def flow_int(value: object, default: int = 0) -> int:
try:
return int(value) if value not in (None, "") else default
except (TypeError, ValueError):
return default
def collect_interfaces() -> list[dict[str, Any]]: def collect_interfaces() -> list[dict[str, Any]]:
interfaces = [] interfaces = []
for item in Path("/sys/class/net").iterdir() if Path("/sys/class/net").exists() else []: for item in Path("/sys/class/net").iterdir() if Path("/sys/class/net").exists() else []:
@@ -56,6 +78,209 @@ def collect_interfaces() -> list[dict[str, Any]]:
return interfaces return interfaces
def interface_rank(name: str) -> int:
if name.startswith("tap"):
return 0
if name.startswith("fwln"):
return 1
if name.startswith("fwpr"):
return 2
if name.startswith("fwbr"):
return 3
return 9
def collect_interface_traffic(interfaces: list[dict[str, Any]]) -> list[dict[str, Any]]:
selected: dict[tuple[str, str], dict[str, Any]] = {}
for interface in interfaces:
vmid = interface.get("vmid")
if not vmid:
continue
detail_match = VM_INTERFACE_DETAIL_RE.search(str(interface.get("name") or ""))
nic = detail_match.group(2) if detail_match else "0"
key = (str(vmid), nic)
current = selected.get(key)
if current and interface_rank(str(current.get("name") or "")) <= interface_rank(str(interface.get("name") or "")):
continue
selected[key] = interface
traffic = []
for (vmid, nic), interface in sorted(selected.items()):
rx_bytes = int(interface.get("rx_bytes") or 0)
tx_bytes = int(interface.get("tx_bytes") or 0)
rx_packets = int(interface.get("rx_packets") or 0)
tx_packets = int(interface.get("tx_packets") or 0)
traffic.append(
{
"vmid": vmid,
"nic": nic,
"interface": interface.get("name"),
"source": f"vm:{vmid}",
"destination": "network",
"protocol": "interface-counter",
"rx_bytes": rx_bytes,
"tx_bytes": tx_bytes,
"bytes": rx_bytes + tx_bytes,
"rx_packets": rx_packets,
"tx_packets": tx_packets,
"packets": rx_packets + tx_packets,
"state": interface.get("operstate") or "unknown",
}
)
return traffic
def selected_flow_interfaces(interfaces: list[dict[str, Any]]) -> list[dict[str, Any]]:
selected: dict[tuple[str, str], dict[str, Any]] = {}
for interface in interfaces:
name = str(interface.get("name") or "")
vmid = interface.get("vmid")
if not vmid or not name.startswith(("tap", "fwln")):
continue
detail_match = VM_INTERFACE_DETAIL_RE.search(name)
nic = detail_match.group(2) if detail_match else "0"
key = (str(vmid), nic)
current = selected.get(key)
if current and interface_rank(str(current.get("name") or "")) <= interface_rank(name):
continue
selected[key] = interface
return list(selected.values())
def ipv4_address(raw: bytes) -> str:
return socket.inet_ntoa(raw)
def parse_packet_flow(packet: bytes) -> dict[str, Any] | None:
if len(packet) < 34:
return None
offset = 12
eth_type = struct.unpack("!H", packet[offset:offset + 2])[0]
offset = 14
while eth_type in {ETH_P_8021Q, ETH_P_8021AD}:
if len(packet) < offset + 4:
return None
eth_type = struct.unpack("!H", packet[offset + 2:offset + 4])[0]
offset += 4
if eth_type != ETH_P_IP or len(packet) < offset + 20:
return None
version_ihl = packet[offset]
version = version_ihl >> 4
ihl = (version_ihl & 0x0F) * 4
if version != 4 or ihl < 20 or len(packet) < offset + ihl:
return None
total_length = struct.unpack("!H", packet[offset + 2:offset + 4])[0]
protocol_number = packet[offset + 9]
protocol = IP_PROTOCOLS.get(protocol_number)
if not protocol:
return None
source_ip = ipv4_address(packet[offset + 12:offset + 16])
destination_ip = ipv4_address(packet[offset + 16:offset + 20])
transport_offset = offset + ihl
source_port = None
destination_port = None
if protocol in {"tcp", "udp"}:
if len(packet) < transport_offset + 4:
return None
source_port, destination_port = struct.unpack("!HH", packet[transport_offset:transport_offset + 4])
elif protocol == "icmp" and len(packet) >= transport_offset + 2:
source_port = packet[transport_offset]
destination_port = packet[transport_offset + 1]
return {
"source_ip": source_ip,
"destination_ip": destination_ip,
"protocol": protocol,
"source_port": source_port,
"destination_port": destination_port,
"packets": 1,
"bytes": total_length if total_length else max(len(packet) - offset, 0),
"state": "observed",
}
def collect_packet_flows(interfaces: list[dict[str, Any]], duration: int, limit: int = 500) -> tuple[list[dict[str, Any]], dict[str, Any]]:
flow_interfaces = selected_flow_interfaces(interfaces)
sockets: dict[socket.socket, dict[str, Any]] = {}
diagnostics: dict[str, Any] = {
"collector": "linux-af-packet",
"duration_seconds": duration,
"interfaces_requested": [interface.get("name") for interface in flow_interfaces],
"interfaces_opened": [],
"errors": [],
}
for interface in flow_interfaces:
name = str(interface.get("name") or "")
try:
packet_socket = socket.socket(socket.AF_PACKET, socket.SOCK_RAW, socket.htons(ETH_P_ALL))
packet_socket.bind((name, 0))
packet_socket.setblocking(False)
sockets[packet_socket] = interface
diagnostics["interfaces_opened"].append(name)
except OSError as exc:
diagnostics["errors"].append({"interface": name, "error": str(exc)})
if not sockets:
return [], diagnostics
flows: dict[tuple[object, ...], dict[str, Any]] = {}
deadline = time.monotonic() + max(duration, 1)
try:
while time.monotonic() < deadline:
timeout = min(1.0, max(deadline - time.monotonic(), 0.0))
readable, _, _ = select.select(list(sockets), [], [], timeout)
for packet_socket in readable:
interface = sockets[packet_socket]
try:
packet = packet_socket.recv(65535)
except OSError:
continue
flow = parse_packet_flow(packet)
if not flow:
continue
name = str(interface.get("name") or "")
vmid = str(interface.get("vmid") or "")
detail_match = VM_INTERFACE_DETAIL_RE.search(name)
nic = detail_match.group(2) if detail_match else "0"
key = (
vmid,
nic,
flow["source_ip"],
flow["destination_ip"],
flow["protocol"],
flow.get("source_port"),
flow.get("destination_port"),
)
current = flows.get(key)
if current:
current["packets"] += 1
current["bytes"] += int(flow["bytes"])
current["last_seen_at"] = datetime.now(timezone.utc).isoformat()
continue
now = datetime.now(timezone.utc).isoformat()
flow.update(
{
"vmid": vmid,
"nic": nic,
"interface": name,
"collector": "linux-af-packet",
"first_seen_at": now,
"last_seen_at": now,
"observed_at": now,
}
)
flows[key] = flow
if len(flows) >= limit:
diagnostics["truncated"] = True
return sorted(flows.values(), key=lambda item: int(item.get("bytes") or 0), reverse=True), diagnostics
finally:
for packet_socket in sockets:
packet_socket.close()
return sorted(flows.values(), key=lambda item: int(item.get("bytes") or 0), reverse=True), diagnostics
def parse_conntrack_line(line: str) -> dict[str, Any] | None: def parse_conntrack_line(line: str) -> dict[str, Any] | None:
parts = line.split() parts = line.split()
if len(parts) < 5 or parts[0] not in {"tcp", "udp", "icmp"}: if len(parts) < 5 or parts[0] not in {"tcp", "udp", "icmp"}:
@@ -113,12 +338,235 @@ def collect_flows(limit: int = 500) -> list[dict[str, Any]]:
if key in seen: if key in seen:
continue continue
seen.add(key) seen.add(key)
now = datetime.now(timezone.utc).isoformat()
flow["first_seen_at"] = now
flow["last_seen_at"] = now
flow["observed_at"] = now
flow["collector"] = flow.get("collector") or "conntrack"
flows.append(flow) flows.append(flow)
if len(flows) >= limit: if len(flows) >= limit:
break break
return flows return flows
def firewall_log_seen_at(line: str) -> str:
match = FIREWALL_LOG_TS_RE.search(line)
if match:
try:
return datetime.strptime(match.group(1), "%d/%b/%Y:%H:%M:%S %z").astimezone(timezone.utc).isoformat()
except ValueError:
pass
return datetime.now(timezone.utc).isoformat()
def parse_firewall_log_line(line: str) -> dict[str, Any] | None:
fields = {key: value for key, value in LOG_FIELD_RE.findall(line)}
source_ip = fields.get("SRC")
destination_ip = fields.get("DST")
protocol = fields.get("PROTO", "").lower()
if not source_ip or not destination_ip or protocol not in {"tcp", "udp", "icmp"}:
return None
lowered = line.lower()
decision = None
if any(token in lowered for token in ("drop", "reject", "deny", "blocked")):
decision = "blocked"
elif any(token in lowered for token in ("accept", "allow")):
decision = "allowed"
if not decision:
return None
source_port = fields.get("SPT")
destination_port = fields.get("DPT")
length = fields.get("LEN")
seen_at = firewall_log_seen_at(line)
return {
"source_ip": source_ip,
"destination_ip": destination_ip,
"protocol": protocol,
"source_port": int(source_port) if source_port and source_port.isdigit() else None,
"destination_port": int(destination_port) if destination_port and destination_port.isdigit() else None,
"packets": 1,
"bytes": int(length) if length and length.isdigit() else 0,
"state": decision,
"decision": decision,
"collector": "firewall-log",
"log_excerpt": line[-180:],
"first_seen_at": seen_at,
"last_seen_at": seen_at,
"observed_at": seen_at,
}
def firewall_log_lines_from_files(max_lines: int = 5000) -> tuple[list[str], list[dict[str, str]]]:
lines: list[str] = []
errors: list[dict[str, str]] = []
for path in ("/var/log/pve-firewall.log", "/var/log/pve-firewall.log.1"):
try:
with open(path, "r", encoding="utf-8", errors="ignore") as handle:
file_lines = handle.readlines()[-max_lines:]
lines.extend(line.rstrip("\n") for line in file_lines)
except OSError as exc:
errors.append({"path": path, "error": str(exc)})
return lines[-max_lines:], errors
def collect_firewall_log_flows(since_minutes: int = 5, limit: int = 500) -> tuple[list[dict[str, Any]], dict[str, Any]]:
diagnostics = {"collector": "journalctl-kernel+pve-firewall-log", "since_minutes": since_minutes, "errors": []}
code, output = run_command(
["journalctl", "-k", "--since", f"-{max(since_minutes, 1)} min", "--no-pager", "-o", "cat"],
timeout=10,
)
log_lines: list[str] = []
if code == 0 and output:
log_lines.extend(output.splitlines())
else:
diagnostics["errors"].append(output or "journalctl returned no firewall log output")
file_lines, file_errors = firewall_log_lines_from_files(max(limit * 2, 1000))
log_lines.extend(file_lines)
diagnostics["file_errors"] = file_errors
diagnostics["lines_scanned"] = len(log_lines)
if not log_lines:
return [], diagnostics
flows: dict[tuple[object, ...], dict[str, Any]] = {}
for line in log_lines:
if "SRC=" not in line or "DST=" not in line:
continue
flow = parse_firewall_log_line(line)
if not flow:
continue
key = (
flow["source_ip"],
flow["destination_ip"],
flow["protocol"],
flow.get("source_port"),
flow.get("destination_port"),
flow.get("decision"),
)
current = flows.get(key)
if current:
current["packets"] += 1
current["bytes"] += int(flow.get("bytes") or 0)
current["last_seen_at"] = flow.get("last_seen_at") or datetime.now(timezone.utc).isoformat()
current["observed_at"] = current["last_seen_at"]
continue
flows[key] = flow
if len(flows) >= limit:
diagnostics["truncated"] = True
break
diagnostics["flow_count"] = len(flows)
return sorted(flows.values(), key=lambda item: int(item.get("packets") or 0), reverse=True), diagnostics
def merge_flow_sources(*sources: list[dict[str, Any]], limit: int = 500) -> list[dict[str, Any]]:
flows: dict[tuple[object, ...], dict[str, Any]] = {}
for source in sources:
for flow in source:
key = (
flow.get("source_ip"),
flow.get("destination_ip"),
flow.get("protocol"),
flow.get("source_port"),
flow.get("destination_port"),
flow.get("decision") or flow.get("state") or "observed",
)
current = flows.get(key)
if current:
current["packets"] = int(current.get("packets") or 0) + int(flow.get("packets") or 0)
current["bytes"] = int(current.get("bytes") or 0) + int(flow.get("bytes") or 0)
current_seen = str(current.get("last_seen_at") or current.get("observed_at") or "")
flow_seen = str(flow.get("last_seen_at") or flow.get("observed_at") or "")
if flow_seen > current_seen:
current["last_seen_at"] = flow_seen
current["observed_at"] = flow_seen
continue
flows[key] = dict(flow)
return sorted(
flows.values(),
key=lambda item: (
1 if str(item.get("decision") or item.get("state") or "").lower() in {"blocked", "drop", "dropped", "reject", "rejected", "deny", "denied"} else 0,
1 if item.get("collector") == "firewall-log" else 0,
int(item.get("bytes") or 0),
int(item.get("packets") or 0),
),
reverse=True,
)[:limit]
def collect_ebpf_flows(config: dict[str, Any], interfaces: list[dict[str, Any]], limit: int) -> tuple[list[dict[str, Any]], dict[str, Any]]:
binary = str(config.get("ebpf_binary") or "/opt/nexafabric-agent/nexafabric-ebpf")
diagnostics: dict[str, Any] = {
"collector": "ebpf",
"enabled": bool(config.get("ebpf_collector", False)),
"binary": binary,
"errors": [],
}
if not diagnostics["enabled"]:
return [], diagnostics
if not executable_exists(binary):
diagnostics["errors"].append("eBPF helper binary is not installed or not executable")
return [], diagnostics
flow_interfaces = [str(interface.get("name")) for interface in selected_flow_interfaces(interfaces)]
if not flow_interfaces:
diagnostics["errors"].append("no VM/LXC tap or firewall-link interfaces found")
return [], diagnostics
args = [
binary,
"--json",
"--limit",
str(limit),
"--duration",
str(int(config.get("ebpf_window_seconds", config.get("packet_flow_window_seconds", 10)))),
"--interfaces",
",".join(flow_interfaces),
]
code, output = run_command(args, timeout=int(config.get("ebpf_timeout_seconds", 15)))
diagnostics["exit_code"] = code
if code != 0 or not output:
diagnostics["errors"].append(output or "eBPF helper returned no output")
return [], diagnostics
try:
payload = json.loads(output)
except json.JSONDecodeError as exc:
diagnostics["errors"].append(f"eBPF helper returned invalid JSON: {exc}")
diagnostics["output_excerpt"] = output[:300]
return [], diagnostics
flows = payload.get("flows", [])
if not isinstance(flows, list):
diagnostics["errors"].append("eBPF helper JSON has no flows array")
return [], diagnostics
normalized = []
for flow in flows[:limit]:
if not isinstance(flow, dict):
continue
source_ip = flow.get("source_ip")
destination_ip = flow.get("destination_ip")
if not source_ip or not destination_ip:
continue
normalized.append(
{
**flow,
"source_ip": str(source_ip),
"destination_ip": str(destination_ip),
"protocol": str(flow.get("protocol") or "unknown").lower(),
"source_port": flow_int(flow.get("source_port"), 0) or None,
"destination_port": flow_int(flow.get("destination_port"), 0) or None,
"packets": flow_int(flow.get("packets")),
"bytes": flow_int(flow.get("bytes")),
"state": str(flow.get("state") or "observed"),
"collector": "ebpf",
}
)
diagnostics["flow_count"] = len(normalized)
if isinstance(payload.get("diagnostics"), dict):
diagnostics["helper"] = payload["diagnostics"]
return normalized, diagnostics
def collect_conntrack() -> dict[str, Any]: def collect_conntrack() -> dict[str, Any]:
code, output = run_command(["conntrack", "-C"]) code, output = run_command(["conntrack", "-C"])
if code == 0 and output.isdigit(): if code == 0 and output.isdigit():
@@ -132,6 +580,29 @@ def collect_conntrack() -> dict[str, Any]:
return {"count": None, "source": "unavailable"} return {"count": None, "source": "unavailable"}
def collect_flow_diagnostics(
conntrack: dict[str, Any],
flow_count: int,
packet_diagnostics: dict[str, Any] | None = None,
firewall_log_diagnostics: dict[str, Any] | None = None,
ebpf_diagnostics: dict[str, Any] | None = None,
) -> dict[str, Any]:
code, conntrack_path = run_command(["sh", "-c", "command -v conntrack"])
return {
"flow_count": flow_count,
"ebpf_collector": ebpf_diagnostics,
"packet_collector": packet_diagnostics,
"firewall_log_collector": firewall_log_diagnostics,
"conntrack_binary": conntrack_path if code == 0 else None,
"conntrack_count": conntrack.get("count"),
"conntrack_source": conntrack.get("source"),
"bridge_nf_call_iptables": read_text("/proc/sys/net/bridge/bridge-nf-call-iptables"),
"bridge_nf_call_ip6tables": read_text("/proc/sys/net/bridge/bridge-nf-call-ip6tables"),
"nf_conntrack_max": read_text("/proc/sys/net/netfilter/nf_conntrack_max"),
"nf_conntrack_count": read_text("/proc/sys/net/netfilter/nf_conntrack_count"),
}
def collect_firewall() -> dict[str, Any]: def collect_firewall() -> dict[str, Any]:
status = {} status = {}
code, output = run_command(["systemctl", "is-active", "pve-firewall"]) code, output = run_command(["systemctl", "is-active", "pve-firewall"])
@@ -146,6 +617,29 @@ def collect_firewall() -> dict[str, Any]:
def collect_payload(config: dict[str, Any]) -> dict[str, Any]: def collect_payload(config: dict[str, Any]) -> dict[str, Any]:
uptime = read_text("/proc/uptime") uptime = read_text("/proc/uptime")
interfaces = collect_interfaces()
flow_limit = int(config.get("flow_limit", 1500))
packet_flows: list[dict[str, Any]] = []
packet_diagnostics: dict[str, Any] | None = None
ebpf_flows: list[dict[str, Any]] = []
ebpf_diagnostics: dict[str, Any] | None = None
firewall_log_flows: list[dict[str, Any]] = []
firewall_log_diagnostics: dict[str, Any] | None = None
ebpf_flows, ebpf_diagnostics = collect_ebpf_flows(config, interfaces, flow_limit)
if bool(config.get("packet_flow_collector", True)):
packet_flows, packet_diagnostics = collect_packet_flows(
interfaces,
int(config.get("packet_flow_window_seconds", 10)),
flow_limit,
)
if bool(config.get("firewall_log_collector", True)):
firewall_log_flows, firewall_log_diagnostics = collect_firewall_log_flows(
int(config.get("firewall_log_window_minutes", 5)),
flow_limit,
)
fallback_flows = packet_flows or collect_flows(flow_limit)
flows = merge_flow_sources(ebpf_flows, fallback_flows, firewall_log_flows, limit=flow_limit)
conntrack = collect_conntrack()
return { return {
"version": VERSION, "version": VERSION,
"node_name": config.get("node_name"), "node_name": config.get("node_name"),
@@ -154,11 +648,23 @@ def collect_payload(config: dict[str, Any]) -> dict[str, Any]:
"kernel": platform.release(), "kernel": platform.release(),
"uptime_seconds": float(uptime.split()[0]) if uptime else None, "uptime_seconds": float(uptime.split()[0]) if uptime else None,
"loadavg": list(os.getloadavg()) if hasattr(os, "getloadavg") else [], "loadavg": list(os.getloadavg()) if hasattr(os, "getloadavg") else [],
"interfaces": collect_interfaces(), "interfaces": interfaces,
"flows": collect_flows(int(config.get("flow_limit", 500))), "interface_traffic": collect_interface_traffic(interfaces),
"conntrack": collect_conntrack(), "flows": flows,
"ebpf_flows": ebpf_flows,
"conntrack": conntrack,
"firewall": collect_firewall(), "firewall": collect_firewall(),
"extra": {"platform": platform.platform()}, "extra": {
"platform": platform.platform(),
"flow_sources": {
"ebpf": len(ebpf_flows),
"packet": len(packet_flows),
"conntrack_fallback": 0 if packet_flows else len(fallback_flows),
"firewall_log": len(firewall_log_flows),
"merged": len(flows),
},
"flow_diagnostics": collect_flow_diagnostics(conntrack, len(flows), packet_diagnostics, firewall_log_diagnostics, ebpf_diagnostics),
},
} }
@@ -205,6 +711,7 @@ def main() -> int:
interval = int(config.get("interval_seconds", 30)) interval = int(config.get("interval_seconds", 30))
while True: while True:
started_at = time.monotonic()
payload = collect_payload(config) payload = collect_payload(config)
try: try:
post_heartbeat(config, payload) post_heartbeat(config, payload)
@@ -216,7 +723,8 @@ def main() -> int:
print(f"heartbeat failed: {exc} api_url={normalized_api_url(config)}", flush=True) print(f"heartbeat failed: {exc} api_url={normalized_api_url(config)}", flush=True)
if args.once: if args.once:
return 0 return 0
time.sleep(interval) elapsed = time.monotonic() - started_at
time.sleep(max(interval - elapsed, 1))
if __name__ == "__main__": if __name__ == "__main__":
+349
View File
@@ -0,0 +1,349 @@
package main
import (
"encoding/binary"
"encoding/json"
"flag"
"fmt"
"net"
"os"
"regexp"
"sort"
"strings"
"syscall"
"time"
)
const (
ethPAll = 0x0003
ethPIP = 0x0800
ethP8021Q = 0x8100
ethP8021AD = 0x88A8
packetOut = 4
protoICMP = 1
protoTCP = 6
protoUDP = 17
maxFrameSize = 65535
)
var vmInterfaceRE = regexp.MustCompile(`(?:tap|fwbr|fwln|fwpr)(\d+)`)
var vmInterfaceDetailRE = regexp.MustCompile(`(?:tap|fwbr|fwln|fwpr)(\d+)i(\d+)`)
type flowKey struct {
VMID string
NIC string
Interface string
Direction string
SourceIP string
DestinationIP string
Protocol string
SourcePort int
DestinationPort int
}
type flowValue struct {
SourceIP string `json:"source_ip"`
DestinationIP string `json:"destination_ip"`
Protocol string `json:"protocol"`
SourcePort *int `json:"source_port,omitempty"`
DestinationPort *int `json:"destination_port,omitempty"`
Packets uint64 `json:"packets"`
Bytes uint64 `json:"bytes"`
VMID string `json:"vmid,omitempty"`
NIC string `json:"nic,omitempty"`
Interface string `json:"interface,omitempty"`
Direction string `json:"direction,omitempty"`
State string `json:"state"`
Collector string `json:"collector"`
FirstSeenAt string `json:"first_seen_at"`
LastSeenAt string `json:"last_seen_at"`
ObservedAt string `json:"observed_at"`
}
type diagnostics struct {
AttachMode string `json:"attach_mode"`
InterfacesRequested []string `json:"interfaces_requested"`
InterfacesAttached []string `json:"interfaces_attached"`
Errors []string `json:"errors"`
}
type payload struct {
Flows []flowValue `json:"flows"`
Diagnostics diagnostics `json:"diagnostics"`
}
func htons(value uint16) uint16 {
return (value<<8)&0xff00 | value>>8
}
func htonsInt(value uint16) int {
return int(htons(value))
}
func intPtr(value int) *int {
if value == 0 {
return nil
}
return &value
}
func protocolName(value byte) string {
switch value {
case protoICMP:
return "icmp"
case protoTCP:
return "tcp"
case protoUDP:
return "udp"
default:
return ""
}
}
func parsePacket(packet []byte) (flowKey, int, bool) {
var key flowKey
if len(packet) < 34 {
return key, 0, false
}
offset := 12
ethType := binary.BigEndian.Uint16(packet[offset : offset+2])
offset = 14
for ethType == ethP8021Q || ethType == ethP8021AD {
if len(packet) < offset+4 {
return key, 0, false
}
ethType = binary.BigEndian.Uint16(packet[offset+2 : offset+4])
offset += 4
}
if ethType != ethPIP || len(packet) < offset+20 {
return key, 0, false
}
versionIHL := packet[offset]
version := versionIHL >> 4
ihl := int(versionIHL&0x0f) * 4
if version != 4 || ihl < 20 || len(packet) < offset+ihl {
return key, 0, false
}
totalLength := int(binary.BigEndian.Uint16(packet[offset+2 : offset+4]))
protocol := protocolName(packet[offset+9])
if protocol == "" {
return key, 0, false
}
key.SourceIP = net.IP(packet[offset+12 : offset+16]).String()
key.DestinationIP = net.IP(packet[offset+16 : offset+20]).String()
key.Protocol = protocol
transportOffset := offset + ihl
if protocol == "tcp" || protocol == "udp" {
if len(packet) < transportOffset+4 {
return key, 0, false
}
key.SourcePort = int(binary.BigEndian.Uint16(packet[transportOffset : transportOffset+2]))
key.DestinationPort = int(binary.BigEndian.Uint16(packet[transportOffset+2 : transportOffset+4]))
} else if protocol == "icmp" && len(packet) >= transportOffset+2 {
key.SourcePort = int(packet[transportOffset])
key.DestinationPort = int(packet[transportOffset+1])
}
if totalLength <= 0 {
totalLength = len(packet) - offset
}
return key, totalLength, true
}
func interfaceMeta(name string) (string, string) {
vmid := ""
nic := "0"
if match := vmInterfaceRE.FindStringSubmatch(name); len(match) > 1 {
vmid = match[1]
}
if match := vmInterfaceDetailRE.FindStringSubmatch(name); len(match) > 2 {
nic = match[2]
}
return vmid, nic
}
func setFd(fd int, set *syscall.FdSet) {
set.Bits[fd/64] |= 1 << uint(fd%64)
}
func isSet(fd int, set *syscall.FdSet) bool {
return set.Bits[fd/64]&(1<<uint(fd%64)) != 0
}
func openSocket(interfaceName string) (int, error) {
iface, err := net.InterfaceByName(interfaceName)
if err != nil {
return -1, err
}
fd, err := syscall.Socket(syscall.AF_PACKET, syscall.SOCK_RAW, htonsInt(ethPAll))
if err != nil {
return -1, err
}
addr := &syscall.SockaddrLinklayer{Protocol: htons(ethPAll), Ifindex: iface.Index}
if err := syscall.Bind(fd, addr); err != nil {
_ = syscall.Close(fd)
return -1, err
}
if err := syscall.SetNonblock(fd, true); err != nil {
_ = syscall.Close(fd)
return -1, err
}
return fd, nil
}
func collect(interfaceNames []string, duration time.Duration, limit int) payload {
result := payload{
Flows: []flowValue{},
Diagnostics: diagnostics{
AttachMode: "af_packet_raw_socket",
InterfacesRequested: interfaceNames,
InterfacesAttached: []string{},
Errors: []string{},
},
}
type socketInfo struct {
name string
fd int
}
sockets := []socketInfo{}
for _, name := range interfaceNames {
if strings.TrimSpace(name) == "" {
continue
}
fd, err := openSocket(strings.TrimSpace(name))
if err != nil {
result.Diagnostics.Errors = append(result.Diagnostics.Errors, fmt.Sprintf("%s: %v", name, err))
continue
}
sockets = append(sockets, socketInfo{name: strings.TrimSpace(name), fd: fd})
result.Diagnostics.InterfacesAttached = append(result.Diagnostics.InterfacesAttached, strings.TrimSpace(name))
}
defer func() {
for _, socket := range sockets {
_ = syscall.Close(socket.fd)
}
}()
if len(sockets) == 0 {
return result
}
fdToSocket := map[int]socketInfo{}
maxFd := 0
for _, socket := range sockets {
fdToSocket[socket.fd] = socket
if socket.fd > maxFd {
maxFd = socket.fd
}
}
flows := map[flowKey]*flowValue{}
deadline := time.Now().Add(duration)
buffer := make([]byte, maxFrameSize)
for time.Now().Before(deadline) {
var readfds syscall.FdSet
for _, socket := range sockets {
setFd(socket.fd, &readfds)
}
timeout := syscall.NsecToTimeval(int64(250 * time.Millisecond))
_, err := syscall.Select(maxFd+1, &readfds, nil, nil, &timeout)
if err != nil && err != syscall.EINTR {
result.Diagnostics.Errors = append(result.Diagnostics.Errors, err.Error())
break
}
for fd, socket := range fdToSocket {
if !isSet(fd, &readfds) {
continue
}
n, from, err := syscall.Recvfrom(fd, buffer, 0)
if err != nil {
if err != syscall.EAGAIN && err != syscall.EWOULDBLOCK {
result.Diagnostics.Errors = append(result.Diagnostics.Errors, fmt.Sprintf("%s: %v", socket.name, err))
}
continue
}
key, bytes, ok := parsePacket(buffer[:n])
if !ok {
continue
}
now := time.Now().UTC().Format(time.RFC3339Nano)
key.Interface = socket.name
key.VMID, key.NIC = interfaceMeta(socket.name)
key.Direction = "ingress"
if link, ok := from.(*syscall.SockaddrLinklayer); ok && link.Pkttype == packetOut {
key.Direction = "egress"
}
current := flows[key]
if current == nil {
current = &flowValue{
SourceIP: key.SourceIP,
DestinationIP: key.DestinationIP,
Protocol: key.Protocol,
SourcePort: intPtr(key.SourcePort),
DestinationPort: intPtr(key.DestinationPort),
VMID: key.VMID,
NIC: key.NIC,
Interface: key.Interface,
Direction: key.Direction,
State: "observed",
Collector: "ebpf-helper",
FirstSeenAt: now,
LastSeenAt: now,
ObservedAt: now,
}
flows[key] = current
}
current.Packets++
current.Bytes += uint64(bytes)
current.LastSeenAt = now
current.ObservedAt = now
if len(flows) >= limit {
break
}
}
if len(flows) >= limit {
break
}
}
for _, flow := range flows {
result.Flows = append(result.Flows, *flow)
}
sort.Slice(result.Flows, func(i, j int) bool {
if result.Flows[i].Bytes == result.Flows[j].Bytes {
return result.Flows[i].Packets > result.Flows[j].Packets
}
return result.Flows[i].Bytes > result.Flows[j].Bytes
})
if len(result.Flows) > limit {
result.Flows = result.Flows[:limit]
}
return result
}
func main() {
jsonOutput := flag.Bool("json", false, "print JSON output")
limit := flag.Int("limit", 1500, "maximum unique flows")
durationSeconds := flag.Int("duration", 10, "collection duration in seconds")
interfaces := flag.String("interfaces", "", "comma-separated interface names")
flag.Parse()
if !*jsonOutput {
fmt.Fprintln(os.Stderr, "only --json output is supported")
os.Exit(2)
}
if *limit <= 0 {
*limit = 1500
}
if *durationSeconds <= 0 {
*durationSeconds = 10
}
names := []string{}
for _, name := range strings.Split(*interfaces, ",") {
if strings.TrimSpace(name) != "" {
names = append(names, strings.TrimSpace(name))
}
}
result := collect(names, time.Duration(*durationSeconds)*time.Second, *limit)
data, err := json.Marshal(result)
if err != nil {
fmt.Fprintf(os.Stderr, "json marshal failed: %v\n", err)
os.Exit(1)
}
fmt.Println(string(data))
}
File diff suppressed because it is too large Load Diff
+16 -1
View File
@@ -1,5 +1,6 @@
from fastapi import FastAPI from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from sqlalchemy import text
from app.api.v1.router import api_router from app.api.v1.router import api_router
from app.core.config import get_settings from app.core.config import get_settings
@@ -7,6 +8,20 @@ from app.db.session import Base, SessionLocal, engine
from app.seed.demo import seed_demo_data from app.seed.demo import seed_demo_data
def ensure_runtime_indexes() -> None:
index_statements = [
"CREATE INDEX IF NOT EXISTS ix_ip_addresses_address ON ip_addresses (address)",
"CREATE INDEX IF NOT EXISTS ix_traffic_flows_node_updated ON traffic_flows (node_id, updated_at)",
"CREATE INDEX IF NOT EXISTS ix_traffic_flows_source_updated ON traffic_flows (source_ip, updated_at)",
"CREATE INDEX IF NOT EXISTS ix_traffic_flows_destination_updated ON traffic_flows (destination_ip, updated_at)",
"CREATE INDEX IF NOT EXISTS ix_traffic_flows_destination_port_updated ON traffic_flows (destination_port, updated_at)",
"CREATE INDEX IF NOT EXISTS ix_traffic_flows_state_updated ON traffic_flows (state, updated_at)",
]
with engine.begin() as connection:
for statement in index_statements:
connection.execute(text(statement))
def create_app() -> FastAPI: def create_app() -> FastAPI:
settings = get_settings() settings = get_settings()
app = FastAPI( app = FastAPI(
@@ -27,6 +42,7 @@ def create_app() -> FastAPI:
@app.on_event("startup") @app.on_event("startup")
def startup() -> None: def startup() -> None:
Base.metadata.create_all(bind=engine) Base.metadata.create_all(bind=engine)
ensure_runtime_indexes()
with SessionLocal() as db: with SessionLocal() as db:
seed_demo_data(db) seed_demo_data(db)
@@ -39,4 +55,3 @@ def create_app() -> FastAPI:
app = create_app() app = create_app()
+23 -2
View File
@@ -2,7 +2,7 @@ from datetime import datetime
from enum import StrEnum from enum import StrEnum
from uuid import uuid4 from uuid import uuid4
from sqlalchemy import JSON, BigInteger, Boolean, DateTime, Enum, ForeignKey, Integer, String, Text, UniqueConstraint from sqlalchemy import JSON, BigInteger, Boolean, DateTime, Enum, ForeignKey, Index, Integer, String, Text, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column, relationship from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.db.session import Base from app.db.session import Base
@@ -185,7 +185,10 @@ class Subnet(Base, TimestampMixin):
class IpAddress(Base, TimestampMixin): class IpAddress(Base, TimestampMixin):
__tablename__ = "ip_addresses" __tablename__ = "ip_addresses"
__table_args__ = (UniqueConstraint("subnet_id", "address"),) __table_args__ = (
UniqueConstraint("subnet_id", "address"),
Index("ix_ip_addresses_address", "address"),
)
id: Mapped[str] = mapped_column(String, primary_key=True, default=new_id) id: Mapped[str] = mapped_column(String, primary_key=True, default=new_id)
subnet_id: Mapped[str] = mapped_column(ForeignKey("subnets.id"), index=True) subnet_id: Mapped[str] = mapped_column(ForeignKey("subnets.id"), index=True)
@@ -197,6 +200,13 @@ class IpAddress(Base, TimestampMixin):
class TrafficFlow(Base, TimestampMixin): class TrafficFlow(Base, TimestampMixin):
__tablename__ = "traffic_flows" __tablename__ = "traffic_flows"
__table_args__ = (
Index("ix_traffic_flows_node_updated", "node_id", "updated_at"),
Index("ix_traffic_flows_source_updated", "source_ip", "updated_at"),
Index("ix_traffic_flows_destination_updated", "destination_ip", "updated_at"),
Index("ix_traffic_flows_destination_port_updated", "destination_port", "updated_at"),
Index("ix_traffic_flows_state_updated", "state", "updated_at"),
)
id: Mapped[str] = mapped_column(String, primary_key=True, default=new_id) id: Mapped[str] = mapped_column(String, primary_key=True, default=new_id)
node_id: Mapped[str] = mapped_column(ForeignKey("nodes.id"), index=True) node_id: Mapped[str] = mapped_column(ForeignKey("nodes.id"), index=True)
@@ -221,6 +231,17 @@ class SecurityGroup(Base, TimestampMixin):
description: Mapped[str | None] = mapped_column(Text) description: Mapped[str | None] = mapped_column(Text)
class SecurityGroupMember(Base, TimestampMixin):
__tablename__ = "security_group_members"
__table_args__ = (UniqueConstraint("security_group_id", "workload_id"),)
id: Mapped[str] = mapped_column(String, primary_key=True, default=new_id)
security_group_id: Mapped[str] = mapped_column(ForeignKey("security_groups.id"), index=True)
workload_id: Mapped[str] = mapped_column(ForeignKey("workloads.id"), index=True)
security_group: Mapped[SecurityGroup] = relationship()
workload: Mapped[Workload] = relationship()
class SecurityRule(Base, TimestampMixin): class SecurityRule(Base, TimestampMixin):
__tablename__ = "security_rules" __tablename__ = "security_rules"
+46
View File
@@ -113,12 +113,24 @@ class SubnetCreate(BaseModel):
dhcp_enabled: bool = False dhcp_enabled: bool = False
class SubnetUpdate(BaseModel):
network_id: str | None = None
cidr: str | None = None
gateway: str | None = None
dns: list[str] | None = None
dhcp_enabled: bool | None = None
class SecurityGroupCreate(BaseModel): class SecurityGroupCreate(BaseModel):
project_id: str | None = None project_id: str | None = None
name: str name: str
description: str | None = None description: str | None = None
class SecurityGroupMemberCreate(BaseModel):
workload_id: str
class SecurityRuleCreate(BaseModel): class SecurityRuleCreate(BaseModel):
security_group_id: str security_group_id: str
direction: str = "ingress" direction: str = "ingress"
@@ -160,6 +172,27 @@ class FirewallApplyRequest(BaseModel):
dry_run: bool = True dry_run: bool = True
class RuntimeSettingsRead(BaseModel):
product: str = "NexaFabric"
firewall_apply_requires_preview: bool = True
agent_optional: bool = True
flow_retention_hours: int = 24
auto_node_sync_enabled: bool = False
auto_node_sync_interval_minutes: int = 60
auto_ipam_sync_enabled: bool = False
auto_ipam_sync_interval_minutes: int = 60
last_node_auto_sync_at: datetime | None = None
last_ipam_auto_sync_at: datetime | None = None
class RuntimeSettingsUpdate(BaseModel):
flow_retention_hours: int | None = Field(default=None, ge=1, le=8760)
auto_node_sync_enabled: bool | None = None
auto_node_sync_interval_minutes: int | None = Field(default=None, ge=1, le=10080)
auto_ipam_sync_enabled: bool | None = None
auto_ipam_sync_interval_minutes: int | None = Field(default=None, ge=1, le=10080)
class ClusterRead(OrmModel): class ClusterRead(OrmModel):
id: str id: str
name: str name: str
@@ -203,7 +236,9 @@ class AgentHeartbeat(BaseModel):
uptime_seconds: float | None = None uptime_seconds: float | None = None
loadavg: list[float] = [] loadavg: list[float] = []
interfaces: list[dict[str, Any]] = [] interfaces: list[dict[str, Any]] = []
interface_traffic: list[dict[str, Any]] = []
flows: list[dict[str, Any]] = [] flows: list[dict[str, Any]] = []
ebpf_flows: list[dict[str, Any]] = []
conntrack: dict[str, Any] = {} conntrack: dict[str, Any] = {}
firewall: dict[str, Any] = {} firewall: dict[str, Any] = {}
extra: dict[str, Any] = {} extra: dict[str, Any] = {}
@@ -275,6 +310,15 @@ class SecurityGroupRead(OrmModel):
project_id: str | None project_id: str | None
name: str name: str
description: str | None description: str | None
members: list[dict[str, Any]] = []
class SecurityGroupMemberRead(OrmModel):
id: str
security_group_id: str
workload_id: str
workload_name: str | None = None
workload_external_id: str | None = None
class SecurityRuleRead(OrmModel): class SecurityRuleRead(OrmModel):
@@ -300,12 +344,14 @@ class PolicyRead(OrmModel):
enforcement_mode: str enforcement_mode: str
definition: dict[str, Any] definition: dict[str, Any]
last_compiled: dict[str, Any] | None last_compiled: dict[str, Any] | None
deployment_status: dict[str, Any] | None = None
class WorkloadInsight(BaseModel): class WorkloadInsight(BaseModel):
workload: WorkloadRead workload: WorkloadRead
assigned_ips: list[IpAddressRead] assigned_ips: list[IpAddressRead]
traffic: list[dict[str, Any]] traffic: list[dict[str, Any]]
active_firewall_rules: list[dict[str, Any]] = []
matching_policies: list[PolicyRead] matching_policies: list[PolicyRead]
effective_decision: str effective_decision: str
audit_mode_notes: list[str] audit_mode_notes: list[str]
+235
View File
@@ -0,0 +1,235 @@
from datetime import datetime, timedelta
from ipaddress import ip_interface
from typing import Any
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.models.domain import Cluster, IpAddress, Job, Network, Node, Subnet, SystemSetting, Workload
from app.services.providers.base import ProviderConnection
from app.services.providers.registry import get_provider
def runtime_setting(db: Session) -> SystemSetting:
setting = db.get(SystemSetting, "runtime")
if not setting:
setting = SystemSetting(key="runtime", value={})
db.add(setting)
db.commit()
db.refresh(setting)
return setting
def parse_last_run(value: dict[str, Any], key: str) -> datetime | None:
raw = value.get(key)
if not raw:
return None
try:
return datetime.fromisoformat(str(raw))
except ValueError:
return None
def due(value: dict[str, Any], enabled_key: str, interval_key: str, last_key: str) -> bool:
if not bool(value.get(enabled_key, False)):
return False
interval = int(value.get(interval_key) or 60)
last_run = parse_last_run(value, last_key)
return last_run is None or datetime.utcnow() - last_run >= timedelta(minutes=interval)
def is_container_network(value: str) -> bool:
try:
interface = ip_interface(value)
except ValueError:
return False
ip = interface.ip
network = str(interface.network)
if ip.is_loopback or ip.is_link_local:
return True
if ip.version == 4 and ip.packed[0] == 172 and 17 <= ip.packed[1] <= 31:
return True
return network.startswith(("10.42.", "10.43.", "10.244.", "10.245."))
def cleanup_discovered_container_networks(db: Session) -> int:
removed = 0
discovered_networks = db.scalars(select(Network).where(Network.name == "discovered-ipam")).all()
for network in discovered_networks:
subnets = db.scalars(select(Subnet).where(Subnet.network_id == network.id)).all()
for subnet in subnets:
if is_container_network(subnet.cidr):
addresses = db.scalars(select(IpAddress).where(IpAddress.subnet_id == subnet.id)).all()
for address in addresses:
db.delete(address)
removed += 1
db.delete(subnet)
return removed
def ensure_discovered_network(db: Session, cluster_id: str) -> Network:
network = db.scalar(select(Network).where(Network.cluster_id == cluster_id, Network.name == "discovered-ipam"))
if network:
return network
network = Network(
cluster_id=cluster_id,
name="discovered-ipam",
kind="discovered",
description="Automatically created for IP addresses discovered during Proxmox sync.",
)
db.add(network)
db.flush()
return network
def import_discovered_ips(db: Session, cluster_id: str, workload: Workload, addresses: list[str]) -> int:
imported = 0
for value in addresses:
try:
interface = ip_interface(value)
except ValueError:
continue
if is_container_network(value):
continue
network = ensure_discovered_network(db, cluster_id)
subnet = db.scalar(select(Subnet).where(Subnet.network_id == network.id, Subnet.cidr == str(interface.network)))
if not subnet:
subnet = Subnet(network_id=network.id, cidr=str(interface.network))
db.add(subnet)
db.flush()
address_value = str(interface.ip)
existing = db.scalar(select(IpAddress).where(IpAddress.subnet_id == subnet.id, IpAddress.address == address_value))
if existing:
existing.workload_id = workload.id
existing.status = "assigned"
else:
db.add(IpAddress(subnet_id=subnet.id, address=address_value, status="assigned", workload_id=workload.id))
imported += 1
return imported
async def sync_cluster_inventory(db: Session, cluster: Cluster, job_kind: str = "proxmox.auto_sync") -> dict[str, Any]:
provider = get_provider(cluster.provider)
try:
inventory = await provider.sync_inventory(
ProviderConnection(
api_url=cluster.api_url,
token=cluster.token_ref or "",
verify_tls=cluster.verify_tls,
read_only=cluster.mode == "read_only",
)
)
except Exception as exc:
cluster.last_sync_at = datetime.utcnow()
cluster.last_sync_status = "failed"
cluster.last_sync_error = str(exc)
db.add(Job(kind=job_kind, status="failed", progress=100, logs=[f"Auto sync failed for {cluster.name}"], error=str(exc)))
db.commit()
return {"cluster": cluster.name, "status": "failed", "error": str(exc)}
cluster.last_sync_at = datetime.utcnow()
cluster.last_sync_status = "success"
cluster.last_sync_error = None
node_by_name = {node.name: node for node in db.scalars(select(Node).where(Node.cluster_id == cluster.id)).all()}
for raw_node in inventory.get("nodes", []):
name = raw_node.get("node") or raw_node.get("name")
if not name:
continue
node = node_by_name.get(name)
if not node:
node = Node(cluster_id=cluster.id, name=name)
db.add(node)
node_by_name[name] = node
node.status = raw_node.get("status", node.status)
node.cpu_count = int(raw_node.get("maxcpu") or raw_node.get("cpu_count") or node.cpu_count or 0)
maxmem = raw_node.get("maxmem")
node.memory_mb = int(maxmem / 1024 / 1024) if isinstance(maxmem, int | float) else int(raw_node.get("memory_mb") or node.memory_mb or 0)
db.flush()
workloads = {workload.external_id: workload for workload in db.scalars(select(Workload).where(Workload.cluster_id == cluster.id)).all()}
for raw_workload in inventory.get("workloads", []):
external_id = str(raw_workload.get("vmid") or raw_workload.get("id") or "")
if not external_id:
continue
node = node_by_name.get(raw_workload.get("node")) or next(iter(node_by_name.values()), None)
if not node:
continue
workload = workloads.get(external_id)
if not workload:
workload = Workload(cluster_id=cluster.id, node_id=node.id, external_id=external_id, name=external_id, kind="qemu")
db.add(workload)
workloads[external_id] = workload
workload.node_id = node.id
workload.name = raw_workload.get("name") or workload.name
workload.kind = raw_workload.get("type") or raw_workload.get("kind") or workload.kind
workload.status = raw_workload.get("status") or workload.status
import_discovered_ips(db, cluster.id, workload, raw_workload.get("ip_addresses", []))
networks = {network.name: network for network in db.scalars(select(Network).where(Network.cluster_id == cluster.id)).all()}
for raw_network in inventory.get("networks", []):
name = raw_network.get("name") or raw_network.get("iface") or raw_network.get("id")
if not name:
continue
network = networks.get(name)
if not network:
network = Network(cluster_id=cluster.id, name=name, kind=raw_network.get("type") or "network")
db.add(network)
networks[name] = network
network.kind = raw_network.get("type") or raw_network.get("kind") or network.kind
vlan = raw_network.get("vlan") or raw_network.get("vlan_id")
network.vlan_id = int(vlan) if vlan not in (None, "") else network.vlan_id
db.add(Job(kind=job_kind, status="success", progress=100, logs=[f"Auto synced {cluster.name}"]))
db.commit()
return {"cluster": cluster.name, "status": "success", "inventory_counts": {key: len(value) for key, value in inventory.items()}}
async def discover_ipam(db: Session, job_kind: str = "ipam.auto_discover") -> dict[str, Any]:
imported = 0
removed = cleanup_discovered_container_networks(db)
errors = []
for cluster in db.scalars(select(Cluster).order_by(Cluster.name)).all():
try:
inventory = await get_provider(cluster.provider).sync_inventory(
ProviderConnection(
api_url=cluster.api_url,
token=cluster.token_ref or "",
verify_tls=cluster.verify_tls,
read_only=True,
)
)
workloads = {workload.external_id: workload for workload in db.scalars(select(Workload).where(Workload.cluster_id == cluster.id)).all()}
for raw_workload in inventory.get("workloads", []):
workload = workloads.get(str(raw_workload.get("vmid") or raw_workload.get("id") or ""))
if workload:
imported += import_discovered_ips(db, cluster.id, workload, raw_workload.get("ip_addresses", []))
except Exception as exc:
errors.append({"cluster": cluster.name, "error": str(exc)})
db.add(
Job(
kind=job_kind,
status="success" if not errors else "failed",
progress=100,
logs=[f"Imported {imported} IP addresses", f"Removed {removed} container bridge IPs"],
error=str(errors) if errors else None,
)
)
db.commit()
return {"imported": imported, "removed": removed, "errors": errors}
async def run_due_jobs(db: Session) -> list[dict[str, Any]]:
setting = runtime_setting(db)
value = dict(setting.value or {})
results = []
if due(value, "auto_node_sync_enabled", "auto_node_sync_interval_minutes", "last_node_auto_sync_at"):
for cluster in db.scalars(select(Cluster).order_by(Cluster.name)).all():
results.append(await sync_cluster_inventory(db, cluster))
value["last_node_auto_sync_at"] = datetime.utcnow().isoformat()
if due(value, "auto_ipam_sync_enabled", "auto_ipam_sync_interval_minutes", "last_ipam_auto_sync_at"):
results.append(await discover_ipam(db))
value["last_ipam_auto_sync_at"] = datetime.utcnow().isoformat()
if results:
setting.value = value
db.commit()
return results
+174 -1
View File
@@ -127,15 +127,61 @@ class ProxmoxProvider(Provider):
"warnings": ["Preview only. No Proxmox firewall changes were sent."], "warnings": ["Preview only. No Proxmox firewall changes were sent."],
} }
async def list_firewall_rules(self, connection: ProviderConnection, target: dict[str, Any]) -> list[dict[str, Any]]:
headers = {"Authorization": self.auth_header(connection.token)}
async with httpx.AsyncClient(verify=connection.verify_tls, timeout=10) as client:
response = await client.get(self.firewall_rules_url(connection, target), headers=headers)
response.raise_for_status()
return response.json().get("data", [])
def firewall_rules_url(self, connection: ProviderConnection, target: dict[str, Any]) -> str: def firewall_rules_url(self, connection: ProviderConnection, target: dict[str, Any]) -> str:
kind = "lxc" if target.get("kind") == "lxc" else "qemu" kind = "lxc" if target.get("kind") == "lxc" else "qemu"
node = target["node"] node = target["node"]
vmid = target["vmid"] vmid = target["vmid"]
return f"{connection.api_url.rstrip('/')}/api2/json/nodes/{node}/{kind}/{vmid}/firewall/rules" return f"{connection.api_url.rstrip('/')}/api2/json/nodes/{node}/{kind}/{vmid}/firewall/rules"
def firewall_options_url(self, connection: ProviderConnection, target: dict[str, Any]) -> str:
kind = "lxc" if target.get("kind") == "lxc" else "qemu"
node = target["node"]
vmid = target["vmid"]
return f"{connection.api_url.rstrip('/')}/api2/json/nodes/{node}/{kind}/{vmid}/firewall/options"
def workload_config_url(self, connection: ProviderConnection, target: dict[str, Any]) -> str:
kind = "lxc" if target.get("kind") == "lxc" else "qemu"
node = target["node"]
vmid = target["vmid"]
return f"{connection.api_url.rstrip('/')}/api2/json/nodes/{node}/{kind}/{vmid}/config"
def cluster_firewall_options_url(self, connection: ProviderConnection) -> str:
return f"{connection.api_url.rstrip('/')}/api2/json/cluster/firewall/options"
def node_firewall_options_url(self, connection: ProviderConnection, target: dict[str, Any]) -> str:
node = target["node"]
return f"{connection.api_url.rstrip('/')}/api2/json/nodes/{node}/firewall/options"
def policy_marker(self, rule: dict[str, Any]) -> str: def policy_marker(self, rule: dict[str, Any]) -> str:
return f"NexaFabric policy={rule.get('policy_id')}" return f"NexaFabric policy={rule.get('policy_id')}"
def policy_id_marker(self, policy_id: str) -> str:
return f"NexaFabric policy={policy_id}"
def rule_comment_matches_marker(self, comment: str, marker: str) -> bool:
return comment == marker or comment.startswith(f"{marker} ")
def network_firewall_enabled_value(self, value: str) -> str:
parts = [part for part in value.split(",") if part]
found = False
updated = []
for part in parts:
if part.startswith("firewall="):
updated.append("firewall=1")
found = True
else:
updated.append(part)
if not found:
updated.append("firewall=1")
return ",".join(updated)
async def delete_existing_policy_rules( async def delete_existing_policy_rules(
self, self,
client: httpx.AsyncClient, client: httpx.AsyncClient,
@@ -150,12 +196,99 @@ class ProxmoxProvider(Provider):
for existing_rule in sorted(existing_rules, key=lambda item: int(item.get("pos", 0)), reverse=True): for existing_rule in sorted(existing_rules, key=lambda item: int(item.get("pos", 0)), reverse=True):
comment = str(existing_rule.get("comment") or "") comment = str(existing_rule.get("comment") or "")
pos = existing_rule.get("pos") pos = existing_rule.get("pos")
if marker in comment and pos is not None: if self.rule_comment_matches_marker(comment, marker) and pos is not None:
delete_response = await client.delete(f"{rules_url}/{pos}", headers=headers) delete_response = await client.delete(f"{rules_url}/{pos}", headers=headers)
delete_response.raise_for_status() delete_response.raise_for_status()
deletions.append({"pos": pos, "comment": comment}) deletions.append({"pos": pos, "comment": comment})
return deletions return deletions
async def enable_guest_firewall(
self,
client: httpx.AsyncClient,
headers: dict[str, str],
connection: ProviderConnection,
target: dict[str, Any],
) -> dict[str, Any]:
response = await client.put(self.firewall_options_url(connection, target), headers=headers, data={"enable": 1})
response.raise_for_status()
return response.json().get("data")
async def enable_guest_firewall_interfaces(
self,
client: httpx.AsyncClient,
headers: dict[str, str],
connection: ProviderConnection,
target: dict[str, Any],
) -> list[dict[str, Any]]:
config_url = self.workload_config_url(connection, target)
response = await client.get(config_url, headers=headers)
response.raise_for_status()
config = response.json().get("data", {})
changes: dict[str, str] = {}
for key, value in config.items():
if not key.startswith("net") or not isinstance(value, str):
continue
enabled_value = self.network_firewall_enabled_value(value)
if enabled_value != value:
changes[key] = enabled_value
if changes:
update_response = await client.put(config_url, headers=headers, data=changes)
update_response.raise_for_status()
return [{"interface": key, "firewall": 1} for key in sorted(changes)]
def config_interface_status(self, config: dict[str, Any]) -> list[dict[str, Any]]:
interfaces = []
for key, value in config.items():
if not key.startswith("net") or not isinstance(value, str):
continue
parts = {part.split("=", 1)[0]: part.split("=", 1)[1] for part in value.split(",") if "=" in part}
interfaces.append(
{
"interface": key,
"bridge": parts.get("bridge"),
"firewall": parts.get("firewall") == "1",
"raw": value,
}
)
return interfaces
async def firewall_enforcement_status(
self,
client: httpx.AsyncClient,
headers: dict[str, str],
connection: ProviderConnection,
target: dict[str, Any],
) -> dict[str, Any]:
status: dict[str, Any] = {"target": target, "warnings": []}
checks = [
("datacenter", self.cluster_firewall_options_url(connection)),
("node", self.node_firewall_options_url(connection, target)),
("guest", self.firewall_options_url(connection, target)),
("config", self.workload_config_url(connection, target)),
]
for name, url in checks:
try:
response = await client.get(url, headers=headers)
response.raise_for_status()
data = response.json().get("data", {})
except Exception as exc:
status[name] = {"error": str(exc)}
status["warnings"].append(f"Unable to read {name} firewall status: {exc}")
continue
if name == "config":
interfaces = self.config_interface_status(data)
status["interfaces"] = interfaces
disabled = [interface["interface"] for interface in interfaces if not interface.get("firewall")]
if disabled:
status["warnings"].append(f"VM/LXC network firewall flag is disabled on: {', '.join(disabled)}")
continue
status[name] = data
enabled = data.get("enable")
if str(enabled) not in {"1", "True", "true"}:
label = {"datacenter": "Datacenter", "node": "Node", "guest": "VM/LXC"}.get(name, name)
status["warnings"].append(f"{label} firewall enable option is not active.")
return status
async def apply_rules(self, connection: ProviderConnection, rules: list[dict[str, Any]]) -> dict[str, Any]: async def apply_rules(self, connection: ProviderConnection, rules: list[dict[str, Any]]) -> dict[str, Any]:
if connection.read_only: if connection.read_only:
return {"applied": False, "reason": "Cluster is read-only", "rules": rules} return {"applied": False, "reason": "Cluster is read-only", "rules": rules}
@@ -177,6 +310,9 @@ class ProxmoxProvider(Provider):
applied_rules = [] applied_rules = []
deleted_rules = [] deleted_rules = []
enabled_targets = []
enabled_interfaces = []
enforcement_status = []
audit_only_rules = [] audit_only_rules = []
async with httpx.AsyncClient(verify=connection.verify_tls, timeout=20) as client: async with httpx.AsyncClient(verify=connection.verify_tls, timeout=20) as client:
for target_rules in grouped.values(): for target_rules in grouped.values():
@@ -184,6 +320,9 @@ class ProxmoxProvider(Provider):
rules_url = self.firewall_rules_url(connection, target) rules_url = self.firewall_rules_url(connection, target)
marker = self.policy_marker(target_rules[0]) marker = self.policy_marker(target_rules[0])
deleted_rules.extend(await self.delete_existing_policy_rules(client, headers, rules_url, marker)) deleted_rules.extend(await self.delete_existing_policy_rules(client, headers, rules_url, marker))
if any(not rule.get("audit_only") for rule in target_rules):
enabled_targets.append({"target": target, "result": await self.enable_guest_firewall(client, headers, connection, target)})
enabled_interfaces.append({"target": target, "interfaces": await self.enable_guest_firewall_interfaces(client, headers, connection, target)})
for rule in target_rules: for rule in target_rules:
if rule.get("audit_only"): if rule.get("audit_only"):
@@ -205,11 +344,45 @@ class ProxmoxProvider(Provider):
create_response = await client.post(rules_url, headers=headers, data=provider_rule) create_response = await client.post(rules_url, headers=headers, data=provider_rule)
create_response.raise_for_status() create_response.raise_for_status()
applied_rules.append({"target": target, "rule": provider_rule, "result": create_response.json().get("data")}) applied_rules.append({"target": target, "rule": provider_rule, "result": create_response.json().get("data")})
enforcement_status.append(await self.firewall_enforcement_status(client, headers, connection, target))
return { return {
"applied": True, "applied": True,
"rules_written": len(applied_rules), "rules_written": len(applied_rules),
"rules_deleted": len(deleted_rules), "rules_deleted": len(deleted_rules),
"firewall_enabled": enabled_targets,
"interfaces_enabled": enabled_interfaces,
"enforcement_status": enforcement_status,
"audit_only": audit_only_rules, "audit_only": audit_only_rules,
"rules": applied_rules, "rules": applied_rules,
} }
async def delete_policy_rules(
self,
connection: ProviderConnection,
targets: list[dict[str, Any]],
policy_id: str,
) -> dict[str, Any]:
if connection.read_only:
return {"applied": False, "reason": "Cluster is read-only", "rules_deleted": 0, "targets": targets}
headers = {"Authorization": self.auth_header(connection.token)}
marker = self.policy_id_marker(policy_id)
deleted_rules = []
errors = []
async with httpx.AsyncClient(verify=connection.verify_tls, timeout=20) as client:
for target in targets:
rules_url = self.firewall_rules_url(connection, target)
try:
deleted = await self.delete_existing_policy_rules(client, headers, rules_url, marker)
except Exception as exc:
errors.append({"target": target, "error": str(exc)})
continue
deleted_rules.extend({"target": target, **item} for item in deleted)
return {
"applied": not errors,
"rules_deleted": len(deleted_rules),
"deleted_rules": deleted_rules,
"errors": errors,
}
+18 -5
View File
@@ -1,12 +1,25 @@
import time import asyncio
from app.db.session import SessionLocal
from app.services.auto_sync import run_due_jobs
async def loop() -> None:
print("NexaFabric worker started. Auto-sync scheduler is active.", flush=True)
while True:
try:
with SessionLocal() as db:
results = await run_due_jobs(db)
for result in results:
print(f"auto-sync: {result}", flush=True)
except Exception as exc:
print(f"auto-sync failed: {exc}", flush=True)
await asyncio.sleep(30)
def main() -> None: def main() -> None:
print("NexaFabric worker started. Configure Celery queues for production job execution.", flush=True) asyncio.run(loop())
while True:
time.sleep(30)
if __name__ == "__main__": if __name__ == "__main__":
main() main()
+30
View File
@@ -0,0 +1,30 @@
import importlib.util
from pathlib import Path
def load_agent_module():
path = Path(__file__).resolve().parents[1] / "app" / "agent_assets" / "nexafabric-agent.py"
spec = importlib.util.spec_from_file_location("nexafabric_agent", path)
assert spec and spec.loader
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module
def test_parse_proxmox_reject_firewall_log_line():
agent = load_agent_module()
line = (
"100 6 tap100i0-IN 09/Jul/2026:23:04:58 +0200 REJECT: IN=fwbr100i0 OUT=fwbr100i0 "
"PHYSIN=fwln100i0 PHYSOUT=tap100i0 SRC=172.16.155.74 DST=172.16.0.100 LEN=52 "
"TTL=128 ID=47107 PROTO=TCP SPT=58945 DPT=80"
)
flow = agent.parse_firewall_log_line(line)
assert flow["source_ip"] == "172.16.155.74"
assert flow["destination_ip"] == "172.16.0.100"
assert flow["protocol"] == "tcp"
assert flow["source_port"] == 58945
assert flow["destination_port"] == 80
assert flow["decision"] == "blocked"
assert flow["state"] == "blocked"
+44 -1
View File
@@ -19,6 +19,8 @@ class FakeResponse:
class FakeAsyncClient: class FakeAsyncClient:
deleted_urls: list[str] = [] deleted_urls: list[str] = []
posted_payloads: list[dict] = [] posted_payloads: list[dict] = []
put_urls: list[str] = []
put_payloads: list[dict] = []
def __init__(self, **_: object) -> None: def __init__(self, **_: object) -> None:
return None return None
@@ -29,7 +31,13 @@ class FakeAsyncClient:
async def __aexit__(self, *_: object) -> None: async def __aexit__(self, *_: object) -> None:
return None return None
async def get(self, _: str, **__: object) -> FakeResponse: async def get(self, url: str, **__: object) -> FakeResponse:
if url.endswith("/config"):
return FakeResponse({"net0": "virtio=AA:BB:CC:DD:EE:FF,bridge=vmbr0,firewall=0", "name": "web"})
if url.endswith("/cluster/firewall/options"):
return FakeResponse({"enable": 1})
if url.endswith("/firewall/options"):
return FakeResponse({"enable": 1})
return FakeResponse( return FakeResponse(
[ [
{"pos": 0, "comment": "manual rule"}, {"pos": 0, "comment": "manual rule"},
@@ -41,6 +49,11 @@ class FakeAsyncClient:
self.deleted_urls.append(url) self.deleted_urls.append(url)
return FakeResponse(None) return FakeResponse(None)
async def put(self, url: str, data: dict, **__: object) -> FakeResponse:
self.put_urls.append(url)
self.put_payloads.append(data)
return FakeResponse(None)
async def post(self, _: str, data: dict, **__: object) -> FakeResponse: async def post(self, _: str, data: dict, **__: object) -> FakeResponse:
self.posted_payloads.append(data) self.posted_payloads.append(data)
return FakeResponse({"pos": 1}) return FakeResponse({"pos": 1})
@@ -50,6 +63,8 @@ class FakeAsyncClient:
async def test_apply_rules_replaces_only_marked_nexafabric_rules(monkeypatch: pytest.MonkeyPatch) -> None: async def test_apply_rules_replaces_only_marked_nexafabric_rules(monkeypatch: pytest.MonkeyPatch) -> None:
FakeAsyncClient.deleted_urls = [] FakeAsyncClient.deleted_urls = []
FakeAsyncClient.posted_payloads = [] FakeAsyncClient.posted_payloads = []
FakeAsyncClient.put_urls = []
FakeAsyncClient.put_payloads = []
monkeypatch.setattr(proxmox.httpx, "AsyncClient", FakeAsyncClient) monkeypatch.setattr(proxmox.httpx, "AsyncClient", FakeAsyncClient)
result = await ProxmoxProvider().apply_rules( result = await ProxmoxProvider().apply_rules(
@@ -74,6 +89,14 @@ async def test_apply_rules_replaces_only_marked_nexafabric_rules(monkeypatch: py
assert result["applied"] is True assert result["applied"] is True
assert result["rules_deleted"] == 1 assert result["rules_deleted"] == 1
assert FakeAsyncClient.deleted_urls == ["https://pve.example:8006/api2/json/nodes/pve1/qemu/100/firewall/rules/1"] assert FakeAsyncClient.deleted_urls == ["https://pve.example:8006/api2/json/nodes/pve1/qemu/100/firewall/rules/1"]
assert FakeAsyncClient.put_urls == [
"https://pve.example:8006/api2/json/nodes/pve1/qemu/100/firewall/options",
"https://pve.example:8006/api2/json/nodes/pve1/qemu/100/config",
]
assert FakeAsyncClient.put_payloads == [
{"enable": 1},
{"net0": "virtio=AA:BB:CC:DD:EE:FF,bridge=vmbr0,firewall=1"},
]
assert FakeAsyncClient.posted_payloads == [ assert FakeAsyncClient.posted_payloads == [
{ {
"type": "in", "type": "in",
@@ -84,3 +107,23 @@ async def test_apply_rules_replaces_only_marked_nexafabric_rules(monkeypatch: py
"comment": "NexaFabric policy=policy-1 version=2 rule=1 target=web", "comment": "NexaFabric policy=policy-1 version=2 rule=1 target=web",
} }
] ]
@pytest.mark.asyncio
async def test_delete_policy_rules_removes_marked_rules(monkeypatch: pytest.MonkeyPatch) -> None:
FakeAsyncClient.deleted_urls = []
FakeAsyncClient.posted_payloads = []
FakeAsyncClient.put_urls = []
FakeAsyncClient.put_payloads = []
monkeypatch.setattr(proxmox.httpx, "AsyncClient", FakeAsyncClient)
result = await ProxmoxProvider().delete_policy_rules(
ProviderConnection(api_url="https://pve.example:8006", token="user@pve!token=secret", read_only=False),
[{"node": "pve1", "kind": "qemu", "vmid": "100"}],
"policy-1",
)
assert result["applied"] is True
assert result["rules_deleted"] == 1
assert FakeAsyncClient.deleted_urls == ["https://pve.example:8006/api2/json/nodes/pve1/qemu/100/firewall/rules/1"]
assert FakeAsyncClient.posted_payloads == []
+5 -2
View File
@@ -16,10 +16,11 @@ import { Policies } from "./pages/Policies";
import { PolicyDesigner } from "./pages/PolicyDesigner"; import { PolicyDesigner } from "./pages/PolicyDesigner";
import { SecurityGroups } from "./pages/SecurityGroups"; import { SecurityGroups } from "./pages/SecurityGroups";
import { ServiceCatalog } from "./pages/ServiceCatalog"; import { ServiceCatalog } from "./pages/ServiceCatalog";
import { Settings } from "./pages/Settings";
import { SetupWizard } from "./pages/SetupWizard"; import { SetupWizard } from "./pages/SetupWizard";
import { TenantsProjects } from "./pages/TenantsProjects"; import { TenantsProjects } from "./pages/TenantsProjects";
import { UsersRoles } from "./pages/UsersRoles"; import { UsersRoles } from "./pages/UsersRoles";
import { Workloads } from "./pages/Workloads"; import { WorkloadDetail, WorkloadFlows, Workloads } from "./pages/Workloads";
import { useTheme } from "./stores/theme"; import { useTheme } from "./stores/theme";
const queryClient = new QueryClient(); const queryClient = new QueryClient();
@@ -49,6 +50,8 @@ function AppRoutes() {
<Route path="clusters" element={<Clusters />} /> <Route path="clusters" element={<Clusters />} />
<Route path="nodes" element={<Nodes />} /> <Route path="nodes" element={<Nodes />} />
<Route path="workloads" element={<Workloads />} /> <Route path="workloads" element={<Workloads />} />
<Route path="workloads/:workloadId" element={<WorkloadDetail />} />
<Route path="workloads/:workloadId/flows" element={<WorkloadFlows />} />
<Route path="networks" element={<Networks />} /> <Route path="networks" element={<Networks />} />
<Route path="ipam" element={<Ipam />} /> <Route path="ipam" element={<Ipam />} />
<Route path="tenants" element={<TenantsProjects />} /> <Route path="tenants" element={<TenantsProjects />} />
@@ -60,7 +63,7 @@ function AppRoutes() {
<Route path="jobs" element={<ListPage title="Jobs" subtitle="Background task state and execution logs." path="/jobs" columns={[{ key: "kind", label: "Kind" }, { key: "status", label: "Status" }, { key: "progress", label: "Progress" }]} />} /> <Route path="jobs" element={<ListPage title="Jobs" subtitle="Background task state and execution logs." path="/jobs" columns={[{ key: "kind", label: "Kind" }, { key: "status", label: "Status" }, { key: "progress", label: "Progress" }]} />} />
<Route path="audit" element={<ListPage title="Audit Logs" subtitle="Security-relevant activity and change history." path="/audit" columns={[{ key: "created_at", label: "Time" }, { key: "action", label: "Action" }, { key: "object_type", label: "Object" }, { key: "result", label: "Result" }]} />} /> <Route path="audit" element={<ListPage title="Audit Logs" subtitle="Security-relevant activity and change history." path="/audit" columns={[{ key: "created_at", label: "Time" }, { key: "action", label: "Action" }, { key: "object_type", label: "Object" }, { key: "result", label: "Result" }]} />} />
<Route path="users" element={<UsersRoles />} /> <Route path="users" element={<UsersRoles />} />
<Route path="settings" element={<ListPage title="Settings" subtitle="Runtime settings and safety defaults." path="/settings" columns={[{ key: "product", label: "Product" }, { key: "firewall_apply_requires_preview", label: "Preview Required" }, { key: "agent_optional", label: "Agent Optional" }]} />} /> <Route path="settings" element={<Settings />} />
</Route> </Route>
</Routes> </Routes>
); );
+36
View File
@@ -6,8 +6,19 @@ export type Dashboard = {
workloads: number; workloads: number;
networks: number; networks: number;
open_policy_violations: number; open_policy_violations: number;
security_posture: string;
faulty_nodes: Array<{ id: string; name: string; status: string }>; faulty_nodes: Array<{ id: string; name: string; status: string }>;
last_syncs: Array<{ id: string; name: string; provider: string; status: string | null; error: string | null; at: string | null }>;
top_talkers: Array<{ name: string; bytes: number }>; top_talkers: Array<{ name: string; bytes: number }>;
suspicious_traffic: Array<{
source: string;
destination: string;
protocol: string;
port: number;
bytes: number;
reason: string;
severity: string;
}>;
}; };
export type SetupStatus = { export type SetupStatus = {
@@ -57,6 +68,7 @@ export type Subnet = {
network_id: string; network_id: string;
cidr: string; cidr: string;
gateway: string | null; gateway: string | null;
dns: string[];
dhcp_enabled: boolean; dhcp_enabled: boolean;
}; };
@@ -87,6 +99,15 @@ export type SecurityGroup = {
project_id: string | null; project_id: string | null;
name: string; name: string;
description: string | null; description: string | null;
members: SecurityGroupMember[];
};
export type SecurityGroupMember = {
id: string;
security_group_id: string;
workload_id: string;
workload_name: string | null;
workload_external_id: string | null;
}; };
export type SecurityRule = { export type SecurityRule = {
@@ -112,6 +133,7 @@ export type Policy = {
enforcement_mode: string; enforcement_mode: string;
definition: Record<string, unknown>; definition: Record<string, unknown>;
last_compiled: Record<string, unknown> | null; last_compiled: Record<string, unknown> | null;
deployment_status: Record<string, unknown> | null;
}; };
export type Workload = { export type Workload = {
@@ -151,10 +173,24 @@ export type AgentInstallInfo = {
command: string; command: string;
}; };
export type RuntimeSettings = {
product: string;
firewall_apply_requires_preview: boolean;
agent_optional: boolean;
flow_retention_hours: number;
auto_node_sync_enabled: boolean;
auto_node_sync_interval_minutes: number;
auto_ipam_sync_enabled: boolean;
auto_ipam_sync_interval_minutes: number;
last_node_auto_sync_at: string | null;
last_ipam_auto_sync_at: string | null;
};
export type WorkloadInsight = { export type WorkloadInsight = {
workload: Workload; workload: Workload;
assigned_ips: IpAddress[]; assigned_ips: IpAddress[];
traffic: Array<Record<string, unknown>>; traffic: Array<Record<string, unknown>>;
active_firewall_rules: Array<Record<string, unknown>>;
matching_policies: Policy[]; matching_policies: Policy[];
effective_decision: string; effective_decision: string;
audit_mode_notes: string[]; audit_mode_notes: string[];
+82 -23
View File
@@ -9,6 +9,7 @@ import {
Flame, Flame,
GitBranch, GitBranch,
LayoutDashboard, LayoutDashboard,
LogOut,
LockKeyhole, LockKeyhole,
Moon, Moon,
Network, Network,
@@ -18,35 +19,85 @@ import {
SquareStack, SquareStack,
Sun, Sun,
Users, Users,
type LucideIcon,
} from "lucide-react"; } from "lucide-react";
import { useEffect } from "react"; import { useEffect } from "react";
import { token } from "../api/client"; import { clearTokens, token } from "../api/client";
import { useTheme } from "../stores/theme"; import { useTheme } from "../stores/theme";
const nav = [ const navGroups = [
{
label: "Operate",
items: [
{ to: "/", label: "Dashboard", icon: LayoutDashboard }, { to: "/", label: "Dashboard", icon: LayoutDashboard },
{ to: "/clusters", label: "Clusters", icon: Server }, { to: "/clusters", label: "Clusters", icon: Server },
{ to: "/nodes", label: "Nodes", icon: Activity }, { to: "/nodes", label: "Nodes", icon: Activity },
{ to: "/workloads", label: "VMs/LXCs", icon: Blocks }, { to: "/workloads", label: "VMs/LXCs", icon: Blocks },
],
},
{
label: "Network",
items: [
{ to: "/networks", label: "Networks", icon: Network }, { to: "/networks", label: "Networks", icon: Network },
{ to: "/ipam", label: "IPAM", icon: Database }, { to: "/ipam", label: "IPAM", icon: Database },
{ to: "/services", label: "Service Catalog", icon: SquareStack },
{ to: "/tenants", label: "Tenants", icon: BriefcaseBusiness }, { to: "/tenants", label: "Tenants", icon: BriefcaseBusiness },
],
},
{
label: "Security",
items: [
{ to: "/security-groups", label: "Security Groups", icon: Shield }, { to: "/security-groups", label: "Security Groups", icon: Shield },
{ to: "/policies", label: "Policies", icon: GitBranch }, { to: "/policies", label: "Policies", icon: GitBranch },
{ to: "/services", label: "Service Catalog", icon: SquareStack },
{ to: "/designer", label: "Policy Designer", icon: LockKeyhole }, { to: "/designer", label: "Policy Designer", icon: LockKeyhole },
{ to: "/firewall", label: "Firewall Preview", icon: Flame }, { to: "/firewall", label: "Firewall Preview", icon: Flame },
],
},
];
const systemNav = [
{ to: "/jobs", label: "Jobs", icon: ClipboardList }, { to: "/jobs", label: "Jobs", icon: ClipboardList },
{ to: "/audit", label: "Audit Logs", icon: BookOpen }, { to: "/audit", label: "Audit Logs", icon: BookOpen },
{ to: "/users", label: "Users", icon: Users }, { to: "/users", label: "Users", icon: Users },
{ to: "/settings", label: "Settings", icon: Settings }, { to: "/settings", label: "Settings", icon: Settings },
]; ];
function SidebarLink({ item }: { item: { to: string; label: string; icon: LucideIcon } }) {
const Icon = item.icon;
return (
<NavLink
key={item.to}
to={item.to}
end={item.to === "/"}
className={({ isActive }) =>
`group relative flex h-9 items-center gap-3 rounded-md px-3 text-sm transition-colors ${
isActive
? "bg-accent/10 text-accent dark:bg-accent/15"
: "text-slate-600 hover:bg-slate-100 hover:text-slate-950 dark:text-slate-300 dark:hover:bg-slate-800/80 dark:hover:text-white"
}`
}
>
{({ isActive }) => (
<>
<span className={`absolute left-0 h-5 w-0.5 rounded-r-full ${isActive ? "bg-accent" : "bg-transparent"}`} />
<Icon size={17} className={isActive ? "text-accent" : "text-slate-400 group-hover:text-slate-700 dark:group-hover:text-slate-200"} />
<span className="truncate">{item.label}</span>
</>
)}
</NavLink>
);
}
export function Layout() { export function Layout() {
const navigate = useNavigate(); const navigate = useNavigate();
const { dark, toggle } = useTheme(); const { dark, toggle } = useTheme();
function logout() {
clearTokens();
navigate("/login");
}
useEffect(() => { useEffect(() => {
if (!token()) navigate("/login"); if (!token()) navigate("/login");
function handleAuthExpired() { function handleAuthExpired() {
@@ -60,37 +111,45 @@ export function Layout() {
<div className="min-h-screen bg-canvas text-slate-900 dark:text-slate-100"> <div className="min-h-screen bg-canvas text-slate-900 dark:text-slate-100">
<aside className="fixed inset-y-0 left-0 hidden w-64 border-r border-border bg-panel md:block"> <aside className="fixed inset-y-0 left-0 hidden w-64 border-r border-border bg-panel md:block">
<div className="flex h-16 items-center border-b border-border px-5"> <div className="flex h-16 items-center border-b border-border px-5">
<div className="flex items-center gap-3">
<div className="grid h-9 w-9 place-items-center rounded-md bg-accent text-sm font-semibold text-white">NF</div>
<div> <div>
<div className="text-lg font-semibold">NexaFabric</div> <div className="text-base font-semibold leading-5">NexaFabric</div>
<div className="text-xs text-slate-500 dark:text-slate-400">Control Plane</div> <div className="text-xs text-slate-500 dark:text-slate-400">Control Plane</div>
</div> </div>
</div> </div>
<nav className="h-[calc(100vh-4rem)] overflow-y-auto p-3"> </div>
{nav.map((item) => { <nav className="flex h-[calc(100vh-4rem)] flex-col overflow-y-auto p-3">
const Icon = item.icon; <div className="space-y-5">
return ( {navGroups.map((group) => (
<NavLink <section key={group.label}>
key={item.to} <div className="mb-2 px-3 text-[11px] font-semibold uppercase tracking-wider text-slate-400 dark:text-slate-500">{group.label}</div>
to={item.to} <div className="space-y-1">
className={({ isActive }) => {group.items.map((item) => <SidebarLink key={item.to} item={item} />)}
`mb-1 flex h-10 items-center gap-3 rounded-md px-3 text-sm ${ </div>
isActive ? "bg-accent text-white" : "text-slate-600 hover:bg-slate-100 dark:text-slate-300 dark:hover:bg-slate-800" </section>
}` ))}
} </div>
> <div className="mt-auto border-t border-border pt-3">
<Icon size={18} /> <div className="mb-2 px-3 text-[11px] font-semibold uppercase tracking-wider text-slate-400 dark:text-slate-500">System</div>
<span>{item.label}</span> <div className="space-y-1">
</NavLink> {systemNav.map((item) => <SidebarLink key={item.to} item={item} />)}
); </div>
})} </div>
</nav> </nav>
</aside> </aside>
<main className="md:pl-64"> <main className="md:pl-64">
<header className="sticky top-0 z-10 flex h-16 items-center justify-between border-b border-border bg-panel px-4 md:px-6"> <header className="sticky top-0 z-10 flex h-16 items-center justify-between border-b border-border bg-panel px-4 md:px-6">
<div className="text-sm text-slate-500 dark:text-slate-400">SDN-like network and security operations</div> <div className="text-sm text-slate-500 dark:text-slate-400">SDN-like network and security operations</div>
<button className="rounded-md border border-border p-2 hover:bg-slate-100 dark:hover:bg-slate-800" onClick={toggle} aria-label="Toggle theme"> <div className="flex items-center gap-2">
<button className="inline-flex h-9 w-9 items-center justify-center rounded-md border border-border hover:bg-slate-100 dark:hover:bg-slate-800" onClick={toggle} aria-label="Toggle theme">
{dark ? <Sun size={18} /> : <Moon size={18} />} {dark ? <Sun size={18} /> : <Moon size={18} />}
</button> </button>
<button className="inline-flex h-9 items-center gap-2 rounded-md border border-border px-3 text-sm hover:bg-slate-100 dark:hover:bg-slate-800" onClick={logout}>
<LogOut size={16} />
Logout
</button>
</div>
</header> </header>
<div className="p-4 md:p-6"> <div className="p-4 md:p-6">
<Outlet /> <Outlet />
@@ -0,0 +1,29 @@
import { LoaderCircle } from "lucide-react";
type LoadingOverlayProps = {
open: boolean;
title?: string;
message?: string;
};
export function LoadingOverlay({ open, title = "Working...", message = "Loading data..." }: LoadingOverlayProps) {
if (!open) {
return null;
}
return (
<div className="fixed inset-0 z-50 grid place-items-center bg-slate-950/35 px-4 backdrop-blur-sm">
<div className="w-full max-w-sm rounded-md border border-border bg-panel p-5 shadow-2xl">
<div className="flex items-center gap-4">
<div className="grid h-11 w-11 shrink-0 place-items-center rounded-md bg-accent/10 text-accent">
<LoaderCircle className="animate-spin" size={24} />
</div>
<div>
<div className="font-medium">{title}</div>
<div className="mt-1 text-sm text-slate-500 dark:text-slate-400">{message}</div>
</div>
</div>
</div>
</div>
);
}
@@ -0,0 +1,74 @@
import { useMemo, useState } from "react";
import { inputClass } from "./FormControls";
export type SearchableOption = {
label: string;
value: string;
detail?: string;
};
type SearchableSelectProps = {
options: SearchableOption[];
value: string;
onChange: (value: string) => void;
placeholder?: string;
};
export function SearchableSelect({ options, value, onChange, placeholder = "Search..." }: SearchableSelectProps) {
const selected = options.find((option) => option.value === value);
const [query, setQuery] = useState(selected?.label ?? "");
const [open, setOpen] = useState(false);
const filtered = useMemo(() => {
const normalized = query.trim().toLowerCase();
if (!normalized || selected?.label === query) {
return options.slice(0, 12);
}
return options
.filter((option) => `${option.label} ${option.detail ?? ""}`.toLowerCase().includes(normalized))
.slice(0, 12);
}, [options, query, selected?.label]);
function choose(option: SearchableOption) {
onChange(option.value);
setQuery(option.label);
setOpen(false);
}
return (
<div className="relative">
<input
className={inputClass}
value={open ? query : selected?.label ?? query}
onBlur={() => window.setTimeout(() => setOpen(false), 120)}
onChange={(event) => {
setQuery(event.target.value);
setOpen(true);
}}
onFocus={() => {
setQuery(selected?.label ?? "");
setOpen(true);
}}
placeholder={placeholder}
/>
{open ? (
<div className="absolute z-20 mt-1 max-h-64 w-full overflow-auto rounded-md border border-border bg-panel shadow-lg">
{filtered.length ? filtered.map((option) => (
<button
className="block w-full px-3 py-2 text-left text-sm hover:bg-slate-100 dark:hover:bg-slate-800"
key={option.value}
onMouseDown={(event) => {
event.preventDefault();
choose(option);
}}
type="button"
>
<span className="block font-medium">{option.label}</span>
{option.detail ? <span className="block text-xs text-slate-500">{option.detail}</span> : null}
</button>
)) : <div className="px-3 py-2 text-sm text-slate-500">No matches.</div>}
</div>
) : null}
</div>
);
}
+8
View File
@@ -5,6 +5,7 @@ import { Cable, Pencil, Plus, RefreshCcw, Server, Trash2 } from "lucide-react";
import { api, Cluster } from "../api/client"; import { api, Cluster } from "../api/client";
import { DataTable } from "../components/DataTable"; import { DataTable } from "../components/DataTable";
import { buttonClass, Field, iconButtonClass, inputClass, selectClass } from "../components/FormControls"; import { buttonClass, Field, iconButtonClass, inputClass, selectClass } from "../components/FormControls";
import { LoadingOverlay } from "../components/LoadingOverlay";
import { Modal } from "../components/Modal"; import { Modal } from "../components/Modal";
import { PageHeader } from "../components/PageHeader"; import { PageHeader } from "../components/PageHeader";
@@ -24,6 +25,7 @@ export function Clusters() {
const [result, setResult] = useState(""); const [result, setResult] = useState("");
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const [editing, setEditing] = useState<Cluster | null>(null); const [editing, setEditing] = useState<Cluster | null>(null);
const [busyMessage, setBusyMessage] = useState("");
const save = useMutation({ const save = useMutation({
mutationFn: () => { mutationFn: () => {
@@ -69,9 +71,14 @@ export function Clusters() {
} }
async function action(cluster: Cluster, kind: "test" | "sync") { async function action(cluster: Cluster, kind: "test" | "sync") {
setBusyMessage(kind === "sync" ? `Syncing inventory for ${cluster.name}...` : `Testing connection to ${cluster.name}...`);
try {
const data = await api<Record<string, unknown>>(`/clusters/${cluster.id}/${kind}`, { method: "POST" }); const data = await api<Record<string, unknown>>(`/clusters/${cluster.id}/${kind}`, { method: "POST" });
setResult(JSON.stringify(data, null, 2)); setResult(JSON.stringify(data, null, 2));
await queryClient.invalidateQueries({ queryKey: ["clusters"] }); await queryClient.invalidateQueries({ queryKey: ["clusters"] });
} finally {
setBusyMessage("");
}
} }
function deleteCluster(cluster: Cluster) { function deleteCluster(cluster: Cluster) {
@@ -83,6 +90,7 @@ export function Clusters() {
return ( return (
<> <>
<PageHeader title="Clusters" subtitle="Register, test, and sync Proxmox or demo providers." /> <PageHeader title="Clusters" subtitle="Register, test, and sync Proxmox or demo providers." />
<LoadingOverlay open={Boolean(busyMessage)} message={busyMessage} />
<div className="space-y-4"> <div className="space-y-4">
<button className={buttonClass} onClick={addCluster}><Plus size={16} /> Add Cluster</button> <button className={buttonClass} onClick={addCluster}><Plus size={16} /> Add Cluster</button>
<Modal title={editing ? "Edit Cluster" : "Add Cluster"} open={open} onClose={() => setOpen(false)}> <Modal title={editing ? "Edit Cluster" : "Add Cluster"} open={open} onClose={() => setOpen(false)}>
+110 -12
View File
@@ -1,5 +1,5 @@
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { AlertTriangle, Boxes, Network, Server, ShieldAlert } from "lucide-react"; import { Activity, AlertTriangle, Boxes, Network, Radar, Server, ShieldAlert, ShieldCheck, Wifi } from "lucide-react";
import { api, Dashboard as DashboardData } from "../api/client"; import { api, Dashboard as DashboardData } from "../api/client";
import { PageHeader } from "../components/PageHeader"; import { PageHeader } from "../components/PageHeader";
@@ -8,16 +8,78 @@ const cards = [
["clusters", "Clusters", Server], ["clusters", "Clusters", Server],
["nodes", "Nodes", Boxes], ["nodes", "Nodes", Boxes],
["workloads", "VMs/LXCs", Network], ["workloads", "VMs/LXCs", Network],
["networks", "Networks", Network], ["networks", "Networks", Wifi],
["open_policy_violations", "Policy Violations", ShieldAlert], ["open_policy_violations", "Signals", ShieldAlert],
] as const; ] as const;
function formatBytes(value: number) {
if (!value) {
return "0 B";
}
const units = ["B", "KB", "MB", "GB", "TB"];
const index = Math.min(Math.floor(Math.log(value) / Math.log(1024)), units.length - 1);
return `${(value / 1024 ** index).toFixed(index === 0 ? 0 : 1)} ${units[index]}`;
}
function BarList({ items }: { items: Array<{ name: string; bytes: number }> }) {
const max = Math.max(...items.map((item) => item.bytes), 1);
if (!items.length) {
return <div className="border-t border-border py-4 text-sm text-slate-500">No flow telemetry collected yet.</div>;
}
return (
<div className="space-y-3 border-t border-border pt-4">
{items.map((item) => (
<div key={item.name} className="grid gap-1">
<div className="flex items-center justify-between gap-3 text-sm">
<span className="truncate font-medium">{item.name}</span>
<span className="shrink-0 text-slate-500">{formatBytes(item.bytes)}</span>
</div>
<div className="h-2 overflow-hidden rounded-full bg-slate-200 dark:bg-slate-800">
<div className="h-full rounded-full bg-accent" style={{ width: `${Math.max((item.bytes / max) * 100, 4)}%` }} />
</div>
</div>
))}
</div>
);
}
export function Dashboard() { export function Dashboard() {
const { data } = useQuery({ queryKey: ["dashboard"], queryFn: () => api<DashboardData>("/dashboard") }); const { data } = useQuery({ queryKey: ["dashboard"], queryFn: () => api<DashboardData>("/dashboard") });
const suspicious = data?.suspicious_traffic ?? [];
const postureStable = data?.security_posture !== "attention";
return ( return (
<> <>
<PageHeader title="Dashboard" subtitle="Operational overview across clusters, networks, policies, and sync health." /> <PageHeader title="Dashboard" subtitle="Operational overview across clusters, networks, policies, and sync health." />
<section className="mb-5 rounded-md border border-border bg-panel p-4">
<div className="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
<div className="flex items-center gap-3">
<div className={`grid h-11 w-11 place-items-center rounded-md ${postureStable ? "bg-accent/10 text-accent" : "bg-danger/10 text-danger"}`}>
{postureStable ? <ShieldCheck size={22} /> : <Radar size={22} />}
</div>
<div>
<div className="text-lg font-semibold">{postureStable ? "Control plane stable" : "Attention required"}</div>
<div className="text-sm text-slate-500">
{postureStable ? "No suspicious traffic signals or faulty nodes detected." : `${suspicious.length} suspicious traffic signal${suspicious.length === 1 ? "" : "s"} require review.`}
</div>
</div>
</div>
<div className="grid grid-cols-3 gap-2 text-sm">
<div className="rounded-md border border-border px-3 py-2">
<div className="text-xs text-slate-500">Telemetry</div>
<div className="font-medium">{(data?.top_talkers ?? []).length ? "Active" : "Waiting"}</div>
</div>
<div className="rounded-md border border-border px-3 py-2">
<div className="text-xs text-slate-500">Faulty Nodes</div>
<div className="font-medium">{data?.faulty_nodes.length ?? 0}</div>
</div>
<div className="rounded-md border border-border px-3 py-2">
<div className="text-xs text-slate-500">Signals</div>
<div className="font-medium">{suspicious.length}</div>
</div>
</div>
</div>
</section>
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-5"> <div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-5">
{cards.map(([key, label, Icon]) => ( {cards.map(([key, label, Icon]) => (
<div key={key} className="rounded-md border border-border bg-panel p-4"> <div key={key} className="rounded-md border border-border bg-panel p-4">
@@ -29,27 +91,63 @@ export function Dashboard() {
</div> </div>
))} ))}
</div> </div>
<div className="mt-6 grid gap-4 lg:grid-cols-2"> <div className="mt-5 grid gap-4 xl:grid-cols-[1.2fr_0.8fr]">
<section className="rounded-md border border-border bg-panel p-4">
<div className="mb-3 flex items-center gap-2 font-medium">
<Radar size={18} />
Suspicious Traffic
</div>
{suspicious.length ? (
<div className="divide-y divide-border border-t border-border">
{suspicious.map((item) => (
<div key={`${item.source}-${item.destination}-${item.port}`} className="grid gap-2 py-3 md:grid-cols-[1fr_auto] md:items-center">
<div>
<div className="font-medium">{item.source} -&gt; {item.destination}</div>
<div className="text-xs text-slate-500">{item.protocol}:{item.port} · {item.reason}</div>
</div>
<div className="flex items-center gap-3 text-sm">
<span className={`rounded-md px-2 py-1 text-xs ${item.severity === "high" ? "bg-danger/10 text-danger" : "bg-amber-500/10 text-amber-500"}`}>{item.severity}</span>
<span className="text-slate-500">{formatBytes(item.bytes)}</span>
</div>
</div>
))}
</div>
) : (
<div className="border-t border-border py-4 text-sm text-slate-500">No suspicious traffic detected from current flow telemetry.</div>
)}
</section>
<section className="rounded-md border border-border bg-panel p-4">
<div className="mb-3 flex items-center gap-2 font-medium">
<Activity size={18} />
Top Talkers
</div>
<BarList items={data?.top_talkers ?? []} />
</section>
</div>
<div className="mt-5 grid gap-4 lg:grid-cols-2">
<section className="rounded-md border border-border bg-panel p-4"> <section className="rounded-md border border-border bg-panel p-4">
<div className="mb-3 flex items-center gap-2 font-medium"> <div className="mb-3 flex items-center gap-2 font-medium">
<AlertTriangle size={18} /> <AlertTriangle size={18} />
Faulty Nodes Faulty Nodes
</div> </div>
{(data?.faulty_nodes ?? []).map((node) => ( {(data?.faulty_nodes ?? []).length ? (data?.faulty_nodes ?? []).map((node) => (
<div key={node.id} className="flex justify-between border-t border-border py-3 text-sm"> <div key={node.id} className="flex justify-between border-t border-border py-3 text-sm">
<span>{node.name}</span> <span>{node.name}</span>
<span className="text-danger">{node.status}</span> <span className="text-danger">{node.status}</span>
</div> </div>
))} )) : <div className="border-t border-border py-3 text-sm text-slate-500">All known nodes are online.</div>}
</section> </section>
<section className="rounded-md border border-border bg-panel p-4"> <section className="rounded-md border border-border bg-panel p-4">
<div className="mb-3 font-medium">Top Talkers</div> <div className="mb-3 font-medium">Recent Cluster Sync</div>
{(data?.top_talkers ?? []).length ? (data?.top_talkers ?? []).map((item) => ( {(data?.last_syncs ?? []).length ? (data?.last_syncs ?? []).map((cluster) => (
<div key={item.name} className="flex justify-between border-t border-border py-3 text-sm"> <div key={cluster.id} className="flex justify-between gap-3 border-t border-border py-3 text-sm">
<span>{item.name}</span> <div>
<span>{Math.round(item.bytes / 1_000_000)} MB</span> <div className="font-medium">{cluster.name}</div>
<div className="text-xs text-slate-500">{cluster.provider} · {cluster.at ? new Date(cluster.at).toLocaleString() : "never synced"}</div>
</div> </div>
)) : <div className="border-t border-border py-3 text-sm text-slate-500">No flow telemetry collected yet.</div>} <span className={cluster.status === "failed" ? "text-danger" : "text-accent"}>{cluster.status ?? "unknown"}</span>
</div>
)) : <div className="border-t border-border py-3 text-sm text-slate-500">No cluster sync history yet.</div>}
</section> </section>
</div> </div>
</> </>
+17 -4
View File
@@ -4,6 +4,7 @@ import { useState } from "react";
import { api, Cluster, Policy } from "../api/client"; import { api, Cluster, Policy } from "../api/client";
import { buttonClass, Field, secondaryButtonClass, selectClass } from "../components/FormControls"; import { buttonClass, Field, secondaryButtonClass, selectClass } from "../components/FormControls";
import { LoadingOverlay } from "../components/LoadingOverlay";
import { PageHeader } from "../components/PageHeader"; import { PageHeader } from "../components/PageHeader";
export function FirewallPreview() { export function FirewallPreview() {
@@ -28,16 +29,26 @@ export function FirewallPreview() {
}), }),
}); });
const selectedPolicyId = policyId || policies.data?.[0]?.id || ""; const selectedPolicyId = policyId || policies.data?.[0]?.id || "";
const selectedPolicy = (policies.data ?? []).find((policy) => policy.id === selectedPolicyId);
const auditMode = selectedPolicy?.enforcement_mode === "audit";
const busyMessage = preview.isPending
? "Generating firewall preview..."
: apply.isPending
? dryRun
? "Running dry apply..."
: "Applying firewall rules..."
: "";
return ( return (
<> <>
<PageHeader title="Firewall Preview" subtitle="Compile policies into provider-specific rules before any apply operation." /> <PageHeader title="Firewall Preview" subtitle="Compile policies into provider-specific rules before any apply operation." />
<LoadingOverlay open={Boolean(busyMessage)} message={busyMessage} />
<div className="grid gap-4 lg:grid-cols-[360px_1fr]"> <div className="grid gap-4 lg:grid-cols-[360px_1fr]">
<section className="rounded-md border border-border bg-panel p-4"> <section className="rounded-md border border-border bg-panel p-4">
<div className="grid gap-3"> <div className="grid gap-3">
<Field label="Policy"> <Field label="Policy">
<select className={selectClass} value={selectedPolicyId} onChange={(event) => setPolicyId(event.target.value)}> <select className={selectClass} value={selectedPolicyId} onChange={(event) => setPolicyId(event.target.value)}>
{(policies.data ?? []).map((policy) => <option key={policy.id} value={policy.id}>{policy.name}</option>)} {(policies.data ?? []).map((policy) => <option key={policy.id} value={policy.id}>{policy.name} · {policy.enforcement_mode} · v{policy.version}</option>)}
</select> </select>
</Field> </Field>
<Field label="Cluster"> <Field label="Cluster">
@@ -50,7 +61,9 @@ export function FirewallPreview() {
Dry run Dry run
</label> </label>
<div className="rounded-md border border-border bg-canvas p-3 text-xs text-slate-500 dark:text-slate-400"> <div className="rounded-md border border-border bg-canvas p-3 text-xs text-slate-500 dark:text-slate-400">
{dryRun {auditMode
? "This policy is in audit mode. Preview and dry apply are allowed, but live apply will not write Proxmox firewall rules."
: dryRun
? "Simulation only. NexaFabric will generate the same provider rules, but nothing is written to Proxmox." ? "Simulation only. NexaFabric will generate the same provider rules, but nothing is written to Proxmox."
: "Live apply. NexaFabric will send the generated rules to the selected write-enabled cluster."} : "Live apply. NexaFabric will send the generated rules to the selected write-enabled cluster."}
</div> </div>
@@ -58,9 +71,9 @@ export function FirewallPreview() {
<Play size={18} /> <Play size={18} />
Generate Preview Generate Preview
</button> </button>
<button className={buttonClass} disabled={!selectedPolicyId || apply.isPending} onClick={() => apply.mutate()}> <button className={buttonClass} disabled={!selectedPolicyId || apply.isPending || (auditMode && !dryRun)} onClick={() => apply.mutate()}>
<ShieldCheck size={18} /> <ShieldCheck size={18} />
{dryRun ? "Run Dry Apply" : "Apply Confirmed"} {dryRun ? "Run Dry Apply" : auditMode ? "Audit Mode Only" : "Apply Confirmed"}
</button> </button>
</div> </div>
</section> </section>
+94 -14
View File
@@ -1,32 +1,42 @@
import { FormEvent, useState } from "react"; import { FormEvent, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Database, Download, Plus } from "lucide-react"; import { Database, Download, Pencil, Plus, RefreshCcw } from "lucide-react";
import { api, authorizedFetch, IpAddress, Network, Subnet } from "../api/client"; import { api, authorizedFetch, IpAddress, Network, RuntimeSettings, Subnet } from "../api/client";
import { DataTable } from "../components/DataTable"; import { DataTable } from "../components/DataTable";
import { buttonClass, Field, inputClass, secondaryButtonClass, selectClass } from "../components/FormControls"; import { buttonClass, Field, iconButtonClass, inputClass, secondaryButtonClass, selectClass } from "../components/FormControls";
import { LoadingOverlay } from "../components/LoadingOverlay";
import { Modal } from "../components/Modal"; import { Modal } from "../components/Modal";
import { PageHeader } from "../components/PageHeader"; import { PageHeader } from "../components/PageHeader";
const emptySubnetForm = { network_id: "", cidr: "10.50.0.0/24", gateway: "10.50.0.1", dns: ["10.50.0.10"], dhcp_enabled: false };
export function Ipam() { export function Ipam() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const networks = useQuery({ queryKey: ["networks"], queryFn: () => api<Network[]>("/networks") }); const networks = useQuery({ queryKey: ["networks"], queryFn: () => api<Network[]>("/networks") });
const subnets = useQuery({ queryKey: ["subnets"], queryFn: () => api<Subnet[]>("/ipam/subnets") }); const subnets = useQuery({ queryKey: ["subnets"], queryFn: () => api<Subnet[]>("/ipam/subnets") });
const addresses = useQuery({ queryKey: ["addresses"], queryFn: () => api<IpAddress[]>("/ipam/addresses") }); const addresses = useQuery({ queryKey: ["addresses"], queryFn: () => api<IpAddress[]>("/ipam/addresses") });
const settings = useQuery({ queryKey: ["settings"], queryFn: () => api<RuntimeSettings>("/settings") });
const [message, setMessage] = useState(""); const [message, setMessage] = useState("");
const [subnetForm, setSubnetForm] = useState({ network_id: "", cidr: "10.50.0.0/24", gateway: "10.50.0.1", dns: ["10.50.0.10"], dhcp_enabled: false }); const [subnetForm, setSubnetForm] = useState(emptySubnetForm);
const [ipForm, setIpForm] = useState({ subnet_id: "", address: "10.50.0.20", status: "reserved", note: "" }); const [ipForm, setIpForm] = useState({ subnet_id: "", address: "10.50.0.20", status: "reserved", note: "" });
const [subnetOpen, setSubnetOpen] = useState(false); const [subnetOpen, setSubnetOpen] = useState(false);
const [ipOpen, setIpOpen] = useState(false); const [ipOpen, setIpOpen] = useState(false);
const [busyMessage, setBusyMessage] = useState("");
const [editingSubnet, setEditingSubnet] = useState<Subnet | null>(null);
const createSubnet = useMutation({ const saveSubnet = useMutation({
mutationFn: () => api<Subnet>("/ipam/subnets", { method: "POST", body: JSON.stringify({ ...subnetForm, network_id: subnetForm.network_id || networks.data?.[0]?.id }) }), mutationFn: () => api<Subnet>(editingSubnet ? `/ipam/subnets/${editingSubnet.id}` : "/ipam/subnets", {
method: editingSubnet ? "PATCH" : "POST",
body: JSON.stringify({ ...subnetForm, network_id: subnetForm.network_id || networks.data?.[0]?.id }),
}),
onSuccess: () => { onSuccess: () => {
setMessage("Subnet created."); setMessage(editingSubnet ? "Subnet updated." : "Subnet created.");
setSubnetOpen(false); setSubnetOpen(false);
setEditingSubnet(null);
queryClient.invalidateQueries({ queryKey: ["subnets"] }); queryClient.invalidateQueries({ queryKey: ["subnets"] });
}, },
onError: (error) => setMessage(error instanceof Error ? error.message : "Subnet creation failed."), onError: (error) => setMessage(error instanceof Error ? error.message : "Subnet save failed."),
}); });
const createIp = useMutation({ const createIp = useMutation({
mutationFn: () => api<IpAddress>("/ipam/addresses", { method: "POST", body: JSON.stringify({ ...ipForm, subnet_id: ipForm.subnet_id || subnets.data?.[0]?.id }) }), mutationFn: () => api<IpAddress>("/ipam/addresses", { method: "POST", body: JSON.stringify({ ...ipForm, subnet_id: ipForm.subnet_id || subnets.data?.[0]?.id }) }),
@@ -37,10 +47,17 @@ export function Ipam() {
}, },
onError: (error) => setMessage(error instanceof Error ? error.message : "IP reservation failed."), onError: (error) => setMessage(error instanceof Error ? error.message : "IP reservation failed."),
}); });
const toggleAutoDiscover = useMutation({
mutationFn: () => api<RuntimeSettings>("/settings", {
method: "PATCH",
body: JSON.stringify({ auto_ipam_sync_enabled: !settings.data?.auto_ipam_sync_enabled }),
}),
onSuccess: () => settings.refetch(),
});
async function submitSubnet(event: FormEvent) { async function submitSubnet(event: FormEvent) {
event.preventDefault(); event.preventDefault();
await createSubnet.mutateAsync(); await saveSubnet.mutateAsync();
} }
async function submitIp(event: FormEvent) { async function submitIp(event: FormEvent) {
@@ -49,6 +66,8 @@ export function Ipam() {
} }
async function exportCsv() { async function exportCsv() {
setBusyMessage("Preparing IPAM export...");
try {
const response = await authorizedFetch("/ipam/export.csv"); const response = await authorizedFetch("/ipam/export.csv");
const blob = await response.blob(); const blob = await response.blob();
const url = URL.createObjectURL(blob); const url = URL.createObjectURL(blob);
@@ -57,9 +76,13 @@ export function Ipam() {
link.download = "nexafabric-ipam.csv"; link.download = "nexafabric-ipam.csv";
link.click(); link.click();
URL.revokeObjectURL(url); URL.revokeObjectURL(url);
} finally {
setBusyMessage("");
}
} }
async function discoverIpam() { async function discoverIpam() {
setBusyMessage("Discovering IP addresses from Proxmox...");
try { try {
const result = await api<{ imported: number; removed: number; errors: Array<Record<string, unknown>> }>("/ipam/discover", { method: "POST" }); const result = await api<{ imported: number; removed: number; errors: Array<Record<string, unknown>> }>("/ipam/discover", { method: "POST" });
setMessage(`Discovery imported ${result.imported} IP addresses and removed ${result.removed} container bridge entries${result.errors.length ? " with errors" : ""}.`); setMessage(`Discovery imported ${result.imported} IP addresses and removed ${result.removed} container bridge entries${result.errors.length ? " with errors" : ""}.`);
@@ -67,23 +90,56 @@ export function Ipam() {
await queryClient.invalidateQueries({ queryKey: ["addresses"] }); await queryClient.invalidateQueries({ queryKey: ["addresses"] });
} catch (error) { } catch (error) {
setMessage(error instanceof Error ? error.message : "IPAM discovery failed."); setMessage(error instanceof Error ? error.message : "IPAM discovery failed.");
} finally {
setBusyMessage("");
} }
} }
function addSubnet() {
setEditingSubnet(null);
setSubnetForm(emptySubnetForm);
setSubnetOpen(true);
}
function editSubnet(subnet: Subnet) {
setEditingSubnet(subnet);
setSubnetForm({
network_id: subnet.network_id,
cidr: subnet.cidr,
gateway: subnet.gateway ?? "",
dns: subnet.dns ?? [],
dhcp_enabled: subnet.dhcp_enabled,
});
setSubnetOpen(true);
}
return ( return (
<> <>
<PageHeader title="IPAM" subtitle="Manage subnets, reservations, assignments, conflicts, and export state." /> <PageHeader title="IPAM" subtitle="Manage subnets, reservations, assignments, conflicts, and export state." />
<LoadingOverlay open={Boolean(busyMessage)} message={busyMessage} />
<div className="space-y-4"> <div className="space-y-4">
<div className="flex flex-wrap items-center justify-between gap-3 rounded-md border border-border bg-panel p-3">
<div>
<div className="text-sm font-medium">Automatic IPAM discovery</div>
<div className="text-xs text-slate-500">
{settings.data?.auto_ipam_sync_enabled ? `Enabled every ${settings.data.auto_ipam_sync_interval_minutes} minutes` : "Disabled"}
</div>
</div>
<button className={secondaryButtonClass} disabled={toggleAutoDiscover.isPending || !settings.data} onClick={() => toggleAutoDiscover.mutate()}>
<RefreshCcw size={16} />
{settings.data?.auto_ipam_sync_enabled ? "Disable Auto Discover" : "Enable Auto Discover"}
</button>
</div>
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
<button className={buttonClass} onClick={() => setSubnetOpen(true)}><Plus size={16} /> Add Subnet</button> <button className={buttonClass} onClick={addSubnet}><Plus size={16} /> Add Subnet</button>
<button className={buttonClass} onClick={() => setIpOpen(true)}><Plus size={16} /> Reserve IP</button> <button className={buttonClass} onClick={() => setIpOpen(true)}><Plus size={16} /> Reserve IP</button>
<button className={secondaryButtonClass} onClick={discoverIpam}>Discover from Proxmox</button> <button className={secondaryButtonClass} onClick={discoverIpam}>Discover from Proxmox</button>
<button className={secondaryButtonClass} onClick={exportCsv}><Download size={16} /> Export CSV</button> <button className={secondaryButtonClass} onClick={exportCsv}><Download size={16} /> Export CSV</button>
</div> </div>
{message ? <div className="rounded-md border border-border bg-panel p-3 text-sm">{message}</div> : null} {message ? <div className="rounded-md border border-border bg-panel p-3 text-sm">{message}</div> : null}
<Modal title="Add Subnet" open={subnetOpen} onClose={() => setSubnetOpen(false)}> <Modal title={editingSubnet ? "Edit Subnet" : "Add Subnet"} open={subnetOpen} onClose={() => setSubnetOpen(false)}>
<form onSubmit={submitSubnet}> <form onSubmit={submitSubnet}>
<div className="mb-4 flex items-center gap-2 font-medium"><Database size={18} /> Add Subnet</div> <div className="mb-4 flex items-center gap-2 font-medium"><Database size={18} /> {editingSubnet ? "Edit Subnet" : "Add Subnet"}</div>
<div className="grid gap-3"> <div className="grid gap-3">
<Field label="Network"> <Field label="Network">
<select className={selectClass} value={subnetForm.network_id} onChange={(event) => setSubnetForm({ ...subnetForm, network_id: event.target.value })}> <select className={selectClass} value={subnetForm.network_id} onChange={(event) => setSubnetForm({ ...subnetForm, network_id: event.target.value })}>
@@ -93,7 +149,12 @@ export function Ipam() {
</Field> </Field>
<Field label="CIDR"><input className={inputClass} value={subnetForm.cidr} onChange={(event) => setSubnetForm({ ...subnetForm, cidr: event.target.value })} /></Field> <Field label="CIDR"><input className={inputClass} value={subnetForm.cidr} onChange={(event) => setSubnetForm({ ...subnetForm, cidr: event.target.value })} /></Field>
<Field label="Gateway"><input className={inputClass} value={subnetForm.gateway} onChange={(event) => setSubnetForm({ ...subnetForm, gateway: event.target.value })} /></Field> <Field label="Gateway"><input className={inputClass} value={subnetForm.gateway} onChange={(event) => setSubnetForm({ ...subnetForm, gateway: event.target.value })} /></Field>
<button className={buttonClass}><Plus size={16} /> Add Subnet</button> <Field label="DNS Servers"><input className={inputClass} value={subnetForm.dns.join(", ")} onChange={(event) => setSubnetForm({ ...subnetForm, dns: event.target.value.split(",").map((item) => item.trim()).filter(Boolean) })} /></Field>
<label className="flex items-center gap-2 text-sm">
<input type="checkbox" checked={subnetForm.dhcp_enabled} onChange={(event) => setSubnetForm({ ...subnetForm, dhcp_enabled: event.target.checked })} />
DHCP enabled
</label>
<button className={buttonClass} disabled={saveSubnet.isPending}><Plus size={16} /> {editingSubnet ? "Update Subnet" : "Add Subnet"}</button>
</div> </div>
</form> </form>
</Modal> </Modal>
@@ -119,7 +180,26 @@ export function Ipam() {
</form> </form>
</Modal> </Modal>
<section className="space-y-4"> <section className="space-y-4">
<DataTable rows={(subnets.data ?? []) as unknown as Record<string, unknown>[]} columns={[{ key: "cidr", label: "Subnet" }, { key: "gateway", label: "Gateway" }, { key: "dhcp_enabled", label: "DHCP" }]} /> <DataTable
rows={(subnets.data ?? []) as unknown as Record<string, unknown>[]}
columns={[
{ key: "cidr", label: "Subnet" },
{ key: "gateway", label: "Gateway" },
{ key: "dhcp_enabled", label: "DHCP" },
{
key: "actions",
label: "Actions",
render: (row) => {
const subnet = row as unknown as Subnet;
return (
<div className="flex justify-end">
<button className={iconButtonClass} title="Edit subnet" aria-label={`Edit subnet ${subnet.cidr}`} onClick={() => editSubnet(subnet)}><Pencil size={16} /></button>
</div>
);
},
},
]}
/>
<DataTable <DataTable
rows={(addresses.data ?? []) as unknown as Record<string, unknown>[]} rows={(addresses.data ?? []) as unknown as Record<string, unknown>[]}
columns={[ columns={[
+133 -2
View File
@@ -1,16 +1,19 @@
import { useMutation, useQuery } from "@tanstack/react-query"; import { useMutation, useQuery } from "@tanstack/react-query";
import { Cpu, Copy, RadioTower } from "lucide-react"; import { Activity, Cpu, Copy, RadioTower, RefreshCcw, ScrollText } from "lucide-react";
import { useState } from "react"; import { useState } from "react";
import { AgentInstallInfo, api, Node } from "../api/client"; import { AgentInstallInfo, api, Node, RuntimeSettings } from "../api/client";
import { DataTable } from "../components/DataTable"; import { DataTable } from "../components/DataTable";
import { iconButtonClass, secondaryButtonClass } from "../components/FormControls"; import { iconButtonClass, secondaryButtonClass } from "../components/FormControls";
import { LoadingOverlay } from "../components/LoadingOverlay";
import { Modal } from "../components/Modal"; import { Modal } from "../components/Modal";
import { PageHeader } from "../components/PageHeader"; import { PageHeader } from "../components/PageHeader";
export function Nodes() { export function Nodes() {
const nodes = useQuery({ queryKey: ["nodes-agents"], queryFn: () => api<Node[]>("/nodes/agents") }); const nodes = useQuery({ queryKey: ["nodes-agents"], queryFn: () => api<Node[]>("/nodes/agents") });
const settings = useQuery({ queryKey: ["settings"], queryFn: () => api<RuntimeSettings>("/settings") });
const [selectedNode, setSelectedNode] = useState<Node | null>(null); const [selectedNode, setSelectedNode] = useState<Node | null>(null);
const [detailNode, setDetailNode] = useState<Node | null>(null);
const [copied, setCopied] = useState(false); const [copied, setCopied] = useState(false);
const installInfo = useMutation({ const installInfo = useMutation({
mutationFn: (node: Node) => api<AgentInstallInfo>(`/nodes/${node.id}/agent/install-info`), mutationFn: (node: Node) => api<AgentInstallInfo>(`/nodes/${node.id}/agent/install-info`),
@@ -19,6 +22,13 @@ export function Nodes() {
setCopied(false); setCopied(false);
}, },
}); });
const toggleAutoSync = useMutation({
mutationFn: () => api<RuntimeSettings>("/settings", {
method: "PATCH",
body: JSON.stringify({ auto_node_sync_enabled: !settings.data?.auto_node_sync_enabled }),
}),
onSuccess: () => settings.refetch(),
});
async function copyCommand() { async function copyCommand() {
if (!installInfo.data) { if (!installInfo.data) {
@@ -28,9 +38,49 @@ export function Nodes() {
setCopied(true); setCopied(true);
} }
function agentFlows(node: Node) {
const flows = node.agent?.last_payload?.flows;
const interfaceTraffic = node.agent?.last_payload?.interface_traffic;
if (Array.isArray(flows) && flows.length) {
return flows;
}
return Array.isArray(interfaceTraffic) ? interfaceTraffic : [];
}
function agentFlowCount(node: Node) {
const count = node.agent?.last_payload?.flow_count;
if (typeof count === "number") {
return count;
}
return agentFlows(node).length;
}
function agentInterfaces(node: Node) {
const interfaces = node.agent?.last_payload?.interfaces;
return Array.isArray(interfaces) ? interfaces : [];
}
function agentConntrack(node: Node) {
const conntrack = node.agent?.last_payload?.conntrack;
return conntrack && typeof conntrack === "object" ? conntrack as Record<string, unknown> : {};
}
return ( return (
<> <>
<PageHeader title="Nodes" subtitle="Cluster nodes, capacity, health, and NexaFabric agent enrollment." /> <PageHeader title="Nodes" subtitle="Cluster nodes, capacity, health, and NexaFabric agent enrollment." />
<LoadingOverlay open={installInfo.isPending} message="Generating node agent installer..." />
<div className="mb-4 flex flex-wrap items-center justify-between gap-3 rounded-md border border-border bg-panel p-3">
<div>
<div className="text-sm font-medium">Automatic node sync</div>
<div className="text-xs text-slate-500">
{settings.data?.auto_node_sync_enabled ? `Enabled every ${settings.data.auto_node_sync_interval_minutes} minutes` : "Disabled"}
</div>
</div>
<button className={secondaryButtonClass} disabled={toggleAutoSync.isPending || !settings.data} onClick={() => toggleAutoSync.mutate()}>
<RefreshCcw size={16} />
{settings.data?.auto_node_sync_enabled ? "Disable Auto Sync" : "Enable Auto Sync"}
</button>
</div>
{nodes.isLoading ? <div className="rounded-md border border-border bg-panel p-8 text-sm">Loading...</div> : null} {nodes.isLoading ? <div className="rounded-md border border-border bg-panel p-8 text-sm">Loading...</div> : null}
{nodes.error ? <div className="rounded-md border border-danger p-4 text-sm text-danger">Failed to load nodes.</div> : null} {nodes.error ? <div className="rounded-md border border-danger p-4 text-sm text-danger">Failed to load nodes.</div> : null}
{!nodes.isLoading && !nodes.error ? ( {!nodes.isLoading && !nodes.error ? (
@@ -56,6 +106,15 @@ export function Nodes() {
const node = row as unknown as Node; const node = row as unknown as Node;
return ( return (
<div className="flex justify-end gap-2"> <div className="flex justify-end gap-2">
<button
className={iconButtonClass}
title="View agent data"
aria-label={`View agent data for ${node.name}`}
disabled={!node.agent?.last_payload}
onClick={() => setDetailNode(node)}
>
<ScrollText size={16} />
</button>
<button <button
className={iconButtonClass} className={iconButtonClass}
title="Install node agent" title="Install node agent"
@@ -93,6 +152,78 @@ export function Nodes() {
</button> </button>
</div> </div>
</Modal> </Modal>
<Modal title="Agent Data" open={Boolean(detailNode)} onClose={() => setDetailNode(null)}>
{detailNode ? (
<div className="space-y-4">
<div className="flex items-center gap-2 font-medium">
<Activity size={18} />
{detailNode.name}
</div>
<div className="grid gap-3 sm:grid-cols-3">
<div className="rounded-md border border-border bg-canvas p-3">
<div className="text-xs text-slate-500 dark:text-slate-400">Status</div>
<div className="mt-1 text-sm font-medium">{detailNode.agent?.status ?? "not_installed"}</div>
</div>
<div className="rounded-md border border-border bg-canvas p-3">
<div className="text-xs text-slate-500 dark:text-slate-400">Flows</div>
<div className="mt-1 text-sm font-medium">{agentFlowCount(detailNode)}</div>
</div>
<div className="rounded-md border border-border bg-canvas p-3">
<div className="text-xs text-slate-500 dark:text-slate-400">Conntrack</div>
<div className="mt-1 text-sm font-medium">{String(agentConntrack(detailNode).count ?? "unknown")}</div>
</div>
</div>
<section>
<div className="mb-2 text-sm font-medium">Interfaces</div>
<div className="max-h-48 overflow-auto rounded-md border border-border">
{agentInterfaces(detailNode).length ? agentInterfaces(detailNode).map((item, index) => {
const iface = item as Record<string, unknown>;
return (
<div key={`${String(iface.name)}-${index}`} className="grid grid-cols-[1fr_auto] gap-3 border-b border-border px-3 py-2 text-xs last:border-0">
<div>
<div className="font-medium">{String(iface.name)}</div>
<div className="text-slate-500">state {String(iface.operstate ?? "unknown")} {iface.vmid ? `· VMID ${String(iface.vmid)}` : ""}</div>
</div>
<div className="text-right text-slate-500">
<div>rx {String(iface.rx_bytes ?? 0)}</div>
<div>tx {String(iface.tx_bytes ?? 0)}</div>
</div>
</div>
);
}) : <div className="p-3 text-xs text-slate-500">No interface telemetry in the last heartbeat.</div>}
</div>
</section>
<section>
<div className="mb-2 text-sm font-medium">Flows</div>
<div className="max-h-48 overflow-auto rounded-md border border-border">
{agentFlows(detailNode).length ? agentFlows(detailNode).slice(0, 50).map((item, index) => {
const flow = item as Record<string, unknown>;
const isInterfaceCounter = flow.protocol === "interface-counter";
return (
<div key={index} className="border-b border-border px-3 py-2 text-xs last:border-0">
<div className="font-medium">
{isInterfaceCounter
? `VMID ${String(flow.vmid)} ${String(flow.interface ?? "")}`
: `${String(flow.source_ip)}:${String(flow.source_port ?? "")} -> ${String(flow.destination_ip)}:${String(flow.destination_port ?? "")}`}
</div>
<div className="text-slate-500">
{String(flow.protocol ?? "unknown")} · {String(flow.bytes ?? 0)} bytes · {String(flow.packets ?? 0)} packets · {String(flow.state ?? "unknown")}
</div>
{isInterfaceCounter ? <div className="text-slate-500">rx {String(flow.rx_bytes ?? 0)} · tx {String(flow.tx_bytes ?? 0)}</div> : null}
</div>
);
}) : <div className="p-3 text-xs text-slate-500">No conntrack flows or interface counters were reported in the last heartbeat.</div>}
</div>
</section>
<section>
<div className="mb-2 text-sm font-medium">Raw Payload</div>
<pre className="max-h-60 overflow-auto rounded-md border border-border bg-canvas p-3 text-xs">
{JSON.stringify(detailNode.agent?.last_payload ?? {}, null, 2)}
</pre>
</section>
</div>
) : null}
</Modal>
</> </>
); );
} }
+94 -6
View File
@@ -1,10 +1,11 @@
import { FormEvent, useState } from "react"; import { FormEvent, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Eye, GitBranch, Pencil, Play, Plus, Trash2 } from "lucide-react"; import { AlertTriangle, CheckCircle2, Clock3, Eye, GitBranch, Pencil, Play, Plus, Shield, Trash2 } from "lucide-react";
import { api, Policy, Project, ServiceCatalogItem } from "../api/client"; import { api, Policy, Project, ServiceCatalogItem, Workload } from "../api/client";
import { DataTable } from "../components/DataTable"; import { DataTable } from "../components/DataTable";
import { buttonClass, Field, iconButtonClass, inputClass, selectClass } from "../components/FormControls"; import { buttonClass, Field, iconButtonClass, inputClass, selectClass } from "../components/FormControls";
import { LoadingOverlay } from "../components/LoadingOverlay";
import { Modal } from "../components/Modal"; import { Modal } from "../components/Modal";
import { PageHeader } from "../components/PageHeader"; import { PageHeader } from "../components/PageHeader";
@@ -12,6 +13,25 @@ function policyValue(policy: Policy, key: string) {
return String(policy.definition?.[key] ?? ""); return String(policy.definition?.[key] ?? "");
} }
function endpointLabel(value: string, workloads: Workload[]) {
if (value.startsWith("workload:")) {
const workloadId = value.replace("workload:", "");
const workload = workloads.find((item) => item.id === workloadId);
return workload ? `VM/LXC: ${workload.name}` : "VM/LXC: unknown";
}
if (value.startsWith("network:")) {
return `Network: ${value.replace("network:", "")}`;
}
if (value.startsWith("sg:")) {
return `Security Group: ${value.replace("sg:", "")}`;
}
return value || "any";
}
function policyEndpoint(policy: Policy, key: string, workloads: Workload[]) {
return endpointLabel(policyValue(policy, key), workloads);
}
function policyService(policy: Policy) { function policyService(policy: Policy) {
const service = policy.definition?.service; const service = policy.definition?.service;
if (!service || typeof service !== "object") { if (!service || typeof service !== "object") {
@@ -21,6 +41,60 @@ function policyService(policy: Policy) {
return `${String(value.protocol ?? "")}/${String(value.ports ?? "")}`; return `${String(value.protocol ?? "")}/${String(value.ports ?? "")}`;
} }
function policyStatus(policy: Policy) {
const status = policy.deployment_status ?? {};
return {
state: String(status.state ?? "unknown"),
label: String(status.label ?? "Unknown"),
expectedRules: Number(status.expected_rules ?? 0),
activeRules: Number(status.active_rules ?? 0),
staleRules: Number(status.stale_rules ?? 0),
};
}
function statusClass(state: string) {
if (state === "active") {
return "border-accent/40 bg-accent/10 text-accent";
}
if (state === "audit") {
return "border-amber-400/40 bg-amber-400/10 text-amber-300";
}
if (["stale", "partial", "unresolved"].includes(state)) {
return "border-danger/40 bg-danger/10 text-danger";
}
return "border-border bg-canvas text-slate-500";
}
function StatusIcon({ state }: { state: string }) {
if (state === "active") {
return <CheckCircle2 size={15} />;
}
if (state === "audit") {
return <Shield size={15} />;
}
if (["stale", "partial", "unresolved"].includes(state)) {
return <AlertTriangle size={15} />;
}
return <Clock3 size={15} />;
}
function PolicyDeploymentStatus({ policy }: { policy: Policy }) {
const status = policyStatus(policy);
const detail =
status.state === "audit"
? "No live firewall write"
: `${status.activeRules}/${status.expectedRules} active${status.staleRules ? ` · ${status.staleRules} stale` : ""}`;
return (
<div className="flex flex-col gap-1">
<span className={`inline-flex w-fit items-center gap-1.5 rounded-md border px-2 py-1 text-xs ${statusClass(status.state)}`}>
<StatusIcon state={status.state} />
{status.label}
</span>
<span className="text-xs text-slate-500">{detail}</span>
</div>
);
}
const defaultPolicyForm = { const defaultPolicyForm = {
project_id: "", project_id: "",
name: "Web to DB", name: "Web to DB",
@@ -40,11 +114,13 @@ export function Policies() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const policies = useQuery({ queryKey: ["policies"], queryFn: () => api<Policy[]>("/policies") }); const policies = useQuery({ queryKey: ["policies"], queryFn: () => api<Policy[]>("/policies") });
const projects = useQuery({ queryKey: ["projects"], queryFn: () => api<Project[]>("/projects") }); const projects = useQuery({ queryKey: ["projects"], queryFn: () => api<Project[]>("/projects") });
const workloads = useQuery({ queryKey: ["workloads"], queryFn: () => api<Workload[]>("/vms") });
const services = useQuery({ queryKey: ["service-catalog"], queryFn: () => api<ServiceCatalogItem[]>("/service-catalog") }); const services = useQuery({ queryKey: ["service-catalog"], queryFn: () => api<ServiceCatalogItem[]>("/service-catalog") });
const [preview, setPreview] = useState(""); const [preview, setPreview] = useState("");
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const [editing, setEditing] = useState<Policy | null>(null); const [editing, setEditing] = useState<Policy | null>(null);
const [form, setForm] = useState(defaultPolicyForm); const [form, setForm] = useState(defaultPolicyForm);
const [busyMessage, setBusyMessage] = useState("");
const save = useMutation({ const save = useMutation({
mutationFn: () => { mutationFn: () => {
@@ -85,14 +161,24 @@ export function Policies() {
} }
async function compile(policy: Policy) { async function compile(policy: Policy) {
setBusyMessage(`Compiling ${policy.name}...`);
try {
const data = await api<Policy>(`/policies/${policy.id}/compile`, { method: "POST" }); const data = await api<Policy>(`/policies/${policy.id}/compile`, { method: "POST" });
setPreview(JSON.stringify(data.last_compiled, null, 2)); setPreview(JSON.stringify(data.last_compiled, null, 2));
await queryClient.invalidateQueries({ queryKey: ["policies"] }); await queryClient.invalidateQueries({ queryKey: ["policies"] });
} finally {
setBusyMessage("");
}
} }
async function firewallPreview(policy: Policy) { async function firewallPreview(policy: Policy) {
setBusyMessage(`Generating preview for ${policy.name}...`);
try {
const data = await api<Record<string, unknown>>(`/firewall/preview/${policy.id}`, { method: "POST" }); const data = await api<Record<string, unknown>>(`/firewall/preview/${policy.id}`, { method: "POST" });
setPreview(JSON.stringify(data, null, 2)); setPreview(JSON.stringify(data, null, 2));
} finally {
setBusyMessage("");
}
} }
function addPolicy() { function addPolicy() {
@@ -140,6 +226,7 @@ export function Policies() {
return ( return (
<> <>
<PageHeader title="Policies" subtitle="Create versioned microsegmentation policies and compile firewall previews." /> <PageHeader title="Policies" subtitle="Create versioned microsegmentation policies and compile firewall previews." />
<LoadingOverlay open={Boolean(busyMessage)} message={busyMessage} />
<div className="space-y-4"> <div className="space-y-4">
<button className={buttonClass} onClick={addPolicy}><Plus size={16} /> Add Policy</button> <button className={buttonClass} onClick={addPolicy}><Plus size={16} /> Add Policy</button>
<Modal title={editing ? "Edit Policy" : "Add Policy"} open={open} onClose={() => setOpen(false)}> <Modal title={editing ? "Edit Policy" : "Add Policy"} open={open} onClose={() => setOpen(false)}>
@@ -149,8 +236,8 @@ export function Policies() {
<Field label="Project"><select className={selectClass} value={form.project_id} onChange={(event) => setForm({ ...form, project_id: event.target.value })}><option value="">Global</option>{(projects.data ?? []).map((project) => <option key={project.id} value={project.id}>{project.name}</option>)}</select></Field> <Field label="Project"><select className={selectClass} value={form.project_id} onChange={(event) => setForm({ ...form, project_id: event.target.value })}><option value="">Global</option>{(projects.data ?? []).map((project) => <option key={project.id} value={project.id}>{project.name}</option>)}</select></Field>
<Field label="Name"><input className={inputClass} value={form.name} onChange={(event) => setForm({ ...form, name: event.target.value })} /></Field> <Field label="Name"><input className={inputClass} value={form.name} onChange={(event) => setForm({ ...form, name: event.target.value })} /></Field>
<div className="grid grid-cols-2 gap-3"> <div className="grid grid-cols-2 gap-3">
<Field label="Source"><input className={inputClass} value={form.source} onChange={(event) => setForm({ ...form, source: event.target.value })} /></Field> <Field label="Source"><input className={inputClass} value={form.source} onChange={(event) => setForm({ ...form, source: event.target.value })} placeholder="any, workload:<id>, 172.16.0.50 or 172.16.0.0/16" /></Field>
<Field label="Destination"><input className={inputClass} value={form.destination} onChange={(event) => setForm({ ...form, destination: event.target.value })} /></Field> <Field label="Destination"><input className={inputClass} value={form.destination} onChange={(event) => setForm({ ...form, destination: event.target.value })} placeholder="any, workload:<id>, 172.16.0.53 or 172.16.10.0/24" /></Field>
</div> </div>
<Field label="Service"><select className={selectClass} value={form.service_id} onChange={(event) => chooseService(event.target.value)}><option value="">Custom</option>{(services.data ?? []).map((service) => <option key={service.id} value={service.id}>{service.name} {service.ports}</option>)}</select></Field> <Field label="Service"><select className={selectClass} value={form.service_id} onChange={(event) => chooseService(event.target.value)}><option value="">Custom</option>{(services.data ?? []).map((service) => <option key={service.id} value={service.id}>{service.name} {service.ports}</option>)}</select></Field>
<div className="grid grid-cols-2 gap-3"> <div className="grid grid-cols-2 gap-3">
@@ -183,10 +270,11 @@ export function Policies() {
rows={(policies.data ?? []) as unknown as Record<string, unknown>[]} rows={(policies.data ?? []) as unknown as Record<string, unknown>[]}
columns={[ columns={[
{ key: "name", label: "Policy" }, { key: "name", label: "Policy" },
{ key: "source", label: "Source", render: (row) => policyValue(row as unknown as Policy, "source") }, { key: "source", label: "Source", render: (row) => policyEndpoint(row as unknown as Policy, "source", workloads.data ?? []) },
{ key: "destination", label: "Destination", render: (row) => policyValue(row as unknown as Policy, "destination") }, { key: "destination", label: "Destination", render: (row) => policyEndpoint(row as unknown as Policy, "destination", workloads.data ?? []) },
{ key: "service", label: "Service", render: (row) => policyService(row as unknown as Policy) }, { key: "service", label: "Service", render: (row) => policyService(row as unknown as Policy) },
{ key: "enforcement_mode", label: "Mode" }, { key: "enforcement_mode", label: "Mode" },
{ key: "deployment_status", label: "Status", render: (row) => <PolicyDeploymentStatus policy={row as unknown as Policy} /> },
{ key: "version", label: "Version" }, { key: "version", label: "Version" },
{ {
key: "actions", key: "actions",
+47 -7
View File
@@ -5,12 +5,23 @@ import { Save, Wand2 } from "lucide-react";
import { api, Network, Policy, SecurityGroup, ServiceCatalogItem, Workload } from "../api/client"; import { api, Network, Policy, SecurityGroup, ServiceCatalogItem, Workload } from "../api/client";
import { buttonClass, Field, inputClass, secondaryButtonClass, selectClass } from "../components/FormControls"; import { buttonClass, Field, inputClass, secondaryButtonClass, selectClass } from "../components/FormControls";
import { PageHeader } from "../components/PageHeader"; import { PageHeader } from "../components/PageHeader";
import { SearchableSelect } from "../components/SearchableSelect";
type TargetOption = { type TargetOption = {
label: string; label: string;
value: string; value: string;
}; };
const customTargetValue = "__custom_ip_cidr__";
function endpointSelectValue(value: string, targets: TargetOption[]) {
return targets.some((target) => target.value === value) ? value : customTargetValue;
}
function isCustomEndpoint(value: string, targets: TargetOption[]) {
return endpointSelectValue(value, targets) === customTargetValue;
}
export function PolicyDesigner() { export function PolicyDesigner() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const workloads = useQuery({ queryKey: ["workloads"], queryFn: () => api<Workload[]>("/vms") }); const workloads = useQuery({ queryKey: ["workloads"], queryFn: () => api<Workload[]>("/vms") });
@@ -35,8 +46,13 @@ export function PolicyDesigner() {
const targets = useMemo<TargetOption[]>(() => { const targets = useMemo<TargetOption[]>(() => {
return [ return [
{ label: "Any", value: "any" }, { label: "Any", value: "any" },
{ label: "Custom IP/CIDR", value: customTargetValue },
...(workloads.data ?? []).map((workload) => ({ label: `VM/LXC: ${workload.name}`, value: `workload:${workload.id}` })), ...(workloads.data ?? []).map((workload) => ({ label: `VM/LXC: ${workload.name}`, value: `workload:${workload.id}` })),
...(securityGroups.data ?? []).map((group) => ({ label: `Security Group: ${group.name}`, value: `sg:${group.name}` })), ...(securityGroups.data ?? []).map((group) => ({
label: `Security Group: ${group.name}`,
value: `sg:${group.id}`,
detail: `${group.members?.length ?? 0} member${group.members?.length === 1 ? "" : "s"}`,
})),
...(networks.data ?? []).map((network) => ({ label: `Network: ${network.name}`, value: `network:${network.name}` })), ...(networks.data ?? []).map((network) => ({ label: `Network: ${network.name}`, value: `network:${network.name}` })),
]; ];
}, [networks.data, securityGroups.data, workloads.data]); }, [networks.data, securityGroups.data, workloads.data]);
@@ -115,14 +131,38 @@ export function PolicyDesigner() {
</Field> </Field>
<div /> <div />
<Field label="Source"> <Field label="Source">
<select className={selectClass} value={form.source} onChange={(event) => setForm({ ...form, source: event.target.value })}> <SearchableSelect
{targets.map((target) => <option key={target.value} value={target.value}>{target.label}</option>)} options={targets}
</select> value={endpointSelectValue(form.source, targets)}
onChange={(value) => setForm({ ...form, source: value === customTargetValue ? "" : value })}
placeholder="Search source..."
/>
{isCustomEndpoint(form.source, targets) ? (
<input
className={`${inputClass} mt-2`}
value={form.source}
onChange={(event) => setForm({ ...form, source: event.target.value.trim() })}
placeholder="172.16.0.50 or 172.16.0.0/16"
required
/>
) : null}
</Field> </Field>
<Field label="Destination"> <Field label="Destination">
<select className={selectClass} value={form.destination} onChange={(event) => setForm({ ...form, destination: event.target.value })}> <SearchableSelect
{targets.map((target) => <option key={target.value} value={target.value}>{target.label}</option>)} options={targets}
</select> value={endpointSelectValue(form.destination, targets)}
onChange={(value) => setForm({ ...form, destination: value === customTargetValue ? "" : value })}
placeholder="Search destination..."
/>
{isCustomEndpoint(form.destination, targets) ? (
<input
className={`${inputClass} mt-2`}
value={form.destination}
onChange={(event) => setForm({ ...form, destination: event.target.value.trim() })}
placeholder="172.16.0.53 or 172.16.10.0/24"
required
/>
) : null}
</Field> </Field>
<Field label="Service"> <Field label="Service">
<select className={selectClass} value={form.service_id} onChange={(event) => chooseService(event.target.value)}> <select className={selectClass} value={form.service_id} onChange={(event) => chooseService(event.target.value)}>
+111 -4
View File
@@ -1,19 +1,32 @@
import { FormEvent, useMemo, useState } from "react"; import { FormEvent, useMemo, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Plus, Shield } from "lucide-react"; import { Plus, Shield, Trash2, UserPlus } from "lucide-react";
import { api, Project, SecurityGroup, SecurityRule } from "../api/client"; import { api, Project, SecurityGroup, SecurityGroupMember, SecurityRule, Workload } from "../api/client";
import { DataTable } from "../components/DataTable"; import { DataTable } from "../components/DataTable";
import { buttonClass, Field, inputClass, selectClass } from "../components/FormControls"; import { buttonClass, Field, iconButtonClass, inputClass, secondaryButtonClass, selectClass } from "../components/FormControls";
import { Modal } from "../components/Modal"; import { Modal } from "../components/Modal";
import { PageHeader } from "../components/PageHeader"; import { PageHeader } from "../components/PageHeader";
import { SearchableSelect, SearchableOption } from "../components/SearchableSelect";
export function SecurityGroups() { export function SecurityGroups() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const projects = useQuery({ queryKey: ["projects"], queryFn: () => api<Project[]>("/projects") }); const projects = useQuery({ queryKey: ["projects"], queryFn: () => api<Project[]>("/projects") });
const groups = useQuery({ queryKey: ["security-groups"], queryFn: () => api<SecurityGroup[]>("/security-groups") }); const groups = useQuery({ queryKey: ["security-groups"], queryFn: () => api<SecurityGroup[]>("/security-groups") });
const workloads = useQuery({ queryKey: ["workloads"], queryFn: () => api<Workload[]>("/vms") });
const [selectedGroupId, setSelectedGroupId] = useState(""); const [selectedGroupId, setSelectedGroupId] = useState("");
const selectedGroup = useMemo(() => selectedGroupId || groups.data?.[0]?.id || "", [groups.data, selectedGroupId]); const selectedGroup = useMemo(() => selectedGroupId || groups.data?.[0]?.id || "", [groups.data, selectedGroupId]);
const selectedGroupRecord = useMemo(() => (groups.data ?? []).find((group) => group.id === selectedGroup), [groups.data, selectedGroup]);
const memberOptions = useMemo<SearchableOption[]>(() => {
const existing = new Set((selectedGroupRecord?.members ?? []).map((member) => member.workload_id));
return (workloads.data ?? [])
.filter((workload) => !existing.has(workload.id))
.map((workload) => ({
label: workload.name,
value: workload.id,
detail: `${workload.kind} · VMID ${workload.external_id} · ${workload.status}`,
}));
}, [selectedGroupRecord?.members, workloads.data]);
const rules = useQuery({ const rules = useQuery({
queryKey: ["security-rules", selectedGroup], queryKey: ["security-rules", selectedGroup],
queryFn: () => api<SecurityRule[]>(`/security-groups/${selectedGroup}/rules`), queryFn: () => api<SecurityRule[]>(`/security-groups/${selectedGroup}/rules`),
@@ -33,6 +46,8 @@ export function SecurityGroups() {
}); });
const [groupOpen, setGroupOpen] = useState(false); const [groupOpen, setGroupOpen] = useState(false);
const [ruleOpen, setRuleOpen] = useState(false); const [ruleOpen, setRuleOpen] = useState(false);
const [memberOpen, setMemberOpen] = useState(false);
const [memberWorkloadId, setMemberWorkloadId] = useState("");
const createGroup = useMutation({ const createGroup = useMutation({
mutationFn: () => api<SecurityGroup>("/security-groups", { method: "POST", body: JSON.stringify({ ...groupForm, project_id: groupForm.project_id || null }) }), mutationFn: () => api<SecurityGroup>("/security-groups", { method: "POST", body: JSON.stringify({ ...groupForm, project_id: groupForm.project_id || null }) }),
@@ -48,6 +63,18 @@ export function SecurityGroups() {
queryClient.invalidateQueries({ queryKey: ["security-rules", selectedGroup] }); queryClient.invalidateQueries({ queryKey: ["security-rules", selectedGroup] });
}, },
}); });
const addMember = useMutation({
mutationFn: () => api<SecurityGroupMember>(`/security-groups/${selectedGroup}/members`, { method: "POST", body: JSON.stringify({ workload_id: memberWorkloadId }) }),
onSuccess: () => {
setMemberOpen(false);
setMemberWorkloadId("");
queryClient.invalidateQueries({ queryKey: ["security-groups"] });
},
});
const removeMember = useMutation({
mutationFn: (member: SecurityGroupMember) => api<{ status: string }>(`/security-groups/${member.security_group_id}/members/${member.id}`, { method: "DELETE" }),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ["security-groups"] }),
});
async function submitGroup(event: FormEvent) { async function submitGroup(event: FormEvent) {
event.preventDefault(); event.preventDefault();
@@ -67,6 +94,7 @@ export function SecurityGroups() {
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
<button className={buttonClass} onClick={() => setGroupOpen(true)}><Plus size={16} /> Add Group</button> <button className={buttonClass} onClick={() => setGroupOpen(true)}><Plus size={16} /> Add Group</button>
<button className={buttonClass} onClick={() => setRuleOpen(true)} disabled={!selectedGroup}><Plus size={16} /> Add Rule</button> <button className={buttonClass} onClick={() => setRuleOpen(true)} disabled={!selectedGroup}><Plus size={16} /> Add Rule</button>
<button className={secondaryButtonClass} onClick={() => setMemberOpen(true)} disabled={!selectedGroup || !memberOptions.length}><UserPlus size={16} /> Add Member</button>
</div> </div>
<Modal title="Add Security Group" open={groupOpen} onClose={() => setGroupOpen(false)}> <Modal title="Add Security Group" open={groupOpen} onClose={() => setGroupOpen(false)}>
<form onSubmit={submitGroup}> <form onSubmit={submitGroup}>
@@ -84,6 +112,26 @@ export function SecurityGroups() {
</div> </div>
</form> </form>
</Modal> </Modal>
<Modal title="Add Group Member" open={memberOpen} onClose={() => setMemberOpen(false)}>
<form
onSubmit={async (event) => {
event.preventDefault();
await addMember.mutateAsync();
}}
>
<div className="mb-4 flex items-center gap-2 font-medium"><UserPlus size={18} /> Add Member</div>
<div className="grid gap-3">
<div className="rounded-md border border-border bg-canvas p-3 text-sm">
<div className="text-xs text-slate-500">Security Group</div>
<div className="mt-1 font-medium">{selectedGroupRecord?.name ?? "No group selected"}</div>
</div>
<Field label="VM/LXC">
<SearchableSelect options={memberOptions} value={memberWorkloadId} onChange={setMemberWorkloadId} placeholder="Search VM/LXC..." />
</Field>
<button className={buttonClass} disabled={!memberWorkloadId || addMember.isPending}><Plus size={16} /> Add Member</button>
</div>
</form>
</Modal>
<Modal title="Add Security Rule" open={ruleOpen} onClose={() => setRuleOpen(false)}> <Modal title="Add Security Rule" open={ruleOpen} onClose={() => setRuleOpen(false)}>
<form onSubmit={submitRule}> <form onSubmit={submitRule}>
<div className="mb-4 font-medium">Add Rule</div> <div className="mb-4 font-medium">Add Rule</div>
@@ -108,7 +156,66 @@ export function SecurityGroups() {
</form> </form>
</Modal> </Modal>
<section className="space-y-4"> <section className="space-y-4">
<DataTable rows={(groups.data ?? []) as unknown as Record<string, unknown>[]} columns={[{ key: "name", label: "Group" }, { key: "description", label: "Description" }]} /> <DataTable
rows={(groups.data ?? []) as unknown as Record<string, unknown>[]}
selectedId={selectedGroup}
onRowClick={(row) => setSelectedGroupId(String(row.id))}
columns={[
{ key: "name", label: "Group" },
{ key: "description", label: "Description" },
{
key: "members",
label: "Members",
render: (row) => {
const group = row as unknown as SecurityGroup;
const members = group.members ?? [];
return (
<div className="flex flex-wrap gap-1.5">
{members.slice(0, 5).map((member) => (
<span key={member.id} className="inline-flex items-center gap-1 rounded-md border border-border px-2 py-1 text-xs text-slate-500">
{member.workload_name ?? member.workload_id}
<button
className="text-slate-400 hover:text-danger"
disabled={removeMember.isPending}
onClick={(event) => {
event.stopPropagation();
removeMember.mutate(member);
}}
title="Remove member"
type="button"
>
<Trash2 size={12} />
</button>
</span>
))}
{members.length > 5 ? <span className="rounded-md border border-border px-2 py-1 text-xs text-slate-500">+{members.length - 5}</span> : null}
{!members.length ? <span className="text-xs text-slate-500">No members</span> : null}
</div>
);
},
},
{
key: "actions",
label: "Actions",
render: (row) => (
<div className="flex justify-end">
<button
className={iconButtonClass}
title="Add member"
aria-label="Add member"
onClick={(event) => {
event.stopPropagation();
setSelectedGroupId(String(row.id));
setMemberOpen(true);
}}
>
<UserPlus size={16} />
</button>
</div>
),
},
]}
/>
<DataTable rows={(rules.data ?? []) as unknown as Record<string, unknown>[]} columns={[{ key: "priority", label: "Priority" }, { key: "direction", label: "Direction" }, { key: "action", label: "Action" }, { key: "protocol", label: "Protocol" }, { key: "port", label: "Port" }]} /> <DataTable rows={(rules.data ?? []) as unknown as Record<string, unknown>[]} columns={[{ key: "priority", label: "Priority" }, { key: "direction", label: "Direction" }, { key: "action", label: "Action" }, { key: "protocol", label: "Protocol" }, { key: "port", label: "Port" }]} />
</section> </section>
</div> </div>
+126
View File
@@ -0,0 +1,126 @@
import { FormEvent, useEffect, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Database, RefreshCcw, Save, ShieldCheck } from "lucide-react";
import { api, RuntimeSettings } from "../api/client";
import { buttonClass, Field, inputClass } from "../components/FormControls";
import { PageHeader } from "../components/PageHeader";
export function Settings() {
const queryClient = useQueryClient();
const settings = useQuery({ queryKey: ["settings"], queryFn: () => api<RuntimeSettings>("/settings") });
const [retentionHours, setRetentionHours] = useState("24");
const [nodeAutoSync, setNodeAutoSync] = useState(false);
const [nodeInterval, setNodeInterval] = useState("60");
const [ipamAutoSync, setIpamAutoSync] = useState(false);
const [ipamInterval, setIpamInterval] = useState("60");
const update = useMutation({
mutationFn: () => api<RuntimeSettings>("/settings", {
method: "PATCH",
body: JSON.stringify({
flow_retention_hours: Number(retentionHours),
auto_node_sync_enabled: nodeAutoSync,
auto_node_sync_interval_minutes: Number(nodeInterval),
auto_ipam_sync_enabled: ipamAutoSync,
auto_ipam_sync_interval_minutes: Number(ipamInterval),
}),
}),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ["settings"] }),
});
useEffect(() => {
if (settings.data) {
setRetentionHours(String(settings.data.flow_retention_hours));
setNodeAutoSync(settings.data.auto_node_sync_enabled);
setNodeInterval(String(settings.data.auto_node_sync_interval_minutes));
setIpamAutoSync(settings.data.auto_ipam_sync_enabled);
setIpamInterval(String(settings.data.auto_ipam_sync_interval_minutes));
}
}, [settings.data]);
async function submit(event: FormEvent) {
event.preventDefault();
await update.mutateAsync();
}
return (
<>
<PageHeader title="Settings" subtitle="Runtime settings and safety defaults." />
<form onSubmit={submit} className="space-y-4">
<div className="grid gap-4 xl:grid-cols-[minmax(0,560px)_1fr]">
<section className="rounded-md border border-border bg-panel p-4">
<div className="mb-4 flex items-center gap-2 font-medium"><Database size={18} /> Flow Retention</div>
<Field label="Keep flow telemetry for">
<div className="grid grid-cols-[1fr_auto] gap-2">
<input
className={inputClass}
min={1}
max={8760}
type="number"
value={retentionHours}
onChange={(event) => setRetentionHours(event.target.value)}
/>
<span className="inline-flex h-10 items-center rounded-md border border-border px-3 text-sm text-slate-500">hours</span>
</div>
</Field>
<div className="mt-3 rounded-md border border-border bg-canvas p-3 text-sm text-slate-500">
New heartbeats update existing flows and remove entries older than this retention window.
</div>
</section>
<section className="rounded-md border border-border bg-panel p-4">
<div className="mb-4 flex items-center gap-2 font-medium"><ShieldCheck size={18} /> Safety Defaults</div>
<div className="grid gap-3 md:grid-cols-3">
<div className="rounded-md border border-border bg-canvas p-3">
<div className="text-xs text-slate-500">Product</div>
<div className="mt-1 font-medium">{settings.data?.product ?? "NexaFabric"}</div>
</div>
<div className="rounded-md border border-border bg-canvas p-3">
<div className="text-xs text-slate-500">Preview Required</div>
<div className="mt-1 font-medium">{String(settings.data?.firewall_apply_requires_preview ?? true)}</div>
</div>
<div className="rounded-md border border-border bg-canvas p-3">
<div className="text-xs text-slate-500">Agent Optional</div>
<div className="mt-1 font-medium">{String(settings.data?.agent_optional ?? true)}</div>
</div>
</div>
</section>
</div>
<div className="grid gap-4 xl:grid-cols-2">
<section className="rounded-md border border-border bg-panel p-4">
<div className="mb-4 flex items-center gap-2 font-medium"><RefreshCcw size={18} /> Nodes Auto Sync</div>
<label className="mb-3 flex items-center gap-2 text-sm">
<input type="checkbox" checked={nodeAutoSync} onChange={(event) => setNodeAutoSync(event.target.checked)} />
Enable automatic cluster inventory sync
</label>
<Field label="Interval">
<div className="grid grid-cols-[1fr_auto] gap-2">
<input className={inputClass} min={1} max={10080} type="number" value={nodeInterval} onChange={(event) => setNodeInterval(event.target.value)} />
<span className="inline-flex h-10 items-center rounded-md border border-border px-3 text-sm text-slate-500">minutes</span>
</div>
</Field>
<div className="mt-3 text-xs text-slate-500">Last run: {settings.data?.last_node_auto_sync_at ? new Date(settings.data.last_node_auto_sync_at).toLocaleString() : "never"}</div>
</section>
<section className="rounded-md border border-border bg-panel p-4">
<div className="mb-4 flex items-center gap-2 font-medium"><RefreshCcw size={18} /> IPAM Auto Discover</div>
<label className="mb-3 flex items-center gap-2 text-sm">
<input type="checkbox" checked={ipamAutoSync} onChange={(event) => setIpamAutoSync(event.target.checked)} />
Enable automatic IPAM discovery from Proxmox
</label>
<Field label="Interval">
<div className="grid grid-cols-[1fr_auto] gap-2">
<input className={inputClass} min={1} max={10080} type="number" value={ipamInterval} onChange={(event) => setIpamInterval(event.target.value)} />
<span className="inline-flex h-10 items-center rounded-md border border-border px-3 text-sm text-slate-500">minutes</span>
</div>
</Field>
<div className="mt-3 text-xs text-slate-500">Last run: {settings.data?.last_ipam_auto_sync_at ? new Date(settings.data.last_ipam_auto_sync_at).toLocaleString() : "never"}</div>
</section>
</div>
{update.error ? <div className="rounded-md border border-danger p-3 text-sm text-danger">Settings could not be saved. Super Admin permission is required.</div> : null}
<button className={buttonClass} disabled={update.isPending || !retentionHours || !nodeInterval || !ipamInterval}>
<Save size={16} />
Save Settings
</button>
</form>
</>
);
}
+727 -45
View File
@@ -1,25 +1,486 @@
import { useState } from "react"; import { useEffect, useMemo, useState } from "react";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { Activity, CircuitBoard, Hash, Network, ShieldCheck } from "lucide-react"; import { Activity, ArrowRight, BarChart3, CircuitBoard, Filter, Hash, Network, Search, Shield, ShieldCheck } from "lucide-react";
import { Link, useNavigate, useParams } from "react-router-dom";
import { api, Workload, WorkloadInsight } from "../api/client"; import { api, Workload, WorkloadInsight } from "../api/client";
import { DataTable } from "../components/DataTable"; import { DataTable } from "../components/DataTable";
import { inputClass, secondaryButtonClass, selectClass } from "../components/FormControls";
import { PageHeader } from "../components/PageHeader"; import { PageHeader } from "../components/PageHeader";
type TrafficSummary = {
key: string;
source: string;
destination: string;
sourceLabel: string;
destinationLabel: string;
sourceIp: string;
destinationIp: string;
protocol: string;
port: string;
sourcePort: string;
bytes: number;
packets: number;
count: number;
decision: string;
interfaceName: string;
note: string;
collector: string;
observedAt: string;
ipAddresses: string[];
matchingFirewallRules: Array<Record<string, unknown>>;
matchingAuditPolicies: Array<Record<string, unknown>>;
matchingPolicies: Array<Record<string, unknown>>;
};
function records(value: unknown) {
return Array.isArray(value) ? (value.filter((item) => item && typeof item === "object") as Array<Record<string, unknown>>) : [];
}
function mergeRecords(left: Array<Record<string, unknown>>, right: Array<Record<string, unknown>>) {
const seen = new Set<string>();
const merged: Array<Record<string, unknown>> = [];
for (const item of [...left, ...right]) {
const key = String(item.id ?? item.pos ?? item.comment ?? JSON.stringify(item));
if (seen.has(key)) {
continue;
}
seen.add(key);
merged.push(item);
}
return merged;
}
function formatBytes(value: unknown) {
const bytes = Number(value ?? 0);
if (!Number.isFinite(bytes) || bytes <= 0) {
return "0 B";
}
const units = ["B", "KB", "MB", "GB", "TB"];
const index = Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), units.length - 1);
return `${(bytes / 1024 ** index).toFixed(index === 0 ? 0 : 1)} ${units[index]}`;
}
function summarizeTraffic(traffic: Array<Record<string, unknown>>) {
const summaries = new Map<string, TrafficSummary>();
for (const flow of traffic) {
const source = String(flow.source ?? flow.source_ip ?? "external");
const destination = String(flow.destination ?? flow.destination_ip ?? "external");
const sourceIp = String(flow.source_ip ?? "");
const destinationIp = String(flow.destination_ip ?? "");
const sourceLabel = String(flow.source_label ?? flow.source_ip ?? source);
const destinationLabel = String(flow.destination_label ?? flow.destination_ip ?? destination);
const protocol = String(flow.protocol ?? "unknown");
const port = String(flow.port ?? flow.destination_port ?? "");
const sourcePort = String(flow.source_port ?? "");
const key = [sourceIp || source, destinationIp || destination, protocol, sourcePort, port, String(flow.interface ?? ""), String(flow.decision ?? "")].join("|");
const existing = summaries.get(key);
const bytes = Number(flow.bytes ?? 0);
const packets = Number(flow.packets ?? 0);
const ipAddresses = Array.isArray(flow.ip_addresses) ? flow.ip_addresses.map(String) : [];
const matchingFirewallRules = records(flow.matching_firewall_rules);
const matchingAuditPolicies = records(flow.matching_audit_policies);
const matchingPolicies = records(flow.matching_policies);
if (existing) {
existing.bytes += Number.isFinite(bytes) ? bytes : 0;
existing.packets += Number.isFinite(packets) ? packets : 0;
existing.count += 1;
existing.ipAddresses = Array.from(new Set([...existing.ipAddresses, ...ipAddresses]));
existing.matchingFirewallRules = mergeRecords(existing.matchingFirewallRules, matchingFirewallRules);
existing.matchingAuditPolicies = mergeRecords(existing.matchingAuditPolicies, matchingAuditPolicies);
existing.matchingPolicies = mergeRecords(existing.matchingPolicies, matchingPolicies);
if (existing.decision === "observed" && String(flow.decision ?? "observed") !== "observed") {
existing.decision = String(flow.decision ?? "observed");
}
if (!existing.observedAt && flow.observed_at) {
existing.observedAt = String(flow.observed_at);
} else if (flow.observed_at) {
const existingTime = Date.parse(existing.observedAt || "");
const flowTime = Date.parse(String(flow.observed_at));
if (Number.isFinite(flowTime) && (!Number.isFinite(existingTime) || flowTime > existingTime)) {
existing.observedAt = String(flow.observed_at);
}
}
continue;
}
summaries.set(key, {
key,
source,
destination,
sourceLabel,
destinationLabel,
sourceIp,
destinationIp,
protocol,
port,
sourcePort,
bytes: Number.isFinite(bytes) ? bytes : 0,
packets: Number.isFinite(packets) ? packets : 0,
count: 1,
decision: String(flow.decision ?? "observed"),
interfaceName: String(flow.interface ?? ""),
note: String(flow.note ?? ""),
collector: String(flow.collector ?? ""),
observedAt: String(flow.observed_at ?? ""),
ipAddresses,
matchingFirewallRules,
matchingAuditPolicies,
matchingPolicies,
});
}
return Array.from(summaries.values()).sort((left, right) => {
const leftTime = Date.parse(left.observedAt || "");
const rightTime = Date.parse(right.observedAt || "");
if (Number.isFinite(leftTime) && Number.isFinite(rightTime) && leftTime !== rightTime) {
return rightTime - leftTime;
}
if (Number.isFinite(rightTime) && !Number.isFinite(leftTime)) {
return 1;
}
if (Number.isFinite(leftTime) && !Number.isFinite(rightTime)) {
return -1;
}
return right.bytes - left.bytes;
});
}
function endpointText(flow: TrafficSummary) {
return `${flow.sourceLabel} -> ${flow.destinationLabel}`;
}
function totalBytes(traffic: TrafficSummary[]) {
return traffic.reduce((sum, flow) => sum + flow.bytes, 0);
}
function uniqueValues(values: string[]) {
return Array.from(new Set(values.filter(Boolean))).sort();
}
function TrafficBars({ traffic }: { traffic: TrafficSummary[] }) {
const top = [...traffic].sort((left, right) => right.bytes - left.bytes).slice(0, 5);
const max = Math.max(...top.map((flow) => flow.bytes), 1);
if (!top.length) {
return <div className="rounded-md border border-border p-3 text-xs text-slate-500">No traffic data yet.</div>;
}
return (
<div className="space-y-1.5">
{top.map((flow) => (
<div key={flow.key} className="grid gap-1">
<div className="flex items-center justify-between gap-3 text-xs">
<span className="truncate">{endpointText(flow)}</span>
<span className="shrink-0 text-slate-500">{formatBytes(flow.bytes)}</span>
</div>
<div className="h-2 overflow-hidden rounded-full bg-slate-200 dark:bg-slate-800">
<div className="h-full rounded-full bg-accent" style={{ width: `${Math.max((flow.bytes / max) * 100, 4)}%` }} />
</div>
</div>
))}
{Array.from({ length: Math.max(5 - top.length, 0) }).map((_, index) => (
<div key={`empty-${index}`} className="grid gap-1 opacity-40">
<div className="flex items-center justify-between gap-3 text-xs text-slate-500">
<span>No additional flow</span>
<span>0 B</span>
</div>
<div className="h-2 rounded-full bg-slate-200 dark:bg-slate-800" />
</div>
))}
</div>
);
}
function CompactFlowList({ traffic }: { traffic: TrafficSummary[] }) {
const top = traffic.filter((flow) => flow.protocol !== "interface-counter").slice(0, 3);
if (!top.length) {
const fallback = traffic.find((flow) => flow.protocol === "interface-counter");
return (
<div className="rounded-md border border-border p-2 text-xs text-slate-500">
{fallback ? `Interface counter fallback: ${formatBytes(fallback.bytes)} observed.` : "No flow telemetry collected yet."}
</div>
);
}
return (
<div className="divide-y divide-border rounded-md border border-border">
{top.map((flow) => (
<div key={flow.key} className="grid grid-cols-[1fr_auto] gap-3 px-3 py-2 text-xs">
<div className="min-w-0">
<div className="truncate font-medium">{endpointText(flow)}</div>
<div className="truncate text-slate-500">{flow.protocol}{flow.port ? `:${flow.port}` : ""} · {flow.decision}</div>
</div>
<div className="shrink-0 text-right text-slate-500">{formatBytes(flow.bytes)}</div>
</div>
))}
</div>
);
}
function ruleLabel(rule: Record<string, unknown>) {
if (rule.error) {
return String(rule.error);
}
const type = String(rule.type ?? "rule");
const action = String(rule.action ?? "unknown");
const proto = rule.proto ? String(rule.proto) : "any";
const port = rule.dport || rule.sport ? `:${String(rule.dport ?? rule.sport)}` : "";
return `${type} ${action} ${proto}${port}`;
}
function policyLabel(policy: Record<string, unknown>) {
const name = String(policy.name ?? "Policy");
const mode = String(policy.enforcement_mode ?? "enforced");
const decision = String(policy.decision ?? "observed").replace("_", " ");
const protocol = String(policy.protocol ?? "any");
const ports = policy.ports ? `:${String(policy.ports)}` : "";
return `${name} · ${mode} · ${decision} · ${protocol}${ports}`;
}
function decisionClass(decision: string) {
if (decision.includes("would")) {
return "border-amber-400/40 bg-amber-400/10 text-amber-300";
}
if (decision.includes("block")) {
return "border-danger/40 bg-danger/10 text-danger";
}
if (decision.includes("allow")) {
return "border-accent/40 bg-accent/10 text-accent";
}
return "border-border bg-canvas text-slate-500";
}
function FlowRuleContext({ flow }: { flow: TrafficSummary }) {
const activeRules = flow.matchingFirewallRules.slice(0, 2);
const auditPolicies = flow.matchingAuditPolicies.slice(0, 2);
const hasContext = activeRules.length || auditPolicies.length;
if (!hasContext) {
return <div className="mt-1 text-xs text-slate-500">No matching active or audit rule.</div>;
}
return (
<div className="mt-2 grid gap-1.5 text-xs">
{activeRules.map((rule, index) => (
<div key={`rule-${flow.key}-${index}`} className="rounded-md border border-border bg-canvas px-2 py-1">
<span className="font-medium">Rule:</span> {ruleLabel(rule)}
<span className={`ml-2 rounded border px-1.5 py-0.5 ${decisionClass(String(rule.decision ?? "observed"))}`}>{String(rule.decision ?? "observed")}</span>
</div>
))}
{auditPolicies.map((policy, index) => (
<div key={`audit-${flow.key}-${index}`} className="rounded-md border border-amber-400/30 bg-amber-400/10 px-2 py-1 text-amber-200">
<span className="font-medium">Audit:</span> {policyLabel(policy)}
</div>
))}
{flow.matchingFirewallRules.length + flow.matchingAuditPolicies.length > activeRules.length + auditPolicies.length ? (
<div className="text-slate-500">
{flow.matchingFirewallRules.length + flow.matchingAuditPolicies.length - activeRules.length - auditPolicies.length} more match
{flow.matchingFirewallRules.length + flow.matchingAuditPolicies.length - activeRules.length - auditPolicies.length === 1 ? "" : "es"}
</div>
) : null}
</div>
);
}
function ActiveRulesList({ rules, compact = false }: { rules: Array<Record<string, unknown>>; compact?: boolean }) {
const visibleRules = compact ? rules.slice(0, 3) : rules;
if (!rules.length) {
return <div className="rounded-md border border-border p-2 text-xs text-slate-500">No active firewall rules were read for this workload.</div>;
}
return (
<div className="divide-y divide-border rounded-md border border-border">
{visibleRules.map((rule, index) => (
<div key={`${String(rule.pos ?? index)}-${index}`} className="grid grid-cols-[1fr_auto] gap-3 px-3 py-2 text-xs">
<div className="min-w-0">
<div className="truncate font-medium">{ruleLabel(rule)}</div>
<div className="truncate text-slate-500">{String(rule.comment ?? (rule.managed_by_nexafabric ? "NexaFabric managed" : "manual or provider rule"))}</div>
</div>
<div className={rule.enable === 0 ? "text-slate-500" : "text-accent"}>{rule.enable === 0 ? "off" : "on"}</div>
</div>
))}
{compact && rules.length > visibleRules.length ? (
<div className="px-3 py-2 text-xs text-slate-500">{rules.length - visibleRules.length} more rule{rules.length - visibleRules.length === 1 ? "" : "s"} on detail page.</div>
) : null}
</div>
);
}
function ProtocolChart({ traffic }: { traffic: TrafficSummary[] }) {
const protocolTotals = Array.from(
traffic.reduce((map, flow) => map.set(flow.protocol, (map.get(flow.protocol) ?? 0) + flow.bytes), new Map<string, number>()),
).sort((left, right) => right[1] - left[1]);
const total = protocolTotals.reduce((sum, [, bytes]) => sum + bytes, 0);
const palette = ["#2dd4bf", "#60a5fa", "#f59e0b", "#f472b6", "#a78bfa"];
if (!total) {
return <div className="rounded-md border border-border p-3 text-xs text-slate-500">No protocol split available.</div>;
}
return (
<div className="space-y-3">
<div className="flex h-3 overflow-hidden rounded-full bg-slate-200 dark:bg-slate-800">
{protocolTotals.map(([protocol, bytes], index) => {
const width = (bytes / total) * 100;
return <div key={protocol} title={protocol} style={{ width: `${width}%`, backgroundColor: palette[index % palette.length] }} />;
})}
</div>
<div className="flex flex-wrap gap-2 text-xs">
{protocolTotals.map(([protocol, bytes], index) => (
<span key={protocol} className="inline-flex items-center gap-2 rounded-md border border-border px-2 py-1">
<span className="h-2 w-2 rounded-full" style={{ backgroundColor: palette[index % palette.length] }} />
{protocol} {formatBytes(bytes)}
</span>
))}
</div>
</div>
);
}
function WorkloadFacts({ insight }: { insight: WorkloadInsight }) {
return (
<div className="grid gap-3 md:grid-cols-4">
<div className="rounded-md border border-border bg-panel p-3">
<div className="flex items-center gap-2 text-xs text-slate-500"><CircuitBoard size={14} /> Type</div>
<div className="mt-1 font-medium">{insight.workload.kind}</div>
</div>
<div className="rounded-md border border-border bg-panel p-3">
<div className="flex items-center gap-2 text-xs text-slate-500"><Activity size={14} /> Status</div>
<div className="mt-1 font-medium">{insight.workload.status}</div>
</div>
<div className="rounded-md border border-border bg-panel p-3">
<div className="flex items-center gap-2 text-xs text-slate-500"><Hash size={14} /> VMID</div>
<div className="mt-1 font-medium">{insight.workload.external_id}</div>
</div>
<div className="rounded-md border border-border bg-panel p-3">
<div className="flex items-center gap-2 text-xs text-slate-500"><ShieldCheck size={14} /> Decision</div>
<div className="mt-1 font-medium">{insight.effective_decision}</div>
</div>
</div>
);
}
function TrafficTable({ traffic, dense = false }: { traffic: TrafficSummary[]; dense?: boolean }) {
return (
<div className="overflow-hidden rounded-md border border-border">
<div className={dense ? "overflow-auto" : "max-h-[460px] overflow-auto"}>
<table className="w-full text-left text-sm">
<thead className="sticky top-0 bg-panel text-xs uppercase text-slate-500">
<tr>
<th className="px-3 py-2 font-medium">Flow</th>
<th className="px-3 py-2 font-medium">Protocol</th>
<th className="px-3 py-2 font-medium">Decision</th>
<th className="px-3 py-2 font-medium">Traffic</th>
<th className="px-3 py-2 font-medium">Packets</th>
<th className="px-3 py-2 font-medium">Seen</th>
</tr>
</thead>
<tbody>
{traffic.map((flow) => (
<tr key={flow.key} className="border-t border-border align-top hover:bg-slate-50 dark:hover:bg-slate-900/50">
<td className="min-w-[420px] px-3 py-3">
<div className="font-medium">{endpointText(flow)}</div>
<div className="mt-1 flex flex-wrap gap-2 text-xs text-slate-500">
<span>{flow.sourceIp || flow.source}</span>
<span>-&gt;</span>
<span>{flow.destinationIp || flow.destination}</span>
{flow.collector ? <span className="rounded border border-border px-1.5">{flow.collector}</span> : null}
</div>
<FlowRuleContext flow={flow} />
</td>
<td className="whitespace-nowrap px-3 py-3">
<div>{flow.protocol}{flow.port ? `:${flow.port}` : ""}</div>
{flow.sourcePort ? <div className="text-xs text-slate-500">source {flow.sourcePort}</div> : null}
</td>
<td className="px-3 py-3">
<span className={`inline-flex rounded-md border px-2 py-1 text-xs ${decisionClass(flow.decision)}`}>{flow.decision.replace("_", " ")}</span>
</td>
<td className="whitespace-nowrap px-3 py-3 font-medium">{formatBytes(flow.bytes)}</td>
<td className="whitespace-nowrap px-3 py-3">{flow.packets}</td>
<td className="whitespace-nowrap px-3 py-3">
<div>{flow.count} sample{flow.count === 1 ? "" : "s"}</div>
{flow.observedAt ? <div className="text-xs text-slate-500">{new Date(flow.observedAt).toLocaleString()}</div> : null}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
);
}
function FlowStatCards({ traffic }: { traffic: TrafficSummary[] }) {
const blocked = traffic.filter((flow) => flow.decision.includes("block")).length;
const allowed = traffic.filter((flow) => flow.decision.includes("allow")).length;
const protocols = uniqueValues(traffic.map((flow) => flow.protocol)).length;
return (
<div className="grid gap-3 md:grid-cols-4">
<div className="rounded-md border border-border bg-panel p-3">
<div className="text-xs text-slate-500">Total Traffic</div>
<div className="mt-1 text-xl font-semibold">{formatBytes(totalBytes(traffic))}</div>
</div>
<div className="rounded-md border border-border bg-panel p-3">
<div className="text-xs text-slate-500">Flows</div>
<div className="mt-1 text-xl font-semibold">{traffic.length}</div>
</div>
<div className="rounded-md border border-border bg-panel p-3">
<div className="text-xs text-slate-500">Allowed / Blocked</div>
<div className="mt-1 text-xl font-semibold">{allowed} / {blocked}</div>
</div>
<div className="rounded-md border border-border bg-panel p-3">
<div className="text-xs text-slate-500">Protocols</div>
<div className="mt-1 text-xl font-semibold">{protocols}</div>
</div>
</div>
);
}
function TopFlowChart({ title, items }: { title: string; items: Array<{ name: string; value: number; suffix?: string }> }) {
const top = items.slice(0, 8);
const max = Math.max(...top.map((item) => item.value), 1);
return (
<section className="rounded-md border border-border bg-panel p-4">
<div className="mb-3 flex items-center gap-2 font-medium"><BarChart3 size={17} /> {title}</div>
<div className="space-y-2">
{top.length ? top.map((item) => (
<div key={item.name} className="grid gap-1">
<div className="flex items-center justify-between gap-3 text-xs">
<span className="truncate">{item.name}</span>
<span className="shrink-0 text-slate-500">{item.suffix ?? formatBytes(item.value)}</span>
</div>
<div className="h-2 overflow-hidden rounded-full bg-slate-200 dark:bg-slate-800">
<div className="h-full rounded-full bg-accent" style={{ width: `${Math.max((item.value / max) * 100, 3)}%` }} />
</div>
</div>
)) : <div className="text-sm text-slate-500">No data for this filter.</div>}
</div>
</section>
);
}
function aggregateBy(traffic: TrafficSummary[], label: (flow: TrafficSummary) => string, value: (flow: TrafficSummary) => number) {
const totals = new Map<string, number>();
for (const flow of traffic) {
const key = label(flow);
totals.set(key, (totals.get(key) ?? 0) + value(flow));
}
return Array.from(totals.entries()).map(([name, total]) => ({ name, value: total })).sort((left, right) => right.value - left.value);
}
export function Workloads() { export function Workloads() {
const navigate = useNavigate();
const workloads = useQuery({ queryKey: ["workloads"], queryFn: () => api<Workload[]>("/vms") }); const workloads = useQuery({ queryKey: ["workloads"], queryFn: () => api<Workload[]>("/vms") });
const [selectedId, setSelectedId] = useState(""); const [selectedId, setSelectedId] = useState("");
const selected = selectedId || workloads.data?.[0]?.id || ""; const selected = selectedId || workloads.data?.[0]?.id || "";
const insight = useQuery({ const insight = useQuery({
queryKey: ["workload-insight", selected], queryKey: ["workload-insight", selected, "summary"],
queryFn: () => api<WorkloadInsight>(`/vms/${selected}/insights`), queryFn: () => api<WorkloadInsight>(`/vms/${selected}/insights?traffic=summary&include_rules=false&include_flow_context=false`),
enabled: Boolean(selected), enabled: Boolean(selected),
}); });
const traffic = useMemo(() => summarizeTraffic(insight.data?.traffic ?? []), [insight.data?.traffic]);
return ( return (
<> <>
<PageHeader title="VMs/LXCs" subtitle="Inspect workloads, observed traffic, and matching policy decisions." /> <PageHeader title="VMs/LXCs" subtitle="Inspect workloads, observed traffic, and matching policy decisions." />
<div className="grid gap-4 xl:grid-cols-[1fr_440px]"> <div className="grid gap-4 xl:grid-cols-[minmax(0,1fr)_420px]">
<section className="space-y-3"> <section className="space-y-3">
<DataTable <DataTable
rows={(workloads.data ?? []) as unknown as Record<string, unknown>[]} rows={(workloads.data ?? []) as unknown as Record<string, unknown>[]}
@@ -28,63 +489,63 @@ export function Workloads() {
onRowClick={(row) => setSelectedId(String(row.id))} onRowClick={(row) => setSelectedId(String(row.id))}
/> />
</section> </section>
<aside className="rounded-md border border-border bg-panel p-4"> <aside className="space-y-3 rounded-md border border-border bg-panel p-3">
<div className="mb-4 flex items-center gap-2 font-medium"><Activity size={18} /> Workload Detail</div> <div className="flex items-center justify-between gap-3">
{insight.data ? ( <div className="flex items-center gap-2 font-medium"><Activity size={18} /> Workload Summary</div>
<div className="space-y-4 text-sm"> {selected ? (
<header className="rounded-md border border-border bg-canvas p-4"> <button className={`${secondaryButtonClass} h-9 px-3`} onClick={() => navigate(`/workloads/${selected}`)}>
<div className="mb-3 text-lg font-semibold">{insight.data.workload.name}</div> <ArrowRight size={16} />
<div className="grid gap-2 text-xs text-slate-500 sm:grid-cols-2"> Details
<div className="flex items-center gap-2"><CircuitBoard size={14} /> Type: {insight.data.workload.kind}</div> </button>
<div className="flex items-center gap-2"><Activity size={14} /> Status: {insight.data.workload.status}</div> ) : null}
<div className="flex items-center gap-2"><Hash size={14} /> VMID: {insight.data.workload.external_id}</div>
<div className="flex items-center gap-2"><ShieldCheck size={14} /> Decision: {insight.data.effective_decision}</div>
</div> </div>
{insight.data ? (
<div className="space-y-3 text-sm">
<header>
<div className="font-semibold">{insight.data.workload.name}</div>
<div className="mt-1 text-xs text-slate-500">{insight.data.workload.kind} · {insight.data.workload.status} · VMID {insight.data.workload.external_id}</div>
</header> </header>
<div className="grid grid-cols-3 gap-2">
<div className="rounded-md border border-border bg-canvas p-2">
<div className="text-xs text-slate-500">Traffic</div>
<div className="mt-1 font-medium">{formatBytes(totalBytes(traffic))}</div>
</div>
<div className="rounded-md border border-border bg-canvas p-2">
<div className="text-xs text-slate-500">Flows</div>
<div className="mt-1 font-medium">{traffic.length}</div>
</div>
<div className="rounded-md border border-border bg-canvas p-2">
<div className="text-xs text-slate-500">IPs</div>
<div className="mt-1 font-medium">{insight.data.assigned_ips.length}</div>
</div>
</div>
<section> <section>
<div className="mb-2 flex items-center gap-2 font-medium"><Network size={16} /> Assigned IPs</div> <div className="mb-1.5 flex items-center gap-2 text-sm font-medium"><Network size={15} /> Assigned IPs</div>
{insight.data.assigned_ips.length ? ( {insight.data.assigned_ips.length ? (
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
{insight.data.assigned_ips.map((ip) => ( {insight.data.assigned_ips.slice(0, 4).map((ip) => (
<div key={ip.id} className="rounded-md border border-border px-3 py-2 text-xs"> <div key={ip.id} className="rounded-md border border-border px-2 py-1 text-xs">
<div className="font-medium">{ip.address}</div> <span className="font-medium">{ip.address}</span>
<div className="text-slate-500">{ip.subnet_cidr ?? "unknown subnet"}</div> <span className="ml-2 text-slate-500">{ip.subnet_cidr ?? "unknown subnet"}</span>
</div> </div>
))} ))}
</div> </div>
) : ( ) : (
<div className="rounded-md border border-border p-3 text-xs text-slate-500">No assigned IP address was discovered for this workload yet.</div> <div className="rounded-md border border-border p-2 text-xs text-slate-500">No assigned IP address was discovered yet.</div>
)} )}
</section> </section>
<section> <section>
<div className="mb-2 font-medium">Traffic</div> <div className="mb-1.5 text-sm font-medium">Top Traffic</div>
<div className="space-y-2"> <TrafficBars traffic={traffic} />
{insight.data.traffic.length ? insight.data.traffic.map((flow, index) => (
<div key={index} className="rounded-md border border-border p-3">
<div>{String(flow.source)} {String(flow.destination)}</div>
<div className="text-xs text-slate-500">{String(flow.protocol)}:{String(flow.port)} · {String(flow.bytes)} bytes · {String(flow.decision)}</div>
{Array.isArray(flow.ip_addresses) ? <div className="mt-1 text-xs text-slate-500">IPs: {flow.ip_addresses.join(", ")}</div> : null}
</div>
)) : <div className="rounded-md border border-border p-3 text-xs text-slate-500">No real traffic telemetry has been collected yet. Install the node agent or enable a flow source to populate this section.</div>}
</div>
</section> </section>
<section> <section>
<div className="mb-2 font-medium">Matching Policies</div> <div className="mb-1.5 text-sm font-medium">Top Flows</div>
<div className="space-y-2"> <CompactFlowList traffic={traffic} />
{insight.data.matching_policies.length ? insight.data.matching_policies.map((policy) => (
<div key={policy.id} className="rounded-md border border-border p-3">
<div>{policy.name}</div>
<div className="text-xs text-slate-500">v{policy.version} · {policy.enforcement_mode}</div>
</div>
)) : <div className="rounded-md border border-border p-3 text-xs text-slate-500">No matching policy for this workload yet.</div>}
</div>
</section> </section>
{insight.data.audit_mode_notes.length ? (
<section> <section>
<div className="mb-2 font-medium">Audit Mode</div> <div className="mb-1.5 flex items-center gap-2 text-sm font-medium"><Shield size={15} /> Active Rules</div>
{insight.data.audit_mode_notes.map((note) => <div key={note} className="rounded-md border border-border p-3 text-xs">{note}</div>)} <ActiveRulesList rules={insight.data.active_firewall_rules ?? []} compact />
</section> </section>
) : null}
</div> </div>
) : ( ) : (
<div className="text-sm text-slate-500">Select a workload.</div> <div className="text-sm text-slate-500">Select a workload.</div>
@@ -94,3 +555,224 @@ export function Workloads() {
</> </>
); );
} }
export function WorkloadDetail() {
const { workloadId } = useParams();
const insight = useQuery({
queryKey: ["workload-insight", workloadId, "summary", "rules"],
queryFn: () => api<WorkloadInsight>(`/vms/${workloadId}/insights?traffic=summary&include_rules=true&include_flow_context=true`),
enabled: Boolean(workloadId),
});
const traffic = useMemo(() => summarizeTraffic(insight.data?.traffic ?? []), [insight.data?.traffic]);
if (insight.isLoading) {
return <div className="rounded-md border border-border bg-panel p-8 text-sm">Loading workload...</div>;
}
if (!insight.data) {
return <div className="rounded-md border border-danger p-4 text-sm text-danger">Workload details could not be loaded.</div>;
}
return (
<>
<PageHeader title={insight.data.workload.name} subtitle="Detailed workload traffic, addressing, and policy context." />
<div className="mb-4">
<Link className={secondaryButtonClass} to="/workloads">Back to VMs/LXCs</Link>
</div>
<div className="space-y-4">
<WorkloadFacts insight={insight.data} />
<div className="grid gap-4 xl:grid-cols-[1fr_360px]">
<section className="space-y-4">
<div className="rounded-md border border-border bg-panel p-4">
<div className="mb-3 flex items-center justify-between">
<div className="font-medium">Traffic Distribution</div>
<div className="text-xs text-slate-500">{traffic.length} aggregated flows · {formatBytes(totalBytes(traffic))}</div>
</div>
<TrafficBars traffic={traffic} />
</div>
<div className="rounded-md border border-border bg-panel p-4">
<div className="mb-3 flex items-center justify-between gap-3">
<div className="font-medium">Flow Table</div>
<Link className={`${secondaryButtonClass} h-9 px-3`} to={`/workloads/${insight.data.workload.id}/flows`}>
<BarChart3 size={16} />
Flow Analytics
</Link>
</div>
{traffic.length ? <TrafficTable traffic={traffic.slice(0, 12)} /> : <div className="rounded-md border border-border p-3 text-xs text-slate-500">No flow telemetry collected yet.</div>}
</div>
</section>
<aside className="space-y-4">
<div className="rounded-md border border-border bg-panel p-4">
<div className="mb-3 font-medium">Protocol Split</div>
<ProtocolChart traffic={traffic} />
</div>
<div className="rounded-md border border-border bg-panel p-4">
<div className="mb-3 flex items-center gap-2 font-medium"><Network size={16} /> Assigned IPs</div>
<div className="space-y-2">
{insight.data.assigned_ips.map((ip) => (
<div key={ip.id} className="rounded-md border border-border px-3 py-2 text-xs">
<div className="font-medium">{ip.address}</div>
<div className="text-slate-500">{ip.subnet_cidr ?? "unknown subnet"} · {ip.status}</div>
</div>
))}
{!insight.data.assigned_ips.length ? <div className="text-xs text-slate-500">No assigned IPs discovered.</div> : null}
</div>
</div>
<div className="rounded-md border border-border bg-panel p-4">
<div className="mb-3 flex items-center gap-2 font-medium"><Shield size={16} /> Active Firewall Rules</div>
<ActiveRulesList rules={insight.data.active_firewall_rules ?? []} />
</div>
<div className="rounded-md border border-border bg-panel p-4">
<div className="mb-3 font-medium">Matching Policies</div>
<div className="space-y-2">
{insight.data.matching_policies.map((policy) => (
<div key={policy.id} className="rounded-md border border-border p-3 text-sm">
<div className="font-medium">{policy.name}</div>
<div className="text-xs text-slate-500">v{policy.version} · {policy.enforcement_mode}</div>
</div>
))}
{!insight.data.matching_policies.length ? <div className="text-xs text-slate-500">No matching policies.</div> : null}
</div>
</div>
{insight.data.audit_mode_notes.length ? (
<div className="rounded-md border border-border bg-panel p-4">
<div className="mb-3 font-medium">Audit Mode</div>
<div className="space-y-2">
{insight.data.audit_mode_notes.map((note) => <div key={note} className="rounded-md border border-border p-3 text-xs">{note}</div>)}
</div>
</div>
) : null}
</aside>
</div>
</div>
</>
);
}
export function WorkloadFlows() {
const { workloadId } = useParams();
const [query, setQuery] = useState("");
const [protocol, setProtocol] = useState("all");
const [decision, setDecision] = useState("all");
const [port, setPort] = useState("");
const [page, setPage] = useState(1);
const insight = useQuery({
queryKey: ["workload-insight", workloadId, "full"],
queryFn: () => api<WorkloadInsight>(`/vms/${workloadId}/insights?traffic=full&include_rules=false&include_flow_context=false`),
enabled: Boolean(workloadId),
});
const traffic = useMemo(() => summarizeTraffic(insight.data?.traffic ?? []), [insight.data?.traffic]);
const protocols = useMemo(() => uniqueValues(traffic.map((flow) => flow.protocol)), [traffic]);
const decisions = useMemo(() => uniqueValues(traffic.map((flow) => flow.decision)), [traffic]);
const filteredTraffic = useMemo(() => {
const normalizedQuery = query.trim().toLowerCase();
const normalizedPort = port.trim();
return traffic.filter((flow) => {
const haystack = [
flow.source,
flow.destination,
flow.sourceLabel,
flow.destinationLabel,
flow.sourceIp,
flow.destinationIp,
flow.protocol,
flow.port,
flow.sourcePort,
flow.decision,
flow.collector,
].join(" ").toLowerCase();
if (normalizedQuery && !haystack.includes(normalizedQuery)) {
return false;
}
if (protocol !== "all" && flow.protocol !== protocol) {
return false;
}
if (decision !== "all" && flow.decision !== decision) {
return false;
}
if (normalizedPort && flow.port !== normalizedPort && flow.sourcePort !== normalizedPort) {
return false;
}
return true;
});
}, [decision, port, protocol, query, traffic]);
const pageSize = 25;
const totalPages = Math.max(Math.ceil(filteredTraffic.length / pageSize), 1);
const currentPage = Math.min(page, totalPages);
const pagedTraffic = filteredTraffic.slice((currentPage - 1) * pageSize, currentPage * pageSize);
useEffect(() => {
setPage(1);
}, [decision, port, protocol, query]);
if (insight.isLoading) {
return <div className="rounded-md border border-border bg-panel p-8 text-sm">Loading flow analytics...</div>;
}
if (!insight.data) {
return <div className="rounded-md border border-danger p-4 text-sm text-danger">Flow analytics could not be loaded.</div>;
}
const endpointTotals = aggregateBy(filteredTraffic, (flow) => endpointText(flow), (flow) => flow.bytes);
const destinationTotals = aggregateBy(filteredTraffic, (flow) => flow.destinationLabel, (flow) => flow.bytes);
const protocolTotals = aggregateBy(filteredTraffic, (flow) => flow.protocol, (flow) => flow.bytes);
const packetTotals = aggregateBy(filteredTraffic, (flow) => endpointText(flow), (flow) => flow.packets).map((item) => ({ ...item, suffix: `${item.value} packets` }));
return (
<>
<PageHeader title={`${insight.data.workload.name} Flow Analytics`} subtitle="Search, filter, and inspect workload traffic decisions." />
<div className="mb-4 flex flex-wrap gap-2">
<Link className={secondaryButtonClass} to={`/workloads/${insight.data.workload.id}`}>Back to Workload</Link>
<Link className={secondaryButtonClass} to="/workloads">Back to VMs/LXCs</Link>
</div>
<div className="space-y-4">
<FlowStatCards traffic={filteredTraffic} />
<section className="rounded-md border border-border bg-panel p-4">
<div className="mb-3 flex items-center gap-2 font-medium"><Filter size={17} /> Filters</div>
<div className="grid gap-3 lg:grid-cols-[minmax(220px,1fr)_180px_180px_150px]">
<label className="relative">
<Search className="pointer-events-none absolute left-3 top-2.5 text-slate-500" size={16} />
<input className={`${inputClass} pl-9`} value={query} onChange={(event) => setQuery(event.target.value)} placeholder="Search IP, workload, collector, protocol..." />
</label>
<select className={selectClass} value={protocol} onChange={(event) => setProtocol(event.target.value)}>
<option value="all">All protocols</option>
{protocols.map((item) => <option key={item} value={item}>{item}</option>)}
</select>
<select className={selectClass} value={decision} onChange={(event) => setDecision(event.target.value)}>
<option value="all">All decisions</option>
{decisions.map((item) => <option key={item} value={item}>{item}</option>)}
</select>
<input className={inputClass} value={port} onChange={(event) => setPort(event.target.value)} placeholder="Port" />
</div>
</section>
<div className="grid gap-4 xl:grid-cols-2">
<TopFlowChart title="Top Conversations" items={endpointTotals} />
<TopFlowChart title="Top Destinations" items={destinationTotals} />
<TopFlowChart title="Protocol Traffic" items={protocolTotals} />
<TopFlowChart title="Packet Volume" items={packetTotals} />
</div>
<section className="rounded-md border border-border bg-panel p-4">
<div className="mb-3 flex items-center justify-between gap-3">
<div className="font-medium">All Flows</div>
<div className="text-xs text-slate-500">{filteredTraffic.length} of {traffic.length} flows · {formatBytes(totalBytes(filteredTraffic))}</div>
</div>
{filteredTraffic.length ? (
<>
<TrafficTable traffic={pagedTraffic} dense />
<div className="mt-3 flex items-center justify-between gap-3 text-sm">
<div className="text-slate-500">
Showing {(currentPage - 1) * pageSize + 1}-{Math.min(currentPage * pageSize, filteredTraffic.length)} of {filteredTraffic.length}
</div>
<div className="flex items-center gap-2">
<button className={secondaryButtonClass} disabled={currentPage === 1} onClick={() => setPage((value) => Math.max(value - 1, 1))}>Previous</button>
<span className="text-slate-500">Page {currentPage} / {totalPages}</span>
<button className={secondaryButtonClass} disabled={currentPage === totalPages} onClick={() => setPage((value) => Math.min(value + 1, totalPages))}>Next</button>
</div>
</div>
</>
) : <div className="rounded-md border border-border p-4 text-sm text-slate-500">No flows match the current filters.</div>}
</section>
</div>
</>
);
}
+3
View File
@@ -1,8 +1,10 @@
server { server {
listen 80; listen 80;
server_name _; server_name _;
client_max_body_size 16m;
location /api/ { location /api/ {
client_max_body_size 16m;
proxy_pass http://api:8000/api/; proxy_pass http://api:8000/api/;
proxy_set_header Host $http_host; proxy_set_header Host $http_host;
proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Real-IP $remote_addr;
@@ -12,6 +14,7 @@ server {
} }
location /agents/ { location /agents/ {
client_max_body_size 16m;
proxy_pass http://api:8000/api/v1/agents/; proxy_pass http://api:8000/api/v1/agents/;
proxy_set_header Host $http_host; proxy_set_header Host $http_host;
proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Real-IP $remote_addr;