From feeeba5cee6e52aeb85edbfb63d2407f8917f50b Mon Sep 17 00:00:00 2001 From: Milo Schwartz Date: Tue, 4 Feb 2025 22:46:41 -0500 Subject: [PATCH 01/24] fix path in cicd --- .github/workflows/cicd.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/cicd.yml b/.github/workflows/cicd.yml index 65b01b26..be0fc303 100644 --- a/.github/workflows/cicd.yml +++ b/.github/workflows/cicd.yml @@ -36,7 +36,7 @@ jobs: run: | TAG=${{ env.TAG }} sed -i "s/export const APP_VERSION = \".*\";/export const APP_VERSION = \"$TAG\";/" server/lib/consts.ts - cat server/lib/ + cat server/lib/consts.ts - name: Pull latest Gerbil version id: get-gerbil-tag From 8165051dd8da55ae063d3642e69b5fd70a7ea1e8 Mon Sep 17 00:00:00 2001 From: Milo Schwartz Date: Mon, 10 Feb 2025 21:35:06 -0500 Subject: [PATCH 02/24] fix toast dismiss causing components to rerender and clean up rules text --- .../settings/access/roles/CreateRoleForm.tsx | 3 +- .../settings/access/roles/DeleteRoleForm.tsx | 3 +- .../settings/access/roles/RolesTable.tsx | 3 +- .../settings/access/users/InviteUserForm.tsx | 3 +- .../settings/access/users/UsersTable.tsx | 3 +- .../users/[userId]/access-controls/page.tsx | 3 +- src/app/[orgId]/settings/general/page.tsx | 3 +- .../settings/resources/CreateResourceForm.tsx | 4 +- .../settings/resources/ResourcesTable.tsx | 4 +- .../SetResourcePasswordForm.tsx | 4 +- .../authentication/SetResourcePincodeForm.tsx | 4 +- .../[resourceId]/authentication/page.tsx | 3 +- .../[resourceId]/connectivity/page.tsx | 3 +- .../resources/[resourceId]/general/page.tsx | 3 +- .../resources/[resourceId]/rules/page.tsx | 66 ++++++++++++------- .../share-links/CreateShareLinkForm.tsx | 3 +- .../settings/share-links/ShareLinksTable.tsx | 4 +- .../[orgId]/settings/sites/CreateSiteForm.tsx | 4 +- src/app/[orgId]/settings/sites/SitesTable.tsx | 4 +- .../settings/sites/[niceId]/general/page.tsx | 3 +- .../auth/reset-password/ResetPasswordForm.tsx | 4 +- .../[resourceId]/ResourceAuthPortal.tsx | 3 +- src/app/auth/verify-email/VerifyEmailForm.tsx | 4 +- src/components/Disable2FaForm.tsx | 4 +- src/components/Enable2FaForm.tsx | 4 +- src/components/ProfileIcon.tsx | 3 +- 26 files changed, 69 insertions(+), 83 deletions(-) diff --git a/src/app/[orgId]/settings/access/roles/CreateRoleForm.tsx b/src/app/[orgId]/settings/access/roles/CreateRoleForm.tsx index c17b1eef..cd9aecc5 100644 --- a/src/app/[orgId]/settings/access/roles/CreateRoleForm.tsx +++ b/src/app/[orgId]/settings/access/roles/CreateRoleForm.tsx @@ -10,7 +10,7 @@ import { FormMessage, } from "@app/components/ui/form"; import { Input } from "@app/components/ui/input"; -import { useToast } from "@app/hooks/useToast"; +import { toast } from "@app/hooks/useToast"; import { zodResolver } from "@hookform/resolvers/zod"; import { AxiosResponse } from "axios"; import { useState } from "react"; @@ -48,7 +48,6 @@ export default function CreateRoleForm({ setOpen, afterCreate, }: CreateRoleFormProps) { - const { toast } = useToast(); const { org } = useOrgContext(); const [loading, setLoading] = useState(false); diff --git a/src/app/[orgId]/settings/access/roles/DeleteRoleForm.tsx b/src/app/[orgId]/settings/access/roles/DeleteRoleForm.tsx index 5c7c2c4b..6bd41df8 100644 --- a/src/app/[orgId]/settings/access/roles/DeleteRoleForm.tsx +++ b/src/app/[orgId]/settings/access/roles/DeleteRoleForm.tsx @@ -9,7 +9,7 @@ import { FormLabel, FormMessage, } from "@app/components/ui/form"; -import { useToast } from "@app/hooks/useToast"; +import { toast } from "@app/hooks/useToast"; import { zodResolver } from "@hookform/resolvers/zod"; import { AxiosResponse } from "axios"; import { useEffect, useState } from "react"; @@ -56,7 +56,6 @@ export default function DeleteRoleForm({ setOpen, afterDelete, }: CreateRoleFormProps) { - const { toast } = useToast(); const { org } = useOrgContext(); const [loading, setLoading] = useState(false); diff --git a/src/app/[orgId]/settings/access/roles/RolesTable.tsx b/src/app/[orgId]/settings/access/roles/RolesTable.tsx index 1c74d91a..66ed7b40 100644 --- a/src/app/[orgId]/settings/access/roles/RolesTable.tsx +++ b/src/app/[orgId]/settings/access/roles/RolesTable.tsx @@ -12,7 +12,7 @@ import { ArrowUpDown, Crown, MoreHorizontal } from "lucide-react"; import { useState } from "react"; import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog"; import { useOrgContext } from "@app/hooks/useOrgContext"; -import { useToast } from "@app/hooks/useToast"; +import { toast } from "@app/hooks/useToast"; import { RolesDataTable } from "./RolesDataTable"; import { Role } from "@server/db/schema"; import CreateRoleForm from "./CreateRoleForm"; @@ -37,7 +37,6 @@ export default function UsersTable({ roles: r }: RolesTableProps) { const api = createApiClient(useEnvContext()); const { org } = useOrgContext(); - const { toast } = useToast(); const columns: ColumnDef[] = [ { diff --git a/src/app/[orgId]/settings/access/users/InviteUserForm.tsx b/src/app/[orgId]/settings/access/users/InviteUserForm.tsx index 346b9fad..c812717d 100644 --- a/src/app/[orgId]/settings/access/users/InviteUserForm.tsx +++ b/src/app/[orgId]/settings/access/users/InviteUserForm.tsx @@ -17,7 +17,7 @@ import { SelectTrigger, SelectValue } from "@app/components/ui/select"; -import { useToast } from "@app/hooks/useToast"; +import { toast } from "@app/hooks/useToast"; import { zodResolver } from "@hookform/resolvers/zod"; import { InviteUserBody, InviteUserResponse } from "@server/routers/user"; import { AxiosResponse } from "axios"; @@ -54,7 +54,6 @@ const formSchema = z.object({ }); export default function InviteUserForm({ open, setOpen }: InviteUserFormProps) { - const { toast } = useToast(); const { org } = useOrgContext(); const { env } = useEnvContext(); diff --git a/src/app/[orgId]/settings/access/users/UsersTable.tsx b/src/app/[orgId]/settings/access/users/UsersTable.tsx index 03454bf1..7c11c06b 100644 --- a/src/app/[orgId]/settings/access/users/UsersTable.tsx +++ b/src/app/[orgId]/settings/access/users/UsersTable.tsx @@ -14,7 +14,7 @@ import { useState } from "react"; import InviteUserForm from "./InviteUserForm"; import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog"; import { useOrgContext } from "@app/hooks/useOrgContext"; -import { useToast } from "@app/hooks/useToast"; +import { toast } from "@app/hooks/useToast"; import Link from "next/link"; import { useRouter } from "next/navigation"; import { formatAxiosError } from "@app/lib/api"; @@ -47,7 +47,6 @@ export default function UsersTable({ users: u }: UsersTableProps) { const { user, updateUser } = useUserContext(); const { org } = useOrgContext(); - const { toast } = useToast(); const columns: ColumnDef[] = [ { diff --git a/src/app/[orgId]/settings/access/users/[userId]/access-controls/page.tsx b/src/app/[orgId]/settings/access/users/[userId]/access-controls/page.tsx index 4305399b..002febc2 100644 --- a/src/app/[orgId]/settings/access/users/[userId]/access-controls/page.tsx +++ b/src/app/[orgId]/settings/access/users/[userId]/access-controls/page.tsx @@ -16,7 +16,7 @@ import { SelectTrigger, SelectValue } from "@app/components/ui/select"; -import { useToast } from "@app/hooks/useToast"; +import { toast } from "@app/hooks/useToast"; import { zodResolver } from "@hookform/resolvers/zod"; import { InviteUserResponse } from "@server/routers/user"; import { AxiosResponse } from "axios"; @@ -47,7 +47,6 @@ const formSchema = z.object({ }); export default function AccessControlsPage() { - const { toast } = useToast(); const { orgUser: user } = userOrgUserContext(); const api = createApiClient(useEnvContext()); diff --git a/src/app/[orgId]/settings/general/page.tsx b/src/app/[orgId]/settings/general/page.tsx index 7e173416..c2ac225c 100644 --- a/src/app/[orgId]/settings/general/page.tsx +++ b/src/app/[orgId]/settings/general/page.tsx @@ -4,7 +4,7 @@ import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog"; import { Button } from "@app/components/ui/button"; import { useOrgContext } from "@app/hooks/useOrgContext"; import { userOrgUserContext } from "@app/hooks/useOrgUserContext"; -import { useToast } from "@app/hooks/useToast"; +import { toast } from "@app/hooks/useToast"; import { useState } from "react"; import { Form, @@ -56,7 +56,6 @@ export default function GeneralPage() { const { orgUser } = userOrgUserContext(); const router = useRouter(); const { org } = useOrgContext(); - const { toast } = useToast(); const api = createApiClient(useEnvContext()); const [loadingDelete, setLoadingDelete] = useState(false); diff --git a/src/app/[orgId]/settings/resources/CreateResourceForm.tsx b/src/app/[orgId]/settings/resources/CreateResourceForm.tsx index 6e33ec79..f62bf8fe 100644 --- a/src/app/[orgId]/settings/resources/CreateResourceForm.tsx +++ b/src/app/[orgId]/settings/resources/CreateResourceForm.tsx @@ -11,7 +11,7 @@ import { FormMessage } from "@app/components/ui/form"; import { Input } from "@app/components/ui/input"; -import { useToast } from "@app/hooks/useToast"; +import { toast } from "@app/hooks/useToast"; import { zodResolver } from "@hookform/resolvers/zod"; import { useEffect, useState } from "react"; import { useForm } from "react-hook-form"; @@ -117,8 +117,6 @@ export default function CreateResourceForm({ open, setOpen }: CreateResourceFormProps) { - const { toast } = useToast(); - const api = createApiClient(useEnvContext()); const [loading, setLoading] = useState(false); diff --git a/src/app/[orgId]/settings/resources/ResourcesTable.tsx b/src/app/[orgId]/settings/resources/ResourcesTable.tsx index fee92999..848838b3 100644 --- a/src/app/[orgId]/settings/resources/ResourcesTable.tsx +++ b/src/app/[orgId]/settings/resources/ResourcesTable.tsx @@ -26,7 +26,7 @@ import { useState } from "react"; import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog"; import { set } from "zod"; import { formatAxiosError } from "@app/lib/api"; -import { useToast } from "@app/hooks/useToast"; +import { toast } from "@app/hooks/useToast"; import { createApiClient } from "@app/lib/api"; import { useEnvContext } from "@app/hooks/useEnvContext"; import CopyToClipboard from "@app/components/CopyToClipboard"; @@ -52,8 +52,6 @@ type ResourcesTableProps = { export default function SitesTable({ resources, orgId }: ResourcesTableProps) { const router = useRouter(); - const { toast } = useToast(); - const api = createApiClient(useEnvContext()); const [isCreateModalOpen, setIsCreateModalOpen] = useState(false); diff --git a/src/app/[orgId]/settings/resources/[resourceId]/authentication/SetResourcePasswordForm.tsx b/src/app/[orgId]/settings/resources/[resourceId]/authentication/SetResourcePasswordForm.tsx index 6a092c0c..fa329ba9 100644 --- a/src/app/[orgId]/settings/resources/[resourceId]/authentication/SetResourcePasswordForm.tsx +++ b/src/app/[orgId]/settings/resources/[resourceId]/authentication/SetResourcePasswordForm.tsx @@ -11,7 +11,7 @@ import { FormMessage, } from "@app/components/ui/form"; import { Input } from "@app/components/ui/input"; -import { useToast } from "@app/hooks/useToast"; +import { toast } from "@app/hooks/useToast"; import { zodResolver } from "@hookform/resolvers/zod"; import { useEffect, useState } from "react"; import { useForm } from "react-hook-form"; @@ -55,8 +55,6 @@ export default function SetResourcePasswordForm({ resourceId, onSetPassword, }: SetPasswordFormProps) { - const { toast } = useToast(); - const api = createApiClient(useEnvContext()); const [loading, setLoading] = useState(false); diff --git a/src/app/[orgId]/settings/resources/[resourceId]/authentication/SetResourcePincodeForm.tsx b/src/app/[orgId]/settings/resources/[resourceId]/authentication/SetResourcePincodeForm.tsx index 1f698c08..704d3f44 100644 --- a/src/app/[orgId]/settings/resources/[resourceId]/authentication/SetResourcePincodeForm.tsx +++ b/src/app/[orgId]/settings/resources/[resourceId]/authentication/SetResourcePincodeForm.tsx @@ -11,7 +11,7 @@ import { FormMessage, } from "@app/components/ui/form"; import { Input } from "@app/components/ui/input"; -import { useToast } from "@app/hooks/useToast"; +import { toast } from "@app/hooks/useToast"; import { zodResolver } from "@hookform/resolvers/zod"; import { useEffect, useState } from "react"; import { useForm } from "react-hook-form"; @@ -60,8 +60,6 @@ export default function SetResourcePincodeForm({ resourceId, onSetPincode, }: SetPincodeFormProps) { - const { toast } = useToast(); - const [loading, setLoading] = useState(false); const api = createApiClient(useEnvContext()); diff --git a/src/app/[orgId]/settings/resources/[resourceId]/authentication/page.tsx b/src/app/[orgId]/settings/resources/[resourceId]/authentication/page.tsx index 9c62b156..0e3dc7bc 100644 --- a/src/app/[orgId]/settings/resources/[resourceId]/authentication/page.tsx +++ b/src/app/[orgId]/settings/resources/[resourceId]/authentication/page.tsx @@ -2,7 +2,7 @@ import { useEffect, useState } from "react"; import { ListRolesResponse } from "@server/routers/role"; -import { useToast } from "@app/hooks/useToast"; +import { toast } from "@app/hooks/useToast"; import { useOrgContext } from "@app/hooks/useOrgContext"; import { useResourceContext } from "@app/hooks/useResourceContext"; import { AxiosResponse } from "axios"; @@ -75,7 +75,6 @@ const whitelistSchema = z.object({ }); export default function ResourceAuthenticationPage() { - const { toast } = useToast(); const { org } = useOrgContext(); const { resource, updateResource, authInfo, updateAuthInfo } = useResourceContext(); diff --git a/src/app/[orgId]/settings/resources/[resourceId]/connectivity/page.tsx b/src/app/[orgId]/settings/resources/[resourceId]/connectivity/page.tsx index b3b26a5a..dfd2f66c 100644 --- a/src/app/[orgId]/settings/resources/[resourceId]/connectivity/page.tsx +++ b/src/app/[orgId]/settings/resources/[resourceId]/connectivity/page.tsx @@ -45,7 +45,7 @@ import { TableHeader, TableRow } from "@app/components/ui/table"; -import { useToast } from "@app/hooks/useToast"; +import { toast } from "@app/hooks/useToast"; import { useResourceContext } from "@app/hooks/useResourceContext"; import { ArrayElement } from "@server/types/ArrayElement"; import { formatAxiosError } from "@app/lib/api/formatAxiosError"; @@ -113,7 +113,6 @@ export default function ReverseProxyTargets(props: { }) { const params = use(props.params); - const { toast } = useToast(); const { resource, updateResource } = useResourceContext(); const api = createApiClient(useEnvContext()); diff --git a/src/app/[orgId]/settings/resources/[resourceId]/general/page.tsx b/src/app/[orgId]/settings/resources/[resourceId]/general/page.tsx index 848115f8..43624e3f 100644 --- a/src/app/[orgId]/settings/resources/[resourceId]/general/page.tsx +++ b/src/app/[orgId]/settings/resources/[resourceId]/general/page.tsx @@ -34,7 +34,7 @@ import { AxiosResponse } from "axios"; import { useParams, useRouter } from "next/navigation"; import { useForm } from "react-hook-form"; import { GetResourceAuthInfoResponse } from "@server/routers/resource"; -import { useToast } from "@app/hooks/useToast"; +import { toast } from "@app/hooks/useToast"; import { SettingsContainer, SettingsSection, @@ -102,7 +102,6 @@ type TransferFormValues = z.infer; export default function GeneralForm() { const params = useParams(); - const { toast } = useToast(); const { resource, updateResource } = useResourceContext(); const { org } = useOrgContext(); const router = useRouter(); diff --git a/src/app/[orgId]/settings/resources/[resourceId]/rules/page.tsx b/src/app/[orgId]/settings/resources/[resourceId]/rules/page.tsx index 6a961a6a..121e0a58 100644 --- a/src/app/[orgId]/settings/resources/[resourceId]/rules/page.tsx +++ b/src/app/[orgId]/settings/resources/[resourceId]/rules/page.tsx @@ -16,7 +16,6 @@ import { z } from "zod"; import { Form, FormControl, - FormDescription, FormField, FormItem, FormLabel, @@ -40,7 +39,7 @@ import { TableHeader, TableRow } from "@app/components/ui/table"; -import { useToast } from "@app/hooks/useToast"; +import { toast } from "@app/hooks/useToast"; import { useResourceContext } from "@app/hooks/useResourceContext"; import { ArrayElement } from "@server/types/ArrayElement"; import { formatAxiosError } from "@app/lib/api/formatAxiosError"; @@ -58,7 +57,7 @@ import { import { ListResourceRulesResponse } from "@server/routers/resource/listResourceRules"; import { SwitchInput } from "@app/components/SwitchInput"; import { Alert, AlertDescription, AlertTitle } from "@app/components/ui/alert"; -import { Check, Info, InfoIcon, X } from "lucide-react"; +import { Check, InfoIcon, X } from "lucide-react"; import { InfoSection, InfoSections, @@ -84,11 +83,16 @@ enum RuleAction { DROP = "Always Deny" } +enum RuleMatch { + IP = "IP", + CIDR = "IP Range", + PATH = "Path" +} + export default function ResourceRules(props: { params: Promise<{ resourceId: number }>; }) { const params = use(props.params); - const { toast } = useToast(); const { resource, updateResource } = useResourceContext(); const api = createApiClient(useEnvContext()); const [rules, setRules] = useState([]); @@ -233,6 +237,17 @@ export default function ResourceRules(props: { } } + function getValueHelpText(type: string) { + switch (type) { + case "CIDR": + return "Enter an address in CIDR format (e.g., 103.21.244.0/22)"; + case "IP": + return "Enter an IP address (e.g., 103.21.244.12)"; + case "PATH": + return "Enter a URL path or pattern (e.g., /api/v1/todos or /api/v1/*)"; + } + } + async function saveRules() { try { setLoading(true); @@ -275,7 +290,10 @@ export default function ResourceRules(props: { } if (rule.new) { - const res = await api.put(`/resource/${params.resourceId}/rule`, data); + const res = await api.put( + `/resource/${params.resourceId}/rule`, + data + ); rule.ruleId = res.data.data.ruleId; } else if (rule.updated) { await api.post( @@ -300,9 +318,7 @@ export default function ResourceRules(props: { await api.delete( `/resource/${params.resourceId}/rule/${ruleId}` ); - setRules( - rules.filter((r) => r.ruleId !== ruleId) - ); + setRules(rules.filter((r) => r.ruleId !== ruleId)); } toast({ @@ -337,7 +353,9 @@ export default function ResourceRules(props: { } > - {row.original.action} + {row.original.action === "ACCEPT" + ? RuleAction.ACCEPT + : RuleAction.DROP} @@ -359,12 +377,16 @@ export default function ResourceRules(props: { } > - {row.original.match} + {row.original.match === "IP" + ? RuleMatch.IP + : row.original.match === "CIDR" + ? RuleMatch.CIDR + : RuleMatch.PATH} - IP - IP Range - PATH + {RuleMatch.IP} + {RuleMatch.CIDR} + {RuleMatch.PATH} ) @@ -547,14 +569,14 @@ export default function ResourceRules(props: { - IP + {RuleMatch.IP} - IP Range + {RuleMatch.CIDR} {resource.http && ( - PATH + {RuleMatch.PATH} )} @@ -572,11 +594,11 @@ export default function ResourceRules(props: { @@ -590,7 +612,7 @@ export default function ResourceRules(props: { diff --git a/src/app/[orgId]/settings/share-links/CreateShareLinkForm.tsx b/src/app/[orgId]/settings/share-links/CreateShareLinkForm.tsx index ccdf25fb..aa9cb74c 100644 --- a/src/app/[orgId]/settings/share-links/CreateShareLinkForm.tsx +++ b/src/app/[orgId]/settings/share-links/CreateShareLinkForm.tsx @@ -18,7 +18,7 @@ import { SelectTrigger, SelectValue } from "@app/components/ui/select"; -import { useToast } from "@app/hooks/useToast"; +import { toast } from "@app/hooks/useToast"; import { zodResolver } from "@hookform/resolvers/zod"; import { InviteUserBody, InviteUserResponse } from "@server/routers/user"; import { AxiosResponse } from "axios"; @@ -94,7 +94,6 @@ export default function CreateShareLinkForm({ setOpen, onCreated }: FormProps) { - const { toast } = useToast(); const { org } = useOrgContext(); const { env } = useEnvContext(); diff --git a/src/app/[orgId]/settings/share-links/ShareLinksTable.tsx b/src/app/[orgId]/settings/share-links/ShareLinksTable.tsx index 8fadbd10..5d5d341f 100644 --- a/src/app/[orgId]/settings/share-links/ShareLinksTable.tsx +++ b/src/app/[orgId]/settings/share-links/ShareLinksTable.tsx @@ -25,7 +25,7 @@ import { useRouter } from "next/navigation"; import { useState } from "react"; import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog"; import { formatAxiosError } from "@app/lib/api"; -import { useToast } from "@app/hooks/useToast"; +import { toast } from "@app/hooks/useToast"; import { createApiClient } from "@app/lib/api"; import { useEnvContext } from "@app/hooks/useEnvContext"; import { ArrayElement } from "@server/types/ArrayElement"; @@ -54,8 +54,6 @@ export default function ShareLinksTable({ }: ShareLinksTableProps) { const router = useRouter(); - const { toast } = useToast(); - const api = createApiClient(useEnvContext()); const [isCreateModalOpen, setIsCreateModalOpen] = useState(false); diff --git a/src/app/[orgId]/settings/sites/CreateSiteForm.tsx b/src/app/[orgId]/settings/sites/CreateSiteForm.tsx index 72774fdd..6df144ca 100644 --- a/src/app/[orgId]/settings/sites/CreateSiteForm.tsx +++ b/src/app/[orgId]/settings/sites/CreateSiteForm.tsx @@ -10,7 +10,7 @@ import { FormMessage } from "@app/components/ui/form"; import { Input } from "@app/components/ui/input"; -import { useToast } from "@app/hooks/useToast"; +import { toast } from "@app/hooks/useToast"; import { zodResolver } from "@hookform/resolvers/zod"; import { useEffect, useState } from "react"; import { useForm } from "react-hook-form"; @@ -72,8 +72,6 @@ export default function CreateSiteForm({ setChecked, orgId }: CreateSiteFormProps) { - const { toast } = useToast(); - const api = createApiClient(useEnvContext()); const { env } = useEnvContext(); diff --git a/src/app/[orgId]/settings/sites/SitesTable.tsx b/src/app/[orgId]/settings/sites/SitesTable.tsx index eaa1ca6c..d9d0ba03 100644 --- a/src/app/[orgId]/settings/sites/SitesTable.tsx +++ b/src/app/[orgId]/settings/sites/SitesTable.tsx @@ -22,7 +22,7 @@ import { AxiosResponse } from "axios"; import { useState } from "react"; import CreateSiteForm from "./CreateSiteForm"; import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog"; -import { useToast } from "@app/hooks/useToast"; +import { toast } from "@app/hooks/useToast"; import { formatAxiosError } from "@app/lib/api"; import { createApiClient } from "@app/lib/api"; import { useEnvContext } from "@app/hooks/useEnvContext"; @@ -47,8 +47,6 @@ type SitesTableProps = { export default function SitesTable({ sites, orgId }: SitesTableProps) { const router = useRouter(); - const { toast } = useToast(); - const [isCreateModalOpen, setIsCreateModalOpen] = useState(false); const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); const [selectedSite, setSelectedSite] = useState(null); diff --git a/src/app/[orgId]/settings/sites/[niceId]/general/page.tsx b/src/app/[orgId]/settings/sites/[niceId]/general/page.tsx index 7d17d58e..b1c24405 100644 --- a/src/app/[orgId]/settings/sites/[niceId]/general/page.tsx +++ b/src/app/[orgId]/settings/sites/[niceId]/general/page.tsx @@ -15,7 +15,7 @@ import { import { Input } from "@/components/ui/input"; import { useSiteContext } from "@app/hooks/useSiteContext"; import { useForm } from "react-hook-form"; -import { useToast } from "@app/hooks/useToast"; +import { toast } from "@app/hooks/useToast"; import { useRouter } from "next/navigation"; import { SettingsContainer, @@ -40,7 +40,6 @@ type GeneralFormValues = z.infer; export default function GeneralPage() { const { site, updateSite } = useSiteContext(); - const { toast } = useToast(); const api = createApiClient(useEnvContext()); diff --git a/src/app/auth/reset-password/ResetPasswordForm.tsx b/src/app/auth/reset-password/ResetPasswordForm.tsx index ae997818..a87762fe 100644 --- a/src/app/auth/reset-password/ResetPasswordForm.tsx +++ b/src/app/auth/reset-password/ResetPasswordForm.tsx @@ -36,7 +36,7 @@ import { } from "@server/routers/auth"; import { Loader2 } from "lucide-react"; import { Alert, AlertDescription } from "../../../components/ui/alert"; -import { useToast } from "@app/hooks/useToast"; +import { toast } from "@app/hooks/useToast"; import { useRouter } from "next/navigation"; import { formatAxiosError } from "@app/lib/api";; import { createApiClient } from "@app/lib/api"; @@ -96,8 +96,6 @@ export default function ResetPasswordForm({ const [state, setState] = useState<"request" | "reset" | "mfa">(getState()); - const { toast } = useToast(); - const api = createApiClient(useEnvContext()); const form = useForm>({ diff --git a/src/app/auth/resource/[resourceId]/ResourceAuthPortal.tsx b/src/app/auth/resource/[resourceId]/ResourceAuthPortal.tsx index 1aec64d9..5eda0809 100644 --- a/src/app/auth/resource/[resourceId]/ResourceAuthPortal.tsx +++ b/src/app/auth/resource/[resourceId]/ResourceAuthPortal.tsx @@ -48,7 +48,7 @@ import { import ResourceAccessDenied from "./ResourceAccessDenied"; import { createApiClient } from "@app/lib/api"; import { useEnvContext } from "@app/hooks/useEnvContext"; -import { useToast } from "@app/hooks/useToast"; +import { toast } from "@app/hooks/useToast"; import Link from "next/link"; const pinSchema = z.object({ @@ -91,7 +91,6 @@ type ResourceAuthPortalProps = { export default function ResourceAuthPortal(props: ResourceAuthPortalProps) { const router = useRouter(); - const { toast } = useToast(); const getNumMethods = () => { let colLength = 0; diff --git a/src/app/auth/verify-email/VerifyEmailForm.tsx b/src/app/auth/verify-email/VerifyEmailForm.tsx index 8a0ca89a..e0dcbffb 100644 --- a/src/app/auth/verify-email/VerifyEmailForm.tsx +++ b/src/app/auth/verify-email/VerifyEmailForm.tsx @@ -31,7 +31,7 @@ import { AxiosResponse } from "axios"; import { VerifyEmailResponse } from "@server/routers/auth"; import { Loader2 } from "lucide-react"; import { Alert, AlertDescription } from "../../../components/ui/alert"; -import { useToast } from "@app/hooks/useToast"; +import { toast } from "@app/hooks/useToast"; import { useRouter } from "next/navigation"; import { formatAxiosError } from "@app/lib/api";; import { createApiClient } from "@app/lib/api"; @@ -61,8 +61,6 @@ export default function VerifyEmailForm({ const [isResending, setIsResending] = useState(false); const [isSubmitting, setIsSubmitting] = useState(false); - const { toast } = useToast(); - const api = createApiClient(useEnvContext()); const form = useForm>({ diff --git a/src/components/Disable2FaForm.tsx b/src/components/Disable2FaForm.tsx index cf36d957..3e87bce4 100644 --- a/src/components/Disable2FaForm.tsx +++ b/src/components/Disable2FaForm.tsx @@ -28,7 +28,7 @@ import { CredenzaHeader, CredenzaTitle } from "@app/components/Credenza"; -import { useToast } from "@app/hooks/useToast"; +import { toast } from "@app/hooks/useToast"; import { formatAxiosError } from "@app/lib/api";; import { useUserContext } from "@app/hooks/useUserContext"; import { InputOTP, InputOTPGroup, InputOTPSlot } from "./ui/input-otp"; @@ -50,8 +50,6 @@ export default function Disable2FaForm({ open, setOpen }: Disable2FaProps) { const [step, setStep] = useState<"password" | "success">("password"); - const { toast } = useToast(); - const { user, updateUser } = useUserContext(); const api = createApiClient(useEnvContext()); diff --git a/src/components/Enable2FaForm.tsx b/src/components/Enable2FaForm.tsx index 57f00725..d9167999 100644 --- a/src/components/Enable2FaForm.tsx +++ b/src/components/Enable2FaForm.tsx @@ -35,7 +35,7 @@ import { CredenzaHeader, CredenzaTitle } from "@app/components/Credenza"; -import { useToast } from "@app/hooks/useToast"; +import { toast } from "@app/hooks/useToast"; import { formatAxiosError } from "@app/lib/api";; import CopyTextBox from "@app/components/CopyTextBox"; import { QRCodeCanvas, QRCodeSVG } from "qrcode.react"; @@ -64,8 +64,6 @@ export default function Enable2FaForm({ open, setOpen }: Enable2FaProps) { const [loading, setLoading] = useState(false); const [backupCodes, setBackupCodes] = useState([]); - const { toast } = useToast(); - const { user, updateUser } = useUserContext(); const api = createApiClient(useEnvContext()); diff --git a/src/components/ProfileIcon.tsx b/src/components/ProfileIcon.tsx index 1d2e74cc..43463fac 100644 --- a/src/components/ProfileIcon.tsx +++ b/src/components/ProfileIcon.tsx @@ -12,7 +12,7 @@ import { DropdownMenuTrigger } from "@app/components/ui/dropdown-menu"; import { useEnvContext } from "@app/hooks/useEnvContext"; -import { useToast } from "@app/hooks/useToast"; +import { toast } from "@app/hooks/useToast"; import { formatAxiosError } from "@app/lib/api";; import { Laptop, LogOut, Moon, Sun } from "lucide-react"; import { useTheme } from "next-themes"; @@ -23,7 +23,6 @@ import Disable2FaForm from "./Disable2FaForm"; import Enable2FaForm from "./Enable2FaForm"; export default function ProfileIcon() { - const { toast } = useToast(); const { setTheme, theme } = useTheme(); const { env } = useEnvContext(); const api = createApiClient({ env }); From c244ef387bb89d204fd623f52f24f4c778cfbc98 Mon Sep 17 00:00:00 2001 From: Milo Schwartz Date: Mon, 10 Feb 2025 21:48:34 -0500 Subject: [PATCH 03/24] make subdomain input better accommodate long domains --- .../settings/resources/[resourceId]/CustomDomainInput.tsx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/app/[orgId]/settings/resources/[resourceId]/CustomDomainInput.tsx b/src/app/[orgId]/settings/resources/[resourceId]/CustomDomainInput.tsx index 5c1cf01c..fd754dde 100644 --- a/src/app/[orgId]/settings/resources/[resourceId]/CustomDomainInput.tsx +++ b/src/app/[orgId]/settings/resources/[resourceId]/CustomDomainInput.tsx @@ -14,7 +14,7 @@ export default function CustomDomainInput({ domainSuffix, placeholder = "Enter subdomain", value: defaultValue, - onChange, + onChange }: CustomDomainInputProps) { const [value, setValue] = React.useState(defaultValue); @@ -34,10 +34,10 @@ export default function CustomDomainInput({ placeholder={placeholder} value={value} onChange={handleChange} - className="rounded-r-none flex-grow" + className="rounded-r-none w-full" /> -
- .{domainSuffix} +
+ .{domainSuffix}
From f14ecf50e42d812a98b3295a6e7d119a3337fd37 Mon Sep 17 00:00:00 2001 From: Milo Schwartz Date: Mon, 10 Feb 2025 22:26:29 -0500 Subject: [PATCH 04/24] add docker deployment snippets to create site form --- .../resources/[resourceId]/rules/page.tsx | 16 +- .../[orgId]/settings/sites/CreateSiteForm.tsx | 138 +++++++++++++----- 2 files changed, 108 insertions(+), 46 deletions(-) diff --git a/src/app/[orgId]/settings/resources/[resourceId]/rules/page.tsx b/src/app/[orgId]/settings/resources/[resourceId]/rules/page.tsx index 121e0a58..5ee16461 100644 --- a/src/app/[orgId]/settings/resources/[resourceId]/rules/page.tsx +++ b/src/app/[orgId]/settings/resources/[resourceId]/rules/page.tsx @@ -352,10 +352,8 @@ export default function ResourceRules(props: { updateRule(row.original.ruleId, { action: value }) } > - - {row.original.action === "ACCEPT" - ? RuleAction.ACCEPT - : RuleAction.DROP} + + @@ -376,12 +374,8 @@ export default function ResourceRules(props: { updateRule(row.original.ruleId, { match: value }) } > - - {row.original.match === "IP" - ? RuleMatch.IP - : row.original.match === "CIDR" - ? RuleMatch.CIDR - : RuleMatch.PATH} + + {RuleMatch.IP} @@ -436,7 +430,7 @@ export default function ResourceRules(props: { return ( - + About Rules diff --git a/src/app/[orgId]/settings/sites/CreateSiteForm.tsx b/src/app/[orgId]/settings/sites/CreateSiteForm.tsx index 6df144ca..98fcc2a6 100644 --- a/src/app/[orgId]/settings/sites/CreateSiteForm.tsx +++ b/src/app/[orgId]/settings/sites/CreateSiteForm.tsx @@ -38,7 +38,16 @@ import { SiteRow } from "./SitesTable"; import { AxiosResponse } from "axios"; import { Button } from "@app/components/ui/button"; import Link from "next/link"; -import { ArrowUpRight, SquareArrowOutUpRight } from "lucide-react"; +import { + ArrowUpRight, + ChevronsUpDown, + SquareArrowOutUpRight +} from "lucide-react"; +import { + Collapsible, + CollapsibleContent, + CollapsibleTrigger +} from "@app/components/ui/collapsible"; const createSiteFormSchema = z.object({ name: z @@ -78,6 +87,8 @@ export default function CreateSiteForm({ const [isLoading, setIsLoading] = useState(false); const [isChecked, setIsChecked] = useState(false); + const [isOpen, setIsOpen] = useState(false); + const [keypair, setKeypair] = useState<{ publicKey: string; privateKey: string; @@ -182,10 +193,9 @@ export default function CreateSiteForm({ } const res = await api - .put>( - `/org/${orgId}/site/`, - payload - ) + .put< + AxiosResponse + >(`/org/${orgId}/site/`, payload) .catch((e) => { toast({ variant: "destructive", @@ -235,6 +245,18 @@ PersistentKeepalive = 5` const newtConfig = `newt --id ${siteDefaults?.newtId} --secret ${siteDefaults?.newtSecret} --endpoint ${env.app.dashboardUrl}`; + const newtConfigDockerCompose = `services: + newt: + image: fosrl/newt + container_name: newt + restart: unless-stopped + environment: + - PANGOLIN_ENDPOINT=${env.app.dashboardUrl} + - NEWT_ID=${siteDefaults?.newtId} + - NEWT_SECRET=${siteDefaults?.newtSecret}`; + + const newtConfigDockerRun = `docker run -it fosrl/newt --id ${siteDefaults?.newtId} --secret ${siteDefaults?.newtSecret} --endpoint ${env.app.dashboardUrl}`; + return (
@@ -305,32 +327,6 @@ PersistentKeepalive = 5` )} /> -
- {form.watch("method") === "wireguard" && !isLoading ? ( - <> - - - You will only be able to see the - configuration once. - - - ) : form.watch("method") === "wireguard" && - isLoading ? ( -

Loading WireGuard configuration...

- ) : form.watch("method") === "newt" ? ( - <> - - - You will only be able to see the - configuration once. - - - ) : null} -
- {form.watch("method") === "newt" && ( )} +
+ {form.watch("method") === "wireguard" && !isLoading ? ( + <> + + + You will only be able to see the + configuration once. + + + ) : form.watch("method") === "wireguard" && + isLoading ? ( +

Loading WireGuard configuration...

+ ) : form.watch("method") === "newt" ? ( + <> +
+ +
+ +
+
+ + + +
+ +
+ Docker Compose + +
+
+ Docker Run + + +
+
+
+
+ + You will only be able to see the + configuration once. + + + ) : null} +
+ {form.watch("method") === "local" && ( - - {" "} - Local sites do not tunnel, learn more - + Local sites do not tunnel, learn more )} From fdf1dfdeba904a9b07faf565f5449f35a252d2b9 Mon Sep 17 00:00:00 2001 From: Milo Schwartz Date: Tue, 11 Feb 2025 23:59:13 -0500 Subject: [PATCH 05/24] rules server validation, enabled toggle, fix wildcard --- server/db/schema.ts | 4 +- .../subdomainSchema.ts => lib/schemas.ts} | 1 + server/lib/validators.ts | 68 +++++ server/routers/badger/verifySession.ts | 252 +++++++++++------- server/routers/resource/createResource.ts | 2 +- server/routers/resource/createResourceRule.ts | 50 +++- server/routers/resource/listResourceRules.ts | 19 +- server/routers/resource/updateResource.ts | 2 +- server/routers/resource/updateResourceRule.ts | 63 ++++- server/setup/scripts/1.0.0-beta13.ts | 2 + .../settings/resources/CreateResourceForm.tsx | 2 +- .../resources/[resourceId]/general/page.tsx | 3 +- .../resources/[resourceId]/rules/page.tsx | 195 ++++++++------ 13 files changed, 467 insertions(+), 196 deletions(-) rename server/{schemas/subdomainSchema.ts => lib/schemas.ts} (99%) create mode 100644 server/lib/validators.ts diff --git a/server/db/schema.ts b/server/db/schema.ts index 16d8ada2..3380cdbf 100644 --- a/server/db/schema.ts +++ b/server/db/schema.ts @@ -377,6 +377,8 @@ export const resourceRules = sqliteTable("resourceRules", { resourceId: integer("resourceId") .notNull() .references(() => resources.resourceId, { onDelete: "cascade" }), + enabled: integer("enabled", { mode: "boolean" }).notNull().default(true), + priority: integer("priority").notNull(), action: text("action").notNull(), // ACCEPT, DROP match: text("match").notNull(), // CIDR, PATH, IP value: text("value").notNull() @@ -414,4 +416,4 @@ export type ResourceOtp = InferSelectModel; export type ResourceAccessToken = InferSelectModel; export type ResourceWhitelist = InferSelectModel; export type VersionMigration = InferSelectModel; -export type ResourceRule = InferSelectModel; \ No newline at end of file +export type ResourceRule = InferSelectModel; diff --git a/server/schemas/subdomainSchema.ts b/server/lib/schemas.ts similarity index 99% rename from server/schemas/subdomainSchema.ts rename to server/lib/schemas.ts index 30ba2ddd..f4b7daf3 100644 --- a/server/schemas/subdomainSchema.ts +++ b/server/lib/schemas.ts @@ -8,3 +8,4 @@ export const subdomainSchema = z ) .min(1, "Subdomain must be at least 1 character long") .transform((val) => val.toLowerCase()); + diff --git a/server/lib/validators.ts b/server/lib/validators.ts new file mode 100644 index 00000000..ffe471bb --- /dev/null +++ b/server/lib/validators.ts @@ -0,0 +1,68 @@ +export function isValidCIDR(cidr: string): boolean { + // Match CIDR pattern (e.g., "192.168.0.0/24") + const cidrPattern = + /^([0-9]{1,3}\.){3}[0-9]{1,3}\/([0-9]|[1-2][0-9]|3[0-2])$/; + + if (!cidrPattern.test(cidr)) { + return false; + } + + // Validate IP address part + const ipPart = cidr.split("/")[0]; + const octets = ipPart.split("."); + + return octets.every((octet) => { + const num = parseInt(octet, 10); + return num >= 0 && num <= 255; + }); +} + +export function isValidIP(ip: string): boolean { + const ipPattern = /^([0-9]{1,3}\.){3}[0-9]{1,3}$/; + + if (!ipPattern.test(ip)) { + return false; + } + + const octets = ip.split("."); + + return octets.every((octet) => { + const num = parseInt(octet, 10); + return num >= 0 && num <= 255; + }); +} + +export function isValidUrlGlobPattern(pattern: string): boolean { + // Remove leading slash if present + pattern = pattern.startsWith("/") ? pattern.slice(1) : pattern; + + // Empty string is not valid + if (!pattern) { + return false; + } + + // Split path into segments + const segments = pattern.split("/"); + + // Check each segment + for (let i = 0; i < segments.length; i++) { + const segment = segments[i]; + + // Empty segments are not allowed (double slashes) + if (!segment && i !== segments.length - 1) { + return false; + } + + // If segment contains *, it must be exactly * + if (segment.includes("*") && segment !== "*") { + return false; + } + + // Check for invalid characters + if (!/^[a-zA-Z0-9_*-]*$/.test(segment)) { + return false; + } + } + + return true; +} diff --git a/server/routers/badger/verifySession.ts b/server/routers/badger/verifySession.ts index a4a2944a..fc1c85f5 100644 --- a/server/routers/badger/verifySession.ts +++ b/server/routers/badger/verifySession.ts @@ -1,36 +1,38 @@ -import HttpCode from "@server/types/HttpCode"; -import { NextFunction, Request, Response } from "express"; -import createHttpError from "http-errors"; -import { z } from "zod"; -import { fromError } from "zod-validation-error"; -import { response } from "@server/lib/response"; -import db from "@server/db"; -import { - resourceRules, - ResourceAccessToken, - ResourcePassword, - resourcePassword, - ResourcePincode, - resourcePincode, - resources, - sessions, - userOrgs, - users, - ResourceRule -} from "@server/db/schema"; -import { and, eq } from "drizzle-orm"; -import config from "@server/lib/config"; +import { generateSessionToken } from "@server/auth/sessions/app"; import { createResourceSession, serializeResourceSessionCookie, validateResourceSessionToken } from "@server/auth/sessions/resource"; -import { Resource, roleResources, userResources } from "@server/db/schema"; -import logger from "@server/logger"; import { verifyResourceAccessToken } from "@server/auth/verifyResourceAccessToken"; -import NodeCache from "node-cache"; -import { generateSessionToken } from "@server/auth/sessions/app"; +import db from "@server/db"; +import { + Resource, + ResourceAccessToken, + ResourcePassword, + resourcePassword, + ResourcePincode, + resourcePincode, + ResourceRule, + resourceRules, + resources, + roleResources, + sessions, + userOrgs, + userResources, + users +} from "@server/db/schema"; +import config from "@server/lib/config"; import { isIpInCidr } from "@server/lib/ip"; +import { response } from "@server/lib/response"; +import logger from "@server/logger"; +import HttpCode from "@server/types/HttpCode"; +import { and, eq } from "drizzle-orm"; +import { NextFunction, Request, Response } from "express"; +import createHttpError from "http-errors"; +import NodeCache from "node-cache"; +import { z } from "zod"; +import { fromError } from "zod-validation-error"; // We'll see if this speeds anything up const cache = new NodeCache({ @@ -169,18 +171,16 @@ export async function verifyResourceSession( // otherwise its undefined and we pass } - const redirectUrl = `${config.getRawConfig().app.dashboard_url}/auth/resource/${encodeURIComponent(resource.resourceId)}?redirect=${encodeURIComponent(originalRequestURL)}`; + const redirectUrl = `${config.getRawConfig().app.dashboard_url}/auth/resource/${encodeURIComponent( + resource.resourceId + )}?redirect=${encodeURIComponent(originalRequestURL)}`; // check for access token let validAccessToken: ResourceAccessToken | undefined; if (token) { const [accessTokenId, accessToken] = token.split("."); const { valid, error, tokenItem } = await verifyResourceAccessToken( - { - resource, - accessTokenId, - accessToken - } + { resource, accessTokenId, accessToken } ); if (error) { @@ -190,7 +190,9 @@ export async function verifyResourceSession( if (!valid) { if (config.getRawConfig().app.log_failed_attempts) { logger.info( - `Resource access token is invalid. Resource ID: ${resource.resourceId}. IP: ${clientIp}.` + `Resource access token is invalid. Resource ID: ${ + resource.resourceId + }. IP: ${clientIp}.` ); } } @@ -211,7 +213,9 @@ export async function verifyResourceSession( if (!sessions) { if (config.getRawConfig().app.log_failed_attempts) { logger.info( - `Missing resource sessions. Resource ID: ${resource.resourceId}. IP: ${clientIp}.` + `Missing resource sessions. Resource ID: ${ + resource.resourceId + }. IP: ${clientIp}.` ); } return notAllowed(res); @@ -219,7 +223,9 @@ export async function verifyResourceSession( const resourceSessionToken = sessions[ - `${config.getRawConfig().server.session_cookie_name}${resource.ssl ? "_s" : ""}` + `${config.getRawConfig().server.session_cookie_name}${ + resource.ssl ? "_s" : "" + }` ]; if (resourceSessionToken) { @@ -242,7 +248,9 @@ export async function verifyResourceSession( ); if (config.getRawConfig().app.log_failed_attempts) { logger.info( - `Resource session is an exchange token. Resource ID: ${resource.resourceId}. IP: ${clientIp}.` + `Resource session is an exchange token. Resource ID: ${ + resource.resourceId + }. IP: ${clientIp}.` ); } return notAllowed(res); @@ -281,7 +289,9 @@ export async function verifyResourceSession( } if (resourceSession.userSessionId && sso) { - const userAccessCacheKey = `userAccess:${resourceSession.userSessionId}:${resource.resourceId}`; + const userAccessCacheKey = `userAccess:${ + resourceSession.userSessionId + }:${resource.resourceId}`; let isAllowed: boolean | undefined = cache.get(userAccessCacheKey); @@ -305,8 +315,8 @@ export async function verifyResourceSession( } } - // At this point we have checked all sessions, but since the access token is valid, we should allow access - // and create a new session. + // At this point we have checked all sessions, but since the access token is + // valid, we should allow access and create a new session. if (validAccessToken) { return await createAccessTokenSession( res, @@ -319,7 +329,9 @@ export async function verifyResourceSession( if (config.getRawConfig().app.log_failed_attempts) { logger.info( - `Resource access not allowed. Resource ID: ${resource.resourceId}. IP: ${clientIp}.` + `Resource access not allowed. Resource ID: ${ + resource.resourceId + }. IP: ${clientIp}.` ); } return notAllowed(res, redirectUrl); @@ -485,69 +497,123 @@ async function checkRules( return; } - let hasAcceptRule = false; + // sort rules by priority in ascending order + rules = rules.sort((a, b) => a.priority - b.priority); - // First pass: look for DROP rules for (const rule of rules) { + if (!rule.enabled) { + continue; + } + if ( - (clientIp && - rule.match == "CIDR" && - isIpInCidr(clientIp, rule.value) && - rule.action === "DROP") || - (clientIp && - rule.match == "IP" && - clientIp == rule.value && - rule.action === "DROP") || - (path && - rule.match == "PATH" && - urlGlobToRegex(rule.value).test(path) && - rule.action === "DROP") + clientIp && + rule.match == "CIDR" && + isIpInCidr(clientIp, rule.value) ) { - return "DROP"; - } - // Track if we see any ACCEPT rules for the second pass - if (rule.action === "ACCEPT") { - hasAcceptRule = true; - } - } - - // Second pass: only check ACCEPT rules if we found one and didn't find a DROP - if (hasAcceptRule) { - for (const rule of rules) { - if (rule.action !== "ACCEPT") continue; - - if ( - (clientIp && - rule.match == "CIDR" && - isIpInCidr(clientIp, rule.value)) || - (clientIp && - rule.match == "IP" && - clientIp == rule.value) || - (path && - rule.match == "PATH" && - urlGlobToRegex(rule.value).test(path)) - ) { - return "ACCEPT"; - } + return rule.action as any; + } else if (clientIp && rule.match == "IP" && clientIp == rule.value) { + return rule.action as any; + } else if ( + path && + rule.match == "PATH" && + isPathAllowed(rule.value, path) + ) { + return rule.action as any; } } return; } -function urlGlobToRegex(pattern: string): RegExp { - // Trim any leading or trailing slashes - pattern = pattern.replace(/^\/+|\/+$/g, ""); +function isPathAllowed(pattern: string, path: string): boolean { + logger.debug(`\nMatching path "${path}" against pattern "${pattern}"`); - // Escape special regex characters except * - const escapedPattern = pattern.replace(/[.+?^${}()|[\]\\]/g, "\\$&"); + // Normalize and split paths into segments + const normalize = (p: string) => p.split("/").filter(Boolean); + const patternParts = normalize(pattern); + const pathParts = normalize(path); - // Replace * with regex pattern for any valid URL segment characters - const regexPattern = escapedPattern.replace(/\*/g, "[a-zA-Z0-9_-]+"); + logger.debug(`Normalized pattern parts: [${patternParts.join(", ")}]`); + logger.debug(`Normalized path parts: [${pathParts.join(", ")}]`); - // Create the final pattern that: - // 1. Optionally matches leading slash - // 2. Matches the pattern - // 3. Optionally matches trailing slash - return new RegExp(`^/?${regexPattern}/?$`); -} \ No newline at end of file + // Recursive function to try different wildcard matches + function matchSegments(patternIndex: number, pathIndex: number): boolean { + const indent = " ".repeat(pathIndex); // Indent based on recursion depth + const currentPatternPart = patternParts[patternIndex]; + const currentPathPart = pathParts[pathIndex]; + + logger.debug( + `${indent}Checking patternIndex=${patternIndex} (${currentPatternPart || "END"}) vs pathIndex=${pathIndex} (${currentPathPart || "END"})` + ); + + // If we've consumed all pattern parts, we should have consumed all path parts + if (patternIndex >= patternParts.length) { + const result = pathIndex >= pathParts.length; + logger.debug( + `${indent}Reached end of pattern, remaining path: ${pathParts.slice(pathIndex).join("/")} -> ${result}` + ); + return result; + } + + // If we've consumed all path parts but still have pattern parts + if (pathIndex >= pathParts.length) { + // The only way this can match is if all remaining pattern parts are wildcards + const remainingPattern = patternParts.slice(patternIndex); + const result = remainingPattern.every((p) => p === "*"); + logger.debug( + `${indent}Reached end of path, remaining pattern: ${remainingPattern.join("/")} -> ${result}` + ); + return result; + } + + // For wildcards, try consuming different numbers of path segments + if (currentPatternPart === "*") { + logger.debug( + `${indent}Found wildcard at pattern index ${patternIndex}` + ); + + // Try consuming 0 segments (skip the wildcard) + logger.debug( + `${indent}Trying to skip wildcard (consume 0 segments)` + ); + if (matchSegments(patternIndex + 1, pathIndex)) { + logger.debug( + `${indent}Successfully matched by skipping wildcard` + ); + return true; + } + + // Try consuming current segment and recursively try rest + logger.debug( + `${indent}Trying to consume segment "${currentPathPart}" for wildcard` + ); + if (matchSegments(patternIndex, pathIndex + 1)) { + logger.debug( + `${indent}Successfully matched by consuming segment for wildcard` + ); + return true; + } + + logger.debug(`${indent}Failed to match wildcard`); + return false; + } + + // For regular segments, they must match exactly + if (currentPatternPart !== currentPathPart) { + logger.debug( + `${indent}Segment mismatch: "${currentPatternPart}" != "${currentPathPart}"` + ); + return false; + } + + logger.debug( + `${indent}Segments match: "${currentPatternPart}" = "${currentPathPart}"` + ); + // Move to next segments in both pattern and path + return matchSegments(patternIndex + 1, pathIndex + 1); + } + + const result = matchSegments(0, 0); + logger.debug(`Final result: ${result}`); + return result; +} diff --git a/server/routers/resource/createResource.ts b/server/routers/resource/createResource.ts index 9f7fa1fb..39b07a57 100644 --- a/server/routers/resource/createResource.ts +++ b/server/routers/resource/createResource.ts @@ -17,7 +17,7 @@ import { eq, and } from "drizzle-orm"; import stoi from "@server/lib/stoi"; import { fromError } from "zod-validation-error"; import logger from "@server/logger"; -import { subdomainSchema } from "@server/schemas/subdomainSchema"; +import { subdomainSchema } from "@server/lib/schemas"; import config from "@server/lib/config"; const createResourceParamsSchema = z diff --git a/server/routers/resource/createResourceRule.ts b/server/routers/resource/createResourceRule.ts index 24b08fc9..304f4bd0 100644 --- a/server/routers/resource/createResourceRule.ts +++ b/server/routers/resource/createResourceRule.ts @@ -8,12 +8,19 @@ import HttpCode from "@server/types/HttpCode"; import createHttpError from "http-errors"; import logger from "@server/logger"; import { fromError } from "zod-validation-error"; +import { + isValidCIDR, + isValidIP, + isValidUrlGlobPattern +} from "@server/lib/validators"; const createResourceRuleSchema = z .object({ action: z.enum(["ACCEPT", "DROP"]), match: z.enum(["CIDR", "IP", "PATH"]), - value: z.string().min(1) + value: z.string().min(1), + priority: z.number().int(), + enabled: z.boolean().optional() }) .strict(); @@ -42,7 +49,7 @@ export async function createResourceRule( ); } - const { action, match, value } = parsedBody.data; + const { action, match, value, priority, enabled } = parsedBody.data; const parsedParams = createResourceRuleParamsSchema.safeParse( req.params @@ -74,6 +81,41 @@ export async function createResourceRule( ); } + if (!resource.http) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "Cannot create rule for non-http resource" + ) + ); + } + + if (match === "CIDR") { + if (!isValidCIDR(value)) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "Invalid CIDR provided" + ) + ); + } + } else if (match === "IP") { + if (!isValidIP(value)) { + return next( + createHttpError(HttpCode.BAD_REQUEST, "Invalid IP provided") + ); + } + } else if (match === "PATH") { + if (!isValidUrlGlobPattern(value)) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "Invalid URL glob pattern provided" + ) + ); + } + } + // Create the new resource rule const [newRule] = await db .insert(resourceRules) @@ -81,7 +123,9 @@ export async function createResourceRule( resourceId, action, match, - value + value, + priority, + enabled }) .returning(); diff --git a/server/routers/resource/listResourceRules.ts b/server/routers/resource/listResourceRules.ts index 3364aa4b..0d29bd99 100644 --- a/server/routers/resource/listResourceRules.ts +++ b/server/routers/resource/listResourceRules.ts @@ -40,12 +40,14 @@ function queryResourceRules(resourceId: number) { resourceId: resourceRules.resourceId, action: resourceRules.action, match: resourceRules.match, - value: resourceRules.value + value: resourceRules.value, + priority: resourceRules.priority, + enabled: resourceRules.enabled }) .from(resourceRules) .leftJoin(resources, eq(resourceRules.resourceId, resources.resourceId)) .where(eq(resourceRules.resourceId, resourceId)); - + return baseQuery; } @@ -71,7 +73,9 @@ export async function listResourceRules( } const { limit, offset } = parsedQuery.data; - const parsedParams = listResourceRulesParamsSchema.safeParse(req.params); + const parsedParams = listResourceRulesParamsSchema.safeParse( + req.params + ); if (!parsedParams.success) { return next( createHttpError( @@ -99,16 +103,19 @@ export async function listResourceRules( } const baseQuery = queryResourceRules(resourceId); - + let countQuery = db .select({ count: sql`cast(count(*) as integer)` }) .from(resourceRules) .where(eq(resourceRules.resourceId, resourceId)); - const rulesList = await baseQuery.limit(limit).offset(offset); + let rulesList = await baseQuery.limit(limit).offset(offset); const totalCountResult = await countQuery; const totalCount = totalCountResult[0].count; + // sort rules list by the priority in ascending order + rulesList = rulesList.sort((a, b) => a.priority - b.priority); + return response(res, { data: { rules: rulesList, @@ -129,4 +136,4 @@ export async function listResourceRules( createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") ); } -} \ No newline at end of file +} diff --git a/server/routers/resource/updateResource.ts b/server/routers/resource/updateResource.ts index cc48894b..e464b4c5 100644 --- a/server/routers/resource/updateResource.ts +++ b/server/routers/resource/updateResource.ts @@ -8,8 +8,8 @@ import HttpCode from "@server/types/HttpCode"; import createHttpError from "http-errors"; import logger from "@server/logger"; import { fromError } from "zod-validation-error"; -import { subdomainSchema } from "@server/schemas/subdomainSchema"; import config from "@server/lib/config"; +import { subdomainSchema } from "@server/lib/schemas"; const updateResourceParamsSchema = z .object({ diff --git a/server/routers/resource/updateResourceRule.ts b/server/routers/resource/updateResourceRule.ts index 0eaacc03..ef23b318 100644 --- a/server/routers/resource/updateResourceRule.ts +++ b/server/routers/resource/updateResourceRule.ts @@ -8,14 +8,16 @@ import HttpCode from "@server/types/HttpCode"; import createHttpError from "http-errors"; import logger from "@server/logger"; import { fromError } from "zod-validation-error"; +import { + isValidCIDR, + isValidIP, + isValidUrlGlobPattern +} from "@server/lib/validators"; // Define Zod schema for request parameters validation const updateResourceRuleParamsSchema = z .object({ - ruleId: z - .string() - .transform(Number) - .pipe(z.number().int().positive()), + ruleId: z.string().transform(Number).pipe(z.number().int().positive()), resourceId: z .string() .transform(Number) @@ -28,7 +30,9 @@ const updateResourceRuleSchema = z .object({ action: z.enum(["ACCEPT", "DROP"]).optional(), match: z.enum(["CIDR", "IP", "PATH"]).optional(), - value: z.string().min(1).optional() + value: z.string().min(1).optional(), + priority: z.number().int(), + enabled: z.boolean().optional() }) .strict() .refine((data) => Object.keys(data).length > 0, { @@ -42,7 +46,9 @@ export async function updateResourceRule( ): Promise { try { // Validate path parameters - const parsedParams = updateResourceRuleParamsSchema.safeParse(req.params); + const parsedParams = updateResourceRuleParamsSchema.safeParse( + req.params + ); if (!parsedParams.success) { return next( createHttpError( @@ -82,6 +88,15 @@ export async function updateResourceRule( ); } + if (!resource.http) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "Cannot create rule for non-http resource" + ) + ); + } + // Verify that the rule exists and belongs to the specified resource const [existingRule] = await db .select() @@ -107,6 +122,40 @@ export async function updateResourceRule( ); } + const match = updateData.match || existingRule.match; + const { value } = updateData; + + if (value !== undefined) { + if (match === "CIDR") { + if (!isValidCIDR(value)) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "Invalid CIDR provided" + ) + ); + } + } else if (match === "IP") { + if (!isValidIP(value)) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "Invalid IP provided" + ) + ); + } + } else if (match === "PATH") { + if (!isValidUrlGlobPattern(value)) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "Invalid URL glob pattern provided" + ) + ); + } + } + } + // Update the rule const [updatedRule] = await db .update(resourceRules) @@ -127,4 +176,4 @@ export async function updateResourceRule( createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") ); } -} \ No newline at end of file +} diff --git a/server/setup/scripts/1.0.0-beta13.ts b/server/setup/scripts/1.0.0-beta13.ts index 26dac8f9..ea47db57 100644 --- a/server/setup/scripts/1.0.0-beta13.ts +++ b/server/setup/scripts/1.0.0-beta13.ts @@ -9,6 +9,8 @@ export default async function migration() { trx.run(sql`CREATE TABLE resourceRules ( ruleId integer PRIMARY KEY AUTOINCREMENT NOT NULL, resourceId integer NOT NULL, + priority integer NOT NULL, + enabled integer DEFAULT true NOT NULL, action text NOT NULL, match text NOT NULL, value text NOT NULL, diff --git a/src/app/[orgId]/settings/resources/CreateResourceForm.tsx b/src/app/[orgId]/settings/resources/CreateResourceForm.tsx index f62bf8fe..d27f8831 100644 --- a/src/app/[orgId]/settings/resources/CreateResourceForm.tsx +++ b/src/app/[orgId]/settings/resources/CreateResourceForm.tsx @@ -59,7 +59,7 @@ import { SelectTrigger, SelectValue } from "@app/components/ui/select"; -import { subdomainSchema } from "@server/schemas/subdomainSchema"; +import { subdomainSchema } from "@server/lib/schemas"; import Link from "next/link"; import { SquareArrowOutUpRight } from "lucide-react"; import CopyTextBox from "@app/components/CopyTextBox"; diff --git a/src/app/[orgId]/settings/resources/[resourceId]/general/page.tsx b/src/app/[orgId]/settings/resources/[resourceId]/general/page.tsx index 43624e3f..301354a3 100644 --- a/src/app/[orgId]/settings/resources/[resourceId]/general/page.tsx +++ b/src/app/[orgId]/settings/resources/[resourceId]/general/page.tsx @@ -49,9 +49,8 @@ import { useOrgContext } from "@app/hooks/useOrgContext"; import CustomDomainInput from "../CustomDomainInput"; import { createApiClient } from "@app/lib/api"; import { useEnvContext } from "@app/hooks/useEnvContext"; -import { subdomainSchema } from "@server/schemas/subdomainSchema"; +import { subdomainSchema } from "@server/lib/schemas"; import { CaretSortIcon, CheckIcon } from "@radix-ui/react-icons"; -import { pullEnv } from "@app/lib/pullEnv"; import { RadioGroup, RadioGroupItem } from "@app/components/ui/radio-group"; import { Label } from "@app/components/ui/label"; diff --git a/src/app/[orgId]/settings/resources/[resourceId]/rules/page.tsx b/src/app/[orgId]/settings/resources/[resourceId]/rules/page.tsx index 5ee16461..7fc16b81 100644 --- a/src/app/[orgId]/settings/resources/[resourceId]/rules/page.tsx +++ b/src/app/[orgId]/settings/resources/[resourceId]/rules/page.tsx @@ -57,7 +57,7 @@ import { import { ListResourceRulesResponse } from "@server/routers/resource/listResourceRules"; import { SwitchInput } from "@app/components/SwitchInput"; import { Alert, AlertDescription, AlertTitle } from "@app/components/ui/alert"; -import { Check, InfoIcon, X } from "lucide-react"; +import { ArrowUpDown, Check, InfoIcon, X } from "lucide-react"; import { InfoSection, InfoSections, @@ -65,12 +65,19 @@ import { } from "@app/components/InfoSection"; import { Separator } from "@app/components/ui/separator"; import { InfoPopup } from "@app/components/ui/info-popup"; +import { + isValidCIDR, + isValidIP, + isValidUrlGlobPattern +} from "@server/lib/validators"; +import { Switch } from "@app/components/ui/switch"; // Schema for rule validation const addRuleSchema = z.object({ action: z.string(), match: z.string(), - value: z.string() + value: z.string(), + priority: z.coerce.number().int().optional() }); type LocalRule = ArrayElement & { @@ -181,11 +188,23 @@ export default function ResourceRules(props: { return; } + // find the highest priority and add one + let priority = data.priority; + if (priority === undefined) { + priority = rules.reduce( + (acc, rule) => (rule.priority > acc ? rule.priority : acc), + 0 + ); + priority++; + } + const newRule: LocalRule = { ...data, ruleId: new Date().getTime(), new: true, - resourceId: resource.resourceId + resourceId: resource.resourceId, + priority, + enabled: true }; setRules([...rules, newRule]); @@ -255,7 +274,9 @@ export default function ResourceRules(props: { const data = { action: rule.action, match: rule.match, - value: rule.value + value: rule.value, + priority: rule.priority, + enabled: rule.enabled }; if (rule.match === "CIDR" && !isValidCIDR(rule.value)) { @@ -289,6 +310,28 @@ export default function ResourceRules(props: { return; } + if (rule.priority === undefined) { + toast({ + variant: "destructive", + title: "Invalid Priority", + description: "Please enter a valid priority" + }); + setLoading(false); + return; + } + + // make sure no duplicate priorities + const priorities = rules.map((r) => r.priority); + if (priorities.length !== new Set(priorities).size) { + toast({ + variant: "destructive", + title: "Duplicate Priorities", + description: "Please enter unique priorities" + }); + setLoading(false); + return; + } + if (rule.new) { const res = await api.put( `/resource/${params.resourceId}/rule`, @@ -342,6 +385,50 @@ export default function ResourceRules(props: { } const columns: ColumnDef[] = [ + { + accessorKey: "priority", + header: ({ column }) => { + return ( + + ); + }, + cell: ({ row }) => ( + { + const parsed = z.coerce + .number() + .int() + .optional() + .safeParse(e.target.value); + + if (!parsed.data) { + toast({ + variant: "destructive", + title: "Invalid IP", + description: "Please enter a valid priority" + }); + setLoading(false); + return; + } + + updateRule(row.original.ruleId, { + priority: parsed.data + }); + }} + /> + ) + }, { accessorKey: "action", header: "Action", @@ -400,6 +487,18 @@ export default function ResourceRules(props: { /> ) }, + { + accessorKey: "enabled", + header: "Enabled", + cell: ({ row }) => ( + + updateRule(row.original.ruleId, { enabled: val }) + } + /> + ) + }, { id: "actions", cell: ({ row }) => ( @@ -434,14 +533,14 @@ export default function ResourceRules(props: { About Rules -

- Rules allow you to control access to your resource based - on a set of criteria. You can create rules to allow or - deny access based on IP address or URL path. Deny rules - take precedence over allow rules. If a request matches - both an allow and a deny rule, the deny rule will be - applied. -

+
+

+ Rules allow you to control access to your resource + based on a set of criteria. You can create rules to + allow or deny access based on IP address or URL + path. +

+
Actions @@ -661,6 +760,9 @@ export default function ResourceRules(props: { +

+ Rules are evaluated by priority in ascending order. +

Tunneled Mesh Reverse Proxy Server with Access Control

+
-Pangolin is a self-hosted tunneled reverse proxy management server with identity and access control, designed to securely expose private resources on distributed networks. With Pangolin, you retain full control over your infrastructure while providing a user-friendly and feature-rich solution for managing proxies, authentication, and access, while simplifying complex network setups. +_Your own self-hosted zero trust tunnel._ + +
+ +Pangolin is a self-hosted tunneled reverse proxy server with identity and access control, designed to securely expose private resources on distributed networks. Acting as a central hub, it connects isolated networks — even those behind restrictive firewalls — through encrypted tunnels, enabling easy access to remote services without opening ports. Preview @@ -38,12 +43,13 @@ _Sites page of Pangolin dashboard (dark mode) showing multiple tunnels connected - Built-in support for any WireGuard client. - Automated **SSL certificates** (https) via [LetsEncrypt](https://letsencrypt.org/). - Support for HTTP/HTTPS and **raw TCP/UDP services**. +- Load balancing. ### Identity & Access Management - Centralized authentication system using platform SSO. **Users will only have to manage one login.** -- **Rules based access control for resources.** -- Totp with backup codes for two-factor authentication. +- **Define access control rules for IPs, IP ranges, and URL paths per resource.** +- TOTP with backup codes for two-factor authentication. - Create organizations, each with multiple sites, users, and roles. - **Role-based access control** to manage resource access permissions. - Additional authentication options include: @@ -61,9 +67,9 @@ _Sites page of Pangolin dashboard (dark mode) showing multiple tunnels connected ### Easy Deployment +- Run on any cloud provider or on-premises. - Docker Compose based setup for simplified deployment. - Future-proof installation script for streamlined setup and feature additions. -- Run on any VPS. - Use your preferred WireGuard client to connect, or use Newt, our custom user space client for the best experience. ### Modular Design @@ -126,17 +132,18 @@ _Sites page of Pangolin dashboard (dark mode) showing multiple tunnels connected ## Similar Projects and Inspirations -Pangolin was inspired by several existing projects and concepts: - -- **Cloudflare Tunnels**: +**Cloudflare Tunnels**: A similar approach to proxying private resources securely, but Pangolin is a self-hosted alternative, giving you full control over your infrastructure. -- **Authentik and Authelia**: +**Authentik and Authelia**: These projects inspired Pangolin’s centralized authentication system for proxies, enabling robust user and role management. ## Project Development / Roadmap -Pangolin is under active development, and we are continuously adding new features and improvements. View the [project board](https://github.com/orgs/fosrl/projects/1) for more detailed info. +> [!NOTE] +> Pangolin is under heavy development. The roadmap is subject to change as we fix bugs, add new features, and make improvements. + +View the [project board](https://github.com/orgs/fosrl/projects/1) for more detailed info. ## Licensing diff --git a/public/logo/word_mark.png b/public/logo/word_mark.png index 14da644ba8acb0ee4e52926e2ecf32953f3d8a36..d75a047c1649680eae1802f1fea640d658177955 100644 GIT binary patch literal 63034 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 By}JMa literal 64948 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 fJN~zu { export function RolesDataTable({ addRole, columns, - data, + data }: DataTableProps) { const [sorting, setSorting] = useState([]); const [columnFilters, setColumnFilters] = useState([]); @@ -50,13 +50,15 @@ export function RolesDataTable({ onColumnFiltersChange: setColumnFilters, getFilteredRowModel: getFilteredRowModel(), initialState: { - sorting, - columnFilters, pagination: { pageSize: 20, - pageIndex: 0, - }, + pageIndex: 0 + } }, + state: { + sorting, + columnFilters + } }); return ( @@ -102,7 +104,7 @@ export function RolesDataTable({ : flexRender( header.column.columnDef .header, - header.getContext(), + header.getContext() )} ); @@ -123,7 +125,7 @@ export function RolesDataTable({ {flexRender( cell.column.columnDef.cell, - cell.getContext(), + cell.getContext() )} ))} diff --git a/src/app/[orgId]/settings/access/users/UsersDataTable.tsx b/src/app/[orgId]/settings/access/users/UsersDataTable.tsx index 8a790a2e..87cebf7d 100644 --- a/src/app/[orgId]/settings/access/users/UsersDataTable.tsx +++ b/src/app/[orgId]/settings/access/users/UsersDataTable.tsx @@ -9,7 +9,7 @@ import { SortingState, getSortedRowModel, ColumnFiltersState, - getFilteredRowModel, + getFilteredRowModel } from "@tanstack/react-table"; import { Table, @@ -18,7 +18,7 @@ import { TableContainer, TableHead, TableHeader, - TableRow, + TableRow } from "@/components/ui/table"; import { Button } from "@app/components/ui/button"; import { useState } from "react"; @@ -35,7 +35,7 @@ interface DataTableProps { export function UsersDataTable({ inviteUser, columns, - data, + data }: DataTableProps) { const [sorting, setSorting] = useState([]); const [columnFilters, setColumnFilters] = useState([]); @@ -50,13 +50,15 @@ export function UsersDataTable({ onColumnFiltersChange: setColumnFilters, getFilteredRowModel: getFilteredRowModel(), initialState: { - sorting, - columnFilters, pagination: { pageSize: 20, - pageIndex: 0, - }, + pageIndex: 0 + } }, + state: { + sorting, + columnFilters + } }); return ( @@ -102,7 +104,7 @@ export function UsersDataTable({ : flexRender( header.column.columnDef .header, - header.getContext(), + header.getContext() )} ); @@ -123,7 +125,7 @@ export function UsersDataTable({ {flexRender( cell.column.columnDef.cell, - cell.getContext(), + cell.getContext() )} ))} diff --git a/src/app/[orgId]/settings/resources/ResourcesDataTable.tsx b/src/app/[orgId]/settings/resources/ResourcesDataTable.tsx index 8991a7e4..ffb6bf40 100644 --- a/src/app/[orgId]/settings/resources/ResourcesDataTable.tsx +++ b/src/app/[orgId]/settings/resources/ResourcesDataTable.tsx @@ -9,7 +9,7 @@ import { SortingState, getSortedRowModel, ColumnFiltersState, - getFilteredRowModel, + getFilteredRowModel } from "@tanstack/react-table"; import { @@ -19,7 +19,7 @@ import { TableContainer, TableHead, TableHeader, - TableRow, + TableRow } from "@/components/ui/table"; import { Button } from "@app/components/ui/button"; import { useState } from "react"; @@ -36,7 +36,7 @@ interface ResourcesDataTableProps { export function ResourcesDataTable({ addResource, columns, - data, + data }: ResourcesDataTableProps) { const [sorting, setSorting] = useState([]); const [columnFilters, setColumnFilters] = useState([]); @@ -51,13 +51,15 @@ export function ResourcesDataTable({ onColumnFiltersChange: setColumnFilters, getFilteredRowModel: getFilteredRowModel(), initialState: { - sorting, - columnFilters, pagination: { pageSize: 20, - pageIndex: 0, - }, + pageIndex: 0 + } }, + state: { + sorting, + columnFilters + } }); return ( @@ -103,7 +105,7 @@ export function ResourcesDataTable({ : flexRender( header.column.columnDef .header, - header.getContext(), + header.getContext() )} ); @@ -124,7 +126,7 @@ export function ResourcesDataTable({ {flexRender( cell.column.columnDef.cell, - cell.getContext(), + cell.getContext() )} ))} diff --git a/src/app/[orgId]/settings/share-links/ShareLinksDataTable.tsx b/src/app/[orgId]/settings/share-links/ShareLinksDataTable.tsx index 5e973273..612a1790 100644 --- a/src/app/[orgId]/settings/share-links/ShareLinksDataTable.tsx +++ b/src/app/[orgId]/settings/share-links/ShareLinksDataTable.tsx @@ -51,12 +51,14 @@ export function ShareLinksDataTable({ onColumnFiltersChange: setColumnFilters, getFilteredRowModel: getFilteredRowModel(), initialState: { - sorting, - columnFilters, pagination: { pageSize: 20, pageIndex: 0 } + }, + state: { + sorting, + columnFilters } }); diff --git a/src/app/[orgId]/settings/sites/SitesDataTable.tsx b/src/app/[orgId]/settings/sites/SitesDataTable.tsx index 33c54313..a30bab12 100644 --- a/src/app/[orgId]/settings/sites/SitesDataTable.tsx +++ b/src/app/[orgId]/settings/sites/SitesDataTable.tsx @@ -9,7 +9,7 @@ import { SortingState, getSortedRowModel, ColumnFiltersState, - getFilteredRowModel, + getFilteredRowModel } from "@tanstack/react-table"; import { @@ -19,7 +19,7 @@ import { TableContainer, TableHead, TableHeader, - TableRow, + TableRow } from "@/components/ui/table"; import { Button } from "@app/components/ui/button"; import { useState } from "react"; @@ -36,7 +36,7 @@ interface DataTableProps { export function SitesDataTable({ addSite, columns, - data, + data }: DataTableProps) { const [sorting, setSorting] = useState([]); const [columnFilters, setColumnFilters] = useState([]); @@ -51,13 +51,15 @@ export function SitesDataTable({ onColumnFiltersChange: setColumnFilters, getFilteredRowModel: getFilteredRowModel(), initialState: { - sorting, - columnFilters, pagination: { - pageSize: 100, - pageIndex: 0, - }, + pageSize: 20, + pageIndex: 0 + } }, + state: { + sorting, + columnFilters + } }); return ( @@ -103,7 +105,7 @@ export function SitesDataTable({ : flexRender( header.column.columnDef .header, - header.getContext(), + header.getContext() )} ); @@ -124,7 +126,7 @@ export function SitesDataTable({ {flexRender( cell.column.columnDef.cell, - cell.getContext(), + cell.getContext() )} ))} From f61d442989fc758f4b5cfbbc5ee07e02962290d7 Mon Sep 17 00:00:00 2001 From: Owen Date: Fri, 14 Feb 2025 09:51:17 -0500 Subject: [PATCH 10/24] Allow . in path; resolves #199 --- server/lib/validators.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/lib/validators.ts b/server/lib/validators.ts index 0aa590e6..f4be6277 100644 --- a/server/lib/validators.ts +++ b/server/lib/validators.ts @@ -35,7 +35,7 @@ export function isValidUrlGlobPattern(pattern: string): boolean { } // Check for invalid characters - if (!/^[a-zA-Z0-9_*-]*$/.test(segment)) { + if (!/^[a-zA-Z0-9_.*-]*$/.test(segment)) { return false; } } From 4c1366ef91ce101b0c5aaff33d0b44f50942b642 Mon Sep 17 00:00:00 2001 From: Milo Schwartz Date: Fri, 14 Feb 2025 12:27:03 -0500 Subject: [PATCH 11/24] force router refresh on save closes #198 --- .../settings/resources/[resourceId]/authentication/page.tsx | 6 ++++++ .../settings/resources/[resourceId]/connectivity/page.tsx | 4 ++++ .../[orgId]/settings/resources/[resourceId]/rules/page.tsx | 6 +++++- 3 files changed, 15 insertions(+), 1 deletion(-) diff --git a/src/app/[orgId]/settings/resources/[resourceId]/authentication/page.tsx b/src/app/[orgId]/settings/resources/[resourceId]/authentication/page.tsx index 0e3dc7bc..df5376f9 100644 --- a/src/app/[orgId]/settings/resources/[resourceId]/authentication/page.tsx +++ b/src/app/[orgId]/settings/resources/[resourceId]/authentication/page.tsx @@ -49,6 +49,7 @@ import { } from "@app/components/Settings"; import { SwitchInput } from "@app/components/SwitchInput"; import { InfoPopup } from "@app/components/ui/info-popup"; +import { useRouter } from "next/navigation"; const UsersRolesFormSchema = z.object({ roles: z.array( @@ -82,6 +83,7 @@ export default function ResourceAuthenticationPage() { const { env } = useEnvContext(); const api = createApiClient({ env }); + const router = useRouter(); const [pageLoading, setPageLoading] = useState(true); @@ -236,6 +238,7 @@ export default function ResourceAuthenticationPage() { title: "Saved successfully", description: "Whitelist settings have been saved" }); + router.refresh(); } catch (e) { console.error(e); toast({ @@ -283,6 +286,7 @@ export default function ResourceAuthenticationPage() { title: "Saved successfully", description: "Authentication settings have been saved" }); + router.refresh(); } catch (e) { console.error(e); toast({ @@ -314,6 +318,7 @@ export default function ResourceAuthenticationPage() { updateAuthInfo({ password: false }); + router.refresh(); }) .catch((e) => { toast({ @@ -344,6 +349,7 @@ export default function ResourceAuthenticationPage() { updateAuthInfo({ pincode: false }); + router.refresh(); }) .catch((e) => { toast({ diff --git a/src/app/[orgId]/settings/resources/[resourceId]/connectivity/page.tsx b/src/app/[orgId]/settings/resources/[resourceId]/connectivity/page.tsx index dfd2f66c..ea67e23e 100644 --- a/src/app/[orgId]/settings/resources/[resourceId]/connectivity/page.tsx +++ b/src/app/[orgId]/settings/resources/[resourceId]/connectivity/page.tsx @@ -64,6 +64,7 @@ import { import { SwitchInput } from "@app/components/SwitchInput"; import { useSiteContext } from "@app/hooks/useSiteContext"; import { InfoPopup } from "@app/components/ui/info-popup"; +import { useRouter } from "next/navigation"; // Regular expressions for validation const DOMAIN_REGEX = @@ -125,6 +126,7 @@ export default function ReverseProxyTargets(props: { const [loading, setLoading] = useState(false); const [pageLoading, setPageLoading] = useState(true); + const router = useRouter(); const addTargetForm = useForm({ resolver: zodResolver(addTargetSchema), @@ -299,6 +301,7 @@ export default function ReverseProxyTargets(props: { }); setTargetsToRemove([]); + router.refresh(); } catch (err) { console.error(err); toast({ @@ -339,6 +342,7 @@ export default function ReverseProxyTargets(props: { title: "SSL Configuration", description: "SSL configuration updated successfully" }); + router.refresh(); } } diff --git a/src/app/[orgId]/settings/resources/[resourceId]/rules/page.tsx b/src/app/[orgId]/settings/resources/[resourceId]/rules/page.tsx index 7fc16b81..1b9eb6ca 100644 --- a/src/app/[orgId]/settings/resources/[resourceId]/rules/page.tsx +++ b/src/app/[orgId]/settings/resources/[resourceId]/rules/page.tsx @@ -71,6 +71,7 @@ import { isValidUrlGlobPattern } from "@server/lib/validators"; import { Switch } from "@app/components/ui/switch"; +import { useRouter } from "next/navigation"; // Schema for rule validation const addRuleSchema = z.object({ @@ -107,6 +108,7 @@ export default function ResourceRules(props: { const [loading, setLoading] = useState(false); const [pageLoading, setPageLoading] = useState(true); const [rulesEnabled, setRulesEnabled] = useState(resource.applyRules); + const router = useRouter(); const addRuleForm = useForm({ resolver: zodResolver(addRuleSchema), @@ -253,6 +255,7 @@ export default function ResourceRules(props: { title: "Enable Rules", description: "Rule evaluation has been updated" }); + router.refresh(); } } @@ -370,6 +373,7 @@ export default function ResourceRules(props: { }); setRulesToRemove([]); + router.refresh(); } catch (err) { console.error(err); toast({ @@ -590,7 +594,7 @@ export default function ResourceRules(props: { { await saveApplyRules(val); }} From 40922fedb8b3ad87bd1fb10aa89a85a45907ea8e Mon Sep 17 00:00:00 2001 From: Owen Date: Fri, 14 Feb 2025 12:32:10 -0500 Subject: [PATCH 12/24] Support v6 --- server/lib/ip.ts | 145 ++++++++++++++++++++++++++++++++++++++++------- 1 file changed, 124 insertions(+), 21 deletions(-) diff --git a/server/lib/ip.ts b/server/lib/ip.ts index 88c64acc..62cf1d2d 100644 --- a/server/lib/ip.ts +++ b/server/lib/ip.ts @@ -3,24 +3,98 @@ interface IPRange { end: bigint; } +type IPVersion = 4 | 6; + /** - * Converts IP address string to BigInt for numerical operations + * Detects IP version from address string + */ +function detectIpVersion(ip: string): IPVersion { + return ip.includes(':') ? 6 : 4; +} + +/** + * Converts IPv4 or IPv6 address string to BigInt for numerical operations */ function ipToBigInt(ip: string): bigint { - return ip.split('.') - .reduce((acc, octet) => BigInt.asUintN(64, (acc << BigInt(8)) + BigInt(parseInt(octet))), BigInt(0)); + const version = detectIpVersion(ip); + + if (version === 4) { + return ip.split('.') + .reduce((acc, octet) => { + const num = parseInt(octet); + if (isNaN(num) || num < 0 || num > 255) { + throw new Error(`Invalid IPv4 octet: ${octet}`); + } + return BigInt.asUintN(64, (acc << BigInt(8)) + BigInt(num)); + }, BigInt(0)); + } else { + // Handle IPv6 + // Expand :: notation + let fullAddress = ip; + if (ip.includes('::')) { + const parts = ip.split('::'); + if (parts.length > 2) throw new Error('Invalid IPv6 address: multiple :: found'); + const missing = 8 - (parts[0].split(':').length + parts[1].split(':').length); + const padding = Array(missing).fill('0').join(':'); + fullAddress = `${parts[0]}:${padding}:${parts[1]}`; + } + + return fullAddress.split(':') + .reduce((acc, hextet) => { + const num = parseInt(hextet || '0', 16); + if (isNaN(num) || num < 0 || num > 65535) { + throw new Error(`Invalid IPv6 hextet: ${hextet}`); + } + return BigInt.asUintN(128, (acc << BigInt(16)) + BigInt(num)); + }, BigInt(0)); + } } /** * Converts BigInt to IP address string */ -function bigIntToIp(num: bigint): string { - const octets: number[] = []; - for (let i = 0; i < 4; i++) { - octets.unshift(Number(num & BigInt(255))); - num = num >> BigInt(8); +function bigIntToIp(num: bigint, version: IPVersion): string { + if (version === 4) { + const octets: number[] = []; + for (let i = 0; i < 4; i++) { + octets.unshift(Number(num & BigInt(255))); + num = num >> BigInt(8); + } + return octets.join('.'); + } else { + const hextets: string[] = []; + for (let i = 0; i < 8; i++) { + hextets.unshift(Number(num & BigInt(65535)).toString(16).padStart(4, '0')); + num = num >> BigInt(16); + } + // Compress zero sequences + let maxZeroStart = -1; + let maxZeroLength = 0; + let currentZeroStart = -1; + let currentZeroLength = 0; + + for (let i = 0; i < hextets.length; i++) { + if (hextets[i] === '0000') { + if (currentZeroStart === -1) currentZeroStart = i; + currentZeroLength++; + if (currentZeroLength > maxZeroLength) { + maxZeroLength = currentZeroLength; + maxZeroStart = currentZeroStart; + } + } else { + currentZeroStart = -1; + currentZeroLength = 0; + } + } + + if (maxZeroLength > 1) { + hextets.splice(maxZeroStart, maxZeroLength, ''); + if (maxZeroStart === 0) hextets.unshift(''); + if (maxZeroStart + maxZeroLength === 8) hextets.push(''); + } + + return hextets.map(h => h === '0000' ? '0' : h.replace(/^0+/, '')).join(':'); } - return octets.join('.'); } /** @@ -28,33 +102,56 @@ function bigIntToIp(num: bigint): string { */ function cidrToRange(cidr: string): IPRange { const [ip, prefix] = cidr.split('/'); + const version = detectIpVersion(ip); const prefixBits = parseInt(prefix); const ipBigInt = ipToBigInt(ip); - const mask = BigInt.asUintN(64, (BigInt(1) << BigInt(32 - prefixBits)) - BigInt(1)); + + // Validate prefix length + const maxPrefix = version === 4 ? 32 : 128; + if (prefixBits < 0 || prefixBits > maxPrefix) { + throw new Error(`Invalid prefix length for IPv${version}: ${prefix}`); + } + + const shiftBits = BigInt(maxPrefix - prefixBits); + const mask = BigInt.asUintN(version === 4 ? 64 : 128, (BigInt(1) << shiftBits) - BigInt(1)); const start = ipBigInt & ~mask; const end = start | mask; + return { start, end }; } /** * Finds the next available CIDR block given existing allocations * @param existingCidrs Array of existing CIDR blocks - * @param blockSize Desired prefix length for the new block (e.g., 24 for /24) - * @param startCidr Optional CIDR to start searching from (default: "0.0.0.0/0") + * @param blockSize Desired prefix length for the new block + * @param startCidr Optional CIDR to start searching from * @returns Next available CIDR block or null if none found */ export function findNextAvailableCidr( existingCidrs: string[], blockSize: number, - startCidr: string = "0.0.0.0/0" + startCidr?: string ): string | null { + if (existingCidrs.length === 0) return null; + + // Determine IP version from first CIDR + const version = detectIpVersion(existingCidrs[0].split('/')[0]); + // Use appropriate default startCidr if none provided + startCidr = startCidr || (version === 4 ? "0.0.0.0/0" : "::/0"); + + // Ensure all CIDRs are same version + if (existingCidrs.some(cidr => detectIpVersion(cidr.split('/')[0]) !== version)) { + throw new Error('All CIDRs must be of the same IP version'); + } + // Convert existing CIDRs to ranges and sort them const existingRanges = existingCidrs .map(cidr => cidrToRange(cidr)) .sort((a, b) => (a.start < b.start ? -1 : 1)); // Calculate block size - const blockSizeBigInt = BigInt(1) << BigInt(32 - blockSize); + const maxPrefix = version === 4 ? 32 : 128; + const blockSizeBigInt = BigInt(1) << BigInt(maxPrefix - blockSize); // Start from the beginning of the given CIDR let current = cidrToRange(startCidr).start; @@ -63,7 +160,6 @@ export function findNextAvailableCidr( // Iterate through existing ranges for (let i = 0; i <= existingRanges.length; i++) { const nextRange = existingRanges[i]; - // Align current to block size const alignedCurrent = current + ((blockSizeBigInt - (current % blockSizeBigInt)) % blockSizeBigInt); @@ -74,7 +170,7 @@ export function findNextAvailableCidr( // If we're at the end of existing ranges or found a gap if (!nextRange || alignedCurrent + blockSizeBigInt - BigInt(1) < nextRange.start) { - return `${bigIntToIp(alignedCurrent)}/${blockSize}`; + return `${bigIntToIp(alignedCurrent, version)}/${blockSize}`; } // Move current pointer to after the current range @@ -85,12 +181,19 @@ export function findNextAvailableCidr( } /** -* Checks if a given IP address is within a CIDR range -* @param ip IP address to check -* @param cidr CIDR range to check against -* @returns boolean indicating if IP is within the CIDR range -*/ + * Checks if a given IP address is within a CIDR range + * @param ip IP address to check + * @param cidr CIDR range to check against + * @returns boolean indicating if IP is within the CIDR range + */ export function isIpInCidr(ip: string, cidr: string): boolean { + const ipVersion = detectIpVersion(ip); + const cidrVersion = detectIpVersion(cidr.split('/')[0]); + + if (ipVersion !== cidrVersion) { + throw new Error('IP address and CIDR must be of the same version'); + } + const ipBigInt = ipToBigInt(ip); const range = cidrToRange(cidr); return ipBigInt >= range.start && ipBigInt <= range.end; From 7797c6c770b5e50d10f6eaf240a904a4ec1d58a7 Mon Sep 17 00:00:00 2001 From: Owen Date: Fri, 14 Feb 2025 12:38:28 -0500 Subject: [PATCH 13/24] Allow the chars from RFC 3986 --- server/lib/validators.ts | 37 ++++++++++++++++++++++++++++--------- 1 file changed, 28 insertions(+), 9 deletions(-) diff --git a/server/lib/validators.ts b/server/lib/validators.ts index f4be6277..675c0809 100644 --- a/server/lib/validators.ts +++ b/server/lib/validators.ts @@ -11,7 +11,7 @@ export function isValidIP(ip: string): boolean { export function isValidUrlGlobPattern(pattern: string): boolean { // Remove leading slash if present pattern = pattern.startsWith("/") ? pattern.slice(1) : pattern; - + // Empty string is not valid if (!pattern) { return false; @@ -19,12 +19,12 @@ export function isValidUrlGlobPattern(pattern: string): boolean { // Split path into segments const segments = pattern.split("/"); - + // Check each segment for (let i = 0; i < segments.length; i++) { const segment = segments[i]; - - // Empty segments are not allowed (double slashes) + + // Empty segments are not allowed (double slashes), except at the end if (!segment && i !== segments.length - 1) { return false; } @@ -34,11 +34,30 @@ export function isValidUrlGlobPattern(pattern: string): boolean { return false; } - // Check for invalid characters - if (!/^[a-zA-Z0-9_.*-]*$/.test(segment)) { - return false; + // Check each character in the segment + for (let j = 0; j < segment.length; j++) { + const char = segment[j]; + + // Check for percent-encoded sequences + if (char === "%" && j + 2 < segment.length) { + const hex1 = segment[j + 1]; + const hex2 = segment[j + 2]; + if (!/^[0-9A-Fa-f]$/.test(hex1) || !/^[0-9A-Fa-f]$/.test(hex2)) { + return false; + } + j += 2; // Skip the next two characters + continue; + } + + // Allow: + // - unreserved (A-Z a-z 0-9 - . _ ~) + // - sub-delims (! $ & ' ( ) * + , ; =) + // - @ : for compatibility with some systems + if (!/^[A-Za-z0-9\-._~!$&'()*+,;=@:]$/.test(char)) { + return false; + } } } - + return true; -} +} \ No newline at end of file From 8dd30c88abeaaa31a5a2fe4bb66079dc57217995 Mon Sep 17 00:00:00 2001 From: Milo Schwartz Date: Fri, 14 Feb 2025 13:12:29 -0500 Subject: [PATCH 14/24] fix reset password sql error --- server/auth/sessions/app.ts | 32 ++++++++++++++++++++++++---- server/routers/auth/resetPassword.ts | 22 +++++++++++++------ 2 files changed, 43 insertions(+), 11 deletions(-) diff --git a/server/auth/sessions/app.ts b/server/auth/sessions/app.ts index 18ea072b..62850453 100644 --- a/server/auth/sessions/app.ts +++ b/server/auth/sessions/app.ts @@ -11,7 +11,7 @@ import { users } from "@server/db/schema"; import db from "@server/db"; -import { eq } from "drizzle-orm"; +import { eq, inArray } from "drizzle-orm"; import config from "@server/lib/config"; import type { RandomReader } from "@oslojs/crypto/random"; import { generateRandomString } from "@oslojs/crypto/random"; @@ -95,12 +95,36 @@ export async function validateSessionToken( } export async function invalidateSession(sessionId: string): Promise { - await db.delete(resourceSessions).where(eq(resourceSessions.userSessionId, sessionId)); - await db.delete(sessions).where(eq(sessions.sessionId, sessionId)); + try { + await db.transaction(async (trx) => { + await trx + .delete(resourceSessions) + .where(eq(resourceSessions.userSessionId, sessionId)); + await trx.delete(sessions).where(eq(sessions.sessionId, sessionId)); + }); + } catch (e) { + logger.error("Failed to invalidate session", e); + } } export async function invalidateAllSessions(userId: string): Promise { - await db.delete(sessions).where(eq(sessions.userId, userId)); + try { + await db.transaction(async (trx) => { + const userSessions = await trx + .select() + .from(sessions) + .where(eq(sessions.userId, userId)); + await trx.delete(resourceSessions).where( + inArray( + resourceSessions.userSessionId, + userSessions.map((s) => s.sessionId) + ) + ); + await trx.delete(sessions).where(eq(sessions.userId, userId)); + }); + } catch (e) { + logger.error("Failed to all invalidate user sessions", e); + } } export function serializeSessionCookie( diff --git a/server/routers/auth/resetPassword.ts b/server/routers/auth/resetPassword.ts index 97b283c6..ac1b6600 100644 --- a/server/routers/auth/resetPassword.ts +++ b/server/routers/auth/resetPassword.ts @@ -149,8 +149,6 @@ export async function resetPassword( const passwordHash = await hashPassword(newPassword); - await invalidateAllSessions(resetRequest[0].userId); - await db.transaction(async (trx) => { await trx .update(users) @@ -162,11 +160,21 @@ export async function resetPassword( .where(eq(passwordResetTokens.email, email)); }); - await sendEmail(ConfirmPasswordReset({ email }), { - from: config.getNoReplyEmail(), - to: email, - subject: "Password Reset Confirmation" - }); + try { + await invalidateAllSessions(resetRequest[0].userId); + } catch (e) { + logger.error("Failed to invalidate user sessions", e); + } + + try { + await sendEmail(ConfirmPasswordReset({ email }), { + from: config.getNoReplyEmail(), + to: email, + subject: "Password Reset Confirmation" + }); + } catch (e) { + logger.error("Failed to send password reset confirmation email", e); + } return response(res, { data: null, From 2ff6d1d117c408e9d0e747eed911deb2da34edbb Mon Sep 17 00:00:00 2001 From: Milo Schwartz Date: Fri, 14 Feb 2025 13:27:34 -0500 Subject: [PATCH 15/24] allow any string as target --- server/routers/target/createTarget.ts | 56 +++++++++---------- server/routers/target/updateTarget.ts | 56 +++++++++---------- .../[resourceId]/connectivity/page.tsx | 42 +++++++------- 3 files changed, 77 insertions(+), 77 deletions(-) diff --git a/server/routers/target/createTarget.ts b/server/routers/target/createTarget.ts index b1080d87..11f3de69 100644 --- a/server/routers/target/createTarget.ts +++ b/server/routers/target/createTarget.ts @@ -13,33 +13,33 @@ import { addTargets } from "../newt/targets"; import { eq } from "drizzle-orm"; import { pickPort } from "./helpers"; -// Regular expressions for validation -const DOMAIN_REGEX = - /^[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$/; -const IPV4_REGEX = - /^(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$/; -const IPV6_REGEX = /^(?:[A-F0-9]{1,4}:){7}[A-F0-9]{1,4}$/i; - -// Schema for domain names and IP addresses -const domainSchema = z - .string() - .min(1, "Domain cannot be empty") - .max(255, "Domain name too long") - .refine( - (value) => { - // Check if it's a valid IP address (v4 or v6) - if (IPV4_REGEX.test(value) || IPV6_REGEX.test(value)) { - return true; - } - - // Check if it's a valid domain name - return DOMAIN_REGEX.test(value); - }, - { - message: "Invalid domain name or IP address format", - path: ["domain"] - } - ); +// // Regular expressions for validation +// const DOMAIN_REGEX = +// /^[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$/; +// const IPV4_REGEX = +// /^(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$/; +// const IPV6_REGEX = /^(?:[A-F0-9]{1,4}:){7}[A-F0-9]{1,4}$/i; +// +// // Schema for domain names and IP addresses +// const domainSchema = z +// .string() +// .min(1, "Domain cannot be empty") +// .max(255, "Domain name too long") +// .refine( +// (value) => { +// // Check if it's a valid IP address (v4 or v6) +// if (IPV4_REGEX.test(value) || IPV6_REGEX.test(value)) { +// return true; +// } +// +// // Check if it's a valid domain name +// return DOMAIN_REGEX.test(value); +// }, +// { +// message: "Invalid domain name or IP address format", +// path: ["domain"] +// } +// ); const createTargetParamsSchema = z .object({ @@ -52,7 +52,7 @@ const createTargetParamsSchema = z const createTargetSchema = z .object({ - ip: domainSchema, + ip: z.string().min(1).max(255), method: z.string().optional().nullable(), port: z.number().int().min(1).max(65535), enabled: z.boolean().default(true) diff --git a/server/routers/target/updateTarget.ts b/server/routers/target/updateTarget.ts index 2ae6222d..4dbb2f45 100644 --- a/server/routers/target/updateTarget.ts +++ b/server/routers/target/updateTarget.ts @@ -12,33 +12,33 @@ import { addPeer } from "../gerbil/peers"; import { addTargets } from "../newt/targets"; import { pickPort } from "./helpers"; -// Regular expressions for validation -const DOMAIN_REGEX = - /^[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$/; -const IPV4_REGEX = - /^(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$/; -const IPV6_REGEX = /^(?:[A-F0-9]{1,4}:){7}[A-F0-9]{1,4}$/i; - -// Schema for domain names and IP addresses -const domainSchema = z - .string() - .min(1, "Domain cannot be empty") - .max(255, "Domain name too long") - .refine( - (value) => { - // Check if it's a valid IP address (v4 or v6) - if (IPV4_REGEX.test(value) || IPV6_REGEX.test(value)) { - return true; - } - - // Check if it's a valid domain name - return DOMAIN_REGEX.test(value); - }, - { - message: "Invalid domain name or IP address format", - path: ["domain"] - } - ); +// // Regular expressions for validation +// const DOMAIN_REGEX = +// /^[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$/; +// const IPV4_REGEX = +// /^(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$/; +// const IPV6_REGEX = /^(?:[A-F0-9]{1,4}:){7}[A-F0-9]{1,4}$/i; +// +// // Schema for domain names and IP addresses +// const domainSchema = z +// .string() +// .min(1, "Domain cannot be empty") +// .max(255, "Domain name too long") +// .refine( +// (value) => { +// // Check if it's a valid IP address (v4 or v6) +// if (IPV4_REGEX.test(value) || IPV6_REGEX.test(value)) { +// return true; +// } +// +// // Check if it's a valid domain name +// return DOMAIN_REGEX.test(value); +// }, +// { +// message: "Invalid domain name or IP address format", +// path: ["domain"] +// } +// ); const updateTargetParamsSchema = z .object({ @@ -48,7 +48,7 @@ const updateTargetParamsSchema = z const updateTargetBodySchema = z .object({ - ip: domainSchema.optional(), + ip: z.string().min(1).max(255), method: z.string().min(1).max(10).optional().nullable(), port: z.number().int().min(1).max(65535).optional(), enabled: z.boolean().optional() diff --git a/src/app/[orgId]/settings/resources/[resourceId]/connectivity/page.tsx b/src/app/[orgId]/settings/resources/[resourceId]/connectivity/page.tsx index ea67e23e..d912b505 100644 --- a/src/app/[orgId]/settings/resources/[resourceId]/connectivity/page.tsx +++ b/src/app/[orgId]/settings/resources/[resourceId]/connectivity/page.tsx @@ -73,29 +73,29 @@ const IPV4_REGEX = /^(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$/; const IPV6_REGEX = /^(?:[A-F0-9]{1,4}:){7}[A-F0-9]{1,4}$/i; -// Schema for domain names and IP addresses -const domainSchema = z - .string() - .min(1, "Domain cannot be empty") - .max(255, "Domain name too long") - .refine( - (value) => { - // Check if it's a valid IP address (v4 or v6) - if (IPV4_REGEX.test(value) || IPV6_REGEX.test(value)) { - return true; - } - - // Check if it's a valid domain name - return DOMAIN_REGEX.test(value); - }, - { - message: "Invalid domain name or IP address format", - path: ["domain"] - } - ); +// // Schema for domain names and IP addresses +// const domainSchema = z +// .string() +// .min(1, "Domain cannot be empty") +// .max(255, "Domain name too long") +// .refine( +// (value) => { +// // Check if it's a valid IP address (v4 or v6) +// if (IPV4_REGEX.test(value) || IPV6_REGEX.test(value)) { +// return true; +// } +// +// // Check if it's a valid domain name +// return DOMAIN_REGEX.test(value); +// }, +// { +// message: "Invalid domain name or IP address format", +// path: ["domain"] +// } +// ); const addTargetSchema = z.object({ - ip: domainSchema, + ip: z.string().min(1).max(255), method: z.string().nullable(), port: z.coerce.number().int().positive() // protocol: z.string(), From a418195b283d0f9825bba34607e714cbab999b2e Mon Sep 17 00:00:00 2001 From: Owen Date: Fri, 14 Feb 2025 15:49:23 -0500 Subject: [PATCH 16/24] Fix ip range pick initial range; add test --- server/lib/ip.test.ts | 183 ++++++++++++++++++++++++++++++++++++++++++ server/lib/ip.ts | 19 +++-- 2 files changed, 196 insertions(+), 6 deletions(-) create mode 100644 server/lib/ip.test.ts diff --git a/server/lib/ip.test.ts b/server/lib/ip.test.ts new file mode 100644 index 00000000..f3925cf1 --- /dev/null +++ b/server/lib/ip.test.ts @@ -0,0 +1,183 @@ +import { cidrToRange, findNextAvailableCidr } from "./ip"; + +/** + * Compares two objects for deep equality + * @param actual The actual value to test + * @param expected The expected value to compare against + * @param message The message to display if assertion fails + * @throws Error if objects are not equal + */ +export function assertEqualsObj(actual: T, expected: T, message: string): void { + const actualStr = JSON.stringify(actual); + const expectedStr = JSON.stringify(expected); + if (actualStr !== expectedStr) { + throw new Error(`${message}\nExpected: ${expectedStr}\nActual: ${actualStr}`); + } +} + +/** + * Compares two primitive values for equality + * @param actual The actual value to test + * @param expected The expected value to compare against + * @param message The message to display if assertion fails + * @throws Error if values are not equal + */ +export function assertEquals(actual: T, expected: T, message: string): void { + if (actual !== expected) { + throw new Error(`${message}\nExpected: ${expected}\nActual: ${actual}`); + } +} + +/** + * Tests if a function throws an expected error + * @param fn The function to test + * @param expectedError The expected error message or part of it + * @param message The message to display if assertion fails + * @throws Error if function doesn't throw or throws unexpected error + */ +export function assertThrows( + fn: () => void, + expectedError: string, + message: string +): void { + try { + fn(); + throw new Error(`${message}: Expected to throw "${expectedError}"`); + } catch (error) { + if (!(error instanceof Error)) { + throw new Error(`${message}\nUnexpected error type: ${typeof error}`); + } + + if (!error.message.includes(expectedError)) { + throw new Error( + `${message}\nExpected error: ${expectedError}\nActual error: ${error.message}` + ); + } + } +} + + +// Test cases +function testFindNextAvailableCidr() { + console.log("Running findNextAvailableCidr tests..."); + + // Test 1: Basic IPv4 allocation + { + const existing = ["10.0.0.0/16", "10.1.0.0/16"]; + const result = findNextAvailableCidr(existing, 16, "10.0.0.0/8"); + assertEquals(result, "10.2.0.0/16", "Basic IPv4 allocation failed"); + } + + // Test 2: Finding gap between allocations + { + const existing = ["10.0.0.0/16", "10.2.0.0/16"]; + const result = findNextAvailableCidr(existing, 16, "10.0.0.0/8"); + assertEquals(result, "10.1.0.0/16", "Finding gap between allocations failed"); + } + + // Test 3: No available space + { + const existing = ["10.0.0.0/8"]; + const result = findNextAvailableCidr(existing, 8, "10.0.0.0/8"); + assertEquals(result, null, "No available space test failed"); + } + + // // Test 4: IPv6 allocation + // { + // const existing = ["2001:db8::/32", "2001:db8:1::/32"]; + // const result = findNextAvailableCidr(existing, 32, "2001:db8::/16"); + // assertEquals(result, "2001:db8:2::/32", "Basic IPv6 allocation failed"); + // } + + // // Test 5: Mixed IP versions + // { + // const existing = ["10.0.0.0/16", "2001:db8::/32"]; + // assertThrows( + // () => findNextAvailableCidr(existing, 16), + // "All CIDRs must be of the same IP version", + // "Mixed IP versions test failed" + // ); + // } + + // Test 6: Empty input + { + const existing: string[] = []; + const result = findNextAvailableCidr(existing, 16); + assertEquals(result, null, "Empty input test failed"); + } + + // Test 7: Block size alignment + { + const existing = ["10.0.0.0/24"]; + const result = findNextAvailableCidr(existing, 24, "10.0.0.0/16"); + assertEquals(result, "10.0.1.0/24", "Block size alignment test failed"); + } + + // Test 8: Block size alignment + { + const existing: string[] = []; + const result = findNextAvailableCidr(existing, 24, "10.0.0.0/16"); + assertEquals(result, "10.0.0.0/24", "Block size alignment test failed"); + } + + // Test 9: Large block size request + { + const existing = ["10.0.0.0/24", "10.0.1.0/24"]; + const result = findNextAvailableCidr(existing, 16, "10.0.0.0/16"); + assertEquals(result, null, "Large block size request test failed"); + } + + console.log("All findNextAvailableCidr tests passed!"); +} + +// function testCidrToRange() { +// console.log("Running cidrToRange tests..."); + +// // Test 1: Basic IPv4 conversion +// { +// const result = cidrToRange("192.168.0.0/24"); +// assertEqualsObj(result, { +// start: BigInt("3232235520"), +// end: BigInt("3232235775") +// }, "Basic IPv4 conversion failed"); +// } + +// // Test 2: IPv6 conversion +// { +// const result = cidrToRange("2001:db8::/32"); +// assertEqualsObj(result, { +// start: BigInt("42540766411282592856903984951653826560"), +// end: BigInt("42540766411282592875350729025363378175") +// }, "IPv6 conversion failed"); +// } + +// // Test 3: Invalid prefix length +// { +// assertThrows( +// () => cidrToRange("192.168.0.0/33"), +// "Invalid prefix length for IPv4", +// "Invalid IPv4 prefix test failed" +// ); +// } + +// // Test 4: Invalid IPv6 prefix +// { +// assertThrows( +// () => cidrToRange("2001:db8::/129"), +// "Invalid prefix length for IPv6", +// "Invalid IPv6 prefix test failed" +// ); +// } + +// console.log("All cidrToRange tests passed!"); +// } + +// Run all tests +try { + // testCidrToRange(); + testFindNextAvailableCidr(); + console.log("All tests passed successfully!"); +} catch (error) { + console.error("Test failed:", error); + process.exit(1); +} \ No newline at end of file diff --git a/server/lib/ip.ts b/server/lib/ip.ts index 62cf1d2d..86fe1169 100644 --- a/server/lib/ip.ts +++ b/server/lib/ip.ts @@ -100,7 +100,7 @@ function bigIntToIp(num: bigint, version: IPVersion): string { /** * Converts CIDR to IP range */ -function cidrToRange(cidr: string): IPRange { +export function cidrToRange(cidr: string): IPRange { const [ip, prefix] = cidr.split('/'); const version = detectIpVersion(ip); const prefixBits = parseInt(prefix); @@ -132,15 +132,22 @@ export function findNextAvailableCidr( blockSize: number, startCidr?: string ): string | null { - if (existingCidrs.length === 0) return null; + + if (!startCidr && existingCidrs.length === 0) { + return null; + } + + // If no existing CIDRs, use the IP version from startCidr + const version = startCidr + ? detectIpVersion(startCidr.split('/')[0]) + : 4; // Default to IPv4 if no startCidr provided - // Determine IP version from first CIDR - const version = detectIpVersion(existingCidrs[0].split('/')[0]); // Use appropriate default startCidr if none provided startCidr = startCidr || (version === 4 ? "0.0.0.0/0" : "::/0"); - // Ensure all CIDRs are same version - if (existingCidrs.some(cidr => detectIpVersion(cidr.split('/')[0]) !== version)) { + // If there are existing CIDRs, ensure all are same version + if (existingCidrs.length > 0 && + existingCidrs.some(cidr => detectIpVersion(cidr.split('/')[0]) !== version)) { throw new Error('All CIDRs must be of the same IP version'); } From d5a220a0047cf76afe6b7639f7510b7a1ecc0684 Mon Sep 17 00:00:00 2001 From: Milo Schwartz Date: Fri, 14 Feb 2025 16:46:46 -0500 Subject: [PATCH 17/24] create target validator and add url validator --- server/lib/validators.ts | 47 ++++++++++++++++--- server/routers/target/createTarget.ts | 31 +----------- server/routers/target/updateTarget.ts | 31 +----------- .../[resourceId]/connectivity/page.tsx | 33 +------------ 4 files changed, 46 insertions(+), 96 deletions(-) diff --git a/server/lib/validators.ts b/server/lib/validators.ts index 675c0809..abb2ebb4 100644 --- a/server/lib/validators.ts +++ b/server/lib/validators.ts @@ -11,7 +11,7 @@ export function isValidIP(ip: string): boolean { export function isValidUrlGlobPattern(pattern: string): boolean { // Remove leading slash if present pattern = pattern.startsWith("/") ? pattern.slice(1) : pattern; - + // Empty string is not valid if (!pattern) { return false; @@ -19,11 +19,11 @@ export function isValidUrlGlobPattern(pattern: string): boolean { // Split path into segments const segments = pattern.split("/"); - + // Check each segment for (let i = 0; i < segments.length; i++) { const segment = segments[i]; - + // Empty segments are not allowed (double slashes), except at the end if (!segment && i !== segments.length - 1) { return false; @@ -37,12 +37,15 @@ export function isValidUrlGlobPattern(pattern: string): boolean { // Check each character in the segment for (let j = 0; j < segment.length; j++) { const char = segment[j]; - + // Check for percent-encoded sequences if (char === "%" && j + 2 < segment.length) { const hex1 = segment[j + 1]; const hex2 = segment[j + 2]; - if (!/^[0-9A-Fa-f]$/.test(hex1) || !/^[0-9A-Fa-f]$/.test(hex2)) { + if ( + !/^[0-9A-Fa-f]$/.test(hex1) || + !/^[0-9A-Fa-f]$/.test(hex2) + ) { return false; } j += 2; // Skip the next two characters @@ -58,6 +61,36 @@ export function isValidUrlGlobPattern(pattern: string): boolean { } } } - + return true; -} \ No newline at end of file +} + +export function isUrlValid(url: string | undefined) { + if (!url) return true; // the link is optional in the schema so if it's empty it's valid + var pattern = new RegExp( + "^(https?:\\/\\/)?" + // protocol + "((([a-z\\d]([a-z\\d-]*[a-z\\d])*)\\.)+[a-z]{2,}|" + // domain name + "((\\d{1,3}\\.){3}\\d{1,3}))" + // OR ip (v4) address + "(\\:\\d+)?(\\/[-a-z\\d%_.~+]*)*" + // port and path + "(\\?[;&a-z\\d%_.~+=-]*)?" + // query string + "(\\#[-a-z\\d_]*)?$", + "i" + ); + return !!pattern.test(url); +} + +export function isTargetValid(value: string | undefined) { + if (!value) return true; + + const DOMAIN_REGEX = + /^[a-zA-Z0-9_](?:[a-zA-Z0-9-_]{0,61}[a-zA-Z0-9_])?(?:\.[a-zA-Z0-9_](?:[a-zA-Z0-9-_]{0,61}[a-zA-Z0-9_])?)*$/; + const IPV4_REGEX = + /^(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$/; + const IPV6_REGEX = /^(?:[A-F0-9]{1,4}:){7}[A-F0-9]{1,4}$/i; + + if (IPV4_REGEX.test(value) || IPV6_REGEX.test(value)) { + return true; + } + + return DOMAIN_REGEX.test(value); +} diff --git a/server/routers/target/createTarget.ts b/server/routers/target/createTarget.ts index 11f3de69..8d07e5d6 100644 --- a/server/routers/target/createTarget.ts +++ b/server/routers/target/createTarget.ts @@ -12,34 +12,7 @@ import { fromError } from "zod-validation-error"; import { addTargets } from "../newt/targets"; import { eq } from "drizzle-orm"; import { pickPort } from "./helpers"; - -// // Regular expressions for validation -// const DOMAIN_REGEX = -// /^[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$/; -// const IPV4_REGEX = -// /^(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$/; -// const IPV6_REGEX = /^(?:[A-F0-9]{1,4}:){7}[A-F0-9]{1,4}$/i; -// -// // Schema for domain names and IP addresses -// const domainSchema = z -// .string() -// .min(1, "Domain cannot be empty") -// .max(255, "Domain name too long") -// .refine( -// (value) => { -// // Check if it's a valid IP address (v4 or v6) -// if (IPV4_REGEX.test(value) || IPV6_REGEX.test(value)) { -// return true; -// } -// -// // Check if it's a valid domain name -// return DOMAIN_REGEX.test(value); -// }, -// { -// message: "Invalid domain name or IP address format", -// path: ["domain"] -// } -// ); +import { isTargetValid } from "@server/lib/validators"; const createTargetParamsSchema = z .object({ @@ -52,7 +25,7 @@ const createTargetParamsSchema = z const createTargetSchema = z .object({ - ip: z.string().min(1).max(255), + ip: z.string().refine(isTargetValid), method: z.string().optional().nullable(), port: z.number().int().min(1).max(65535), enabled: z.boolean().default(true) diff --git a/server/routers/target/updateTarget.ts b/server/routers/target/updateTarget.ts index 4dbb2f45..45051e0a 100644 --- a/server/routers/target/updateTarget.ts +++ b/server/routers/target/updateTarget.ts @@ -11,34 +11,7 @@ import { fromError } from "zod-validation-error"; import { addPeer } from "../gerbil/peers"; import { addTargets } from "../newt/targets"; import { pickPort } from "./helpers"; - -// // Regular expressions for validation -// const DOMAIN_REGEX = -// /^[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$/; -// const IPV4_REGEX = -// /^(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$/; -// const IPV6_REGEX = /^(?:[A-F0-9]{1,4}:){7}[A-F0-9]{1,4}$/i; -// -// // Schema for domain names and IP addresses -// const domainSchema = z -// .string() -// .min(1, "Domain cannot be empty") -// .max(255, "Domain name too long") -// .refine( -// (value) => { -// // Check if it's a valid IP address (v4 or v6) -// if (IPV4_REGEX.test(value) || IPV6_REGEX.test(value)) { -// return true; -// } -// -// // Check if it's a valid domain name -// return DOMAIN_REGEX.test(value); -// }, -// { -// message: "Invalid domain name or IP address format", -// path: ["domain"] -// } -// ); +import { isTargetValid } from "@server/lib/validators"; const updateTargetParamsSchema = z .object({ @@ -48,7 +21,7 @@ const updateTargetParamsSchema = z const updateTargetBodySchema = z .object({ - ip: z.string().min(1).max(255), + ip: z.string().refine(isTargetValid), method: z.string().min(1).max(10).optional().nullable(), port: z.number().int().min(1).max(65535).optional(), enabled: z.boolean().optional() diff --git a/src/app/[orgId]/settings/resources/[resourceId]/connectivity/page.tsx b/src/app/[orgId]/settings/resources/[resourceId]/connectivity/page.tsx index d912b505..c565b525 100644 --- a/src/app/[orgId]/settings/resources/[resourceId]/connectivity/page.tsx +++ b/src/app/[orgId]/settings/resources/[resourceId]/connectivity/page.tsx @@ -62,40 +62,11 @@ import { SettingsSectionFooter } from "@app/components/Settings"; import { SwitchInput } from "@app/components/SwitchInput"; -import { useSiteContext } from "@app/hooks/useSiteContext"; -import { InfoPopup } from "@app/components/ui/info-popup"; import { useRouter } from "next/navigation"; - -// Regular expressions for validation -const DOMAIN_REGEX = - /^[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$/; -const IPV4_REGEX = - /^(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$/; -const IPV6_REGEX = /^(?:[A-F0-9]{1,4}:){7}[A-F0-9]{1,4}$/i; - -// // Schema for domain names and IP addresses -// const domainSchema = z -// .string() -// .min(1, "Domain cannot be empty") -// .max(255, "Domain name too long") -// .refine( -// (value) => { -// // Check if it's a valid IP address (v4 or v6) -// if (IPV4_REGEX.test(value) || IPV6_REGEX.test(value)) { -// return true; -// } -// -// // Check if it's a valid domain name -// return DOMAIN_REGEX.test(value); -// }, -// { -// message: "Invalid domain name or IP address format", -// path: ["domain"] -// } -// ); +import { isTargetValid } from "@server/lib/validators"; const addTargetSchema = z.object({ - ip: z.string().min(1).max(255), + ip: z.string().refine(isTargetValid), method: z.string().nullable(), port: z.coerce.number().int().positive() // protocol: z.string(), From 6aa49084465df74f2da5c6d59b1168bb3cb210ac Mon Sep 17 00:00:00 2001 From: Milo Schwartz Date: Fri, 14 Feb 2025 16:53:05 -0500 Subject: [PATCH 18/24] bump version --- server/lib/consts.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/lib/consts.ts b/server/lib/consts.ts index 20376f8e..e502ccc8 100644 --- a/server/lib/consts.ts +++ b/server/lib/consts.ts @@ -2,7 +2,7 @@ import path from "path"; import { fileURLToPath } from "url"; // This is a placeholder value replaced by the build process -export const APP_VERSION = "1.0.0-beta.13"; +export const APP_VERSION = "1.0.0-beta.14"; export const __FILENAME = fileURLToPath(import.meta.url); export const __DIRNAME = path.dirname(__FILENAME); From bdee036ab422683c9534d9a2d589d93702972bd2 Mon Sep 17 00:00:00 2001 From: Owen Date: Sat, 15 Feb 2025 17:08:11 -0500 Subject: [PATCH 19/24] Add name; Resolves #190 --- docker-compose.example.yml | 8 +++----- install/fs/docker-compose.yml | 1 + 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/docker-compose.example.yml b/docker-compose.example.yml index bc5ad10c..ad755174 100644 --- a/docker-compose.example.yml +++ b/docker-compose.example.yml @@ -1,5 +1,4 @@ -version: "3.7" - +name: pangolin services: pangolin: image: fosrl/pangolin:latest @@ -32,7 +31,6 @@ services: - SYS_MODULE ports: - 51820:51820/udp - - 8080:8080 # Port for traefik because of the network_mode - 443:443 # Port for traefik because of the network_mode - 80:80 # Port for traefik because of the network_mode @@ -47,8 +45,8 @@ services: command: - --configFile=/etc/traefik/traefik_config.yml volumes: - - ./traefik:/etc/traefik:ro # Volume to store the Traefik configuration - - ./letsencrypt:/letsencrypt # Volume to store the Let's Encrypt certificates + - ./config/traefik:/etc/traefik:ro # Volume to store the Traefik configuration + - ./config/letsencrypt:/letsencrypt # Volume to store the Let's Encrypt certificates networks: default: diff --git a/install/fs/docker-compose.yml b/install/fs/docker-compose.yml index ea673eb0..b26e0257 100644 --- a/install/fs/docker-compose.yml +++ b/install/fs/docker-compose.yml @@ -1,3 +1,4 @@ +name: pangolin services: pangolin: image: fosrl/pangolin:{{.PangolinVersion}} From b862e1aeef667ccd7e0157639d95dcea6e14d48f Mon Sep 17 00:00:00 2001 From: Owen Date: Sat, 15 Feb 2025 17:43:44 -0500 Subject: [PATCH 20/24] Add h2c as target method; Resolves #115 --- .../settings/resources/[resourceId]/connectivity/page.tsx | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/app/[orgId]/settings/resources/[resourceId]/connectivity/page.tsx b/src/app/[orgId]/settings/resources/[resourceId]/connectivity/page.tsx index c565b525..67434404 100644 --- a/src/app/[orgId]/settings/resources/[resourceId]/connectivity/page.tsx +++ b/src/app/[orgId]/settings/resources/[resourceId]/connectivity/page.tsx @@ -421,6 +421,7 @@ export default function ReverseProxyTargets(props: { http https + h2c ) @@ -517,6 +518,9 @@ export default function ReverseProxyTargets(props: { https + + h2c +
From 7bf820a4bfd19c5278e2d39463151fe1aa5fd7f8 Mon Sep 17 00:00:00 2001 From: Owen Date: Sat, 15 Feb 2025 17:48:27 -0500 Subject: [PATCH 21/24] Clean off ports for 80 and 443 hosts --- server/routers/badger/verifySession.ts | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/server/routers/badger/verifySession.ts b/server/routers/badger/verifySession.ts index fc1c85f5..69314cbc 100644 --- a/server/routers/badger/verifySession.ts +++ b/server/routers/badger/verifySession.ts @@ -90,7 +90,15 @@ export async function verifyResourceSession( const clientIp = requestIp?.split(":")[0]; - const resourceCacheKey = `resource:${host}`; + let cleanHost = host; + // if the host ends with :443 or :80 remove it + if (cleanHost.endsWith(":443")) { + cleanHost = cleanHost.slice(0, -4); + } else if (cleanHost.endsWith(":80")) { + cleanHost = cleanHost.slice(0, -3); + } + + const resourceCacheKey = `resource:${cleanHost}`; let resourceData: | { resource: Resource | null; @@ -111,11 +119,11 @@ export async function verifyResourceSession( resourcePassword, eq(resourcePassword.resourceId, resources.resourceId) ) - .where(eq(resources.fullDomain, host)) + .where(eq(resources.fullDomain, cleanHost)) .limit(1); if (!result) { - logger.debug("Resource not found", host); + logger.debug("Resource not found", cleanHost); return notAllowed(res); } @@ -131,7 +139,7 @@ export async function verifyResourceSession( const { resource, pincode, password } = resourceData; if (!resource) { - logger.debug("Resource not found", host); + logger.debug("Resource not found", cleanHost); return notAllowed(res); } From 3194dc56eb990cac131a070c148538cc68d944e2 Mon Sep 17 00:00:00 2001 From: Owen Date: Wed, 19 Feb 2025 09:44:40 -0500 Subject: [PATCH 22/24] Move path to first in dropdown --- .../settings/resources/[resourceId]/rules/page.tsx | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/src/app/[orgId]/settings/resources/[resourceId]/rules/page.tsx b/src/app/[orgId]/settings/resources/[resourceId]/rules/page.tsx index 1b9eb6ca..a3fb033d 100644 --- a/src/app/[orgId]/settings/resources/[resourceId]/rules/page.tsx +++ b/src/app/[orgId]/settings/resources/[resourceId]/rules/page.tsx @@ -92,9 +92,9 @@ enum RuleAction { } enum RuleMatch { + PATH = "Path", IP = "IP", CIDR = "IP Range", - PATH = "Path" } export default function ResourceRules(props: { @@ -469,9 +469,9 @@ export default function ResourceRules(props: { + {RuleMatch.PATH} {RuleMatch.IP} {RuleMatch.CIDR} - {RuleMatch.PATH} ) @@ -665,17 +665,17 @@ export default function ResourceRules(props: { + {resource.http && ( + + {RuleMatch.PATH} + + )} {RuleMatch.IP} {RuleMatch.CIDR} - {resource.http && ( - - {RuleMatch.PATH} - - )} From 372932985db67047dba5aef96e440e77a9b54b52 Mon Sep 17 00:00:00 2001 From: Milo Schwartz Date: Sun, 23 Feb 2025 17:34:28 -0500 Subject: [PATCH 23/24] update README.md --- README.md | 20 ++++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 61e1a689..324680f5 100644 --- a/README.md +++ b/README.md @@ -9,6 +9,13 @@ +

Tunneled Mesh Reverse Proxy Server with Access Control

+
+ +_Your own self-hosted zero trust tunnel._ + +
+ -

Tunneled Mesh Reverse Proxy Server with Access Control

-
- -_Your own self-hosted zero trust tunnel._ - -
- Pangolin is a self-hosted tunneled reverse proxy server with identity and access control, designed to securely expose private resources on distributed networks. Acting as a central hub, it connects isolated networks — even those behind restrictive firewalls — through encrypted tunnels, enabling easy access to remote services without opening ports. Preview @@ -108,7 +108,11 @@ _Sites page of Pangolin dashboard (dark mode) showing multiple tunnels connected 1. **Deploy the Central Server**: - - Deploy the Docker Compose stack onto a VPS hosted on a cloud platform like Amazon EC2, DigitalOcean Droplet, or similar. There are many cheap VPS hosting options available to suit your needs. + - Deploy the Docker Compose stack onto a VPS hosted on a cloud platform like RackNerd, Amazon EC2, DigitalOcean Droplet, or similar. There are many cheap VPS hosting options available to suit your needs. + +> [!TIP] +> Many of our users have had a great experience with [RackNerd](https://my.racknerd.com/aff.php?aff=13788). Depending on promotions, you can likely get a **VPS with 1 vCPU, 1GB RAM, and ~20GB SSD for just around $12/year**. That's a great deal! +> We are part of the [RackNerd](https://my.racknerd.com/aff.php?aff=13788) affiliate program, so if you sign up using [our link](https://my.racknerd.com/aff.php?aff=13788), we receive a small commission which helps us maintain the project and keep it free for everyone. 2. **Domain Configuration**: From ccbe56e110460c93a6507c4b177a26f731d94fcc Mon Sep 17 00:00:00 2001 From: Milo Schwartz Date: Mon, 24 Feb 2025 12:03:48 -0500 Subject: [PATCH 24/24] update README.md --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 324680f5..5baef277 100644 --- a/README.md +++ b/README.md @@ -151,7 +151,7 @@ View the [project board](https://github.com/orgs/fosrl/projects/1) for more deta ## Licensing -Pangolin is dual licensed under the AGPLv3 and the Fossorial Commercial license. For inquiries about commercial licensing, please contact us. +Pangolin is dual licensed under the AGPLv3 and the Fossorial Commercial license. For inquiries about commercial licensing, please contact us at [numbat@fossorial.io](mailto:numbat@fossorial.io). ## Contributions