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 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({
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' ]),

View File

@ -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<boolean> {
const server = await findServer(id)
static async removePeer(serverId: string, publicKey: string): Promise<boolean> {
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<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> {
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!`)
}

View File

@ -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<WgServer, 'dns'> {
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) {
</div>
<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 className={'col-span-12 md:col-span-4'}>
<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>
if (name) {
await WGServer.update(server.id, { name })
await WGServer.updatePeer(server.id, peer.publicKey, { name })
}
return res

View File

@ -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<typeof PutRequestSchema>
const { status, name } = req.body as z.infer<typeof PutRequestSchema>
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 })

View File

@ -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<any>
const data = await resp.json() as APIResponse<WgServer[]>
if (!data.ok) throw new Error('Server responded with error status')
return data.result
}
@ -48,13 +50,19 @@ export default function Home() {
<Card className={'flex items-center justify-center p-4'}>
Loading...
</Card>
) : data.length > 0 ? (
) : Array.isArray(data) && data.length > 0 ? (
<Card
className={'[&>.ant-card-body]:p-0'}
title={<span> Servers </span>}
>
<List>
{data.map((s) => <Server key={s.id} {...s} />)}
{data.map((s) => (
<ServerListItem
{...s}
key={s.id}
refreshTrigger={() => mutate()}
/>
))}
</List>
</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 (
<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'} />
<span className={'font-medium col-span-4'}> {s.name} </span>
<ServerIcon type={props.type} className={'col-span-1'} />
<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'}>
<StatusBadge status={s.status} />
<StatusBadge status={props.status} />
</div>
</div>
<Link href={`/${s.id}`}>
<Link href={`/${props.id}`}>
<Button type={'primary'}>
Manage
</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 { 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<
<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])
}
}
]}>
<Form.Item name={'name'} label={'Name'} rules={RLS_NAME_INPUT}>
<Input placeholder={'Unicorn 🦄'} />
</Form.Item>