Adds an ability to update Server or Client name

This commit is contained in:
Shahrad Elahi 2023-09-28 09:23:45 +03:30
parent de5f35eec5
commit 379d715ecd
10 changed files with 289 additions and 52 deletions

18
src/lib/form-rules.ts Normal file
View File

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

42
src/lib/swr-fetch.ts Normal file
View File

@ -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<WgServer> }) => {
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<any>
if (!data.ok) throw new Error('Server responded with error status')
return true
}
export const UPDATE_CLIENT = async (url: string, { arg }: { arg: Partial<Peer> }) => {
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<any>
if (!data.ok) throw new Error('Server responded with error status')
return true
}

View File

@ -30,6 +30,17 @@ const WgPeerSchema = z.object({
export type WgPeer = z.infer<typeof WgPeerSchema> export type WgPeer = z.infer<typeof WgPeerSchema>
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<typeof PeerSchema>
export const WgServerSchema = z.object({ export const WgServerSchema = z.object({
id: z.string().uuid(), id: z.string().uuid(),
confId: z.number(), confId: z.number(),
@ -42,16 +53,7 @@ export const WgServerSchema = z.object({
preDown: z.string().nullable(), preDown: z.string().nullable(),
postDown: z.string().nullable(), postDown: z.string().nullable(),
dns: z.string().regex(IPV4_REGEX).nullable(), dns: z.string().regex(IPV4_REGEX).nullable(),
peers: z.array( peers: z.array(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)
),
createdAt: z.string().datetime(), createdAt: z.string().datetime(),
updatedAt: z.string().datetime(), updatedAt: z.string().datetime(),
status: z.enum([ 'up', 'down' ]), status: z.enum([ 'up', 'down' ]),

View File

@ -2,7 +2,7 @@ import { promises as fs } from "fs";
import path from "path"; import path from "path";
import { WG_PATH } from "@lib/constants"; import { WG_PATH } from "@lib/constants";
import Shell from "@lib/shell"; 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 { client, WG_SEVER_PATH } from "@lib/redis";
import { dynaJoin, isJson } from "@lib/utils"; import { dynaJoin, isJson } from "@lib/utils";
import deepmerge from "deepmerge"; import deepmerge from "deepmerge";
@ -124,15 +124,15 @@ export class WGServer {
return true return true
} }
static async removePeer(id: string, publicKey: string): Promise<boolean> { static async removePeer(serverId: string, publicKey: string): Promise<boolean> {
const server = await findServer(id) const server = await findServer(serverId)
if (!server) { if (!server) {
console.error('server could not be updated (reason: not exists)') console.error('server could not be updated (reason: not exists)')
return false return false
} }
const peers = await wgPeersStr(server.confId) const peers = await wgPeersStr(server.confId)
const index = await findServerIndex(id) const index = await findServerIndex(serverId)
if (typeof index !== 'number') { if (typeof index !== 'number') {
console.warn('findServerIndex: index not found') console.warn('findServerIndex: index not found')
return true return true
@ -162,6 +162,60 @@ export class WGServer {
return true return true
} }
static async updatePeer(serverId: string, publicKey: string, update: Partial<Peer>): Promise<boolean> {
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<number | undefined> {
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<WgServer, 'id' | 'confId'>, publicKey: string, peers: Peer[]): Promise<void> {
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<string | undefined> { static async getFreePeerIp(id: string): Promise<string | undefined> {
const server = await findServer(id) const server = await findServer(id)
if (!server) { if (!server) {
@ -388,7 +442,7 @@ export async function generateWgServer(config: {
throw new Error(`Address ${config.address} is already reserved!`) 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!`) throw new Error(`Port ${config.port} is already reserved!`)
} }

View File

@ -4,7 +4,7 @@ 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, WgServer } from "@lib/typings"; import { APIResponse, Peer, WgServer } from "@lib/typings";
import useSWRMutation from "swr/mutation"; import useSWRMutation from "swr/mutation";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import { MiddleEllipsis } from "@ui/MiddleEllipsis"; import { MiddleEllipsis } from "@ui/MiddleEllipsis";
@ -14,6 +14,9 @@ import CreateClientModal from "@ui/Modal/CreateClientModal";
import { twMerge } from "tailwind-merge"; import { twMerge } from "tailwind-merge";
import QRCodeModal from "@ui/Modal/QRCodeModal"; import QRCodeModal from "@ui/Modal/QRCodeModal";
import { getPeerConf } from "@lib/wireguard-utils"; 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) { 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<WgServer, 'dns'> { interface ClientProps extends Peer, Pick<WgServer, 'dns'> {
serverId: string serverId: string
serverPublicKey: string serverPublicKey: string
@ -237,10 +238,13 @@ function Client(props: ClientProps) {
dns: props.dns, dns: props.dns,
}) })
.then((s) => setConf(s)) .then((s) => setConf(s))
console.log('conf', conf)
}, [ props ]) }, [ props ])
const RefreshOptions = {
onSuccess: () => props.refreshTrigger(),
onError: () => props.refreshTrigger()
}
const { isMutating: removingClient, trigger: removeClient } = useSWRMutation( const { isMutating: removingClient, trigger: removeClient } = useSWRMutation(
`/api/wireguard/${props.serverId}/${props.id}`, `/api/wireguard/${props.serverId}/${props.id}`,
async (url: string,) => { async (url: string,) => {
@ -252,10 +256,13 @@ function Client(props: ClientProps) {
if (!data.ok) throw new Error('Server responded with error status') if (!data.ok) throw new Error('Server responded with error status')
return true return true
}, },
{ RefreshOptions
onSuccess: () => props.refreshTrigger(), )
onError: () => props.refreshTrigger()
} const { isMutating, trigger } = useSWRMutation(
`/api/wireguard/${props.serverId}/${props.id}`,
UPDATE_CLIENT,
RefreshOptions
) )
return ( return (
@ -268,7 +275,14 @@ function Client(props: ClientProps) {
</div> </div>
<div className={'col-span-12 md:col-span-4'}> <div className={'col-span-12 md:col-span-4'}>
<div className={'col-span-12 md:col-span-4'}> <div className={'col-span-12 md:col-span-4'}>
<span className={'inline-block font-medium'}> {props.name} </span> <EditableText
disabled={isMutating}
rules={RLS_NAME_INPUT}
rootClassName={'font-medium col-span-4'}
inputClassName={'w-20'}
content={props.name}
onChange={(v) => trigger({ name: v })}
/>
</div> </div>
<div className={'col-span-12 md:col-span-4'}> <div className={'col-span-12 md:col-span-4'}>
<span className={'font-mono text-gray-400 text-xs'}> {props.allowedIps} </span> <span className={'font-mono text-gray-400 text-xs'}> {props.allowedIps} </span>

View File

@ -68,7 +68,7 @@ async function update(server: WgServer, peer: Peer, req: NextApiRequest, res: Ne
const { name } = req.body as z.infer<typeof PutRequestSchema> const { name } = req.body as z.infer<typeof PutRequestSchema>
if (name) { if (name) {
await WGServer.update(server.id, { name }) await WGServer.updatePeer(server.id, peer.publicKey, { name })
} }
return res return res

View File

@ -56,7 +56,7 @@ async function update(server: WgServer, req: NextApiRequest, res: NextApiRespons
return zodErrorToResponse(res, parsed.error) return zodErrorToResponse(res, parsed.error)
} }
const { status } = req.body as z.infer<typeof PutRequestSchema> const { status, name } = req.body as z.infer<typeof PutRequestSchema>
switch (status) { switch (status) {
case 'start': case 'start':
@ -71,6 +71,10 @@ async function update(server: WgServer, req: NextApiRequest, res: NextApiRespons
break; break;
} }
if (name) {
await WGServer.update(server.id, { name })
}
return res return res
.status(200) .status(200)
.json({ ok: true }) .json({ ok: true })

View File

@ -11,6 +11,10 @@ import { SmartModalRef } from "@ui/Modal/SmartModal";
import { twMerge } from "tailwind-merge"; import { twMerge } from "tailwind-merge";
import CreateServerModal from "@ui/Modal/CreateServerModal"; import CreateServerModal from "@ui/Modal/CreateServerModal";
import StatusBadge from "@ui/StatusBadge"; 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() { export default function Home() {
const { data, error, isLoading, mutate } = useSWR( const { data, error, isLoading, mutate } = useSWR(
@ -18,11 +22,9 @@ export default function Home() {
async (url: string) => { async (url: string) => {
const resp = await fetch(url, { const resp = await fetch(url, {
method: 'GET', method: 'GET',
headers: { headers: { 'Content-Type': 'application/json' }
'Content-Type': 'application/json'
}
}) })
const data = await resp.json() as APIResponse<any> const data = await resp.json() as APIResponse<WgServer[]>
if (!data.ok) throw new Error('Server responded with error status') if (!data.ok) throw new Error('Server responded with error status')
return data.result return data.result
} }
@ -48,13 +50,19 @@ export default function Home() {
<Card className={'flex items-center justify-center p-4'}> <Card className={'flex items-center justify-center p-4'}>
Loading... Loading...
</Card> </Card>
) : data.length > 0 ? ( ) : Array.isArray(data) && data.length > 0 ? (
<Card <Card
className={'[&>.ant-card-body]:p-0'} className={'[&>.ant-card-body]:p-0'}
title={<span> Servers </span>} title={<span> Servers </span>}
> >
<List> <List>
{data.map((s) => <Server key={s.id} {...s} />)} {data.map((s) => (
<ServerListItem
{...s}
key={s.id}
refreshTrigger={() => mutate()}
/>
))}
</List> </List>
</Card> </Card>
) : ( ) : (
@ -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 ( return (
<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={props.type} className={'col-span-1'} />
<span className={'font-medium col-span-4'}> {s.name} </span> <EditableText
disabled={isMutating}
rules={RLS_NAME_INPUT}
rootClassName={'font-medium col-span-4'}
inputClassName={'w-20'}
content={props.name}
onChange={(v) => trigger({ name: v })}
/>
<div className={'col-span-4 justify-end'}> <div className={'col-span-4 justify-end'}>
<StatusBadge status={s.status} /> <StatusBadge status={props.status} />
</div> </div>
</div> </div>
<Link href={`/${s.id}`}> <Link href={`/${props.id}`}>
<Button type={'primary'}> <Button type={'primary'}>
Manage Manage
</Button> </Button>

86
src/ui/EditableText.tsx Normal file
View File

@ -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<ReactHTMLProps<HTMLSpanElement>, '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<InputRef | null>(null)
const [ val, setVal ] = React.useState(content)
React.useEffect(() => {
const { input } = inputRef.current || {}
if (input) {
input.value = val || ''
}
}, [ val ])
const [ form ] = Form.useForm()
return (
<div className={twMerge('group', rootClassName)}>
<span {...rest} className={twMerge(
editMode ? 'hidden' : 'flex items-center gap-x-2',
'leading-none'
)}>
{val}
<i
className={'fal fa-pen-to-square text-sm opacity-0 group-hover:opacity-100 text-neutral-400 hover:text-primary cursor-pointer'}
onClick={() => setEditMode(true)}
/>
</span>
<Form
rootClassName={twMerge(editMode ? 'block' : 'hidden')}
form={form}
onFinish={() => {
setEditMode(false)
const newVal = inputRef.current?.input?.value || ''
onChange && onChange(newVal)
setVal(newVal)
}}
>
<Form.Item
name={'input'}
rules={rules}
rootClassName={'m-0'}
className={'[&>.ant-row>.ant-col>div>.ant-form-item-explain]:hidden'}
>
<Input
disabled={disabled}
size={inputSize || 'small'}
ref={inputRef}
style={{ width: inputWidth }}
defaultValue={val}
className={inputClassName}
onKeyDown={async (evt) => {
if (evt.key === 'Enter') {
form.submit()
}
}}
/>
</Form.Item>
</Form>
</div>
)
}

View File

@ -6,6 +6,7 @@ import { APIResponse } from "@lib/typings";
import useSWRMutation from "swr/mutation"; import useSWRMutation from "swr/mutation";
import { NameSchema } from "@lib/schemas/WireGuard"; import { NameSchema } from "@lib/schemas/WireGuard";
import { zodErrorMessage } from "@lib/zod"; import { zodErrorMessage } from "@lib/zod";
import { RLS_NAME_INPUT } from "@lib/form-rules";
type CreateClientModalProps = { type CreateClientModalProps = {
@ -89,20 +90,7 @@ const CreateClientModal = React.forwardRef<
<h4 className={'mb-6'}> Create Client </h4> <h4 className={'mb-6'}> Create Client </h4>
<Form form={form} onFinish={onFinish}> <Form form={form} onFinish={onFinish}>
<Form.Item name={'name'} label={'Name'} rules={[ <Form.Item name={'name'} label={'Name'} rules={RLS_NAME_INPUT}>
{
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 🦄'} /> <Input placeholder={'Unicorn 🦄'} />
</Form.Item> </Form.Item>