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
This commit is contained in:
2026-03-15 16:32:34 +01:00
commit 830491cb0d
91 changed files with 5279 additions and 0 deletions

11
.gitignore vendored Normal file
View File

@@ -0,0 +1,11 @@
node_modules/
dist/
build/
.DS_Store
.env
.env.local
coverage/
tmp/
*.log
desktop-client/src-tauri/target/
backend/bin/

60
README.md Normal file
View File

@@ -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

10
admin-web/Dockerfile Normal file
View File

@@ -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

12
admin-web/index.html Normal file
View File

@@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>NexaVPN Admin</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

18
admin-web/nginx.conf Normal file
View File

@@ -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;
}
}

24
admin-web/package.json Normal file
View File

@@ -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"
}
}

View File

@@ -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<T>(path: string, init?: RequestInit): Promise<T> {
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<T>;
}
export const api = {
users: () => request<User[]>("/admin/users"),
devices: () => request<Device[]>("/admin/devices"),
policies: () => request<Policy[]>("/admin/policies"),
gateways: () => request<Gateway[]>("/admin/gateways"),
audit: () => request<AuditEvent[]>("/admin/audit-logs")
};

40
admin-web/src/app/App.tsx Normal file
View File

@@ -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 (
<Routes>
<Route
path="/login"
element={authenticated ? <Navigate to="/" replace /> : <LoginPage onAuthenticated={handleAuthenticated} />}
/>
<Route element={authenticated ? <Layout /> : <Navigate to="/login" replace />}>
<Route path="/" element={<DashboardPage />} />
<Route path="/users" element={<UsersPage />} />
<Route path="/devices" element={<DevicesPage />} />
<Route path="/policies" element={<PoliciesPage />} />
<Route path="/gateways" element={<GatewaysPage />} />
<Route path="/audit" element={<AuditPage />} />
<Route path="/settings" element={<SettingsPage />} />
</Route>
</Routes>
);
}

View File

@@ -0,0 +1,5 @@
import { PropsWithChildren } from "react";
export function Card({ children }: PropsWithChildren) {
return <div className="card">{children}</div>;
}

View File

@@ -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 (
<div className="shell">
<aside className="sidebar">
<div>
<p className="eyebrow">NexaVPN</p>
<h1>Control Plane</h1>
</div>
<nav className="nav">
{items.map(([label, path]) => (
<NavLink
key={path}
to={path}
className={({ isActive }) => (isActive ? "nav-link active" : "nav-link")}
>
{label}
</NavLink>
))}
</nav>
</aside>
<main className="content">
<header className="topbar">
<div>
<p className="eyebrow">Enterprise WireGuard</p>
<h2>Self-hosted VPN management</h2>
</div>
<div className="pill">Secure by design</div>
</header>
<Outlet />
</main>
</div>
);
}

View File

@@ -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 (
<section className="page">
<div className="page-header">
<div>
<h3>{title}</h3>
<p>{subtitle}</p>
</div>
{actions}
</div>
{children}
</section>
);
}

View File

@@ -0,0 +1,37 @@
import { PropsWithChildren, ReactNode } from "react";
type Column = {
key: string;
label: string;
};
type TableProps<T> = {
columns: Column[];
rows: T[];
renderCell: (row: T, column: Column) => ReactNode;
};
export function Table<T>({ columns, rows, renderCell }: PropsWithChildren<TableProps<T>>) {
return (
<div className="table-wrap">
<table className="table">
<thead>
<tr>
{columns.map((column) => (
<th key={column.key}>{column.label}</th>
))}
</tr>
</thead>
<tbody>
{rows.map((row, index) => (
<tr key={index}>
{columns.map((column) => (
<td key={column.key}>{renderCell(row, column)}</td>
))}
</tr>
))}
</tbody>
</table>
</div>
);
}

View File

@@ -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 (
<Page title="Audit Logs" subtitle="Review authentication, provisioning, and admin activity.">
{query.isError ? <p className="notice">Unable to load audit events from the API.</p> : null}
<Table
columns={columns}
rows={rows}
renderCell={(row, column) => <span>{row[column.key as keyof (typeof rows)[number]]}</span>}
/>
</Page>
);
}

View File

