Compare commits

...

29 Commits
1.7.0 ... 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
Owen
2e986def78 const 2025-07-17 23:15:16 -07:00
miloschwartz
d16a05959d Merge branch 'main' into dev 2025-07-17 23:14:50 -07:00
Owen
7e58e0b490 Correctly handle ssl on new domains 2025-07-17 22:57:47 -07:00
Owen
9b01aecf3c Add default cert resovler 2025-07-17 22:37:33 -07:00
miloschwartz
86043fd5f8 add defaults for domain cert resolver and prefer wildcard cert 2025-07-17 22:35:07 -07:00
Milo Schwartz
372a1758e9 Update README.md 2025-07-17 19:35:27 -04:00
Owen
0a2b1d9e53 Use a records for the wildcard 2025-07-17 16:17:01 -07:00
Owen
e562946308 Fix logic 2025-07-17 16:03:34 -07:00
Owen
398e15b3c6 Format 2025-07-17 15:59:28 -07:00
miloschwartz
c225a54dbe Merge branch 'dev' 2025-07-17 15:05:02 -07:00
Owen
5148988dcc Also dont return if you are passing an exit node 2025-07-17 14:59:49 -07:00
Owen
28b57ba652 Allow null exit node id as well 2025-07-17 14:57:09 -07:00
Owen
9c7e74ef37 Remove ping logic 2025-07-17 14:43:47 -07:00
Owen
330b28ad9c Fix local sites 2025-07-17 14:35:22 -07:00
miloschwartz
da7166a7ea fix config 2025-07-17 14:34:57 -07:00
Milo Schwartz
e8793c5d8d Update README.md 2025-07-17 16:35:00 -04:00
25 changed files with 482 additions and 461 deletions

View File

