Compare commits

...

8 Commits

Author SHA1 Message Date
Owen e248571268 Merge branch 'dev' of github.com:fosrl/pangolin into dev 2026-06-09 22:02:24 -07:00
miloschwartz fcf03854ff fix tag input wrapping 2026-06-09 22:01:13 -07:00
Owen dd1fba4e45 Also clear the roles and users 2026-06-09 21:59:30 -07:00
miloschwartz a1ab8d8f35 standardize client titles 2026-06-09 21:47:15 -07:00
miloschwartz c789e967db standardize client titles 2026-06-09 21:36:54 -07:00
Owen d870b9ff49 Drop the not null on resource columns 2026-06-09 21:36:27 -07:00
miloschwartz 9c09019ddb add protocol filter 2026-06-09 21:33:56 -07:00
Owen 9d88683fc5 Reset resource info when on inline policy 2026-06-09 21:28:25 -07:00
16 changed files with 248 additions and 128 deletions
+2 -2
View File
@@ -3542,14 +3542,14 @@
"sshConnecting": "Connecting…",
"sshInitializing": "Initializing…",
"sshSignInTitle": "Sign in to SSH",
"sshSignInDescription": "Enter your SSH credentials",
"sshSignInDescription": "Enter your SSH credentials to connect",
"sshPasswordTab": "Password",
"sshPrivateKeyTab": "Private Key",
"sshPrivateKeyField": "Private Key",
"sshPrivateKeyDisclaimer": "Your private key is not stored or visible to Pangolin. Alternatively, you can use short-lived certificates for seamless authentication using your existing Pangolin identity.",
"sshLearnMore": "Learn more",
"sshPrivateKeyFile": "Private Key File",
"sshAuthenticate": "Authenticate",
"sshAuthenticate": "Connect",
"sshTerminate": "Terminate",
"sshPoweredBy": "Powered by",
"sshErrorNoTarget": "No target specified",
+3 -5
View File
@@ -147,12 +147,10 @@ export const resources = pgTable("resources", {
}),
ssl: boolean("ssl").notNull().default(false),
blockAccess: boolean("blockAccess").notNull().default(false),
sso: boolean("sso").notNull().default(true),
proxyPort: integer("proxyPort"),
emailWhitelistEnabled: boolean("emailWhitelistEnabled")
.notNull()
.default(false),
applyRules: boolean("applyRules").notNull().default(false),
sso: boolean("sso"),
emailWhitelistEnabled: boolean("emailWhitelistEnabled"),
applyRules: boolean("applyRules"),
enabled: boolean("enabled").notNull().default(true),
stickySession: boolean("stickySession").notNull().default(false),
tlsServerName: varchar("tlsServerName"),
+3 -3
View File
@@ -45,9 +45,9 @@ export type ResourceWithAuth = {
password: ResourcePassword | ResourcePolicyPassword | null;
headerAuth: ResourceHeaderAuth | ResourcePolicyHeaderAuth | null;
headerAuthExtendedCompatibility: ResourceHeaderAuthExtendedCompatibility | null;
applyRules: boolean;
sso: boolean;
emailWhitelistEnabled: boolean;
applyRules: boolean | null;
sso: boolean | null;
emailWhitelistEnabled: boolean | null;
org: Org;
};
+5 -7
View File
@@ -165,14 +165,12 @@ export const resources = sqliteTable("resources", {
blockAccess: integer("blockAccess", { mode: "boolean" })
.notNull()
.default(false),
sso: integer("sso", { mode: "boolean" }).notNull().default(true),
proxyPort: integer("proxyPort"),
emailWhitelistEnabled: integer("emailWhitelistEnabled", { mode: "boolean" })
.notNull()
.default(false),
applyRules: integer("applyRules", { mode: "boolean" })
.notNull()
.default(false),
sso: integer("sso", { mode: "boolean" }),
emailWhitelistEnabled: integer("emailWhitelistEnabled", {
mode: "boolean"
}),
applyRules: integer("applyRules", { mode: "boolean" }),
enabled: integer("enabled", { mode: "boolean" }).notNull().default(true),
stickySession: integer("stickySession", { mode: "boolean" })
.notNull()
+3 -3
View File
@@ -219,9 +219,9 @@ export type ResourceWithAuth = {
password: ResourcePassword | ResourcePolicyPassword | null;
headerAuth: ResourceHeaderAuth | ResourcePolicyHeaderAuth | null;
headerAuthExtendedCompatibility: ResourceHeaderAuthExtendedCompatibility | null;
applyRules: boolean;
sso: boolean;
emailWhitelistEnabled: boolean;
applyRules: boolean | null;
sso: boolean | null;
emailWhitelistEnabled: boolean | null;
org: Org;
};
+3 -3
View File
@@ -145,9 +145,9 @@ export async function verifyResourceSession(
| ResourcePolicyHeaderAuth
| null;
headerAuthExtendedCompatibility: ResourceHeaderAuthExtendedCompatibility | null;
applyRules: boolean;
sso: boolean;
emailWhitelistEnabled: boolean;
applyRules: boolean | null;
sso: boolean | null;
emailWhitelistEnabled: boolean | null;
org: Org;
}
| undefined = localCache.get(resourceCacheKey);
+2 -2
View File
@@ -203,9 +203,9 @@ export async function getUserResources(
fullDomain: string | null;
ssl: boolean;
enabled: boolean;
sso: boolean;
sso: boolean | null;
mode: string;
emailWhitelistEnabled: boolean;
emailWhitelistEnabled: boolean | null;
policyEmailWhitelistEnabled: boolean | null;
}> = [];
if (uniqueResourceIds.length > 0) {
+33
View File
@@ -123,6 +123,16 @@ const listResourcesSchema = z.object({
description:
"Filter resources based on health status of their targets. `healthy` means all targets are healthy. `degraded` means at least one target is unhealthy, but not all are unhealthy. `offline` means all targets are unhealthy. `unknown` means all targets have unknown health status."
}),
protocol: z
.enum(["http", "https", "tcp", "udp", "ssh", "rdp", "vnc"])
.optional()
.catch(undefined)
.openapi({
type: "string",
enum: ["http", "https", "tcp", "udp", "ssh", "rdp", "vnc"],
description:
"Filter resources by protocol. `http` and `https` match HTTP resources without and with SSL respectively."
}),
siteId: z.coerce.number<string>().int().positive().optional().openapi({
type: "integer",
description:
@@ -437,6 +447,7 @@ export async function listResources(
enabled,
query,
healthStatus,
protocol,
sort_by,
order,
siteId,
@@ -632,6 +643,28 @@ export async function listResources(
if (typeof healthStatus !== "undefined") {
conditions.push(eq(resources.health, healthStatus));
}
if (typeof protocol !== "undefined") {
switch (protocol) {
case "http":
conditions.push(
and(
eq(resources.mode, "http"),
eq(resources.ssl, false)
)
);
break;
case "https":
conditions.push(
and(eq(resources.mode, "http"), eq(resources.ssl, true))
);
break;
default:
conditions.push(eq(resources.mode, protocol));
break;
}
}
if (siteId != null) {
const resourcesWithSite = db
.select({ resourceId: targets.resourceId })
+94 -78
View File
@@ -9,7 +9,11 @@ import {
resourcePassword,
resourcePincode,
resourceRules,
resourceWhitelist
resourceWhitelist,
roleResources,
roles,
Transaction,
userResources
} from "@server/db";
import {
domains,
@@ -310,6 +314,61 @@ export async function updateResource(
}
}
async function clearResourceSpecificSettings(
resourceId: number,
orgId: string,
trx: Transaction | typeof db
) {
const adminRole = await db
.select()
.from(roles)
.where(and(eq(roles.isAdmin, true), eq(roles.orgId, orgId)))
.limit(1);
if (adminRole.length === 0) {
throw new Error(`Admin role not found for org ${orgId}`);
}
// remove the resource specific pincode, password, header auth, rules, nad whitelist entries so that the resource will fall back to the policy settings
await Promise.all([
trx
.delete(resourcePassword)
.where(eq(resourcePassword.resourceId, resourceId)),
trx
.delete(resourcePincode)
.where(eq(resourcePincode.resourceId, resourceId)),
trx
.delete(resourceHeaderAuth)
.where(eq(resourceHeaderAuth.resourceId, resourceId)),
trx
.delete(resourceHeaderAuthExtendedCompatibility)
.where(
eq(
resourceHeaderAuthExtendedCompatibility.resourceId,
resourceId
)
),
trx
.delete(resourceWhitelist)
.where(eq(resourceWhitelist.resourceId, resourceId)),
trx
.delete(resourceRules)
.where(eq(resourceRules.resourceId, resourceId)),
// delete the roles and the users as well
trx
.delete(userResources)
.where(eq(userResources.resourceId, resourceId)),
// except the admin role
trx
.delete(roleResources)
.where(
and(
eq(roleResources.resourceId, resourceId),
ne(roleResources.roleId, adminRole[0].roleId)
)
)
]);
}
async function updateHttpResource(
route: {
req: Request;
@@ -372,6 +431,15 @@ async function updateHttpResource(
}
}
// catch when the resource policy changes or gets cleared
if (resource.resourcePolicyId != updateData.resourcePolicyId) {
await clearResourceSpecificSettings(
resource.resourceId,
resource.orgId,
db
);
}
if (updateData.niceId) {
const [existingResource] = await db
.select()
@@ -560,9 +628,17 @@ async function updateHttpResource(
emailWhitelistEnabled,
applyRules,
skipToIdpId,
...resourceOnlyData
...resourceOnlyDataRest
} = updateData;
const resourceOnlyData = {
...resourceOnlyDataRest,
sso: null, // reset these because they are controlled by the inline policy
emailWhitelistEnabled: null,
applyRules: null,
skipToIdpId: null
};
const policyUpdate: Record<string, unknown> = {};
if (sso !== undefined) policyUpdate.sso = sso;
if (emailWhitelistEnabled !== undefined)
@@ -659,81 +735,6 @@ async function updateRawResource(
.limit(1);
await db.transaction(async (trx) => {
if (updateData.resourcePolicyId != null) {
const [existingPolicy] = await trx
.select()
.from(resourcePolicies)
.where(
eq(
resourcePolicies.resourcePolicyId,
updateData.resourcePolicyId
)
)
.limit(1);
if (!existingPolicy) {
return next(
createHttpError(
HttpCode.NOT_FOUND,
`Resource policy with ID ${updateData.resourcePolicyId} not found`
)
);
}
} else {
// we are in an inline policy and we need to clear out the old tables
await Promise.all([
trx
.delete(resourcePassword)
.where(
eq(
resourcePassword.resourceId,
existingResource.resourceId
)
),
trx
.delete(resourcePincode)
.where(
eq(
resourcePincode.resourceId,
existingResource.resourceId
)
),
trx
.delete(resourceHeaderAuth)
.where(
eq(
resourceHeaderAuth.resourceId,
existingResource.resourceId
)
),
trx
.delete(resourceHeaderAuthExtendedCompatibility)
.where(
eq(
resourceHeaderAuthExtendedCompatibility.resourceId,
existingResource.resourceId
)
),
trx
.delete(resourceWhitelist)
.where(
eq(
resourceWhitelist.resourceId,
existingResource.resourceId
)
),
trx
.delete(resourceRules)
.where(
eq(
resourceRules.resourceId,
existingResource.resourceId
)
)
]);
}
if (updateData.niceId) {
const [existingResourceConflict] = await trx
.select()
@@ -758,9 +759,24 @@ async function updateRawResource(
}
}
await clearResourceSpecificSettings(
resource.resourceId,
resource.orgId,
trx
); // none of these are supported on raw resources
// we should make sure sso, emailWhitelistEnabled, and applyRules are null because this is a raw resource
const realUpdateData = {
...updateData,
sso: null,
emailWhitelistEnabled: null,
applyRules: null,
skipToIdpId: null
};
[updatedResource] = await trx
.update(resources)
.set(updateData)
.set(realUpdateData)
.where(eq(resources.resourceId, resource.resourceId))
.returning();
});
+27
View File
@@ -304,6 +304,25 @@ export default async function migration() {
await db.execute(sql`
ALTER TABLE "resourceSessions" ADD CONSTRAINT "resourceSessions_policyWhitelistId_resourcePolicyWhitelist_id_fk" FOREIGN KEY ("policyWhitelistId") REFERENCES "public"."resourcePolicyWhitelist"("id") ON DELETE cascade ON UPDATE no action;
`);
// remove not null/default from sso, applyRules, and emailWhitelistEnabled in preparation for resource policies
await db.execute(
sql`ALTER TABLE "resources" ALTER COLUMN "sso" DROP NOT NULL;`
);
await db.execute(
sql`ALTER TABLE "resources" ALTER COLUMN "sso" DROP DEFAULT;`
);
await db.execute(
sql`ALTER TABLE "resources" ALTER COLUMN "applyRules" DROP NOT NULL;`
);
await db.execute(
sql`ALTER TABLE "resources" ALTER COLUMN "applyRules" DROP DEFAULT;`
);
await db.execute(
sql`ALTER TABLE "resources" ALTER COLUMN "emailWhitelistEnabled" DROP NOT NULL;`
);
await db.execute(
sql`ALTER TABLE "resources" ALTER COLUMN "emailWhitelistEnabled" DROP DEFAULT;`
);
await db.execute(sql`COMMIT`);
console.log("Migrated database");
@@ -600,6 +619,14 @@ export default async function migration() {
`);
}
// clear the sso, applyRules, and emailWhitelistEnabled columns on all resources since that information is now in the resource policies
await db.execute(sql`
UPDATE "resources"
SET "sso" = null,
"applyRules" = null,
"emailWhitelistEnabled" = null
`);
await db.execute(sql`COMMIT`);
console.log(
`Migrated inline resource policies for ${existingResources.length} resource(s)`
+19
View File
@@ -680,6 +680,25 @@ export default async function migration() {
deleteResourceRules.run(resource.resourceId);
deleteResourceWhitelist.run(resource.resourceId);
}
// remove not null/default from sso, applyRules, and emailWhitelistEnabled in preparation for resource policies
db.prepare(`ALTER TABLE 'resources' DROP COLUMN 'sso';`).run();
db.prepare(
`ALTER TABLE 'resources' ADD COLUMN 'sso' integer;`
).run();
db.prepare(
`ALTER TABLE 'resources' DROP COLUMN 'applyRules';`
).run();
db.prepare(
`ALTER TABLE 'resources' ADD COLUMN 'applyRules' integer;`
).run();
db.prepare(
`ALTER TABLE 'resources' DROP COLUMN 'emailWhitelistEnabled';`
).run();
db.prepare(
`ALTER TABLE 'resources' ADD COLUMN 'emailWhitelistEnabled' integer;`
).run();
});
migrateInlinePolicies();
+1 -5
View File
@@ -373,11 +373,7 @@ export default function RdpClient({
<PoweredByPangolin />
<Card className="w-full">
<CardHeader>
<CardTitle>
{resourceName
? `${t("rdpSignInTitle")} - ${resourceName}`
: t("rdpSignInTitle")}
</CardTitle>
<CardTitle>{t("rdpSignInTitle")}</CardTitle>
<CardDescription>
{resourceName
? `${t("rdpSignInDescription")} (${resourceName})`
+1 -5
View File
@@ -510,11 +510,7 @@ export default function SshClient({
<PoweredByPangolin />
<Card className="w-full">
<CardHeader>
<CardTitle>
{resourceName
? `${t("sshSignInTitle")} - ${resourceName}`
: t("sshSignInTitle")}
</CardTitle>
<CardTitle>{t("sshSignInTitle")}</CardTitle>
<CardDescription>
{resourceName
? `${t("sshSignInDescription")} (${resourceName})`
+1 -5
View File
@@ -252,11 +252,7 @@ export default function VncClient({
<PoweredByPangolin />
<Card className="w-full">
<CardHeader>
<CardTitle>
{resourceName
? `${t("vncTitle")} - ${resourceName}`
: t("vncTitle")}
</CardTitle>
<CardTitle>{t("vncTitle")}</CardTitle>
<CardDescription>
{resourceName
? `${t("vncSignInDescription")} (${resourceName})`
+44 -1
View File
@@ -278,7 +278,50 @@ export default function PublicResourcesTable({
accessorKey: "protocol",
friendlyName: t("protocol"),
enableHiding: true,
header: () => <span className="p-3">{t("protocol")}</span>,
header: () => (
<ColumnFilterButton
options={[
{
value: "http",
label: t("editInternalResourceDialogModeHttp")
},
{
value: "https",
label: t("editInternalResourceDialogModeHttps")
},
{
value: "tcp",
label: t("editInternalResourceDialogTcp")
},
{
value: "udp",
label: t("editInternalResourceDialogUdp")
},
{
value: "ssh",
label: t("editInternalResourceDialogModeSsh")
},
{
value: "rdp",
label: t("rdpTitle")
},
{
value: "vnc",
label: t("vncTitle")
}
]}
selectedValue={
searchParams.get("protocol") ?? undefined
}
onValueChange={(value) =>
handleFilterChange("protocol", value)
}
searchPlaceholder={t("searchPlaceholder")}
emptyMessage={t("emptySearchOptions")}
label={t("protocol")}
className="p-3"
/>
),
cell: ({ row }) => {
const resourceRow = row.original;
return (
@@ -42,16 +42,16 @@ export function MultiSelectTagInput<T extends TagValue>({
buttonVariants({
variant: "outline"
}),
"justify-between w-full inline-flex",
"text-muted-foreground pl-1.5 cursor-text h-9 py-0",
"justify-between w-full flex items-center",
"text-muted-foreground pl-1.5 cursor-text h-auto min-h-9 py-1.5",
"whitespace-normal",
"hover:bg-transparent hover:text-muted-foreground",
props.disabled && "pointer-events-none opacity-50"
)}
>
<span
className={cn(
"inline-flex items-center gap-1 min-w-0 flex-1",
"overflow-x-auto flex-nowrap h-full"
"flex items-center gap-1 min-w-0 flex-1 flex-wrap"
)}
>
{props.value.map((option) => {
@@ -60,15 +60,13 @@ export function MultiSelectTagInput<T extends TagValue>({
<span
key={option.id}
className={cn(
"bg-muted-foreground/10 font-normal text-foreground rounded-sm flex-none",
"py-0.5 pl-1.5 pr-0.5 text-xs inline-flex items-center gap-0.5",
"bg-muted-foreground/10 font-normal text-foreground rounded-sm shrink-0",
"py-0.5 pl-1.5 pr-0.5 text-xs inline-flex items-center gap-0.5 whitespace-nowrap",
isLocked && "opacity-60"
)}
onClick={(e) => e.stopPropagation()}
>
<span className="max-w-40 text-ellipsis overflow-hidden">
{option.text}
</span>
<span>{option.text}</span>
{isLocked ? (
<span className="p-0.5 flex-none">
<LockIcon className="size-3" />