mirror of
https://github.com/wireadmin/wireadmin
synced 2025-03-09 13:20:39 +00:00
add utilities for working with WireGuard
and Shell
This commit is contained in:
parent
5095de22f8
commit
5d4a2c3d51
3
web/src/lib/constants.ts
Normal file
3
web/src/lib/constants.ts
Normal 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}$/);
|
32
web/src/lib/file-manager.ts
Normal file
32
web/src/lib/file-manager.ts
Normal 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
35
web/src/lib/network.ts
Normal 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
9
web/src/lib/redis.ts
Normal 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`;
|
8
web/src/lib/server-error.ts
Normal file
8
web/src/lib/server-error.ts
Normal 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
22
web/src/lib/shell.ts
Normal 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
77
web/src/lib/typings.ts
Normal 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];
|
@ -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));
|
||||
|
625
web/src/lib/wireguard/index.ts
Normal file
625
web/src/lib/wireguard/index.ts
Normal 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');
|
||||
}
|
65
web/src/lib/wireguard/schema.ts
Normal file
65
web/src/lib/wireguard/schema.ts
Normal 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' };
|
||||
}
|
||||
},
|
||||
});
|
33
web/src/lib/wireguard/utils.ts
Normal file
33
web/src/lib/wireguard/utils.ts
Normal 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
19
web/src/lib/zod.ts
Normal 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
11
web/src/routes/schema.ts
Normal 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,
|
||||
});
|
Loading…
Reference in New Issue
Block a user