Compare commits

...

13 Commits
1.7.2 ... 1.7.3

Author SHA1 Message Date
Milo Schwartz
54f9282166 Merge pull request #1091 from fosrl/dev
Dev
2025-07-18 18:53:45 -04:00
miloschwartz
a39b1db266 bump version 2025-07-18 15:50:55 -07:00
miloschwartz
2ddb4ec905 allow multi level sudomains in domain picker 2025-07-18 15:48:23 -07:00
miloschwartz
7a59e3acf7 fix external user select box 2025-07-18 14:45:16 -07:00
miloschwartz
b34c3db956 fix redirect bug for some accounts when disable create org is enabled 2025-07-18 12:59:57 -07:00
Owen
afea958aca Also limit to org 2025-07-18 11:48:14 -07:00
Owen
dca2a29865 Fix #1085 2025-07-18 11:32:07 -07:00
Owen
97b8e84143 Fix #1085 2025-07-18 11:16:10 -07:00
Owen Schwartz
23eb0da7d7 Merge pull request #1089 from tomribbens/unauthenticated_email
test if smtp user/pass config is set and if not set auth: null
2025-07-18 10:28:17 -07:00
Owen Schwartz
2edda471e7 Merge pull request #1087 from itsbhanusharma/patch-1
Small Typo causes crowdsec to fail
2025-07-18 10:26:20 -07:00
Tom Ribbens
676aa1358d test if user/pass config is set and if not set auth: null 2025-07-18 17:09:22 +02:00
Bhanu
87a36d6ae3 Small Typo causes crowdsec to fail
first rule is named iame instead of name. seems like a recent typo. I edited file manually and it seems to have allowed crowdsec to boot up.
2025-07-18 18:40:15 +05:30
Owen
b67611094e YC 2025-07-18 00:28:10 -07:00
19 changed files with 172 additions and 159 deletions

View File

@@ -38,9 +38,12 @@ _Pangolin tunnels your services to the internet so you can access anything from
<p align="center">
<strong>
Start testing Pangolin at <a href="https://pangolin.fossorial.io/auth/signup">pangolin.fossorial.io</a>
<br/>
</strong>
Start testing Pangolin at <a href="https://pangolin.fossorial.io/auth/signup">pangolin.fossorial.io</a>
</strong>
</p>
<p align="center">
<a href='https://www.ycombinator.com/launches/O0B-pangolin-open-source-secure-gateway-to-private-networks' target="_blank"><img src='https://www.ycombinator.com/launches/O0B-pangolin-open-source-secure-gateway-to-private-networks/upvote_embed.svg' alt='Launch YC: Pangolin Open-source secure gateway to private networks'/ ></a>
</p>
Pangolin is a self-hosted tunneled reverse proxy server with identity and access control, designed to securely expose private resources on distributed networks. Acting as a central hub, it connects isolated networks — even those behind restrictive firewalls — through encrypted tunnels, enabling easy access to remote services without opening ports.

BIN
config/db/db.sqlite.bak Normal file

Binary file not shown.

View File

@@ -1,4 +1,4 @@
iame: captcha_remediation
name: captcha_remediation
filters:
- Alert.Remediation == true && Alert.GetScope() == "Ip" && Alert.GetScenario() contains "http"
decisions:
@@ -22,4 +22,4 @@ filters:
decisions:
- type: ban
duration: 4h
on_success: break
on_success: break

View File

@@ -1162,7 +1162,7 @@
"selectDomainTypeCnameName": "Single Domain (CNAME)",
"selectDomainTypeCnameDescription": "Just this specific domain. Use this for individual subdomains or specific domain entries.",
"selectDomainTypeWildcardName": "Wildcard Domain",
"selectDomainTypeWildcardDescription": "This domain and its first level of subdomains.",
"selectDomainTypeWildcardDescription": "This domain and its subdomains.",
"domainDelegation": "Single Domain",
"selectType": "Select a type",
"actions": "Actions",

