diff --git a/bun.lock b/bun.lock
index c2d0a8e..288260c 100644
--- a/bun.lock
+++ b/bun.lock
@@ -5,7 +5,7 @@
"": {
"name": "horandez-and-detroit",
"dependencies": {
- "@moduletrace/bun-sqlite": "^1.1.12",
+ "@moduletrace/bun-sqlite": "^1.1.13",
"@moduletrace/bunext": "^1.1.0",
"gray-matter": "^4.0.3",
"html-to-react": "^1.7.0",
@@ -222,7 +222,7 @@
"@mixmark-io/domino": ["@mixmark-io/domino@2.2.0", "", {}, "sha512-Y28PR25bHXUg88kCV7nivXrP2Nj2RueZ3/l/jdx6J9f8J4nsEGcgX0Qe6lt7Pa+J79+kPiJU3LguR6O/6zrLOw=="],
- "@moduletrace/bun-sqlite": ["@moduletrace/bun-sqlite@1.1.12", "https://git.tben.me/api/packages/Moduletrace/npm/%40moduletrace%2Fbun-sqlite/-/1.1.12/bun-sqlite-1.1.12.tgz", { "dependencies": { "@inquirer/prompts": "^8.3.0", "chalk": "^5.6.2", "commander": "^14.0.3", "inquirer": "^13.3.2", "lodash": "^4.17.23", "mysql": "^2.18.1", "sqlite-vec": "^0.1.7-alpha.2" }, "peerDependencies": { "typescript": "^5" }, "bin": { "bun-sqlite": "dist/commands/index.js" } }, "sha512-2/4NFgI/uvdc3SOYd2lPGIni+8x2jHWwEhSLZaCWpG7666Hbg3Yg6eakZmZ43Blm/7hEap8B3Bt1Jo2wCBk1cA=="],
+ "@moduletrace/bun-sqlite": ["@moduletrace/bun-sqlite@1.1.13", "https://git.tben.me/api/packages/Moduletrace/npm/%40moduletrace%2Fbun-sqlite/-/1.1.13/bun-sqlite-1.1.13.tgz", { "dependencies": { "@inquirer/prompts": "^8.3.0", "chalk": "^5.6.2", "commander": "^14.0.3", "inquirer": "^13.3.2", "lodash": "^4.17.23", "mysql": "^2.18.1", "sqlite-vec": "^0.1.7-alpha.2" }, "peerDependencies": { "typescript": "^5" }, "bin": { "bun-sqlite": "dist/commands/index.js" } }, "sha512-C6e4Uf4Rv/8hgqbnomGa9UCiX+lMcIZPB8mzmovihbczC+sv70NwfA0KyfaubyBa5bgznnNkiB14dugOVqmbFw=="],
"@moduletrace/bunext": ["@moduletrace/bunext@1.1.0", "https://git.tben.me/api/packages/Moduletrace/npm/%40moduletrace%2Fbunext/-/1.1.0/bunext-1.1.0.tgz", { "dependencies": { "@tailwindcss/postcss": "^4.2.2", "@types/bun": "latest", "@types/node": "^24.10.0", "@types/react": "^19.2.2", "@types/react-dom": "^19.2.2", "bun-plugin-tailwind": "^0.1.2", "chalk": "^5.6.2", "chokidar": "^5.0.0", "commander": "^14.0.2", "esbuild": "^0.27.4", "lightningcss-wasm": "^1.32.0", "lodash": "^4.17.23", "micromatch": "^4.0.8", "ora": "^9.0.0", "postcss": "^8.5.8", "react": "^19.2.4", "react-dom": "^19.2.4", "tailwindcss": "^4.2.2", "typescript": "^5.0.0" }, "bin": { "bunext": "dist/commands/index.js" } }, "sha512-nlqRpO9BmkfTwmh/FAoxDrIdogGxVz91cK/vLt0d5o0A8Pm9VhPdaWnYtJWvPEdMiPwThYpAwVUBjT3bgg+lAA=="],
diff --git a/db/schema.ts b/db/schema.ts
index 02a6f2c..8189f00 100644
--- a/db/schema.ts
+++ b/db/schema.ts
@@ -1,3 +1,7 @@
+import {
+ ClientRuleProtocols,
+ ClientRuleTypes,
+} from "@/src/dict/client-rules-dict";
import { MediaParadigms, MediaTypes } from "@/src/dict/media-dict";
import { UserTypes } from "@/src/dict/user-types-dict";
import { Variables } from "@/src/dict/variables-dict";
@@ -237,12 +241,38 @@ const schema: BUN_SQLITE_DatabaseSchemaType = {
dataType: "INTEGER",
},
{
- fieldName: "client_id",
+ fieldName: "host_id",
dataType: "INTEGER",
+ defaultValue: 0,
},
{
- fieldName: "rule",
+ fieldName: "client_id",
+ dataType: "INTEGER",
+ foreignKey: {
+ cascadeDelete: true,
+ destinationTableName: "clients",
+ destinationTableColumnName: "id",
+ destinationTableColumnType: "INTEGER",
+ foreignKeyName: "client_rules_client_fk",
+ },
+ },
+ {
+ fieldName: "rule_type",
dataType: "TEXT",
+ options: ClientRuleTypes.map((rt) => rt.value),
+ },
+ {
+ fieldName: "destination",
+ dataType: "TEXT",
+ },
+ {
+ fieldName: "ports",
+ dataType: "TEXT",
+ },
+ {
+ fieldName: "protocol",
+ dataType: "TEXT",
+ options: ClientRuleProtocols.map((p) => p.value),
},
],
},
diff --git a/db/types/db.ts b/db/types/db.ts
index fae67fd..4fdeb1b 100644
--- a/db/types/db.ts
+++ b/db/types/db.ts
@@ -150,8 +150,12 @@ export type BUN_SQLITE_WGUI_CLIENT_RULES = {
*/
updated_at?: number | "";
user_id?: number | "";
+ host_id?: number | "";
client_id?: number | "";
- rule?: string;
+ rule_type?: "all" | "destination" | "";
+ destination?: string;
+ ports?: string;
+ protocol?: "any" | "tcp" | "udp" | "";
}
export type BUN_SQLITE_WGUI_VARIABLES = {
diff --git a/package.json b/package.json
index e71dbef..a48090e 100644
--- a/package.json
+++ b/package.json
@@ -30,7 +30,7 @@
"typescript": "^7.0.2"
},
"dependencies": {
- "@moduletrace/bun-sqlite": "^1.1.12",
+ "@moduletrace/bun-sqlite": "^1.1.13",
"@moduletrace/bunext": "^1.1.0",
"gray-matter": "^4.0.3",
"html-to-react": "^1.7.0",
diff --git a/src/components/general/client-row.tsx b/src/components/general/client-row.tsx
index fca74fc..a74ad0f 100644
--- a/src/components/general/client-row.tsx
+++ b/src/components/general/client-row.tsx
@@ -5,16 +5,22 @@ import Row from "@/src/components/twui/layout/Row";
import Span from "@/src/components/twui/layout/Span";
import formatUnixTimestamp from "@/src/utils/format-unix-timestamp";
import adminCrudHandler from "@/src/functions/frontend/admin-crud-handler";
-import type { BUN_SQLITE_WGUI_CLIENTS } from "@/db/types/db";
+import type {
+ BUN_SQLITE_WGUI_CLIENTS,
+ BUN_SQLITE_WGUI_CLIENT_RULES,
+} from "@/db/types/db";
+import deriveClientAccessLabel from "@/src/functions/frontend/derive-client-access-label";
type Props = {
client: BUN_SQLITE_WGUI_CLIENTS;
+ rules?: BUN_SQLITE_WGUI_CLIENT_RULES[];
};
-export default function ClientRow({ client }: Props) {
+export default function ClientRow({ client, rules }: Props) {
const [deleting, setDeleting] = useState(false);
const host_id = Number(client.host_id || 0);
+ const access = deriveClientAccessLabel({ rules });
async function handleDelete() {
if (
@@ -58,6 +64,17 @@ export default function ClientRow({ client }: Props) {
{client.allowed_ips || "—"}
|
+
+
+ {access.text}
+
+ |
{client.public_key
diff --git a/src/dict/client-rules-dict.ts b/src/dict/client-rules-dict.ts
new file mode 100644
index 0000000..335c977
--- /dev/null
+++ b/src/dict/client-rules-dict.ts
@@ -0,0 +1,25 @@
+export const ClientRuleTypes = [
+ {
+ title: "All access",
+ value: "all",
+ },
+ {
+ title: "Specific destination",
+ value: "destination",
+ },
+] as const;
+
+export const ClientRuleProtocols = [
+ {
+ title: "Any",
+ value: "any",
+ },
+ {
+ title: "TCP",
+ value: "tcp",
+ },
+ {
+ title: "UDP",
+ value: "udp",
+ },
+] as const;
diff --git a/src/functions/backend/auth/login-user.ts b/src/functions/backend/auth/login-user.ts
index 0fd45c4..4393d0c 100644
--- a/src/functions/backend/auth/login-user.ts
+++ b/src/functions/backend/auth/login-user.ts
@@ -111,6 +111,8 @@ export default async function loginUser({
},
]);
+ console.log("new_logged_in_user", new_logged_in_user);
+
console.log(
`User #${new_logged_in_user.id} [${new_logged_in_user.first_name} ${new_logged_in_user.last_name}] logged in successfully.`,
);
diff --git a/src/functions/backend/db/client-rules/grab-client-rules.ts b/src/functions/backend/db/client-rules/grab-client-rules.ts
new file mode 100644
index 0000000..088a2f1
--- /dev/null
+++ b/src/functions/backend/db/client-rules/grab-client-rules.ts
@@ -0,0 +1,31 @@
+import type { BUN_SQLITE_WGUI_CLIENT_RULES } from "@/db/types/db";
+import type { TableType } from "@/src/types";
+import BunSQLite from "@moduletrace/bun-sqlite";
+
+type Params = {
+ client_id?: string | number;
+ client_ids?: (string | number)[];
+};
+
+export default async function grabClientRules({
+ client_id,
+ client_ids,
+}: Params) {
+ const ids = client_id
+ ? [client_id]
+ : (client_ids || []).filter((id) => id !== undefined && id !== "");
+
+ if (!ids[0]) {
+ return [] as BUN_SQLITE_WGUI_CLIENT_RULES[];
+ }
+
+ const res = await BunSQLite.select({
+ table: "client_rules",
+ });
+
+ const id_set = new Set(ids.map((id) => Number(id)));
+
+ return (res.payload || []).filter(
+ (rule) => rule.client_id && id_set.has(Number(rule.client_id)),
+ );
+}
diff --git a/src/functions/backend/setup/build-host-iptables-scripts.test.ts b/src/functions/backend/setup/build-host-iptables-scripts.test.ts
new file mode 100644
index 0000000..08a77f2
--- /dev/null
+++ b/src/functions/backend/setup/build-host-iptables-scripts.test.ts
@@ -0,0 +1,134 @@
+import { describe, expect, test } from "bun:test";
+import buildHostIptablesScripts from "./build-host-iptables-scripts";
+
+const base = {
+ host_id: 0,
+ interface_name: "wgui0",
+ target_interface: "eth0",
+};
+
+describe("buildHostIptablesScripts", () => {
+ test("deny by default with no clients", () => {
+ const res = buildHostIptablesScripts({
+ ...base,
+ clients: [],
+ });
+
+ expect(res.success).toBe(true);
+ expect(res.post_up).toContain("iptables -N WGUI0FWD");
+ expect(res.post_up).toContain("iptables -A WGUI0FWD -j DROP");
+ expect(res.post_up).toContain("iptables -A WGUI0IN -j DROP");
+ expect(res.post_up).not.toContain("WGUI0FWD -s");
+ expect(res.post_down).toContain("iptables -X WGUI0FWD");
+ expect(res.post_down).toContain("iptables -t nat -D POSTROUTING -o eth0 -j MASQUERADE");
+ });
+
+ test("all access accepts the client on forward and input", () => {
+ const res = buildHostIptablesScripts({
+ ...base,
+ clients: [
+ {
+ id: 1,
+ wg_ip_address: "10.0.0.2",
+ rules: [{ rule_type: "all" }],
+ },
+ ],
+ });
+
+ expect(res.success).toBe(true);
+ expect(res.post_up).toContain(
+ "iptables -A WGUI0FWD -s 10.0.0.2/32 -j ACCEPT",
+ );
+ expect(res.post_up).toContain(
+ "iptables -A WGUI0IN -s 10.0.0.2/32 -j ACCEPT",
+ );
+ });
+
+ test("destination IP and ports", () => {
+ const res = buildHostIptablesScripts({
+ ...base,
+ clients: [
+ {
+ id: 2,
+ wg_ip_address: "10.0.0.3",
+ rules: [
+ {
+ rule_type: "destination",
+ destination: "192.168.1.10",
+ ports: "80,443",
+ protocol: "tcp",
+ },
+ ],
+ },
+ ],
+ });
+
+ expect(res.success).toBe(true);
+ expect(res.post_up).toContain(
+ "iptables -A WGUI0FWD -s 10.0.0.3/32 -d 192.168.1.10/32 -p tcp -m multiport --dports 80,443 -j ACCEPT",
+ );
+ expect(res.post_up).toContain(
+ "iptables -A WGUI0IN -s 10.0.0.3/32 -d 192.168.1.10/32 -p tcp -m multiport --dports 80,443 -j ACCEPT",
+ );
+ });
+
+ test("protocol any with ports emits tcp and udp", () => {
+ const res = buildHostIptablesScripts({
+ ...base,
+ clients: [
+ {
+ id: 3,
+ wg_ip_address: "10.0.0.4",
+ rules: [
+ {
+ rule_type: "destination",
+ ports: "53",
+ protocol: "any",
+ },
+ ],
+ },
+ ],
+ });
+
+ expect(res.success).toBe(true);
+ expect(res.post_up).toContain(
+ "iptables -A WGUI0FWD -s 10.0.0.4/32 -p tcp --dport 53 -j ACCEPT",
+ );
+ expect(res.post_up).toContain(
+ "iptables -A WGUI0FWD -s 10.0.0.4/32 -p udp --dport 53 -j ACCEPT",
+ );
+ });
+
+ test("rejects invalid destination", () => {
+ const res = buildHostIptablesScripts({
+ ...base,
+ clients: [
+ {
+ id: 4,
+ wg_ip_address: "10.0.0.5",
+ rules: [
+ {
+ rule_type: "destination",
+ destination: "not-an-ip",
+ },
+ ],
+ },
+ ],
+ });
+
+ expect(res.success).toBe(false);
+ });
+
+ test("cleans up legacy allow-all rules", () => {
+ const res = buildHostIptablesScripts({
+ ...base,
+ });
+
+ expect(res.post_up).toContain(
+ "iptables -D FORWARD -i wgui0 -j ACCEPT 2>/dev/null || true",
+ );
+ expect(res.post_down).toContain(
+ "iptables -D FORWARD -i wgui0 -j ACCEPT 2>/dev/null || true",
+ );
+ });
+});
diff --git a/src/functions/backend/setup/build-host-iptables-scripts.ts b/src/functions/backend/setup/build-host-iptables-scripts.ts
new file mode 100644
index 0000000..dbd5963
--- /dev/null
+++ b/src/functions/backend/setup/build-host-iptables-scripts.ts
@@ -0,0 +1,288 @@
+import type { BUN_SQLITE_WGUI_CLIENT_RULES } from "@/db/types/db";
+import parsePortList from "@/src/utils/parse-port-list";
+import validateIpv4 from "@/src/utils/validate-ipv4";
+import deriveIptablesChainNames from "./derive-iptables-chain-names";
+import normalizeIptablesDestination from "./normalize-iptables-destination";
+import validateClientRule from "./validate-client-rule";
+
+type HostIptablesClient = {
+ id?: number | "";
+ wg_ip_address?: string;
+ rules?: BUN_SQLITE_WGUI_CLIENT_RULES[];
+};
+
+type Params = {
+ host_id: number;
+ interface_name: string;
+ target_interface: string;
+ clients?: HostIptablesClient[];
+};
+
+const SAFE_IFACE = /^[A-Za-z][A-Za-z0-9._-]*$/;
+const MULTIPORT_LIMIT = 15;
+
+function joinArgs(parts: (string | undefined)[]) {
+ return parts.filter(Boolean).join(" ");
+}
+
+function protocolsForRule({
+ protocol,
+ has_ports,
+}: {
+ protocol?: string;
+ has_ports: boolean;
+}) {
+ if (has_ports) {
+ if (protocol == "udp") {
+ return ["udp"];
+ }
+
+ if (protocol == "tcp") {
+ return ["tcp"];
+ }
+
+ return ["tcp", "udp"];
+ }
+
+ if (protocol == "tcp" || protocol == "udp") {
+ return [protocol];
+ }
+
+ return [undefined];
+}
+
+function portMatch({ ports }: { ports: string[] }) {
+ if (!ports[0]) {
+ return;
+ }
+
+ if (ports.length == 1) {
+ return `--dport ${ports[0]}`;
+ }
+
+ return `-m multiport --dports ${ports.join(",")}`;
+}
+
+function chunkPorts({ ports }: { ports: string[] }) {
+ if (ports.length <= MULTIPORT_LIMIT) {
+ return [ports];
+ }
+
+ const chunks: string[][] = [];
+
+ for (let i = 0; i < ports.length; i += MULTIPORT_LIMIT) {
+ chunks.push(ports.slice(i, i + MULTIPORT_LIMIT));
+ }
+
+ return chunks;
+}
+
+function acceptLines({
+ chain,
+ source,
+ destination,
+ protocol,
+ ports,
+}: {
+ chain: string;
+ source: string;
+ destination?: string;
+ protocol?: string;
+ ports: string[];
+}) {
+ const lines: string[] = [];
+ const protocols = protocolsForRule({
+ protocol,
+ has_ports: Boolean(ports[0]),
+ });
+ const port_chunks = ports[0] ? chunkPorts({ ports }) : [[]];
+
+ for (let p = 0; p < protocols.length; p++) {
+ const proto = protocols[p];
+
+ for (let c = 0; c < port_chunks.length; c++) {
+ const chunk = port_chunks[c] || [];
+
+ lines.push(
+ joinArgs([
+ `iptables -A ${chain}`,
+ `-s ${source}`,
+ destination ? `-d ${destination}` : undefined,
+ proto ? `-p ${proto}` : undefined,
+ proto ? portMatch({ ports: chunk }) : undefined,
+ `-j ACCEPT`,
+ ]),
+ );
+ }
+ }
+
+ return lines;
+}
+
+export default function buildHostIptablesScripts({
+ host_id,
+ interface_name,
+ target_interface,
+ clients,
+}: Params) {
+ if (!Number.isInteger(host_id) || host_id < 0) {
+ return {
+ success: false,
+ msg: `Invalid host id`,
+ };
+ }
+
+ if (!SAFE_IFACE.test(interface_name)) {
+ return {
+ success: false,
+ msg: `Invalid interface name`,
+ };
+ }
+
+ if (!SAFE_IFACE.test(target_interface)) {
+ return {
+ success: false,
+ msg: `Invalid target interface`,
+ };
+ }
+
+ const { forward, input } = deriveIptablesChainNames({ host_id });
+ const accept_lines: string[] = [];
+ const host_clients = clients || [];
+
+ for (let i = 0; i < host_clients.length; i++) {
+ const client = host_clients[i];
+
+ if (!client) {
+ continue;
+ }
+
+ const source_ip = client.wg_ip_address?.trim();
+
+ if (!source_ip) {
+ return {
+ success: false,
+ msg: `Client ${client.id || i} is missing a WireGuard IP`,
+ };
+ }
+
+ if (!validateIpv4({ ip: source_ip })) {
+ return {
+ success: false,
+ msg: `Client ${client.id || i} has an invalid WireGuard IP`,
+ };
+ }
+
+ const source = `${source_ip}/32`;
+ const rules = client.rules || [];
+
+ for (let r = 0; r < rules.length; r++) {
+ const rule = rules[r];
+
+ if (!rule) {
+ continue;
+ }
+
+ const valid = validateClientRule({ rule });
+
+ if (!valid.success) {
+ return {
+ success: false,
+ msg: `Client ${client.id || i}: ${valid.msg}`,
+ };
+ }
+
+ if (rule.rule_type == "all") {
+ accept_lines.push(
+ `iptables -A ${forward} -s ${source} -j ACCEPT`,
+ );
+ accept_lines.push(
+ `iptables -A ${input} -s ${source} -j ACCEPT`,
+ );
+ continue;
+ }
+
+ const destination = normalizeIptablesDestination({
+ destination: rule.destination,
+ });
+ const parsed_ports = parsePortList({ ports: rule.ports });
+
+ if (!parsed_ports.success) {
+ return {
+ success: false,
+ msg: `Client ${client.id || i}: ${parsed_ports.msg}`,
+ };
+ }
+
+ accept_lines.push(
+ ...acceptLines({
+ chain: forward,
+ source,
+ destination,
+ protocol: rule.protocol || "any",
+ ports: parsed_ports.ports,
+ }),
+ ...acceptLines({
+ chain: input,
+ source,
+ destination,
+ protocol: rule.protocol || "any",
+ ports: parsed_ports.ports,
+ }),
+ );
+ }
+ }
+
+ const post_up = [
+ `#!/bin/bash`,
+ ``,
+ `iptables -D INPUT -i ${interface_name} -j ACCEPT 2>/dev/null || true`,
+ `iptables -D OUTPUT -o ${interface_name} -j ACCEPT 2>/dev/null || true`,
+ `iptables -D FORWARD -i ${interface_name} -j ACCEPT 2>/dev/null || true`,
+ `iptables -D FORWARD -o ${interface_name} -j ACCEPT 2>/dev/null || true`,
+ ``,
+ `iptables -N ${forward} 2>/dev/null || true`,
+ `iptables -F ${forward}`,
+ `iptables -N ${input} 2>/dev/null || true`,
+ `iptables -F ${input}`,
+ ``,
+ `iptables -A ${forward} -m conntrack --ctstate ESTABLISHED,RELATED -j ACCEPT`,
+ `iptables -A ${input} -m conntrack --ctstate ESTABLISHED,RELATED -j ACCEPT`,
+ ``,
+ ...accept_lines,
+ ``,
+ `iptables -A ${forward} -j DROP`,
+ `iptables -A ${input} -j DROP`,
+ ``,
+ `iptables -C FORWARD -i ${interface_name} -j ${forward} 2>/dev/null || iptables -I FORWARD 1 -i ${interface_name} -j ${forward}`,
+ `iptables -C FORWARD -o ${interface_name} -m conntrack --ctstate ESTABLISHED,RELATED -j ACCEPT 2>/dev/null || iptables -I FORWARD 1 -o ${interface_name} -m conntrack --ctstate ESTABLISHED,RELATED -j ACCEPT`,
+ `iptables -C INPUT -i ${interface_name} -j ${input} 2>/dev/null || iptables -I INPUT 1 -i ${interface_name} -j ${input}`,
+ ``,
+ `iptables -t nat -C POSTROUTING -o ${target_interface} -j MASQUERADE 2>/dev/null || iptables -t nat -A POSTROUTING -o ${target_interface} -j MASQUERADE`,
+ ``,
+ ].join("\n");
+
+ const post_down = [
+ `#!/bin/bash`,
+ ``,
+ `iptables -D FORWARD -i ${interface_name} -j ${forward} 2>/dev/null || true`,
+ `iptables -D FORWARD -o ${interface_name} -m conntrack --ctstate ESTABLISHED,RELATED -j ACCEPT 2>/dev/null || true`,
+ `iptables -D INPUT -i ${interface_name} -j ${input} 2>/dev/null || true`,
+ `iptables -D INPUT -i ${interface_name} -j ACCEPT 2>/dev/null || true`,
+ `iptables -D OUTPUT -o ${interface_name} -j ACCEPT 2>/dev/null || true`,
+ `iptables -D FORWARD -i ${interface_name} -j ACCEPT 2>/dev/null || true`,
+ `iptables -D FORWARD -o ${interface_name} -j ACCEPT 2>/dev/null || true`,
+ `iptables -F ${forward} 2>/dev/null || true`,
+ `iptables -X ${forward} 2>/dev/null || true`,
+ `iptables -F ${input} 2>/dev/null || true`,
+ `iptables -X ${input} 2>/dev/null || true`,
+ `iptables -t nat -D POSTROUTING -o ${target_interface} -j MASQUERADE 2>/dev/null || true`,
+ ``,
+ ].join("\n");
+
+ return {
+ success: true,
+ post_up,
+ post_down,
+ };
+}
diff --git a/src/functions/backend/setup/derive-iptables-chain-names.ts b/src/functions/backend/setup/derive-iptables-chain-names.ts
new file mode 100644
index 0000000..b985039
--- /dev/null
+++ b/src/functions/backend/setup/derive-iptables-chain-names.ts
@@ -0,0 +1,10 @@
+type Params = {
+ host_id: number;
+};
+
+export default function deriveIptablesChainNames({ host_id }: Params) {
+ return {
+ forward: `WGUI${host_id}FWD`,
+ input: `WGUI${host_id}IN`,
+ };
+}
diff --git a/src/functions/backend/setup/expand-client-rule-destinations.ts b/src/functions/backend/setup/expand-client-rule-destinations.ts
new file mode 100644
index 0000000..f7caa91
--- /dev/null
+++ b/src/functions/backend/setup/expand-client-rule-destinations.ts
@@ -0,0 +1,31 @@
+import type { BUN_SQLITE_WGUI_CLIENT_RULES } from "@/db/types/db";
+
+type Params = {
+ rule: BUN_SQLITE_WGUI_CLIENT_RULES;
+};
+
+export default function expandClientRuleDestinations({ rule }: Params) {
+ if (rule.rule_type == "all") {
+ return [rule];
+ }
+
+ const destinations = (rule.destination || "")
+ .split(",")
+ .map((value) => value.trim())
+ .filter(Boolean);
+
+ if (destinations.length <= 1) {
+ return [
+ {
+ ...rule,
+ destination: destinations[0] || "",
+ },
+ ];
+ }
+
+ return destinations.map((destination) => ({
+ ...rule,
+ id: undefined,
+ destination,
+ }));
+}
diff --git a/src/functions/backend/setup/grab-client-dir-names.ts b/src/functions/backend/setup/grab-client-dir-names.ts
index 78766ff..49bece5 100644
--- a/src/functions/backend/setup/grab-client-dir-names.ts
+++ b/src/functions/backend/setup/grab-client-dir-names.ts
@@ -49,19 +49,6 @@ export default function grabClientDirnames({
WIREGUARD_CLIENT_CONFIG_FILE_NAME,
);
- const CLIENT_PRIVATE_KEY = execSync(`cat ${CLIENT_PRIVATE_KEY_FILE}`, {
- encoding: "utf-8",
- }).trim();
-
- const CLIENT_PUBLIC_KEY = execSync(`cat ${CLIENT_PUBLIC_KEY_FILE}`, {
- encoding: "utf-8",
- }).trim();
-
- const HOST_PUBLIC_KEY = execSync(
- `cat ${path.join(HOST_CONFIG_DIR, WIREGUARD_PUBLIC_KEY_FILE_NAME)}`,
- { encoding: "utf-8" },
- ).trim();
-
return {
CLIENT_WG_IP,
HOST_CLIENTS_DIR,
@@ -70,8 +57,5 @@ export default function grabClientDirnames({
CLIENT_PRIVATE_KEY_FILE,
CLIENT_PUBLIC_KEY_FILE,
CLIENT_CONFIG_FILE,
- CLIENT_PRIVATE_KEY,
- CLIENT_PUBLIC_KEY,
- HOST_PUBLIC_KEY,
};
}
diff --git a/src/functions/backend/setup/manage-wireguard-host.ts b/src/functions/backend/setup/manage-wireguard-host.ts
index 4a92dae..8f36bed 100644
--- a/src/functions/backend/setup/manage-wireguard-host.ts
+++ b/src/functions/backend/setup/manage-wireguard-host.ts
@@ -37,7 +37,9 @@ export default function manageWireguardHost({
const MANAGE_COMMAND = `${WGUI_WG_QUICK_MANAGE_SCRIPT} ${action} ${INTERFACE_NAME} ${HOST_CONFIG_FILE}`;
try {
- const output = execSync(MANAGE_COMMAND, { encoding: "utf-8" }).trim();
+ const output = execSync(MANAGE_COMMAND, {
+ encoding: "utf-8",
+ }).trim();
return {
success: true,
diff --git a/src/functions/backend/setup/normalize-iptables-destination.ts b/src/functions/backend/setup/normalize-iptables-destination.ts
new file mode 100644
index 0000000..1652b62
--- /dev/null
+++ b/src/functions/backend/setup/normalize-iptables-destination.ts
@@ -0,0 +1,24 @@
+import validateIpv4 from "@/src/utils/validate-ipv4";
+import validateIpv4Cidr from "@/src/utils/validate-ipv4-cidr";
+
+type Params = {
+ destination?: string;
+};
+
+export default function normalizeIptablesDestination({ destination }: Params) {
+ const value = destination?.trim();
+
+ if (!value) {
+ return;
+ }
+
+ if (!validateIpv4Cidr({ value })) {
+ return;
+ }
+
+ if (validateIpv4({ ip: value })) {
+ return `${value}/32`;
+ }
+
+ return value;
+}
diff --git a/src/functions/backend/setup/sanitize-client-rule.ts b/src/functions/backend/setup/sanitize-client-rule.ts
new file mode 100644
index 0000000..38f1168
--- /dev/null
+++ b/src/functions/backend/setup/sanitize-client-rule.ts
@@ -0,0 +1,35 @@
+import type { BUN_SQLITE_WGUI_CLIENT_RULES } from "@/db/types/db";
+
+type Params = {
+ rule?: BUN_SQLITE_WGUI_CLIENT_RULES | null;
+ client_id?: number | "";
+ user_id?: number | "";
+};
+
+export default function sanitizeClientRule({
+ rule,
+ client_id,
+ user_id,
+}: Params): BUN_SQLITE_WGUI_CLIENT_RULES | undefined {
+ if (!rule) {
+ return;
+ }
+
+ const rule_type = rule.rule_type == "all" ? "all" : "destination";
+
+ return {
+ ...(rule.id ? { id: rule.id } : {}),
+ user_id: user_id || rule.user_id,
+ client_id: client_id || rule.client_id,
+ rule_type,
+ destination:
+ rule_type == "all" ? "" : (rule.destination || "").trim(),
+ ports: rule_type == "all" ? "" : (rule.ports || "").trim(),
+ protocol:
+ rule_type == "all"
+ ? "any"
+ : rule.protocol == "tcp" || rule.protocol == "udp"
+ ? rule.protocol
+ : "any",
+ };
+}
diff --git a/src/functions/backend/setup/setup-wireguard-client.ts b/src/functions/backend/setup/setup-wireguard-client.ts
index a78b9f2..019c509 100644
--- a/src/functions/backend/setup/setup-wireguard-client.ts
+++ b/src/functions/backend/setup/setup-wireguard-client.ts
@@ -64,11 +64,7 @@ export default async function setupWireguardClient({
CLIENT_DIR,
CLIENT_PRIVATE_KEY_FILE,
CLIENT_PUBLIC_KEY_FILE,
- HOST_CLIENTS_DIR,
HOST_CONFIG_DIR,
- CLIENT_PRIVATE_KEY,
- CLIENT_PUBLIC_KEY,
- HOST_PUBLIC_KEY,
} = grabClientDirnames({ client, host_id });
if (!CLIENT_WG_IP) {
@@ -114,6 +110,19 @@ export default async function setupWireguardClient({
client?.public_ip_address ||
HOST_WG_IP;
+ const CLIENT_PRIVATE_KEY = execSync(`cat ${CLIENT_PRIVATE_KEY_FILE}`, {
+ encoding: "utf-8",
+ }).trim();
+
+ const CLIENT_PUBLIC_KEY = execSync(`cat ${CLIENT_PUBLIC_KEY_FILE}`, {
+ encoding: "utf-8",
+ }).trim();
+
+ const HOST_PUBLIC_KEY = execSync(
+ `cat ${path.join(HOST_CONFIG_DIR, WIREGUARD_PUBLIC_KEY_FILE_NAME)}`,
+ { encoding: "utf-8" },
+ ).trim();
+
let sh = ``;
sh += `cd ${CLIENT_DIR}\n`;
diff --git a/src/functions/backend/setup/setup-wireguard-host.ts b/src/functions/backend/setup/setup-wireguard-host.ts
index 7696d2e..b76de6b 100644
--- a/src/functions/backend/setup/setup-wireguard-host.ts
+++ b/src/functions/backend/setup/setup-wireguard-host.ts
@@ -1,5 +1,6 @@
import type {
BUN_SQLITE_WGUI_CLIENTS,
+ BUN_SQLITE_WGUI_CLIENT_RULES,
BUN_SQLITE_WGUI_HOSTS,
BUN_SQLITE_WGUI_VARIABLES,
} from "@/db/types/db";
@@ -12,8 +13,8 @@ import type { TableType, User } from "@/src/types";
import checkPrivateIPAvailability from "./check-private-ip-availability";
import manageWireguardHost from "./manage-wireguard-host";
import grabHostDirnames from "./grab-host-dir-names";
-import path from "node:path";
import grabClientDirnames from "./grab-client-dir-names";
+import buildHostIptablesScripts from "./build-host-iptables-scripts";
const { WIREGUARD_PRIVATE_KEY_FILE_NAME, WIREGUARD_PUBLIC_KEY_FILE_NAME } =
grabDirNames();
@@ -69,6 +70,36 @@ export default async function setupWireguardHost({
const clients = host_clients_res.payload || [];
+ const client_rules_res = await BunSQLite.select<
+ BUN_SQLITE_WGUI_CLIENT_RULES,
+ TableType
+ >({
+ table: "client_rules",
+ });
+
+ const client_ids = new Set(
+ clients
+ .map((client) => client.id)
+ .filter((id): id is number => Boolean(id)),
+ );
+
+ const rules_by_client_id = new Map<
+ number,
+ BUN_SQLITE_WGUI_CLIENT_RULES[]
+ >();
+
+ for (let i = 0; i < (client_rules_res.payload || []).length; i++) {
+ const rule = client_rules_res.payload?.[i];
+
+ if (!rule?.client_id || !client_ids.has(rule.client_id)) {
+ continue;
+ }
+
+ const existing_rules = rules_by_client_id.get(rule.client_id) || [];
+ existing_rules.push(rule);
+ rules_by_client_id.set(rule.client_id, existing_rules);
+ }
+
const TARGET_INTERFACE = await grabHostNetworkInterface();
const HOST_WG_IP =
host?.wg_ip_address ||
@@ -172,37 +203,37 @@ export default async function setupWireguardHost({
sh += `cd ${HOST_CONFIG_DIR}\n`;
- sh += `cat > ${POST_UP_PATH} << EOF\n`;
- sh += `#!/bin/bash\n\n`;
- sh += `# Allow WireGuard traffic to/from the server itself\n`;
- sh += `iptables -I INPUT 1 -i ${INTERFACE_NAME} -j ACCEPT\n`;
- sh += `iptables -I OUTPUT 1 -o ${INTERFACE_NAME} -j ACCEPT\n`;
- sh += `\n`;
- sh += `# Allow WireGuard traffic to be forwarded (insert above Docker rules)\n`;
- sh += `iptables -I FORWARD 1 -i ${INTERFACE_NAME} -j ACCEPT\n`;
- sh += `iptables -I FORWARD 1 -o ${INTERFACE_NAME} -j ACCEPT\n`;
- sh += `\n`;
- sh += `iptables -t nat -A POSTROUTING -o ${TARGET_INTERFACE} -j MASQUERADE\n`;
- sh += `EOF\n`;
- sh += `chmod +x ${POST_UP_PATH}\n`;
+ const iptables_scripts = buildHostIptablesScripts({
+ host_id: Number(HOST_ID),
+ interface_name: INTERFACE_NAME,
+ target_interface: TARGET_INTERFACE || "eth0",
+ clients: clients.map((client) => ({
+ id: client.id,
+ wg_ip_address: client.wg_ip_address,
+ rules:
+ client.id && typeof client.id == "number"
+ ? rules_by_client_id.get(client.id) || []
+ : [],
+ })),
+ });
- sh += `\n`;
+ if (
+ !iptables_scripts.success ||
+ !iptables_scripts.post_up ||
+ !iptables_scripts.post_down
+ ) {
+ return {
+ success: false,
+ msg: iptables_scripts.msg || `Could not build iptables scripts`,
+ };
+ }
- sh += `cat > ${POST_DOWN_PATH} << EOF\n`;
- sh += `#!/bin/bash\n\n`;
- sh += `# Remove WireGuard INPUT/OUTPUT rules\n`;
- sh += `iptables -D INPUT -i ${INTERFACE_NAME} -j ACCEPT\n`;
- sh += `iptables -D OUTPUT -o ${INTERFACE_NAME} -j ACCEPT\n`;
- sh += `\n`;
- sh += `# Remove FORWARD rules\n`;
- sh += `iptables -D FORWARD -i ${INTERFACE_NAME} -j ACCEPT\n`;
- sh += `iptables -D FORWARD -o ${INTERFACE_NAME} -j ACCEPT\n`;
- sh += `\n`;
- sh += `iptables -t nat -D POSTROUTING -o ${TARGET_INTERFACE} -j MASQUERADE\n`;
- sh += `EOF\n`;
- sh += `chmod +x ${POST_DOWN_PATH}\n`;
-
- sh += `\n`;
+ execSync(`mkdir -p ${HOST_IPTABLES_DIR}`, { encoding: "utf-8" });
+ await Bun.write(POST_UP_PATH, iptables_scripts.post_up);
+ await Bun.write(POST_DOWN_PATH, iptables_scripts.post_down);
+ execSync(`chmod +x ${POST_UP_PATH} ${POST_DOWN_PATH}`, {
+ encoding: "utf-8",
+ });
sh += `cat > ${INTERFACE_NAME}.conf << EOF\n`;
sh += `[Interface]\n`;
@@ -218,14 +249,21 @@ export default async function setupWireguardHost({
const client = clients[i];
if (!client?.id) continue;
- const { CLIENT_PUBLIC_KEY } = grabClientDirnames({
+ const { CLIENT_PUBLIC_KEY_FILE } = grabClientDirnames({
client,
host_id: HOST_ID,
});
+ const CLIENT_PUBLIC_KEY = execSync(
+ `cat ${CLIENT_PUBLIC_KEY_FILE}`,
+ {
+ encoding: "utf-8",
+ },
+ ).trim();
+
sh += `[Peer]\n`;
sh += `PublicKey = ${CLIENT_PUBLIC_KEY}\n`;
- sh += `AllowedIPs = ${client.allowed_ips}\n`;
+ sh += `AllowedIPs = ${client.wg_ip_address}/32\n`;
sh += `\n`;
}
}
diff --git a/src/functions/backend/setup/sync-wireguard-hosts.ts b/src/functions/backend/setup/sync-wireguard-hosts.ts
index 4ddb89d..27303a4 100644
--- a/src/functions/backend/setup/sync-wireguard-hosts.ts
+++ b/src/functions/backend/setup/sync-wireguard-hosts.ts
@@ -35,27 +35,40 @@ export default async function syncWireguardHosts() {
table: "hosts",
});
- const main_host_id = AppData["WireguardHostID"];
const main_host_ip = variables.payload?.find(
(v) => v.key == "main_host_wg_ip_address",
)?.value;
+ const main_host_public_ip = variables.payload?.find(
+ (v) => v.key == "main_host_public_ip_address",
+ )?.value;
+
if (!main_host_ip) {
throw new Error(`Main Host not set yet`);
}
- for (const host_config_file_name of host_config_file_names) {
- const host_id = Number(
- host_config_file_name.match(HOST_CONFIG_FILE_NAME_PATTERN)?.[1],
- );
+ const all_hosts: BUN_SQLITE_WGUI_HOSTS[] = [
+ {
+ id: 0,
+ public_ip_address: main_host_public_ip,
+ wg_ip_address: main_host_ip,
+ },
+ ...(hosts.payload || []),
+ ];
- const res = manageWireguardHost({ action: "up", host_id });
+ for (const host of all_hosts) {
+ if (typeof host.id == "number") {
+ const res = manageWireguardHost({
+ action: "up",
+ host_id: host.id,
+ });
- bunext.bunextLog.info(
- `[wgui] wireguard host ${host_config_file_name}: ${
- res.success ? `up` : `failed — ${res.msg}`
- }`,
- );
+ bunext.bunextLog.info(
+ `[wgui] wireguard host ${host.id}: ${
+ res.success ? `up` : `failed — ${res.msg}`
+ }`,
+ );
+ }
}
} catch (error: any) {
console.log(`[wgui] skipping wireguard host sync — ${error.message}`);
diff --git a/src/functions/backend/setup/update-wireguard-host-public-ip.ts b/src/functions/backend/setup/update-wireguard-host-public-ip.ts
index bedf264..06ffb8f 100644
--- a/src/functions/backend/setup/update-wireguard-host-public-ip.ts
+++ b/src/functions/backend/setup/update-wireguard-host-public-ip.ts
@@ -139,8 +139,6 @@ export default async function updateWireguardHostPublicIP({
skip_host_setup: true,
});
- console.log("client_setup_res", client_setup_res);
-
if (client_setup_res.success) {
updated_count++;
} else {
diff --git a/src/functions/backend/setup/validate-client-rule.ts b/src/functions/backend/setup/validate-client-rule.ts
new file mode 100644
index 0000000..cb05629
--- /dev/null
+++ b/src/functions/backend/setup/validate-client-rule.ts
@@ -0,0 +1,78 @@
+import type { BUN_SQLITE_WGUI_CLIENT_RULES } from "@/db/types/db";
+import {
+ ClientRuleProtocols,
+ ClientRuleTypes,
+} from "@/src/dict/client-rules-dict";
+import type { ClientRuleProtocol, ClientRuleType } from "@/src/types";
+import parsePortList from "@/src/utils/parse-port-list";
+import validateIpv4Cidr from "@/src/utils/validate-ipv4-cidr";
+
+type Params = {
+ rule?: BUN_SQLITE_WGUI_CLIENT_RULES | null;
+};
+
+const RULE_TYPES = ClientRuleTypes.map((rt) => rt.value);
+const PROTOCOLS = ClientRuleProtocols.map((p) => p.value);
+
+export default function validateClientRule({ rule }: Params) {
+ if (!rule) {
+ return {
+ success: false,
+ msg: `No client rule provided`,
+ };
+ }
+
+ const rule_type = rule.rule_type as ClientRuleType | undefined;
+
+ if (!rule_type || !RULE_TYPES.includes(rule_type)) {
+ return {
+ success: false,
+ msg: `Invalid rule type`,
+ };
+ }
+
+ const protocol = (rule.protocol || "any") as ClientRuleProtocol;
+
+ if (!PROTOCOLS.includes(protocol)) {
+ return {
+ success: false,
+ msg: `Invalid protocol`,
+ };
+ }
+
+ if (rule_type == "all") {
+ return {
+ success: true,
+ };
+ }
+
+ const destination = rule.destination?.trim() || "";
+ const ports = rule.ports?.trim() || "";
+
+ if (!destination && !ports && protocol == "any") {
+ return {
+ success: false,
+ msg: `Destination rules need an IP, ports, or a protocol`,
+ };
+ }
+
+ if (destination && !validateIpv4Cidr({ value: destination })) {
+ return {
+ success: false,
+ msg: `Invalid destination "${destination}"`,
+ };
+ }
+
+ const parsed_ports = parsePortList({ ports });
+
+ if (!parsed_ports.success) {
+ return {
+ success: false,
+ msg: parsed_ports.msg,
+ };
+ }
+
+ return {
+ success: true,
+ };
+}
diff --git a/src/functions/frontend/derive-client-access-label.ts b/src/functions/frontend/derive-client-access-label.ts
new file mode 100644
index 0000000..95b71dd
--- /dev/null
+++ b/src/functions/frontend/derive-client-access-label.ts
@@ -0,0 +1,28 @@
+import type { BUN_SQLITE_WGUI_CLIENT_RULES } from "@/db/types/db";
+
+type Params = {
+ rules?: BUN_SQLITE_WGUI_CLIENT_RULES[] | null;
+};
+
+export default function deriveClientAccessLabel({ rules }: Params) {
+ if (!rules?.[0]) {
+ return {
+ kind: "none" as const,
+ text: "None",
+ };
+ }
+
+ if (rules.some((rule) => rule.rule_type == "all")) {
+ return {
+ kind: "all" as const,
+ text: "All",
+ };
+ }
+
+ const count = rules.length;
+
+ return {
+ kind: "limited" as const,
+ text: count == 1 ? "1 rule" : `${count} rules`,
+ };
+}
diff --git a/src/functions/frontend/format-client-rule-label.test.ts b/src/functions/frontend/format-client-rule-label.test.ts
new file mode 100644
index 0000000..ed5a05d
--- /dev/null
+++ b/src/functions/frontend/format-client-rule-label.test.ts
@@ -0,0 +1,40 @@
+import { describe, expect, test } from "bun:test";
+import formatClientRuleLabel from "./format-client-rule-label";
+import deriveClientAccessLabel from "./derive-client-access-label";
+
+describe("formatClientRuleLabel", () => {
+ test("labels all access", () => {
+ expect(formatClientRuleLabel({ rule: { rule_type: "all" } })).toBe(
+ "All access",
+ );
+ });
+
+ test("labels destination and ports", () => {
+ expect(
+ formatClientRuleLabel({
+ rule: {
+ rule_type: "destination",
+ destination: "10.0.0.5",
+ ports: "443",
+ protocol: "tcp",
+ },
+ }),
+ ).toBe("10.0.0.5 tcp/443");
+ });
+});
+
+describe("deriveClientAccessLabel", () => {
+ test("none / all / limited", () => {
+ expect(deriveClientAccessLabel({ rules: [] }).kind).toBe("none");
+ expect(
+ deriveClientAccessLabel({
+ rules: [{ rule_type: "all" }],
+ }).kind,
+ ).toBe("all");
+ expect(
+ deriveClientAccessLabel({
+ rules: [{ rule_type: "destination", destination: "10.0.0.5" }],
+ }).text,
+ ).toBe("1 rule");
+ });
+});
diff --git a/src/functions/frontend/format-client-rule-label.ts b/src/functions/frontend/format-client-rule-label.ts
new file mode 100644
index 0000000..d304f42
--- /dev/null
+++ b/src/functions/frontend/format-client-rule-label.ts
@@ -0,0 +1,33 @@
+import type { BUN_SQLITE_WGUI_CLIENT_RULES } from "@/db/types/db";
+
+type Params = {
+ rule?: BUN_SQLITE_WGUI_CLIENT_RULES | null;
+};
+
+export default function formatClientRuleLabel({ rule }: Params) {
+ if (!rule) {
+ return "—";
+ }
+
+ if (rule.rule_type == "all") {
+ return "All access";
+ }
+
+ const dest = rule.destination?.trim() || "any host";
+ const ports = rule.ports?.trim();
+ const protocol = rule.protocol && rule.protocol != "any" ? rule.protocol : "";
+
+ if (!ports && !protocol) {
+ return dest;
+ }
+
+ if (!ports) {
+ return `${dest} ${protocol}`.trim();
+ }
+
+ if (!protocol) {
+ return `${dest} :${ports}`;
+ }
+
+ return `${dest} ${protocol}/${ports}`;
+}
diff --git a/src/pages/admin/clients/(sections)/clients-table-section.tsx b/src/pages/admin/clients/(sections)/clients-table-section.tsx
index b9417f5..98f2392 100644
--- a/src/pages/admin/clients/(sections)/clients-table-section.tsx
+++ b/src/pages/admin/clients/(sections)/clients-table-section.tsx
@@ -9,7 +9,10 @@ import Row from "@/src/components/twui/layout/Row";
import Stack from "@/src/components/twui/layout/Stack";
import ClientFormModal from "../(partials)/client-form-modal";
import { useAdminCrudGet } from "@/src/hooks/use-admin-crud-get";
-import type { BUN_SQLITE_WGUI_CLIENTS } from "@/db/types/db";
+import type {
+ BUN_SQLITE_WGUI_CLIENTS,
+ BUN_SQLITE_WGUI_CLIENT_RULES,
+} from "@/db/types/db";
type Props = {
addOpen: boolean;
@@ -33,6 +36,32 @@ export default function ClientsTableSection({ addOpen, setAddOpen }: Props) {
},
});
+ const { res: client_rules } = useAdminCrudGet({
+ table: "client_rules",
+ sql_query: {
+ limit: 500,
+ },
+ });
+
+ const rules_by_client_id = useMemo(() => {
+ const map = new Map();
+
+ for (let i = 0; i < (client_rules || []).length; i++) {
+ const rule = client_rules?.[i];
+ const client_id = Number(rule?.client_id);
+
+ if (!rule || !client_id) {
+ continue;
+ }
+
+ const existing = map.get(client_id) || [];
+ existing.push(rule);
+ map.set(client_id, existing);
+ }
+
+ return map;
+ }, [client_rules]);
+
const filtered = useMemo(() => {
const clients = res || [];
const q = query.trim().toLowerCase();
@@ -80,15 +109,26 @@ export default function ClientsTableSection({ addOpen, setAddOpen }: Props) {
| Client |
Tunnel IP |
Allowed IPs |
+ Access |
Public key |
Created |
Actions |
- {filtered.map((client) => (
-
- ))}
+ {filtered.map((client) => (
+
+ ))}
diff --git a/src/pages/admin/hosts/[host_id]/clients/(partials)/client-rules/add-client-rule-modal.tsx b/src/pages/admin/hosts/[host_id]/clients/(partials)/client-rules/add-client-rule-modal.tsx
new file mode 100644
index 0000000..98b5d1d
--- /dev/null
+++ b/src/pages/admin/hosts/[host_id]/clients/(partials)/client-rules/add-client-rule-modal.tsx
@@ -0,0 +1,105 @@
+import { useState, type Dispatch, type SetStateAction } from "react";
+import { Plus } from "lucide-react";
+import Button from "@/src/components/twui/layout/Button";
+import Stack from "@/src/components/twui/layout/Stack";
+import expandClientRuleDestinations from "@/src/functions/backend/setup/expand-client-rule-destinations";
+import sanitizeClientRule from "@/src/functions/backend/setup/sanitize-client-rule";
+import validateClientRule from "@/src/functions/backend/setup/validate-client-rule";
+import ClientRuleFields from "./client-rule-fields";
+import {
+ clientRuleDraftToRecord,
+ clientRuleToDraft,
+ createClientRuleDraft,
+ type ClientRuleDraft,
+} from "./client-rule-draft";
+import Modal from "@/src/components/twui/elements/Modal";
+import useStatus from "@/src/components/twui/hooks/useStatus";
+import H4 from "@/src/components/twui/layout/H4";
+import Span from "@/src/components/twui/layout/Span";
+import Form from "@/src/components/twui/form/Form";
+import Divider from "@/src/components/twui/layout/Divider";
+
+type Props = {
+ rules: ClientRuleDraft[];
+ setRules: (rules: ClientRuleDraft[]) => void;
+ setError: Dispatch>;
+};
+
+export default function AddClientRuleModal({
+ rules,
+ setRules,
+ setError,
+}: Props) {
+ const [draft, setDraft] = useState(
+ createClientRuleDraft(),
+ );
+ const { open, setOpen } = useStatus();
+
+ function handleAdd() {
+ const sanitized = sanitizeClientRule({
+ rule: clientRuleDraftToRecord({ draft }),
+ });
+ const valid = validateClientRule({ rule: sanitized });
+
+ if (!valid.success || !sanitized) {
+ setError(valid.msg || "Invalid rule");
+ return;
+ }
+
+ const expanded = expandClientRuleDestinations({ rule: sanitized }).map(
+ (rule) => clientRuleToDraft({ rule }),
+ );
+
+ setRules([...rules, ...expanded]);
+ setDraft(createClientRuleDraft());
+ setError(undefined);
+ }
+
+ const is_ll_access = Boolean(rules.find((r) => r.rule_type == "all"));
+
+ if (is_ll_access) {
+ return null;
+ }
+
+ return (
+
+ Add Client Rule
+
+ }
+ >
+
+
+ Add Rule
+ Add a new rule for this client{" "}
+
+
+
+
+
+
+ }
+ onClick={(e) => {
+ e.preventDefault();
+ handleAdd();
+ setOpen(false);
+ }}
+ >
+ Add rule
+
+
+
+ );
+}
diff --git a/src/pages/admin/hosts/[host_id]/clients/(partials)/client-rules/client-rule-destination-field.tsx b/src/pages/admin/hosts/[host_id]/clients/(partials)/client-rules/client-rule-destination-field.tsx
new file mode 100644
index 0000000..5e8b7f7
--- /dev/null
+++ b/src/pages/admin/hosts/[host_id]/clients/(partials)/client-rules/client-rule-destination-field.tsx
@@ -0,0 +1,29 @@
+import Input from "@/src/components/twui/form/Input";
+import type { ClientRuleDraft } from "./client-rule-draft";
+
+type Props = {
+ draft: ClientRuleDraft;
+ setDraft: (draft: ClientRuleDraft) => void;
+};
+
+export default function ClientRuleDestinationField({ draft, setDraft }: Props) {
+ if (draft.rule_type == "all") {
+ return null;
+ }
+
+ return (
+ {
+ setDraft({
+ ...draft,
+ destination: value,
+ });
+ }}
+ info="One IPv4 or CIDR. Comma-separate to add several."
+ />
+ );
+}
diff --git a/src/pages/admin/hosts/[host_id]/clients/(partials)/client-rules/client-rule-draft-item.tsx b/src/pages/admin/hosts/[host_id]/clients/(partials)/client-rules/client-rule-draft-item.tsx
new file mode 100644
index 0000000..6c519f4
--- /dev/null
+++ b/src/pages/admin/hosts/[host_id]/clients/(partials)/client-rules/client-rule-draft-item.tsx
@@ -0,0 +1,42 @@
+import { ShieldCheck, Trash2 } from "lucide-react";
+import Button from "@/src/components/twui/layout/Button";
+import Row from "@/src/components/twui/layout/Row";
+import Span from "@/src/components/twui/layout/Span";
+import formatClientRuleLabel from "@/src/functions/frontend/format-client-rule-label";
+import {
+ clientRuleDraftToRecord,
+ type ClientRuleDraft,
+} from "./client-rule-draft";
+
+type Props = {
+ draft: ClientRuleDraft;
+ onRemove: ({ local_id }: { local_id: string }) => void;
+};
+
+export default function ClientRuleDraftItem({ draft, onRemove }: Props) {
+ return (
+
+
+
+
+ {formatClientRuleLabel({
+ rule: clientRuleDraftToRecord({ draft }),
+ })}
+
+
+ }
+ type="button"
+ onClick={() => {
+ onRemove({ local_id: draft.local_id });
+ }}
+ >
+ Remove
+
+
+ );
+}
diff --git a/src/pages/admin/hosts/[host_id]/clients/(partials)/client-rules/client-rule-draft.ts b/src/pages/admin/hosts/[host_id]/clients/(partials)/client-rules/client-rule-draft.ts
new file mode 100644
index 0000000..973624d
--- /dev/null
+++ b/src/pages/admin/hosts/[host_id]/clients/(partials)/client-rules/client-rule-draft.ts
@@ -0,0 +1,56 @@
+import type { BUN_SQLITE_WGUI_CLIENT_RULES } from "@/db/types/db";
+import type { ClientRuleProtocol, ClientRuleType } from "@/src/types";
+
+export type ClientRuleDraft = {
+ local_id: string;
+ id?: number | "";
+ rule_type: ClientRuleType;
+ destination?: string;
+ ports?: string;
+ protocol: ClientRuleProtocol;
+};
+
+type CreateParams = Partial;
+
+export function createClientRuleDraft(params?: CreateParams): ClientRuleDraft {
+ return {
+ local_id: crypto.randomUUID(),
+ rule_type: "destination",
+ destination: "",
+ ports: "",
+ protocol: "any",
+ ...params,
+ };
+}
+
+export function clientRuleToDraft({
+ rule,
+}: {
+ rule: BUN_SQLITE_WGUI_CLIENT_RULES;
+}): ClientRuleDraft {
+ return {
+ local_id: String(rule.id || crypto.randomUUID()),
+ id: rule.id,
+ rule_type: rule.rule_type == "all" ? "all" : "destination",
+ destination: rule.destination || "",
+ ports: rule.ports || "",
+ protocol:
+ rule.protocol == "tcp" || rule.protocol == "udp"
+ ? rule.protocol
+ : "any",
+ };
+}
+
+export function clientRuleDraftToRecord({
+ draft,
+}: {
+ draft: ClientRuleDraft;
+}): BUN_SQLITE_WGUI_CLIENT_RULES {
+ return {
+ ...(draft.id ? { id: draft.id } : {}),
+ rule_type: draft.rule_type,
+ destination: draft.destination || "",
+ ports: draft.ports || "",
+ protocol: draft.protocol,
+ };
+}
diff --git a/src/pages/admin/hosts/[host_id]/clients/(partials)/client-rules/client-rule-fields.tsx b/src/pages/admin/hosts/[host_id]/clients/(partials)/client-rules/client-rule-fields.tsx
new file mode 100644
index 0000000..7891e2b
--- /dev/null
+++ b/src/pages/admin/hosts/[host_id]/clients/(partials)/client-rules/client-rule-fields.tsx
@@ -0,0 +1,25 @@
+import Stack from "@/src/components/twui/layout/Stack";
+import type { ClientRuleDraft } from "./client-rule-draft";
+import ClientRuleDestinationField from "./client-rule-destination-field";
+import ClientRulePortsField from "./client-rule-ports-field";
+import ClientRuleProtocolField from "./client-rule-protocol-field";
+import ClientRuleTypeField from "./client-rule-type-field";
+import Form from "@/src/components/twui/form/Form";
+
+type Props = {
+ draft: ClientRuleDraft;
+ setDraft: (draft: ClientRuleDraft) => void;
+};
+
+export default function ClientRuleFields({ draft, setDraft }: Props) {
+ return (
+
+ );
+}
diff --git a/src/pages/admin/hosts/[host_id]/clients/(partials)/client-rules/client-rule-ports-field.tsx b/src/pages/admin/hosts/[host_id]/clients/(partials)/client-rules/client-rule-ports-field.tsx
new file mode 100644
index 0000000..deb184a
--- /dev/null
+++ b/src/pages/admin/hosts/[host_id]/clients/(partials)/client-rules/client-rule-ports-field.tsx
@@ -0,0 +1,29 @@
+import Input from "@/src/components/twui/form/Input";
+import type { ClientRuleDraft } from "./client-rule-draft";
+
+type Props = {
+ draft: ClientRuleDraft;
+ setDraft: (draft: ClientRuleDraft) => void;
+};
+
+export default function ClientRulePortsField({ draft, setDraft }: Props) {
+ if (draft.rule_type == "all") {
+ return null;
+ }
+
+ return (
+ {
+ setDraft({
+ ...draft,
+ ports: e.target.value,
+ });
+ }}
+ info="Leave empty for any port."
+ />
+ );
+}
diff --git a/src/pages/admin/hosts/[host_id]/clients/(partials)/client-rules/client-rule-protocol-field.tsx b/src/pages/admin/hosts/[host_id]/clients/(partials)/client-rules/client-rule-protocol-field.tsx
new file mode 100644
index 0000000..11279af
--- /dev/null
+++ b/src/pages/admin/hosts/[host_id]/clients/(partials)/client-rules/client-rule-protocol-field.tsx
@@ -0,0 +1,33 @@
+import Select from "@/src/components/twui/form/Select";
+import { ClientRuleProtocols } from "@/src/dict/client-rules-dict";
+import type { ClientRuleProtocol } from "@/src/types";
+import type { ClientRuleDraft } from "./client-rule-draft";
+
+type Props = {
+ draft: ClientRuleDraft;
+ setDraft: (draft: ClientRuleDraft) => void;
+};
+
+export default function ClientRuleProtocolField({ draft, setDraft }: Props) {
+ if (draft.rule_type == "all") {
+ return null;
+ }
+
+ return (
+ |