Compare commits

...

16 Commits
1.3.0 ... 1.3.2

Author SHA1 Message Date
Milo Schwartz
a512148348 Merge pull request #701 from fosrl/dev
1.3.2
2025-05-10 11:30:07 -04:00
miloschwartz
d9eccd6c13 bump version 2025-05-10 11:28:22 -04:00
miloschwartz
492669f68a set default congig values 2025-05-09 18:32:14 -04:00
miloschwartz
caded23b51 allow root path 2025-05-09 17:37:55 -04:00
miloschwartz
e9cc48a3ae fix bug causing duplicate targets 2025-05-09 17:18:42 -04:00
miloschwartz
4ed98c227b fix setting tlsServerName and hostHeader conflict 2025-05-09 17:12:01 -04:00
miloschwartz
f66fb7d4a3 fix justification for profile icon 2025-05-09 17:09:22 -04:00
miloschwartz
f25990a9a7 add id token and claims to debug logs 2025-05-09 16:46:51 -04:00
miloschwartz
21d5b67ef1 Merge branch 'main' into dev 2025-05-09 16:44:09 -04:00
Milo Schwartz
198810121c Update README.md 2025-05-09 16:44:01 -04:00
Owen
83c0379c6b Merge branch 'dev' of github.com:fosrl/pangolin into dev 2025-05-03 22:04:29 -04:00
Milo Schwartz
21f1326045 Merge pull request #651 from fosrl/dev
Dev
2025-05-03 13:07:18 -04:00
miloschwartz
f62e32724c Merge branch 'main' into dev 2025-05-03 12:43:50 -04:00
miloschwartz
5e052a446a 1.3.1 2025-05-03 12:25:02 -04:00
Owen
9a167b5acb Dont overwrite the secret and crowdsec vars 2025-05-02 14:16:10 -04:00
Milo Schwartz
5d2f3186cc Update README.md 2025-05-02 12:25:49 -04:00
20 changed files with 247 additions and 123 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**: **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. 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 ## Similar Projects and Inspirations
**Cloudflare Tunnels**: **Cloudflare Tunnels**:

View File

