38 KiB
NexusGuard SD-WAN — Full System Build Plan
Overview
Build the complete NexusGuard SD-WAN system across 3 submodules (server-core, dashboard-ui, device-agent). The system provides Zero-Trust network isolation via nftables, stealth WireGuard tunneling via wireguard-go, hardware-bound device identity, and a Vue 3 management dashboard.
Current State
- Main repo: Initialized with 3 git submodules pointing to
git.datadunia.com - server-core: Empty (README only)
- dashboard-ui: Empty (README only)
- device-agent: Empty (README only)
- Environment: Go 1.25.1, Node 24.7.0, Vite 8.0.13, Git 2.50.1. Docker NOT installed.
- Remote CI/CD: Gitea Actions runner already configured
Architecture Decisions (Binding)
Stack Per Repo
| Repo | Language | Framework | DB/Cache | Key Deps |
|---|---|---|---|---|
| server-core | Go 1.25 | Gin, GORM | PostgreSQL, Redis | google/nftables, crypto/aes, golang-jwt |
| dashboard-ui | TypeScript | Vue 3, Vite, Pinia, Tailwind | — | axios, vue-router |
| device-agent | Go 1.25 | Static binary | None (memory only) | golang.zx2c4.com/wireguard/device, crypto/aes |
Security Decisions
- Zero-Trust: nftables default DROP policy. Per-user sets for isolation.
- Stealth Agent: wireguard-go via
device.IpcSet()— no config files on disk. Ever. - Hardware Binding: SHA256(HWID + Salt) for AES key derivation. HWID = product_uuid | machine-id | cpuinfo.
- Config Encryption: AES-256-GCM between Server ↔ Agent. Key never transmitted.
- Auth: JWT for Dashboard ↔ Server. X-Token-Auth for Agent ↔ Server (one-time registration token).
Deployment Decisions
- Docker Compose: PostgreSQL + Redis + Server-Core (for dev/CI)
- Bare Metal: Systemd service + Go binary (for production)
- TLS: Deferred. Document nginx/Caddy reverse proxy config. HTTP-only during development.
- Android: Skipped. Web-only dashboard Phase 4.
Database Migration
- Dev/Test: GORM AutoMigrate
- Production:
gooseversioned migrations - Migration files:
migrations/directory in server-core
Testing Strategy
- Go unit tests: All business logic (crypto, IPAM, models, API handlers)
- nftables: Manual testing only via SSH to Linux server (no Linux dev env)
- Vue tests: Vitest for stores/utils. Manual browser testing for components.
- E2E: Post-Phase 4 manual verification
Scope Guardrails (Must-NOT-Have)
- NO STUN/P2P implementation in Phase 1–4
- NO key rotation logic in Phase 1–4
- NO peer discovery mechanism in Phase 1–4
- NO WebSocket — polling only for "real-time" status
- NO PHP migration scaffolding beyond API_SPEC.md (doc only)
- NO agent disk writes of any kind (not even encrypted cache)
- NO WireGuard kernel module dependency — userspace wireguard-go only
- NO
nft flush table— element-level operations only - NO GORM AutoMigrate outside dev/test mode (goose for prod)
Phase Completion Protocol (CRITICAL — READ BEFORE EXECUTING)
How Phases Work
Each phase is self-contained and sequential. Phase N+1 MUST NOT start until Phase N is fully verified and tagged.
Git Tagging Strategy
Every phase creates a git tag in its respective submodule:
phase-1-server-core → apps/server-core
phase-2-device-agent → apps/device-agent
phase-3-devops → nexus-guard-suite (main repo)
phase-4-dashboard-ui → apps/dashboard-ui
Phase Exit Gates (MUST pass before next phase)
| Gate | Check | Who |
|---|---|---|
| All tasks committed | git log --oneline shows all tasks |
Implementer |
| All tests pass | go test ./... or npm test returns 0 |
Implementer |
| Git tag created | git tag phase-N-name pushed |
Implementer |
| Phase QA verified | Per-phase exit criteria manually checked | User confirms |
| Gate passed | ⏸ STOP — user approval required to proceed | User signs off |
Shared Code Rules (NEVER Rebuild)
| Code | Built In | Used By | Rule |
|---|---|---|---|
shared/crypto/encryptor.go |
Phase 1 Task 1.4 | Phase 2 Task 2.1 | Copy file from server-core. Do NOT rewrite. Identical code. |
.env.example patterns |
Phase 3 Task 3.5 | All repos | Finalized in Phase 3. Phase 1 creates initial stub only. |
Dockerfile for server-core |
Phase 3 Task 3.1 | Phase 3 docker-compose | References binary built in Phase 1. Do not rebuild Go code. |
API_SPEC.md |
Phase 1 Task 1.11 | Phase 4 (dashboard integration) | Document only. Do not regenerate. |
Dependency Graph (Which Phase Depends On What)
Phase 1 (Server Core) — No dependencies. ✓ Foundation.
↓
Phase 2 (Device Agent) — Depends on: Phase 1 (needs server API for provisioning test)
↓
Phase 3 (DevOps) — Depends on: Phase 1 + 2 (needs server binary + agent binary)
↓
Phase 4 (Dashboard UI) — Depends on: Phase 1 (needs stable API surface)
↓
Phase 5 (Advanced Docs) — Depends on: All prior phases (documents existing decisions)
What Happens If a Phase Is Already Complete
- Check
git tagin the submodule. If the phase tag exists, the phase is done. - Do NOT re-run tasks. Skip to the next phase's entry criteria.
- If code needs fixing, create a NEW task in the current phase. Never reopen completed phases.
Phase 1: Server Core (apps/server-core)
Goal: Build the Go-Gin backend with all core services — database models, nftables manager, IPAM, crypto, API endpoints, and heartbeat tracking.
Phase Dependency: None (foundation phase)
Entry Criteria: Submodule apps/server-core exists with .git initialized.
Exit Criteria (all must pass before Phase 2):
go build ./...compiles without errorsgo test ./... -tags dev— ALL tests pass (crypto, IPAM, models, API handlers, heartbeat)go run -tags dev .starts server on:8080without panicPOST /api/v1/auth/registerreturns JWT tokenPOST /api/v1/auth/loginreturns JWT token with valid credentialsPOST /api/v1/devicescreates device + returns registration tokenPOST /api/v1/provisioningwith valid token + HWID returns encrypted configPOST /api/v1/provisioningwith used token returns 409- Redis heartbeat: ping device → shows online; TTL expires → shows offline
- nftables: SSH to Linux server, verify
nft list table ip nexusguardshows DROP policy + user sets - Gate:
git tag phase-1-server-corepushed to remote - Gate: User confirms "Phase 1 done — proceed to Phase 2"
Task 1.1: Go Module Init + Project Structure ✅
- Files:
go.mod,go.sum,main.go,.env.example,internal/config/config.go - Actions:
go mod init github.com/nexusguard/nexus-server-core- Create directory structure:
api/,internal/models/,internal/firewall/,internal/ipam/,internal/heartbeat/,internal/auth/,shared/crypto/,migrations/,docs/ - Create
main.gowith Gin engine initialization, config loading from env - Create
internal/config/config.gowith typed config struct (DB, Redis, JWT secret, nftables table name, IPAM pool CIDR) - Create
.env.examplewith all config keys documented
- QA:
go build ./...succeeds. Config loads from env vars with defaults. - Test:
TestConfigLoad— verify env parsing with mock env
Task 1.2: GORM Models + AutoMigrate + Goose Migration Setup ✅
- Files:
internal/models/models.go,internal/models/migrations.go,migrations/001_init.sql - Models:
User: ID (uuid), Username (unique), PasswordHash, Devices (has many)Device: ID (uuid), UserID (FK), Name, HWID (unique, index), InternalIP (unique), PublicKey, PrivateKey (encrypted at rest), PresharedKey, AllowInternet (default false), LastHandshake, IsActive (default true), FirewallRules (has many)FirewallRule: ID (uuid), DeviceID (FK), DestIPRange, DestPortRange, Protocol (default tcp), Action (default accept)WgServer: ID (uuid), Name, PublicKey, Endpoint, ListenPort
- Actions:
- Define all GORM models with proper tags, constraints, and relations
- AutoMigrate in
main.gobehind-tags devbuild flag - Initialize
goosewithmigrations/001_init.sql(CREATE TABLE statements matching models) - Add
gooseas dev tool dependency
- QA:
go run -tags dev .creates all tables. Goose migration applies cleanly. - Test:
TestModelRelations— create User + Device + Rule, verify FK constraints.TestAutoMigrate— verify tables match structs.
Task 1.3: nftables Manager (Init + Set CRUD + Interval Ranges) ✅ ✅
- File:
internal/firewall/nftables.go,internal/firewall/nftables_test.go - Key Library:
github.com/google/nftables - Functions:
NewNetManager() *NetManager— init nftables connectionInitNetwork() error— createnexusguardtable (IPv4),forwardchain with Policy DROPAddUserIsolation(userID string, ipList []net.IP) error— create Set per user, add elementsRemoveUserIsolation(userID string) error— delete the user's setAddDeviceToSet(userID string, deviceIP net.IP) error— add single element to existing setRemoveDeviceFromSet(userID string, deviceIP net.IP) error— remove element from setAddRangeRule(deviceName string, startIP, endIP net.IP, startPort, endPort uint16) error— interval set withInterval: true,KeyEndfor IP ranges,TypeInetServicefor port rangesRemoveRangeRule(deviceName string) error
- Critical Guardrail: NEVER call
nft flush table. Only add/remove individual elements. - QA: Mock nftables.Conn interface. Verify InitNetwork creates table + chain with DROP policy. Verify AddUserIsolation creates set with correct type. Verify AddRangeRule creates interval set with KeyEnd.
- Test:
TestInitNetwork(mock verifies table/chain creation),TestAddRemoveDevice,TestIntervalRange - Note: Integration testing is MANUAL — run on Linux server via SSH. No automated nftables tests on Windows.
Task 1.4: AES-256-GCM Crypto Module ✅
- File:
shared/crypto/encryptor.go,shared/crypto/encryptor_test.go - Functions:
DeriveKey(hwid string, salt []byte) []byte— SHA256(hwid + salt), returns 32-byte keyEncrypt(plaintext []byte, key []byte) ([]byte, error)— AES-GCM with random nonce, returns nonce|ciphertextDecrypt(ciphertext []byte, key []byte) ([]byte, error)— split nonce, GCM Open
- Security Rules:
- Nonce must be random (crypto/rand), never zero/sequential
- Decrypt with wrong key must return error (authenticated encryption)
- Plaintext and key must not be logged or printed
- QA: Encrypt/Decrypt roundtrip returns original. Wrong key returns error. Different nonces produce different ciphertexts.
- Test:
TestEncryptDecryptRoundtrip,TestDecryptWrongKey,TestKeyDerivation,TestNonceUniqueness
Task 1.5: IPAM Manager ✅
- File:
internal/ipam/manager.go,internal/ipam/manager_test.go - Functions:
NewIPAM(poolCIDR string, db *gorm.DB) *Manager— init with CIDR (default 10.8.0.0/16)AllocateIP() (net.IP, error)— find next unused /32 from pool, mark as used in DBReleaseIP(ip net.IP) error— mark IP as availableIsAvailable(ip net.IP) bool— check if IP is free
- Edge Cases:
- Pool exhaustion: return error with pool stats
- Concurrent allocation: DB UNIQUE constraint on Device.InternalIP handles collisions; retry up to 3 times
- Release non-existent IP: no-op, no error
- QA: Allocate returns sequential /32. Exhaust pool and verify error returned. Release and re-allocate.
- Test:
TestAllocateSequential,TestPoolExhaustion,TestReleaseAndReallocate,TestConcurrentAllocation
Task 1.6: JWT Auth Service + API Endpoints ✅
- Files:
internal/auth/jwt.go,internal/auth/jwt_test.go,api/auth.go - Functions:
GenerateToken(userID uuid.UUID, username string) (string, error)— JWT with 24h expiryValidateToken(tokenString string) (*Claims, error)— parse + validate signature + expiryAuthMiddleware() gin.HandlerFunc— Gin middleware that extracts user from JWT
- API Endpoints:
POST /api/v1/auth/login— username + password → JWT tokenPOST /api/v1/auth/register— create new user (admin only, X-Admin-Key header)
- Password Storage: bcrypt hash (cost 12)
- QA: Token generation → validation roundtrip. Expired token rejected. Wrong password returns 401. Duplicate registration returns 409.
- Test:
TestGenerateAndValidate,TestExpiredToken,TestAuthMiddleware,TestLoginEndpoint
Task 1.7: Device Management API ✅
- File:
api/devices.go,api/devices_test.go - Endpoints (all require JWT auth):
GET /api/v1/devices— list user's devices (name, IP, status, last handshake)POST /api/v1/devices— create device (name only), returns device + registration tokenGET /api/v1/devices/:id— get device detailsPUT /api/v1/devices/:id— update device (name, allow_internet)DELETE /api/v1/devices/:id— delete device (also removes nftables rules)POST /api/v1/devices/:id/regenerate-token— invalidate old token, generate new one
- Token: Registration token is UUIDv4, stored hashed in DB, single-use (marked used on provisioning)
- QA: CRUD operations return correct data. Token regeneration invalidates old token. Delete removes nftables rules.
- Test:
TestCreateDevice,TestDeleteDeviceRemovesRules,TestRegenerateToken
Task 1.8: Provisioning API ✅
- File:
api/provisioning.go,api/provisioning_test.go - Endpoint:
POST /api/v1/provisioning - Request:
{"token": "REG-UUID", "hwid": "sha256-hex"} - Flow:
- Look up token in DB — 404 if not found, 409 if already used
- Mark token as used, bind HWID to device
- Generate WireGuard keys if not exist (server side)
- Derive AES key: SHA256(HWID + ServerSalt)
- Encrypt config payload:
{private_key, internal_ip, server_pub, endpoint, dns} - Return encrypted JSON to agent
- Edge Cases:
- Token reuse: return 409 Conflict, log attempted fraud
- HWID collision (two devices claim same HWID): reject second, return 409, alert admin
- IP pool exhausted: return 503 with "no available IPs"
- QA: Valid token + HWID returns encrypted config. Reused token returns 409. Wrong HWID format returns 400.
- Test:
TestProvisioningSuccess,TestTokenReuse,TestHWIDCollision
Task 1.9: Firewall Rules API ✅
- File:
api/rules.go,api/rules_test.go - Endpoints (JWT auth + device belongs-to-user check):
GET /api/v1/devices/:id/rules— list rules for devicePOST /api/v1/devices/:id/rules— create rule (dest_ip_range, dest_port_range, protocol, action)PUT /api/v1/rules/:ruleId— update ruleDELETE /api/v1/rules/:ruleId— delete rule (also removes from nftables)
- Rule Engine: On create/update/delete, trigger nftables sync:
- If no rules + AllowInternet=false → device isolated (default DROP)
- If AllowInternet=true → accept to 0.0.0.0/0
- If specific rules exist → apply as nftables verdict map
- QA: Create rule → appears in GET. Delete rule → removed from DB + nftables. Overlapping ranges handled correctly.
- Test:
TestCreateDeleteRule,TestAllowInternetToggle,TestRuleBelongsToDevice
Task 1.10: Redis Heartbeat Worker ✅
- File:
internal/heartbeat/redis.go,internal/heartbeat/redis_test.go - Functions:
StartHeartbeatCollector(rdb *redis.Client, db *gorm.DB)— goroutine: every 30s, scan Redis keysdevice:{id}:ping, updatelast_handshakeandis_activein DBRecordPing(rdb *redis.Client, deviceID uuid.UUID)— SETdevice:{id}:pingtimestamp, EX 90 (TTL)GetOnlineDevices(rdb *redis.Client) ([]Device, error)— check TTL for all known devices
- Agent Push: Device sends periodic ping to
POST /api/v1/heartbeat(JWT or X-Device-Token auth), which callsRecordPing - Heartbeat Endpoint:
POST /api/v1/heartbeat— accept device ID (from auth), call RecordPing
- Graceful Degradation: If Redis is down, server logs error and uses DB-only status (stale, but functional)
- QA: Ping → device marked online. TTL expires → device marked offline. Redis down → server still responds.
- Test:
TestPingAndTTL(miniredis),TestRedisDownGraceful
Task 1.11: API_SPEC.md Documentation ✅
- File:
docs/API_SPEC.md - Content:
- Full OpenAPI 3.0 specification of all endpoints
- Auth scheme: JWT Bearer (Dashboard), X-Token-Auth header (Agent registration), X-Device-Token (heartbeat)
- Request/response examples for every endpoint
- Error codes catalog (400, 401, 404, 409, 500, 503)
- Deployment notes: TLS via nginx/Caddy reverse proxy (config templates included)
- QA: Spec is internally consistent. All endpoints documented with request/response schemas.
Phase 2: Device Agent (apps/device-agent)
Goal: Build the stealth WireGuard agent — HWID discovery, embedded wireguard-go tunnel via IpcSet, AES-GCM decryption of server config, provisioning client, and auto-reconnect.
Phase Dependency: Phase 1 must be COMPLETE and TAGGED (phase-1-server-core)
Entry Criteria:
git tag -l phase-1-server-coreexists inapps/server-core- Server API is running (locally or remote) for provisioning integration test
- User has confirmed Phase 1 is done
Shared Code Warning: shared/crypto/encryptor.go — COPY from server-core (Phase 1 Task 1.4). Do NOT rewrite. Identical code.
Exit Criteria (all must pass before Phase 3):
go build -o sys-bridge .compiles without errorsgo test ./...— ALL tests pass (HWID, UAPI conversion, provisioning client, heartbeat)- Agent runs on Linux VM:
./sys-bridgestarts without crash GET /sys/class/dmi/id/product_uuid→ HWID is deterministic SHA256 hash- Agent provisions against Phase 1 server: token + HWID → tunnel starts
wg show(on agent) shows handshake with server- Agent heartbeat appears in Redis:
GET device:{id}:pingexists with TTL - No files created in
/etc/wireguard/after agent runs - Agent reconnects after server restart (auto-heal)
- Gate:
git tag phase-2-device-agentpushed to remote - Gate: User confirms "Phase 2 done — proceed to Phase 3"
Task 2.1: Go Module Init + Agent Scaffold ✅
- Files:
go.mod,main.go,.env.example,internal/tunnel/wireguard.go,internal/identity/hwid.go,internal/client/provisioning.go - Actions:
go mod init github.com/nexusguard/nexus-device-agent- Create directory structure:
internal/tunnel/,internal/identity/,internal/client/,shared/crypto/ - Create
main.gowith: config load → HWID discovery → provisioning → tunnel start → heartbeat loop - Stealth binary name: build with
-o sys-bridge(configurable) - Copy
shared/crypto/encryptor.gofrom server-core (identical code)
- QA:
go build -o sys-bridge .succeeds. Binary runs without config file.
Task 2.2: HWID Discovery ✅
- File:
internal/identity/hwid.go,internal/identity/hwid_test.go - Functions:
GetHWID() (string, error)— cascading discovery:- Read
/sys/class/dmi/id/product_uuid→ if exists, return SHA256(trimmed) - Fallback: read
/etc/machine-id→ SHA256 - Fallback: read
/proc/cpuinfo, extract "Serial" → SHA256 - If all fail: return error
- Read
GetHWIDWithFallback() string— same as GetHWID but returns "unknown" on error (graceful)
- Edge Cases:
- VM without product_uuid → fallback to machine-id
- Container without machine-id → fallback to cpuinfo
- All missing → "unknown" with warning log
- QA: Returns deterministic hash for same input. Returns error only when all sources are unavailable.
- Test:
TestHWIDFromProductUUID(mock fs),TestHWIDFallback,TestAllSourcesMissing
Task 2.3: Stealth WireGuard Tunnel (IpcSet) ✅
- File:
internal/tunnel/wireguard.go,internal/tunnel/wireguard_test.go - Functions:
StartStealthTunnel(interfaceName string, uapiConfig string) errorStopTunnel() errorconvertToUAPI(wgConfig string) string— parse standard WG config → UAPI key=value format
- Implementation:
tunDev, err := tun.CreateTUN(interfaceName, 1420) logger := device.NewLogger(device.LogLevelError, "(nxg-wg) ") dev := device.NewDevice(tunDev, logger) err = dev.IpcSet(uapiConfig) // INJECT TO MEMORY — NO FILES dev.Up() - Stealth Rules:
- NO write to
/etc/wireguard/ - NO file-based config — only
IpcSet - Binary name doesn't contain "wireguard" or "wg"
- NO write to
- QA: Tunnel starts without touching disk. Stop cleans up TUN device. Multiple start/stop cycles work.
- Test:
TestUAPIConversion,TestStartStopCycle(mock tun),TestNoFileWrites
Task 2.4: Provisioning Client ✅
- File:
internal/client/provisioning.go,internal/client/provisioning_test.go - Functions:
Provision(serverURL, token, hwid string) (*Config, error)— POST to/api/v1/provisioningDecryptConfig(encrypted []byte, hwid string) (*WireGuardConfig, error)— DeriveKey + Decrypt
- Flow:
- Construct POST request with
{"token": token, "hwid": hwid} - Parse response, extract encrypted config
- Derive AES key from HWID + hardcoded salt (same as server)
- Decrypt config, parse into
WireGuardConfigstruct - Call
StartStealthTunnelwith decrypted config
- Construct POST request with
- Retry Logic: Retry on network errors (3 attempts, 5s backoff). No retry on 400/401/409.
- QA: Provisioning with valid response starts tunnel. Network failure retries. Invalid token stops.
- Test:
TestProvisionSuccess(httptest server),TestRetryOnNetworkError,TestInvalidToken
Task 2.5: Heartbeat + Auto-Reconnect ✅
- File:
internal/client/heartbeat.go,internal/client/heartbeat_test.go - Functions:
StartHeartbeat(serverURL, deviceID string, interval time.Duration)— goroutine: every 30s, POST to/api/v1/heartbeatMonitorHandshake(wgDev *device.Device, onFailure func())— check last handshake time, if > 120s, trigger reconnectReconnect(serverURL, token, hwid string)— re-provision and restart tunnel
- Graceful Degradation:
- If heartbeat fails (server offline): log warning, keep running, retry
- If handshake fails for 120s: attempt re-provisioning
- If re-provisioning fails: exponential backoff (30s, 60s, 120s, 300s max)
- QA: Heartbeat fires at correct interval. Handshake timeout triggers reconnection. Exponential backoff caps at 300s.
- Test:
TestHeartbeatInterval,TestHandshakeTimeout,TestReconnectBackoff
Phase 3: DevOps & Installer
Goal: Create the infrastructure — Docker compose for local dev, bash installer for agent deployment, systemd service templates, Gitea CI/CD pipelines, and environment configuration.
Phase Dependency: Phase 1 + Phase 2 must be COMPLETE and TAGGED
Entry Criteria:
git tag -l phase-1-server-coreexists inapps/server-coregit tag -l phase-2-device-agentexists inapps/device-agent- Server binary compiles (
go build -o bin/server-core .in server-core) - Agent binary compiles (
go build -o sys-bridge .in device-agent) - User has confirmed Phase 2 is done
Exit Criteria (all must pass before Phase 4):
docker-compose upstarts PostgreSQL 16 + Redis 7 + Server-Core- Server-Core inside container connects to PG + Redis (health checks pass)
docker-compose downcleans up without errors- Bash installer script prints usage when run with
--help - Bash installer on Ubuntu VM: detects OS, installs deps, downloads binary, creates systemd service
- Systemd service:
systemctl start sys-bridge→ agent runs - Gitea Actions pipeline for server-core: push → test → build → docker image
- Gitea Actions pipeline for device-agent: push → test → cross-build → release
- Gitea Actions pipeline for dashboard-ui: push → test → build → (manual deploy)
.env.examplefiles exist in all 3 repos with all variables documented- Gate:
git tag phase-3-devopspushed to main repo (nexus-guard-suite) - Gate: User confirms "Phase 3 done — proceed to Phase 4"
Task 3.1: Docker Compose (PostgreSQL + Redis + Server-Core) ✅
- File:
docker-compose.yml,docker-compose.dev.yml,Dockerfile(in server-core) - Services:
postgres: PostgreSQL 16, volume for data, health checkredis: Redis 7, health checkserver-core: Go binary (multi-stage build), depends on postgres+redis, env vars
- Dockerfile (server-core): Multi-stage —
golang:1.25-alpinebuild stage →alpine:3.20runtime - docker-compose.dev.yml: Hot-reload via
airornodemon, exposed ports for local dev - QA:
docker-compose upstarts all containers. Server connects to postgres+redis. Health checks pass.
Task 3.2: Bash Installer Script ✅
- File:
scripts/install_agent.sh(in main repo or device-agent) - Features:
- Root check
- OS detection: Debian/Ubuntu/Raspbian → apt, CentOS/RHEL/Fedora → yum/dnf
- Dependency install: nftables, curl, iproute2, wireguard-tools
- Architecture detection: amd64, arm64, armv7l
- Binary download from Gitea releases using X-Token-Auth
- Binary installation to
/usr/local/bin/with configurable name (defaultsys-bridge) - Systemd service creation:
/etc/systemd/system/sys-bridge.service - Service enable + start
- Flags:
--token,--server-url,--binary-name,--help - QA: Runs on Ubuntu → creates systemd service. Runs on CentOS → uses yum. Missing
--tokenprints usage.
Task 3.3: Systemd Service Template ✅
- File:
scripts/sys-bridge.service(as template), installer generates the actual file - Service Config:
- Type=simple
- ExecStart=/usr/local/bin/sys-bridge
- Restart=always, RestartSec=5
- EnvironmentFile=/etc/sys-bridge.env
- StandardOutput=journal, StandardError=journal
- Environment File (
/etc/sys-bridge.env):- SERVER_URL, REG_TOKEN, BINARY_NAME, LOG_LEVEL
- QA:
systemctl start sys-bridgestarts the agent.systemctl status sys-bridgeshows running.
Task 3.4: Gitea Actions CI/CD
- File:
.gitea/workflows/build.yml(in each repo) - server-core pipeline:
test:go test ./... -tags dev -coverbuild:go build -o bin/server-core .docker: Build and push Docker image to Gitea Container Registry
- device-agent pipeline:
test:go test ./... -covercross-build: Build for linux/amd64, linux/arm64, linux/armrelease: Upload binaries to Gitea Releases (tag-based trigger)
- dashboard-ui pipeline:
test:npm testbuild:npm run builddeploy: Copy dist/ to web server (manual approval step)
- QA: Push triggers pipeline. Tests pass. Binary releases created.
Task 3.5: Environment Configuration
- Files:
.env.examplein each repo,scripts/sys-bridge.env.example - server-core .env.example:
DB_HOST=localhost DB_PORT=5432 DB_USER=nexusguard DB_PASSWORD=<generate> DB_NAME=nexusguard REDIS_ADDR=localhost:6379 JWT_SECRET=<generate-256bit-hex> SERVER_SALT=<generate-256bit-hex> NFTABLES_TABLE=nexusguard IPAM_POOL=10.8.0.0/16 LOG_LEVEL=info - device-agent .env.example:
SERVER_URL=https://nxg.example.com REG_TOKEN=<from-dashboard> BINARY_NAME=sys-bridge LOG_LEVEL=info - dashboard-ui .env.example:
VITE_API_BASE_URL=http://localhost:8080/api/v1 - QA: Docs explain every variable with example values and where to obtain them.
Phase 4: Dashboard UI (apps/dashboard-ui)
Goal: Build the Vue 3 management dashboard — JWT login, device CRUD, firewall rule editor, real-time status monitoring. Web-only (no Android).
Phase Dependency: Phase 1 must be COMPLETE and TAGGED (dashboard consumes Phase 1 API)
Entry Criteria:
git tag -l phase-1-server-coreexists inapps/server-core- Server API is running on
http://localhost:8080/api/v1 - At least one user and device exist in database (for testing dashboard features)
- User has confirmed Phase 3 is done (or Phase 1 if skipping DevOps install)
Exit Criteria (all must pass before Phase 5):
npm run devstarts Vite dev servernpm run buildproduces production bundle without errors- Login page: valid credentials → redirect to dashboard. Invalid → error message.
- Device list: shows devices with name, IP, online/offline badge
- Create device: dialog → POST → registration token displayed with copy button
- Device detail: edit name, toggle "Allow Internet", regenerate token
- Delete device: confirmation dialog → device removed from list
- Firewall rule editor: add rule → appears in list. Delete → removed.
- Dashboard: summary cards show correct counts. Polling updates status.
- Error states: loading spinner, error message with retry, empty state
- Responsive layout: sidebar collapses on mobile, works on 375px viewport
- API service adds JWT header to all requests automatically
- 401 response → redirect to /login automatically
- Gate:
git tag phase-4-dashboard-uipushed to remote - Gate: User confirms "Phase 4 done — proceed to Phase 5"
Task 4.1: Vite + Vue 3 Scaffold + API Layer ✅
- Files: scaffold via
npm create vite@latest,src/services/api.ts,src/stores/auth.ts - Actions:
- Scaffold with Vue 3 + TypeScript + Vite
- Add dependencies: vue-router, pinia, axios, tailwindcss, @tailwindcss/vite
- Configure Tailwind CSS
- Create
src/services/api.ts— axios instance with base URL fromimport.meta.env.VITE_API_BASE_URL, interceptors for JWT header + 401 redirect - Create
src/stores/auth.ts— Pinia store for JWT token (localStorage persistence), login/logout actions - Create router with auth guard: redirect to /login if no token
- QA:
npm run devstarts. API service adds JWT header. Auth guard works.
Task 4.2: Login Page + Auth Flow ✅
- File:
src/views/Login.vue,src/api/auth.ts - Components:
- Login form: username + password + submit button
- Error display: invalid credentials, server error, network error
- Loading state during submission
- API Service:
src/api/auth.ts—login(username, password),register(username, password, adminKey) - Flow: Submit → POST /auth/login → store token in Pinia + localStorage → redirect to /dashboard
- QA: Login with valid credentials → redirect to dashboard. Invalid → error message. Already logged in → redirect to dashboard automatically.
Task 4.3: Device Management Page ✅
- File:
src/views/Devices.vue,src/views/DeviceDetail.vue,src/api/devices.ts,src/stores/devices.ts - Components:
- Device list table: name, IP, status (online/offline badge), last handshake, actions
- Create device dialog: name input → POST → show registration token (copy button)
- Device detail view: edit name, toggle "Allow Internet", regenerate token
- Delete device: confirmation dialog
- API Service:
src/api/devices.ts— CRUD calls - Store:
src/stores/devices.ts— Pinia store with device list, selected device, polling interval - QA: Create device → appears in list with token. Delete → removed from list. Status badge reflects online/offline.
Task 4.4: Firewall Rule Editor ✅
- File:
src/views/FirewallRules.vue(or tab in DeviceDetail),src/api/rules.ts - Components:
- Rules list for selected device: table of dest_ip, dest_port, protocol, action, delete button
- Add rule form: dest IP (single/CIDR/range), dest port (single/range), protocol (TCP/UDP/Both), action (Accept/Drop)
- "Allow Internet" toggle switch (separate from specific rules)
- Validation:
- IP format validation (single, CIDR, range: 192.168.1.10-192.168.1.20)
- Port validation (single: 80, range: 8000-9000)
- No duplicate rule submission
- QA: Add rule → appears in list. Delete rule → removed. Allow Internet toggle → updates device. Invalid IP → validation error.
Task 4.5: Dashboard + Status Monitoring ✅
- File:
src/views/Dashboard.vue,src/stores/dashboard.ts - Components:
- Summary cards: total devices, online count, offline count, active rules count
- Device status grid: cards with name, IP, online/offline indicator, last handshake time
- Auto-refresh: polling every 10s (NOT WebSocket)
- Visual indicators: green dot = online (< 90s since ping), red dot = offline, gray = unknown
- Store:
src/stores/dashboard.ts— polling timer, device status cache - QA: Dashboard shows correct counts. Online/offline indicators update with polling. Summary cards reflect data.
Task 4.6: Navigation Shell + Responsive Layout ✅
- File:
src/App.vue,src/components/Sidebar.vue,src/components/Navbar.vue,src/router/index.ts - Components:
- Sidebar: logo, nav links (Dashboard, Devices), user info + logout
- Top navbar: breadcrumb, mobile hamburger menu
- Responsive: sidebar collapses on mobile, full sidebar on desktop
- Routes:
/login— Login page (public)/dashboard— Dashboard (protected)/devices— Device list (protected)/devices/:id— Device detail + firewall rules (protected)
- QA: All routes work. Auth guard redirects to /login. Responsive layout works on mobile viewport.
Task 4.7: Error Handling + UX Polish ✅
- Files:
src/components/ErrorBoundary.vue,src/components/LoadingSpinner.vue,src/components/EmptyState.vue - Components:
- Error boundary for API failures: retry button, error message
- Loading spinner for async operations
- Empty state: "No devices yet. Create your first device."
- Toast notification component for success/error feedback (create, delete, update)
- Confirmation dialog for destructive actions (delete device, regenerate token)
- QA: API failure shows error with retry. Loading spinner shows during requests. Toast appears after CRUD actions.
Phase 5: Advanced Features (Deferred)
Goal: Document the deferred features. No implementation — only architectural notes for future phases.
Phase Dependency: All prior phases COMPLETE and TAGGED
Entry Criteria:
git tag -l phase-1-server-coreexistsgit tag -l phase-2-device-agentexistsgit tag -l phase-3-devopsexistsgit tag -l phase-4-dashboard-uiexists- User has confirmed Phase 4 is done
Exit Criteria:
docs/TLS_DEPLOYMENT.mdcontains nginx + Caddy reverse proxy configs with Let's Encrypt- STUN signaling architectural notes documented in
docs/STUN_ARCHITECTURE.md - Key rotation plan documented in
docs/KEY_ROTATION.md - Peer discovery design documented in
docs/PEER_DISCOVERY.md - Gate: User confirms "All phases complete. System ready."
Task 5.1: nginx/Caddy TLS Documentation
- File:
docs/TLS_DEPLOYMENT.md(in server-core) - Content: nginx and Caddy reverse proxy config templates for TLS termination, Let's Encrypt auto-provisioning, HTTP-to-HTTPS redirect
- Note: Documentation only. No code changes.
Task 5.2: STUN Signaling Notes
- Document: Architectural notes for UDP hole punching
- Server: STUN endpoint on server-core,
/api/v1/stun— returns public IP:port of the agent - Agent: On start, send STUN request to server. Server records public endpoint. Agent receives peer's public endpoint via polling/push.
- Implementation: Deferred to future phase.
Task 5.3: Key Rotation Notes
- Document: 30-day key rotation plan
- Dual-key buffer: Server generates new keypair 5 minutes before expiry. Agent fetches new key while old key is active. Graceful handover period.
- Implementation: Deferred to future phase.
Task 5.4: Peer Discovery Notes
- Document:
/api/v1/peersendpoint spec - Design: Server maintains device list with internal IP per user. When new device provisions, server pushes/notifies all peers in same user group.
- Implementation: Deferred to future phase.
Roll-up Verification Wave (End-to-End)
Run this ONLY after all 5 phases are complete and tagged. This validates the integrated system works end-to-end. Each individual check here should already have passed during per-phase exit gates — this is a final integration smoke test.
Pre-flight: Phase Tags Check
git tag -linapps/server-coreshowsphase-1-server-coregit tag -linapps/device-agentshowsphase-2-device-agentgit tag -lin root showsphase-3-devopsgit tag -linapps/dashboard-uishowsphase-4-dashboard-ui- All docs exist from Phase 5
Integration Smoke Tests
| # | Test | Expected | If Fails |
|---|---|---|---|
| 1 | docker-compose up → PostgreSQL + Redis + Server-Core start |
All 3 containers healthy | Fix Phase 3 |
| 2 | POST /api/v1/auth/register (admin) |
201 + JWT token | Fix Phase 1 |
| 3 | POST /api/v1/auth/login |
200 + JWT token | Fix Phase 1 |
| 4 | POST /api/v1/devices (with JWT) |
201 + device + reg token | Fix Phase 1 |
| 5 | Agent binary runs: ./sys-bridge --server-url http://localhost:8080 --token REG-TOKEN |
Tunnel established, no errors | Fix Phase 2 |
| 6 | GET /api/v1/devices → device shows "online" |
Status = online | Fix Phase 1/2 |
| 7 | Dashboard login → device list shows device | Device visible in UI | Fix Phase 4 |
| 8 | Dashboard: create firewall rule → SSH verify nftables | Rule appears in nft list table |
Fix Phase 1/4 |
| 9 | Toggle "Allow Internet" in dashboard → verify nftables | Verdict map changes | Fix Phase 1/4 |
| 10 | Delete device in dashboard → verify nftables cleanup | Set element removed | Fix Phase 1/4 |
| 11 | Kill agent process → server marks offline within 90s | Status = offline | Fix Phase 1/2 |
| 12 | Restart agent → reconnects automatically | Status returns to online | Fix Phase 2 |
| 13 | Bash installer on fresh Ubuntu VM | Installs deps + binary + systemd | Fix Phase 3 |
| 14 | Gitea Actions: push to any repo → pipeline triggers | Green build | Fix Phase 3 |
Pass / Fail Decision
- ALL 14 pass: System is complete and production-ready. 🟢
- Any fail: Fix the failing component in its original phase. Do NOT create workarounds in other phases.
Approval required from user. Run each check, report results.