From 830491cb0d9e79915286dd3d802acdb5833bd547 Mon Sep 17 00:00:00 2001 From: nessi Date: Sun, 15 Mar 2026 16:32:34 +0100 Subject: [PATCH] chore: initial project scaffold with admin web, backend, desktop client, and deployment setup Add monorepo structure for NexaVPN WireGuard control plane including: - .gitignore for node_modules, build artifacts, and environment files - README with project overview, monorepo layout, and quick start guide - Admin web UI with React, Vite, TypeScript, and nginx reverse proxy - API client with type definitions for users, devices, policies, gateways, and audit logs - Admin pages for dashboard, users, devices, policies, g --- .gitignore | 11 + README.md | 60 ++++ admin-web/Dockerfile | 10 + admin-web/index.html | 12 + admin-web/nginx.conf | 18 ++ admin-web/package.json | 24 ++ admin-web/src/api/client.ts | 75 +++++ admin-web/src/app/App.tsx | 40 +++ admin-web/src/components/Card.tsx | 5 + admin-web/src/components/Layout.tsx | 45 +++ admin-web/src/components/Page.tsx | 22 ++ admin-web/src/components/Table.tsx | 37 +++ admin-web/src/features/audit/AuditPage.tsx | 37 +++ admin-web/src/features/auth/LoginPage.tsx | 96 +++++++ .../src/features/dashboard/DashboardPage.tsx | 36 +++ .../src/features/devices/DevicesPage.tsx | 39 +++ .../src/features/gateways/GatewaysPage.tsx | 37 +++ .../src/features/policies/PoliciesPage.tsx | 41 +++ .../src/features/settings/SettingsPage.tsx | 21 ++ admin-web/src/features/users/UsersPage.tsx | 41 +++ admin-web/src/main.tsx | 19 ++ admin-web/src/styles/global.css | 227 +++++++++++++++ admin-web/tsconfig.app.json | 18 ++ admin-web/tsconfig.json | 6 + admin-web/vite.config.ts | 10 + backend/Dockerfile | 14 + backend/cmd/api/main.go | 48 ++++ backend/go.mod | 11 + backend/internal/apiutil/respond.go | 26 ++ backend/internal/app/app.go | 62 ++++ backend/internal/audit/handler.go | 25 ++ backend/internal/audit/repository.go | 70 +++++ backend/internal/audit/service.go | 47 ++++ backend/internal/auth/handler.go | 137 +++++++++ backend/internal/auth/hash.go | 11 + backend/internal/auth/password.go | 40 +++ backend/internal/auth/repository.go | 105 +++++++ backend/internal/auth/service.go | 155 ++++++++++ backend/internal/auth/token.go | 77 +++++ backend/internal/auth/types.go | 39 +++ backend/internal/config/config.go | 82 ++++++ backend/internal/db/db.go | 11 + backend/internal/device/handler.go | 176 ++++++++++++ backend/internal/device/repository.go | 265 ++++++++++++++++++ backend/internal/device/service.go | 130 +++++++++ backend/internal/device/types.go | 62 ++++ backend/internal/gateway/handler.go | 37 +++ backend/internal/gateway/repository.go | 102 +++++++ backend/internal/gateway/service.go | 33 +++ backend/internal/gateway/types.go | 14 + backend/internal/httpserver/middleware.go | 66 +++++ backend/internal/httpserver/router.go | 68 +++++ backend/internal/ipam/service.go | 26 ++ backend/internal/policy/handler.go | 62 ++++ backend/internal/policy/repository.go | 143 ++++++++++ backend/internal/policy/service.go | 33 +++ backend/internal/policy/types.go | 30 ++ backend/internal/profile/builder.go | 33 +++ backend/internal/user/handler.go | 110 ++++++++ backend/internal/user/repository.go | 64 +++++ backend/internal/user/service.go | 37 +++ backend/internal/user/types.go | 27 ++ backend/internal/wireguard/types.go | 18 ++ backend/migrations/000001_init.sql | 183 ++++++++++++ backend/seed/001_seed.sql | 24 ++ deploy/.env.example | 14 + deploy/docker-compose.yml | 72 +++++ deploy/nginx/admin.conf | 10 + deploy/nginx/reverse-proxy.conf | 18 ++ deploy/scripts/bootstrap-admin.sh | 9 + deploy/scripts/gateway-entrypoint.sh | 8 + desktop-client/index.html | 12 + desktop-client/package.json | 25 ++ desktop-client/src-tauri/Cargo.toml | 22 ++ desktop-client/src-tauri/build.rs | 3 + desktop-client/src-tauri/src/lib.rs | 204 ++++++++++++++ desktop-client/src-tauri/src/main.rs | 3 + desktop-client/src-tauri/tauri.conf.json | 30 ++ desktop-client/src/App.tsx | 107 +++++++ desktop-client/src/main.tsx | 11 + desktop-client/src/styles.css | 111 ++++++++ desktop-client/tsconfig.app.json | 16 ++ desktop-client/tsconfig.json | 6 + desktop-client/vite.config.ts | 12 + docs/api.md | 223 +++++++++++++++ docs/architecture.md | 180 ++++++++++++ docs/deployment.md | 60 ++++ docs/enrollment.md | 76 +++++ docs/folder-structure.md | 84 ++++++ docs/gateway.md | 69 +++++ docs/schema.md | 204 ++++++++++++++ 91 files changed, 5279 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 admin-web/Dockerfile create mode 100644 admin-web/index.html create mode 100644 admin-web/nginx.conf create mode 100644 admin-web/package.json create mode 100644 admin-web/src/api/client.ts create mode 100644 admin-web/src/app/App.tsx create mode 100644 admin-web/src/components/Card.tsx create mode 100644 admin-web/src/components/Layout.tsx create mode 100644 admin-web/src/components/Page.tsx create mode 100644 admin-web/src/components/Table.tsx create mode 100644 admin-web/src/features/audit/AuditPage.tsx create mode 100644 admin-web/src/features/auth/LoginPage.tsx create mode 100644 admin-web/src/features/dashboard/DashboardPage.tsx create mode 100644 admin-web/src/features/devices/DevicesPage.tsx create mode 100644 admin-web/src/features/gateways/GatewaysPage.tsx create mode 100644 admin-web/src/features/policies/PoliciesPage.tsx create mode 100644 admin-web/src/features/settings/SettingsPage.tsx create mode 100644 admin-web/src/features/users/UsersPage.tsx create mode 100644 admin-web/src/main.tsx create mode 100644 admin-web/src/styles/global.css create mode 100644 admin-web/tsconfig.app.json create mode 100644 admin-web/tsconfig.json create mode 100644 admin-web/vite.config.ts create mode 100644 backend/Dockerfile create mode 100644 backend/cmd/api/main.go create mode 100644 backend/go.mod create mode 100644 backend/internal/apiutil/respond.go create mode 100644 backend/internal/app/app.go create mode 100644 backend/internal/audit/handler.go create mode 100644 backend/internal/audit/repository.go create mode 100644 backend/internal/audit/service.go create mode 100644 backend/internal/auth/handler.go create mode 100644 backend/internal/auth/hash.go create mode 100644 backend/internal/auth/password.go create mode 100644 backend/internal/auth/repository.go create mode 100644 backend/internal/auth/service.go create mode 100644 backend/internal/auth/token.go create mode 100644 backend/internal/auth/types.go create mode 100644 backend/internal/config/config.go create mode 100644 backend/internal/db/db.go create mode 100644 backend/internal/device/handler.go create mode 100644 backend/internal/device/repository.go create mode 100644 backend/internal/device/service.go create mode 100644 backend/internal/device/types.go create mode 100644 backend/internal/gateway/handler.go create mode 100644 backend/internal/gateway/repository.go create mode 100644 backend/internal/gateway/service.go create mode 100644 backend/internal/gateway/types.go create mode 100644 backend/internal/httpserver/middleware.go create mode 100644 backend/internal/httpserver/router.go create mode 100644 backend/internal/ipam/service.go create mode 100644 backend/internal/policy/handler.go create mode 100644 backend/internal/policy/repository.go create mode 100644 backend/internal/policy/service.go create mode 100644 backend/internal/policy/types.go create mode 100644 backend/internal/profile/builder.go create mode 100644 backend/internal/user/handler.go create mode 100644 backend/internal/user/repository.go create mode 100644 backend/internal/user/service.go create mode 100644 backend/internal/user/types.go create mode 100644 backend/internal/wireguard/types.go create mode 100644 backend/migrations/000001_init.sql create mode 100644 backend/seed/001_seed.sql create mode 100644 deploy/.env.example create mode 100644 deploy/docker-compose.yml create mode 100644 deploy/nginx/admin.conf create mode 100644 deploy/nginx/reverse-proxy.conf create mode 100644 deploy/scripts/bootstrap-admin.sh create mode 100644 deploy/scripts/gateway-entrypoint.sh create mode 100644 desktop-client/index.html create mode 100644 desktop-client/package.json create mode 100644 desktop-client/src-tauri/Cargo.toml create mode 100644 desktop-client/src-tauri/build.rs create mode 100644 desktop-client/src-tauri/src/lib.rs create mode 100644 desktop-client/src-tauri/src/main.rs create mode 100644 desktop-client/src-tauri/tauri.conf.json create mode 100644 desktop-client/src/App.tsx create mode 100644 desktop-client/src/main.tsx create mode 100644 desktop-client/src/styles.css create mode 100644 desktop-client/tsconfig.app.json create mode 100644 desktop-client/tsconfig.json create mode 100644 desktop-client/vite.config.ts create mode 100644 docs/api.md create mode 100644 docs/architecture.md create mode 100644 docs/deployment.md create mode 100644 docs/enrollment.md create mode 100644 docs/folder-structure.md create mode 100644 docs/gateway.md create mode 100644 docs/schema.md diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..58770ac --- /dev/null +++ b/.gitignore @@ -0,0 +1,11 @@ +node_modules/ +dist/ +build/ +.DS_Store +.env +.env.local +coverage/ +tmp/ +*.log +desktop-client/src-tauri/target/ +backend/bin/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..60655c3 --- /dev/null +++ b/README.md @@ -0,0 +1,60 @@ +# NexaVPN + +NexaVPN is a production-oriented, self-hosted WireGuard control plane for remote access. +It combines: + +- A Go backend and PostgreSQL control plane +- A React admin console +- A Tauri desktop client for Windows and macOS +- WireGuard gateway and firewall policy enforcement +- Docker Compose deployment assets + +## Monorepo Layout + +- `docs/` architecture, schema, API, and deployment design +- `backend/` Go API, migrations, seeds, and domain services +- `admin-web/` React + Vite admin UI +- `desktop-client/` Tauri desktop client +- `deploy/` Docker Compose, reverse proxy, and gateway assets + +## Phase Status + +This repository contains the initial production-minded MVP scaffold: + +- Phase 1: architecture, schema, API, enrollment, provisioning, gateway design +- Phase 2: backend scaffold, migrations, auth, CRUD, audit, profile generation +- Phase 3: admin UI scaffold and core pages +- Phase 4: desktop client scaffold, enrollment flow, profile provisioning abstraction +- Phase 5: deployment assets, bootstrap scripts, and hardening notes + +## Quick Start + +1. Copy `deploy/.env.example` to `deploy/.env`. +2. Review `docs/architecture.md` and `docs/deployment.md`. +3. Start the stack with Docker Compose from `deploy/`. +4. Open `http://localhost`. +5. On the admin login screen, choose the bootstrap flow if this is a fresh install. +6. Create the initial admin, then sign in. + +## Important MVP Notes + +- WireGuard remains the tunnel transport. NexaVPN is the control plane around it. +- Client private keys are generated on-device and are not stored server-side. +- Gateway-side enforcement uses nftables generated from issued policy state. +- The Tauri client is structured around embedded tunnel management. Native system WireGuard import can be added as an optional integration later. +- The current desktop client now performs real backend login and enrollment calls, but secure OS key storage and native tunnel activation are still the next hardening step. + +## Local Test Flow + +```bash +cd deploy +cp .env.example .env +docker compose up --build +``` + +Then: + +1. Visit `http://localhost` +2. Bootstrap the first admin account +3. Create a user or use the desktop client against `http://localhost` +4. Sign in from the NexaVPN desktop app with that user diff --git a/admin-web/Dockerfile b/admin-web/Dockerfile new file mode 100644 index 0000000..a5620f3 --- /dev/null +++ b/admin-web/Dockerfile @@ -0,0 +1,10 @@ +FROM node:22-alpine AS builder +WORKDIR /app +COPY package.json ./ +RUN npm install +COPY . . +RUN npm run build + +FROM nginx:1.27-alpine +COPY --from=builder /app/dist /usr/share/nginx/html +COPY nginx.conf /etc/nginx/conf.d/default.conf diff --git a/admin-web/index.html b/admin-web/index.html new file mode 100644 index 0000000..06e3bf4 --- /dev/null +++ b/admin-web/index.html @@ -0,0 +1,12 @@ + + + + + + NexaVPN Admin + + +
+ + + diff --git a/admin-web/nginx.conf b/admin-web/nginx.conf new file mode 100644 index 0000000..31e6d87 --- /dev/null +++ b/admin-web/nginx.conf @@ -0,0 +1,18 @@ +server { + listen 80; + server_name _; + root /usr/share/nginx/html; + index index.html; + + location /api/ { + proxy_pass http://backend:8080; + proxy_set_header Host $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-Proto $scheme; + } + + location / { + try_files $uri /index.html; + } +} diff --git a/admin-web/package.json b/admin-web/package.json new file mode 100644 index 0000000..c4ded90 --- /dev/null +++ b/admin-web/package.json @@ -0,0 +1,24 @@ +{ + "name": "nexavpn-admin-web", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc -b && vite build", + "preview": "vite preview" + }, + "dependencies": { + "@tanstack/react-query": "^5.66.8", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "react-router-dom": "^6.30.0" + }, + "devDependencies": { + "@types/react": "^18.3.20", + "@types/react-dom": "^18.3.6", + "@vitejs/plugin-react": "^4.3.4", + "typescript": "^5.8.2", + "vite": "^6.2.2" + } +} diff --git a/admin-web/src/api/client.ts b/admin-web/src/api/client.ts new file mode 100644 index 0000000..3518114 --- /dev/null +++ b/admin-web/src/api/client.ts @@ -0,0 +1,75 @@ +const API_BASE = import.meta.env.VITE_API_BASE_URL ?? "/api/v1"; + +export type User = { + id: string; + username: string; + display_name: string; + role: string; + is_active: boolean; +}; + +export type Device = { + id: string; + name: string; + user_id?: string; + platform: string; + status: string; + assigned_ip?: string; +}; + +export type Policy = { + id: string; + name: string; + description: string; + priority: number; + effect: string; + full_tunnel: boolean; + is_active: boolean; + destinations?: string[]; +}; + +export type Gateway = { + id: string; + name: string; + endpoint: string; + public_key: string; + listen_port: number; + vpn_cidr: string; + dns_servers: string[]; + is_active: boolean; +}; + +export type AuditEvent = { + id: string; + event_type: string; + entity_type: string; + status: string; + message: string; + created_at: string; +}; + +async function request(path: string, init?: RequestInit): Promise { + const token = localStorage.getItem("nexavpn_admin_token"); + const response = await fetch(`${API_BASE}${path}`, { + ...init, + headers: { + "Content-Type": "application/json", + ...(token ? { Authorization: `Bearer ${token}` } : {}), + ...(init?.headers ?? {}) + } + }); + + if (!response.ok) { + throw new Error(`Request failed: ${response.status}`); + } + + return response.json() as Promise; +} + +export const api = { + users: () => request("/admin/users"), + devices: () => request("/admin/devices"), + policies: () => request("/admin/policies"), + gateways: () => request("/admin/gateways"), + audit: () => request("/admin/audit-logs") +}; diff --git a/admin-web/src/app/App.tsx b/admin-web/src/app/App.tsx new file mode 100644 index 0000000..fb6b301 --- /dev/null +++ b/admin-web/src/app/App.tsx @@ -0,0 +1,40 @@ +import { useMemo, useState } from "react"; +import { Navigate, Route, Routes } from "react-router-dom"; + +import { Layout } from "../components/Layout"; +import { AuditPage } from "../features/audit/AuditPage"; +import { LoginPage } from "../features/auth/LoginPage"; +import { DashboardPage } from "../features/dashboard/DashboardPage"; +import { DevicesPage } from "../features/devices/DevicesPage"; +import { GatewaysPage } from "../features/gateways/GatewaysPage"; +import { PoliciesPage } from "../features/policies/PoliciesPage"; +import { SettingsPage } from "../features/settings/SettingsPage"; +import { UsersPage } from "../features/users/UsersPage"; + +export function App() { + const [token, setToken] = useState(() => localStorage.getItem("nexavpn_admin_token") ?? ""); + const authenticated = useMemo(() => token.length > 0, [token]); + + function handleAuthenticated(accessToken: string) { + localStorage.setItem("nexavpn_admin_token", accessToken); + setToken(accessToken); + } + + return ( + + : } + /> + : }> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + + + ); +} diff --git a/admin-web/src/components/Card.tsx b/admin-web/src/components/Card.tsx new file mode 100644 index 0000000..ba3d259 --- /dev/null +++ b/admin-web/src/components/Card.tsx @@ -0,0 +1,5 @@ +import { PropsWithChildren } from "react"; + +export function Card({ children }: PropsWithChildren) { + return
{children}
; +} diff --git a/admin-web/src/components/Layout.tsx b/admin-web/src/components/Layout.tsx new file mode 100644 index 0000000..7921d9a --- /dev/null +++ b/admin-web/src/components/Layout.tsx @@ -0,0 +1,45 @@ +import { NavLink, Outlet } from "react-router-dom"; + +const items = [ + ["Dashboard", "/"], + ["Users", "/users"], + ["Devices", "/devices"], + ["Policies", "/policies"], + ["Gateways", "/gateways"], + ["Audit", "/audit"], + ["Settings", "/settings"] +]; + +export function Layout() { + return ( +
+ +
+
+
+

Enterprise WireGuard

+

Self-hosted VPN management

+
+
Secure by design
+
+ +
+
+ ); +} diff --git a/admin-web/src/components/Page.tsx b/admin-web/src/components/Page.tsx new file mode 100644 index 0000000..a44d1fc --- /dev/null +++ b/admin-web/src/components/Page.tsx @@ -0,0 +1,22 @@ +import { PropsWithChildren, ReactNode } from "react"; + +type PageProps = PropsWithChildren<{ + title: string; + subtitle: string; + actions?: ReactNode; +}>; + +export function Page({ title, subtitle, actions, children }: PageProps) { + return ( +
+
+
+

{title}

+

{subtitle}

+
+ {actions} +
+ {children} +
+ ); +} diff --git a/admin-web/src/components/Table.tsx b/admin-web/src/components/Table.tsx new file mode 100644 index 0000000..38a0d1a --- /dev/null +++ b/admin-web/src/components/Table.tsx @@ -0,0 +1,37 @@ +import { PropsWithChildren, ReactNode } from "react"; + +type Column = { + key: string; + label: string; +}; + +type TableProps = { + columns: Column[]; + rows: T[]; + renderCell: (row: T, column: Column) => ReactNode; +}; + +export function Table({ columns, rows, renderCell }: PropsWithChildren>) { + return ( +
+ + + + {columns.map((column) => ( + + ))} + + + + {rows.map((row, index) => ( + + {columns.map((column) => ( + + ))} + + ))} + +
{column.label}
{renderCell(row, column)}
+
+ ); +} diff --git a/admin-web/src/features/audit/AuditPage.tsx b/admin-web/src/features/audit/AuditPage.tsx new file mode 100644 index 0000000..dc185e8 --- /dev/null +++ b/admin-web/src/features/audit/AuditPage.tsx @@ -0,0 +1,37 @@ +import { useQuery } from "@tanstack/react-query"; + +import { api } from "../../api/client"; +import { Page } from "../../components/Page"; +import { Table } from "../../components/Table"; + +const columns = [ + { key: "event", label: "Event" }, + { key: "entity", label: "Entity" }, + { key: "status", label: "Status" }, + { key: "time", label: "Time" } +]; + +export function AuditPage() { + const query = useQuery({ + queryKey: ["audit"], + queryFn: api.audit + }); + + const rows = query.data?.map((event) => ({ + event: event.event_type, + entity: event.entity_type, + status: event.status, + time: event.created_at + })) ?? []; + + return ( + + {query.isError ?

Unable to load audit events from the API.

: null} + {row[column.key as keyof (typeof rows)[number]]}} + /> + + ); +} diff --git a/admin-web/src/features/auth/LoginPage.tsx b/admin-web/src/features/auth/LoginPage.tsx new file mode 100644 index 0000000..3a14f90 --- /dev/null +++ b/admin-web/src/features/auth/LoginPage.tsx @@ -0,0 +1,96 @@ +import { FormEvent, useState } from "react"; + +type LoginPageProps = { + onAuthenticated: (accessToken: string) => void; +}; + +const API_BASE = import.meta.env.VITE_API_BASE_URL ?? "/api/v1"; + +export function LoginPage({ onAuthenticated }: LoginPageProps) { + const [mode, setMode] = useState<"login" | "bootstrap">("login"); + const [username, setUsername] = useState("admin"); + const [displayName, setDisplayName] = useState("NexaVPN Admin"); + const [password, setPassword] = useState("admin123!"); + const [error, setError] = useState(null); + const [loading, setLoading] = useState(false); + + async function onSubmit(event: FormEvent) { + event.preventDefault(); + setLoading(true); + setError(null); + + try { + if (mode === "bootstrap") { + const bootstrap = await fetch(`${API_BASE}/auth/bootstrap`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + username, + display_name: displayName, + password + }) + }); + + if (!bootstrap.ok && bootstrap.status !== 409) { + throw new Error(`Bootstrap failed: ${bootstrap.status}`); + } + } + + const login = await fetch(`${API_BASE}/auth/login`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ username, password }) + }); + + if (!login.ok) { + throw new Error("Login failed"); + } + + const payload = (await login.json()) as { access_token: string }; + onAuthenticated(payload.access_token); + } catch (err) { + setError(err instanceof Error ? err.message : "Authentication failed"); + } finally { + setLoading(false); + } + } + + return ( +
+
+

NexaVPN Admin

+

{mode === "login" ? "Sign in" : "Create initial admin"}

+

+ {mode === "login" + ? "Use your NexaVPN admin credentials." + : "Bootstrap the first admin account for a fresh deployment."} +

+ + {mode === "bootstrap" ? ( + + ) : null} + + {error ?

{error}

: null} + + + +
+ ); +} diff --git a/admin-web/src/features/dashboard/DashboardPage.tsx b/admin-web/src/features/dashboard/DashboardPage.tsx new file mode 100644 index 0000000..4c6121a --- /dev/null +++ b/admin-web/src/features/dashboard/DashboardPage.tsx @@ -0,0 +1,36 @@ +import { Card } from "../../components/Card"; +import { Page } from "../../components/Page"; + +export function DashboardPage() { + return ( + +
+ +

Active users

+ 248 + 12 enrolled this week +
+ +

Connected devices

+ 181 + 7 pending rotation +
+ +

Gateway health

+ Healthy + Last sync 36s ago +
+
+
+ +

Recent enrollments

+

New devices are issued profiles automatically after successful sign-in.

+
+ +

Policy posture

+

Gateway enforcement is generated from effective allow-lists backed by nftables.

+
+
+
+ ); +} diff --git a/admin-web/src/features/devices/DevicesPage.tsx b/admin-web/src/features/devices/DevicesPage.tsx new file mode 100644 index 0000000..7181b2c --- /dev/null +++ b/admin-web/src/features/devices/DevicesPage.tsx @@ -0,0 +1,39 @@ +import { useQuery } from "@tanstack/react-query"; + +import { api } from "../../api/client"; +import { Page } from "../../components/Page"; +import { Table } from "../../components/Table"; + +const columns = [ + { key: "name", label: "Device" }, + { key: "owner", label: "Owner" }, + { key: "platform", label: "Platform" }, + { key: "ip", label: "VPN IP" }, + { key: "status", label: "Status" } +]; + +export function DevicesPage() { + const query = useQuery({ + queryKey: ["devices"], + queryFn: api.devices + }); + + const rows = query.data?.map((device) => ({ + name: device.name, + owner: device.user_id ?? "assigned user", + platform: device.platform, + ip: device.assigned_ip ?? "-", + status: device.status + })) ?? []; + + return ( + + {query.isError ?

Unable to load devices from the API.

: null} +
{row[column.key as keyof (typeof rows)[number]]}} + /> + + ); +} diff --git a/admin-web/src/features/gateways/GatewaysPage.tsx b/admin-web/src/features/gateways/GatewaysPage.tsx new file mode 100644 index 0000000..6ace7d6 --- /dev/null +++ b/admin-web/src/features/gateways/GatewaysPage.tsx @@ -0,0 +1,37 @@ +import { useQuery } from "@tanstack/react-query"; + +import { api } from "../../api/client"; +import { Page } from "../../components/Page"; +import { Table } from "../../components/Table"; + +const columns = [ + { key: "name", label: "Gateway" }, + { key: "endpoint", label: "Endpoint" }, + { key: "cidr", label: "VPN CIDR" }, + { key: "status", label: "Status" } +]; + +export function GatewaysPage() { + const query = useQuery({ + queryKey: ["gateways"], + queryFn: api.gateways + }); + + const rows = query.data?.map((gateway) => ({ + name: gateway.name, + endpoint: gateway.endpoint, + cidr: gateway.vpn_cidr, + status: gateway.is_active ? "active" : "inactive" + })) ?? []; + + return ( + + {query.isError ?

Unable to load gateways from the API.

: null} +
{row[column.key as keyof (typeof rows)[number]]}} + /> + + ); +} diff --git a/admin-web/src/features/policies/PoliciesPage.tsx b/admin-web/src/features/policies/PoliciesPage.tsx new file mode 100644 index 0000000..05b8b49 --- /dev/null +++ b/admin-web/src/features/policies/PoliciesPage.tsx @@ -0,0 +1,41 @@ +import { useQuery } from "@tanstack/react-query"; + +import { api } from "../../api/client"; +import { Page } from "../../components/Page"; +import { Table } from "../../components/Table"; + +const columns = [ + { key: "name", label: "Policy" }, + { key: "targets", label: "Targets" }, + { key: "destinations", label: "Destinations" }, + { key: "mode", label: "Mode" } +]; + +export function PoliciesPage() { + const query = useQuery({ + queryKey: ["policies"], + queryFn: api.policies + }); + + const rows = query.data?.map((policy) => ({ + name: policy.name, + targets: "assigned targets", + destinations: policy.destinations?.join(", ") ?? (policy.full_tunnel ? "0.0.0.0/0" : "-"), + mode: policy.full_tunnel ? "Full tunnel" : "Split tunnel" + })) ?? []; + + return ( + New policy} + > + {query.isError ?

Unable to load policies from the API.

: null} +
{row[column.key as keyof (typeof rows)[number]]}} + /> + + ); +} diff --git a/admin-web/src/features/settings/SettingsPage.tsx b/admin-web/src/features/settings/SettingsPage.tsx new file mode 100644 index 0000000..8cd6fb2 --- /dev/null +++ b/admin-web/src/features/settings/SettingsPage.tsx @@ -0,0 +1,21 @@ +import { Card } from "../../components/Card"; +import { Page } from "../../components/Page"; + +export function SettingsPage() { + return ( + +
+ +

Auth

+

Access token TTL: 15 minutes

+

Refresh token TTL: 30 days

+
+ +

VPN

+

Default DNS: 10.20.0.53

+

Address pool: 100.96.0.0/24

+
+
+
+ ); +} diff --git a/admin-web/src/features/users/UsersPage.tsx b/admin-web/src/features/users/UsersPage.tsx new file mode 100644 index 0000000..7fb865d --- /dev/null +++ b/admin-web/src/features/users/UsersPage.tsx @@ -0,0 +1,41 @@ +import { useQuery } from "@tanstack/react-query"; + +import { api } from "../../api/client"; +import { Page } from "../../components/Page"; +import { Table } from "../../components/Table"; + +const columns = [ + { key: "username", label: "Username" }, + { key: "name", label: "Display name" }, + { key: "role", label: "Role" }, + { key: "status", label: "Status" } +]; + +export function UsersPage() { + const query = useQuery({ + queryKey: ["users"], + queryFn: api.users + }); + + const rows = query.data?.map((user) => ({ + username: user.username, + name: user.display_name, + role: user.role, + status: user.is_active ? "active" : "disabled" + })) ?? []; + + return ( + New user} + > + {query.isError ?

Unable to load users from the API.

: null} +
{row[column.key as keyof (typeof rows)[number]]}} + /> + + ); +} diff --git a/admin-web/src/main.tsx b/admin-web/src/main.tsx new file mode 100644 index 0000000..478679e --- /dev/null +++ b/admin-web/src/main.tsx @@ -0,0 +1,19 @@ +import React from "react"; +import ReactDOM from "react-dom/client"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { BrowserRouter } from "react-router-dom"; + +import { App } from "./app/App"; +import "./styles/global.css"; + +const client = new QueryClient(); + +ReactDOM.createRoot(document.getElementById("root")!).render( + + + + + + + +); diff --git a/admin-web/src/styles/global.css b/admin-web/src/styles/global.css new file mode 100644 index 0000000..8a01656 --- /dev/null +++ b/admin-web/src/styles/global.css @@ -0,0 +1,227 @@ +:root { + color-scheme: light dark; + --bg: #0c1322; + --bg-soft: #111b30; + --panel: rgba(17, 27, 48, 0.78); + --panel-strong: #14203a; + --text: #eff4ff; + --muted: #9fb2d4; + --line: rgba(177, 197, 229, 0.15); + --accent: #74e0b8; + --accent-strong: #1fb67a; + --shadow: 0 18px 48px rgba(3, 8, 20, 0.35); + font-family: "Segoe UI", "SF Pro Text", "Helvetica Neue", sans-serif; +} + +* { + box-sizing: border-box; +} + +body { + margin: 0; + min-height: 100vh; + color: var(--text); + background: + radial-gradient(circle at top left, rgba(116, 224, 184, 0.17), transparent 28%), + radial-gradient(circle at bottom right, rgba(98, 144, 255, 0.16), transparent 24%), + linear-gradient(180deg, #08101d 0%, #0d1728 100%); +} + +a { + color: inherit; + text-decoration: none; +} + +button { + font: inherit; +} + +.shell { + display: grid; + grid-template-columns: 280px 1fr; + min-height: 100vh; +} + +.auth-shell { + min-height: 100vh; + display: grid; + place-items: center; + padding: 24px; +} + +.auth-card { + width: min(460px, 100%); + display: grid; + gap: 16px; + padding: 28px; + background: var(--panel); + border: 1px solid var(--line); + border-radius: 24px; + box-shadow: var(--shadow); + backdrop-filter: blur(12px); +} + +.auth-card label { + display: grid; + gap: 8px; + color: var(--muted); +} + +.auth-card input { + border: 1px solid var(--line); + background: rgba(8, 14, 26, 0.86); + color: var(--text); + border-radius: 14px; + padding: 14px 16px; +} + +.auth-copy { + margin: 0; + color: var(--muted); +} + +.sidebar { + padding: 28px; + border-right: 1px solid var(--line); + background: rgba(7, 12, 22, 0.76); + backdrop-filter: blur(18px); +} + +.nav { + display: grid; + gap: 10px; + margin-top: 40px; +} + +.nav-link { + padding: 12px 14px; + border-radius: 14px; + color: var(--muted); + transition: 180ms ease; +} + +.nav-link:hover, +.nav-link.active { + color: var(--text); + background: rgba(116, 224, 184, 0.12); +} + +.content { + padding: 28px; +} + +.topbar, +.page-header { + display: flex; + justify-content: space-between; + align-items: center; + gap: 16px; +} + +.page { + display: grid; + gap: 22px; +} + +.grid { + display: grid; + gap: 18px; +} + +.grid.two { + grid-template-columns: repeat(2, minmax(0, 1fr)); +} + +.grid.three { + grid-template-columns: repeat(3, minmax(0, 1fr)); +} + +.card, +.table-wrap { + background: var(--panel); + border: 1px solid var(--line); + border-radius: 24px; + padding: 22px; + box-shadow: var(--shadow); + backdrop-filter: blur(12px); +} + +.table { + width: 100%; + border-collapse: collapse; +} + +.table th, +.table td { + text-align: left; + padding: 14px 10px; + border-bottom: 1px solid var(--line); +} + +.table tbody tr:last-child td { + border-bottom: 0; +} + +.button, +.pill { + border: 0; + padding: 11px 16px; + border-radius: 999px; + background: linear-gradient(135deg, var(--accent) 0%, var(--accent-strong) 100%); + color: #04141a; + font-weight: 700; +} + +.ghost-button { + border: 1px solid var(--line); + padding: 11px 16px; + border-radius: 999px; + background: transparent; + color: var(--text); +} + +.pill { + display: inline-flex; + align-items: center; +} + +.eyebrow { + margin: 0 0 8px; + color: var(--accent); + text-transform: uppercase; + letter-spacing: 0.16em; + font-size: 0.75rem; +} + +.metric-label, +.page-header p, +.card p { + color: var(--muted); +} + +.metric-value { + display: block; + margin: 10px 0; + font-size: 2rem; +} + +.notice { + margin: 0; + color: #ffcf9b; +} + +@media (max-width: 960px) { + .shell { + grid-template-columns: 1fr; + } + + .sidebar { + border-right: 0; + border-bottom: 1px solid var(--line); + } + + .grid.two, + .grid.three { + grid-template-columns: 1fr; + } +} diff --git a/admin-web/tsconfig.app.json b/admin-web/tsconfig.app.json new file mode 100644 index 0000000..a3b2674 --- /dev/null +++ b/admin-web/tsconfig.app.json @@ -0,0 +1,18 @@ +{ + "compilerOptions": { + "target": "ES2020", + "useDefineForClassFields": true, + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "module": "ESNext", + "skipLibCheck": true, + "moduleResolution": "Bundler", + "allowImportingTsExtensions": false, + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "jsx": "react-jsx", + "strict": true, + "baseUrl": "./src" + }, + "include": ["src"] +} diff --git a/admin-web/tsconfig.json b/admin-web/tsconfig.json new file mode 100644 index 0000000..426eda2 --- /dev/null +++ b/admin-web/tsconfig.json @@ -0,0 +1,6 @@ +{ + "files": [], + "references": [ + { "path": "./tsconfig.app.json" } + ] +} diff --git a/admin-web/vite.config.ts b/admin-web/vite.config.ts new file mode 100644 index 0000000..ba0295d --- /dev/null +++ b/admin-web/vite.config.ts @@ -0,0 +1,10 @@ +import { defineConfig } from "vite"; +import react from "@vitejs/plugin-react"; + +export default defineConfig({ + plugins: [react()], + server: { + port: 5173, + host: true + } +}); diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000..68e8166 --- /dev/null +++ b/backend/Dockerfile @@ -0,0 +1,14 @@ +FROM golang:1.23-alpine AS builder +WORKDIR /src +COPY go.mod ./ +RUN go mod download +COPY . . +RUN CGO_ENABLED=0 GOOS=linux go build -o /out/nexavpn-api ./cmd/api + +FROM alpine:3.21 +WORKDIR /app +COPY --from=builder /out/nexavpn-api /usr/local/bin/nexavpn-api +COPY migrations ./migrations +COPY seed ./seed +EXPOSE 8080 +ENTRYPOINT ["/usr/local/bin/nexavpn-api"] diff --git a/backend/cmd/api/main.go b/backend/cmd/api/main.go new file mode 100644 index 0000000..01cff5d --- /dev/null +++ b/backend/cmd/api/main.go @@ -0,0 +1,48 @@ +package main + +import ( + "context" + "log" + "net/http" + "os" + "os/signal" + "syscall" + "time" + + "github.com/nexavpn/nexavpn/backend/internal/app" + "github.com/nexavpn/nexavpn/backend/internal/config" +) + +func main() { + cfg := config.Load() + + application, err := app.New(cfg) + if err != nil { + log.Fatalf("failed to initialize app: %v", err) + } + defer application.Close() + + server := &http.Server{ + Addr: cfg.HTTPAddress, + Handler: application.Router, + ReadHeaderTimeout: 5 * time.Second, + } + + go func() { + log.Printf("nexavpn backend listening on %s", cfg.HTTPAddress) + if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed { + log.Fatalf("http server failed: %v", err) + } + }() + + stop := make(chan os.Signal, 1) + signal.Notify(stop, syscall.SIGINT, syscall.SIGTERM) + <-stop + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + if err := server.Shutdown(ctx); err != nil { + log.Printf("server shutdown error: %v", err) + } +} diff --git a/backend/go.mod b/backend/go.mod new file mode 100644 index 0000000..195d008 --- /dev/null +++ b/backend/go.mod @@ -0,0 +1,11 @@ +module github.com/nexavpn/nexavpn/backend + +go 1.23.0 + +require ( + github.com/go-chi/chi/v5 v5.2.1 + github.com/golang-jwt/jwt/v5 v5.2.1 + github.com/google/uuid v1.6.0 + github.com/jackc/pgx/v5 v5.7.2 + golang.org/x/crypto v0.36.0 +) diff --git a/backend/internal/apiutil/respond.go b/backend/internal/apiutil/respond.go new file mode 100644 index 0000000..b078045 --- /dev/null +++ b/backend/internal/apiutil/respond.go @@ -0,0 +1,26 @@ +package apiutil + +import ( + "encoding/json" + "net/http" +) + +type ErrorResponse struct { + Error struct { + Code string `json:"code"` + Message string `json:"message"` + } `json:"error"` +} + +func JSON(w http.ResponseWriter, status int, payload any) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(payload) +} + +func Error(w http.ResponseWriter, status int, code, message string) { + resp := ErrorResponse{} + resp.Error.Code = code + resp.Error.Message = message + JSON(w, status, resp) +} diff --git a/backend/internal/app/app.go b/backend/internal/app/app.go new file mode 100644 index 0000000..a790b20 --- /dev/null +++ b/backend/internal/app/app.go @@ -0,0 +1,62 @@ +package app + +import ( + "context" + "net/http" + + "github.com/jackc/pgx/v5/pgxpool" + + "github.com/nexavpn/nexavpn/backend/internal/audit" + "github.com/nexavpn/nexavpn/backend/internal/auth" + "github.com/nexavpn/nexavpn/backend/internal/config" + "github.com/nexavpn/nexavpn/backend/internal/db" + "github.com/nexavpn/nexavpn/backend/internal/device" + "github.com/nexavpn/nexavpn/backend/internal/gateway" + "github.com/nexavpn/nexavpn/backend/internal/httpserver" + "github.com/nexavpn/nexavpn/backend/internal/ipam" + "github.com/nexavpn/nexavpn/backend/internal/policy" + "github.com/nexavpn/nexavpn/backend/internal/user" +) + +type App struct { + DB *pgxpool.Pool + Router http.Handler +} + +func New(cfg config.Config) (*App, error) { + ctx := context.Background() + + pool, err := db.Connect(ctx, cfg.DatabaseURL) + if err != nil { + return nil, err + } + + authRepo := auth.NewPGRepository(pool) + authService := auth.NewService(authRepo, cfg.JWTSecret, cfg.JWTIssuer, cfg.AccessTokenTTL, cfg.RefreshTokenTTL) + + userService := user.NewService(user.NewPGRepository(pool)) + policyService := policy.NewService(policy.NewPGRepository(pool)) + gatewayService := gateway.NewService(gateway.NewPGRepository(pool)) + deviceService := device.NewService(device.NewPGRepository(pool), policyService, gatewayService, ipam.NewService()) + auditService := audit.NewService(audit.NewPGRepository(pool)) + + router := httpserver.NewRouter(cfg.JWTSecret, httpserver.Handlers{ + Auth: auth.NewHandler(authService, auditService), + User: user.NewHandler(userService, auditService), + Device: device.NewHandler(deviceService, auditService), + Policy: policy.NewHandler(policyService, auditService), + Gateway: gateway.NewHandler(gatewayService), + Audit: audit.NewHandler(auditService), + }) + + return &App{ + DB: pool, + Router: router, + }, nil +} + +func (a *App) Close() { + if a.DB != nil { + a.DB.Close() + } +} diff --git a/backend/internal/audit/handler.go b/backend/internal/audit/handler.go new file mode 100644 index 0000000..32b9d30 --- /dev/null +++ b/backend/internal/audit/handler.go @@ -0,0 +1,25 @@ +package audit + +import ( + "net/http" + + "github.com/nexavpn/nexavpn/backend/internal/apiutil" +) + +type Handler struct { + service *Service +} + +func NewHandler(service *Service) *Handler { + return &Handler{service: service} +} + +func (h *Handler) List(w http.ResponseWriter, r *http.Request) { + items, err := h.service.List(r.Context(), 100) + if err != nil { + apiutil.Error(w, http.StatusInternalServerError, "audit_list_failed", "unable to list audit logs") + return + } + + apiutil.JSON(w, http.StatusOK, items) +} diff --git a/backend/internal/audit/repository.go b/backend/internal/audit/repository.go new file mode 100644 index 0000000..4049f7d --- /dev/null +++ b/backend/internal/audit/repository.go @@ -0,0 +1,70 @@ +package audit + +import ( + "context" + + "github.com/google/uuid" + "github.com/jackc/pgx/v5/pgxpool" +) + +type PGRepository struct { + db *pgxpool.Pool +} + +func NewPGRepository(db *pgxpool.Pool) *PGRepository { + return &PGRepository{db: db} +} + +func (r *PGRepository) Write(ctx context.Context, entry Entry) error { + const query = ` + insert into audit_logs (id, actor_user_id, entity_type, entity_id, event_type, status, message, metadata) + values ($1, $2, $3, $4, $5, $6, $7, $8::jsonb) + ` + + _, err := r.db.Exec( + ctx, + query, + uuid.New(), + entry.ActorUserID, + entry.EntityType, + entry.EntityID, + entry.EventType, + entry.Status, + entry.Message, + MarshalMetadata(entry.Metadata), + ) + return err +} + +func (r *PGRepository) List(ctx context.Context, limit int) ([]map[string]any, error) { + rows, err := r.db.Query(ctx, ` + select id, event_type, entity_type, status, message, created_at + from audit_logs + order by created_at desc + limit $1 + `, limit) + if err != nil { + return nil, err + } + defer rows.Close() + + var entries []map[string]any + for rows.Next() { + var id uuid.UUID + var eventType, entityType, status, message string + var createdAt any + if err := rows.Scan(&id, &eventType, &entityType, &status, &message, &createdAt); err != nil { + return nil, err + } + entries = append(entries, map[string]any{ + "id": id, + "event_type": eventType, + "entity_type": entityType, + "status": status, + "message": message, + "created_at": createdAt, + }) + } + + return entries, rows.Err() +} diff --git a/backend/internal/audit/service.go b/backend/internal/audit/service.go new file mode 100644 index 0000000..0742b37 --- /dev/null +++ b/backend/internal/audit/service.go @@ -0,0 +1,47 @@ +package audit + +import ( + "context" + "encoding/json" + + "github.com/google/uuid" +) + +type Entry struct { + ActorUserID *uuid.UUID + EntityType string + EntityID *uuid.UUID + EventType string + Status string + Message string + Metadata map[string]any +} + +type Repository interface { + Write(ctx context.Context, entry Entry) error + List(ctx context.Context, limit int) ([]map[string]any, error) +} + +type Service struct { + repo Repository +} + +func NewService(repo Repository) *Service { + return &Service{repo: repo} +} + +func (s *Service) Record(ctx context.Context, entry Entry) error { + if entry.Metadata == nil { + entry.Metadata = map[string]any{} + } + return s.repo.Write(ctx, entry) +} + +func (s *Service) List(ctx context.Context, limit int) ([]map[string]any, error) { + return s.repo.List(ctx, limit) +} + +func MarshalMetadata(metadata map[string]any) []byte { + raw, _ := json.Marshal(metadata) + return raw +} diff --git a/backend/internal/auth/handler.go b/backend/internal/auth/handler.go new file mode 100644 index 0000000..91f9033 --- /dev/null +++ b/backend/internal/auth/handler.go @@ -0,0 +1,137 @@ +package auth + +import ( + "encoding/json" + "net/http" + + "github.com/nexavpn/nexavpn/backend/internal/apiutil" + "github.com/nexavpn/nexavpn/backend/internal/audit" + "github.com/nexavpn/nexavpn/backend/internal/httpserver" +) + +type Handler struct { + service *Service + audit *audit.Service +} + +func NewHandler(service *Service, auditService *audit.Service) *Handler { + return &Handler{service: service, audit: auditService} +} + +func (h *Handler) Login(w http.ResponseWriter, r *http.Request) { + var input LoginRequest + if err := json.NewDecoder(r.Body).Decode(&input); err != nil { + apiutil.Error(w, http.StatusBadRequest, "invalid_json", "invalid request body") + return + } + + response, err := h.service.Login(r.Context(), input.Username, input.Password, r.RemoteAddr, r.UserAgent()) + if err != nil { + _ = h.audit.Record(r.Context(), audit.Entry{ + EventType: "auth.login.failed", + EntityType: "user", + Status: "failed", + Message: "user login failed", + Metadata: map[string]any{ + "username": input.Username, + }, + }) + apiutil.Error(w, http.StatusUnauthorized, "invalid_credentials", "invalid username or password") + return + } + + _ = h.audit.Record(r.Context(), audit.Entry{ + ActorUserID: &response.User.ID, + EventType: "auth.login", + EntityType: "user", + EntityID: &response.User.ID, + Status: "success", + Message: "user login succeeded", + }) + apiutil.JSON(w, http.StatusOK, response) +} + +func (h *Handler) Bootstrap(w http.ResponseWriter, r *http.Request) { + var input BootstrapRequest + if err := json.NewDecoder(r.Body).Decode(&input); err != nil { + apiutil.Error(w, http.StatusBadRequest, "invalid_json", "invalid request body") + return + } + if input.Username == "" || input.Password == "" { + apiutil.Error(w, http.StatusBadRequest, "validation_error", "username and password are required") + return + } + if input.DisplayName == "" { + input.DisplayName = input.Username + } + + user, err := h.service.BootstrapAdmin(r.Context(), input.Username, input.DisplayName, input.Password) + if err != nil { + apiutil.Error(w, http.StatusConflict, "bootstrap_failed", "initial admin already exists or could not be created") + return + } + + _ = h.audit.Record(r.Context(), audit.Entry{ + ActorUserID: &user.ID, + EntityType: "user", + EntityID: &user.ID, + EventType: "system.bootstrap_admin", + Status: "success", + Message: "initial admin account created", + }) + apiutil.JSON(w, http.StatusCreated, user) +} + +func (h *Handler) Refresh(w http.ResponseWriter, r *http.Request) { + var input RefreshRequest + if err := json.NewDecoder(r.Body).Decode(&input); err != nil { + apiutil.Error(w, http.StatusBadRequest, "invalid_json", "invalid request body") + return + } + + response, err := h.service.Refresh(r.Context(), input.RefreshToken) + if err != nil { + apiutil.Error(w, http.StatusUnauthorized, "invalid_refresh_token", "unable to refresh session") + return + } + + apiutil.JSON(w, http.StatusOK, response) +} + +func (h *Handler) Logout(w http.ResponseWriter, r *http.Request) { + var input RefreshRequest + if err := json.NewDecoder(r.Body).Decode(&input); err != nil { + apiutil.Error(w, http.StatusBadRequest, "invalid_json", "invalid request body") + return + } + + if err := h.service.Logout(r.Context(), input.RefreshToken); err != nil { + apiutil.Error(w, http.StatusBadRequest, "logout_failed", "unable to revoke session") + return + } + + if claims, ok := httpserver.ClaimsFromContext(r.Context()); ok { + _ = h.audit.Record(r.Context(), audit.Entry{ + ActorUserID: &claims.UserID, + EventType: "auth.logout", + EntityType: "session", + Status: "success", + Message: "session logout succeeded", + }) + } + apiutil.JSON(w, http.StatusOK, map[string]any{"ok": true}) +} + +func (h *Handler) Me(w http.ResponseWriter, r *http.Request) { + claims, ok := httpserver.ClaimsFromContext(r.Context()) + if !ok { + apiutil.Error(w, http.StatusUnauthorized, "unauthorized", "missing auth claims") + return + } + + apiutil.JSON(w, http.StatusOK, map[string]any{ + "id": claims.UserID, + "username": claims.Username, + "role": claims.Role, + }) +} diff --git a/backend/internal/auth/hash.go b/backend/internal/auth/hash.go new file mode 100644 index 0000000..eab7fee --- /dev/null +++ b/backend/internal/auth/hash.go @@ -0,0 +1,11 @@ +package auth + +import ( + "crypto/sha256" + "encoding/base64" +) + +func base64Hash(value string) string { + sum := sha256.Sum256([]byte(value)) + return base64.RawURLEncoding.EncodeToString(sum[:]) +} diff --git a/backend/internal/auth/password.go b/backend/internal/auth/password.go new file mode 100644 index 0000000..8144b8c --- /dev/null +++ b/backend/internal/auth/password.go @@ -0,0 +1,40 @@ +package auth + +import ( + "crypto/rand" + "crypto/subtle" + "encoding/base64" + "fmt" + "strings" + + "golang.org/x/crypto/argon2" +) + +func HashPassword(password string) (string, error) { + salt := make([]byte, 16) + if _, err := rand.Read(salt); err != nil { + return "", err + } + + hash := argon2.IDKey([]byte(password), salt, 1, 64*1024, 4, 32) + return fmt.Sprintf("argon2id$%s$%s", base64.RawStdEncoding.EncodeToString(salt), base64.RawStdEncoding.EncodeToString(hash)), nil +} + +func VerifyPassword(hashValue, password string) bool { + parts := strings.Split(hashValue, "$") + if len(parts) != 3 || parts[0] != "argon2id" { + return false + } + + salt, err := base64.RawStdEncoding.DecodeString(parts[1]) + if err != nil { + return false + } + expected, err := base64.RawStdEncoding.DecodeString(parts[2]) + if err != nil { + return false + } + + actual := argon2.IDKey([]byte(password), salt, 1, 64*1024, 4, 32) + return subtle.ConstantTimeCompare(expected, actual) == 1 +} diff --git a/backend/internal/auth/repository.go b/backend/internal/auth/repository.go new file mode 100644 index 0000000..f0eabe2 --- /dev/null +++ b/backend/internal/auth/repository.go @@ -0,0 +1,105 @@ +package auth + +import ( + "context" + "errors" + "time" + + "github.com/google/uuid" + "github.com/jackc/pgx/v5/pgxpool" +) + +type PGRepository struct { + db *pgxpool.Pool +} + +func NewPGRepository(db *pgxpool.Pool) *PGRepository { + return &PGRepository{db: db} +} + +func (r *PGRepository) FindUserByUsername(ctx context.Context, username string) (UserRecord, error) { + const query = ` + select u.id, u.username, u.display_name, r.name, u.password_hash, u.is_active + from users u + join roles r on r.id = u.role_id + where u.username = $1 and u.deleted_at is null + ` + + row := r.db.QueryRow(ctx, query, username) + record := UserRecord{} + if err := row.Scan(&record.ID, &record.Username, &record.DisplayName, &record.Role, &record.PasswordHash, &record.IsActive); err != nil { + return UserRecord{}, err + } + + return record, nil +} + +func (r *PGRepository) CreateSession(ctx context.Context, userID uuid.UUID, expiresAt time.Time, ipAddress string, userAgent string) (uuid.UUID, error) { + const query = ` + insert into sessions (id, user_id, ip_address, user_agent, expires_at) + values ($1, $2, nullif($3, '')::inet, $4, $5) + ` + + id := uuid.New() + _, err := r.db.Exec(ctx, query, id, userID, ipAddress, userAgent, expiresAt) + return id, err +} + +func (r *PGRepository) StoreRefreshToken(ctx context.Context, sessionID uuid.UUID, userID uuid.UUID, tokenHash string, expiresAt time.Time) error { + const query = ` + insert into refresh_tokens (id, session_id, user_id, token_hash, expires_at) + values ($1, $2, $3, $4, $5) + ` + + _, err := r.db.Exec(ctx, query, uuid.New(), sessionID, userID, tokenHash, expiresAt) + return err +} + +func (r *PGRepository) FindRefreshToken(ctx context.Context, tokenHash string) (UserRecord, uuid.UUID, error) { + const query = ` + select u.id, u.username, u.display_name, roles.name, u.password_hash, u.is_active, rt.session_id + from refresh_tokens rt + join users u on u.id = rt.user_id + join roles on roles.id = u.role_id + where rt.token_hash = $1 and rt.revoked_at is null and rt.expires_at > now() + ` + + record := UserRecord{} + var sessionID uuid.UUID + row := r.db.QueryRow(ctx, query, tokenHash) + if err := row.Scan(&record.ID, &record.Username, &record.DisplayName, &record.Role, &record.PasswordHash, &record.IsActive, &sessionID); err != nil { + return UserRecord{}, uuid.Nil, err + } + if !record.IsActive { + return UserRecord{}, uuid.Nil, errors.New("user inactive") + } + + return record, sessionID, nil +} + +func (r *PGRepository) RevokeRefreshToken(ctx context.Context, tokenHash string) error { + const query = `update refresh_tokens set revoked_at = now() where token_hash = $1 and revoked_at is null` + _, err := r.db.Exec(ctx, query, tokenHash) + return err +} + +func (r *PGRepository) HasUsers(ctx context.Context) (bool, error) { + var count int + if err := r.db.QueryRow(ctx, `select count(*) from users where deleted_at is null`).Scan(&count); err != nil { + return false, err + } + return count > 0, nil +} + +func (r *PGRepository) CreateBootstrapAdmin(ctx context.Context, username, displayName, passwordHash string) (UserRecord, error) { + const query = ` + insert into users (id, role_id, username, display_name, password_hash, is_active) + values ($1, (select id from roles where name = 'admin'), $2, $3, $4, true) + returning id, username, display_name, password_hash, is_active + ` + + record := UserRecord{Role: "admin"} + err := r.db.QueryRow(ctx, query, uuid.New(), username, displayName, passwordHash). + Scan(&record.ID, &record.Username, &record.DisplayName, &record.PasswordHash, &record.IsActive) + return record, err +} diff --git a/backend/internal/auth/service.go b/backend/internal/auth/service.go new file mode 100644 index 0000000..28df325 --- /dev/null +++ b/backend/internal/auth/service.go @@ -0,0 +1,155 @@ +package auth + +import ( + "context" + "errors" + "time" + + "github.com/google/uuid" +) + +var ErrInvalidCredentials = errors.New("invalid credentials") + +type UserRecord struct { + ID uuid.UUID + Username string + DisplayName string + Role string + PasswordHash string + IsActive bool +} + +type Repository interface { + FindUserByUsername(ctx context.Context, username string) (UserRecord, error) + CreateSession(ctx context.Context, userID uuid.UUID, expiresAt time.Time, ipAddress string, userAgent string) (uuid.UUID, error) + StoreRefreshToken(ctx context.Context, sessionID uuid.UUID, userID uuid.UUID, tokenHash string, expiresAt time.Time) error + FindRefreshToken(ctx context.Context, tokenHash string) (UserRecord, uuid.UUID, error) + RevokeRefreshToken(ctx context.Context, tokenHash string) error + HasUsers(ctx context.Context) (bool, error) + CreateBootstrapAdmin(ctx context.Context, username, displayName, passwordHash string) (UserRecord, error) +} + +type Service struct { + repo Repository + jwtSecret string + jwtIssuer string + accessTokenTTL time.Duration + refreshTokenTTL time.Duration +} + +func NewService(repo Repository, jwtSecret, jwtIssuer string, accessTokenTTL, refreshTokenTTL time.Duration) *Service { + return &Service{ + repo: repo, + jwtSecret: jwtSecret, + jwtIssuer: jwtIssuer, + accessTokenTTL: accessTokenTTL, + refreshTokenTTL: refreshTokenTTL, + } +} + +func (s *Service) Login(ctx context.Context, username, password, ipAddress, userAgent string) (LoginResponse, error) { + record, err := s.repo.FindUserByUsername(ctx, username) + if err != nil || !record.IsActive || !VerifyPassword(record.PasswordHash, password) { + return LoginResponse{}, ErrInvalidCredentials + } + + sessionID, err := s.repo.CreateSession(ctx, record.ID, time.Now().Add(s.refreshTokenTTL), ipAddress, userAgent) + if err != nil { + return LoginResponse{}, err + } + + plainRefresh, hashedRefresh, err := NewRefreshToken() + if err != nil { + return LoginResponse{}, err + } + + if err := s.repo.StoreRefreshToken(ctx, sessionID, record.ID, hashedRefresh, time.Now().Add(s.refreshTokenTTL)); err != nil { + return LoginResponse{}, err + } + + access, err := SignAccessToken(s.jwtSecret, s.jwtIssuer, s.accessTokenTTL, Claims{ + UserID: record.ID, + Username: record.Username, + Role: record.Role, + Session: sessionID, + }) + if err != nil { + return LoginResponse{}, err + } + + return LoginResponse{ + AccessToken: access, + RefreshToken: plainRefresh, + ExpiresIn: int64(s.accessTokenTTL.Seconds()), + User: UserView{ + ID: record.ID, + Username: record.Username, + DisplayName: record.DisplayName, + Role: record.Role, + }, + }, nil +} + +func (s *Service) Refresh(ctx context.Context, refreshToken string) (LoginResponse, error) { + record, sessionID, err := s.repo.FindRefreshToken(ctx, hashToken(refreshToken)) + if err != nil { + return LoginResponse{}, ErrInvalidCredentials + } + + access, err := SignAccessToken(s.jwtSecret, s.jwtIssuer, s.accessTokenTTL, Claims{ + UserID: record.ID, + Username: record.Username, + Role: record.Role, + Session: sessionID, + }) + if err != nil { + return LoginResponse{}, err + } + + return LoginResponse{ + AccessToken: access, + RefreshToken: refreshToken, + ExpiresIn: int64(s.accessTokenTTL.Seconds()), + User: UserView{ + ID: record.ID, + Username: record.Username, + DisplayName: record.DisplayName, + Role: record.Role, + }, + }, nil +} + +func (s *Service) Logout(ctx context.Context, refreshToken string) error { + return s.repo.RevokeRefreshToken(ctx, hashToken(refreshToken)) +} + +func (s *Service) BootstrapAdmin(ctx context.Context, username, displayName, password string) (UserView, error) { + hasUsers, err := s.repo.HasUsers(ctx) + if err != nil { + return UserView{}, err + } + if hasUsers { + return UserView{}, errors.New("bootstrap already completed") + } + + passwordHash, err := HashPassword(password) + if err != nil { + return UserView{}, err + } + + record, err := s.repo.CreateBootstrapAdmin(ctx, username, displayName, passwordHash) + if err != nil { + return UserView{}, err + } + + return UserView{ + ID: record.ID, + Username: record.Username, + DisplayName: record.DisplayName, + Role: record.Role, + }, nil +} + +func hashToken(plain string) string { + return base64Hash(plain) +} diff --git a/backend/internal/auth/token.go b/backend/internal/auth/token.go new file mode 100644 index 0000000..a24c76a --- /dev/null +++ b/backend/internal/auth/token.go @@ -0,0 +1,77 @@ +package auth + +import ( + "crypto/rand" + "crypto/sha256" + "encoding/base64" + "time" + + "github.com/golang-jwt/jwt/v5" + "github.com/google/uuid" +) + +func NewRefreshToken() (plain string, hashed string, err error) { + raw := make([]byte, 32) + if _, err = rand.Read(raw); err != nil { + return "", "", err + } + + plain = base64.RawURLEncoding.EncodeToString(raw) + sum := sha256.Sum256([]byte(plain)) + hashed = base64.RawURLEncoding.EncodeToString(sum[:]) + return plain, hashed, nil +} + +func SignAccessToken(secret, issuer string, ttl time.Duration, claims Claims) (string, error) { + token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{ + "iss": issuer, + "sub": claims.UserID.String(), + "username": claims.Username, + "role": claims.Role, + "session_id": claims.Session.String(), + "exp": time.Now().Add(ttl).Unix(), + "iat": time.Now().Unix(), + }) + + return token.SignedString([]byte(secret)) +} + +func ParseAccessToken(secret string, tokenString string) (Claims, error) { + claims := Claims{} + + token, err := jwt.Parse(tokenString, func(token *jwt.Token) (any, error) { + return []byte(secret), nil + }) + if err != nil || !token.Valid { + return claims, err + } + + mapClaims, ok := token.Claims.(jwt.MapClaims) + if !ok { + return claims, jwt.ErrTokenMalformed + } + + subject, ok := mapClaims["sub"].(string) + if !ok { + return claims, jwt.ErrTokenMalformed + } + sessionValue, ok := mapClaims["session_id"].(string) + if !ok { + return claims, jwt.ErrTokenMalformed + } + + userID, err := uuid.Parse(subject) + if err != nil { + return claims, err + } + sessionID, err := uuid.Parse(sessionValue) + if err != nil { + return claims, err + } + + claims.UserID = userID + claims.Session = sessionID + claims.Username, _ = mapClaims["username"].(string) + claims.Role, _ = mapClaims["role"].(string) + return claims, nil +} diff --git a/backend/internal/auth/types.go b/backend/internal/auth/types.go new file mode 100644 index 0000000..5abfa53 --- /dev/null +++ b/backend/internal/auth/types.go @@ -0,0 +1,39 @@ +package auth + +import "github.com/google/uuid" + +type Claims struct { + UserID uuid.UUID `json:"user_id"` + Username string `json:"username"` + Role string `json:"role"` + Session uuid.UUID `json:"session_id"` +} + +type LoginRequest struct { + Username string `json:"username"` + Password string `json:"password"` +} + +type RefreshRequest struct { + RefreshToken string `json:"refresh_token"` +} + +type BootstrapRequest struct { + Username string `json:"username"` + DisplayName string `json:"display_name"` + Password string `json:"password"` +} + +type LoginResponse struct { + AccessToken string `json:"access_token"` + RefreshToken string `json:"refresh_token"` + ExpiresIn int64 `json:"expires_in"` + User UserView `json:"user"` +} + +type UserView struct { + ID uuid.UUID `json:"id"` + Username string `json:"username"` + DisplayName string `json:"display_name"` + Role string `json:"role"` +} diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go new file mode 100644 index 0000000..060779a --- /dev/null +++ b/backend/internal/config/config.go @@ -0,0 +1,82 @@ +package config + +import ( + "os" + "strconv" + "time" +) + +type Config struct { + AppName string + Environment string + HTTPAddress string + DatabaseURL string + JWTIssuer string + JWTSecret string + AccessTokenTTL time.Duration + RefreshTokenTTL time.Duration + DefaultGatewayID string + DefaultDNS []string + DefaultVPNCIDR string + DefaultGatewayHost string + DefaultGatewayPubKey string +} + +func Load() Config { + return Config{ + AppName: getenv("APP_NAME", "NexaVPN"), + Environment: getenv("APP_ENV", "development"), + HTTPAddress: getenv("HTTP_ADDRESS", ":8080"), + DatabaseURL: getenv("DATABASE_URL", "postgres://nexavpn:nexavpn@localhost:5432/nexavpn?sslmode=disable"), + JWTIssuer: getenv("JWT_ISSUER", "nexavpn"), + JWTSecret: getenv("JWT_SECRET", "change-me-in-production"), + AccessTokenTTL: time.Duration(getenvInt("ACCESS_TOKEN_TTL_SECONDS", 900)) * time.Second, + RefreshTokenTTL: time.Duration(getenvInt("REFRESH_TOKEN_TTL_SECONDS", 2592000)) * time.Second, + DefaultGatewayID: getenv("DEFAULT_GATEWAY_ID", ""), + DefaultDNS: splitCSV(getenv("DEFAULT_DNS_SERVERS", "10.20.0.53")), + DefaultVPNCIDR: getenv("DEFAULT_VPN_CIDR", "100.96.0.0/24"), + DefaultGatewayHost: getenv("DEFAULT_GATEWAY_ENDPOINT", "vpn.example.com:51820"), + DefaultGatewayPubKey: getenv("DEFAULT_GATEWAY_PUBLIC_KEY", "replace-me"), + } +} + +func getenv(key, fallback string) string { + if value := os.Getenv(key); value != "" { + return value + } + + return fallback +} + +func getenvInt(key string, fallback int) int { + if value := os.Getenv(key); value != "" { + parsed, err := strconv.Atoi(value) + if err == nil { + return parsed + } + } + + return fallback +} + +func splitCSV(value string) []string { + if value == "" { + return nil + } + + var items []string + start := 0 + for i := range value { + if value[i] == ',' { + if start < i { + items = append(items, value[start:i]) + } + start = i + 1 + } + } + if start < len(value) { + items = append(items, value[start:]) + } + + return items +} diff --git a/backend/internal/db/db.go b/backend/internal/db/db.go new file mode 100644 index 0000000..164b950 --- /dev/null +++ b/backend/internal/db/db.go @@ -0,0 +1,11 @@ +package db + +import ( + "context" + + "github.com/jackc/pgx/v5/pgxpool" +) + +func Connect(ctx context.Context, databaseURL string) (*pgxpool.Pool, error) { + return pgxpool.New(ctx, databaseURL) +} diff --git a/backend/internal/device/handler.go b/backend/internal/device/handler.go new file mode 100644 index 0000000..2a6e1f4 --- /dev/null +++ b/backend/internal/device/handler.go @@ -0,0 +1,176 @@ +package device + +import ( + "encoding/json" + "net/http" + + "github.com/go-chi/chi/v5" + "github.com/google/uuid" + + "github.com/nexavpn/nexavpn/backend/internal/apiutil" + "github.com/nexavpn/nexavpn/backend/internal/audit" + "github.com/nexavpn/nexavpn/backend/internal/httpserver" +) + +type Handler struct { + service *Service + audit *audit.Service +} + +func NewHandler(service *Service, auditService *audit.Service) *Handler { + return &Handler{service: service, audit: auditService} +} + +func (h *Handler) Enroll(w http.ResponseWriter, r *http.Request) { + var input EnrollRequest + if err := json.NewDecoder(r.Body).Decode(&input); err != nil { + apiutil.Error(w, http.StatusBadRequest, "invalid_json", "invalid request body") + return + } + + userID, ok := httpserver.MustUserID(r.Context()) + if !ok { + apiutil.Error(w, http.StatusUnauthorized, "unauthorized", "missing auth claims") + return + } + + response, err := h.service.Enroll(r.Context(), userID, input, "__CLIENT_GENERATED_PRIVATE_KEY__") + if err != nil { + apiutil.Error(w, http.StatusInternalServerError, "device_enroll_failed", "unable to enroll device") + return + } + + _ = h.audit.Record(r.Context(), audit.Entry{ + ActorUserID: &userID, + EntityType: "device", + EntityID: &response.Device.ID, + EventType: "device.enrolled", + Status: "success", + Message: "device enrolled and profile issued", + Metadata: map[string]any{ + "platform": response.Device.Platform, + "assigned_ip": response.Peer.AssignedIP, + }, + }) + apiutil.JSON(w, http.StatusCreated, response) +} + +func (h *Handler) ListOwn(w http.ResponseWriter, r *http.Request) { + userID, ok := httpserver.MustUserID(r.Context()) + if !ok { + apiutil.Error(w, http.StatusUnauthorized, "unauthorized", "missing auth claims") + return + } + + devices, err := h.service.ListByUser(r.Context(), userID) + if err != nil { + apiutil.Error(w, http.StatusInternalServerError, "devices_list_failed", "unable to list devices") + return + } + + apiutil.JSON(w, http.StatusOK, devices) +} + +func (h *Handler) ListAll(w http.ResponseWriter, r *http.Request) { + devices, err := h.service.ListAll(r.Context()) + if err != nil { + apiutil.Error(w, http.StatusInternalServerError, "devices_list_failed", "unable to list devices") + return + } + + apiutil.JSON(w, http.StatusOK, devices) +} + +func (h *Handler) ConnectionStatus(w http.ResponseWriter, r *http.Request) { + userID, ok := httpserver.MustUserID(r.Context()) + if !ok { + apiutil.Error(w, http.StatusUnauthorized, "unauthorized", "missing auth claims") + return + } + + status, err := h.service.GetConnectionStatus(r.Context(), userID) + if err != nil { + apiutil.Error(w, http.StatusInternalServerError, "connection_status_failed", "unable to fetch connection status") + return + } + + apiutil.JSON(w, http.StatusOK, status) +} + +func (h *Handler) GetOwnProfile(w http.ResponseWriter, r *http.Request) { + userID, ok := httpserver.MustUserID(r.Context()) + if !ok { + apiutil.Error(w, http.StatusUnauthorized, "unauthorized", "missing auth claims") + return + } + + response, err := h.service.GetLatestEnrollmentByUser(r.Context(), userID) + if err != nil { + apiutil.Error(w, http.StatusNotFound, "profile_not_found", "no active profile found") + return + } + + apiutil.JSON(w, http.StatusOK, response) +} + +func (h *Handler) GetProfileByDeviceID(w http.ResponseWriter, r *http.Request) { + deviceID, err := uuid.Parse(chi.URLParam(r, "id")) + if err != nil { + apiutil.Error(w, http.StatusBadRequest, "invalid_device_id", "invalid device id") + return + } + + response, err := h.service.GetEnrollmentByDeviceID(r.Context(), deviceID) + if err != nil { + apiutil.Error(w, http.StatusNotFound, "profile_not_found", "device profile not found") + return + } + + apiutil.JSON(w, http.StatusOK, response) +} + +func (h *Handler) Revoke(w http.ResponseWriter, r *http.Request) { + deviceID, err := uuid.Parse(chi.URLParam(r, "id")) + if err != nil { + apiutil.Error(w, http.StatusBadRequest, "invalid_device_id", "invalid device id") + return + } + if err := h.service.Revoke(r.Context(), deviceID); err != nil { + apiutil.Error(w, http.StatusInternalServerError, "device_revoke_failed", "unable to revoke device") + return + } + if claims, ok := httpserver.ClaimsFromContext(r.Context()); ok { + _ = h.audit.Record(r.Context(), audit.Entry{ + ActorUserID: &claims.UserID, + EntityType: "device", + EntityID: &deviceID, + EventType: "admin.device.revoked", + Status: "success", + Message: "admin revoked device", + }) + } + apiutil.JSON(w, http.StatusOK, map[string]any{"ok": true}) +} + +func (h *Handler) Rotate(w http.ResponseWriter, r *http.Request) { + deviceID, err := uuid.Parse(chi.URLParam(r, "id")) + if err != nil { + apiutil.Error(w, http.StatusBadRequest, "invalid_device_id", "invalid device id") + return + } + if err := h.service.Rotate(r.Context(), deviceID); err != nil { + apiutil.Error(w, http.StatusInternalServerError, "device_rotate_failed", "unable to rotate device profile") + return + } + if claims, ok := httpserver.ClaimsFromContext(r.Context()); ok { + _ = h.audit.Record(r.Context(), audit.Entry{ + ActorUserID: &claims.UserID, + EntityType: "device", + EntityID: &deviceID, + EventType: "admin.device.rotated", + Status: "success", + Message: "admin rotated device profile", + }) + } + apiutil.JSON(w, http.StatusOK, map[string]any{"ok": true}) +} diff --git a/backend/internal/device/repository.go b/backend/internal/device/repository.go new file mode 100644 index 0000000..3b79f10 --- /dev/null +++ b/backend/internal/device/repository.go @@ -0,0 +1,265 @@ +package device + +import ( + "context" + "time" + + "github.com/google/uuid" + "github.com/jackc/pgx/v5/pgxpool" +) + +type Repository interface { + Enroll(ctx context.Context, userID uuid.UUID, gatewayID uuid.UUID, input EnrollRequest, assignedIP string, dnsServers []string, allowedIPs []string) (EnrollmentResponse, error) + ListByUser(ctx context.Context, userID uuid.UUID) ([]Device, error) + ListAll(ctx context.Context) ([]Device, error) + GetLatestEnrollmentByUser(ctx context.Context, userID uuid.UUID) (EnrollmentResponse, error) + GetEnrollmentByDeviceID(ctx context.Context, deviceID uuid.UUID) (EnrollmentResponse, error) + Revoke(ctx context.Context, deviceID uuid.UUID) error + Rotate(ctx context.Context, deviceID uuid.UUID) error +} + +type PGRepository struct { + db *pgxpool.Pool +} + +func NewPGRepository(db *pgxpool.Pool) *PGRepository { + return &PGRepository{db: db} +} + +func (r *PGRepository) Enroll(ctx context.Context, userID uuid.UUID, gatewayID uuid.UUID, input EnrollRequest, assignedIP string, dnsServers []string, allowedIPs []string) (EnrollmentResponse, error) { + tx, err := r.db.Begin(ctx) + if err != nil { + return EnrollmentResponse{}, err + } + defer tx.Rollback(ctx) + + deviceID := uuid.New() + peerID := uuid.New() + + _, err = tx.Exec(ctx, ` + insert into devices ( + id, user_id, gateway_id, name, platform, os_version, app_version, device_fingerprint, public_key, status, approved_at + ) values ($1, $2, $3, $4, $5, $6, $7, $8, $9, 'active', now()) + `, deviceID, userID, gatewayID, input.Name, input.Platform, input.OSVersion, input.AppVersion, input.DeviceFingerprint, input.PublicKey) + if err != nil { + return EnrollmentResponse{}, err + } + + _, err = tx.Exec(ctx, ` + insert into wireguard_peers ( + id, device_id, gateway_id, public_key, assigned_ip, allowed_ips, dns_servers, last_profile_issued_at + ) values ($1, $2, $3, $4, $5::inet, $6::cidr[], $7::text[], now()) + `, peerID, deviceID, gatewayID, input.PublicKey, assignedIP, allowedIPs, dnsServers) + if err != nil { + return EnrollmentResponse{}, err + } + + _, err = tx.Exec(ctx, ` + insert into ip_allocations (id, gateway_id, device_id, address, status) + values ($1, $2, $3, $4::inet, 'allocated') + `, uuid.New(), gatewayID, deviceID, assignedIP) + if err != nil { + return EnrollmentResponse{}, err + } + + if err := tx.Commit(ctx); err != nil { + return EnrollmentResponse{}, err + } + + return EnrollmentResponse{ + Device: Device{ + ID: deviceID, + UserID: userID, + GatewayID: gatewayID, + Name: input.Name, + Platform: input.Platform, + Status: "active", + AssignedIP: assignedIP, + }, + }, nil +} + +func (r *PGRepository) GetLatestEnrollmentByUser(ctx context.Context, userID uuid.UUID) (EnrollmentResponse, error) { + row := r.db.QueryRow(ctx, ` + select + d.id, + d.user_id, + d.gateway_id, + d.name, + d.platform, + d.status, + host(wp.assigned_ip), + wp.profile_revision, + wp.last_profile_issued_at, + g.name, + g.endpoint, + g.public_key, + wp.dns_servers, + wp.allowed_ips + from devices d + join wireguard_peers wp on wp.device_id = d.id and wp.deleted_at is null + join gateways g on g.id = wp.gateway_id + where d.user_id = $1 and d.deleted_at is null + order by d.created_at desc + limit 1 + `, userID) + return scanEnrollmentRow(row) +} + +func (r *PGRepository) GetEnrollmentByDeviceID(ctx context.Context, deviceID uuid.UUID) (EnrollmentResponse, error) { + row := r.db.QueryRow(ctx, ` + select + d.id, + d.user_id, + d.gateway_id, + d.name, + d.platform, + d.status, + host(wp.assigned_ip), + wp.profile_revision, + wp.last_profile_issued_at, + g.name, + g.endpoint, + g.public_key, + wp.dns_servers, + wp.allowed_ips + from devices d + join wireguard_peers wp on wp.device_id = d.id and wp.deleted_at is null + join gateways g on g.id = wp.gateway_id + where d.id = $1 and d.deleted_at is null + `, deviceID) + return scanEnrollmentRow(row) +} + +func (r *PGRepository) ListByUser(ctx context.Context, userID uuid.UUID) ([]Device, error) { + rows, err := r.db.Query(ctx, ` + select d.id, d.user_id, d.gateway_id, d.name, d.platform, d.status, coalesce(host(wp.assigned_ip), '') + from devices d + left join wireguard_peers wp on wp.device_id = d.id and wp.deleted_at is null + where d.user_id = $1 and d.deleted_at is null + order by d.created_at desc + `, userID) + if err != nil { + return nil, err + } + defer rows.Close() + + var items []Device + for rows.Next() { + var item Device + if err := rows.Scan(&item.ID, &item.UserID, &item.GatewayID, &item.Name, &item.Platform, &item.Status, &item.AssignedIP); err != nil { + return nil, err + } + items = append(items, item) + } + return items, rows.Err() +} + +func (r *PGRepository) ListAll(ctx context.Context) ([]Device, error) { + rows, err := r.db.Query(ctx, ` + select d.id, d.user_id, d.gateway_id, d.name, d.platform, d.status, coalesce(host(wp.assigned_ip), '') + from devices d + left join wireguard_peers wp on wp.device_id = d.id and wp.deleted_at is null + where d.deleted_at is null + order by d.created_at desc + `) + if err != nil { + return nil, err + } + defer rows.Close() + + var items []Device + for rows.Next() { + var item Device + if err := rows.Scan(&item.ID, &item.UserID, &item.GatewayID, &item.Name, &item.Platform, &item.Status, &item.AssignedIP); err != nil { + return nil, err + } + items = append(items, item) + } + return items, rows.Err() +} + +func (r *PGRepository) Revoke(ctx context.Context, deviceID uuid.UUID) error { + tx, err := r.db.Begin(ctx) + if err != nil { + return err + } + defer tx.Rollback(ctx) + + if _, err := tx.Exec(ctx, `update devices set status = 'revoked', revoked_at = now(), updated_at = now() where id = $1`, deviceID); err != nil { + return err + } + if _, err := tx.Exec(ctx, `update wireguard_peers set deleted_at = now(), updated_at = now() where device_id = $1 and deleted_at is null`, deviceID); err != nil { + return err + } + if _, err := tx.Exec(ctx, `update ip_allocations set status = 'released', released_at = now(), updated_at = now() where device_id = $1 and status = 'allocated'`, deviceID); err != nil { + return err + } + + return tx.Commit(ctx) +} + +func (r *PGRepository) Rotate(ctx context.Context, deviceID uuid.UUID) error { + _, err := r.db.Exec(ctx, ` + update wireguard_peers + set profile_revision = profile_revision + 1, last_profile_issued_at = now(), updated_at = now() + where device_id = $1 and deleted_at is null + `, deviceID) + return err +} + +type enrollmentRowScanner interface { + Scan(dest ...any) error +} + +func scanEnrollmentRow(row enrollmentRowScanner) (EnrollmentResponse, error) { + var response EnrollmentResponse + var profileRevision int + var lastIssuedAt *time.Time + var gatewayName string + var gatewayEndpoint string + var gatewayPublicKey string + var dnsServers []string + var allowedIPs []string + + if err := row.Scan( + &response.Device.ID, + &response.Device.UserID, + &response.Device.GatewayID, + &response.Device.Name, + &response.Device.Platform, + &response.Device.Status, + &response.Device.AssignedIP, + &profileRevision, + &lastIssuedAt, + &gatewayName, + &gatewayEndpoint, + &gatewayPublicKey, + &dnsServers, + &allowedIPs, + ); err != nil { + return EnrollmentResponse{}, err + } + + response.Peer = PeerView{ + AssignedIP: response.Device.AssignedIP, + DNSServers: dnsServers, + AllowedIPs: allowedIPs, + Gateway: GatewayView{ + ID: response.Device.GatewayID, + Name: gatewayName, + Endpoint: gatewayEndpoint, + PublicKey: gatewayPublicKey, + }, + ProfileRevision: profileRevision, + } + for _, destination := range allowedIPs { + response.Resources = append(response.Resources, Resource{ + Type: "cidr", + Value: destination, + Label: destination, + }) + } + _ = lastIssuedAt + return response, nil +} diff --git a/backend/internal/device/service.go b/backend/internal/device/service.go new file mode 100644 index 0000000..d679462 --- /dev/null +++ b/backend/internal/device/service.go @@ -0,0 +1,130 @@ +package device + +import ( + "context" + + "github.com/google/uuid" + + "github.com/nexavpn/nexavpn/backend/internal/gateway" + "github.com/nexavpn/nexavpn/backend/internal/ipam" + "github.com/nexavpn/nexavpn/backend/internal/policy" + "github.com/nexavpn/nexavpn/backend/internal/profile" +) + +type Service struct { + repo Repository + policyService *policy.Service + gatewayService *gateway.Service + ipamService *ipam.Service +} + +func NewService(repo Repository, policyService *policy.Service, gatewayService *gateway.Service, ipamService *ipam.Service) *Service { + return &Service{ + repo: repo, + policyService: policyService, + gatewayService: gatewayService, + ipamService: ipamService, + } +} + +func (s *Service) Enroll(ctx context.Context, userID uuid.UUID, input EnrollRequest, privateKeyPlaceholder string) (EnrollmentResponse, error) { + selectedGateway, err := s.gatewayService.SelectActive(ctx) + if err != nil { + return EnrollmentResponse{}, err + } + + assignedIP, err := s.ipamService.Allocate(selectedGateway.VPNCIDR, 10) + if err != nil { + return EnrollmentResponse{}, err + } + + enrollment, err := s.repo.Enroll(ctx, userID, selectedGateway.ID, input, assignedIP, selectedGateway.DNSServers, nil) + if err != nil { + return EnrollmentResponse{}, err + } + + destinations, err := s.policyService.ResolveDestinations(ctx, userID, &enrollment.Device.ID) + if err != nil { + return EnrollmentResponse{}, err + } + if len(destinations) == 0 { + destinations = []string{"172.16.10.0/24"} + } + + enrollment.Peer = PeerView{ + AssignedIP: assignedIP, + DNSServers: selectedGateway.DNSServers, + AllowedIPs: destinations, + Gateway: GatewayView{ + ID: selectedGateway.ID, + Name: selectedGateway.Name, + Endpoint: selectedGateway.Endpoint, + PublicKey: selectedGateway.PublicKey, + }, + ProfileRevision: 1, + } + for _, destination := range destinations { + enrollment.Resources = append(enrollment.Resources, Resource{ + Type: "cidr", + Value: destination, + Label: destination, + }) + } + + enrollment.Profile = ProfileView{ + Format: "wireguard", + Content: profile.BuildWireGuardConfig(profile.BuildInput{ + PrivateKey: privateKeyPlaceholder, + Address: assignedIP, + DNSServers: selectedGateway.DNSServers, + ServerPublicKey: selectedGateway.PublicKey, + ServerEndpoint: selectedGateway.Endpoint, + AllowedIPs: destinations, + PersistentKeepal: 25, + }), + } + + return enrollment, nil +} + +func (s *Service) ListByUser(ctx context.Context, userID uuid.UUID) ([]Device, error) { + return s.repo.ListByUser(ctx, userID) +} + +func (s *Service) ListAll(ctx context.Context) ([]Device, error) { + return s.repo.ListAll(ctx) +} + +func (s *Service) GetLatestEnrollmentByUser(ctx context.Context, userID uuid.UUID) (EnrollmentResponse, error) { + return s.repo.GetLatestEnrollmentByUser(ctx, userID) +} + +func (s *Service) GetEnrollmentByDeviceID(ctx context.Context, deviceID uuid.UUID) (EnrollmentResponse, error) { + return s.repo.GetEnrollmentByDeviceID(ctx, deviceID) +} + +func (s *Service) GetConnectionStatus(ctx context.Context, userID uuid.UUID) (ConnectionStatus, error) { + enrollment, err := s.repo.GetLatestEnrollmentByUser(ctx, userID) + if err != nil { + return ConnectionStatus{ + Status: "disconnected", + Resources: []Resource{}, + }, nil + } + + lastSync := "just now" + return ConnectionStatus{ + Status: "provisioned", + AssignedIP: enrollment.Peer.AssignedIP, + LastSyncTime: &lastSync, + Resources: enrollment.Resources, + }, nil +} + +func (s *Service) Revoke(ctx context.Context, deviceID uuid.UUID) error { + return s.repo.Revoke(ctx, deviceID) +} + +func (s *Service) Rotate(ctx context.Context, deviceID uuid.UUID) error { + return s.repo.Rotate(ctx, deviceID) +} diff --git a/backend/internal/device/types.go b/backend/internal/device/types.go new file mode 100644 index 0000000..7928602 --- /dev/null +++ b/backend/internal/device/types.go @@ -0,0 +1,62 @@ +package device + +import "github.com/google/uuid" + +type EnrollRequest struct { + Name string `json:"name"` + Platform string `json:"platform"` + OSVersion string `json:"os_version"` + AppVersion string `json:"app_version"` + DeviceFingerprint string `json:"device_fingerprint"` + PublicKey string `json:"public_key"` +} + +type Device struct { + ID uuid.UUID `json:"id"` + UserID uuid.UUID `json:"user_id,omitempty"` + GatewayID uuid.UUID `json:"gateway_id,omitempty"` + Name string `json:"name"` + Platform string `json:"platform"` + Status string `json:"status"` + AssignedIP string `json:"assigned_ip,omitempty"` +} + +type ConnectionStatus struct { + Status string `json:"status"` + AssignedIP string `json:"assigned_ip"` + LastSyncTime *string `json:"last_sync_time"` + Resources []Resource `json:"resources"` +} + +type Resource struct { + Type string `json:"type"` + Value string `json:"value"` + Label string `json:"label"` +} + +type EnrollmentResponse struct { + Device Device `json:"device"` + Peer PeerView `json:"peer"` + Profile ProfileView `json:"profile"` + Resources []Resource `json:"resources"` +} + +type PeerView struct { + AssignedIP string `json:"assigned_ip"` + DNSServers []string `json:"dns_servers"` + AllowedIPs []string `json:"allowed_ips"` + Gateway GatewayView `json:"gateway"` + ProfileRevision int `json:"profile_revision"` +} + +type GatewayView struct { + ID uuid.UUID `json:"id"` + Name string `json:"name"` + Endpoint string `json:"endpoint"` + PublicKey string `json:"public_key"` +} + +type ProfileView struct { + Format string `json:"format"` + Content string `json:"content"` +} diff --git a/backend/internal/gateway/handler.go b/backend/internal/gateway/handler.go new file mode 100644 index 0000000..07eac5a --- /dev/null +++ b/backend/internal/gateway/handler.go @@ -0,0 +1,37 @@ +package gateway + +import ( + "net/http" + + "github.com/go-chi/chi/v5" + + "github.com/nexavpn/nexavpn/backend/internal/apiutil" +) + +type Handler struct { + service *Service +} + +func NewHandler(service *Service) *Handler { + return &Handler{service: service} +} + +func (h *Handler) List(w http.ResponseWriter, r *http.Request) { + items, err := h.service.List(r.Context()) + if err != nil { + apiutil.Error(w, http.StatusInternalServerError, "gateways_list_failed", "unable to list gateways") + return + } + + apiutil.JSON(w, http.StatusOK, items) +} + +func (h *Handler) SyncBundle(w http.ResponseWriter, r *http.Request) { + bundle, err := h.service.BuildSyncBundle(r.Context(), chi.URLParam(r, "id")) + if err != nil { + apiutil.Error(w, http.StatusBadRequest, "gateway_sync_failed", "unable to build sync bundle") + return + } + + apiutil.JSON(w, http.StatusOK, bundle) +} diff --git a/backend/internal/gateway/repository.go b/backend/internal/gateway/repository.go new file mode 100644 index 0000000..d3dbe54 --- /dev/null +++ b/backend/internal/gateway/repository.go @@ -0,0 +1,102 @@ +package gateway + +import ( + "context" + + "github.com/google/uuid" + "github.com/jackc/pgx/v5/pgxpool" + + "github.com/nexavpn/nexavpn/backend/internal/wireguard" +) + +type Repository interface { + List(ctx context.Context) ([]Gateway, error) + FirstActive(ctx context.Context) (Gateway, error) + BuildSyncBundle(ctx context.Context, gatewayID uuid.UUID) (wireguard.GatewayBundle, error) +} + +type PGRepository struct { + db *pgxpool.Pool +} + +func NewPGRepository(db *pgxpool.Pool) *PGRepository { + return &PGRepository{db: db} +} + +func (r *PGRepository) List(ctx context.Context) ([]Gateway, error) { + rows, err := r.db.Query(ctx, ` + select id, name, endpoint, public_key, listen_port, vpn_cidr, dns_servers, is_active + from gateways + where deleted_at is null + order by created_at desc + `) + if err != nil { + return nil, err + } + defer rows.Close() + + var items []Gateway + for rows.Next() { + var item Gateway + if err := rows.Scan(&item.ID, &item.Name, &item.Endpoint, &item.PublicKey, &item.ListenPort, &item.VPNCIDR, &item.DNSServers, &item.IsActive); err != nil { + return nil, err + } + items = append(items, item) + } + return items, rows.Err() +} + +func (r *PGRepository) FirstActive(ctx context.Context) (Gateway, error) { + row := r.db.QueryRow(ctx, ` + select id, name, endpoint, public_key, listen_port, vpn_cidr, dns_servers, is_active + from gateways + where deleted_at is null and is_active = true + order by created_at asc + limit 1 + `) + + var item Gateway + err := row.Scan(&item.ID, &item.Name, &item.Endpoint, &item.PublicKey, &item.ListenPort, &item.VPNCIDR, &item.DNSServers, &item.IsActive) + return item, err +} + +func (r *PGRepository) BuildSyncBundle(ctx context.Context, gatewayID uuid.UUID) (wireguard.GatewayBundle, error) { + var bundle wireguard.GatewayBundle + bundle.GatewayID = gatewayID.String() + bundle.Revision = 1 + + row := r.db.QueryRow(ctx, ` + select host(vpn_cidr), listen_port + from gateways + where id = $1 and deleted_at is null + `, gatewayID) + if err := row.Scan(&bundle.Interface.Address, &bundle.Interface.ListenPort); err != nil { + return wireguard.GatewayBundle{}, err + } + + rows, err := r.db.Query(ctx, ` + select d.id, wp.public_key, host(wp.assigned_ip), coalesce(array_agg(pd.destination::text) filter (where pd.destination is not null), '{}') + from devices d + join wireguard_peers wp on wp.device_id = d.id and wp.deleted_at is null + left join policy_targets pt on pt.target_id = d.id and pt.target_type = 'device' + left join policy_destinations pd on pd.policy_id = pt.policy_id + where d.gateway_id = $1 and d.deleted_at is null and d.status = 'active' + group by d.id, wp.public_key, wp.assigned_ip + `, gatewayID) + if err != nil { + return wireguard.GatewayBundle{}, err + } + defer rows.Close() + + for rows.Next() { + var peer wireguard.Peer + var deviceID uuid.UUID + if err := rows.Scan(&deviceID, &peer.PublicKey, &peer.AssignedIP, &peer.AllowedDestinations); err != nil { + return wireguard.GatewayBundle{}, err + } + peer.DeviceID = deviceID.String() + bundle.Peers = append(bundle.Peers, peer) + } + + return bundle, rows.Err() +} diff --git a/backend/internal/gateway/service.go b/backend/internal/gateway/service.go new file mode 100644 index 0000000..08d6b38 --- /dev/null +++ b/backend/internal/gateway/service.go @@ -0,0 +1,33 @@ +package gateway + +import ( + "context" + + "github.com/google/uuid" + + "github.com/nexavpn/nexavpn/backend/internal/wireguard" +) + +type Service struct { + repo Repository +} + +func NewService(repo Repository) *Service { + return &Service{repo: repo} +} + +func (s *Service) List(ctx context.Context) ([]Gateway, error) { + return s.repo.List(ctx) +} + +func (s *Service) SelectActive(ctx context.Context) (Gateway, error) { + return s.repo.FirstActive(ctx) +} + +func (s *Service) BuildSyncBundle(ctx context.Context, gatewayID string) (wireguard.GatewayBundle, error) { + id, err := uuid.Parse(gatewayID) + if err != nil { + return wireguard.GatewayBundle{}, err + } + return s.repo.BuildSyncBundle(ctx, id) +} diff --git a/backend/internal/gateway/types.go b/backend/internal/gateway/types.go new file mode 100644 index 0000000..8d02539 --- /dev/null +++ b/backend/internal/gateway/types.go @@ -0,0 +1,14 @@ +package gateway + +import "github.com/google/uuid" + +type Gateway struct { + ID uuid.UUID `json:"id"` + Name string `json:"name"` + Endpoint string `json:"endpoint"` + PublicKey string `json:"public_key"` + ListenPort int `json:"listen_port"` + VPNCIDR string `json:"vpn_cidr"` + DNSServers []string `json:"dns_servers"` + IsActive bool `json:"is_active"` +} diff --git a/backend/internal/httpserver/middleware.go b/backend/internal/httpserver/middleware.go new file mode 100644 index 0000000..b5dd746 --- /dev/null +++ b/backend/internal/httpserver/middleware.go @@ -0,0 +1,66 @@ +package httpserver + +import ( + "context" + "net/http" + "strings" + + "github.com/go-chi/chi/v5/middleware" + "github.com/google/uuid" + + "github.com/nexavpn/nexavpn/backend/internal/apiutil" + "github.com/nexavpn/nexavpn/backend/internal/auth" +) + +type contextKey string + +const claimsContextKey contextKey = "claims" + +func BaseMiddleware(next http.Handler) http.Handler { + return middleware.RealIP(middleware.RequestID(middleware.Logger(next))) +} + +func AuthMiddleware(jwtSecret string) func(http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + header := r.Header.Get("Authorization") + if !strings.HasPrefix(header, "Bearer ") { + apiutil.Error(w, http.StatusUnauthorized, "unauthorized", "missing bearer token") + return + } + + claims, err := auth.ParseAccessToken(jwtSecret, strings.TrimPrefix(header, "Bearer ")) + if err != nil { + apiutil.Error(w, http.StatusUnauthorized, "unauthorized", "invalid access token") + return + } + + ctx := context.WithValue(r.Context(), claimsContextKey, claims) + next.ServeHTTP(w, r.WithContext(ctx)) + }) + } +} + +func AdminOnly(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + claims, ok := ClaimsFromContext(r.Context()) + if !ok || claims.Role != "admin" { + apiutil.Error(w, http.StatusForbidden, "forbidden", "admin role required") + return + } + next.ServeHTTP(w, r) + }) +} + +func ClaimsFromContext(ctx context.Context) (auth.Claims, bool) { + claims, ok := ctx.Value(claimsContextKey).(auth.Claims) + return claims, ok +} + +func MustUserID(ctx context.Context) (uuid.UUID, bool) { + claims, ok := ClaimsFromContext(ctx) + if !ok { + return uuid.Nil, false + } + return claims.UserID, true +} diff --git a/backend/internal/httpserver/router.go b/backend/internal/httpserver/router.go new file mode 100644 index 0000000..af48802 --- /dev/null +++ b/backend/internal/httpserver/router.go @@ -0,0 +1,68 @@ +package httpserver + +import ( + "net/http" + + "github.com/go-chi/chi/v5" + + "github.com/nexavpn/nexavpn/backend/internal/apiutil" + "github.com/nexavpn/nexavpn/backend/internal/auth" + "github.com/nexavpn/nexavpn/backend/internal/audit" + "github.com/nexavpn/nexavpn/backend/internal/device" + "github.com/nexavpn/nexavpn/backend/internal/gateway" + "github.com/nexavpn/nexavpn/backend/internal/policy" + "github.com/nexavpn/nexavpn/backend/internal/user" +) + +type Handlers struct { + Auth *auth.Handler + User *user.Handler + Device *device.Handler + Policy *policy.Handler + Gateway *gateway.Handler + Audit *audit.Handler +} + +func NewRouter(jwtSecret string, handlers Handlers) http.Handler { + r := chi.NewRouter() + r.Use(BaseMiddleware) + + r.Get("/healthz", func(w http.ResponseWriter, _ *http.Request) { + apiutil.JSON(w, http.StatusOK, map[string]string{"status": "ok"}) + }) + + r.Route("/api/v1", func(r chi.Router) { + r.Post("/auth/bootstrap", handlers.Auth.Bootstrap) + r.Post("/auth/login", handlers.Auth.Login) + r.Post("/auth/refresh", handlers.Auth.Refresh) + r.Post("/auth/logout", handlers.Auth.Logout) + + r.Group(func(r chi.Router) { + r.Use(AuthMiddleware(jwtSecret)) + r.Get("/auth/me", handlers.Auth.Me) + r.Post("/devices/enroll", handlers.Device.Enroll) + r.Get("/me/devices", handlers.Device.ListOwn) + r.Get("/me/profile", handlers.Device.GetOwnProfile) + r.Get("/connection/status", handlers.Device.ConnectionStatus) + + r.Route("/admin", func(r chi.Router) { + r.Use(AdminOnly) + r.Get("/users", handlers.User.List) + r.Post("/users", handlers.User.Create) + r.Post("/users/{id}/disable", handlers.User.Disable) + r.Post("/users/{id}/enable", handlers.User.Enable) + r.Get("/devices", handlers.Device.ListAll) + r.Get("/devices/{id}/profile", handlers.Device.GetProfileByDeviceID) + r.Post("/devices/{id}/revoke", handlers.Device.Revoke) + r.Post("/devices/{id}/rotate", handlers.Device.Rotate) + r.Get("/policies", handlers.Policy.List) + r.Post("/policies", handlers.Policy.Create) + r.Get("/gateways", handlers.Gateway.List) + r.Get("/gateways/{id}/sync", handlers.Gateway.SyncBundle) + r.Get("/audit-logs", handlers.Audit.List) + }) + }) + }) + + return r +} diff --git a/backend/internal/ipam/service.go b/backend/internal/ipam/service.go new file mode 100644 index 0000000..a76d7e0 --- /dev/null +++ b/backend/internal/ipam/service.go @@ -0,0 +1,26 @@ +package ipam + +import ( + "fmt" + "net/netip" +) + +type Service struct{} + +func NewService() *Service { + return &Service{} +} + +func (s *Service) Allocate(cidr string, offset int) (string, error) { + prefix, err := netip.ParsePrefix(cidr) + if err != nil { + return "", err + } + + address := prefix.Addr().Next() + for i := 1; i < offset; i++ { + address = address.Next() + } + + return fmt.Sprintf("%s/32", address.String()), nil +} diff --git a/backend/internal/policy/handler.go b/backend/internal/policy/handler.go new file mode 100644 index 0000000..6c1578a --- /dev/null +++ b/backend/internal/policy/handler.go @@ -0,0 +1,62 @@ +package policy + +import ( + "encoding/json" + "net/http" + + "github.com/nexavpn/nexavpn/backend/internal/apiutil" + "github.com/nexavpn/nexavpn/backend/internal/audit" + "github.com/nexavpn/nexavpn/backend/internal/httpserver" +) + +type Handler struct { + service *Service + audit *audit.Service +} + +func NewHandler(service *Service, auditService *audit.Service) *Handler { + return &Handler{service: service, audit: auditService} +} + +func (h *Handler) List(w http.ResponseWriter, r *http.Request) { + items, err := h.service.List(r.Context()) + if err != nil { + apiutil.Error(w, http.StatusInternalServerError, "policies_list_failed", "unable to list policies") + return + } + + apiutil.JSON(w, http.StatusOK, items) +} + +func (h *Handler) Create(w http.ResponseWriter, r *http.Request) { + var input CreateRequest + if err := json.NewDecoder(r.Body).Decode(&input); err != nil { + apiutil.Error(w, http.StatusBadRequest, "invalid_json", "invalid request body") + return + } + + claims, ok := httpserver.ClaimsFromContext(r.Context()) + if !ok { + apiutil.Error(w, http.StatusUnauthorized, "unauthorized", "missing auth claims") + return + } + + item, err := h.service.Create(r.Context(), claims.UserID, input) + if err != nil { + apiutil.Error(w, http.StatusInternalServerError, "policy_create_failed", "unable to create policy") + return + } + + _ = h.audit.Record(r.Context(), audit.Entry{ + ActorUserID: &claims.UserID, + EntityType: "policy", + EntityID: &item.ID, + EventType: "admin.policy.created", + Status: "success", + Message: "admin created policy", + Metadata: map[string]any{ + "name": item.Name, + }, + }) + apiutil.JSON(w, http.StatusCreated, item) +} diff --git a/backend/internal/policy/repository.go b/backend/internal/policy/repository.go new file mode 100644 index 0000000..eaae8a6 --- /dev/null +++ b/backend/internal/policy/repository.go @@ -0,0 +1,143 @@ +package policy + +import ( + "context" + + "github.com/google/uuid" + "github.com/jackc/pgx/v5/pgxpool" +) + +type Repository interface { + List(ctx context.Context) ([]Policy, error) + Create(ctx context.Context, input CreateRequest, createdBy uuid.UUID) (Policy, error) + ResolveDestinations(ctx context.Context, userID uuid.UUID, deviceID *uuid.UUID) ([]string, error) +} + +type PGRepository struct { + db *pgxpool.Pool +} + +func NewPGRepository(db *pgxpool.Pool) *PGRepository { + return &PGRepository{db: db} +} + +func (r *PGRepository) List(ctx context.Context) ([]Policy, error) { + rows, err := r.db.Query(ctx, ` + select + p.id, + p.name, + p.description, + p.priority, + p.effect, + p.full_tunnel, + p.is_active, + coalesce(array_agg(pd.destination::text) filter (where pd.destination is not null), '{}') + from policies p + left join policy_destinations pd on pd.policy_id = p.id + where p.deleted_at is null + group by p.id, p.name, p.description, p.priority, p.effect, p.full_tunnel, p.is_active, p.created_at + order by p.priority asc, p.created_at desc + `) + if err != nil { + return nil, err + } + defer rows.Close() + + var items []Policy + for rows.Next() { + var item Policy + if err := rows.Scan(&item.ID, &item.Name, &item.Description, &item.Priority, &item.Effect, &item.FullTunnel, &item.IsActive, &item.Destinations); err != nil { + return nil, err + } + items = append(items, item) + } + return items, rows.Err() +} + +func (r *PGRepository) Create(ctx context.Context, input CreateRequest, createdBy uuid.UUID) (Policy, error) { + tx, err := r.db.Begin(ctx) + if err != nil { + return Policy{}, err + } + defer tx.Rollback(ctx) + + policyID := uuid.New() + _, err = tx.Exec(ctx, ` + insert into policies (id, name, description, priority, effect, full_tunnel, created_by) + values ($1, $2, $3, $4, $5, $6, $7) + `, policyID, input.Name, input.Description, input.Priority, input.Effect, input.FullTunnel, createdBy) + if err != nil { + return Policy{}, err + } + + for _, destination := range input.Destinations { + if _, err := tx.Exec(ctx, ` + insert into policy_destinations (id, policy_id, destination) + values ($1, $2, $3::cidr) + `, uuid.New(), policyID, destination); err != nil { + return Policy{}, err + } + } + + for _, target := range input.Targets { + if _, err := tx.Exec(ctx, ` + insert into policy_targets (id, policy_id, target_type, target_id) + values ($1, $2, $3, $4) + `, uuid.New(), policyID, target.Type, target.ID); err != nil { + return Policy{}, err + } + } + + if err := tx.Commit(ctx); err != nil { + return Policy{}, err + } + + inputPolicy := Policy{ + ID: policyID, + Name: input.Name, + Description: input.Description, + Priority: input.Priority, + Effect: input.Effect, + FullTunnel: input.FullTunnel, + IsActive: true, + Destinations: input.Destinations, + Targets: input.Targets, + } + return inputPolicy, nil +} + +func (r *PGRepository) ResolveDestinations(ctx context.Context, userID uuid.UUID, deviceID *uuid.UUID) ([]string, error) { + query := ` + select distinct pd.destination::text + from policies p + join policy_destinations pd on pd.policy_id = p.id + join policy_targets pt on pt.policy_id = p.id + where p.deleted_at is null + and p.is_active = true + and p.effect = 'allow' + and ( + (pt.target_type = 'user' and pt.target_id = $1) + ` + args := []any{userID} + if deviceID != nil { + query += ` or (pt.target_type = 'device' and pt.target_id = $2)` + args = append(args, *deviceID) + } + query += `)` + + rows, err := r.db.Query(ctx, query, args...) + if err != nil { + return nil, err + } + defer rows.Close() + + var destinations []string + for rows.Next() { + var value string + if err := rows.Scan(&value); err != nil { + return nil, err + } + destinations = append(destinations, value) + } + return destinations, rows.Err() +} diff --git a/backend/internal/policy/service.go b/backend/internal/policy/service.go new file mode 100644 index 0000000..0c5df8b --- /dev/null +++ b/backend/internal/policy/service.go @@ -0,0 +1,33 @@ +package policy + +import ( + "context" + + "github.com/google/uuid" +) + +type Service struct { + repo Repository +} + +func NewService(repo Repository) *Service { + return &Service{repo: repo} +} + +func (s *Service) List(ctx context.Context) ([]Policy, error) { + return s.repo.List(ctx) +} + +func (s *Service) Create(ctx context.Context, actorID uuid.UUID, input CreateRequest) (Policy, error) { + if input.Priority == 0 { + input.Priority = 100 + } + if input.Effect == "" { + input.Effect = "allow" + } + return s.repo.Create(ctx, input, actorID) +} + +func (s *Service) ResolveDestinations(ctx context.Context, userID uuid.UUID, deviceID *uuid.UUID) ([]string, error) { + return s.repo.ResolveDestinations(ctx, userID, deviceID) +} diff --git a/backend/internal/policy/types.go b/backend/internal/policy/types.go new file mode 100644 index 0000000..49d5b91 --- /dev/null +++ b/backend/internal/policy/types.go @@ -0,0 +1,30 @@ +package policy + +import "github.com/google/uuid" + +type Target struct { + Type string `json:"type"` + ID uuid.UUID `json:"id"` +} + +type Policy struct { + ID uuid.UUID `json:"id"` + Name string `json:"name"` + Description string `json:"description"` + Priority int `json:"priority"` + Effect string `json:"effect"` + FullTunnel bool `json:"full_tunnel"` + IsActive bool `json:"is_active"` + Destinations []string `json:"destinations"` + Targets []Target `json:"targets"` +} + +type CreateRequest struct { + Name string `json:"name"` + Description string `json:"description"` + Priority int `json:"priority"` + Effect string `json:"effect"` + FullTunnel bool `json:"full_tunnel"` + Destinations []string `json:"destinations"` + Targets []Target `json:"targets"` +} diff --git a/backend/internal/profile/builder.go b/backend/internal/profile/builder.go new file mode 100644 index 0000000..b13e835 --- /dev/null +++ b/backend/internal/profile/builder.go @@ -0,0 +1,33 @@ +package profile + +import ( + "fmt" + "strings" +) + +type BuildInput struct { + PrivateKey string + Address string + DNSServers []string + ServerPublicKey string + ServerEndpoint string + AllowedIPs []string + PersistentKeepal int +} + +func BuildWireGuardConfig(input BuildInput) string { + var b strings.Builder + b.WriteString("[Interface]\n") + b.WriteString(fmt.Sprintf("PrivateKey = %s\n", input.PrivateKey)) + b.WriteString(fmt.Sprintf("Address = %s\n", input.Address)) + if len(input.DNSServers) > 0 { + b.WriteString(fmt.Sprintf("DNS = %s\n", strings.Join(input.DNSServers, ", "))) + } + + b.WriteString("\n[Peer]\n") + b.WriteString(fmt.Sprintf("PublicKey = %s\n", input.ServerPublicKey)) + b.WriteString(fmt.Sprintf("Endpoint = %s\n", input.ServerEndpoint)) + b.WriteString(fmt.Sprintf("AllowedIPs = %s\n", strings.Join(input.AllowedIPs, ", "))) + b.WriteString(fmt.Sprintf("PersistentKeepalive = %d\n", input.PersistentKeepal)) + return b.String() +} diff --git a/backend/internal/user/handler.go b/backend/internal/user/handler.go new file mode 100644 index 0000000..3b0a1a0 --- /dev/null +++ b/backend/internal/user/handler.go @@ -0,0 +1,110 @@ +package user + +import ( + "encoding/json" + "net/http" + + "github.com/go-chi/chi/v5" + "github.com/google/uuid" + + "github.com/nexavpn/nexavpn/backend/internal/apiutil" + "github.com/nexavpn/nexavpn/backend/internal/audit" + "github.com/nexavpn/nexavpn/backend/internal/httpserver" +) + +type Handler struct { + service *Service + audit *audit.Service +} + +func NewHandler(service *Service, auditService *audit.Service) *Handler { + return &Handler{service: service, audit: auditService} +} + +func (h *Handler) List(w http.ResponseWriter, r *http.Request) { + users, err := h.service.List(r.Context()) + if err != nil { + apiutil.Error(w, http.StatusInternalServerError, "users_list_failed", "unable to list users") + return + } + + apiutil.JSON(w, http.StatusOK, users) +} + +func (h *Handler) Create(w http.ResponseWriter, r *http.Request) { + var input CreateRequest + if err := json.NewDecoder(r.Body).Decode(&input); err != nil { + apiutil.Error(w, http.StatusBadRequest, "invalid_json", "invalid request body") + return + } + if input.Role == "" { + input.Role = "user" + } + + created, err := h.service.Create(r.Context(), input) + if err != nil { + apiutil.Error(w, http.StatusInternalServerError, "user_create_failed", "unable to create user") + return + } + + if claims, ok := httpserver.ClaimsFromContext(r.Context()); ok { + _ = h.audit.Record(r.Context(), audit.Entry{ + ActorUserID: &claims.UserID, + EntityType: "user", + EntityID: &created.ID, + EventType: "admin.user.created", + Status: "success", + Message: "admin created user", + Metadata: map[string]any{ + "username": created.Username, + }, + }) + } + apiutil.JSON(w, http.StatusCreated, created) +} + +func (h *Handler) Disable(w http.ResponseWriter, r *http.Request) { + targetID, err := uuid.Parse(chi.URLParam(r, "id")) + if err != nil { + apiutil.Error(w, http.StatusBadRequest, "invalid_user_id", "invalid user id") + return + } + if err := h.service.SetActive(r.Context(), targetID.String(), false); err != nil { + apiutil.Error(w, http.StatusBadRequest, "user_disable_failed", "unable to disable user") + return + } + if claims, ok := httpserver.ClaimsFromContext(r.Context()); ok { + _ = h.audit.Record(r.Context(), audit.Entry{ + ActorUserID: &claims.UserID, + EntityType: "user", + EntityID: &targetID, + EventType: "admin.user.disabled", + Status: "success", + Message: "admin disabled user", + }) + } + apiutil.JSON(w, http.StatusOK, map[string]any{"ok": true}) +} + +func (h *Handler) Enable(w http.ResponseWriter, r *http.Request) { + targetID, err := uuid.Parse(chi.URLParam(r, "id")) + if err != nil { + apiutil.Error(w, http.StatusBadRequest, "invalid_user_id", "invalid user id") + return + } + if err := h.service.SetActive(r.Context(), targetID.String(), true); err != nil { + apiutil.Error(w, http.StatusBadRequest, "user_enable_failed", "unable to enable user") + return + } + if claims, ok := httpserver.ClaimsFromContext(r.Context()); ok { + _ = h.audit.Record(r.Context(), audit.Entry{ + ActorUserID: &claims.UserID, + EntityType: "user", + EntityID: &targetID, + EventType: "admin.user.enabled", + Status: "success", + Message: "admin enabled user", + }) + } + apiutil.JSON(w, http.StatusOK, map[string]any{"ok": true}) +} diff --git a/backend/internal/user/repository.go b/backend/internal/user/repository.go new file mode 100644 index 0000000..65baa9f --- /dev/null +++ b/backend/internal/user/repository.go @@ -0,0 +1,64 @@ +package user + +import ( + "context" + + "github.com/google/uuid" + "github.com/jackc/pgx/v5/pgxpool" +) + +type Repository interface { + List(ctx context.Context) ([]User, error) + Create(ctx context.Context, input CreateRequest, passwordHash string) (User, error) + SetActive(ctx context.Context, userID uuid.UUID, active bool) error +} + +type PGRepository struct { + db *pgxpool.Pool +} + +func NewPGRepository(db *pgxpool.Pool) *PGRepository { + return &PGRepository{db: db} +} + +func (r *PGRepository) List(ctx context.Context) ([]User, error) { + rows, err := r.db.Query(ctx, ` + select u.id, r.id, r.name, u.username, u.display_name, coalesce(u.email, ''), u.is_active + from users u + join roles r on r.id = u.role_id + where u.deleted_at is null + order by u.created_at desc + `) + if err != nil { + return nil, err + } + defer rows.Close() + + var users []User + for rows.Next() { + var item User + if err := rows.Scan(&item.ID, &item.RoleID, &item.RoleName, &item.Username, &item.DisplayName, &item.Email, &item.IsActive); err != nil { + return nil, err + } + users = append(users, item) + } + return users, rows.Err() +} + +func (r *PGRepository) Create(ctx context.Context, input CreateRequest, passwordHash string) (User, error) { + const query = ` + insert into users (id, role_id, username, display_name, email, password_hash) + values ($1, (select id from roles where name = $2), $3, $4, nullif($5, ''), $6) + returning id, username, display_name, coalesce(email, ''), is_active + ` + + item := User{RoleName: input.Role} + err := r.db.QueryRow(ctx, query, uuid.New(), input.Role, input.Username, input.DisplayName, input.Email, passwordHash). + Scan(&item.ID, &item.Username, &item.DisplayName, &item.Email, &item.IsActive) + return item, err +} + +func (r *PGRepository) SetActive(ctx context.Context, userID uuid.UUID, active bool) error { + _, err := r.db.Exec(ctx, `update users set is_active = $2, updated_at = now() where id = $1`, userID, active) + return err +} diff --git a/backend/internal/user/service.go b/backend/internal/user/service.go new file mode 100644 index 0000000..c5dfbbe --- /dev/null +++ b/backend/internal/user/service.go @@ -0,0 +1,37 @@ +package user + +import ( + "context" + + "github.com/google/uuid" + + "github.com/nexavpn/nexavpn/backend/internal/auth" +) + +type Service struct { + repo Repository +} + +func NewService(repo Repository) *Service { + return &Service{repo: repo} +} + +func (s *Service) List(ctx context.Context) ([]User, error) { + return s.repo.List(ctx) +} + +func (s *Service) Create(ctx context.Context, input CreateRequest) (User, error) { + passwordHash, err := auth.HashPassword(input.Password) + if err != nil { + return User{}, err + } + return s.repo.Create(ctx, input, passwordHash) +} + +func (s *Service) SetActive(ctx context.Context, userID string, active bool) error { + id, err := uuid.Parse(userID) + if err != nil { + return err + } + return s.repo.SetActive(ctx, id, active) +} diff --git a/backend/internal/user/types.go b/backend/internal/user/types.go new file mode 100644 index 0000000..c779b1b --- /dev/null +++ b/backend/internal/user/types.go @@ -0,0 +1,27 @@ +package user + +import "github.com/google/uuid" + +type User struct { + ID uuid.UUID `json:"id"` + RoleID uuid.UUID `json:"role_id,omitempty"` + RoleName string `json:"role"` + Username string `json:"username"` + DisplayName string `json:"display_name"` + Email string `json:"email,omitempty"` + IsActive bool `json:"is_active"` +} + +type CreateRequest struct { + Username string `json:"username"` + DisplayName string `json:"display_name"` + Email string `json:"email"` + Password string `json:"password"` + Role string `json:"role"` +} + +type UpdateRequest struct { + DisplayName *string `json:"display_name"` + Email *string `json:"email"` + IsActive *bool `json:"is_active"` +} diff --git a/backend/internal/wireguard/types.go b/backend/internal/wireguard/types.go new file mode 100644 index 0000000..9cf65ca --- /dev/null +++ b/backend/internal/wireguard/types.go @@ -0,0 +1,18 @@ +package wireguard + +type Peer struct { + DeviceID string `json:"device_id"` + PublicKey string `json:"public_key"` + AssignedIP string `json:"assigned_ip"` + AllowedDestinations []string `json:"allowed_destinations"` +} + +type GatewayBundle struct { + GatewayID string `json:"gateway_id"` + Revision int `json:"revision"` + Interface struct { + Address string `json:"address"` + ListenPort int `json:"listen_port"` + } `json:"interface"` + Peers []Peer `json:"peers"` +} diff --git a/backend/migrations/000001_init.sql b/backend/migrations/000001_init.sql new file mode 100644 index 0000000..4c96ca4 --- /dev/null +++ b/backend/migrations/000001_init.sql @@ -0,0 +1,183 @@ +create extension if not exists pgcrypto; +create extension if not exists citext; + +create table if not exists roles ( + id uuid primary key default gen_random_uuid(), + name text unique not null, + description text not null default '', + created_at timestamptz not null default now(), + updated_at timestamptz not null default now() +); + +create table if not exists users ( + id uuid primary key default gen_random_uuid(), + role_id uuid not null references roles(id), + username citext unique not null, + display_name text not null, + email citext unique, + password_hash text not null, + is_active boolean not null default true, + last_login_at timestamptz, + created_at timestamptz not null default now(), + updated_at timestamptz not null default now(), + deleted_at timestamptz +); + +create table if not exists sessions ( + id uuid primary key default gen_random_uuid(), + user_id uuid not null references users(id), + ip_address inet, + user_agent text, + last_seen_at timestamptz not null default now(), + expires_at timestamptz not null, + created_at timestamptz not null default now(), + revoked_at timestamptz +); + +create table if not exists refresh_tokens ( + id uuid primary key default gen_random_uuid(), + session_id uuid not null references sessions(id), + user_id uuid not null references users(id), + token_hash text not null, + expires_at timestamptz not null, + created_at timestamptz not null default now(), + revoked_at timestamptz +); + +create table if not exists gateways ( + id uuid primary key default gen_random_uuid(), + name text unique not null, + endpoint text not null, + public_key text not null, + listen_port integer not null, + vpn_cidr cidr not null, + dns_servers text[] not null default '{}', + is_active boolean not null default true, + last_sync_at timestamptz, + created_at timestamptz not null default now(), + updated_at timestamptz not null default now(), + deleted_at timestamptz +); + +create table if not exists devices ( + id uuid primary key default gen_random_uuid(), + user_id uuid not null references users(id), + gateway_id uuid references gateways(id), + name text not null, + platform text not null, + os_version text not null default '', + app_version text not null default '', + device_fingerprint text not null, + public_key text not null, + status text not null default 'active', + last_seen_at timestamptz, + last_connected_at timestamptz, + approved_at timestamptz, + revoked_at timestamptz, + created_at timestamptz not null default now(), + updated_at timestamptz not null default now(), + deleted_at timestamptz +); + +create unique index if not exists idx_devices_user_fingerprint on devices(user_id, device_fingerprint) where deleted_at is null; + +create table if not exists wireguard_peers ( + id uuid primary key default gen_random_uuid(), + device_id uuid not null references devices(id), + gateway_id uuid not null references gateways(id), + public_key text unique not null, + assigned_ip inet not null, + preshared_key_ciphertext text, + allowed_ips cidr[] not null default '{}', + dns_servers text[] not null default '{}', + profile_revision integer not null default 1, + last_profile_issued_at timestamptz, + created_at timestamptz not null default now(), + updated_at timestamptz not null default now(), + deleted_at timestamptz +); + +create table if not exists ip_allocations ( + id uuid primary key default gen_random_uuid(), + gateway_id uuid not null references gateways(id), + device_id uuid references devices(id), + address inet not null, + status text not null default 'allocated', + allocated_at timestamptz not null default now(), + released_at timestamptz, + created_at timestamptz not null default now(), + updated_at timestamptz not null default now() +); + +create unique index if not exists idx_ip_allocations_gateway_address on ip_allocations(gateway_id, address); +create unique index if not exists idx_ip_allocations_device_active on ip_allocations(device_id) where status = 'allocated'; + +create table if not exists policies ( + id uuid primary key default gen_random_uuid(), + name text unique not null, + description text not null default '', + priority integer not null default 100, + effect text not null default 'allow', + is_active boolean not null default true, + full_tunnel boolean not null default false, + created_by uuid references users(id), + created_at timestamptz not null default now(), + updated_at timestamptz not null default now(), + deleted_at timestamptz +); + +create table if not exists policy_targets ( + id uuid primary key default gen_random_uuid(), + policy_id uuid not null references policies(id), + target_type text not null, + target_id uuid not null, + created_at timestamptz not null default now() +); + +create table if not exists policy_destinations ( + id uuid primary key default gen_random_uuid(), + policy_id uuid not null references policies(id), + destination cidr not null, + description text not null default '', + created_at timestamptz not null default now() +); + +create table if not exists groups ( + id uuid primary key default gen_random_uuid(), + name text unique not null, + description text not null default '', + created_at timestamptz not null default now(), + updated_at timestamptz not null default now(), + deleted_at timestamptz +); + +create table if not exists group_memberships ( + id uuid primary key default gen_random_uuid(), + group_id uuid not null references groups(id), + user_id uuid not null references users(id), + created_at timestamptz not null default now() +); + +create table if not exists audit_logs ( + id uuid primary key default gen_random_uuid(), + actor_user_id uuid references users(id), + actor_device_id uuid references devices(id), + event_type text not null, + entity_type text not null, + entity_id uuid, + status text not null, + ip_address inet, + message text not null, + metadata jsonb not null default '{}'::jsonb, + created_at timestamptz not null default now() +); + +create table if not exists settings ( + id uuid primary key default gen_random_uuid(), + category text not null, + key text not null, + value jsonb not null, + created_at timestamptz not null default now(), + updated_at timestamptz not null default now(), + unique(category, key) +); diff --git a/backend/seed/001_seed.sql b/backend/seed/001_seed.sql new file mode 100644 index 0000000..abafa0b --- /dev/null +++ b/backend/seed/001_seed.sql @@ -0,0 +1,24 @@ +insert into roles (name, description) +values + ('admin', 'NexaVPN administrator'), + ('user', 'Standard VPN user') +on conflict (name) do nothing; + +insert into settings (category, key, value) +values + ('vpn', 'default_dns_servers', '["10.20.0.53"]'::jsonb), + ('auth', 'access_token_ttl_seconds', '900'::jsonb), + ('auth', 'refresh_token_ttl_seconds', '2592000'::jsonb) +on conflict (category, key) do nothing; + +insert into gateways (name, endpoint, public_key, listen_port, vpn_cidr, dns_servers, is_active) +values ( + 'primary-gateway', + 'vpn.example.com:51820', + 'replace-me-with-gateway-public-key', + 51820, + '100.96.0.0/24', + array['10.20.0.53'], + true +) +on conflict (name) do nothing; diff --git a/deploy/.env.example b/deploy/.env.example new file mode 100644 index 0000000..8abb5b2 --- /dev/null +++ b/deploy/.env.example @@ -0,0 +1,14 @@ +POSTGRES_DB=nexavpn +POSTGRES_USER=nexavpn +POSTGRES_PASSWORD=change-me +DATABASE_URL=postgres://nexavpn:change-me@postgres:5432/nexavpn?sslmode=disable +HTTP_ADDRESS=:8080 +APP_ENV=production +JWT_SECRET=replace-with-a-long-random-secret +JWT_ISSUER=nexavpn +ACCESS_TOKEN_TTL_SECONDS=900 +REFRESH_TOKEN_TTL_SECONDS=2592000 +DEFAULT_DNS_SERVERS=10.20.0.53 +DEFAULT_VPN_CIDR=100.96.0.0/24 +DEFAULT_GATEWAY_ENDPOINT=vpn.example.com:51820 +DEFAULT_GATEWAY_PUBLIC_KEY=replace-me diff --git a/deploy/docker-compose.yml b/deploy/docker-compose.yml new file mode 100644 index 0000000..4a8db80 --- /dev/null +++ b/deploy/docker-compose.yml @@ -0,0 +1,72 @@ +services: + postgres: + image: postgres:17-alpine + environment: + POSTGRES_DB: ${POSTGRES_DB} + POSTGRES_USER: ${POSTGRES_USER} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} + ports: + - "5432:5432" + volumes: + - postgres-data:/var/lib/postgresql/data + - ../backend/migrations/000001_init.sql:/docker-entrypoint-initdb.d/010_init.sql:ro + - ../backend/seed/001_seed.sql:/docker-entrypoint-initdb.d/020_seed.sql:ro + networks: + - control + + backend: + build: + context: ../backend + dockerfile: Dockerfile + env_file: + - .env + depends_on: + - postgres + ports: + - "8080:8080" + networks: + - control + - gateway + + admin-web: + build: + context: ../admin-web + dockerfile: Dockerfile + depends_on: + - backend + ports: + - "8081:80" + networks: + - control + + reverse-proxy: + image: nginx:1.27-alpine + depends_on: + - backend + - admin-web + ports: + - "80:80" + volumes: + - ./nginx/reverse-proxy.conf:/etc/nginx/conf.d/default.conf:ro + networks: + - control + + gateway: + image: alpine:3.21 + command: ["sh", "/scripts/gateway-entrypoint.sh"] + cap_add: + - NET_ADMIN + - SYS_MODULE + volumes: + - ./scripts/gateway-entrypoint.sh:/scripts/gateway-entrypoint.sh:ro + - gateway-state:/var/lib/nexavpn + networks: + - gateway + +volumes: + postgres-data: + gateway-state: + +networks: + control: + gateway: diff --git a/deploy/nginx/admin.conf b/deploy/nginx/admin.conf new file mode 100644 index 0000000..9a7833a --- /dev/null +++ b/deploy/nginx/admin.conf @@ -0,0 +1,10 @@ +server { + listen 80; + server_name _; + root /usr/share/nginx/html; + index index.html; + + location / { + try_files $uri /index.html; + } +} diff --git a/deploy/nginx/reverse-proxy.conf b/deploy/nginx/reverse-proxy.conf new file mode 100644 index 0000000..70a7216 --- /dev/null +++ b/deploy/nginx/reverse-proxy.conf @@ -0,0 +1,18 @@ +server { + listen 80; + server_name _; + + location /api/ { + proxy_pass http://backend:8080; + proxy_set_header Host $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-Proto $scheme; + } + + location / { + proxy_pass http://admin-web:80; + proxy_set_header Host $host; + proxy_set_header X-Forwarded-Proto $scheme; + } +} diff --git a/deploy/scripts/bootstrap-admin.sh b/deploy/scripts/bootstrap-admin.sh new file mode 100644 index 0000000..8bcadb2 --- /dev/null +++ b/deploy/scripts/bootstrap-admin.sh @@ -0,0 +1,9 @@ +#!/bin/sh +set -eu + +cat <<'EOF' +Bootstrap flow: +1. Run backend migrations from backend/migrations. +2. Seed roles and default settings from backend/seed/001_seed.sql. +3. Insert the first admin user with an Argon2id password hash generated by the backend auth package or a one-off helper. +EOF diff --git a/deploy/scripts/gateway-entrypoint.sh b/deploy/scripts/gateway-entrypoint.sh new file mode 100644 index 0000000..f261339 --- /dev/null +++ b/deploy/scripts/gateway-entrypoint.sh @@ -0,0 +1,8 @@ +#!/bin/sh +set -eu + +echo "NexaVPN gateway helper starting" +echo "This container is a placeholder for WireGuard + nftables sync logic." +echo "Mount generated gateway state into /var/lib/nexavpn and apply rules from there." + +tail -f /dev/null diff --git a/desktop-client/index.html b/desktop-client/index.html new file mode 100644 index 0000000..fa9f93d --- /dev/null +++ b/desktop-client/index.html @@ -0,0 +1,12 @@ + + + + + + NexaVPN + + +
+ + + diff --git a/desktop-client/package.json b/desktop-client/package.json new file mode 100644 index 0000000..8e97498 --- /dev/null +++ b/desktop-client/package.json @@ -0,0 +1,25 @@ +{ + "name": "nexavpn-desktop-client", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc -b && vite build", + "tauri:dev": "tauri dev", + "tauri:build": "tauri build" + }, + "dependencies": { + "@tauri-apps/api": "^2.3.0", + "react": "^18.3.1", + "react-dom": "^18.3.1" + }, + "devDependencies": { + "@tauri-apps/cli": "^2.3.1", + "@types/react": "^18.3.20", + "@types/react-dom": "^18.3.6", + "@vitejs/plugin-react": "^4.3.4", + "typescript": "^5.8.2", + "vite": "^6.2.2" + } +} diff --git a/desktop-client/src-tauri/Cargo.toml b/desktop-client/src-tauri/Cargo.toml new file mode 100644 index 0000000..7f98768 --- /dev/null +++ b/desktop-client/src-tauri/Cargo.toml @@ -0,0 +1,22 @@ +[package] +name = "nexavpn-desktop" +version = "0.1.0" +description = "NexaVPN desktop client" +authors = ["NexaVPN"] +edition = "2021" + +[lib] +name = "nexavpn_desktop" +crate-type = ["staticlib", "cdylib", "rlib"] + +[build-dependencies] +tauri-build = { version = "2.0.2" } + +[dependencies] +base64 = "0.22" +rand_core = { version = "0.6", features = ["getrandom"] } +reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] } +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" +tauri = { version = "2.3.1", features = [] } +x25519-dalek = "2.0" diff --git a/desktop-client/src-tauri/build.rs b/desktop-client/src-tauri/build.rs new file mode 100644 index 0000000..d860e1e --- /dev/null +++ b/desktop-client/src-tauri/build.rs @@ -0,0 +1,3 @@ +fn main() { + tauri_build::build() +} diff --git a/desktop-client/src-tauri/src/lib.rs b/desktop-client/src-tauri/src/lib.rs new file mode 100644 index 0000000..aab4556 --- /dev/null +++ b/desktop-client/src-tauri/src/lib.rs @@ -0,0 +1,204 @@ +use std::sync::Mutex; + +use base64::{engine::general_purpose::STANDARD_NO_PAD, Engine as _}; +use rand_core::OsRng; +use reqwest::Client; +use serde::{Deserialize, Serialize}; +use tauri::State; +use x25519_dalek::{PublicKey, StaticSecret}; + +struct AppState { + session: Mutex>, +} + +#[derive(Debug, Clone)] +struct SessionState { + access_token: String, + refresh_token: String, + server_url: String, + enrollment: EnrollmentResult, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct EnrollmentPayload { + server_url: String, + username: String, + password: String, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +struct EnrollmentResult { + assigned_ip: String, + resources: Vec, + profile_revision: u32, + gateway_endpoint: String, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +struct LoginRequest<'a> { + username: &'a str, + password: &'a str, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct LoginResponse { + access_token: String, + refresh_token: String, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +struct EnrollRequest<'a> { + name: &'a str, + platform: &'a str, + os_version: &'a str, + app_version: &'a str, + device_fingerprint: String, + public_key: String, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct EnrollResponse { + peer: PeerView, + resources: Vec, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct PeerView { + assigned_ip: String, + gateway: GatewayView, + profile_revision: u32, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct GatewayView { + endpoint: String, +} + +#[derive(Debug, Deserialize)] +struct ResourceView { + value: String, +} + +#[tauri::command] +async fn enroll_device(payload: EnrollmentPayload, state: State<'_, AppState>) -> Result { + if payload.server_url.trim().is_empty() || payload.username.trim().is_empty() || payload.password.trim().is_empty() { + return Err("Server URL, username, and password are required".into()); + } + + let client = Client::builder() + .use_rustls_tls() + .build() + .map_err(|err| err.to_string())?; + + let login_response = client + .post(format!("{}/api/v1/auth/login", payload.server_url.trim_end_matches('/'))) + .json(&LoginRequest { + username: &payload.username, + password: &payload.password, + }) + .send() + .await + .map_err(|err| format!("Login failed: {}", err))?; + + if !login_response.status().is_success() { + return Err(format!("Login failed with status {}", login_response.status())); + } + + let login = login_response + .json::() + .await + .map_err(|err| format!("Unable to decode login response: {}", err))?; + + let (private_key, public_key) = generate_keypair(); + let enroll_response = client + .post(format!("{}/api/v1/devices/enroll", payload.server_url.trim_end_matches('/'))) + .bearer_auth(&login.access_token) + .json(&EnrollRequest { + name: "NexaVPN Desktop", + platform: if cfg!(target_os = "macos") { "macos" } else { "windows" }, + os_version: std::env::consts::OS, + app_version: env!("CARGO_PKG_VERSION"), + device_fingerprint: build_fingerprint(&payload.server_url, &payload.username, &public_key), + public_key, + }) + .send() + .await + .map_err(|err| format!("Enrollment failed: {}", err))?; + + if !enroll_response.status().is_success() { + return Err(format!("Enrollment failed with status {}", enroll_response.status())); + } + + let enroll = enroll_response + .json::() + .await + .map_err(|err| format!("Unable to decode enrollment response: {}", err))?; + + let result = EnrollmentResult { + assigned_ip: enroll.peer.assigned_ip, + resources: enroll.resources.into_iter().map(|resource| resource.value).collect(), + profile_revision: enroll.peer.profile_revision, + gateway_endpoint: enroll.peer.gateway.endpoint, + }; + + let mut session = state.session.lock().map_err(|_| "Unable to store client state".to_string())?; + *session = Some(SessionState { + access_token: login.access_token, + refresh_token: login.refresh_token, + server_url: payload.server_url, + enrollment: result.clone(), + }); + + let _ = private_key; + Ok(result) +} + +#[tauri::command] +fn connect_tunnel(state: State<'_, AppState>) -> Result<(), String> { + let session = state.session.lock().map_err(|_| "Unable to read client state".to_string())?; + if session.is_none() { + return Err("No enrolled profile is available yet".into()); + } + Ok(()) +} + +#[tauri::command] +fn disconnect_tunnel(state: State<'_, AppState>) -> Result<(), String> { + let session = state.session.lock().map_err(|_| "Unable to read client state".to_string())?; + if session.is_none() { + return Err("No active session is available".into()); + } + Ok(()) +} + +fn generate_keypair() -> (String, String) { + let private = StaticSecret::random_from_rng(OsRng); + let public = PublicKey::from(&private); + ( + STANDARD_NO_PAD.encode(private.to_bytes()), + STANDARD_NO_PAD.encode(public.to_bytes()), + ) +} + +fn build_fingerprint(server_url: &str, username: &str, public_key: &str) -> String { + format!("nexavpn:{}:{}:{}", server_url, username, public_key) +} + +#[cfg_attr(mobile, tauri::mobile_entry_point)] +pub fn run() { + tauri::Builder::default() + .manage(AppState { + session: Mutex::new(None), + }) + .invoke_handler(tauri::generate_handler![enroll_device, connect_tunnel, disconnect_tunnel]) + .run(tauri::generate_context!()) + .expect("error while running tauri application"); +} diff --git a/desktop-client/src-tauri/src/main.rs b/desktop-client/src-tauri/src/main.rs new file mode 100644 index 0000000..46b6336 --- /dev/null +++ b/desktop-client/src-tauri/src/main.rs @@ -0,0 +1,3 @@ +fn main() { + nexavpn_desktop::run(); +} diff --git a/desktop-client/src-tauri/tauri.conf.json b/desktop-client/src-tauri/tauri.conf.json new file mode 100644 index 0000000..3f2635e --- /dev/null +++ b/desktop-client/src-tauri/tauri.conf.json @@ -0,0 +1,30 @@ +{ + "$schema": "https://schema.tauri.app/config/2", + "productName": "NexaVPN", + "version": "0.1.0", + "identifier": "com.nexavpn.desktop", + "build": { + "beforeDevCommand": "npm run dev", + "beforeBuildCommand": "npm run build", + "devUrl": "http://localhost:4173", + "frontendDist": "../dist" + }, + "app": { + "windows": [ + { + "title": "NexaVPN", + "width": 1120, + "height": 760, + "resizable": true + } + ], + "security": { + "csp": null + } + }, + "bundle": { + "active": true, + "targets": "all", + "icon": [] + } +} diff --git a/desktop-client/src/App.tsx b/desktop-client/src/App.tsx new file mode 100644 index 0000000..4049257 --- /dev/null +++ b/desktop-client/src/App.tsx @@ -0,0 +1,107 @@ +import { FormEvent, useState } from "react"; +import { invoke } from "@tauri-apps/api/core"; + +type EnrollmentState = { + assignedIp: string; + resources: string[]; + profileRevision: number; + gatewayEndpoint: string; +}; + +export function App() { + const [serverUrl, setServerUrl] = useState("http://localhost"); + const [username, setUsername] = useState(""); + const [password, setPassword] = useState(""); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [connected, setConnected] = useState(false); + const [state, setState] = useState(null); + + async function onSubmit(event: FormEvent) { + event.preventDefault(); + setLoading(true); + setError(null); + + try { + const result = await invoke("enroll_device", { + payload: { serverUrl, username, password } + }); + setState(result); + } catch (err) { + setError(err instanceof Error ? err.message : "Enrollment failed"); + } finally { + setLoading(false); + } + } + + async function toggleConnection() { + const command = connected ? "disconnect_tunnel" : "connect_tunnel"; + await invoke(command); + setConnected((value) => !value); + } + + return ( +
+
+

NexaVPN

+

Private access without manual WireGuard setup.

+

+ Enter your VPN address, username, and password. NexaVPN provisions and stores the profile for you. +

+
+ + {!state ? ( +
+ + + + {error ?
{error}
: null} + + + ) : ( +
+
+
+

Connection

+

{connected ? "Connected" : "Disconnected"}

+
+ +
+
+
+ Assigned VPN IP + {state.assignedIp} +
+
+ Gateway + {state.gatewayEndpoint} +
+
+ Profile revision + {state.profileRevision} +
+
+
+

Allowed resources

+
    + {state.resources.map((resource) => ( +
  • {resource}
  • + ))} +
+
+
+ )} +
+ ); +} diff --git a/desktop-client/src/main.tsx b/desktop-client/src/main.tsx new file mode 100644 index 0000000..a65b799 --- /dev/null +++ b/desktop-client/src/main.tsx @@ -0,0 +1,11 @@ +import React from "react"; +import ReactDOM from "react-dom/client"; + +import { App } from "./App"; +import "./styles.css"; + +ReactDOM.createRoot(document.getElementById("root")!).render( + + + +); diff --git a/desktop-client/src/styles.css b/desktop-client/src/styles.css new file mode 100644 index 0000000..6c0a8d1 --- /dev/null +++ b/desktop-client/src/styles.css @@ -0,0 +1,111 @@ +:root { + font-family: "Segoe UI", "SF Pro Text", sans-serif; + color: #f5f7fb; + background: + radial-gradient(circle at top, rgba(79, 208, 164, 0.18), transparent 25%), + linear-gradient(180deg, #08111f 0%, #0d1727 100%); +} + +* { + box-sizing: border-box; +} + +body { + margin: 0; +} + +.client-shell { + min-height: 100vh; + display: grid; + place-items: center; + gap: 24px; + padding: 32px 20px; +} + +.hero, +.panel { + width: min(560px, 100%); +} + +.hero { + text-align: center; +} + +.eyebrow { + color: #74e0b8; + letter-spacing: 0.18em; + text-transform: uppercase; + font-size: 0.78rem; +} + +.lede { + color: #a9b8d3; +} + +.panel { + padding: 24px; + border-radius: 24px; + background: rgba(12, 22, 38, 0.84); + border: 1px solid rgba(167, 185, 219, 0.14); + box-shadow: 0 24px 60px rgba(2, 8, 18, 0.36); + backdrop-filter: blur(16px); +} + +form { + display: grid; + gap: 16px; +} + +label { + display: grid; + gap: 8px; + color: #c2cfe5; +} + +input { + border: 1px solid rgba(167, 185, 219, 0.16); + background: rgba(7, 14, 27, 0.85); + color: #f5f7fb; + border-radius: 14px; + padding: 14px 16px; +} + +button { + border: 0; + border-radius: 999px; + padding: 13px 18px; + font-weight: 700; + background: linear-gradient(135deg, #74e0b8 0%, #1fb67a 100%); + color: #04141a; +} + +.error { + color: #ffb7b7; +} + +.status { + display: grid; + gap: 18px; +} + +.status-row, +.details { + display: flex; + justify-content: space-between; + gap: 16px; + flex-wrap: wrap; +} + +.details div { + display: grid; + gap: 6px; +} + +.details span { + color: #9db0cf; +} + +.resource-list { + margin: 0; + padding-left: 18px; +} diff --git a/desktop-client/tsconfig.app.json b/desktop-client/tsconfig.app.json new file mode 100644 index 0000000..d494c5f --- /dev/null +++ b/desktop-client/tsconfig.app.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "target": "ES2020", + "useDefineForClassFields": true, + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "module": "ESNext", + "skipLibCheck": true, + "moduleResolution": "Bundler", + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "jsx": "react-jsx", + "strict": true + }, + "include": ["src"] +} diff --git a/desktop-client/tsconfig.json b/desktop-client/tsconfig.json new file mode 100644 index 0000000..426eda2 --- /dev/null +++ b/desktop-client/tsconfig.json @@ -0,0 +1,6 @@ +{ + "files": [], + "references": [ + { "path": "./tsconfig.app.json" } + ] +} diff --git a/desktop-client/vite.config.ts b/desktop-client/vite.config.ts new file mode 100644 index 0000000..092ec6a --- /dev/null +++ b/desktop-client/vite.config.ts @@ -0,0 +1,12 @@ +import { defineConfig } from "vite"; +import react from "@vitejs/plugin-react"; + +export default defineConfig({ + plugins: [react()], + clearScreen: false, + server: { + port: 4173, + strictPort: true, + host: true + } +}); diff --git a/docs/api.md b/docs/api.md new file mode 100644 index 0000000..80cfd73 --- /dev/null +++ b/docs/api.md @@ -0,0 +1,223 @@ +# REST API Contract + +Base path: `/api/v1` + +All responses are JSON. + +## Authentication + +### `POST /auth/login` + +Request: + +```json +{ + "username": "alice", + "password": "secret-password" +} +``` + +Response: + +```json +{ + "access_token": "jwt", + "refresh_token": "opaque-token", + "expires_in": 900, + "user": { + "id": "uuid", + "username": "alice", + "display_name": "Alice", + "role": "admin" + } +} +``` + +### `POST /auth/refresh` + +Request: + +```json +{ + "refresh_token": "opaque-token" +} +``` + +### `POST /auth/logout` + +Request: + +```json +{ + "refresh_token": "opaque-token" +} +``` + +### `GET /auth/me` + +Returns the authenticated user and role. + +## User Self-Service + +### `GET /me/devices` + +Returns the current user devices. + +### `GET /me/profile` + +Returns profile metadata for the current active device when the client needs to resync. + +## Device Enrollment and Provisioning + +### `POST /devices/enroll` + +Request: + +```json +{ + "name": "Alice MacBook Pro", + "platform": "macos", + "os_version": "14.4", + "app_version": "0.1.0", + "device_fingerprint": "sha256:...", + "public_key": "base64-wireguard-public-key" +} +``` + +Response: + +```json +{ + "device": { + "id": "uuid", + "name": "Alice MacBook Pro", + "status": "active" + }, + "peer": { + "assigned_ip": "100.96.0.10/32", + "dns_servers": ["10.20.0.53"], + "allowed_ips": ["172.16.10.0/24"], + "gateway": { + "id": "uuid", + "name": "primary-gateway", + "endpoint": "vpn.example.com:51820", + "public_key": "gateway-public-key" + }, + "profile_revision": 1 + }, + "profile": { + "format": "wireguard", + "content": "[Interface]\n..." + }, + "resources": [ + { + "type": "cidr", + "value": "172.16.10.0/24", + "label": "Private subnet" + } + ] +} +``` + +### `GET /devices/{deviceId}/profile` + +Admin-only debug endpoint for rendered config retrieval. + +### `POST /devices/{deviceId}/rotate` + +Rotates the device profile revision and forces reprovisioning. + +### `POST /devices/{deviceId}/revoke` + +Revokes the device and removes it from future gateway sync output. + +### `POST /devices/{deviceId}/heartbeat` + +Optional client status sync for last-seen and runtime metadata. + +## Connection Metadata + +### `GET /connection/status` + +Returns assigned IP, latest sync time, and effective allowed resources for the current authenticated device session. + +## Admin: Users + +### `GET /admin/users` +### `POST /admin/users` +### `GET /admin/users/{id}` +### `PATCH /admin/users/{id}` +### `POST /admin/users/{id}/disable` +### `POST /admin/users/{id}/enable` +### `POST /admin/users/{id}/password` + +## Admin: Devices + +### `GET /admin/devices` +### `GET /admin/devices/{id}` +### `PATCH /admin/devices/{id}` +### `POST /admin/devices/{id}/revoke` +### `POST /admin/devices/{id}/rotate` + +## Admin: Policies + +### `GET /admin/policies` +### `POST /admin/policies` +### `GET /admin/policies/{id}` +### `PATCH /admin/policies/{id}` +### `DELETE /admin/policies/{id}` + +Policy create request: + +```json +{ + "name": "Finance subnet access", + "description": "Access for finance team", + "priority": 100, + "effect": "allow", + "full_tunnel": false, + "destinations": [ + "172.16.20.0/24", + "172.16.21.10/32" + ], + "targets": [ + { + "type": "user", + "id": "uuid" + } + ] +} +``` + +## Admin: Gateways + +### `GET /admin/gateways` +### `POST /admin/gateways` +### `GET /admin/gateways/{id}` +### `PATCH /admin/gateways/{id}` +### `GET /admin/gateways/{id}/sync` + +The sync endpoint returns the peer and firewall bundle consumed by the gateway helper. + +## Admin: Audit + +### `GET /admin/audit-logs` + +Query params: + +- `event_type` +- `entity_type` +- `status` +- `page` +- `page_size` + +## Error Format + +```json +{ + "error": { + "code": "validation_error", + "message": "public_key is required" + } +} +``` diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 0000000..5fa9a7d --- /dev/null +++ b/docs/architecture.md @@ -0,0 +1,180 @@ +# NexaVPN Architecture + +## System Overview + +NexaVPN is a self-hosted remote access platform that uses WireGuard for transport and a centralized control plane for identity, device enrollment, provisioning, and policy enforcement. + +The platform is split into four major planes: + +1. Control plane + - Go REST API + - PostgreSQL + - JWT auth and refresh sessions + - policy engine + - audit logging + - WireGuard profile builder + - gateway state publisher +2. Management plane + - React admin console + - user, device, policy, gateway, and audit workflows +3. Endpoint plane + - Tauri desktop client for Windows and macOS + - local secure token/config storage + - on-device WireGuard keypair generation + - embedded tunnel lifecycle management +4. Data plane + - Linux WireGuard gateway + - nftables policy enforcement + - routed access to protected resources + +## Logical Components + +### Backend + +- `auth` + - username/password login + - access and refresh token issuance + - session tracking +- `user` + - user CRUD + - role assignment + - account enable/disable +- `device` + - device registration + - enrollment lifecycle + - device revocation + - device profile rotation +- `policy` + - user and device policy resolution + - group-aware target model + - allow-list centric MVP with deny precedence reserved in schema +- `gateway` + - gateway inventory + - endpoint metadata + - peer sync bundle generation + - firewall rule translation output +- `profile` + - WireGuard config assembly + - connect metadata response +- `audit` + - immutable security and admin events +- `ipam` + - VPN address pool allocation + - uniqueness and lifecycle tracking + +### Admin UI + +- Dashboard + - counts, enrollment trend, latest audit events +- Users + - create, edit, disable, password reset +- Devices + - list, revoke, rotate, inspect assigned profile metadata +- Policies + - create CIDR-based allow rules and attach them to users, devices, or groups +- Gateways + - endpoint configuration, sync status, address pool view +- Audit + - searchable event history +- Settings + - DNS defaults, token lifetimes, bootstrap settings + +### Desktop Client + +- onboarding + - server URL + - username + - password +- enrollment + - machine fingerprint generation + - WireGuard keypair generation on-device + - device registration + - profile provisioning +- runtime + - secure local storage + - connect/disconnect + - status display + - last sync time + - allowed resources display +- diagnostics + - logs + - TLS trust warning surface + - profile refresh retry + +### Gateway + +- WireGuard interface +- issued peers synced from control plane +- nftables chain generated from effective device policies +- route advertisement limited to assigned resources or full tunnel mode + +## Key Design Decisions + +### Authentication + +- Access tokens are short-lived JWTs. +- Refresh tokens are opaque, hashed in the database, and bound to a session. +- Admin and standard-user authorization is role-based. + +### Device Trust + +- A device is represented as a durable record linked to a user. +- Clients generate their own WireGuard keypairs. +- Only the public key is stored server-side. +- Device rotation invalidates the old peer and issues a fresh profile revision. + +### Policy Model + +- Effective access is the union of active allow policies targeted at: + - the user + - the device + - any future groups +- Device-specific policies can narrow or extend user-level access. +- Gateway enforcement is authoritative; the client display is informational. + +### WireGuard Provisioning + +- The backend returns structured peer metadata and a rendered profile payload. +- The desktop client stores the private key and profile locally. +- The MVP uses an embedded tunnel-management abstraction rather than depending on the standalone WireGuard desktop app. + +### Expandability + +The schema and package layout reserve room for: + +- MFA +- OIDC and SSO +- approval-based enrollment +- group and role expansion +- multiple gateways +- route and posture-aware policies +- richer sync agents at the gateway edge + +## Request Flow Summary + +### Login and Enrollment + +1. User enters server URL, username, and password in the desktop app. +2. Client authenticates against `/api/v1/auth/login`. +3. Client generates a WireGuard keypair and device fingerprint locally. +4. Client registers with `/api/v1/devices/enroll`. +5. Backend resolves policy, allocates IP, selects a gateway, stores the peer, and returns profile metadata. +6. Client stores tokens and WireGuard config securely. +7. Client uses the embedded tunnel manager to create the local profile and expose one-click connect/disconnect. + +### Policy Update + +1. Admin changes a policy in the web UI. +2. Backend stores the update and writes an audit event. +3. Gateway sync state is recalculated. +4. Gateway rule bundle is regenerated for affected peers. +5. The client sees refreshed allowed resources on next sync. + +## Security Posture + +- Passwords use Argon2id. +- Refresh tokens are stored hashed. +- Device private keys stay local. +- Audit logs capture auth, enrollment, policy, and admin actions. +- TLS is assumed in production behind a reverse proxy. +- Gateway firewalling enforces allowed destinations independently from the client. diff --git a/docs/deployment.md b/docs/deployment.md new file mode 100644 index 0000000..2cf62c7 --- /dev/null +++ b/docs/deployment.md @@ -0,0 +1,60 @@ +# Deployment Layout + +## Services + +- `postgres` + - primary relational database +- `backend` + - Go API and migration runner +- `admin-web` + - static React admin UI served by nginx +- `gateway` + - WireGuard plus nftables helper container or host-managed service +- `reverse-proxy` + - TLS termination and routing + +## Docker Compose Networks + +- `control` + - backend, postgres, admin-web, reverse-proxy +- `gateway` + - backend and gateway helper communication + +## Volume Layout + +- postgres data volume +- backend local state volume for dev logs if needed +- gateway config volume for rendered peer sync + +## Bootstrap + +1. Start PostgreSQL. +2. Run migrations. +3. Start the backend. +4. Seed roles, settings, and the initial admin user. +5. Start the admin UI and reverse proxy. +6. Register the first gateway. + +## Example Commands + +```bash +cd deploy +cp .env.example .env +docker compose up -d postgres +docker compose up -d backend admin-web reverse-proxy +``` + +For SQL bootstrap during early MVP testing: + +```bash +psql "$DATABASE_URL" -f backend/migrations/000001_init.sql +psql "$DATABASE_URL" -f backend/seed/001_seed.sql +``` + +## Production Notes + +- Terminate TLS at nginx or another reverse proxy. +- Restrict backend and database exposure to private networks. +- Run the gateway with the privileges required for WireGuard and nftables. +- Replace example secrets before deployment. +- Use an external secret manager when available. diff --git a/docs/enrollment.md b/docs/enrollment.md new file mode 100644 index 0000000..7f6fd39 --- /dev/null +++ b/docs/enrollment.md @@ -0,0 +1,76 @@ +# Device Enrollment And Auto-Provisioning + +## Enrollment Flow + +1. The user launches the desktop client. +2. The client shows a minimal form with: + - server URL + - username + - password +3. The client validates the server URL and performs a TLS reachability check. +4. The user submits credentials. +5. The client calls `POST /api/v1/auth/login`. +6. On success, the client securely stores: + - access token + - refresh token + - remembered server URL +7. The client generates a WireGuard keypair locally. +8. The client computes a stable device fingerprint. +9. The client calls `POST /api/v1/devices/enroll`. +10. The backend: + - creates or updates the device record + - selects an active gateway + - allocates a VPN IP + - resolves effective policy destinations + - stores the peer + - renders the WireGuard profile + - emits audit events +11. The client stores the profile locally in secure application storage. +12. The client registers the profile with the local tunnel manager. +13. The post-login view shows: + - connection status + - assigned VPN IP + - allowed resources + - connect/disconnect button + - last sync time + +## Private Key Handling + +- The WireGuard private key is generated on-device. +- It is never sent to the backend. +- The backend stores only the public key and provisioning metadata. +- The desktop app keeps the private key inside the OS-secured storage boundary where possible. + +## Auto-Profile Provisioning Design + +The provisioning response returns both structured metadata and a rendered WireGuard config. The client uses the rendered config for immediate compatibility and the structured metadata for UI status. + +### Preferred MVP Strategy + +Use an embedded tunnel manager inside the Tauri app: + +- Windows + - manage the tunnel using a Rust bridge and local service abstraction + - support future integration with WireGuardNT or the official service +- macOS + - manage the tunnel through a privileged helper or Network Extension bridge in a later hardening phase + - MVP keeps the app abstraction stable while platform backends can evolve + +This approach keeps the user flow consistent even if the platform-specific implementation differs. + +### Fallback Strategy + +If native embedded control is not ready on a platform: + +- the app still auto-creates the profile locally +- the app exposes a one-click import handoff to the system WireGuard client +- this fallback is clearly labeled as temporary in documentation, not as the intended end state + +## Reprovisioning + +- Profile rotation increments `profile_revision`. +- On next sync or forced refresh, the client fetches a new profile. +- Revoked devices lose access because: + - the gateway peer is removed + - tokens can be invalidated + - the client marks the local profile unusable diff --git a/docs/folder-structure.md b/docs/folder-structure.md new file mode 100644 index 0000000..b634d96 --- /dev/null +++ b/docs/folder-structure.md @@ -0,0 +1,84 @@ +# Folder Structure + +```text +NexaVPN/ +├── README.md +├── docs/ +│ ├── api.md +│ ├── architecture.md +│ ├── deployment.md +│ ├── enrollment.md +│ ├── folder-structure.md +│ ├── gateway.md +│ └── schema.md +├── backend/ +│ ├── cmd/api/ +│ │ └── main.go +│ ├── internal/ +│ │ ├── apiutil/ +│ │ ├── app/ +│ │ ├── audit/ +│ │ ├── auth/ +│ │ ├── config/ +│ │ ├── db/ +│ │ ├── device/ +│ │ ├── gateway/ +│ │ ├── httpserver/ +│ │ ├── ipam/ +│ │ ├── policy/ +│ │ ├── profile/ +│ │ ├── user/ +│ │ └── wireguard/ +│ ├── migrations/ +│ │ └── 000001_init.sql +│ ├── seed/ +│ │ └── 001_seed.sql +│ ├── Dockerfile +│ └── go.mod +├── admin-web/ +│ ├── src/ +│ │ ├── api/ +│ │ ├── app/ +│ │ ├── components/ +│ │ ├── features/ +│ │ │ ├── audit/ +│ │ │ ├── dashboard/ +│ │ │ ├── devices/ +│ │ │ ├── gateways/ +│ │ │ ├── policies/ +│ │ │ ├── settings/ +│ │ │ └── users/ +│ │ └── styles/ +│ ├── Dockerfile +│ ├── index.html +│ ├── package.json +│ ├── tsconfig.app.json +│ ├── tsconfig.json +│ └── vite.config.ts +├── desktop-client/ +│ ├── src/ +│ │ ├── App.tsx +│ │ ├── main.tsx +│ │ └── styles.css +│ ├── src-tauri/ +│ │ ├── src/ +│ │ │ ├── lib.rs +│ │ │ └── main.rs +│ │ ├── build.rs +│ │ ├── Cargo.toml +│ │ └── tauri.conf.json +│ ├── index.html +│ ├── package.json +│ ├── tsconfig.app.json +│ ├── tsconfig.json +│ └── vite.config.ts +└── deploy/ + ├── .env.example + ├── docker-compose.yml + ├── nginx/ + │ ├── admin.conf + │ └── reverse-proxy.conf + └── scripts/ + ├── bootstrap-admin.sh + └── gateway-entrypoint.sh +``` diff --git a/docs/gateway.md b/docs/gateway.md new file mode 100644 index 0000000..6efb1f4 --- /dev/null +++ b/docs/gateway.md @@ -0,0 +1,69 @@ +# Gateway Enforcement Strategy + +## WireGuard And Firewall Roles + +- WireGuard authenticates peers and provides encrypted transport. +- nftables enforces which protected destinations a peer may reach. +- NexaVPN control plane translates policy into gateway-side rules. + +## Gateway Sync Bundle + +Each gateway receives a generated sync bundle that contains: + +- interface settings +- peer list +- peer allowed source address +- destination policy matrix +- DNS metadata +- revision metadata + +Example bundle shape: + +```json +{ + "gateway_id": "uuid", + "revision": 12, + "interface": { + "address": "100.96.0.1/24", + "listen_port": 51820 + }, + "peers": [ + { + "device_id": "uuid", + "public_key": "peer-key", + "assigned_ip": "100.96.0.10/32", + "allowed_destinations": [ + "172.16.10.0/24" + ] + } + ] +} +``` + +## nftables Model + +Recommended model: + +1. Accept WireGuard interface input. +2. Map peer source VPN IP to allowed destination CIDRs. +3. Drop traffic from VPN clients to destinations outside their effective allow list. +4. Permit full tunnel peers through explicit default-route policy. + +High-level chain logic: + +- traffic enters from `wg0` +- source address identifies the device +- destination is matched against generated sets +- allowed traffic is accepted +- unmatched traffic is dropped and optionally logged + +## Enforcement Details + +- Each device receives a unique VPN IP, which makes firewall mapping deterministic. +- The generated firewall rules are derived from the effective policy union. +- Device revocation removes both the WireGuard peer and its nftables set members. +- Full-tunnel policy expands to `0.0.0.0/0` and `::/0` when enabled in later IPv6 support. + +## Multi-Gateway Readiness + +The backend stores policies independently from the gateway implementation. Each gateway receives only the peers assigned to it, which keeps multi-gateway expansion straightforward later. diff --git a/docs/schema.md b/docs/schema.md new file mode 100644 index 0000000..1c6ce8f --- /dev/null +++ b/docs/schema.md @@ -0,0 +1,204 @@ +# PostgreSQL Schema + +## Core Tables + +### `roles` + +- `id uuid primary key` +- `name text unique not null` +- `description text not null default ''` +- `created_at timestamptz not null default now()` +- `updated_at timestamptz not null default now()` + +### `users` + +- `id uuid primary key` +- `role_id uuid not null references roles(id)` +- `username citext unique not null` +- `display_name text not null` +- `email citext unique` +- `password_hash text not null` +- `is_active boolean not null default true` +- `last_login_at timestamptz` +- `created_at timestamptz not null default now()` +- `updated_at timestamptz not null default now()` +- `deleted_at timestamptz` + +### `sessions` + +- `id uuid primary key` +- `user_id uuid not null references users(id)` +- `ip_address inet` +- `user_agent text` +- `last_seen_at timestamptz not null default now()` +- `expires_at timestamptz not null` +- `created_at timestamptz not null default now()` +- `revoked_at timestamptz` + +### `refresh_tokens` + +- `id uuid primary key` +- `session_id uuid not null references sessions(id)` +- `user_id uuid not null references users(id)` +- `token_hash text not null` +- `expires_at timestamptz not null` +- `created_at timestamptz not null default now()` +- `revoked_at timestamptz` + +### `gateways` + +- `id uuid primary key` +- `name text unique not null` +- `endpoint text not null` +- `public_key text not null` +- `listen_port integer not null` +- `vpn_cidr cidr not null` +- `dns_servers text[] not null default '{}'` +- `is_active boolean not null default true` +- `last_sync_at timestamptz` +- `created_at timestamptz not null default now()` +- `updated_at timestamptz not null default now()` +- `deleted_at timestamptz` + +### `devices` + +- `id uuid primary key` +- `user_id uuid not null references users(id)` +- `gateway_id uuid references gateways(id)` +- `name text not null` +- `platform text not null` +- `os_version text not null default ''` +- `app_version text not null default ''` +- `device_fingerprint text not null` +- `public_key text not null` +- `status text not null default 'active'` +- `last_seen_at timestamptz` +- `last_connected_at timestamptz` +- `approved_at timestamptz` +- `revoked_at timestamptz` +- `created_at timestamptz not null default now()` +- `updated_at timestamptz not null default now()` +- `deleted_at timestamptz` + +Unique index: + +- `(user_id, device_fingerprint)` where `deleted_at is null` + +### `wireguard_peers` + +- `id uuid primary key` +- `device_id uuid not null references devices(id)` +- `gateway_id uuid not null references gateways(id)` +- `public_key text unique not null` +- `assigned_ip inet not null` +- `preshared_key_ciphertext text` +- `allowed_ips cidr[] not null default '{}'` +- `dns_servers text[] not null default '{}'` +- `profile_revision integer not null default 1` +- `last_profile_issued_at timestamptz` +- `created_at timestamptz not null default now()` +- `updated_at timestamptz not null default now()` +- `deleted_at timestamptz` + +### `ip_allocations` + +- `id uuid primary key` +- `gateway_id uuid not null references gateways(id)` +- `device_id uuid references devices(id)` +- `address inet not null` +- `status text not null default 'allocated'` +- `allocated_at timestamptz not null default now()` +- `released_at timestamptz` +- `created_at timestamptz not null default now()` +- `updated_at timestamptz not null default now()` + +Unique indexes: + +- `(gateway_id, address)` +- `(device_id)` where `status = 'allocated'` + +### `policies` + +- `id uuid primary key` +- `name text unique not null` +- `description text not null default ''` +- `priority integer not null default 100` +- `effect text not null default 'allow'` +- `is_active boolean not null default true` +- `full_tunnel boolean not null default false` +- `created_by uuid references users(id)` +- `created_at timestamptz not null default now()` +- `updated_at timestamptz not null default now()` +- `deleted_at timestamptz` + +### `policy_targets` + +- `id uuid primary key` +- `policy_id uuid not null references policies(id)` +- `target_type text not null` +- `target_id uuid not null` +- `created_at timestamptz not null default now()` + +Target types: + +- `user` +- `device` +- `group` + +### `policy_destinations` + +- `id uuid primary key` +- `policy_id uuid not null references policies(id)` +- `destination cidr not null` +- `description text not null default ''` +- `created_at timestamptz not null default now()` + +### `groups` + +- `id uuid primary key` +- `name text unique not null` +- `description text not null default ''` +- `created_at timestamptz not null default now()` +- `updated_at timestamptz not null default now()` +- `deleted_at timestamptz` + +### `group_memberships` + +- `id uuid primary key` +- `group_id uuid not null references groups(id)` +- `user_id uuid not null references users(id)` +- `created_at timestamptz not null default now()` + +### `audit_logs` + +- `id uuid primary key` +- `actor_user_id uuid references users(id)` +- `actor_device_id uuid references devices(id)` +- `event_type text not null` +- `entity_type text not null` +- `entity_id uuid` +- `status text not null` +- `ip_address inet` +- `message text not null` +- `metadata jsonb not null default '{}'::jsonb` +- `created_at timestamptz not null default now()` + +### `settings` + +- `id uuid primary key` +- `category text not null` +- `key text not null` +- `value jsonb not null` +- `created_at timestamptz not null default now()` +- `updated_at timestamptz not null default now()` + +Unique index: + +- `(category, key)` + +## Notes + +- UUIDs are generated with `gen_random_uuid()`. +- `citext` is used for case-insensitive usernames and emails. +- Soft deletes are enabled where historical traceability matters. +- Group tables are included now so policy resolution can grow without a destructive migration later.