Updates
This commit is contained in:
@@ -5,7 +5,7 @@
|
|||||||
"": {
|
"": {
|
||||||
"name": "horandez-and-detroit",
|
"name": "horandez-and-detroit",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@moduletrace/bun-sqlite": "^1.1.12",
|
"@moduletrace/bun-sqlite": "^1.1.13",
|
||||||
"@moduletrace/bunext": "^1.1.0",
|
"@moduletrace/bunext": "^1.1.0",
|
||||||
"gray-matter": "^4.0.3",
|
"gray-matter": "^4.0.3",
|
||||||
"html-to-react": "^1.7.0",
|
"html-to-react": "^1.7.0",
|
||||||
@@ -222,7 +222,7 @@
|
|||||||
|
|
||||||
"@mixmark-io/domino": ["@mixmark-io/[email protected]", "", {}, "sha512-Y28PR25bHXUg88kCV7nivXrP2Nj2RueZ3/l/jdx6J9f8J4nsEGcgX0Qe6lt7Pa+J79+kPiJU3LguR6O/6zrLOw=="],
|
"@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=="],
|
"@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
@@ -1,3 +1,7 @@
|
|||||||
|
import {
|
||||||
|
ClientRuleProtocols,
|
||||||
|
ClientRuleTypes,
|
||||||
|
} from "@/src/dict/client-rules-dict";
|
||||||
import { MediaParadigms, MediaTypes } from "@/src/dict/media-dict";
|
import { MediaParadigms, MediaTypes } from "@/src/dict/media-dict";
|
||||||
import { UserTypes } from "@/src/dict/user-types-dict";
|
import { UserTypes } from "@/src/dict/user-types-dict";
|
||||||
import { Variables } from "@/src/dict/variables-dict";
|
import { Variables } from "@/src/dict/variables-dict";
|
||||||
@@ -237,12 +241,38 @@ const schema: BUN_SQLITE_DatabaseSchemaType = {
|
|||||||
dataType: "INTEGER",
|
dataType: "INTEGER",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
fieldName: "client_id",
|
fieldName: "host_id",
|
||||||
dataType: "INTEGER",
|
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",
|
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
@@ -150,8 +150,12 @@ export type BUN_SQLITE_WGUI_CLIENT_RULES = {
|
|||||||
*/
|
*/
|
||||||
updated_at?: number | "";
|
updated_at?: number | "";
|
||||||
user_id?: number | "";
|
user_id?: number | "";
|
||||||
|
host_id?: number | "";
|
||||||
client_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 = {
|
export type BUN_SQLITE_WGUI_VARIABLES = {
|
||||||
|
|||||||
+1
-1
@@ -30,7 +30,7 @@
|
|||||||
"typescript": "^7.0.2"
|
"typescript": "^7.0.2"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@moduletrace/bun-sqlite": "^1.1.12",
|
"@moduletrace/bun-sqlite": "^1.1.13",
|
||||||
"@moduletrace/bunext": "^1.1.0",
|
"@moduletrace/bunext": "^1.1.0",
|
||||||
"gray-matter": "^4.0.3",
|
"gray-matter": "^4.0.3",
|
||||||
"html-to-react": "^1.7.0",
|
"html-to-react": "^1.7.0",
|
||||||
|
|||||||
@@ -5,16 +5,22 @@ import Row from "@/src/components/twui/layout/Row";
|
|||||||
import Span from "@/src/components/twui/layout/Span";
|
import Span from "@/src/components/twui/layout/Span";
|
||||||
import formatUnixTimestamp from "@/src/utils/format-unix-timestamp";
|
import formatUnixTimestamp from "@/src/utils/format-unix-timestamp";
|
||||||
import adminCrudHandler from "@/src/functions/frontend/admin-crud-handler";
|
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 = {
|
type Props = {
|
||||||
client: BUN_SQLITE_WGUI_CLIENTS;
|
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 [deleting, setDeleting] = useState(false);
|
||||||
|
|
||||||
const host_id = Number(client.host_id || 0);
|
const host_id = Number(client.host_id || 0);
|
||||||
|
const access = deriveClientAccessLabel({ rules });
|
||||||
|
|
||||||
async function handleDelete() {
|
async function handleDelete() {
|
||||||
if (
|
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">
|
<td className="px-4 py-[9px] tabular text-[13px] text-foreground-light/45 dark:text-foreground-dark/45 whitespace-nowrap">
|
||||||
{client.allowed_ips || "—"}
|
{client.allowed_ips || "—"}
|
||||||
</td>
|
</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]">
|
<td className="px-4 py-[9px]">
|
||||||
<Span className="font-mono text-[12px] text-foreground-light/45 dark:text-foreground-dark/45 whitespace-nowrap">
|
<Span className="font-mono text-[12px] text-foreground-light/45 dark:text-foreground-dark/45 whitespace-nowrap">
|
||||||
{client.public_key
|
{client.public_key
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -111,6 +111,8 @@ export default async function loginUser({
|
|||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
console.log("new_logged_in_user", new_logged_in_user);
|
||||||
|
|
||||||
console.log(
|
console.log(
|
||||||
`User #${new_logged_in_user.id} [${new_logged_in_user.first_name} ${new_logged_in_user.last_name}] logged in successfully.`,
|
`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,
|
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 {
|
return {
|
||||||
CLIENT_WG_IP,
|
CLIENT_WG_IP,
|
||||||
HOST_CLIENTS_DIR,
|
HOST_CLIENTS_DIR,
|
||||||
@@ -70,8 +57,5 @@ export default function grabClientDirnames({
|
|||||||
CLIENT_PRIVATE_KEY_FILE,
|
CLIENT_PRIVATE_KEY_FILE,
|
||||||
CLIENT_PUBLIC_KEY_FILE,
|
CLIENT_PUBLIC_KEY_FILE,
|
||||||
CLIENT_CONFIG_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}`;
|
const MANAGE_COMMAND = `${WGUI_WG_QUICK_MANAGE_SCRIPT} ${action} ${INTERFACE_NAME} ${HOST_CONFIG_FILE}`;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const output = execSync(MANAGE_COMMAND, { encoding: "utf-8" }).trim();
|
const output = execSync(MANAGE_COMMAND, {
|
||||||
|
encoding: "utf-8",
|
||||||
|
}).trim();
|
||||||
|
|
||||||
return {
|
return {
|
||||||
success: true,
|
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_DIR,
|
||||||
CLIENT_PRIVATE_KEY_FILE,
|
CLIENT_PRIVATE_KEY_FILE,
|
||||||
CLIENT_PUBLIC_KEY_FILE,
|
CLIENT_PUBLIC_KEY_FILE,
|
||||||
HOST_CLIENTS_DIR,
|
|
||||||
HOST_CONFIG_DIR,
|
HOST_CONFIG_DIR,
|
||||||
CLIENT_PRIVATE_KEY,
|
|
||||||
CLIENT_PUBLIC_KEY,
|
|
||||||
HOST_PUBLIC_KEY,
|
|
||||||
} = grabClientDirnames({ client, host_id });
|
} = grabClientDirnames({ client, host_id });
|
||||||
|
|
||||||
if (!CLIENT_WG_IP) {
|
if (!CLIENT_WG_IP) {
|
||||||
@@ -114,6 +110,19 @@ export default async function setupWireguardClient({
|
|||||||
client?.public_ip_address ||
|
client?.public_ip_address ||
|
||||||
HOST_WG_IP;
|
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 = ``;
|
let sh = ``;
|
||||||
|
|
||||||
sh += `cd ${CLIENT_DIR}\n`;
|
sh += `cd ${CLIENT_DIR}\n`;
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import type {
|
import type {
|
||||||
BUN_SQLITE_WGUI_CLIENTS,
|
BUN_SQLITE_WGUI_CLIENTS,
|
||||||
|
BUN_SQLITE_WGUI_CLIENT_RULES,
|
||||||
BUN_SQLITE_WGUI_HOSTS,
|
BUN_SQLITE_WGUI_HOSTS,
|
||||||
BUN_SQLITE_WGUI_VARIABLES,
|
BUN_SQLITE_WGUI_VARIABLES,
|
||||||
} from "@/db/types/db";
|
} from "@/db/types/db";
|
||||||
@@ -12,8 +13,8 @@ import type { TableType, User } from "@/src/types";
|
|||||||
import checkPrivateIPAvailability from "./check-private-ip-availability";
|
import checkPrivateIPAvailability from "./check-private-ip-availability";
|
||||||
import manageWireguardHost from "./manage-wireguard-host";
|
import manageWireguardHost from "./manage-wireguard-host";
|
||||||
import grabHostDirnames from "./grab-host-dir-names";
|
import grabHostDirnames from "./grab-host-dir-names";
|
||||||
import path from "node:path";
|
|
||||||
import grabClientDirnames from "./grab-client-dir-names";
|
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 } =
|
const { WIREGUARD_PRIVATE_KEY_FILE_NAME, WIREGUARD_PUBLIC_KEY_FILE_NAME } =
|
||||||
grabDirNames();
|
grabDirNames();
|
||||||
@@ -69,6 +70,36 @@ export default async function setupWireguardHost({
|
|||||||
|
|
||||||
const clients = host_clients_res.payload || [];
|
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 TARGET_INTERFACE = await grabHostNetworkInterface();
|
||||||
const HOST_WG_IP =
|
const HOST_WG_IP =
|
||||||
host?.wg_ip_address ||
|
host?.wg_ip_address ||
|
||||||
@@ -172,37 +203,37 @@ export default async function setupWireguardHost({
|
|||||||
|
|
||||||
sh += `cd ${HOST_CONFIG_DIR}\n`;
|
sh += `cd ${HOST_CONFIG_DIR}\n`;
|
||||||
|
|
||||||
sh += `cat > ${POST_UP_PATH} << EOF\n`;
|
const iptables_scripts = buildHostIptablesScripts({
|
||||||
sh += `#!/bin/bash\n\n`;
|
host_id: Number(HOST_ID),
|
||||||
sh += `# Allow WireGuard traffic to/from the server itself\n`;
|
interface_name: INTERFACE_NAME,
|
||||||
sh += `iptables -I INPUT 1 -i ${INTERFACE_NAME} -j ACCEPT\n`;
|
target_interface: TARGET_INTERFACE || "eth0",
|
||||||
sh += `iptables -I OUTPUT 1 -o ${INTERFACE_NAME} -j ACCEPT\n`;
|
clients: clients.map((client) => ({
|
||||||
sh += `\n`;
|
id: client.id,
|
||||||
sh += `# Allow WireGuard traffic to be forwarded (insert above Docker rules)\n`;
|
wg_ip_address: client.wg_ip_address,
|
||||||
sh += `iptables -I FORWARD 1 -i ${INTERFACE_NAME} -j ACCEPT\n`;
|
rules:
|
||||||
sh += `iptables -I FORWARD 1 -o ${INTERFACE_NAME} -j ACCEPT\n`;
|
client.id && typeof client.id == "number"
|
||||||
sh += `\n`;
|
? rules_by_client_id.get(client.id) || []
|
||||||
sh += `iptables -t nat -A POSTROUTING -o ${TARGET_INTERFACE} -j MASQUERADE\n`;
|
: [],
|
||||||
sh += `EOF\n`;
|
})),
|
||||||
sh += `chmod +x ${POST_UP_PATH}\n`;
|
});
|
||||||
|
|
||||||
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`;
|
execSync(`mkdir -p ${HOST_IPTABLES_DIR}`, { encoding: "utf-8" });
|
||||||
sh += `#!/bin/bash\n\n`;
|
await Bun.write(POST_UP_PATH, iptables_scripts.post_up);
|
||||||
sh += `# Remove WireGuard INPUT/OUTPUT rules\n`;
|
await Bun.write(POST_DOWN_PATH, iptables_scripts.post_down);
|
||||||
sh += `iptables -D INPUT -i ${INTERFACE_NAME} -j ACCEPT\n`;
|
execSync(`chmod +x ${POST_UP_PATH} ${POST_DOWN_PATH}`, {
|
||||||
sh += `iptables -D OUTPUT -o ${INTERFACE_NAME} -j ACCEPT\n`;
|
encoding: "utf-8",
|
||||||
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`;
|
|
||||||
|
|
||||||
sh += `cat > ${INTERFACE_NAME}.conf << EOF\n`;
|
sh += `cat > ${INTERFACE_NAME}.conf << EOF\n`;
|
||||||
sh += `[Interface]\n`;
|
sh += `[Interface]\n`;
|
||||||
@@ -218,14 +249,21 @@ export default async function setupWireguardHost({
|
|||||||
const client = clients[i];
|
const client = clients[i];
|
||||||
if (!client?.id) continue;
|
if (!client?.id) continue;
|
||||||
|
|
||||||
const { CLIENT_PUBLIC_KEY } = grabClientDirnames({
|
const { CLIENT_PUBLIC_KEY_FILE } = grabClientDirnames({
|
||||||
client,
|
client,
|
||||||
host_id: HOST_ID,
|
host_id: HOST_ID,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const CLIENT_PUBLIC_KEY = execSync(
|
||||||
|
`cat ${CLIENT_PUBLIC_KEY_FILE}`,
|
||||||
|
{
|
||||||
|
encoding: "utf-8",
|
||||||
|
},
|
||||||
|
).trim();
|
||||||
|
|
||||||
sh += `[Peer]\n`;
|
sh += `[Peer]\n`;
|
||||||
sh += `PublicKey = ${CLIENT_PUBLIC_KEY}\n`;
|
sh += `PublicKey = ${CLIENT_PUBLIC_KEY}\n`;
|
||||||
sh += `AllowedIPs = ${client.allowed_ips}\n`;
|
sh += `AllowedIPs = ${client.wg_ip_address}/32\n`;
|
||||||
sh += `\n`;
|
sh += `\n`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,27 +35,40 @@ export default async function syncWireguardHosts() {
|
|||||||
table: "hosts",
|
table: "hosts",
|
||||||
});
|
});
|
||||||
|
|
||||||
const main_host_id = AppData["WireguardHostID"];
|
|
||||||
const main_host_ip = variables.payload?.find(
|
const main_host_ip = variables.payload?.find(
|
||||||
(v) => v.key == "main_host_wg_ip_address",
|
(v) => v.key == "main_host_wg_ip_address",
|
||||||
)?.value;
|
)?.value;
|
||||||
|
|
||||||
|
const main_host_public_ip = variables.payload?.find(
|
||||||
|
(v) => v.key == "main_host_public_ip_address",
|
||||||
|
)?.value;
|
||||||
|
|
||||||
if (!main_host_ip) {
|
if (!main_host_ip) {
|
||||||
throw new Error(`Main Host not set yet`);
|
throw new Error(`Main Host not set yet`);
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const host_config_file_name of host_config_file_names) {
|
const all_hosts: BUN_SQLITE_WGUI_HOSTS[] = [
|
||||||
const host_id = Number(
|
{
|
||||||
host_config_file_name.match(HOST_CONFIG_FILE_NAME_PATTERN)?.[1],
|
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(
|
bunext.bunextLog.info(
|
||||||
`[wgui] wireguard host ${host_config_file_name}: ${
|
`[wgui] wireguard host ${host.id}: ${
|
||||||
res.success ? `up` : `failed — ${res.msg}`
|
res.success ? `up` : `failed — ${res.msg}`
|
||||||
}`,
|
}`,
|
||||||
);
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.log(`[wgui] skipping wireguard host sync — ${error.message}`);
|
console.log(`[wgui] skipping wireguard host sync — ${error.message}`);
|
||||||
|
|||||||
@@ -139,8 +139,6 @@ export default async function updateWireguardHostPublicIP({
|
|||||||
skip_host_setup: true,
|
skip_host_setup: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
console.log("client_setup_res", client_setup_res);
|
|
||||||
|
|
||||||
if (client_setup_res.success) {
|
if (client_setup_res.success) {
|
||||||
updated_count++;
|
updated_count++;
|
||||||
} else {
|
} 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 Stack from "@/src/components/twui/layout/Stack";
|
||||||
import ClientFormModal from "../(partials)/client-form-modal";
|
import ClientFormModal from "../(partials)/client-form-modal";
|
||||||
import { useAdminCrudGet } from "@/src/hooks/use-admin-crud-get";
|
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 = {
|
type Props = {
|
||||||
addOpen: boolean;
|
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 filtered = useMemo(() => {
|
||||||
const clients = res || [];
|
const clients = res || [];
|
||||||
const q = query.trim().toLowerCase();
|
const q = query.trim().toLowerCase();
|
||||||
@@ -80,15 +109,26 @@ export default function ClientsTableSection({ addOpen, setAddOpen }: Props) {
|
|||||||
<th className={thClass}>Client</th>
|
<th className={thClass}>Client</th>
|
||||||
<th className={thClass}>Tunnel IP</th>
|
<th className={thClass}>Tunnel IP</th>
|
||||||
<th className={thClass}>Allowed IPs</th>
|
<th className={thClass}>Allowed IPs</th>
|
||||||
|
<th className={thClass}>Access</th>
|
||||||
<th className={thClass}>Public key</th>
|
<th className={thClass}>Public key</th>
|
||||||
<th className={thRightClass}>Created</th>
|
<th className={thRightClass}>Created</th>
|
||||||
<th className={thRightClass}>Actions</th>
|
<th className={thRightClass}>Actions</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{filtered.map((client) => (
|
{filtered.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>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+105
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
+29
@@ -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."
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
+42
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
+25
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
+29
@@ -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."
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
+33
@@ -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,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
+45
@@ -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,
|
||||||
|
// });
|
||||||
|
// }}
|
||||||
|
// />
|
||||||
|
// );
|
||||||
|
}
|
||||||
+76
@@ -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 ClientRow from "@/src/components/general/client-row";
|
||||||
import { useAdminCrudGet } from "@/src/hooks/use-admin-crud-get";
|
import { useAdminCrudGet } from "@/src/hooks/use-admin-crud-get";
|
||||||
import { AppContext } from "@/src/pages/__root";
|
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 = {
|
type Props = {
|
||||||
host_id: string | number;
|
host_id: string | number;
|
||||||
@@ -32,6 +33,33 @@ export default function ClientsList({ host_id }: Props) {
|
|||||||
initial_res: pageProps?.clients || undefined,
|
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) {
|
if (!clients) {
|
||||||
return (
|
return (
|
||||||
<Paper>
|
<Paper>
|
||||||
@@ -60,6 +88,7 @@ export default function ClientsList({ host_id }: Props) {
|
|||||||
<th className={thClass}>Client</th>
|
<th className={thClass}>Client</th>
|
||||||
<th className={thClass}>Tunnel IP</th>
|
<th className={thClass}>Tunnel IP</th>
|
||||||
<th className={thClass}>Allowed IPs</th>
|
<th className={thClass}>Allowed IPs</th>
|
||||||
|
<th className={thClass}>Access</th>
|
||||||
<th className={thClass}>Public key</th>
|
<th className={thClass}>Public key</th>
|
||||||
<th className={thRightClass}>Created</th>
|
<th className={thRightClass}>Created</th>
|
||||||
<th className={thRightClass}>Actions</th>
|
<th className={thRightClass}>Actions</th>
|
||||||
@@ -67,7 +96,15 @@ export default function ClientsList({ host_id }: Props) {
|
|||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{clients.map((client) => (
|
{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>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
|||||||
+73
@@ -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 Row from "@/src/components/twui/layout/Row";
|
||||||
import Stack from "@/src/components/twui/layout/Stack";
|
import Stack from "@/src/components/twui/layout/Stack";
|
||||||
import { AppContext } from "@/src/pages/__root";
|
import { AppContext } from "@/src/pages/__root";
|
||||||
|
import deriveClientAccessLabel from "@/src/functions/frontend/derive-client-access-label";
|
||||||
import ClientInfoRow from "../(partials)/client-info-row";
|
import ClientInfoRow from "../(partials)/client-info-row";
|
||||||
|
|
||||||
export default function ClientInfoSection() {
|
export default function ClientInfoSection() {
|
||||||
@@ -11,6 +12,9 @@ export default function ClientInfoSection() {
|
|||||||
|
|
||||||
const client = pageProps?.client;
|
const client = pageProps?.client;
|
||||||
const host_id = Number(client?.host_id || 0);
|
const host_id = Number(client?.host_id || 0);
|
||||||
|
const access = deriveClientAccessLabel({
|
||||||
|
rules: pageProps?.client_rules,
|
||||||
|
});
|
||||||
|
|
||||||
if (!client) {
|
if (!client) {
|
||||||
return null;
|
return null;
|
||||||
@@ -31,6 +35,10 @@ export default function ClientInfoSection() {
|
|||||||
value: client.allowed_ips || "—",
|
value: client.allowed_ips || "—",
|
||||||
mono: true,
|
mono: true,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
label: "Access",
|
||||||
|
value: access.text,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
label: "Public key",
|
label: "Public key",
|
||||||
value: client.public_key || "—",
|
value: client.public_key || "—",
|
||||||
|
|||||||
+87
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
+1
@@ -13,6 +13,7 @@ export default function EditClientFormSection() {
|
|||||||
<AddClientForm
|
<AddClientForm
|
||||||
existing_client={pageProps?.client || undefined}
|
existing_client={pageProps?.client || undefined}
|
||||||
existing_client_full={pageProps?.client || undefined}
|
existing_client_full={pageProps?.client || undefined}
|
||||||
|
existing_client_rules={pageProps?.client_rules || undefined}
|
||||||
host_id={host_id}
|
host_id={host_id}
|
||||||
/>
|
/>
|
||||||
</Section>
|
</Section>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { BUN_SQLITE_WGUI_CLIENTS } from "@/db/types/db";
|
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 type { PagePropsType, PageQueryObject, TableType } from "@/src/types";
|
||||||
import BunSQLite from "@moduletrace/bun-sqlite";
|
import BunSQLite from "@moduletrace/bun-sqlite";
|
||||||
import type { BunextPageServerFn } from "@moduletrace/bunext/types";
|
import type { BunextPageServerFn } from "@moduletrace/bunext/types";
|
||||||
@@ -18,9 +19,16 @@ const server: BunextPageServerFn<PagePropsType> = async ({ query }) => {
|
|||||||
targetId: page_query.client_id,
|
targetId: page_query.client_id,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const client = client_res.singleRes || null;
|
||||||
|
|
||||||
|
const client_rules = await grabClientRules({
|
||||||
|
client_id: client?.id,
|
||||||
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
props: {
|
props: {
|
||||||
client: client_res.singleRes || null,
|
client,
|
||||||
|
client_rules,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import type {
|
|||||||
BUN_SQLITE_WGUI_HOSTS,
|
BUN_SQLITE_WGUI_HOSTS,
|
||||||
} from "@/db/types/db";
|
} from "@/db/types/db";
|
||||||
import { AppData } from "@/src/data/app-data";
|
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 generateWireguardClientQR from "@/src/functions/backend/setup/generate-wireguard-client-qr";
|
||||||
import readWireguardClientConfig from "@/src/functions/backend/setup/read-wireguard-client-config";
|
import readWireguardClientConfig from "@/src/functions/backend/setup/read-wireguard-client-config";
|
||||||
import type { PagePropsType, PageQueryObject, TableType } from "@/src/types";
|
import type { PagePropsType, PageQueryObject, TableType } from "@/src/types";
|
||||||
@@ -55,6 +56,10 @@ const server: BunextPageServerFn<PagePropsType> = async ({ query }) => {
|
|||||||
config: config_res.config,
|
config: config_res.config,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const client_rules = await grabClientRules({
|
||||||
|
client_id: client?.id,
|
||||||
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
props: {
|
props: {
|
||||||
client,
|
client,
|
||||||
@@ -62,6 +67,7 @@ const server: BunextPageServerFn<PagePropsType> = async ({ query }) => {
|
|||||||
client_config: config_res.config,
|
client_config: config_res.config,
|
||||||
client_config_path: config_res.config_path,
|
client_config_path: config_res.config_path,
|
||||||
client_qr_data_uri: qr_res.qr_data_uri,
|
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 ClientInfoSection from "./(sections)/client-info-section";
|
||||||
import ClientConfigSection from "./(sections)/client-config-section";
|
import ClientConfigSection from "./(sections)/client-config-section";
|
||||||
import ClientQRSection from "./(sections)/client-qr-section";
|
import ClientQRSection from "./(sections)/client-qr-section";
|
||||||
|
import ClientRulesSection from "./(sections)/client-rules-section";
|
||||||
import ClientDetailActions from "./(partials)/client-detail-actions";
|
import ClientDetailActions from "./(partials)/client-detail-actions";
|
||||||
|
|
||||||
export default function AdminClientDetailPage() {
|
export default function AdminClientDetailPage() {
|
||||||
@@ -52,6 +53,7 @@ export default function AdminClientDetailPage() {
|
|||||||
{pageProps?.client ? (
|
{pageProps?.client ? (
|
||||||
<>
|
<>
|
||||||
<ClientInfoSection />
|
<ClientInfoSection />
|
||||||
|
<ClientRulesSection />
|
||||||
<ClientQRSection />
|
<ClientQRSection />
|
||||||
<ClientConfigSection />
|
<ClientConfigSection />
|
||||||
</>
|
</>
|
||||||
|
|||||||
+57
-2
@@ -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 adminCrudHandler from "@/src/functions/frontend/admin-crud-handler";
|
||||||
import type useFormInit from "@/src/hooks/use-form-init";
|
import type useFormInit from "@/src/hooks/use-form-init";
|
||||||
import _ from "lodash";
|
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 = {
|
type Params = {
|
||||||
host_id?: string | number;
|
host_id?: string | number;
|
||||||
|
rules?: ClientRuleDraft[];
|
||||||
};
|
};
|
||||||
|
|
||||||
export default async function submitAddClientForm(
|
export default async function submitAddClientForm(
|
||||||
@@ -15,7 +25,7 @@ export default async function submitAddClientForm(
|
|||||||
existing_full,
|
existing_full,
|
||||||
app_context,
|
app_context,
|
||||||
}: ReturnType<typeof useFormInit<BUN_SQLITE_WGUI_CLIENTS>>,
|
}: ReturnType<typeof useFormInit<BUN_SQLITE_WGUI_CLIENTS>>,
|
||||||
{ host_id }: Params,
|
{ host_id, rules }: Params,
|
||||||
) {
|
) {
|
||||||
try {
|
try {
|
||||||
const confirm_msg = existing_full?.id
|
const confirm_msg = existing_full?.id
|
||||||
@@ -44,6 +54,10 @@ export default async function submitAddClientForm(
|
|||||||
|
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
|
|
||||||
|
const client_rules = (rules || []).map((draft) =>
|
||||||
|
clientRuleDraftToRecord({ draft }),
|
||||||
|
);
|
||||||
|
|
||||||
const res = existing_full?.id
|
const res = existing_full?.id
|
||||||
? await adminCrudHandler({
|
? await adminCrudHandler({
|
||||||
action: "update",
|
action: "update",
|
||||||
@@ -57,6 +71,47 @@ export default async function submitAddClientForm(
|
|||||||
insert_data: [new_client_data],
|
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 (res.success) {
|
||||||
if (existing_full?.id) {
|
if (existing_full?.id) {
|
||||||
window.location.pathname = `/admin/hosts/${final_host_id}/clients/${existing_full.id}`;
|
window.location.pathname = `/admin/hosts/${final_host_id}/clients/${existing_full.id}`;
|
||||||
|
|||||||
+9
-2
@@ -10,7 +10,7 @@ export default function AddClientFormAllowedIps({
|
|||||||
}: ReturnType<typeof useFormInit<BUN_SQLITE_WGUI_CLIENTS>>) {
|
}: ReturnType<typeof useFormInit<BUN_SQLITE_WGUI_CLIENTS>>) {
|
||||||
return (
|
return (
|
||||||
<Stack className="gap-2 my-4 w-full">
|
<Stack className="gap-2 my-4 w-full">
|
||||||
{form.allow_all_ips ? null : (
|
{form.allow_all_ips == 1 ? null : (
|
||||||
<Input
|
<Input
|
||||||
title="Allowed IPs"
|
title="Allowed IPs"
|
||||||
placeholder="Eg. 10.0.0.2/24"
|
placeholder="Eg. 10.0.0.2/24"
|
||||||
@@ -24,8 +24,9 @@ export default function AddClientFormAllowedIps({
|
|||||||
showLabel
|
showLabel
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Checkbox
|
<Checkbox
|
||||||
label={`Allow all IPs? That is 0.0.0.0/0, ::/0`}
|
label={`Allow all IPs?`}
|
||||||
defaultChecked={form.allow_all_ips == 1}
|
defaultChecked={form.allow_all_ips == 1}
|
||||||
changeHandler={(value) => {
|
changeHandler={(value) => {
|
||||||
setForm((prev) => ({
|
setForm((prev) => ({
|
||||||
@@ -33,6 +34,12 @@ export default function AddClientFormAllowedIps({
|
|||||||
allow_all_ips: value ? 1 : 0,
|
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>
|
</Stack>
|
||||||
);
|
);
|
||||||
|
|||||||
+33
-11
@@ -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 Form from "@/src/components/twui/form/Form";
|
||||||
import LoadingOverlay from "@/src/components/twui/elements/LoadingOverlay";
|
import LoadingOverlay from "@/src/components/twui/elements/LoadingOverlay";
|
||||||
import Stack from "@/src/components/twui/layout/Stack";
|
import Stack from "@/src/components/twui/layout/Stack";
|
||||||
import Tag from "@/src/components/twui/elements/Tag";
|
import Tag from "@/src/components/twui/elements/Tag";
|
||||||
import useFormInit from "@/src/hooks/use-form-init";
|
import useFormInit from "@/src/hooks/use-form-init";
|
||||||
import submitAddClientForm from "../../(functions)/submit-add-client-form";
|
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 AddClientFormAction from "./add-client-form-action";
|
||||||
import AddClientFormAllowedIps from "./add-client-form-allowed-ips";
|
import AddClientFormAllowedIps from "./add-client-form-allowed-ips";
|
||||||
import AddClientFormNotes from "./add-client-form-notes";
|
import AddClientFormNotes from "./add-client-form-notes";
|
||||||
import AddClientFormTitle from "./add-client-form-title";
|
import AddClientFormTitle from "./add-client-form-title";
|
||||||
import AddClientFormWgIpAddress from "./add-client-form-wg-ip-address";
|
import AddClientFormWgIpAddress from "./add-client-form-wg-ip-address";
|
||||||
|
import LoadingRectangleBlock from "@/src/components/twui/layout/LoadingRectangleBlock";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
existing_client?: BUN_SQLITE_WGUI_CLIENTS;
|
existing_client?: BUN_SQLITE_WGUI_CLIENTS;
|
||||||
existing_client_full?: BUN_SQLITE_WGUI_CLIENTS;
|
existing_client_full?: BUN_SQLITE_WGUI_CLIENTS;
|
||||||
|
existing_client_rules?: BUN_SQLITE_WGUI_CLIENT_RULES[];
|
||||||
host_id?: string | number;
|
host_id?: string | number;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function AddClientForm({
|
export default function AddClientForm({
|
||||||
existing_client,
|
existing_client,
|
||||||
existing_client_full,
|
existing_client_full,
|
||||||
|
existing_client_rules,
|
||||||
host_id,
|
host_id,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const init = useFormInit<BUN_SQLITE_WGUI_CLIENTS>({
|
const init = useFormInit<BUN_SQLITE_WGUI_CLIENTS>({
|
||||||
@@ -28,25 +40,35 @@ export default function AddClientForm({
|
|||||||
title: "Add Client",
|
title: "Add Client",
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const [rules, setRules] = useState<ClientRuleDraft[]>(
|
||||||
|
(existing_client_rules || []).map((rule) =>
|
||||||
|
clientRuleToDraft({ rule }),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
const { loading, status } = init;
|
const { loading, status } = init;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Form
|
<Form
|
||||||
className="w-full gap-6"
|
className="w-full gap-6"
|
||||||
onSubmit={() => {
|
onSubmit={() => {
|
||||||
submitAddClientForm(init, { host_id });
|
submitAddClientForm(init, { host_id, rules });
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{status?.error && <Tag color="error">{status.msg}</Tag>}
|
{status?.error && <Tag color="error">{status.msg}</Tag>}
|
||||||
{loading && <LoadingOverlay />}
|
{loading && <LoadingOverlay />}
|
||||||
|
{init.ready ? (
|
||||||
<Stack>
|
<Stack>
|
||||||
<AddClientFormTitle {...init} />
|
<AddClientFormTitle {...init} />
|
||||||
<AddClientFormWgIpAddress {...init} host_id={host_id} />
|
<AddClientFormWgIpAddress {...init} host_id={host_id} />
|
||||||
<AddClientFormAllowedIps {...init} />
|
<AddClientFormAllowedIps {...init} />
|
||||||
<AddClientFormNotes {...init} />
|
<AddClientFormNotes {...init} />
|
||||||
<AddClientFormAction {...init} />
|
<ClientRulesEditor rules={rules} setRules={setRules} />
|
||||||
</Stack>
|
<AddClientFormAction {...init} />
|
||||||
|
</Stack>
|
||||||
|
) : (
|
||||||
|
<LoadingRectangleBlock className="h-[400px]" />
|
||||||
|
)}
|
||||||
</Form>
|
</Form>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import type {
|
|||||||
BUN_SQLITE_WGUI_CLIENTS,
|
BUN_SQLITE_WGUI_CLIENTS,
|
||||||
BUN_SQLITE_WGUI_HOSTS,
|
BUN_SQLITE_WGUI_HOSTS,
|
||||||
} from "@/db/types/db";
|
} 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 type { PagePropsType, PageQueryObject, TableType } from "@/src/types";
|
||||||
import BunSQLite from "@moduletrace/bun-sqlite";
|
import BunSQLite from "@moduletrace/bun-sqlite";
|
||||||
import type { BunextPageServerFn } from "@moduletrace/bunext/types";
|
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 {
|
return {
|
||||||
props: {
|
props: {
|
||||||
host: host_record_res.singleRes || null,
|
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;
|
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 {
|
import type {
|
||||||
BUN_SQLITE_WGUI_ALL_TYPEDEFS,
|
BUN_SQLITE_WGUI_ALL_TYPEDEFS,
|
||||||
BUN_SQLITE_WGUI_CLIENTS,
|
BUN_SQLITE_WGUI_CLIENTS,
|
||||||
|
BUN_SQLITE_WGUI_CLIENT_RULES,
|
||||||
BUN_SQLITE_WGUI_HOSTS,
|
BUN_SQLITE_WGUI_HOSTS,
|
||||||
} from "@/db/types/db";
|
} from "@/db/types/db";
|
||||||
import BunSQLite from "@moduletrace/bun-sqlite";
|
import BunSQLite from "@moduletrace/bun-sqlite";
|
||||||
@@ -76,6 +77,17 @@ export default async function (
|
|||||||
|
|
||||||
affected_host_ids.add(host_id);
|
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>({
|
await BunSQLite.delete<BUN_SQLITE_WGUI_CLIENTS, TableType>({
|
||||||
table: "clients",
|
table: "clients",
|
||||||
targetId: client.id,
|
targetId: client.id,
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import checkUserAccess from "@/src/functions/backend/auth/check-user-access";
|
|||||||
import type { AdminCrudAPIParams } from "@/src/types";
|
import type { AdminCrudAPIParams } from "@/src/types";
|
||||||
import BunSQLite from "@moduletrace/bun-sqlite";
|
import BunSQLite from "@moduletrace/bun-sqlite";
|
||||||
import type { APIResponseObject } from "@moduletrace/bunext/types";
|
import type { APIResponseObject } from "@moduletrace/bunext/types";
|
||||||
import runClientSetup from "./(functions)/run-client-setup";
|
|
||||||
import _ from "lodash";
|
import _ from "lodash";
|
||||||
|
|
||||||
export default async function (
|
export default async function (
|
||||||
@@ -33,20 +32,5 @@ export default async function (
|
|||||||
update_on_duplicate: body?.update_on_duplicate,
|
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;
|
return POST;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -37,16 +37,16 @@ export default async function (
|
|||||||
query: final_sql_query,
|
query: final_sql_query,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (PUT.success) {
|
// if (PUT.success) {
|
||||||
const client_setup_res = await runClientSetup({
|
// const client_setup_res = await runClientSetup({
|
||||||
client_id: targetId,
|
// client_id: targetId,
|
||||||
user,
|
// user,
|
||||||
});
|
// });
|
||||||
|
|
||||||
if (!client_setup_res.success) {
|
// if (!client_setup_res.success) {
|
||||||
return client_setup_res;
|
// return client_setup_res;
|
||||||
}
|
// }
|
||||||
}
|
// }
|
||||||
|
|
||||||
return PUT;
|
return PUT;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import type { AdminCrudAPIParams, ApiReqParams, TableType } from "@/src/types";
|
|||||||
import userAuth from "@/src/functions/backend/auth/user-auth";
|
import userAuth from "@/src/functions/backend/auth/user-auth";
|
||||||
import checkUserAccess from "@/src/functions/backend/auth/check-user-access";
|
import checkUserAccess from "@/src/functions/backend/auth/check-user-access";
|
||||||
import clients from "./(tables)/clients";
|
import clients from "./(tables)/clients";
|
||||||
|
import clientRules from "./(tables)/client_rules";
|
||||||
|
|
||||||
export const handler: BunextAPIRouteHandler<APIResponseObject> = async (
|
export const handler: BunextAPIRouteHandler<APIResponseObject> = async (
|
||||||
params,
|
params,
|
||||||
@@ -61,6 +62,8 @@ export const handler: BunextAPIRouteHandler<APIResponseObject> = async (
|
|||||||
return await media(crud_params);
|
return await media(crud_params);
|
||||||
case "clients":
|
case "clients":
|
||||||
return await clients(crud_params);
|
return await clients(crud_params);
|
||||||
|
case "client_rules":
|
||||||
|
return await clientRules(crud_params);
|
||||||
|
|
||||||
default:
|
default:
|
||||||
break;
|
break;
|
||||||
|
|||||||
@@ -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
@@ -104,10 +104,6 @@ h4 {
|
|||||||
@apply font-semibold;
|
@apply font-semibold;
|
||||||
}
|
}
|
||||||
|
|
||||||
.twui-row {
|
|
||||||
@apply gap-4;
|
|
||||||
}
|
|
||||||
|
|
||||||
a {
|
a {
|
||||||
@apply outline-none border-none text-primary;
|
@apply outline-none border-none text-primary;
|
||||||
}
|
}
|
||||||
@@ -201,3 +197,8 @@ header nav a:hover > * {
|
|||||||
#admin-main section {
|
#admin-main section {
|
||||||
@apply gap-10;
|
@apply gap-10;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
p code,
|
||||||
|
span code {
|
||||||
|
@apply p-1 mx-1 bg-slate-100;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import type {
|
import type {
|
||||||
BUN_SQLITE_WGUI_ALL_TYPEDEFS,
|
BUN_SQLITE_WGUI_ALL_TYPEDEFS,
|
||||||
BUN_SQLITE_WGUI_CLIENTS,
|
BUN_SQLITE_WGUI_CLIENTS,
|
||||||
|
BUN_SQLITE_WGUI_CLIENT_RULES,
|
||||||
BUN_SQLITE_WGUI_HOSTS,
|
BUN_SQLITE_WGUI_HOSTS,
|
||||||
BUN_SQLITE_WGUI_MEDIA,
|
BUN_SQLITE_WGUI_MEDIA,
|
||||||
BUN_SQLITE_WGUI_USER_TYPES,
|
BUN_SQLITE_WGUI_USER_TYPES,
|
||||||
@@ -10,6 +11,10 @@ import type {
|
|||||||
import type { SBFSusidiaries } from "../dict/subsidiaries-dict";
|
import type { SBFSusidiaries } from "../dict/subsidiaries-dict";
|
||||||
import type { UserTypes } from "../dict/user-types-dict";
|
import type { UserTypes } from "../dict/user-types-dict";
|
||||||
import type { MediaParadigms, MediaTypes } from "../dict/media-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 { BunextPageModuleServerReturnURLObject } from "@moduletrace/bunext/types";
|
||||||
import type { BUN_SQLITE_WGUI_USERS_JOIN } from "./sql-joins";
|
import type { BUN_SQLITE_WGUI_USERS_JOIN } from "./sql-joins";
|
||||||
import type { ImageInputToBase64FunctionReturn } from "../components/twui/utils/form/imageInputToBase64";
|
import type { ImageInputToBase64FunctionReturn } from "../components/twui/utils/form/imageInputToBase64";
|
||||||
@@ -40,6 +45,7 @@ export type PagePropsType = {
|
|||||||
hosts?: BUN_SQLITE_WGUI_HOSTS[] | null;
|
hosts?: BUN_SQLITE_WGUI_HOSTS[] | null;
|
||||||
client?: BUN_SQLITE_WGUI_CLIENTS | null;
|
client?: BUN_SQLITE_WGUI_CLIENTS | null;
|
||||||
clients?: 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?: string | null;
|
||||||
client_config_path?: string | null;
|
client_config_path?: string | null;
|
||||||
client_qr_data_uri?: string | null;
|
client_qr_data_uri?: string | null;
|
||||||
@@ -202,7 +208,9 @@ export type ApiReqParams<
|
|||||||
user_id?: string | number | null;
|
user_id?: string | number | null;
|
||||||
dependent_id?: string | number | null;
|
dependent_id?: string | number | null;
|
||||||
host_id?: string | number | null;
|
host_id?: string | number | null;
|
||||||
|
client_id?: string | number | null;
|
||||||
public_ip_address?: string | null;
|
public_ip_address?: string | null;
|
||||||
|
// client_rules?: BUN_SQLITE_WGUI_CLIENT_RULES[];
|
||||||
|
|
||||||
media_base_64?: string;
|
media_base_64?: string;
|
||||||
media_base_64_data_url?: string;
|
media_base_64_data_url?: string;
|
||||||
@@ -253,6 +261,8 @@ export type SQLInsertGenValueType =
|
|||||||
|
|
||||||
export type MediaType = (typeof MediaTypes)[number]["value"];
|
export type MediaType = (typeof MediaTypes)[number]["value"];
|
||||||
export type MediaParadigm = (typeof MediaParadigms)[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];
|
export type TableType = (typeof BunSQLiteTables)[number];
|
||||||
|
|
||||||
|
|||||||
@@ -62,6 +62,8 @@ export function setCookies(response: Response, cookies: CookieOptions[]): void {
|
|||||||
if (maxAge !== undefined) header += `; Max-Age=${maxAge}`;
|
if (maxAge !== undefined) header += `; Max-Age=${maxAge}`;
|
||||||
if (domain) header += `; Domain=${domain}`;
|
if (domain) header += `; Domain=${domain}`;
|
||||||
|
|
||||||
|
console.log("header", header);
|
||||||
|
|
||||||
response.headers.append("Set-Cookie", header);
|
response.headers.append("Set-Cookie", header);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user