Files
Nexus-Guard-Suite/AGENTS.md
T
datadunia 155536d2c0
NexusGuard CI / server-core-test (push) Failing after 4s
NexusGuard CI / server-core-build (push) Has been skipped
NexusGuard CI / device-agent-test (push) Failing after 3s
NexusGuard CI / device-agent-cross-build (amd64, linux) (push) Has been skipped
NexusGuard CI / device-agent-cross-build (amd64, windows) (push) Has been skipped
NexusGuard CI / device-agent-cross-build (arm64, linux) (push) Has been skipped
NexusGuard CI / dashboard-test (push) Failing after 5s
NexusGuard CI / dashboard-dist (push) Has been skipped
docs: add INPUT vs FORWARD chain architecture to AGENTS.md
2026-06-04 20:02:36 +07:00

9.6 KiB

PROJECT KNOWLEDGE BASE

Generated: 2026-05-22 Commit: 92051d5 Branch: main

OVERVIEW

NexusGuard SD-WAN Suite — Enterprise Zero-Trust SD-WAN with WireGuard tunneling, centralized IPAM, and real-time nftables network isolation. Monorepo with 3 git submodules: Go backend (Gin), Vue 3 dashboard, Go device agent.

STRUCTURE

./
├── apps/
│   ├── server-core/    # Go/Gin API backend (submodule)
│   ├── dashboard-ui/   # Vue 3 + Vite frontend (submodule)
│   └── device-agent/   # Go stealth daemon (submodule)
├── docker-compose.yml  # Production orchestration
├── docker-compose.dev.yml  # Dev (air hot-reload)
├── Makefile            # up/down/dev/migrate/reset-db
├── setup.sh            # First-run: generate .env + random keys
├── update.sh           # Docker update: pull/build/migrate
├── nexusguard-install.sh   # Native install (systemd + nginx)
├── nexusguard-uninstall.sh # Native uninstall
├── .env.example        # DB/JWT/SALT/VITE config template
├── .gitmodules         # 3 submodules → git.datadunia.com
└── .opencode/          # IDE agent config (tooling, not project code)

CRITICAL: apps/* are git submodules — clone with --recurse-submodules.

WHERE TO LOOK

Task Location Notes
API handlers apps/server-core/api/ 17 files: auth, devices, peers, rules, share, provisioning, servers, wg
Backend core apps/server-core/internal/ auth, config, firewall, heartbeat, ipam, models, wgmanager
Dev migration apps/server-core/main_dev.go GORM AutoMigrate (build tag dev)
Firewall rules apps/server-core/internal/firewall/ nftables Linux rules
Dashboard views apps/dashboard-ui/src/views/ Vue SFC pages
Dashboard API client apps/dashboard-ui/src/api/ Axios API modules
Dashboard stores apps/dashboard-ui/src/stores/ Pinia state stores
Agent client apps/device-agent/internal/client/ Provisioning + heartbeat
Agent tunnel apps/device-agent/internal/tunnel/ Memory-injected WireGuard
Shared crypto apps/*/shared/crypto/encryptor.go AES-256-GCM (duplicated identical)
CI workflows apps/*/.gitea/workflows/build.yml Gitea Actions per submodule
Build config apps/dashboard-ui/vite.config.ts Vite 8 + Vue + TailwindCSS v4
Source of truth apps/server-core/docs/ API_SPEC, KEY_ROTATION, PEER_DISCOVERY
Plan guardrails .sisyphus/plans/ Anti-patterns, "Must NOT do" rules

CODE MAP

Symbol Type Location Role
main() (server-core) func apps/server-core/main.go Entry: CLI flags + Gin init
main() (device-agent) func apps/device-agent/main.go Entry: agent daemon lifecycle
config.Load() func apps/server-core/internal/config/ Env-based config loader
config.LoadConfFile() func apps/server-core/internal/config/config_loader.go Config file parser (.env / nexusguard.conf)
auth.Init() func apps/server-core/internal/auth/ JWT sign/verify init
firewall.InitNetwork() func apps/server-core/internal/firewall/ nftables table/set creation
ipam.AllocateIP() func apps/server-core/internal/ipam/ IP pool allocation from CIDR
wgmanager.SetConfig() func apps/server-core/internal/wgmanager/ WireGuard config push
models.AutoMigrate() func apps/server-core/internal/models/ GORM schema migration
encrypt() / decrypt() func apps/*/shared/crypto/encryptor.go AES-256-GCM (identical)

CONVENTIONS

  • Go: Standard layout (main.go in root, internal/, api/)
  • Vue 3: Composition API + <script setup lang="ts"> throughout
  • State management: Pinia stores in src/stores/
  • API client: Axios-based services in src/api/ + base src/services/
  • Styling: TailwindCSS v4 (no PostCSS — @tailwindcss/vite plugin)
  • DB: GORM ORM, PostgreSQL, AutoMigrate in dev / -migrate-prod in prod
  • Naming: UPPER_SNAKE_CASE env vars, camelCase Go vars, PascalCase exported Go
  • Auth: JWT, admin-only enforced by middleware pattern
  • Build tags: //go:build dev for AutoMigrate
  • Capabilities: Server containers need NET_ADMIN + NET_RAW
  • Ports: API 8080, Dashboard 80 (Nginx), Postgres 5432, Redis 6379
  • Config: Docker uses .env, native uses /etc/nexusguard/nexusguard.conf

