mirror of
https://github.com/fosrl/pangolin.git
synced 2026-09-09 20:46:55 +02:00
Compare commits
7 Commits
23ca3efbf4
...
4c1e1daf07
| Author | SHA1 | Date | |
|---|---|---|---|
| 4c1e1daf07 | |||
| 7c54df7ed1 | |||
| 9d77fcc457 | |||
| 454449ec8a | |||
| fe67e8e384 | |||
| 715b957660 | |||
| f1e4bf8d36 |
+33
-1
@@ -1872,6 +1872,7 @@
|
||||
"billingManageLicenseSubscription": "Manage your subscription for paid self-hosted license keys",
|
||||
"billingCurrentKeys": "Current Keys",
|
||||
"billingModifyCurrentPlan": "Modify Current Plan",
|
||||
"billingManageLicenseSubscriptionDescription": "Manage your subscription for paid self-hosted license keys and download invoices.",
|
||||
"billingConfirmUpgrade": "Confirm Upgrade",
|
||||
"billingConfirmDowngrade": "Confirm Downgrade",
|
||||
"billingConfirmUpgradeDescription": "You are about to upgrade your plan. Review the new limits and pricing below.",
|
||||
@@ -1969,6 +1970,36 @@
|
||||
"timeIsInSeconds": "Time is in seconds",
|
||||
"requireDeviceApproval": "Require Device Approvals",
|
||||
"requireDeviceApprovalDescription": "Users with this role need new devices approved by an admin before they can connect and access resources.",
|
||||
"sshSettings": "SSH Settings",
|
||||
"rdpSettings": "RDP Settings",
|
||||
"vncSettings": "VNC Settings",
|
||||
"sshServer": "SSH Server",
|
||||
"rdpServer": "RDP Server",
|
||||
"vncServer": "VNC Server",
|
||||
"sshServerDescription": "Set up the authentication method, daemon location, and server destination",
|
||||
"rdpServerDescription": "Configure the destination and port of the RDP server",
|
||||
"vncServerDescription": "Configure the destination and port of the VNC server",
|
||||
"sshServerMode": "Mode",
|
||||
"sshServerModeStandard": "Standard SSH Server",
|
||||
"sshServerModePangolin": "Pangolin SSH",
|
||||
"sshServerModeStandardDescription": "Uses a Pangolin auth daemon to manage SSH authentication on the site or remote host.",
|
||||
"sshServerModeNative": "Native SSH Server",
|
||||
"sshServerModeNativeDescription": "SSH authentication is handled natively by an existing SSH server without a separate auth daemon.",
|
||||
"sshAuthenticationMethod": "Authentication Method",
|
||||
"sshAuthMethodManual": "Manual Authentication",
|
||||
"sshAuthMethodManualDescription": "Requires existing host credentials. Bypasses automatic provisioning.",
|
||||
"sshAuthMethodAutomated": "Automated Provisioning",
|
||||
"sshAuthMethodAutomatedDescription": "Automatically creates users, groups, and sudo permissions on host.",
|
||||
"sshAuthDaemonLocation": "Auth Daemon Location",
|
||||
"sshDaemonLocationSiteDescription": "Executes locally on the machine hosting the site connector.",
|
||||
"sshDaemonLocationRemote": "On Remote Host",
|
||||
"sshDaemonLocationRemoteDescription": "Executes on a separate target machine on the same network.",
|
||||
"sshDaemonDisclaimer": "Ensure your target host is properly configured to run the auth daemon before completing this setup, or provisioning will fail.",
|
||||
"sshDaemonPort": "Daemon Port",
|
||||
"sshServerDestination": "Server Destination",
|
||||
"sshServerDestinationDescription": "Configure the destination and port of the SSH server",
|
||||
"destination": "Destination",
|
||||
"bgTargetMultiSiteDisclaimer": "Selecting multiple sites enables resilient routing and failover for high availability.",
|
||||
"sshAccess": "SSH Access",
|
||||
"roleAllowSsh": "Allow SSH",
|
||||
"roleAllowSshAllow": "Allow",
|
||||
@@ -2963,7 +2994,7 @@
|
||||
"learnMore": "Learn more",
|
||||
"backToHome": "Go back to home",
|
||||
"needToSignInToOrg": "Need to use your organization's identity provider?",
|
||||
"maintenanceMode": "Maintenance Mode",
|
||||
"maintenanceMode": "Maintenance Page",
|
||||
"maintenanceModeDescription": "Display a maintenance page to visitors",
|
||||
"maintenanceModeType": "Maintenance Mode Type",
|
||||
"showMaintenancePage": "Show a maintenance page to visitors",
|
||||
@@ -2993,6 +3024,7 @@
|
||||
"maintenanceScreenEstimatedCompletion": "Estimated Completion:",
|
||||
"createInternalResourceDialogDestinationRequired": "Destination is required",
|
||||
"available": "Available",
|
||||
"disabledResourceDescription": "When disabled, the resource will be inaccessible by everyone.",
|
||||
"archived": "Archived",
|
||||
"noArchivedDevices": "No archived devices found",
|
||||
"deviceArchived": "Device archived",
|
||||
|
||||
@@ -147,7 +147,6 @@ export const resources = pgTable("resources", {
|
||||
headers: text("headers"), // comma-separated list of headers to add to the request
|
||||
proxyProtocol: boolean("proxyProtocol").notNull().default(false),
|
||||
proxyProtocolVersion: integer("proxyProtocolVersion").default(1),
|
||||
|
||||
maintenanceModeEnabled: boolean("maintenanceModeEnabled")
|
||||
.notNull()
|
||||
.default(false),
|
||||
@@ -160,7 +159,14 @@ export const resources = pgTable("resources", {
|
||||
postAuthPath: text("postAuthPath"),
|
||||
health: varchar("health").default("unknown"), // "healthy", "unhealthy", "unknown"
|
||||
wildcard: boolean("wildcard").notNull().default(false),
|
||||
browserAccessType: text("browserAccessType").default("http") // rdp, ssh, http, vnc
|
||||
browserAccessType: text("browserAccessType").default("http"), // rdp, ssh, http, vnc
|
||||
pamMode: varchar("pamMode", { length: 32 })
|
||||
.$type<"passthrough" | "push">()
|
||||
.default("passthrough"),
|
||||
authDaemonMode: varchar("authDaemonMode", { length: 32 })
|
||||
.$type<"site" | "remote" | "native">()
|
||||
.default("site"),
|
||||
authDaemonPort: integer("authDaemonPort").default(22123)
|
||||
});
|
||||
|
||||
export const labels = pgTable("labels", {
|
||||
@@ -352,8 +358,11 @@ export const siteResources = pgTable("siteResources", {
|
||||
udpPortRangeString: varchar("udpPortRangeString").notNull().default("*"),
|
||||
disableIcmp: boolean("disableIcmp").notNull().default(false),
|
||||
authDaemonPort: integer("authDaemonPort").default(22123),
|
||||
pamMode: varchar("pamMode", { length: 32 })
|
||||
.$type<"passthrough" | "push">()
|
||||
.default("passthrough"),
|
||||
authDaemonMode: varchar("authDaemonMode", { length: 32 })
|
||||
.$type<"site" | "remote">()
|
||||
.$type<"site" | "remote" | "native">()
|
||||
.default("site"),
|
||||
domainId: varchar("domainId").references(() => domains.domainId, {
|
||||
onDelete: "set null"
|
||||
|
||||
@@ -181,7 +181,14 @@ export const resources = sqliteTable("resources", {
|
||||
postAuthPath: text("postAuthPath"),
|
||||
health: text("health").default("unknown"), // "healthy", "unhealthy", "unknown"
|
||||
wildcard: integer("wildcard", { mode: "boolean" }).notNull().default(false),
|
||||
browserAccessType: text("browserAccessType").default("http") // rdp, ssh, http, vnc
|
||||
browserAccessType: text("browserAccessType").default("http"), // rdp, ssh, http, vnc
|
||||
pamMode: text("pamMode")
|
||||
.$type<"passthrough" | "push">()
|
||||
.default("passthrough"),
|
||||
authDaemonMode: text("authDaemonMode")
|
||||
.$type<"site" | "remote" | "native">()
|
||||
.default("site"),
|
||||
authDaemonPort: integer("authDaemonPort").default(22123)
|
||||
});
|
||||
|
||||
export const labels = sqliteTable("labels", {
|
||||
@@ -387,8 +394,11 @@ export const siteResources = sqliteTable("siteResources", {
|
||||
.notNull()
|
||||
.default(false),
|
||||
authDaemonPort: integer("authDaemonPort").default(22123),
|
||||
pamMode: text("pamMode")
|
||||
.$type<"passthrough" | "push">()
|
||||
.default("passthrough"),
|
||||
authDaemonMode: text("authDaemonMode")
|
||||
.$type<"site" | "remote">()
|
||||
.$type<"site" | "remote" | "native">()
|
||||
.default("site"),
|
||||
domainId: text("domainId").references(() => domains.domainId, {
|
||||
onDelete: "set null"
|
||||
|
||||
@@ -23,7 +23,8 @@ import {
|
||||
roundTripMessageTracker,
|
||||
siteResources,
|
||||
siteNetworks,
|
||||
userOrgs
|
||||
userOrgs,
|
||||
sites
|
||||
} from "@server/db";
|
||||
import { logAccessAudit } from "#private/lib/logAccessAudit";
|
||||
import { isLicensedOrSubscribed } from "#private/lib/isLicencedOrSubscribed";
|
||||
@@ -48,7 +49,8 @@ const bodySchema = z
|
||||
.strictObject({
|
||||
publicKey: z.string().nonempty(),
|
||||
resourceId: z.number().int().positive().optional(),
|
||||
resource: z.string().nonempty().optional() // this is either the nice id or the alias
|
||||
resource: z.string().nonempty().optional(), // this is either the nice id or the alias
|
||||
username: z.string().nonempty().optional()
|
||||
})
|
||||
.refine(
|
||||
(data) => {
|
||||
@@ -63,19 +65,19 @@ const bodySchema = z
|
||||
);
|
||||
|
||||
export type SignSshKeyResponse = {
|
||||
certificate: string;
|
||||
certificate?: string;
|
||||
messageIds: number[];
|
||||
messageId: number;
|
||||
messageId?: number;
|
||||
sshUsername: string;
|
||||
sshHost: string;
|
||||
resourceId: number;
|
||||
siteIds: number[];
|
||||
siteId: number;
|
||||
keyId: string;
|
||||
validPrincipals: string[];
|
||||
validAfter: string;
|
||||
validBefore: string;
|
||||
expiresIn: number;
|
||||
keyId?: string;
|
||||
validPrincipals?: string[];
|
||||
validAfter?: string;
|
||||
validBefore?: string;
|
||||
expiresIn?: number;
|
||||
};
|
||||
|
||||
// registry.registerPath({
|
||||
@@ -126,7 +128,8 @@ export async function signSshKey(
|
||||
const {
|
||||
publicKey,
|
||||
resourceId,
|
||||
resource: resourceQueryString
|
||||
resource: resourceQueryString,
|
||||
username
|
||||
} = parsedBody.data;
|
||||
const userId = req.user?.userId;
|
||||
const roleIds = req.userOrgRoleIds ?? [];
|
||||
@@ -174,101 +177,6 @@ export async function signSshKey(
|
||||
);
|
||||
}
|
||||
|
||||
let usernameToUse;
|
||||
if (!userOrg.pamUsername) {
|
||||
if (req.user?.email) {
|
||||
// Extract username from email (first part before @)
|
||||
usernameToUse = req.user?.email
|
||||
.split("@")[0]
|
||||
.replace(/[^a-zA-Z0-9_-]/g, "");
|
||||
if (!usernameToUse) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
"Unable to extract username from email"
|
||||
)
|
||||
);
|
||||
}
|
||||
} else if (req.user?.username) {
|
||||
usernameToUse = req.user.username;
|
||||
// We need to clean out any spaces or special characters from the username to ensure it's valid for SSH certificates
|
||||
usernameToUse = usernameToUse.replace(/[^a-zA-Z0-9_-]/g, "-");
|
||||
if (!usernameToUse) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
"Username is not valid for SSH certificate"
|
||||
)
|
||||
);
|
||||
}
|
||||
} else {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
"User does not have a valid email or username for SSH certificate"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
// prefix with p-
|
||||
usernameToUse = `p-${usernameToUse}`;
|
||||
|
||||
// check if we have a existing user in this org with the same
|
||||
const [existingUserWithSameName] = await db
|
||||
.select()
|
||||
.from(userOrgs)
|
||||
.where(
|
||||
and(
|
||||
eq(userOrgs.orgId, orgId),
|
||||
eq(userOrgs.pamUsername, usernameToUse)
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
if (existingUserWithSameName) {
|
||||
let foundUniqueUsername = false;
|
||||
for (let attempt = 0; attempt < 20; attempt++) {
|
||||
const randomNum = Math.floor(Math.random() * 101); // 0 to 100
|
||||
const candidateUsername = `${usernameToUse}${randomNum}`;
|
||||
|
||||
const [existingUser] = await db
|
||||
.select()
|
||||
.from(userOrgs)
|
||||
.where(
|
||||
and(
|
||||
eq(userOrgs.orgId, orgId),
|
||||
eq(userOrgs.pamUsername, candidateUsername)
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
if (!existingUser) {
|
||||
usernameToUse = candidateUsername;
|
||||
foundUniqueUsername = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!foundUniqueUsername) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.CONFLICT,
|
||||
"Unable to generate a unique username for SSH certificate"
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
await db
|
||||
.update(userOrgs)
|
||||
.set({ pamUsername: usernameToUse })
|
||||
.where(
|
||||
and(eq(userOrgs.orgId, orgId), eq(userOrgs.userId, userId))
|
||||
);
|
||||
} else {
|
||||
usernameToUse = userOrg.pamUsername;
|
||||
}
|
||||
|
||||
// Get and decrypt the org's CA keys
|
||||
const caKeys = await getOrgCAKeys(
|
||||
orgId,
|
||||
@@ -361,90 +269,303 @@ export async function signSshKey(
|
||||
);
|
||||
}
|
||||
|
||||
const roleRows = await db
|
||||
.select({
|
||||
sshSudoCommands: roles.sshSudoCommands,
|
||||
sshUnixGroups: roles.sshUnixGroups,
|
||||
sshCreateHomeDir: roles.sshCreateHomeDir,
|
||||
sshSudoMode: roles.sshSudoMode
|
||||
})
|
||||
.from(roles)
|
||||
.innerJoin(
|
||||
roleSiteResources,
|
||||
eq(roleSiteResources.roleId, roles.roleId)
|
||||
)
|
||||
.where(
|
||||
and(
|
||||
inArray(roles.roleId, roleIds),
|
||||
eq(
|
||||
roleSiteResources.siteResourceId,
|
||||
resource.siteResourceId
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
const parsedSudoCommands: string[] = [];
|
||||
const parsedGroupsSet = new Set<string>();
|
||||
let homedir: boolean | null = null;
|
||||
const sudoModeOrder = { none: 0, commands: 1, full: 2 };
|
||||
let sudoMode: "none" | "commands" | "full" = "none";
|
||||
for (const roleRow of roleRows) {
|
||||
try {
|
||||
const cmds = JSON.parse(roleRow?.sshSudoCommands ?? "[]");
|
||||
if (Array.isArray(cmds)) parsedSudoCommands.push(...cmds);
|
||||
} catch {
|
||||
// skip
|
||||
}
|
||||
try {
|
||||
const grps = JSON.parse(roleRow?.sshUnixGroups ?? "[]");
|
||||
if (Array.isArray(grps))
|
||||
grps.forEach((g: string) => parsedGroupsSet.add(g));
|
||||
} catch {
|
||||
// skip
|
||||
}
|
||||
if (roleRow?.sshCreateHomeDir === true) homedir = true;
|
||||
const m = roleRow?.sshSudoMode ?? "none";
|
||||
if (
|
||||
sudoModeOrder[m as keyof typeof sudoModeOrder] >
|
||||
sudoModeOrder[sudoMode]
|
||||
) {
|
||||
sudoMode = m as "none" | "commands" | "full";
|
||||
}
|
||||
}
|
||||
const parsedGroups = Array.from(parsedGroupsSet);
|
||||
if (homedir === null && roleRows.length > 0) {
|
||||
homedir = roleRows[0].sshCreateHomeDir ?? null;
|
||||
}
|
||||
|
||||
const sites = await db
|
||||
const sitesFromNetworks = await db
|
||||
.select({ siteId: siteNetworks.siteId })
|
||||
.from(siteNetworks)
|
||||
.where(eq(siteNetworks.networkId, resource.networkId!));
|
||||
|
||||
const siteIds = sites.map((site) => site.siteId);
|
||||
const siteIds = sitesFromNetworks.map((site) => site.siteId);
|
||||
|
||||
// Sign the public key
|
||||
const now = BigInt(Math.floor(Date.now() / 1000));
|
||||
// only valid for 5 minutes
|
||||
const validFor = 300n;
|
||||
let expiresIn: number | undefined;
|
||||
let messageIds: number[] = [];
|
||||
let cert:
|
||||
| {
|
||||
certificate: string;
|
||||
keyId: string;
|
||||
validPrincipals: string[];
|
||||
validAfter: Date;
|
||||
validBefore: Date;
|
||||
}
|
||||
| undefined;
|
||||
// if the pam mode is push then we generate the user's pam username and use that or pull it from the userOrgs table
|
||||
// if the mode is passthrough then just use what was provided because the user will log in themselves
|
||||
let usernameToUse;
|
||||
if (resource.pamMode === "push") {
|
||||
if (!userOrg.pamUsername) {
|
||||
if (req.user?.email) {
|
||||
// Extract username from email (first part before @)
|
||||
usernameToUse = req.user?.email
|
||||
.split("@")[0]
|
||||
.replace(/[^a-zA-Z0-9_-]/g, "");
|
||||
if (!usernameToUse) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
"Unable to extract username from email"
|
||||
)
|
||||
);
|
||||
}
|
||||
} else if (req.user?.username) {
|
||||
usernameToUse = req.user.username;
|
||||
// We need to clean out any spaces or special characters from the username to ensure it's valid for SSH certificates
|
||||
usernameToUse = usernameToUse.replace(
|
||||
/[^a-zA-Z0-9_-]/g,
|
||||
"-"
|
||||
);
|
||||
if (!usernameToUse) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
"Username is not valid for SSH certificate"
|
||||
)
|
||||
);
|
||||
}
|
||||
} else {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
"User does not have a valid email or username for SSH certificate"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const cert = signPublicKey(caKeys.privateKeyPem, publicKey, {
|
||||
keyId: `${usernameToUse}@${resource.niceId}`,
|
||||
validPrincipals: [usernameToUse, resource.niceId],
|
||||
validAfter: now - 60n, // Start 1 min ago for clock skew
|
||||
validBefore: now + validFor
|
||||
});
|
||||
// prefix with p-
|
||||
usernameToUse = `p-${usernameToUse}`;
|
||||
|
||||
// check if we have a existing user in this org with the same
|
||||
const [existingUserWithSameName] = await db
|
||||
.select()
|
||||
.from(userOrgs)
|
||||
.where(
|
||||
and(
|
||||
eq(userOrgs.orgId, orgId),
|
||||
eq(userOrgs.pamUsername, usernameToUse)
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
if (existingUserWithSameName) {
|
||||
let foundUniqueUsername = false;
|
||||
for (let attempt = 0; attempt < 20; attempt++) {
|
||||
const randomNum = Math.floor(Math.random() * 101); // 0 to 100
|
||||
const candidateUsername = `${usernameToUse}${randomNum}`;
|
||||
|
||||
const [existingUser] = await db
|
||||
.select()
|
||||
.from(userOrgs)
|
||||
.where(
|
||||
and(
|
||||
eq(userOrgs.orgId, orgId),
|
||||
eq(userOrgs.pamUsername, candidateUsername)
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
if (!existingUser) {
|
||||
usernameToUse = candidateUsername;
|
||||
foundUniqueUsername = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!foundUniqueUsername) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.CONFLICT,
|
||||
"Unable to generate a unique username for SSH certificate"
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
await db
|
||||
.update(userOrgs)
|
||||
.set({ pamUsername: usernameToUse })
|
||||
.where(
|
||||
and(
|
||||
eq(userOrgs.orgId, orgId),
|
||||
eq(userOrgs.userId, userId)
|
||||
)
|
||||
);
|
||||
} else {
|
||||
usernameToUse = userOrg.pamUsername;
|
||||
}
|
||||
|
||||
const roleRows = await db
|
||||
.select({
|
||||
sshSudoCommands: roles.sshSudoCommands,
|
||||
sshUnixGroups: roles.sshUnixGroups,
|
||||
sshCreateHomeDir: roles.sshCreateHomeDir,
|
||||
sshSudoMode: roles.sshSudoMode
|
||||
})
|
||||
.from(roles)
|
||||
.innerJoin(
|
||||
roleSiteResources,
|
||||
eq(roleSiteResources.roleId, roles.roleId)
|
||||
)
|
||||
.where(
|
||||
and(
|
||||
inArray(roles.roleId, roleIds),
|
||||
eq(
|
||||
roleSiteResources.siteResourceId,
|
||||
resource.siteResourceId
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
const parsedSudoCommands: string[] = [];
|
||||
const parsedGroupsSet = new Set<string>();
|
||||
let homedir: boolean | null = null;
|
||||
const sudoModeOrder = { none: 0, commands: 1, full: 2 };
|
||||
let sudoMode: "none" | "commands" | "full" = "none";
|
||||
for (const roleRow of roleRows) {
|
||||
try {
|
||||
const cmds = JSON.parse(roleRow?.sshSudoCommands ?? "[]");
|
||||
if (Array.isArray(cmds)) parsedSudoCommands.push(...cmds);
|
||||
} catch {
|
||||
// skip
|
||||
}
|
||||
try {
|
||||
const grps = JSON.parse(roleRow?.sshUnixGroups ?? "[]");
|
||||
if (Array.isArray(grps))
|
||||
grps.forEach((g: string) => parsedGroupsSet.add(g));
|
||||
} catch {
|
||||
// skip
|
||||
}
|
||||
if (roleRow?.sshCreateHomeDir === true) homedir = true;
|
||||
const m = roleRow?.sshSudoMode ?? "none";
|
||||
if (
|
||||
sudoModeOrder[m as keyof typeof sudoModeOrder] >
|
||||
sudoModeOrder[sudoMode]
|
||||
) {
|
||||
sudoMode = m as "none" | "commands" | "full";
|
||||
}
|
||||
}
|
||||
const parsedGroups = Array.from(parsedGroupsSet);
|
||||
if (homedir === null && roleRows.length > 0) {
|
||||
homedir = roleRows[0].sshCreateHomeDir ?? null;
|
||||
}
|
||||
|
||||
// Sign the public key
|
||||
const now = BigInt(Math.floor(Date.now() / 1000));
|
||||
// only valid for 5 minutes
|
||||
const validFor = 300n;
|
||||
expiresIn = Number(validFor); // seconds
|
||||
|
||||
const cert = signPublicKey(caKeys.privateKeyPem, publicKey, {
|
||||
keyId: `${usernameToUse}@${resource.niceId}`,
|
||||
validPrincipals: [usernameToUse, resource.niceId],
|
||||
validAfter: now - 60n, // Start 1 min ago for clock skew
|
||||
validBefore: now + validFor
|
||||
});
|
||||
|
||||
const messageIds: number[] = [];
|
||||
for (const siteId of siteIds) {
|
||||
// get the site
|
||||
const [newt] = await db
|
||||
.select()
|
||||
.from(newts)
|
||||
.where(eq(newts.siteId, siteId))
|
||||
.limit(1);
|
||||
|
||||
if (!newt) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.INTERNAL_SERVER_ERROR,
|
||||
"Site associated with resource not found"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const [message] = await db
|
||||
.insert(roundTripMessageTracker)
|
||||
.values({
|
||||
wsClientId: newt.newtId,
|
||||
messageType: `newt/pam/connection`,
|
||||
sentAt: Math.floor(Date.now() / 1000)
|
||||
})
|
||||
.returning();
|
||||
|
||||
if (!message) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.INTERNAL_SERVER_ERROR,
|
||||
"Failed to create message tracker entry"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
messageIds.push(message.messageId);
|
||||
|
||||
await sendToClient(newt.newtId, {
|
||||
type: `newt/pam/connection`,
|
||||
data: {
|
||||
messageId: message.messageId,
|
||||
orgId: orgId,
|
||||
agentPort: resource.authDaemonPort ?? 22123,
|
||||
authDaemonMode: resource.authDaemonMode, // site, remote, native where native is the pty mode
|
||||
externalAuthDaemon:
|
||||
resource.authDaemonMode === "remote", // keep this for backward compatibility but new newts are using the authDaemonMode field
|
||||
agentHost: resource.destination,
|
||||
caCert: caKeys.publicKeyOpenSSH,
|
||||
username: usernameToUse,
|
||||
niceId: resource.niceId,
|
||||
metadata: {
|
||||
sudoMode: sudoMode,
|
||||
sudoCommands: parsedSudoCommands,
|
||||
homedir: homedir,
|
||||
groups: parsedGroups
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
} else if (resource.pamMode === "passthrough") {
|
||||
usernameToUse = username;
|
||||
if (!usernameToUse) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
"Username must be provided when PAM mode is passthrough"
|
||||
)
|
||||
);
|
||||
}
|
||||
} else {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.INTERNAL_SERVER_ERROR,
|
||||
"Invalid PAM mode configured for resource"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
let sshHost: string | undefined;
|
||||
if (
|
||||
resource.authDaemonMode === "site" ||
|
||||
resource.authDaemonMode === "remote"
|
||||
) {
|
||||
if (resource.alias && resource.alias != "") {
|
||||
sshHost = resource.alias;
|
||||
} else {
|
||||
sshHost = resource.destination;
|
||||
}
|
||||
} else if (resource.authDaemonMode === "native") {
|
||||
if (siteIds.length > 1) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.INTERNAL_SERVER_ERROR,
|
||||
"Multiple sites associated with resource, unable to determine SSH host when in native mode"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const messageIds: number[] = [];
|
||||
for (const siteId of siteIds) {
|
||||
// get the site
|
||||
const [newt] = await db
|
||||
const [site] = await db
|
||||
.select()
|
||||
.from(newts)
|
||||
.where(eq(newts.siteId, siteId))
|
||||
.from(sites)
|
||||
.where(eq(sites.siteId, siteIds[0]))
|
||||
.limit(1);
|
||||
|
||||
if (!newt) {
|
||||
if (!site) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.INTERNAL_SERVER_ERROR,
|
||||
@@ -453,54 +574,26 @@ export async function signSshKey(
|
||||
);
|
||||
}
|
||||
|
||||
const [message] = await db
|
||||
.insert(roundTripMessageTracker)
|
||||
.values({
|
||||
wsClientId: newt.newtId,
|
||||
messageType: `newt/pam/connection`,
|
||||
sentAt: Math.floor(Date.now() / 1000)
|
||||
})
|
||||
.returning();
|
||||
|
||||
if (!message) {
|
||||
if (!site.address) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.INTERNAL_SERVER_ERROR,
|
||||
"Failed to create message tracker entry"
|
||||
"Site address not configured, unable to determine SSH host when in native mode"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
messageIds.push(message.messageId);
|
||||
|
||||
await sendToClient(newt.newtId, {
|
||||
type: `newt/pam/connection`,
|
||||
data: {
|
||||
messageId: message.messageId,
|
||||
orgId: orgId,
|
||||
agentPort: resource.authDaemonPort ?? 22123,
|
||||
externalAuthDaemon: resource.authDaemonMode === "remote",
|
||||
agentHost: resource.destination,
|
||||
caCert: caKeys.publicKeyOpenSSH,
|
||||
username: usernameToUse,
|
||||
niceId: resource.niceId,
|
||||
metadata: {
|
||||
sudoMode: sudoMode,
|
||||
sudoCommands: parsedSudoCommands,
|
||||
homedir: homedir,
|
||||
groups: parsedGroups
|
||||
}
|
||||
}
|
||||
});
|
||||
// its the address but split off the cidr if there is one
|
||||
sshHost = site.address.split("/")[0];
|
||||
}
|
||||
|
||||
const expiresIn = Number(validFor); // seconds
|
||||
|
||||
let sshHost;
|
||||
if (resource.alias && resource.alias != "") {
|
||||
sshHost = resource.alias;
|
||||
} else {
|
||||
sshHost = resource.destination;
|
||||
if (!sshHost) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.INTERNAL_SERVER_ERROR,
|
||||
"Unable to determine SSH host for the resource"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
await logsDb.insert(actionAuditLog).values({
|
||||
@@ -527,7 +620,7 @@ export async function signSshKey(
|
||||
: undefined,
|
||||
metadata: {
|
||||
resourceName: resource.name,
|
||||
siteId: siteIds[0],
|
||||
siteIds: siteIds,
|
||||
sshUsername: usernameToUse,
|
||||
sshHost: sshHost
|
||||
},
|
||||
@@ -537,18 +630,18 @@ export async function signSshKey(
|
||||
|
||||
return response<SignSshKeyResponse>(res, {
|
||||
data: {
|
||||
certificate: cert.certificate,
|
||||
certificate: cert?.certificate,
|
||||
messageIds: messageIds,
|
||||
messageId: messageIds[0], // just pick the first one for backward compatibility
|
||||
messageId: messageIds[0], // just pick the first one for backward compatibility with older olms
|
||||
sshUsername: usernameToUse,
|
||||
sshHost: sshHost,
|
||||
sshHost: sshHost, // just pick the first one for backward compatibility with older olms
|
||||
resourceId: resource.siteResourceId,
|
||||
siteIds: siteIds,
|
||||
siteId: siteIds[0], // just pick the first one for backward compatibility
|
||||
keyId: cert.keyId,
|
||||
validPrincipals: cert.validPrincipals,
|
||||
validAfter: cert.validAfter.toISOString(),
|
||||
validBefore: cert.validBefore.toISOString(),
|
||||
siteId: siteIds[0], // just pick the first one for backward compatibility with older olms
|
||||
keyId: cert?.keyId,
|
||||
validPrincipals: cert?.validPrincipals,
|
||||
validAfter: cert?.validAfter.toISOString(),
|
||||
validBefore: cert?.validBefore.toISOString(),
|
||||
expiresIn
|
||||
},
|
||||
success: true,
|
||||
|
||||
@@ -21,6 +21,11 @@ export type GetBrowserTargetResponse = {
|
||||
ip: string;
|
||||
port: number;
|
||||
authToken: string;
|
||||
orgId: string;
|
||||
resourceId: number;
|
||||
niceId: string;
|
||||
pamMode: "passthrough" | "push" | null;
|
||||
authDaemonMode: "site" | "remote" | "native" | null;
|
||||
};
|
||||
|
||||
export async function getBrowserTarget(
|
||||
@@ -47,7 +52,12 @@ export async function getBrowserTarget(
|
||||
.select({
|
||||
destination: browserGatewayTarget.destination,
|
||||
destinationPort: browserGatewayTarget.destinationPort,
|
||||
authToken: browserGatewayTarget.authToken
|
||||
authToken: browserGatewayTarget.authToken,
|
||||
resourceId: resources.resourceId,
|
||||
niceId: resources.niceId,
|
||||
orgId: resources.orgId,
|
||||
pamMode: resources.pamMode,
|
||||
authDaemonMode: resources.authDaemonMode
|
||||
})
|
||||
.from(browserGatewayTarget)
|
||||
.innerJoin(
|
||||
@@ -57,7 +67,7 @@ export async function getBrowserTarget(
|
||||
.where(eq(resources.fullDomain, fullDomain))
|
||||
.limit(1);
|
||||
|
||||
const decryptAuthToken = decrypt(
|
||||
const decryptedAuthToken = decrypt(
|
||||
browserTarget.authToken,
|
||||
config.getRawConfig().server.secret!
|
||||
);
|
||||
@@ -75,7 +85,12 @@ export async function getBrowserTarget(
|
||||
data: {
|
||||
ip: browserTarget.destination,
|
||||
port: browserTarget.destinationPort,
|
||||
authToken: decryptAuthToken
|
||||
authToken: decryptedAuthToken,
|
||||
pamMode: browserTarget.pamMode,
|
||||
authDaemonMode: browserTarget.authDaemonMode,
|
||||
orgId: browserTarget.orgId,
|
||||
resourceId: browserTarget.resourceId,
|
||||
niceId: browserTarget.niceId
|
||||
},
|
||||
success: true,
|
||||
error: false,
|
||||
|
||||
@@ -72,7 +72,11 @@ const updateHttpResourceBodySchema = z
|
||||
maintenanceMessage: z.string().max(2000).nullable().optional(),
|
||||
maintenanceEstimatedTime: z.string().max(100).nullable().optional(),
|
||||
postAuthPath: z.string().nullable().optional(),
|
||||
browserAccessType: z.enum(["http", "ssh", "rdp", "vnc"]).optional()
|
||||
browserAccessType: z.enum(["http", "ssh", "rdp", "vnc"]).optional(),
|
||||
// SSH settings
|
||||
pamMode: z.enum(["passthrough", "push"]).optional(),
|
||||
authDaemonMode: z.enum(["site", "remote", "native"]).optional(),
|
||||
authDaemonPort: z.int().min(1).max(65535).nullable().optional()
|
||||
})
|
||||
.refine((data) => Object.keys(data).length > 0, {
|
||||
error: "At least one field must be provided for update"
|
||||
|
||||
@@ -1352,6 +1352,12 @@ export default function BillingPage() {
|
||||
{t("billingModifyCurrentPlan") ||
|
||||
"Modify Current Plan"}
|
||||
</Button>
|
||||
<p className="text-sm text-muted-foreground mt-2">
|
||||
{t(
|
||||
"billingManageLicenseSubscriptionDescription"
|
||||
) ||
|
||||
"Manage your subscription for paid self-hosted license keys and download invoices."}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</SettingsSectionBody>
|
||||
|
||||
@@ -584,43 +584,44 @@ export default function GeneralForm() {
|
||||
className="space-y-4"
|
||||
id="general-settings-form"
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t("name")}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t("name")}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="niceId"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t("identifier")}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
placeholder={t(
|
||||
"enterIdentifier"
|
||||
)}
|
||||
className="flex-1"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="niceId"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t("identifier")}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
placeholder={t(
|
||||
"enterIdentifier"
|
||||
)}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{!resource.http && (
|
||||
<>
|
||||
@@ -730,28 +731,31 @@ export default function GeneralForm() {
|
||||
control={form.control}
|
||||
name="enabled"
|
||||
render={() => (
|
||||
<FormItem className="col-span-2">
|
||||
<div className="flex items-center space-x-2">
|
||||
<FormControl>
|
||||
<SwitchInput
|
||||
id="enable-resource"
|
||||
defaultChecked={
|
||||
resource.enabled
|
||||
}
|
||||
label={t(
|
||||
"resourceEnable"
|
||||
)}
|
||||
onCheckedChange={(
|
||||
<FormItem>
|
||||
<FormControl>
|
||||
<SwitchInput
|
||||
id="enable-resource"
|
||||
defaultChecked={
|
||||
resource.enabled
|
||||
}
|
||||
label={t(
|
||||
"resourceEnable"
|
||||
)}
|
||||
onCheckedChange={(
|
||||
val
|
||||
) =>
|
||||
form.setValue(
|
||||
"enabled",
|
||||
val
|
||||
) =>
|
||||
form.setValue(
|
||||
"enabled",
|
||||
val
|
||||
)
|
||||
}
|
||||
/>
|
||||
</FormControl>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t(
|
||||
"disabledResourceDescription"
|
||||
)}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
|
||||
@@ -86,8 +86,8 @@ export default async function ResourceLayout(props: ResourceLayoutProps) {
|
||||
href: `/{orgId}/settings/resources/proxy/{niceId}/general`
|
||||
},
|
||||
{
|
||||
title: t("proxy"),
|
||||
href: `/{orgId}/settings/resources/proxy/{niceId}/proxy`
|
||||
title: t(`${resource.browserAccessType}Settings`),
|
||||
href: `/{orgId}/settings/resources/proxy/{niceId}/${resource.browserAccessType}`
|
||||
}
|
||||
];
|
||||
|
||||
|
||||
@@ -10,6 +10,6 @@ export default async function ResourcePage(props: {
|
||||
}) {
|
||||
const params = await props.params;
|
||||
redirect(
|
||||
`/${params.orgId}/settings/resources/proxy/${params.niceId}/proxy`
|
||||
`/${params.orgId}/settings/resources/proxy/${params.niceId}/general`
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,250 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
SettingsContainer,
|
||||
SettingsSection,
|
||||
SettingsSectionBody,
|
||||
SettingsSectionDescription,
|
||||
SettingsSectionForm,
|
||||
SettingsSectionHeader,
|
||||
SettingsSectionTitle
|
||||
} from "@app/components/Settings";
|
||||
import { BrowserGatewayTargetForm } from "@app/components/BrowserGatewayTargetForm";
|
||||
import { type Selectedsite } from "@app/components/site-selector";
|
||||
import { Button } from "@app/components/ui/button";
|
||||
import { toast } from "@app/hooks/useToast";
|
||||
import { useResourceContext } from "@app/hooks/useResourceContext";
|
||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||
import { createApiClient } from "@app/lib/api";
|
||||
import { formatAxiosError } from "@app/lib/api/formatAxiosError";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { use, useActionState, useEffect, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { z } from "zod";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { GetResourceResponse } from "@server/routers/resource";
|
||||
import type { ResourceContextType } from "@app/contexts/resourceContext";
|
||||
|
||||
type ExistingTarget = {
|
||||
browserGatewayTargetId: number;
|
||||
siteId: number;
|
||||
};
|
||||
|
||||
const sshFormSchema = z.object({
|
||||
authDaemonPort: z.string().refine(
|
||||
(val) => {
|
||||
if (!val) return true;
|
||||
const n = Number(val);
|
||||
return Number.isInteger(n) && n >= 1 && n <= 65535;
|
||||
},
|
||||
{ message: "Port must be between 1 and 65535" }
|
||||
)
|
||||
});
|
||||
|
||||
export default function SshSettingsPage(props: {
|
||||
params: Promise<{ orgId: string }>;
|
||||
}) {
|
||||
const params = use(props.params);
|
||||
const { resource, updateResource } = useResourceContext();
|
||||
|
||||
return (
|
||||
<SettingsContainer>
|
||||
<SshServerForm
|
||||
orgId={params.orgId}
|
||||
resource={resource}
|
||||
updateResource={updateResource}
|
||||
/>
|
||||
</SettingsContainer>
|
||||
);
|
||||
}
|
||||
|
||||
function SshServerForm({
|
||||
orgId,
|
||||
resource,
|
||||
updateResource
|
||||
}: {
|
||||
orgId: string;
|
||||
resource: GetResourceResponse;
|
||||
updateResource: ResourceContextType["updateResource"];
|
||||
}) {
|
||||
const t = useTranslations();
|
||||
const api = createApiClient(useEnvContext());
|
||||
const router = useRouter();
|
||||
|
||||
// Standard mode: multi-site
|
||||
const [selectedSites, setSelectedSites] = useState<Selectedsite[]>([]);
|
||||
const [bgDestination, setBgDestination] = useState("");
|
||||
const [bgDestinationPort, setBgDestinationPort] = useState("22");
|
||||
const [existingTargets, setExistingTargets] = useState<ExistingTarget[]>(
|
||||
[]
|
||||
);
|
||||
|
||||
// Native mode: single site
|
||||
const [selectedNativeSite, setSelectedNativeSite] =
|
||||
useState<Selectedsite | null>(null);
|
||||
const [nativeExistingTarget, setNativeExistingTarget] =
|
||||
useState<ExistingTarget | null>(null);
|
||||
|
||||
const { data: bgTargetsResponse } = useQuery({
|
||||
queryKey: ["browserGatewayTargets", resource.resourceId, orgId],
|
||||
queryFn: async () => {
|
||||
const res = await api.get(
|
||||
`/org/${orgId}/resource/${resource.resourceId}/browser-gateway-targets`
|
||||
);
|
||||
return res.data.data as {
|
||||
targets: Array<{
|
||||
browserGatewayTargetId: number;
|
||||
resourceId: number;
|
||||
siteId: number;
|
||||
siteName?: string;
|
||||
type: string;
|
||||
destination: string;
|
||||
destinationPort: number;
|
||||
}>;
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!bgTargetsResponse?.targets?.length) return;
|
||||
const targets = bgTargetsResponse.targets;
|
||||
const first = targets[0];
|
||||
|
||||
setBgDestination(first.destination);
|
||||
setBgDestinationPort(String(first.destinationPort));
|
||||
setExistingTargets(
|
||||
targets.map((t) => ({
|
||||
browserGatewayTargetId: t.browserGatewayTargetId,
|
||||
siteId: t.siteId
|
||||
}))
|
||||
);
|
||||
setSelectedSites(
|
||||
targets.map((t) => ({
|
||||
siteId: t.siteId,
|
||||
name: t.siteName ?? String(t.siteId),
|
||||
type: "newt" as const
|
||||
}))
|
||||
);
|
||||
}, [bgTargetsResponse]);
|
||||
|
||||
const [, formAction, isSubmitting] = useActionState(save, null);
|
||||
|
||||
async function save() {
|
||||
try {
|
||||
if (bgDestination && bgDestinationPort) {
|
||||
const selectedSiteIds = new Set(
|
||||
selectedSites.map((s) => s.siteId)
|
||||
);
|
||||
const existingSiteIds = new Set(
|
||||
existingTargets.map((t) => t.siteId)
|
||||
);
|
||||
|
||||
const toDelete = existingTargets.filter(
|
||||
(t) => !selectedSiteIds.has(t.siteId)
|
||||
);
|
||||
await Promise.all(
|
||||
toDelete.map((t) =>
|
||||
api.delete(
|
||||
`/org/${orgId}/browser-gateway-target/${t.browserGatewayTargetId}`
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
const toUpdate = existingTargets.filter((t) =>
|
||||
selectedSiteIds.has(t.siteId)
|
||||
);
|
||||
await Promise.all(
|
||||
toUpdate.map((t) =>
|
||||
api.post(
|
||||
`/org/${orgId}/browser-gateway-target/${t.browserGatewayTargetId}`,
|
||||
{
|
||||
type: "rdp",
|
||||
destination: bgDestination,
|
||||
destinationPort: Number(bgDestinationPort),
|
||||
siteId: t.siteId
|
||||
}
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
const toCreate = selectedSites.filter(
|
||||
(s) => !existingSiteIds.has(s.siteId)
|
||||
);
|
||||
const created = await Promise.all(
|
||||
toCreate.map((s) =>
|
||||
api.put(
|
||||
`/org/${orgId}/resource/${resource.resourceId}/browser-gateway-target`,
|
||||
{
|
||||
siteId: s.siteId,
|
||||
type: "rdp",
|
||||
destination: bgDestination,
|
||||
destinationPort: Number(bgDestinationPort)
|
||||
}
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
const newTargets: ExistingTarget[] = created.map((res, i) => ({
|
||||
browserGatewayTargetId:
|
||||
res.data.data.browserGatewayTargetId,
|
||||
siteId: toCreate[i].siteId
|
||||
}));
|
||||
setExistingTargets([...toUpdate, ...newTargets]);
|
||||
}
|
||||
|
||||
toast({
|
||||
title: t("settingsUpdated"),
|
||||
description: t("settingsUpdatedDescription")
|
||||
});
|
||||
router.refresh();
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: t("settingsErrorUpdate"),
|
||||
description: formatAxiosError(
|
||||
err,
|
||||
t("settingsErrorUpdateDescription")
|
||||
)
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<SettingsSection>
|
||||
<SettingsSectionHeader>
|
||||
<SettingsSectionTitle>{t("rdpServer")}</SettingsSectionTitle>
|
||||
<SettingsSectionDescription>
|
||||
{t("rdpServerDescription")}
|
||||
</SettingsSectionDescription>
|
||||
</SettingsSectionHeader>
|
||||
<SettingsSectionBody>
|
||||
<SettingsSectionForm variant="half">
|
||||
<BrowserGatewayTargetForm
|
||||
orgId={orgId}
|
||||
multiSite={true}
|
||||
selectedSites={selectedSites}
|
||||
onSitesChange={setSelectedSites}
|
||||
destination={bgDestination}
|
||||
destinationPort={bgDestinationPort}
|
||||
onDestinationChange={setBgDestination}
|
||||
onDestinationPortChange={setBgDestinationPort}
|
||||
learnMoreHref="https://docs.pangolin.net/manage/resources/public/rdp"
|
||||
defaultPort={3389}
|
||||
/>
|
||||
</SettingsSectionForm>
|
||||
</SettingsSectionBody>
|
||||
<form action={formAction} className="flex justify-end mt-4">
|
||||
<Button
|
||||
disabled={isSubmitting}
|
||||
loading={isSubmitting}
|
||||
type="submit"
|
||||
>
|
||||
{t("saveSettings")}
|
||||
</Button>
|
||||
</form>
|
||||
</SettingsSection>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,524 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
SettingsContainer,
|
||||
SettingsSection,
|
||||
SettingsSectionBody,
|
||||
SettingsSectionDescription,
|
||||
SettingsSectionForm,
|
||||
SettingsSectionHeader,
|
||||
SettingsSectionTitle
|
||||
} from "@app/components/Settings";
|
||||
import { StrategySelect, StrategyOption } from "@app/components/StrategySelect";
|
||||
import { BrowserGatewayTargetForm } from "@app/components/BrowserGatewayTargetForm";
|
||||
import {
|
||||
SitesSelector,
|
||||
type Selectedsite
|
||||
} from "@app/components/site-selector";
|
||||
import { Button } from "@app/components/ui/button";
|
||||
import { Input } from "@app/components/ui/input";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage
|
||||
} from "@app/components/ui/form";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger
|
||||
} from "@app/components/ui/popover";
|
||||
import { ChevronsUpDown, ExternalLink } from "lucide-react";
|
||||
import { Badge } from "@app/components/ui/badge";
|
||||
import { toast } from "@app/hooks/useToast";
|
||||
import { useResourceContext } from "@app/hooks/useResourceContext";
|
||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||
import { createApiClient } from "@app/lib/api";
|
||||
import { formatAxiosError } from "@app/lib/api/formatAxiosError";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { use, useActionState, useEffect, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { z } from "zod";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { GetResourceResponse } from "@server/routers/resource";
|
||||
import type { ResourceContextType } from "@app/contexts/resourceContext";
|
||||
|
||||
type ExistingTarget = {
|
||||
browserGatewayTargetId: number;
|
||||
siteId: number;
|
||||
};
|
||||
|
||||
const sshFormSchema = z.object({
|
||||
authDaemonPort: z.string().refine(
|
||||
(val) => {
|
||||
if (!val) return true;
|
||||
const n = Number(val);
|
||||
return Number.isInteger(n) && n >= 1 && n <= 65535;
|
||||
},
|
||||
{ message: "Port must be between 1 and 65535" }
|
||||
)
|
||||
});
|
||||
|
||||
export default function SshSettingsPage(props: {
|
||||
params: Promise<{ orgId: string }>;
|
||||
}) {
|
||||
const params = use(props.params);
|
||||
const { resource, updateResource } = useResourceContext();
|
||||
|
||||
return (
|
||||
<SettingsContainer>
|
||||
<SshServerForm
|
||||
orgId={params.orgId}
|
||||
resource={resource}
|
||||
updateResource={updateResource}
|
||||
/>
|
||||
</SettingsContainer>
|
||||
);
|
||||
}
|
||||
|
||||
function SshServerForm({
|
||||
orgId,
|
||||
resource,
|
||||
updateResource
|
||||
}: {
|
||||
orgId: string;
|
||||
resource: GetResourceResponse;
|
||||
updateResource: ResourceContextType["updateResource"];
|
||||
}) {
|
||||
const t = useTranslations();
|
||||
const api = createApiClient(useEnvContext());
|
||||
const router = useRouter();
|
||||
|
||||
const isNativeInitially = resource.authDaemonMode === "native";
|
||||
|
||||
const [sshServerMode, setSshServerMode] = useState<"standard" | "native">(
|
||||
isNativeInitially ? "native" : "standard"
|
||||
);
|
||||
const isNative = sshServerMode === "native";
|
||||
|
||||
const [pamMode, setPamMode] = useState<"passthrough" | "push">(
|
||||
(resource.pamMode as "passthrough" | "push") || "passthrough"
|
||||
);
|
||||
|
||||
const [standardDaemonLocation, setStandardDaemonLocation] = useState<
|
||||
"site" | "remote"
|
||||
>(
|
||||
isNativeInitially
|
||||
? "site"
|
||||
: (resource.authDaemonMode as "site" | "remote") || "site"
|
||||
);
|
||||
|
||||
const form = useForm({
|
||||
resolver: zodResolver(sshFormSchema),
|
||||
defaultValues: {
|
||||
authDaemonPort: (resource as any).authDaemonPort
|
||||
? String((resource as any).authDaemonPort)
|
||||
: "22123"
|
||||
}
|
||||
});
|
||||
|
||||
// Standard mode: multi-site
|
||||
const [selectedSites, setSelectedSites] = useState<Selectedsite[]>([]);
|
||||
const [selectedSite, setSelectedSite] = useState<Selectedsite | null>(null);
|
||||
const [bgDestination, setBgDestination] = useState("");
|
||||
const [bgDestinationPort, setBgDestinationPort] = useState("22");
|
||||
const [existingTargets, setExistingTargets] = useState<ExistingTarget[]>(
|
||||
[]
|
||||
);
|
||||
|
||||
// Native mode: single site
|
||||
const [selectedNativeSite, setSelectedNativeSite] =
|
||||
useState<Selectedsite | null>(null);
|
||||
const [nativeExistingTarget, setNativeExistingTarget] =
|
||||
useState<ExistingTarget | null>(null);
|
||||
const [nativeSiteOpen, setNativeSiteOpen] = useState(false);
|
||||
|
||||
const { data: bgTargetsResponse } = useQuery({
|
||||
queryKey: ["browserGatewayTargets", resource.resourceId, orgId],
|
||||
queryFn: async () => {
|
||||
const res = await api.get(
|
||||
`/org/${orgId}/resource/${resource.resourceId}/browser-gateway-targets`
|
||||
);
|
||||
return res.data.data as {
|
||||
targets: Array<{
|
||||
browserGatewayTargetId: number;
|
||||
resourceId: number;
|
||||
siteId: number;
|
||||
siteName?: string;
|
||||
type: string;
|
||||
destination: string;
|
||||
destinationPort: number;
|
||||
}>;
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!bgTargetsResponse?.targets?.length) return;
|
||||
const targets = bgTargetsResponse.targets;
|
||||
const first = targets[0];
|
||||
if (isNativeInitially) {
|
||||
setSelectedNativeSite({
|
||||
siteId: first.siteId,
|
||||
name: first.siteName ?? String(first.siteId),
|
||||
type: "newt" as const
|
||||
});
|
||||
setNativeExistingTarget({
|
||||
browserGatewayTargetId: first.browserGatewayTargetId,
|
||||
siteId: first.siteId
|
||||
});
|
||||
} else {
|
||||
setBgDestination(first.destination);
|
||||
setBgDestinationPort(String(first.destinationPort));
|
||||
setExistingTargets(
|
||||
targets.map((t) => ({
|
||||
browserGatewayTargetId: t.browserGatewayTargetId,
|
||||
siteId: t.siteId
|
||||
}))
|
||||
);
|
||||
setSelectedSites(
|
||||
targets.map((t) => ({
|
||||
siteId: t.siteId,
|
||||
name: t.siteName ?? String(t.siteId),
|
||||
type: "newt" as const
|
||||
}))
|
||||
);
|
||||
}
|
||||
}, [bgTargetsResponse]);
|
||||
|
||||
const [, formAction, isSubmitting] = useActionState(save, null);
|
||||
|
||||
async function save() {
|
||||
const isValid = await form.trigger();
|
||||
if (!isValid) return;
|
||||
|
||||
const effectiveMode = isNative ? "native" : standardDaemonLocation;
|
||||
const portVal = form.getValues().authDaemonPort;
|
||||
const effectivePort =
|
||||
!isNative && standardDaemonLocation === "remote" && portVal
|
||||
? Number(portVal)
|
||||
: null;
|
||||
|
||||
try {
|
||||
await api.post(`/resource/${resource.resourceId}`, {
|
||||
pamMode,
|
||||
authDaemonMode: effectiveMode,
|
||||
authDaemonPort: effectivePort
|
||||
});
|
||||
|
||||
updateResource({
|
||||
...resource,
|
||||
pamMode,
|
||||
authDaemonMode: effectiveMode
|
||||
});
|
||||
|
||||
if (isNative) {
|
||||
if (selectedNativeSite) {
|
||||
if (nativeExistingTarget) {
|
||||
await api.post(
|
||||
`/org/${orgId}/browser-gateway-target/${nativeExistingTarget.browserGatewayTargetId}`,
|
||||
{
|
||||
type: "ssh",
|
||||
destination: "localhost",
|
||||
destinationPort: 22,
|
||||
siteId: selectedNativeSite.siteId
|
||||
}
|
||||
);
|
||||
} else {
|
||||
const res = await api.put(
|
||||
`/org/${orgId}/resource/${resource.resourceId}/browser-gateway-target`,
|
||||
{
|
||||
siteId: selectedNativeSite.siteId,
|
||||
type: "ssh",
|
||||
destination: "localhost",
|
||||
destinationPort: 22
|
||||
}
|
||||
);
|
||||
setNativeExistingTarget({
|
||||
browserGatewayTargetId:
|
||||
res.data.data.browserGatewayTargetId,
|
||||
siteId: selectedNativeSite.siteId
|
||||
});
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if (bgDestination && bgDestinationPort) {
|
||||
const selectedSiteIds = new Set(
|
||||
selectedSites.map((s) => s.siteId)
|
||||
);
|
||||
const existingSiteIds = new Set(
|
||||
existingTargets.map((t) => t.siteId)
|
||||
);
|
||||
|
||||
const toDelete = existingTargets.filter(
|
||||
(t) => !selectedSiteIds.has(t.siteId)
|
||||
);
|
||||
await Promise.all(
|
||||
toDelete.map((t) =>
|
||||
api.delete(
|
||||
`/org/${orgId}/browser-gateway-target/${t.browserGatewayTargetId}`
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
const toUpdate = existingTargets.filter((t) =>
|
||||
selectedSiteIds.has(t.siteId)
|
||||
);
|
||||
await Promise.all(
|
||||
toUpdate.map((t) =>
|
||||
api.post(
|
||||
`/org/${orgId}/browser-gateway-target/${t.browserGatewayTargetId}`,
|
||||
{
|
||||
type: "ssh",
|
||||
destination: bgDestination,
|
||||
destinationPort: Number(bgDestinationPort),
|
||||
siteId: t.siteId
|
||||
}
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
const toCreate = selectedSites.filter(
|
||||
(s) => !existingSiteIds.has(s.siteId)
|
||||
);
|
||||
const created = await Promise.all(
|
||||
toCreate.map((s) =>
|
||||
api.put(
|
||||
`/org/${orgId}/resource/${resource.resourceId}/browser-gateway-target`,
|
||||
{
|
||||
siteId: s.siteId,
|
||||
type: "ssh",
|
||||
destination: bgDestination,
|
||||
destinationPort: Number(bgDestinationPort)
|
||||
}
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
const newTargets: ExistingTarget[] = created.map(
|
||||
(res, i) => ({
|
||||
browserGatewayTargetId:
|
||||
res.data.data.browserGatewayTargetId,
|
||||
siteId: toCreate[i].siteId
|
||||
})
|
||||
);
|
||||
setExistingTargets([...toUpdate, ...newTargets]);
|
||||
}
|
||||
}
|
||||
|
||||
toast({
|
||||
title: t("settingsUpdated"),
|
||||
description: t("settingsUpdatedDescription")
|
||||
});
|
||||
router.refresh();
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: t("settingsErrorUpdate"),
|
||||
description: formatAxiosError(
|
||||
err,
|
||||
t("settingsErrorUpdateDescription")
|
||||
)
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const authMethodOptions: StrategyOption<"passthrough" | "push">[] = [
|
||||
{
|
||||
id: "passthrough",
|
||||
title: t("sshAuthMethodManual"),
|
||||
description: t("sshAuthMethodManualDescription")
|
||||
},
|
||||
{
|
||||
id: "push",
|
||||
title: t("sshAuthMethodAutomated"),
|
||||
description: t("sshAuthMethodAutomatedDescription")
|
||||
}
|
||||
];
|
||||
|
||||
const daemonLocationOptions: StrategyOption<"site" | "remote">[] = [
|
||||
{
|
||||
id: "site",
|
||||
title: t("internalResourceAuthDaemonSite"),
|
||||
description: t("sshDaemonLocationSiteDescription")
|
||||
},
|
||||
{
|
||||
id: "remote",
|
||||
title: t("sshDaemonLocationRemote"),
|
||||
description: t("sshDaemonLocationRemoteDescription")
|
||||
}
|
||||
];
|
||||
|
||||
const showDaemonLocation = !isNative && pamMode === "push";
|
||||
const showDaemonPort =
|
||||
!isNative && pamMode === "push" && standardDaemonLocation === "remote";
|
||||
|
||||
return (
|
||||
<SettingsSection>
|
||||
<SettingsSectionHeader>
|
||||
<SettingsSectionTitle>{t("sshServer")}</SettingsSectionTitle>
|
||||
<SettingsSectionDescription>
|
||||
{t("sshServerDescription")}
|
||||
</SettingsSectionDescription>
|
||||
</SettingsSectionHeader>
|
||||
<SettingsSectionBody>
|
||||
<SettingsSectionForm variant="half">
|
||||
<div className="space-y-3">
|
||||
<p className="text-sm font-semibold">
|
||||
{t("sshServerMode")}
|
||||
</p>
|
||||
<Badge variant="secondary">
|
||||
{sshServerMode == "standard"
|
||||
? t("sshServerModeStandard")
|
||||
: t("sshServerModePangolin")}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<p className="text-sm font-semibold">
|
||||
{t("sshAuthenticationMethod")}
|
||||
</p>
|
||||
<StrategySelect<"passthrough" | "push">
|
||||
value={pamMode}
|
||||
options={authMethodOptions}
|
||||
onChange={setPamMode}
|
||||
cols={2}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{showDaemonLocation && (
|
||||
<div className="space-y-3">
|
||||
<p className="text-sm font-semibold">
|
||||
{t("sshAuthDaemonLocation")}
|
||||
</p>
|
||||
<StrategySelect<"site" | "remote">
|
||||
value={standardDaemonLocation}
|
||||
options={daemonLocationOptions}
|
||||
onChange={setStandardDaemonLocation}
|
||||
cols={2}
|
||||
/>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("sshDaemonDisclaimer")}{" "}
|
||||
<a
|
||||
href="https://docs.pangolin.net/manage/resources/public/ssh"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary hover:underline inline-flex items-center gap-1"
|
||||
>
|
||||
{t("learnMore")}
|
||||
<ExternalLink className="size-3.5 shrink-0" />
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showDaemonPort && (
|
||||
<Form {...form}>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="authDaemonPort"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t("sshDaemonPort")}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
max={65535}
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</Form>
|
||||
)}
|
||||
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<h2 className="text-1xl font-semibold tracking-tight flex items-center gap-2">
|
||||
{t("sshServerDestination")}
|
||||
</h2>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("sshServerDestinationDescription")}
|
||||
</p>
|
||||
</div>
|
||||
{isNative ? (
|
||||
<Popover
|
||||
open={nativeSiteOpen}
|
||||
onOpenChange={setNativeSiteOpen}
|
||||
>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
className="w-full max-w-xs justify-between font-normal"
|
||||
>
|
||||
<span className="truncate">
|
||||
{selectedNativeSite?.name ??
|
||||
t("siteSelect")}
|
||||
</span>
|
||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-[var(--radix-popover-trigger-width)] p-0">
|
||||
<SitesSelector
|
||||
orgId={orgId}
|
||||
selectedSite={selectedNativeSite}
|
||||
onSelectSite={(site) => {
|
||||
setSelectedNativeSite(site);
|
||||
setNativeSiteOpen(false);
|
||||
}}
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
) : standardDaemonLocation !== "site" ? (
|
||||
<BrowserGatewayTargetForm
|
||||
orgId={orgId}
|
||||
multiSite={true}
|
||||
selectedSites={selectedSites}
|
||||
onSitesChange={setSelectedSites}
|
||||
destination={bgDestination}
|
||||
destinationPort={bgDestinationPort}
|
||||
onDestinationChange={setBgDestination}
|
||||
onDestinationPortChange={setBgDestinationPort}
|
||||
learnMoreHref="https://docs.pangolin.net/manage/resources/public/ssh"
|
||||
defaultPort={22}
|
||||
/>
|
||||
) : (
|
||||
<BrowserGatewayTargetForm
|
||||
orgId={orgId}
|
||||
multiSite={false}
|
||||
selectedSite={selectedSite}
|
||||
onSiteChange={setSelectedSite}
|
||||
destination={bgDestination}
|
||||
destinationPort={bgDestinationPort}
|
||||
onDestinationChange={setBgDestination}
|
||||
onDestinationPortChange={setBgDestinationPort}
|
||||
learnMoreHref="https://docs.pangolin.net/manage/resources/public/ssh"
|
||||
defaultPort={22}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</SettingsSectionForm>
|
||||
</SettingsSectionBody>
|
||||
<form action={formAction} className="flex justify-end mt-4">
|
||||
<Button
|
||||
disabled={isSubmitting}
|
||||
loading={isSubmitting}
|
||||
type="submit"
|
||||
>
|
||||
{t("saveSettings")}
|
||||
</Button>
|
||||
</form>
|
||||
</SettingsSection>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,248 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
SettingsContainer,
|
||||
SettingsSection,
|
||||
SettingsSectionBody,
|
||||
SettingsSectionDescription,
|
||||
SettingsSectionForm,
|
||||
SettingsSectionHeader,
|
||||
SettingsSectionTitle
|
||||
} from "@app/components/Settings";
|
||||
import { BrowserGatewayTargetForm } from "@app/components/BrowserGatewayTargetForm";
|
||||
import { type Selectedsite } from "@app/components/site-selector";
|
||||
import { Button } from "@app/components/ui/button";
|
||||
import { toast } from "@app/hooks/useToast";
|
||||
import { useResourceContext } from "@app/hooks/useResourceContext";
|
||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||
import { createApiClient } from "@app/lib/api";
|
||||
import { formatAxiosError } from "@app/lib/api/formatAxiosError";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { use, useActionState, useEffect, useState } from "react";
|
||||
import { z } from "zod";
|
||||
import { GetResourceResponse } from "@server/routers/resource";
|
||||
import type { ResourceContextType } from "@app/contexts/resourceContext";
|
||||
|
||||
type ExistingTarget = {
|
||||
browserGatewayTargetId: number;
|
||||
siteId: number;
|
||||
};
|
||||
|
||||
const sshFormSchema = z.object({
|
||||
authDaemonPort: z.string().refine(
|
||||
(val) => {
|
||||
if (!val) return true;
|
||||
const n = Number(val);
|
||||
return Number.isInteger(n) && n >= 1 && n <= 65535;
|
||||
},
|
||||
{ message: "Port must be between 1 and 65535" }
|
||||
)
|
||||
});
|
||||
|
||||
export default function SshSettingsPage(props: {
|
||||
params: Promise<{ orgId: string }>;
|
||||
}) {
|
||||
const params = use(props.params);
|
||||
const { resource, updateResource } = useResourceContext();
|
||||
|
||||
return (
|
||||
<SettingsContainer>
|
||||
<SshServerForm
|
||||
orgId={params.orgId}
|
||||
resource={resource}
|
||||
updateResource={updateResource}
|
||||
/>
|
||||
</SettingsContainer>
|
||||
);
|
||||
}
|
||||
|
||||
function SshServerForm({
|
||||
orgId,
|
||||
resource,
|
||||
updateResource
|
||||
}: {
|
||||
orgId: string;
|
||||
resource: GetResourceResponse;
|
||||
updateResource: ResourceContextType["updateResource"];
|
||||
}) {
|
||||
const t = useTranslations();
|
||||
const api = createApiClient(useEnvContext());
|
||||
const router = useRouter();
|
||||
|
||||
// Standard mode: multi-site
|
||||
const [selectedSites, setSelectedSites] = useState<Selectedsite[]>([]);
|
||||
const [bgDestination, setBgDestination] = useState("");
|
||||
const [bgDestinationPort, setBgDestinationPort] = useState("22");
|
||||
const [existingTargets, setExistingTargets] = useState<ExistingTarget[]>(
|
||||
[]
|
||||
);
|
||||
|
||||
// Native mode: single site
|
||||
const [selectedNativeSite, setSelectedNativeSite] =
|
||||
useState<Selectedsite | null>(null);
|
||||
const [nativeExistingTarget, setNativeExistingTarget] =
|
||||
useState<ExistingTarget | null>(null);
|
||||
|
||||
const { data: bgTargetsResponse } = useQuery({
|
||||
queryKey: ["browserGatewayTargets", resource.resourceId, orgId],
|
||||
queryFn: async () => {
|
||||
const res = await api.get(
|
||||
`/org/${orgId}/resource/${resource.resourceId}/browser-gateway-targets`
|
||||
);
|
||||
return res.data.data as {
|
||||
targets: Array<{
|
||||
browserGatewayTargetId: number;
|
||||
resourceId: number;
|
||||
siteId: number;
|
||||
siteName?: string;
|
||||
type: string;
|
||||
destination: string;
|
||||
destinationPort: number;
|
||||
}>;
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!bgTargetsResponse?.targets?.length) return;
|
||||
const targets = bgTargetsResponse.targets;
|
||||
const first = targets[0];
|
||||
|
||||
setBgDestination(first.destination);
|
||||
setBgDestinationPort(String(first.destinationPort));
|
||||
setExistingTargets(
|
||||
targets.map((t) => ({
|
||||
browserGatewayTargetId: t.browserGatewayTargetId,
|
||||
siteId: t.siteId
|
||||
}))
|
||||
);
|
||||
setSelectedSites(
|
||||
targets.map((t) => ({
|
||||
siteId: t.siteId,
|
||||
name: t.siteName ?? String(t.siteId),
|
||||
type: "newt" as const
|
||||
}))
|
||||
);
|
||||
}, [bgTargetsResponse]);
|
||||
|
||||
const [, formAction, isSubmitting] = useActionState(save, null);
|
||||
|
||||
async function save() {
|
||||
try {
|
||||
if (bgDestination && bgDestinationPort) {
|
||||
const selectedSiteIds = new Set(
|
||||
selectedSites.map((s) => s.siteId)
|
||||
);
|
||||
const existingSiteIds = new Set(
|
||||
existingTargets.map((t) => t.siteId)
|
||||
);
|
||||
|
||||
const toDelete = existingTargets.filter(
|
||||
(t) => !selectedSiteIds.has(t.siteId)
|
||||
);
|
||||
await Promise.all(
|
||||
toDelete.map((t) =>
|
||||
api.delete(
|
||||
`/org/${orgId}/browser-gateway-target/${t.browserGatewayTargetId}`
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
const toUpdate = existingTargets.filter((t) =>
|
||||
selectedSiteIds.has(t.siteId)
|
||||
);
|
||||
await Promise.all(
|
||||
toUpdate.map((t) =>
|
||||
api.post(
|
||||
`/org/${orgId}/browser-gateway-target/${t.browserGatewayTargetId}`,
|
||||
{
|
||||
type: "vnc",
|
||||
destination: bgDestination,
|
||||
destinationPort: Number(bgDestinationPort),
|
||||
siteId: t.siteId
|
||||
}
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
const toCreate = selectedSites.filter(
|
||||
(s) => !existingSiteIds.has(s.siteId)
|
||||
);
|
||||
const created = await Promise.all(
|
||||
toCreate.map((s) =>
|
||||
api.put(
|
||||
`/org/${orgId}/resource/${resource.resourceId}/browser-gateway-target`,
|
||||
{
|
||||
siteId: s.siteId,
|
||||
type: "vnc",
|
||||
destination: bgDestination,
|
||||
destinationPort: Number(bgDestinationPort)
|
||||
}
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
const newTargets: ExistingTarget[] = created.map((res, i) => ({
|
||||
browserGatewayTargetId:
|
||||
res.data.data.browserGatewayTargetId,
|
||||
siteId: toCreate[i].siteId
|
||||
}));
|
||||
setExistingTargets([...toUpdate, ...newTargets]);
|
||||
}
|
||||
|
||||
toast({
|
||||
title: t("settingsUpdated"),
|
||||
description: t("settingsUpdatedDescription")
|
||||
});
|
||||
router.refresh();
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: t("settingsErrorUpdate"),
|
||||
description: formatAxiosError(
|
||||
err,
|
||||
t("settingsErrorUpdateDescription")
|
||||
)
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<SettingsSection>
|
||||
<SettingsSectionHeader>
|
||||
<SettingsSectionTitle>{t("vncServer")}</SettingsSectionTitle>
|
||||
<SettingsSectionDescription>
|
||||
{t("vncServerDescription")}
|
||||
</SettingsSectionDescription>
|
||||
</SettingsSectionHeader>
|
||||
<SettingsSectionBody>
|
||||
<SettingsSectionForm variant="half">
|
||||
<BrowserGatewayTargetForm
|
||||
orgId={orgId}
|
||||
multiSite={true}
|
||||
selectedSites={selectedSites}
|
||||
onSitesChange={setSelectedSites}
|
||||
destination={bgDestination}
|
||||
destinationPort={bgDestinationPort}
|
||||
onDestinationChange={setBgDestination}
|
||||
onDestinationPortChange={setBgDestinationPort}
|
||||
learnMoreHref="https://docs.pangolin.net/manage/resources/public/vnc"
|
||||
defaultPort={5900}
|
||||
/>
|
||||
</SettingsSectionForm>
|
||||
</SettingsSectionBody>
|
||||
<form action={formAction} className="flex justify-end mt-4">
|
||||
<Button
|
||||
disabled={isSubmitting}
|
||||
loading={isSubmitting}
|
||||
type="submit"
|
||||
>
|
||||
{t("saveSettings")}
|
||||
</Button>
|
||||
</form>
|
||||
</SettingsSection>
|
||||
);
|
||||
}
|
||||
@@ -713,7 +713,7 @@ export default function Page() {
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
return (
|
||||
<div className="flex items-center justify-center w-full">
|
||||
{row.original.siteType === "newt" ? (
|
||||
<Button
|
||||
@@ -732,7 +732,6 @@ export default function Page() {
|
||||
{getStatusText(status)}
|
||||
</div>
|
||||
</Button>
|
||||
|
||||
) : (
|
||||
<span>-</span>
|
||||
)}
|
||||
@@ -1427,10 +1426,12 @@ export default function Page() {
|
||||
)}
|
||||
{build === "saas" &&
|
||||
targets.length > 1 &&
|
||||
new Set(targets.map((t) => t.siteId)).size >
|
||||
1 && (
|
||||
new Set(targets.map((t) => t.siteId))
|
||||
.size > 1 && (
|
||||
<p className="text-sm text-muted-foreground mt-3">
|
||||
{t("proxyMultiSiteRoundRobinNodeHelp")}{" "}
|
||||
{t(
|
||||
"proxyMultiSiteRoundRobinNodeHelp"
|
||||
)}{" "}
|
||||
<a
|
||||
href="https://docs.pangolin.net/manage/resources/public/targets#distributing-sites-load-across-servers"
|
||||
target="_blank"
|
||||
@@ -1627,7 +1628,7 @@ export default function Page() {
|
||||
type="button"
|
||||
onClick={() =>
|
||||
router.push(
|
||||
`/${orgId}/settings/resources/proxy/${niceId}/proxy`
|
||||
`/${orgId}/settings/resources/proxy/${niceId}`
|
||||
)
|
||||
}
|
||||
>
|
||||
|
||||
@@ -14,6 +14,7 @@ import type {
|
||||
RdpFileTransferProvider,
|
||||
FileInfo
|
||||
} from "@devolutions/iron-remote-desktop-rdp/dist";
|
||||
import { GetBrowserTargetResponse } from "@server/routers/resource";
|
||||
|
||||
declare module "react" {
|
||||
namespace JSX {
|
||||
@@ -31,12 +32,6 @@ declare module "react" {
|
||||
}
|
||||
}
|
||||
|
||||
type Target = {
|
||||
ip: string;
|
||||
port: number;
|
||||
authToken: string;
|
||||
};
|
||||
|
||||
type FormState = {
|
||||
username: string;
|
||||
password: string;
|
||||
@@ -59,7 +54,7 @@ export default function RdpClient({
|
||||
target,
|
||||
error
|
||||
}: {
|
||||
target: Target | null;
|
||||
target: GetBrowserTargetResponse | null;
|
||||
error: string | null;
|
||||
}) {
|
||||
const STORAGE_KEY = "pangolin_rdp_credentials";
|
||||
|
||||
+81
-17
@@ -6,12 +6,8 @@ import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
|
||||
type Target = {
|
||||
ip: string;
|
||||
port: number;
|
||||
authToken: string;
|
||||
};
|
||||
import type { SignSshKeyResponse } from "@server/private/routers/ssh";
|
||||
import { GetBrowserTargetResponse } from "@server/routers/resource";
|
||||
|
||||
type FormState = {
|
||||
username: string;
|
||||
@@ -19,12 +15,23 @@ type FormState = {
|
||||
privateKey: string;
|
||||
};
|
||||
|
||||
type ConnectCredentials = {
|
||||
username: string;
|
||||
password?: string;
|
||||
privateKey?: string;
|
||||
certificate?: string;
|
||||
};
|
||||
|
||||
export default function SshClient({
|
||||
target,
|
||||
error
|
||||
error,
|
||||
signedKeyData,
|
||||
privateKey: signedPrivateKey
|
||||
}: {
|
||||
target: Target | null;
|
||||
target: GetBrowserTargetResponse | null;
|
||||
error: string | null;
|
||||
signedKeyData?: SignSshKeyResponse | null;
|
||||
privateKey?: string | null;
|
||||
}) {
|
||||
const STORAGE_KEY = "pangolin_ssh_credentials";
|
||||
|
||||
@@ -148,7 +155,19 @@ export default function SshClient({
|
||||
};
|
||||
}, []);
|
||||
|
||||
function connect() {
|
||||
// Auto-connect when signed key data is provided (push PAM mode).
|
||||
useEffect(() => {
|
||||
if (signedKeyData && signedPrivateKey && target) {
|
||||
connect({
|
||||
username: signedKeyData.sshUsername,
|
||||
privateKey: signedPrivateKey,
|
||||
certificate: signedKeyData.certificate
|
||||
});
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
function connect(override?: ConnectCredentials) {
|
||||
setConnectError(null);
|
||||
setConnecting(true);
|
||||
|
||||
@@ -158,11 +177,16 @@ export default function SshClient({
|
||||
return;
|
||||
}
|
||||
|
||||
const username = override?.username ?? form.username;
|
||||
const password = override?.password ?? form.password;
|
||||
const privateKey = override?.privateKey ?? form.privateKey;
|
||||
const certificate = override?.certificate;
|
||||
|
||||
const proxyAddress = `${window.location.protocol === "https:" ? "wss" : "ws"}://${window.location.host}/gateway/ssh`;
|
||||
const url = new URL(proxyAddress);
|
||||
url.searchParams.set("host", target.ip ?? "");
|
||||
url.searchParams.set("port", String(target.port ?? 22));
|
||||
url.searchParams.set("username", form.username);
|
||||
url.searchParams.set("username", username);
|
||||
url.searchParams.set("authToken", target.authToken ?? "");
|
||||
|
||||
const ws = new WebSocket(url.toString(), ["ssh"]);
|
||||
@@ -174,14 +198,17 @@ export default function SshClient({
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: "auth",
|
||||
password: form.password,
|
||||
privateKey: form.privateKey
|
||||
password,
|
||||
privateKey,
|
||||
certificate
|
||||
})
|
||||
);
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(form));
|
||||
} catch {
|
||||
// ignore
|
||||
if (!override) {
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(form));
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
setConnecting(false);
|
||||
setConnected(true);
|
||||
@@ -240,6 +267,43 @@ export default function SshClient({
|
||||
);
|
||||
}
|
||||
|
||||
// In push mode, show a connecting/connected state without the login form.
|
||||
if (signedKeyData && signedPrivateKey) {
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
{!connected && (
|
||||
<div className="flex min-h-screen items-center justify-center">
|
||||
<p className="text-muted-foreground">
|
||||
{connectError
|
||||
? connectError
|
||||
: connecting
|
||||
? "Connecting…"
|
||||
: "Initializing…"}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{connected && (
|
||||
<div className="flex h-screen flex-col bg-neutral-900">
|
||||
<div className="flex flex-wrap items-center gap-2 bg-black p-2 text-white">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="destructive"
|
||||
onClick={disconnect}
|
||||
>
|
||||
Terminate
|
||||
</Button>
|
||||
</div>
|
||||
<div
|
||||
ref={terminalRef}
|
||||
className="flex-1 overflow-hidden"
|
||||
style={{ minHeight: 0 }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
{!connected && (
|
||||
@@ -335,7 +399,7 @@ export default function SshClient({
|
||||
)}
|
||||
|
||||
<Button
|
||||
onClick={connect}
|
||||
onClick={() => connect()}
|
||||
loading={connecting}
|
||||
disabled={
|
||||
!form.username ||
|
||||
|
||||
+62
-2
@@ -3,6 +3,42 @@ import { priv } from "@app/lib/api";
|
||||
import { AxiosResponse } from "axios";
|
||||
import { GetBrowserTargetResponse } from "@server/routers/resource";
|
||||
import SshClient from "./SshClient";
|
||||
import { SignSshKeyResponse } from "@server/private/routers/ssh";
|
||||
import crypto from "crypto";
|
||||
|
||||
function generateEphemeralKeyPair(): {
|
||||
privateKeyPem: string;
|
||||
publicKeyOpenSSH: string;
|
||||
} {
|
||||
const { publicKey: pubKeyObj, privateKey: privKeyObj } =
|
||||
crypto.generateKeyPairSync("ed25519");
|
||||
|
||||
const privateKeyPem = privKeyObj.export({
|
||||
type: "pkcs8",
|
||||
format: "pem"
|
||||
}) as string;
|
||||
|
||||
// Build OpenSSH wire format: uint32-length-prefixed strings
|
||||
const pubKeyDer = pubKeyObj.export({
|
||||
type: "spki",
|
||||
format: "der"
|
||||
}) as Buffer;
|
||||
const rawPubKey = pubKeyDer.subarray(pubKeyDer.length - 32); // last 32 bytes are the Ed25519 key
|
||||
|
||||
function encodeField(b: Buffer): Buffer {
|
||||
const len = Buffer.allocUnsafe(4);
|
||||
len.writeUInt32BE(b.length, 0);
|
||||
return Buffer.concat([len, b]);
|
||||
}
|
||||
|
||||
const keyBlob = Buffer.concat([
|
||||
encodeField(Buffer.from("ssh-ed25519")),
|
||||
encodeField(rawPubKey)
|
||||
]);
|
||||
const publicKeyOpenSSH = `ssh-ed25519 ${keyBlob.toString("base64")}`;
|
||||
|
||||
return { privateKeyPem, publicKeyOpenSSH };
|
||||
}
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
@@ -15,7 +51,9 @@ export default async function SshPage() {
|
||||
const host = headersList.get("host") || "";
|
||||
const hostname = host.split(":")[0];
|
||||
|
||||
let target: { ip: string; port: number; authToken: string } | null = null;
|
||||
let target: GetBrowserTargetResponse | null = null;
|
||||
let signedKeyData: SignSshKeyResponse | null = null;
|
||||
let privateKey: string | null = null;
|
||||
let error: string | null = null;
|
||||
|
||||
try {
|
||||
@@ -23,10 +61,32 @@ export default async function SshPage() {
|
||||
`/resource/browser-target?fullDomain=${encodeURIComponent(hostname)}`
|
||||
);
|
||||
target = res.data.data;
|
||||
|
||||
if (target.pamMode === "push") {
|
||||
const { privateKeyPem, publicKeyOpenSSH } =
|
||||
generateEphemeralKeyPair();
|
||||
privateKey = privateKeyPem;
|
||||
const res = await priv.post<AxiosResponse<SignSshKeyResponse>>(
|
||||
`/org/${target.orgId}/ssh/sign-key`,
|
||||
{
|
||||
publicKey: publicKeyOpenSSH,
|
||||
resource: target.niceId
|
||||
}
|
||||
);
|
||||
signedKeyData = res.data.data;
|
||||
console.log("Received signed SSH key:", signedKeyData);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error fetching browser target:", error);
|
||||
error = "No resource found for this domain";
|
||||
}
|
||||
|
||||
return <SshClient target={target} error={error} />;
|
||||
return (
|
||||
<SshClient
|
||||
target={target}
|
||||
error={error}
|
||||
signedKeyData={signedKeyData}
|
||||
privateKey={privateKey}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,12 +5,7 @@ import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { toast } from "@app/hooks/useToast";
|
||||
|
||||
type Target = {
|
||||
ip: string;
|
||||
port: number;
|
||||
authToken: string;
|
||||
};
|
||||
import { GetBrowserTargetResponse } from "@server/routers/resource";
|
||||
|
||||
type FormState = {
|
||||
password: string;
|
||||
@@ -20,7 +15,7 @@ export default function VncClient({
|
||||
target,
|
||||
error
|
||||
}: {
|
||||
target: Target | null;
|
||||
target: GetBrowserTargetResponse | null;
|
||||
error: string | null;
|
||||
}) {
|
||||
const STORAGE_KEY = "pangolin_vnc_credentials";
|
||||
|
||||
@@ -15,7 +15,7 @@ export default async function VncPage() {
|
||||
const host = headersList.get("host") || "";
|
||||
const hostname = host.split(":")[0];
|
||||
|
||||
let target: { ip: string; port: number; authToken: string } | null = null;
|
||||
let target: GetBrowserTargetResponse | null = null;
|
||||
let error: string | null = null;
|
||||
|
||||
try {
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
"use client";
|
||||
|
||||
import { ChevronsUpDown, ExternalLink } from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useState } from "react";
|
||||
import {
|
||||
MultiSitesSelector,
|
||||
formatMultiSitesSelectorLabel
|
||||
} from "./multi-site-selector";
|
||||
import { SitesSelector, type Selectedsite } from "./site-selector";
|
||||
import { Button } from "./ui/button";
|
||||
import { Input } from "./ui/input";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "./ui/popover";
|
||||
|
||||
type SingleSiteProps = {
|
||||
multiSite?: false;
|
||||
selectedSite: Selectedsite | null;
|
||||
onSiteChange: (site: Selectedsite | null) => void;
|
||||
};
|
||||
|
||||
type MultiSiteProps = {
|
||||
multiSite: true;
|
||||
selectedSites: Selectedsite[];
|
||||
onSitesChange: (sites: Selectedsite[]) => void;
|
||||
};
|
||||
|
||||
export type BrowserGatewayTargetFormProps = {
|
||||
orgId: string;
|
||||
destination: string;
|
||||
defaultPort: number;
|
||||
destinationPort: string;
|
||||
onDestinationChange: (v: string) => void;
|
||||
onDestinationPortChange: (v: string) => void;
|
||||
learnMoreHref?: string;
|
||||
} & (SingleSiteProps | MultiSiteProps);
|
||||
|
||||
export function BrowserGatewayTargetForm(props: BrowserGatewayTargetFormProps) {
|
||||
const t = useTranslations();
|
||||
const [siteOpen, setSiteOpen] = useState(false);
|
||||
|
||||
const siteSelector =
|
||||
props.multiSite === true ? (
|
||||
<Popover open={siteOpen} onOpenChange={setSiteOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
className="w-full justify-between font-normal"
|
||||
>
|
||||
<span className="truncate">
|
||||
{formatMultiSitesSelectorLabel(
|
||||
props.selectedSites,
|
||||
t
|
||||
)}
|
||||
</span>
|
||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-[var(--radix-popover-trigger-width)] p-0">
|
||||
<MultiSitesSelector
|
||||
orgId={props.orgId}
|
||||
selectedSites={props.selectedSites}
|
||||
onSelectionChange={props.onSitesChange}
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
) : (
|
||||
<Popover open={siteOpen} onOpenChange={setSiteOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
className="w-full justify-between font-normal"
|
||||
>
|
||||
<span className="truncate">
|
||||
{props.selectedSite?.name ?? t("siteSelect")}
|
||||
</span>
|
||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-[var(--radix-popover-trigger-width)] p-0">
|
||||
<SitesSelector
|
||||
orgId={props.orgId}
|
||||
selectedSite={props.selectedSite}
|
||||
onSelectSite={(site) => {
|
||||
props.onSiteChange(site);
|
||||
setSiteOpen(false);
|
||||
}}
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-semibold">
|
||||
{t("sites")}
|
||||
</label>
|
||||
{siteSelector}
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-semibold">
|
||||
{t("destination")}
|
||||
</label>
|
||||
<Input
|
||||
placeholder="192.168.1.1"
|
||||
value={props.destination}
|
||||
onChange={(e) =>
|
||||
props.onDestinationChange(e.target.value)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-semibold">{t("port")}</label>
|
||||
<Input
|
||||
type="number"
|
||||
placeholder={props.defaultPort.toString()}
|
||||
value={props.destinationPort}
|
||||
onChange={(e) =>
|
||||
props.onDestinationPortChange(e.target.value)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{props.multiSite === true && props.selectedSites.length > 1 && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("bgTargetMultiSiteDisclaimer")}{" "}
|
||||
<a
|
||||
href={
|
||||
props.learnMoreHref ??
|
||||
"https://docs.pangolin.net/manage/resources/public/ssh"
|
||||
}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary hover:underline inline-flex items-center gap-1"
|
||||
>
|
||||
{t("learnMore")}
|
||||
<ExternalLink className="size-3.5 shrink-0" />
|
||||
</a>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -4,11 +4,9 @@ import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||
import {
|
||||
ShieldCheck,
|
||||
ShieldOff,
|
||||
Eye,
|
||||
EyeOff,
|
||||
CheckCircle2,
|
||||
XCircle,
|
||||
Clock
|
||||
XCircle
|
||||
} from "lucide-react";
|
||||
import { useResourceContext } from "@app/hooks/useResourceContext";
|
||||
import CopyToClipboard from "@app/components/CopyToClipboard";
|
||||
@@ -32,19 +30,40 @@ export default function ResourceInfoBox({}: ResourceInfoBoxType) {
|
||||
|
||||
const fullUrl = `${resource.ssl ? "https" : "http"}://${toUnicode(resource.fullDomain || "")}`;
|
||||
|
||||
const showCertificate = !!(
|
||||
resource.http &&
|
||||
resource.domainId &&
|
||||
resource.fullDomain &&
|
||||
build != "oss"
|
||||
);
|
||||
const showType = !!(resource.http && resource.browserAccessType);
|
||||
const showHealth =
|
||||
!["ssh", "rdp", "vnc"].includes(resource.browserAccessType || "") &&
|
||||
!!resource.health &&
|
||||
resource.health !== "unknown";
|
||||
const showVisibility = !resource.enabled;
|
||||
|
||||
const numSections = [
|
||||
true, // URL or Protocol
|
||||
true, // Authentication or Port
|
||||
showType,
|
||||
showCertificate,
|
||||
showHealth,
|
||||
showVisibility
|
||||
].filter(Boolean).length;
|
||||
|
||||
return (
|
||||
<Alert>
|
||||
<AlertDescription>
|
||||
{/* 4 cols because of the certs */}
|
||||
<InfoSections cols={resource.http && build != "oss" ? 6 : 5}>
|
||||
<InfoSection>
|
||||
<InfoSections cols={numSections}>
|
||||
{/* <InfoSection>
|
||||
<InfoSectionTitle>{t("identifier")}</InfoSectionTitle>
|
||||
<InfoSectionContent>
|
||||
<span className="inline-flex items-center">
|
||||
{resource.niceId}
|
||||
</span>
|
||||
</InfoSectionContent>
|
||||
</InfoSection>
|
||||
</InfoSection> */}
|
||||
{resource.http ? (
|
||||
<>
|
||||
<InfoSection>
|
||||
@@ -62,6 +81,18 @@ export default function ResourceInfoBox({}: ResourceInfoBoxType) {
|
||||
)}
|
||||
</InfoSectionContent>
|
||||
</InfoSection>
|
||||
{showType && (
|
||||
<InfoSection>
|
||||
<InfoSectionTitle>
|
||||
{t("type")}
|
||||
</InfoSectionTitle>
|
||||
<InfoSectionContent>
|
||||
<span className="inline-flex items-center">
|
||||
{resource.browserAccessType!.toUpperCase()}
|
||||
</span>
|
||||
</InfoSectionContent>
|
||||
</InfoSection>
|
||||
)}
|
||||
<InfoSection>
|
||||
<InfoSectionTitle>
|
||||
{t("authentication")}
|
||||
@@ -84,24 +115,6 @@ export default function ResourceInfoBox({}: ResourceInfoBoxType) {
|
||||
)}
|
||||
</InfoSectionContent>
|
||||
</InfoSection>
|
||||
{/* {isEnabled && (
|
||||
<InfoSection>
|
||||
<InfoSectionTitle>Socket</InfoSectionTitle>
|
||||
<InfoSectionContent>
|
||||
{isAvailable ? (
|
||||
<span className="flex items-center space-x-2">
|
||||
<div className="w-2 h-2 bg-green-500 rounded-full"></div>
|
||||
<span>Online</span>
|
||||
</span>
|
||||
) : (
|
||||
<span className="flex items-center space-x-2">
|
||||
<div className="w-2 h-2 bg-neutral-500 rounded-full"></div>
|
||||
<span>Offline</span>
|
||||
</span>
|
||||
)}
|
||||
</InfoSectionContent>
|
||||
</InfoSection>
|
||||
)} */}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
@@ -149,74 +162,69 @@ export default function ResourceInfoBox({}: ResourceInfoBoxType) {
|
||||
{/* </InfoSectionContent> */}
|
||||
{/* </InfoSection> */}
|
||||
{/* Certificate Status Column */}
|
||||
{resource.http &&
|
||||
resource.domainId &&
|
||||
resource.fullDomain &&
|
||||
build != "oss" && (
|
||||
<InfoSection>
|
||||
<InfoSectionTitle>
|
||||
{t("certificateStatus", {
|
||||
defaultValue: "Certificate"
|
||||
})}
|
||||
</InfoSectionTitle>
|
||||
<InfoSectionContent>
|
||||
<CertificateStatus
|
||||
orgId={resource.orgId}
|
||||
domainId={resource.domainId}
|
||||
fullDomain={resource.fullDomain}
|
||||
autoFetch={true}
|
||||
showLabel={false}
|
||||
polling={true}
|
||||
/>
|
||||
</InfoSectionContent>
|
||||
</InfoSection>
|
||||
)}
|
||||
<InfoSection>
|
||||
<InfoSectionTitle>{t("health")}</InfoSectionTitle>
|
||||
<InfoSectionContent>
|
||||
{resource.health === "healthy" && (
|
||||
<div className="flex items-center space-x-2">
|
||||
<CheckCircle2 className="w-4 h-4 flex-shrink-0 text-green-500" />
|
||||
<span>{t("resourcesTableHealthy")}</span>
|
||||
</div>
|
||||
)}
|
||||
{resource.health === "degraded" && (
|
||||
<div className="flex items-center space-x-2">
|
||||
<CheckCircle2 className="w-4 h-4 flex-shrink-0 text-yellow-500" />
|
||||
<span>{t("resourcesTableDegraded")}</span>
|
||||
</div>
|
||||
)}
|
||||
{resource.health === "unhealthy" && (
|
||||
<div className="flex items-center space-x-2">
|
||||
<XCircle className="w-4 h-4 flex-shrink-0 text-destructive" />
|
||||
<span>{t("resourcesTableUnhealthy")}</span>
|
||||
</div>
|
||||
)}
|
||||
{(!resource.health ||
|
||||
resource.health === "unknown") && (
|
||||
<div className="flex items-center space-x-2">
|
||||
<Clock className="w-4 h-4 flex-shrink-0" />
|
||||
<span>{t("resourcesTableUnknown")}</span>
|
||||
</div>
|
||||
)}
|
||||
</InfoSectionContent>
|
||||
</InfoSection>
|
||||
<InfoSection>
|
||||
<InfoSectionTitle>{t("visibility")}</InfoSectionTitle>
|
||||
<InfoSectionContent>
|
||||
{resource.enabled ? (
|
||||
<div className="flex items-center space-x-2">
|
||||
<Eye className="w-4 h-4 flex-shrink-0 text-green-500" />
|
||||
<span>{t("enabled")}</span>
|
||||
</div>
|
||||
) : (
|
||||
{showCertificate && (
|
||||
<InfoSection>
|
||||
<InfoSectionTitle>
|
||||
{t("certificateStatus", {
|
||||
defaultValue: "Certificate"
|
||||
})}
|
||||
</InfoSectionTitle>
|
||||
<InfoSectionContent>
|
||||
<CertificateStatus
|
||||
orgId={resource.orgId}
|
||||
domainId={resource.domainId!}
|
||||
fullDomain={resource.fullDomain!}
|
||||
autoFetch={true}
|
||||
showLabel={false}
|
||||
polling={true}
|
||||
/>
|
||||
</InfoSectionContent>
|
||||
</InfoSection>
|
||||
)}
|
||||
{showHealth && (
|
||||
<InfoSection>
|
||||
<InfoSectionTitle>{t("health")}</InfoSectionTitle>
|
||||
<InfoSectionContent>
|
||||
{resource.health === "healthy" && (
|
||||
<div className="flex items-center space-x-2">
|
||||
<CheckCircle2 className="w-4 h-4 flex-shrink-0 text-green-500" />
|
||||
<span>
|
||||
{t("resourcesTableHealthy")}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{resource.health === "degraded" && (
|
||||
<div className="flex items-center space-x-2">
|
||||
<CheckCircle2 className="w-4 h-4 flex-shrink-0 text-yellow-500" />
|
||||
<span>
|
||||
{t("resourcesTableDegraded")}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{resource.health === "unhealthy" && (
|
||||
<div className="flex items-center space-x-2">
|
||||
<XCircle className="w-4 h-4 flex-shrink-0 text-destructive" />
|
||||
<span>
|
||||
{t("resourcesTableUnhealthy")}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</InfoSectionContent>
|
||||
</InfoSection>
|
||||
)}
|
||||
{showVisibility && (
|
||||
<InfoSection>
|
||||
<InfoSectionTitle>
|
||||
{t("visibility")}
|
||||
</InfoSectionTitle>
|
||||
<InfoSectionContent>
|
||||
<div className="flex items-center space-x-2">
|
||||
<EyeOff className="w-4 h-4 flex-shrink-0 text-neutral-500" />
|
||||
<span>{t("disabled")}</span>
|
||||
</div>
|
||||
)}
|
||||
</InfoSectionContent>
|
||||
</InfoSection>
|
||||
</InfoSectionContent>
|
||||
</InfoSection>
|
||||
)}
|
||||
</InfoSections>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
@@ -22,13 +22,24 @@ export function SettingsSectionHeader({
|
||||
|
||||
export function SettingsSectionForm({
|
||||
children,
|
||||
className
|
||||
className,
|
||||
variant = "compact"
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
variant?: "half" | "compact";
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<div className={cn("max-w-xl space-y-4", className)}>{children}</div>
|
||||
<div
|
||||
className={cn(
|
||||
variant === "half"
|
||||
? "max-w-3xl space-y-4"
|
||||
: "max-w-xl space-y-4",
|
||||
className
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user