mirror of
https://github.com/fosrl/pangolin.git
synced 2026-02-03 08:39:09 +00:00
Compare commits
29 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
54f9282166 | ||
|
|
a39b1db266 | ||
|
|
2ddb4ec905 | ||
|
|
7a59e3acf7 | ||
|
|
b34c3db956 | ||
|
|
afea958aca | ||
|
|
dca2a29865 | ||
|
|
97b8e84143 | ||
|
|
23eb0da7d7 | ||
|
|
2edda471e7 | ||
|
|
676aa1358d | ||
|
|
87a36d6ae3 | ||
|
|
b67611094e | ||
|
|
2e986def78 | ||
|
|
d16a05959d | ||
|
|
7e58e0b490 | ||
|
|
9b01aecf3c | ||
|
|
86043fd5f8 | ||
|
|
372a1758e9 | ||
|
|
0a2b1d9e53 | ||
|
|
e562946308 | ||
|
|
398e15b3c6 | ||
|
|
c225a54dbe | ||
|
|
5148988dcc | ||
|
|
28b57ba652 | ||
|
|
9c7e74ef37 | ||
|
|
330b28ad9c | ||
|
|
da7166a7ea | ||
|
|
e8793c5d8d |
13
README.md
13
README.md
@@ -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
BIN
config/db/db.sqlite.bak
Normal file
Binary file not shown.
@@ -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}}
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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) => {
|
||||||
|
|||||||
@@ -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`
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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 node’s 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;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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(
|
||||||
@@ -164,28 +164,38 @@ export async function createSite(
|
|||||||
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`
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -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>
|
||||||
// );
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 && (
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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") }),
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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>) {
|
||||||
|
|||||||
Reference in New Issue
Block a user