diff --git a/web/src/lib/constants.ts b/web/src/lib/constants.ts new file mode 100644 index 0000000..315ffe8 --- /dev/null +++ b/web/src/lib/constants.ts @@ -0,0 +1,3 @@ +export const WG_PATH = '/etc/wireguard'; + +export const IPV4_REGEX = new RegExp(/^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$/); diff --git a/web/src/lib/file-manager.ts b/web/src/lib/file-manager.ts new file mode 100644 index 0000000..ba74721 --- /dev/null +++ b/web/src/lib/file-manager.ts @@ -0,0 +1,32 @@ +import fs from 'fs'; +import path from 'path'; + +export default class FileManager { + static readDirectoryFiles(dir: string): string[] { + const files_: string[] = []; + const files = fs.readdirSync(dir); + for (const i in files) { + const name = dir + '/' + files[i]; + if (!fs.statSync(name).isDirectory()) { + files_.push(path.resolve(process.cwd(), name)); + } + } + return files_; + } + + static readFile(filePath: string): string { + if (!fs.existsSync(filePath)) { + throw new Error('file not found'); + } + return fs.readFileSync(filePath, { encoding: 'utf8' }); + } + + static writeFile(filePath: string, content: string, forced: boolean = false): void { + const dir_ = filePath.split('/'); + const dir = dir_.slice(0, dir_.length - 1).join('/'); + if (!fs.existsSync(dir) && forced) { + fs.mkdirSync(dir, { mode: 0o744 }); + } + fs.writeFileSync(filePath, content, { encoding: 'utf8' }); + } +} diff --git a/web/src/lib/network.ts b/web/src/lib/network.ts new file mode 100644 index 0000000..0627e19 --- /dev/null +++ b/web/src/lib/network.ts @@ -0,0 +1,35 @@ +import Shell from '$lib/shell'; + +export default class Network { + public static async createInterface(inet: string, address: string): Promise { + // First, check if the interface already exists. + const interfaces = await Shell.exec(`ip link show | grep ${inet}`, true); + if (interfaces.includes(`${inet}`)) { + console.error(`failed to create interface, ${inet} already exists!`); + return false; + } + + const o2 = await Shell.exec(`ip address add dev ${inet} ${address}`); + // check if it has any error + if (o2 !== '') { + console.error(`failed to assign ip to interface, ${o2}`); + console.log(`removing interface ${inet} due to errors`); + await Shell.exec(`ip link delete dev ${inet}`, true); + return false; + } + + return true; + } + + public static async dropInterface(inet: string) { + await Shell.exec(`ip link delete dev ${inet}`, true); + } + + public static async defaultInterface(): Promise { + return await Shell.exec(`ip route list default | awk '{print $5}'`); + } + + public static async checkInterfaceExists(inet: string): Promise { + return await Shell.exec(`ip link show | grep ${inet}`, true).then((o) => o.trim() !== ''); + } +} diff --git a/web/src/lib/redis.ts b/web/src/lib/redis.ts new file mode 100644 index 0000000..08b7c3a --- /dev/null +++ b/web/src/lib/redis.ts @@ -0,0 +1,9 @@ +import IORedis from 'ioredis'; + +export const client = new IORedis({ + port: 6479, +}); + +export type RedisClient = typeof client; + +export const WG_SEVER_PATH = `WG::SERVERS`; diff --git a/web/src/lib/server-error.ts b/web/src/lib/server-error.ts new file mode 100644 index 0000000..9837774 --- /dev/null +++ b/web/src/lib/server-error.ts @@ -0,0 +1,8 @@ +export default class ServerError extends Error { + statusCode; + + constructor(message: string, statusCode: number = 500) { + super(message); + this.statusCode = statusCode; + } +} diff --git a/web/src/lib/shell.ts b/web/src/lib/shell.ts new file mode 100644 index 0000000..9bc4f77 --- /dev/null +++ b/web/src/lib/shell.ts @@ -0,0 +1,22 @@ +import childProcess from 'child_process'; + +export default class Shell { + public static async exec(command: string, safe: boolean = false, ...args: string[]): Promise { + if (process.platform !== 'linux') { + throw new Error('This program is not meant to run non UNIX systems'); + } + return new Promise(async (resolve, reject) => { + const cmd = `${command}${args.length > 0 ? ` ${args.join(' ')}` : ''}`; + childProcess.exec(cmd, { shell: 'bash' }, (err, stdout, stderr) => { + if (err) { + console.error( + `${safe ? 'Ignored::' : 'CRITICAL::'} Shell Command Failed:`, + JSON.stringify({ cmd, code: err.code, killed: err.killed, stderr }), + ); + return safe ? resolve(stderr) : reject(err); + } + return resolve(String(stdout).trim()); + }); + }); + } +} diff --git a/web/src/lib/typings.ts b/web/src/lib/typings.ts new file mode 100644 index 0000000..b78d3f0 --- /dev/null +++ b/web/src/lib/typings.ts @@ -0,0 +1,77 @@ +import { z } from 'zod'; +import { IPV4_REGEX } from '$lib/constants'; +import { NameSchema } from '$lib/wireguard/schema'; + +export const WgKeySchema = z.object({ + privateKey: z.string(), + publicKey: z.string(), + preSharedKey: z.string(), +}); + +export type WgKey = z.infer; + +const WgPeerSchema = z + .object({ + id: z.string().uuid(), + name: z.string().regex(/^[A-Za-z\d\s]{3,32}$/), + preSharedKey: z.string(), + endpoint: z.string(), + address: z.string().regex(IPV4_REGEX), + latestHandshakeAt: z.string().nullable(), + transferRx: z.number().nullable(), + transferTx: z.number().nullable(), + persistentKeepalive: z.number().nullable(), + createdAt: z.string().datetime(), + updatedAt: z.string().datetime(), + enabled: z.boolean(), + }) + .merge(WgKeySchema); + +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(), + confHash: z.string().nullable(), + type: z.enum(['direct', 'bridge', 'tor']), + name: NameSchema, + address: z.string().regex(IPV4_REGEX), + listen: z.number(), + preUp: z.string().nullable(), + postUp: z.string().nullable(), + preDown: z.string().nullable(), + postDown: z.string().nullable(), + dns: z.string().regex(IPV4_REGEX).nullable(), + peers: z.array(PeerSchema), + createdAt: z.string().datetime(), + updatedAt: z.string().datetime(), + status: z.enum(['up', 'down']), + }) + .merge(WgKeySchema.omit({ preSharedKey: true })); + +export type WgServer = z.infer; + +export type APIErrorResponse = { + ok: false; + details: string; +}; + +export type APISuccessResponse = { + ok: true; + result: D; +}; + +export type LeastOne }> = Partial & U[keyof U]; diff --git a/web/src/lib/utils.ts b/web/src/lib/utils.ts index fde9a7a..9b805ff 100644 --- a/web/src/lib/utils.ts +++ b/web/src/lib/utils.ts @@ -2,6 +2,51 @@ import { type ClassValue, clsx } from 'clsx'; import { twMerge } from 'tailwind-merge'; import { cubicOut } from 'svelte/easing'; import type { TransitionConfig } from 'svelte/transition'; +import { IPV4_REGEX } from '$lib/constants'; + +export function isValidIPv4(str: string): boolean { + return IPV4_REGEX.test(str); +} + +export function isBetween(v: any, n1: number, n2: number): boolean { + if (Number.isNaN(v)) { + return false; + } + const n = Number(v); + return n1 <= n && n >= n2; +} + +export function isJson(str: string | object): boolean { + if (typeof str === 'object' && isObject(str)) { + return true; + } + try { + return typeof str === 'string' && JSON.parse(str); + } catch (ex) { + return false; + } +} + +export function isObject(obj: 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); +} + +export function dynaJoin(lines: (string | 0 | null | undefined)[]): string[] { + return lines.filter((d) => typeof d === 'string') as string[]; +} export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)); diff --git a/web/src/lib/wireguard/index.ts b/web/src/lib/wireguard/index.ts new file mode 100644 index 0000000..a646c4a --- /dev/null +++ b/web/src/lib/wireguard/index.ts @@ -0,0 +1,625 @@ +import fs from 'fs'; +import path from 'path'; +import deepmerge from 'deepmerge'; +import { enc, SHA256 } from 'crypto-js'; +import type { Peer, WgKey, WgServer } from '$lib/typings'; +import Network from '$lib/network'; +import Shell from '$lib/shell'; +import { WG_PATH } from '$lib/constants'; +import { client, WG_SEVER_PATH } from '$lib/redis'; +import { dynaJoin, isJson } from '$lib/utils'; +import { getPeerConf } from '$lib/wireguard/utils'; + +export class WGServer { + static async stop(id: string): Promise { + const server = await findServer(id); + if (!server) { + console.error('server could not be updated (reason: not exists)'); + return false; + } + + if (await Network.checkInterfaceExists(`wg${server.confId}`)) { + await Shell.exec(`wg-quick down wg${server.confId}`, true); + } + + await this.update(id, { status: 'down' }); + return true; + } + + static async start(id: string): Promise { + const server = await findServer(id); + if (!server) { + console.error('server could not be updated (reason: not exists)'); + return false; + } + + const HASH = await getConfigHash(server.confId); + if (!HASH || server.confHash !== HASH) { + await writeConfigFile(server); + await WGServer.update(id, { confHash: await getConfigHash(server.confId) }); + } + + if (await Network.checkInterfaceExists(`wg${server.confId}`)) { + await Shell.exec(`wg-quick down wg${server.confId}`, true); + } + + await Shell.exec(`wg-quick up wg${server.confId}`); + + await this.update(id, { status: 'up' }); + return true; + } + + static async remove(id: string): Promise { + const server = await findServer(id); + if (!server) { + console.error('server could not be updated (reason: not exists)'); + return false; + } + + await this.stop(id); + fs.unlinkSync(path.join(WG_PATH, `wg${server.confId}.conf`)); + + const index = await findServerIndex(id); + if (typeof index !== 'number') { + console.warn('findServerIndex: index not found'); + return true; + } + + const element = await client.lindex(WG_SEVER_PATH, index); + if (!element) { + console.warn('remove: element not found'); + return true; + } + + await client.lrem(WG_SEVER_PATH, 1, element); + + return true; + } + + static async update(id: string, update: Partial): Promise { + const server = await findServer(id); + if (!server) { + console.error('server could not be updated (reason: not exists)'); + return false; + } + const index = await findServerIndex(id); + if (typeof index !== 'number') { + console.warn('findServerIndex: index not found'); + return true; + } + const res = await client.lset( + WG_SEVER_PATH, + index, + JSON.stringify({ + ...deepmerge(server, update), + peers: update?.peers || server?.peers || [], + updatedAt: new Date().toISOString(), + }), + ); + return res === 'OK'; + } + + static async findAttachedUuid(confId: number): Promise { + const server = await getServers(); + return server.find((s) => s.confId === confId)?.id; + } + + static async addPeer(id: string, peer: WgServer['peers'][0]): Promise { + const server = await findServer(id); + if (!server) { + console.error('server could not be updated (reason: not exists)'); + return false; + } + + const confPath = path.join(WG_PATH, `wg${server.confId}.conf`); + const conf = fs.readFileSync(confPath, 'utf-8'); + const lines = conf.split('\n'); + + lines.push( + ...dynaJoin([ + `[Peer]`, + `PublicKey = ${peer.publicKey}`, + peer.preSharedKey && `PresharedKey = ${peer.preSharedKey}`, + `AllowedIPs = ${peer.allowedIps}/32`, + peer.persistentKeepalive && `PersistentKeepalive = ${peer.persistentKeepalive}`, + ]), + ); + fs.writeFileSync(confPath, lines.join('\n'), { mode: 0o600 }); + await WGServer.update(id, { confHash: await getConfigHash(server.confId) }); + + const index = await findServerIndex(id); + if (typeof index !== 'number') { + console.warn('findServerIndex: index not found'); + return true; + } + await client.lset( + WG_SEVER_PATH, + index, + JSON.stringify({ + ...server, + peers: [...server.peers, peer], + }), + ); + + await this.stop(server.id); + await this.start(server.id); + return true; + } + + 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(serverId); + if (typeof index !== 'number') { + console.warn('findServerIndex: index not found'); + return true; + } + await client.lset( + WG_SEVER_PATH, + index, + JSON.stringify({ + ...server, + peers: server.peers.filter((p) => p.publicKey !== publicKey), + }), + ); + + const peerIndex = peers.findIndex((p) => p.includes(`PublicKey = ${publicKey}`)); + if (peerIndex === -1) { + console.warn('removePeer: no peer found'); + return false; + } + + const confPath = path.join(WG_PATH, `wg${server.confId}.conf`); + const conf = fs.readFileSync(confPath, 'utf-8'); + const serverConfStr = conf.includes('[Peer]') ? conf.split('[Peer]')[0] : conf; + const peersStr = peers.filter((_, i) => i !== peerIndex).join('\n'); + fs.writeFileSync(confPath, `${serverConfStr}\n${peersStr}`, { mode: 0o600 }); + await WGServer.update(server.id, { confHash: await getConfigHash(server.confId) }); + + await WGServer.stop(server.id); + await WGServer.start(server.id); + + 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 = fs.readFileSync(confPath, 'utf-8'); + const serverConfStr = conf.includes('[Peer]') ? conf.split('[Peer]')[0] : conf; + + const peersStr = peers.filter((_, i) => i !== peerIndex).join('\n'); + fs.writeFileSync(confPath, `${serverConfStr}\n${peersStr}`, { mode: 0o600 }); + await WGServer.update(sd.id, { confHash: await getConfigHash(sd.confId) }); + } + + static async getFreePeerIp(id: string): Promise { + const server = await findServer(id); + if (!server) { + console.error('getFreePeerIp: server not found'); + return undefined; + } + const reservedIps = server.peers.map((p) => p.allowedIps); + const ips = reservedIps.map((ip) => ip.split('/')[0]); + const net = server.address.split('/')[0].split('.'); + for (let i = 1; i < 255; i++) { + const ip = `${net[0]}.${net[1]}.${net[2]}.${i}`; + if (!ips.includes(ip) && ip !== server.address.split('/')[0]) { + return ip; + } + } + console.error('getFreePeerIp: no free ip found'); + return undefined; + } + + static async generatePeerConfig(id: string, peerId: string): Promise { + const server = await findServer(id); + if (!server) { + console.error('generatePeerConfig: server not found'); + return undefined; + } + const peer = server.peers.find((p) => p.id === peerId); + if (!peer) { + console.error('generatePeerConfig: peer not found'); + return undefined; + } + return await getPeerConf({ + ...peer, + serverPublicKey: server.publicKey, + port: server.listen, + dns: server.dns, + }); + } +} + +/** + * This function is for checking out WireGuard server is running + */ +async function wgCheckout(configId: number): Promise { + const res = await Shell.exec(`ip link show | grep wg${configId}`, true); + return res.includes(`wg${configId}`); +} + +export async function readWgConf(configId: number): Promise { + const confPath = path.join(WG_PATH, `wg${configId}.conf`); + const conf = fs.readFileSync(confPath, 'utf-8'); + const lines = conf.split('\n'); + const server: WgServer = { + id: crypto.randomUUID(), + confId: configId, + confHash: null, + type: 'direct', + name: '', + address: '', + listen: 0, + dns: null, + privateKey: '', + publicKey: '', + preUp: null, + preDown: null, + postDown: null, + postUp: null, + peers: [], + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + status: 'down', + }; + let reachedPeers = false; + for (const line of lines) { + const [key, value] = line.split('=').map((s) => s.trim()); + if (reachedPeers) { + if (key === '[Peer]') { + server.peers.push({ + id: crypto.randomUUID(), + name: `Unknown #${server.peers.length + 1}`, + publicKey: '', + privateKey: '', // it's okay to be empty because, we not using it on server + preSharedKey: '', + allowedIps: '', + persistentKeepalive: null, + }); + } + if (key === 'PublicKey') { + server.peers[server.peers.length - 1].publicKey = value; + } + if (key === 'PresharedKey') { + server.peers[server.peers.length - 1].preSharedKey = value; + } + if (key === 'AllowedIPs') { + server.peers[server.peers.length - 1].allowedIps = value; + } + if (key === 'PersistentKeepalive') { + server.peers[server.peers.length - 1].persistentKeepalive = parseInt(value); + } + } + if (key === 'PrivateKey') { + server.privateKey = value; + } + if (key === 'Address') { + server.address = value; + } + if (key === 'ListenPort') { + server.listen = parseInt(value); + } + if (key === 'DNS') { + server.dns = value; + } + if (key === 'PreUp') { + server.preUp = value; + } + if (key === 'PreDown') { + server.preDown = value; + } + if (key === 'PostUp') { + server.postUp = value; + } + if (key === 'PostDown') { + server.postDown = value; + } + if (key === 'PublicKey') { + server.publicKey = value; + } + if (key === '[Peer]') { + reachedPeers = true; + } + } + server.status = (await wgCheckout(configId)) ? 'up' : 'down'; + return server; +} + +/** + * This function checks if a WireGuard config exists in file system + * @param configId + */ +function wgConfExists(configId: number): boolean { + const confPath = path.join(WG_PATH, `wg${configId}.conf`); + try { + fs.accessSync(confPath); + return true; + } catch (e) { + return false; + } +} + +/** + * Used to read /etc/wireguard/*.conf and sync them with our + * redis server. + */ +async function syncServers(): Promise { + // get files in /etc/wireguard + const files = fs.readdirSync(WG_PATH); + // filter files that start with wg and end with .conf + const reg = new RegExp(/^wg(\d+)\.conf$/); + const confs = files.filter((f) => reg.test(f)); + // read all confs + const servers = await Promise.all(confs.map((f) => readWgConf(parseInt(f.match(reg)![1])))); + // remove old servers + await client.del(WG_SEVER_PATH); + // save all servers to redis + await client.lpush(WG_SEVER_PATH, ...servers.map((s) => JSON.stringify(s))); + return true; +} + +function wgPeersStr(configId: number): string[] { + const confPath = path.join(WG_PATH, `wg${configId}.conf`); + const conf = fs.readFileSync(confPath, 'utf-8'); + const rawPeers = conf.split('[Peer]'); + return rawPeers.slice(1).map((p) => `[Peer]\n${p}`); +} + +export async function generateWgKey(): Promise { + const privateKey = await Shell.exec('wg genkey'); + const publicKey = await Shell.exec(`echo ${privateKey} | wg pubkey`); + const preSharedKey = await Shell.exec('wg genkey'); + return { privateKey, publicKey, preSharedKey }; +} + +export async function generateWgServer(config: { + name: string; + address: string; + type: WgServer['type']; + port: number; + dns?: string; + mtu?: number; + insertDb?: boolean; +}): Promise { + const { privateKey, publicKey } = await generateWgKey(); + + // inside redis create a config list + const confId = (await maxConfId()) + 1; + const uuid = crypto.randomUUID(); + + let server: WgServer = { + id: uuid, + confId, + confHash: null, + type: config.type, + name: config.name, + address: config.address, + listen: config.port, + dns: config.dns || null, + privateKey, + publicKey, + preUp: null, + preDown: null, + postDown: null, + postUp: null, + peers: [], + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + status: 'up', + }; + + // check if address or port are already reserved + const [addresses, ports] = (await getServers()).map((s) => [s.address, s.listen]); + + // check for the conflict + if (Array.isArray(addresses) && addresses.includes(config.address)) { + throw new Error(`Address ${config.address} is already reserved!`); + } + + if (Array.isArray(ports) && ports.includes(config.port)) { + throw new Error(`Port ${config.port} is already reserved!`); + } + + // setting iptables + const iptables = await makeWgIptables(server); + server.postUp = iptables.up; + server.postDown = iptables.down; + + // save server config + if (false !== config.insertDb) { + await client.lpush(WG_SEVER_PATH, JSON.stringify(server)); + } + + const CONFIG_PATH = path.join(WG_PATH, `wg${confId}.conf`); + + // save server config to disk + fs.writeFileSync(CONFIG_PATH, await genServerConf(server), { mode: 0o600 }); + + // updating hash of the config + await WGServer.update(uuid, { confHash: await getConfigHash(confId) }); + + // to ensure interface does not exists + await Shell.exec(`wg-quick down wg${confId}`, true); + + // restart WireGuard + await Shell.exec(`wg-quick up wg${confId}`); + + // return server id + return uuid; +} + +export async function getConfigHash(confId: number): Promise { + if (!(await wgConfExists(confId))) { + return undefined; + } + + const confPath = path.join(WG_PATH, `wg${confId}.conf`); + const conf = fs.readFileSync(confPath, 'utf-8'); + return enc.Hex.stringify(SHA256(conf)); +} + +export async function writeConfigFile(wg: WgServer): Promise { + const CONFIG_PATH = path.join(WG_PATH, `wg${wg.confId}.conf`); + fs.writeFileSync(CONFIG_PATH, await genServerConf(wg), { mode: 0o600 }); + await WGServer.update(wg.id, { confHash: await getConfigHash(wg.confId) }); +} + +export function maxConfId(): number { + // get files in /etc/wireguard + const files = fs.readdirSync(WG_PATH); + // filter files that start with wg and end with .conf + const reg = new RegExp(/^wg(\d+)\.conf$/); + const confs = files.filter((f) => reg.test(f)); + const ids = confs.map((f) => { + const m = f.match(reg); + if (m) { + return parseInt(m[1]); + } + return 0; + }); + return Math.max(0, ...ids); +} + +export async function getServers(): Promise { + return (await client.lrange(WG_SEVER_PATH, 0, -1)).map((s) => JSON.parse(s)); +} + +export async function findServerIndex(id: string): Promise { + 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 { + const servers = await getServers(); + return id + ? servers.find((s) => s.id === id) + : hash && isJson(hash) + ? servers.find((s) => JSON.stringify(s) === hash) + : undefined; +} + +export async function makeWgIptables(s: WgServer): Promise<{ up: string; down: string }> { + const inet = await Network.defaultInterface(); + const inet_address = await Shell.exec(`hostname -i | awk '{print $1}'`); + + const source = `${s.address}/24`; + const wg_inet = `wg${s.confId}`; + + if (s.type === 'direct') { + const up = dynaJoin([ + `iptables -t nat -A POSTROUTING -s ${source} -o ${inet} -j MASQUERADE`, + `iptables -A INPUT -p udp -m udp --dport ${s.listen} -j ACCEPT`, + `iptables -A INPUT -p tcp -m tcp --dport ${s.listen} -j ACCEPT`, + `iptables -A FORWARD -i ${wg_inet} -j ACCEPT`, + `iptables -A FORWARD -o ${wg_inet} -j ACCEPT`, + ]).join('; '); + return { up, down: up.replace(/ -A /g, ' -D ') }; + } + + if (s.type === 'tor') { + const up = dynaJoin([ + `iptables -A INPUT -m state --state ESTABLISHED -j ACCEPT`, + `iptables -A INPUT -i ${wg_inet} -s ${source} -m state --state NEW -j ACCEPT`, + `iptables -t nat -A PREROUTING -i ${wg_inet} -p udp -s ${source} --dport 53 -j DNAT --to-destination ${inet_address}:53530`, + `iptables -t nat -A PREROUTING -i ${wg_inet} -p tcp -s ${source} -j DNAT --to-destination ${inet_address}:9040`, + `iptables -t nat -A PREROUTING -i ${wg_inet} -p udp -s ${source} -j DNAT --to-destination ${inet_address}:9040`, + `iptables -t nat -A OUTPUT -o lo -j RETURN`, + `iptables -A OUTPUT -m conntrack --ctstate INVALID -j DROP`, + `iptables -A OUTPUT -m state --state INVALID -j DROP`, + `iptables -A OUTPUT ! -o lo ! -d 127.0.0.1 ! -s 127.0.0.1 -p tcp -m tcp --tcp-flags ACK,FIN ACK,FIN -j DROP`, + ]).join('; '); + return { up, down: up.replace(/-A/g, '-D') }; + } + + return { up: '', down: '' }; +} + +export async function genServerConf(server: WgServer): Promise { + const iptables = await makeWgIptables(server); + server.postUp = iptables.up; + server.postDown = iptables.down; + + const lines = [ + '# Autogenerated by WireGuard UI (WireAdmin)', + '[Interface]', + `PrivateKey = ${server.privateKey}`, + `Address = ${server.address}/24`, + `ListenPort = ${server.listen}`, + `${server.dns ? `DNS = ${server.dns}` : 'OMIT'}`, + '', + `${server.preUp ? `PreUp = ${server.preUp}` : 'OMIT'}`, + `${server.postUp ? `PostUp = ${server.postUp}` : 'OMIT'}`, + `${server.preDown ? `PreDown = ${server.preDown}` : 'OMIT'}`, + `${server.postDown ? `PostDown = ${server.postDown}` : 'OMIT'}`, + ]; + server.peers.forEach((peer, index) => { + lines.push(''); + lines.push(`## ${peer.name || `Peer #${index + 1}`}`); + lines.push('[Peer]'); + lines.push(`PublicKey = ${peer.publicKey}`); + lines.push(`${peer.preSharedKey ? `PresharedKey = ${peer.preSharedKey}` : 'OMIT'}`); + lines.push(`AllowedIPs = ${peer.allowedIps}/32`); + lines.push(`${peer.persistentKeepalive ? `PersistentKeepalive = ${peer.persistentKeepalive}` : 'OMIT'}`); + }); + + return lines.filter((l) => l !== 'OMIT').join('\n'); +} diff --git a/web/src/lib/wireguard/schema.ts b/web/src/lib/wireguard/schema.ts new file mode 100644 index 0000000..03270df --- /dev/null +++ b/web/src/lib/wireguard/schema.ts @@ -0,0 +1,65 @@ +import { z } from 'zod'; +import { isBetween, isPrivateIP } from '$lib/utils'; +import { IPV4_REGEX } from '$lib/constants'; + +export const NameSchema = z + .string() + .min(1, { message: 'Name must be at least 1 character' }) + .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() + .min(1, { message: 'Address cannot be empty' }) + .refine((v) => isPrivateIP(v), { + message: 'Address must be a private IP address', + }); + +export const PortSchema = z + .string() + .min(1, { message: 'Port cannot be empty' }) + .refine( + (v) => { + const port = parseInt(v); + return port > 0 && port < 65535; + }, + { + message: 'Port must be a valid port number', + }, + ); + +export const TypeSchema = z.enum(['direct', '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 ClientId = z.string().uuid({ message: 'Client 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' }; + } + }, +}); diff --git a/web/src/lib/wireguard/utils.ts b/web/src/lib/wireguard/utils.ts new file mode 100644 index 0000000..4137820 --- /dev/null +++ b/web/src/lib/wireguard/utils.ts @@ -0,0 +1,33 @@ +import type { WgServer } from '$lib/typings'; + +type Peer = WgServer['peers'][0]; + +interface GenPeerConParams extends Peer, Pick { + serverAddress?: string; + serverPublicKey: string; + port: number; +} + +export async function getServerIP(): Promise { + const resp = await fetch('/api/host'); + return resp.text(); +} + +export async function getPeerConf(params: GenPeerConParams): Promise { + const serverAddress = params.serverAddress || (await getServerIP()); + const lines = [ + '# Autogenerated by WireGuard UI (WireAdmin)', + '[Interface]', + `PrivateKey = ${params.privateKey}`, + `Address = ${params.allowedIps}/24`, + `${params.dns ? `DNS = ${params.dns}` : 'OMIT'}`, + '', + '[Peer]', + `PublicKey = ${params.serverPublicKey}`, + `${params.preSharedKey ? `PresharedKey = ${params.preSharedKey}` : 'OMIT'}`, + `AllowedIPs = 0.0.0.0/0, ::/0`, + `PersistentKeepalive = ${params.persistentKeepalive}`, + `Endpoint = ${serverAddress}:${params.port}`, + ]; + return lines.filter((l) => l !== 'OMIT').join('\n'); +} diff --git a/web/src/lib/zod.ts b/web/src/lib/zod.ts new file mode 100644 index 0000000..6d5adb9 --- /dev/null +++ b/web/src/lib/zod.ts @@ -0,0 +1,19 @@ +import type { 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); +} diff --git a/web/src/routes/schema.ts b/web/src/routes/schema.ts new file mode 100644 index 0000000..b8fd21b --- /dev/null +++ b/web/src/routes/schema.ts @@ -0,0 +1,11 @@ +import { z } from 'zod'; +import { AddressSchema, DnsSchema, MtuSchema, NameSchema, PortSchema, TypeSchema } from '$lib/wireguard/schema'; + +export const CreateServerSchema = z.object({ + name: NameSchema, + address: AddressSchema, + port: PortSchema, + type: TypeSchema, + dns: DnsSchema, + mtu: MtuSchema, +});