ANTI-PATTERNS (THIS PROJECT)

  • NEVER nft flush table — only atomic add/remove
  • NEVER log plaintext or encryption keys
  • NEVER reopen completed phases/commits — fix forward only
  • NEVER rebuild shared/crypto/encryptor.go — copy identical file
  • NEVER commit build artifacts (binaries, dist/)
  • NEVER force push
  • NEVER create cross-phase workarounds

WIREGUARD AllowedIPs — SERVER vs CLIENT (CRITICAL)

WireGuard AllowedIPs has two different meanings depending on context. Mixing them causes only 1 peer to work.

Rule

Context Where Value Purpose
Server-side (kernel wg set) SyncLocalPeers()peer_sync.go InternalIP/32 per peer WireGuard routing table — each IP must belong to exactly ONE peer
Client-side (.conf file) getDeviceConfig()peers.go EndpointAllowedIPs from DB (e.g. /24, 0.0.0.0/0) Tells client which traffic routes through VPN tunnel
Firewall (nftables) AddForwardRule()nftables_linux.go EndpointAllowedIPs from DB Controls which IPs peer can reach via forwarding

Why

WireGuard uses AllowedIPs as an internal routing table. When two peers share the same /24, WireGuard assigns the AllowedIPs to the last peer configured only — the first peer gets (none). This is not a bug; it's how WireGuard routing works.

Anti-pattern

// WRONG — uses client config for server-side routing
if d.EndpointAllowedIPs != "" {
    allowedIPs = d.EndpointAllowedIPs  // "10.172.21.0/24" ← SAME for both peers!
}

Correct pattern

// CORRECT — server-side always /32 per peer
allowedIPs := *d.InternalIP + "/32"   // "10.172.21.2/32" for gogo2
if d.AllowInternet {
    allowedIPs = "0.0.0.0/0"
}
// Do NOT override with EndpointAllowedIPs here

Database field: endpoint_allowed_ips

  • Used for client config and firewall rules
  • NOT used for server-side WireGuard kernel config
  • Example: 10.172.21.0/24 allows peer to reach full subnet via firewall + routes full subnet through VPN on client

FIREWALL CHAINS — INPUT vs FORWARD (CRITICAL)

nftables traffic enters different chains depending on destination:

  • Traffic TO server's own IP (e.g. 10.172.21.1) → INPUT chain
  • Traffic THROUGH server (peer-to-peer, e.g. 10.172.21.2 → 10.172.21.3) → FORWARD chain

Rule routing in code

Destination Chain Method
Server's own interface_address INPUT AddInputFirewallRule()
Other peer IPs / subnets FORWARD AddFirewallRule()

Detection logic (syncRuleToFirewall)

serverIP = strings.Split(localServer.InterfaceAddress, "/")[0]
destBase = strings.Split(destCIDR, "/")[0]
if destBase == serverIP {
    fw.AddInputFirewallRule(...)  // → INPUT chain
} else {
    fw.AddFirewallRule(...)       // → FORWARD chain
}

Anti-pattern

// WRONG — all rules go to FORWARD, server IP rules are dead
fw.AddFirewallRule(...)  // for dest=10.172.21.1 → enters FORWARD chain → never matched

Correct pattern

// CORRECT — detect server IP, route to correct chain
if destBase == serverIP {
    fw.AddInputFirewallRule(...)  // → INPUT chain (matches traffic TO server)
} else {
    fw.AddFirewallRule(...)       // → FORWARD chain (matches traffic THROUGH server)
}

Why

Traffic to server's own IP is processed by INPUT chain, not FORWARD chain. Placing rules in FORWARD chain for server-bound traffic makes them dead rules that never match.

UNIQUE STYLES

  • Zero-Attack Surface: /auth/register locked; admin via -create-admin CLI only
  • Stealth Agent: No /etc/wireguard/ — config in memory only
  • Duplicate crypto: encryptor.go copy-pasted in server-core + device-agent (known debt, do not deduplicate)
  • nftables default Accept: Contradicts Zero-Trust "Default DROP" — intentional gap

COMMANDS

# Docker
make up              # Start all services
make down            # Stop all services
make dev             # Start with hot-reload (air)
make logs            # Tail all logs
make migrate         # Run DB migration (requires local Go)
make reset-db        # Nuke PG volume + recreate + migrate

bash update.sh       # Smart update (rebuild only if changes)
bash update.sh --force  # Force rebuild

# Native Install
sudo bash nexusguard-install.sh
sudo bash nexusguard-uninstall.sh
sudo bash nexusguard-uninstall.sh --remove-db

# Development
cd apps/server-core && go run -tags dev .
cd apps/dashboard-ui && npm run dev
cd apps/device-agent && go run .

# Admin
go run -tags dev ./apps/server-core -create-admin -user admin -pass "..."
sudo /usr/local/bin/nexusguard-server -create-admin -user admin -pass "..."

NOTES

  • Submodules → private Gitea (git.datadunia.com); CI via Gitea Actions per submodule
  • Go versions diverge: server-core 1.25.7, device-agent 1.25.1
  • No root linter configs (.golangci.yml, .eslintrc, .editorconfig)
  • Shell scripts use deprecated docker-compose v1, Makefile uses docker compose v2
  • Root has stale artifacts: connect_remote.txt, temp_section*.txt
  • package.json name is "temp-ui" (stale scaffold remnant)