Browse Source

more nix config

pull/2678/head
connoralydon 1 month ago
parent
commit
af533b6f31
  1. 3
      Dockerfile
  2. 3
      Dockerfile.dev
  3. 43
      README.md
  4. 27
      flake.lock
  5. 71
      flake.nix
  6. 254
      nix/module.nix
  7. 105
      nix/package.nix
  8. 1
      privatekey
  9. 1
      publickey
  10. 59
      src/app/composables/useSubmit.ts
  11. 2
      src/app/pages/me.vue
  12. 11
      src/app/stores/auth.ts
  13. 6
      src/cli/clients/qr.ts
  14. 4
      src/cli/db.ts
  15. 4
      src/server/database/migrations/0001_classy_the_stranger.sql
  16. 40
      src/server/database/repositories/client/service.ts
  17. 6
      src/server/database/repositories/hooks/service.ts
  18. 12
      src/server/database/repositories/interface/service.ts
  19. 10
      src/server/database/repositories/userConfig/service.ts
  20. 170
      src/server/database/sqlite.ts
  21. 3
      src/server/utils/WireGuard.ts
  22. 135
      src/server/utils/config.ts
  23. 2
      src/server/utils/ip.ts
  24. 2
      src/server/utils/template.ts
  25. 28
      src/server/utils/wgHelper.ts

3
Dockerfile

@ -80,6 +80,9 @@ ENV HOST=0.0.0.0
ENV INSECURE=false
ENV INIT_ENABLED=false
ENV DISABLE_IPV6=false
ENV WG_INTERFACE_NAME=wg0
ENV WG_PORT=51820
ENV WG_STATE_DIR=/etc/wireguard
LABEL org.opencontainers.image.source=https://github.com/wg-easy/wg-easy

3
Dockerfile.dev

@ -30,6 +30,9 @@ ENV HOST=0.0.0.0
ENV INSECURE=true
ENV INIT_ENABLED=false
ENV DISABLE_IPV6=false
ENV WG_INTERFACE_NAME=wg0
ENV WG_PORT=51820
ENV WG_STATE_DIR=/etc/wireguard
# Install Dependencies
COPY src/package.json src/pnpm-lock.yaml src/pnpm-workspace.yaml ./

43
README.md

