This commit is contained in:
2026-09-20 07:27:29 +01:00
parent dc2cd66b25
commit 8ec44ee0a7
70 changed files with 2657 additions and 128 deletions
+2 -2
View File
@@ -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/[email protected]", "", {}, "sha512-Y28PR25bHXUg88kCV7nivXrP2Nj2RueZ3/l/jdx6J9f8J4nsEGcgX0Qe6lt7Pa+J79+kPiJU3LguR6O/6zrLOw=="],
"@moduletrace/bun-sqlite": ["@moduletrace/[email protected]2", "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/[email protected]3", "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/[email protected]", "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=="],
+32 -2
View File
@@ -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),
},
],
},
+5 -1
View File
@@ -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 = {
+1 -1
View File
@@ -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",
+19 -2
View File
@@ -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) {
<td className="px-4 py-[9px] tabular text-[13px] text-foreground-light/45 dark:text-foreground-dark/45 whitespace-nowrap">
{client.allowed_ips || "—"}
</td>
<td className="px-4 py-[9px] whitespace-nowrap">
<Span
className={
access.kind == "none"
? "text-[12.5px] text-error/80"
: "text-[12.5px] text-foreground-light/60 dark:text-foreground-dark/60"
}
>
{access.text}
</Span>
</td>
<td className="px-4 py-[9px]">
<Span className="font-mono text-[12px] text-foreground-light/45 dark:text-foreground-dark/45 whitespace-nowrap">
{client.public_key
+25
View File
@@ -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;
+2
View File
@@ -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.`,
);
@@ -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<BUN_SQLITE_WGUI_CLIENT_RULES, TableType>({
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)),
);
}
@@ -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",
);
});
});
@@ -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,
};
}
@@ -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`,
};
}
@@ -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,
}));
}
@@ -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,
};
}
@@ -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,
@@ -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;
}
@@ -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",
};
}
@@ -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`;
@@ -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`;
}
}
@@ -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}`);
@@ -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 {
@@ -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,
};
}
@@ -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`,
};
}
@@ -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");
});
});
@@ -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}`;
}
@@ -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<BUN_SQLITE_WGUI_CLIENT_RULES>({
table: "client_rules",
sql_query: {
limit: 500,
},
});
const rules_by_client_id = useMemo(() => {
const map = new Map<number, BUN_SQLITE_WGUI_CLIENT_RULES[]>();
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) {
<th className={thClass}>Client</th>
<th className={thClass}>Tunnel IP</th>
<th className={thClass}>Allowed IPs</th>
<th className={thClass}>Access</th>
<th className={thClass}>Public key</th>
<th className={thRightClass}>Created</th>
<th className={thRightClass}>Actions</th>
</tr>
</thead>
<tbody>
{filtered.map((client) => (
<ClientRow key={client.id} client={client} />
))}
{filtered.map((client) => (
<ClientRow
key={client.id}
client={client}
rules={
client.id
? rules_by_client_id.get(
Number(client.id),
)
: undefined
}
/>
))}
</tbody>
</table>
</div>
@@ -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<SetStateAction<string | undefined>>;
};
export default function AddClientRuleModal({
rules,
setRules,
setError,
}: Props) {
const [draft, setDraft] = useState<ClientRuleDraft>(
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 (
<Modal
open={open}
setOpen={setOpen}
target={
<Button
title="Add Client Rule"
size="smaller"
variant="outlined"
>
Add Client Rule
</Button>
}
>
<Stack className="w-full items-stretch gap-8">
<Stack className="gap-1!">
<H4>Add Rule</H4>
<Span>Add a new rule for this client</Span>{" "}
</Stack>
<Divider />
<ClientRuleFields draft={draft} setDraft={setDraft} />
<Button
title="Add access rule"
type="button"
variant="outlined"
size="small"
beforeIcon={<Plus size={14} />}
onClick={(e) => {
e.preventDefault();
handleAdd();
setOpen(false);
}}
>
Add rule
</Button>
</Stack>
</Modal>
);
}
@@ -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 (
<Input
title="Destination IP"
placeholder="10.0.0.5 or 192.168.1.0/24"
showLabel
defaultValue={draft.destination}
changeHandler={(value) => {
setDraft({
...draft,
destination: value,
});
}}
info="One IPv4 or CIDR. Comma-separate to add several."
/>
);
}
@@ -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 (
<Row className="w-full justify-between gap-3 py-2.5 border-t border-slate-200/60 dark:border-white/5">
<Row className="items-center gap-2!">
<ShieldCheck size={15} className="-mt-[4px] text-success" />
<Span className="font-mono text-[12.5px] text-foreground-light/70 dark:text-foreground-dark/70 break-all">
{formatClientRuleLabel({
rule: clientRuleDraftToRecord({ draft }),
})}
</Span>
</Row>
<Button
title="Remove rule"
variant="ghost"
color="error"
size="smaller"
beforeIcon={<Trash2 size={14} />}
type="button"
onClick={() => {
onRemove({ local_id: draft.local_id });
}}
>
Remove
</Button>
</Row>
);
}
@@ -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<ClientRuleDraft>;
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,
};
}
@@ -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 (
<Form>
<Stack className="w-full items-stretch gap-4">
<ClientRuleTypeField draft={draft} setDraft={setDraft} />
<ClientRuleDestinationField draft={draft} setDraft={setDraft} />
<ClientRulePortsField draft={draft} setDraft={setDraft} />
<ClientRuleProtocolField draft={draft} setDraft={setDraft} />
</Stack>
</Form>
);
}
@@ -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 (
<Input
title="Ports"
placeholder="80,443 or 8000:8080"
showLabel
value={draft.ports}
onChange={(e) => {
setDraft({
...draft,
ports: e.target.value,
});
}}
info="Leave empty for any port."
/>
);
}
@@ -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 (
<Select<ClientRuleProtocol>
title="Protocol"
showLabel
options={ClientRuleProtocols.map((protocol) => ({
title: protocol.title,
value: protocol.value,
default: protocol.value == draft.protocol,
}))}
changeHandler={(value) => {
setDraft({
...draft,
protocol: value,
});
}}
/>
);
}
@@ -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 (
<Checkbox
defaultChecked={draft.rule_type == "all"}
label={`Allow all access?`}
info={`This allows unlimited access to all clients in this network`}
changeHandler={(checked) => {
setDraft({
...draft,
rule_type: checked ? "all" : "destination",
});
}}
wrapperWrapperProps={{ className: `mb-4` }}
/>
);
// return (
// <Select<ClientRuleType>
// 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,
// });
// }}
// />
// );
}
@@ -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<string>();
return (
<Paper className="w-full xl:p-6">
<Stack className="w-full items-stretch gap-4">
<Row className="w-full justify-between">
<Stack className="gap-1">
<H4 className="mb-0!">Access rules</H4>
<P
noMargin
className="text-[12.5px] text-foreground-light/45 dark:text-foreground-dark/45"
>
Zero trust: nothing is allowed until you add a rule.
</P>
</Stack>
<AddClientRuleModal
rules={rules}
setRules={setRules}
setError={setError}
/>
</Row>
{error ? <Tag color="error">{error}</Tag> : null}
{rules[0] ? (
<Stack className="w-full items-stretch">
{rules.map((rule) => (
<ClientRuleDraftItem
key={rule.local_id}
draft={rule}
onRemove={({ local_id }) => {
setRules(
rules.filter(
(item) => item.local_id != local_id,
),
);
}}
/>
))}
</Stack>
) : (
<ClientRulesEmpty />
)}
</Stack>
</Paper>
);
}
@@ -0,0 +1,9 @@
import EmptyContent from "@/src/components/twui/elements/EmptyContent";
export default function ClientRulesEmpty() {
return (
<EmptyContent
title={`No rules. This client cannot reach the host, LAN, internet, or other clients.`}
/>
);
}
@@ -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<BUN_SQLITE_WGUI_CLIENT_RULES>({
table: "client_rules",
sql_query: {
limit: 500,
},
initial_res: pageProps?.client_rules || undefined,
});
const rules_by_client_id = useMemo(() => {
const map = new Map<number, BUN_SQLITE_WGUI_CLIENT_RULES[]>();
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 (
<Paper>
@@ -60,6 +88,7 @@ export default function ClientsList({ host_id }: Props) {
<th className={thClass}>Client</th>
<th className={thClass}>Tunnel IP</th>
<th className={thClass}>Allowed IPs</th>
<th className={thClass}>Access</th>
<th className={thClass}>Public key</th>
<th className={thRightClass}>Created</th>
<th className={thRightClass}>Actions</th>
@@ -67,7 +96,15 @@ export default function ClientsList({ host_id }: Props) {
</thead>
<tbody>
{clients.map((client) => (
<ClientRow key={client.id} client={client} />
<ClientRow
key={client.id}
client={client}
rules={
client.id
? rules_by_client_id.get(Number(client.id))
: undefined
}
/>
))}
</tbody>
</table>
@@ -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<ClientRuleDraft>(
createClientRuleDraft(),
);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string>();
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 (
<Stack className="w-full items-stretch gap-4">
{error ? <Tag color="error">{error}</Tag> : null}
<ClientRuleFields draft={draft} setDraft={setDraft} />
<Button
title="Add access rule"
type="button"
variant="outlined"
size="small"
beforeIcon={<Plus size={14} />}
loading={busy}
onClick={handleAdd}
>
Add rule
</Button>
</Stack>
);
}
@@ -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 (
<Row className="justify-between gap-3 px-5 py-2.5 border-t border-slate-200/60 dark:border-white/5">
<Span className="font-mono text-[12.5px] text-foreground-light/70 dark:text-foreground-dark/70 break-all">
{formatClientRuleLabel({ rule })}
</Span>
<Button
title="Delete rule"
variant="ghost"
color="error"
size="smaller"
beforeIcon={<Trash2 size={14} />}
disabled={busy}
onClick={handleDelete}
>
{busy ? "Deleting…" : "Delete"}
</Button>
</Row>
);
}
@@ -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 || "—",
@@ -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 (
<AdminCard className="w-full overflow-hidden">
<Stack className="w-full items-stretch py-4 gap-4">
<Row className="w-full justify-between px-5">
<Stack className="gap-0.5">
<Row className="justify-between gap-3 flex-wrap">
<H3 className="mb-0!">Access rules</H3>
</Row>
<P
noMargin
className="text-[12.5px] text-foreground-light/45 dark:text-foreground-dark/45"
>
Zero trust: this client can only reach what you list
here.
</P>
</Stack>
<Modal
open={open}
setOpen={setOpen}
target={
<Button
title="Add Rule"
size="smaller"
variant="outlined"
>
Add Rule
</Button>
}
>
<Stack className="gap-10 w-full items-stretch">
<Stack className="gap-0.5">
<H4>Add Rule</H4>
<Span>
Add a new access rule to this client
</Span>
</Stack>
<AddClientRuleForm client={client} />
</Stack>
</Modal>
</Row>
{rules[0] ? (
<Stack className="w-full items-stretch">
{rules.map((rule) => (
<ClientRuleRow key={rule.id} rule={rule} />
))}
</Stack>
) : (
<EmptyContent
title="This client cannot reach anything until you add a rule"
icon={<ShieldOff size={16} />}
/>
)}
</Stack>
</AdminCard>
);
}
@@ -13,6 +13,7 @@ export default function EditClientFormSection() {
<AddClientForm
existing_client={pageProps?.client || undefined}
existing_client_full={pageProps?.client || undefined}
existing_client_rules={pageProps?.client_rules || undefined}
host_id={host_id}
/>
</Section>
@@ -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<PagePropsType> = 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,
},
};
};
@@ -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<PagePropsType> = 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<PagePropsType> = 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,
},
};
};
@@ -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 ? (
<>
<ClientInfoSection />
<ClientRulesSection />
<ClientQRSection />
<ClientConfigSection />
</>
@@ -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<typeof useFormInit<BUN_SQLITE_WGUI_CLIENTS>>,
{ 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<BUN_SQLITE_WGUI_CLIENT_RULES>({
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<BUN_SQLITE_WGUI_CLIENT_RULES>({
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<ApiReqParams>(`/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}`;
@@ -10,7 +10,7 @@ export default function AddClientFormAllowedIps({
}: ReturnType<typeof useFormInit<BUN_SQLITE_WGUI_CLIENTS>>) {
return (
<Stack className="gap-2 my-4 w-full">
{form.allow_all_ips ? null : (
{form.allow_all_ips == 1 ? null : (
<Input
title="Allowed IPs"
placeholder="Eg. 10.0.0.2/24"
@@ -24,8 +24,9 @@ export default function AddClientFormAllowedIps({
showLabel
/>
)}
<Checkbox
label={`Allow all IPs? That is 0.0.0.0/0, ::/0`}
label={`Allow all IPs?`}
defaultChecked={form.allow_all_ips == 1}
changeHandler={(value) => {
setForm((prev) => ({
@@ -33,6 +34,12 @@ export default function AddClientFormAllowedIps({
allow_all_ips: value ? 1 : 0,
}));
}}
info={
<span>
Sets the wireguard <code>AllowedIPs</code> parameter to{" "}
<code>0.0.0.0/0, ::/0</code>
</span>
}
/>
</Stack>
);
@@ -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<BUN_SQLITE_WGUI_CLIENTS>({
@@ -28,25 +40,35 @@ export default function AddClientForm({
title: "Add Client",
});
const [rules, setRules] = useState<ClientRuleDraft[]>(
(existing_client_rules || []).map((rule) =>
clientRuleToDraft({ rule }),
),
);
const { loading, status } = init;
return (
<Form
className="w-full gap-6"
onSubmit={() => {
submitAddClientForm(init, { host_id });
submitAddClientForm(init, { host_id, rules });
}}
>
{status?.error && <Tag color="error">{status.msg}</Tag>}
{loading && <LoadingOverlay />}
<Stack>
<AddClientFormTitle {...init} />
<AddClientFormWgIpAddress {...init} host_id={host_id} />
<AddClientFormAllowedIps {...init} />
<AddClientFormNotes {...init} />
<AddClientFormAction {...init} />
</Stack>
{init.ready ? (
<Stack>
<AddClientFormTitle {...init} />
<AddClientFormWgIpAddress {...init} host_id={host_id} />
<AddClientFormAllowedIps {...init} />
<AddClientFormNotes {...init} />
<ClientRulesEditor rules={rules} setRules={setRules} />
<AddClientFormAction {...init} />
</Stack>
) : (
<LoadingRectangleBlock className="h-[400px]" />
)}
</Form>
);
}
}
@@ -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<PagePropsType> = 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,
},
};
};
@@ -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,
};
}
@@ -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<APIResponseObject> {
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<number>();
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`,
};
}
@@ -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<APIResponseObject> {
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<BUN_SQLITE_WGUI_CLIENT_RULES, TableType>({
table,
query: final_sql_query,
targetId: id,
});
return GET;
}
@@ -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<APIResponseObject> {
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,
};
}
}
@@ -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<APIResponseObject> {
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;
}
@@ -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<APIResponseObject> {
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;
}
@@ -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,
});
}
@@ -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<APIResponseObject> {
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,
};
}
@@ -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<BUN_SQLITE_WGUI_CLIENT_RULES, TableType>({
table: "client_rules",
query: {
query: {
client_id: {
value: String(client.id),
},
},
},
});
await BunSQLite.delete<BUN_SQLITE_WGUI_CLIENTS, TableType>({
table: "clients",
targetId: client.id,
@@ -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;
}
}
@@ -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;
}
+3
View File
@@ -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<APIResponseObject> = async (
params,
@@ -61,6 +62,8 @@ export const handler: BunextAPIRouteHandler<APIResponseObject> = async (
return await media(crud_params);
case "clients":
return await clients(crud_params);
case "client_rules":
return await clientRules(crud_params);
default:
break;
+67
View File
@@ -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<APIResponseObject> = 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,
};
}
};
+5 -4
View File
@@ -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;
}
+10
View File
@@ -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];
+2
View File
@@ -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);
}
}
+33
View File
@@ -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);
});
});
+96
View File
@@ -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,
};
}
+21
View File
@@ -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);
});
});
+33
View File
@@ -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;
}
+19
View File
@@ -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);
});
});
+35
View File
@@ -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;
}