View File

@@ -18,10 +18,10 @@ function createEmailClient() {
host: emailConfig.smtp_host,
port: emailConfig.smtp_port,
secure: emailConfig.smtp_secure || false,
auth: {
auth: (emailConfig.smtp_user && emailConfig.smtp_pass) ? {
user: emailConfig.smtp_user,
pass: emailConfig.smtp_pass
}
} : null
} as SMTPTransport.Options;
if (emailConfig.smtp_tls_reject_unauthorized !== undefined) {

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.7.0";
export const APP_VERSION = "1.7.3";
export const __FILENAME = fileURLToPath(import.meta.url);
export const __DIRNAME = path.dirname(__FILENAME);

View File

@@ -144,14 +144,16 @@ export async function createClient(
const subnetExistsClients = await db
.select()
.from(clients)
.where(eq(clients.subnet, updatedSubnet))
.where(
and(eq(clients.subnet, updatedSubnet), eq(clients.orgId, orgId))
)
.limit(1);
if (subnetExistsClients.length > 0) {
return next(
createHttpError(
HttpCode.CONFLICT,
`Subnet ${subnet} already exists`
`Subnet ${updatedSubnet} already exists in clients`
)
);
}
@@ -159,14 +161,16 @@ export async function createClient(
const subnetExistsSites = await db
.select()
.from(sites)
.where(eq(sites.address, updatedSubnet))
.where(
and(eq(sites.address, updatedSubnet), eq(sites.orgId, orgId))
)
.limit(1);
if (subnetExistsSites.length > 0) {
return next(
createHttpError(
HttpCode.CONFLICT,
`Subnet ${subnet} already exists`
`Subnet ${updatedSubnet} already exists in sites`
)
);
}

View File

@@ -620,8 +620,6 @@ authenticated.post(
authenticated.delete("/idp/:idpId", verifyUserIsServerAdmin, idp.deleteIdp);
authenticated.get("/idp", verifyUserIsServerAdmin, idp.listIdps);
authenticated.get("/idp/:idpId", verifyUserIsServerAdmin, idp.getIdp);
authenticated.put(

View File

@@ -162,6 +162,12 @@ export async function validateOidcCallback(
);
}
logger.debug("State verified", {
urL: ensureTrailingSlash(existingIdp.idpOidcConfig.tokenUrl),
expectedState,
state
});
const tokens = await client.validateAuthorizationCode(
ensureTrailingSlash(existingIdp.idpOidcConfig.tokenUrl),
code,

View File

@@ -261,14 +261,6 @@ async function createHttpResource(
)
);
}
if (parsedSubdomain.data.includes(".")) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
"Subdomain cannot contain a dot when using wildcard domains"
)
);
}
fullDomain = `${subdomain}.${domainRes.domains.baseDomain}`;
} else {
fullDomain = domainRes.domains.baseDomain;

View File

@@ -297,14 +297,6 @@ async function updateHttpResource(
)
);
}
if (parsedSubdomain.data.includes(".")) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
"Subdomain cannot contain a dot when using wildcard domains"
)
);
}
fullDomain = `${updateData.subdomain}.${domainRes.domains.baseDomain}`;
} else {
fullDomain = domainRes.domains.baseDomain;

View File

@@ -1,6 +1,6 @@
import { Request, Response, NextFunction } from "express";
import { z } from "zod";
import { db } from "@server/db";
import { clients, db } from "@server/db";
import { roles, userSites, sites, roleSites, Site, orgs } from "@server/db";
import response from "@server/lib/response";
import HttpCode from "@server/types/HttpCode";
@@ -129,17 +129,17 @@ export async function createSite(
);
}
if (!org.subnet) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
`Organization with ID ${orgId} has no subnet defined`
)
);
}
let updatedAddress = null;
if (address) {
if (!org.subnet) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
`Organization with ID ${orgId} has no subnet defined`
)
);
}
if (!isValidIP(address)) {
return next(
createHttpError(
@@ -148,7 +148,7 @@ export async function createSite(
)
);
}
if (!isIpInCidr(address, org.subnet)) {
return next(
createHttpError(
@@ -157,35 +157,45 @@ export async function createSite(
)
);
}
updatedAddress = `${address}/${org.subnet.split("/")[1]}`; // we want the block size of the whole org
// make sure the subnet is unique
const addressExistsSites = await db
.select()
.from(sites)
.where(eq(sites.address, updatedAddress))
.where(
and(
eq(sites.address, updatedAddress),
eq(sites.orgId, orgId)
)
)
.limit(1);
if (addressExistsSites.length > 0) {
return next(
createHttpError(
HttpCode.CONFLICT,
`Subnet ${subnet} already exists`
`Subnet ${updatedAddress} already exists in sites`
)
);
}
const addressExistsClients = await db
.select()
.from(sites)
.where(eq(sites.subnet, updatedAddress))
.from(clients)
.where(
and(
eq(clients.subnet, updatedAddress),
eq(clients.orgId, orgId)
)
)
.limit(1);
if (addressExistsClients.length > 0) {
return next(
createHttpError(
HttpCode.CONFLICT,
`Subnet ${subnet} already exists`
`Subnet ${updatedAddress} already exists in clients`
)
);
}

View File

@@ -9,7 +9,7 @@ import {
SettingsSectionHeader,
SettingsSectionTitle
} from "@app/components/Settings";
import { StrategySelect } from "@app/components/StrategySelect";
import { StrategyOption, StrategySelect } from "@app/components/StrategySelect";
import HeaderTitle from "@app/components/SettingsSectionTitle";
import { Button } from "@app/components/ui/button";
import { useParams, useRouter } from "next/navigation";
@@ -45,15 +45,10 @@ import { createApiClient } from "@app/lib/api";
import { Checkbox } from "@app/components/ui/checkbox";
import { ListIdpsResponse } from "@server/routers/idp";
import { useTranslations } from "next-intl";
import { build } from "@server/build";
type UserType = "internal" | "oidc";
interface UserTypeOption {
id: UserType;
title: string;
description: string;
}
interface IdpOption {
idpId: number;
name: string;
@@ -147,13 +142,20 @@ export default function Page() {
}
}, [userType, env.email.emailEnabled, internalForm, externalForm]);
const userTypes: UserTypeOption[] = [
const [userTypes, setUserTypes] = useState<StrategyOption<string>[]>([
{
id: "internal",
title: t("userTypeInternal"),
description: t("userTypeInternalDescription")
description: t("userTypeInternalDescription"),
disabled: false
},
{
id: "oidc",
title: t("userTypeExternal"),
description: t("userTypeExternalDescription"),
disabled: true
}
];
]);
useEffect(() => {
if (!userType) {
@@ -177,9 +179,6 @@ export default function Page() {
if (res?.status === 200) {
setRoles(res.data.data.roles);
if (userType === "internal") {
setDataLoaded(true);
}
}
}
@@ -200,24 +199,32 @@ export default function Page() {
if (res?.status === 200) {
setIdps(res.data.data.idps);
setDataLoaded(true);
if (res.data.data.idps.length) {
userTypes.push({
id: "oidc",
title: t("userTypeExternal"),
description: t("userTypeExternalDescription")
});
setUserTypes((prev) =>
prev.map((type) => {
if (type.id === "oidc") {
return {
...type,
disabled: false
};
}
return type;
})
);
}
}
}
setDataLoaded(false);
fetchRoles();
if (userType !== "internal") {
fetchIdps();
async function fetchInitialData() {
setDataLoaded(false);
await fetchRoles();
await fetchIdps();
setDataLoaded(true);
}
}, [userType]);
fetchInitialData();
}, []);
async function onSubmitInternal(
values: z.infer<typeof internalFormSchema>
@@ -323,7 +330,7 @@ export default function Page() {
<div>
<SettingsContainer>
{!inviteLink && userTypes.length > 1 ? (
{!inviteLink && build !== "saas" ? (
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>
@@ -610,7 +617,7 @@ export default function Page() {
idp || null
);
}}
cols={3}
cols={2}
/>
<FormMessage />
</FormItem>

View File

@@ -12,6 +12,7 @@ import { cleanRedirect } from "@app/lib/cleanRedirect";
import { Layout } from "@app/components/Layout";
import { InitialSetupCompleteResponse } from "@server/routers/auth";
import { cookies } from "next/headers";
import { build } from "@server/build";
export const dynamic = "force-dynamic";
@@ -83,25 +84,27 @@ export default async function Page(props: {
if (ownedOrg) {
redirect(`/${ownedOrg.orgId}`);
} else {
redirect("/setup");
if (!env.flags.disableUserCreateOrg || user.serverAdmin) {
redirect("/setup");
}
}
}
// return (
// <UserProvider user={user}>
// <Layout orgs={orgs} navItems={[]}>
// <div className="w-full max-w-md mx-auto md:mt-32 mt-4">
// <OrganizationLanding
// disableCreateOrg={
// env.flags.disableUserCreateOrg && !user.serverAdmin
// }
// organizations={orgs.map((org) => ({
// name: org.name,
// id: org.orgId
// }))}
// />
// </div>
// </Layout>
// </UserProvider>
// );
return (
<UserProvider user={user}>
<Layout orgs={orgs} navItems={[]}>
<div className="w-full max-w-md mx-auto md:mt-32 mt-4">
<OrganizationLanding
disableCreateOrg={
env.flags.disableUserCreateOrg && !user.serverAdmin
}
organizations={orgs.map((org) => ({
name: org.name,
id: org.orgId
}))}
/>
</div>
</Layout>
</UserProvider>
);
}

View File

@@ -179,7 +179,7 @@ export default function DomainPicker({
});
}
} else if (orgDomain.type === "wildcard") {
// For wildcard domains, allow the base domain or one level up
// For wildcard domains, allow the base domain or multiple levels up
const userInputLower = userInput.toLowerCase();
const baseDomainLower = orgDomain.baseDomain.toLowerCase();
@@ -194,24 +194,22 @@ export default function DomainPicker({
domainId: orgDomain.domainId
});
}
// Check if user input is one level up (subdomain.baseDomain)
// Check if user input ends with the base domain (allows multiple level subdomains)
else if (userInputLower.endsWith(`.${baseDomainLower}`)) {
const subdomain = userInputLower.slice(
0,
-(baseDomainLower.length + 1)
);
// Only allow one level up (no dots in subdomain)
if (!subdomain.includes(".")) {
options.push({
id: `org-${orgDomain.domainId}`,
domain: userInput,
type: "organization",
verified: orgDomain.verified,
domainType: "wildcard",
domainId: orgDomain.domainId,
subdomain: subdomain
});
}
// Allow multiple levels (subdomain can contain dots)
options.push({
id: `org-${orgDomain.domainId}`,
domain: userInput,
type: "organization",
verified: orgDomain.verified,
domainType: "wildcard",
domainId: orgDomain.domainId,
subdomain: subdomain
});
}
}
});
@@ -320,7 +318,7 @@ export default function DomainPicker({
setUserInput(validInput);
}}
/>
<p className="text-xs text-muted-foreground">
<p className="text-sm text-muted-foreground">
{build === "saas"
? t("domainPickerDescriptionSaas")
: t("domainPickerDescription")}
@@ -328,42 +326,44 @@ export default function DomainPicker({
</div>
{/* Tabs and Sort Toggle */}
<div className="flex justify-between items-center">
<Tabs
value={activeTab}
onValueChange={(value) =>
setActiveTab(
value as "all" | "organization" | "provided"
)
}
>
<TabsList>
<TabsTrigger value="all">
{t("domainPickerTabAll")}
</TabsTrigger>
<TabsTrigger value="organization">
{t("domainPickerTabOrganization")}
</TabsTrigger>
{build == "saas" && (
<TabsTrigger value="provided">
{t("domainPickerTabProvided")}
{build === "saas" && (
<div className="flex justify-between items-center">
<Tabs
value={activeTab}
onValueChange={(value) =>
setActiveTab(
value as "all" | "organization" | "provided"
)
}
>
<TabsList>
<TabsTrigger value="all">
{t("domainPickerTabAll")}
</TabsTrigger>
)}
</TabsList>
</Tabs>
<Button
variant="outline"
size="sm"
onClick={() =>
setSortOrder(sortOrder === "asc" ? "desc" : "asc")
}
>
<ArrowUpDown className="h-4 w-4 mr-2" />
{sortOrder === "asc"
? t("domainPickerSortAsc")
: t("domainPickerSortDesc")}
</Button>
</div>
<TabsTrigger value="organization">
{t("domainPickerTabOrganization")}
</TabsTrigger>
{build == "saas" && (
<TabsTrigger value="provided">
{t("domainPickerTabProvided")}
</TabsTrigger>
)}
</TabsList>
</Tabs>
<Button
variant="outline"
size="sm"
onClick={() =>
setSortOrder(sortOrder === "asc" ? "desc" : "asc")
}
>
<ArrowUpDown className="h-4 w-4 mr-2" />
{sortOrder === "asc"
? t("domainPickerSortAsc")
: t("domainPickerSortDesc")}
</Button>
</div>
)}
{/* Loading State */}
{isChecking && (

View File

@@ -41,35 +41,31 @@ export default function OrganizationLanding({
function getDescriptionText() {
if (organizations.length === 0) {
if (!disableCreateOrg) {
return t('componentsErrorNoMemberCreate');
return t("componentsErrorNoMemberCreate");
} else {
return t('componentsErrorNoMember');
return t("componentsErrorNoMember");
}
}
return t('componentsMember', {count: organizations.length});
return t("componentsMember", { count: organizations.length });
}
return (
<Card>
<CardHeader>
<CardTitle>{t('welcome')}</CardTitle>
<CardTitle>{t("welcome")}</CardTitle>
<CardDescription>{getDescriptionText()}</CardDescription>
</CardHeader>
<CardContent>
{organizations.length === 0 ? (
disableCreateOrg ? (
<p className="text-center text-muted-foreground">
t('componentsErrorNoMember')
</p>
) : (
!disableCreateOrg && (
<Link href="/setup">
<Button
className="w-full h-auto py-3 text-lg"
size="lg"
>
<Plus className="mr-2 h-5 w-5" />
{t('componentsCreateOrg')}
{t("componentsCreateOrg")}
</Button>
</Link>
)

View File

@@ -103,8 +103,10 @@ export default function SecurityKeyForm({
});
useEffect(() => {
loadSecurityKeys();
}, []);
if (open) {
loadSecurityKeys();
}
}, [open]);
const registerSchema = z.object({
name: z.string().min(1, { message: t("securityKeyNameRequired") }),

View File

@@ -4,7 +4,7 @@ import { cn } from "@app/lib/cn";
import { RadioGroup, RadioGroupItem } from "./ui/radio-group";
import { useState } from "react";
interface StrategyOption<TValue extends string> {
export interface StrategyOption<TValue extends string> {
id: TValue;
title: string;
description: string;

View File

@@ -18,7 +18,7 @@ function PopoverTrigger({
function PopoverContent({
className,
align = "center",
align = "start",
sideOffset = 4,
...props
}: React.ComponentProps<typeof PopoverPrimitive.Content>) {