the-wireguard-effect
Version:
Cross platform wireguard api client for nodejs built on wireguard-go with effect-ts
675 lines (618 loc) • 26.7 kB
text/typescript
import type * as Cause from "effect/Cause";
import type * as PlatformError from "effect/PlatformError";
import type * as Ast from "effect/SchemaAST";
import type * as Scope from "effect/Scope";
import type * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner";
import type * as Socket from "effect/unstable/socket/Socket";
import * as Array from "effect/Array";
import * as Effect from "effect/Effect";
import * as FileSystem from "effect/FileSystem";
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 Path from "effect/Path";
import * as Predicate from "effect/Predicate";
import * as Result from "effect/Result";
import * as Schedule from "effect/Schedule";
import * as Schema from "effect/Schema";
import * as SchemaGetter from "effect/SchemaGetter";
import * as SchemaIssue from "effect/SchemaIssue";
import * as Stream from "effect/Stream";
import * as String from "effect/String";
import * as assert from "node:assert";
import * as os from "node:os";
import * as InternetSchemas from "effect-schemas/Internet";
import * as ini from "ini";
import * as WireguardControl from "../WireguardControl.ts";
import * as WireguardErrors from "../WireguardErrors.ts";
import * as WireguardKey from "../WireguardKey.ts";
import * as WireguardPeer from "../WireguardPeer.ts";
import * as internalWireguardConfig from "./wireguardConfig.ts";
import * as internalInterface from "./wireguardInterface.ts";
// --------------------------------------------
// WireguardConfig.ts
// --------------------------------------------
export class WireguardConfig extends internalWireguardConfig.WireguardConfigVariantSchema.Class<WireguardConfig>(
"WireguardIniConfig"
)({
/** The Address of this peer. */
Address: InternetSchemas.CidrBlockFromString,
/** DNS for this peer. */
Dns: Schema.optional(InternetSchemas.Address),
/**
* The value for this is a decimal-string integer corresponding to the
* listening port of the interface.
*/
ListenPort: Schema.Union([
InternetSchemas.Port,
Schema.NumberFromString.pipe(Schema.decodeTo(InternetSchemas.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: Schema.Number.pipe(Schema.NullOr, Schema.optional),
/**
* 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: internalWireguardConfig.WireguardConfigVariantSchema.Field({
json: Schema.Array(WireguardPeer.WireguardPeer).pipe(Schema.withDecodingDefault(Effect.succeed([]))),
uapi: Schema.Array(WireguardPeer.WireguardPeer["uapi"]).pipe(Schema.withDecodingDefault(Effect.succeed([]))),
}),
}) {
/**
* Writes a wireguard interface configuration to an INI file.
*
* @since 1.0.0
* @category Constructors
* @param file - The path to the INI file.
*/
public writeToFile: {
(
file: string
): Effect.Effect<void, Schema.SchemaError | PlatformError.PlatformError, FileSystem.FileSystem | Path.Path>;
} = (file: string) =>
Effect.gen({ self: this }, function* () {
const path = yield* Path.Path;
const fs = yield* FileSystem.FileSystem;
const configEncoded = yield* Schema.encodeEffect(WireguardConfig)(this);
const iniConfigDecoded = yield* Schema.decodeEffect(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
*/
public up: {
(
wireguardInterface?: WireguardInterface | undefined
): Effect.Effect<
WireguardInterface,
| Socket.SocketError
| Schema.SchemaError
| Cause.TimeoutError
| PlatformError.PlatformError
| PlatformError.SystemError
| PlatformError.BadArgument
| WireguardErrors.WireguardError,
| FileSystem.FileSystem
| Path.Path
| ChildProcessSpawner.ChildProcessSpawner
| WireguardControl.WireguardControl
>;
} = (wireguardInterface?: WireguardInterface | undefined) =>
Function.pipe(
wireguardInterface,
Option.fromUndefinedOr,
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
*/
public upScoped: {
(
wireguardInterface?: WireguardInterface | undefined
): Effect.Effect<
WireguardInterface,
| Socket.SocketError
| Schema.SchemaError
| Cause.TimeoutError
| PlatformError.PlatformError
| PlatformError.SystemError
| PlatformError.BadArgument
| WireguardErrors.WireguardError,
| FileSystem.FileSystem
| Path.Path
| ChildProcessSpawner.ChildProcessSpawner
| WireguardControl.WireguardControl
| Scope.Scope
>;
} = (wireguardInterface?: WireguardInterface | undefined) =>
Function.pipe(
wireguardInterface,
Option.fromUndefinedOr,
Option.map(Effect.succeed),
Option.getOrElse(() => WireguardInterface.getNextAvailableInterface),
Effect.flatMap((io) => upScoped(io, this))
);
}
export const WireguardIniConfig = WireguardConfig.pipe(
Schema.decodeTo(Schema.String, {
// Encoding is non-trivial, as we need to handle all the peers individually.
decode: SchemaGetter.transformOrFail((config: WireguardConfig) =>
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.encodeEffect(WireguardPeer.WireguardPeer)(peer)),
Array.map(Effect.flatMap(Schema.decodeEffect(WireguardPeer.WireguardIniPeer))),
Effect.all,
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: SchemaGetter.transformOrFail((iniConfig: string) =>
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.encodeEffect(WireguardPeer.WireguardIniPeer)(peer)),
Effect.all
);
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),
}) as const,
Schema.decodeEffect(WireguardConfig)
);
return yield* parseInterface;
}).pipe(Effect.mapError(({ issue }) => issue))
),
}),
Schema.annotate({
identifier: "WireguardIniConfig",
description: "A wireguard ini configuration",
})
);
// --------------------------------------------
// WireguardInterface.ts
// --------------------------------------------
/** @internal */
export const UnsupportedArchitecture = (arch: string) =>
Result.fail(new WireguardErrors.WireguardError({ message: `Unsupported architecture ${arch}` }));
/** @internal */
export const InterfaceRegExpForPlatform: Result.Result<RegExp, WireguardErrors.WireguardError> = Function.pipe(
Match.value(`${process.arch}:${process.platform}`),
Match.not(
Predicate.some(Array.map(internalInterface.SupportedArchitectures, (arch) => String.startsWith(`${arch}:`))),
UnsupportedArchitecture
),
Match.when(String.endsWith(":linux"), () => Result.succeed(internalInterface.LinuxInterfaceNameRegExp)),
Match.when(String.endsWith(":win32"), () => Result.succeed(internalInterface.WindowsInterfaceNameRegExp)),
Match.when(String.endsWith(":darwin"), () => Result.succeed(internalInterface.DarwinInterfaceNameRegExp)),
Match.orElse(UnsupportedArchitecture)
);
/**
* A wireguard interface name.
*
* @since 1.0.0
* @category Datatypes
*/
export class WireguardInterface extends Schema.Class<WireguardInterface>("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: Schema.String.check(
Result.match(InterfaceRegExpForPlatform, {
onSuccess: (regex): Ast.Filter<string> => Schema.isPattern(regex),
onFailure: (error): Ast.Filter<string> =>
Schema.makeFilter(
(input) =>
new SchemaIssue.InvalidValue(Option.some(input), {
message: error.message,
})
),
})
),
}) {
/**
* @since 1.0.0
* @category Constructors
*/
public static readonly getNextAvailableInterface: Effect.Effect<
WireguardInterface,
WireguardErrors.WireguardError,
never
> = Effect.gen(function* () {
// Determine all the used interface indexes
const regex = yield* Effect.fromResult(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(Result.fromOption(() => Result.failVoid))
);
// Find the next available interface index
const nextAvailableInterfaceIndex = yield* Function.pipe(
Stream.iterate(0, (x) => x + 1),
Stream.filter((x) => !Array.contains(usedInterfaceIndexes, x)),
Stream.take(1),
Stream.runCollect,
Effect.map(Array.head),
Effect.map(Option.getOrThrow)
);
// We know this will be a supported platform now because otherwise
// the WireguardInterface.InterfaceRegExpForPlatform would have failed
const platform: (typeof internalInterface.SupportedPlatforms)[number] =
process.platform as (typeof internalInterface.SupportedPlatforms)[number];
// 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<WireguardInterface>(platform);
}
});
/**
* @since 1.0.0
* @category Static members
*/
public static readonly InterfaceRegExpForPlatform = InterfaceRegExpForPlatform;
/**
* @since 1.0.0
* @category Userspace api
*/
public readonly SocketLocation = Function.pipe(
Match.type<(typeof internalInterface.SupportedPlatforms)[number]>(),
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
)(process.platform as (typeof internalInterface.SupportedPlatforms)[number]);
/**
* 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
*/
public upScoped: {
(
config: WireguardConfig
): Effect.Effect<
WireguardInterface,
| Socket.SocketError
| Schema.SchemaError
| PlatformError.PlatformError
| PlatformError.SystemError
| PlatformError.BadArgument
| Cause.TimeoutError,
| FileSystem.FileSystem
| Path.Path
| Scope.Scope
| ChildProcessSpawner.ChildProcessSpawner
| WireguardControl.WireguardControl
>;
} = (config: WireguardConfig) => 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
*/
public up: {
(
config: WireguardConfig
): Effect.Effect<
WireguardInterface,
| Socket.SocketError
| Schema.SchemaError
| PlatformError.PlatformError
| PlatformError.SystemError
| PlatformError.BadArgument
| Cause.TimeoutError,
| FileSystem.FileSystem
| Path.Path
| ChildProcessSpawner.ChildProcessSpawner
| WireguardControl.WireguardControl
>;
} = (config: WireguardConfig) => up(this, config);
/**
* Stops a previously started wireguard tunnel.
*
* @since 1.0.0
* @category Wireguard control
*/
public down: {
(
config: WireguardConfig
): Effect.Effect<
WireguardInterface,
| PlatformError.PlatformError
| PlatformError.SystemError
| PlatformError.BadArgument
| Schema.SchemaError
| Cause.TimeoutError,
| FileSystem.FileSystem
| Path.Path
| ChildProcessSpawner.ChildProcessSpawner
| WireguardControl.WireguardControl
>;
} = (config: WireguardConfig) => down(this, config);
/**
* Sets the config for this wireguard interface.
*
* @since 1.0.0
* @category Wireguard control
*/
public setConfig: {
(
wireguardConfig: WireguardConfig
): Effect.Effect<WireguardInterface, Socket.SocketError | Schema.SchemaError, never>;
} = (wireguardConfig: WireguardConfig) => setConfig(this, wireguardConfig);
/**
* Retrieves the config from this wireguard interface.
*
* @since 1.0.0
*/
public getConfig: {
(
address: (typeof InternetSchemas.CidrBlockFromString)["Encoded"]
): Effect.Effect<
Schema.Schema.Type<(typeof WireguardConfig)["uapi"]>,
Socket.SocketError | Schema.SchemaError,
never
>;
} = (address: (typeof InternetSchemas.CidrBlockFromString)["Encoded"]) => getConfig(this, address);
/**
* Adds a peer to this interface.
*
* @since 1.0.0
* @category Wireguard control
*/
public addPeer: {
(peer: WireguardPeer.WireguardPeer): Effect.Effect<void, Socket.SocketError | Schema.SchemaError, never>;
} = (peer: WireguardPeer.WireguardPeer) => addPeer(this, peer);
/**
* Removes a peer from this interface.
*
* @since 1.0.0
* @category Wireguard control
*/
public removePeer: {
(peer: WireguardPeer.WireguardPeer): Effect.Effect<void, Socket.SocketError | Schema.SchemaError, never>;
} = (peer: WireguardPeer.WireguardPeer) => removePeer(this, peer);
/**
* Streams the stats from all the peers on this interface.
*
* @since 1.0.0
* @category Wireguard control
*/
public streamPeerStats: {
(): Stream.Stream<
ReadonlyArray<Schema.Schema.Type<(typeof WireguardPeer.WireguardPeer)["uapi"]>>,
Socket.SocketError | Schema.SchemaError,
never
>;
} = () => streamPeerStats(this);
}
// --------------------------------------------
// WireguardRpc.ts
// --------------------------------------------
/** @internal */
export const up: {
(
wireguardInterface: WireguardInterface,
wireguardConfig: WireguardConfig
): Effect.Effect<
WireguardInterface,
| Socket.SocketError
| Schema.SchemaError
| PlatformError.PlatformError
| PlatformError.SystemError
| PlatformError.BadArgument
| Cause.TimeoutError,
FileSystem.FileSystem | Path.Path | ChildProcessSpawner.ChildProcessSpawner | WireguardControl.WireguardControl
>;
} = (wireguardInterface: WireguardInterface, wireguardConfig: WireguardConfig) =>
Effect.flatMap(WireguardControl.WireguardControl, (control) => control.up(wireguardConfig, wireguardInterface));
/** @internal */
export const upScoped: {
(
wireguardInterface: WireguardInterface,
wireguardConfig: WireguardConfig
): Effect.Effect<
WireguardInterface,
| Socket.SocketError
| Schema.SchemaError
| PlatformError.PlatformError
| PlatformError.SystemError
| PlatformError.BadArgument
| Cause.TimeoutError,
| FileSystem.FileSystem
| Path.Path
| Scope.Scope
| ChildProcessSpawner.ChildProcessSpawner
| WireguardControl.WireguardControl
>;
} = (wireguardInterface: WireguardInterface, wireguardConfig: WireguardConfig) =>
Effect.flatMap(WireguardControl.WireguardControl, (control) =>
control.upScoped(wireguardConfig, wireguardInterface)
);
/** @internal */
export const down: {
(
wireguardInterface: WireguardInterface,
wireguardConfig: WireguardConfig
): Effect.Effect<
WireguardInterface,
| PlatformError.BadArgument
| PlatformError.SystemError
| PlatformError.PlatformError
| Schema.SchemaError
| Cause.TimeoutError,
FileSystem.FileSystem | Path.Path | ChildProcessSpawner.ChildProcessSpawner | WireguardControl.WireguardControl
>;
} = (wireguardInterface: WireguardInterface, wireguardConfig: WireguardConfig) =>
Effect.flatMap(WireguardControl.WireguardControl, (control) => control.down(wireguardConfig, wireguardInterface));
/** @internal */
export const setConfig: {
(
wireguardInterface: WireguardInterface,
wireguardConfig: WireguardConfig
): Effect.Effect<WireguardInterface, Socket.SocketError | Schema.SchemaError, never>;
} = (wireguardInterface: WireguardInterface, wireguardConfig: WireguardConfig) =>
Effect.gen(function* () {
const listenPort = `listen_port=${wireguardConfig.ListenPort}\n` as const;
const privateKeyHex = Buffer.from(wireguardConfig.PrivateKey, "base64").toString("hex");
const privateKey = `private_key=${privateKeyHex}\n` as const;
const fwmark = Predicate.isNotUndefined(wireguardConfig.FirewallMark)
? (`fwmark=${wireguardConfig.FirewallMark}\n` as const)
: String.empty;
const peers = yield* Function.pipe(
wireguardConfig.Peers,
Array.map((peer) => Schema.encodeEffect(WireguardPeer.WireguardPeer)(peer)),
Array.map(Effect.flatMap((peer) => Schema.decodeEffect(WireguardPeer.WireguardUapiSetPeer)(peer))),
Effect.all,
Effect.map(Array.join("\n"))
);
const uapiConfig = `${privateKey}${listenPort}${fwmark}${peers}\n` as const;
yield* internalInterface.userspaceContact(wireguardInterface, `set=1\n${uapiConfig}\n`);
return wireguardInterface;
});
/** @internal */
export const getConfig: {
(
wireguardInterface: WireguardInterface,
address: (typeof InternetSchemas.CidrBlockFromString)["Encoded"]
): Effect.Effect<
Schema.Schema.Type<(typeof WireguardConfig)["uapi"]>,
Socket.SocketError | Schema.SchemaError,
never
>;
} = (wireguardInterface: WireguardInterface, address: (typeof InternetSchemas.CidrBlockFromString)["Encoded"]) =>
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.decodeEffect(WireguardPeer.WireguardUapiGetPeer, { onExcessProperty: "error" })(x)),
Array.map(Effect.flatMap((x) => Schema.encodeEffect(WireguardPeer.WireguardPeer["uapi"])(x))),
Effect.all
);
return yield* Schema.decodeEffect(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: WireguardInterface,
peer: WireguardPeer.WireguardPeer
): Effect.Effect<void, Socket.SocketError | Schema.SchemaError, never>;
} = (wireguardInterface: WireguardInterface, peer: WireguardPeer.WireguardPeer) =>
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" as const);
assert.ok(configBefore.Peers.find((p) => p.PublicKey === peer.PublicKey) === undefined);
// Add the peer to the interface
const a = yield* Schema.encodeEffect(WireguardPeer.WireguardPeer)(peer);
const b = yield* Schema.decodeEffect(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" as const);
assert.ok(configAfter.Peers.find((p) => p.PublicKey === peer.PublicKey) !== undefined);
});
/** @internal */
export const removePeer: {
(
wireguardInterface: WireguardInterface,
peer: WireguardPeer.WireguardPeer
): Effect.Effect<void, Socket.SocketError | Schema.SchemaError, never>;
} = (wireguardInterface: WireguardInterface, peer: WireguardPeer.WireguardPeer) =>
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" as const);
assert.ok(configBefore.Peers.find((p) => p.PublicKey === peer.PublicKey) !== undefined);
// Remove the peer from the interface
const a = yield* Schema.encodeEffect(WireguardPeer.WireguardPeer)(peer);
const b = yield* Schema.decodeEffect(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" as const);
assert.ok(configAfter.Peers.find((p) => p.PublicKey === peer.PublicKey) === undefined);
});
/** @internal */
export const streamPeerStats = (
wireguardInterface: WireguardInterface
): Stream.Stream<
ReadonlyArray<Schema.Schema.Type<(typeof WireguardPeer.WireguardPeer)["uapi"]>>,
Socket.SocketError | Schema.SchemaError,
never
> => {
const pull = getConfig(wireguardInterface, "0.0.0.0/0" as const);
const schedule = Schedule.spaced("1 second");
const stream = Stream.fromEffectSchedule(pull, schedule);
return Stream.map(stream, ({ Peers: peers }) => peers);
};