Merge branch 'dev' of https://github.com/fosrl/pangolin into dev

This commit is contained in:
miloschwartz
2025-12-19 17:06:57 -05:00
9 changed files with 109 additions and 21 deletions

View File

@@ -1053,6 +1053,8 @@
"actionGetSite": "Get Site", "actionGetSite": "Get Site",
"actionListSites": "List Sites", "actionListSites": "List Sites",
"actionApplyBlueprint": "Apply Blueprint", "actionApplyBlueprint": "Apply Blueprint",
"actionListBlueprints": "List Blueprints",
"actionGetBlueprint": "Get Blueprint",
"setupToken": "Setup Token", "setupToken": "Setup Token",
"setupTokenDescription": "Enter the setup token from the server console.", "setupTokenDescription": "Enter the setup token from the server console.",
"setupTokenRequired": "Setup token is required", "setupTokenRequired": "Setup token is required",
@@ -2309,6 +2311,8 @@
"setupFailedToFetchSubnet": "Failed to fetch default subnet", "setupFailedToFetchSubnet": "Failed to fetch default subnet",
"setupSubnetAdvanced": "Subnet (Advanced)", "setupSubnetAdvanced": "Subnet (Advanced)",
"setupSubnetDescription": "The subnet for this organization's internal network.", "setupSubnetDescription": "The subnet for this organization's internal network.",
"setupUtilitySubnet": "Utility Subnet (Advanced)",
"setupUtilitySubnetDescription": "The subnet for this organization's alias addresses and DNS server.",
"siteRegenerateAndDisconnect": "Regenerate and Disconnect", "siteRegenerateAndDisconnect": "Regenerate and Disconnect",
"siteRegenerateAndDisconnectConfirmation": "Are you sure you want to regenerate the credentials and disconnect this site?", "siteRegenerateAndDisconnectConfirmation": "Are you sure you want to regenerate the credentials and disconnect this site?",
"siteRegenerateAndDisconnectWarning": "This will regenerate the credentials and immediately disconnect the site. The site will need to be restarted with the new credentials.", "siteRegenerateAndDisconnectWarning": "This will regenerate the credentials and immediately disconnect the site. The site will need to be restarted with the new credentials.",

View File

@@ -1022,6 +1022,8 @@
"actionGetSite": "獲取站點", "actionGetSite": "獲取站點",
"actionListSites": "站點列表", "actionListSites": "站點列表",
"actionApplyBlueprint": "應用藍圖", "actionApplyBlueprint": "應用藍圖",
"actionListBlueprints": "藍圖列表",
"actionGetBlueprint": "獲取藍圖",
"setupToken": "設置令牌", "setupToken": "設置令牌",
"setupTokenDescription": "從伺服器控制台輸入設定令牌。", "setupTokenDescription": "從伺服器控制台輸入設定令牌。",
"setupTokenRequired": "需要設置令牌", "setupTokenRequired": "需要設置令牌",

View File

