mirror of https://github.com/wg-easy/wg-easy
You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
590 lines
16 KiB
590 lines
16 KiB
import fs from 'node:fs/promises';
|
|
import path from 'path';
|
|
import debug_logger from 'debug';
|
|
import crypto from 'node:crypto';
|
|
import QRCode from 'qrcode';
|
|
import CRC32 from 'crc-32';
|
|
|
|
const debug = debug_logger('WireGuard');
|
|
|
|
type Server = {
|
|
privateKey: string;
|
|
publicKey: string;
|
|
address: string;
|
|
};
|
|
|
|
type Client = {
|
|
id: string;
|
|
name: string;
|
|
address: string;
|
|
privateKey: string;
|
|
publicKey: string;
|
|
preSharedKey: string;
|
|
createdAt: string;
|
|
updatedAt: string;
|
|
expireAt: string | null;
|
|
endpoint: string | null;
|
|
enabled: boolean;
|
|
allowedIPs?: never;
|
|
oneTimeLink: string | null;
|
|
oneTimeLinkExpiresAt: string | null;
|
|
};
|
|
|
|
type Config = {
|
|
server: Server;
|
|
clients: Record<string, Client>;
|
|
};
|
|
|
|
class WireGuard {
|
|
#configCache: Config | null = null;
|
|
async __buildConfig() {
|
|
if (!WG_HOST) {
|
|
throw new Error('WG_HOST Environment Variable Not Set!');
|
|
}
|
|
|
|
debug('Loading configuration...');
|
|
this.#configCache = null;
|
|
try {
|
|
const config = await fs.readFile(path.join(WG_PATH, 'wg0.json'), 'utf8');
|
|
const parsedConfig = JSON.parse(config);
|
|
debug('Configuration loaded.');
|
|
return parsedConfig as Config;
|
|
} catch {
|
|
const privateKey = await exec('wg genkey');
|
|
const publicKey = await exec(`echo ${privateKey} | wg pubkey`, {
|
|
log: 'echo ***hidden*** | wg pubkey',
|
|
});
|
|
const address = WG_DEFAULT_ADDRESS.replace('x', '1');
|
|
|
|
const config: Config = {
|
|
server: {
|
|
privateKey,
|
|
publicKey,
|
|
address,
|
|
},
|
|
clients: {},
|
|
};
|
|
debug('Configuration generated.');
|
|
return config;
|
|
}
|
|
}
|
|
|
|
async getConfig(): Promise<Config> {
|
|
if (this.#configCache !== null) {
|
|
return this.#configCache;
|
|
}
|
|
const config = await this.__buildConfig();
|
|
|
|
await this.__saveConfig(config);
|
|
await exec('wg-quick down wg0').catch(() => {});
|
|
await exec('wg-quick up wg0').catch((err) => {
|
|
if (
|
|
err &&
|
|
err.message &&
|
|
err.message.includes('Cannot find device "wg0"')
|
|
) {
|
|
throw new Error(
|
|
'WireGuard exited with the error: Cannot find device "wg0"\nThis usually means that your host\'s kernel does not support WireGuard!'
|
|
);
|
|
}
|
|
|
|
throw err;
|
|
});
|
|
// await Util.exec(`iptables -t nat -A POSTROUTING -s ${WG_DEFAULT_ADDRESS.replace('x', '0')}/24 -o ' + WG_DEVICE + ' -j MASQUERADE`);
|
|
// await Util.exec('iptables -A INPUT -p udp -m udp --dport 51820 -j ACCEPT');
|
|
// await Util.exec('iptables -A FORWARD -i wg0 -j ACCEPT');
|
|
// await Util.exec('iptables -A FORWARD -o wg0 -j ACCEPT');
|
|
await this.__syncConfig();
|
|
this.#configCache = config;
|
|
return this.#configCache;
|
|
}
|
|
|
|
async saveConfig() {
|
|
const config = await this.getConfig();
|
|
await this.__saveConfig(config);
|
|
await this.__syncConfig();
|
|
}
|
|
|
|
async __saveConfig(config: Config) {
|
|
let result = `
|
|
# Note: Do not edit this file directly.
|
|
# Your changes will be overwritten!
|
|
|
|
# Server
|
|
[Interface]
|
|
PrivateKey = ${config.server.privateKey}
|
|
Address = ${config.server.address}/24
|
|
ListenPort = ${WG_PORT}
|
|
PreUp = ${WG_PRE_UP}
|
|
PostUp = ${WG_POST_UP}
|
|
PreDown = ${WG_PRE_DOWN}
|
|
PostDown = ${WG_POST_DOWN}
|
|
`;
|
|
|
|
for (const [clientId, client] of Object.entries(config.clients)) {
|
|
if (!client.enabled) continue;
|
|
|
|
result += `
|
|
|
|
# Client: ${client.name} (${clientId})
|
|
[Peer]
|
|
PublicKey = ${client.publicKey}
|
|
${
|
|
client.preSharedKey ? `PresharedKey = ${client.preSharedKey}\n` : ''
|
|
}AllowedIPs = ${client.address}/32`;
|
|
}
|
|
|
|
debug('Config saving...');
|
|
await fs.writeFile(
|
|
path.join(WG_PATH, 'wg0.json'),
|
|
JSON.stringify(config, undefined, 2),
|
|
{
|
|
mode: 0o660,
|
|
}
|
|
);
|
|
await fs.writeFile(path.join(WG_PATH, 'wg0.conf'), result, {
|
|
mode: 0o600,
|
|
});
|
|
debug('Config saved.');
|
|
}
|
|
|
|
async __syncConfig() {
|
|
debug('Config syncing...');
|
|
await exec('wg syncconf wg0 <(wg-quick strip wg0)');
|
|
debug('Config synced.');
|
|
}
|
|
|
|
async getClients() {
|
|
const config = await this.getConfig();
|
|
const clients = Object.entries(config.clients).map(
|
|
([clientId, client]) => ({
|
|
id: clientId,
|
|
name: client.name,
|
|
enabled: client.enabled,
|
|
address: client.address,
|
|
publicKey: client.publicKey,
|
|
createdAt: new Date(client.createdAt),
|
|
updatedAt: new Date(client.updatedAt),
|
|
expireAt: client.expireAt !== null ? new Date(client.expireAt) : null,
|
|
allowedIPs: client.allowedIPs,
|
|
oneTimeLink: client.oneTimeLink ?? null,
|
|
oneTimeLinkExpiresAt: client.oneTimeLinkExpiresAt ?? null,
|
|
downloadableConfig: 'privateKey' in client,
|
|
persistentKeepalive: null as string | null,
|
|
latestHandshakeAt: null as Date | null,
|
|
endpoint: null as string | null,
|
|
transferRx: null as number | null,
|
|
transferTx: null as number | null,
|
|
})
|
|
);
|
|
|
|
// Loop WireGuard status
|
|
const dump = await exec('wg show wg0 dump', {
|
|
log: false,
|
|
});
|
|
dump
|
|
.trim()
|
|
.split('\n')
|
|
.slice(1)
|
|
.forEach((line) => {
|
|
const [
|
|
publicKey,
|
|
_preSharedKey,
|
|
endpoint,
|
|
_allowedIps,
|
|
latestHandshakeAt,
|
|
transferRx,
|
|
transferTx,
|
|
persistentKeepalive,
|
|
] = line.split('\t');
|
|
|
|
const client = clients.find((client) => client.publicKey === publicKey);
|
|
if (!client) return;
|
|
|
|
client.latestHandshakeAt =
|
|
latestHandshakeAt === '0'
|
|
? null
|
|
: new Date(Number(`${latestHandshakeAt}000`));
|
|
client.endpoint = endpoint === '(none)' ? null : (endpoint ?? null);
|
|
client.transferRx = Number(transferRx);
|
|
client.transferTx = Number(transferTx);
|
|
client.persistentKeepalive = persistentKeepalive ?? null;
|
|
});
|
|
|
|
return clients;
|
|
}
|
|
|
|
async getClient({ clientId }: { clientId: string }) {
|
|
const config = await this.getConfig();
|
|
const client = config.clients[clientId];
|
|
if (!client) {
|
|
throw createError({
|
|
statusCode: 404,
|
|
statusMessage: `Client Not Found: ${clientId}`,
|
|
});
|
|
}
|
|
|
|
return client;
|
|
}
|
|
|
|
async getClientConfiguration({ clientId }: { clientId: string }) {
|
|
const config = await this.getConfig();
|
|
const client = await this.getClient({ clientId });
|
|
|
|
return `
|
|
[Interface]
|
|
PrivateKey = ${client.privateKey ? `${client.privateKey}` : 'REPLACE_ME'}
|
|
Address = ${client.address}/24
|
|
${WG_DEFAULT_DNS ? `DNS = ${WG_DEFAULT_DNS}\n` : ''}\
|
|
${WG_MTU ? `MTU = ${WG_MTU}\n` : ''}\
|
|
|
|
[Peer]
|
|
PublicKey = ${config.server.publicKey}
|
|
${
|
|
client.preSharedKey ? `PresharedKey = ${client.preSharedKey}\n` : ''
|
|
}AllowedIPs = ${WG_ALLOWED_IPS}
|
|
PersistentKeepalive = ${WG_PERSISTENT_KEEPALIVE}
|
|
Endpoint = ${WG_HOST}:${WG_CONFIG_PORT}`;
|
|
}
|
|
|
|
async getClientQRCodeSVG({ clientId }: { clientId: string }) {
|
|
const config = await this.getClientConfiguration({ clientId });
|
|
return QRCode.toString(config, {
|
|
type: 'svg',
|
|
width: 512,
|
|
});
|
|
}
|
|
|
|
async createClient({
|
|
name,
|
|
expireDate,
|
|
}: {
|
|
name: string;
|
|
expireDate: string | null;
|
|
}) {
|
|
if (!name) {
|
|
throw new Error('Missing: Name');
|
|
}
|
|
|
|
const config = await this.getConfig();
|
|
|
|
const privateKey = await exec('wg genkey');
|
|
const publicKey = await exec(`echo ${privateKey} | wg pubkey`, {
|
|
log: 'echo ***hidden*** | wg pubkey',
|
|
});
|
|
const preSharedKey = await exec('wg genpsk');
|
|
|
|
// Calculate next IP
|
|
let address;
|
|
for (let i = 2; i < 255; i++) {
|
|
const client = Object.values(config.clients).find((client) => {
|
|
return client.address === WG_DEFAULT_ADDRESS.replace('x', i.toString());
|
|
});
|
|
|
|
if (!client) {
|
|
address = WG_DEFAULT_ADDRESS.replace('x', i.toString());
|
|
break;
|
|
}
|
|
}
|
|
|
|
if (!address) {
|
|
throw new Error('Maximum number of clients reached.');
|
|
}
|
|
|
|
// Create Client
|
|
const id = crypto.randomUUID();
|
|
const client: Client = {
|
|
id,
|
|
name,
|
|
address,
|
|
privateKey,
|
|
publicKey,
|
|
preSharedKey,
|
|
|
|
createdAt: new Date().toISOString(),
|
|
updatedAt: new Date().toISOString(),
|
|
|
|
endpoint: null,
|
|
oneTimeLink: null,
|
|
oneTimeLinkExpiresAt: null,
|
|
expireAt: null,
|
|
enabled: true,
|
|
};
|
|
|
|
if (expireDate) {
|
|
const date = new Date(expireDate);
|
|
date.setHours(23);
|
|
date.setMinutes(59);
|
|
date.setSeconds(59);
|
|
client.expireAt = date.toISOString();
|
|
}
|
|
|
|
config.clients[id] = client;
|
|
|
|
await this.saveConfig();
|
|
|
|
return client;
|
|
}
|
|
|
|
async deleteClient({ clientId }: { clientId: string }) {
|
|
const config = await this.getConfig();
|
|
|
|
if (config.clients[clientId]) {
|
|
// eslint-disable-next-line @typescript-eslint/no-dynamic-delete
|
|
delete config.clients[clientId];
|
|
await this.saveConfig();
|
|
}
|
|
}
|
|
|
|
async enableClient({ clientId }: { clientId: string }) {
|
|
const client = await this.getClient({ clientId });
|
|
|
|
client.enabled = true;
|
|
client.updatedAt = new Date().toISOString();
|
|
|
|
await this.saveConfig();
|
|
}
|
|
|
|
async generateOneTimeLink({ clientId }: { clientId: string }) {
|
|
const client = await this.getClient({ clientId });
|
|
const key = `${clientId}-${Math.floor(Math.random() * 1000)}`;
|
|
client.oneTimeLink = Math.abs(CRC32.str(key)).toString(16);
|
|
client.oneTimeLinkExpiresAt = new Date(
|
|
Date.now() + 5 * 60 * 1000
|
|
).toISOString();
|
|
client.updatedAt = new Date().toISOString();
|
|
await this.saveConfig();
|
|
}
|
|
|
|
async eraseOneTimeLink({ clientId }: { clientId: string }) {
|
|
const client = await this.getClient({ clientId });
|
|
// client.oneTimeLink = null;
|
|
client.oneTimeLinkExpiresAt = new Date(
|
|
Date.now() + 10 * 1000
|
|
).toISOString();
|
|
client.updatedAt = new Date().toISOString();
|
|
await this.saveConfig();
|
|
}
|
|
|
|
async disableClient({ clientId }: { clientId: string }) {
|
|
const client = await this.getClient({ clientId });
|
|
|
|
client.enabled = false;
|
|
client.updatedAt = new Date().toISOString();
|
|
|
|
await this.saveConfig();
|
|
}
|
|
|
|
async updateClientName({
|
|
clientId,
|
|
name,
|
|
}: {
|
|
clientId: string;
|
|
name: string;
|
|
}) {
|
|
const client = await this.getClient({ clientId });
|
|
|
|
client.name = name;
|
|
client.updatedAt = new Date().toISOString();
|
|
|
|
await this.saveConfig();
|
|
}
|
|
|
|
async updateClientAddress({
|
|
clientId,
|
|
address,
|
|
}: {
|
|
clientId: string;
|
|
address: string;
|
|
}) {
|
|
const client = await this.getClient({ clientId });
|
|
|
|
if (!isValidIPv4(address)) {
|
|
throw createError({
|
|
statusCode: 400,
|
|
statusMessage: `Invalid Address: ${address}`,
|
|
});
|
|
}
|
|
|
|
client.address = address;
|
|
client.updatedAt = new Date().toISOString();
|
|
|
|
await this.saveConfig();
|
|
}
|
|
|
|
async updateClientExpireDate({
|
|
clientId,
|
|
expireDate,
|
|
}: {
|
|
clientId: string;
|
|
expireDate: string | null;
|
|
}) {
|
|
const client = await this.getClient({ clientId });
|
|
|
|
if (expireDate) {
|
|
const date = new Date(expireDate);
|
|
date.setHours(23);
|
|
date.setMinutes(59);
|
|
date.setSeconds(59);
|
|
client.expireAt = date.toISOString();
|
|
} else {
|
|
client.expireAt = null;
|
|
}
|
|
client.updatedAt = new Date().toISOString();
|
|
|
|
await this.saveConfig();
|
|
}
|
|
|
|
async __reloadConfig() {
|
|
await this.__buildConfig();
|
|
await this.__syncConfig();
|
|
}
|
|
|
|
async restoreConfiguration(config: string) {
|
|
debug('Starting configuration restore process.');
|
|
// TODO: sanitize config
|
|
const _config = JSON.parse(config);
|
|
await this.__saveConfig(_config);
|
|
await this.__reloadConfig();
|
|
debug('Configuration restore process completed.');
|
|
}
|
|
|
|
async backupConfiguration() {
|
|
debug('Starting configuration backup.');
|
|
const config = await this.getConfig();
|
|
const backup = JSON.stringify(config, null, 2);
|
|
debug('Configuration backup completed.');
|
|
return backup;
|
|
}
|
|
|
|
// Shutdown wireguard
|
|
async Shutdown() {
|
|
await exec('wg-quick down wg0').catch(() => {});
|
|
}
|
|
|
|
async cronJobEveryMinute() {
|
|
const config = await this.getConfig();
|
|
let needSaveConfig = false;
|
|
// Expires Feature
|
|
if (WG_ENABLE_EXPIRES_TIME === 'true') {
|
|
for (const client of Object.values(config.clients)) {
|
|
if (client.enabled !== true) continue;
|
|
if (
|
|
client.expireAt !== null &&
|
|
new Date() > new Date(client.expireAt)
|
|
) {
|
|
debug(`Client ${client.id} expired.`);
|
|
needSaveConfig = true;
|
|
client.enabled = false;
|
|
client.updatedAt = new Date().toISOString();
|
|
}
|
|
}
|
|
}
|
|
// One Time Link Feature
|
|
if (WG_ENABLE_ONE_TIME_LINKS === 'true') {
|
|
for (const client of Object.values(config.clients)) {
|
|
if (
|
|
client.oneTimeLink !== null &&
|
|
client.oneTimeLinkExpiresAt !== null &&
|
|
new Date() > new Date(client.oneTimeLinkExpiresAt)
|
|
) {
|
|
debug(`Client ${client.id} One Time Link expired.`);
|
|
needSaveConfig = true;
|
|
client.oneTimeLink = null;
|
|
client.oneTimeLinkExpiresAt = null;
|
|
client.updatedAt = new Date().toISOString();
|
|
}
|
|
}
|
|
}
|
|
if (needSaveConfig) {
|
|
await this.saveConfig();
|
|
}
|
|
}
|
|
|
|
async getMetrics() {
|
|
const clients = await this.getClients();
|
|
let wireguardPeerCount = 0;
|
|
let wireguardEnabledPeersCount = 0;
|
|
let wireguardConnectedPeersCount = 0;
|
|
let wireguardSentBytes = '';
|
|
let wireguardReceivedBytes = '';
|
|
let wireguardLatestHandshakeSeconds = '';
|
|
for (const client of Object.values(clients)) {
|
|
wireguardPeerCount++;
|
|
if (client.enabled === true) {
|
|
wireguardEnabledPeersCount++;
|
|
}
|
|
if (client.endpoint !== null) {
|
|
wireguardConnectedPeersCount++;
|
|
}
|
|
wireguardSentBytes += `wireguard_sent_bytes{interface="wg0",enabled="${client.enabled}",address="${client.address}",name="${client.name}"} ${Number(client.transferTx)}\n`;
|
|
wireguardReceivedBytes += `wireguard_received_bytes{interface="wg0",enabled="${client.enabled}",address="${client.address}",name="${client.name}"} ${Number(client.transferRx)}\n`;
|
|
wireguardLatestHandshakeSeconds += `wireguard_latest_handshake_seconds{interface="wg0",enabled="${client.enabled}",address="${client.address}",name="${client.name}"} ${client.latestHandshakeAt ? (new Date().getTime() - new Date(client.latestHandshakeAt).getTime()) / 1000 : 0}\n`;
|
|
}
|
|
|
|
let returnText = '# HELP wg-easy and wireguard metrics\n';
|
|
|
|
returnText += '\n# HELP wireguard_configured_peers\n';
|
|
returnText += '# TYPE wireguard_configured_peers gauge\n';
|
|
returnText += `wireguard_configured_peers{interface="wg0"} ${Number(wireguardPeerCount)}\n`;
|
|
|
|
returnText += '\n# HELP wireguard_enabled_peers\n';
|
|
returnText += '# TYPE wireguard_enabled_peers gauge\n';
|
|
returnText += `wireguard_enabled_peers{interface="wg0"} ${Number(wireguardEnabledPeersCount)}\n`;
|
|
|
|
returnText += '\n# HELP wireguard_connected_peers\n';
|
|
returnText += '# TYPE wireguard_connected_peers gauge\n';
|
|
returnText += `wireguard_connected_peers{interface="wg0"} ${Number(wireguardConnectedPeersCount)}\n`;
|
|
|
|
returnText += '\n# HELP wireguard_sent_bytes Bytes sent to the peer\n';
|
|
returnText += '# TYPE wireguard_sent_bytes counter\n';
|
|
returnText += `${wireguardSentBytes}`;
|
|
|
|
returnText +=
|
|
'\n# HELP wireguard_received_bytes Bytes received from the peer\n';
|
|
returnText += '# TYPE wireguard_received_bytes counter\n';
|
|
returnText += `${wireguardReceivedBytes}`;
|
|
|
|
returnText +=
|
|
'\n# HELP wireguard_latest_handshake_seconds UNIX timestamp seconds of the last handshake\n';
|
|
returnText += '# TYPE wireguard_latest_handshake_seconds gauge\n';
|
|
returnText += `${wireguardLatestHandshakeSeconds}`;
|
|
|
|
return returnText;
|
|
}
|
|
|
|
async getMetricsJSON() {
|
|
const clients = await this.getClients();
|
|
let wireguardPeerCount = 0;
|
|
let wireguardEnabledPeersCount = 0;
|
|
let wireguardConnectedPeersCount = 0;
|
|
for (const client of Object.values(clients)) {
|
|
wireguardPeerCount++;
|
|
if (client.enabled === true) {
|
|
wireguardEnabledPeersCount++;
|
|
}
|
|
if (client.endpoint !== null) {
|
|
wireguardConnectedPeersCount++;
|
|
}
|
|
}
|
|
return {
|
|
wireguard_configured_peers: Number(wireguardPeerCount),
|
|
wireguard_enabled_peers: Number(wireguardEnabledPeersCount),
|
|
wireguard_connected_peers: Number(wireguardConnectedPeersCount),
|
|
};
|
|
}
|
|
}
|
|
|
|
const inst = new WireGuard();
|
|
|
|
// This also has to also start the WireGuard Server
|
|
async function cronJobEveryMinute() {
|
|
await inst.cronJobEveryMinute().catch((err) => {
|
|
debug('Running Cron Job failed.');
|
|
console.error(err);
|
|
});
|
|
setTimeout(cronJobEveryMinute, 60 * 1000);
|
|
}
|
|
cronJobEveryMinute();
|
|
|
|
export default inst;
|
|
|