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:
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
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user