Merge branch 'dev' of https://github.com/fosrl/pangolin into dev

This commit is contained in:
Milo Schwartz 2025-01-12 15:59:36 -05:00
commit 7b3db11b82
No known key found for this signature in database
8 changed files with 359 additions and 211 deletions

View file

@ -11,6 +11,7 @@ services:
timeout: "3s" timeout: "3s"
retries: 5 retries: 5
{{if .InstallGerbil}}
gerbil: gerbil:
image: fosrl/gerbil:{{.GerbilVersion}} image: fosrl/gerbil:{{.GerbilVersion}}
container_name: gerbil container_name: gerbil
@ -32,12 +33,20 @@ services:
- 51820:51820/udp - 51820:51820/udp
- 443:443 # 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 - 80:80 # Port for traefik because of the network_mode
{{end}}
traefik: traefik:
image: traefik:v3.1 image: traefik:v3.1
container_name: traefik container_name: traefik
restart: unless-stopped restart: unless-stopped
{{if .InstallGerbil}}
network_mode: service:gerbil # Ports appear on the gerbil service network_mode: service:gerbil # Ports appear on the gerbil service
{{end}}
{{if not .InstallGerbil}}
ports:
- 443:443
- 80:80
{{end}}
depends_on: depends_on:
pangolin: pangolin:
condition: service_healthy condition: service_healthy

View file

