Merge branch 'dev' into auth-providers-clients

This commit is contained in:
Owen 2025-05-11 10:31:29 -04:00
commit 160a7ff3db
No known key found for this signature in database
GPG key ID: 8271FDFFD9E0CCBD
20 changed files with 188 additions and 90 deletions

View file

@ -122,8 +122,6 @@ You can use Pangolin as an easy way to expose your business applications to your
**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**:

View file

@ -29,9 +29,12 @@ const configSchema = z.object({
.optional()
.pipe(z.string().url())
.transform((url) => url.toLowerCase()),
log_level: z.enum(["debug", "info", "warn", "error"]),
save_logs: z.boolean(),
log_failed_attempts: z.boolean().optional()
log_level: z
.enum(["debug", "info", "warn", "error"])
.optional()
.default("info"),
save_logs: z.boolean().optional().default(false),
log_failed_attempts: z.boolean().optional().default(false)
}),
domains: z
.record(
@ -41,8 +44,8 @@ const configSchema = z.object({
.string()
.nonempty("base_domain must not be empty")
.transform((url) => url.toLowerCase()),
cert_resolver: z.string().optional(),
prefer_wildcard_cert: z.boolean().optional()
cert_resolver: z.string().optional().default("letsencrypt"),
prefer_wildcard_cert: z.boolean().optional().default(false)
})
)
.refine(
@ -62,19 +65,42 @@ const configSchema = z.object({
server: z.object({
integration_port: portSchema
.optional()
.default(3003)
.transform(stoi)
.pipe(portSchema.optional()),
external_port: portSchema.optional().transform(stoi).pipe(portSchema),
internal_port: portSchema.optional().transform(stoi).pipe(portSchema),
next_port: portSchema.optional().transform(stoi).pipe(portSchema),
internal_hostname: z.string().transform((url) => url.toLowerCase()),
session_cookie_name: z.string(),
resource_access_token_param: z.string(),
resource_access_token_headers: z.object({
id: z.string(),
token: z.string()
}),
resource_session_request_param: z.string(),
external_port: portSchema
.optional()
.default(3000)
.transform(stoi)
.pipe(portSchema),
internal_port: portSchema
.optional()
.default(3001)
.transform(stoi)
.pipe(portSchema),
next_port: portSchema
.optional()
.default(3002)
.transform(stoi)
.pipe(portSchema),
internal_hostname: z
.string()
.optional()
.default("pangolin")
.transform((url) => url.toLowerCase()),
session_cookie_name: z.string().optional().default("p_session_token"),
resource_access_token_param: z.string().optional().default("p_token"),
resource_access_token_headers: z
.object({
id: z.string().optional().default("P-Access-Token-Id"),
token: z.string().optional().default("P-Access-Token")
})
.optional()
.default({}),
resource_session_request_param: z
.string()
.optional()
.default("resource_session_request_param"),
dashboard_session_length_hours: z
.number()
.positive()

View file

@ -2,7 +2,7 @@ import path from "path";
import { fileURLToPath } from "url";
// This is a placeholder value replaced by the build process
export const APP_VERSION = "1.3.0";
export const APP_VERSION = "1.3.2";
export const __FILENAME = fileURLToPath(import.meta.url);
export const __DIRNAME = path.dirname(__FILENAME);

View file

@ -22,7 +22,7 @@ function detectIpVersion(ip: string): IPVersion {
*/
function ipToBigInt(ip: string): bigint {
const version = detectIpVersion(ip);
if (version === 4) {
return ip.split('.')
.reduce((acc, octet) => {
@ -110,7 +110,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) {
@ -121,7 +121,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 };
}
@ -140,17 +140,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');
}
@ -207,9 +207,11 @@ 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);
@ -274,4 +276,4 @@ export async function getNextAvailableOrgSubnet(): Promise<string> {
}
return subnet;
}
}

View file

@ -9,6 +9,10 @@ export function isValidIP(ip: string): boolean {
}
export function isValidUrlGlobPattern(pattern: string): boolean {
if (pattern === "/") {
return true;
}
// Remove leading slash if present
pattern = pattern.startsWith("/") ? pattern.slice(1) : pattern;

View file

@ -1,61 +1,136 @@
import { isPathAllowed } from './verifySession';
import { assertEquals } from '@test/assert';
function isPathAllowed(pattern: string, path: string): boolean {
// Normalize and split paths into segments
const normalize = (p: string) => p.split("/").filter(Boolean);
const patternParts = normalize(pattern);
const pathParts = normalize(path);
// Recursive function to try different wildcard matches
function matchSegments(patternIndex: number, pathIndex: number): boolean {
const indent = " ".repeat(pathIndex); // Indent based on recursion depth
const currentPatternPart = patternParts[patternIndex];
const currentPathPart = pathParts[pathIndex];
// If we've consumed all pattern parts, we should have consumed all path parts
if (patternIndex >= patternParts.length) {
const result = pathIndex >= pathParts.length;
return result;
}
// If we've consumed all path parts but still have pattern parts
if (pathIndex >= pathParts.length) {
// The only way this can match is if all remaining pattern parts are wildcards
const remainingPattern = patternParts.slice(patternIndex);
const result = remainingPattern.every((p) => p === "*");
return result;
}
// For full segment wildcards, try consuming different numbers of path segments
if (currentPatternPart === "*") {
// Try consuming 0 segments (skip the wildcard)
if (matchSegments(patternIndex + 1, pathIndex)) {
return true;
}
// Try consuming current segment and recursively try rest
if (matchSegments(patternIndex, pathIndex + 1)) {
return true;
}
return false;
}
// Check for in-segment wildcard (e.g., "prefix*" or "prefix*suffix")
if (currentPatternPart.includes("*")) {
// Convert the pattern segment to a regex pattern
const regexPattern = currentPatternPart
.replace(/\*/g, ".*") // Replace * with .* for regex wildcard
.replace(/\?/g, "."); // Replace ? with . for single character wildcard if needed
const regex = new RegExp(`^${regexPattern}$`);
if (regex.test(currentPathPart)) {
return matchSegments(patternIndex + 1, pathIndex + 1);
}
return false;
}
// For regular segments, they must match exactly
if (currentPatternPart !== currentPathPart) {
return false;
}
// Move to next segments in both pattern and path
return matchSegments(patternIndex + 1, pathIndex + 1);
}
const result = matchSegments(0, 0);
return result;
}
function runTests() {
console.log('Running path matching tests...');
// Test exact matching
assertEquals(isPathAllowed('foo', 'foo'), true, 'Exact match should be allowed');
assertEquals(isPathAllowed('foo', 'bar'), false, 'Different segments should not match');
assertEquals(isPathAllowed('foo/bar', 'foo/bar'), true, 'Exact multi-segment match should be allowed');
assertEquals(isPathAllowed('foo/bar', 'foo/baz'), false, 'Partial multi-segment match should not be allowed');
// Test with leading and trailing slashes
assertEquals(isPathAllowed('/foo', 'foo'), true, 'Pattern with leading slash should match');
assertEquals(isPathAllowed('foo/', 'foo'), true, 'Pattern with trailing slash should match');
assertEquals(isPathAllowed('/foo/', 'foo'), true, 'Pattern with both leading and trailing slashes should match');
assertEquals(isPathAllowed('foo', '/foo/'), true, 'Path with leading and trailing slashes should match');
// Test simple wildcard matching
assertEquals(isPathAllowed('*', 'foo'), true, 'Single wildcard should match any single segment');
assertEquals(isPathAllowed('*', 'foo/bar'), true, 'Single wildcard should match multiple segments');
assertEquals(isPathAllowed('*/bar', 'foo/bar'), true, 'Wildcard prefix should match');
assertEquals(isPathAllowed('foo/*', 'foo/bar'), true, 'Wildcard suffix should match');
assertEquals(isPathAllowed('foo/*/baz', 'foo/bar/baz'), true, 'Wildcard in middle should match');
// Test multiple wildcards
assertEquals(isPathAllowed('*/*', 'foo/bar'), true, 'Multiple wildcards should match corresponding segments');
assertEquals(isPathAllowed('*/*/*', 'foo/bar/baz'), true, 'Three wildcards should match three segments');
assertEquals(isPathAllowed('foo/*/*', 'foo/bar/baz'), true, 'Specific prefix with wildcards should match');
assertEquals(isPathAllowed('*/*/baz', 'foo/bar/baz'), true, 'Wildcards with specific suffix should match');
// Test wildcard consumption behavior
assertEquals(isPathAllowed('*', ''), true, 'Wildcard should optionally consume segments');
assertEquals(isPathAllowed('foo/*', 'foo'), true, 'Trailing wildcard should be optional');
assertEquals(isPathAllowed('*/*', 'foo'), true, 'Multiple wildcards can match fewer segments');
assertEquals(isPathAllowed('*/*/*', 'foo/bar'), true, 'Extra wildcards can be skipped');
// Test complex nested paths
assertEquals(isPathAllowed('api/*/users', 'api/v1/users'), true, 'API versioning pattern should match');
assertEquals(isPathAllowed('api/*/users/*', 'api/v1/users/123'), true, 'API resource pattern should match');
assertEquals(isPathAllowed('api/*/users/*/profile', 'api/v1/users/123/profile'), true, 'Nested API pattern should match');
// Test for the requested padbootstrap* pattern
assertEquals(isPathAllowed('padbootstrap*', 'padbootstrap'), true, 'padbootstrap* should match padbootstrap');
assertEquals(isPathAllowed('padbootstrap*', 'padbootstrapv1'), true, 'padbootstrap* should match padbootstrapv1');
assertEquals(isPathAllowed('padbootstrap*', 'padbootstrap/files'), false, 'padbootstrap* should not match padbootstrap/files');
assertEquals(isPathAllowed('padbootstrap*/*', 'padbootstrap/files'), true, 'padbootstrap*/* should match padbootstrap/files');
assertEquals(isPathAllowed('padbootstrap*/files', 'padbootstrapv1/files'), true, 'padbootstrap*/files should not match padbootstrapv1/files (wildcard is segment-based, not partial)');
// Test wildcard edge cases
assertEquals(isPathAllowed('*/*/*/*/*/*', 'a/b'), true, 'Many wildcards can match few segments');
assertEquals(isPathAllowed('a/*/b/*/c', 'a/anything/b/something/c'), true, 'Multiple wildcards in pattern should match corresponding segments');
// Test patterns with partial segment matches
assertEquals(isPathAllowed('padbootstrap*', 'padbootstrap-123'), true, 'Wildcards in isPathAllowed should be segment-based, not character-based');
assertEquals(isPathAllowed('test*', 'testuser'), true, 'Asterisk as part of segment name is treated as a literal, not a wildcard');
assertEquals(isPathAllowed('my*app', 'myapp'), true, 'Asterisk in middle of segment name is treated as a literal, not a wildcard');
assertEquals(isPathAllowed('/', '/'), true, 'Root path should match root path');
assertEquals(isPathAllowed('/', '/test'), false, 'Root path should not match non-root path');
console.log('All tests passed!');
}
@ -64,4 +139,4 @@ try {
runTests();
} catch (error) {
console.error('Test failed:', error);
}
}

View file

@ -28,7 +28,7 @@ const bodySchema = z
.strict();
const ensureTrailingSlash = (url: string): string => {
return url.endsWith('/') ? url : `${url}/`;
return url;
};
export type GenerateOidcUrlResponse = {

View file

@ -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
@ -160,7 +160,9 @@ export async function validateOidcCallback(
);
const idToken = tokens.idToken();
logger.debug("ID token", { idToken });
const claims = arctic.decodeIdToken(idToken);
logger.debug("ID token claims", { claims });
const userIdentifier = jmespath.search(
claims,
@ -243,7 +245,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.`
)
);
}

View file

@ -318,8 +318,8 @@ async function updateHttpResource(
domainId: updatePayload.domainId,
enabled: updatePayload.enabled,
stickySession: updatePayload.stickySession,
tlsServerName: updatePayload.tlsServerName || null,
setHostHeader: updatePayload.setHostHeader || null,
tlsServerName: updatePayload.tlsServerName,
setHostHeader: updatePayload.setHostHeader,
fullDomain: updatePayload.fullDomain
})
.where(eq(resources.resourceId, resource.resourceId))

View file

@ -320,8 +320,10 @@ export default function ReverseProxyTargets(props: {
AxiosResponse<CreateTargetResponse>
>(`/resource/${params.resourceId}/target`, data);
target.targetId = res.data.data.targetId;
target.new = false;
} else if (target.updated) {
await api.post(`/target/${target.targetId}`, data);
target.updated = false;
}
}
@ -363,12 +365,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 +395,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",
@ -796,6 +798,12 @@ export default function ReverseProxyTargets(props: {
type="submit"
variant="outlinePrimary"
className="mt-6"
disabled={
!(
addTargetForm.getValues("ip") &&
addTargetForm.getValues("port")
)
}
>
Add Target
</Button>

View file

@ -64,7 +64,6 @@ import {
InfoSections,
InfoSectionTitle
} from "@app/components/InfoSection";
import { Separator } from "@app/components/ui/separator";
import { InfoPopup } from "@app/components/ui/info-popup";
import {
isValidCIDR,

View file

@ -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 {

View file

@ -137,8 +137,8 @@ export function SitePriceCalculator({
</div>
<p className="text-muted-foreground text-sm mt-2 text-center">
For the most up-to-date pricing, please visit
our{" "}
For the most up-to-date pricing and discounts,
please visit the{" "}
<a
href="https://docs.fossorial.io/pricing"
target="_blank"

View file

@ -452,6 +452,12 @@ export default function LicensePage() {
in system
</div>
</div>
{!licenseStatus?.isHostLicensed && (
<p className="text-sm text-muted-foreground">
There is no limit on the number of sites
using an unlicensed host.
</p>
)}
{licenseStatus?.maxSites && (
<div className="space-y-2">
<div className="flex justify-between text-sm">

View file

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

View file

@ -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 };
});

View file

@ -250,7 +250,7 @@ export default function LoginForm({ redirect, onLogin, idps }: LoginFormProps) {
}
onChange={(e) => {
field.onChange(e);
if (e.target.value.length === 6) {
if (e.length === 6) {
mfaForm.handleSubmit(onSubmit)();
}
}}

View file

@ -55,7 +55,7 @@ export function SettingsSectionFooter({
}: {
children: React.ReactNode;
}) {
return <div className="flex justify-end space-x-2 mt-auto pt-8">{children}</div>;
return <div className="flex justify-end space-x-2 mt-auto pt-6">{children}</div>;
}
export function SettingsSectionGrid({

View file

@ -189,10 +189,12 @@ export default function SupporterStatus() {
<CredenzaBody>
<p>
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.
</p>
<p>

View file

@ -16,7 +16,7 @@ const ToastViewport = React.forwardRef<
<ToastPrimitives.Viewport
ref={ref}
className={cn(
"fixed top-0 z-[100] flex max-h-screen w-full flex-col-reverse p-4 sm:bottom-0 sm:right-0 sm:top-auto sm:flex-col md:max-w-[420px]",
"fixed top-0 right-0 z-[100] flex max-h-screen w-full flex-col-reverse p-4 md:max-w-[420px]",
className
)}
{...props}