WIP: Wireguard web dashboard #1

Closed
Sirherobrine23 wants to merge 4 commits from nextjs into main
22 changed files with 399 additions and 439 deletions
Showing only changes of commit 228d28bc22 - Show all commits

1
.env Normal file
View File

@ -0,0 +1 @@
DB_CONNECTION="sqlite:database.db"

10
.vscode/settings.json vendored Normal file
View File

@ -0,0 +1,10 @@
{
"editor.tabSize": 2,
"files.autoSave": "afterDelay",
"files.autoSaveDelay": 2000,
"files.exclude": {
"**/.next/": true,
"**/node_modules/": true,
"**/*-lock*/": true,
},
}

View File

@ -1,36 +0,0 @@
"use client";
import DynamicInput from "../component/DynamicInput";
import { CreateInterface, CreatePeer, DeleteInterface } from "./wg";
export interface CreateInterfaceProps {
WGcustomName: boolean;
randomIp: string
}
export async function WGInterfaceForm({ WGcustomName, randomIp }: CreateInterfaceProps) {
return <form action={(e) => CreateInterface(e)}>
{WGcustomName && <div><span>Interface name: </span><DynamicInput type="text" name="name" /></div>}
<div>
{/* <span>IPv4 Address: </span> */}
<DynamicInput width="10px" type="text" name="ipv4" required placeholder="IPv4 address, example: 10.0.0.1" defaultValue={randomIp} />
</div>
<DynamicInput type="submit" value="Create interface" />
</form>;
}
export interface WDDeleteInterfaceProps {
idInterface: number;
}
export function WDDeleteInterface({ idInterface }: WDDeleteInterfaceProps) {
return <form action={DeleteInterface}>
<DynamicInput type="hidden" name="interfaceid" defaultValue={idInterface} />
<DynamicInput type="submit" value="Delete interface" />
</form>;
}
export function WgCreatePeer() {
return <form action={CreatePeer}>
<DynamicInput type="text" />
</form>
}

View File

View File

