Merge pull request #32 from fosrl/dev

add site_block_size to config, improve target input form validation, and lock down redirects
This commit is contained in:
Milo Schwartz 2025-01-11 15:21:53 -05:00 committed by GitHub
commit 5774e534e5
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
37 changed files with 273 additions and 86 deletions

1
.gitignore vendored
View file

@ -30,3 +30,4 @@ dist
.dist .dist
installer installer
*.tar *.tar
bin

View file

@ -1,4 +1,4 @@
build-all: build-release:
@if [ -z "$(tag)" ]; then \ @if [ -z "$(tag)" ]; then \
echo "Error: tag is required. Usage: make build-all tag=<tag>"; \ echo "Error: tag is required. Usage: make build-all tag=<tag>"; \
exit 1; \ exit 1; \
@ -12,6 +12,9 @@ build-arm:
build-x86: build-x86:
docker buildx build --platform linux/amd64 -t fosrl/pangolin:latest . docker buildx build --platform linux/amd64 -t fosrl/pangolin:latest .
build-x86-ecr:
docker buildx build --platform linux/amd64 -t 216989133116.dkr.ecr.us-east-1.amazonaws.com/pangolin:latest --push .
build: build:
docker build -t fosrl/pangolin:latest . docker build -t fosrl/pangolin:latest .

View file