@@ -16,7 +16,7 @@ _Pangolin tunnels your services to the internet so you can access anything from
<div align="center"> <div align="center">
<h5> <h5>
<a href="https://fossorial.io"> <a href="https://digpangolin.com">
Website Website
</a> </a>
<span> | </span> <span> | </span>
@@ -38,9 +38,12 @@ _Pangolin tunnels your services to the internet so you can access anything from
<p align="center"> <p align="center">
<strong> <strong>
Start testing Pangolin at <a href="https://pangolin.fossorial.io/auth/signup">pangolin.fossorial.io</a> Start testing Pangolin at <a href="https://pangolin.fossorial.io/auth/signup">pangolin.fossorial.io</a>
<br/> </strong>
</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> </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. 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.
@@ -111,7 +114,7 @@ Host the full application on your own server or on the cloud with a VPS. Take a
### Pangolin Cloud ### Pangolin Cloud
Easy to use with simple pay as you go pricing. [Check it out here](https://pangolin.fossorial.io/auth/signup). Easy to use with simple [pay as you go pricing](https://digpangolin.io/pricing). [Check it out here](https://pangolin.fossorial.io/auth/signup).
- Everything you get with self hosted Pangolin, but fully managed for you. - Everything you get with self hosted Pangolin, but fully managed for you.

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

Binary file not shown.

View File

@@ -30,7 +30,7 @@ orgs:
email: email:
smtp_host: "{{.EmailSMTPHost}}" smtp_host: "{{.EmailSMTPHost}}"
smtp_port: {{.EmailSMTPPort}} smtp_port: {{.EmailSMTPPort}}
smtp_user: "{{.EmailSMTPUser}allow_base_domain_resources}" smtp_user: "{{.EmailSMTPUser}}"
smtp_pass: "{{.EmailSMTPPass}}" smtp_pass: "{{.EmailSMTPPass}}"
no_reply: "{{.EmailNoReply}}" no_reply: "{{.EmailNoReply}}"
{{end}} {{end}}

View File

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

View File

@@ -1162,7 +1162,7 @@
"selectDomainTypeCnameName": "Single Domain (CNAME)", "selectDomainTypeCnameName": "Single Domain (CNAME)",
"selectDomainTypeCnameDescription": "Just this specific domain. Use this for individual subdomains or specific domain entries.", "selectDomainTypeCnameDescription": "Just this specific domain. Use this for individual subdomains or specific domain entries.",
"selectDomainTypeWildcardName": "Wildcard Domain", "selectDomainTypeWildcardName": "Wildcard Domain",
"selectDomainTypeWildcardDescription": "This domain and its first level of subdomains.", "selectDomainTypeWildcardDescription": "This domain and its subdomains.",
"domainDelegation": "Single Domain", "domainDelegation": "Single Domain",
"selectType": "Select a type", "selectType": "Select a type",
"actions": "Actions", "actions": "Actions",
@@ -1266,6 +1266,7 @@
"createDomainName": "Name:", "createDomainName": "Name:",
"createDomainValue": "Value:", "createDomainValue": "Value:",
"createDomainCnameRecords": "CNAME Records", "createDomainCnameRecords": "CNAME Records",
"createDomainARecords": "A Records",
"createDomainRecordNumber": "Record {number}", "createDomainRecordNumber": "Record {number}",
"createDomainTxtRecords": "TXT Records", "createDomainTxtRecords": "TXT Records",
"createDomainSaveTheseRecords": "Save These Records", "createDomainSaveTheseRecords": "Save These Records",

View File

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

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.7.0"; export const APP_VERSION = "1.7.3";
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

@@ -128,7 +128,9 @@ export const configSchema = z
.object({ .object({
http_entrypoint: z.string().optional().default("web"), http_entrypoint: z.string().optional().default("web"),
https_entrypoint: z.string().optional().default("websecure"), https_entrypoint: z.string().optional().default("websecure"),
additional_middlewares: z.array(z.string()).optional() additional_middlewares: z.array(z.string()).optional(),
cert_resolver: z.string().optional().default("letsencrypt"),
prefer_wildcard_cert: z.boolean().optional().default(false)
}) })
.optional() .optional()
.default({}), .default({}),
@@ -157,13 +159,16 @@ export const configSchema = z
}) })
.optional() .optional()
.default({}), .default({}),
orgs: z.object({ orgs: z
block_size: z.number().positive().gt(0).optional().default(24), .object({
subnet_group: z.string().optional().default("100.90.128.0/24") block_size: z.number().positive().gt(0).optional().default(24),
}).optional().default({ subnet_group: z.string().optional().default("100.90.128.0/24")
block_size: 24, })
subnet_group: "100.90.128.0/24" .optional()
}), .default({
block_size: 24,
subnet_group: "100.90.128.0/24"
}),
rate_limits: z rate_limits: z
.object({ .object({
global: z global: z
@@ -242,7 +247,7 @@ export const configSchema = z
{ {
message: "At least one domain must be defined" message: "At least one domain must be defined"
} }
) );
export function readConfigFile() { export function readConfigFile() {
const loadConfig = (configPath: string) => { const loadConfig = (configPath: string) => {

View File

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

View File

@@ -29,6 +29,7 @@ export type CreateDomainResponse = {
domainId: string; domainId: string;
nsRecords?: string[]; nsRecords?: string[];
cnameRecords?: { baseDomain: string; value: string }[]; cnameRecords?: { baseDomain: string; value: string }[];
aRecords?: { baseDomain: string; value: string }[];
txtRecords?: { baseDomain: string; value: string }[]; txtRecords?: { baseDomain: string; value: string }[];
}; };
@@ -97,6 +98,7 @@ export async function createOrgDomain(
} }
let numOrgDomains: OrgDomains[] | undefined; let numOrgDomains: OrgDomains[] | undefined;
let aRecords: CreateDomainResponse["aRecords"];
let cnameRecords: CreateDomainResponse["cnameRecords"]; let cnameRecords: CreateDomainResponse["cnameRecords"];
let txtRecords: CreateDomainResponse["txtRecords"]; let txtRecords: CreateDomainResponse["txtRecords"];
let nsRecords: CreateDomainResponse["nsRecords"]; let nsRecords: CreateDomainResponse["nsRecords"];
@@ -239,7 +241,7 @@ export async function createOrgDomain(
} }
]; ];
} else if (type === "wildcard") { } else if (type === "wildcard") {
cnameRecords = [ aRecords = [
{ {
value: `Server IP Address`, value: `Server IP Address`,
baseDomain: `*.${baseDomain}` baseDomain: `*.${baseDomain}`
@@ -271,7 +273,8 @@ export async function createOrgDomain(
domainId: returned.domainId, domainId: returned.domainId,
cnameRecords, cnameRecords,
txtRecords, txtRecords,
nsRecords nsRecords,
aRecords
}, },
success: true, success: true,
error: false, error: false,

View File

@@ -620,8 +620,6 @@ authenticated.post(
authenticated.delete("/idp/:idpId", verifyUserIsServerAdmin, idp.deleteIdp); authenticated.delete("/idp/:idpId", verifyUserIsServerAdmin, idp.deleteIdp);
authenticated.get("/idp", verifyUserIsServerAdmin, idp.listIdps);
authenticated.get("/idp/:idpId", verifyUserIsServerAdmin, idp.getIdp); authenticated.get("/idp/:idpId", verifyUserIsServerAdmin, idp.getIdp);
authenticated.put( 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( const tokens = await client.validateAuthorizationCode(
ensureTrailingSlash(existingIdp.idpOidcConfig.tokenUrl), ensureTrailingSlash(existingIdp.idpOidcConfig.tokenUrl),
code, code,

View File

@@ -258,101 +258,13 @@ export const handleNewtRegisterMessage: MessageHandler = async (context) => {
}; };
}; };
/**
* Selects the most suitable exit node from a list of ping results.
*
* The selection algorithm follows these steps:
*
* 1. **Filter Invalid Nodes**: Excludes nodes with errors or zero weight.
*
* 2. **Sort by Latency**: Sorts valid nodes in ascending order of latency.
*
* 3. **Preferred Selection**:
* - If the lowest-latency node has sufficient capacity (≥10% weight),
* check if a previously connected node is also acceptable.
* - The previously connected node is preferred if its latency is within
* 30ms or 15% of the best nodes latency.
*
* 4. **Fallback to Next Best**:
* - If the lowest-latency node is under capacity, find the next node
* with acceptable capacity.
*
* 5. **Final Fallback**:
* - If no nodes meet the capacity threshold, fall back to the node
* with the highest weight (i.e., most available capacity).
*
*/
function selectBestExitNode( function selectBestExitNode(
pingResults: ExitNodePingResult[] pingResults: ExitNodePingResult[]
): ExitNodePingResult | null { ): ExitNodePingResult | null {
const MIN_CAPACITY_THRESHOLD = 0.1; if (!pingResults || pingResults.length === 0) {
const LATENCY_TOLERANCE_MS = 30; logger.warn("No ping results provided");
const LATENCY_TOLERANCE_PERCENT = 0.15;
// Filter out invalid nodes
const validNodes = pingResults.filter((n) => !n.error && n.weight > 0);
if (validNodes.length === 0) {
logger.error("No valid exit nodes available");
return null; return null;
} }
// Sort by latency (ascending) return pingResults[0];
const sortedNodes = validNodes
.slice()
.sort((a, b) => a.latencyMs - b.latencyMs);
const lowestLatencyNode = sortedNodes[0];
logger.info(
`Lowest latency node: ${lowestLatencyNode.exitNodeName} (${lowestLatencyNode.latencyMs} ms, weight=${lowestLatencyNode.weight.toFixed(2)})`
);
// If lowest latency node has enough capacity, check if previously connected node is acceptable
if (lowestLatencyNode.weight >= MIN_CAPACITY_THRESHOLD) {
const previouslyConnectedNode = sortedNodes.find(
(n) =>
n.wasPreviouslyConnected && n.weight >= MIN_CAPACITY_THRESHOLD
);
if (previouslyConnectedNode) {
const latencyDiff =
previouslyConnectedNode.latencyMs - lowestLatencyNode.latencyMs;
const percentDiff = latencyDiff / lowestLatencyNode.latencyMs;
if (
latencyDiff <= LATENCY_TOLERANCE_MS ||
percentDiff <= LATENCY_TOLERANCE_PERCENT
) {
logger.info(
`Sticking with previously connected node: ${previouslyConnectedNode.exitNodeName} ` +
`(${previouslyConnectedNode.latencyMs} ms), latency diff = ${latencyDiff.toFixed(1)}ms ` +
`/ ${(percentDiff * 100).toFixed(1)}%.`
);
return previouslyConnectedNode;
}
}
return lowestLatencyNode;
}
// Otherwise, find the next node (after the lowest) that has enough capacity
for (let i = 1; i < sortedNodes.length; i++) {
const node = sortedNodes[i];
if (node.weight >= MIN_CAPACITY_THRESHOLD) {
logger.info(
`Lowest latency node under capacity. Using next best: ${node.exitNodeName} ` +
`(${node.latencyMs} ms, weight=${node.weight.toFixed(2)})`
);
return node;
}
}
// Fallback: pick the highest weight node
const fallbackNode = validNodes.reduce((a, b) =>
a.weight > b.weight ? a : b
);
logger.warn(
`No nodes with ≥10% weight. Falling back to highest capacity node: ${fallbackNode.exitNodeName}`
);
return fallbackNode;
} }

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}`; fullDomain = `${subdomain}.${domainRes.domains.baseDomain}`;
} else { } else {
fullDomain = domainRes.domains.baseDomain; 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}`; fullDomain = `${updateData.subdomain}.${domainRes.domains.baseDomain}`;
} else { } else {
fullDomain = domainRes.domains.baseDomain; fullDomain = domainRes.domains.baseDomain;

View File

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

View File

@@ -1,6 +1,6 @@
import { Request, Response } from "express"; import { Request, Response } from "express";
import { db, exitNodes } from "@server/db"; import { db, exitNodes } from "@server/db";
import { and, eq, inArray } from "drizzle-orm"; import { and, eq, inArray, or, isNull } from "drizzle-orm";
import logger from "@server/logger"; import logger from "@server/logger";
import HttpCode from "@server/types/HttpCode"; import HttpCode from "@server/types/HttpCode";
import config from "@server/lib/config"; import config from "@server/lib/config";
@@ -27,13 +27,9 @@ export async function traefikConfigProvider(
}) })
.from(exitNodes) .from(exitNodes)
.where(eq(exitNodes.name, exitNodeName)); .where(eq(exitNodes.name, exitNodeName));
if (!exitNode) { if (exitNode) {
logger.error( currentExitNodeId = exitNode.exitNodeId;
`Exit node with name ${exitNodeName} not found in the database`
);
return [];
} }
currentExitNodeId = exitNode.exitNodeId;
} else { } else {
const [exitNode] = await tx const [exitNode] = await tx
.select({ .select({
@@ -42,12 +38,9 @@ export async function traefikConfigProvider(
.from(exitNodes) .from(exitNodes)
.limit(1); .limit(1);
if (!exitNode) { if (exitNode) {
logger.error("No exit node found in the database"); currentExitNodeId = exitNode.exitNodeId;
return [];
} }
currentExitNodeId = exitNode.exitNodeId;
} }
} }
@@ -68,7 +61,7 @@ export async function traefikConfigProvider(
siteId: sites.siteId, siteId: sites.siteId,
type: sites.type, type: sites.type,
subnet: sites.subnet, subnet: sites.subnet,
exitNodeId: sites.exitNodeId, exitNodeId: sites.exitNodeId
}, },
enabled: resources.enabled, enabled: resources.enabled,
stickySession: resources.stickySession, stickySession: resources.stickySession,
@@ -77,7 +70,12 @@ export async function traefikConfigProvider(
}) })
.from(resources) .from(resources)
.innerJoin(sites, eq(sites.siteId, resources.siteId)) .innerJoin(sites, eq(sites.siteId, resources.siteId))
.where(eq(sites.exitNodeId, currentExitNodeId)); .where(
or(
eq(sites.exitNodeId, currentExitNodeId),
isNull(sites.exitNodeId)
)
);
// Get all resource IDs from the first query // Get all resource IDs from the first query
const resourceIds = resourcesWithRelations.map((r) => r.resourceId); const resourceIds = resourcesWithRelations.map((r) => r.resourceId);
@@ -216,22 +214,29 @@ export async function traefikConfigProvider(
const configDomain = config.getDomain(resource.domainId); const configDomain = config.getDomain(resource.domainId);
let tls = {}; let certResolver: string, preferWildcardCert: boolean;
if (configDomain) { if (!configDomain) {
tls = { certResolver = config.getRawConfig().traefik.cert_resolver;
certResolver: configDomain.cert_resolver, preferWildcardCert =
...(configDomain.prefer_wildcard_cert config.getRawConfig().traefik.prefer_wildcard_cert;
? { } else {
domains: [ certResolver = configDomain.cert_resolver;
{ preferWildcardCert = configDomain.prefer_wildcard_cert;
main: wildCard
}
]
}
: {})
};
} }
const tls = {
certResolver: certResolver,
...(preferWildcardCert
? {
domains: [
{
main: wildCard
}
]
}
: {})
};
const additionalMiddlewares = const additionalMiddlewares =
config.getRawConfig().traefik.additional_middlewares || []; config.getRawConfig().traefik.additional_middlewares || [];
@@ -284,7 +289,8 @@ export async function traefikConfigProvider(
} else if (site.type === "newt") { } else if (site.type === "newt") {
if ( if (
!target.internalPort || !target.internalPort ||
!target.method || !site.subnet !target.method ||
!site.subnet
) { ) {
return false; return false;
} }

View File

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

View File

@@ -205,188 +205,267 @@ export default function CreateDomainForm({
</Alert> </Alert>
<div className="space-y-4"> <div className="space-y-4">
{domainType === "ns" && {createdDomain.nsRecords &&
createdDomain.nsRecords && ( createdDomain.nsRecords.length > 0 && (
<div>
<h3 className="font-medium mb-3">
{t("createDomainNsRecords")}
</h3>
<InfoSections cols={1}>
<InfoSection>
<InfoSectionTitle>
{t("createDomainRecord")}
</InfoSectionTitle>
<InfoSectionContent>
<div className="space-y-2">
<div className="flex justify-between items-center">
<span className="text-sm font-medium">
{t(
"createDomainType"
)}
</span>
<span className="text-sm font-mono">
NS
</span>
</div>
<div className="flex justify-between items-center">
<span className="text-sm font-medium">
{t(
"createDomainName"
)}
</span>
<span className="text-sm font-mono">
{baseDomain}
</span>
</div>
<span className="text-sm font-medium">
{t(
"createDomainValue"
)}
</span>
{createdDomain.nsRecords.map(
(
nsRecord,
index
) => (
<div
className="flex justify-between items-center"
key={index}
>
<CopyToClipboard
text={
nsRecord
}
/>
</div>
)
)}
</div>
</InfoSectionContent>
</InfoSection>
</InfoSections>
</div>
)}
{createdDomain.cnameRecords &&
createdDomain.cnameRecords.length > 0 && (
<div> <div>
<h3 className="font-medium mb-3"> <h3 className="font-medium mb-3">
{t("createDomainNsRecords")} {t("createDomainCnameRecords")}
</h3> </h3>
<InfoSections cols={1}> <InfoSections cols={1}>
<InfoSection> {createdDomain.cnameRecords.map(
<InfoSectionTitle> (cnameRecord, index) => (
{t("createDomainRecord")} <InfoSection
</InfoSectionTitle> key={index}
<InfoSectionContent> >
<div className="space-y-2"> <InfoSectionTitle>
<div className="flex justify-between items-center"> {t(
<span className="text-sm font-medium"> "createDomainRecordNumber",
{t("createDomainType")} {
</span> number:
<span className="text-sm font-mono"> index +
NS 1
</span> }
</div> )}
<div className="flex justify-between items-center"> </InfoSectionTitle>
<span className="text-sm font-medium"> <InfoSectionContent>
{t("createDomainName")} <div className="space-y-2">
</span> <div className="flex justify-between items-center">
<span className="text-sm font-mono"> <span className="text-sm font-medium">
{baseDomain} {t(
</span> "createDomainType"
</div> )}
<span className="text-sm font-medium"> </span>
{t("createDomainValue")} <span className="text-sm font-mono">
</span> CNAME
{createdDomain.nsRecords.map( </span>
( </div>
nsRecord, <div className="flex justify-between items-center">
index <span className="text-sm font-medium">
) => ( {t(
<div "createDomainName"
className="flex justify-between items-center" )}
key={ </span>
index <span className="text-sm font-mono">
} {
> cnameRecord.baseDomain
}
</span>
</div>
<div className="flex justify-between items-center">
<span className="text-sm font-medium">
{t(
"createDomainValue"
)}
</span>
<CopyToClipboard <CopyToClipboard
text={ text={
nsRecord cnameRecord.value
} }
/> />
</div> </div>
) </div>
)} </InfoSectionContent>
</div> </InfoSection>
</InfoSectionContent> )
</InfoSection> )}
</InfoSections> </InfoSections>
</div> </div>
)} )}
{domainType === "cname" || {createdDomain.aRecords &&
(domainType == "wildcard" && ( createdDomain.aRecords.length > 0 && (
<> <div>
{createdDomain.cnameRecords && <h3 className="font-medium mb-3">
createdDomain.cnameRecords {t("createDomainARecords")}
.length > 0 && ( </h3>
<div> <InfoSections cols={1}>
<h3 className="font-medium mb-3"> {createdDomain.aRecords.map(
{t("createDomainCnameRecords")} (aRecord, index) => (
</h3> <InfoSection
<InfoSections cols={1}> key={index}
{createdDomain.cnameRecords.map( >
( <InfoSectionTitle>
cnameRecord, {t(
index "createDomainRecordNumber",
) => ( {
<InfoSection number:
key={ index +
index 1
} }
> )}
<InfoSectionTitle> </InfoSectionTitle>
{t("createDomainRecordNumber", { number: index + 1 })} <InfoSectionContent>
</InfoSectionTitle> <div className="space-y-2">
<InfoSectionContent> <div className="flex justify-between items-center">
<div className="space-y-2"> <span className="text-sm font-medium">
<div className="flex justify-between items-center"> {t(
<span className="text-sm font-medium"> "createDomainType"
{t("createDomainType")} )}
</span> </span>
<span className="text-sm font-mono"> <span className="text-sm font-mono">
CNAME A
</span> </span>
</div> </div>
<div className="flex justify-between items-center"> <div className="flex justify-between items-center">
<span className="text-sm font-medium"> <span className="text-sm font-medium">
{t("createDomainName")} {t(
</span> "createDomainName"
<span className="text-sm font-mono"> )}
{ </span>
cnameRecord.baseDomain <span className="text-sm font-mono">
} {
</span> aRecord.baseDomain
</div> }
<div className="flex justify-between items-center"> </span>
<span className="text-sm font-medium"> </div>
{t("createDomainValue")} <div className="flex justify-between items-center">
</span> <span className="text-sm font-medium">
<CopyToClipboard {t(
text={ "createDomainValue"
cnameRecord.value )}
} </span>
/> <span className="text-sm font-mono">
</div> {
</div> aRecord.value
</InfoSectionContent> }
</InfoSection> </span>
) </div>
)} </div>
</InfoSections> </InfoSectionContent>
</div> </InfoSection>
)
)} )}
</InfoSections>
{createdDomain.txtRecords && </div>
createdDomain.txtRecords )}
.length > 0 && ( {createdDomain.txtRecords &&
<div> createdDomain.txtRecords.length > 0 && (
<h3 className="font-medium mb-3"> <div>
{t("createDomainTxtRecords")} <h3 className="font-medium mb-3">
</h3> {t("createDomainTxtRecords")}
<InfoSections cols={1}> </h3>
{createdDomain.txtRecords.map( <InfoSections cols={1}>
( {createdDomain.txtRecords.map(
txtRecord, (txtRecord, index) => (
index <InfoSection
) => ( key={index}
<InfoSection >
key={ <InfoSectionTitle>
index {t(
} "createDomainRecordNumber",
> {
<InfoSectionTitle> number:
{t("createDomainRecordNumber", { number: index + 1 })} index +
</InfoSectionTitle> 1
<InfoSectionContent> }
<div className="space-y-2"> )}
<div className="flex justify-between items-center"> </InfoSectionTitle>
<span className="text-sm font-medium"> <InfoSectionContent>
{t("createDomainType")} <div className="space-y-2">
</span> <div className="flex justify-between items-center">
<span className="text-sm font-mono"> <span className="text-sm font-medium">
TXT {t(
</span> "createDomainType"
</div> )}
<div className="flex justify-between items-center"> </span>
<span className="text-sm font-medium"> <span className="text-sm font-mono">
{t("createDomainName")} TXT
</span> </span>
<span className="text-sm font-mono"> </div>
{ <div className="flex justify-between items-center">
txtRecord.baseDomain <span className="text-sm font-medium">
} {t(
</span> "createDomainName"
</div> )}
<div className="flex justify-between items-center"> </span>
<span className="text-sm font-medium"> <span className="text-sm font-mono">
{t("createDomainValue")} {
</span> txtRecord.baseDomain
<CopyToClipboard }
text={ </span>
txtRecord.value </div>
} <div className="flex justify-between items-center">
/> <span className="text-sm font-medium">
</div> {t(
</div> "createDomainValue"
</InfoSectionContent> )}
</InfoSection> </span>
) <CopyToClipboard
)} text={
</InfoSections> txtRecord.value
</div> }
/>
</div>
</div>
</InfoSectionContent>
</InfoSection>
)
)} )}
</> </InfoSections>
))} </div>
)}
</div> </div>
{build == "saas" || {build == "saas" ||
@@ -397,7 +476,9 @@ export default function CreateDomainForm({
{t("createDomainSaveTheseRecords")} {t("createDomainSaveTheseRecords")}
</AlertTitle> </AlertTitle>
<AlertDescription> <AlertDescription>
{t("createDomainSaveTheseRecordsDescription")} {t(
"createDomainSaveTheseRecordsDescription"
)}
</AlertDescription> </AlertDescription>
</Alert> </Alert>
))} ))}

View File

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

View File

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

View File

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

View File

@@ -103,8 +103,10 @@ export default function SecurityKeyForm({
}); });
useEffect(() => { useEffect(() => {
loadSecurityKeys(); if (open) {
}, []); loadSecurityKeys();
}
}, [open]);
const registerSchema = z.object({ const registerSchema = z.object({
name: z.string().min(1, { message: t("securityKeyNameRequired") }), 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 { RadioGroup, RadioGroupItem } from "./ui/radio-group";
import { useState } from "react"; import { useState } from "react";
interface StrategyOption<TValue extends string> { export interface StrategyOption<TValue extends string> {
id: TValue; id: TValue;
title: string; title: string;
description: string; description: string;

View File

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