@@ -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<string | null>(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 (
<div className="auth-shell">
<form className="auth-card" onSubmit={onSubmit}>
<p className="eyebrow">NexaVPN Admin</p>
<h2>{mode === "login" ? "Sign in" : "Create initial admin"}</h2>
<p className="auth-copy">
{mode === "login"
? "Use your NexaVPN admin credentials."
: "Bootstrap the first admin account for a fresh deployment."}
</p>
<label>
Username
<input value={username} onChange={(event) => setUsername(event.target.value)} />
</label>
{mode === "bootstrap" ? (
<label>
Display name
<input value={displayName} onChange={(event) => setDisplayName(event.target.value)} />
</label>
) : null}
<label>
Password
<input type="password" value={password} onChange={(event) => setPassword(event.target.value)} />
</label>
{error ? <p className="notice">{error}</p> : null}
<button className="button" disabled={loading} type="submit">
{loading ? "Please wait..." : mode === "login" ? "Sign in" : "Bootstrap and sign in"}
</button>
<button
className="ghost-button"
type="button"
onClick={() => setMode((current) => (current === "login" ? "bootstrap" : "login"))}
>
{mode === "login" ? "Need initial setup?" : "Already have an admin?"}
</button>
</form>
</div>
);
}

View File

@@ -0,0 +1,36 @@
import { Card } from "../../components/Card";
import { Page } from "../../components/Page";
export function DashboardPage() {
return (
<Page title="Dashboard" subtitle="Operational visibility across users, devices, gateways, and policy activity.">
<div className="grid three">
<Card>
<p className="metric-label">Active users</p>
<strong className="metric-value">248</strong>
<span>12 enrolled this week</span>
</Card>
<Card>
<p className="metric-label">Connected devices</p>
<strong className="metric-value">181</strong>
<span>7 pending rotation</span>
</Card>
<Card>
<p className="metric-label">Gateway health</p>
<strong className="metric-value">Healthy</strong>
<span>Last sync 36s ago</span>
</Card>
</div>
<div className="grid two">
<Card>
<h4>Recent enrollments</h4>
<p>New devices are issued profiles automatically after successful sign-in.</p>
</Card>
<Card>
<h4>Policy posture</h4>
<p>Gateway enforcement is generated from effective allow-lists backed by nftables.</p>
</Card>
</div>
</Page>
);
}

View File

@@ -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 (
<Page title="Devices" subtitle="Inspect enrolled clients, assignments, and profile lifecycle.">
{query.isError ? <p className="notice">Unable to load devices from the API.</p> : null}
<Table
columns={columns}
rows={rows}
renderCell={(row, column) => <span>{row[column.key as keyof (typeof rows)[number]]}</span>}
/>
</Page>
);
}

View File

@@ -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 (
<Page title="Gateways" subtitle="Track WireGuard endpoints and sync state.">
{query.isError ? <p className="notice">Unable to load gateways from the API.</p> : null}
<Table
columns={columns}
rows={rows}
renderCell={(row, column) => <span>{row[column.key as keyof (typeof rows)[number]]}</span>}
/>
</Page>
);
}

View File

@@ -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 (
<Page
title="Policies"
subtitle="Manage per-user and per-device access controls enforced at the gateway."
actions={<button className="button">New policy</button>}
>
{query.isError ? <p className="notice">Unable to load policies from the API.</p> : null}
<Table
columns={columns}
rows={rows}
renderCell={(row, column) => <span>{row[column.key as keyof (typeof rows)[number]]}</span>}
/>
</Page>
);
}

View File

@@ -0,0 +1,21 @@
import { Card } from "../../components/Card";
import { Page } from "../../components/Page";
export function SettingsPage() {
return (
<Page title="Settings" subtitle="Default DNS, token lifetime, and deployment configuration surfaces.">
<div className="grid two">
<Card>
<h4>Auth</h4>
<p>Access token TTL: 15 minutes</p>
<p>Refresh token TTL: 30 days</p>
</Card>
<Card>
<h4>VPN</h4>
<p>Default DNS: 10.20.0.53</p>
<p>Address pool: 100.96.0.0/24</p>
</Card>
</div>
</Page>
);
}

View File

@@ -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 (
<Page
title="Users"
subtitle="Create, disable, and manage platform identities."
actions={<button className="button">New user</button>}
>
{query.isError ? <p className="notice">Unable to load users from the API.</p> : null}
<Table
columns={columns}
rows={rows}
renderCell={(row, column) => <span>{row[column.key as keyof (typeof rows)[number]]}</span>}
/>
</Page>
);
}

19
admin-web/src/main.tsx Normal file
View File

@@ -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(
<React.StrictMode>
<QueryClientProvider client={client}>
<BrowserRouter>
<App />
</BrowserRouter>
</QueryClientProvider>
</React.StrictMode>
);

View File

@@ -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;
}
}

View File

@@ -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"]
}

6
admin-web/tsconfig.json Normal file
View File

@@ -0,0 +1,6 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" }
]
}

10
admin-web/vite.config.ts Normal file
View File

@@ -0,0 +1,10 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
export default defineConfig({
plugins: [react()],
server: {
port: 5173,
host: true
}
});

14
backend/Dockerfile Normal file
View File

@@ -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"]

48
backend/cmd/api/main.go Normal file
View File

@@ -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)
}
}

11
backend/go.mod Normal file
View File

