mirror of
https://github.com/wireadmin/wireadmin
synced 2025-03-09 13:20:39 +00:00
Adds an ability to update Server
or Client
name
This commit is contained in:
parent
de5f35eec5
commit
379d715ecd
18
src/lib/form-rules.ts
Normal file
18
src/lib/form-rules.ts
Normal 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
42
src/lib/swr-fetch.ts
Normal 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
|
||||
}
|
@ -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' ]),
|
||||
|
@ -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!`)
|
||||
}
|
||||
|
||||
|
@ -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>
|
||||
|
@ -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
|
||||
|
@ -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 })
|
||||
|
@ -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
86
src/ui/EditableText.tsx
Normal 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>
|
||||
)
|
||||
}
|
@ -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>
|
||||
|
||||
|
Loading…
Reference in New Issue
Block a user