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{" "} +
+ + + + + + +
+
+ ); +} 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 }), + })} + + + + + ); +} 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 ( + + title="Protocol" + showLabel + options={ClientRuleProtocols.map((protocol) => ({ + title: protocol.title, + value: protocol.value, + default: protocol.value == draft.protocol, + }))} + changeHandler={(value) => { + setDraft({ + ...draft, + protocol: value, + }); + }} + /> + ); +} diff --git a/src/pages/admin/hosts/[host_id]/clients/(partials)/client-rules/client-rule-type-field.tsx b/src/pages/admin/hosts/[host_id]/clients/(partials)/client-rules/client-rule-type-field.tsx new file mode 100644 index 0000000..a3690ec --- /dev/null +++ b/src/pages/admin/hosts/[host_id]/clients/(partials)/client-rules/client-rule-type-field.tsx @@ -0,0 +1,45 @@ +import Select from "@/src/components/twui/form/Select"; +import { ClientRuleTypes } from "@/src/dict/client-rules-dict"; +import type { ClientRuleType } from "@/src/types"; +import type { ClientRuleDraft } from "./client-rule-draft"; +import Checkbox from "@/src/components/twui/form/Checkbox"; + +type Props = { + draft: ClientRuleDraft; + setDraft: (draft: ClientRuleDraft) => void; +}; + +export default function ClientRuleTypeField({ draft, setDraft }: Props) { + return ( + { + setDraft({ + ...draft, + rule_type: checked ? "all" : "destination", + }); + }} + wrapperWrapperProps={{ className: `mb-4` }} + /> + ); + + // return ( + // + // title="Access" + // showLabel + // options={ClientRuleTypes.map((type) => ({ + // title: type.title, + // value: type.value, + // default: type.value == draft.rule_type, + // }))} + // changeHandler={(value) => { + // setDraft({ + // ...draft, + // rule_type: value, + // }); + // }} + // /> + // ); +} diff --git a/src/pages/admin/hosts/[host_id]/clients/(partials)/client-rules/client-rules-editor.tsx b/src/pages/admin/hosts/[host_id]/clients/(partials)/client-rules/client-rules-editor.tsx new file mode 100644 index 0000000..07f2740 --- /dev/null +++ b/src/pages/admin/hosts/[host_id]/clients/(partials)/client-rules/client-rules-editor.tsx @@ -0,0 +1,76 @@ +import { useState } from "react"; +import { Plus } from "lucide-react"; +import Button from "@/src/components/twui/layout/Button"; +import H4 from "@/src/components/twui/layout/H4"; +import P from "@/src/components/twui/layout/P"; +import Stack from "@/src/components/twui/layout/Stack"; +import Tag from "@/src/components/twui/elements/Tag"; +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 ClientRuleDraftItem from "./client-rule-draft-item"; +import ClientRulesEmpty from "./client-rules-empty"; +import { + clientRuleDraftToRecord, + clientRuleToDraft, + createClientRuleDraft, + type ClientRuleDraft, +} from "./client-rule-draft"; +import AddClientRuleModal from "./add-client-rule-modal"; +import Row from "@/src/components/twui/layout/Row"; +import Paper from "@/src/components/twui/elements/Paper"; + +type Props = { + rules: ClientRuleDraft[]; + setRules: (rules: ClientRuleDraft[]) => void; +}; + +export default function ClientRulesEditor({ rules, setRules }: Props) { + const [error, setError] = useState(); + + return ( + + + + +

Access rules

+

+ Zero trust: nothing is allowed until you add a rule. +