@ -41,6 +41,7 @@ type Config struct {
EmailSMTPUser string EmailSMTPUser string
EmailSMTPPass string EmailSMTPPass string
EmailNoReply string EmailNoReply string
InstallGerbil bool
} }
func main() { func main() {
@ -64,7 +65,7 @@ func main() {
} }
if !isDockerInstalled() && runtime.GOOS == "linux" { if !isDockerInstalled() && runtime.GOOS == "linux" {
if shouldInstallDocker() { if readBool(reader, "Docker is not installed. Would you like to install it?", true) {
installDocker() 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.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.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.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 // Admin user configuration
fmt.Println("\n=== Admin User Configuration ===") fmt.Println("\n=== Admin User Configuration ===")
@ -340,13 +342,6 @@ func createConfigFiles(config Config) error {
return nil 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 { func installDocker() error {
// Detect Linux distribution // Detect Linux distribution
cmd := exec.Command("cat", "/etc/os-release") cmd := exec.Command("cat", "/etc/os-release")

View file

@ -24,7 +24,7 @@ const createSiteParamsSchema = z
const createSiteSchema = z const createSiteSchema = z
.object({ .object({
name: z.string().min(1).max(255), name: z.string().min(1).max(255),
exitNodeId: z.number().int().positive(), exitNodeId: z.number().int().positive().optional(),
// subdomain: z // subdomain: z
// .string() // .string()
// .min(1) // .min(1)
@ -32,7 +32,7 @@ const createSiteSchema = z
// .transform((val) => val.toLowerCase()) // .transform((val) => val.toLowerCase())
// .optional(), // .optional(),
pubKey: z.string().optional(), pubKey: z.string().optional(),
subnet: z.string(), subnet: z.string().optional(),
newtId: z.string().optional(), newtId: z.string().optional(),
secret: z.string().optional(), secret: z.string().optional(),
type: z.string() type: z.string()
@ -82,28 +82,46 @@ export async function createSite(
const niceId = await getUniqueSiteName(orgId); 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) => { await db.transaction(async (trx) => {
const [newSite] = await trx let newSite: Site;
.insert(sites)
.values(payload) if (exitNodeId) {
.returning(); // 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 const adminRole = await trx
.select() .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, { await addPeer(exitNodeId, {
publicKey: pubKey, publicKey: pubKey,
allowedIps: [] allowedIps: []

View file

@ -123,88 +123,100 @@ export async function createTarget(
); );
} }
// make sure the target is within the site subnet let newTarget: Target[] = [];
if ( if (site.type == "local") {
site.type == "wireguard" && newTarget = await db
!isIpInCidr(targetData.ip, site.subnet!) .insert(targets)
) { .values({
return next( resourceId,
createHttpError( protocol: "tcp", // hard code for now
HttpCode.BAD_REQUEST, ...targetData
`Target IP is not within the site subnet` })
) .returning();
); } else {
} // make sure the target is within the site subnet
if (
// Fetch resources for this site site.type == "wireguard" &&
const resourcesRes = await db.query.resources.findMany({ !isIpInCidr(targetData.ip, site.subnet!)
where: eq(resources.siteId, site.siteId) ) {
}); return next(
createHttpError(
// TODO: is this all inefficient? HttpCode.BAD_REQUEST,
// Fetch targets for all resources of this site `Target IP is not within the site subnet`
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;
} }
}
if (!internalPort) { // Fetch resources for this site
return next( const resourcesRes = await db.query.resources.findMany({
createHttpError( where: eq(resources.siteId, site.siteId)
HttpCode.BAD_REQUEST, });
`No available internal port`
) // 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 let internalPort!: number;
.insert(targets) // pick a port
.values({ for (let i = 40000; i < 65535; i++) {
resourceId, if (!targetInternalPorts.includes(i)) {
protocol: "tcp", // hard code for now internalPort = i;
internalPort, break;
...targetData }
}) }
.returning();
// add the new target to the targetIps array if (!internalPort) {
targetIps.push(`${targetData.ip}/32`); return next(
createHttpError(
HttpCode.BAD_REQUEST,
`No available internal port`
)
);
}
if (site.pubKey) { newTarget = await db
if (site.type == "wireguard") { .insert(targets)
await addPeer(site.exitNodeId!, { .values({
publicKey: site.pubKey, resourceId,
allowedIps: targetIps.flat() protocol: "tcp", // hard code for now
}); internalPort,
} else if (site.type == "newt") { ...targetData
// get the newt on the site by querying the newt table for siteId })
const [newt] = await db .returning();
.select()
.from(newts)
.where(eq(newts.siteId, site.siteId))
.limit(1);
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);
}
} }
} }

View file

@ -49,7 +49,7 @@ const createSiteFormSchema = z.object({
.max(30, { .max(30, {
message: "Name must not be longer than 30 characters." message: "Name must not be longer than 30 characters."
}), }),
method: z.enum(["wireguard", "newt"]) method: z.enum(["wireguard", "newt", "local"])
}); });
type CreateSiteFormValues = z.infer<typeof createSiteFormSchema>; type CreateSiteFormValues = z.infer<typeof createSiteFormSchema>;
@ -79,17 +79,16 @@ export default function CreateSiteForm({
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [isChecked, setIsChecked] = useState(false); const [isChecked, setIsChecked] = useState(false);
const router = useRouter();
const [keypair, setKeypair] = useState<{ const [keypair, setKeypair] = useState<{
publicKey: string; publicKey: string;
privateKey: string; privateKey: string;
} | null>(null); } | null>(null);
const [siteDefaults, setSiteDefaults] = const [siteDefaults, setSiteDefaults] =
useState<PickSiteDefaultsResponse | null>(null); useState<PickSiteDefaultsResponse | null>(null);
const handleCheckboxChange = (checked: boolean) => { const handleCheckboxChange = (checked: boolean) => {
setChecked?.(checked); // setChecked?.(checked);
setIsChecked(checked); setIsChecked(checked);
}; };
@ -98,6 +97,17 @@ export default function CreateSiteForm({
defaultValues 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(() => { useEffect(() => {
if (!open) return; if (!open) return;
@ -114,11 +124,8 @@ export default function CreateSiteForm({
api.get(`/org/${orgId}/pick-site-defaults`) api.get(`/org/${orgId}/pick-site-defaults`)
.catch((e) => { .catch((e) => {
toast({ // update the default value of the form to be local method
variant: "destructive", form.setValue("method", "local");
title: "Error picking site defaults",
description: formatAxiosError(e)
});
}) })
.then((res) => { .then((res) => {
if (res && res.status === 200) { if (res && res.status === 200) {
@ -130,24 +137,54 @@ export default function CreateSiteForm({
async function onSubmit(data: CreateSiteFormValues) { async function onSubmit(data: CreateSiteFormValues) {
setLoading?.(true); setLoading?.(true);
setIsLoading(true); setIsLoading(true);
if (!siteDefaults || !keypair) {
return;
}
let payload: CreateSiteBody = { let payload: CreateSiteBody = {
name: data.name, name: data.name,
subnet: siteDefaults.subnet,
exitNodeId: siteDefaults.exitNodeId,
pubKey: keypair.publicKey,
type: data.method type: data.method
}; };
if (data.method === "newt") {
payload.secret = siteDefaults.newtSecret; if (data.method == "wireguard") {
payload.newtId = siteDefaults.newtId; 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 const res = await api
.put< .put<AxiosResponse<CreateSiteResponse>>(
AxiosResponse<CreateSiteResponse> `/org/${orgId}/site/`,
>(`/org/${orgId}/site/`, payload) payload
)
.catch((e) => { .catch((e) => {
toast({ toast({
variant: "destructive", variant: "destructive",
@ -157,18 +194,20 @@ export default function CreateSiteForm({
}); });
if (res && res.status === 201) { 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; const data = res.data.data;
onCreate?.({ onCreate?.({
name: data.name, name: data.name,
id: data.siteId, id: data.siteId,
nice: data.niceId.toString(), nice: data.niceId.toString(),
mbIn: "0 MB", mbIn:
mbOut: "0 MB", data.type == "wireguard" || data.type == "newt"
? "0 MB"
: "--",
mbOut:
data.type == "wireguard" || data.type == "newt"
? "0 MB"
: "--",
orgId: orgId as string, orgId: orgId as string,
type: data.type as any, type: data.type as any,
online: false online: false
@ -245,12 +284,21 @@ PersistentKeepalive = 5`
<SelectValue placeholder="Select method" /> <SelectValue placeholder="Select method" />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="wireguard"> <SelectItem value="local">
WireGuard Local
</SelectItem> </SelectItem>
<SelectItem value="newt"> <SelectItem
value="newt"
disabled={!siteDefaults}
>
Newt Newt
</SelectItem> </SelectItem>
<SelectItem
value="wireguard"
disabled={!siteDefaults}
>
WireGuard
</SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
</FormControl> </FormControl>
@ -264,50 +312,76 @@ PersistentKeepalive = 5`
<div className="w-full"> <div className="w-full">
{form.watch("method") === "wireguard" && !isLoading ? ( {form.watch("method") === "wireguard" && !isLoading ? (
<CopyTextBox text={wgConfig} /> <>
<CopyTextBox text={wgConfig} />
<span className="text-sm text-muted-foreground">
You will only be able to see the
configuration once.
</span>
</>
) : form.watch("method") === "wireguard" && ) : form.watch("method") === "wireguard" &&
isLoading ? ( isLoading ? (
<p>Loading WireGuard configuration...</p> <p>Loading WireGuard configuration...</p>
) : ( ) : form.watch("method") === "newt" ? (
<CopyTextBox text={newtConfig} wrapText={false} /> <>
)} <CopyTextBox
text={newtConfig}
wrapText={false}
/>
<span className="text-sm text-muted-foreground">
You will only be able to see the
configuration once.
</span>
</>
) : null}
</div> </div>
<span className="text-sm text-muted-foreground">
You will only be able to see the configuration once.
</span>
{form.watch("method") === "newt" && ( {form.watch("method") === "newt" && (
<> <Link
<br /> className="text-sm text-primary flex items-center gap-1"
<Link href="https://docs.fossorial.io/Newt/install"
className="text-sm text-primary flex items-center gap-1" target="_blank"
href="https://docs.fossorial.io/Newt/install" rel="noopener noreferrer"
target="_blank" >
rel="noopener noreferrer" <span>
> {" "}
<span> Learn how to install Newt on your system
{" "} </span>
Learn how to install Newt on your system <SquareArrowOutUpRight size={14} />
</span> </Link>
<SquareArrowOutUpRight size={14} />
</Link>
</>
)} )}
<div className="flex items-center space-x-2"> {form.watch("method") === "local" && (
<Checkbox <Link
id="terms" className="text-sm text-primary flex items-center gap-1"
checked={isChecked} href="https://docs.fossorial.io/Pangolin/without-tunneling"
onCheckedChange={handleCheckboxChange} target="_blank"
/> rel="noopener noreferrer"
<label
htmlFor="terms"
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
> >
I have copied the config <span>
</label> {" "}
</div> Local sites do not tunnel, learn more
</span>
<SquareArrowOutUpRight size={14} />
</Link>
)}
{(form.watch("method") === "newt" ||
form.watch("method") === "wireguard") && (
<div className="flex items-center space-x-2">
<Checkbox
id="terms"
checked={isChecked}
onCheckedChange={handleCheckboxChange}
/>
<label
htmlFor="terms"
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
>
I have copied the config
</label>
</div>
)}
</form> </form>
</Form> </Form>
</div> </div>

View file

@ -23,7 +23,7 @@ import { useState } from "react";
import CreateSiteForm from "./CreateSiteForm"; import CreateSiteForm from "./CreateSiteForm";
import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog"; import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog";
import { useToast } from "@app/hooks/useToast"; 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 { createApiClient } from "@app/lib/api";
import { useEnvContext } from "@app/hooks/useEnvContext"; import { useEnvContext } from "@app/hooks/useEnvContext";
import CreateSiteFormModal from "./CreateSiteModal"; import CreateSiteFormModal from "./CreateSiteModal";
@ -146,21 +146,27 @@ export default function SitesTable({ sites, orgId }: SitesTableProps) {
}, },
cell: ({ row }) => { cell: ({ row }) => {
const originalRow = row.original; const originalRow = row.original;
if (
if (originalRow.online) { originalRow.type == "newt" ||
return ( originalRow.type == "wireguard"
<span className="text-green-500 flex items-center space-x-2"> ) {
<div className="w-2 h-2 bg-green-500 rounded-full"></div> if (originalRow.online) {
<span>Online</span> return (
</span> <span className="text-green-500 flex items-center space-x-2">
); <div className="w-2 h-2 bg-green-500 rounded-full"></div>
<span>Online</span>
</span>
);
} else {
return (
<span className="text-neutral-500 flex items-center space-x-2">
<div className="w-2 h-2 bg-gray-500 rounded-full"></div>
<span>Offline</span>
</span>
);
}
} else { } else {
return ( return <span>--</span>;
<span className="text-neutral-500 flex items-center space-x-2">
<div className="w-2 h-2 bg-gray-500 rounded-full"></div>
<span>Offline</span>
</span>
);
} }
} }
}, },
@ -245,6 +251,14 @@ export default function SitesTable({ sites, orgId }: SitesTableProps) {
</div> </div>
); );
} }
if (originalRow.type === "local") {
return (
<div className="flex items-center space-x-2">
<span>Local</span>
</div>
);
}
} }
}, },
{ {

View file

@ -16,37 +16,50 @@ type SiteInfoCardProps = {};
export default function SiteInfoCard({}: SiteInfoCardProps) { export default function SiteInfoCard({}: SiteInfoCardProps) {
const { site, updateSite } = useSiteContext(); 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 ( return (
<Alert> <Alert>
<InfoIcon className="h-4 w-4" /> <InfoIcon className="h-4 w-4" />
<AlertTitle className="font-semibold">Site Information</AlertTitle> <AlertTitle className="font-semibold">Site Information</AlertTitle>
<AlertDescription className="mt-4"> <AlertDescription className="mt-4">
<InfoSections> <InfoSections>
<InfoSection> {(site.type == "newt" || site.type == "wireguard") && (
<InfoSectionTitle>Status</InfoSectionTitle> <>
<InfoSectionContent> <InfoSection>
{site.online ? ( <InfoSectionTitle>Status</InfoSectionTitle>
<div className="text-green-500 flex items-center space-x-2"> <InfoSectionContent>
<div className="w-2 h-2 bg-green-500 rounded-full"></div> {site.online ? (
<span>Online</span> <div className="text-green-500 flex items-center space-x-2">
</div> <div className="w-2 h-2 bg-green-500 rounded-full"></div>
) : ( <span>Online</span>
<div className="text-neutral-500 flex items-center space-x-2"> </div>
<div className="w-2 h-2 bg-gray-500 rounded-full"></div> ) : (
<span>Offline</span> <div className="text-neutral-500 flex items-center space-x-2">
</div> <div className="w-2 h-2 bg-gray-500 rounded-full"></div>
)} <span>Offline</span>
</InfoSectionContent> </div>
</InfoSection> )}
<Separator orientation="vertical" /> </InfoSectionContent>
</InfoSection>
<Separator orientation="vertical" />
</>
)}
<InfoSection> <InfoSection>
<InfoSectionTitle>Connection Type</InfoSectionTitle> <InfoSectionTitle>Connection Type</InfoSectionTitle>
<InfoSectionContent> <InfoSectionContent>
{site.type === "newt" {getConnectionTypeString(site.type)}
? "Newt"
: site.type === "wireguard"
? "WireGuard"
: "Unknown"}
</InfoSectionContent> </InfoSectionContent>
</InfoSection> </InfoSection>
</InfoSections> </InfoSections>

View file

@ -23,7 +23,10 @@ export default async function SitesPage(props: SitesPageProps) {
sites = res.data.data.sites; sites = res.data.data.sites;
} catch (e) {} } 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) { if (mb >= 1024 * 1024) {
return `${(mb / (1024 * 1024)).toFixed(2)} TB`; return `${(mb / (1024 * 1024)).toFixed(2)} TB`;
} else if (mb >= 1024) { } else if (mb >= 1024) {
@ -38,8 +41,8 @@ export default async function SitesPage(props: SitesPageProps) {
name: site.name, name: site.name,
id: site.siteId, id: site.siteId,
nice: site.niceId.toString(), nice: site.niceId.toString(),
mbIn: formatSize(site.megabytesIn || 0), mbIn: formatSize(site.megabytesIn || 0, site.type),
mbOut: formatSize(site.megabytesOut || 0), mbOut: formatSize(site.megabytesOut || 0, site.type),
orgId: params.orgId, orgId: params.orgId,
type: site.type as any, type: site.type as any,
online: site.online online: site.online