diff --git a/README.md b/README.md index bff1b17..15ca7ad 100644 --- a/README.md +++ b/README.md @@ -42,46 +42,50 @@ _Resources page of Pangolin dashboard (dark mode) showing multiple resources ava ### Reverse Proxy Through WireGuard Tunnel -- Expose private resources on your network **without opening ports** (firewall punching). -- Secure and easy to configure site-to-site connectivity via a custom **user space WireGuard client**, [Newt](https://github.com/fosrl/newt). -- Built-in support for any WireGuard client. -- Automated **SSL certificates** (https) via [LetsEncrypt](https://letsencrypt.org/). -- Support for HTTP/HTTPS and **raw TCP/UDP services**. -- Load balancing. +- Expose private resources on your network **without opening ports** (firewall punching). +- Secure and easy to configure site-to-site connectivity via a custom **user space WireGuard client**, [Newt](https://github.com/fosrl/newt). +- Built-in support for any WireGuard client. +- Automated **SSL certificates** (https) via [LetsEncrypt](https://letsencrypt.org/). +- Support for HTTP/HTTPS and **raw TCP/UDP services**. +- Load balancing. ### Identity & Access Management -- Centralized authentication system using platform SSO. **Users will only have to manage one login.** -- **Define access control rules for IPs, IP ranges, and URL paths per resource.** -- TOTP with backup codes for two-factor authentication. -- Create organizations, each with multiple sites, users, and roles. -- **Role-based access control** to manage resource access permissions. -- Additional authentication options include: - - Email whitelisting with **one-time passcodes.** - - **Temporary, self-destructing share links.** - - Resource specific pin codes. - - Resource specific passwords. -- OIDC Support for IDPs like Authentik +- Centralized authentication system using platform SSO. **Users will only have to manage one login.** +- **Define access control rules for IPs, IP ranges, and URL paths per resource.** +- TOTP with backup codes for two-factor authentication. +- Create organizations, each with multiple sites, users, and roles. +- **Role-based access control** to manage resource access permissions. +- Additional authentication options include: + - Email whitelisting with **one-time passcodes.** + - **Temporary, self-destructing share links.** + - Resource specific pin codes. + - Resource specific passwords. +- External identity provider (IdP) support with OAuth2/OIDC, such as Authentik, Keycloak, Okta, and others. + - Auto-provision users and roles from your IdP. ### Simple Dashboard UI -- Manage sites, users, and roles with a clean and intuitive UI. -- Monitor site usage and connectivity. -- Light and dark mode options. -- Mobile friendly. +- Manage sites, users, and roles with a clean and intuitive UI. +- Monitor site usage and connectivity. +- Light and dark mode options. +- Mobile friendly. ### Easy Deployment -- Run on any cloud provider or on-premises. -- **Docker Compose based setup** for simplified deployment. -- Future-proof installation script for streamlined setup and feature additions. -- Use any WireGuard client to connect, or use **Newt, our custom user space client** for the best experience. +- Run on any cloud provider or on-premises. +- **Docker Compose based setup** for simplified deployment. +- Future-proof installation script for streamlined setup and feature additions. +- Use any WireGuard client to connect, or use **Newt, our custom user space client** for the best experience. +- Use the API to create custom integrations and scripts. + - Fine-grained access control to the API via scoped API keys. + - Comprehensive Swagger documentation for the API. ### Modular Design -- 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). +- 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. +- Attach as many sites to the central server as you wish. Collage @@ -89,8 +93,8 @@ _Resources page of Pangolin dashboard (dark mode) showing multiple resources ava 1. **Deploy the Central Server**: - - Deploy the Docker Compose stack onto a VPS hosted on a cloud platform like RackNerd, Amazon EC2, DigitalOcean Droplet, or similar. There are many cheap VPS hosting options available to suit your needs. - + - Deploy the Docker Compose stack onto a VPS hosted on a cloud platform like RackNerd, Amazon EC2, DigitalOcean Droplet, or similar. There are many cheap VPS hosting options available to suit your needs. + > [!TIP] > Many of our users have had a great experience with [RackNerd](https://my.racknerd.com/aff.php?aff=13788). Depending on promotions, you can likely get a **VPS with 1 vCPU, 1GB RAM, and ~20GB SSD for just around $12/year**. That's a great deal! > We are part of the [RackNerd](https://my.racknerd.com/aff.php?aff=13788) affiliate program, so if you 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. @@ -112,23 +116,19 @@ _Resources page of Pangolin dashboard (dark mode) showing multiple resources ava **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. -**Use Case Example - Deploying Services For Your Business** - You can use Pangolin as an easy way to expose your business applications to your users behind a safe authentication portal you can integrate into your IDP solution. Expose resources on prem and on the cloud. +**Use Case Example - Deploying Services For Your Business**: +You can use Pangolin as an easy way to expose your business applications to your users behind a safe authentication portal you can integrate into your IdP solution. Expose resources on prem and on the cloud. **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 - -_Resources page of Pangolin dashboard (dark mode) showing HTTPS and TCP resources with access control rules._ - ## Similar Projects and Inspirations **Cloudflare Tunnels**: - A similar approach to proxying private resources securely, but Pangolin is a self-hosted alternative, giving you full control over your infrastructure. + A similar approach to proxying private resources securely, but Pangolin is a self-hosted alternative, giving you full control over your infrastructure. -**Authentik and Authelia**: - These projects inspired Pangolin’s centralized authentication system for proxies, enabling robust user and role management. +**Authelia**: + This inspired Pangolin’s centralized authentication system for proxies, enabling robust user and role management. ## Project Development / Roadmap diff --git a/config/config.example.yml b/config/config.example.yml index f3ab8d6..7b5c144 100644 --- a/config/config.example.yml +++ b/config/config.example.yml @@ -18,6 +18,7 @@ server: internal_hostname: "pangolin" session_cookie_name: "p_session_token" resource_access_token_param: "p_token" + secret: "your_secret_key_here" resource_access_token_headers: id: "P-Access-Token-Id" token: "P-Access-Token" diff --git a/install/config/docker-compose.yml b/install/config/docker-compose.yml index 496b013..6c1a375 100644 --- a/install/config/docker-compose.yml +++ b/install/config/docker-compose.yml @@ -35,7 +35,7 @@ services: - 80:80 # Port for traefik because of the network_mode {{end}} traefik: - image: traefik:v3.3.5 + image: traefik:v3.3.6 container_name: traefik restart: unless-stopped {{if .InstallGerbil}} diff --git a/install/main.go b/install/main.go index 82f7902..abb67ac 100644 --- a/install/main.go +++ b/install/main.go @@ -202,7 +202,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) + config.InstallGerbil = readBool(reader, "Do you want to use Gerbil to allow tunneled connections", true) // Admin user configuration fmt.Println("\n=== Admin User Configuration ===") diff --git a/public/screenshots/collage.png b/public/screenshots/collage.png index 74fe6de..c791e7e 100644 Binary files a/public/screenshots/collage.png and b/public/screenshots/collage.png differ diff --git a/server/lib/ip.ts b/server/lib/ip.ts index 86fe116..fd6f07a 100644 --- a/server/lib/ip.ts +++ b/server/lib/ip.ts @@ -17,7 +17,7 @@ function detectIpVersion(ip: string): IPVersion { */ function ipToBigInt(ip: string): bigint { const version = detectIpVersion(ip); - + if (version === 4) { return ip.split('.') .reduce((acc, octet) => { @@ -105,7 +105,7 @@ export function cidrToRange(cidr: string): IPRange { const version = detectIpVersion(ip); const prefixBits = parseInt(prefix); const ipBigInt = ipToBigInt(ip); - + // Validate prefix length const maxPrefix = version === 4 ? 32 : 128; if (prefixBits < 0 || prefixBits > maxPrefix) { @@ -116,7 +116,7 @@ export function cidrToRange(cidr: string): IPRange { const mask = BigInt.asUintN(version === 4 ? 64 : 128, (BigInt(1) << shiftBits) - BigInt(1)); const start = ipBigInt & ~mask; const end = start | mask; - + return { start, end }; } @@ -136,17 +136,17 @@ export function findNextAvailableCidr( if (!startCidr && existingCidrs.length === 0) { return null; } - + // If no existing CIDRs, use the IP version from startCidr - const version = startCidr + const version = startCidr ? detectIpVersion(startCidr.split('/')[0]) : 4; // Default to IPv4 if no startCidr provided - + // Use appropriate default startCidr if none provided startCidr = startCidr || (version === 4 ? "0.0.0.0/0" : "::/0"); - + // If there are existing CIDRs, ensure all are same version - if (existingCidrs.length > 0 && + if (existingCidrs.length > 0 && existingCidrs.some(cidr => detectIpVersion(cidr.split('/')[0]) !== version)) { throw new Error('All CIDRs must be of the same IP version'); } @@ -196,12 +196,14 @@ export function findNextAvailableCidr( export function isIpInCidr(ip: string, cidr: string): boolean { const ipVersion = detectIpVersion(ip); const cidrVersion = detectIpVersion(cidr.split('/')[0]); - + + // If IP versions don't match, the IP cannot be in the CIDR range if (ipVersion !== cidrVersion) { - throw new Error('IP address and CIDR must be of the same version'); + // throw new Erorr + return false; } const ipBigInt = ipToBigInt(ip); const range = cidrToRange(cidr); return ipBigInt >= range.start && ipBigInt <= range.end; -} \ No newline at end of file +} diff --git a/server/license/license.ts b/server/license/license.ts index 7887f45..e97b8f5 100644 --- a/server/license/license.ts +++ b/server/license/license.ts @@ -13,12 +13,19 @@ import moment from "moment"; import { setHostMeta } from "@server/setup/setHostMeta"; import { encrypt, decrypt } from "@server/lib/crypto"; +const keyTypes = ["HOST", "SITES"] as const; +type KeyType = (typeof keyTypes)[number]; + +const keyTiers = ["PROFESSIONAL", "ENTERPRISE"] as const; +type KeyTier = (typeof keyTiers)[number]; + export type LicenseStatus = { isHostLicensed: boolean; // Are there any license keys? isLicenseValid: boolean; // Is the license key valid? hostId: string; // Host ID maxSites?: number; usedSites?: number; + tier?: KeyTier; }; export type LicenseKeyCache = { @@ -26,7 +33,8 @@ export type LicenseKeyCache = { licenseKeyEncrypted: string; valid: boolean; iat?: Date; - type?: "LICENSE" | "SITES"; + type?: KeyType; + tier?: KeyTier; numSites?: number; }; @@ -54,7 +62,8 @@ type ValidateLicenseAPIResponse = { type TokenPayload = { valid: boolean; - type: "LICENSE" | "SITES"; + type: KeyType; + tier: KeyTier; quantity: number; terminateAt: string; // ISO iat: number; // Issued at @@ -182,11 +191,12 @@ LQIDAQAB licenseKeyEncrypted: key.licenseKeyId, valid: payload.valid, type: payload.type, + tier: payload.tier, numSites: payload.quantity, iat: new Date(payload.iat * 1000) }); - if (payload.type === "LICENSE") { + if (payload.type === "HOST") { foundHostKey = true; } } catch (e) { @@ -273,6 +283,7 @@ LQIDAQAB ); cached.valid = payload.valid; cached.type = payload.type; + cached.tier = payload.tier; cached.numSites = payload.quantity; cached.iat = new Date(payload.iat * 1000); @@ -311,8 +322,9 @@ LQIDAQAB logger.debug("Checking key", cached); - if (cached.type === "LICENSE") { + if (cached.type === "HOST") { status.isLicenseValid = cached.valid; + status.tier = cached.tier; } if (!cached.valid) { diff --git a/server/routers/accessToken/listAccessTokens.ts b/server/routers/accessToken/listAccessTokens.ts index 1ed7b14..07ef9aa 100644 --- a/server/routers/accessToken/listAccessTokens.ts +++ b/server/routers/accessToken/listAccessTokens.ts @@ -172,9 +172,20 @@ export async function listAccessTokens( ) ); } - const { orgId, resourceId } = parsedParams.data; + const { resourceId } = parsedParams.data; - if (orgId && orgId !== req.userOrgId) { + const orgId = + parsedParams.data.orgId || + req.userOrg?.orgId || + req.apiKeyOrg?.orgId; + + if (!orgId) { + return next( + createHttpError(HttpCode.BAD_REQUEST, "Invalid organization ID") + ); + } + + if (req.user && orgId && orgId !== req.userOrgId) { return next( createHttpError( HttpCode.FORBIDDEN, @@ -183,21 +194,29 @@ export async function listAccessTokens( ); } - const accessibleResources = await db - .select({ - resourceId: sql`COALESCE(${userResources.resourceId}, ${roleResources.resourceId})` - }) - .from(userResources) - .fullJoin( - roleResources, - eq(userResources.resourceId, roleResources.resourceId) - ) - .where( - or( - eq(userResources.userId, req.user!.userId), - eq(roleResources.roleId, req.userOrgRoleId!) + let accessibleResources; + if (req.user) { + accessibleResources = await db + .select({ + resourceId: sql`COALESCE(${userResources.resourceId}, ${roleResources.resourceId})` + }) + .from(userResources) + .fullJoin( + roleResources, + eq(userResources.resourceId, roleResources.resourceId) ) - ); + .where( + or( + eq(userResources.userId, req.user!.userId), + eq(roleResources.roleId, req.userOrgRoleId!) + ) + ); + } else { + accessibleResources = await db + .select({ resourceId: resources.resourceId }) + .from(resources) + .where(eq(resources.orgId, orgId)); + } const accessibleResourceIds = accessibleResources.map( (resource) => resource.resourceId diff --git a/server/routers/idp/generateOidcUrl.ts b/server/routers/idp/generateOidcUrl.ts index 4a62cac..371a2c2 100644 --- a/server/routers/idp/generateOidcUrl.ts +++ b/server/routers/idp/generateOidcUrl.ts @@ -28,7 +28,7 @@ const bodySchema = z .strict(); const ensureTrailingSlash = (url: string): string => { - return url.endsWith('/') ? url : `${url}/`; + return url; }; export type GenerateOidcUrlResponse = { diff --git a/server/routers/idp/validateOidcCallback.ts b/server/routers/idp/validateOidcCallback.ts index 1624616..7d588fe 100644 --- a/server/routers/idp/validateOidcCallback.ts +++ b/server/routers/idp/validateOidcCallback.ts @@ -23,7 +23,7 @@ import { oidcAutoProvision } from "./oidcAutoProvision"; import license from "@server/license/license"; const ensureTrailingSlash = (url: string): string => { - return url.endsWith("/") ? url : `${url}/`; + return url; }; const paramsSchema = z @@ -243,7 +243,7 @@ export async function validateOidcCallback( return next( createHttpError( HttpCode.UNAUTHORIZED, - "User not provisioned in the system" + `User with username ${userIdentifier} is unprovisioned. This user must be added to an organization before logging in.` ) ); } diff --git a/server/routers/newt/createNewt.ts b/server/routers/newt/createNewt.ts index 25f4bb3..02517db 100644 --- a/server/routers/newt/createNewt.ts +++ b/server/routers/newt/createNewt.ts @@ -49,7 +49,7 @@ export async function createNewt( const { newtId, secret } = parsedBody.data; - if (!req.userOrgRoleId) { + if (req.user && !req.userOrgRoleId) { return next( createHttpError(HttpCode.FORBIDDEN, "User does not have a role") ); diff --git a/server/routers/org/createOrg.ts b/server/routers/org/createOrg.ts index d382151..60ff555 100644 --- a/server/routers/org/createOrg.ts +++ b/server/routers/org/createOrg.ts @@ -3,13 +3,16 @@ import { z } from "zod"; import { db } from "@server/db"; import { eq } from "drizzle-orm"; import { + apiKeyOrg, + apiKeys, domains, Org, orgDomains, orgs, roleActions, roles, - userOrgs + userOrgs, + users } from "@server/db/schemas"; import response from "@server/lib/response"; import HttpCode from "@server/types/HttpCode"; @@ -55,7 +58,7 @@ export async function createOrg( try { // should this be in a middleware? if (config.getRawConfig().flags?.disable_user_create_org) { - if (!req.user?.serverAdmin) { + if (req.user && !req.user?.serverAdmin) { return next( createHttpError( HttpCode.FORBIDDEN, @@ -143,12 +146,33 @@ export async function createOrg( })) ); - await trx.insert(userOrgs).values({ - userId: req.user!.userId, - orgId: newOrg[0].orgId, - roleId: roleId, - isOwner: true - }); + if (req.user) { + await trx.insert(userOrgs).values({ + userId: req.user!.userId, + orgId: newOrg[0].orgId, + roleId: roleId, + isOwner: true + }); + } else { + // if org created by root api key, set the server admin as the owner + const [serverAdmin] = await trx + .select() + .from(users) + .where(eq(users.serverAdmin, true)); + + if (!serverAdmin) { + error = "Server admin not found"; + trx.rollback(); + return; + } + + await trx.insert(userOrgs).values({ + userId: serverAdmin.userId, + orgId: newOrg[0].orgId, + roleId: roleId, + isOwner: true + }); + } const memberRole = await trx .insert(roles) @@ -166,6 +190,18 @@ export async function createOrg( orgId })) ); + + const rootApiKeys = await trx + .select() + .from(apiKeys) + .where(eq(apiKeys.isRoot, true)); + + for (const apiKey of rootApiKeys) { + await trx.insert(apiKeyOrg).values({ + apiKeyId: apiKey.apiKeyId, + orgId: newOrg[0].orgId + }); + } }); if (!org) { diff --git a/server/routers/resource/createResource.ts b/server/routers/resource/createResource.ts index af6807b..e899530 100644 --- a/server/routers/resource/createResource.ts +++ b/server/routers/resource/createResource.ts @@ -39,6 +39,7 @@ const createHttpResourceSchema = z isBaseDomain: z.boolean().optional(), siteId: z.number(), http: z.boolean(), + protocol: z.string(), domainId: z.string() }) .strict() @@ -129,7 +130,7 @@ export async function createResource( const { siteId, orgId } = parsedParams.data; - if (!req.userOrgRoleId) { + if (req.user && !req.userOrgRoleId) { return next( createHttpError(HttpCode.FORBIDDEN, "User does not have a role") ); @@ -202,7 +203,7 @@ async function createHttpResource( ); } - const { name, subdomain, isBaseDomain, http, domainId } = + const { name, subdomain, isBaseDomain, http, protocol, domainId } = parsedBody.data; const [orgDomain] = await db @@ -261,7 +262,7 @@ async function createHttpResource( name, subdomain, http, - protocol: "tcp", + protocol, ssl: true, isBaseDomain }) @@ -284,7 +285,7 @@ async function createHttpResource( resourceId: newResource[0].resourceId }); - if (req.userOrgRoleId != adminRole[0].roleId) { + if (req.user && req.userOrgRoleId != adminRole[0].roleId) { // make sure the user can access the resource await trx.insert(userResources).values({ userId: req.user?.userId!, diff --git a/server/routers/resource/listResources.ts b/server/routers/resource/listResources.ts index daac698..9af2474 100644 --- a/server/routers/resource/listResources.ts +++ b/server/routers/resource/listResources.ts @@ -69,9 +69,7 @@ function queryResources( http: resources.http, protocol: resources.protocol, proxyPort: resources.proxyPort, - enabled: resources.enabled, - tlsServerName: resources.tlsServerName, - setHostHeader: resources.setHostHeader + enabled: resources.enabled }) .from(resources) .leftJoin(sites, eq(resources.siteId, sites.siteId)) @@ -105,9 +103,7 @@ function queryResources( http: resources.http, protocol: resources.protocol, proxyPort: resources.proxyPort, - enabled: resources.enabled, - tlsServerName: resources.tlsServerName, - setHostHeader: resources.setHostHeader + enabled: resources.enabled }) .from(resources) .leftJoin(sites, eq(resources.siteId, sites.siteId)) @@ -187,9 +183,17 @@ export async function listResources( ) ); } - const { siteId, orgId } = parsedParams.data; + const { siteId } = parsedParams.data; - if (orgId && orgId !== req.userOrgId) { + const orgId = parsedParams.data.orgId || req.userOrg?.orgId || req.apiKeyOrg?.orgId; + + if (!orgId) { + return next( + createHttpError(HttpCode.BAD_REQUEST, "Invalid organization ID") + ); + } + + if (req.user && orgId && orgId !== req.userOrgId) { return next( createHttpError( HttpCode.FORBIDDEN, @@ -198,7 +202,9 @@ export async function listResources( ); } - const accessibleResources = await db + let accessibleResources; + if (req.user) { + accessibleResources = await db .select({ resourceId: sql`COALESCE(${userResources.resourceId}, ${roleResources.resourceId})` }) @@ -213,6 +219,11 @@ export async function listResources( eq(roleResources.roleId, req.userOrgRoleId!) ) ); + } else { + accessibleResources = await db.select({ + resourceId: resources.resourceId + }).from(resources).where(eq(resources.orgId, orgId)); + } const accessibleResourceIds = accessibleResources.map( (resource) => resource.resourceId diff --git a/server/routers/resource/setResourceRoles.ts b/server/routers/resource/setResourceRoles.ts index 2699be1..0f0b3df 100644 --- a/server/routers/resource/setResourceRoles.ts +++ b/server/routers/resource/setResourceRoles.ts @@ -1,7 +1,7 @@ import { Request, Response, NextFunction } from "express"; import { z } from "zod"; import { db } from "@server/db"; -import { roleResources, roles } from "@server/db/schemas"; +import { apiKeys, roleResources, roles } from "@server/db/schemas"; import response from "@server/lib/response"; import HttpCode from "@server/types/HttpCode"; import createHttpError from "http-errors"; @@ -74,6 +74,17 @@ export async function setResourceRoles( const { resourceId } = parsedParams.data; + const orgId = req.userOrg?.orgId || req.apiKeyOrg?.orgId; + + if (!orgId) { + return next( + createHttpError( + HttpCode.INTERNAL_SERVER_ERROR, + "Organization not found" + ) + ); + } + // get this org's admin role const adminRole = await db .select() @@ -81,7 +92,7 @@ export async function setResourceRoles( .where( and( eq(roles.name, "Admin"), - eq(roles.orgId, req.userOrg!.orgId) + eq(roles.orgId, orgId) ) ) .limit(1); @@ -136,3 +147,4 @@ export async function setResourceRoles( ); } } + diff --git a/server/routers/resource/updateResource.ts b/server/routers/resource/updateResource.ts index 36f8c23..a857e10 100644 --- a/server/routers/resource/updateResource.ts +++ b/server/routers/resource/updateResource.ts @@ -45,8 +45,8 @@ const updateHttpResourceBodySchema = z domainId: z.string().optional(), enabled: z.boolean().optional(), stickySession: z.boolean().optional(), - tlsServerName: z.string().optional(), - setHostHeader: z.string().optional() + tlsServerName: z.string().nullable().optional(), + setHostHeader: z.string().nullable().optional() }) .strict() .refine((data) => Object.keys(data).length > 0, { diff --git a/server/routers/site/createSite.ts b/server/routers/site/createSite.ts index 03b0d74..87eaa95 100644 --- a/server/routers/site/createSite.ts +++ b/server/routers/site/createSite.ts @@ -1,7 +1,7 @@ import { Request, Response, NextFunction } from "express"; import { z } from "zod"; import { db } from "@server/db"; -import { roles, userSites, sites, roleSites, Site } from "@server/db/schemas"; +import { roles, userSites, sites, roleSites, Site, orgs } from "@server/db/schemas"; import response from "@server/lib/response"; import HttpCode from "@server/types/HttpCode"; import createHttpError from "http-errors"; @@ -10,7 +10,6 @@ import { eq, and } from "drizzle-orm"; import { getUniqueSiteName } from "@server/db/names"; import { addPeer } from "../gerbil/peers"; import { fromError } from "zod-validation-error"; -import { hash } from "@node-rs/argon2"; import { newts } from "@server/db/schemas"; import moment from "moment"; import { OpenAPITags, registry } from "@server/openApi"; @@ -78,8 +77,15 @@ export async function createSite( ); } - const { name, type, exitNodeId, pubKey, subnet, newtId, secret } = - parsedBody.data; + const { + name, + type, + exitNodeId, + pubKey, + subnet, + newtId, + secret + } = parsedBody.data; const parsedParams = createSiteParamsSchema.safeParse(req.params); if (!parsedParams.success) { @@ -93,12 +99,23 @@ export async function createSite( const { orgId } = parsedParams.data; - if (!req.userOrgRoleId) { + if (req.user && !req.userOrgRoleId) { return next( createHttpError(HttpCode.FORBIDDEN, "User does not have a role") ); } + const [org] = await db.select().from(orgs).where(eq(orgs.orgId, orgId)); + + if (!org) { + return next( + createHttpError( + HttpCode.NOT_FOUND, + `Organization with ID ${orgId} not found` + ) + ); + } + const niceId = await getUniqueSiteName(orgId); await db.transaction(async (trx) => { @@ -159,7 +176,7 @@ export async function createSite( siteId: newSite.siteId }); - if (req.userOrgRoleId != adminRole[0].roleId) { + if (req.user && req.userOrgRoleId != adminRole[0].roleId) { // make sure the user can access the site trx.insert(userSites).values({ userId: req.user?.userId!, diff --git a/server/routers/site/listSites.ts b/server/routers/site/listSites.ts index ae8b4fe..1b8791c 100644 --- a/server/routers/site/listSites.ts +++ b/server/routers/site/listSites.ts @@ -100,7 +100,7 @@ export async function listSites( } const { orgId } = parsedParams.data; - if (orgId && orgId !== req.userOrgId) { + if (req.user && orgId && orgId !== req.userOrgId) { return next( createHttpError( HttpCode.FORBIDDEN, @@ -109,18 +109,26 @@ export async function listSites( ); } - const accessibleSites = await db - .select({ - siteId: sql`COALESCE(${userSites.siteId}, ${roleSites.siteId})` - }) - .from(userSites) - .fullJoin(roleSites, eq(userSites.siteId, roleSites.siteId)) - .where( - or( - eq(userSites.userId, req.user!.userId), - eq(roleSites.roleId, req.userOrgRoleId!) - ) - ); + let accessibleSites; + if (req.user) { + accessibleSites = await db + .select({ + siteId: sql`COALESCE(${userSites.siteId}, ${roleSites.siteId})` + }) + .from(userSites) + .fullJoin(roleSites, eq(userSites.siteId, roleSites.siteId)) + .where( + or( + eq(userSites.userId, req.user!.userId), + eq(roleSites.roleId, req.userOrgRoleId!) + ) + ); + } else { + accessibleSites = await db + .select({ siteId: sites.siteId }) + .from(sites) + .where(eq(sites.orgId, orgId)); + } const accessibleSiteIds = accessibleSites.map((site) => site.siteId); const baseQuery = querySites(orgId, accessibleSiteIds); diff --git a/server/routers/user/addUserRole.ts b/server/routers/user/addUserRole.ts index b482e39..c0ac31b 100644 --- a/server/routers/user/addUserRole.ts +++ b/server/routers/user/addUserRole.ts @@ -49,7 +49,7 @@ export async function addUserRole( const { userId, roleId } = parsedParams.data; - if (!req.userOrg) { + if (req.user && !req.userOrg) { return next( createHttpError( HttpCode.FORBIDDEN, @@ -58,7 +58,13 @@ export async function addUserRole( ); } - const orgId = req.userOrg.orgId; + const orgId = req.userOrg?.orgId || req.apiKeyOrg?.orgId; + + if (!orgId) { + return next( + createHttpError(HttpCode.BAD_REQUEST, "Invalid organization ID") + ); + } const existingUser = await db .select() diff --git a/server/routers/user/getOrgUser.ts b/server/routers/user/getOrgUser.ts index f03cf0f..6ebd33c 100644 --- a/server/routers/user/getOrgUser.ts +++ b/server/routers/user/getOrgUser.ts @@ -106,7 +106,7 @@ export async function getOrgUser( ); } - if (user.userId !== req.userOrg.userId) { + if (req.user && user.userId !== req.userOrg.userId) { const hasPermission = await checkUserActionPermission( ActionsEnum.getOrgUser, req diff --git a/src/app/[orgId]/settings/resources/[resourceId]/proxy/page.tsx b/src/app/[orgId]/settings/resources/[resourceId]/proxy/page.tsx index ea38817..90e05ff 100644 --- a/src/app/[orgId]/settings/resources/[resourceId]/proxy/page.tsx +++ b/src/app/[orgId]/settings/resources/[resourceId]/proxy/page.tsx @@ -363,12 +363,12 @@ export default function ReverseProxyTargets(props: { setHttpsTlsLoading(true); await api.post(`/resource/${params.resourceId}`, { ssl: data.ssl, - tlsServerName: data.tlsServerName || undefined + tlsServerName: data.tlsServerName || null }); updateResource({ ...resource, ssl: data.ssl, - tlsServerName: data.tlsServerName || undefined + tlsServerName: data.tlsServerName || null }); toast({ title: "TLS settings updated", @@ -393,11 +393,11 @@ export default function ReverseProxyTargets(props: { try { setProxySettingsLoading(true); await api.post(`/resource/${params.resourceId}`, { - setHostHeader: data.setHostHeader || undefined + setHostHeader: data.setHostHeader || null }); updateResource({ ...resource, - setHostHeader: data.setHostHeader || undefined + setHostHeader: data.setHostHeader || null }); toast({ title: "Proxy settings updated", @@ -657,7 +657,7 @@ export default function ReverseProxyTargets(props: { loading={httpsTlsLoading} form="tls-settings-form" > - Save HTTPS & TLS Settings + Save Settings @@ -896,7 +896,7 @@ export default function ReverseProxyTargets(props: { - The Host header to set when + The host header to set when proxying requests. Leave empty to use the default. diff --git a/src/app/[orgId]/settings/resources/create/page.tsx b/src/app/[orgId]/settings/resources/create/page.tsx index 704a194..c1be635 100644 --- a/src/app/[orgId]/settings/resources/create/page.tsx +++ b/src/app/[orgId]/settings/resources/create/page.tsx @@ -173,13 +173,15 @@ export default function Page() { if (httpData.isBaseDomain) { Object.assign(payload, { domainId: httpData.domainId, - isBaseDomain: true + isBaseDomain: true, + protocol: "tcp" }); } else { Object.assign(payload, { subdomain: httpData.subdomain, domainId: httpData.domainId, - isBaseDomain: false + isBaseDomain: false, + protocol: "tcp" }); } } else { diff --git a/src/app/admin/license/components/SitePriceCalculator.tsx b/src/app/admin/license/components/SitePriceCalculator.tsx index b27c8ad..cf771b5 100644 --- a/src/app/admin/license/components/SitePriceCalculator.tsx +++ b/src/app/admin/license/components/SitePriceCalculator.tsx @@ -40,6 +40,21 @@ export function SitePriceCalculator({ setSiteCount((prev) => (prev > 1 ? prev - 1 : 1)); }; + function continueToPayment() { + if (mode === "license") { + // open in new tab + window.open( + `https://payment.fossorial.io/buy/dab98d3d-9976-49b1-9e55-1580059d833f?quantity=${siteCount}`, + "_blank" + ); + } else { + window.open( + `https://payment.fossorial.io/buy/2b881c36-ea5d-4c11-8652-9be6810a054f?quantity=${siteCount}`, + "_blank" + ); + } + } + const totalCost = mode === "license" ? licenseFlatRate + siteCount * pricePerSite @@ -122,8 +137,8 @@ export function SitePriceCalculator({

