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 { export default class FileManager {
static readDirectoryFiles(dir: string): string[] { static readDirectoryFiles(dir: string): string[] {
const files_ = []; const files_: string[] = [];
const files = fs.readdirSync(dir); const files = fs.readdirSync(dir);
for (const i in files) { for (const i in files) {
const name = dir + '/' + files[i]; const name = dir + '/' + files[i];

View File

@ -7,7 +7,7 @@ export default async function safeServe(res: NextApiResponse, fn: () => void): P
} catch (e) { } catch (e) {
console.error('[SafeServe]: ', e) console.error('[SafeServe]: ', e)
return res return res
.status(200) .status(500)
.json({ ok: false, details: 'Server Internal Error' }) .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 { 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') { if (process.platform !== 'linux') {
throw new Error('This program is not meant to run on UNIX systems'); 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(' ')}` : ''}`; const cmd = `${command}${args.length > 0 ? ` ${args.join(' ')}` : ''}`;
childProcess.exec(cmd, { shell: 'bash', }, (err, stdout) => { childProcess.exec(
if (err) return reject(err); cmd,
return resolve(String(stdout).trim()); { 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) { export function isObject(obj: object) {
return Object.prototype.toString.call(obj) === '[object 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 { promises as fs } from "fs";
import path from "path"; import path from "path";
import QRCode from "qrcode";
import { WG_PATH } from "@lib/constants"; import { WG_PATH } from "@lib/constants";
import Shell from "@lib/shell"; 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 { client, WG_SEVER_PATH } from "@lib/redis";
import { isJson } from "@lib/utils"; import { isJson } from "@lib/utils";
import deepmerge from "deepmerge";
export class WireGuardServer { export class WGServer {
serverId: number static async stop(id: string): Promise<boolean> {
const server = await findServer(id)
constructor(serverId: number) { if (!server) {
this.serverId = serverId console.error('server could not be updated (reason: not exists)')
} return false
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;
});
} }
await Shell.exec(`ip link set down dev wg${server.confId}`, true)
return this.__configPromise; await this.update(id, { status: 'down' })
return true
} }
async saveConfig() { static async start(id: string): Promise<boolean> {
const config = await this.getConfig(); const server = await findServer(id)
await this.__saveConfig(config); if (!server) {
await this.__syncConfig(); console.error('server could not be updated (reason: not exists)')
} return false
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`;
} }
await createInterface(server.confId, server.address)
await fs.writeFile(path.join(WG_PATH, `wg${this.serverId}.conf`), result, { await Shell.exec(`ip link set up dev wg${server.confId}`)
mode: 0o600, await this.update(id, { status: 'up' })
}); return true
} }
async getClients() { static async remove(id: string): Promise<boolean> {
const config = await this.getConfig(); const server = await findServer(id)
const clients = Object.entries(config.clients).map(([ clientId, client ]) => ({ if (!server) {
id: clientId, console.error('server could not be updated (reason: not exists)')
name: client.name, return false
enabled: client.enabled, }
address: client.address, await this.stop(id)
publicKey: client.publicKey, await dropInterface(server.confId)
createdAt: new Date(client.createdAt), await fs.unlink(path.join(WG_PATH, `wg${server.confId}.conf`)).catch(() => null)
updatedAt: new Date(client.updatedAt), const index = await findServerIndex(id)
allowedIPs: client.allowedIPs, console.log('index', index)
if (typeof index !== 'number') {
persistentKeepalive: null, console.warn('findServerIndex: index not found')
latestHandshakeAt: null, return true
transferRx: null, } else {
transferTx: null, await client.lrem(WG_SEVER_PATH, 1, JSON.stringify(server))
})); }
return true
// 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;
} }
async getClient(clientId: string): Promise<WgPeer> { static async update(id: string, update: Partial<WgServer>): Promise<boolean> {
throw new Error('Yet not implanted!'); const server = await findServer(id)
} if (!server) {
console.error('server could not be updated (reason: not exists)')
async getClientConfiguration(clientId: string): Promise<string> { return false
const config = await this.getConfig(); }
const client = await this.getClient(clientId); const index = await findServerIndex(id)
if (typeof index !== 'number') {
return ` console.warn('findServerIndex: index not found')
[Interface] return true
PrivateKey = ${client.privateKey} }
Address = ${client.address}/24 const res = await client.lset(WG_SEVER_PATH, index, JSON.stringify(deepmerge(server, update)))
${WG_DEFAULT_DNS ? `DNS = ${WG_DEFAULT_DNS}` : ''} return res === 'OK'
${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!');
} }
} }
/** /**
* Used to read /etc/wireguard/*.conf and sync them with our * Used to read /etc/wireguard/*.conf and sync them with our
* redis server. * redis server.
@ -218,10 +77,6 @@ async function syncServers(): Promise<boolean> {
throw new Error('Yet not implanted!'); throw new Error('Yet not implanted!');
} }
export interface IServerConfig extends WgServerConfig {
peers: WgPeer[]
}
export async function generateWgKey(): Promise<WgKey> { export async function generateWgKey(): Promise<WgKey> {
const privateKey = await Shell.exec('wg genkey'); const privateKey = await Shell.exec('wg genkey');
const publicKey = await Shell.exec(`echo ${privateKey} | wg pubkey`); const publicKey = await Shell.exec(`echo ${privateKey} | wg pubkey`);
@ -268,30 +123,81 @@ export async function generateWgServer(config: {
.map((s) => [ s.address, s.listen ]) .map((s) => [ s.address, s.listen ])
// check for the conflict // 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!`) 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!`) throw new Error(`Port ${config.port} is already reserved!`)
} }
// save server config // save server config
await client.lpush(WG_SEVER_PATH, JSON.stringify(server)) await client.lpush(WG_SEVER_PATH, JSON.stringify(server))
const CONFIG_PATH = path.join(WG_PATH, `wg${confId}.conf`)
// save server config to disk // 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, mode: 0o600,
}) })
// restart wireguard // to ensure interface does not exists
await Shell.exec(`wg-quick down wg${confId}`) await dropInterface(confId)
await Shell.exec(`wg-quick up wg${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 server id
return uuid 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 { export function getServerConf(server: WgServer): string {
return ` return `
# Autogenerated by WireGuard UI (WireAdmin) # 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)) 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() const servers = await getServers()
return id ? return id ?
servers.find((s) => s.id === hash) : servers.find((s) => s.id === id) :
hash && isJson(hash) ? servers.find((s) => JSON.stringify(s) === hash) : hash && isJson(hash) ? servers.find((s) => JSON.stringify(s) === hash) :
undefined 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", "antd": "5.8.6",
"clsx": "^2.0.0", "clsx": "^2.0.0",
"crypto-js": "^4.1.1", "crypto-js": "^4.1.1",
"deepmerge": "^4.3.1",
"dotenv": "16.3.1", "dotenv": "16.3.1",
"ioredis": "5.3.2", "ioredis": "5.3.2",
"next": "13.4.19", "next": "13.4.19",
@ -1029,6 +1030,14 @@
"node": ">=0.10.0" "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": { "node_modules/denque": {
"version": "2.1.0", "version": "2.1.0",
"license": "Apache-2.0", "license": "Apache-2.0",

View File

@ -16,6 +16,7 @@
"antd": "5.8.6", "antd": "5.8.6",
"clsx": "^2.0.0", "clsx": "^2.0.0",
"crypto-js": "^4.1.1", "crypto-js": "^4.1.1",
"deepmerge": "^4.3.1",
"dotenv": "16.3.1", "dotenv": "16.3.1",
"ioredis": "5.3.2", "ioredis": "5.3.2",
"next": "13.4.19", "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 BasePage from "@ui/pages/BasePage";
import PageRouter from "@ui/pages/PageRouter"; import PageRouter from "@ui/pages/PageRouter";
import React from "react"; import React from "react";
import { PlusOutlined } from "@ant-design/icons"; import { PlusOutlined } from "@ant-design/icons";
import useSWR from "swr"; import useSWR from "swr";
import { APIResponse } from "@lib/typings"; 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) { export async function getServerSideProps(context: any) {
@ -20,22 +26,66 @@ type PageProps = {
} }
export default function ServerPage(props: PageProps) { export default function ServerPage(props: PageProps) {
const { data, error, isLoading } = useSWR(`/api/wireguard/${props.serverId}`, async (url: string) => {
const resp = await fetch(url, { const router = useRouter()
method: 'GET',
headers: { const createClientRef = React.useRef<SmartModalRef | null>(null)
'Content-Type': 'application/json'
} const { data, error, isLoading } = useSWR(
}) `/api/wireguard/${props.serverId}`,
const data = await resp.json() as APIResponse<any> async (url: string) => {
if (!data.ok) throw new Error('Server responded with error status') const resp = await fetch(url, {
return data.result 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 ( return (
<BasePage> <BasePage>
<CreateClientModal ref={createClientRef} />
<PageRouter <PageRouter
route={[ route={[
{ title: 'SERVER_ID' } { title: data ? data.name.toString() : 'LOADING...' }
]} ]}
/> />
{error ? ( {error ? (
@ -48,17 +98,59 @@ export default function ServerPage(props: PageProps) {
</Card> </Card>
) : ( ) : (
<div className={'space-y-4'}> <div className={'space-y-4'}>
<Card> <Card className={'[&>.ant-card-body]:max-md:p1-2'}>
<div className={'flex items-center gap-x-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' ? ( {data.status === 'up' ? (
<React.Fragment> <React.Fragment>
<Button> Restart </Button> <Button
<Button> Stop </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>
) : ( ) : (
<React.Fragment> <React.Fragment>
<Button type={'primary'} className={'bg-green-500'}> Start </Button> <Button
<Button danger={true}> Remove </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> </React.Fragment>
)} )}
</div> </div>
@ -72,7 +164,7 @@ export default function ServerPage(props: PageProps) {
<p className={'text-gray-400 text-md'}> <p className={'text-gray-400 text-md'}>
There are no clients yet! There are no clients yet!
</p> </p>
<Button type={'primary'} icon={<PlusOutlined />}> <Button type={'primary'} icon={<PlusOutlined />} onClick={() => createClientRef.current?.open()}>
Add a client Add a client
</Button> </Button>
</div> </div>
@ -82,3 +174,20 @@ export default function ServerPage(props: PageProps) {
</BasePage> </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 type { NextApiRequest, NextApiResponse } from "next";
import safeServe from "@lib/safe-serve"; 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) { export default async function handler(req: NextApiRequest, res: NextApiResponse) {
return safeServe(res, async () => { 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') { if (req.method === 'GET') {
return get(req, res) return res
.status(200)
.json({ ok: true, result: server })
} }
if (req.method === 'PUT') { if (req.method === 'PUT') {
return update(req, res) return await update(server, req, res)
} }
if (req.method === 'DELETE') { if (req.method === 'DELETE') {
return remove(req, res) return await remove(server, req, res)
} }
return 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 async function update(server: WgServer, req: NextApiRequest, res: NextApiResponse) {
.status(500) return safeServe(res, async () => {
.json({ ok: false, details: 'Not yet implemented!' })
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) { const PutRequestSchema = z.object({
return res name: NameSchema.optional(),
.status(500) status: z
.json({ ok: false, details: 'Not yet implemented!' }) .enum(
} [ 'start', 'stop', 'restart' ],
{ errorMap: () => zodEnumError('Invalid status') }
async function remove(req: NextApiRequest, res: NextApiResponse) { )
return res .optional(),
.status(500) })
.json({ ok: false, details: 'Not yet implemented!' })
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 type { NextApiRequest, NextApiResponse } from 'next'
import safeServe from "@lib/safe-serve"; import safeServe from "@lib/safe-serve";
import { z } from "zod"; import { z } from "zod";
import { IPV4_REGEX } from "@/lib/constants"; import { AddressSchema, DnsSchema, MtuSchema, NameSchema, PortSchema, TypeSchema } from "@lib/schemas/WireGuard";
import { client, WG_SEVER_PATH } from "@/lib/redis"; import { generateWgServer } from "@lib/wireguard";
import { isBetween } from "@/lib/utils"; import { zodErrorToResponse } from "@lib/zod";
export default function handler(req: NextApiRequest, res: NextApiResponse) { export default function handler(req: NextApiRequest, res: NextApiResponse) {
return safeServe(res, async () => { return safeServe(res, async () => {
@ -14,38 +14,34 @@ export default function handler(req: NextApiRequest, res: NextApiResponse) {
.json({ ok: false, details: 'Method not allowed' }) .json({ ok: false, details: 'Method not allowed' })
} }
if (!RequestSchema.safeParse(req.body).success) { const parsed = RequestSchema.safeParse(req.body)
return res if (!parsed.success) {
.status(400) return zodErrorToResponse(res, parsed.error)
.json({ ok: false, details: 'Bad Request' })
} }
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 serverId = await generateWgServer({
const server = {
id: serversCount + 1,
name, name,
address, address,
listen, port,
type,
mtu, mtu,
dns dns
} })
await client.lpush(WG_SEVER_PATH, JSON.stringify(server))
return res return res
.status(200) .status(200)
.json({ ok: true }) .json({ ok: true, result: serverId })
}) })
} }
const RequestSchema = z.object({ const RequestSchema = z.object({
name: z.string().regex(/^[A-Za-z\d\s]{3,32}$/), name: NameSchema,
address: z.string().regex(IPV4_REGEX), address: AddressSchema,
listen: z.string().refine((d) => isBetween(d, 1, 65535)), port: PortSchema,
dns: z.string().regex(IPV4_REGEX).optional(), type: TypeSchema,
mtu: z.string().refine((d) => isBetween(d, 1, 1500)).optional() dns: DnsSchema,
mtu: MtuSchema
}) })

View File

@ -24,7 +24,7 @@ export default function handler(req: NextApiRequest, res: NextApiResponse) {
const m = file.match(reg) const m = file.match(reg)
if (m) { if (m) {
const confId = parseInt(m[1]) 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)) fs.unlinkSync(path.join(WG_PATH, file))
} }
}) })

View File

@ -1,5 +1,5 @@
import React from "react"; 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 BasePage from "@ui/pages/BasePage";
import { APIResponse, WgServer } from "@lib/typings"; import { APIResponse, WgServer } from "@lib/typings";
import { PlusOutlined } from "@ant-design/icons"; import { PlusOutlined } from "@ant-design/icons";
@ -7,8 +7,10 @@ import Image, { ImageProps } from "next/image";
import Link from "next/link"; import Link from "next/link";
import PageRouter from "@ui/pages/PageRouter"; import PageRouter from "@ui/pages/PageRouter";
import useSWR from "swr"; import useSWR from "swr";
import SmartModal, { SmartModalRef } from "@ui/SmartModal"; import { SmartModalRef } from "@ui/Modal/SmartModal";
import { twMerge } from "tailwind-merge"; import { twMerge } from "tailwind-merge";
import CreateServerModal from "@ui/Modal/CreateServerModal";
import StatusBadge from "@ui/StatusBadge";
export default function Home() { export default function Home() {
const { data, error, isLoading } = useSWR('/api/wireguard/listServers', async (url: string) => { const { data, error, isLoading } = useSWR('/api/wireguard/listServers', async (url: string) => {
@ -33,22 +35,7 @@ export default function Home() {
</Button> </Button>
)} )}
</PageRouter> </PageRouter>
<SmartModal <CreateServerModal ref={createServerRef} />
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>
<div className={'space-y-4'}> <div className={'space-y-4'}>
{error ? ( {error ? (
<Card className={'flex items-center justify-center p-4'}> <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'}> <List.Item className={'flex items-center justify-between p-4'}>
<div className={'w-full grid grid-cols-12 items-center gap-x-2'}> <div className={'w-full grid grid-cols-12 items-center gap-x-2'}>
<ServerIcon type={s.type} className={'col-span-1'} /> <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'}> <div className={'col-span-4 justify-end'}>
<Badge <StatusBadge status={s.status} />
size={'small'}
color={s.status === 'up' ? 'rgb(82, 196, 26)' : 'rgb(255, 77, 79)'}
text={s.status === 'up' ? 'Running' : 'Stopped'}
/>
</div> </div>
</div> </div>
<Link href={`/${s.id}`}> <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 components;
@tailwind utilities; @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}
/>
)
}