diff --git a/README.md b/README.md
index 5baef277f..707c4b7cc 100644
--- a/README.md
+++ b/README.md
@@ -25,6 +25,10 @@ _Your own self-hosted zero trust tunnel._
Full Documentation
+ |
+
+ Contact Us
+
@@ -68,41 +72,17 @@ _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.
+- **Docker Compose based setup** for simplified deployment.
- Future-proof installation script for streamlined setup and feature additions.
-- Use your preferred WireGuard client to connect, or use Newt, our custom user space client for the best experience.
+- Use any WireGuard client to connect, or use **Newt, our custom user space client** for the best experience.
### Modular Design
-- Extend functionality with existing [Traefik](https://github.com/traefik/traefik) plugins, such as [Fail2Ban](https://plugins.traefik.io/plugins/628c9ebcffc0cd18356a979f/fail2-ban) or [CrowdSec](https://plugins.traefik.io/plugins/6335346ca4caa9ddeffda116/crowdsec-bouncer-traefik-plugin).
+- Extend functionality with existing [Traefik](https://github.com/traefik/traefik) plugins, such as [CrowdSec](https://plugins.traefik.io/plugins/6335346ca4caa9ddeffda116/crowdsec-bouncer-traefik-plugin) and [Geoblock](github.com/PascalMinder/geoblock).
+ - **Automatically install and configure Crowdsec via Pangolin's installer script.**
- Attach as many sites to the central server as you wish.
-## Screenshots
-
-
-
-
-
-
-
-
-
-
Sites
-
Users
-
Share Link
-
-
-
-
-
-
-
-
Authentication
-
Connectivity
-
-
-
-
+
## Deployment and Usage Example
@@ -112,7 +92,7 @@ _Sites page of Pangolin dashboard (dark mode) showing multiple tunnels connected
> [!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.
+> We are part of the [RackNerd](https://my.racknerd.com/aff.php?aff=13788) affiliate program, so if you purchase through [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**:
@@ -123,10 +103,10 @@ _Sites page of Pangolin dashboard (dark mode) showing multiple tunnels connected
- Install Newt or use another WireGuard client on private sites.
- Automatically establish a connection from these sites to the central server.
-4. **Configure Users & Roles**
+4. **Expose Resources**:
- - Define organizations and invite users.
- - Implement user- or role-based permissions to control resource access.
+ - Add resources to the central server and configure access control rules.
+ - Access these resources securely from anywhere.
**Use Case Example - Bypassing Port Restrictions in Home Lab**:
Imagine private sites where the ISP restricts port forwarding. By connecting these sites to Pangolin via WireGuard, you can securely expose HTTP and HTTPS resources on the private network without any networking complexity.
@@ -134,6 +114,11 @@ _Sites page of Pangolin dashboard (dark mode) showing multiple tunnels connected
**Use Case Example - IoT Networks**:
IoT networks are often fragmented and difficult to manage. By deploying Pangolin on a central server, you can connect all your IoT sites via Newt or another WireGuard client. This creates a simple, secure, and centralized way to access IoT resources without the need for intricate networking setups.
+
+
+
+_Resources page of Pangolin dashboard (dark mode) showing HTTPS and TCP resources with access control rules._
+
## Similar Projects and Inspirations
**Cloudflare Tunnels**:
diff --git a/public/screenshots/auth.png b/public/screenshots/auth.png
deleted file mode 100644
index 1bcc35e6c..000000000
Binary files a/public/screenshots/auth.png and /dev/null differ
diff --git a/public/screenshots/collage.png b/public/screenshots/collage.png
new file mode 100644
index 000000000..2e68c3b9d
Binary files /dev/null and b/public/screenshots/collage.png differ
diff --git a/public/screenshots/connectivity.png b/public/screenshots/connectivity.png
deleted file mode 100644
index 7b6ca88dd..000000000
Binary files a/public/screenshots/connectivity.png and /dev/null differ
diff --git a/public/screenshots/resources.png b/public/screenshots/resources.png
new file mode 100644
index 000000000..bcf1a64e0
Binary files /dev/null and b/public/screenshots/resources.png differ
diff --git a/public/screenshots/share-link.png b/public/screenshots/share-link.png
deleted file mode 100644
index 7515c8fe9..000000000
Binary files a/public/screenshots/share-link.png and /dev/null differ
diff --git a/public/screenshots/sites.png b/public/screenshots/sites.png
index eb82212ff..f950b11ed 100644
Binary files a/public/screenshots/sites.png and b/public/screenshots/sites.png differ
diff --git a/public/screenshots/users.png b/public/screenshots/users.png
deleted file mode 100644
index 08a8f591b..000000000
Binary files a/public/screenshots/users.png and /dev/null differ
diff --git a/server/auth/sessions/app.ts b/server/auth/sessions/app.ts
index 62850453c..bdd593f79 100644
--- a/server/auth/sessions/app.ts
+++ b/server/auth/sessions/app.ts
@@ -129,18 +129,19 @@ export async function invalidateAllSessions(userId: string): Promise {
export function serializeSessionCookie(
token: string,
- isSecure: boolean
+ isSecure: boolean,
+ expiresAt: Date
): string {
if (isSecure) {
- return `${SESSION_COOKIE_NAME}=${token}; HttpOnly; SameSite=Strict; Max-Age=${SESSION_COOKIE_EXPIRES / 1000}; Path=/; Secure; Domain=${COOKIE_DOMAIN}`;
+ return `${SESSION_COOKIE_NAME}=${token}; HttpOnly; SameSite=Lax; Expires=${expiresAt.toUTCString()}; Path=/; Secure; Domain=${COOKIE_DOMAIN}`;
} else {
- return `${SESSION_COOKIE_NAME}=${token}; HttpOnly; SameSite=Lax; Max-Age=${SESSION_COOKIE_EXPIRES}; Path=/;`;
+ return `${SESSION_COOKIE_NAME}=${token}; HttpOnly; SameSite=Lax; Expires=${expiresAt.toUTCString()}; Path=/;`;
}
}
export function createBlankSessionTokenCookie(isSecure: boolean): string {
if (isSecure) {
- return `${SESSION_COOKIE_NAME}=; HttpOnly; SameSite=Strict; Max-Age=0; Path=/; Secure; Domain=${COOKIE_DOMAIN}`;
+ return `${SESSION_COOKIE_NAME}=; HttpOnly; SameSite=Lax; Max-Age=0; Path=/; Secure; Domain=${COOKIE_DOMAIN}`;
} else {
return `${SESSION_COOKIE_NAME}=; HttpOnly; SameSite=Lax; Max-Age=0; Path=/;`;
}
diff --git a/server/auth/sessions/resource.ts b/server/auth/sessions/resource.ts
index 3336ebdeb..65f4674d2 100644
--- a/server/auth/sessions/resource.ts
+++ b/server/auth/sessions/resource.ts
@@ -167,12 +167,19 @@ export function serializeResourceSessionCookie(
cookieName: string,
domain: string,
token: string,
- isHttp: boolean = false
+ isHttp: boolean = false,
+ expiresAt?: Date
): string {
if (!isHttp) {
- return `${cookieName}_s=${token}; HttpOnly; SameSite=Lax; Max-Age=${SESSION_COOKIE_EXPIRES / 1000}; Path=/; Secure; Domain=${"." + domain}`;
+ if (expiresAt === undefined) {
+ return `${cookieName}_s=${token}; HttpOnly; SameSite=Lax; Path=/; Secure; Domain=${"." + domain}`;
+ }
+ return `${cookieName}_s=${token}; HttpOnly; SameSite=Lax; Expires=${expiresAt.toUTCString()}; Path=/; Secure; Domain=${"." + domain}`;
} else {
- return `${cookieName}=${token}; HttpOnly; SameSite=Lax; Max-Age=${SESSION_COOKIE_EXPIRES / 1000}; Path=/; Domain=${"." + domain}`;
+ if (expiresAt === undefined) {
+ return `${cookieName}=${token}; HttpOnly; SameSite=Lax; Path=/; Domain=${"." + domain}`;
+ }
+ return `${cookieName}=${token}; HttpOnly; SameSite=Lax; Expires=${expiresAt.toUTCString()}; Path=/; Domain=${"." + domain}`;
}
}
diff --git a/server/emails/index.ts b/server/emails/index.ts
index c1c2bc878..f0ec0f466 100644
--- a/server/emails/index.ts
+++ b/server/emails/index.ts
@@ -3,6 +3,7 @@ export * from "@server/emails/sendEmail";
import nodemailer from "nodemailer";
import config from "@server/lib/config";
import logger from "@server/logger";
+import SMTPTransport from "nodemailer/lib/smtp-transport";
function createEmailClient() {
const emailConfig = config.getRawConfig().email;
@@ -13,7 +14,7 @@ function createEmailClient() {
return;
}
- return nodemailer.createTransport({
+ const settings = {
host: emailConfig.smtp_host,
port: emailConfig.smtp_port,
secure: emailConfig.smtp_secure || false,
@@ -21,7 +22,15 @@ function createEmailClient() {
user: emailConfig.smtp_user,
pass: emailConfig.smtp_pass
}
- });
+ } as SMTPTransport.Options;
+
+ if (emailConfig.smtp_tls_reject_unathorized !== undefined) {
+ settings.tls = {
+ rejectUnauthorized: emailConfig.smtp_tls_reject_unathorized
+ };
+ }
+
+ return nodemailer.createTransport(settings);
}
export const emailClient = createEmailClient();
diff --git a/server/lib/config.ts b/server/lib/config.ts
index 7d8d9c8b8..2cc397888 100644
--- a/server/lib/config.ts
+++ b/server/lib/config.ts
@@ -160,6 +160,7 @@ const configSchema = z.object({
smtp_user: z.string().optional(),
smtp_pass: z.string().optional(),
smtp_secure: z.boolean().optional(),
+ smtp_tls_reject_unathorized: z.boolean().optional(),
no_reply: z.string().email().optional()
})
.optional(),
@@ -184,7 +185,8 @@ const configSchema = z.object({
disable_signup_without_invite: z.boolean().optional(),
disable_user_create_org: z.boolean().optional(),
allow_raw_resources: z.boolean().optional(),
- allow_base_domain_resources: z.boolean().optional()
+ allow_base_domain_resources: z.boolean().optional(),
+ allow_local_sites: z.boolean().optional()
})
.optional()
});
diff --git a/server/routers/auth/login.ts b/server/routers/auth/login.ts
index 09bd96610..8ed49cf06 100644
--- a/server/routers/auth/login.ts
+++ b/server/routers/auth/login.ts
@@ -137,9 +137,13 @@ export async function login(
}
const token = generateSessionToken();
- await createSession(token, existingUser.userId);
+ const sess = await createSession(token, existingUser.userId);
const isSecure = req.protocol === "https";
- const cookie = serializeSessionCookie(token, isSecure);
+ const cookie = serializeSessionCookie(
+ token,
+ isSecure,
+ new Date(sess.expiresAt)
+ );
res.appendHeader("Set-Cookie", cookie);
diff --git a/server/routers/auth/signup.ts b/server/routers/auth/signup.ts
index 4bb5394e8..cb099162e 100644
--- a/server/routers/auth/signup.ts
+++ b/server/routers/auth/signup.ts
@@ -170,9 +170,13 @@ export async function signup(
// });
const token = generateSessionToken();
- await createSession(token, userId);
+ const sess = await createSession(token, userId);
const isSecure = req.protocol === "https";
- const cookie = serializeSessionCookie(token, isSecure);
+ const cookie = serializeSessionCookie(
+ token,
+ isSecure,
+ new Date(sess.expiresAt)
+ );
res.appendHeader("Set-Cookie", cookie);
if (config.getRawConfig().flags?.require_email_verification) {
diff --git a/server/routers/badger/exchangeSession.ts b/server/routers/badger/exchangeSession.ts
index 093dfbb98..ad8eb9766 100644
--- a/server/routers/badger/exchangeSession.ts
+++ b/server/routers/badger/exchangeSession.ts
@@ -102,6 +102,8 @@ export async function exchangeSession(
const token = generateSessionToken();
+ let expiresAt: number | null = null;
+
if (requestSession.userSessionId) {
const [res] = await db
.select()
@@ -118,6 +120,7 @@ export async function exchangeSession(
expiresAt: res.expiresAt,
sessionLength: SESSION_COOKIE_EXPIRES
});
+ expiresAt = res.expiresAt;
}
} else if (requestSession.accessTokenId) {
const [res] = await db
@@ -140,8 +143,12 @@ export async function exchangeSession(
expiresAt: res.expiresAt,
sessionLength: res.sessionLength
});
+ expiresAt = res.expiresAt;
}
} else {
+ const expires = new Date(
+ Date.now() + SESSION_COOKIE_EXPIRES
+ ).getTime();
await createResourceSession({
token,
resourceId: resource.resourceId,
@@ -152,11 +159,10 @@ export async function exchangeSession(
whitelistId: requestSession.whitelistId,
accessTokenId: requestSession.accessTokenId,
doNotExtend: false,
- expiresAt: new Date(
- Date.now() + SESSION_COOKIE_EXPIRES
- ).getTime(),
+ expiresAt: expires,
sessionLength: RESOURCE_SESSION_COOKIE_EXPIRES
});
+ expiresAt = expires;
}
const cookieName = `${config.getRawConfig().server.session_cookie_name}`;
@@ -164,7 +170,8 @@ export async function exchangeSession(
cookieName,
resource.fullDomain!,
token,
- !resource.ssl
+ !resource.ssl,
+ expiresAt ? new Date(expiresAt) : undefined
);
logger.debug(JSON.stringify("Exchange cookie: " + cookie));
diff --git a/server/routers/badger/verifySession.ts b/server/routers/badger/verifySession.ts
index 86ba06291..35617f370 100644
--- a/server/routers/badger/verifySession.ts
+++ b/server/routers/badger/verifySession.ts
@@ -384,7 +384,7 @@ async function createAccessTokenSession(
tokenItem: ResourceAccessToken
) {
const token = generateSessionToken();
- await createResourceSession({
+ const sess = await createResourceSession({
resourceId: resource.resourceId,
token,
accessTokenId: tokenItem.accessTokenId,
@@ -397,7 +397,8 @@ async function createAccessTokenSession(
cookieName,
resource.fullDomain!,
token,
- !resource.ssl
+ !resource.ssl,
+ new Date(sess.expiresAt)
);
res.appendHeader("Set-Cookie", cookie);
logger.debug("Access token is valid, creating new session");
diff --git a/src/app/[orgId]/settings/access/roles/CreateRoleForm.tsx b/src/app/[orgId]/settings/access/roles/CreateRoleForm.tsx
index d95f3e202..2312d67a9 100644
--- a/src/app/[orgId]/settings/access/roles/CreateRoleForm.tsx
+++ b/src/app/[orgId]/settings/access/roles/CreateRoleForm.tsx
@@ -7,7 +7,7 @@ import {
FormField,
FormItem,
FormLabel,
- FormMessage,
+ FormMessage
} from "@app/components/ui/form";
import { Input } from "@app/components/ui/input";
import { toast } from "@app/hooks/useToast";
@@ -24,11 +24,11 @@ import {
CredenzaDescription,
CredenzaFooter,
CredenzaHeader,
- CredenzaTitle,
+ CredenzaTitle
} from "@app/components/Credenza";
import { useOrgContext } from "@app/hooks/useOrgContext";
import { CreateRoleBody, CreateRoleResponse } from "@server/routers/role";
-import { formatAxiosError } from "@app/lib/api";;
+import { formatAxiosError } from "@app/lib/api";
import { createApiClient } from "@app/lib/api";
import { useEnvContext } from "@app/hooks/useEnvContext";
@@ -40,13 +40,13 @@ type CreateRoleFormProps = {
const formSchema = z.object({
name: z.string({ message: "Name is required" }).max(32),
- description: z.string().max(255).optional(),
+ description: z.string().max(255).optional()
});
export default function CreateRoleForm({
open,
setOpen,
- afterCreate,
+ afterCreate
}: CreateRoleFormProps) {
const { org } = useOrgContext();
@@ -58,8 +58,8 @@ export default function CreateRoleForm({
resolver: zodResolver(formSchema),
defaultValues: {
name: "",
- description: "",
- },
+ description: ""
+ }
});
async function onSubmit(values: z.infer) {
@@ -70,7 +70,7 @@ export default function CreateRoleForm({
`/org/${org?.org.orgId}/role`,
{
name: values.name,
- description: values.description,
+ description: values.description
} as CreateRoleBody
)
.catch((e) => {
@@ -80,7 +80,7 @@ export default function CreateRoleForm({
description: formatAxiosError(
e,
"An error occurred while creating the role."
- ),
+ )
});
});
@@ -88,7 +88,7 @@ export default function CreateRoleForm({
toast({
variant: "default",
title: "Role created",
- description: "The role has been successfully created.",
+ description: "The role has been successfully created."
});
if (open) {
@@ -135,9 +135,7 @@ export default function CreateRoleForm({
Role Name
-
+
@@ -150,9 +148,7 @@ export default function CreateRoleForm({
Description
-
+
@@ -162,6 +158,9 @@ export default function CreateRoleForm({
+
+
+
-
-
-
diff --git a/src/app/[orgId]/settings/access/roles/DeleteRoleForm.tsx b/src/app/[orgId]/settings/access/roles/DeleteRoleForm.tsx
index 6bd41df8a..80d97267d 100644
--- a/src/app/[orgId]/settings/access/roles/DeleteRoleForm.tsx
+++ b/src/app/[orgId]/settings/access/roles/DeleteRoleForm.tsx
@@ -7,7 +7,7 @@ import {
FormField,
FormItem,
FormLabel,
- FormMessage,
+ FormMessage
} from "@app/components/ui/form";
import { toast } from "@app/hooks/useToast";
import { zodResolver } from "@hookform/resolvers/zod";
@@ -23,7 +23,7 @@ import {
CredenzaDescription,
CredenzaFooter,
CredenzaHeader,
- CredenzaTitle,
+ CredenzaTitle
} from "@app/components/Credenza";
import { useOrgContext } from "@app/hooks/useOrgContext";
import { ListRolesResponse } from "@server/routers/role";
@@ -32,10 +32,10 @@ import {
SelectContent,
SelectItem,
SelectTrigger,
- SelectValue,
+ SelectValue
} from "@app/components/ui/select";
import { RoleRow } from "./RolesTable";
-import { formatAxiosError } from "@app/lib/api";;
+import { formatAxiosError } from "@app/lib/api";
import { createApiClient } from "@app/lib/api";
import { useEnvContext } from "@app/hooks/useEnvContext";
@@ -47,14 +47,14 @@ type CreateRoleFormProps = {
};
const formSchema = z.object({
- newRoleId: z.string({ message: "New role is required" }),
+ newRoleId: z.string({ message: "New role is required" })
});
export default function DeleteRoleForm({
open,
roleToDelete,
setOpen,
- afterDelete,
+ afterDelete
}: CreateRoleFormProps) {
const { org } = useOrgContext();
@@ -66,9 +66,9 @@ export default function DeleteRoleForm({
useEffect(() => {
async function fetchRoles() {
const res = await api
- .get>(
- `/org/${org?.org.orgId}/roles`
- )
+ .get<
+ AxiosResponse
+ >(`/org/${org?.org.orgId}/roles`)
.catch((e) => {
console.error(e);
toast({
@@ -77,7 +77,7 @@ export default function DeleteRoleForm({
description: formatAxiosError(
e,
"An error occurred while fetching the roles"
- ),
+ )
});
});
@@ -96,8 +96,8 @@ export default function DeleteRoleForm({
const form = useForm>({
resolver: zodResolver(formSchema),
defaultValues: {
- newRoleId: "",
- },
+ newRoleId: ""
+ }
});
async function onSubmit(values: z.infer) {
@@ -106,8 +106,8 @@ export default function DeleteRoleForm({
const res = await api
.delete(`/role/${roleToDelete.roleId}`, {
data: {
- roleId: values.newRoleId,
- },
+ roleId: values.newRoleId
+ }
})
.catch((e) => {
toast({
@@ -116,7 +116,7 @@ export default function DeleteRoleForm({
description: formatAxiosError(
e,
"An error occurred while removing the role."
- ),
+ )
});
});
@@ -124,7 +124,7 @@ export default function DeleteRoleForm({
toast({
variant: "default",
title: "Role removed",
- description: "The role has been successfully removed.",
+ description: "The role has been successfully removed."
});
if (open) {
@@ -214,6 +214,9 @@ export default function DeleteRoleForm({
+
+
+
-
-
-
diff --git a/src/app/[orgId]/settings/access/users/InviteUserForm.tsx b/src/app/[orgId]/settings/access/users/InviteUserForm.tsx
index c629c0bc8..0285123a5 100644
--- a/src/app/[orgId]/settings/access/users/InviteUserForm.tsx
+++ b/src/app/[orgId]/settings/access/users/InviteUserForm.tsx
@@ -37,7 +37,7 @@ import {
} from "@app/components/Credenza";
import { useOrgContext } from "@app/hooks/useOrgContext";
import { ListRolesResponse } from "@server/routers/role";
-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 { Checkbox } from "@app/components/ui/checkbox";
@@ -194,9 +194,7 @@ export default function InviteUserForm({ open, setOpen }: InviteUserFormProps) {
Email
-
+
@@ -340,6 +338,9 @@ export default function InviteUserForm({ open, setOpen }: InviteUserFormProps) {
+
+
+
-
-
-
diff --git a/src/app/[orgId]/settings/access/users/UsersTable.tsx b/src/app/[orgId]/settings/access/users/UsersTable.tsx
index 7c11c06b2..29529d66c 100644
--- a/src/app/[orgId]/settings/access/users/UsersTable.tsx
+++ b/src/app/[orgId]/settings/access/users/UsersTable.tsx
@@ -185,7 +185,7 @@ export default function UsersTable({ users: u }: UsersTableProps) {
-