- For the most up-to-date pricing, please visit - our{" "} + For the most up-to-date pricing and discounts, + please visit the{" "} - + diff --git a/src/app/admin/license/page.tsx b/src/app/admin/license/page.tsx index 74d561e..a967889 100644 --- a/src/app/admin/license/page.tsx +++ b/src/app/admin/license/page.tsx @@ -121,7 +121,7 @@ export default function LicensePage() { ); const keys = response.data.data; setRows(keys); - const hostKey = keys.find((key) => key.type === "LICENSE"); + const hostKey = keys.find((key) => key.type === "HOST"); if (hostKey) { setHostLicense(hostKey.licenseKey); } else { @@ -285,17 +285,22 @@ export default function LicensePage() {

+ {!licenseStatus?.isHostLicensed && ( +

+ There is no limit on the number of sites + using an unlicensed host. +

+ )} {licenseStatus?.maxSites && (
diff --git a/src/app/admin/users/AdminUsersTable.tsx b/src/app/admin/users/AdminUsersTable.tsx index 12a6145..68ad279 100644 --- a/src/app/admin/users/AdminUsersTable.tsx +++ b/src/app/admin/users/AdminUsersTable.tsx @@ -173,7 +173,7 @@ export default function UsersTable({ users }: Props) {

Are you sure you want to permanently delete{" "} - + {selected?.email || selected?.name || selected?.username} diff --git a/src/app/components/LicenseViolation.tsx b/src/app/components/LicenseViolation.tsx index 1771475..75d544d 100644 --- a/src/app/components/LicenseViolation.tsx +++ b/src/app/components/LicenseViolation.tsx @@ -5,21 +5,33 @@ "use client"; +import { Button } from "@app/components/ui/button"; import { useLicenseStatusContext } from "@app/hooks/useLicenseStatusContext"; +import { useState } from "react"; export default function LicenseViolation() { const { licenseStatus } = useLicenseStatusContext(); + const [isDismissed, setIsDismissed] = useState(false); - if (!licenseStatus) return null; + if (!licenseStatus || isDismissed) return null; // Show invalid license banner if (licenseStatus.isHostLicensed && !licenseStatus.isLicenseValid) { return (

-

- Invalid or expired license keys detected. Follow license - terms to continue using all features. -

+
+

+ Invalid or expired license keys detected. Follow license + terms to continue using all features. +

+ +
); } @@ -32,12 +44,21 @@ export default function LicenseViolation() { ) { return (
-

- License Violation: This server is using{" "} - {licenseStatus.usedSites} sites which exceeds its licensed - limit of {licenseStatus.maxSites} sites. Follow license - terms to continue using all features. -

+
+

+ License Violation: This server is using{" "} + {licenseStatus.usedSites} sites which exceeds its + licensed limit of {licenseStatus.maxSites} sites. Follow + license terms to continue using all features. +

+ +
); } diff --git a/src/components/Breadcrumbs.tsx b/src/components/Breadcrumbs.tsx index 9740759..25366ff 100644 --- a/src/components/Breadcrumbs.tsx +++ b/src/components/Breadcrumbs.tsx @@ -16,33 +16,7 @@ export function Breadcrumbs() { const breadcrumbs: BreadcrumbItem[] = segments.map((segment, index) => { const href = `/${segments.slice(0, index + 1).join("/")}`; - let label = segment; - - // // Format labels - // if (segment === "settings") { - // label = "Settings"; - // } else if (segment === "sites") { - // label = "Sites"; - // } else if (segment === "resources") { - // label = "Resources"; - // } else if (segment === "access") { - // label = "Access Control"; - // } else if (segment === "general") { - // label = "General"; - // } else if (segment === "share-links") { - // label = "Shareable Links"; - // } else if (segment === "users") { - // label = "Users"; - // } else if (segment === "roles") { - // label = "Roles"; - // } else if (segment === "invitations") { - // label = "Invitations"; - // } else if (segment === "proxy") { - // label = "proxy"; - // } else if (segment === "authentication") { - // label = "Authentication"; - // } - + let label = decodeURIComponent(segment); return { label, href }; }); diff --git a/src/components/ConfirmDeleteDialog.tsx b/src/components/ConfirmDeleteDialog.tsx index 5ed13d5..a928ed6 100644 --- a/src/components/ConfirmDeleteDialog.tsx +++ b/src/components/ConfirmDeleteDialog.tsx @@ -105,7 +105,7 @@ export default function InviteUserForm({ {title} -
{dialog}
+
{dialog}
{ + field.onChange(e); + if (e.length === 6) { + mfaForm.handleSubmit(onSubmit)(); + } + }} > , + outline = true +}) { + + return ( +
+
+ {children} +
+
+ ); +} diff --git a/src/components/SupporterStatus.tsx b/src/components/SupporterStatus.tsx index 25da9b5..48abf7b 100644 --- a/src/components/SupporterStatus.tsx +++ b/src/components/SupporterStatus.tsx @@ -189,10 +189,12 @@ export default function SupporterStatus() {

Purchase a supporter key to help us continue - developing Pangolin. Your contribution allows us - commit more time to maintain and add new features to - the application for everyone. We will never use this - to paywall features. + developing Pangolin for the community. Your + contribution allows us to commit more time to + maintain and add new features to the application for + everyone. We will never use this to paywall + features. This is separate from the Professional + Edition.

diff --git a/src/components/ui/toast.tsx b/src/components/ui/toast.tsx index f5cbe00..af5d5ed 100644 --- a/src/components/ui/toast.tsx +++ b/src/components/ui/toast.tsx @@ -16,7 +16,7 @@ const ToastViewport = React.forwardRef<