@ -52,6 +52,49 @@ This is a quick start guide to get you up and running with WireGuard Easy.
For a more detailed installation guide, please refer to the [Getting Started](https://wg-easy.github.io/wg-easy/latest/getting-started/) page.
### NixOS
This repository exposes a NixOS module through `nixosModules.default`:
```nix
{
inputs.wg-easy.url = "github:<owner>/wg-easy-nix";
outputs = { nixpkgs, wg-easy, ... }: {
nixosConfigurations.server = nixpkgs.lib.nixosSystem {
system = "x86_64-linux";
modules = [
wg-easy.nixosModules.default
{
services.wg-easy = {
enable = true;
interfaceName = "wg0";
wireguardPort = 51820;
uiPort = 51821;
stateDir = "/var/lib/wg-easy";
enableIPv6 = false;
defaultDns = [ "10.8.0.1" ];
defaultAllowedIps = [ "10.8.0.1/32" ];
defaultPersistentKeepalive = 25;
firewallEnabled = true;
defaultServerAllowedIps = [ ];
defaultFirewallAllowedIps = [ "10.8.0.1/32" ];
forceUpdateClients = false;
openFirewall = true;
};
}
];
};
};
}
```
WireGuard's conventional default port is `51820`. Set `wireguardPort = 51280;` if you want that port instead.
The `defaultDns`, `defaultAllowedIps`, `defaultPersistentKeepalive`, `defaultServerAllowedIps`, and `defaultFirewallAllowedIps` settings are stamped onto new clients when they are created. Existing clients keep their previous values unless `forceUpdateClients = true;` is set.
`defaultAllowedIps` goes into generated client configs and controls what each client routes through the tunnel. `defaultServerAllowedIps` adds extra server-side peer routes for subnets behind a client; leave it empty for normal clients. `defaultFirewallAllowedIps` is the per-client destination allowlist used by wg-easy firewall filtering. Per-client firewall filtering currently applies to forwarded traffic, not local `INPUT` traffic to the server's own `10.8.0.1` address.
### 1. Install Docker
If you haven't installed Docker yet, install it by running as root:

27
flake.lock

@ -0,0 +1,27 @@
{
"nodes": {
"nixpkgs": {
"locked": {
"lastModified": 1779508470,
"narHash": "sha256-Ap9KJX+5xHIn3bPIpfNgT6MEXdAECECwo4/rmlQD74M=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "29916453413845e54a65b8a1cf996842300cd299",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "nixos-unstable",
"repo": "nixpkgs",
"type": "github"
}
},
"root": {
"inputs": {
"nixpkgs": "nixpkgs"
}
}
},
"root": "root",
"version": 7
}

71
flake.nix

@ -0,0 +1,71 @@
{
description = "Nix package and NixOS module for wg-easy";
inputs.nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
outputs =
{ self, nixpkgs }:
let
linuxSystems = [
"x86_64-linux"
"aarch64-linux"
];
formatterSystems = linuxSystems ++ [
"aarch64-darwin"
"x86_64-darwin"
];
forAllSystems = nixpkgs.lib.genAttrs linuxSystems;
forFormatterSystems = nixpkgs.lib.genAttrs formatterSystems;
pkgsFor = system: import nixpkgs { inherit system; };
in
{
packages = forAllSystems (system: {
wg-easy = (pkgsFor system).callPackage ./nix/package.nix { };
default = self.packages.${system}.wg-easy;
});
checks = forAllSystems (system: {
package = self.packages.${system}.wg-easy;
});
devShells = forAllSystems (
system:
let
pkgs = pkgsFor system;
in
{
default = pkgs.mkShell {
packages = with pkgs; [
nodejs_24
pnpm_11
wireguard-tools
wireguard-go
iptables
nftables
];
};
}
);
formatter = forFormatterSystems (
system:
let
pkgs = pkgsFor system;
in
pkgs.writeShellApplication {
name = "wg-easy-nix-fmt";
runtimeInputs = [ pkgs.nixfmt ];
text = ''
if [ "$#" -eq 0 ]; then
set -- flake.nix nix/*.nix
fi
exec nixfmt "$@"
'';
}
);
nixosModules.default = import ./nix/module.nix;
nixosModules.wg-easy = self.nixosModules.default;
};
}

254
nix/module.nix

@ -0,0 +1,254 @@
{
config,
lib,
pkgs,
...
}:
let
cfg = config.services.wg-easy;
boolString = value: if value then "true" else "false";
listString = values: lib.concatStringsSep "," values;
validListItems =
values: lib.all (value: value != "" && builtins.match ".*,.*" value == null) values;
interfaceNamePattern = "[A-Za-z0-9_.-]{1,15}";
in
{
options.services.wg-easy = {
enable = lib.mkEnableOption "wg-easy";
package = lib.mkOption {
type = lib.types.package;
default = pkgs.callPackage ./package.nix { };
defaultText = lib.literalExpression "pkgs.callPackage ./package.nix { }";
description = "wg-easy package to run.";
};
interfaceName = lib.mkOption {
type = lib.types.str;
default = "wg0";
description = "WireGuard interface name managed by wg-easy.";
};
wireguardPort = lib.mkOption {
type = lib.types.port;
default = 51820;
description = "UDP port used by the WireGuard interface.";
};
uiPort = lib.mkOption {
type = lib.types.port;
default = 51821;
description = "TCP port used by the wg-easy web UI.";
};
stateDir = lib.mkOption {
type = lib.types.str;
default = "/var/lib/wg-easy";
description = "Writable directory for the SQLite database and generated WireGuard config.";
};
externalInterface = lib.mkOption {
type = lib.types.nullOr lib.types.str;
default = null;
description = "Optional egress network interface used in wg-easy NAT hooks.";
};
enableIPv6 = lib.mkOption {
type = lib.types.bool;
default = false;
description = "Enable IPv6 addresses and IPv6 firewall hooks in wg-easy.";
};
defaultDns = lib.mkOption {
type = lib.types.nullOr (lib.types.listOf lib.types.str);
default = null;
description = "DNS servers to stamp onto new clients. Leave null to use wg-easy's global default.";
};
defaultAllowedIps = lib.mkOption {
type = lib.types.nullOr (lib.types.listOf lib.types.str);
default = null;
description = "Allowed IPs to stamp onto new client configs. Leave null to use wg-easy's global default.";
};
defaultServerAllowedIps = lib.mkOption {
type = lib.types.nullOr (lib.types.listOf lib.types.str);
default = null;
description = "Additional server-side peer AllowedIPs to stamp onto new clients.";
};
defaultFirewallAllowedIps = lib.mkOption {
type = lib.types.nullOr (lib.types.listOf lib.types.str);
default = null;
description = "Firewall-enforced allowed destinations to stamp onto new clients.";
};
defaultPersistentKeepalive = lib.mkOption {
type = lib.types.nullOr (lib.types.ints.between 0 65535);
default = 25;
description = "Persistent keepalive to stamp onto new clients. Set null to preserve wg-easy's configured default.";
};
firewallEnabled = lib.mkOption {
type = lib.types.nullOr lib.types.bool;
default = null;
description = "Enable or disable wg-easy per-client firewall filtering. Leave null to preserve wg-easy's configured value.";
};
forceUpdateClients = lib.mkOption {
type = lib.types.bool;
default = false;
description = "Overwrite existing clients with configured Nix defaults on service startup.";
};
insecure = lib.mkOption {
type = lib.types.bool;
default = false;
description = "Serve the UI as insecure HTTP. Keep false when using HTTPS through a reverse proxy.";
};
openFirewall = lib.mkOption {
type = lib.types.bool;
default = true;
description = "Open the WireGuard UDP port in the NixOS firewall.";
};
openWebUIFirewall = lib.mkOption {
type = lib.types.bool;
default = false;
description = "Open the wg-easy web UI TCP port in the NixOS firewall.";
};
extraEnvironment = lib.mkOption {
type = lib.types.attrsOf lib.types.str;
default = { };
description = "Additional environment variables for the wg-easy service.";
};
};
config = lib.mkIf cfg.enable {
assertions = [
{
assertion = builtins.match interfaceNamePattern cfg.interfaceName != null;
message = "services.wg-easy.interfaceName must be 1-15 characters using only letters, numbers, '.', '_', or '-'.";
}
{
assertion =
cfg.externalInterface == null || builtins.match interfaceNamePattern cfg.externalInterface != null;
message = "services.wg-easy.externalInterface must be 1-15 characters using only letters, numbers, '.', '_', or '-'.";
}
{
assertion = lib.hasPrefix "/" cfg.stateDir;
message = "services.wg-easy.stateDir must be an absolute path.";
}
{
assertion = cfg.defaultDns == null || validListItems cfg.defaultDns;
message = "services.wg-easy.defaultDns entries must be non-empty and must not contain commas.";
}
{
assertion =
cfg.defaultAllowedIps == null
|| (cfg.defaultAllowedIps != [ ] && validListItems cfg.defaultAllowedIps);
message = "services.wg-easy.defaultAllowedIps must be null or a non-empty list without commas.";
}
{
assertion = cfg.defaultServerAllowedIps == null || validListItems cfg.defaultServerAllowedIps;
message = "services.wg-easy.defaultServerAllowedIps entries must be non-empty and must not contain commas.";
}
{
assertion =
cfg.defaultFirewallAllowedIps == null
|| (cfg.defaultFirewallAllowedIps != [ ] && validListItems cfg.defaultFirewallAllowedIps);
message = "services.wg-easy.defaultFirewallAllowedIps must be null or a non-empty list without commas.";
}
];
boot.kernel.sysctl = {
"net.ipv4.ip_forward" = lib.mkDefault 1;
"net.ipv4.conf.all.src_valid_mark" = lib.mkDefault 1;
}
// lib.optionalAttrs cfg.enableIPv6 {
"net.ipv6.conf.all.disable_ipv6" = lib.mkDefault 0;
"net.ipv6.conf.all.forwarding" = lib.mkDefault 1;
"net.ipv6.conf.default.forwarding" = lib.mkDefault 1;
};
networking.firewall.allowedUDPPorts = lib.mkIf cfg.openFirewall [ cfg.wireguardPort ];
networking.firewall.allowedTCPPorts = lib.mkIf cfg.openWebUIFirewall [ cfg.uiPort ];
systemd.tmpfiles.rules = [
"d ${cfg.stateDir} 0700 root root -"
];
systemd.services.wg-easy = {
description = "WireGuard Easy";
documentation = [ "https://wg-easy.github.io/wg-easy/latest/" ];
wantedBy = [ "multi-user.target" ];
wants = [ "network-online.target" ];
after = [ "network-online.target" ];
environment = {
DISABLE_IPV6 = boolString (!cfg.enableIPv6);
INSECURE = boolString cfg.insecure;
PORT = toString cfg.uiPort;
WG_FORCE_UPDATE_CLIENTS = boolString cfg.forceUpdateClients;
WG_INTERFACE_NAME = cfg.interfaceName;
WG_PORT = toString cfg.wireguardPort;
WG_STATE_DIR = cfg.stateDir;
}
// lib.optionalAttrs (cfg.defaultDns != null) {
WG_DEFAULT_DNS = listString cfg.defaultDns;
}
// lib.optionalAttrs (cfg.defaultAllowedIps != null) {
WG_DEFAULT_ALLOWED_IPS = listString cfg.defaultAllowedIps;
}
// lib.optionalAttrs (cfg.defaultServerAllowedIps != null) {
WG_DEFAULT_SERVER_ALLOWED_IPS = listString cfg.defaultServerAllowedIps;
}
// lib.optionalAttrs (cfg.defaultFirewallAllowedIps != null) {
WG_DEFAULT_FIREWALL_ALLOWED_IPS = listString cfg.defaultFirewallAllowedIps;
}
// lib.optionalAttrs (cfg.defaultPersistentKeepalive != null) {
WG_DEFAULT_PERSISTENT_KEEPALIVE = toString cfg.defaultPersistentKeepalive;
}
// lib.optionalAttrs (cfg.firewallEnabled != null) {
WG_FIREWALL_ENABLED = boolString cfg.firewallEnabled;
}
// lib.optionalAttrs (cfg.externalInterface != null) {
WG_DEVICE = cfg.externalInterface;
}
// cfg.extraEnvironment;
path = with pkgs; [
bash
coreutils
gawk
gnugrep
gnused
iproute2
iptables
kmod
nftables
procps
wireguard-go
wireguard-tools
];
serviceConfig = {
ExecStart = lib.getExe cfg.package;
Restart = "on-failure";
RestartSec = "5s";
User = "root";
AmbientCapabilities = [
"CAP_NET_ADMIN"
"CAP_SYS_MODULE"
];
CapabilityBoundingSet = [
"CAP_NET_ADMIN"
"CAP_SYS_MODULE"
];
};
};
};
}

105
nix/package.nix

@ -0,0 +1,105 @@
{
lib,
stdenv,
nodejs_24,
pnpm_10,
pnpmConfigHook,
fetchPnpmDeps,
makeWrapper,
python3,
pkg-config,
bash,
coreutils,
gawk,
gnugrep,
gnused,
iproute2,
iptables,
kmod,
nftables,
procps,
wireguard-go,
wireguard-tools,
}:
let
nodejs = nodejs_24;
runtimePath = lib.makeBinPath [
bash
coreutils
gawk
gnugrep
gnused
iproute2
iptables
kmod
nftables
procps
wireguard-go
wireguard-tools
];
in
stdenv.mkDerivation (finalAttrs: {
pname = "wg-easy";
version = "15.3.0";
src = ../src;
CI = true;
nativeBuildInputs = [
nodejs
pnpm_10
pnpmConfigHook
makeWrapper
python3
pkg-config
];
pnpmDeps = fetchPnpmDeps {
inherit (finalAttrs) pname version src;
fetcherVersion = 3;
hash = "sha256-+fvK2wzd3AbOeUzeDhMXjW8rO2nkv1KziQrW6SNqm+g=";
};
buildPhase = ''
runHook preBuild
pnpm build
runHook postBuild
'';
installPhase = ''
runHook preInstall
mkdir -p $out/share/wg-easy $out/bin
cp -R .output/* $out/share/wg-easy/
mkdir -p $out/share/wg-easy/server/database
cp -R server/database/migrations $out/share/wg-easy/server/database/migrations
if [ -e node_modules/libsql ]; then
mkdir -p $out/share/wg-easy/server/node_modules
cp -RL node_modules/libsql $out/share/wg-easy/server/node_modules/libsql
fi
makeWrapper ${lib.getExe nodejs} $out/bin/wg-easy \
--run "cd $out/share/wg-easy" \
--add-flags "$out/share/wg-easy/server/index.mjs" \
--prefix PATH : ${runtimePath}
makeWrapper ${lib.getExe nodejs} $out/bin/wg-easy-cli \
--run "cd $out/share/wg-easy" \
--add-flags "$out/share/wg-easy/server/cli.mjs" \
--prefix PATH : ${runtimePath}
runHook postInstall
'';
meta = {
description = "WireGuard VPN server with a web-based admin UI";
homepage = "https://github.com/wg-easy/wg-easy";
license = lib.licenses.agpl3Only;
platforms = lib.platforms.linux;
mainProgram = "wg-easy";
};
})

1
privatekey

@ -0,0 +1 @@
8PLg+fY2dl4JuoWZvsmmumA6rzFn8tvb5bg/juCuc0I=

1
publickey

@ -0,0 +1 @@
VjyJs31bpDkEqlSEERvjNCjIDj7fRY0Odc69Eo665Qo=

59
src/app/composables/useSubmit.ts

@ -1,46 +1,42 @@
import type {
NitroFetchRequest,
NitroFetchOptions,
TypedInternalResponse,
ExtractedRouteMethod,
} from 'nitropack/types';
import { FetchError } from 'ofetch';
type RevertFn<
R extends NitroFetchRequest,
T = unknown,
O extends NitroFetchOptions<R> = NitroFetchOptions<R>,
> = (
type SubmitResponse = {
status?: string;
type?: string;
uri?: string;
key?: string;
};
type SubmitOptions = {
method: 'post' | 'delete' | 'put' | 'patch';
};
type SubmitFetch = (
request: string,
options: SubmitOptions & { body: unknown }
) => Promise<SubmitResponse>;
type RevertFn = (
success: boolean,
data:
| TypedInternalResponse<
R,
T,
NitroFetchOptions<R> extends O ? 'get' : ExtractedRouteMethod<R, O>
>
| undefined
data: SubmitResponse | undefined
) => Promise<void>;
type SubmitOpts<
R extends NitroFetchRequest,
T = unknown,
O extends NitroFetchOptions<R> = NitroFetchOptions<R>,
> = {
revert: RevertFn<R, T, O>;
type SubmitOpts = {
revert: RevertFn;
successMsg?: string;
noSuccessToast?: boolean;
};
export function useSubmit<
R extends NitroFetchRequest,
O extends NitroFetchOptions<R> & { body?: never },
T = unknown,
>(url: R, options: O, opts: SubmitOpts<R, T, O>) {
export function useSubmit(
url: string,
options: SubmitOptions,
opts: SubmitOpts
) {
const toast = useToast();
return async (data: unknown) => {
try {
const res = await $fetch(url, {
const res = await ($fetch as SubmitFetch)(url, {
...options,
body: data,
});
@ -52,8 +48,7 @@ export function useSubmit<
});
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
await opts.revert(true, res as any);
await opts.revert(true, res);
} catch (e) {
if (e instanceof FetchError) {
toast.showToast({

2
src/app/pages/me.vue

@ -177,7 +177,7 @@ const _setup2fa = useSubmit(
},
{
revert: async (success, data) => {
if (success && data?.type === 'setup') {
if (success && data?.type === 'setup' && data.uri && data.key) {
const qrcode = encodeQR(data.uri, 'svg', {
ecc: 'high',
scale: 4,

11
src/app/stores/auth.ts

@ -1,11 +1,20 @@
import type { H3Event } from 'h3';
import type { SharedPublicUser } from '~~/shared/utils/permissions';
type SessionFetch = (
request: string,
options: { method: 'get' }
) => Promise<SharedPublicUser | null>;
export const useAuthStore = defineStore('Auth', () => {
const userData = useState<SharedPublicUser | null>('user-data', () => null);
async function getSession(event?: H3Event) {
const fetch = event?.$fetch || $fetch;
let fetch = $fetch as unknown as SessionFetch;
if (event) {
fetch = event.$fetch as unknown as SessionFetch;
}
try {
const data = await fetch('/api/session', {
method: 'get',

6
src/cli/clients/qr.ts

@ -6,6 +6,8 @@ import { wg } from '../../server/utils/wgHelper';
import { encodeQRCodeTerm } from '../../server/utils/qr';
import { db, schema } from '../db';
const interfaceName = process.env.WG_INTERFACE_NAME?.trim() || 'wg0';
export default defineCommand({
meta: {
name: 'clients:qr',
@ -34,7 +36,7 @@ export default defineCommand({
consola.info('Generating QR code for client...');
const wgInterface = await db.query.wgInterface.findFirst({
where: eq(schema.wgInterface.name, 'wg0'),
where: eq(schema.wgInterface.name, interfaceName),
});
if (!wgInterface) {
consola.error('WireGuard interface not found');
@ -42,7 +44,7 @@ export default defineCommand({
}
const userConfig = await db.query.userConfig.findFirst({
where: eq(schema.userConfig.id, 'wg0'),
where: eq(schema.userConfig.id, interfaceName),
});
if (!userConfig) {
consola.error('User config not found');

4
src/cli/db.ts

@ -3,8 +3,8 @@ import { drizzle } from 'drizzle-orm/libsql';
import * as schema from '../server/database/schema';
//const client = createClient({ url: 'file:../data/wg-easy.db' });
const client = createClient({ url: 'file:/etc/wireguard/wg-easy.db' });
const stateDir = process.env.WG_STATE_DIR?.trim() || '/etc/wireguard';
const client = createClient({ url: `file:${stateDir}/wg-easy.db` });
export const db = drizzle({ client, schema });
export { schema };

4
src/server/database/migrations/0001_classy_the_stranger.sql

@ -9,9 +9,9 @@ INSERT INTO `hooks_table` (`id`, `pre_up`, `post_up`, `pre_down`, `post_down`)
VALUES (
'wg0',
'',
'iptables -t nat -A POSTROUTING -s {{ipv4Cidr}} -o {{device}} -j MASQUERADE; iptables -A INPUT -p udp -m udp --dport {{port}} -j ACCEPT; iptables -A FORWARD -i wg0 -j ACCEPT; iptables -A FORWARD -o wg0 -j ACCEPT; ip6tables -t nat -A POSTROUTING -s {{ipv6Cidr}} -o {{device}} -j MASQUERADE; ip6tables -A INPUT -p udp -m udp --dport {{port}} -j ACCEPT; ip6tables -A FORWARD -i wg0 -j ACCEPT; ip6tables -A FORWARD -o wg0 -j ACCEPT;',
'iptables -t nat -A POSTROUTING -s {{ipv4Cidr}} -o {{device}} -j MASQUERADE; iptables -A INPUT -p udp -m udp --dport {{port}} -j ACCEPT; iptables -A FORWARD -i {{name}} -j ACCEPT; iptables -A FORWARD -o {{name}} -j ACCEPT; ip6tables -t nat -A POSTROUTING -s {{ipv6Cidr}} -o {{device}} -j MASQUERADE; ip6tables -A INPUT -p udp -m udp --dport {{port}} -j ACCEPT; ip6tables -A FORWARD -i {{name}} -j ACCEPT; ip6tables -A FORWARD -o {{name}} -j ACCEPT;',
'',
'iptables -t nat -D POSTROUTING -s {{ipv4Cidr}} -o {{device}} -j MASQUERADE; iptables -D INPUT -p udp -m udp --dport {{port}} -j ACCEPT; iptables -D FORWARD -i wg0 -j ACCEPT; iptables -D FORWARD -o wg0 -j ACCEPT; ip6tables -t nat -D POSTROUTING -s {{ipv6Cidr}} -o {{device}} -j MASQUERADE; ip6tables -D INPUT -p udp -m udp --dport {{port}} -j ACCEPT; ip6tables -D FORWARD -i wg0 -j ACCEPT; ip6tables -D FORWARD -o wg0 -j ACCEPT;'
'iptables -t nat -D POSTROUTING -s {{ipv4Cidr}} -o {{device}} -j MASQUERADE; iptables -D INPUT -p udp -m udp --dport {{port}} -j ACCEPT; iptables -D FORWARD -i {{name}} -j ACCEPT; iptables -D FORWARD -o {{name}} -j ACCEPT; ip6tables -t nat -D POSTROUTING -s {{ipv6Cidr}} -o {{device}} -j MASQUERADE; ip6tables -D INPUT -p udp -m udp --dport {{port}} -j ACCEPT; ip6tables -D FORWARD -i {{name}} -j ACCEPT; ip6tables -D FORWARD -o {{name}} -j ACCEPT;'
);
--> statement-breakpoint
INSERT INTO `user_configs_table` (`id`, `default_mtu`, `default_persistent_keepalive`, `default_dns`, `default_allowed_ips`, `host`, `port`)

40
src/server/database/repositories/client/service.ts

@ -9,6 +9,31 @@ import type {
import type { DBType } from '#db/sqlite';
import { wgInterface, userConfig } from '#db/schema';
function nixManagedClientDefaults(fallback?: {
allowedIps?: string[];
dns?: string[];
}) {
return {
...(WG_CLIENT_DEFAULTS.ALLOWED_IPS !== undefined
? { allowedIps: WG_CLIENT_DEFAULTS.ALLOWED_IPS }
: fallback?.allowedIps !== undefined
? { allowedIps: fallback.allowedIps }
: {}),
...(WG_CLIENT_DEFAULTS.DNS !== undefined
? { dns: WG_CLIENT_DEFAULTS.DNS }
: fallback?.dns !== undefined
? { dns: fallback.dns }
: {}),
serverAllowedIps: WG_CLIENT_DEFAULTS.SERVER_ALLOWED_IPS ?? [],
...(WG_CLIENT_DEFAULTS.FIREWALL_ALLOWED_IPS !== undefined
? { firewallIps: WG_CLIENT_DEFAULTS.FIREWALL_ALLOWED_IPS }
: {}),
...(WG_CLIENT_DEFAULTS.PERSISTENT_KEEPALIVE !== undefined
? { persistentKeepalive: WG_CLIENT_DEFAULTS.PERSISTENT_KEEPALIVE }
: {}),
};
}
function createPreparedStatement(db: DBType) {
return {
findAll: db.query.client
@ -177,7 +202,7 @@ export class ClientService {
const clients = await tx.query.client.findMany().execute();
const clientInterface = await tx.query.wgInterface
.findFirst({
where: eq(wgInterface.name, 'wg0'),
where: eq(wgInterface.name, WG_ENV.INTERFACE_NAME),
})
.execute();
@ -206,7 +231,7 @@ export class ClientService {
name,
// TODO: properly assign user id
userId: 1,
interfaceId: 'wg0',
interfaceId: WG_ENV.INTERFACE_NAME,
expiresAt,
privateKey,
publicKey,
@ -223,7 +248,7 @@ export class ClientService {
i4: clientConfig.defaultI4,
i5: clientConfig.defaultI5,
persistentKeepalive: clientConfig.defaultPersistentKeepalive,
serverAllowedIps: [],
...nixManagedClientDefaults(),
enabled: true,
})
.returning({ clientId: client.id })
@ -243,7 +268,7 @@ export class ClientService {
return this.#db.transaction(async (tx) => {
const clientInterface = await tx.query.wgInterface
.findFirst({
where: eq(wgInterface.name, 'wg0'),
where: eq(wgInterface.name, WG_ENV.INTERFACE_NAME),
})
.execute();
@ -279,7 +304,7 @@ export class ClientService {
.values({
name,
userId: 1,
interfaceId: 'wg0',
interfaceId: WG_ENV.INTERFACE_NAME,
privateKey,
publicKey,
preSharedKey,
@ -296,7 +321,10 @@ export class ClientService {
allowedIps: clientConfig.defaultAllowedIps,
dns: clientConfig.defaultDns,
persistentKeepalive: clientConfig.defaultPersistentKeepalive,
serverAllowedIps: [],
...nixManagedClientDefaults({
allowedIps: clientConfig.defaultAllowedIps,
dns: clientConfig.defaultDns,
}),
enabled,
})
.execute();

6
src/server/database/repositories/hooks/service.ts

@ -21,7 +21,9 @@ export class HooksService {
}
async get() {
const hooks = await this.#statements.get.execute({ interface: 'wg0' });
const hooks = await this.#statements.get.execute({
interface: WG_ENV.INTERFACE_NAME,
});
if (!hooks) {
throw new Error('Hooks not found');
}
@ -32,7 +34,7 @@ export class HooksService {
return this.#db
.update(hooks)
.set(data)
.where(eq(hooks.id, 'wg0'))
.where(eq(hooks.id, WG_ENV.INTERFACE_NAME))
.execute();
}
}

12
src/server/database/repositories/interface/service.ts

@ -39,7 +39,7 @@ export class InterfaceService {
async get() {
const wgInterface = await this.#statements.get.execute({
interface: 'wg0',
interface: WG_ENV.INTERFACE_NAME,
});
if (!wgInterface) {
throw new Error('Interface not found');
@ -49,7 +49,7 @@ export class InterfaceService {
updateKeyPair(privateKey: string, publicKey: string) {
return this.#statements.updateKeyPair.execute({
interface: 'wg0',
interface: WG_ENV.INTERFACE_NAME,
privateKey,
publicKey,
});
@ -59,13 +59,13 @@ export class InterfaceService {
return this.#db
.update(wgInterface)
.set(data)
.where(eq(wgInterface.name, 'wg0'))
.where(eq(wgInterface.name, WG_ENV.INTERFACE_NAME))
.execute();
}
setFirewallEnabled(firewallEnabled: boolean) {
return this.#statements.setFirewallEnabled.execute({
interface: 'wg0',
interface: WG_ENV.INTERFACE_NAME,
firewallEnabled,
});
}
@ -74,7 +74,7 @@ export class InterfaceService {
return this.#db.transaction(async (tx) => {
const oldCidr = await tx.query.wgInterface
.findFirst({
where: eq(wgInterface.name, 'wg0'),
where: eq(wgInterface.name, WG_ENV.INTERFACE_NAME),
columns: { ipv4Cidr: true, ipv6Cidr: true },
})
.execute();
@ -86,7 +86,7 @@ export class InterfaceService {
await tx
.update(wgInterface)
.set(data)
.where(eq(wgInterface.name, 'wg0'))
.where(eq(wgInterface.name, WG_ENV.INTERFACE_NAME))
.execute();
const clients = await tx.query.client.findMany().execute();

10
src/server/database/repositories/userConfig/service.ts

@ -22,7 +22,9 @@ export class UserConfigService {
}
async get() {
const userConfig = await this.#statements.get.execute({ interface: 'wg0' });
const userConfig = await this.#statements.get.execute({
interface: WG_ENV.INTERFACE_NAME,
});
if (!userConfig) {
throw new Error('User config not found');
@ -43,13 +45,13 @@ export class UserConfigService {
await tx
.update(userConfig)
.set({ host, port })
.where(eq(userConfig.id, 'wg0'))
.where(eq(userConfig.id, WG_ENV.INTERFACE_NAME))
.execute();
await tx
.update(wgInterface)
.set({ port })
.where(eq(wgInterface.name, 'wg0'))
.where(eq(wgInterface.name, WG_ENV.INTERFACE_NAME))
.execute();
});
}
@ -58,7 +60,7 @@ export class UserConfigService {
return this.#db
.update(userConfig)
.set(data)
.where(eq(userConfig.id, 'wg0'))
.where(eq(userConfig.id, WG_ENV.INTERFACE_NAME))
.execute();
}
}

170
src/server/database/sqlite.ts

@ -1,3 +1,4 @@
import fs from 'node:fs/promises';
import { drizzle } from 'drizzle-orm/libsql';
import { migrate as drizzleMigrate } from 'drizzle-orm/libsql/migrator';
import { createClient } from '@libsql/client';
@ -15,17 +16,23 @@ import { OneTimeLinkService } from './repositories/oneTimeLink/service';
const DB_DEBUG = createDebug('Database');
const client = createClient({ url: 'file:/etc/wireguard/wg-easy.db' });
await fs.mkdir(WG_ENV.STATE_DIR, { recursive: true, mode: 0o700 });
const client = createClient({ url: `file:${WG_ENV.STATE_DIR}/wg-easy.db` });
const db = drizzle({ client, schema });
export async function connect() {
await migrate();
await normalizeInterfaceName(db);
const dbService = new DBService(db);
if (WG_INITIAL_ENV.ENABLED) {
await initialSetup(dbService);
}
await applyRuntimeInterfaceConfig(db);
await applyNixClientDefaults(db);
if (WG_ENV.DISABLE_IPV6) {
DB_DEBUG('Warning: Disabling IPv6...');
await disableIpv6(db);
@ -120,16 +127,167 @@ async function initialSetup(db: DBServiceType) {
}
}
function normalizeHookTemplate(hook: string) {
return hook
.replace(/-i wg0/g, '-i {{name}}')
.replace(/-o wg0/g, '-o {{name}}');
}
async function normalizeInterfaceName(db: DBType) {
const interfaceName = WG_ENV.INTERFACE_NAME;
if (interfaceName === 'wg0') {
return;
}
const configuredInterface = await db.query.wgInterface
.findFirst({
where: eq(schema.wgInterface.name, interfaceName),
})
.execute();
if (configuredInterface) {
return;
}
const defaultInterface = await db.query.wgInterface
.findFirst({
where: eq(schema.wgInterface.name, 'wg0'),
})
.execute();
if (!defaultInterface) {
return;
}
DB_DEBUG(`Renaming default interface wg0 to ${interfaceName}...`);
await db.transaction(async (tx) => {
await tx
.update(schema.wgInterface)
.set({ name: interfaceName })
.where(eq(schema.wgInterface.name, 'wg0'))
.execute();
await tx
.update(schema.hooks)
.set({ id: interfaceName })
.where(eq(schema.hooks.id, 'wg0'))
.execute();
await tx
.update(schema.userConfig)
.set({ id: interfaceName })
.where(eq(schema.userConfig.id, 'wg0'))
.execute();
await tx
.update(schema.client)
.set({ interfaceId: interfaceName })
.where(eq(schema.client.interfaceId, 'wg0'))
.execute();
});
}
async function applyRuntimeInterfaceConfig(db: DBType) {
const interfaceName = WG_ENV.INTERFACE_NAME;
const interfaceConfig: {
port: number;
device?: string;
firewallEnabled?: boolean;
} = WG_ENV.WG_DEVICE
? { port: WG_ENV.WG_PORT, device: WG_ENV.WG_DEVICE }
: { port: WG_ENV.WG_PORT };
if (WG_ENV.FIREWALL_ENABLED !== undefined) {
interfaceConfig.firewallEnabled = WG_ENV.FIREWALL_ENABLED;
}
await db.transaction(async (tx) => {
await tx
.update(schema.wgInterface)
.set(interfaceConfig)
.where(eq(schema.wgInterface.name, interfaceName))
.execute();
await tx
.update(schema.userConfig)
.set({ port: WG_ENV.WG_PORT })
.where(eq(schema.userConfig.id, interfaceName))
.execute();
const hooks = await tx.query.hooks
.findFirst({
where: eq(schema.hooks.id, interfaceName),
})
.execute();
if (!hooks) {
return;
}
const nextPostUp = normalizeHookTemplate(hooks.postUp);
const nextPostDown = normalizeHookTemplate(hooks.postDown);
if (nextPostUp !== hooks.postUp || nextPostDown !== hooks.postDown) {
await tx
.update(schema.hooks)
.set({
postUp: nextPostUp,
postDown: nextPostDown,
})
.where(eq(schema.hooks.id, interfaceName))
.execute();
}
});
}
async function applyNixClientDefaults(db: DBType) {
if (!WG_CLIENT_DEFAULTS.FORCE_UPDATE_CLIENTS) {
return;
}
const clientDefaults: Partial<typeof schema.client.$inferInsert> = {};
if (WG_CLIENT_DEFAULTS.DNS !== undefined) {
clientDefaults.dns = WG_CLIENT_DEFAULTS.DNS;
}
if (WG_CLIENT_DEFAULTS.ALLOWED_IPS !== undefined) {
clientDefaults.allowedIps = WG_CLIENT_DEFAULTS.ALLOWED_IPS;
}
if (WG_CLIENT_DEFAULTS.SERVER_ALLOWED_IPS !== undefined) {
clientDefaults.serverAllowedIps = WG_CLIENT_DEFAULTS.SERVER_ALLOWED_IPS;
}
if (WG_CLIENT_DEFAULTS.FIREWALL_ALLOWED_IPS !== undefined) {
clientDefaults.firewallIps = WG_CLIENT_DEFAULTS.FIREWALL_ALLOWED_IPS;
}
if (WG_CLIENT_DEFAULTS.PERSISTENT_KEEPALIVE !== undefined) {
clientDefaults.persistentKeepalive = WG_CLIENT_DEFAULTS.PERSISTENT_KEEPALIVE;
}
if (Object.keys(clientDefaults).length === 0) {
return;
}
DB_DEBUG('Force updating clients with Nix-managed defaults...');
await db
.update(schema.client)
.set(clientDefaults)
.where(eq(schema.client.interfaceId, WG_ENV.INTERFACE_NAME))
.execute();
}
async function disableIpv6(db: DBType) {
// This should match the initial value migration
const postUpMatch =
' ip6tables -t nat -A POSTROUTING -s {{ipv6Cidr}} -o {{device}} -j MASQUERADE; ip6tables -A INPUT -p udp -m udp --dport {{port}} -j ACCEPT; ip6tables -A FORWARD -i wg0 -j ACCEPT; ip6tables -A FORWARD -o wg0 -j ACCEPT;';
' ip6tables -t nat -A POSTROUTING -s {{ipv6Cidr}} -o {{device}} -j MASQUERADE; ip6tables -A INPUT -p udp -m udp --dport {{port}} -j ACCEPT; ip6tables -A FORWARD -i {{name}} -j ACCEPT; ip6tables -A FORWARD -o {{name}} -j ACCEPT;';
const postDownMatch =
' ip6tables -t nat -D POSTROUTING -s {{ipv6Cidr}} -o {{device}} -j MASQUERADE; ip6tables -D INPUT -p udp -m udp --dport {{port}} -j ACCEPT; ip6tables -D FORWARD -i wg0 -j ACCEPT; ip6tables -D FORWARD -o wg0 -j ACCEPT;';
' ip6tables -t nat -D POSTROUTING -s {{ipv6Cidr}} -o {{device}} -j MASQUERADE; ip6tables -D INPUT -p udp -m udp --dport {{port}} -j ACCEPT; ip6tables -D FORWARD -i {{name}} -j ACCEPT; ip6tables -D FORWARD -o {{name}} -j ACCEPT;';
await db.transaction(async (tx) => {
const hooks = await tx.query.hooks.findFirst({
where: eq(schema.hooks.id, 'wg0'),
where: eq(schema.hooks.id, WG_ENV.INTERFACE_NAME),
});
if (!hooks) {
@ -144,7 +302,7 @@ async function disableIpv6(db: DBType) {
postUp: hooks.postUp.replace(postUpMatch, ''),
postDown: hooks.postDown.replace(postDownMatch, ''),
})
.where(eq(schema.hooks.id, 'wg0'))
.where(eq(schema.hooks.id, WG_ENV.INTERFACE_NAME))
.execute();
} else {
DB_DEBUG('IPv6 Post Up hooks already disabled, skipping...');
@ -157,7 +315,7 @@ async function disableIpv6(db: DBType) {
postUp: hooks.postUp.replace(postUpMatch, ''),
postDown: hooks.postDown.replace(postDownMatch, ''),
})
.where(eq(schema.hooks.id, 'wg0'))
.where(eq(schema.hooks.id, WG_ENV.INTERFACE_NAME))
.execute();
} else {
DB_DEBUG('IPv6 Post Down hooks already disabled, skipping...');

3
src/server/utils/WireGuard.ts

@ -62,8 +62,9 @@ class WireGuard {
result.push('');
WG_DEBUG('Saving Config...');
await fs.mkdir(WG_ENV.STATE_DIR, { recursive: true, mode: 0o700 });
await fs.writeFile(
`/etc/wireguard/${wgInterface.name}.conf`,
`${WG_ENV.STATE_DIR}/${wgInterface.name}.conf`,
result.join('\n\n'),
{
mode: 0o600,

135
src/server/utils/config.ts

@ -5,6 +5,106 @@ export const RELEASE = 'v' + packageJson.version;
export const SERVER_DEBUG = createDebug('Server');
const DEFAULT_INTERFACE_NAME = 'wg0';
const DEFAULT_UI_PORT = '51821';
const DEFAULT_WG_PORT = 51820;
const DEFAULT_STATE_DIR = '/etc/wireguard';
const interfaceNamePattern = /^[A-Za-z0-9_.-]{1,15}$/;
function parseInterfaceName(env: string, fallback = DEFAULT_INTERFACE_NAME) {
const value = process.env[env]?.trim() || fallback;
if (!interfaceNamePattern.test(value)) {
throw new Error(
`${env} must be 1-15 characters and contain only letters, numbers, '.', '_', or '-'`
);
}
return value;
}
function parsePortEnv(env: string, fallback: number) {
const raw = process.env[env]?.trim();
if (!raw) {
return fallback;
}
const port = Number.parseInt(raw, 10);
if (
!Number.isInteger(port) ||
port < 1 ||
port > 65535 ||
`${port}` !== raw
) {
throw new Error(`${env} must be a TCP/UDP port between 1 and 65535`);
}
return port;
}
function parseOptionalIntegerEnv(env: string, min: number, max: number) {
const raw = process.env[env];
if (raw === undefined) {
return undefined;
}
const value = raw.trim();
const integer = Number.parseInt(value, 10);
if (
!Number.isInteger(integer) ||
integer < min ||
integer > max ||
`${integer}` !== value
) {
throw new Error(`${env} must be an integer between ${min} and ${max}`);
}
return integer;
}
function parseOptionalBooleanEnv(env: string) {
const raw = process.env[env];
if (raw === undefined) {
return undefined;
}
const value = raw.trim();
if (value === 'true') {
return true;
}
if (value === 'false') {
return false;
}
throw new Error(`${env} must be either true or false`);
}
function parseStateDir() {
const value = process.env.WG_STATE_DIR?.trim() || DEFAULT_STATE_DIR;
if (!value.startsWith('/') || value.includes('\0')) {
throw new Error('WG_STATE_DIR must be an absolute path');
}
return value.replace(/\/+$/, '') || '/';
}
function parseOptionalListEnv(env: string) {
const raw = process.env[env];
if (raw === undefined) {
return undefined;
}
if (raw.trim() === '') {
return [];
}
return raw
.split(',')
.map((value) => value.trim())
.filter((value) => value.length > 0);
}
export const OLD_ENV = {
/** @deprecated Only for migration purposes */
PASSWORD: process.env.PASSWORD,
@ -34,9 +134,21 @@ export const WG_ENV = {
/** UI is hosted on HTTP instead of HTTPS */
INSECURE: process.env.INSECURE === 'true',
/** Port the UI is listening on */
PORT: assertEnv('PORT'),
PORT: process.env.PORT?.trim() || DEFAULT_UI_PORT,
/** If IPv6 should be disabled */
DISABLE_IPV6: process.env.DISABLE_IPV6 === 'true',
/** WireGuard interface name */
INTERFACE_NAME: parseInterfaceName('WG_INTERFACE_NAME'),
/** WireGuard listen port */
WG_PORT: parsePortEnv('WG_PORT', DEFAULT_WG_PORT),
/** Optional external network device for NAT hooks */
WG_DEVICE: process.env.WG_DEVICE
? parseInterfaceName('WG_DEVICE', process.env.WG_DEVICE)
: null,
/** Optional Nix-managed per-client firewall setting */
FIREWALL_ENABLED: parseOptionalBooleanEnv('WG_FIREWALL_ENABLED'),
/** Writable directory for database and WireGuard configs */
STATE_DIR: parseStateDir(),
WG_EXECUTABLE: await detectAwg(),
};
@ -54,12 +166,15 @@ export const WG_INITIAL_ENV = {
: undefined,
};
function assertEnv<T extends string>(env: T) {
const val = process.env[env];
if (!val) {
throw new Error(`Missing environment variable: ${env}`);
}
return val;
}
export const WG_CLIENT_DEFAULTS = {
DNS: parseOptionalListEnv('WG_DEFAULT_DNS'),
ALLOWED_IPS: parseOptionalListEnv('WG_DEFAULT_ALLOWED_IPS'),
SERVER_ALLOWED_IPS: parseOptionalListEnv('WG_DEFAULT_SERVER_ALLOWED_IPS'),
FIREWALL_ALLOWED_IPS: parseOptionalListEnv('WG_DEFAULT_FIREWALL_ALLOWED_IPS'),
PERSISTENT_KEEPALIVE: parseOptionalIntegerEnv(
'WG_DEFAULT_PERSISTENT_KEEPALIVE',
0,
65535
),
FORCE_UPDATE_CLIENTS: process.env.WG_FORCE_UPDATE_CLIENTS === 'true',
};

2
src/server/utils/ip.ts

@ -93,7 +93,7 @@ function getPrivateInformation() {
const obj: Record<string, { ipv4: string[]; ipv6: string[] }> = {};
for (const name of interfaceNames) {
if (name === 'wg0') {
if (name === WG_ENV.INTERFACE_NAME) {
continue;
}

2
src/server/utils/template.ts

@ -19,6 +19,7 @@ export function removeNewlines(templ: string) {
* Available keys:
* - ipv4Cidr: IPv4 CIDR
* - ipv6Cidr: IPv6 CIDR
* - name: WireGuard interface name
* - device: Network device
* - port: Port number
* - uiPort: UI port number
@ -27,6 +28,7 @@ export function iptablesTemplate(templ: string, wgInterface: InterfaceType) {
return template(removeNewlines(templ), {
ipv4Cidr: wgInterface.ipv4Cidr,
ipv6Cidr: wgInterface.ipv6Cidr,
name: wgInterface.name,
device: wgInterface.device,
port: wgInterface.port.toString(),
uiPort: WG_ENV.PORT,

28
src/server/utils/wgHelper.ts

@ -16,6 +16,16 @@ type Options = {
// needed to support cli
const wgExecutable =
typeof WG_ENV !== 'undefined' ? WG_ENV.WG_EXECUTABLE : 'dev';
const wgStateDir =
typeof WG_ENV !== 'undefined' ? WG_ENV.STATE_DIR : '/etc/wireguard';
function shellArg(value: string) {
return `'${value.replace(/'/g, `'\\''`)}'`;
}
function wgQuickTarget(infName: string) {
return shellArg(`${wgStateDir}/${infName}.conf`);
}
export const wg = {
generateServerPeer: (
@ -185,29 +195,33 @@ Endpoint = ${userConfig.host}:${userConfig.port}`;
},
up: (infName: string) => {
return exec(`${wgExecutable}-quick up ${infName}`);
return exec(`${wgExecutable}-quick up ${wgQuickTarget(infName)}`);
},
down: (infName: string) => {
return exec(`${wgExecutable}-quick down ${infName}`);
return exec(`${wgExecutable}-quick down ${wgQuickTarget(infName)}`);
},
restart: (infName: string) => {
const target = wgQuickTarget(infName);
return exec(
`${wgExecutable}-quick down ${infName}; ${wgExecutable}-quick up ${infName}`
`${wgExecutable}-quick down ${target}; ${wgExecutable}-quick up ${target}`
);
},
sync: (infName: string) => {
return exec(
`${wgExecutable} syncconf ${infName} <(${wgExecutable}-quick strip ${infName})`
`${wgExecutable} syncconf ${shellArg(infName)} <(${wgExecutable}-quick strip ${wgQuickTarget(infName)})`
);
},
dump: async (infName: string) => {
const rawDump = await exec(`${wgExecutable} show ${infName} dump`, {
log: false,
});
const rawDump = await exec(
`${wgExecutable} show ${shellArg(infName)} dump`,
{
log: false,
}
);
type wgDumpLine = [
string,

Loading…
Cancel
Save