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:
11
.gitignore
vendored
Normal file
11
.gitignore
vendored
Normal 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
60
README.md
Normal 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
10
admin-web/Dockerfile
Normal 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
12
admin-web/index.html
Normal 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
18
admin-web/nginx.conf
Normal 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
24
admin-web/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
75
admin-web/src/api/client.ts
Normal file
75
admin-web/src/api/client.ts
Normal 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
40
admin-web/src/app/App.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
5
admin-web/src/components/Card.tsx
Normal file
5
admin-web/src/components/Card.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { PropsWithChildren } from "react";
|
||||
|
||||
export function Card({ children }: PropsWithChildren) {
|
||||
return <div className="card">{children}</div>;
|
||||
}
|
||||
45
admin-web/src/components/Layout.tsx
Normal file
45
admin-web/src/components/Layout.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
22
admin-web/src/components/Page.tsx
Normal file
22
admin-web/src/components/Page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
37
admin-web/src/components/Table.tsx
Normal file
37
admin-web/src/components/Table.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
37
admin-web/src/features/audit/AuditPage.tsx
Normal file
37
admin-web/src/features/audit/AuditPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
96
admin-web/src/features/auth/LoginPage.tsx
Normal file
96
admin-web/src/features/auth/LoginPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
36
admin-web/src/features/dashboard/DashboardPage.tsx
Normal file
36
admin-web/src/features/dashboard/DashboardPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
39
admin-web/src/features/devices/DevicesPage.tsx
Normal file
39
admin-web/src/features/devices/DevicesPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
37
admin-web/src/features/gateways/GatewaysPage.tsx
Normal file
37
admin-web/src/features/gateways/GatewaysPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
41
admin-web/src/features/policies/PoliciesPage.tsx
Normal file
41
admin-web/src/features/policies/PoliciesPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
21
admin-web/src/features/settings/SettingsPage.tsx
Normal file
21
admin-web/src/features/settings/SettingsPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
41
admin-web/src/features/users/UsersPage.tsx
Normal file
41
admin-web/src/features/users/UsersPage.tsx
Normal 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
19
admin-web/src/main.tsx
Normal 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>
|
||||
);
|
||||
227
admin-web/src/styles/global.css
Normal file
227
admin-web/src/styles/global.css
Normal 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;
|
||||
}
|
||||
}
|
||||
18
admin-web/tsconfig.app.json
Normal file
18
admin-web/tsconfig.app.json
Normal 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
6
admin-web/tsconfig.json
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" }
|
||||
]
|
||||
}
|
||||
10
admin-web/vite.config.ts
Normal file
10
admin-web/vite.config.ts
Normal 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
14
backend/Dockerfile
Normal 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
48
backend/cmd/api/main.go
Normal 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
11
backend/go.mod
Normal 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
|
||||
)
|
||||
26
backend/internal/apiutil/respond.go
Normal file
26
backend/internal/apiutil/respond.go
Normal 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)
|
||||
}
|
||||
62
backend/internal/app/app.go
Normal file
62
backend/internal/app/app.go
Normal 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()
|
||||
}
|
||||
}
|
||||
25
backend/internal/audit/handler.go
Normal file
25
backend/internal/audit/handler.go
Normal 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)
|
||||
}
|
||||
70
backend/internal/audit/repository.go
Normal file
70
backend/internal/audit/repository.go
Normal 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()
|
||||
}
|
||||
47
backend/internal/audit/service.go
Normal file
47
backend/internal/audit/service.go
Normal 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
|
||||
}
|
||||
137
backend/internal/auth/handler.go
Normal file
137
backend/internal/auth/handler.go
Normal 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,
|
||||
})
|
||||
}
|
||||
11
backend/internal/auth/hash.go
Normal file
11
backend/internal/auth/hash.go
Normal 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[:])
|
||||
}
|
||||
40
backend/internal/auth/password.go
Normal file
40
backend/internal/auth/password.go
Normal 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
|
||||
}
|
||||
105
backend/internal/auth/repository.go
Normal file
105
backend/internal/auth/repository.go
Normal 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
|
||||
}
|
||||
155
backend/internal/auth/service.go
Normal file
155
backend/internal/auth/service.go
Normal 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)
|
||||
}
|
||||
77
backend/internal/auth/token.go
Normal file
77
backend/internal/auth/token.go
Normal 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
|
||||
}
|
||||
39
backend/internal/auth/types.go
Normal file
39
backend/internal/auth/types.go
Normal 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"`
|
||||
}
|
||||
82
backend/internal/config/config.go
Normal file
82
backend/internal/config/config.go
Normal 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
11
backend/internal/db/db.go
Normal 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)
|
||||
}
|
||||
176
backend/internal/device/handler.go
Normal file
176
backend/internal/device/handler.go
Normal 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})
|
||||
}
|
||||
265
backend/internal/device/repository.go
Normal file
265
backend/internal/device/repository.go
Normal 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
|
||||
}
|
||||
130
backend/internal/device/service.go
Normal file
130
backend/internal/device/service.go
Normal 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)
|
||||
}
|
||||
62
backend/internal/device/types.go
Normal file
62
backend/internal/device/types.go
Normal 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"`
|
||||
}
|
||||
37
backend/internal/gateway/handler.go
Normal file
37
backend/internal/gateway/handler.go
Normal 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)
|
||||
}
|
||||
102
backend/internal/gateway/repository.go
Normal file
102
backend/internal/gateway/repository.go
Normal 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()
|
||||
}
|
||||
33
backend/internal/gateway/service.go
Normal file
33
backend/internal/gateway/service.go
Normal 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)
|
||||
}
|
||||
14
backend/internal/gateway/types.go
Normal file
14
backend/internal/gateway/types.go
Normal 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"`
|
||||
}
|
||||
66
backend/internal/httpserver/middleware.go
Normal file
66
backend/internal/httpserver/middleware.go
Normal 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
|
||||
}
|
||||
68
backend/internal/httpserver/router.go
Normal file
68
backend/internal/httpserver/router.go
Normal 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
|
||||
}
|
||||
26
backend/internal/ipam/service.go
Normal file
26
backend/internal/ipam/service.go
Normal 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
|
||||
}
|
||||
62
backend/internal/policy/handler.go
Normal file
62
backend/internal/policy/handler.go
Normal 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)
|
||||
}
|
||||
143
backend/internal/policy/repository.go
Normal file
143
backend/internal/policy/repository.go
Normal 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()
|
||||
}
|
||||
33
backend/internal/policy/service.go
Normal file
33
backend/internal/policy/service.go
Normal 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)
|
||||
}
|
||||
30
backend/internal/policy/types.go
Normal file
30
backend/internal/policy/types.go
Normal 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"`
|
||||
}
|
||||
33
backend/internal/profile/builder.go
Normal file
33
backend/internal/profile/builder.go
Normal 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()
|
||||
}
|
||||
110
backend/internal/user/handler.go
Normal file
110
backend/internal/user/handler.go
Normal 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})
|
||||
}
|
||||
64
backend/internal/user/repository.go
Normal file
64
backend/internal/user/repository.go
Normal 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
|
||||
}
|
||||
37
backend/internal/user/service.go
Normal file
37
backend/internal/user/service.go
Normal 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)
|
||||
}
|
||||
27
backend/internal/user/types.go
Normal file
27
backend/internal/user/types.go
Normal 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"`
|
||||
}
|
||||
18
backend/internal/wireguard/types.go
Normal file
18
backend/internal/wireguard/types.go
Normal 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"`
|
||||
}
|
||||
183
backend/migrations/000001_init.sql
Normal file
183
backend/migrations/000001_init.sql
Normal 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
24
backend/seed/001_seed.sql
Normal 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
14
deploy/.env.example
Normal 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
72
deploy/docker-compose.yml
Normal 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
10
deploy/nginx/admin.conf
Normal file
@@ -0,0 +1,10 @@
|
||||
server {
|
||||
listen 80;
|
||||
server_name _;
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
location / {
|
||||
try_files $uri /index.html;
|
||||
}
|
||||
}
|
||||
18
deploy/nginx/reverse-proxy.conf
Normal file
18
deploy/nginx/reverse-proxy.conf
Normal 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;
|
||||
}
|
||||
}
|
||||
9
deploy/scripts/bootstrap-admin.sh
Normal file
9
deploy/scripts/bootstrap-admin.sh
Normal 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
|
||||
8
deploy/scripts/gateway-entrypoint.sh
Normal file
8
deploy/scripts/gateway-entrypoint.sh
Normal 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
12
desktop-client/index.html
Normal 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>
|
||||
25
desktop-client/package.json
Normal file
25
desktop-client/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
22
desktop-client/src-tauri/Cargo.toml
Normal file
22
desktop-client/src-tauri/Cargo.toml
Normal 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"
|
||||
3
desktop-client/src-tauri/build.rs
Normal file
3
desktop-client/src-tauri/build.rs
Normal file
@@ -0,0 +1,3 @@
|
||||
fn main() {
|
||||
tauri_build::build()
|
||||
}
|
||||
204
desktop-client/src-tauri/src/lib.rs
Normal file
204
desktop-client/src-tauri/src/lib.rs
Normal 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");
|
||||
}
|
||||
3
desktop-client/src-tauri/src/main.rs
Normal file
3
desktop-client/src-tauri/src/main.rs
Normal file
@@ -0,0 +1,3 @@
|
||||
fn main() {
|
||||
nexavpn_desktop::run();
|
||||
}
|
||||
30
desktop-client/src-tauri/tauri.conf.json
Normal file
30
desktop-client/src-tauri/tauri.conf.json
Normal 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
107
desktop-client/src/App.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
11
desktop-client/src/main.tsx
Normal file
11
desktop-client/src/main.tsx
Normal 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>
|
||||
);
|
||||
111
desktop-client/src/styles.css
Normal file
111
desktop-client/src/styles.css
Normal 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;
|
||||
}
|
||||
16
desktop-client/tsconfig.app.json
Normal file
16
desktop-client/tsconfig.app.json
Normal 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"]
|
||||
}
|
||||
6
desktop-client/tsconfig.json
Normal file
6
desktop-client/tsconfig.json
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" }
|
||||
]
|
||||
}
|
||||
12
desktop-client/vite.config.ts
Normal file
12
desktop-client/vite.config.ts
Normal 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
223
docs/api.md
Normal 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
180
docs/architecture.md
Normal 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
60
docs/deployment.md
Normal 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
76
docs/enrollment.md
Normal 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
84
docs/folder-structure.md
Normal 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
69
docs/gateway.md
Normal 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
204
docs/schema.md
Normal 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.
|
||||
Reference in New Issue
Block a user