@@ -301,6 +301,29 @@ export function isIpInCidr(ip: string, cidr: string): boolean {
return ipBigInt >= range.start && ipBigInt <= range.end; return ipBigInt >= range.start && ipBigInt <= range.end;
} }
/**
* Checks if two CIDR ranges overlap
* @param cidr1 First CIDR string
* @param cidr2 Second CIDR string
* @returns boolean indicating if the two CIDRs overlap
*/
export function doCidrsOverlap(cidr1: string, cidr2: string): boolean {
const version1 = detectIpVersion(cidr1.split("/")[0]);
const version2 = detectIpVersion(cidr2.split("/")[0]);
if (version1 !== version2) {
// Different IP versions cannot overlap
return false;
}
const range1 = cidrToRange(cidr1);
const range2 = cidrToRange(cidr2);
// Overlap if the ranges intersect
return (
range1.start <= range2.end &&
range2.start <= range1.end
);
}
export async function getNextAvailableClientSubnet( export async function getNextAvailableClientSubnet(
orgId: string, orgId: string,
transaction: Transaction | typeof db = db transaction: Transaction | typeof db = db

View File

@@ -255,11 +255,11 @@ export const configSchema = z
orgs: z orgs: z
.object({ .object({
block_size: z.number().positive().gt(0).optional().default(24), block_size: z.number().positive().gt(0).optional().default(24),
subnet_group: z.string().optional().default("100.90.128.0/24"), subnet_group: z.string().optional().default("100.90.128.0/20"),
utility_subnet_group: z utility_subnet_group: z
.string() .string()
.optional() .optional()
.default("100.96.128.0/24") //just hardcode this for now as well .default("100.96.128.0/20") //just hardcode this for now as well
}) })
.optional() .optional()
.default({ .default({

View File

@@ -858,6 +858,22 @@ authenticated.put(
blueprints.applyJSONBlueprint blueprints.applyJSONBlueprint
); );
authenticated.get(
"/org/:orgId/blueprint/:blueprintId",
verifyApiKeyOrgAccess,
verifyApiKeyHasAction(ActionsEnum.getBlueprint),
blueprints.getBlueprint
);
authenticated.get(
"/org/:orgId/blueprints",
verifyApiKeyOrgAccess,
verifyApiKeyHasAction(ActionsEnum.listBlueprints),
blueprints.listBlueprints
);
authenticated.get( authenticated.get(
"/org/:orgId/logs/request", "/org/:orgId/logs/request",
verifyApiKeyOrgAccess, verifyApiKeyOrgAccess,

View File

@@ -27,6 +27,7 @@ import { usageService } from "@server/lib/billing/usageService";
import { FeatureId } from "@server/lib/billing"; import { FeatureId } from "@server/lib/billing";
import { build } from "@server/build"; import { build } from "@server/build";
import { calculateUserClientsForOrgs } from "@server/lib/calculateUserClientsForOrgs"; import { calculateUserClientsForOrgs } from "@server/lib/calculateUserClientsForOrgs";
import { doCidrsOverlap } from "@server/lib/ip";
const createOrgSchema = z.strictObject({ const createOrgSchema = z.strictObject({
orgId: z.string(), orgId: z.string(),
@@ -36,6 +37,11 @@ const createOrgSchema = z.strictObject({
.union([z.cidrv4()]) // for now lets just do ipv4 until we verify ipv6 works everywhere .union([z.cidrv4()]) // for now lets just do ipv4 until we verify ipv6 works everywhere
.refine((val) => isValidCIDR(val), { .refine((val) => isValidCIDR(val), {
message: "Invalid subnet CIDR" message: "Invalid subnet CIDR"
}),
utilitySubnet: z
.union([z.cidrv4()]) // for now lets just do ipv4 until we verify ipv6 works everywhere
.refine((val) => isValidCIDR(val), {
message: "Invalid utility subnet CIDR"
}) })
}); });
@@ -84,7 +90,7 @@ export async function createOrg(
); );
} }
const { orgId, name, subnet } = parsedBody.data; const { orgId, name, subnet, utilitySubnet } = parsedBody.data;
// TODO: for now we are making all of the orgs the same subnet // TODO: for now we are making all of the orgs the same subnet
// make sure the subnet is unique // make sure the subnet is unique
@@ -119,6 +125,15 @@ export async function createOrg(
); );
} }
if (doCidrsOverlap(subnet, utilitySubnet)) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
`Subnet ${subnet} overlaps with utility subnet ${utilitySubnet}`
)
);
}
let error = ""; let error = "";
let org: Org | null = null; let org: Org | null = null;
@@ -128,9 +143,6 @@ export async function createOrg(
.from(domains) .from(domains)
.where(eq(domains.configManaged, true)); .where(eq(domains.configManaged, true));
const utilitySubnet =
config.getRawConfig().orgs.utility_subnet_group;
const newOrg = await trx const newOrg = await trx
.insert(orgs) .insert(orgs)
.values({ .values({

View File

@@ -8,6 +8,7 @@ import config from "@server/lib/config";
export type PickOrgDefaultsResponse = { export type PickOrgDefaultsResponse = {
subnet: string; subnet: string;
utilitySubnet: string;
}; };
export async function pickOrgDefaults( export async function pickOrgDefaults(
@@ -20,10 +21,13 @@ export async function pickOrgDefaults(
// const subnet = await getNextAvailableOrgSubnet(); // const subnet = await getNextAvailableOrgSubnet();
// Just hard code the subnet for now for everyone // Just hard code the subnet for now for everyone
const subnet = config.getRawConfig().orgs.subnet_group; const subnet = config.getRawConfig().orgs.subnet_group;
const utilitySubnet =
config.getRawConfig().orgs.utility_subnet_group;
return response<PickOrgDefaultsResponse>(res, { return response<PickOrgDefaultsResponse>(res, {
data: { data: {
subnet: subnet subnet: subnet,
utilitySubnet: utilitySubnet
}, },
success: true, success: true,
error: false, error: false,

View File

@@ -41,13 +41,14 @@ export default function StepperForm() {
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [isChecked, setIsChecked] = useState(false); const [isChecked, setIsChecked] = useState(false);
const [error, setError] = useState<string | null>(null); // Removed error state, now using toast for API errors
const [orgCreated, setOrgCreated] = useState(false); const [orgCreated, setOrgCreated] = useState(false);
const orgSchema = z.object({ const orgSchema = z.object({
orgName: z.string().min(1, { message: t("orgNameRequired") }), orgName: z.string().min(1, { message: t("orgNameRequired") }),
orgId: z.string().min(1, { message: t("orgIdRequired") }), orgId: z.string().min(1, { message: t("orgIdRequired") }),
subnet: z.string().min(1, { message: t("subnetRequired") }) subnet: z.string().min(1, { message: t("subnetRequired") }),
utilitySubnet: z.string().min(1, { message: t("subnetRequired") })
}); });
const orgForm = useForm({ const orgForm = useForm({
@@ -55,7 +56,8 @@ export default function StepperForm() {
defaultValues: { defaultValues: {
orgName: "", orgName: "",
orgId: "", orgId: "",
subnet: "" subnet: "",
utilitySubnet: ""
} }
}); });
@@ -72,6 +74,7 @@ export default function StepperForm() {
const res = await api.get(`/pick-org-defaults`); const res = await api.get(`/pick-org-defaults`);
if (res && res.data && res.data.data) { if (res && res.data && res.data.data) {
orgForm.setValue("subnet", res.data.data.subnet); orgForm.setValue("subnet", res.data.data.subnet);
orgForm.setValue("utilitySubnet", res.data.data.utilitySubnet);
} }
} catch (e) { } catch (e) {
console.error("Failed to fetch default subnet:", e); console.error("Failed to fetch default subnet:", e);
@@ -129,7 +132,8 @@ export default function StepperForm() {
const res = await api.put(`/org`, { const res = await api.put(`/org`, {
orgId: values.orgId, orgId: values.orgId,
name: values.orgName, name: values.orgName,
subnet: values.subnet subnet: values.subnet,
utilitySubnet: values.utilitySubnet
}); });
if (res && res.status === 201) { if (res && res.status === 201) {
@@ -138,7 +142,11 @@ export default function StepperForm() {
} }
} catch (e) { } catch (e) {
console.error(e); console.error(e);
setError(formatAxiosError(e, t("orgErrorCreate"))); toast({
title: t("error"),
description: formatAxiosError(e, t("orgErrorCreate")),
variant: "destructive"
});
} }
setLoading(false); setLoading(false);
@@ -320,6 +328,30 @@ export default function StepperForm() {
)} )}
/> />
<FormField
control={orgForm.control}
name="utilitySubnet"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("setupUtilitySubnet")}
</FormLabel>
<FormControl>
<Input
type="text"
{...field}
/>
</FormControl>
<FormMessage />
<FormDescription>
{t(
"setupUtilitySubnetDescription"
)}
</FormDescription>
</FormItem>
)}
/>
{orgIdTaken && !orgCreated ? ( {orgIdTaken && !orgCreated ? (
<Alert variant="destructive"> <Alert variant="destructive">
<AlertDescription> <AlertDescription>
@@ -328,20 +360,13 @@ export default function StepperForm() {
</Alert> </Alert>
) : null} ) : null}
{error && ( {/* Error Alert removed, errors now shown as toast */}
<Alert variant="destructive">
<AlertDescription>
{error}
</AlertDescription>
</Alert>
)}
<div className="flex justify-end"> <div className="flex justify-end">
<Button <Button
type="submit" type="submit"
loading={loading} loading={loading}
disabled={ disabled={
error !== null ||
loading || loading ||
orgIdTaken orgIdTaken
} }

View File

@@ -34,7 +34,9 @@ function getActionsCategories(root: boolean) {
[t("actionListOrgDomains")]: "listOrgDomains", [t("actionListOrgDomains")]: "listOrgDomains",
[t("updateOrgUser")]: "updateOrgUser", [t("updateOrgUser")]: "updateOrgUser",
[t("createOrgUser")]: "createOrgUser", [t("createOrgUser")]: "createOrgUser",
[t("actionApplyBlueprint")]: "applyBlueprint" [t("actionApplyBlueprint")]: "applyBlueprint",
[t("actionListBlueprints")]: "listBlueprints",
[t("actionGetBlueprint")]: "getBlueprint"
}, },
Site: { Site: {