mirror of
https://github.com/wireadmin/wireadmin
synced 2025-06-26 18:28:06 +00:00
Updates much stuff for least stability
This commit is contained in:
parent
e4413590ce
commit
789f7088f8
@ -4,7 +4,7 @@ import path from "path";
|
||||
export default class FileManager {
|
||||
|
||||
static readDirectoryFiles(dir: string): string[] {
|
||||
const files_ = [];
|
||||
const files_: string[] = [];
|
||||
const files = fs.readdirSync(dir);
|
||||
for (const i in files) {
|
||||
const name = dir + '/' + files[i];
|
||||
|
@ -7,7 +7,7 @@ export default async function safeServe(res: NextApiResponse, fn: () => void): P
|
||||
} catch (e) {
|
||||
console.error('[SafeServe]: ', e)
|
||||
return res
|
||||
.status(200)
|
||||
.status(500)
|
||||
.json({ ok: false, details: 'Server Internal Error' })
|
||||
}
|
||||
})
|
||||
|
64
src/lib/schemas/WireGuard.ts
Normal file
64
src/lib/schemas/WireGuard.ts
Normal file
@ -0,0 +1,64 @@
|
||||
import { z } from "zod";
|
||||
import { IPV4_REGEX } from "@lib/constants";
|
||||
import { isBetween, isPrivateIP } from "@lib/utils";
|
||||
import { ZodErrorMap } from "zod/lib/ZodError";
|
||||
|
||||
export const NameSchema = z
|
||||
.string()
|
||||
.nonempty()
|
||||
.refine((v) => v.length < 32, {
|
||||
message: 'Name must be less than 32 characters'
|
||||
})
|
||||
.refine((v) => v.match(/^[a-zA-Z0-9-_]+$/), {
|
||||
message: 'Name must only contain alphanumeric characters, dashes, and underscores'
|
||||
})
|
||||
|
||||
export const AddressSchema = z
|
||||
.string()
|
||||
.nonempty()
|
||||
.refine((v) => isPrivateIP(v), {
|
||||
message: 'Address must be a private IP address'
|
||||
})
|
||||
|
||||
export const PortSchema = z
|
||||
.string()
|
||||
.nonempty()
|
||||
.refine((v) => {
|
||||
const port = parseInt(v)
|
||||
return port > 0 && port < 65535
|
||||
}, {
|
||||
message: 'Port must be a valid port number'
|
||||
})
|
||||
|
||||
export const TypeSchema = z.enum([ 'default', 'tor' ])
|
||||
|
||||
export const DnsSchema = z
|
||||
.string()
|
||||
.regex(IPV4_REGEX, {
|
||||
message: 'DNS must be a valid IPv4 address'
|
||||
})
|
||||
.optional()
|
||||
|
||||
export const MtuSchema = z
|
||||
.string()
|
||||
.refine((d) => isBetween(d, 1, 1500), {
|
||||
message: 'MTU must be between 1 and 1500'
|
||||
})
|
||||
.optional()
|
||||
|
||||
export const ServerId = z
|
||||
.string()
|
||||
.uuid({ message: 'Server ID must be a valid UUID' })
|
||||
|
||||
export const ServerStatusSchema = z
|
||||
.enum([ 'up', 'down' ], {
|
||||
errorMap: issue => {
|
||||
switch (issue.code) {
|
||||
case 'invalid_type':
|
||||
case 'invalid_enum_value':
|
||||
return { message: 'Status must be either "up" or "down"' }
|
||||
default:
|
||||
return { message: 'Invalid status' }
|
||||
}
|
||||
}
|
||||
})
|
@ -2,16 +2,23 @@ import childProcess from "child_process";
|
||||
|
||||
export default class Shell {
|
||||
|
||||
public static async exec(command: string, ...args: string[]): Promise<string> {
|
||||
public static async exec(command: string, safe: boolean = false, ...args: string[]): Promise<string> {
|
||||
if (process.platform !== 'linux') {
|
||||
throw new Error('This program is not meant to run on UNIX systems');
|
||||
}
|
||||
return new Promise((resolve, reject) => {
|
||||
return new Promise(async (resolve, reject) => {
|
||||
const cmd = `${command}${args.length > 0 ? ` ${args.join(' ')}` : ''}`;
|
||||
childProcess.exec(cmd, { shell: 'bash', }, (err, stdout) => {
|
||||
if (err) return reject(err);
|
||||
return resolve(String(stdout).trim());
|
||||
});
|
||||
childProcess.exec(
|
||||
cmd,
|
||||
{ shell: 'bash' },
|
||||
(err, stdout, stderr) => {
|
||||
if (err) {
|
||||
console.error('Shell Exec:', err, stderr);
|
||||
return safe ? resolve('') : reject(err);
|
||||
}
|
||||
return resolve(String(stdout).trim());
|
||||
}
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
|
@ -26,3 +26,16 @@ export function isJson(str: string | object): boolean {
|
||||
export function isObject(obj: object) {
|
||||
return Object.prototype.toString.call(obj) === '[object Object]';
|
||||
}
|
||||
|
||||
/**
|
||||
* Private IP Address Identifier in Regular Expression
|
||||
*
|
||||
* 127. 0.0.0 – 127.255.255.255 127.0.0.0 /8
|
||||
* 10. 0.0.0 – 10.255.255.255 10.0.0.0 /8
|
||||
* 172. 16.0.0 – 172. 31.255.255 172.16.0.0 /12
|
||||
* 192.168.0.0 – 192.168.255.255 192.168.0.0 /16
|
||||
*/
|
||||
export function isPrivateIP(ip: string) {
|
||||
const ipRegex = /^(127\.)|(10\.)|(172\.1[6-9]\.)|(172\.2[0-9]\.)|(172\.3[0-1]\.)|(192\.168\.)/
|
||||
return ipRegex.test(ip)
|
||||
}
|
||||
|
@ -1,215 +1,74 @@
|
||||
import { promises as fs } from "fs";
|
||||
import path from "path";
|
||||
import QRCode from "qrcode";
|
||||
import { WG_PATH } from "@lib/constants";
|
||||
import Shell from "@lib/shell";
|
||||
import { WgKey, WgPeer, WgServer, WgServerConfig } from "@lib/typings";
|
||||
import { WgKey, WgPeer, WgServer } from "@lib/typings";
|
||||
import { client, WG_SEVER_PATH } from "@lib/redis";
|
||||
import { isJson } from "@lib/utils";
|
||||
import deepmerge from "deepmerge";
|
||||
|
||||
export class WireGuardServer {
|
||||
export class WGServer {
|
||||
|
||||
serverId: number
|
||||
|
||||
constructor(serverId: number) {
|
||||
this.serverId = serverId
|
||||
}
|
||||
|
||||
async getConfig() {
|
||||
if (!this.__configPromise) {
|
||||
this.__configPromise = Promise.resolve().then(async () => {
|
||||
if (!WG_HOST) {
|
||||
throw new Error('WG_HOST Environment Variable Not Set!');
|
||||
}
|
||||
|
||||
console.log('Loading configuration...')
|
||||
let config;
|
||||
try {
|
||||
config = await fs.readFile(path.join(WG_PATH, 'wg0.json'), 'utf8');
|
||||
config = JSON.parse(config);
|
||||
console.log('Configuration loaded.')
|
||||
} catch (err) {
|
||||
// const privateKey = await Shell.exec('wg genkey');
|
||||
// const publicKey = await Shell.exec(`echo ${privateKey} | wg pubkey`, {
|
||||
// log: 'echo ***hidden*** | wg pubkey',
|
||||
// });
|
||||
const { privateKey, publicKey } = await this.genKey()
|
||||
const address = WG_DEFAULT_ADDRESS.replace('x', '1');
|
||||
|
||||
config = {
|
||||
server: {
|
||||
privateKey,
|
||||
publicKey,
|
||||
address,
|
||||
},
|
||||
clients: {},
|
||||
};
|
||||
console.log('Configuration generated.')
|
||||
}
|
||||
|
||||
await this.__saveConfig(config);
|
||||
await Shell.exec('wg-quick down wg0').catch(() => {
|
||||
});
|
||||
await Shell.exec('wg-quick up wg0').catch(err => {
|
||||
if (err && err.message && err.message.includes('Cannot find device "wg0"')) {
|
||||
throw new Error('WireGuard exited with the error: Cannot find device "wg0"\nThis usually means that your host\'s kernel does not support WireGuard!');
|
||||
}
|
||||
|
||||
throw err;
|
||||
});
|
||||
// await Util.exec(`iptables -t nat -A POSTROUTING -s ${WG_DEFAULT_ADDRESS.replace('x', '0')}/24 -o eth0 -j MASQUERADE`);
|
||||
// await Util.exec('iptables -A INPUT -p udp -m udp --dport 51820 -j ACCEPT');
|
||||
// await Util.exec('iptables -A FORWARD -i wg0 -j ACCEPT');
|
||||
// await Util.exec('iptables -A FORWARD -o wg0 -j ACCEPT');
|
||||
await this.__syncConfig();
|
||||
|
||||
return config;
|
||||
});
|
||||
static async stop(id: string): Promise<boolean> {
|
||||
const server = await findServer(id)
|
||||
if (!server) {
|
||||
console.error('server could not be updated (reason: not exists)')
|
||||
return false
|
||||
}
|
||||
|
||||
return this.__configPromise;
|
||||
await Shell.exec(`ip link set down dev wg${server.confId}`, true)
|
||||
await this.update(id, { status: 'down' })
|
||||
return true
|
||||
}
|
||||
|
||||
async saveConfig() {
|
||||
const config = await this.getConfig();
|
||||
await this.__saveConfig(config);
|
||||
await this.__syncConfig();
|
||||
}
|
||||
|
||||
async __saveConfig(config: IServerConfig) {
|
||||
let result = `
|
||||
[Interface]
|
||||
PrivateKey = ${config.privateKey}
|
||||
Address = ${config.address}/24
|
||||
ListenPort = ${config.listen}
|
||||
PreUp = ${config.preUp}
|
||||
PostUp = ${config.postUp}
|
||||
PreDown = ${config.preDown}
|
||||
PostDown = ${config.postDown}
|
||||
`;
|
||||
|
||||
for (const { id, ...client } of config.peers) {
|
||||
if (!client.enabled) continue;
|
||||
|
||||
result += `
|
||||
|
||||
# Client: ${client.name} (${id})
|
||||
[Peer]
|
||||
PublicKey = ${client.publicKey}
|
||||
PresharedKey = ${client.preSharedKey}
|
||||
AllowedIPs = ${client.address}/32`;
|
||||
static async start(id: string): Promise<boolean> {
|
||||
const server = await findServer(id)
|
||||
if (!server) {
|
||||
console.error('server could not be updated (reason: not exists)')
|
||||
return false
|
||||
}
|
||||
|
||||
await fs.writeFile(path.join(WG_PATH, `wg${this.serverId}.conf`), result, {
|
||||
mode: 0o600,
|
||||
});
|
||||
await createInterface(server.confId, server.address)
|
||||
await Shell.exec(`ip link set up dev wg${server.confId}`)
|
||||
await this.update(id, { status: 'up' })
|
||||
return true
|
||||
}
|
||||
|
||||
async getClients() {
|
||||
const config = await this.getConfig();
|
||||
const clients = Object.entries(config.clients).map(([ clientId, client ]) => ({
|
||||
id: clientId,
|
||||
name: client.name,
|
||||
enabled: client.enabled,
|
||||
address: client.address,
|
||||
publicKey: client.publicKey,
|
||||
createdAt: new Date(client.createdAt),
|
||||
updatedAt: new Date(client.updatedAt),
|
||||
allowedIPs: client.allowedIPs,
|
||||
|
||||
persistentKeepalive: null,
|
||||
latestHandshakeAt: null,
|
||||
transferRx: null,
|
||||
transferTx: null,
|
||||
}));
|
||||
|
||||
// Loop WireGuard status
|
||||
const dump = await Shell.exec(`wg show wg${this.serverId} dump`);
|
||||
dump
|
||||
.trim()
|
||||
.split('\n')
|
||||
.slice(1)
|
||||
.forEach(line => {
|
||||
const [
|
||||
publicKey,
|
||||
preSharedKey, // eslint-disable-line no-unused-vars
|
||||
endpoint, // eslint-disable-line no-unused-vars
|
||||
allowedIps, // eslint-disable-line no-unused-vars
|
||||
latestHandshakeAt,
|
||||
transferRx,
|
||||
transferTx,
|
||||
persistentKeepalive,
|
||||
] = line.split('\t');
|
||||
|
||||
const client = clients.find(client => client.publicKey === publicKey);
|
||||
if (!client) return;
|
||||
|
||||
client.latestHandshakeAt = latestHandshakeAt === '0'
|
||||
? null
|
||||
: new Date(Number(`${latestHandshakeAt}000`));
|
||||
client.transferRx = Number(transferRx);
|
||||
client.transferTx = Number(transferTx);
|
||||
client.persistentKeepalive = persistentKeepalive;
|
||||
});
|
||||
|
||||
return clients;
|
||||
static async remove(id: string): Promise<boolean> {
|
||||
const server = await findServer(id)
|
||||
if (!server) {
|
||||
console.error('server could not be updated (reason: not exists)')
|
||||
return false
|
||||
}
|
||||
await this.stop(id)
|
||||
await dropInterface(server.confId)
|
||||
await fs.unlink(path.join(WG_PATH, `wg${server.confId}.conf`)).catch(() => null)
|
||||
const index = await findServerIndex(id)
|
||||
console.log('index', index)
|
||||
if (typeof index !== 'number') {
|
||||
console.warn('findServerIndex: index not found')
|
||||
return true
|
||||
} else {
|
||||
await client.lrem(WG_SEVER_PATH, 1, JSON.stringify(server))
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
async getClient(clientId: string): Promise<WgPeer> {
|
||||
throw new Error('Yet not implanted!');
|
||||
}
|
||||
|
||||
async getClientConfiguration(clientId: string): Promise<string> {
|
||||
const config = await this.getConfig();
|
||||
const client = await this.getClient(clientId);
|
||||
|
||||
return `
|
||||
[Interface]
|
||||
PrivateKey = ${client.privateKey}
|
||||
Address = ${client.address}/24
|
||||
${WG_DEFAULT_DNS ? `DNS = ${WG_DEFAULT_DNS}` : ''}
|
||||
${WG_MTU ? `MTU = ${WG_MTU}` : ''}
|
||||
|
||||
[Peer]
|
||||
PublicKey = ${config.server.publicKey}
|
||||
PresharedKey = ${client.preSharedKey}
|
||||
AllowedIPs = ${WG_ALLOWED_IPS}
|
||||
PersistentKeepalive = ${WG_PERSISTENT_KEEPALIVE}
|
||||
Endpoint = ${WG_HOST}:${WG_PORT}`;
|
||||
}
|
||||
|
||||
async getClientQRCodeSVG(clientId: string) {
|
||||
const config = await this.getClientConfiguration(clientId);
|
||||
return QRCode.toString(config, { type: 'svg', width: 512 });
|
||||
}
|
||||
|
||||
async createClient(name: string) {
|
||||
throw new Error('Yet not implanted!');
|
||||
}
|
||||
|
||||
async deleteClient(clientId: string) {
|
||||
throw new Error('Yet not implanted!');
|
||||
}
|
||||
|
||||
async enableClient(clientId: string) {
|
||||
throw new Error('Yet not implanted!');
|
||||
}
|
||||
|
||||
async disableClient(clientId: string) {
|
||||
throw new Error('Yet not implanted!');
|
||||
}
|
||||
|
||||
async updateClientName(clientId: string) {
|
||||
throw new Error('Yet not implanted!');
|
||||
}
|
||||
|
||||
async updateClientAddress(clientId: string, address: string) {
|
||||
throw new Error('Yet not implanted!');
|
||||
static async update(id: string, update: Partial<WgServer>): Promise<boolean> {
|
||||
const server = await findServer(id)
|
||||
if (!server) {
|
||||
console.error('server could not be updated (reason: not exists)')
|
||||
return false
|
||||
}
|
||||
const index = await findServerIndex(id)
|
||||
if (typeof index !== 'number') {
|
||||
console.warn('findServerIndex: index not found')
|
||||
return true
|
||||
}
|
||||
const res = await client.lset(WG_SEVER_PATH, index, JSON.stringify(deepmerge(server, update)))
|
||||
return res === 'OK'
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Used to read /etc/wireguard/*.conf and sync them with our
|
||||
* redis server.
|
||||
@ -218,10 +77,6 @@ async function syncServers(): Promise<boolean> {
|
||||
throw new Error('Yet not implanted!');
|
||||
}
|
||||
|
||||
export interface IServerConfig extends WgServerConfig {
|
||||
peers: WgPeer[]
|
||||
}
|
||||
|
||||
export async function generateWgKey(): Promise<WgKey> {
|
||||
const privateKey = await Shell.exec('wg genkey');
|
||||
const publicKey = await Shell.exec(`echo ${privateKey} | wg pubkey`);
|
||||
@ -268,30 +123,81 @@ export async function generateWgServer(config: {
|
||||
.map((s) => [ s.address, s.listen ])
|
||||
|
||||
// check for the conflict
|
||||
if (addresses.includes(config.address)) {
|
||||
if (Array.isArray(addresses) && addresses.includes(config.address)) {
|
||||
throw new Error(`Address ${config.address} is already reserved!`)
|
||||
}
|
||||
|
||||
if (ports.includes(config.port)) {
|
||||
if (Array.isArray(addresses) && ports.includes(config.port)) {
|
||||
throw new Error(`Port ${config.port} is already reserved!`)
|
||||
}
|
||||
|
||||
// save server config
|
||||
await client.lpush(WG_SEVER_PATH, JSON.stringify(server))
|
||||
|
||||
const CONFIG_PATH = path.join(WG_PATH, `wg${confId}.conf`)
|
||||
|
||||
// save server config to disk
|
||||
await fs.writeFile(path.join(WG_PATH, `wg${confId}.conf`), getServerConf(server), {
|
||||
await fs.writeFile(CONFIG_PATH, getServerConf(server), {
|
||||
mode: 0o600,
|
||||
})
|
||||
|
||||
// restart wireguard
|
||||
await Shell.exec(`wg-quick down wg${confId}`)
|
||||
await Shell.exec(`wg-quick up wg${confId}`)
|
||||
// to ensure interface does not exists
|
||||
await dropInterface(confId)
|
||||
await Shell.exec(`ip link set down dev wg${confId}`, true)
|
||||
|
||||
|
||||
// create a interface
|
||||
await createInterface(confId, config.address)
|
||||
|
||||
// restart WireGuard
|
||||
await Shell.exec(`wg setconf wg${confId} ${CONFIG_PATH}`)
|
||||
await Shell.exec(`ip link set up dev wg${confId}`)
|
||||
|
||||
// return server id
|
||||
return uuid
|
||||
}
|
||||
|
||||
/**
|
||||
* # ip link add dev wg0 type wireguard
|
||||
* # ip address add dev wg0 10.0.0.1/24
|
||||
*
|
||||
* @param configId
|
||||
* @param address
|
||||
*/
|
||||
export async function createInterface(configId: number, address: string): Promise<boolean> {
|
||||
|
||||
// first checking for the interface is already exists
|
||||
const interfaces = await Shell.exec(`ip link show | grep wg${configId}`, true)
|
||||
if (interfaces.includes(`wg${configId}`)) {
|
||||
console.error(`failed to create interface, wg${configId} already exists!`)
|
||||
return false
|
||||
}
|
||||
|
||||
// create interface
|
||||
const o1 = await Shell.exec(`ip link add dev wg${configId} type wireguard`)
|
||||
// check if it has error
|
||||
if (o1 !== '') {
|
||||
console.error(`failed to create interface, ${o1}`)
|
||||
return false
|
||||
}
|
||||
|
||||
const o2 = await Shell.exec(`ip address add dev wg${configId} ${address}/24`)
|
||||
// check if it has error
|
||||
if (o2 !== '') {
|
||||
console.error(`failed to assign ip to interface, ${o2}`)
|
||||
console.log(`removing interface wg${configId} due to errors`)
|
||||
await Shell.exec(`ip link delete dev wg${configId}`, true)
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
|
||||
}
|
||||
|
||||
export async function dropInterface(configId: number) {
|
||||
await Shell.exec(`ip link delete dev wg${configId}`, true)
|
||||
}
|
||||
|
||||
export function getServerConf(server: WgServer): string {
|
||||
return `
|
||||
# Autogenerated by WireGuard UI (WireAdmin)
|
||||
@ -340,10 +246,22 @@ export async function getServers(): Promise<WgServer[]> {
|
||||
return (await client.lrange(WG_SEVER_PATH, 0, -1)).map((s) => JSON.parse(s))
|
||||
}
|
||||
|
||||
export async function findServer(id: string | undefined, hash: string | undefined): Promise<WgServer | undefined> {
|
||||
export async function findServerIndex(id: string): Promise<number | undefined> {
|
||||
let index = 0;
|
||||
const servers = await getServers()
|
||||
for (const s of servers) {
|
||||
if (s.id === id) {
|
||||
return index
|
||||
}
|
||||
index++
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
export async function findServer(id: string | undefined, hash?: string): Promise<WgServer | undefined> {
|
||||
const servers = await getServers()
|
||||
return id ?
|
||||
servers.find((s) => s.id === hash) :
|
||||
servers.find((s) => s.id === id) :
|
||||
hash && isJson(hash) ? servers.find((s) => JSON.stringify(s) === hash) :
|
||||
undefined
|
||||
}
|
||||
|
20
src/lib/zod.ts
Normal file
20
src/lib/zod.ts
Normal file
@ -0,0 +1,20 @@
|
||||
import { NextApiResponse } from "next";
|
||||
import { ZodError } from "zod";
|
||||
|
||||
export function zodErrorToResponse(res: NextApiResponse, z: ZodError) {
|
||||
return res
|
||||
.status(400)
|
||||
.json({
|
||||
ok: false,
|
||||
message: 'Bad Request',
|
||||
details: zodErrorMessage(z)
|
||||
})
|
||||
}
|
||||
|
||||
export function zodEnumError(message: string) {
|
||||
return { message }
|
||||
}
|
||||
|
||||
export function zodErrorMessage(ze: ZodError): string[] {
|
||||
return ze.errors.map((e) => e.message)
|
||||
}
|
9
src/package-lock.json
generated
9
src/package-lock.json
generated
@ -14,6 +14,7 @@
|
||||
"antd": "5.8.6",
|
||||
"clsx": "^2.0.0",
|
||||
"crypto-js": "^4.1.1",
|
||||
"deepmerge": "^4.3.1",
|
||||
"dotenv": "16.3.1",
|
||||
"ioredis": "5.3.2",
|
||||
"next": "13.4.19",
|
||||
@ -1029,6 +1030,14 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/deepmerge": {
|
||||
"version": "4.3.1",
|
||||
"resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz",
|
||||
"integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/denque": {
|
||||
"version": "2.1.0",
|
||||
"license": "Apache-2.0",
|
||||
|
@ -16,6 +16,7 @@
|
||||
"antd": "5.8.6",
|
||||
"clsx": "^2.0.0",
|
||||
"crypto-js": "^4.1.1",
|
||||
"deepmerge": "^4.3.1",
|
||||
"dotenv": "16.3.1",
|
||||
"ioredis": "5.3.2",
|
||||
"next": "13.4.19",
|
||||
|
@ -1,10 +1,16 @@
|
||||
import { Button, Card } from "antd";
|
||||
import { Button, Card, List } from "antd";
|
||||
import BasePage from "@ui/pages/BasePage";
|
||||
import PageRouter from "@ui/pages/PageRouter";
|
||||
import React from "react";
|
||||
import { PlusOutlined } from "@ant-design/icons";
|
||||
import useSWR from "swr";
|
||||
import { APIResponse } from "@lib/typings";
|
||||
import useSWRMutation from "swr/mutation";
|
||||
import { useRouter } from "next/router";
|
||||
import { MiddleEllipsis } from "@ui/MiddleEllipsis";
|
||||
import StatusBadge from "@ui/StatusBadge";
|
||||
import { SmartModalRef } from "@ui/Modal/SmartModal";
|
||||
import CreateClientModal from "@ui/Modal/CreateClientModal";
|
||||
|
||||
|
||||
export async function getServerSideProps(context: any) {
|
||||
@ -20,22 +26,66 @@ type PageProps = {
|
||||
}
|
||||
|
||||
export default function ServerPage(props: PageProps) {
|
||||
const { data, error, isLoading } = useSWR(`/api/wireguard/${props.serverId}`, async (url: string) => {
|
||||
const resp = await fetch(url, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
})
|
||||
const data = await resp.json() as APIResponse<any>
|
||||
if (!data.ok) throw new Error('Server responded with error status')
|
||||
return data.result
|
||||
})
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
const createClientRef = React.useRef<SmartModalRef | null>(null)
|
||||
|
||||
const { data, error, isLoading } = useSWR(
|
||||
`/api/wireguard/${props.serverId}`,
|
||||
async (url: string) => {
|
||||
const resp = await fetch(url, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
})
|
||||
if (resp.status === 404) {
|
||||
router.replace('/').catch()
|
||||
return false
|
||||
}
|
||||
const data = await resp.json() as APIResponse<any>
|
||||
if (!data.ok) throw new Error('Server responded with error status')
|
||||
return data.result
|
||||
}
|
||||
)
|
||||
|
||||
const { isMutating: isChangingStatus, trigger: changeStatus } = useSWRMutation(
|
||||
`/api/wireguard/${props.serverId}`,
|
||||
async (url: string, { arg }: { arg: string }) => {
|
||||
const resp = await fetch(url, {
|
||||
method: arg === 'remove' ? 'DELETE' : 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: arg === 'remove' ? undefined : JSON.stringify({
|
||||
status: arg
|
||||
})
|
||||
})
|
||||
if (resp.status === 404) {
|
||||
router.replace('/').catch()
|
||||
return false
|
||||
}
|
||||
const data = await resp.json() as APIResponse<any>
|
||||
if (!data.ok) throw new Error('Server responded with error status')
|
||||
return true
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
},
|
||||
onError: () => {
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
const lastChangeStatus = React.useRef<string | null>(null)
|
||||
|
||||
return (
|
||||
<BasePage>
|
||||
<CreateClientModal ref={createClientRef} />
|
||||
<PageRouter
|
||||
route={[
|
||||
{ title: 'SERVER_ID' }
|
||||
{ title: data ? data.name.toString() : 'LOADING...' }
|
||||
]}
|
||||
/>
|
||||
{error ? (
|
||||
@ -48,17 +98,59 @@ export default function ServerPage(props: PageProps) {
|
||||
</Card>
|
||||
) : (
|
||||
<div className={'space-y-4'}>
|
||||
<Card>
|
||||
<div className={'flex items-center gap-x-2'}>
|
||||
<Card className={'[&>.ant-card-body]:max-md:p1-2'}>
|
||||
<List>
|
||||
<Row label={'IP address'}>
|
||||
<pre> {data.address}/24 </pre>
|
||||
</Row>
|
||||
<Row label={'Status'}>
|
||||
<StatusBadge status={data.status} />
|
||||
</Row>
|
||||
<Row label={'Public Key'}>
|
||||
<MiddleEllipsis
|
||||
content={data.publicKey}
|
||||
maxLength={16}
|
||||
/>
|
||||
</Row>
|
||||
</List>
|
||||
<div className={'flex flex-wrap items-center gap-2 mt-6'}>
|
||||
{data.status === 'up' ? (
|
||||
<React.Fragment>
|
||||
<Button> Restart </Button>
|
||||
<Button> Stop </Button>
|
||||
<Button
|
||||
className={'max-md:col-span-12'}
|
||||
loading={isChangingStatus && lastChangeStatus.current === 'restart'}
|
||||
disabled={isChangingStatus}
|
||||
onClick={() => changeStatus('restart')}
|
||||
> Restart </Button>
|
||||
<Button
|
||||
danger={true}
|
||||
className={'max-md:col-span-12'}
|
||||
loading={isChangingStatus && lastChangeStatus.current === 'stop'}
|
||||
disabled={isChangingStatus}
|
||||
onClick={() => changeStatus('stop')}
|
||||
> Stop </Button>
|
||||
</React.Fragment>
|
||||
) : (
|
||||
<React.Fragment>
|
||||
<Button type={'primary'} className={'bg-green-500'}> Start </Button>
|
||||
<Button danger={true}> Remove </Button>
|
||||
<Button
|
||||
type={'primary'}
|
||||
className={'max-md:col-span-12 bg-green-500'}
|
||||
loading={isChangingStatus && lastChangeStatus.current === 'start'}
|
||||
disabled={isChangingStatus}
|
||||
onClick={() => changeStatus('start')}
|
||||
> Start </Button>
|
||||
<Button
|
||||
danger={true}
|
||||
className={'max-md:col-span-12'}
|
||||
loading={isChangingStatus && lastChangeStatus.current === 'remove'}
|
||||
disabled={isChangingStatus}
|
||||
onClick={() => {
|
||||
changeStatus('remove').finally(() => {
|
||||
lastChangeStatus.current = null
|
||||
router.replace('/').catch()
|
||||
})
|
||||
}}
|
||||
> Remove </Button>
|
||||
</React.Fragment>
|
||||
)}
|
||||
</div>
|
||||
@ -72,7 +164,7 @@ export default function ServerPage(props: PageProps) {
|
||||
<p className={'text-gray-400 text-md'}>
|
||||
There are no clients yet!
|
||||
</p>
|
||||
<Button type={'primary'} icon={<PlusOutlined />}>
|
||||
<Button type={'primary'} icon={<PlusOutlined />} onClick={() => createClientRef.current?.open()}>
|
||||
Add a client
|
||||
</Button>
|
||||
</div>
|
||||
@ -82,3 +174,20 @@ export default function ServerPage(props: PageProps) {
|
||||
</BasePage>
|
||||
);
|
||||
}
|
||||
|
||||
function Row(props: {
|
||||
label: string
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
return (
|
||||
<List.Item className={'flex flex-wrap items-center gap-2 leading-none relative overflow-ellipsis'}>
|
||||
<div className={'flex items-center text-gray-400 text-sm col-span-12 md:col-span-3'}>
|
||||
{props.label}
|
||||
</div>
|
||||
<div className={'flex items-center gap-x-2 col-span-12 md:col-span-9'}>
|
||||
{props.children}
|
||||
</div>
|
||||
</List.Item>
|
||||
)
|
||||
}
|
||||
|
||||
|
0
src/pages/api/wireguard/[serverId]/createClient.ts
Normal file
0
src/pages/api/wireguard/[serverId]/createClient.ts
Normal file
@ -1,20 +1,39 @@
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
import safeServe from "@lib/safe-serve";
|
||||
import { findServer } from "@lib/wireguard";
|
||||
import { findServer, WGServer } from "@lib/wireguard";
|
||||
import { z } from "zod";
|
||||
import { NameSchema, ServerId } from "@lib/schemas/WireGuard";
|
||||
import { WgServer } from "@lib/typings";
|
||||
import { zodEnumError, zodErrorToResponse } from "@lib/zod";
|
||||
|
||||
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
return safeServe(res, async () => {
|
||||
|
||||
const parsed = RequestSchema.safeParse(req.query)
|
||||
if (!parsed.success) {
|
||||
return zodErrorToResponse(res, parsed.error)
|
||||
}
|
||||
|
||||
const { serverId } = req.query as z.infer<typeof RequestSchema>
|
||||
const server = await findServer(serverId)
|
||||
if (!server) {
|
||||
return res
|
||||
.status(404)
|
||||
.json({ ok: false, message: 'Not Found' })
|
||||
}
|
||||
|
||||
if (req.method === 'GET') {
|
||||
return get(req, res)
|
||||
return res
|
||||
.status(200)
|
||||
.json({ ok: true, result: server })
|
||||
}
|
||||
|
||||
if (req.method === 'PUT') {
|
||||
return update(req, res)
|
||||
return await update(server, req, res)
|
||||
}
|
||||
|
||||
if (req.method === 'DELETE') {
|
||||
return remove(req, res)
|
||||
return await remove(server, req, res)
|
||||
}
|
||||
|
||||
return res
|
||||
@ -24,24 +43,57 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
|
||||
})
|
||||
}
|
||||
|
||||
async function get(req: NextApiRequest, res: NextApiResponse) {
|
||||
const RequestSchema = z.object({
|
||||
serverId: ServerId
|
||||
})
|
||||
|
||||
const server = findServer()
|
||||
|
||||
return res
|
||||
.status(500)
|
||||
.json({ ok: false, details: 'Not yet implemented!' })
|
||||
async function update(server: WgServer, req: NextApiRequest, res: NextApiResponse) {
|
||||
return safeServe(res, async () => {
|
||||
|
||||
const parsed = PutRequestSchema.safeParse(req.body)
|
||||
if (!parsed.success) {
|
||||
return zodErrorToResponse(res, parsed.error)
|
||||
}
|
||||
|
||||
const { status } = req.body as z.infer<typeof PutRequestSchema>
|
||||
|
||||
switch (status) {
|
||||
case 'start':
|
||||
await WGServer.start(server.id)
|
||||
break;
|
||||
case 'stop':
|
||||
await WGServer.stop(server.id)
|
||||
break;
|
||||
case 'restart':
|
||||
await WGServer.stop(server.id)
|
||||
await WGServer.start(server.id)
|
||||
break;
|
||||
}
|
||||
|
||||
return res
|
||||
.status(200)
|
||||
.json({ ok: true })
|
||||
|
||||
})
|
||||
}
|
||||
|
||||
async function update(req: NextApiRequest, res: NextApiResponse) {
|
||||
return res
|
||||
.status(500)
|
||||
.json({ ok: false, details: 'Not yet implemented!' })
|
||||
}
|
||||
|
||||
async function remove(req: NextApiRequest, res: NextApiResponse) {
|
||||
return res
|
||||
.status(500)
|
||||
.json({ ok: false, details: 'Not yet implemented!' })
|
||||
const PutRequestSchema = z.object({
|
||||
name: NameSchema.optional(),
|
||||
status: z
|
||||
.enum(
|
||||
[ 'start', 'stop', 'restart' ],
|
||||
{ errorMap: () => zodEnumError('Invalid status') }
|
||||
)
|
||||
.optional(),
|
||||
})
|
||||
|
||||
async function remove(server: WgServer, req: NextApiRequest, res: NextApiResponse) {
|
||||
return safeServe(res, async () => {
|
||||
await WGServer.remove(server.id)
|
||||
return res
|
||||
.status(200)
|
||||
.json({ ok: true })
|
||||
})
|
||||
}
|
||||
|
||||
|
42
src/pages/api/wireguard/[serverId]/stop.ts
Normal file
42
src/pages/api/wireguard/[serverId]/stop.ts
Normal file
@ -0,0 +1,42 @@
|
||||
import type { NextApiRequest, NextApiResponse } from 'next'
|
||||
import safeServe from "@lib/safe-serve";
|
||||
import { findServer, WGServer } from "@lib/wireguard";
|
||||
import { z } from "zod";
|
||||
import { ServerId } from "@lib/schemas/WireGuard";
|
||||
|
||||
export default function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
return safeServe(res, async () => {
|
||||
|
||||
if (req.method !== 'GET') {
|
||||
return res
|
||||
.status(400)
|
||||
.json({ ok: false, details: 'Method not allowed' })
|
||||
}
|
||||
|
||||
const { serverId } = req.query as z.infer<typeof RequestSchema>
|
||||
const server = await findServer(serverId)
|
||||
|
||||
if (!server) {
|
||||
return res
|
||||
.status(404)
|
||||
.json({ ok: false, message: 'Not Found' })
|
||||
}
|
||||
|
||||
const updated = await WGServer.stop(serverId)
|
||||
if (!updated) {
|
||||
return res
|
||||
.status(500)
|
||||
.json({ ok: false, details: 'Server Internal Error' })
|
||||
}
|
||||
|
||||
return res
|
||||
.status(200)
|
||||
.json({ ok: true })
|
||||
|
||||
})
|
||||
}
|
||||
|
||||
const RequestSchema = z.object({
|
||||
serverId: ServerId
|
||||
})
|
||||
|
@ -1,9 +1,9 @@
|
||||
import type { NextApiRequest, NextApiResponse } from 'next'
|
||||
import safeServe from "@lib/safe-serve";
|
||||
import { z } from "zod";
|
||||
import { IPV4_REGEX } from "@/lib/constants";
|
||||
import { client, WG_SEVER_PATH } from "@/lib/redis";
|
||||
import { isBetween } from "@/lib/utils";
|
||||
import { AddressSchema, DnsSchema, MtuSchema, NameSchema, PortSchema, TypeSchema } from "@lib/schemas/WireGuard";
|
||||
import { generateWgServer } from "@lib/wireguard";
|
||||
import { zodErrorToResponse } from "@lib/zod";
|
||||
|
||||
export default function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
return safeServe(res, async () => {
|
||||
@ -14,38 +14,34 @@ export default function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
.json({ ok: false, details: 'Method not allowed' })
|
||||
}
|
||||
|
||||
if (!RequestSchema.safeParse(req.body).success) {
|
||||
return res
|
||||
.status(400)
|
||||
.json({ ok: false, details: 'Bad Request' })
|
||||
const parsed = RequestSchema.safeParse(req.body)
|
||||
if (!parsed.success) {
|
||||
return zodErrorToResponse(res, parsed.error)
|
||||
}
|
||||
|
||||
const { name, address, listen, dns = null, mtu = 1420 } = req.body
|
||||
const { name, address, type, port, dns = null, mtu = 1420 } = req.body
|
||||
|
||||
const serversCount = (await client.lrange(WG_SEVER_PATH, 0, -1)).length
|
||||
|
||||
const server = {
|
||||
id: serversCount + 1,
|
||||
const serverId = await generateWgServer({
|
||||
name,
|
||||
address,
|
||||
listen,
|
||||
port,
|
||||
type,
|
||||
mtu,
|
||||
dns
|
||||
}
|
||||
|
||||
await client.lpush(WG_SEVER_PATH, JSON.stringify(server))
|
||||
})
|
||||
|
||||
return res
|
||||
.status(200)
|
||||
.json({ ok: true })
|
||||
.json({ ok: true, result: serverId })
|
||||
|
||||
})
|
||||
}
|
||||
|
||||
const RequestSchema = z.object({
|
||||
name: z.string().regex(/^[A-Za-z\d\s]{3,32}$/),
|
||||
address: z.string().regex(IPV4_REGEX),
|
||||
listen: z.string().refine((d) => isBetween(d, 1, 65535)),
|
||||
dns: z.string().regex(IPV4_REGEX).optional(),
|
||||
mtu: z.string().refine((d) => isBetween(d, 1, 1500)).optional()
|
||||
name: NameSchema,
|
||||
address: AddressSchema,
|
||||
port: PortSchema,
|
||||
type: TypeSchema,
|
||||
dns: DnsSchema,
|
||||
mtu: MtuSchema
|
||||
})
|
||||
|
@ -24,7 +24,7 @@ export default function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
const m = file.match(reg)
|
||||
if (m) {
|
||||
const confId = parseInt(m[1])
|
||||
Shell.exec(`wg-quick down wg${confId}`).catch()
|
||||
Shell.exec(`ip link set down dev wg${confId}`).catch()
|
||||
fs.unlinkSync(path.join(WG_PATH, file))
|
||||
}
|
||||
})
|
||||
|
@ -1,5 +1,5 @@
|
||||
import React from "react";
|
||||
import { Badge, Button, Card, List, Segmented } from "antd";
|
||||
import { Badge, Button, Card, List } from "antd";
|
||||
import BasePage from "@ui/pages/BasePage";
|
||||
import { APIResponse, WgServer } from "@lib/typings";
|
||||
import { PlusOutlined } from "@ant-design/icons";
|
||||
@ -7,8 +7,10 @@ import Image, { ImageProps } from "next/image";
|
||||
import Link from "next/link";
|
||||
import PageRouter from "@ui/pages/PageRouter";
|
||||
import useSWR from "swr";
|
||||
import SmartModal, { SmartModalRef } from "@ui/SmartModal";
|
||||
import { SmartModalRef } from "@ui/Modal/SmartModal";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import CreateServerModal from "@ui/Modal/CreateServerModal";
|
||||
import StatusBadge from "@ui/StatusBadge";
|
||||
|
||||
export default function Home() {
|
||||
const { data, error, isLoading } = useSWR('/api/wireguard/listServers', async (url: string) => {
|
||||
@ -33,22 +35,7 @@ export default function Home() {
|
||||
</Button>
|
||||
)}
|
||||
</PageRouter>
|
||||
<SmartModal
|
||||
ref={createServerRef}
|
||||
title={null}
|
||||
footer={null}
|
||||
rootClassName={'w-full max-w-[340px]'}
|
||||
>
|
||||
<div className={'flex items-center justify-center'}>
|
||||
<Segmented
|
||||
className={'select-none'}
|
||||
options={[
|
||||
{ label: 'Direct', value: 'default', icon: <i className={'far fa-arrows-left-right-to-line'} /> },
|
||||
{ label: 'Tor', value: 'tor', icon: <TorOnion /> }
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
</SmartModal>
|
||||
<CreateServerModal ref={createServerRef} />
|
||||
<div className={'space-y-4'}>
|
||||
{error ? (
|
||||
<Card className={'flex items-center justify-center p-4'}>
|
||||
@ -89,13 +76,9 @@ function Server(s: WgServer) {
|
||||
<List.Item className={'flex items-center justify-between p-4'}>
|
||||
<div className={'w-full grid grid-cols-12 items-center gap-x-2'}>
|
||||
<ServerIcon type={s.type} className={'col-span-1'} />
|
||||
<h3 className={'font-medium col-span-4'}> {s.name} </h3>
|
||||
<span className={'font-medium col-span-4'}> {s.name} </span>
|
||||
<div className={'col-span-4 justify-end'}>
|
||||
<Badge
|
||||
size={'small'}
|
||||
color={s.status === 'up' ? 'rgb(82, 196, 26)' : 'rgb(255, 77, 79)'}
|
||||
text={s.status === 'up' ? 'Running' : 'Stopped'}
|
||||
/>
|
||||
<StatusBadge status={s.status} />
|
||||
</div>
|
||||
</div>
|
||||
<Link href={`/${s.id}`}>
|
||||
|
@ -12,6 +12,40 @@
|
||||
|
||||
}
|
||||
|
||||
|
||||
@layer base {
|
||||
|
||||
h1 {
|
||||
@apply text-4xl font-bold;
|
||||
}
|
||||
|
||||
h2 {
|
||||
@apply text-3xl font-bold;
|
||||
}
|
||||
|
||||
h3 {
|
||||
@apply text-2xl font-semibold;
|
||||
}
|
||||
|
||||
h4 {
|
||||
@apply text-xl font-semibold;
|
||||
}
|
||||
|
||||
h5 {
|
||||
@apply text-lg font-medium;
|
||||
}
|
||||
|
||||
h6 {
|
||||
@apply text-base font-medium;
|
||||
}
|
||||
|
||||
a {
|
||||
text-decoration: none;
|
||||
transition: color 0.2s ease-in-out;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
|
33
src/ui/MiddleEllipsis.tsx
Normal file
33
src/ui/MiddleEllipsis.tsx
Normal file
@ -0,0 +1,33 @@
|
||||
import React from "react";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import { ReactHTMLProps } from "@lib/typings";
|
||||
|
||||
export interface MiddleEllipsisProps extends ReactHTMLProps<HTMLSpanElement> {
|
||||
content: string
|
||||
maxLength: number
|
||||
rootClassName?: string
|
||||
}
|
||||
|
||||
export function MiddleEllipsis(props: MiddleEllipsisProps) {
|
||||
|
||||
const { content, maxLength, className, rootClassName, ...rest } = props
|
||||
|
||||
const [ leftL, rightL ] = React.useMemo(() => {
|
||||
const left = Math.floor(maxLength / 2)
|
||||
const right = Math.ceil(maxLength / 2)
|
||||
return [ left, right ]
|
||||
}, [ maxLength ])
|
||||
|
||||
const [ left, right ] = React.useMemo(() => {
|
||||
if (content.length <= maxLength) return [ content, '' ]
|
||||
return [ content.slice(0, leftL), content.slice(content.length - rightL) ]
|
||||
}, [ content, leftL, rightL ])
|
||||
|
||||
return (
|
||||
<span {...rest} className={rootClassName}>
|
||||
{left}
|
||||
<span className={twMerge('text-gray-400', className)}> ... </span>
|
||||
{right}
|
||||
</span>
|
||||
)
|
||||
}
|
123
src/ui/Modal/CreateClientModal.tsx
Normal file
123
src/ui/Modal/CreateClientModal.tsx
Normal file
@ -0,0 +1,123 @@
|
||||
import React from "react";
|
||||
import SmartModal, { SmartModalRef } from "@ui/Modal/SmartModal";
|
||||
import { Button, Form, Input, notification } from "antd";
|
||||
import { z } from "zod";
|
||||
import { APIResponse } from "@lib/typings";
|
||||
import useSWRMutation from "swr/mutation";
|
||||
import { AddressSchema, DnsSchema, MtuSchema, NameSchema, PortSchema, TypeSchema } from "@lib/schemas/WireGuard";
|
||||
import { zodErrorMessage } from "@lib/zod";
|
||||
|
||||
export type CreateClientModalProps = {}
|
||||
|
||||
const CreateClientModal = React.forwardRef<
|
||||
SmartModalRef,
|
||||
CreateClientModalProps
|
||||
>((props, ref) => {
|
||||
|
||||
|
||||
const [ notificationApi, contextHolder ] = notification.useNotification()
|
||||
|
||||
const innerRef = React.useRef<SmartModalRef | null>(null)
|
||||
const [ form ] = Form.useForm()
|
||||
|
||||
React.useImperativeHandle(ref, () => innerRef.current as SmartModalRef)
|
||||
|
||||
React.useEffect(() => {
|
||||
form?.resetFields()
|
||||
}, [])
|
||||
|
||||
const { isMutating, trigger } = useSWRMutation(
|
||||
'/api/wireguard/createClient',
|
||||
async (url: string, { arg }: { arg: FormValues }) => {
|
||||
const resp = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify(arg)
|
||||
})
|
||||
const data = await resp.json() as APIResponse<any>
|
||||
if (!data.ok) throw new Error('Client responded with error status')
|
||||
return data.result
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
notificationApi.success({
|
||||
message: 'Success',
|
||||
description: (
|
||||
<div>
|
||||
hi
|
||||
</div>
|
||||
)
|
||||
})
|
||||
innerRef.current?.close()
|
||||
form?.resetFields()
|
||||
},
|
||||
onError: () => {
|
||||
notificationApi.error({
|
||||
message: 'Error',
|
||||
description: 'Failed to create Client'
|
||||
})
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
const onFinish = (values: Record<string, string | undefined>) => {
|
||||
if (isMutating) return
|
||||
const parsed = FormSchema.safeParse(values)
|
||||
if (!parsed.success) {
|
||||
console.error(zodErrorMessage(parsed.error))
|
||||
return;
|
||||
}
|
||||
trigger(values as FormValues).catch()
|
||||
}
|
||||
|
||||
return (
|
||||
<SmartModal
|
||||
ref={innerRef}
|
||||
title={null}
|
||||
footer={null}
|
||||
rootClassName={'w-full max-w-[340px]'}
|
||||
>
|
||||
{contextHolder}
|
||||
<h4 className={'mb-6'}> Create Client </h4>
|
||||
<Form form={form} onFinish={onFinish}>
|
||||
|
||||
<Form.Item name={'name'} label={'Name'} rules={[
|
||||
{
|
||||
required: true,
|
||||
message: 'Name is required'
|
||||
},
|
||||
{
|
||||
validator: (_, value) => {
|
||||
if (!value) return Promise.resolve()
|
||||
const res = NameSchema.safeParse(value)
|
||||
if (res.success) return Promise.resolve()
|
||||
return Promise.reject(zodErrorMessage(res.error)[0])
|
||||
}
|
||||
}
|
||||
]}>
|
||||
<Input placeholder={'Unicorn 🦄'} />
|
||||
</Form.Item>
|
||||
|
||||
<Button type={'primary'} htmlType={'submit'} className={'w-full'}>
|
||||
Create
|
||||
</Button>
|
||||
|
||||
</Form>
|
||||
</SmartModal>
|
||||
)
|
||||
})
|
||||
|
||||
export default CreateClientModal
|
||||
|
||||
const FormSchema = z.object({
|
||||
name: NameSchema,
|
||||
address: AddressSchema,
|
||||
port: PortSchema,
|
||||
type: TypeSchema,
|
||||
dns: DnsSchema,
|
||||
mtu: MtuSchema
|
||||
})
|
||||
|
||||
type FormValues = z.infer<typeof FormSchema>
|
201
src/ui/Modal/CreateServerModal.tsx
Normal file
201
src/ui/Modal/CreateServerModal.tsx
Normal file
@ -0,0 +1,201 @@
|
||||
import React from "react";
|
||||
import SmartModal, { SmartModalRef } from "@ui/Modal/SmartModal";
|
||||
import { Button, Form, Input, notification, Segmented } from "antd";
|
||||
import { TorOnion } from "@/pages";
|
||||
import { z } from "zod";
|
||||
import { APIResponse } from "@lib/typings";
|
||||
import useSWRMutation from "swr/mutation";
|
||||
import { isPrivateIP } from "@lib/utils";
|
||||
import { AddressSchema, DnsSchema, MtuSchema, NameSchema, PortSchema, TypeSchema } from "@lib/schemas/WireGuard";
|
||||
import { zodErrorMessage } from "@lib/zod";
|
||||
|
||||
export type CreateServerModalProps = {}
|
||||
|
||||
const CreateServerModal = React.forwardRef<
|
||||
SmartModalRef,
|
||||
CreateServerModalProps
|
||||
>((props, ref) => {
|
||||
|
||||
|
||||
const [ notificationApi, contextHolder ] = notification.useNotification()
|
||||
|
||||
const innerRef = React.useRef<SmartModalRef | null>(null)
|
||||
const [ form ] = Form.useForm()
|
||||
|
||||
const [ type, setType ] = React.useState<'default' | 'tor'>('default')
|
||||
|
||||
React.useImperativeHandle(ref, () => innerRef.current as SmartModalRef)
|
||||
|
||||
React.useEffect(() => {
|
||||
form?.resetFields()
|
||||
}, [])
|
||||
|
||||
const { isMutating, trigger } = useSWRMutation(
|
||||
'/api/wireguard/createServer',
|
||||
async (url: string, { arg }: { arg: FormValues }) => {
|
||||
const resp = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify(arg)
|
||||
})
|
||||
const data = await resp.json() as APIResponse<any>
|
||||
if (!data.ok) throw new Error('Server responded with error status')
|
||||
return data.result
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
notificationApi.success({
|
||||
message: 'Success',
|
||||
description: (
|
||||
<div>
|
||||
hi
|
||||
</div>
|
||||
)
|
||||
})
|
||||
innerRef.current?.close()
|
||||
form?.resetFields()
|
||||
},
|
||||
onError: () => {
|
||||
notificationApi.error({
|
||||
message: 'Error',
|
||||
description: 'Failed to create server'
|
||||
})
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
const onFinish = (values: Record<string, string | undefined>) => {
|
||||
if (isMutating) return
|
||||
const data = { ...values, type }
|
||||
const parsed = FormSchema.safeParse(data)
|
||||
if (!parsed.success) {
|
||||
console.error(zodErrorMessage(parsed.error))
|
||||
return;
|
||||
}
|
||||
trigger(data as FormValues)
|
||||
}
|
||||
|
||||
return (
|
||||
<SmartModal
|
||||
ref={innerRef}
|
||||
title={null}
|
||||
footer={null}
|
||||
rootClassName={'w-full max-w-[340px]'}
|
||||
>
|
||||
{contextHolder}
|
||||
<h4 className={'mb-6'}> Create Server </h4>
|
||||
<Form form={form} onFinish={onFinish}>
|
||||
|
||||
<Form.Item name={'name'} label={'Name'} rules={[
|
||||
{
|
||||
required: true,
|
||||
message: 'Name is required'
|
||||
},
|
||||
{
|
||||
validator: (_, value) => {
|
||||
if (!value) return Promise.resolve()
|
||||
const res = NameSchema.safeParse(value)
|
||||
if (res.success) return Promise.resolve()
|
||||
return Promise.reject(zodErrorMessage(res.error)[0])
|
||||
}
|
||||
}
|
||||
]}>
|
||||
<Input placeholder={'Kitty World'} />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item name={'address'} label={'Address'} rules={[
|
||||
{
|
||||
required: true,
|
||||
message: 'Address is required'
|
||||
},
|
||||
{
|
||||
validator: (_, value) => {
|
||||
if (value && !isPrivateIP(value)) {
|
||||
return Promise.reject('Address must be a private IP address')
|
||||
}
|
||||
return Promise.resolve()
|
||||
}
|
||||
}
|
||||
]}>
|
||||
<Input placeholder={'10.0.0.1'} />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item name={'port'} label={'Port'} rules={[
|
||||
{
|
||||
required: true,
|
||||
message: 'Port is required'
|
||||
},
|
||||
{
|
||||
validator: (_, value) => {
|
||||
const port = parseInt(value || '')
|
||||
if (port > 0 && port < 65535) {
|
||||
return Promise.resolve()
|
||||
}
|
||||
return Promise.reject('Port must be a valid port number')
|
||||
}
|
||||
}
|
||||
]}>
|
||||
<Input placeholder={'51820'} />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item label={'Server Mode'}>
|
||||
<Segmented
|
||||
className={'select-none'}
|
||||
defaultValue={type}
|
||||
onChange={(v) => setType(v as any)}
|
||||
options={[
|
||||
{ label: 'Direct', value: 'default', icon: <i className={'fal fa-arrows-left-right-to-line'} /> },
|
||||
{ label: 'Tor', value: 'tor', icon: <TorOnion />, disabled: true }
|
||||
]}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item name={'dns'} label={'DNS'} rules={[
|
||||
{
|
||||
validator: (_, value) => {
|
||||
if (!value) return Promise.resolve()
|
||||
const res = NameSchema.safeParse(value)
|
||||
if (res.success) return Promise.resolve()
|
||||
return Promise.reject(zodErrorMessage(res.error)[0])
|
||||
}
|
||||
}
|
||||
]}>
|
||||
<Input placeholder={'dns.google'} />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item name={'mtu'} label={'MTU'} rules={[
|
||||
{
|
||||
validator: (_, value) => {
|
||||
if (!value) return Promise.resolve()
|
||||
const res = NameSchema.safeParse(value)
|
||||
if (res.success) return Promise.resolve()
|
||||
return Promise.reject(zodErrorMessage(res.error)[0])
|
||||
}
|
||||
}
|
||||
]}>
|
||||
<Input placeholder={'1420'} />
|
||||
</Form.Item>
|
||||
|
||||
<Button type={'primary'} htmlType={'submit'} className={'w-full'}>
|
||||
Create
|
||||
</Button>
|
||||
|
||||
</Form>
|
||||
</SmartModal>
|
||||
)
|
||||
})
|
||||
|
||||
export default CreateServerModal
|
||||
|
||||
const FormSchema = z.object({
|
||||
name: NameSchema,
|
||||
address: AddressSchema,
|
||||
port: PortSchema,
|
||||
type: TypeSchema,
|
||||
dns: DnsSchema,
|
||||
mtu: MtuSchema
|
||||
})
|
||||
|
||||
type FormValues = z.infer<typeof FormSchema>
|
19
src/ui/StatusBadge.tsx
Normal file
19
src/ui/StatusBadge.tsx
Normal file
@ -0,0 +1,19 @@
|
||||
import { Badge } from "antd";
|
||||
import React from "react";
|
||||
import { BadgeProps } from "antd/es/badge";
|
||||
|
||||
export interface StatusBadgeProps extends Omit<BadgeProps, 'status'> {
|
||||
status: 'up' | 'down'
|
||||
}
|
||||
|
||||
export default function StatusBadge(props: StatusBadgeProps) {
|
||||
const { status,...rest } = props
|
||||
return (
|
||||
<Badge
|
||||
size={'small'}
|
||||
color={status === 'up' ? 'rgb(82, 196, 26)' : 'rgb(255, 77, 79)'}
|
||||
text={status === 'up' ? 'Running' : 'Stopped'}
|
||||
{...rest}
|
||||
/>
|
||||
)
|
||||
}
|
Loading…
Reference in New Issue
Block a user