@@ -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
)

View File

@@ -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)
}

View File

@@ -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()
}
}

View File

@@ -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)
}

View File

@@ -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()
}

View File

@@ -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
}

View File

@@ -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,
})
}

View File

@@ -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[:])
}

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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)
}

View File

@@ -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
}

View File

@@ -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"`
}

View File

@@ -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
}

11
backend/internal/db/db.go Normal file
View File

@@ -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)
}

View File

@@ -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})
}

View File

@@ -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
}

View File

@@ -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)
}

View File

@@ -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"`
}

View File

@@ -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)
}

View File

@@ -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()
}

View File

@@ -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)
}

View File

@@ -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"`
}

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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)
}

View File

@@ -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()
}

View File

@@ -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)
}

View File

@@ -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"`
}

View File

@@ -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()
}

View File

@@ -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})
}

View File

@@ -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
}

View File

@@ -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)
}

View File

@@ -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"`
}

View File

@@ -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"`
}

View File

@@ -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)
);

24
backend/seed/001_seed.sql Normal file
View File

@@ -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;

14
deploy/.env.example Normal file
View File

@@ -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

72
deploy/docker-compose.yml Normal file
View File

@@ -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:

10
deploy/nginx/admin.conf Normal file
View File

@@ -0,0 +1,10 @@
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
location / {
try_files $uri /index.html;
}
}

View File

@@ -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;
}
}

View File

@@ -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

View File

@@ -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

12
desktop-client/index.html Normal file
View File

@@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>NexaVPN</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

View File

@@ -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"
}
}

View File

@@ -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"

View File

@@ -0,0 +1,3 @@
fn main() {
tauri_build::build()
}

View File

@@ -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<Option<SessionState>>,
}
#[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<String>,
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<ResourceView>,
}
#[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<EnrollmentResult, String> {
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::<LoginResponse>()
.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::<EnrollResponse>()
.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");
}

View File

@@ -0,0 +1,3 @@
fn main() {
nexavpn_desktop::run();
}

View File

@@ -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": []
}
}

107
desktop-client/src/App.tsx Normal file
View File

@@ -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<string | null>(null);
const [connected, setConnected] = useState(false);
const [state, setState] = useState<EnrollmentState | null>(null);
async function onSubmit(event: FormEvent) {
event.preventDefault();
setLoading(true);
setError(null);
try {
const result = await invoke<EnrollmentState>("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 (
<div className="client-shell">
<div className="hero">
<p className="eyebrow">NexaVPN</p>
<h1>Private access without manual WireGuard setup.</h1>
<p className="lede">
Enter your VPN address, username, and password. NexaVPN provisions and stores the profile for you.
</p>
</div>
{!state ? (
<form className="panel" onSubmit={onSubmit}>
<label>
VPN server address
<input value={serverUrl} onChange={(event) => setServerUrl(event.target.value)} />
</label>
<label>
Username
<input value={username} onChange={(event) => setUsername(event.target.value)} />
</label>
<label>
Password
<input type="password" value={password} onChange={(event) => setPassword(event.target.value)} />
</label>
{error ? <div className="error">{error}</div> : null}
<button disabled={loading} type="submit">
{loading ? "Provisioning..." : "Sign in"}
</button>
</form>
) : (
<div className="panel status">
<div className="status-row">
<div>
<p className="eyebrow">Connection</p>
<h2>{connected ? "Connected" : "Disconnected"}</h2>
</div>
<button onClick={toggleConnection}>{connected ? "Disconnect" : "Connect"}</button>
</div>
<div className="details">
<div>
<span>Assigned VPN IP</span>
<strong>{state.assignedIp}</strong>
</div>
<div>
<span>Gateway</span>
<strong>{state.gatewayEndpoint}</strong>
</div>
<div>
<span>Profile revision</span>
<strong>{state.profileRevision}</strong>
</div>
</div>
<div>
<p className="eyebrow">Allowed resources</p>
<ul className="resource-list">
{state.resources.map((resource) => (
<li key={resource}>{resource}</li>
))}
</ul>
</div>
</div>
)}
</div>
);
}

View File

@@ -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(
<React.StrictMode>
<App />
</React.StrictMode>
);

View File

@@ -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;
}

View File

@@ -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"]
}

View File

@@ -0,0 +1,6 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" }
]
}

View File

@@ -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
}
});

223
docs/api.md Normal file
View File

@@ -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"
}
}
```

180
docs/architecture.md Normal file
View File

@@ -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.

60
docs/deployment.md Normal file
View File

@@ -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.

76
docs/enrollment.md Normal file
View File

@@ -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

84
docs/folder-structure.md Normal file
View File

@@ -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
```

69
docs/gateway.md Normal file
View File

@@ -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.

204
docs/schema.md Normal file
View File

@@ -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.