@ -1,5 +1,11 @@
# Pangolin # Pangolin
[![Documentation](https://img.shields.io/badge/docs-latest-blue.svg?style=flat-square)](https://docs.fossorial.io/)
[![Docker](https://img.shields.io/docker/pulls/fosrl/pangolin?style=flat-square)](https://hub.docker.com/r/fosrl/pangolin)
![Stars](https://img.shields.io/github/stars/fosrl/pangolin?style=flat-square)
[![Discord](https://img.shields.io/discord/1325658630518865980?logo=discord&style=flat-square)](https://discord.gg/HCJR8Xhme4)
[![Youtube](https://img.shields.io/badge/YouTube-red?logo=youtube&logoColor=white&style=flat-square)](https://www.youtube.com/@fossorial-app)
Pangolin is a self-hosted tunneled reverse proxy management server with identity and access management, designed to securely expose private resources through use with the Traefik reverse proxy and WireGuard tunnel clients like Newt. With Pangolin, you retain full control over your infrastructure while providing a user-friendly and feature-rich solution for managing proxies, authentication, and access, and simplifying complex network setups, all with a clean and simple UI. Pangolin is a self-hosted tunneled reverse proxy management server with identity and access management, designed to securely expose private resources through use with the Traefik reverse proxy and WireGuard tunnel clients like Newt. With Pangolin, you retain full control over your infrastructure while providing a user-friendly and feature-rich solution for managing proxies, authentication, and access, and simplifying complex network setups, all with a clean and simple UI.
### Installation and Documentation ### Installation and Documentation

View file

@ -21,8 +21,9 @@ traefik:
gerbil: gerbil:
start_port: 51820 start_port: 51820
base_endpoint: localhost base_endpoint: localhost
block_size: 16 block_size: 24
subnet_group: 10.0.0.0/8 site_block_size: 30
subnet_group: 100.89.137.0/20
use_subdomain: true use_subdomain: true
rate_limits: rate_limits:

View file

@ -2,7 +2,13 @@
all: build all: build
build: build:
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o installer CGO_ENABLED=0 go build -o bin/installer
release:
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o bin/installer_linux_amd64
CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -o bin/installer_linux_arm64
clean: clean:
rm installer rm bin/installer
rm bin/installer_linux_amd64
rm bin/installer_linux_arm64

View file

@ -1,6 +1,6 @@
app: app:
dashboard_url: https://{{.Domain}} dashboard_url: https://{{.DashboardDomain}}
base_domain: {{.Domain}} base_domain: {{.BaseDomain}}
log_level: info log_level: info
save_logs: false save_logs: false
@ -21,10 +21,11 @@ traefik:
gerbil: gerbil:
start_port: 51820 start_port: 51820
base_endpoint: {{.Domain}} base_endpoint: {{.DashboardDomain}}
use_subdomain: false use_subdomain: false
block_size: 16 block_size: 24
subnet_group: 10.0.0.0/8 site_block_size: 30
subnet_group: 100.89.137.0/20
rate_limits: rate_limits:
global: global:

View file

@ -8,7 +8,7 @@ http:
routers: routers:
# HTTP to HTTPS redirect router # HTTP to HTTPS redirect router
main-app-router-redirect: main-app-router-redirect:
rule: "Host(`{{.Domain}}`)" rule: "Host(`{{.DashboardDomain}}`)"
service: next-service service: next-service
entryPoints: entryPoints:
- web - web
@ -17,7 +17,7 @@ http:
# Next.js router (handles everything except API and WebSocket paths) # Next.js router (handles everything except API and WebSocket paths)
next-router: next-router:
rule: "Host(`{{.Domain}}`) && !PathPrefix(`/api/v1`)" rule: "Host(`{{.DashboardDomain}}`) && !PathPrefix(`/api/v1`)"
service: next-service service: next-service
entryPoints: entryPoints:
- websecure - websecure
@ -26,7 +26,7 @@ http:
# API router (handles /api/v1 paths) # API router (handles /api/v1 paths)
api-router: api-router:
rule: "Host(`{{.Domain}}`) && PathPrefix(`/api/v1`)" rule: "Host(`{{.DashboardDomain}}`) && PathPrefix(`/api/v1`)"
service: api-service service: api-service
entryPoints: entryPoints:
- websecure - websecure
@ -35,7 +35,7 @@ http:
# WebSocket router # WebSocket router
ws-router: ws-router:
rule: "Host(`{{.Domain}}`)" rule: "Host(`{{.DashboardDomain}}`)"
service: api-service service: api-service
entryPoints: entryPoints:
- websecure - websecure

View file

@ -18,7 +18,8 @@ import (
var configFiles embed.FS var configFiles embed.FS
type Config struct { type Config struct {
Domain string `yaml:"domain"` BaseDomain string `yaml:"baseDomain"`
DashboardDomain string `yaml:"dashboardUrl"`
LetsEncryptEmail string `yaml:"letsEncryptEmail"` LetsEncryptEmail string `yaml:"letsEncryptEmail"`
AdminUserEmail string `yaml:"adminUserEmail"` AdminUserEmail string `yaml:"adminUserEmail"`
AdminUserPassword string `yaml:"adminUserPassword"` AdminUserPassword string `yaml:"adminUserPassword"`
@ -44,7 +45,10 @@ func main() {
// check if there is already a config file // check if there is already a config file
if _, err := os.Stat("config/config.yml"); err != nil { if _, err := os.Stat("config/config.yml"); err != nil {
config := collectUserInput(reader) config := collectUserInput(reader)
createConfigFiles(config) if err := createConfigFiles(config); err != nil {
fmt.Printf("Error creating config files: %v\n", err)
os.Exit(1)
}
if !isDockerInstalled() && runtime.GOOS == "linux" { if !isDockerInstalled() && runtime.GOOS == "linux" {
if shouldInstallDocker() { if shouldInstallDocker() {
@ -102,12 +106,13 @@ func collectUserInput(reader *bufio.Reader) Config {
// Basic configuration // Basic configuration
fmt.Println("\n=== Basic Configuration ===") fmt.Println("\n=== Basic Configuration ===")
config.Domain = readString(reader, "Enter your domain name", "") 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.LetsEncryptEmail = readString(reader, "Enter email for Let's Encrypt certificates", "")
// Admin user configuration // Admin user configuration
fmt.Println("\n=== Admin User Configuration ===") fmt.Println("\n=== Admin User Configuration ===")
config.AdminUserEmail = readString(reader, "Enter admin user email", "admin@"+config.Domain) config.AdminUserEmail = readString(reader, "Enter admin user email", "admin@"+config.BaseDomain)
for { for {
config.AdminUserPassword = readString(reader, "Enter admin user password", "") config.AdminUserPassword = readString(reader, "Enter admin user password", "")
if valid, message := validatePassword(config.AdminUserPassword); valid { if valid, message := validatePassword(config.AdminUserPassword); valid {
@ -140,10 +145,14 @@ func collectUserInput(reader *bufio.Reader) Config {
} }
// Validate required fields // Validate required fields
if config.Domain == "" { if config.BaseDomain == "" {
fmt.Println("Error: Domain name is required") fmt.Println("Error: Domain name is required")
os.Exit(1) os.Exit(1)
} }
if config.DashboardDomain == "" {
fmt.Println("Error: Dashboard Domain name is required")
os.Exit(1)
}
if config.LetsEncryptEmail == "" { if config.LetsEncryptEmail == "" {
fmt.Println("Error: Let's Encrypt email is required") fmt.Println("Error: Let's Encrypt email is required")
os.Exit(1) os.Exit(1)
@ -269,8 +278,26 @@ func createConfigFiles(config Config) error {
return fmt.Errorf("error walking config files: %v", err) return fmt.Errorf("error walking config files: %v", err)
} }
// move the docker-compose.yml file to the root directory // get the current directory
os.Rename("config/docker-compose.yml", "docker-compose.yml") dir, err := os.Getwd()
if err != nil {
return fmt.Errorf("failed to get current directory: %v", err)
}
sourcePath := filepath.Join(dir, "config/docker-compose.yml")
destPath := filepath.Join(dir, "docker-compose.yml")
// Check if source file exists
if _, err := os.Stat(sourcePath); err != nil {
return fmt.Errorf("source docker-compose.yml not found: %v", err)
}
// Try to move the file
err = os.Rename(sourcePath, destPath)
if err != nil {
return fmt.Errorf("failed to move docker-compose.yml from %s to %s: %v",
sourcePath, destPath, err)
}
return nil return nil
} }

View file

@ -1,6 +1,6 @@
{ {
"name": "@fosrl/pangolin", "name": "@fosrl/pangolin",
"version": "1.0.0-beta.2", "version": "1.0.0-beta.3",
"private": true, "private": true,
"type": "module", "type": "module",
"description": "Tunneled Reverse Proxy Management Server with Identity and Access Control and Dashboard UI", "description": "Tunneled Reverse Proxy Management Server with Identity and Access Control and Dashboard UI",

View file

@ -45,7 +45,8 @@ const environmentSchema = z.object({
base_endpoint: z.string().transform((url) => url.toLowerCase()), base_endpoint: z.string().transform((url) => url.toLowerCase()),
use_subdomain: z.boolean(), use_subdomain: z.boolean(),
subnet_group: z.string(), subnet_group: z.string(),
block_size: z.number().positive().gt(0) block_size: z.number().positive().gt(0),
site_block_size: z.number().positive().gt(0)
}), }),
rate_limits: z.object({ rate_limits: z.object({
global: z.object({ global: z.object({

View file

@ -8,6 +8,7 @@ import createHttpError from "http-errors";
import logger from "@server/logger"; import logger from "@server/logger";
import { findNextAvailableCidr } from "@server/lib/ip"; import { findNextAvailableCidr } from "@server/lib/ip";
import { generateId } from "@server/auth/sessions/app"; import { generateId } from "@server/auth/sessions/app";
import config from "@server/lib/config";
export type PickSiteDefaultsResponse = { export type PickSiteDefaultsResponse = {
exitNodeId: number; exitNodeId: number;
@ -51,9 +52,9 @@ export async function pickSiteDefaults(
// TODO: we need to lock this subnet for some time so someone else does not take it // TODO: we need to lock this subnet for some time so someone else does not take it
let subnets = sitesQuery.map((site) => site.subnet); let subnets = sitesQuery.map((site) => site.subnet);
// exclude the exit node address by replacing after the / with a /28 // exclude the exit node address by replacing after the / with a site block size
subnets.push(exitNode.address.replace(/\/\d+$/, "/29")); subnets.push(exitNode.address.replace(/\/\d+$/, `/${config.getRawConfig().gerbil.site_block_size}`));
const newSubnet = findNextAvailableCidr(subnets, 29, exitNode.address); const newSubnet = findNextAvailableCidr(subnets, config.getRawConfig().gerbil.site_block_size, exitNode.address);
if (!newSubnet) { if (!newSubnet) {
return next( return next(
createHttpError( createHttpError(

View file

@ -72,6 +72,16 @@ export async function acceptInvite(
const { user, session } = await verifySession(req); const { user, session } = await verifySession(req);
// at this point we know the user exists
if (!user) {
return next(
createHttpError(
HttpCode.UNAUTHORIZED,
"You must be logged in to accept an invite"
)
);
}
if (user && user.email !== existingInvite.email) { if (user && user.email !== existingInvite.email) {
return next( return next(
createHttpError( createHttpError(

View file

@ -8,6 +8,7 @@ import { __DIRNAME } from "@server/lib/consts";
import { loadAppVersion } from "@server/lib/loadAppVersion"; import { loadAppVersion } from "@server/lib/loadAppVersion";
import m1 from "./scripts/1.0.0-beta1"; import m1 from "./scripts/1.0.0-beta1";
import m2 from "./scripts/1.0.0-beta2"; import m2 from "./scripts/1.0.0-beta2";
import m3 from "./scripts/1.0.0-beta3";
// THIS CANNOT IMPORT ANYTHING FROM THE SERVER // THIS CANNOT IMPORT ANYTHING FROM THE SERVER
// EXCEPT FOR THE DATABASE AND THE SCHEMA // EXCEPT FOR THE DATABASE AND THE SCHEMA
@ -15,7 +16,8 @@ import m2 from "./scripts/1.0.0-beta2";
// Define the migration list with versions and their corresponding functions // Define the migration list with versions and their corresponding functions
const migrations = [ const migrations = [
{ version: "1.0.0-beta.1", run: m1 }, { version: "1.0.0-beta.1", run: m1 },
{ version: "1.0.0-beta.2", run: m2 } { version: "1.0.0-beta.2", run: m2 },
{ version: "1.0.0-beta.3", run: m3 }
// Add new migrations here as they are created // Add new migrations here as they are created
] as const; ] as const;

View file

@ -0,0 +1,42 @@
import { configFilePath1, configFilePath2 } from "@server/lib/consts";
import fs from "fs";
import yaml from "js-yaml";
export default async function migration() {
console.log("Running setup script 1.0.0-beta.3...");
// Determine which config file exists
const filePaths = [configFilePath1, configFilePath2];
let filePath = "";
for (const path of filePaths) {
if (fs.existsSync(path)) {
filePath = path;
break;
}
}
if (!filePath) {
throw new Error(
`No config file found (expected config.yml or config.yaml).`
);
}
// Read and parse the YAML file
let rawConfig: any;
const fileContents = fs.readFileSync(filePath, "utf8");
rawConfig = yaml.load(fileContents);
// Validate the structure
if (!rawConfig.gerbil) {
throw new Error(`Invalid config file: gerbil is missing.`);
}
// Update the config
rawConfig.gerbil.site_block_size = 29;
// Write the updated YAML back to the file
const updatedYaml = yaml.dump(rawConfig);
fs.writeFileSync(filePath, updatedYaml, "utf8");
console.log("Done.");
}

View file

@ -25,7 +25,7 @@ export default async function OrgLayout(props: {
const user = await getUser(); const user = await getUser();
if (!user) { if (!user) {
redirect(`/?redirect=/${orgId}`); redirect(`/`);
} }
try { try {

View file

@ -26,7 +26,7 @@ export default async function GeneralSettingsPage({
const user = await getUser(); const user = await getUser();
if (!user) { if (!user) {
redirect(`/?redirect=/${orgId}/settings/general`); redirect(`/`);
} }
let orgUser = null; let orgUser = null;

View file

@ -61,7 +61,7 @@ export default async function SettingsLayout(props: SettingsLayoutProps) {
const user = await getUser(); const user = await getUser();
if (!user) { if (!user) {
redirect(`/?redirect=/${params.orgId}/`); redirect(`/`);
} }
const cookie = await authCookieHeader(); const cookie = await authCookieHeader();

View file

@ -62,6 +62,7 @@ import {
SettingsSectionFooter SettingsSectionFooter
} from "@app/components/Settings"; } from "@app/components/Settings";
import { SwitchInput } from "@app/components/SwitchInput"; import { SwitchInput } from "@app/components/SwitchInput";
import { useSiteContext } from "@app/hooks/useSiteContext";
// Regular expressions for validation // Regular expressions for validation
const DOMAIN_REGEX = const DOMAIN_REGEX =
@ -351,7 +352,7 @@ export default function ReverseProxyTargets(props: {
}, },
{ {
accessorKey: "ip", accessorKey: "ip",
header: "IP Address", header: "IP / Hostname",
cell: ({ row }) => ( cell: ({ row }) => (
<Input <Input
defaultValue={row.original.ip} defaultValue={row.original.ip}
@ -528,11 +529,23 @@ export default function ReverseProxyTargets(props: {
name="ip" name="ip"
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel>IP Address</FormLabel> <FormLabel>IP / Hostname</FormLabel>
<FormControl> <FormControl>
<Input id="ip" {...field} /> <Input id="ip" {...field} />
</FormControl> </FormControl>
<FormMessage /> <FormMessage />
{site?.type === "newt" ? (
<FormDescription>
This is the IP or hostname
of the target service on
your network.
</FormDescription>
) : site?.type === "wireguard" ? (
<FormDescription>
This is the IP of the
WireGuard peer.
</FormDescription>
) : null}
</FormItem> </FormItem>
)} )}
/> />
@ -551,6 +564,19 @@ export default function ReverseProxyTargets(props: {
/> />
</FormControl> </FormControl>
<FormMessage /> <FormMessage />
{site?.type === "newt" ? (
<FormDescription>
This is the port of the
target service on your
network.
</FormDescription>
) : site?.type === "wireguard" ? (
<FormDescription>
This is the port exposed on
an address on the WireGuard
network.
</FormDescription>
) : null}
</FormItem> </FormItem>
)} )}
/> />

View file

@ -2,13 +2,12 @@ import ResourceProvider from "@app/providers/ResourceProvider";
import { internal } from "@app/lib/api"; import { internal } from "@app/lib/api";
import { import {
GetResourceAuthInfoResponse, GetResourceAuthInfoResponse,
GetResourceResponse, GetResourceResponse
} from "@server/routers/resource"; } from "@server/routers/resource";
import { AxiosResponse } from "axios"; import { AxiosResponse } from "axios";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { authCookieHeader } from "@app/lib/api/cookies"; import { authCookieHeader } from "@app/lib/api/cookies";
import { SidebarSettings } from "@app/components/SidebarSettings"; import { SidebarSettings } from "@app/components/SidebarSettings";
import { Cloud, Settings, Shield } from "lucide-react";
import SettingsSectionTitle from "@app/components/SettingsSectionTitle"; import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
import { GetOrgResponse } from "@server/routers/org"; import { GetOrgResponse } from "@server/routers/org";
import OrgProvider from "@app/providers/OrgProvider"; import OrgProvider from "@app/providers/OrgProvider";
@ -20,7 +19,7 @@ import {
BreadcrumbLink, BreadcrumbLink,
BreadcrumbList, BreadcrumbList,
BreadcrumbPage, BreadcrumbPage,
BreadcrumbSeparator, BreadcrumbSeparator
} from "@app/components/ui/breadcrumb"; } from "@app/components/ui/breadcrumb";
import Link from "next/link"; import Link from "next/link";
@ -39,7 +38,7 @@ export default async function ResourceLayout(props: ResourceLayoutProps) {
try { try {
const res = await internal.get<AxiosResponse<GetResourceResponse>>( const res = await internal.get<AxiosResponse<GetResourceResponse>>(
`/resource/${params.resourceId}`, `/resource/${params.resourceId}`,
await authCookieHeader(), await authCookieHeader()
); );
resource = res.data.data; resource = res.data.data;
} catch { } catch {
@ -68,8 +67,8 @@ export default async function ResourceLayout(props: ResourceLayoutProps) {
const getOrg = cache(async () => const getOrg = cache(async () =>
internal.get<AxiosResponse<GetOrgResponse>>( internal.get<AxiosResponse<GetOrgResponse>>(
`/org/${params.orgId}`, `/org/${params.orgId}`,
await authCookieHeader(), await authCookieHeader()
), )
); );
const res = await getOrg(); const res = await getOrg();
org = res.data.data; org = res.data.data;
@ -84,19 +83,19 @@ export default async function ResourceLayout(props: ResourceLayoutProps) {
const sidebarNavItems = [ const sidebarNavItems = [
{ {
title: "General", title: "General",
href: `/{orgId}/settings/resources/{resourceId}/general`, href: `/{orgId}/settings/resources/{resourceId}/general`
// icon: <Settings className="w-4 h-4" />, // icon: <Settings className="w-4 h-4" />,
}, },
{ {
title: "Connectivity", title: "Connectivity",
href: `/{orgId}/settings/resources/{resourceId}/connectivity`, href: `/{orgId}/settings/resources/{resourceId}/connectivity`
// icon: <Cloud className="w-4 h-4" />, // icon: <Cloud className="w-4 h-4" />,
}, },
{ {
title: "Authentication", title: "Authentication",
href: `/{orgId}/settings/resources/{resourceId}/authentication`, href: `/{orgId}/settings/resources/{resourceId}/authentication`
// icon: <Shield className="w-4 h-4" />, // icon: <Shield className="w-4 h-4" />,
}, }
]; ];
return ( return (

View file

@ -21,7 +21,7 @@ export default async function AuthLayout({ children }: AuthLayoutProps) {
<> <>
{user && ( {user && (
<UserProvider user={user}> <UserProvider user={user}>
<div> <div className="p-3">
<ProfileIcon /> <ProfileIcon />
</div> </div>
</UserProvider> </UserProvider>

View file

@ -13,6 +13,7 @@ import { useEnvContext } from "@app/hooks/useEnvContext";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { useEffect } from "react"; import { useEffect } from "react";
import Image from "next/image"; import Image from "next/image";
import { cleanRedirect } from "@app/lib/cleanRedirect";
type DashboardLoginFormProps = { type DashboardLoginFormProps = {
redirect?: string; redirect?: string;
@ -57,10 +58,9 @@ export default function DashboardLoginForm({
<LoginForm <LoginForm
redirect={redirect} redirect={redirect}
onLogin={() => { onLogin={() => {
if (redirect && redirect.includes("http")) { if (redirect) {
window.location.href = redirect; const safe = cleanRedirect(redirect);
} else if (redirect) { router.push(safe);
router.push(redirect);
} else { } else {
router.push("/"); router.push("/");
} }

View file

@ -5,6 +5,7 @@ import { cache } from "react";
import DashboardLoginForm from "./DashboardLoginForm"; import DashboardLoginForm from "./DashboardLoginForm";
import { Mail } from "lucide-react"; import { Mail } from "lucide-react";
import { pullEnv } from "@app/lib/pullEnv"; import { pullEnv } from "@app/lib/pullEnv";
import { cleanRedirect } from "@app/lib/cleanRedirect";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
@ -25,6 +26,11 @@ export default async function Page(props: {
redirect("/"); redirect("/");
} }
let redirectUrl: string | undefined = undefined;
if (searchParams.redirect) {
redirectUrl = cleanRedirect(searchParams.redirect as string);
}
return ( return (
<> <>
{isInvite && ( {isInvite && (
@ -42,16 +48,16 @@ export default async function Page(props: {
</div> </div>
)} )}
<DashboardLoginForm redirect={searchParams.redirect as string} /> <DashboardLoginForm redirect={redirectUrl} />
{(!signUpDisabled || isInvite) && ( {(!signUpDisabled || isInvite) && (
<p className="text-center text-muted-foreground mt-4"> <p className="text-center text-muted-foreground mt-4">
Don't have an account?{" "} Don't have an account?{" "}
<Link <Link
href={ href={
!searchParams.redirect !redirectUrl
? `/auth/signup` ? `/auth/signup`
: `/auth/signup?redirect=${searchParams.redirect}` : `/auth/signup?redirect=${redirectUrl}`
} }
className="underline" className="underline"
> >

View file

@ -43,6 +43,7 @@ import { createApiClient } from "@app/lib/api";
import { useEnvContext } from "@app/hooks/useEnvContext"; import { useEnvContext } from "@app/hooks/useEnvContext";
import { REGEXP_ONLY_DIGITS_AND_CHARS } from "input-otp"; import { REGEXP_ONLY_DIGITS_AND_CHARS } from "input-otp";
import { passwordSchema } from "@server/auth/passwordSchema"; import { passwordSchema } from "@server/auth/passwordSchema";
import { cleanRedirect } from "@app/lib/cleanRedirect";
const requestSchema = z.object({ const requestSchema = z.object({
email: z.string().email() email: z.string().email()
@ -186,11 +187,9 @@ export default function ResetPasswordForm({
setSuccessMessage("Password reset successfully! Back to login..."); setSuccessMessage("Password reset successfully! Back to login...");
setTimeout(() => { setTimeout(() => {
if (redirect && redirect.includes("http")) {
window.location.href = redirect;
}
if (redirect) { if (redirect) {
router.push(redirect); const safe = cleanRedirect(redirect);
router.push(safe);
} else { } else {
router.push("/login"); router.push("/login");
} }

View file

@ -3,6 +3,7 @@ import { redirect } from "next/navigation";
import { cache } from "react"; import { cache } from "react";
import ResetPasswordForm from "./ResetPasswordForm"; import ResetPasswordForm from "./ResetPasswordForm";
import Link from "next/link"; import Link from "next/link";
import { cleanRedirect } from "@app/lib/cleanRedirect";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
@ -21,6 +22,11 @@ export default async function Page(props: {
redirect("/"); redirect("/");
} }
let redirectUrl: string | undefined = undefined;
if (searchParams.redirect) {
redirectUrl = cleanRedirect(searchParams.redirect);
}
return ( return (
<> <>
<ResetPasswordForm <ResetPasswordForm
@ -34,7 +40,7 @@ export default async function Page(props: {
href={ href={
!searchParams.redirect !searchParams.redirect
? `/auth/signup` ? `/auth/signup`
: `/auth/signup?redirect=${searchParams.redirect}` : `/auth/signup?redirect=${redirectUrl}`
} }
className="underline" className="underline"
> >

View file

@ -481,11 +481,7 @@ export default function ResourceAuthPortal(props: ResourceAuthPortalProps) {
className={`${numMethods <= 1 ? "mt-0" : ""}`} className={`${numMethods <= 1 ? "mt-0" : ""}`}
> >
<LoginForm <LoginForm
redirect={ redirect={`/auth/resource/${props.resource.id}`}
typeof window !== "undefined"
? window.location.href
: ""
}
onLogin={async () => onLogin={async () =>
await handleSSOAuth() await handleSSOAuth()
} }

View file

@ -55,7 +55,17 @@ export default async function ResourceAuthPage(props: {
); );
} }
const redirectUrl = searchParams.redirect || authInfo.url; let redirectUrl = authInfo.url;
if (searchParams.redirect) {
try {
const serverResourceHost = new URL(authInfo.url).host;
const redirectHost = new URL(searchParams.redirect).host;
if (serverResourceHost === redirectHost) {
redirectUrl = searchParams.redirect;
}
} catch (e) {}
}
const hasAuth = const hasAuth =
authInfo.password || authInfo.password ||

View file

@ -30,6 +30,7 @@ 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 Image from "next/image"; import Image from "next/image";
import { cleanRedirect } from "@app/lib/cleanRedirect";
type SignupFormProps = { type SignupFormProps = {
redirect?: string; redirect?: string;
@ -92,17 +93,17 @@ export default function SignupForm({
if (res.data?.data?.emailVerificationRequired) { if (res.data?.data?.emailVerificationRequired) {
if (redirect) { if (redirect) {
router.push(`/auth/verify-email?redirect=${redirect}`); const safe = cleanRedirect(redirect);
router.push(`/auth/verify-email?redirect=${safe}`);
} else { } else {
router.push("/auth/verify-email"); router.push("/auth/verify-email");
} }
return; return;
} }
if (redirect && redirect.includes("http")) { if (redirect) {
window.location.href = redirect; const safe = cleanRedirect(redirect);
} else if (redirect) { router.push(safe);
router.push(redirect);
} else { } else {
router.push("/"); router.push("/");
} }

View file

@ -1,5 +1,6 @@
import SignupForm from "@app/app/auth/signup/SignupForm"; import SignupForm from "@app/app/auth/signup/SignupForm";
import { verifySession } from "@app/lib/auth/verifySession"; import { verifySession } from "@app/lib/auth/verifySession";
import { cleanRedirect } from "@app/lib/cleanRedirect";
import { pullEnv } from "@app/lib/pullEnv"; import { pullEnv } from "@app/lib/pullEnv";
import { Mail } from "lucide-react"; import { Mail } from "lucide-react";
import Link from "next/link"; import Link from "next/link";
@ -41,6 +42,11 @@ export default async function Page(props: {
} }
} }
let redirectUrl: string | undefined;
if (searchParams.redirect) {
redirectUrl = cleanRedirect(searchParams.redirect);
}
return ( return (
<> <>
{isInvite && ( {isInvite && (
@ -59,7 +65,7 @@ export default async function Page(props: {
)} )}
<SignupForm <SignupForm
redirect={searchParams.redirect as string} redirect={redirectUrl}
inviteToken={inviteToken} inviteToken={inviteToken}
inviteId={inviteId} inviteId={inviteId}
/> />
@ -68,9 +74,9 @@ export default async function Page(props: {
Already have an account?{" "} Already have an account?{" "}
<Link <Link
href={ href={
!searchParams.redirect !redirectUrl
? `/auth/login` ? `/auth/login`
: `/auth/login?redirect=${searchParams.redirect}` : `/auth/login?redirect=${redirectUrl}`
} }
className="underline" className="underline"
> >

View file

@ -36,6 +36,7 @@ import { useRouter } from "next/navigation";
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 { cleanRedirect } from "@app/lib/cleanRedirect";
const FormSchema = z.object({ const FormSchema = z.object({
email: z.string().email({ message: "Invalid email address" }), email: z.string().email({ message: "Invalid email address" }),
@ -91,11 +92,9 @@ export default function VerifyEmailForm({
"Email successfully verified! Redirecting you..." "Email successfully verified! Redirecting you..."
); );
setTimeout(() => { setTimeout(() => {
if (redirect && redirect.includes("http")) {
window.location.href = redirect;
}
if (redirect) { if (redirect) {
router.push(redirect); const safe = cleanRedirect(redirect);
router.push(safe);
} else { } else {
router.push("/"); router.push("/");
} }

View file

@ -1,5 +1,6 @@
import VerifyEmailForm from "@app/app/auth/verify-email/VerifyEmailForm"; import VerifyEmailForm from "@app/app/auth/verify-email/VerifyEmailForm";
import { verifySession } from "@app/lib/auth/verifySession"; import { verifySession } from "@app/lib/auth/verifySession";
import { cleanRedirect } from "@app/lib/cleanRedirect";
import { pullEnv } from "@app/lib/pullEnv"; import { pullEnv } from "@app/lib/pullEnv";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { cache } from "react"; import { cache } from "react";
@ -27,11 +28,16 @@ export default async function Page(props: {
redirect("/"); redirect("/");
} }
let redirectUrl: string | undefined;
if (searchParams.redirect) {
redirectUrl = cleanRedirect(searchParams.redirect as string);
}
return ( return (
<> <>
<VerifyEmailForm <VerifyEmailForm
email={user.email} email={user.email}
redirect={searchParams.redirect as string} redirect={redirectUrl}
/> />
</> </>
); );

View file

@ -14,7 +14,7 @@ import { XCircle } from "lucide-react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
type InviteStatusCardProps = { type InviteStatusCardProps = {
type: "rejected" | "wrong_user" | "user_does_not_exist"; type: "rejected" | "wrong_user" | "user_does_not_exist" | "not_logged_in";
token: string; token: string;
}; };

View file

@ -60,6 +60,8 @@ export default async function InvitePage(props: {
) )
) { ) {
return "user_does_not_exist"; return "user_does_not_exist";
} else if (error.includes("You must be logged in to accept an invite")) {
return "not_logged_in";
} else { } else {
return "rejected"; return "rejected";
} }
@ -71,6 +73,10 @@ export default async function InvitePage(props: {
redirect(`/auth/signup?redirect=/invite?token=${params.token}`); redirect(`/auth/signup?redirect=/invite?token=${params.token}`);
} }
if (!user && type === "not_logged_in") {
redirect(`/auth/login?redirect=/invite?token=${params.token}`);
}
return ( return (
<> <>
<InviteStatusCard type={type} token={tokenParam} /> <InviteStatusCard type={type} token={tokenParam} />

View file

@ -6,6 +6,8 @@ import { ThemeProvider } from "@app/providers/ThemeProvider";
import EnvProvider from "@app/providers/EnvProvider"; import EnvProvider from "@app/providers/EnvProvider";
import { Separator } from "@app/components/ui/separator"; import { Separator } from "@app/components/ui/separator";
import { pullEnv } from "@app/lib/pullEnv"; import { pullEnv } from "@app/lib/pullEnv";
import { BookOpenText } from "lucide-react";
import Image from "next/image";
export const metadata: Metadata = { export const metadata: Metadata = {
title: `Dashboard - Pangolin`, title: `Dashboard - Pangolin`,
@ -38,10 +40,10 @@ export default async function RootLayout({
<div className="flex-grow">{children}</div> <div className="flex-grow">{children}</div>
{/* Footer */} {/* Footer */}
<footer className="w-full mt-12 py-3 mb-6"> <footer className="w-full mt-12 py-3 mb-6 px-4">
<div className="container mx-auto flex flex-wrap justify-center items-center h-4 space-x-4 text-sm text-neutral-400 select-none"> <div className="container mx-auto flex flex-wrap justify-center items-center h-3 space-x-4 text-sm text-neutral-400 dark:text-neutral-600 select-none">
<div className="whitespace-nowrap"> <div className="flex items-center space-x-2 whitespace-nowrap">
Pangolin <span>Pangolin</span>
</div> </div>
<Separator orientation="vertical" /> <Separator orientation="vertical" />
<div className="whitespace-nowrap"> <div className="whitespace-nowrap">
@ -60,7 +62,7 @@ export default async function RootLayout({
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24" viewBox="0 0 24 24"
fill="currentColor" fill="currentColor"
className="w-4 h-4" className="w-3 h-3"
> >
<path d="M12 0C5.37 0 0 5.373 0 12c0 5.303 3.438 9.8 8.207 11.385.6.11.82-.26.82-.577v-2.17c-3.338.726-4.042-1.61-4.042-1.61-.546-1.385-1.333-1.755-1.333-1.755-1.09-.744.082-.73.082-.73 1.205.085 1.84 1.24 1.84 1.24 1.07 1.835 2.807 1.305 3.492.997.107-.775.42-1.305.763-1.605-2.665-.305-5.467-1.335-5.467-5.93 0-1.31.468-2.382 1.236-3.22-.123-.303-.535-1.523.117-3.176 0 0 1.008-.322 3.3 1.23a11.52 11.52 0 013.006-.403c1.02.005 2.045.137 3.006.403 2.29-1.552 3.295-1.23 3.295-1.23.654 1.653.242 2.873.12 3.176.77.838 1.235 1.91 1.235 3.22 0 4.605-2.805 5.623-5.475 5.92.43.37.814 1.1.814 2.22v3.293c0 .32.217.693.825.576C20.565 21.795 24 17.298 24 12 24 5.373 18.627 0 12 0z" /> <path d="M12 0C5.37 0 0 5.373 0 12c0 5.303 3.438 9.8 8.207 11.385.6.11.82-.26.82-.577v-2.17c-3.338.726-4.042-1.61-4.042-1.61-.546-1.385-1.333-1.755-1.333-1.755-1.09-.744.082-.73.082-.73 1.205.085 1.84 1.24 1.84 1.24 1.07 1.835 2.807 1.305 3.492.997.107-.775.42-1.305.763-1.605-2.665-.305-5.467-1.335-5.467-5.93 0-1.31.468-2.382 1.236-3.22-.123-.303-.535-1.523.117-3.176 0 0 1.008-.322 3.3 1.23a11.52 11.52 0 013.006-.403c1.02.005 2.045.137 3.006.403 2.29-1.552 3.295-1.23 3.295-1.23.654 1.653.242 2.873.12 3.176.77.838 1.235 1.91 1.235 3.22 0 4.605-2.805 5.623-5.475 5.92.43.37.814 1.1.814 2.22v3.293c0 .32.217.693.825.576C20.565 21.795 24 17.298 24 12 24 5.373 18.627 0 12 0z" />
</svg> </svg>
@ -70,10 +72,11 @@ export default async function RootLayout({
href="https://docs.fossorial.io/Pangolin/overview" href="https://docs.fossorial.io/Pangolin/overview"
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
aria-label="GitHub" aria-label="Documentation"
className="flex items-center space-x-3 whitespace-nowrap" className="flex items-center space-x-3 whitespace-nowrap"
> >
<span>Docs</span> <span>Documentation</span>
<BookOpenText className="w-3 h-3" />
</a> </a>
{version && ( {version && (
<> <>

View file

@ -11,6 +11,7 @@ import { redirect } from "next/navigation";
import { cache } from "react"; import { cache } from "react";
import OrganizationLanding from "./components/OrganizationLanding"; import OrganizationLanding from "./components/OrganizationLanding";
import { pullEnv } from "@app/lib/pullEnv"; import { pullEnv } from "@app/lib/pullEnv";
import { cleanRedirect } from "@app/lib/cleanRedirect";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
@ -29,7 +30,8 @@ export default async function Page(props: {
if (!user) { if (!user) {
if (params.redirect) { if (params.redirect) {
redirect(`/auth/login?redirect=${params.redirect}`); const safe = cleanRedirect(params.redirect);
redirect(`/auth/login?redirect=${safe}`);
} else { } else {
redirect(`/auth/login`); redirect(`/auth/login`);
} }
@ -40,7 +42,8 @@ export default async function Page(props: {
env.flags.emailVerificationRequired env.flags.emailVerificationRequired
) { ) {
if (params.redirect) { if (params.redirect) {
redirect(`/auth/verify-email?redirect=${params.redirect}`); const safe = cleanRedirect(params.redirect);
redirect(`/auth/verify-email?redirect=${safe}`);
} else { } else {
redirect(`/auth/verify-email`); redirect(`/auth/verify-email`);
} }
@ -80,6 +83,7 @@ export default async function Page(props: {
<div className="w-full max-w-md mx-auto md:mt-32 mt-4"> <div className="w-full max-w-md mx-auto md:mt-32 mt-4">
<OrganizationLanding <OrganizationLanding
disableCreateOrg={env.flags.disableUserCreateOrg && !user.serverAdmin}
organizations={orgs.map((org) => ({ organizations={orgs.map((org) => ({
name: org.name, name: org.name,
id: org.orgId id: org.orgId

View file

@ -41,7 +41,7 @@ import Image from 'next/image'
type LoginFormProps = { type LoginFormProps = {
redirect?: string; redirect?: string;
onLogin?: () => void; onLogin?: () => void | Promise<void>;
}; };
const formSchema = z.object({ const formSchema = z.object({

View file

@ -57,6 +57,7 @@ export default function ProfileIcon() {
}) })
.then(() => { .then(() => {
router.push("/auth/login"); router.push("/auth/login");
router.refresh();
}); });
} }

18
src/lib/cleanRedirect.ts Normal file
View file

@ -0,0 +1,18 @@
type PatternConfig = {
name: string;
regex: RegExp;
};
const patterns: PatternConfig[] = [
{ name: "Invite Token", regex: /^\/invite\?token=[a-zA-Z0-9-]+$/ },
{ name: "Setup", regex: /^\/setup$/ },
{ name: "Resource Auth Portal", regex: /^\/auth\/resource\/\d+$/ }
];
export function cleanRedirect(input: string): string {
if (!input || typeof input !== "string") {
return "/";
}
const isAccepted = patterns.some((pattern) => pattern.regex.test(input));
return isAccepted ? input : "/";
}