diff --git a/install/fs/docker-compose.yml b/install/fs/docker-compose.yml index 2adcee58..ab6528d0 100644 --- a/install/fs/docker-compose.yml +++ b/install/fs/docker-compose.yml @@ -11,6 +11,7 @@ services: timeout: "3s" retries: 5 +{{if .InstallGerbil}} gerbil: image: fosrl/gerbil:{{.GerbilVersion}} container_name: gerbil @@ -32,12 +33,20 @@ services: - 51820:51820/udp - 443:443 # Port for traefik because of the network_mode - 80:80 # Port for traefik because of the network_mode +{{end}} traefik: image: traefik:v3.1 container_name: traefik restart: unless-stopped +{{if .InstallGerbil}} network_mode: service:gerbil # Ports appear on the gerbil service +{{end}} +{{if not .InstallGerbil}} + ports: + - 443:443 + - 80:80 +{{end}} depends_on: pangolin: condition: service_healthy diff --git a/install/main.go b/install/main.go index 486137a1..2c479dd3 100644 --- a/install/main.go +++ b/install/main.go @@ -41,6 +41,7 @@ type Config struct { EmailSMTPUser string EmailSMTPPass string EmailNoReply string + InstallGerbil bool } func main() { @@ -64,7 +65,7 @@ func main() { } if !isDockerInstalled() && runtime.GOOS == "linux" { - if shouldInstallDocker() { + if readBool(reader, "Docker is not installed. Would you like to install it?", true) { installDocker() } } @@ -140,6 +141,7 @@ func collectUserInput(reader *bufio.Reader) Config { config.BaseDomain = readString(reader, "Enter your base domain (no subdomain e.g. example.com)", "") config.DashboardDomain = readString(reader, "Enter the domain for the Pangolin dashboard", "pangolin."+config.BaseDomain) config.LetsEncryptEmail = readString(reader, "Enter email for Let's Encrypt certificates", "") + config.InstallGerbil = readBool(reader, "Do you want to use Gerbil to allow tunned connections", true) // Admin user configuration fmt.Println("\n=== Admin User Configuration ===") @@ -340,13 +342,6 @@ func createConfigFiles(config Config) error { return nil } -func shouldInstallDocker() bool { - reader := bufio.NewReader(os.Stdin) - fmt.Print("Would you like to install Docker? (yes/no): ") - response, _ := reader.ReadString('\n') - return strings.ToLower(strings.TrimSpace(response)) == "yes" -} - func installDocker() error { // Detect Linux distribution cmd := exec.Command("cat", "/etc/os-release") diff --git a/server/routers/site/createSite.ts b/server/routers/site/createSite.ts index 1151feac..9a5e6f46 100644 --- a/server/routers/site/createSite.ts +++ b/server/routers/site/createSite.ts @@ -24,7 +24,7 @@ const createSiteParamsSchema = z const createSiteSchema = z .object({ name: z.string().min(1).max(255), - exitNodeId: z.number().int().positive(), + exitNodeId: z.number().int().positive().optional(), // subdomain: z // .string() // .min(1) @@ -32,7 +32,7 @@ const createSiteSchema = z // .transform((val) => val.toLowerCase()) // .optional(), pubKey: z.string().optional(), - subnet: z.string(), + subnet: z.string().optional(), newtId: z.string().optional(), secret: z.string().optional(), type: z.string() @@ -82,28 +82,46 @@ export async function createSite( const niceId = await getUniqueSiteName(orgId); - let payload: any = { - orgId, - exitNodeId, - name, - niceId, - subnet, - type - }; - - if (pubKey && type == "wireguard") { - // we dont add the pubKey for newts because the newt will generate it - payload = { - ...payload, - pubKey - }; - } - await db.transaction(async (trx) => { - const [newSite] = await trx - .insert(sites) - .values(payload) - .returning(); + let newSite: Site; + + if (exitNodeId) { + // we are creating a site with an exit node (tunneled) + if (!subnet) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "Subnet is required for tunneled sites" + ) + ); + } + + [newSite] = await trx + .insert(sites) + .values({ + orgId, + exitNodeId, + name, + niceId, + subnet, + type, + ...(pubKey && type == "wireguard" && { pubKey }) + }) + .returning(); + } else { + // we are creating a site with no tunneling + + [newSite] = await trx + .insert(sites) + .values({ + orgId, + name, + niceId, + type, + subnet: "0.0.0.0/0" + }) + .returning(); + } const adminRole = await trx .select() @@ -149,6 +167,16 @@ export async function createSite( ) ); } + + if (!exitNodeId) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "Exit node ID is required for wireguard sites" + ) + ); + } + await addPeer(exitNodeId, { publicKey: pubKey, allowedIps: [] diff --git a/server/routers/target/createTarget.ts b/server/routers/target/createTarget.ts index e7ae3aca..1376ab0a 100644 --- a/server/routers/target/createTarget.ts +++ b/server/routers/target/createTarget.ts @@ -123,88 +123,100 @@ export async function createTarget( ); } - // make sure the target is within the site subnet - if ( - site.type == "wireguard" && - !isIpInCidr(targetData.ip, site.subnet!) - ) { - return next( - createHttpError( - HttpCode.BAD_REQUEST, - `Target IP is not within the site subnet` - ) - ); - } - - // Fetch resources for this site - const resourcesRes = await db.query.resources.findMany({ - where: eq(resources.siteId, site.siteId) - }); - - // TODO: is this all inefficient? - // Fetch targets for all resources of this site - let targetIps: string[] = []; - let targetInternalPorts: number[] = []; - await Promise.all( - resourcesRes.map(async (resource) => { - const targetsRes = await db.query.targets.findMany({ - where: eq(targets.resourceId, resource.resourceId) - }); - targetsRes.forEach((target) => { - targetIps.push(`${target.ip}/32`); - if (target.internalPort) { - targetInternalPorts.push(target.internalPort); - } - }); - }) - ); - - let internalPort!: number; - // pick a port - for (let i = 40000; i < 65535; i++) { - if (!targetInternalPorts.includes(i)) { - internalPort = i; - break; + let newTarget: Target[] = []; + if (site.type == "local") { + newTarget = await db + .insert(targets) + .values({ + resourceId, + protocol: "tcp", // hard code for now + ...targetData + }) + .returning(); + } else { + // make sure the target is within the site subnet + if ( + site.type == "wireguard" && + !isIpInCidr(targetData.ip, site.subnet!) + ) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + `Target IP is not within the site subnet` + ) + ); } - } - if (!internalPort) { - return next( - createHttpError( - HttpCode.BAD_REQUEST, - `No available internal port` - ) + // Fetch resources for this site + const resourcesRes = await db.query.resources.findMany({ + where: eq(resources.siteId, site.siteId) + }); + + // TODO: is this all inefficient? + // Fetch targets for all resources of this site + let targetIps: string[] = []; + let targetInternalPorts: number[] = []; + await Promise.all( + resourcesRes.map(async (resource) => { + const targetsRes = await db.query.targets.findMany({ + where: eq(targets.resourceId, resource.resourceId) + }); + targetsRes.forEach((target) => { + targetIps.push(`${target.ip}/32`); + if (target.internalPort) { + targetInternalPorts.push(target.internalPort); + } + }); + }) ); - } - const newTarget = await db - .insert(targets) - .values({ - resourceId, - protocol: "tcp", // hard code for now - internalPort, - ...targetData - }) - .returning(); + let internalPort!: number; + // pick a port + for (let i = 40000; i < 65535; i++) { + if (!targetInternalPorts.includes(i)) { + internalPort = i; + break; + } + } - // add the new target to the targetIps array - targetIps.push(`${targetData.ip}/32`); + if (!internalPort) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + `No available internal port` + ) + ); + } - if (site.pubKey) { - if (site.type == "wireguard") { - await addPeer(site.exitNodeId!, { - publicKey: site.pubKey, - allowedIps: targetIps.flat() - }); - } else if (site.type == "newt") { - // get the newt on the site by querying the newt table for siteId - const [newt] = await db - .select() - .from(newts) - .where(eq(newts.siteId, site.siteId)) - .limit(1); + newTarget = await db + .insert(targets) + .values({ + resourceId, + protocol: "tcp", // hard code for now + internalPort, + ...targetData + }) + .returning(); - addTargets(newt.newtId, newTarget); + // add the new target to the targetIps array + targetIps.push(`${targetData.ip}/32`); + + if (site.pubKey) { + if (site.type == "wireguard") { + await addPeer(site.exitNodeId!, { + publicKey: site.pubKey, + allowedIps: targetIps.flat() + }); + } else if (site.type == "newt") { + // get the newt on the site by querying the newt table for siteId + const [newt] = await db + .select() + .from(newts) + .where(eq(newts.siteId, site.siteId)) + .limit(1); + + addTargets(newt.newtId, newTarget); + } } } diff --git a/src/app/[orgId]/settings/sites/CreateSiteForm.tsx b/src/app/[orgId]/settings/sites/CreateSiteForm.tsx index 2515284d..3064cfa5 100644 --- a/src/app/[orgId]/settings/sites/CreateSiteForm.tsx +++ b/src/app/[orgId]/settings/sites/CreateSiteForm.tsx @@ -49,7 +49,7 @@ const createSiteFormSchema = z.object({ .max(30, { message: "Name must not be longer than 30 characters." }), - method: z.enum(["wireguard", "newt"]) + method: z.enum(["wireguard", "newt", "local"]) }); type CreateSiteFormValues = z.infer; @@ -79,17 +79,16 @@ export default function CreateSiteForm({ const [isLoading, setIsLoading] = useState(false); const [isChecked, setIsChecked] = useState(false); - const router = useRouter(); - const [keypair, setKeypair] = useState<{ publicKey: string; privateKey: string; } | null>(null); + const [siteDefaults, setSiteDefaults] = useState(null); const handleCheckboxChange = (checked: boolean) => { - setChecked?.(checked); + // setChecked?.(checked); setIsChecked(checked); }; @@ -98,6 +97,17 @@ export default function CreateSiteForm({ defaultValues }); + const nameField = form.watch("name"); + const methodField = form.watch("method"); + + useEffect(() => { + const nameIsValid = nameField?.length >= 2 && nameField?.length <= 30; + const isFormValid = methodField === "local" || isChecked; + + // Only set checked to true if name is valid AND (method is local OR checkbox is checked) + setChecked?.(nameIsValid && isFormValid); + }, [nameField, methodField, isChecked, setChecked]); + useEffect(() => { if (!open) return; @@ -114,11 +124,8 @@ export default function CreateSiteForm({ api.get(`/org/${orgId}/pick-site-defaults`) .catch((e) => { - toast({ - variant: "destructive", - title: "Error picking site defaults", - description: formatAxiosError(e) - }); + // update the default value of the form to be local method + form.setValue("method", "local"); }) .then((res) => { if (res && res.status === 200) { @@ -130,24 +137,54 @@ export default function CreateSiteForm({ async function onSubmit(data: CreateSiteFormValues) { setLoading?.(true); setIsLoading(true); - if (!siteDefaults || !keypair) { - return; - } let payload: CreateSiteBody = { name: data.name, - subnet: siteDefaults.subnet, - exitNodeId: siteDefaults.exitNodeId, - pubKey: keypair.publicKey, type: data.method }; - if (data.method === "newt") { - payload.secret = siteDefaults.newtSecret; - payload.newtId = siteDefaults.newtId; + + if (data.method == "wireguard") { + if (!keypair || !siteDefaults) { + toast({ + variant: "destructive", + title: "Error creating site", + description: "Key pair or site defaults not found" + }); + setLoading?.(false); + setIsLoading(false); + return; + } + + payload = { + ...payload, + subnet: siteDefaults.subnet, + exitNodeId: siteDefaults.exitNodeId, + pubKey: keypair.publicKey + }; } + if (data.method === "newt") { + if (!siteDefaults) { + toast({ + variant: "destructive", + title: "Error creating site", + description: "Site defaults not found" + }); + setLoading?.(false); + setIsLoading(false); + return; + } + + payload = { + ...payload, + secret: siteDefaults.newtSecret, + newtId: siteDefaults.newtId + }; + } + const res = await api - .put< - AxiosResponse - >(`/org/${orgId}/site/`, payload) + .put>( + `/org/${orgId}/site/`, + payload + ) .catch((e) => { toast({ variant: "destructive", @@ -157,18 +194,20 @@ export default function CreateSiteForm({ }); if (res && res.status === 201) { - const niceId = res.data.data.niceId; - // navigate to the site page - // router.push(`/${orgId}/settings/sites/${niceId}`); - const data = res.data.data; onCreate?.({ name: data.name, id: data.siteId, nice: data.niceId.toString(), - mbIn: "0 MB", - mbOut: "0 MB", + mbIn: + data.type == "wireguard" || data.type == "newt" + ? "0 MB" + : "--", + mbOut: + data.type == "wireguard" || data.type == "newt" + ? "0 MB" + : "--", orgId: orgId as string, type: data.type as any, online: false @@ -245,12 +284,21 @@ PersistentKeepalive = 5` - - WireGuard + + Local - + Newt + + WireGuard + @@ -264,50 +312,76 @@ 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}
- - You will only be able to see the configuration once. - - {form.watch("method") === "newt" && ( - <> -
- - - {" "} - Learn how to install Newt on your system - - - - + + + {" "} + Learn how to install Newt on your system + + + )} -
- - -
+ + {" "} + Local sites do not tunnel, learn more + + + + )} + + {(form.watch("method") === "newt" || + form.watch("method") === "wireguard") && ( +
+ + +
+ )} diff --git a/src/app/[orgId]/settings/sites/SitesTable.tsx b/src/app/[orgId]/settings/sites/SitesTable.tsx index f4361177..e76203a3 100644 --- a/src/app/[orgId]/settings/sites/SitesTable.tsx +++ b/src/app/[orgId]/settings/sites/SitesTable.tsx @@ -23,7 +23,7 @@ import { useState } from "react"; import CreateSiteForm from "./CreateSiteForm"; import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog"; import { useToast } from "@app/hooks/useToast"; -import { formatAxiosError } from "@app/lib/api";; +import { formatAxiosError } from "@app/lib/api"; import { createApiClient } from "@app/lib/api"; import { useEnvContext } from "@app/hooks/useEnvContext"; import CreateSiteFormModal from "./CreateSiteModal"; @@ -146,21 +146,27 @@ export default function SitesTable({ sites, orgId }: SitesTableProps) { }, cell: ({ row }) => { const originalRow = row.original; - - if (originalRow.online) { - return ( - -
- Online -
- ); + if ( + originalRow.type == "newt" || + originalRow.type == "wireguard" + ) { + if (originalRow.online) { + return ( + +
+ Online +
+ ); + } else { + return ( + +
+ Offline +
+ ); + } } else { - return ( - -
- Offline -
- ); + return --; } } }, @@ -245,6 +251,14 @@ export default function SitesTable({ sites, orgId }: SitesTableProps) { ); } + + if (originalRow.type === "local") { + return ( +
+ Local +
+ ); + } } }, { diff --git a/src/app/[orgId]/settings/sites/[niceId]/SiteInfoCard.tsx b/src/app/[orgId]/settings/sites/[niceId]/SiteInfoCard.tsx index d93b815b..2a05606c 100644 --- a/src/app/[orgId]/settings/sites/[niceId]/SiteInfoCard.tsx +++ b/src/app/[orgId]/settings/sites/[niceId]/SiteInfoCard.tsx @@ -16,37 +16,50 @@ type SiteInfoCardProps = {}; export default function SiteInfoCard({}: SiteInfoCardProps) { const { site, updateSite } = useSiteContext(); + const getConnectionTypeString = (type: string) => { + if (type === "newt") { + return "Newt"; + } else if (type === "wireguard") { + return "WireGuard"; + } else if (type === "local") { + return "Local"; + } else { + return "Unknown"; + } + }; + return ( Site Information - - Status - - {site.online ? ( -
-
- Online -
- ) : ( -
-
- Offline -
- )} -
-
- + {(site.type == "newt" || site.type == "wireguard") && ( + <> + + Status + + {site.online ? ( +
+
+ Online +
+ ) : ( +
+
+ Offline +
+ )} +
+
+ + + + )} Connection Type - {site.type === "newt" - ? "Newt" - : site.type === "wireguard" - ? "WireGuard" - : "Unknown"} + {getConnectionTypeString(site.type)}
diff --git a/src/app/[orgId]/settings/sites/page.tsx b/src/app/[orgId]/settings/sites/page.tsx index c5d83ce2..f3fa4957 100644 --- a/src/app/[orgId]/settings/sites/page.tsx +++ b/src/app/[orgId]/settings/sites/page.tsx @@ -23,7 +23,10 @@ export default async function SitesPage(props: SitesPageProps) { sites = res.data.data.sites; } catch (e) {} - function formatSize(mb: number): string { + function formatSize(mb: number, type: string): string { + if (type === "local") { + return "--"; // because we are not able to track the data use in a local site right now + } if (mb >= 1024 * 1024) { return `${(mb / (1024 * 1024)).toFixed(2)} TB`; } else if (mb >= 1024) { @@ -38,8 +41,8 @@ export default async function SitesPage(props: SitesPageProps) { name: site.name, id: site.siteId, nice: site.niceId.toString(), - mbIn: formatSize(site.megabytesIn || 0), - mbOut: formatSize(site.megabytesOut || 0), + mbIn: formatSize(site.megabytesIn || 0, site.type), + mbOut: formatSize(site.megabytesOut || 0, site.type), orgId: params.orgId, type: site.type as any, online: site.online