+
+ + +
+ + {error ? {error} : null} + + {rules[0] ? ( + + {rules.map((rule) => ( + { + setRules( + rules.filter( + (item) => item.local_id != local_id, + ), + ); + }} + /> + ))} + + ) : ( + + )} +
+
+ ); +} diff --git a/src/pages/admin/hosts/[host_id]/clients/(partials)/client-rules/client-rules-empty.tsx b/src/pages/admin/hosts/[host_id]/clients/(partials)/client-rules/client-rules-empty.tsx new file mode 100644 index 0000000..8ab0a7f --- /dev/null +++ b/src/pages/admin/hosts/[host_id]/clients/(partials)/client-rules/client-rules-empty.tsx @@ -0,0 +1,9 @@ +import EmptyContent from "@/src/components/twui/elements/EmptyContent"; + +export default function ClientRulesEmpty() { + return ( + + ); +} diff --git a/src/pages/admin/hosts/[host_id]/clients/(partials)/clients-list.tsx b/src/pages/admin/hosts/[host_id]/clients/(partials)/clients-list.tsx index ed4e8fb..75de836 100644 --- a/src/pages/admin/hosts/[host_id]/clients/(partials)/clients-list.tsx +++ b/src/pages/admin/hosts/[host_id]/clients/(partials)/clients-list.tsx @@ -6,7 +6,8 @@ import Stack from "@/src/components/twui/layout/Stack"; import ClientRow from "@/src/components/general/client-row"; import { useAdminCrudGet } from "@/src/hooks/use-admin-crud-get"; import { AppContext } from "@/src/pages/__root"; -import { useContext } from "react"; +import { useContext, useMemo } from "react"; +import type { BUN_SQLITE_WGUI_CLIENT_RULES } from "@/db/types/db"; type Props = { host_id: string | number; @@ -32,6 +33,33 @@ export default function ClientsList({ host_id }: Props) { initial_res: pageProps?.clients || undefined, }); + const { res: client_rules } = useAdminCrudGet({ + table: "client_rules", + sql_query: { + limit: 500, + }, + initial_res: pageProps?.client_rules || undefined, + }); + + 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]); + if (!clients) { return ( @@ -60,6 +88,7 @@ export default function ClientsList({ host_id }: Props) { Client Tunnel IP Allowed IPs + Access Public key Created Actions @@ -67,7 +96,15 @@ export default function ClientsList({ host_id }: Props) { {clients.map((client) => ( - + ))} diff --git a/src/pages/admin/hosts/[host_id]/clients/[client_id]/(partials)/add-client-rule-form.tsx b/src/pages/admin/hosts/[host_id]/clients/[client_id]/(partials)/add-client-rule-form.tsx new file mode 100644 index 0000000..d439021 --- /dev/null +++ b/src/pages/admin/hosts/[host_id]/clients/[client_id]/(partials)/add-client-rule-form.tsx @@ -0,0 +1,73 @@ +import { useState } from "react"; +import { Plus } from "lucide-react"; +import type { BUN_SQLITE_WGUI_CLIENTS } from "@/db/types/db"; +import Button from "@/src/components/twui/layout/Button"; +import Stack from "@/src/components/twui/layout/Stack"; +import Tag from "@/src/components/twui/elements/Tag"; +import adminCrudHandler from "@/src/functions/frontend/admin-crud-handler"; +import ClientRuleFields from "../../(partials)/client-rules/client-rule-fields"; +import { + clientRuleDraftToRecord, + createClientRuleDraft, + type ClientRuleDraft, +} from "../../(partials)/client-rules/client-rule-draft"; + +type Props = { + client: BUN_SQLITE_WGUI_CLIENTS; +}; + +export default function AddClientRuleForm({ client }: Props) { + const [draft, setDraft] = useState( + createClientRuleDraft(), + ); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(); + + async function handleAdd() { + if (!client.id) { + setError("Client is missing an id"); + return; + } + + setBusy(true); + setError(undefined); + + const res = await adminCrudHandler({ + action: "insert", + table: "client_rules", + insert_data: [ + { + ...clientRuleDraftToRecord({ draft }), + client_id: client.id, + user_id: client.user_id, + }, + ], + }); + + if (!res.success) { + setBusy(false); + setError(res.msg || "Could not add rule"); + return; + } + + window.location.reload(); + } + + return ( + + {error ? {error} : null} + + + + ); +} diff --git a/src/pages/admin/hosts/[host_id]/clients/[client_id]/(partials)/client-rule-row.tsx b/src/pages/admin/hosts/[host_id]/clients/[client_id]/(partials)/client-rule-row.tsx new file mode 100644 index 0000000..51d93b4 --- /dev/null +++ b/src/pages/admin/hosts/[host_id]/clients/[client_id]/(partials)/client-rule-row.tsx @@ -0,0 +1,57 @@ +import { useState } from "react"; +import { Trash2 } from "lucide-react"; +import type { BUN_SQLITE_WGUI_CLIENT_RULES } from "@/db/types/db"; +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 adminCrudHandler from "@/src/functions/frontend/admin-crud-handler"; +import formatClientRuleLabel from "@/src/functions/frontend/format-client-rule-label"; + +type Props = { + rule: BUN_SQLITE_WGUI_CLIENT_RULES; +}; + +export default function ClientRuleRow({ rule }: Props) { + const [busy, setBusy] = useState(false); + + async function handleDelete() { + if (!window.confirm(`Delete this access rule?`)) { + return; + } + + setBusy(true); + + const res = await adminCrudHandler({ + action: "delete", + table: "client_rules", + id: rule.id, + }); + + if (!res.success) { + setBusy(false); + window.alert(res.msg || "Could not delete rule"); + return; + } + + window.location.reload(); + } + + return ( + + + {formatClientRuleLabel({ rule })} + + + + ); +} diff --git a/src/pages/admin/hosts/[host_id]/clients/[client_id]/(sections)/client-info-section.tsx b/src/pages/admin/hosts/[host_id]/clients/[client_id]/(sections)/client-info-section.tsx index b1516a4..ffaad4b 100644 --- a/src/pages/admin/hosts/[host_id]/clients/[client_id]/(sections)/client-info-section.tsx +++ b/src/pages/admin/hosts/[host_id]/clients/[client_id]/(sections)/client-info-section.tsx @@ -4,6 +4,7 @@ import H3 from "@/src/components/twui/layout/H3"; import Row from "@/src/components/twui/layout/Row"; import Stack from "@/src/components/twui/layout/Stack"; import { AppContext } from "@/src/pages/__root"; +import deriveClientAccessLabel from "@/src/functions/frontend/derive-client-access-label"; import ClientInfoRow from "../(partials)/client-info-row"; export default function ClientInfoSection() { @@ -11,6 +12,9 @@ export default function ClientInfoSection() { const client = pageProps?.client; const host_id = Number(client?.host_id || 0); + const access = deriveClientAccessLabel({ + rules: pageProps?.client_rules, + }); if (!client) { return null; @@ -31,6 +35,10 @@ export default function ClientInfoSection() { value: client.allowed_ips || "—", mono: true, }, + { + label: "Access", + value: access.text, + }, { label: "Public key", value: client.public_key || "—", diff --git a/src/pages/admin/hosts/[host_id]/clients/[client_id]/(sections)/client-rules-section.tsx b/src/pages/admin/hosts/[host_id]/clients/[client_id]/(sections)/client-rules-section.tsx new file mode 100644 index 0000000..06ea2d4 --- /dev/null +++ b/src/pages/admin/hosts/[host_id]/clients/[client_id]/(sections)/client-rules-section.tsx @@ -0,0 +1,87 @@ +import { ShieldOff } from "lucide-react"; +import { useContext } from "react"; +import AdminCard from "@/src/components/general/admin-card"; +import EmptyContent from "@/src/components/twui/elements/EmptyContent"; +import H3 from "@/src/components/twui/layout/H3"; +import P from "@/src/components/twui/layout/P"; +import Row from "@/src/components/twui/layout/Row"; +import Stack from "@/src/components/twui/layout/Stack"; +import { AppContext } from "@/src/pages/__root"; +import AddClientRuleForm from "../(partials)/add-client-rule-form"; +import ClientRuleRow from "../(partials)/client-rule-row"; +import Modal from "@/src/components/twui/elements/Modal"; +import useStatus from "@/src/components/twui/hooks/useStatus"; +import Button from "@/src/components/twui/layout/Button"; +import H4 from "@/src/components/twui/layout/H4"; +import Span from "@/src/components/twui/layout/Span"; + +export default function ClientRulesSection() { + const { pageProps } = useContext(AppContext); + + const client = pageProps?.client; + const rules = pageProps?.client_rules || []; + + const { open, setOpen } = useStatus(); + + if (!client) { + return null; + } + + return ( + + + + + +

Access rules

+
+

+ Zero trust: this client can only reach what you list + here. +

+
+ + + Add Rule + + } + > + + +

Add Rule

+ + Add a new access rule to this client + +
+ +
+
+
+ + {rules[0] ? ( + + {rules.map((rule) => ( + + ))} + + ) : ( + } + /> + )} +
+
+ ); +} diff --git a/src/pages/admin/hosts/[host_id]/clients/[client_id]/edit/(sections)/edit-client-form-section.tsx b/src/pages/admin/hosts/[host_id]/clients/[client_id]/edit/(sections)/edit-client-form-section.tsx index a50431c..43b7780 100644 --- a/src/pages/admin/hosts/[host_id]/clients/[client_id]/edit/(sections)/edit-client-form-section.tsx +++ b/src/pages/admin/hosts/[host_id]/clients/[client_id]/edit/(sections)/edit-client-form-section.tsx @@ -13,6 +13,7 @@ export default function EditClientFormSection() { diff --git a/src/pages/admin/hosts/[host_id]/clients/[client_id]/edit/index.server.ts b/src/pages/admin/hosts/[host_id]/clients/[client_id]/edit/index.server.ts index 0454c59..7ecd937 100644 --- a/src/pages/admin/hosts/[host_id]/clients/[client_id]/edit/index.server.ts +++ b/src/pages/admin/hosts/[host_id]/clients/[client_id]/edit/index.server.ts @@ -1,4 +1,5 @@ import type { BUN_SQLITE_WGUI_CLIENTS } from "@/db/types/db"; +import grabClientRules from "@/src/functions/backend/db/client-rules/grab-client-rules"; import type { PagePropsType, PageQueryObject, TableType } from "@/src/types"; import BunSQLite from "@moduletrace/bun-sqlite"; import type { BunextPageServerFn } from "@moduletrace/bunext/types"; @@ -18,9 +19,16 @@ const server: BunextPageServerFn = async ({ query }) => { targetId: page_query.client_id, }); + const client = client_res.singleRes || null; + + const client_rules = await grabClientRules({ + client_id: client?.id, + }); + return { props: { - client: client_res.singleRes || null, + client, + client_rules, }, }; }; diff --git a/src/pages/admin/hosts/[host_id]/clients/[client_id]/index.server.ts b/src/pages/admin/hosts/[host_id]/clients/[client_id]/index.server.ts index 512a8ee..35a60ec 100644 --- a/src/pages/admin/hosts/[host_id]/clients/[client_id]/index.server.ts +++ b/src/pages/admin/hosts/[host_id]/clients/[client_id]/index.server.ts @@ -3,6 +3,7 @@ import type { BUN_SQLITE_WGUI_HOSTS, } from "@/db/types/db"; import { AppData } from "@/src/data/app-data"; +import grabClientRules from "@/src/functions/backend/db/client-rules/grab-client-rules"; import generateWireguardClientQR from "@/src/functions/backend/setup/generate-wireguard-client-qr"; import readWireguardClientConfig from "@/src/functions/backend/setup/read-wireguard-client-config"; import type { PagePropsType, PageQueryObject, TableType } from "@/src/types"; @@ -55,6 +56,10 @@ const server: BunextPageServerFn = async ({ query }) => { config: config_res.config, }); + const client_rules = await grabClientRules({ + client_id: client?.id, + }); + return { props: { client, @@ -62,6 +67,7 @@ const server: BunextPageServerFn = async ({ query }) => { client_config: config_res.config, client_config_path: config_res.config_path, client_qr_data_uri: qr_res.qr_data_uri, + client_rules, }, }; }; diff --git a/src/pages/admin/hosts/[host_id]/clients/[client_id]/index.tsx b/src/pages/admin/hosts/[host_id]/clients/[client_id]/index.tsx index 3797277..c4f7776 100644 --- a/src/pages/admin/hosts/[host_id]/clients/[client_id]/index.tsx +++ b/src/pages/admin/hosts/[host_id]/clients/[client_id]/index.tsx @@ -11,6 +11,7 @@ import EmptyContent from "@/src/components/twui/elements/EmptyContent"; import ClientInfoSection from "./(sections)/client-info-section"; import ClientConfigSection from "./(sections)/client-config-section"; import ClientQRSection from "./(sections)/client-qr-section"; +import ClientRulesSection from "./(sections)/client-rules-section"; import ClientDetailActions from "./(partials)/client-detail-actions"; export default function AdminClientDetailPage() { @@ -52,6 +53,7 @@ export default function AdminClientDetailPage() { {pageProps?.client ? ( <> + diff --git a/src/pages/admin/hosts/[host_id]/clients/add-client/(functions)/submit-add-client-form.ts b/src/pages/admin/hosts/[host_id]/clients/add-client/(functions)/submit-add-client-form.ts index b55404f..ec87e30 100644 --- a/src/pages/admin/hosts/[host_id]/clients/add-client/(functions)/submit-add-client-form.ts +++ b/src/pages/admin/hosts/[host_id]/clients/add-client/(functions)/submit-add-client-form.ts @@ -1,10 +1,20 @@ -import type { BUN_SQLITE_WGUI_CLIENTS } from "@/db/types/db"; +import type { + BUN_SQLITE_WGUI_CLIENT_RULES, + BUN_SQLITE_WGUI_CLIENTS, +} from "@/db/types/db"; import adminCrudHandler from "@/src/functions/frontend/admin-crud-handler"; import type useFormInit from "@/src/hooks/use-form-init"; import _ from "lodash"; +import { + clientRuleDraftToRecord, + type ClientRuleDraft, +} from "../../(partials)/client-rules/client-rule-draft"; +import fetchApi from "@/src/components/twui/utils/fetch/fetchApi"; +import type { ApiReqParams } from "@/src/types"; type Params = { host_id?: string | number; + rules?: ClientRuleDraft[]; }; export default async function submitAddClientForm( @@ -15,7 +25,7 @@ export default async function submitAddClientForm( existing_full, app_context, }: ReturnType>, - { host_id }: Params, + { host_id, rules }: Params, ) { try { const confirm_msg = existing_full?.id @@ -44,6 +54,10 @@ export default async function submitAddClientForm( setLoading(true); + const client_rules = (rules || []).map((draft) => + clientRuleDraftToRecord({ draft }), + ); + const res = existing_full?.id ? await adminCrudHandler({ action: "update", @@ -57,6 +71,47 @@ export default async function submitAddClientForm( insert_data: [new_client_data], }); + const client_id = existing_full?.id || res.postInsertReturn?.insertId; + + const delete_client_rules = + await adminCrudHandler({ + action: "delete", + table: "client_rules", + sql_query: { + query: { + client_id: { + value: String(client_id), + }, + host_id: { + value: String(host_id), + }, + }, + }, + }); + + if (client_rules?.[0] && client_id) { + const insert_client_rules = + await adminCrudHandler({ + action: "insert", + table: "client_rules", + insert_data: client_rules.map((cr) => ({ + ...cr, + host_id: Number(host_id), + client_id: Number(client_id), + user_id: app_context.user?.id, + })), + }); + } + + if (client_id) { + await fetchApi(`/api/admin/run-client-setup`, { + method: "POST", + body: { + client_id, + }, + }); + } + if (res.success) { if (existing_full?.id) { window.location.pathname = `/admin/hosts/${final_host_id}/clients/${existing_full.id}`; diff --git a/src/pages/admin/hosts/[host_id]/clients/add-client/(partials)/client-form/add-client-form-allowed-ips.tsx b/src/pages/admin/hosts/[host_id]/clients/add-client/(partials)/client-form/add-client-form-allowed-ips.tsx index 2c3dd10..7887af4 100644 --- a/src/pages/admin/hosts/[host_id]/clients/add-client/(partials)/client-form/add-client-form-allowed-ips.tsx +++ b/src/pages/admin/hosts/[host_id]/clients/add-client/(partials)/client-form/add-client-form-allowed-ips.tsx @@ -10,7 +10,7 @@ export default function AddClientFormAllowedIps({ }: ReturnType>) { return ( - {form.allow_all_ips ? null : ( + {form.allow_all_ips == 1 ? null : ( )} + { setForm((prev) => ({ @@ -33,6 +34,12 @@ export default function AddClientFormAllowedIps({ allow_all_ips: value ? 1 : 0, })); }} + info={ + + Sets the wireguard AllowedIPs parameter to{" "} + 0.0.0.0/0, ::/0 + + } /> ); diff --git a/src/pages/admin/hosts/[host_id]/clients/add-client/(partials)/client-form/add-client-form.tsx b/src/pages/admin/hosts/[host_id]/clients/add-client/(partials)/client-form/add-client-form.tsx index 8a01884..f49d2f4 100644 --- a/src/pages/admin/hosts/[host_id]/clients/add-client/(partials)/client-form/add-client-form.tsx +++ b/src/pages/admin/hosts/[host_id]/clients/add-client/(partials)/client-form/add-client-form.tsx @@ -1,25 +1,37 @@ -import type { BUN_SQLITE_WGUI_CLIENTS } from "@/db/types/db"; +import { useState } from "react"; +import type { + BUN_SQLITE_WGUI_CLIENTS, + BUN_SQLITE_WGUI_CLIENT_RULES, +} from "@/db/types/db"; import Form from "@/src/components/twui/form/Form"; import LoadingOverlay from "@/src/components/twui/elements/LoadingOverlay"; import Stack from "@/src/components/twui/layout/Stack"; import Tag from "@/src/components/twui/elements/Tag"; import useFormInit from "@/src/hooks/use-form-init"; import submitAddClientForm from "../../(functions)/submit-add-client-form"; +import ClientRulesEditor from "../../../(partials)/client-rules/client-rules-editor"; +import { + clientRuleToDraft, + type ClientRuleDraft, +} from "../../../(partials)/client-rules/client-rule-draft"; import AddClientFormAction from "./add-client-form-action"; import AddClientFormAllowedIps from "./add-client-form-allowed-ips"; import AddClientFormNotes from "./add-client-form-notes"; import AddClientFormTitle from "./add-client-form-title"; import AddClientFormWgIpAddress from "./add-client-form-wg-ip-address"; +import LoadingRectangleBlock from "@/src/components/twui/layout/LoadingRectangleBlock"; type Props = { existing_client?: BUN_SQLITE_WGUI_CLIENTS; existing_client_full?: BUN_SQLITE_WGUI_CLIENTS; + existing_client_rules?: BUN_SQLITE_WGUI_CLIENT_RULES[]; host_id?: string | number; }; export default function AddClientForm({ existing_client, existing_client_full, + existing_client_rules, host_id, }: Props) { const init = useFormInit({ @@ -28,25 +40,35 @@ export default function AddClientForm({ title: "Add Client", }); + const [rules, setRules] = useState( + (existing_client_rules || []).map((rule) => + clientRuleToDraft({ rule }), + ), + ); + const { loading, status } = init; return (
{ - submitAddClientForm(init, { host_id }); + submitAddClientForm(init, { host_id, rules }); }} > {status?.error && {status.msg}} {loading && } - - - - - - - - + {init.ready ? ( + + + + + + + + + ) : ( + + )} ); -} \ No newline at end of file +} diff --git a/src/pages/admin/hosts/[host_id]/clients/index.server.ts b/src/pages/admin/hosts/[host_id]/clients/index.server.ts index f230433..707e875 100644 --- a/src/pages/admin/hosts/[host_id]/clients/index.server.ts +++ b/src/pages/admin/hosts/[host_id]/clients/index.server.ts @@ -2,6 +2,7 @@ import type { BUN_SQLITE_WGUI_CLIENTS, BUN_SQLITE_WGUI_HOSTS, } from "@/db/types/db"; +import grabClientRules from "@/src/functions/backend/db/client-rules/grab-client-rules"; import type { PagePropsType, PageQueryObject, TableType } from "@/src/types"; import BunSQLite from "@moduletrace/bun-sqlite"; import type { BunextPageServerFn } from "@moduletrace/bunext/types"; @@ -32,10 +33,19 @@ const server: BunextPageServerFn = async ({ }, }); + const clients = host_clients.payload || null; + + const client_rules = await grabClientRules({ + client_ids: (clients || []) + .map((client) => client.id) + .filter((id): id is number => Boolean(id)), + }); + return { props: { host: host_record_res.singleRes || null, - clients: host_clients.payload || null, + clients, + client_rules, }, }; }; diff --git a/src/pages/api/admin/crud/(tables)/client_rules/(functions)/prepare-client-rules.ts b/src/pages/api/admin/crud/(tables)/client_rules/(functions)/prepare-client-rules.ts new file mode 100644 index 0000000..685db8c --- /dev/null +++ b/src/pages/api/admin/crud/(tables)/client_rules/(functions)/prepare-client-rules.ts @@ -0,0 +1,57 @@ +import type { BUN_SQLITE_WGUI_CLIENT_RULES } from "@/db/types/db"; +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"; + +type Params = { + rules?: BUN_SQLITE_WGUI_CLIENT_RULES[]; + client_id?: number | ""; + user_id?: number | ""; +}; + +export default function prepareClientRules({ + rules, + client_id, + user_id, +}: Params) { + const prepared: BUN_SQLITE_WGUI_CLIENT_RULES[] = []; + + for (let i = 0; i < (rules || []).length; i++) { + const sanitized = sanitizeClientRule({ + rule: rules?.[i], + client_id, + user_id, + }); + + if (!sanitized) { + continue; + } + + const expanded = expandClientRuleDestinations({ rule: sanitized }); + + for (let e = 0; e < expanded.length; e++) { + const rule = expanded[e]; + + if (!rule) { + continue; + } + + const valid = validateClientRule({ rule }); + + if (!valid.success) { + return { + success: false as const, + msg: valid.msg, + rules: [] as BUN_SQLITE_WGUI_CLIENT_RULES[], + }; + } + + prepared.push(rule); + } + } + + return { + success: true as const, + rules: prepared, + }; +} diff --git a/src/pages/api/admin/crud/(tables)/client_rules/del.ts b/src/pages/api/admin/crud/(tables)/client_rules/del.ts new file mode 100644 index 0000000..a7cabbb --- /dev/null +++ b/src/pages/api/admin/crud/(tables)/client_rules/del.ts @@ -0,0 +1,75 @@ +import type { BUN_SQLITE_WGUI_CLIENT_RULES } from "@/db/types/db"; +import checkUserAccess from "@/src/functions/backend/auth/check-user-access"; +import type { AdminCrudAPIParams, TableType } from "@/src/types"; +import BunSQLite from "@moduletrace/bun-sqlite"; +import type { APIResponseObject } from "@moduletrace/bunext/types"; +import _ from "lodash"; + +export default async function ( + params: AdminCrudAPIParams, +): Promise { + const { user, user_types, body, id } = params; + + const can_delete_rule = checkUserAccess({ + user_types, + }); + + const existing_res = await BunSQLite.select< + BUN_SQLITE_WGUI_CLIENT_RULES, + TableType + >({ + table: "client_rules", + query: _.merge(body?.sql_query || {}, {}), + targetId: id, + }); + + const rules = existing_res.payload || []; + + if (!rules[0]) { + return { + success: false, + msg: `No client rule to delete`, + }; + } + + const client_ids = new Set(); + + for (let i = 0; i < rules.length; i++) { + const rule = rules[i]; + + if (!rule?.id) { + continue; + } + + if (!can_delete_rule.success && rule.user_id != user.id) { + return { + success: false, + msg: `Can't delete client rule`, + }; + } + + if (rule.client_id) { + client_ids.add(Number(rule.client_id)); + } + + const del_res = await BunSQLite.delete< + BUN_SQLITE_WGUI_CLIENT_RULES, + TableType + >({ + table: "client_rules", + targetId: rule.id, + }); + + if (!del_res.success) { + return { + success: false, + msg: del_res.msg || `Could not delete client rule`, + }; + } + } + + return { + success: true, + msg: `${rules.length} client rule(s) deleted`, + }; +} diff --git a/src/pages/api/admin/crud/(tables)/client_rules/get.ts b/src/pages/api/admin/crud/(tables)/client_rules/get.ts new file mode 100644 index 0000000..de1eea3 --- /dev/null +++ b/src/pages/api/admin/crud/(tables)/client_rules/get.ts @@ -0,0 +1,38 @@ +import type { BUN_SQLITE_WGUI_CLIENT_RULES } from "@/db/types/db"; +import checkUserAccess from "@/src/functions/backend/auth/check-user-access"; +import type { AdminCrudAPIParams, TableType } from "@/src/types"; +import BunSQLite from "@moduletrace/bun-sqlite"; +import type { APIResponseObject } from "@moduletrace/bunext/types"; +import _ from "lodash"; + +export default async function ( + params: AdminCrudAPIParams, +): Promise { + const { table, user_types, body, id, query, user } = params; + + let final_sql_query = _.merge(query?.sql_query, body?.sql_query) || {}; + + delete final_sql_query.join; + + const can_user_see_all_rules = checkUserAccess({ + user_types, + includes: ["admin"], + }); + + if (!can_user_see_all_rules.success) { + final_sql_query.query = { + ...final_sql_query.query, + user_id: { + value: user.id, + }, + }; + } + + const GET = await BunSQLite.select({ + table, + query: final_sql_query, + targetId: id, + }); + + return GET; +} diff --git a/src/pages/api/admin/crud/(tables)/client_rules/index.ts b/src/pages/api/admin/crud/(tables)/client_rules/index.ts new file mode 100644 index 0000000..ce6c13a --- /dev/null +++ b/src/pages/api/admin/crud/(tables)/client_rules/index.ts @@ -0,0 +1,28 @@ +import type { AdminCrudAPIParams } from "@/src/types"; +import type { APIResponseObject } from "@moduletrace/bunext/types"; +import get from "./get"; +import post from "./post"; +import put from "./put"; +import del from "./del"; + +export default async function ( + params: AdminCrudAPIParams, +): Promise { + const { req } = params; + + switch (req.method) { + case "GET": + return await get(params); + case "POST": + return await post(params); + case "PUT": + return await put(params); + case "DELETE": + return await del(params); + + default: + return { + success: false, + }; + } +} diff --git a/src/pages/api/admin/crud/(tables)/client_rules/post.ts b/src/pages/api/admin/crud/(tables)/client_rules/post.ts new file mode 100644 index 0000000..bf1c40a --- /dev/null +++ b/src/pages/api/admin/crud/(tables)/client_rules/post.ts @@ -0,0 +1,50 @@ +import type { BUN_SQLITE_WGUI_CLIENT_RULES } from "@/db/types/db"; +import checkUserAccess from "@/src/functions/backend/auth/check-user-access"; +import type { AdminCrudAPIParams } from "@/src/types"; +import BunSQLite from "@moduletrace/bun-sqlite"; +import type { APIResponseObject } from "@moduletrace/bunext/types"; +import prepareClientRules from "./(functions)/prepare-client-rules"; + +export default async function ( + params: AdminCrudAPIParams, +): Promise { + const { table, body, user_types, user } = params; + + const is_user_allowed_to_post_any = checkUserAccess({ + user_types, + }); + + const prepared = prepareClientRules({ + rules: body?.insert_data as BUN_SQLITE_WGUI_CLIENT_RULES[] | undefined, + user_id: user.id, + }); + + if (!prepared.success) { + return { + success: false, + msg: prepared.msg, + }; + } + + let final_insert_data = prepared.rules.map((rule) => ({ + ...rule, + user_id: is_user_allowed_to_post_any.success + ? rule.user_id || user.id + : user.id, + })); + + if (!final_insert_data[0]) { + return { + success: false, + msg: `No client rules to insert`, + }; + } + + const POST = await BunSQLite.insert({ + table, + data: final_insert_data, + update_on_duplicate: body?.update_on_duplicate, + }); + + return POST; +} diff --git a/src/pages/api/admin/crud/(tables)/client_rules/put.ts b/src/pages/api/admin/crud/(tables)/client_rules/put.ts new file mode 100644 index 0000000..51003ee --- /dev/null +++ b/src/pages/api/admin/crud/(tables)/client_rules/put.ts @@ -0,0 +1,89 @@ +import type { BUN_SQLITE_WGUI_CLIENT_RULES } from "@/db/types/db"; +import checkUserAccess from "@/src/functions/backend/auth/check-user-access"; +import sanitizeClientRule from "@/src/functions/backend/setup/sanitize-client-rule"; +import validateClientRule from "@/src/functions/backend/setup/validate-client-rule"; +import type { AdminCrudAPIParams, TableType } from "@/src/types"; +import BunSQLite from "@moduletrace/bun-sqlite"; +import type { APIResponseObject } from "@moduletrace/bunext/types"; +import _ from "lodash"; + +export default async function ( + params: AdminCrudAPIParams, +): Promise { + const { table, body, user_types, id, query, user } = params; + + if (!body?.update_data) { + return { + success: false, + msg: `No Update Data`, + }; + } + + const is_user_allowed_to_put = checkUserAccess({ + user_types, + }); + + let final_sql_query = _.merge(query?.sql_query, body?.sql_query) || {}; + + if (!is_user_allowed_to_put.success) { + final_sql_query = {}; + } + + const existing_res = await BunSQLite.select< + BUN_SQLITE_WGUI_CLIENT_RULES, + TableType + >({ + table: "client_rules", + targetId: id, + }); + + const existing = existing_res.singleRes; + + if (!existing?.id) { + return { + success: false, + msg: `Client rule not found`, + }; + } + + if (!is_user_allowed_to_put.success && existing.user_id != user.id) { + return { + success: false, + msg: `Unauthorized`, + }; + } + + const sanitized = sanitizeClientRule({ + rule: { + ...existing, + ...body.update_data, + }, + client_id: existing.client_id, + user_id: existing.user_id, + }); + + if (!sanitized) { + return { + success: false, + msg: `Invalid client rule`, + }; + } + + const valid = validateClientRule({ rule: sanitized }); + + if (!valid.success) { + return { + success: false, + msg: valid.msg, + }; + } + + const PUT = await BunSQLite.update({ + table, + data: sanitized, + targetId: existing.id, + query: final_sql_query, + }); + + return PUT; +} diff --git a/src/pages/api/admin/crud/(tables)/clients/(functions)/run-client-setup.ts b/src/pages/api/admin/crud/(tables)/clients/(functions)/run-client-setup.ts index 850acf5..539167b 100644 --- a/src/pages/api/admin/crud/(tables)/clients/(functions)/run-client-setup.ts +++ b/src/pages/api/admin/crud/(tables)/clients/(functions)/run-client-setup.ts @@ -54,5 +54,9 @@ export default async function runClientSetup({ host = host_res.singleRes || undefined; } - return await setupWireguardClient({ client, host, user }); + return await setupWireguardClient({ + client, + host, + user, + }); } diff --git a/src/pages/api/admin/crud/(tables)/clients/(functions)/sync-client-rules.ts b/src/pages/api/admin/crud/(tables)/clients/(functions)/sync-client-rules.ts new file mode 100644 index 0000000..bca9987 --- /dev/null +++ b/src/pages/api/admin/crud/(tables)/clients/(functions)/sync-client-rules.ts @@ -0,0 +1,156 @@ +import type { BUN_SQLITE_WGUI_CLIENT_RULES } from "@/db/types/db"; +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 type { TableType } from "@/src/types"; +import BunSQLite from "@moduletrace/bun-sqlite"; +import type { APIResponseObject } from "@moduletrace/bunext/types"; + +type Params = { + client_id: number; + user_id?: number | ""; + rules?: BUN_SQLITE_WGUI_CLIENT_RULES[]; +}; + +export default async function syncClientRules({ + client_id, + user_id, + rules, +}: Params): Promise { + const existing_res = await BunSQLite.select< + BUN_SQLITE_WGUI_CLIENT_RULES, + TableType + >({ + table: "client_rules", + query: { + query: { + client_id: { + value: String(client_id), + }, + }, + }, + }); + + if (!existing_res.success) { + return { + success: false, + msg: existing_res.msg || `Could not load client rules`, + }; + } + + const incoming: BUN_SQLITE_WGUI_CLIENT_RULES[] = []; + + for (let i = 0; i < (rules || []).length; i++) { + const sanitized = sanitizeClientRule({ + rule: rules?.[i], + client_id, + user_id, + }); + + if (!sanitized) { + continue; + } + + const expanded = expandClientRuleDestinations({ rule: sanitized }); + + for (let e = 0; e < expanded.length; e++) { + const rule = expanded[e]; + + if (!rule) { + continue; + } + + const valid = validateClientRule({ rule }); + + if (!valid.success) { + return { + success: false, + msg: valid.msg, + }; + } + + incoming.push(rule); + } + } + + const incoming_ids = new Set( + incoming + .map((rule) => rule.id) + .filter((id): id is number => Boolean(id) && typeof id == "number"), + ); + + const existing = existing_res.payload || []; + + for (let i = 0; i < existing.length; i++) { + const rule = existing[i]; + + if (!rule?.id || incoming_ids.has(rule.id)) { + continue; + } + + const del_res = await BunSQLite.delete< + BUN_SQLITE_WGUI_CLIENT_RULES, + TableType + >({ + table: "client_rules", + targetId: rule.id, + }); + + if (!del_res.success) { + return { + success: false, + msg: del_res.msg || `Could not delete client rule`, + }; + } + } + + const to_insert = incoming.filter((rule) => !rule.id); + const to_update = incoming.filter( + (rule) => rule.id && typeof rule.id == "number", + ); + + for (let i = 0; i < to_update.length; i++) { + const rule = to_update[i]; + + if (!rule?.id) { + continue; + } + + const update_res = await BunSQLite.update< + BUN_SQLITE_WGUI_CLIENT_RULES, + TableType + >({ + table: "client_rules", + data: rule, + targetId: rule.id, + }); + + if (!update_res.success) { + return { + success: false, + msg: update_res.msg || `Could not update client rule`, + }; + } + } + + if (to_insert[0]) { + const insert_res = await BunSQLite.insert< + BUN_SQLITE_WGUI_CLIENT_RULES, + TableType + >({ + table: "client_rules", + data: to_insert, + }); + + if (!insert_res.success) { + return { + success: false, + msg: insert_res.msg || `Could not insert client rules`, + }; + } + } + + return { + success: true, + }; +} diff --git a/src/pages/api/admin/crud/(tables)/clients/del.ts b/src/pages/api/admin/crud/(tables)/clients/del.ts index 0a32166..7b981d4 100644 --- a/src/pages/api/admin/crud/(tables)/clients/del.ts +++ b/src/pages/api/admin/crud/(tables)/clients/del.ts @@ -10,6 +10,7 @@ import setupWireguardHost from "@/src/functions/backend/setup/setup-wireguard-ho import type { BUN_SQLITE_WGUI_ALL_TYPEDEFS, BUN_SQLITE_WGUI_CLIENTS, + BUN_SQLITE_WGUI_CLIENT_RULES, BUN_SQLITE_WGUI_HOSTS, } from "@/db/types/db"; import BunSQLite from "@moduletrace/bun-sqlite"; @@ -76,6 +77,17 @@ export default async function ( affected_host_ids.add(host_id); + await BunSQLite.delete({ + table: "client_rules", + query: { + query: { + client_id: { + value: String(client.id), + }, + }, + }, + }); + await BunSQLite.delete({ table: "clients", targetId: client.id, diff --git a/src/pages/api/admin/crud/(tables)/clients/post.ts b/src/pages/api/admin/crud/(tables)/clients/post.ts index 2962c9c..f0a5e8d 100644 --- a/src/pages/api/admin/crud/(tables)/clients/post.ts +++ b/src/pages/api/admin/crud/(tables)/clients/post.ts @@ -3,7 +3,6 @@ import checkUserAccess from "@/src/functions/backend/auth/check-user-access"; import type { AdminCrudAPIParams } from "@/src/types"; import BunSQLite from "@moduletrace/bun-sqlite"; import type { APIResponseObject } from "@moduletrace/bunext/types"; -import runClientSetup from "./(functions)/run-client-setup"; import _ from "lodash"; export default async function ( @@ -33,20 +32,5 @@ export default async function ( update_on_duplicate: body?.update_on_duplicate, }); - if (POST.success) { - const client_setup_res = await runClientSetup({ - client_id: POST.postInsertReturn?.insertId, - user, - }); - - if (!client_setup_res.success) { - POST.debug = { - ...(POST.debug || {}), - client_setup: client_setup_res, - }; - POST.error = client_setup_res.msg || client_setup_res.error; - } - } - return POST; -} \ No newline at end of file +} diff --git a/src/pages/api/admin/crud/(tables)/clients/put.ts b/src/pages/api/admin/crud/(tables)/clients/put.ts index e5d85c6..13a4633 100644 --- a/src/pages/api/admin/crud/(tables)/clients/put.ts +++ b/src/pages/api/admin/crud/(tables)/clients/put.ts @@ -37,16 +37,16 @@ export default async function ( query: final_sql_query, }); - if (PUT.success) { - const client_setup_res = await runClientSetup({ - client_id: targetId, - user, - }); + // if (PUT.success) { + // const client_setup_res = await runClientSetup({ + // client_id: targetId, + // user, + // }); - if (!client_setup_res.success) { - return client_setup_res; - } - } + // if (!client_setup_res.success) { + // return client_setup_res; + // } + // } return PUT; } diff --git a/src/pages/api/admin/crud/[[...paths]].ts b/src/pages/api/admin/crud/[[...paths]].ts index 28f223b..fbc07c7 100644 --- a/src/pages/api/admin/crud/[[...paths]].ts +++ b/src/pages/api/admin/crud/[[...paths]].ts @@ -14,6 +14,7 @@ import type { AdminCrudAPIParams, ApiReqParams, TableType } from "@/src/types"; import userAuth from "@/src/functions/backend/auth/user-auth"; import checkUserAccess from "@/src/functions/backend/auth/check-user-access"; import clients from "./(tables)/clients"; +import clientRules from "./(tables)/client_rules"; export const handler: BunextAPIRouteHandler = async ( params, @@ -61,6 +62,8 @@ export const handler: BunextAPIRouteHandler = async ( return await media(crud_params); case "clients": return await clients(crud_params); + case "client_rules": + return await clientRules(crud_params); default: break; diff --git a/src/pages/api/admin/run-client-setup.ts b/src/pages/api/admin/run-client-setup.ts new file mode 100644 index 0000000..f93fa97 --- /dev/null +++ b/src/pages/api/admin/run-client-setup.ts @@ -0,0 +1,67 @@ +import checkUserAccess from "@/src/functions/backend/auth/check-user-access"; +import userAuth from "@/src/functions/backend/auth/user-auth"; +import type { ApiReqParams } from "@/src/types"; +import type { + APIResponseObject, + BunextAPIRouteHandler, +} from "@moduletrace/bunext/types"; +import _ from "lodash"; +import runClientSetup from "./crud/(tables)/clients/(functions)/run-client-setup"; + +export const handler: BunextAPIRouteHandler = async ( + params, +) => { + const req = params.req; + const body = params.body as ApiReqParams; + + if (req.method !== "POST") { + return { + success: false, + }; + } + + const { user, user_types } = await userAuth({ req }); + + if (!user?.logged_in_status) { + return { + success: false, + msg: `Unauthorized`, + logoutUser: true, + }; + } + + try { + const is_super_admin = checkUserAccess({ user_types }); + + if (!is_super_admin.success) { + return { + success: false, + msg: `Unauthorized`, + }; + } + + const client_id = body.client_id ? Number(body.client_id) : undefined; + + if (!client_id) { + throw new Error(`No Client ID found!`); + } + + const client_setup_res = await runClientSetup({ + client_id, + user, + }); + + if (!client_setup_res.success) { + return client_setup_res; + } + + return { + success: true, + }; + } catch (error: any) { + return { + success: false, + msg: error.message, + }; + } +}; diff --git a/src/styles/main.css b/src/styles/main.css index 2fe4b7c..6969aa7 100755 --- a/src/styles/main.css +++ b/src/styles/main.css @@ -104,10 +104,6 @@ h4 { @apply font-semibold; } -.twui-row { - @apply gap-4; -} - a { @apply outline-none border-none text-primary; } @@ -201,3 +197,8 @@ header nav a:hover > * { #admin-main section { @apply gap-10; } + +p code, +span code { + @apply p-1 mx-1 bg-slate-100; +} diff --git a/src/types/index.ts b/src/types/index.ts index ee103ae..7f0c484 100755 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -1,6 +1,7 @@ import type { BUN_SQLITE_WGUI_ALL_TYPEDEFS, BUN_SQLITE_WGUI_CLIENTS, + BUN_SQLITE_WGUI_CLIENT_RULES, BUN_SQLITE_WGUI_HOSTS, BUN_SQLITE_WGUI_MEDIA, BUN_SQLITE_WGUI_USER_TYPES, @@ -10,6 +11,10 @@ import type { import type { SBFSusidiaries } from "../dict/subsidiaries-dict"; import type { UserTypes } from "../dict/user-types-dict"; import type { MediaParadigms, MediaTypes } from "../dict/media-dict"; +import type { + ClientRuleProtocols, + ClientRuleTypes, +} from "../dict/client-rules-dict"; import type { BunextPageModuleServerReturnURLObject } from "@moduletrace/bunext/types"; import type { BUN_SQLITE_WGUI_USERS_JOIN } from "./sql-joins"; import type { ImageInputToBase64FunctionReturn } from "../components/twui/utils/form/imageInputToBase64"; @@ -40,6 +45,7 @@ export type PagePropsType = { hosts?: BUN_SQLITE_WGUI_HOSTS[] | null; client?: BUN_SQLITE_WGUI_CLIENTS | null; clients?: BUN_SQLITE_WGUI_CLIENTS[] | null; + client_rules?: BUN_SQLITE_WGUI_CLIENT_RULES[] | null; client_config?: string | null; client_config_path?: string | null; client_qr_data_uri?: string | null; @@ -202,7 +208,9 @@ export type ApiReqParams< user_id?: string | number | null; dependent_id?: string | number | null; host_id?: string | number | null; + client_id?: string | number | null; public_ip_address?: string | null; + // client_rules?: BUN_SQLITE_WGUI_CLIENT_RULES[]; media_base_64?: string; media_base_64_data_url?: string; @@ -253,6 +261,8 @@ export type SQLInsertGenValueType = export type MediaType = (typeof MediaTypes)[number]["value"]; export type MediaParadigm = (typeof MediaParadigms)[number]["value"]; +export type ClientRuleType = (typeof ClientRuleTypes)[number]["value"]; +export type ClientRuleProtocol = (typeof ClientRuleProtocols)[number]["value"]; export type TableType = (typeof BunSQLiteTables)[number]; diff --git a/src/utils/cookies.ts b/src/utils/cookies.ts index e056eb7..a58a37a 100644 --- a/src/utils/cookies.ts +++ b/src/utils/cookies.ts @@ -62,6 +62,8 @@ export function setCookies(response: Response, cookies: CookieOptions[]): void { if (maxAge !== undefined) header += `; Max-Age=${maxAge}`; if (domain) header += `; Domain=${domain}`; + console.log("header", header); + response.headers.append("Set-Cookie", header); } } diff --git a/src/utils/parse-port-list.test.ts b/src/utils/parse-port-list.test.ts new file mode 100644 index 0000000..f267c32 --- /dev/null +++ b/src/utils/parse-port-list.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, test } from "bun:test"; +import parsePortList from "./parse-port-list"; + +describe("parsePortList", () => { + test("treats empty as any port", () => { + expect(parsePortList({ ports: "" })).toEqual({ + success: true, + ports: [], + }); + expect(parsePortList({})).toEqual({ + success: true, + ports: [], + }); + }); + + test("parses singles, lists, and ranges", () => { + expect(parsePortList({ ports: "443" }).ports).toEqual(["443"]); + expect(parsePortList({ ports: "80, 443" }).ports).toEqual([ + "80", + "443", + ]); + expect(parsePortList({ ports: "8000:8080" }).ports).toEqual([ + "8000:8080", + ]); + }); + + test("rejects invalid ports", () => { + expect(parsePortList({ ports: "0" }).success).toBe(false); + expect(parsePortList({ ports: "65536" }).success).toBe(false); + expect(parsePortList({ ports: "8080:8000" }).success).toBe(false); + expect(parsePortList({ ports: "80-443" }).success).toBe(false); + }); +}); diff --git a/src/utils/parse-port-list.ts b/src/utils/parse-port-list.ts new file mode 100644 index 0000000..4f223ba --- /dev/null +++ b/src/utils/parse-port-list.ts @@ -0,0 +1,96 @@ +type Params = { + ports?: string; +}; + +export type ParsePortListResult = { + success: boolean; + msg?: string; + ports: string[]; +}; + +function isValidPort({ value }: { value: string }) { + if (!/^\d{1,5}$/.test(value)) { + return false; + } + + const port = Number(value); + + return port >= 1 && port <= 65535 && String(port) == value; +} + +export default function parsePortList({ + ports, +}: Params): ParsePortListResult { + if (!ports || !ports.trim()) { + return { + success: true, + ports: [], + }; + } + + const tokens = ports + .split(",") + .map((token) => token.trim()) + .filter(Boolean); + + if (!tokens[0]) { + return { + success: true, + ports: [], + }; + } + + const parsed: string[] = []; + + for (let i = 0; i < tokens.length; i++) { + const token = tokens[i]; + + if (!token) { + continue; + } + + if (token.includes(":")) { + const [start, end, extra] = token.split(":"); + + if ( + extra || + !start || + !end || + !isValidPort({ value: start }) || + !isValidPort({ value: end }) + ) { + return { + success: false, + msg: `Invalid port range "${token}"`, + ports: [], + }; + } + + if (Number(start) > Number(end)) { + return { + success: false, + msg: `Invalid port range "${token}"`, + ports: [], + }; + } + + parsed.push(`${start}:${end}`); + continue; + } + + if (!isValidPort({ value: token })) { + return { + success: false, + msg: `Invalid port "${token}"`, + ports: [], + }; + } + + parsed.push(token); + } + + return { + success: true, + ports: parsed, + }; +} diff --git a/src/utils/validate-ipv4-cidr.test.ts b/src/utils/validate-ipv4-cidr.test.ts new file mode 100644 index 0000000..cd2a17d --- /dev/null +++ b/src/utils/validate-ipv4-cidr.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, test } from "bun:test"; +import validateIpv4Cidr from "./validate-ipv4-cidr"; + +describe("validateIpv4Cidr", () => { + test("accepts a bare IPv4 address", () => { + expect(validateIpv4Cidr({ value: "10.0.0.2" })).toBe(true); + }); + + test("accepts CIDR notation", () => { + expect(validateIpv4Cidr({ value: "10.0.0.0/24" })).toBe(true); + expect(validateIpv4Cidr({ value: "0.0.0.0/0" })).toBe(true); + expect(validateIpv4Cidr({ value: "192.168.1.1/32" })).toBe(true); + }); + + test("rejects invalid values", () => { + expect(validateIpv4Cidr({ value: "10.0.0.0/33" })).toBe(false); + expect(validateIpv4Cidr({ value: "10.0.0.0/024" })).toBe(false); + expect(validateIpv4Cidr({ value: "10.0.0.0/24/24" })).toBe(false); + expect(validateIpv4Cidr({ value: "example.com" })).toBe(false); + }); +}); diff --git a/src/utils/validate-ipv4-cidr.ts b/src/utils/validate-ipv4-cidr.ts new file mode 100644 index 0000000..9219489 --- /dev/null +++ b/src/utils/validate-ipv4-cidr.ts @@ -0,0 +1,33 @@ +import validateIpv4 from "./validate-ipv4"; + +type Params = { + value?: string; +}; + +export default function validateIpv4Cidr({ value }: Params) { + if (!value) { + return false; + } + + const [ip, prefix, extra] = value.split("/"); + + if (extra) { + return false; + } + + if (!validateIpv4({ ip })) { + return false; + } + + if (prefix == undefined) { + return true; + } + + if (!/^\d{1,2}$/.test(prefix)) { + return false; + } + + const prefix_value = Number(prefix); + + return prefix_value >= 0 && prefix_value <= 32 && String(prefix_value) == prefix; +} diff --git a/src/utils/validate-ipv4.test.ts b/src/utils/validate-ipv4.test.ts new file mode 100644 index 0000000..ebbf2c3 --- /dev/null +++ b/src/utils/validate-ipv4.test.ts @@ -0,0 +1,19 @@ +import { describe, expect, test } from "bun:test"; +import validateIpv4 from "./validate-ipv4"; + +describe("validateIpv4", () => { + test("accepts valid addresses", () => { + expect(validateIpv4({ ip: "10.0.0.2" })).toBe(true); + expect(validateIpv4({ ip: "0.0.0.0" })).toBe(true); + expect(validateIpv4({ ip: "255.255.255.255" })).toBe(true); + }); + + test("rejects invalid addresses", () => { + expect(validateIpv4({ ip: "" })).toBe(false); + expect(validateIpv4({})).toBe(false); + expect(validateIpv4({ ip: "10.0.0" })).toBe(false); + expect(validateIpv4({ ip: "10.0.0.256" })).toBe(false); + expect(validateIpv4({ ip: "10.0.0.01" })).toBe(false); + expect(validateIpv4({ ip: "10.0.0.2/32" })).toBe(false); + }); +}); diff --git a/src/utils/validate-ipv4.ts b/src/utils/validate-ipv4.ts new file mode 100644 index 0000000..335dc7a --- /dev/null +++ b/src/utils/validate-ipv4.ts @@ -0,0 +1,35 @@ +type Params = { + ip?: string; +}; + +export default function validateIpv4({ ip }: Params) { + if (!ip) { + return false; + } + + const parts = ip.split("."); + + if (parts.length != 4) { + return false; + } + + for (let i = 0; i < parts.length; i++) { + const part = parts[i]; + + if (!part || !/^\d{1,3}$/.test(part)) { + return false; + } + + const value = Number(part); + + if (value < 0 || value > 255) { + return false; + } + + if (String(value) != part) { + return false; + } + } + + return true; +}