Compare commits

..

16 Commits
1.7.0 ... 1.7.2

Author SHA1 Message Date
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
8 changed files with 310 additions and 302 deletions

View File

@@ -16,7 +16,7 @@ _Pangolin tunnels your services to the internet so you can access anything from
<div align="center">
<h5>
<a href="https://fossorial.io">
<a href="https://digpangolin.com">
Website
</a>
<span> | </span>
@@ -111,7 +111,7 @@ Host the full application on your own server or on the cloud with a VPS. Take a
### 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.

View File

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

View File

@@ -1266,6 +1266,7 @@
"createDomainName": "Name:",
"createDomainValue": "Value:",
"createDomainCnameRecords": "CNAME Records",
"createDomainARecords": "A Records",
"createDomainRecordNumber": "Record {number}",
"createDomainTxtRecords": "TXT Records",
"createDomainSaveTheseRecords": "Save These Records",

View File

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

View File

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

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(
pingResults: ExitNodePingResult[]
): ExitNodePingResult | null {
const MIN_CAPACITY_THRESHOLD = 0.1;
const LATENCY_TOLERANCE_MS = 30;
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");
if (!pingResults || pingResults.length === 0) {
logger.warn("No ping results provided");
return null;
}
// Sort by latency (ascending)
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;
return pingResults[0];
}

View File

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

View File

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