Compare commits

..
47 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
nessi 714aebfdc0 feat: add /agents/ nginx proxy route and improve agent heartbeat error handling with URL normalization
CI / backend (push) Failing after 3s
CI / frontend (push) Failing after 27s
Add /agents/ location block to nginx config to proxy agent heartbeat requests directly to backend API without /api/v1 prefix duplication, implement normalized_api_url helper to detect and fix double /api/v1 suffixes in agent config with automatic /api/v1 appending when missing, enhance heartbeat error logging to include HTTP status codes, response body preview, and resolved API URL for debugging connection
2026-07-09 14:30:11 +02:00
nessi 45baa6ae7a feat: add JWT refresh token support with automatic token renewal and session expiration handling
CI / backend (push) Failing after 3s
CI / frontend (push) Failing after 26s
Add /auth/refresh endpoint to issue new access tokens using refresh tokens with token type validation and user activity checks, implement automatic token refresh on 401 responses with single retry logic in frontend API client, add authorizedFetch helper for non-JSON endpoints with refresh support, store both access and refresh tokens in localStorage with clearTokens cleanup helper, add nexafabric.authExpired event
2026-07-09 14:27:46 +02:00
nessi 7818158a9b feat: fix agent heartbeat payload serialization to handle datetime objects
CI / backend (push) Failing after 3s
CI / frontend (push) Failing after 29s
Add mode="json" to model_dump() call in agent_heartbeat endpoint to properly serialize datetime fields in AgentHeartbeat payload, preventing serialization errors when storing payload in database
2026-07-09 14:22:44 +02:00
nessi ffc69011c4 feat: add reverse proxy support with X-Forwarded-Host header handling for agent installer URL generation
CI / backend (push) Failing after 3s
CI / frontend (push) Failing after 27s
Add external_base_url helper to detect base URL from X-Forwarded-Host and X-Forwarded-Proto headers with fallback to request.base_url, replace hardcoded request.base_url usage in node_agent_install, node_agent_install_info, and public_node_agent_install endpoints to support reverse proxy deployments, update nginx config to pass X-Forwarded-Host header and use $http_host instead of $host for proper hostname forw
2026-07-09 14:21:11 +02:00
nessi e67174a4ae feat: add node agent system with heartbeat collection, installer generation, and traffic flow telemetry
CI / backend (push) Failing after 3s
CI / frontend (push) Failing after 28s
Add NodeAgent and TrafficFlow models to track agent status and network flows, implement /agents/heartbeat endpoint to receive interface counters, conntrack flows, firewall status, and nftables ruleset hash from agents, add nexafabric-agent.py Python script to collect host telemetry including VM/LXC interface hints via tap/fwbr regex matching, conntrack flow parsing with protocol/state/byte counters, and pve-firewall status checks,
2026-07-09 14:13:47 +02:00
nessi 88badf1f22 feat: add cluster update/delete endpoints, expand tcp/udp protocol handling, and enhance cluster management UI
CI / backend (push) Failing after 3s
CI / frontend (push) Failing after 28s
Add PATCH /clusters/{cluster_id} endpoint with optional token update and audit logging, implement DELETE /clusters/{cluster_id} with cascading deletion of nodes, workloads, networks, subnets, and IP addresses, expand firewall rule generation to split tcp/udp protocol into separate tcp and udp rules for Proxmox compatibility, add ClusterUpdate schema with optional api_token field, include
2026-07-09 14:04:17 +02:00
nessi 1ada59d3c7 docs: document Proxmox write permissions and firewall apply scope, add live apply implementation with rule resolution and provider integration
CI / backend (push) Failing after 3s
CI / frontend (push) Failing after 28s
Add minimum write privileges section covering VM.Audit and VM.Config.Network requirements for firewall orchestration, document NexaFabric comment marker approach for safe rule replacement, clarify that only VM/LXC-level rules with concrete workload targets are supported for live apply while security groups remain preview-only, add firewall interface checkbox requirement for enforcement, document
2026-07-09 13:43:08 +02:00
nessi 7fa4bcaad1 feat: add policy deletion, improve dry run handling, and enhance policy designer UX
CI / backend (push) Failing after 3s
CI / frontend (push) Failing after 32s
Add DELETE /policies/{policy_id} endpoint with audit logging, improve firewall apply to handle dry run mode without calling provider and track operation success separately from applied status, update Proxmox provider error message to clarify rule-to-VM mapping requirement, add dry run explanation text to FirewallPreview with conditional button labels, enhance Policies page with expanded DataTable columns showing source
2026-07-09 13:32:35 +02:00
nessi b382d4362c feat: add container network filtering, enhance IP address display, and improve workload insights UI
CI / backend (push) Failing after 3s
CI / frontend (push) Failing after 29s
Add is_docker_or_container_network helper to detect Docker bridge, Kubernetes CNI, and loopback networks, implement cleanup_discovered_container_networks to remove container bridge IPs from discovered networks during IPAM discovery, add ip_address_payload helper to enrich IP addresses with subnet CIDR and workload details, update ProxmoxProvider to ignore guest interfaces matching common container pref
2026-07-09 13:27:39 +02:00
nessi 701835e9f3 feat: redesign login page with split layout, feature highlights, and improved UX
CI / backend (push) Failing after 3s
CI / frontend (push) Failing after 27s
Add two-column layout with left panel showcasing product features including inventory sync, policy audit mode, and preview capabilities with animated decorative elements, redesign right panel with larger form inputs and focus states, remove hardcoded demo credentials to require manual entry, add autocomplete attributes for username and password fields, implement disabled state for submit button when fields are empty, add animations
2026-07-09 13:16:50 +02:00
nessi 0aaaa12ba1 docs: add Proxmox preparation guide and update quick start for setup wizard
Add comprehensive Proxmox integration guide covering API token creation, minimum read permissions, QEMU guest agent setup for IP discovery, LXC IP configuration, cluster sync workflow, firewall requirements, network flow visibility options, and troubleshooting common integration errors. Update quick start section to document setup wizard for fresh installations, remove hardcoded demo credentials, add instructions for res
2026-07-09 13:15:37 +02:00
33 changed files with 6037 additions and 317 deletions
-1
View File
@@ -1,7 +1,6 @@
name: CI name: CI
on: on:
push:
pull_request: pull_request:
jobs: jobs:
+200 -6
View File
@@ -7,7 +7,7 @@ NexaFabric is an open-source SDN-like network and security control plane for Pro
- FastAPI backend with SQLAlchemy 2, Alembic-ready models, JWT auth, RBAC primitives, audit logging, and provider interfaces. - FastAPI backend with SQLAlchemy 2, Alembic-ready models, JWT auth, RBAC primitives, audit logging, and provider interfaces.
- React + TypeScript frontend with Vite, Tailwind CSS, TanStack Query, React Router, Zustand, dark/light mode, and production-oriented pages. - React + TypeScript frontend with Vite, Tailwind CSS, TanStack Query, React Router, Zustand, dark/light mode, and production-oriented pages.
- PostgreSQL, Redis, worker, API, frontend, and reverse proxy through Docker Compose. - PostgreSQL, Redis, worker, API, frontend, and reverse proxy through Docker Compose.
- Demo seed data for clusters, nodes, workloads, networks, tenants, policies, IPAM, jobs, and audit events. - Optional demo seed data for clusters, nodes, workloads, networks, tenants, policies, IPAM, jobs, and audit events.
- Tests, lint/type-check scripts, CI workflow, and operational documentation. - Tests, lint/type-check scripts, CI workflow, and operational documentation.
## Quick Start ## Quick Start
@@ -22,10 +22,205 @@ Then open:
- Frontend: http://localhost:8080 - Frontend: http://localhost:8080
- API docs: http://localhost:8080/api/docs - API docs: http://localhost:8080/api/docs
Demo login: On a fresh database NexaFabric opens the setup wizard first. The wizard creates the first Super Admin user and registers the first Proxmox or demo provider.
- Email: `admin@nexafabric.local` To start from scratch during testing:
- Password: `ChangeMe_UseEnvInstead`
```bash
docker compose down -v
docker compose up --build
```
Demo data is disabled by default. Enable it only for lab screenshots or UI testing:
```env
SEED_DEMO_DATA=true
```
## Proxmox Preparation
NexaFabric can read inventory from the Proxmox VE API immediately after you add a cluster, but IPAM and flow visibility depend on what Proxmox and the guests expose. Use this checklist before expecting full data in the UI.
### 1. Create A Dedicated API Token
In Proxmox VE, create a dedicated user and API token instead of using a personal admin token.
Recommended UI path:
1. `Datacenter` -> `Permissions` -> `Users`
2. Create a user such as `nexafabric@pve` or another realm you manage.
3. `Datacenter` -> `Permissions` -> `API Tokens`
4. Add a token such as `nexafabric@pve!control-plane`.
5. Keep privilege separation enabled unless you intentionally want the token to inherit all user privileges.
NexaFabric accepts both token formats:
```text
PVEAPIToken=nexafabric@pve!control-plane=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx
nexafabric@pve!control-plane=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx
```
The installer stores the token with the cluster and starts in `read_only` mode by default.
### 2. Assign Minimum Read Permissions
For inventory, IP discovery, policy preview, and dashboard data, assign the token read access at the Datacenter level or the narrowest paths that contain your nodes and guests.
Minimum practical read privileges:
- `Sys.Audit` for cluster and node inventory.
- `VM.Audit` for VM and container inventory/config visibility.
- `SDN.Audit` if you use Proxmox SDN zones, VNets, EVPN, or related network objects.
For write-enabled firewall orchestration, create a separate token or role and do not reuse the read-only token. Only enable write mode after previews and audit logging have been verified in your environment.
Minimum practical write privileges for VM/LXC-level firewall rules:
- `VM.Audit` so NexaFabric can resolve guests and inspect existing rules.
- `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.
### 3. Enable QEMU Guest Agent For VM IP Discovery
For QEMU VMs, Proxmox only exposes guest interface/IP details reliably when the QEMU Guest Agent is installed in the VM and enabled in Proxmox.
Per VM:
1. Install the guest agent inside the VM.
- Debian/Ubuntu: `apt install qemu-guest-agent`
- RHEL/Rocky/Alma: `dnf install qemu-guest-agent`
- Windows: install the VirtIO guest tools including the QEMU guest agent.
2. Enable and start the service in the guest.
- Linux: `systemctl enable --now qemu-guest-agent`
3. In Proxmox UI, open the VM:
- `Options` -> `QEMU Guest Agent` -> `Enabled`
4. Reboot the VM or fully stop/start it if Proxmox does not immediately report the agent.
NexaFabric uses the Proxmox guest-agent network interface endpoint to discover IPv4 addresses for IPAM. If the guest agent is missing, the VM can still appear in inventory, but IPAM may not learn its IP address.
### 4. LXC IP Discovery
For LXC containers, NexaFabric reads static IPs from the Proxmox container network config when available.
Works best when container interfaces are configured with explicit IPs, for example:
```text
net0: name=eth0,bridge=vmbr0,ip=10.10.10.50/24,gw=10.10.10.1
```
If the container uses DHCP, Proxmox may not always have a stable IP value in config. In that case, use a DHCP lease source, static reservations, or a future NexaFabric node-agent/flow-source integration.
### 5. Sync Cluster Inventory
After adding the cluster in NexaFabric:
1. Open `Clusters`.
2. Click the cluster row/name.
3. Use `Test` to validate the token.
4. Use `Sync` to import nodes, VMs/LXCs, networks, and discoverable IP addresses.
5. Open `IPAM` -> `Discover from Proxmox` if you want to rerun IP discovery later.
Imported IPs are placed into an automatically created `discovered-ipam` network if NexaFabric cannot map them to an existing subnet.
### 6. Firewall And Policy Requirements
NexaFabric policy preview does not require Proxmox firewall writes. It compiles NexaFabric policies into provider-specific preview output and records audit events.
Before enabling real firewall apply workflows:
- Ensure Proxmox firewall is enabled intentionally at the Datacenter, node, and guest level where you want enforcement.
- Ensure the firewall checkbox is enabled on the relevant VM/LXC network interfaces, otherwise Proxmox may store rules without enforcing them for that interface.
- Keep NexaFabric clusters in `read_only` mode until previews are reviewed.
- Use `audit` mode policies first to see what would be allowed or blocked.
- Confirm that Proxmox API token permissions match the exact write operations you plan to allow.
- Keep backups of Proxmox firewall configuration before enabling automation.
Live apply currently supports VM/LXC-level rules where the enforcement side is a concrete workload:
- `ingress` policies apply to the destination VM/LXC.
- `egress` policies apply to the source VM/LXC.
- The opposite side can be `any`, an IP/CIDR, or another workload with an assigned IP in IPAM.
- Security group and network-wide targets remain preview-only until they can be expanded safely.
- `audit` mode does not write blocking Proxmox rules; it records the intended result without enforcement.
NexaFabric is designed to read first, simulate second, and only apply after explicit confirmation.
### 7. Network Flow Visibility
Proxmox inventory and guest agent data are enough for:
- Cluster, node, VM, LXC inventory.
- Network object visibility.
- IPAM discovery for VMs with QEMU Guest Agent.
- Static LXC IP discovery.
- Policy matching and firewall previews.
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 options:
- 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.
- Router/firewall flow exports from pfSense, OPNsense, FRR/BGP edge devices, or physical switches.
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
After a Proxmox cluster sync has imported nodes:
1. Open `Nodes`.
2. Click the agent icon on the node row.
3. Copy the installer command.
4. Run it as `root` on the matching Proxmox node.
The installer creates:
- `/opt/nexafabric-agent/nexafabric-agent.py`
- `/etc/nexafabric-agent/config.json`
- `nexafabric-agent.service`
The agent sends a heartbeat every 30 seconds to NexaFabric. It uses a node-specific enrollment token generated by the UI and does not need your Proxmox API token.
Useful commands on the Proxmox node:
```bash
systemctl status nexafabric-agent
journalctl -u nexafabric-agent -f
systemctl restart nexafabric-agent
```
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
`401 No ticket` or `Provider sync failed` usually means:
- The API token format is wrong.
- The token was copied without the secret value after `=`.
- The token lacks the required ACLs.
- Privilege separation is enabled but no permissions were assigned to the token.
- The wrong realm/user/token ID was used.
TLS errors usually mean:
- Proxmox uses a self-signed certificate.
- The hostname in `Cluster API URL` does not match the certificate.
- Disable `Verify TLS certificate` only for trusted lab systems, or install a valid certificate on Proxmox.
## Repository Layout ## Repository Layout
@@ -38,5 +233,4 @@ nginx/ Reverse proxy example
## Safety Model ## Safety Model
NexaFabric never applies firewall changes without a preview, validation, and audit record. The included Proxmox provider is designed around read-only inventory first. Write-enabled orchestration is intentionally routed through explicit dry-run and apply workflows. NexaFabric never applies firewall changes without a preview, validation, and audit record. The included Proxmox provider is designed around read-only inventory first, then explicit dry-run and apply workflows for concrete VM/LXC firewall rules.
@@ -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.
@@ -0,0 +1,731 @@
#!/usr/bin/env python3
import argparse
import hashlib
import json
import os
import platform
import re
import select
import socket
import ssl
import struct
import subprocess
import time
import urllib.error
import urllib.request
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
VERSION = "0.3.0"
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:
try:
return Path(path).read_text(encoding="utf-8").strip()
except OSError:
return None
def run_command(args: list[str], timeout: int = 5) -> tuple[int, str]:
try:
result = subprocess.run(args, capture_output=True, text=True, timeout=timeout, check=False)
output = result.stdout.strip() or result.stderr.strip()
return result.returncode, output
except (OSError, subprocess.SubprocessError):
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]]:
interfaces = []
for item in Path("/sys/class/net").iterdir() if Path("/sys/class/net").exists() else []:
name = item.name
if name == "lo":
continue
vm_match = VM_INTERFACE_RE.search(name)
interfaces.append(
{
"name": name,
"vmid": vm_match.group(1) if vm_match else None,
"operstate": read_text(f"/sys/class/net/{name}/operstate") or "unknown",
"rx_bytes": int(read_text(f"/sys/class/net/{name}/statistics/rx_bytes") or 0),
"tx_bytes": int(read_text(f"/sys/class/net/{name}/statistics/tx_bytes") or 0),
"rx_packets": int(read_text(f"/sys/class/net/{name}/statistics/rx_packets") or 0),
"tx_packets": int(read_text(f"/sys/class/net/{name}/statistics/tx_packets") or 0),
}
)
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:
parts = line.split()
if len(parts) < 5 or parts[0] not in {"tcp", "udp", "icmp"}:
return None
protocol = parts[0]
state = None
if protocol == "tcp" and len(parts) > 3 and "=" not in parts[3]:
state = parts[3]
values: dict[str, list[str]] = {}
for part in parts:
if "=" not in part:
continue
key, value = part.split("=", 1)
values.setdefault(key, []).append(value)
src_values = values.get("src", [])
dst_values = values.get("dst", [])
if not src_values or not dst_values:
return None
packet_values = [int(value) for value in values.get("packets", []) if value.isdigit()]
byte_values = [int(value) for value in values.get("bytes", []) if value.isdigit()]
sport = values.get("sport", [None])[0]
dport = values.get("dport", [None])[0]
return {
"source_ip": src_values[0],
"destination_ip": dst_values[0],
"protocol": protocol,
"source_port": int(sport) if sport and sport.isdigit() else None,
"destination_port": int(dport) if dport and dport.isdigit() else None,
"packets": sum(packet_values),
"bytes": sum(byte_values),
"state": state,
}
def collect_flows(limit: int = 500) -> list[dict[str, Any]]:
code, output = run_command(["conntrack", "-L", "-o", "extended"], timeout=10)
if code != 0 or not output:
return []
flows = []
seen = set()
for line in output.splitlines():
flow = parse_conntrack_line(line)
if not flow:
continue
key = (
flow["source_ip"],
flow["destination_ip"],
flow["protocol"],
flow.get("source_port"),
flow.get("destination_port"),
)
if key in seen:
continue
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)
if len(flows) >= limit:
break
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]:
code, output = run_command(["conntrack", "-C"])
if code == 0 and output.isdigit():
return {"count": int(output), "source": "conntrack"}
for path in ("/proc/net/nf_conntrack", "/proc/net/ip_conntrack"):
try:
with open(path, "r", encoding="utf-8", errors="ignore") as handle:
return {"count": sum(1 for _ in handle), "source": path}
except OSError:
continue
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]:
status = {}
code, output = run_command(["systemctl", "is-active", "pve-firewall"])
status["pve_firewall"] = output if code == 0 else "unknown"
code, output = run_command(["nft", "-j", "list", "ruleset"], timeout=10)
if code == 0 and output:
status["nft_ruleset_sha256"] = hashlib.sha256(output.encode("utf-8")).hexdigest()
else:
status["nft_ruleset_sha256"] = None
return status
def collect_payload(config: dict[str, Any]) -> dict[str, Any]:
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 {
"version": VERSION,
"node_name": config.get("node_name"),
"collected_at": datetime.now(timezone.utc).isoformat(),
"hostname": socket.gethostname(),
"kernel": platform.release(),
"uptime_seconds": float(uptime.split()[0]) if uptime else None,
"loadavg": list(os.getloadavg()) if hasattr(os, "getloadavg") else [],
"interfaces": interfaces,
"interface_traffic": collect_interface_traffic(interfaces),
"flows": flows,
"ebpf_flows": ebpf_flows,
"conntrack": conntrack,
"firewall": collect_firewall(),
"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),
},
}
def normalized_api_url(config: dict[str, Any]) -> str:
api_url = str(config["api_url"]).rstrip("/")
while api_url.endswith("/api/v1/api/v1"):
api_url = api_url.removesuffix("/api/v1")
if not api_url.endswith("/api/v1"):
api_url = f"{api_url}/api/v1"
return api_url
def post_heartbeat(config: dict[str, Any], payload: dict[str, Any]) -> None:
api_url = normalized_api_url(config)
heartbeat_url = f"{api_url}/agents/heartbeat"
data = json.dumps(payload).encode("utf-8")
request = urllib.request.Request(
heartbeat_url,
data=data,
headers={
"Authorization": f"Bearer {config['token']}",
"Content-Type": "application/json",
"User-Agent": f"nexafabric-agent/{VERSION}",
},
method="POST",
)
context = None
if not bool(config.get("verify_tls", True)):
context = ssl._create_unverified_context()
with urllib.request.urlopen(request, timeout=15, context=context) as response:
response.read()
def load_config(path: str) -> dict[str, Any]:
return json.loads(Path(path).read_text(encoding="utf-8"))
def main() -> int:
parser = argparse.ArgumentParser(description="NexaFabric Proxmox node telemetry agent")
parser.add_argument("--config", default="/etc/nexafabric-agent/config.json")
parser.add_argument("--once", action="store_true")
args = parser.parse_args()
config = load_config(args.config)
interval = int(config.get("interval_seconds", 30))
while True:
started_at = time.monotonic()
payload = collect_payload(config)
try:
post_heartbeat(config, payload)
print(f"heartbeat ok: {payload['collected_at']}", flush=True)
except urllib.error.HTTPError as exc:
body = exc.read().decode("utf-8", errors="replace")[:500]
print(f"heartbeat failed: HTTP {exc.code} {exc.reason} url={exc.url} body={body}", flush=True)
except (OSError, urllib.error.URLError) as exc:
print(f"heartbeat failed: {exc} api_url={normalized_api_url(config)}", flush=True)
if args.once:
return 0
elapsed = time.monotonic() - started_at
time.sleep(max(interval - elapsed, 1))
if __name__ == "__main__":
raise SystemExit(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))
}
+22 -1
View File
@@ -5,7 +5,7 @@ from sqlalchemy import select
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.api.deps import CurrentUser from app.api.deps import CurrentUser
from app.core.security import create_access_token, create_refresh_token, verify_password from app.core.security import create_access_token, create_refresh_token, decode_token, verify_password
from app.db.session import get_db from app.db.session import get_db
from app.models.domain import User from app.models.domain import User
from app.schemas.domain import LoginRequest, TokenPair, UserRead from app.schemas.domain import LoginRequest, TokenPair, UserRead
@@ -33,6 +33,27 @@ def login(payload: LoginRequest, db: Session = Depends(get_db)) -> TokenPair:
) )
@router.post("/refresh", response_model=TokenPair)
def refresh(payload: dict[str, str], db: Session = Depends(get_db)) -> TokenPair:
token = payload.get("refresh_token")
if not token:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Missing refresh token")
try:
claims = decode_token(token)
except Exception as exc:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid refresh token") from exc
if claims.get("typ") != "refresh":
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token type")
user = db.scalar(select(User).where(User.id == claims.get("sub"), User.is_active.is_(True)))
if not user:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Inactive or missing user")
permissions = sorted({permission for role in user.roles for permission in role.permissions})
return TokenPair(
access_token=create_access_token(user.id, permissions),
refresh_token=create_refresh_token(user.id),
)
@router.get("/me", response_model=UserRead) @router.get("/me", response_model=UserRead)
def me(user: CurrentUser) -> User: def me(user: CurrentUser) -> User:
return user return user
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()
+52 -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, 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
@@ -129,6 +129,18 @@ class Node(Base, TimestampMixin):
cluster: Mapped[Cluster] = relationship() cluster: Mapped[Cluster] = relationship()
class NodeAgent(Base, TimestampMixin):
__tablename__ = "node_agents"
node_id: Mapped[str] = mapped_column(ForeignKey("nodes.id"), primary_key=True)
status: Mapped[str] = mapped_column(String(100), default="not_installed")
version: Mapped[str | None] = mapped_column(String(50))
last_seen_at: Mapped[datetime | None] = mapped_column(DateTime)
last_payload: Mapped[dict | None] = mapped_column(JSON)
install_count: Mapped[int] = mapped_column(Integer, default=0)
node: Mapped[Node] = relationship()
class Workload(Base, TimestampMixin): class Workload(Base, TimestampMixin):
__tablename__ = "workloads" __tablename__ = "workloads"
@@ -173,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)
@@ -183,6 +198,30 @@ class IpAddress(Base, TimestampMixin):
note: Mapped[str | None] = mapped_column(Text) note: Mapped[str | None] = mapped_column(Text)
class TrafficFlow(Base, TimestampMixin):
__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)
node_id: Mapped[str] = mapped_column(ForeignKey("nodes.id"), index=True)
source_ip: Mapped[str] = mapped_column(String(100), index=True)
destination_ip: Mapped[str] = mapped_column(String(100), index=True)
protocol: Mapped[str] = mapped_column(String(20), default="unknown")
source_port: Mapped[int | None] = mapped_column(Integer)
destination_port: Mapped[int | None] = mapped_column(Integer)
bytes: Mapped[int] = mapped_column(BigInteger, default=0)
packets: Mapped[int] = mapped_column(BigInteger, default=0)
state: Mapped[str | None] = mapped_column(String(100))
observed_at: Mapped[datetime | None] = mapped_column(DateTime)
raw: Mapped[dict | None] = mapped_column(JSON)
class SecurityGroup(Base, TimestampMixin): class SecurityGroup(Base, TimestampMixin):
__tablename__ = "security_groups" __tablename__ = "security_groups"
@@ -192,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"
+88
View File
@@ -71,6 +71,15 @@ class ClusterCreate(BaseModel):
verify_tls: bool = True verify_tls: bool = True
class ClusterUpdate(BaseModel):
name: str
api_url: str
api_token: str | None = Field(default=None, min_length=8)
provider: str = "proxmox"
mode: str = "read_only"
verify_tls: bool = True
class TenantCreate(BaseModel): class TenantCreate(BaseModel):
name: str name: str
description: str | None = None description: str | None = None
@@ -104,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"
@@ -151,12 +172,34 @@ 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
api_url: str api_url: str
provider: str provider: str
mode: str mode: str
verify_tls: bool
last_sync_at: datetime | None last_sync_at: datetime | None
last_sync_status: str | None last_sync_status: str | None
last_sync_error: str | None last_sync_error: str | None
@@ -171,6 +214,36 @@ class NodeRead(OrmModel):
memory_mb: int memory_mb: int
class NodeAgentRead(BaseModel):
node_id: str
status: str
version: str | None = None
last_seen_at: datetime | None = None
install_count: int = 0
last_payload: dict[str, Any] | None = None
class NodeWithAgentRead(NodeRead):
agent: NodeAgentRead | None = None
class AgentHeartbeat(BaseModel):
version: str
node_name: str | None = None
collected_at: datetime | None = None
hostname: str | None = None
kernel: str | None = None
uptime_seconds: float | None = None
loadavg: list[float] = []
interfaces: list[dict[str, Any]] = []
interface_traffic: list[dict[str, Any]] = []
flows: list[dict[str, Any]] = []
ebpf_flows: list[dict[str, Any]] = []
conntrack: dict[str, Any] = {}
firewall: dict[str, Any] = {}
extra: dict[str, Any] = {}
class WorkloadRead(OrmModel): class WorkloadRead(OrmModel):
id: str id: str
cluster_id: str cluster_id: str
@@ -210,9 +283,12 @@ class SubnetRead(OrmModel):
class IpAddressRead(OrmModel): class IpAddressRead(OrmModel):
id: str id: str
subnet_id: str subnet_id: str
subnet_cidr: str | None = None
address: str address: str
status: str status: str
workload_id: str | None workload_id: str | None
workload_name: str | None = None
workload_external_id: str | None = None
note: str | None note: str | None
@@ -234,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):
@@ -259,11 +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]
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
+270 -1
View File
@@ -7,6 +7,17 @@ from app.services.providers.base import Provider, ProviderConnection
class ProxmoxProvider(Provider): class ProxmoxProvider(Provider):
name = "proxmox" name = "proxmox"
ignored_guest_interface_prefixes = (
"br-",
"cali",
"cni",
"docker",
"flannel",
"kube",
"lo",
"veth",
"virbr",
)
def auth_header(self, token: str) -> str: def auth_header(self, token: str) -> str:
token = token.strip() token = token.strip()
@@ -67,6 +78,9 @@ class ProxmoxProvider(Provider):
return return
interfaces = response.json().get("data", {}).get("result", []) interfaces = response.json().get("data", {}).get("result", [])
for interface in interfaces: for interface in interfaces:
interface_name = str(interface.get("name") or "").lower()
if interface_name.startswith(self.ignored_guest_interface_prefixes):
continue
for address in interface.get("ip-addresses", []): for address in interface.get("ip-addresses", []):
ip_address = address.get("ip-address") ip_address = address.get("ip-address")
prefix = address.get("prefix") prefix = address.get("prefix")
@@ -113,7 +127,262 @@ 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:
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/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:
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(
self,
client: httpx.AsyncClient,
headers: dict[str, str],
rules_url: str,
marker: str,
) -> list[dict[str, Any]]:
existing_response = await client.get(rules_url, headers=headers)
existing_response.raise_for_status()
existing_rules = existing_response.json().get("data", [])
deletions = []
for existing_rule in sorted(existing_rules, key=lambda item: int(item.get("pos", 0)), reverse=True):
comment = str(existing_rule.get("comment") or "")
pos = existing_rule.get("pos")
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.raise_for_status()
deletions.append({"pos": pos, "comment": comment})
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}
return {"applied": False, "reason": "Apply adapter intentionally requires explicit implementation", "rules": rules} missing_mapping = [rule for rule in rules if "provider_target" not in rule]
if missing_mapping:
return {
"applied": False,
"reason": "Live apply requires every rule to resolve to a concrete Proxmox VM/LXC target.",
"rules": missing_mapping,
}
headers = {"Authorization": self.auth_header(connection.token)}
grouped: dict[tuple[str, str, str, str], list[dict[str, Any]]] = {}
for rule in rules:
target = rule["provider_target"]
marker = self.policy_marker(rule)
key = (str(target["node"]), str(target["kind"]), str(target["vmid"]), marker)
grouped.setdefault(key, []).append(rule)
applied_rules = []
deleted_rules = []
enabled_targets = []
enabled_interfaces = []
enforcement_status = []
audit_only_rules = []
async with httpx.AsyncClient(verify=connection.verify_tls, timeout=20) as client:
for target_rules in grouped.values():
target = target_rules[0]["provider_target"]
rules_url = self.firewall_rules_url(connection, target)
marker = self.policy_marker(target_rules[0])
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:
if rule.get("audit_only"):
audit_only_rules.append(
{
"policy_id": rule.get("policy_id"),
"target": target,
"reason": "Audit mode does not enforce or write blocking Proxmox rules.",
}
)
continue
provider_rule = rule.get("provider_rule")
if not provider_rule:
return {
"applied": False,
"reason": "Resolved rule is missing provider_rule payload.",
"rule": rule,
}
create_response = await client.post(rules_url, headers=headers, data=provider_rule)
create_response.raise_for_status()
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 {
"applied": True,
"rules_written": len(applied_rules),
"rules_deleted": len(deleted_rules),
"firewall_enabled": enabled_targets,
"interfaces_enabled": enabled_interfaces,
"enforcement_status": enforcement_status,
"audit_only": audit_only_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"
+129
View File
@@ -0,0 +1,129 @@
import pytest
from app.services.providers import proxmox
from app.services.providers.base import ProviderConnection
from app.services.providers.proxmox import ProxmoxProvider
class FakeResponse:
def __init__(self, data: object) -> None:
self._data = data
def json(self) -> dict:
return {"data": self._data}
def raise_for_status(self) -> None:
return None
class FakeAsyncClient:
deleted_urls: list[str] = []
posted_payloads: list[dict] = []
put_urls: list[str] = []
put_payloads: list[dict] = []
def __init__(self, **_: object) -> None:
return None
async def __aenter__(self) -> "FakeAsyncClient":
return self
async def __aexit__(self, *_: object) -> None:
return None
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(
[
{"pos": 0, "comment": "manual rule"},
{"pos": 1, "comment": "NexaFabric policy=policy-1 version=1 rule=1 target=web"},
]
)
async def delete(self, url: str, **__: object) -> FakeResponse:
self.deleted_urls.append(url)
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:
self.posted_payloads.append(data)
return FakeResponse({"pos": 1})
@pytest.mark.asyncio
async def test_apply_rules_replaces_only_marked_nexafabric_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().apply_rules(
ProviderConnection(api_url="https://pve.example:8006", token="user@pve!token=secret", read_only=False),
[
{
"policy_id": "policy-1",
"audit_only": False,
"provider_target": {"node": "pve1", "kind": "qemu", "vmid": "100"},
"provider_rule": {
"type": "in",
"action": "ACCEPT",
"enable": 1,
"proto": "tcp",
"dport": "443",
"comment": "NexaFabric policy=policy-1 version=2 rule=1 target=web",
},
}
],
)
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.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 == [
{
"type": "in",
"action": "ACCEPT",
"enable": 1,
"proto": "tcp",
"dport": "443",
"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 == []
+7 -3
View File
@@ -11,14 +11,16 @@ import { Ipam } from "./pages/Ipam";
import { ListPage } from "./pages/ListPage"; import { ListPage } from "./pages/ListPage";
import { Login } from "./pages/Login"; import { Login } from "./pages/Login";
import { Networks } from "./pages/Networks"; import { Networks } from "./pages/Networks";
import { Nodes } from "./pages/Nodes";
import { Policies } from "./pages/Policies"; 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();
@@ -46,8 +48,10 @@ function AppRoutes() {
<Route element={<Layout />}> <Route element={<Layout />}>
<Route index element={<Dashboard />} /> <Route index element={<Dashboard />} />
<Route path="clusters" element={<Clusters />} /> <Route path="clusters" element={<Clusters />} />
<Route path="nodes" element={<ListPage title="Nodes" subtitle="Cluster nodes, capacity, and health." path="/nodes" columns={[{ key: "name", label: "Name" }, { key: "status", label: "Status" }, { key: "cpu_count", label: "CPU" }, { key: "memory_mb", label: "Memory MB" }]} />} /> <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 />} />
@@ -59,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>
); );
+134 -5
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 = {
@@ -22,6 +33,7 @@ export type Cluster = {
api_url: string; api_url: string;
provider: string; provider: string;
mode: string; mode: string;
verify_tls: boolean;
last_sync_status: string | null; last_sync_status: string | null;
}; };
@@ -56,14 +68,19 @@ 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;
}; };
export type IpAddress = { export type IpAddress = {
id: string; id: string;
subnet_id: string; subnet_id: string;
subnet_cidr: string | null;
address: string; address: string;
status: string; status: string;
workload_id: string | null;
workload_name: string | null;
workload_external_id: string | null;
note: string | null; note: string | null;
}; };
@@ -82,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 = {
@@ -100,12 +126,14 @@ export type SecurityRule = {
export type Policy = { export type Policy = {
id: string; id: string;
project_id: string | null;
name: string; name: string;
version: number; version: number;
enabled: boolean; enabled: boolean;
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 = {
@@ -120,9 +148,49 @@ export type Workload = {
tags: string[]; tags: string[];
}; };
export type NodeAgent = {
node_id: string;
status: string;
version: string | null;
last_seen_at: string | null;
install_count: number;
last_payload: Record<string, unknown> | null;
};
export type Node = {
id: string;
cluster_id: string;
name: string;
status: string;
cpu_count: number;
memory_mb: number;
agent: NodeAgent | null;
};
export type AgentInstallInfo = {
node_id: string;
install_url: 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[];
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[];
@@ -148,11 +216,41 @@ export function token() {
return localStorage.getItem("nexafabric.token"); return localStorage.getItem("nexafabric.token");
} }
export function setToken(value: string) { export function refreshToken() {
localStorage.setItem("nexafabric.token", value); return localStorage.getItem("nexafabric.refreshToken");
} }
export async function api<T>(path: string, init: RequestInit = {}): Promise<T> { export function setTokens(accessToken: string, nextRefreshToken: string) {
localStorage.setItem("nexafabric.token", accessToken);
localStorage.setItem("nexafabric.refreshToken", nextRefreshToken);
}
export function clearTokens() {
localStorage.removeItem("nexafabric.token");
localStorage.removeItem("nexafabric.refreshToken");
}
async function refreshAccessToken() {
const currentRefreshToken = refreshToken();
if (!currentRefreshToken) {
clearTokens();
return false;
}
const response = await fetch(`${API_BASE_URL}/auth/refresh`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ refresh_token: currentRefreshToken }),
});
if (!response.ok) {
clearTokens();
return false;
}
const data = (await response.json()) as { access_token: string; refresh_token: string };
setTokens(data.access_token, data.refresh_token);
return true;
}
async function request<T>(path: string, init: RequestInit, retry: boolean): Promise<T> {
const response = await fetch(`${API_BASE_URL}${path}`, { const response = await fetch(`${API_BASE_URL}${path}`, {
...init, ...init,
headers: { headers: {
@@ -161,12 +259,23 @@ export async function api<T>(path: string, init: RequestInit = {}): Promise<T> {
...init.headers, ...init.headers,
}, },
}); });
if (response.status === 401 && retry && (await refreshAccessToken())) {
return request<T>(path, init, false);
}
if (response.status === 401) {
clearTokens();
window.dispatchEvent(new Event("nexafabric.authExpired"));
}
if (!response.ok) { if (!response.ok) {
throw new Error(await response.text()); throw new Error(await response.text());
} }
return response.json() as Promise<T>; return response.json() as Promise<T>;
} }
export async function api<T>(path: string, init: RequestInit = {}): Promise<T> {
return request<T>(path, init, true);
}
export async function publicApi<T>(path: string, init: RequestInit = {}): Promise<T> { export async function publicApi<T>(path: string, init: RequestInit = {}): Promise<T> {
const response = await fetch(`${API_BASE_URL}${path}`, { const response = await fetch(`${API_BASE_URL}${path}`, {
...init, ...init,
@@ -181,11 +290,31 @@ export async function publicApi<T>(path: string, init: RequestInit = {}): Promis
return response.json() as Promise<T>; return response.json() as Promise<T>;
} }
export async function authorizedFetch(path: string, init: RequestInit = {}) {
const makeRequest = () =>
fetch(`${API_BASE_URL}${path}`, {
...init,
headers: {
...(token() ? { Authorization: `Bearer ${token()}` } : {}),
...init.headers,
},
});
let response = await makeRequest();
if (response.status === 401 && (await refreshAccessToken())) {
response = await makeRequest();
}
if (response.status === 401) {
clearTokens();
window.dispatchEvent(new Event("nexafabric.authExpired"));
}
return response;
}
export async function login(email: string, password: string) { export async function login(email: string, password: string) {
const data = await api<{ access_token: string }>("/auth/login", { const data = await publicApi<{ access_token: string; refresh_token: string }>("/auth/login", {
method: "POST", method: "POST",
body: JSON.stringify({ email, password }), body: JSON.stringify({ email, password }),
}); });
setToken(data.access_token); setTokens(data.access_token, data.refresh_token);
return data; return data;
} }
+1 -1
View File
@@ -18,4 +18,4 @@ export const inputClass = "h-10 w-full rounded-md border border-border bg-transp
export const selectClass = inputClass; export const selectClass = inputClass;
export const buttonClass = "inline-flex h-10 items-center justify-center gap-2 rounded-md bg-accent px-4 text-sm font-medium text-white disabled:opacity-50"; export const buttonClass = "inline-flex h-10 items-center justify-center gap-2 rounded-md bg-accent px-4 text-sm font-medium text-white disabled:opacity-50";
export const secondaryButtonClass = "inline-flex h-10 items-center justify-center gap-2 rounded-md border border-border px-4 text-sm hover:bg-slate-100 dark:hover:bg-slate-800"; export const secondaryButtonClass = "inline-flex h-10 items-center justify-center gap-2 rounded-md border border-border px-4 text-sm hover:bg-slate-100 dark:hover:bg-slate-800";
export const iconButtonClass = "inline-flex h-9 w-9 items-center justify-center rounded-md border border-border text-slate-600 hover:bg-slate-100 hover:text-slate-950 disabled:opacity-50 dark:text-slate-300 dark:hover:bg-slate-800 dark:hover:text-white";
+87 -23
View File
@@ -9,6 +9,7 @@ import {
Flame, Flame,
GitBranch, GitBranch,
LayoutDashboard, LayoutDashboard,
LogOut,
LockKeyhole, LockKeyhole,
Moon, Moon,
Network, Network,
@@ -18,74 +19,137 @@ 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() {
navigate("/login");
}
window.addEventListener("nexafabric.authExpired", handleAuthExpired);
return () => window.removeEventListener("nexafabric.authExpired", handleAuthExpired);
}, [navigate]); }, [navigate]);
return ( return (
<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>
);
}
+76 -22
View File
@@ -1,58 +1,105 @@
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 { Cable, Plus, RefreshCcw, Server } from "lucide-react"; 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, inputClass, secondaryButtonClass, 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";
export function Clusters() { const emptyClusterForm = {
const queryClient = useQueryClient();
const clusters = useQuery({ queryKey: ["clusters"], queryFn: () => api<Cluster[]>("/clusters") });
const [form, setForm] = useState({
name: "Demo Provider", name: "Demo Provider",
api_url: "https://demo.local:8006", api_url: "https://demo.local:8006",
api_token: "PVEAPIToken=demo", api_token: "PVEAPIToken=demo",
provider: "demo", provider: "demo",
mode: "read_only", mode: "read_only",
verify_tls: true, verify_tls: true,
}); };
export function Clusters() {
const queryClient = useQueryClient();
const clusters = useQuery({ queryKey: ["clusters"], queryFn: () => api<Cluster[]>("/clusters") });
const [form, setForm] = useState(emptyClusterForm);
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 [busyMessage, setBusyMessage] = useState("");
const create = useMutation({ const save = useMutation({
mutationFn: () => api<Cluster>("/clusters", { method: "POST", body: JSON.stringify(form) }), mutationFn: () => {
const body = editing && !form.api_token ? { ...form, api_token: null } : form;
return api<Cluster>(editing ? `/clusters/${editing.id}` : "/clusters", {
method: editing ? "PATCH" : "POST",
body: JSON.stringify(body),
});
},
onSuccess: () => { onSuccess: () => {
setOpen(false); setOpen(false);
setEditing(null);
queryClient.invalidateQueries({ queryKey: ["clusters"] }); queryClient.invalidateQueries({ queryKey: ["clusters"] });
}, },
}); });
const remove = useMutation({
mutationFn: (cluster: Cluster) => api(`/clusters/${cluster.id}`, { method: "DELETE" }),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ["clusters"] }),
});
async function submit(event: FormEvent) { async function submit(event: FormEvent) {
event.preventDefault(); event.preventDefault();
await create.mutateAsync(); await save.mutateAsync();
}
function addCluster() {
setEditing(null);
setForm(emptyClusterForm);
setOpen(true);
}
function editCluster(cluster: Cluster) {
setEditing(cluster);
setForm({
name: cluster.name,
api_url: cluster.api_url,
api_token: "",
provider: cluster.provider,
mode: cluster.mode,
verify_tls: cluster.verify_tls,
});
setOpen(true);
} }
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) {
if (window.confirm(`Delete cluster "${cluster.name}" and its imported inventory/IPAM data?`)) {
remove.mutate(cluster);
}
} }
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={() => setOpen(true)}><Plus size={16} /> Add Cluster</button> <button className={buttonClass} onClick={addCluster}><Plus size={16} /> Add Cluster</button>
<Modal title="Add Cluster" open={open} onClose={() => setOpen(false)}> <Modal title={editing ? "Edit Cluster" : "Add Cluster"} open={open} onClose={() => setOpen(false)}>
<form onSubmit={submit}> <form onSubmit={submit}>
<div className="mb-4 flex items-center gap-2 font-medium"><Server size={18} /> Provider connection</div> <div className="mb-4 flex items-center gap-2 font-medium"><Server size={18} /> Provider connection</div>
<div className="grid gap-3"> <div className="grid gap-3">
<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>
<Field label="API URL"><input className={inputClass} value={form.api_url} onChange={(event) => setForm({ ...form, api_url: event.target.value })} /></Field> <Field label="API URL"><input className={inputClass} value={form.api_url} onChange={(event) => setForm({ ...form, api_url: event.target.value })} /></Field>
<Field label="API Token"><input className={inputClass} value={form.api_token} onChange={(event) => setForm({ ...form, api_token: event.target.value })} /></Field> <Field label={editing ? "API Token (leave empty to keep current)" : "API Token"}><input className={inputClass} value={form.api_token} onChange={(event) => setForm({ ...form, api_token: event.target.value })} /></Field>
<Field label="Provider"> <Field label="Provider">
<select className={selectClass} value={form.provider} onChange={(event) => setForm({ ...form, provider: event.target.value })}> <select className={selectClass} value={form.provider} onChange={(event) => setForm({ ...form, provider: event.target.value })}>
<option value="demo">demo</option> <option value="demo">demo</option>
@@ -69,7 +116,7 @@ export function Clusters() {
<input type="checkbox" checked={form.verify_tls} onChange={(event) => setForm({ ...form, verify_tls: event.target.checked })} /> <input type="checkbox" checked={form.verify_tls} onChange={(event) => setForm({ ...form, verify_tls: event.target.checked })} />
Verify TLS Verify TLS
</label> </label>
<button className={buttonClass} disabled={create.isPending}>Save Cluster</button> <button className={buttonClass} disabled={save.isPending}>{editing ? "Update Cluster" : "Save Cluster"}</button>
</div> </div>
</form> </form>
</Modal> </Modal>
@@ -81,16 +128,23 @@ export function Clusters() {
{ key: "provider", label: "Provider" }, { key: "provider", label: "Provider" },
{ key: "mode", label: "Mode" }, { key: "mode", label: "Mode" },
{ key: "last_sync_status", label: "Sync" }, { key: "last_sync_status", label: "Sync" },
{
key: "actions",
label: "Actions",
render: (row) => {
const cluster = row as unknown as Cluster;
return (
<div className="flex justify-end gap-2">
<button className={iconButtonClass} title="Test connection" aria-label={`Test ${cluster.name}`} onClick={() => action(cluster, "test")}><Cable size={16} /></button>
<button className={iconButtonClass} title="Sync inventory" aria-label={`Sync ${cluster.name}`} onClick={() => action(cluster, "sync")}><RefreshCcw size={16} /></button>
<button className={iconButtonClass} title="Edit cluster" aria-label={`Edit ${cluster.name}`} onClick={() => editCluster(cluster)}><Pencil size={16} /></button>
<button className={iconButtonClass} title="Delete cluster" aria-label={`Delete ${cluster.name}`} disabled={remove.isPending} onClick={() => deleteCluster(cluster)}><Trash2 size={16} /></button>
</div>
);
},
},
]} ]}
/> />
<div className="flex flex-wrap gap-2">
{(clusters.data ?? []).map((cluster) => (
<div key={cluster.id} className="flex gap-2">
<button className={secondaryButtonClass} onClick={() => action(cluster, "test")}><Cable size={16} /> {cluster.name}</button>
<button className={secondaryButtonClass} onClick={() => action(cluster, "sync")}><RefreshCcw size={16} /> Sync</button>
</div>
))}
</div>
<pre className="min-h-24 overflow-auto rounded-md border border-border bg-panel p-3 text-xs">{result || "No cluster action result yet."}</pre> <pre className="min-h-24 overflow-auto rounded-md border border-border bg-panel p-3 text-xs">{result || "No cluster action result yet."}</pre>
</section> </section>
</div> </div>
+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>
</> </>
+21 -3
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">
@@ -49,13 +60,20 @@ export function FirewallPreview() {
<input type="checkbox" checked={dryRun} onChange={(event) => setDryRun(event.target.checked)} /> <input type="checkbox" checked={dryRun} onChange={(event) => setDryRun(event.target.checked)} />
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">
{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."
: "Live apply. NexaFabric will send the generated rules to the selected write-enabled cluster."}
</div>
<button className={secondaryButtonClass} disabled={!selectedPolicyId} onClick={() => preview.mutate(selectedPolicyId)}> <button className={secondaryButtonClass} disabled={!selectedPolicyId} onClick={() => preview.mutate(selectedPolicyId)}>
<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} />
Apply Confirmed {dryRun ? "Run Dry Apply" : auditMode ? "Audit Mode Only" : "Apply Confirmed"}
</button> </button>
</div> </div>
</section> </section>
+108 -20
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, IpAddress, Network, Subnet, token } 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,9 +66,9 @@ export function Ipam() {
} }
async function exportCsv() { async function exportCsv() {
const response = await fetch("/api/v1/ipam/export.csv", { setBusyMessage("Preparing IPAM export...");
headers: token() ? { Authorization: `Bearer ${token()}` } : {}, try {
}); 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);
const link = document.createElement("a"); const link = document.createElement("a");
@@ -59,33 +76,70 @@ 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; 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${result.errors.length ? " with errors" : ""}.`); setMessage(`Discovery imported ${result.imported} IP addresses and removed ${result.removed} container bridge entries${result.errors.length ? " with errors" : ""}.`);
await queryClient.invalidateQueries({ queryKey: ["subnets"] }); await queryClient.invalidateQueries({ queryKey: ["subnets"] });
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 })}>
@@ -95,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>
@@ -121,8 +180,37 @@ 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
<DataTable rows={(addresses.data ?? []) as unknown as Record<string, unknown>[]} columns={[{ key: "address", label: "Address" }, { key: "status", label: "Status" }, { key: "note", label: "Note" }]} /> 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
rows={(addresses.data ?? []) as unknown as Record<string, unknown>[]}
columns={[
{ key: "address", label: "Address" },
{ key: "subnet_cidr", label: "Subnet" },
{ key: "status", label: "Status" },
{ key: "workload_name", label: "VM/LXC" },
{ key: "workload_external_id", label: "VMID" },
{ key: "note", label: "Note" },
]}
/>
</section> </section>
</div> </div>
</> </>
+72 -15
View File
@@ -1,14 +1,14 @@
import { FormEvent, useState } from "react"; import { FormEvent, useState } from "react";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { Moon, ShieldCheck, Sun } from "lucide-react"; import { Activity, LockKeyhole, Moon, Network, ShieldCheck, Sun } from "lucide-react";
import { login } from "../api/client"; import { login } from "../api/client";
import { useTheme } from "../stores/theme"; import { useTheme } from "../stores/theme";
export function Login() { export function Login() {
const navigate = useNavigate(); const navigate = useNavigate();
const [email, setEmail] = useState("admin@nexafabric.local"); const [email, setEmail] = useState("");
const [password, setPassword] = useState("ChangeMe_UseEnvInstead"); const [password, setPassword] = useState("");
const [error, setError] = useState(""); const [error, setError] = useState("");
const { dark, toggle } = useTheme(); const { dark, toggle } = useTheme();
@@ -24,36 +24,93 @@ export function Login() {
} }
return ( return (
<div className="grid min-h-screen place-items-center bg-canvas px-4"> <div className="min-h-screen bg-canvas text-slate-900 dark:text-slate-100">
<button <button
className="fixed right-4 top-4 rounded-md border border-border bg-panel p-2 text-slate-700 hover:bg-slate-100 dark:text-slate-200 dark:hover:bg-slate-800" className="fixed right-4 top-4 z-10 rounded-md border border-border bg-panel p-2 text-slate-700 hover:bg-slate-100 dark:text-slate-200 dark:hover:bg-slate-800"
onClick={toggle} onClick={toggle}
type="button" type="button"
aria-label="Toggle theme" aria-label="Toggle theme"
> >
{dark ? <Sun size={18} /> : <Moon size={18} />} {dark ? <Sun size={18} /> : <Moon size={18} />}
</button> </button>
<form onSubmit={submit} className="w-full max-w-sm rounded-md border border-border bg-panel p-6 shadow-sm"> <div className="grid min-h-screen lg:grid-cols-[1.1fr_0.9fr]">
<div className="mb-6 flex items-center gap-3"> <section className="relative hidden overflow-hidden border-r border-border bg-panel lg:block">
<div className="rounded-md bg-accent p-2 text-white"> <div className="absolute inset-0 opacity-40">
<ShieldCheck size={22} /> <div className="absolute left-16 top-24 h-48 w-48 rounded-full border border-accent/40" />
<div className="absolute right-20 top-48 h-72 w-72 rounded-full border border-slate-400/30" />
<div className="absolute bottom-24 left-1/3 h-56 w-56 rounded-full border border-accent/30" />
</div>
<div className="relative flex h-full flex-col justify-between p-12">
<div className="flex items-center gap-3">
<div className="rounded-md bg-accent p-3 text-white">
<ShieldCheck size={26} />
</div> </div>
<div> <div>
<h1 className="text-xl font-semibold">NexaFabric</h1> <div className="text-2xl font-semibold">NexaFabric</div>
<p className="text-sm text-slate-500">Sign in to the control plane</p> <div className="text-sm text-slate-500 dark:text-slate-400">Network and security control plane</div>
</div> </div>
</div> </div>
<div className="max-w-xl animate-[slideUp_520ms_ease-out]">
<h1 className="mb-4 text-5xl font-semibold leading-tight tracking-normal">
Operate Proxmox networks with intent.
</h1>
<p className="text-base leading-7 text-slate-500 dark:text-slate-400">
Centralize inventory, IPAM, segmentation policy, firewall preview, and audit history without patching Proxmox.
</p>
<div className="mt-8 grid gap-3">
{[
["Inventory sync", Network],
["Policy audit mode", Activity],
["Preview before apply", LockKeyhole],
].map(([label, Icon]) => {
const LucideIcon = Icon as typeof Network;
return (
<div key={String(label)} className="flex items-center gap-3 rounded-md border border-border bg-canvas/70 p-3 text-sm">
<LucideIcon size={18} className="text-accent" />
<span>{String(label)}</span>
</div>
);
})}
</div>
</div>
<div className="text-xs text-slate-500 dark:text-slate-400">Read first. Simulate second. Apply only after confirmation.</div>
</div>
</section>
<section className="grid place-items-center px-4 py-12">
<form onSubmit={submit} className="w-full max-w-md animate-[fadeIn_420ms_ease-out] rounded-md border border-border bg-panel p-7 shadow-sm">
<div className="mb-7">
<div className="mb-4 inline-flex rounded-md bg-accent p-3 text-white lg:hidden">
<ShieldCheck size={24} />
</div>
<h1 className="text-2xl font-semibold">Sign in</h1>
<p className="mt-1 text-sm text-slate-500 dark:text-slate-400">Use the administrator account created during setup.</p>
</div>
<label className="mb-4 block text-sm"> <label className="mb-4 block text-sm">
Email Email
<input className="mt-1 w-full rounded-md border border-border bg-transparent px-3 py-2" value={email} onChange={(event) => setEmail(event.target.value)} /> <input
className="mt-1 h-11 w-full rounded-md border border-border bg-transparent px-3 outline-none focus:border-accent"
value={email}
autoComplete="username"
onChange={(event) => setEmail(event.target.value)}
/>
</label> </label>
<label className="mb-4 block text-sm"> <label className="mb-5 block text-sm">
Password Password
<input className="mt-1 w-full rounded-md border border-border bg-transparent px-3 py-2" type="password" value={password} onChange={(event) => setPassword(event.target.value)} /> <input
className="mt-1 h-11 w-full rounded-md border border-border bg-transparent px-3 outline-none focus:border-accent"
type="password"
value={password}
autoComplete="current-password"
onChange={(event) => setPassword(event.target.value)}
/>
</label> </label>
{error ? <div className="mb-4 rounded-md border border-danger px-3 py-2 text-sm text-danger">{error}</div> : null} {error ? <div className="mb-4 rounded-md border border-danger px-3 py-2 text-sm text-danger">{error}</div> : null}
<button className="h-10 w-full rounded-md bg-accent text-sm font-medium text-white">Sign In</button> <button className="h-11 w-full rounded-md bg-accent text-sm font-medium text-white disabled:opacity-50" disabled={!email || !password}>
Sign In
</button>
</form> </form>
</section>
</div>
</div> </div>
); );
} }
+229
View File
@@ -0,0 +1,229 @@
import { useMutation, useQuery } from "@tanstack/react-query";
import { Activity, Cpu, Copy, RadioTower, RefreshCcw, ScrollText } from "lucide-react";
import { useState } from "react";
import { AgentInstallInfo, api, Node, RuntimeSettings } from "../api/client";
import { DataTable } from "../components/DataTable";
import { iconButtonClass, secondaryButtonClass } from "../components/FormControls";
import { LoadingOverlay } from "../components/LoadingOverlay";
import { Modal } from "../components/Modal";
import { PageHeader } from "../components/PageHeader";
export function Nodes() {
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 [detailNode, setDetailNode] = useState<Node | null>(null);
const [copied, setCopied] = useState(false);
const installInfo = useMutation({
mutationFn: (node: Node) => api<AgentInstallInfo>(`/nodes/${node.id}/agent/install-info`),
onSuccess: (_data, node) => {
setSelectedNode(node);
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() {
if (!installInfo.data) {
return;
}
await navigator.clipboard.writeText(installInfo.data.command);
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 (
<>
<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.error ? <div className="rounded-md border border-danger p-4 text-sm text-danger">Failed to load nodes.</div> : null}
{!nodes.isLoading && !nodes.error ? (
<DataTable
rows={(nodes.data ?? []) as unknown as Record<string, unknown>[]}
columns={[
{ key: "name", label: "Name" },
{ key: "status", label: "Status" },
{ key: "cpu_count", label: "CPU" },
{ key: "memory_mb", label: "Memory MB" },
{
key: "agent",
label: "Agent",
render: (row) => {
const node = row as unknown as Node;
return node.agent ? `${node.agent.status}${node.agent.version ? ` · ${node.agent.version}` : ""}` : "not_installed";
},
},
{
key: "actions",
label: "Actions",
render: (row) => {
const node = row as unknown as Node;
return (
<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
className={iconButtonClass}
title="Install node agent"
aria-label={`Install agent on ${node.name}`}
onClick={() => installInfo.mutate(node)}
>
<RadioTower size={16} />
</button>
</div>
);
},
},
]}
/>
) : null}
<Modal title="Install Node Agent" open={Boolean(selectedNode)} onClose={() => setSelectedNode(null)}>
<div className="space-y-4">
<div className="flex items-center gap-2 font-medium">
<Cpu size={18} />
{selectedNode?.name}
</div>
<div className="rounded-md border border-border bg-canvas p-3 text-xs text-slate-500 dark:text-slate-400">
Run this command as root on the Proxmox node. It installs the agent under <code>/opt/nexafabric-agent</code> and starts a systemd service.
</div>
<pre className="max-h-48 overflow-auto rounded-md border border-border bg-canvas p-3 text-xs">
{installInfo.data?.command ?? "Generating installer..."}
</pre>
<div className="grid gap-2 text-xs">
<span className="text-slate-500 dark:text-slate-400">Installer link</span>
<code className="break-all rounded-md border border-border bg-canvas p-3">{installInfo.data?.install_url ?? ""}</code>
</div>
<button className={secondaryButtonClass} disabled={!installInfo.data} onClick={copyCommand}>
<Copy size={16} />
{copied ? "Copied" : "Copy Command"}
</button>
</div>
</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>
</>
);
}
+206 -32
View File
@@ -1,21 +1,101 @@
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 { GitBranch, Play, Plus } 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, inputClass, secondaryButtonClass, 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";
export function Policies() { function policyValue(policy: Policy, key: string) {
const queryClient = useQueryClient(); return String(policy.definition?.[key] ?? "");
const policies = useQuery({ queryKey: ["policies"], queryFn: () => api<Policy[]>("/policies") }); }
const projects = useQuery({ queryKey: ["projects"], queryFn: () => api<Project[]>("/projects") });
const services = useQuery({ queryKey: ["service-catalog"], queryFn: () => api<ServiceCatalogItem[]>("/service-catalog") }); function endpointLabel(value: string, workloads: Workload[]) {
const [preview, setPreview] = useState(""); if (value.startsWith("workload:")) {
const [open, setOpen] = useState(false); const workloadId = value.replace("workload:", "");
const [form, setForm] = useState({ 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) {
const service = policy.definition?.service;
if (!service || typeof service !== "object") {
return "";
}
const value = service as { protocol?: unknown; ports?: unknown };
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 = {
project_id: "", project_id: "",
name: "Web to DB", name: "Web to DB",
source: "sg:Web Tier", source: "sg:Web Tier",
@@ -28,13 +108,25 @@ export function Policies() {
enforcement_mode: "enforced", enforcement_mode: "enforced",
logging: true, logging: true,
description: "Allow application database traffic", description: "Allow application database traffic",
}); };
const create = useMutation({ export function Policies() {
const queryClient = useQueryClient();
const policies = useQuery({ queryKey: ["policies"], queryFn: () => api<Policy[]>("/policies") });
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 [preview, setPreview] = useState("");
const [open, setOpen] = useState(false);
const [editing, setEditing] = useState<Policy | null>(null);
const [form, setForm] = useState(defaultPolicyForm);
const [busyMessage, setBusyMessage] = useState("");
const save = useMutation({
mutationFn: () => { mutationFn: () => {
const service = services.data?.find((item) => item.id === form.service_id); const service = services.data?.find((item) => item.id === form.service_id);
return api<Policy>("/policies", { return api<Policy>(editing ? `/policies/${editing.id}` : "/policies", {
method: "POST", method: editing ? "PATCH" : "POST",
body: JSON.stringify({ body: JSON.stringify({
project_id: form.project_id || null, project_id: form.project_id || null,
name: form.name, name: form.name,
@@ -54,44 +146,108 @@ export function Policies() {
}, },
onSuccess: () => { onSuccess: () => {
setOpen(false); setOpen(false);
setEditing(null);
queryClient.invalidateQueries({ queryKey: ["policies"] }); queryClient.invalidateQueries({ queryKey: ["policies"] });
}, },
}); });
const remove = useMutation({
mutationFn: (policy: Policy) => api(`/policies/${policy.id}`, { method: "DELETE" }),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ["policies"] }),
});
async function submit(event: FormEvent) { async function submit(event: FormEvent) {
event.preventDefault(); event.preventDefault();
await create.mutateAsync(); await save.mutateAsync();
} }
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() {
setEditing(null);
setForm(defaultPolicyForm);
setOpen(true);
}
function editPolicy(policy: Policy) {
const service = policy.definition?.service as { protocol?: string; ports?: string } | undefined;
setEditing(policy);
setForm({
project_id: policy.project_id ?? "",
name: policy.name,
source: policyValue(policy, "source") || "any",
destination: policyValue(policy, "destination") || "any",
service_id: "",
protocol: service?.protocol ?? "tcp",
ports: service?.ports ?? "",
action: policyValue(policy, "action") || "allow",
direction: policyValue(policy, "direction") || "ingress",
enforcement_mode: policy.enforcement_mode || "enforced",
logging: Boolean(policy.definition?.logging),
description: policyValue(policy, "description"),
});
setOpen(true);
}
function chooseService(serviceId: string) {
const service = services.data?.find((item) => item.id === serviceId);
setForm({
...form,
service_id: serviceId,
protocol: service?.protocol ?? form.protocol,
ports: service?.ports ?? form.ports,
});
}
function deletePolicy(policy: Policy) {
if (window.confirm(`Delete policy "${policy.name}"?`)) {
remove.mutate(policy);
}
} }
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={() => setOpen(true)}><Plus size={16} /> Add Policy</button> <button className={buttonClass} onClick={addPolicy}><Plus size={16} /> Add Policy</button>
<Modal title="Add Policy" open={open} onClose={() => setOpen(false)}> <Modal title={editing ? "Edit Policy" : "Add Policy"} open={open} onClose={() => setOpen(false)}>
<form onSubmit={submit}> <form onSubmit={submit}>
<div className="mb-4 flex items-center gap-2 font-medium"><GitBranch size={18} /> Add Policy</div> <div className="mb-4 flex items-center gap-2 font-medium"><GitBranch size={18} /> {editing ? "Edit Policy" : "Add Policy"}</div>
<div className="grid gap-3"> <div className="grid gap-3">
<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) => setForm({ ...form, service_id: 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">
<Field label="Protocol"><input className={inputClass} value={form.protocol} onChange={(event) => setForm({ ...form, protocol: event.target.value })} /></Field> <Field label="Protocol">
<select className={selectClass} value={form.protocol} onChange={(event) => setForm({ ...form, protocol: event.target.value })}>
<option value="tcp">tcp</option>
<option value="udp">udp</option>
<option value="tcp/udp">tcp & udp</option>
</select>
</Field>
<Field label="Ports"><input className={inputClass} value={form.ports} onChange={(event) => setForm({ ...form, ports: event.target.value })} /></Field> <Field label="Ports"><input className={inputClass} value={form.ports} onChange={(event) => setForm({ ...form, ports: event.target.value })} /></Field>
</div> </div>
<div className="grid grid-cols-2 gap-3"> <div className="grid grid-cols-2 gap-3">
@@ -105,20 +261,38 @@ export function Policies() {
</select> </select>
</Field> </Field>
<Field label="Description"><input className={inputClass} value={form.description} onChange={(event) => setForm({ ...form, description: event.target.value })} /></Field> <Field label="Description"><input className={inputClass} value={form.description} onChange={(event) => setForm({ ...form, description: event.target.value })} /></Field>
<button className={buttonClass}><Plus size={16} /> Save Policy</button> <button className={buttonClass}><Plus size={16} /> {editing ? "Update Policy" : "Save Policy"}</button>
</div> </div>
</form> </form>
</Modal> </Modal>
<section className="space-y-4"> <section className="space-y-4">
<DataTable rows={(policies.data ?? []) as unknown as Record<string, unknown>[]} columns={[{ key: "name", label: "Policy" }, { key: "version", label: "Version" }, { key: "enabled", label: "Enabled" }]} /> <DataTable
<div className="flex flex-wrap gap-2"> rows={(policies.data ?? []) as unknown as Record<string, unknown>[]}
{(policies.data ?? []).map((policy) => ( columns={[
<div key={policy.id} className="flex gap-2"> { key: "name", label: "Policy" },
<button className={secondaryButtonClass} onClick={() => compile(policy)}><Play size={16} /> Compile {policy.name}</button> { key: "source", label: "Source", render: (row) => policyEndpoint(row as unknown as Policy, "source", workloads.data ?? []) },
<button className={secondaryButtonClass} onClick={() => firewallPreview(policy)}><Play size={16} /> Preview</button> { key: "destination", label: "Destination", render: (row) => policyEndpoint(row as unknown as Policy, "destination", workloads.data ?? []) },
</div> { key: "service", label: "Service", render: (row) => policyService(row as unknown as Policy) },
))} { key: "enforcement_mode", label: "Mode" },
{ key: "deployment_status", label: "Status", render: (row) => <PolicyDeploymentStatus policy={row as unknown as Policy} /> },
{ key: "version", label: "Version" },
{
key: "actions",
label: "Actions",
render: (row) => {
const policy = row as unknown as Policy;
return (
<div className="flex justify-end gap-2">
<button className={iconButtonClass} title="Compile policy" aria-label={`Compile ${policy.name}`} onClick={() => compile(policy)}><Play size={16} /></button>
<button className={iconButtonClass} title="Preview policy" aria-label={`Preview ${policy.name}`} onClick={() => firewallPreview(policy)}><Eye size={16} /></button>
<button className={iconButtonClass} title="Edit policy" aria-label={`Edit ${policy.name}`} onClick={() => editPolicy(policy)}><Pencil size={16} /></button>
<button className={iconButtonClass} title="Delete policy" aria-label={`Delete ${policy.name}`} disabled={remove.isPending} onClick={() => deletePolicy(policy)}><Trash2 size={16} /></button>
</div> </div>
);
},
},
]}
/>
<pre className="min-h-40 overflow-auto rounded-md border border-border bg-panel p-3 text-xs">{preview || "No policy output yet."}</pre> <pre className="min-h-40 overflow-auto rounded-md border border-border bg-panel p-3 text-xs">{preview || "No policy output yet."}</pre>
</section> </section>
</div> </div>
+79 -14
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") });
@@ -19,7 +30,7 @@ export function PolicyDesigner() {
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<Record<string, unknown> | null>(null); const [preview, setPreview] = useState<Record<string, unknown> | null>(null);
const [form, setForm] = useState({ const [form, setForm] = useState({
name: "Designed Policy", name: "",
source: "any", source: "any",
destination: "any", destination: "any",
service_id: "", service_id: "",
@@ -35,22 +46,26 @@ 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]);
function payload() { function payload() {
const service = services.data?.find((item) => item.id === form.service_id);
return { return {
project_id: null, project_id: null,
name: form.name, name: form.name.trim(),
enabled: true, enabled: true,
definition: { definition: {
source: form.source, source: form.source,
destination: form.destination, destination: form.destination,
service: { protocol: service?.protocol ?? form.protocol, ports: service?.ports ?? form.ports }, service: { protocol: form.protocol, ports: form.ports },
action: form.action, action: form.action,
direction: form.direction, direction: form.direction,
enforcement_mode: form.enforcement_mode, enforcement_mode: form.enforcement_mode,
@@ -89,24 +104,68 @@ export function PolicyDesigner() {
}); });
} }
function chooseService(serviceId: string) {
const service = services.data?.find((item) => item.id === serviceId);
setForm({
...form,
service_id: serviceId,
protocol: service?.protocol ?? form.protocol,
ports: service?.ports ?? form.ports,
});
}
return ( return (
<> <>
<PageHeader title="Policy Designer" subtitle="Build tenant-aware microsegmentation policies and inspect their intended impact." /> <PageHeader title="Policy Designer" subtitle="Build tenant-aware microsegmentation policies and inspect their intended impact." />
<div className="grid gap-4 lg:grid-cols-[1fr_420px]"> <div className="grid gap-4 lg:grid-cols-[1fr_420px]">
<form onSubmit={submit} className="rounded-md border border-border bg-panel p-4"> <form onSubmit={submit} className="rounded-md border border-border bg-panel p-4">
<div className="grid gap-4 md:grid-cols-2"> <div className="grid gap-4 md:grid-cols-2">
<Field label="Policy Name">
<input
className={inputClass}
value={form.name}
onChange={(event) => setForm({ ...form, name: event.target.value })}
placeholder="DNS from VMs to resolver"
required
/>
</Field>
<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) => setForm({ ...form, service_id: event.target.value })}> <select className={selectClass} value={form.service_id} onChange={(event) => chooseService(event.target.value)}>
<option value="">Custom</option> <option value="">Custom</option>
{(services.data ?? []).map((service) => <option key={service.id} value={service.id}>{service.name} {service.ports}</option>)} {(services.data ?? []).map((service) => <option key={service.id} value={service.id}>{service.name} {service.ports}</option>)}
</select> </select>
@@ -118,7 +177,13 @@ export function PolicyDesigner() {
<option>reject</option> <option>reject</option>
</select> </select>
</Field> </Field>
<Field label="Protocol"><input className={inputClass} value={form.protocol} onChange={(event) => setForm({ ...form, protocol: event.target.value })} /></Field> <Field label="Protocol">
<select className={selectClass} value={form.protocol} onChange={(event) => setForm({ ...form, protocol: event.target.value })}>
<option value="tcp">tcp</option>
<option value="udp">udp</option>
<option value="tcp/udp">tcp & udp</option>
</select>
</Field>
<Field label="Ports"><input className={inputClass} value={form.ports} onChange={(event) => setForm({ ...form, ports: event.target.value })} /></Field> <Field label="Ports"><input className={inputClass} value={form.ports} onChange={(event) => setForm({ ...form, ports: event.target.value })} /></Field>
<Field label="Direction"> <Field label="Direction">
<select className={selectClass} value={form.direction} onChange={(event) => setForm({ ...form, direction: event.target.value })}> <select className={selectClass} value={form.direction} onChange={(event) => setForm({ ...form, direction: event.target.value })}>
@@ -145,7 +210,7 @@ export function PolicyDesigner() {
<Wand2 size={18} /> <Wand2 size={18} />
Dry Run Dry Run
</button> </button>
<button className={buttonClass}> <button className={buttonClass} disabled={!form.name.trim() || save.isPending}>
<Save size={18} /> <Save size={18} />
Save Save
</button> </button>
+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>
</>
);
}
+735 -33
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 } 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,43 +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">
<div className="flex items-center gap-2 font-medium"><Activity size={18} /> Workload Summary</div>
{selected ? (
<button className={`${secondaryButtonClass} h-9 px-3`} onClick={() => navigate(`/workloads/${selected}`)}>
<ArrowRight size={16} />
Details
</button>
) : null}
</div>
{insight.data ? ( {insight.data ? (
<div className="space-y-4 text-sm"> <div className="space-y-3 text-sm">
<div> <header>
<div className="text-lg font-semibold">{insight.data.workload.name}</div> <div className="font-semibold">{insight.data.workload.name}</div>
<div className="text-slate-500">{insight.data.workload.kind} · {insight.data.workload.status} · decision {insight.data.effective_decision}</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>
<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> </div>
<section> <section>
<div className="mb-2 font-medium">Traffic</div> <div className="mb-1.5 flex items-center gap-2 text-sm font-medium"><Network size={15} /> Assigned IPs</div>
<div className="space-y-2"> {insight.data.assigned_ips.length ? (
{insight.data.traffic.length ? insight.data.traffic.map((flow, index) => ( <div className="flex flex-wrap gap-2">
<div key={index} className="rounded-md border border-border p-3"> {insight.data.assigned_ips.slice(0, 4).map((ip) => (
<div>{String(flow.source)} {String(flow.destination)}</div> <div key={ip.id} className="rounded-md border border-border px-2 py-1 text-xs">
<div className="text-xs text-slate-500">{String(flow.protocol)}:{String(flow.port)} · {String(flow.bytes)} bytes · {String(flow.decision)}</div> <span className="font-medium">{ip.address}</span>
{Array.isArray(flow.ip_addresses) ? <div className="mt-1 text-xs text-slate-500">IPs: {flow.ip_addresses.join(", ")}</div> : null} <span className="ml-2 text-slate-500">{ip.subnet_cidr ?? "unknown subnet"}</span>
</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>
<div className="mb-2 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">
<div>{policy.name}</div>
<div className="text-xs text-slate-500">v{policy.version} · {policy.enforcement_mode}</div>
</div> </div>
))} ))}
</div> </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>
{insight.data.audit_mode_notes.length ? (
<section> <section>
<div className="mb-2 font-medium">Audit Mode</div> <div className="mb-1.5 text-sm font-medium">Top Traffic</div>
{insight.data.audit_mode_notes.map((note) => <div key={note} className="rounded-md border border-border p-3 text-xs">{note}</div>)} <TrafficBars traffic={traffic} />
</section>
<section>
<div className="mb-1.5 text-sm font-medium">Top Flows</div>
<CompactFlowList traffic={traffic} />
</section>
<section>
<div className="mb-1.5 flex items-center gap-2 text-sm font-medium"><Shield size={15} /> Active Rules</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>
@@ -74,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>
</>
);
}
+15 -3
View File
@@ -1,12 +1,25 @@
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 $host; proxy_set_header Host $http_host;
proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Host $http_host;
proxy_set_header X-Forwarded-Proto $scheme;
}
location /agents/ {
client_max_body_size 16m;
proxy_pass http://api:8000/api/v1/agents/;
proxy_set_header Host $http_host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Host $http_host;
proxy_set_header X-Forwarded-Proto $scheme; proxy_set_header X-Forwarded-Proto $scheme;
} }
@@ -16,7 +29,6 @@ server {
location / { location / {
proxy_pass http://frontend:80; proxy_pass http://frontend:80;
proxy_set_header Host $host; proxy_set_header Host $http_host;
} }
} }