the-wireguard-effect
Version:
Cross platform wireguard api client for nodejs built on wireguard-go with effect-ts
350 lines • 17 kB
JavaScript
import * as FileSystem from "@effect/platform/FileSystem";
import * as Path from "@effect/platform/Path";
import * as Array from "effect/Array";
import * as Chunk from "effect/Chunk";
import * as Effect from "effect/Effect";
import * as Either from "effect/Either";
import * as Function from "effect/Function";
import * as Match from "effect/Match";
import * as Number from "effect/Number";
import * as Option from "effect/Option";
import * as ParseResult from "effect/ParseResult";
import * as Predicate from "effect/Predicate";
import * as Schedule from "effect/Schedule";
import * as Schema from "effect/Schema";
import * as Stream from "effect/Stream";
import * as String from "effect/String";
import * as ini from "ini";
import * as assert from "node:assert";
import * as os from "node:os";
import * as InternetSchemas from "../InternetSchemas.js";
import * as WireguardControl from "../WireguardControl.js";
import * as WireguardErrors from "../WireguardErrors.js";
import * as WireguardKey from "../WireguardKey.js";
import * as WireguardPeer from "../WireguardPeer.js";
import * as internalWireguardConfig from "./wireguardConfig.js";
import * as internalInterface from "./wireguardInterface.js";
// --------------------------------------------
// WireguardConfig.ts
// --------------------------------------------
export class WireguardConfig extends /*#__PURE__*/internalWireguardConfig.WireguardConfigVariantSchema.Class("WireguardIniConfig")({
/** The Address of this peer. */
Address: InternetSchemas.CidrBlockFromString,
/** DNS for this peer. */
Dns: /*#__PURE__*/Schema.optional(InternetSchemas.Address),
/**
* The value for this is a decimal-string integer corresponding to the
* listening port of the interface.
*/
ListenPort: /*#__PURE__*/Schema.Union(InternetSchemas.Port, /*#__PURE__*/Schema.transformOrFail(Schema.String, InternetSchemas.Port, {
decode: (str, _options, ast) => Either.fromOption(Number.parse(str), () => new ParseResult.Type(ast, str)),
encode: port => Effect.succeed(`${port}`)
})),
/**
* The value for this is a decimal-string integer corresponding to the
* fwmark of the interface. The value may 0 in the case of a set operation,
* in which case it indicates that the fwmark should be removed.
*/
FirewallMark: /*#__PURE__*/Schema.optionalWith(Schema.Number, {
nullable: true
}),
/**
* The value for this key should be a lowercase hex-encoded private key of
* the interface. The value may be an all zero string in the case of a set
* operation, in which case it indicates that the private key should be
* removed.
*/
PrivateKey: WireguardKey.WireguardKey,
/** List of peers to add. */
Peers: /*#__PURE__*/internalWireguardConfig.WireguardConfigVariantSchema.Field({
json: /*#__PURE__*/Schema.optionalWith(/*#__PURE__*/Schema.Array(WireguardPeer.WireguardPeer), {
default: () => [],
nullable: true
}),
uapi: /*#__PURE__*/Schema.optionalWith(/*#__PURE__*/Schema.Array(WireguardPeer.WireguardPeer["uapi"]), {
default: () => [],
nullable: true
})
})
}) {
/**
* Writes a wireguard interface configuration to an INI file.
*
* @since 1.0.0
* @category Constructors
* @param file - The path to the INI file.
*/
writeToFile = file => Effect.gen(this, function* () {
const path = yield* Path.Path;
const fs = yield* FileSystem.FileSystem;
const configEncoded = yield* Schema.encode(WireguardConfig)(this);
const iniConfigDecoded = yield* Schema.decode(WireguardIniConfig)(configEncoded);
yield* fs.makeDirectory(path.dirname(file), {
recursive: true
});
yield* fs.writeFileString(file, iniConfigDecoded);
});
/**
* Starts a wireguard tunnel that will continue to run and serve traffic
* even after the nodejs process exits.
*
* @since 1.0.0
* @category Wireguard
*/
up = wireguardInterface => Function.pipe(wireguardInterface, Option.fromNullable, Option.map(Effect.succeed), Option.getOrElse(() => WireguardInterface.getNextAvailableInterface), Effect.flatMap(io => up(io, this)));
/**
* Starts a wireguard tunnel that will be gracefully shutdown and stop
* serving traffic once the scope is closed.
*
* @since 1.0.0
* @category Wireguard
*/
upScoped = wireguardInterface => Function.pipe(wireguardInterface, Option.fromNullable, Option.map(Effect.succeed), Option.getOrElse(() => WireguardInterface.getNextAvailableInterface), Effect.flatMap(io => upScoped(io, this)));
}
export class WireguardIniConfig extends /*#__PURE__*/Schema.transformOrFail(WireguardConfig, Schema.String, {
// Encoding is non-trivial, as we need to handle all the peers individually.
decode: (config, _options, _ast) => Effect.gen(function* () {
const listenPort = `ListenPort = ${config.ListenPort}\n`;
const privateKey = `PrivateKey = ${config.PrivateKey}\n`;
const address = `Address = ${config.Address.address.ip}/${config.Address.mask}\n`;
const dns = Predicate.isNotUndefined(config.Dns) ? `Dns = ${config.Dns?.ip}\n` : "";
const fwmark = Predicate.isNotUndefined(config.FirewallMark) ? `FirewallMark = ${config.FirewallMark}\n` : "";
const peersConfig = yield* Function.pipe(config.Peers, Array.map(peer => Schema.encode(WireguardPeer.WireguardPeer)(peer)), Array.map(Effect.flatMap(Schema.decode(WireguardPeer.WireguardIniPeer))), Effect.allWith(), Effect.map(Array.join("\n")));
return `[Interface]\n${dns}${listenPort}${fwmark}${address}${privateKey}\n${peersConfig}`;
}).pipe(Effect.mapError(({
issue
}) => issue)),
// Decoding is likewise non-trivial, as we need to parse all the peers from the ini config.
encode: (iniConfig, _options, _ast) => Effect.gen(function* () {
const sections = iniConfig.split(/(?=\[Peer\])/g);
const maybeInterfaceSection = Array.findFirst(sections, text => text.startsWith("[Interface]"));
const interfaceSection = Option.getOrThrowWith(maybeInterfaceSection, () => new WireguardErrors.WireguardError({
message: "No [Interface] section found"
}));
const peerSections = Function.pipe(sections, Array.filter(text => text.startsWith("[Peer]")), Array.map(text => text.replace("[Peer]", "")));
const parsePeers = yield* Function.pipe(peerSections, Array.map(peer => Schema.encode(WireguardPeer.WireguardIniPeer)(peer)), Effect.allWith());
const parseInterface = Function.pipe(interfaceSection, ini.parse, jsonConfig => ({
...jsonConfig["Interface"],
Peers: parsePeers
}), ({
Address,
Dns,
FirewallMark,
ListenPort,
Peers,
PrivateKey
}) => ({
Dns,
Peers,
Address,
PrivateKey,
ListenPort,
FirewallMark: Number.parse(FirewallMark || "").pipe(Option.getOrUndefined)
}), Schema.decode(WireguardConfig));
return yield* parseInterface;
}).pipe(Effect.mapError(({
issue
}) => issue))
}).annotations({
identifier: "WireguardIniConfig",
description: "A wireguard ini configuration"
}) {}
// --------------------------------------------
// WireguardInterface.ts
// --------------------------------------------
/**
* A wireguard interface name.
*
* @since 1.0.0
* @category Datatypes
*/
export class WireguardInterface extends /*#__PURE__*/Schema.Class("WireguardInterface")({
/**
* Ensures the interface name matches the platform's interface name regex.
* These functions need to be fully typed as we are accessing a static
* method on this same class and otherwise typescript really complains about
* inference.
*/
Name: /*#__PURE__*/Schema.transformOrFail(Schema.String, Schema.String, {
decode: (str, _options, ast) => Function.pipe(WireguardInterface.InterfaceRegExpForPlatform, Effect.mapError(error => new ParseResult.Type(ast, str, error.message)), Effect.flatMap(x => x.test(str) ? Effect.succeed(str) : Effect.fail(new ParseResult.Type(ast, str, `Expected interface name to match ${x}`)))),
encode: s => Effect.succeed(s)
})
}) {
/**
* @since 1.0.0
* @category Constructors
*/
static getNextAvailableInterface = /*#__PURE__*/Effect.gen(function* () {
// Determine all the used interface indexes
const regex = yield* WireguardInterface.InterfaceRegExpForPlatform;
const usedInterfaceIndexes = Function.pipe(os.networkInterfaces(), Object.keys, Array.filter(name => regex.test(name)), Array.map(String.replaceAll(/\D/g, "")), Array.map(Number.parse), Array.filterMap(Function.identity));
// Find the next available interface index
const nextAvailableInterfaceIndex = yield* Function.pipe(Stream.iterate(0, x => x + 1), Stream.find(x => !Array.contains(usedInterfaceIndexes, x)), Stream.take(1), Stream.runCollect, Effect.map(Chunk.head), Effect.map(Option.getOrThrow));
// We know this will be a supported platform now because otherwise
// the WireguardInterface.InterfaceRegExpForPlatform would have failed
const platform = Function.unsafeCoerce(process.platform);
// Construct the next available interface name
const fromString = Schema.decodeSync(WireguardInterface);
switch (platform) {
case "win32":
return fromString({
Name: `eth${nextAvailableInterfaceIndex}`
});
case "linux":
return fromString({
Name: `wg${nextAvailableInterfaceIndex}`
});
case "darwin":
return fromString({
Name: `utun${nextAvailableInterfaceIndex}`
});
default:
return Function.absurd(platform);
}
});
static InterfaceRegExpForPlatform = /*#__PURE__*/Function.pipe(/*#__PURE__*/Match.value(`${process.arch}:${process.platform}`), /*#__PURE__*/Match.not(/*#__PURE__*/Predicate.some(/*#__PURE__*/Array.map(internalInterface.SupportedArchitectures, arch => String.startsWith(`${arch}:`))), bad => Effect.fail(new WireguardErrors.WireguardError({
message: `Unsupported architecture ${bad}`
}))), /*#__PURE__*/Match.when(/*#__PURE__*/String.endsWith(":linux"), () => Effect.succeed(internalInterface.LinuxInterfaceNameRegExp)), /*#__PURE__*/Match.when(/*#__PURE__*/String.endsWith(":win32"), () => Effect.succeed(internalInterface.WindowsInterfaceNameRegExp)), /*#__PURE__*/Match.when(/*#__PURE__*/String.endsWith(":darwin"), () => Effect.succeed(internalInterface.DarwinInterfaceNameRegExp)), /*#__PURE__*/Match.orElse(bad => Effect.fail(new WireguardErrors.WireguardError({
message: `Unsupported platform ${bad}`
}))));
/**
* @since 1.0.0
* @category Userspace api
*/
SocketLocation = /*#__PURE__*/Function.pipe(Match.type(), Match.when("linux", () => `/var/run/wireguard/${this.Name}.sock`), Match.when("darwin", () => `/var/run/wireguard/${this.Name}.sock`), Match.when("win32", () => `\\\\.\\pipe\\ProtectedPrefix\\Administrators\\WireGuard\\${this.Name}`), Match.exhaustive)(/*#__PURE__*/Function.unsafeCoerce(process.platform));
/**
* Starts a wireguard tunnel that will be gracefully shutdown and stop
* serving traffic once the scope is closed.
*
* @since 1.0.0
* @category Wireguard control
*/
upScoped = config => upScoped(this, config);
/**
* Starts a wireguard tunnel that will continue to run and serve traffic
* even after the nodejs process exits.
*
* @since 1.0.0
* @category Wireguard control
*/
up = config => up(this, config);
/**
* Stops a previously started wireguard tunnel.
*
* @since 1.0.0
* @category Wireguard control
*/
down = config => down(this, config);
/**
* Sets the config for this wireguard interface.
*
* @since 1.0.0
* @category Wireguard control
*/
setConfig = wireguardConfig => setConfig(this, wireguardConfig);
/**
* Retrieves the config from this wireguard interface.
*
* @since 1.0.0
*/
getConfig = address => getConfig(this, address);
/**
* Adds a peer to this interface.
*
* @since 1.0.0
* @category Wireguard control
*/
addPeer = peer => addPeer(this, peer);
/**
* Removes a peer from this interface.
*
* @since 1.0.0
* @category Wireguard control
*/
removePeer = peer => removePeer(this, peer);
/**
* Streams the stats from all the peers on this interface.
*
* @since 1.0.0
* @category Wireguard control
*/
streamPeerStats = () => streamPeerStats(this);
}
// --------------------------------------------
// WireguardRpc.ts
// --------------------------------------------
/** @internal */
export const up = (wireguardInterface, wireguardConfig) => Effect.flatMap(WireguardControl.WireguardControl, control => control.up(wireguardConfig, wireguardInterface));
/** @internal */
export const upScoped = (wireguardInterface, wireguardConfig) => Effect.flatMap(WireguardControl.WireguardControl, control => control.upScoped(wireguardConfig, wireguardInterface));
/** @internal */
export const down = (wireguardInterface, wireguardConfig) => Effect.flatMap(WireguardControl.WireguardControl, control => control.down(wireguardConfig, wireguardInterface));
/** @internal */
export const setConfig = (wireguardInterface, wireguardConfig) => Effect.gen(function* () {
const listenPort = `listen_port=${wireguardConfig.ListenPort}\n`;
const privateKeyHex = Buffer.from(wireguardConfig.PrivateKey, "base64").toString("hex");
const privateKey = `private_key=${privateKeyHex}\n`;
const fwmark = Predicate.isNotUndefined(wireguardConfig.FirewallMark) ? `fwmark=${wireguardConfig.FirewallMark}\n` : String.empty;
const peers = yield* Function.pipe(wireguardConfig.Peers, Array.map(peer => Schema.encode(WireguardPeer.WireguardPeer)(peer)), Array.map(Effect.flatMap(peer => Schema.decode(WireguardPeer.WireguardUapiSetPeer)(peer))), Effect.allWith(), Effect.map(Array.join("\n")));
const uapiConfig = `${privateKey}${listenPort}${fwmark}${peers}\n`;
yield* internalInterface.userspaceContact(wireguardInterface, `set=1\n${uapiConfig}\n`);
return wireguardInterface;
});
/** @internal */
export const getConfig = (wireguardInterface, address) => Effect.gen(function* () {
const uapiConfig = yield* internalInterface.userspaceContact(wireguardInterface, "get=1\n\n");
const [interfaceConfig, ...peers] = uapiConfig.split("public_key=");
const {
fwmark,
listen_port,
private_key
} = ini.decode(interfaceConfig ?? "");
const peerConfigs = yield* Function.pipe(peers, Array.map(peer => `public_key=${peer}`), Array.map(x => Schema.decode(WireguardPeer.WireguardUapiGetPeer, {
onExcessProperty: "error"
})(x)), Array.map(Effect.flatMap(x => Schema.encode(WireguardPeer.WireguardPeer["uapi"])(x))), Effect.allWith());
return yield* Schema.decode(WireguardConfig["uapi"], {
onExcessProperty: "error"
})({
Address: address,
ListenPort: listen_port,
PrivateKey: Buffer.from(private_key, "hex").toString("base64"),
FirewallMark: Number.parse(fwmark || "").pipe(Option.getOrUndefined),
Peers: peerConfigs
});
});
/** @internal */
export const addPeer = (wireguardInterface, peer) => Effect.gen(function* () {
// Get the config before adding this peer and ensure this peer is not present
const configBefore = yield* getConfig(wireguardInterface, "0.0.0.0/0");
assert.ok(configBefore.Peers.find(p => p.PublicKey === peer.PublicKey) === undefined);
// Add the peer to the interface
const a = yield* Schema.encode(WireguardPeer.WireguardPeer)(peer);
const b = yield* Schema.decode(WireguardPeer.WireguardUapiSetPeer)(a);
yield* internalInterface.userspaceContact(wireguardInterface, `set=1\n${b}`);
// Get the config after adding this peer and ensure this peer is present
const configAfter = yield* getConfig(wireguardInterface, "0.0.0.0/0");
assert.ok(configAfter.Peers.find(p => p.PublicKey === peer.PublicKey) !== undefined);
});
/** @internal */
export const removePeer = (wireguardInterface, peer) => Effect.gen(function* () {
// Get the config before removing this peer and ensure this peer is present
const configBefore = yield* getConfig(wireguardInterface, "0.0.0.0/0");
assert.ok(configBefore.Peers.find(p => p.PublicKey === peer.PublicKey) !== undefined);
// Remove the peer from the interface
const a = yield* Schema.encode(WireguardPeer.WireguardPeer)(peer);
const b = yield* Schema.decode(WireguardPeer.WireguardUapiSetPeer)(a);
yield* internalInterface.userspaceContact(wireguardInterface, `set=1\n${b}remove=true\n`);
// Get the config after removing this peer and ensure this peer is not present
const configAfter = yield* getConfig(wireguardInterface, "0.0.0.0/0");
assert.ok(configAfter.Peers.find(p => p.PublicKey === peer.PublicKey) === undefined);
});
/** @internal */
export const streamPeerStats = wireguardInterface => {
const pull = getConfig(wireguardInterface, "0.0.0.0/0");
const schedule = Schedule.spaced("1 second");
const stream = Stream.repeatEffectWithSchedule(pull, schedule);
return Stream.map(stream, ({
Peers: peers
}) => peers);
};
//# sourceMappingURL=circular.js.map