@@ -64,14 +64,14 @@ func main() {
} }
var config Config var config Config
config.DoCrowdsecInstall = false
config.Secret = generateRandomSecretKey()
// 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)
loadVersions(&config) loadVersions(&config)
config.DoCrowdsecInstall = false
config.Secret = generateRandomSecretKey()
if err := createConfigFiles(config); err != nil { if err := createConfigFiles(config); err != nil {
fmt.Printf("Error creating config files: %v\n", err) fmt.Printf("Error creating config files: %v\n", err)

View File

@@ -29,9 +29,12 @@ const configSchema = z.object({
.optional() .optional()
.pipe(z.string().url()) .pipe(z.string().url())
.transform((url) => url.toLowerCase()), .transform((url) => url.toLowerCase()),
log_level: z.enum(["debug", "info", "warn", "error"]), log_level: z
save_logs: z.boolean(), .enum(["debug", "info", "warn", "error"])
log_failed_attempts: z.boolean().optional() .optional()
.default("info"),
save_logs: z.boolean().optional().default(false),
log_failed_attempts: z.boolean().optional().default(false)
}), }),
domains: z domains: z
.record( .record(
@@ -41,8 +44,8 @@ const configSchema = z.object({
.string() .string()
.nonempty("base_domain must not be empty") .nonempty("base_domain must not be empty")
.transform((url) => url.toLowerCase()), .transform((url) => url.toLowerCase()),
cert_resolver: z.string().optional(), cert_resolver: z.string().optional().default("letsencrypt"),
prefer_wildcard_cert: z.boolean().optional() prefer_wildcard_cert: z.boolean().optional().default(false)
}) })
) )
.refine( .refine(
@@ -62,19 +65,42 @@ const configSchema = z.object({
server: z.object({ server: z.object({
integration_port: portSchema integration_port: portSchema
.optional() .optional()
.default(3003)
.transform(stoi) .transform(stoi)
.pipe(portSchema.optional()), .pipe(portSchema.optional()),
external_port: portSchema.optional().transform(stoi).pipe(portSchema), external_port: portSchema
internal_port: portSchema.optional().transform(stoi).pipe(portSchema), .optional()
next_port: portSchema.optional().transform(stoi).pipe(portSchema), .default(3000)
internal_hostname: z.string().transform((url) => url.toLowerCase()), .transform(stoi)
session_cookie_name: z.string(), .pipe(portSchema),
resource_access_token_param: z.string(), internal_port: portSchema
resource_access_token_headers: z.object({ .optional()
id: z.string(), .default(3001)
token: z.string() .transform(stoi)
}), .pipe(portSchema),
resource_session_request_param: z.string(), 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 dashboard_session_length_hours: z
.number() .number()
.positive() .positive()
@@ -102,35 +128,61 @@ const configSchema = z.object({
.transform(getEnvOrYaml("SERVER_SECRET")) .transform(getEnvOrYaml("SERVER_SECRET"))
.pipe(z.string().min(8)) .pipe(z.string().min(8))
}), }),
traefik: z.object({ traefik: z
http_entrypoint: z.string(), .object({
https_entrypoint: z.string().optional(), http_entrypoint: z.string().optional().default("web"),
additional_middlewares: z.array(z.string()).optional() https_entrypoint: z.string().optional().default("websecure"),
}), additional_middlewares: z.array(z.string()).optional()
gerbil: z.object({ })
start_port: portSchema.optional().transform(stoi).pipe(portSchema), .optional()
base_endpoint: z .default({}),
.string() gerbil: z
.optional() .object({
.pipe(z.string()) start_port: portSchema
.transform((url) => url.toLowerCase()), .optional()
use_subdomain: z.boolean(), .default(51820)
subnet_group: z.string(), .transform(stoi)
block_size: z.number().positive().gt(0), .pipe(portSchema),
site_block_size: z.number().positive().gt(0) base_endpoint: z
}), .string()
rate_limits: z.object({ .optional()
global: z.object({ .pipe(z.string())
window_minutes: z.number().positive().gt(0), .transform((url) => url.toLowerCase()),
max_requests: z.number().positive().gt(0) use_subdomain: z.boolean().optional().default(false),
}), subnet_group: z.string().optional().default("100.89.137.0/20"),
auth: z block_size: z.number().positive().gt(0).optional().default(24),
.object({ site_block_size: z.number().positive().gt(0).optional().default(30)
window_minutes: z.number().positive().gt(0), })
max_requests: z.number().positive().gt(0) .optional()
}) .default({}),
.optional() rate_limits: z
}), .object({
global: z
.object({
window_minutes: z
.number()
.positive()
.gt(0)
.optional()
.default(1),
max_requests: z
.number()
.positive()
.gt(0)
.optional()
.default(500)
})
.optional()
.default({}),
auth: z
.object({
window_minutes: z.number().positive().gt(0),
max_requests: z.number().positive().gt(0)
})
.optional()
})
.optional()
.default({}),
email: z email: z
.object({ .object({
smtp_host: z.string().optional(), smtp_host: z.string().optional(),

View File

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

View File

@@ -197,8 +197,10 @@ export function isIpInCidr(ip: string, cidr: string): boolean {
const ipVersion = detectIpVersion(ip); const ipVersion = detectIpVersion(ip);
const cidrVersion = detectIpVersion(cidr.split('/')[0]); const cidrVersion = detectIpVersion(cidr.split('/')[0]);
// If IP versions don't match, the IP cannot be in the CIDR range
if (ipVersion !== cidrVersion) { 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 ipBigInt = ipToBigInt(ip);

View File

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

View File

@@ -1,6 +1,78 @@
import { isPathAllowed } from './verifySession';
import { assertEquals } from '@test/assert'; 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() { function runTests() {
console.log('Running path matching tests...'); console.log('Running path matching tests...');
@@ -56,6 +128,9 @@ function runTests() {
assertEquals(isPathAllowed('test*', 'testuser'), true, 'Asterisk as part of segment name is treated as a literal, not a wildcard'); 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('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!'); console.log('All tests passed!');
} }

View File

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

View File

@@ -23,7 +23,7 @@ import { oidcAutoProvision } from "./oidcAutoProvision";
import license from "@server/license/license"; import license from "@server/license/license";
const ensureTrailingSlash = (url: string): string => { const ensureTrailingSlash = (url: string): string => {
return url.endsWith("/") ? url : `${url}/`; return url;
}; };
const paramsSchema = z const paramsSchema = z
@@ -160,7 +160,9 @@ export async function validateOidcCallback(
); );
const idToken = tokens.idToken(); const idToken = tokens.idToken();
logger.debug("ID token", { idToken });
const claims = arctic.decodeIdToken(idToken); const claims = arctic.decodeIdToken(idToken);
logger.debug("ID token claims", { claims });
const userIdentifier = jmespath.search( const userIdentifier = jmespath.search(
claims, claims,
@@ -243,7 +245,7 @@ export async function validateOidcCallback(
return next( return next(
createHttpError( createHttpError(
HttpCode.UNAUTHORIZED, 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, domainId: updatePayload.domainId,
enabled: updatePayload.enabled, enabled: updatePayload.enabled,
stickySession: updatePayload.stickySession, stickySession: updatePayload.stickySession,
tlsServerName: updatePayload.tlsServerName || null, tlsServerName: updatePayload.tlsServerName,
setHostHeader: updatePayload.setHostHeader || null, setHostHeader: updatePayload.setHostHeader,
fullDomain: updatePayload.fullDomain fullDomain: updatePayload.fullDomain
}) })
.where(eq(resources.resourceId, resource.resourceId)) .where(eq(resources.resourceId, resource.resourceId))

View File

@@ -320,8 +320,10 @@ export default function ReverseProxyTargets(props: {
AxiosResponse<CreateTargetResponse> AxiosResponse<CreateTargetResponse>
>(`/resource/${params.resourceId}/target`, data); >(`/resource/${params.resourceId}/target`, data);
target.targetId = res.data.data.targetId; target.targetId = res.data.data.targetId;
target.new = false;
} else if (target.updated) { } else if (target.updated) {
await api.post(`/target/${target.targetId}`, data); await api.post(`/target/${target.targetId}`, data);
target.updated = false;
} }
} }
@@ -363,12 +365,12 @@ export default function ReverseProxyTargets(props: {
setHttpsTlsLoading(true); setHttpsTlsLoading(true);
await api.post(`/resource/${params.resourceId}`, { await api.post(`/resource/${params.resourceId}`, {
ssl: data.ssl, ssl: data.ssl,
tlsServerName: data.tlsServerName || undefined tlsServerName: data.tlsServerName || null
}); });
updateResource({ updateResource({
...resource, ...resource,
ssl: data.ssl, ssl: data.ssl,
tlsServerName: data.tlsServerName || undefined tlsServerName: data.tlsServerName || null
}); });
toast({ toast({
title: "TLS settings updated", title: "TLS settings updated",
@@ -393,11 +395,11 @@ export default function ReverseProxyTargets(props: {
try { try {
setProxySettingsLoading(true); setProxySettingsLoading(true);
await api.post(`/resource/${params.resourceId}`, { await api.post(`/resource/${params.resourceId}`, {
setHostHeader: data.setHostHeader || undefined setHostHeader: data.setHostHeader || null
}); });
updateResource({ updateResource({
...resource, ...resource,
setHostHeader: data.setHostHeader || undefined setHostHeader: data.setHostHeader || null
}); });
toast({ toast({
title: "Proxy settings updated", title: "Proxy settings updated",
@@ -796,6 +798,12 @@ export default function ReverseProxyTargets(props: {
type="submit" type="submit"
variant="outlinePrimary" variant="outlinePrimary"
className="mt-6" className="mt-6"
disabled={
!(
addTargetForm.getValues("ip") &&
addTargetForm.getValues("port")
)
}
> >
Add Target Add Target
</Button> </Button>

View File

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

View File

@@ -173,13 +173,15 @@ export default function Page() {
if (httpData.isBaseDomain) { if (httpData.isBaseDomain) {
Object.assign(payload, { Object.assign(payload, {
domainId: httpData.domainId, domainId: httpData.domainId,
isBaseDomain: true isBaseDomain: true,
protocol: "tcp"
}); });
} else { } else {
Object.assign(payload, { Object.assign(payload, {
subdomain: httpData.subdomain, subdomain: httpData.subdomain,
domainId: httpData.domainId, domainId: httpData.domainId,
isBaseDomain: false isBaseDomain: false,
protocol: "tcp"
}); });
} }
} else { } else {

View File

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

View File

@@ -452,6 +452,12 @@ export default function LicensePage() {
in system in system
</div> </div>
</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 && ( {licenseStatus?.maxSites && (
<div className="space-y-2"> <div className="space-y-2">
<div className="flex justify-between text-sm"> <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"> <div className="h-full flex flex-col">
{user && ( {user && (
<UserProvider user={user}> <UserProvider user={user}>
<div className="p-3"> <div className="p-3 ml-auto">
<ProfileIcon /> <ProfileIcon />
</div> </div>
</UserProvider> </UserProvider>

View File

@@ -16,33 +16,7 @@ export function Breadcrumbs() {
const breadcrumbs: BreadcrumbItem[] = segments.map((segment, index) => { const breadcrumbs: BreadcrumbItem[] = segments.map((segment, index) => {
const href = `/${segments.slice(0, index + 1).join("/")}`; const href = `/${segments.slice(0, index + 1).join("/")}`;
let label = segment; let label = decodeURIComponent(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";
// }
return { label, href }; return { label, href };
}); });

View File

@@ -55,7 +55,7 @@ export function SettingsSectionFooter({
}: { }: {
children: React.ReactNode; 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({ export function SettingsSectionGrid({

View File

@@ -189,10 +189,12 @@ export default function SupporterStatus() {
<CredenzaBody> <CredenzaBody>
<p> <p>
Purchase a supporter key to help us continue Purchase a supporter key to help us continue
developing Pangolin. Your contribution allows us developing Pangolin for the community. Your
commit more time to maintain and add new features to contribution allows us to commit more time to
the application for everyone. We will never use this maintain and add new features to the application for
to paywall features. everyone. We will never use this to paywall
features. This is separate from the Professional
Edition.
</p> </p>
<p> <p>

View File

@@ -16,7 +16,7 @@ const ToastViewport = React.forwardRef<
<ToastPrimitives.Viewport <ToastPrimitives.Viewport
ref={ref} ref={ref}
className={cn( 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 className
)} )}
{...props} {...props}