From 379d715ecdea75c5d1da9670e78d5f085738e46b Mon Sep 17 00:00:00 2001 From: Shahrad Elahi Date: Thu, 28 Sep 2023 09:23:45 +0330 Subject: [PATCH] Adds an ability to update `Server` or `Client` name --- src/lib/form-rules.ts | 18 ++++ src/lib/swr-fetch.ts | 42 +++++++++ src/lib/typings.ts | 22 ++--- src/lib/wireguard.ts | 64 ++++++++++++-- src/pages/[serverId]/index.tsx | 34 +++++--- .../wireguard/[serverId]/[clientId]/index.ts | 2 +- src/pages/api/wireguard/[serverId]/index.ts | 6 +- src/pages/index.tsx | 51 ++++++++--- src/ui/EditableText.tsx | 86 +++++++++++++++++++ src/ui/Modal/CreateClientModal.tsx | 16 +--- 10 files changed, 289 insertions(+), 52 deletions(-) create mode 100644 src/lib/form-rules.ts create mode 100644 src/lib/swr-fetch.ts create mode 100644 src/ui/EditableText.tsx diff --git a/src/lib/form-rules.ts b/src/lib/form-rules.ts new file mode 100644 index 0000000..388d885 --- /dev/null +++ b/src/lib/form-rules.ts @@ -0,0 +1,18 @@ +import { NameSchema } from "@lib/schemas/WireGuard"; +import { zodErrorMessage } from "@lib/zod"; +import type { Rule } from "rc-field-form/lib/interface"; + +export const RLS_NAME_INPUT: Rule[] = [ + { + 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]) + } + } +] \ No newline at end of file diff --git a/src/lib/swr-fetch.ts b/src/lib/swr-fetch.ts new file mode 100644 index 0000000..efea968 --- /dev/null +++ b/src/lib/swr-fetch.ts @@ -0,0 +1,42 @@ +import { APIResponse, Peer, PeerSchema, WgServer, WgServerSchema } from "@lib/typings"; +import { zodErrorMessage } from "@lib/zod"; + +export const UPDATE_SERVER = async (url: string, { arg }: { arg: Partial }) => { + const parsed = WgServerSchema.partial().safeParse(arg) + if (!parsed.success) { + console.error('invalid server schema', zodErrorMessage(parsed.error)) + return false + } + + const resp = await fetch(url, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(arg) + }) + + const data = await resp.json() as APIResponse + if (!data.ok) throw new Error('Server responded with error status') + + return true +} + +export const UPDATE_CLIENT = async (url: string, { arg }: { arg: Partial }) => { + const parsed = PeerSchema + .partial() + .safeParse(arg) + if (!parsed.success) { + console.error('invalid peer schema', zodErrorMessage(parsed.error)) + return false + } + + const resp = await fetch(url, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(arg) + }) + + const data = await resp.json() as APIResponse + if (!data.ok) throw new Error('Server responded with error status') + + return true +} diff --git a/src/lib/typings.ts b/src/lib/typings.ts index b24c4cb..4d2528a 100644 --- a/src/lib/typings.ts +++ b/src/lib/typings.ts @@ -30,6 +30,17 @@ const WgPeerSchema = z.object({ export type WgPeer = z.infer +export const PeerSchema = z.object({ + id: z.string().uuid(), + name: NameSchema, + preSharedKey: z.string().nullable(), + allowedIps: z.string().regex(IPV4_REGEX), + persistentKeepalive: z.number().nullable(), +}) + .merge(WgKeySchema) + +export type Peer = z.infer + export const WgServerSchema = z.object({ id: z.string().uuid(), confId: z.number(), @@ -42,16 +53,7 @@ export const WgServerSchema = z.object({ preDown: z.string().nullable(), postDown: z.string().nullable(), dns: z.string().regex(IPV4_REGEX).nullable(), - peers: z.array( - z.object({ - id: z.string().uuid(), - name: NameSchema, - preSharedKey: z.string().nullable(), - allowedIps: z.string().regex(IPV4_REGEX), - persistentKeepalive: z.number().nullable(), - }) - .merge(WgKeySchema) - ), + peers: z.array(PeerSchema), createdAt: z.string().datetime(), updatedAt: z.string().datetime(), status: z.enum([ 'up', 'down' ]), diff --git a/src/lib/wireguard.ts b/src/lib/wireguard.ts index 5ffb796..83f0b18 100644 --- a/src/lib/wireguard.ts +++ b/src/lib/wireguard.ts @@ -2,7 +2,7 @@ import { promises as fs } from "fs"; import path from "path"; import { WG_PATH } from "@lib/constants"; import Shell from "@lib/shell"; -import { WgKey, WgServer } from "@lib/typings"; +import { Peer, WgKey, WgServer } from "@lib/typings"; import { client, WG_SEVER_PATH } from "@lib/redis"; import { dynaJoin, isJson } from "@lib/utils"; import deepmerge from "deepmerge"; @@ -124,15 +124,15 @@ export class WGServer { return true } - static async removePeer(id: string, publicKey: string): Promise { - const server = await findServer(id) + static async removePeer(serverId: string, publicKey: string): Promise { + const server = await findServer(serverId) if (!server) { console.error('server could not be updated (reason: not exists)') return false } const peers = await wgPeersStr(server.confId) - const index = await findServerIndex(id) + const index = await findServerIndex(serverId) if (typeof index !== 'number') { console.warn('findServerIndex: index not found') return true @@ -162,6 +162,60 @@ export class WGServer { return true } + static async updatePeer(serverId: string, publicKey: string, update: Partial): Promise { + const server = await findServer(serverId) + if (!server) { + console.error('WGServer:UpdatePeer: server could not be updated (Reason: not exists)') + return false + } + + const index = await findServerIndex(serverId) + if (typeof index !== 'number') { + console.warn('findServerIndex: index not found') + return true + } + + const updatedPeers = server.peers.map((p) => { + if (p.publicKey !== publicKey) return p + return deepmerge(p, update) + }) + + await client.lset(WG_SEVER_PATH, index, JSON.stringify({ ...server, peers: updatedPeers })) + await this.storePeers({ id: server.id, confId: server.confId }, publicKey, updatedPeers) + + await WGServer.stop(serverId) + await WGServer.start(serverId) + + return true + } + + private static async getPeerIndex(id: string, publicKey: string): Promise { + const server = await findServer(id) + if (!server) { + console.error('server could not be updated (reason: not exists)') + return undefined + } + return server.peers.findIndex((p) => p.publicKey === publicKey) + } + + private static async storePeers(sd: Pick, publicKey: string, peers: Peer[]): Promise { + + const peerIndex = await this.getPeerIndex(sd.id, publicKey) + if (peerIndex === -1) { + console.warn('WGServer:StorePeers: no peer found') + return + } + + const confPath = path.join(WG_PATH, `wg${sd.confId}.conf`) + const conf = await fs.readFile(confPath, 'utf-8') + const serverConfStr = conf.includes('[Peer]') ? + conf.split('[Peer]')[0] : + conf + + const peersStr = peers.filter((_, i) => i !== peerIndex).join('\n') + await fs.writeFile(confPath, `${serverConfStr}\n${peersStr}`) + } + static async getFreePeerIp(id: string): Promise { const server = await findServer(id) if (!server) { @@ -388,7 +442,7 @@ export async function generateWgServer(config: { throw new Error(`Address ${config.address} is already reserved!`) } - if (Array.isArray(addresses) && ports.includes(config.port)) { + if (Array.isArray(ports) && ports.includes(config.port)) { throw new Error(`Port ${config.port} is already reserved!`) } diff --git a/src/pages/[serverId]/index.tsx b/src/pages/[serverId]/index.tsx index a804da6..8c10a1a 100644 --- a/src/pages/[serverId]/index.tsx +++ b/src/pages/[serverId]/index.tsx @@ -4,7 +4,7 @@ import PageRouter from "@ui/pages/PageRouter"; import React from "react"; import { PlusOutlined } from "@ant-design/icons"; import useSWR from "swr"; -import { APIResponse, WgServer } from "@lib/typings"; +import { APIResponse, Peer, WgServer } from "@lib/typings"; import useSWRMutation from "swr/mutation"; import { useRouter } from "next/router"; import { MiddleEllipsis } from "@ui/MiddleEllipsis"; @@ -14,6 +14,9 @@ import CreateClientModal from "@ui/Modal/CreateClientModal"; import { twMerge } from "tailwind-merge"; import QRCodeModal from "@ui/Modal/QRCodeModal"; import { getPeerConf } from "@lib/wireguard-utils"; +import EditableText from "@ui/EditableText"; +import { RLS_NAME_INPUT } from "@lib/form-rules"; +import { UPDATE_CLIENT } from "@lib/swr-fetch"; export async function getServerSideProps(context: any) { @@ -214,8 +217,6 @@ export default function ServerPage(props: PageProps) { ); } -type Peer = WgServer['peers'][0] - interface ClientProps extends Peer, Pick { serverId: string serverPublicKey: string @@ -237,10 +238,13 @@ function Client(props: ClientProps) { dns: props.dns, }) .then((s) => setConf(s)) - - console.log('conf', conf) }, [ props ]) + const RefreshOptions = { + onSuccess: () => props.refreshTrigger(), + onError: () => props.refreshTrigger() + } + const { isMutating: removingClient, trigger: removeClient } = useSWRMutation( `/api/wireguard/${props.serverId}/${props.id}`, async (url: string,) => { @@ -252,10 +256,13 @@ function Client(props: ClientProps) { if (!data.ok) throw new Error('Server responded with error status') return true }, - { - onSuccess: () => props.refreshTrigger(), - onError: () => props.refreshTrigger() - } + RefreshOptions + ) + + const { isMutating, trigger } = useSWRMutation( + `/api/wireguard/${props.serverId}/${props.id}`, + UPDATE_CLIENT, + RefreshOptions ) return ( @@ -268,7 +275,14 @@ function Client(props: ClientProps) {
- {props.name} + trigger({ name: v })} + />
{props.allowedIps} diff --git a/src/pages/api/wireguard/[serverId]/[clientId]/index.ts b/src/pages/api/wireguard/[serverId]/[clientId]/index.ts index c149346..23913fb 100644 --- a/src/pages/api/wireguard/[serverId]/[clientId]/index.ts +++ b/src/pages/api/wireguard/[serverId]/[clientId]/index.ts @@ -68,7 +68,7 @@ async function update(server: WgServer, peer: Peer, req: NextApiRequest, res: Ne const { name } = req.body as z.infer if (name) { - await WGServer.update(server.id, { name }) + await WGServer.updatePeer(server.id, peer.publicKey, { name }) } return res diff --git a/src/pages/api/wireguard/[serverId]/index.ts b/src/pages/api/wireguard/[serverId]/index.ts index 0a1e02e..cb45fa1 100644 --- a/src/pages/api/wireguard/[serverId]/index.ts +++ b/src/pages/api/wireguard/[serverId]/index.ts @@ -56,7 +56,7 @@ async function update(server: WgServer, req: NextApiRequest, res: NextApiRespons return zodErrorToResponse(res, parsed.error) } - const { status } = req.body as z.infer + const { status, name } = req.body as z.infer switch (status) { case 'start': @@ -71,6 +71,10 @@ async function update(server: WgServer, req: NextApiRequest, res: NextApiRespons break; } + if (name) { + await WGServer.update(server.id, { name }) + } + return res .status(200) .json({ ok: true }) diff --git a/src/pages/index.tsx b/src/pages/index.tsx index 3c6a19e..603f6d5 100644 --- a/src/pages/index.tsx +++ b/src/pages/index.tsx @@ -11,6 +11,10 @@ import { SmartModalRef } from "@ui/Modal/SmartModal"; import { twMerge } from "tailwind-merge"; import CreateServerModal from "@ui/Modal/CreateServerModal"; import StatusBadge from "@ui/StatusBadge"; +import EditableText from "@ui/EditableText"; +import useSWRMutation from "swr/mutation"; +import { UPDATE_SERVER } from "@lib/swr-fetch"; +import { RLS_NAME_INPUT } from "@lib/form-rules"; export default function Home() { const { data, error, isLoading, mutate } = useSWR( @@ -18,11 +22,9 @@ export default function Home() { async (url: string) => { const resp = await fetch(url, { method: 'GET', - headers: { - 'Content-Type': 'application/json' - } + headers: { 'Content-Type': 'application/json' } }) - const data = await resp.json() as APIResponse + const data = await resp.json() as APIResponse if (!data.ok) throw new Error('Server responded with error status') return data.result } @@ -48,13 +50,19 @@ export default function Home() { Loading... - ) : data.length > 0 ? ( + ) : Array.isArray(data) && data.length > 0 ? ( .ant-card-body]:p-0'} title={ Servers } > - {data.map((s) => )} + {data.map((s) => ( + mutate()} + /> + ))} ) : ( @@ -74,17 +82,38 @@ export default function Home() { ); } -function Server(s: WgServer) { +interface ServerListItemProps extends WgServer { + refreshTrigger: () => void +} + +function ServerListItem(props: ServerListItemProps) { + + const { isMutating, trigger } = useSWRMutation( + `/api/wireguard/${props.id}`, + UPDATE_SERVER, + { + onSuccess: () => props.refreshTrigger(), + onError: () => props.refreshTrigger(), + } + ) + return (
- - {s.name} + + trigger({ name: v })} + />
- +
- + diff --git a/src/ui/EditableText.tsx b/src/ui/EditableText.tsx new file mode 100644 index 0000000..28be949 --- /dev/null +++ b/src/ui/EditableText.tsx @@ -0,0 +1,86 @@ +import React from "react"; +import type { ReactHTMLProps } from "@lib/typings"; +import { Form, Input, InputRef } from "antd"; +import { twMerge } from "tailwind-merge"; +import type { SizeType } from "antd/lib/config-provider/SizeContext"; +import type { Rule } from "rc-field-form/lib/interface"; + +export interface EditableTextProps extends Omit, 'onChange' | 'children'> { + content?: string + rootClassName?: string + inputClassName?: string + inputWidth?: string | number | undefined + inputSize?: SizeType + rules?: Rule[] + onChange?: (val: string) => void + disabled?: boolean +} + +export default function EditableText(props: EditableTextProps) { + const { + rootClassName, + disabled, + inputClassName, + rules, + inputSize, + inputWidth, + onChange, + content, + ...rest + } = props + const [ editMode, setEditMode ] = React.useState(false) + const inputRef = React.useRef(null) + const [ val, setVal ] = React.useState(content) + React.useEffect(() => { + const { input } = inputRef.current || {} + if (input) { + input.value = val || '' + } + }, [ val ]) + const [ form ] = Form.useForm() + return ( +
+ + {val} + setEditMode(true)} + /> + +
{ + setEditMode(false) + const newVal = inputRef.current?.input?.value || '' + onChange && onChange(newVal) + setVal(newVal) + }} + > + .ant-row>.ant-col>div>.ant-form-item-explain]:hidden'} + > + { + if (evt.key === 'Enter') { + form.submit() + } + }} + /> + +
+
+ ) +} diff --git a/src/ui/Modal/CreateClientModal.tsx b/src/ui/Modal/CreateClientModal.tsx index 18e1e80..1ccd89c 100644 --- a/src/ui/Modal/CreateClientModal.tsx +++ b/src/ui/Modal/CreateClientModal.tsx @@ -6,6 +6,7 @@ import { APIResponse } from "@lib/typings"; import useSWRMutation from "swr/mutation"; import { NameSchema } from "@lib/schemas/WireGuard"; import { zodErrorMessage } from "@lib/zod"; +import { RLS_NAME_INPUT } from "@lib/form-rules"; type CreateClientModalProps = { @@ -89,20 +90,7 @@ const CreateClientModal = React.forwardRef<

Create Client

- { - if (!value) return Promise.resolve() - const res = NameSchema.safeParse(value) - if (res.success) return Promise.resolve() - return Promise.reject(zodErrorMessage(res.error)[0]) - } - } - ]}> +