@ -1,4 +1,12 @@
import "./global.css";
import DataUsageIcon from '@mui/icons-material/DataUsage';
import GroupIcon from '@mui/icons-material/Group';
import PersonIcon from '@mui/icons-material/Person';
import StorageIcon from '@mui/icons-material/Storage';
import { cookies } from 'next/headers';
import Link from "next/link";
import "./layout_global.css";
import { LoginPage, LogoutPage } from './login';
import { cookie, users } from '@/db';
const {
title = "WIreguard dashboard",
@ -7,11 +15,50 @@ const {
export const metadata = { title, description };
export default async function RootLayout({ children }: { children: React.ReactNode }) {
const cookiesSessions = cookies();
let loginPage = true;
if (cookiesSessions.has("sessionsLogin")) {
const ck = cookiesSessions.get("sessionsLogin").value;
const user = await cookie.findOne({ where: { value: ck } });
if (user && (await users.findOne({ where: { userID: user.userID } })).activate) loginPage = false;
}
return (
<html lang="en">
<body>
<div className="bodyCamp">
{children}
<div className="container">
<div className="navbar">
<nav>
<ul>
<span>Main</span>
<li>
<StorageIcon />
<Link href="/">Wireguard Clients</Link>
</li>
<li>
<PersonIcon />
<Link href="/servers">Wireguard Servers</Link>
</li>
</ul>
<ul>
<span>Utils</span>
<li>
<GroupIcon />
<Link href="/users">Users</Link>
</li>
<li>
<DataUsageIcon />
<Link href="/stats">Status and Stats</Link>
</li>
</ul>
{!loginPage && <ul>
<LogoutPage />
</ul>}
</nav>
</div>
<div className="bodyCamp">
{loginPage && <LoginPage />}
{!loginPage && children}
</div>
</div>
</body>
</html>

55
app/layout_global.css Normal file
View File

@ -0,0 +1,55 @@
body {
margin: 0;
}
.container {
display: flex;
height: 100vh;
width: 100vw;
}
.navbar {
background-color: rgb(0, 19, 50);
margin-right: 12px;
padding-right: 8px;
color: white;
}
.navbar ul {
padding-left: 14px;
display: grid;
justify-items: center;
}
.navbar ul > span {
background-color: grey;
color: black;
border-radius: 12rem;
width: 42%;
margin-bottom: 10px;
display: flex;
justify-content: center;
}
.navbar ul > li {
display: flex;
align-items: center;
padding-bottom: 12px;
}
.navbar ul > li > svg {
padding-right: 6px;
}
.navbar li {
list-style-type: none;
}
.navbar a {
color: white;
text-decoration: none;
}
.logoutButton {
cursor: pointer;
}

30
app/login.tsx Normal file
View File

@ -0,0 +1,30 @@
"use client";
import { Login, Logout } from "./login_back";
import LogoutIcon from '@mui/icons-material/Logout';
export function LoginPage() {
return <div className="loginArea">
<h1>Login</h1>
<form action={Login}>
<div>
<label htmlFor="username">Username: </label>
<input type="text" name="username" placeholder="Username" />
</div>
<div>
<label htmlFor="password">Password: </label>
<input type="password" name="password" placeholder="Password" />
</div>
<div>
<input type="submit" value="Login" />
</div>
</form>
</div>;
}
export function LogoutPage() {
return <form action={Logout}>
<button>
<LogoutIcon className="logoutButton" />
</button>
</form>
}

33
app/login_back.ts Normal file
View File

@ -0,0 +1,33 @@
"use server";
import { cookies } from "next/headers";
import { redirect } from "next/navigation";
import { users, cookie } from "@/db";
import { decrypt } from "@/pass";
import { randomBytes } from "crypto";
export async function Login(form: FormData) {
const username = String(form.get("username")||""), password = String(form.get("password")||"");
const user = await users.findOne({ where: { username } });
if (user) {
if (await decrypt(user.password) === password) {
let cookieVal: string;
do {
cookieVal = randomBytes(32).toString("hex");
} while (await cookie.findOne({ where: { value: cookieVal } }));
await cookie.create({
userID: user.userID,
value: cookieVal
});
cookies().set("sessionsLogin", cookieVal);
}
}
redirect("/");
}
export async function Logout() {
const ck = cookies().get("sessionsLogin")?.value;
const bk = await cookie.findOne({ where: { value: ck } });
await bk.destroy();
redirect("/");
}

View File

@ -1,52 +1,7 @@
"use server";
import { wg_interfaces, wg_peer } from "@/database";
import { WDDeleteInterface, WGInterfaceForm, WgCreatePeer } from "./clients";
import { key_experimental } from "wireguard-tools.js";
import DynamicInput from "../component/DynamicInput";
import { randomIp } from "@sirherobrine23/extends/src/net";
export default async function Home() {
const wgInterfaces = (await wg_interfaces.findAll()).map(s => s.toJSON());
const wgPeers = (await wg_peer.findAll()).map(s => s.toJSON());
const customName = process.platform === "win32" || process.platform === "linux" || process.platform === "android";
return <div>
<div>
<h1>Create interface</h1>
<WGInterfaceForm WGcustomName={customName} randomIp={await randomIp("10.0.0.0/8")} />
</div>
<hr />
<div>
{wgInterfaces.map(wg => {
const peers = wgPeers.filter(s => s.interfaceID === wg.interfaceID);
return <div id={String.prototype.concat("wgInterface_", String(wg.interfaceID))}>
<div style={{ display: "flex", justifyContent: "space-between" }}>
<div>
<h1>{wg.name}</h1>
</div>
<div>
<WDDeleteInterface idInterface={wg.interfaceID} />
</div>
</div>
<div style={{ margin: "12px" }}>
<div>
{wg.privateKey && <div><DynamicInput placeholder="Private key" type="text" disabled defaultValue={wg.privateKey} /></div>}
{wg.portListen && <div><DynamicInput placeholder="Port listen" type="number" defaultValue={wg.portListen} /></div>}
{wg.ipv4 && <div><DynamicInput placeholder="IPv4" type="text" defaultValue={wg.ipv4} /></div>}
{wg.ipv6 && <div><DynamicInput placeholder="IPv6" type="text" defaultValue={wg.ipv6} /></div>}
</div>
<div>
<WgCreatePeer />
</div>
{peers.length > 0 && <div>
{peers.map((peer, pIndex) => {
return <div key={String.prototype.concat("peer_", String(wg.interfaceID), "_", String(peer.peerID))}>
<span>{key_experimental.publicKey(peer.privateKey)}</span>
</div>;
})}
</div>}
</div>
</div>
})}
</div>
Peers
</div>;
}

5
app/servers/page.tsx Normal file
View File

@ -0,0 +1,5 @@
export default async function Servers() {
return <div>
Servers
</div>;
}

5
app/stats/page.tsx Normal file
View File

@ -0,0 +1,5 @@
export default async function Stats() {
return <div>
Stats
</div>;
}

5
app/users/page.tsx Normal file
View File

@ -0,0 +1,5 @@
export default async function Users() {
return <div>
Users
</div>
}

View File

@ -1,34 +0,0 @@
"use server";
import { key_experimental } from "wireguard-tools.js";
import { extendNet } from "@sirherobrine23/extends";
import { revalidatePath } from "next/cache";
import { wg_interfaces, wg_peer } from "@/database";
import { randomBytes } from "crypto";
import { randomBigint } from "@sirherobrine23/extends/src/crypto";
export async function CreateInterface(form: FormData) {
const ports = (await wg_interfaces.findAll({ attributes: [ "portListen" ] })).map(s => s.toJSON().portListen);
let port: number;
do {
port = Number(await randomBigint(1, 25565));
} while (ports.includes(port));
await wg_interfaces.create({
name: randomBytes(4).toString("hex"),
portListen: port,
privateKey: await key_experimental.privateKey(),
ipv4: form.get("ipv4").toString(),
ipv6: extendNet.toString(extendNet.toInt(form.get("ipv4").toString()), true),
});
revalidatePath("/");
}
export async function DeleteInterface(form: FormData) {
await wg_peer.destroy({ where: { interfaceID: Number(form.get("interfaceid")) } });
await wg_interfaces.destroy({ where: { interfaceID: Number(form.get("interfaceid")) } });
revalidatePath("/");
}
export async function CreatePeer(form: FormData) {
revalidatePath("/");
}

View File

@ -1,26 +0,0 @@
.resizeContainer, .resizeText {
margin: 0;
}
.resizeContainer {
padding: 2px 0px;
display: inline-block;
position: relative;
}
.resizeText {
display: inline-block;
visibility: hidden;
white-space: pre;
padding: 2px 0px 4px 0px;
min-width: 180px;
}
.resizeInput {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
margin: 0;
}

View File

@ -1,19 +0,0 @@
"use client";
import { DetailedHTMLProps, InputHTMLAttributes } from "react";
import CssMd from "./DynamicInput.module.css";
export interface DynamicInputProps extends DetailedHTMLProps<InputHTMLAttributes<HTMLInputElement>, HTMLInputElement> {}
export default DynamicInput;
export function DynamicInput(props: DynamicInputProps) {
return <div className={CssMd["input-label"]}>
{ props.placeholder && <span>{props.placeholder}: </span> }
<div className={CssMd["resizeContainer"]}>
<span className={CssMd["resizeText"]}>{props.defaultValue||props.value}</span>
<input {...props} className={CssMd["resizeInput"]}
onInput={e => (e.currentTarget && e.currentTarget.previousElementSibling) && (e.currentTarget.previousElementSibling.textContent = e.currentTarget.value)}
onChange={e => (e.currentTarget && e.currentTarget.previousElementSibling) && (e.currentTarget.previousElementSibling.textContent = e.currentTarget.value)}
/>
</div>
</div>;
}

View File

@ -1,164 +0,0 @@
import path from "node:path";
import sequelize from "sequelize";
export enum UserType {
root = "wg::root",
user = "wg::user"
}
export interface User {
userID: string;
username: string;
password: string;
type?: UserType;
}
/** Interface struct */
export interface Wg {
interfaceID: number;
name: string;
privateKey: string;
portListen: number;
ipv4: string;
ipv6: string|null;
}
/** Peer base struct */
export interface PeerWg {
interfaceID: number;
peerID: number;
privateKey: string;
presharedKey?: string;
ipv4: string;
ipv6: string|null;
}
export const databasePath = path.join(process.cwd(), "database.db");
export const db = new sequelize.Sequelize({
dialect: "sqlite",
storage: databasePath,
logging(sql, _timing) {
console.error(sql);
},
});
export interface UserSequelize extends sequelize.Model<sequelize.InferAttributes<UserSequelize>, sequelize.InferCreationAttributes<UserSequelize>>, User { };
export interface WgSequelize extends sequelize.Model<sequelize.InferAttributes<WgSequelize>, sequelize.InferCreationAttributes<WgSequelize>>, Wg { };
export interface PeerWgSequelize extends sequelize.Model<sequelize.InferAttributes<PeerWgSequelize>, sequelize.InferCreationAttributes<PeerWgSequelize>>, PeerWg { };
export const user = db.define<UserSequelize>("user", {
userID: {
type: sequelize.DataTypes.STRING,
autoIncrement: false,
primaryKey: true,
allowNull: false,
unique: true,
},
username: {
type: sequelize.DataTypes.STRING,
autoIncrement: false,
allowNull: false,
unique: true,
},
password: {
type: sequelize.DataTypes.STRING,
autoIncrement: false,
allowNull: false,
unique: false,
},
type: {
type: sequelize.DataTypes.STRING,
defaultValue: UserType.user,
autoIncrement: false,
allowNull: false,
validate: {
is: [UserType.root, UserType.user]
}
},
});
export const wg_interfaces = db.define<WgSequelize>("wg", {
interfaceID: {
type: sequelize.DataTypes.INTEGER,
autoIncrement: true,
primaryKey: true,
},
name: {
type: sequelize.DataTypes.STRING,
unique: true,
allowNull: false,
},
privateKey: {
type: sequelize.DataTypes.STRING,
unique: true,
allowNull: false,
},
portListen: {
type: sequelize.DataTypes.NUMBER,
defaultValue: 0,
allowNull: false,
set(val: number) {
if (isNaN(val)) throw new TypeError("Invalid port listen!");
else if (!(isFinite(val))) throw new TypeError("Invalid port listen!");
else if (val < 0) throw new TypeError("Invalid port listen!");
this.setDataValue("portListen", val);
},
},
ipv4: {
type: sequelize.DataTypes.STRING,
unique: true,
allowNull: false,
set(val: string) {
this.setDataValue("ipv4", String(val).split("/")[0]);
},
},
ipv6: {
type: sequelize.DataTypes.STRING,
unique: true,
allowNull: true,
set(val: string) {
this.setDataValue("ipv6", String(val).split("/")[0]);
},
},
});
export const wg_peer = db.define<PeerWgSequelize>("peers", {
interfaceID: {
type: sequelize.DataTypes.INTEGER,
allowNull: false,
},
peerID: {
type: sequelize.DataTypes.INTEGER,
autoIncrement: true,
primaryKey: true,
},
privateKey: {
type: sequelize.DataTypes.STRING,
unique: true,
allowNull: false,
},
presharedKey: {
type: sequelize.DataTypes.STRING,
unique: true,
allowNull: true,
},
ipv4: {
type: sequelize.DataTypes.STRING,
unique: true,
allowNull: false,
},
ipv6: {
type: sequelize.DataTypes.STRING,
unique: true,
allowNull: true,
},
});
Promise.all([
user.sync(),
wg_interfaces.sync(),
wg_peer.sync(),
]).then(() => console.info("Databases created"), err => {
console.error(err);
process.exit(-1);
});

74
lib/db.ts Normal file
View File

@ -0,0 +1,74 @@
import { Sequelize, DataTypes, Model, InferAttributes, InferCreationAttributes } from "sequelize";
import { encrypt } from "@/pass";
const { DB_CONNECTION = "sqlite:memory:" } = process.env;
export const db = new Sequelize(DB_CONNECTION);
export interface User {
userID?: number;
activate: boolean;
name: string;
username: string;
password: string;
};
export interface UserSequelize extends Model<InferAttributes<UserSequelize>, InferCreationAttributes<UserSequelize>>, User { };
export const users = db.define<UserSequelize>("users", {
userID: {
type: DataTypes.INTEGER,
autoIncrement: true,
primaryKey: true,
unique: true,
},
activate: {
type: DataTypes.BOOLEAN,
defaultValue: false
},
name: {
type: DataTypes.STRING,
allowNull: false,
},
username: {
type: DataTypes.CHAR(46),
allowNull: false,
unique: true,
},
password: {
type: DataTypes.STRING,
allowNull: false,
}
});
users.sync().then(async () => {
(await users.count() === 0) && await users.create({
activate: true,
name: "Root admin",
username: "admin",
password: await encrypt("admin"),
})
});
export interface Cookies {
id: number;
userID: number;
value: string;
};
export interface CookiesSequelize extends Model<InferAttributes<CookiesSequelize>, InferCreationAttributes<CookiesSequelize>>, Cookies { };
export const cookie = db.define<CookiesSequelize>("cookies", {
id: {
type: DataTypes.INTEGER,
primaryKey: true,
unique: true,
autoIncrement: true
},
userID: {
type: DataTypes.INTEGER,
},
value: {
type: DataTypes.STRING,
unique: true
}
});
cookie.sync();

18
lib/pass.ts Normal file
View File

@ -0,0 +1,18 @@
import crypto from "node:crypto";
import util from "node:util";
const scrypt = util.promisify(crypto.scrypt) as (password: crypto.BinaryLike, salt: crypto.BinaryLike, keylen: number) => Promise<Buffer>;
export async function encrypt(text: string): Promise<string> {
const iv = crypto.randomBytes(16), secret = crypto.randomBytes(24);
const key = await scrypt(secret, "salt", 24);
const cipher = crypto.createCipheriv("aes-192-cbc", key, iv); cipher.on("error", () => {});
return (String()).concat(iv.toString("hex"), secret.toString("hex"), cipher.update(text, "utf8", "hex"), cipher.final("hex"));
}
export async function decrypt(hash: string): Promise<string> {
const iv = Buffer.from(hash.substring(0, 32), "hex"), secret = Buffer.from(hash.substring(32, 80), "hex"), cipher = hash.substring(80);
const key = await scrypt(secret, "salt", 24);
const decipher = crypto.createDecipheriv("aes-192-cbc", key, iv); decipher.on("error", () => {});
return (String()).concat(decipher.update(cipher, "hex", "utf8"), decipher.final("utf8"));
}

View File

@ -12,6 +12,10 @@
"typescript": "^5.3.3"
},
"dependencies": {
"@emotion/react": "^11.11.3",
"@emotion/styled": "^11.11.0",
"@mui/icons-material": "^5.15.6",
"@mui/material": "^5.15.6",
"@sirherobrine23/extends": "^3.7.4",
"neste": "^3.1.2",
"next": "^14.1.0",
@ -20,6 +24,6 @@
"react-dom": "^18.2.0",
"sequelize": "^6.35.2",
"sqlite3": "^5.1.7",
"wireguard-tools.js": "^1.8.2"
"wireguard-tools.js": "^1.8.3"
}
}