mirror of
https://github.com/fosrl/pangolin.git
synced 2026-08-23 12:40:25 +02:00
Compare commits
4 Commits
42974d1739
...
407ba567a0
| Author | SHA1 | Date | |
|---|---|---|---|
| 407ba567a0 | |||
| f28571629f | |||
| 5a575c916b | |||
| 9a7e534b10 |
@@ -79,7 +79,10 @@ import logger from "@server/logger";
|
||||
import { decrypt } from "@server/lib/crypto";
|
||||
import config from "@server/lib/config";
|
||||
import { exchangeSession } from "@server/routers/badger";
|
||||
import { validateResourceSessionToken } from "@server/auth/sessions/resource";
|
||||
import {
|
||||
ResourceSessionValidationResult,
|
||||
validateResourceSessionToken
|
||||
} from "@server/auth/sessions/resource";
|
||||
import { checkExitNodeOrg, resolveExitNodes } from "#private/lib/exitNodes";
|
||||
import { maxmindLookup } from "@server/db/maxmind";
|
||||
import { verifyResourceAccessToken } from "@server/auth/verifyResourceAccessToken";
|
||||
@@ -1754,11 +1757,34 @@ hybridRouter.post(
|
||||
resourceId
|
||||
);
|
||||
|
||||
// this is for backward compatibility with nodes that did not have the policy id checking
|
||||
const modifiedResult: ResourceSessionValidationResult = {
|
||||
...result,
|
||||
resourceSession: result.resourceSession
|
||||
? {
|
||||
...result.resourceSession,
|
||||
// Prefer policy IDs, but keep legacy IDs populated for older nodes.
|
||||
pincodeId:
|
||||
result.resourceSession.policyPincodeId ??
|
||||
result.resourceSession.pincodeId ??
|
||||
null,
|
||||
passwordId:
|
||||
result.resourceSession.policyPasswordId ??
|
||||
result.resourceSession.passwordId ??
|
||||
null,
|
||||
whitelistId:
|
||||
result.resourceSession.policyWhitelistId ??
|
||||
result.resourceSession.whitelistId ??
|
||||
null
|
||||
}
|
||||
: null
|
||||
};
|
||||
|
||||
return response(res, {
|
||||
data: result,
|
||||
data: modifiedResult,
|
||||
success: true,
|
||||
error: false,
|
||||
message: result.resourceSession
|
||||
message: modifiedResult.resourceSession
|
||||
? "Resource session token is valid"
|
||||
: "Resource session token is invalid or expired",
|
||||
status: HttpCode.OK
|
||||
|
||||
@@ -20,7 +20,8 @@ import {
|
||||
ResourcePolicyPincode,
|
||||
ResourcePolicyPassword,
|
||||
ResourcePolicyHeaderAuth,
|
||||
ResourceRule
|
||||
ResourceRule,
|
||||
ResourceSession
|
||||
} from "@server/db";
|
||||
import config from "@server/lib/config";
|
||||
import { isIpInCidr, stripPortFromHost } from "@server/lib/ip";
|
||||
@@ -536,7 +537,8 @@ export async function verifyResourceSession(
|
||||
|
||||
if (resourceSessionToken) {
|
||||
const sessionCacheKey = `session:${resourceSessionToken}`;
|
||||
let resourceSession: any = localCache.get(sessionCacheKey);
|
||||
let resourceSession: ResourceSession | null | undefined =
|
||||
localCache.get(sessionCacheKey);
|
||||
|
||||
if (!resourceSession) {
|
||||
const result = await validateResourceSessionToken(
|
||||
@@ -671,7 +673,7 @@ export async function verifyResourceSession(
|
||||
orgId: resource.orgId,
|
||||
location: ipCC,
|
||||
apiKey: {
|
||||
name: resourceSession.accessTokenTitle,
|
||||
name: null,
|
||||
apiKeyId: resourceSession.accessTokenId
|
||||
}
|
||||
},
|
||||
@@ -717,7 +719,7 @@ export async function verifyResourceSession(
|
||||
location: ipCC,
|
||||
user: {
|
||||
username: allowedUserData.username,
|
||||
userId: resourceSession.userId
|
||||
userId: allowedUserData.userId
|
||||
}
|
||||
},
|
||||
parsedBody.data
|
||||
|
||||
@@ -197,6 +197,11 @@ export default async function migration() {
|
||||
await db.execute(
|
||||
sql`ALTER TABLE "siteResources" ADD COLUMN "pamMode" varchar(32) DEFAULT 'passthrough';`
|
||||
);
|
||||
await db.execute(sql`
|
||||
UPDATE "siteResources"
|
||||
SET "pamMode" = 'push'
|
||||
WHERE LOWER(COALESCE("mode", '')) = 'host';
|
||||
`);
|
||||
await db.execute(
|
||||
sql`ALTER TABLE "sites" ADD COLUMN "autoUpdateEnabled" boolean DEFAULT false NOT NULL;`
|
||||
);
|
||||
|
||||
@@ -247,6 +247,13 @@ export default async function migration() {
|
||||
ALTER TABLE 'siteResources' ADD COLUMN 'pamMode' text DEFAULT 'passthrough';
|
||||
`
|
||||
).run();
|
||||
db.prepare(
|
||||
`
|
||||
UPDATE 'siteResources'
|
||||
SET "pamMode" = 'push'
|
||||
WHERE LOWER(COALESCE("mode", '')) = 'host';
|
||||
`
|
||||
).run();
|
||||
db.prepare(
|
||||
`
|
||||
|
||||
|
||||
@@ -108,10 +108,7 @@ export default async function ClientResourcesPage(
|
||||
siteNiceId: siteResource.siteNiceIds[idx],
|
||||
online: siteResource.siteOnlines[idx]
|
||||
})),
|
||||
mode:
|
||||
siteResource.pamMode && siteResource.mode === "host"
|
||||
? "ssh"
|
||||
: siteResource.mode,
|
||||
mode: siteResource.mode,
|
||||
scheme: siteResource.scheme,
|
||||
ssl: siteResource.ssl,
|
||||
siteNames: siteResource.siteNames,
|
||||
|
||||
@@ -41,6 +41,7 @@ import {
|
||||
FormMessage
|
||||
} from "@app/components/ui/form";
|
||||
import { Input } from "@app/components/ui/input";
|
||||
import { Label } from "@app/components/ui/label";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
@@ -1172,52 +1173,55 @@ export default function Page() {
|
||||
|
||||
{isNative ? (
|
||||
<SettingsFormCell span="half">
|
||||
<Popover
|
||||
open={
|
||||
nativeSiteOpen
|
||||
}
|
||||
onOpenChange={
|
||||
setNativeSiteOpen
|
||||
}
|
||||
>
|
||||
<PopoverTrigger
|
||||
asChild
|
||||
<div className="grid gap-2">
|
||||
<Label>{t("sites")}</Label>
|
||||
<Popover
|
||||
open={
|
||||
nativeSiteOpen
|
||||
}
|
||||
onOpenChange={
|
||||
setNativeSiteOpen
|
||||
}
|
||||
>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
className="w-full justify-between font-normal"
|
||||
<PopoverTrigger
|
||||
asChild
|
||||
>
|
||||
<span className="truncate">
|
||||
{nativeSelectedSite?.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 as string
|
||||
}
|
||||
selectedSite={
|
||||
nativeSelectedSite
|
||||
}
|
||||
onSelectSite={(
|
||||
site
|
||||
) => {
|
||||
setNativeSelectedSite(
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
className="w-full justify-between font-normal"
|
||||
>
|
||||
<span className="truncate">
|
||||
{nativeSelectedSite?.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 as string
|
||||
}
|
||||
selectedSite={
|
||||
nativeSelectedSite
|
||||
}
|
||||
onSelectSite={(
|
||||
site
|
||||
);
|
||||
setNativeSiteOpen(
|
||||
false
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
) => {
|
||||
setNativeSelectedSite(
|
||||
site
|
||||
);
|
||||
setNativeSiteOpen(
|
||||
false
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
</SettingsFormCell>
|
||||
) : standardDaemonLocation !==
|
||||
"site" ||
|
||||
|
||||
@@ -122,6 +122,9 @@ export default function SshClient({
|
||||
const [connected, setConnected] = useState(false);
|
||||
const [connecting, setConnecting] = useState(false);
|
||||
const [connectError, setConnectError] = useState<string | null>(null);
|
||||
const [sessionClosedCode, setSessionClosedCode] = useState<number | null>(
|
||||
null
|
||||
);
|
||||
|
||||
const terminalRef = useRef<HTMLDivElement>(null);
|
||||
const xtermRef = useRef<import("@xterm/xterm").Terminal | null>(null);
|
||||
@@ -222,6 +225,8 @@ export default function SshClient({
|
||||
authMethod: AuthTab = "password"
|
||||
) {
|
||||
setConnecting(true);
|
||||
setSessionClosedCode(null);
|
||||
setConnectError(null);
|
||||
|
||||
if (!target) {
|
||||
setConnectError(t("sshErrorNoTarget"));
|
||||
@@ -257,8 +262,10 @@ export default function SshClient({
|
||||
|
||||
let authConfirmed = false;
|
||||
let authErrorShown = false;
|
||||
let socketOpened = false;
|
||||
|
||||
ws.onopen = () => {
|
||||
socketOpened = true;
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: "auth",
|
||||
@@ -331,13 +338,18 @@ export default function SshClient({
|
||||
};
|
||||
|
||||
ws.onclose = (evt) => {
|
||||
wsRef.current = null;
|
||||
setConnecting(false);
|
||||
const isCleanClose = evt.wasClean || evt.code === 1000;
|
||||
if (isCleanClose && (authConfirmed || socketOpened)) {
|
||||
xtermRef.current?.dispose();
|
||||
xtermRef.current = null;
|
||||
setConnected(false);
|
||||
setSessionClosedCode(evt.code);
|
||||
return;
|
||||
}
|
||||
if (authConfirmed) {
|
||||
setConnected(false);
|
||||
if (evt.wasClean || evt.code === 1000) {
|
||||
window.close();
|
||||
return;
|
||||
}
|
||||
xtermRef.current?.writeln(
|
||||
`\r\n\x1b[33m${t("sshConnectionClosedCode", { code: evt.code })}\x1b[0m\r\n`
|
||||
);
|
||||
@@ -457,6 +469,40 @@ export default function SshClient({
|
||||
);
|
||||
}
|
||||
|
||||
if (sessionClosedCode !== null) {
|
||||
return (
|
||||
<BrandedAuthSurface primaryColor={primaryColor}>
|
||||
<PoweredByPangolin />
|
||||
<Card className="w-full max-w-md">
|
||||
<CardHeader>
|
||||
<CardTitle>{t("sshTitle")}</CardTitle>
|
||||
<CardDescription>
|
||||
{t("sshConnectionClosedCode", {
|
||||
code: sessionClosedCode
|
||||
})}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<Alert>
|
||||
<AlertDescription>
|
||||
This session has ended. You can close this tab
|
||||
now.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
<Button
|
||||
type="button"
|
||||
className="w-full"
|
||||
onClick={() => window.close()}
|
||||
>
|
||||
{t("close")}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<AuthPageFooterNotices />
|
||||
</BrandedAuthSurface>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{!connected && (
|
||||
|
||||
@@ -91,7 +91,7 @@ export default function AuthPageBrandingForm({
|
||||
orgSubtitle: branding?.orgSubtitle ?? `Log in to {{orgName}}`,
|
||||
resourceTitle:
|
||||
branding?.resourceTitle ??
|
||||
`Authenticate to access {{resourceName}}`,
|
||||
`Authenticate to Access {{resourceName}}`,
|
||||
resourceSubtitle:
|
||||
branding?.resourceSubtitle ??
|
||||
`Choose your preferred authentication method for {{resourceName}}`,
|
||||
|
||||
@@ -12,11 +12,9 @@ import {
|
||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||
import { useOrgContext } from "@app/hooks/useOrgContext";
|
||||
import { usePaidStatus } from "@app/hooks/usePaidStatus";
|
||||
import { getUserDisplayName } from "@app/lib/getUserDisplayName";
|
||||
import { orgQueries } from "@app/lib/queries";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { build } from "@server/build";
|
||||
import { UserType } from "@server/types/UserTypes";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useTranslations } from "next-intl";
|
||||
import {
|
||||
@@ -67,12 +65,6 @@ export function CreatePolicyForm({}: CreatePolicyFormProps) {
|
||||
env.server.maxmind_asn_path && env.server.maxmind_asn_path.length > 0
|
||||
);
|
||||
|
||||
const { data: orgRoles = [], isLoading: isLoadingOrgRoles } = useQuery(
|
||||
orgQueries.roles({ orgId: org.org.orgId })
|
||||
);
|
||||
const { data: orgUsers = [], isLoading: isLoadingOrgUsers } = useQuery(
|
||||
orgQueries.users({ orgId: org.org.orgId })
|
||||
);
|
||||
const { data: orgIdps = [], isLoading: isLoadingOrgIdps } = useQuery(
|
||||
orgQueries.identityProviders({
|
||||
orgId: org.org.orgId,
|
||||
@@ -163,26 +155,6 @@ export function CreatePolicyForm({}: CreatePolicyFormProps) {
|
||||
}
|
||||
}
|
||||
|
||||
const allRoles = useMemo(
|
||||
() =>
|
||||
orgRoles
|
||||
.map((role) => ({
|
||||
id: role.roleId.toString(),
|
||||
text: role.name
|
||||
}))
|
||||
.filter((role) => role.text !== "Admin"),
|
||||
[orgRoles]
|
||||
);
|
||||
|
||||
const allUsers = useMemo(
|
||||
() =>
|
||||
orgUsers.map((user) => ({
|
||||
id: user.id.toString(),
|
||||
text: `${getUserDisplayName({ email: user.email, username: user.username })}${user.type !== UserType.Internal ? ` (${user.idpName})` : ""}`
|
||||
})),
|
||||
[orgUsers]
|
||||
);
|
||||
|
||||
const allIdps = useMemo(() => {
|
||||
if (build === "saas") {
|
||||
if (isPaidUser(tierMatrix.orgOidc)) {
|
||||
@@ -197,7 +169,7 @@ export function CreatePolicyForm({}: CreatePolicyFormProps) {
|
||||
return [];
|
||||
}, [orgIdps, isPaidUser]);
|
||||
|
||||
if (isLoadingOrgRoles || isLoadingOrgUsers || isLoadingOrgIdps) {
|
||||
if (isLoadingOrgIdps) {
|
||||
return <></>;
|
||||
}
|
||||
|
||||
@@ -252,8 +224,6 @@ export function CreatePolicyForm({}: CreatePolicyFormProps) {
|
||||
form={form}
|
||||
orgId={org.org.orgId}
|
||||
allIdps={allIdps}
|
||||
allRoles={allRoles}
|
||||
allUsers={allUsers}
|
||||
emailEnabled={env.email.emailEnabled}
|
||||
/>
|
||||
<PolicyAccessRulesSection
|
||||
|
||||
@@ -19,8 +19,6 @@ type PolicyAuthStackSectionCreateProps = {
|
||||
form: UseFormReturn<PolicyFormValues, any, any>;
|
||||
orgId: string;
|
||||
allIdps: { id: number; text: string }[];
|
||||
allRoles: { id: string; text: string }[];
|
||||
allUsers: { id: string; text: string }[];
|
||||
emailEnabled: boolean;
|
||||
};
|
||||
|
||||
|
||||
@@ -12,7 +12,8 @@ import {
|
||||
SettingsSubsectionTitle,
|
||||
SettingsSectionTitle
|
||||
} from "@app/components/Settings";
|
||||
import { TagInput } from "@app/components/tags/tag-input";
|
||||
import { RolesSelector } from "@app/components/roles-selector";
|
||||
import { UsersSelector } from "@app/components/users-selector";
|
||||
import { FormField } from "@app/components/ui/form";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useState } from "react";
|
||||
@@ -38,27 +39,18 @@ export type PolicyAuthStackSectionCreateProps = {
|
||||
form: UseFormReturn<PolicyFormValues, any, any>;
|
||||
orgId: string;
|
||||
allIdps: { id: number; text: string }[];
|
||||
allRoles: { id: string; text: string }[];
|
||||
allUsers: { id: string; text: string }[];
|
||||
emailEnabled: boolean;
|
||||
};
|
||||
|
||||
export function PolicyAuthStackSectionCreate({
|
||||
form: parentForm,
|
||||
orgId,
|
||||
allIdps,
|
||||
allRoles,
|
||||
allUsers,
|
||||
emailEnabled
|
||||
}: PolicyAuthStackSectionCreateProps) {
|
||||
const t = useTranslations();
|
||||
const [editingMethod, setEditingMethod] =
|
||||
useState<PolicyAuthMethodId | null>(null);
|
||||
const [activeRolesTagIndex, setActiveRolesTagIndex] = useState<
|
||||
number | null
|
||||
>(null);
|
||||
const [activeUsersTagIndex, setActiveUsersTagIndex] = useState<
|
||||
number | null
|
||||
>(null);
|
||||
|
||||
const sso = useWatch({ control: parentForm.control, name: "sso" });
|
||||
const skipToIdpId = useWatch({
|
||||
@@ -126,47 +118,38 @@ export function PolicyAuthStackSectionCreate({
|
||||
}
|
||||
allIdps={allIdps}
|
||||
rolesEditor={
|
||||
<FormField<PolicyFormValues, "roles">
|
||||
<FormField
|
||||
control={parentForm.control}
|
||||
name="roles"
|
||||
render={({ field }) => (
|
||||
<TagInput
|
||||
{...field}
|
||||
activeTagIndex={activeRolesTagIndex}
|
||||
setActiveTagIndex={
|
||||
setActiveRolesTagIndex
|
||||
<RolesSelector
|
||||
orgId={orgId}
|
||||
selectedRoles={field.value}
|
||||
onSelectRoles={(selected) =>
|
||||
parentForm.setValue(
|
||||
"roles",
|
||||
selected
|
||||
)
|
||||
}
|
||||
placeholder={t("accessRoleSelect2")}
|
||||
tags={field.value ?? []}
|
||||
setTags={(newRoles) =>
|
||||
field.onChange(newRoles)
|
||||
}
|
||||
autocompleteOptions={allRoles}
|
||||
allowDuplicates={false}
|
||||
size="sm"
|
||||
restrictAdminRole
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
}
|
||||
usersEditor={
|
||||
<FormField<PolicyFormValues, "users">
|
||||
<FormField
|
||||
control={parentForm.control}
|
||||
name="users"
|
||||
render={({ field }) => (
|
||||
<TagInput
|
||||
{...field}
|
||||
activeTagIndex={activeUsersTagIndex}
|
||||
setActiveTagIndex={
|
||||
setActiveUsersTagIndex
|
||||
<UsersSelector
|
||||
orgId={orgId}
|
||||
selectedUsers={field.value}
|
||||
onSelectUsers={(selected) =>
|
||||
parentForm.setValue(
|
||||
"users",
|
||||
selected
|
||||
)
|
||||
}
|
||||
placeholder={t("accessUserSelect")}
|
||||
tags={field.value ?? []}
|
||||
setTags={(newUsers) =>
|
||||
field.onChange(newUsers)
|
||||
}
|
||||
autocompleteOptions={allUsers}
|
||||
allowDuplicates={false}
|
||||
size="sm"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
@@ -5,7 +5,7 @@ import { cva, type VariantProps } from "class-variance-authority";
|
||||
import { cn } from "@app/lib/cn";
|
||||
|
||||
const buttonVariants = cva(
|
||||
"cursor-pointer inline-flex items-center justify-center whitespace-nowrap text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-0 disabled:pointer-events-none disabled:opacity-50",
|
||||
"cursor-pointer inline-flex items-center justify-center whitespace-nowrap text-sm font-normal ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-0 disabled:pointer-events-none disabled:opacity-50",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
|
||||
@@ -351,10 +351,6 @@ export function ControlledDataTable<TData, TValue>({
|
||||
dataTableFilterDropdownContentClassName
|
||||
}
|
||||
>
|
||||
<DropdownMenuLabel>
|
||||
{filter.label}
|
||||
</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
{filter.options.map(
|
||||
(option) => {
|
||||
const isChecked =
|
||||
@@ -484,13 +480,6 @@ export function ControlledDataTable<TData, TValue>({
|
||||
align="end"
|
||||
className="w-48"
|
||||
>
|
||||
<DropdownMenuLabel>
|
||||
{t(
|
||||
"toggleColumns"
|
||||
) ||
|
||||
"Toggle columns"}
|
||||
</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
{table
|
||||
.getAllColumns()
|
||||
.filter(
|
||||
|
||||
@@ -91,7 +91,7 @@ const TableHead = React.forwardRef<
|
||||
<th
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"h-10 text-left align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0",
|
||||
"h-10 text-left align-middle font-medium text-muted-foreground [&_button]:font-medium [&:has([role=checkbox])]:pr-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
|
||||
Reference in New Issue
Block a user