Updates much stuff for least stability

This commit is contained in:
Shahrad Elahi 2023-09-20 04:40:44 +03:30
parent e4413590ce
commit 789f7088f8
23 changed files with 922 additions and 298 deletions

View File

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

View File

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

View 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' }
}
}
})

View File

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

View File

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

View File

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

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

View File

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

View File

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

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

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

View File

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

View File

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

View 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}`}>

View File

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

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

View 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
View 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}
/>
)
}