add utilities for working with WireGuard and Shell

This commit is contained in:
Shahrad Elahi 2023-11-05 19:56:44 +03:30
parent 5095de22f8
commit 5d4a2c3d51
13 changed files with 984 additions and 0 deletions

3
web/src/lib/constants.ts Normal file
View File

@ -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}$/);

View File

@ -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' });
}
}

35
web/src/lib/network.ts Normal file
View File

@ -0,0 +1,35 @@
import Shell from '$lib/shell';
export default class Network {
public static async createInterface(inet: string, address: string): Promise<boolean> {
// 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<string> {
return await Shell.exec(`ip route list default | awk '{print $5}'`);
}
public static async checkInterfaceExists(inet: string): Promise<boolean> {
return await Shell.exec(`ip link show | grep ${inet}`, true).then((o) => o.trim() !== '');
}
}

9
web/src/lib/redis.ts Normal file
View File

@ -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`;

View File

@ -0,0 +1,8 @@
export default class ServerError extends Error {
statusCode;
constructor(message: string, statusCode: number = 500) {
super(message);
this.statusCode = statusCode;
}
}

22
web/src/lib/shell.ts Normal file
View File

@ -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<string> {
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());
});
});
}
}

77
web/src/lib/typings.ts Normal file
View File

@ -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<typeof WgKeySchema>;
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<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(),
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<typeof WgServerSchema>;
export type APIErrorResponse = {
ok: false;
details: string;
};
export type APISuccessResponse<D> = {
ok: true;
result: D;
};
export type LeastOne<T, U = { [K in keyof T]: Pick<T, K> }> = Partial<T> & U[keyof U];

View File

@ -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));

View File

@ -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<boolean> {
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<boolean> {
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<boolean> {
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<WgServer>): Promise<boolean> {
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<string | undefined> {
const server = await getServers();
return server.find((s) => s.confId === confId)?.id;
}
static async addPeer(id: string, peer: WgServer['peers'][0]): Promise<boolean> {
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<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(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<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 = 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<string | undefined> {
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<string | undefined> {
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<boolean> {
const res = await Shell.exec(`ip link show | grep wg${configId}`, true);
return res.includes(`wg${configId}`);
}
export async function readWgConf(configId: number): Promise<WgServer> {
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<boolean> {
// 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<WgKey> {
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<string> {
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<string | undefined> {
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<void> {
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<WgServer[]> {
return (await client.lrange(WG_SEVER_PATH, 0, -1)).map((s) => JSON.parse(s));
}
export async function findServerIndex(id: string): Promise<number | undefined> {
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<WgServer | undefined> {
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<string> {
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');
}

View File

@ -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' };
}
},
});

View File

@ -0,0 +1,33 @@
import type { WgServer } from '$lib/typings';
type Peer = WgServer['peers'][0];
interface GenPeerConParams extends Peer, Pick<WgServer, 'dns'> {
serverAddress?: string;
serverPublicKey: string;
port: number;
}
export async function getServerIP(): Promise<string> {
const resp = await fetch('/api/host');
return resp.text();
}
export async function getPeerConf(params: GenPeerConParams): Promise<string> {
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');
}

19
web/src/lib/zod.ts Normal file
View File

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

11
web/src/routes/schema.ts Normal file
View File

@ -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,
});