Merge pull request #3457 from fosrl/dev

1.21.0
This commit is contained in:
Milo Schwartz
2026-07-17 17:17:35 -04:00
committed by GitHub
34 changed files with 1419 additions and 257 deletions
+1 -1
View File
@@ -76,7 +76,7 @@ var redisFlag *bool
func main() {
crowdsecFlag := flag.Bool("crowdsec", false, "Enable the CrowdSec installation prompt")
redisFlag = flag.Bool("redis", false, "Install Redis as cacheing solution. Required for HA. Not required for the Enterprise version.")
redisFlag = flag.Bool("redis", false, "Install Redis as caching solution. Required for HA. Not required for the Enterprise version.")
flag.Parse()
// print a banner about prerequisites - opening port 80, 443, 51820, and 21820 on the VPS and firewall and pointing your domain to the VPS IP with a records. Docs are at http://localhost:3000/Getting%20Started/dns-networking
+9 -3
View File
@@ -178,7 +178,7 @@
"shareDeleteConfirm": "Confirm Delete Shareable Link",
"shareQuestionRemove": "Are you sure you want to delete this share link?",
"shareMessageRemove": "Once deleted, the link will no longer work and anyone using it will lose access to the resource.",
"shareTokenDescription": "The access token can be passed in two ways: as a query parameter or in the request headers. These must be passed from the client on every request for authenticated access.",
"shareTokenDescription": "The access token can be passed as a query parameter or in request headers. By default it must be sent on every request. If session persistence is enabled, the first request exchanges it for a session cookie.",
"accessToken": "Access Token",
"usageExamples": "Usage Examples",
"tokenId": "Token ID",
@@ -196,8 +196,14 @@
"shareTitleOptional": "Title (optional)",
"sharePathOptional": "Path (optional)",
"sharePathDescription": "The link will redirect users to this path after authentication.",
"shareAssociateUserOptional": "Associate User (optional)",
"shareAssociateUserDescription": "When set, requests using this link are attributed to the user in access logs and identity headers. The link is removed if the user leaves the organization.",
"userSelect": "Select user",
"usersNotFound": "No users found",
"expireIn": "Expire In",
"neverExpire": "Never expire",
"sharePersistSession": "Persist session after first use",
"sharePersistSessionDescription": "When enabled, the first request with this token via a query param or header sets a session cookie so later requests do not need the token. Leave off for API clients that should send the token on every request.",
"shareExpireDescription": "Expiration time is how long the link will be usable and provide access to the resource. After this time, the link will no longer work, and users who used this link will lose access to the resource.",
"shareSeeOnce": "You will only be able to see this link once. Make sure to copy it.",
"shareAccessHint": "Anyone with this link can access the resource. Share it with care.",
@@ -3089,8 +3095,8 @@
"sourceAddress": "Source Address",
"destinationAddress": "Destination Address",
"duration": "Duration",
"licenseRequiredToUse": "An <enterpriseLicenseLink>Enterprise Edition</enterpriseLicenseLink> license or <pangolinCloudLink>Pangolin Cloud</pangolinCloudLink> is required to use this feature. <bookADemoLink>Book a free demo or POC trial to learn more</bookADemoLink>.",
"ossEnterpriseEditionRequired": "The <enterpriseEditionLink>Enterprise Edition</enterpriseEditionLink> is required to use this feature. This feature is also available in <pangolinCloudLink>Pangolin Cloud</pangolinCloudLink>. <bookADemoLink>Book a free demo or POC trial to learn more</bookADemoLink>.",
"licenseRequiredToUse": "An <enterpriseLicenseLink>Enterprise Edition</enterpriseLicenseLink> license or <pangolinCloudLink>Pangolin Cloud</pangolinCloudLink> is required to use this feature. <bookADemoLink>Book a free demo or POC trial to learn more.</bookADemoLink>",
"ossEnterpriseEditionRequired": "The <enterpriseEditionLink>Enterprise Edition</enterpriseEditionLink> is required to use this feature. This feature is also available in <pangolinCloudLink>Pangolin Cloud</pangolinCloudLink>. <bookADemoLink>Book a free demo or POC trial to learn more.</bookADemoLink>",
"certResolver": "Certificate Resolver",
"certResolverDescription": "Select the certificate resolver to use for this resource.",
"selectCertResolver": "Select Certificate Resolver",
+5
View File
@@ -107,6 +107,7 @@ export const sites = pgTable(
lastPing: integer("lastPing"),
address: varchar("address"),
endpoint: varchar("endpoint"),
localEndpoints: varchar("localEndpoints"), // JSON encoded list of string ips on the local machine to try to connect to
publicKey: varchar("publicKey"),
lastHolePunch: bigint("lastHolePunch", { mode: "number" }),
listenPort: integer("listenPort"),
@@ -905,12 +906,16 @@ export const resourceAccessToken = pgTable("resourceAccessToken", {
resourceId: integer("resourceId")
.notNull()
.references(() => resources.resourceId, { onDelete: "cascade" }),
userId: varchar("userId").references(() => users.userId, {
onDelete: "cascade"
}),
path: varchar("path"),
tokenHash: varchar("tokenHash").notNull(),
sessionLength: bigint("sessionLength", { mode: "number" }).notNull(),
expiresAt: bigint("expiresAt", { mode: "number" }),
title: varchar("title"),
description: varchar("description"),
persistSession: boolean("persistSession").notNull().default(false),
createdAt: bigint("createdAt", { mode: "number" }).notNull()
});
+7
View File
@@ -118,6 +118,7 @@ export const sites = sqliteTable("sites", {
// exit node stuff that is how to connect to the site when it has a wg server
address: text("address"), // this is the address of the wireguard interface in newt
endpoint: text("endpoint"), // this is how to reach gerbil externally - gets put into the wireguard config
localEndpoints: text("localEndpoints"), // JSON encoded list of string ips on the local machine to try to connect to
publicKey: text("publicKey"), // TODO: Fix typo in publicKey
lastHolePunch: integer("lastHolePunch"),
listenPort: integer("listenPort"),
@@ -1125,12 +1126,18 @@ export const resourceAccessToken = sqliteTable("resourceAccessToken", {
resourceId: integer("resourceId")
.notNull()
.references(() => resources.resourceId, { onDelete: "cascade" }),
userId: text("userId").references(() => users.userId, {
onDelete: "cascade"
}),
path: text("path"),
tokenHash: text("tokenHash").notNull(),
sessionLength: integer("sessionLength").notNull(),
expiresAt: integer("expiresAt"),
title: text("title"),
description: text("description"),
persistSession: integer("persistSession", { mode: "boolean" })
.notNull()
.default(false),
createdAt: integer("createdAt").notNull()
});
+1 -1
View File
@@ -2,7 +2,7 @@ import path from "path";
import { fileURLToPath } from "url";
// This is a placeholder value replaced by the build process
export const APP_VERSION = "1.20.0";
export const APP_VERSION = "1.21.0";
export const __FILENAME = fileURLToPath(import.meta.url);
export const __DIRNAME = path.dirname(__FILENAME);
+10
View File
@@ -2,6 +2,7 @@ import {
db,
Org,
orgs,
resourceAccessToken,
resources,
siteResources,
sites,
@@ -83,6 +84,15 @@ export async function removeUserFromOrg(
.delete(userOrgs)
.where(and(eq(userOrgs.userId, userId), eq(userOrgs.orgId, org.orgId)));
await trx
.delete(resourceAccessToken)
.where(
and(
eq(resourceAccessToken.userId, userId),
eq(resourceAccessToken.orgId, org.orgId)
)
);
await trx.delete(userResources).where(
and(
eq(userResources.userId, userId),
+317 -2
View File
@@ -58,7 +58,8 @@ import {
resourceRules,
resourcePolicyRules,
userOrgRoles,
roles
roles,
resourceAccessToken
} from "@server/db";
import { eq, and, inArray, isNotNull, isNull, ne, or, sql } from "drizzle-orm";
import { alias } from "@server/db";
@@ -81,11 +82,16 @@ import config from "@server/lib/config";
import { exchangeSession } from "@server/routers/badger";
import {
ResourceSessionValidationResult,
createResourceSession,
serializeResourceSessionCookie,
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";
import { generateSessionToken } from "@server/auth/sessions/app";
import { logAccessAudit } from "#private/lib/logAccessAudit";
import { getUserOrgRoles } from "@server/lib/userOrgRoles";
import semver from "semver";
import { maxmindAsnLookup } from "@server/db/maxmindAsn";
import { checkOrgAccessPolicy } from "@server/lib/checkOrgAccessPolicy";
@@ -178,6 +184,73 @@ const validateResourceAccessTokenBodySchema = z.strictObject({
accessToken: z.string()
});
const createAccessTokenSessionParamsSchema = z.strictObject({
resourceId: z.coerce.number().int().positive()
});
const createAccessTokenSessionBodySchema = z.strictObject({
accessTokenId: z.string().min(1)
});
const getAccessTokenParamsSchema = z.strictObject({
accessTokenId: z.string().min(1)
});
const logAccessAuditBodySchema = z.strictObject({
action: z.boolean(),
type: z.string(),
orgId: z.string(),
resourceId: z.number().optional(),
siteResourceId: z.number().optional(),
user: z
.object({
username: z.string(),
userId: z.string()
})
.optional(),
apiKey: z
.object({
name: z.string().nullable(),
apiKeyId: z.string()
})
.optional(),
metadata: z.any().optional(),
userAgent: z.string().optional(),
requestIp: z.string().optional()
});
type AccessTokenUserData = {
userId: string;
username: string;
email: string | null;
name: string | null;
role: string | null;
};
async function resolveAccessTokenUserData(
userId: string,
orgId: string
): Promise<AccessTokenUserData | undefined> {
const [user] = await db
.select()
.from(users)
.where(eq(users.userId, userId))
.limit(1);
if (!user) {
return undefined;
}
const userOrgRoles = await getUserOrgRoles(user.userId, orgId);
return {
userId: user.userId,
username: user.username,
email: user.email,
name: user.name,
role: userOrgRoles.map((r) => r.roleName).join(", ") || null
};
}
// Certificates by domains query validation
const getCertificatesByDomainsQuerySchema = z.strictObject({
// Accept domains as string or array (domains or domains[])
@@ -1829,8 +1902,23 @@ hybridRouter.post(
resourceId
});
let userData: AccessTokenUserData | undefined;
if (
result.valid &&
result.tokenItem?.userId &&
result.tokenItem.orgId
) {
userData = await resolveAccessTokenUserData(
result.tokenItem.userId,
result.tokenItem.orgId
);
}
return response(res, {
data: result,
data: {
...result,
userData
},
success: true,
error: false,
message: result.valid
@@ -1850,6 +1938,233 @@ hybridRouter.post(
}
);
// Create a resource session from a valid access token (for remote nodes)
hybridRouter.post(
"/resource/:resourceId/session/create-access-token",
async (req: Request, res: Response, next: NextFunction) => {
try {
const parsedParams = createAccessTokenSessionParamsSchema.safeParse(
req.params
);
if (!parsedParams.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedParams.error).toString()
)
);
}
const parsedBody = createAccessTokenSessionBodySchema.safeParse(
req.body
);
if (!parsedBody.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedBody.error).toString()
)
);
}
const { resourceId } = parsedParams.data;
const { accessTokenId } = parsedBody.data;
const [tokenItem] = await db
.select()
.from(resourceAccessToken)
.where(eq(resourceAccessToken.accessTokenId, accessTokenId))
.limit(1);
if (!tokenItem || tokenItem.resourceId !== resourceId) {
return next(
createHttpError(
HttpCode.NOT_FOUND,
"Access token not found"
)
);
}
if (!tokenItem.persistSession) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
"Access token does not allow session persistence"
)
);
}
const [resource] = await db
.select()
.from(resources)
.where(eq(resources.resourceId, resourceId))
.limit(1);
if (!resource || !resource.fullDomain) {
return next(
createHttpError(HttpCode.NOT_FOUND, "Resource not found")
);
}
const token = generateSessionToken();
const sess = await createResourceSession({
resourceId: resource.resourceId,
token,
accessTokenId: tokenItem.accessTokenId,
sessionLength: tokenItem.sessionLength,
expiresAt: tokenItem.expiresAt,
doNotExtend: tokenItem.expiresAt ? true : false
});
const cookieName = config.getRawConfig().server.session_cookie_name;
const cookie = serializeResourceSessionCookie(
cookieName,
resource.fullDomain,
token,
!resource.ssl,
new Date(sess.expiresAt)
);
return response(res, {
data: { cookie },
success: true,
error: false,
message: "Access token session created successfully",
status: HttpCode.OK
});
} catch (error) {
logger.error(error);
return next(
createHttpError(
HttpCode.INTERNAL_SERVER_ERROR,
"Failed to create access token session"
)
);
}
}
);
// Resolve access token metadata for remote nodes (cookie session path)
hybridRouter.get(
"/resource/access-token/:accessTokenId",
async (req: Request, res: Response, next: NextFunction) => {
try {
const parsedParams = getAccessTokenParamsSchema.safeParse(
req.params
);
if (!parsedParams.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedParams.error).toString()
)
);
}
const { accessTokenId } = parsedParams.data;
const [tokenItem] = await db
.select()
.from(resourceAccessToken)
.where(eq(resourceAccessToken.accessTokenId, accessTokenId))
.limit(1);
if (!tokenItem) {
return next(
createHttpError(
HttpCode.NOT_FOUND,
"Access token not found"
)
);
}
let userData: AccessTokenUserData | undefined;
if (tokenItem.userId) {
userData = await resolveAccessTokenUserData(
tokenItem.userId,
tokenItem.orgId
);
}
return response(res, {
data: { tokenItem, userData },
success: true,
error: false,
message: "Access token retrieved successfully",
status: HttpCode.OK
});
} catch (error) {
logger.error(error);
return next(
createHttpError(
HttpCode.INTERNAL_SERVER_ERROR,
"Failed to get access token"
)
);
}
}
);
// Access audit log from remote nodes
hybridRouter.post(
"/logs/access",
async (req: Request, res: Response, next: NextFunction) => {
try {
const parsedBody = logAccessAuditBodySchema.safeParse(req.body);
if (!parsedBody.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedBody.error).toString()
)
);
}
const remoteExitNode = req.remoteExitNode;
if (!remoteExitNode || !remoteExitNode.exitNodeId) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
"Remote exit node not found"
)
);
}
if (
await checkExitNodeOrg(
remoteExitNode.exitNodeId,
parsedBody.data.orgId
)
) {
return next(
createHttpError(
HttpCode.FORBIDDEN,
"Exit node not allowed for this organization"
)
);
}
await logAccessAudit(parsedBody.data);
return response(res, {
data: null,
success: true,
error: false,
message: "Access audit log saved successfully",
status: HttpCode.OK
});
} catch (error) {
logger.error(error);
return next(
createHttpError(
HttpCode.INTERNAL_SERVER_ERROR,
"Failed to save access audit log"
)
);
}
}
);
const geoIpLookupParamsSchema = z.object({
ip: z.union([z.ipv4(), z.ipv6()])
});
@@ -1,4 +1,3 @@
import { hash } from "@node-rs/argon2";
import {
generateId,
generateIdFromEntropySize,
@@ -8,18 +7,18 @@ import { db } from "@server/db";
import {
ResourceAccessToken,
resourceAccessToken,
resources
resources,
userOrgs
} from "@server/db";
import HttpCode from "@server/types/HttpCode";
import response from "@server/lib/response";
import { eq } from "drizzle-orm";
import { and, eq } from "drizzle-orm";
import { NextFunction, Request, Response } from "express";
import createHttpError from "http-errors";
import { z } from "zod";
import { fromError } from "zod-validation-error";
import logger from "@server/logger";
import { createDate, TimeSpan } from "oslo";
import { hashPassword } from "@server/auth/password";
import { encodeHexLowerCase } from "@oslojs/encoding";
import { sha256 } from "@oslojs/crypto/sha2";
import { OpenAPITags, registry } from "@server/openApi";
@@ -28,7 +27,9 @@ export const generateAccessTokenBodySchema = z.strictObject({
validForSeconds: z.int().positive().optional(), // seconds
title: z.string().optional(),
path: z.string().optional(),
description: z.string().optional()
description: z.string().optional(),
persistSession: z.boolean().optional().default(false),
userId: z.string().optional()
});
export const generateAccssTokenParamsSchema = z.strictObject({
@@ -101,7 +102,14 @@ export async function generateAccessToken(
}
const { resourceId } = parsedParams.data;
const { validForSeconds, title, path, description } = parsedBody.data;
const {
validForSeconds,
title,
path,
description,
persistSession,
userId
} = parsedBody.data;
const [resource] = await db
.select()
@@ -112,6 +120,28 @@ export async function generateAccessToken(
return next(createHttpError(HttpCode.NOT_FOUND, "Resource not found"));
}
if (userId) {
const [membership] = await db
.select()
.from(userOrgs)
.where(
and(
eq(userOrgs.userId, userId),
eq(userOrgs.orgId, resource.orgId)
)
)
.limit(1);
if (!membership) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
"User is not a member of this organization"
)
);
}
}
try {
const sessionLength = validForSeconds
? validForSeconds * 1000
@@ -133,23 +163,27 @@ export async function generateAccessToken(
accessTokenId: id,
orgId: resource.orgId,
resourceId,
userId: userId || null,
tokenHash,
expiresAt: expiresAt || null,
sessionLength: sessionLength,
title: title || null,
path: path || null,
description: description || null,
persistSession,
createdAt: new Date().getTime()
})
.returning({
accessTokenId: resourceAccessToken.accessTokenId,
orgId: resourceAccessToken.orgId,
resourceId: resourceAccessToken.resourceId,
userId: resourceAccessToken.userId,
expiresAt: resourceAccessToken.expiresAt,
sessionLength: resourceAccessToken.sessionLength,
title: resourceAccessToken.title,
path: resourceAccessToken.path,
description: resourceAccessToken.description,
persistSession: resourceAccessToken.persistSession,
createdAt: resourceAccessToken.createdAt
})
.execute();
+10 -2
View File
@@ -6,12 +6,13 @@ import {
userResources,
roleResources,
resourceAccessToken,
sites
sites,
users
} from "@server/db";
import response from "@server/lib/response";
import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors";
import { sql, eq, or, inArray, and, count, isNull, lt, gt } from "drizzle-orm";
import { sql, eq, or, inArray, and, count, isNull, gt } from "drizzle-orm";
import logger from "@server/logger";
import stoi from "@server/lib/stoi";
import { fromZodError } from "zod-validation-error";
@@ -55,11 +56,16 @@ function queryAccessTokens(
accessTokenId: resourceAccessToken.accessTokenId,
orgId: resourceAccessToken.orgId,
resourceId: resourceAccessToken.resourceId,
userId: resourceAccessToken.userId,
userName: users.name,
username: users.username,
userEmail: users.email,
sessionLength: resourceAccessToken.sessionLength,
expiresAt: resourceAccessToken.expiresAt,
tokenHash: resourceAccessToken.tokenHash,
title: resourceAccessToken.title,
description: resourceAccessToken.description,
persistSession: resourceAccessToken.persistSession,
createdAt: resourceAccessToken.createdAt,
resourceName: resources.name,
resourceNiceId: resources.niceId,
@@ -75,6 +81,7 @@ function queryAccessTokens(
eq(resourceAccessToken.resourceId, resources.resourceId)
)
.leftJoin(sites, eq(resources.resourceId, sites.siteId))
.leftJoin(users, eq(resourceAccessToken.userId, users.userId))
.where(
and(
inArray(
@@ -97,6 +104,7 @@ function queryAccessTokens(
eq(resourceAccessToken.resourceId, resources.resourceId)
)
.leftJoin(sites, eq(resources.resourceId, sites.siteId))
.leftJoin(users, eq(resourceAccessToken.userId, users.userId))
.where(
and(
inArray(
+15 -10
View File
@@ -16,18 +16,26 @@ export async function logout(
next: NextFunction
): Promise<any> {
const { user, session } = await verifySession(req);
const isSecure = req.protocol === "https";
// Always clear the session cookie so logout is idempotent, even when
// the session is already missing or invalid
res.setHeader("Set-Cookie", createBlankSessionTokenCookie(isSecure));
if (!user || !session) {
if (config.getRawConfig().app.log_failed_attempts) {
logger.info(
`Log out failed because missing or invalid session. IP: ${req.ip}.`
`Log out with missing or invalid session. IP: ${req.ip}.`
);
}
return next(
createHttpError(
HttpCode.BAD_REQUEST,
"You must be logged in to sign out"
)
);
return response<null>(res, {
data: null,
success: true,
error: false,
message: "Logged out successfully",
status: HttpCode.OK
});
}
try {
@@ -37,9 +45,6 @@ export async function logout(
logger.error("Failed to invalidate session", error);
}
const isSecure = req.protocol === "https";
res.setHeader("Set-Cookie", createBlankSessionTokenCookie(isSecure));
return response<null>(res, {
data: null,
success: true,
+273 -40
View File
@@ -1,4 +1,9 @@
import { validateResourceSessionToken } from "@server/auth/sessions/resource";
import {
createResourceSession,
serializeResourceSessionCookie,
validateResourceSessionToken
} from "@server/auth/sessions/resource";
import { generateSessionToken } from "@server/auth/sessions/app";
import { verifyResourceAccessToken } from "@server/auth/verifyResourceAccessToken";
import {
getResourceByDomain,
@@ -13,6 +18,7 @@ import {
LoginPage,
Org,
Resource,
ResourceAccessToken,
ResourceHeaderAuth,
ResourceHeaderAuthExtendedCompatibility,
ResourcePassword,
@@ -21,7 +27,10 @@ import {
ResourcePolicyPassword,
ResourcePolicyHeaderAuth,
ResourceRule,
ResourceSession
ResourceSession,
db,
resourceAccessToken,
users
} from "@server/db";
import config from "@server/lib/config";
import { isIpInCidr, stripPortFromHost } from "@server/lib/ip";
@@ -41,11 +50,13 @@ import {
enforceResourceSessionLength
} from "#dynamic/lib/checkOrgAccessPolicy";
import { logRequestAudit } from "./logRequestAudit";
import { logAccessAudit } from "#dynamic/lib/logAccessAudit";
import { REGIONS } from "@server/db/regions";
import { localCache } from "#dynamic/lib/cache";
import { APP_VERSION } from "@server/lib/consts";
import { isSubscribed } from "#dynamic/lib/isSubscribed";
import { tierMatrix } from "@server/lib/billing/tierMatrix";
import { eq } from "drizzle-orm";
const verifyResourceSessionSchema = z.object({
sessions: z.record(z.string(), z.string()).optional(),
@@ -350,22 +361,15 @@ export async function verifyResourceSession(
}
if (valid && tokenItem) {
logRequestAudit(
{
action: true,
reason: 102, // valid access token
resourceId: resource.resourceId,
orgId: resource.orgId,
location: ipCC,
apiKey: {
name: tokenItem.title,
apiKeyId: tokenItem.accessTokenId
}
},
parsedBody.data
return await allowAccessToken(
res,
resource,
tokenItem,
sessions,
dontStripSession,
parsedBody.data,
ipCC
);
return allowed(res, undefined, dontStripSession);
}
}
@@ -401,22 +405,15 @@ export async function verifyResourceSession(
}
if (valid && tokenItem) {
logRequestAudit(
{
action: true,
reason: 102, // valid access token
resourceId: resource.resourceId,
orgId: resource.orgId,
location: ipCC,
apiKey: {
name: tokenItem.title,
apiKeyId: tokenItem.accessTokenId
}
},
parsedBody.data
return await allowAccessToken(
res,
resource,
tokenItem,
sessions,
dontStripSession,
parsedBody.data,
ipCC
);
return allowed(res, undefined, dontStripSession);
}
}
@@ -666,22 +663,37 @@ export async function verifyResourceSession(
"Resource allowed because access token session is valid"
);
logRequestAudit(
const [tokenItem] = await db
.select()
.from(resourceAccessToken)
.where(
eq(
resourceAccessToken.accessTokenId,
resourceSession.accessTokenId
)
)
.limit(1);
const userData = tokenItem
? await getAccessTokenUserData(
tokenItem,
resource.orgId
)
: undefined;
logAccessTokenRequestAudit(
{
action: true,
reason: 102, // valid access token
resourceId: resource.resourceId,
orgId: resource.orgId,
location: ipCC,
apiKey: {
name: null,
apiKeyId: resourceSession.accessTokenId
}
accessTokenId: resourceSession.accessTokenId,
tokenTitle: tokenItem?.title ?? null,
userData
},
parsedBody.data
);
return allowed(res, undefined, dontStripSession);
return allowed(res, userData, dontStripSession);
}
if (resourceSession.userSessionId && sso) {
@@ -892,6 +904,227 @@ function allowed(
return response<VerifyUserResponse>(res, data);
}
async function allowAccessToken(
res: Response,
resource: Resource,
tokenItem: ResourceAccessToken,
sessions: Record<string, string> | undefined,
dontStripSession: boolean | undefined,
auditBody: VerifyResourceSessionSchema,
location?: string
) {
const userData = await getAccessTokenUserData(tokenItem, resource.orgId);
logAccessTokenRequestAudit(
{
resourceId: resource.resourceId,
orgId: resource.orgId,
location,
accessTokenId: tokenItem.accessTokenId,
tokenTitle: tokenItem.title,
userData
},
auditBody
);
if (!tokenItem.persistSession) {
logAccessTokenAccessAudit(tokenItem, resource, userData, auditBody);
return allowed(res, userData, dontStripSession);
}
const resourceSessionToken = extractResourceSessionToken(
sessions ?? {},
resource.ssl
);
if (resourceSessionToken) {
const sessionCacheKey = `session:${resourceSessionToken}`;
let resourceSession: ResourceSession | null | undefined =
localCache.get(sessionCacheKey);
if (!resourceSession) {
const result = await validateResourceSessionToken(
resourceSessionToken,
resource.resourceId
);
resourceSession = result?.resourceSession;
localCache.set(sessionCacheKey, resourceSession, 5);
}
if (
resourceSession &&
!resourceSession.isRequestToken &&
resourceSession.accessTokenId === tokenItem.accessTokenId
) {
logger.debug(
"Resource allowed because existing access token session is valid"
);
return allowed(res, userData, dontStripSession);
}
}
logAccessTokenAccessAudit(tokenItem, resource, userData, auditBody);
return await createAccessTokenSession(res, resource, tokenItem, userData);
}
async function createAccessTokenSession(
res: Response,
resource: Resource,
tokenItem: ResourceAccessToken,
userData?: BasicUserData
) {
const token = generateSessionToken();
const sess = await createResourceSession({
resourceId: resource.resourceId,
token,
accessTokenId: tokenItem.accessTokenId,
sessionLength: tokenItem.sessionLength,
expiresAt: tokenItem.expiresAt,
doNotExtend: tokenItem.expiresAt ? true : false
});
const cookieName = config.getRawConfig().server.session_cookie_name;
const cookie = serializeResourceSessionCookie(
cookieName,
resource.fullDomain!,
token,
!resource.ssl,
new Date(sess.expiresAt)
);
res.appendHeader("Set-Cookie", cookie);
logger.debug("Access token is valid, creating new session");
return allowed(res, userData);
}
async function getAccessTokenUserData(
tokenItem: ResourceAccessToken,
orgId: string
): Promise<BasicUserData | undefined> {
if (!tokenItem.userId) {
return undefined;
}
const cacheKey = `accessTokenUser:${tokenItem.userId}:${orgId}`;
const cached = localCache.get(cacheKey) as BasicUserData | null | undefined;
if (cached !== undefined) {
return cached ?? undefined;
}
const [user] = await db
.select()
.from(users)
.where(eq(users.userId, tokenItem.userId))
.limit(1);
if (!user) {
localCache.set(cacheKey, null, 5);
return undefined;
}
const userOrgRoles = await getUserOrgRoles(user.userId, orgId);
const userData: BasicUserData = {
userId: user.userId,
username: user.username,
email: user.email,
name: user.name,
role: userOrgRoles.map((r) => r.roleName).join(", ") || null
};
localCache.set(cacheKey, userData, 12);
return userData;
}
function logAccessTokenRequestAudit(
data: {
resourceId: number;
orgId: string;
location?: string;
accessTokenId: string;
tokenTitle: string | null;
userData?: BasicUserData;
},
body: VerifyResourceSessionSchema
) {
if (data.userData) {
logRequestAudit(
{
action: true,
reason: 102, // valid access token
resourceId: data.resourceId,
orgId: data.orgId,
location: data.location,
user: {
username: data.userData.username,
userId: data.userData.userId
},
metadata: {
accessTokenId: data.accessTokenId,
accessTokenTitle: data.tokenTitle
}
},
body
);
return;
}
logRequestAudit(
{
action: true,
reason: 102, // valid access token
resourceId: data.resourceId,
orgId: data.orgId,
location: data.location,
apiKey: {
name: data.tokenTitle,
apiKeyId: data.accessTokenId
}
},
body
);
}
function logAccessTokenAccessAudit(
tokenItem: ResourceAccessToken,
resource: Resource,
userData: BasicUserData | undefined,
body: VerifyResourceSessionSchema
) {
const userAgent =
body.headers?.["user-agent"] || body.headers?.["User-Agent"];
if (userData) {
logAccessAudit({
orgId: resource.orgId,
resourceId: resource.resourceId,
action: true,
type: "accessToken",
user: {
username: userData.username,
userId: userData.userId
},
metadata: {
accessTokenId: tokenItem.accessTokenId,
accessTokenTitle: tokenItem.title
},
userAgent,
requestIp: body.requestIp
});
return;
}
logAccessAudit({
orgId: resource.orgId,
resourceId: resource.resourceId,
action: true,
type: "accessToken",
apiKey: {
name: tokenItem.title,
apiKeyId: tokenItem.accessTokenId
},
userAgent,
requestIp: body.requestIp
});
}
async function headerAuthChallenged(
res: Response,
redirectPath?: string,
-10
View File
@@ -809,16 +809,6 @@ authenticated.post(
accessToken.generateAccessToken
);
authenticated.post(
`/resource/:resourceId/session-token`,
verifyApiKeyResourceAccess,
verifyApiKeyUserAccess,
verifyLimits,
verifyApiKeyHasAction(ActionsEnum.createResourceSessionToken),
logActionAudit(ActionsEnum.createResourceSessionToken),
resource.createResourceSessionToken
);
authenticated.delete(
`/access-token/:accessTokenId`,
verifyApiKeyAccessTokenAccess,
@@ -29,7 +29,7 @@ export const handleNewtGetConfigMessage: MessageHandler = async (context) => {
return;
}
const { publicKey, port, chainId } = message.data;
const { publicKey, port, localEndpoints, chainId } = message.data;
const siteId = newt.siteId;
// Get the current site data
@@ -69,7 +69,10 @@ export const handleNewtGetConfigMessage: MessageHandler = async (context) => {
.update(sites)
.set({
publicKey,
listenPort: port
listenPort: port,
localEndpoints: localEndpoints
? JSON.stringify(localEndpoints)
: null
})
.where(eq(sites.siteId, siteId))
.returning();
+4
View File
@@ -30,6 +30,7 @@ export async function buildSiteConfigurationForOlmClient(
siteId: number;
name?: string;
endpoint?: string;
localEndpoints?: string[];
publicKey?: string;
serverIP?: string | null;
serverPort?: number | null;
@@ -206,6 +207,9 @@ export async function buildSiteConfigurationForOlmClient(
name: site.name,
// relayEndpoint: relayEndpoint, // this can be undefined now if not relayed // lets not do this for now because it would conflict with the hole punch testing
endpoint: site.endpoint,
localEndpoints: site.localEndpoints
? JSON.parse(site.localEndpoints)
: undefined,
publicKey: site.publicKey,
serverIP: site.address,
serverPort: site.listenPort,
@@ -0,0 +1,74 @@
import { db, sites } from "@server/db";
import { MessageHandler } from "@server/routers/ws";
import { clients, Olm } from "@server/db";
import { and, eq } from "drizzle-orm";
import { updatePeer as newtUpdatePeer } from "../newt/peers";
import logger from "@server/logger";
export const handleOlmLocalMessage: MessageHandler = async (context) => {
const { message, client: c, sendToClient } = context;
const olm = c as Olm;
logger.info("Handling local olm message!");
if (!olm) {
logger.warn("Olm not found");
return;
}
if (!olm.clientId) {
logger.warn("Olm has no client!");
return;
}
const clientId = olm.clientId;
const [client] = await db
.select()
.from(clients)
.where(eq(clients.clientId, clientId))
.limit(1);
if (!client) {
logger.warn("Client not found");
return;
}
// make sure we hand endpoints for both the site and the client and the lastHolePunch is not too old
if (!client.pubKey) {
logger.warn("Client has no endpoint or listen port");
return;
}
const { siteId, chainId } = message.data;
// Get the site
const [site] = await db
.select()
.from(sites)
.where(eq(sites.siteId, siteId))
.limit(1);
if (!site || !site.exitNodeId) {
logger.warn("Site not found or has no exit node");
return;
}
// update the peer on the newt
await newtUpdatePeer(siteId, client.pubKey, {
endpoint: "" // this removes the endpoint so the newt knows to accept local
});
// Just ack the message, we don't keep sending it
return {
message: {
type: "olm/wg/peer/local",
data: {
siteId: siteId,
chainId
}
},
broadcast: false,
excludeSender: false
};
};
+2 -2
View File
@@ -79,9 +79,9 @@ export const handleOlmRelayMessage: MessageHandler = async (context) => {
)
);
// update the peer on the exit node
// update the peer on the newt
await newtUpdatePeer(siteId, client.pubKey, {
endpoint: "" // this removes the endpoint so the exit node knows to relay
endpoint: "" // this removes the endpoint so the newt knows to relay
});
return {
@@ -3,24 +3,15 @@ import {
db,
networks,
siteNetworks,
siteResources,
siteResources
} from "@server/db";
import { MessageHandler } from "@server/routers/ws";
import {
clients,
clientSitesAssociationsCache,
Olm,
sites
} from "@server/db";
import { clients, clientSitesAssociationsCache, Olm, sites } from "@server/db";
import { and, eq, inArray, isNotNull, isNull } from "drizzle-orm";
import logger from "@server/logger";
import {
generateAliasConfig,
} from "@server/lib/ip";
import { generateAliasConfig } from "@server/lib/ip";
import { generateRemoteSubnets } from "@server/lib/ip";
import {
addPeer as newtAddPeer,
} from "@server/routers/newt/peers";
import { addPeer as newtAddPeer } from "@server/routers/newt/peers";
export const handleOlmServerPeerAddMessage: MessageHandler = async (
context
@@ -135,10 +126,7 @@ export const handleOlmServerPeerAddMessage: MessageHandler = async (
clientSiteResourcesAssociationsCache.siteResourceId
)
)
.innerJoin(
networks,
eq(siteResources.networkId, networks.networkId)
)
.innerJoin(networks, eq(siteResources.networkId, networks.networkId))
.innerJoin(
siteNetworks,
and(
@@ -147,10 +135,7 @@ export const handleOlmServerPeerAddMessage: MessageHandler = async (
)
)
.where(
eq(
clientSiteResourcesAssociationsCache.clientId,
client.clientId
)
eq(clientSiteResourcesAssociationsCache.clientId, client.clientId)
);
// Return connect message with all site configurations
@@ -161,6 +146,9 @@ export const handleOlmServerPeerAddMessage: MessageHandler = async (
siteId: site.siteId,
name: site.name,
endpoint: site.endpoint,
localEndpoints: site.localEndpoints
? JSON.parse(site.localEndpoints)
: undefined,
publicKey: site.publicKey,
serverIP: site.address,
serverPort: site.listenPort,
@@ -170,7 +158,7 @@ export const handleOlmServerPeerAddMessage: MessageHandler = async (
aliases: generateAliasConfig(
allSiteResources.map(({ siteResources }) => siteResources)
),
chainId: chainId,
chainId: chainId
}
},
broadcast: false,
@@ -0,0 +1,95 @@
import { db, exitNodes, sites } from "@server/db";
import { MessageHandler } from "@server/routers/ws";
import { clients, clientSitesAssociationsCache, Olm } from "@server/db";
import { and, eq } from "drizzle-orm";
import { updatePeer as newtUpdatePeer } from "../newt/peers";
import logger from "@server/logger";
export const handleOlmUnLocalMessage: MessageHandler = async (context) => {
const { message, client: c, sendToClient } = context;
const olm = c as Olm;
logger.info("Handling unlocal olm message!");
if (!olm) {
logger.warn("Olm not found");
return;
}
if (!olm.clientId) {
logger.warn("Olm has no client!");
return;
}
const clientId = olm.clientId;
const [client] = await db
.select()
.from(clients)
.where(eq(clients.clientId, clientId))
.limit(1);
if (!client) {
logger.warn("Client not found");
return;
}
// make sure we hand endpoints for both the site and the client and the lastHolePunch is not too old
if (!client.pubKey) {
logger.warn("Client has no endpoint or listen port");
return;
}
const { siteId, chainId } = message.data;
// Get the site
const [site] = await db
.select()
.from(sites)
.where(eq(sites.siteId, siteId))
.limit(1);
if (!site) {
logger.warn("Site not found or has no exit node");
return;
}
const [clientSiteAssociation] = await db
.select()
.from(clientSitesAssociationsCache)
.where(
and(
eq(clientSitesAssociationsCache.clientId, olm.clientId),
eq(clientSitesAssociationsCache.siteId, siteId)
)
);
if (!clientSiteAssociation) {
logger.warn("Client-Site association not found");
return;
}
if (!clientSiteAssociation.endpoint) {
logger.warn("Client-Site association has no endpoint, cannot unrelay");
return;
}
// update the peer on the newt
await newtUpdatePeer(siteId, client.pubKey, {
endpoint: clientSiteAssociation.isRelayed
? ""
: clientSiteAssociation.endpoint // this is the endpoint of the client to connect directly to the newt
});
return {
message: {
type: "olm/wg/peer/unlocal",
data: {
siteId: siteId,
chainId
}
},
broadcast: false,
excludeSender: false
};
};
@@ -77,9 +77,9 @@ export const handleOlmUnRelayMessage: MessageHandler = async (context) => {
return;
}
// update the peer on the exit node
// update the peer on the newt
await newtUpdatePeer(siteId, client.pubKey, {
endpoint: clientSiteAssociation.endpoint // this is the endpoint of the client to connect directly to the exit node
endpoint: clientSiteAssociation.endpoint // this is the endpoint of the client to connect directly to the newt
});
return {
+2
View File
@@ -13,3 +13,5 @@ export * from "./recoverOlmWithFingerprint";
export * from "./handleOlmDisconnectingMessage";
export * from "./handleOlmServerInitAddPeerHandshake";
export * from "./offlineChecker";
export * from "./handleOlmUnLocalMessage";
export * from "./handleOlmLocalMessage";
+2
View File
@@ -18,6 +18,7 @@ export async function addPeer(
serverPort: number | null;
remoteSubnets: string[] | null; // optional, comma-separated list of subnets that this site can access
aliases: Alias[];
localEndpoints?: string[]; // optional, list of local endpoints for the peer
},
olmId?: string,
version?: string | null
@@ -44,6 +45,7 @@ export async function addPeer(
name: peer.name,
publicKey: peer.publicKey,
endpoint: peer.endpoint,
localEndpoints: peer.localEndpoints,
relayEndpoint: peer.relayEndpoint,
serverIP: peer.serverIP,
serverPort: peer.serverPort,
+32 -1
View File
@@ -1,6 +1,6 @@
import { generateSessionToken } from "@server/auth/sessions/app";
import { db } from "@server/db";
import { Resource, resources } from "@server/db";
import { Resource, resources, users } from "@server/db";
import HttpCode from "@server/types/HttpCode";
import response from "@server/lib/response";
import { eq } from "drizzle-orm";
@@ -156,11 +156,42 @@ export async function authWithAccessToken(
doNotExtend: true
});
let accessAuditUser: { username: string; userId: string } | undefined;
if (tokenItem.userId) {
const [associatedUser] = await db
.select({
userId: users.userId,
username: users.username
})
.from(users)
.where(eq(users.userId, tokenItem.userId))
.limit(1);
if (associatedUser) {
accessAuditUser = {
userId: associatedUser.userId,
username: associatedUser.username
};
}
}
logAccessAudit({
orgId: resource.orgId,
resourceId: resource.resourceId,
action: true,
type: "accessToken",
apiKey: accessAuditUser
? undefined
: {
name: tokenItem.title,
apiKeyId: tokenItem.accessTokenId
},
user: accessAuditUser,
metadata: accessAuditUser
? {
accessTokenId: tokenItem.accessTokenId,
accessTokenTitle: tokenItem.title
}
: undefined,
userAgent: req.headers["user-agent"],
requestIp: req.ip
});
@@ -1,133 +0,0 @@
import { Request, Response, NextFunction } from "express";
import { z } from "zod";
import { db } from "@server/db";
import { resources, users, userOrgs } from "@server/db";
import { eq, and } from "drizzle-orm";
import { createResourceSession } from "@server/auth/sessions/resource";
import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors";
import { fromError } from "zod-validation-error";
import logger from "@server/logger";
import { createSession, generateSessionToken } from "@server/auth/sessions/app";
import { response } from "@server/lib/response";
const createResourceSessionTokenParams = z.strictObject({
resourceId: z.coerce.number().int().positive()
});
const createResourceSessionTokenBody = z.strictObject({
userId: z.string().nonempty(),
idpId: z.coerce.number().int().positive().optional()
});
export type CreateResourceSessionTokenResponse = {
requestToken: string;
};
export async function createResourceSessionToken(
req: Request,
res: Response,
next: NextFunction
): Promise<any> {
try {
const parsedParams = createResourceSessionTokenParams.safeParse(
req.params
);
if (!parsedParams.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedParams.error).toString()
)
);
}
const parsedBody = createResourceSessionTokenBody.safeParse(req.body);
if (!parsedBody.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedBody.error).toString()
)
);
}
const { resourceId } = parsedParams.data;
const { userId, idpId } = parsedBody.data;
const [resource] = await db
.select()
.from(resources)
.where(eq(resources.resourceId, resourceId))
.limit(1);
if (!resource) {
return next(
createHttpError(
HttpCode.NOT_FOUND,
`Resource with ID ${resourceId} not found`
)
);
}
const candidates = await db
.select({ userId: users.userId })
.from(userOrgs)
.innerJoin(users, eq(userOrgs.userId, users.userId))
.where(
and(
eq(users.userId, userId),
eq(userOrgs.orgId, resource.orgId)
)
);
if (candidates.length === 0) {
return next(
createHttpError(
HttpCode.NOT_FOUND,
`User not found in the organization that owns this resource`
)
);
}
if (candidates.length > 1) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
"Multiple users match this username (external users from different identity providers). Specify idpId to disambiguate."
)
);
}
const targetUserId = candidates[0].userId;
const appSessionToken = generateSessionToken();
const appSession = await createSession(appSessionToken, targetUserId);
const requestToken = generateSessionToken();
await createResourceSession({
resourceId,
token: requestToken,
userSessionId: appSession.sessionId,
isRequestToken: true,
expiresAt: Date.now() + 1000 * 30, // 30 seconds
sessionLength: 1000 * 30,
doNotExtend: true
});
logger.debug("Resource session token created successfully");
return response<CreateResourceSessionTokenResponse>(res, {
data: { requestToken },
success: true,
error: false,
message: "Resource session token created successfully",
status: HttpCode.OK
});
} catch (error) {
logger.error(error);
return next(
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
);
}
}
-1
View File
@@ -17,7 +17,6 @@ export * from "./getResourceWhitelist";
export * from "./authWithWhitelist";
export * from "./authWithAccessToken";
export * from "./getExchangeToken";
export * from "./createResourceSessionToken";
export * from "./createResourceRule";
export * from "./deleteResourceRule";
export * from "./listResourceRules";
+5 -1
View File
@@ -20,7 +20,9 @@ import {
handleOlmServerPeerAddMessage,
handleOlmUnRelayMessage,
handleOlmDisconnectingMessage,
handleOlmServerInitAddPeerHandshake
handleOlmServerInitAddPeerHandshake,
handleOlmLocalMessage,
handleOlmUnLocalMessage
} from "../olm";
import { handleHealthcheckStatusMessage } from "../target";
import { handleRoundTripMessage } from "./handleRoundTripMessage";
@@ -32,6 +34,8 @@ export const messageHandlers: Record<string, MessageHandler> = {
"olm/wg/register": handleOlmRegisterMessage,
"olm/wg/relay": handleOlmRelayMessage,
"olm/wg/unrelay": handleOlmUnRelayMessage,
"olm/wg/local": handleOlmLocalMessage,
"olm/wg/unlocal": handleOlmUnLocalMessage,
"olm/ping": handleOlmPingMessage,
"olm/disconnecting": handleOlmDisconnectingMessage,
"newt/disconnecting": handleNewtDisconnectingMessage,
+3 -1
View File
@@ -27,6 +27,7 @@ import m18 from "./scriptsPg/1.18.3";
import m19 from "./scriptsPg/1.18.4";
import m20 from "./scriptsPg/1.19.0";
import m21 from "./scriptsPg/1.20.0";
import m22 from "./scriptsPg/1.21.0";
// THIS CANNOT IMPORT ANYTHING FROM THE SERVER
// EXCEPT FOR THE DATABASE AND THE SCHEMA
@@ -53,7 +54,8 @@ const migrations = [
{ version: "1.18.3", run: m18 },
{ version: "1.18.4", run: m19 },
{ version: "1.19.0", run: m20 },
{ version: "1.20.0", run: m21 }
{ version: "1.20.0", run: m21 },
{ version: "1.21.0", run: m22 }
// Add new migrations here as they are created
] as {
version: string;
+3 -1
View File
@@ -46,6 +46,7 @@ import m40 from "./scriptsSqlite/1.18.4";
import m41 from "./scriptsSqlite/1.19.0";
import m42 from "./scriptsSqlite/1.19.1";
import m43 from "./scriptsSqlite/1.20.0";
import m44 from "./scriptsSqlite/1.21.0";
// THIS CANNOT IMPORT ANYTHING FROM THE SERVER
// EXCEPT FOR THE DATABASE AND THE SCHEMA
@@ -89,7 +90,8 @@ const migrations = [
{ version: "1.18.4", run: m40 },
{ version: "1.19.0", run: m41 },
{ version: "1.19.1", run: m42 },
{ version: "1.20.0", run: m43 }
{ version: "1.20.0", run: m43 },
{ version: "1.21.0", run: m44 }
// Add new migrations here as they are created
] as const;
+95
View File
@@ -0,0 +1,95 @@
import { db } from "@server/db/pg/driver";
import { APP_PATH } from "@server/lib/consts";
import { sql } from "drizzle-orm";
import fs from "fs";
import yaml from "js-yaml";
import path from "path";
import z from "zod";
import { fromZodError } from "zod-validation-error";
const version = "1.21.0";
export default async function migration() {
console.log(`Running setup script ${version}...`);
try {
await db.execute(sql`BEGIN`);
await db.execute(sql`
ALTER TABLE "resourceAccessToken" ADD COLUMN "userId" varchar;
`);
await db.execute(sql`
ALTER TABLE "resourceAccessToken" ADD COLUMN "persistSession" boolean DEFAULT false NOT NULL;
`);
await db.execute(sql`
ALTER TABLE "resources" ADD COLUMN "status" varchar DEFAULT 'approved';
`);
await db.execute(sql`
ALTER TABLE "siteResources" ADD COLUMN "status" varchar DEFAULT 'approved';
`);
await db.execute(sql`
ALTER TABLE "sites" ADD COLUMN "localEndpoints" varchar;
`);
await db.execute(sql`
ALTER TABLE "resourceAccessToken" ADD CONSTRAINT "resourceAccessToken_userId_user_id_fk" FOREIGN KEY ("userId") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;
`);
await db.execute(sql`COMMIT`);
console.log("Migrated database");
} catch (e) {
await db.execute(sql`ROLLBACK`);
console.log("Unable to migrate database");
console.log(e);
throw e;
}
try {
const traefikPath = path.join(
APP_PATH,
"traefik",
"traefik_config.yml"
);
const schema = z.object({
experimental: z.object({
plugins: z.object({
badger: z.object({
moduleName: z.string(),
version: z.string()
})
})
})
});
const traefikFileContents = fs.readFileSync(traefikPath, "utf8");
const traefikConfig = yaml.load(traefikFileContents) as any;
const parsedConfig = schema.safeParse(traefikConfig);
if (!parsedConfig.success) {
throw new Error(fromZodError(parsedConfig.error).toString());
}
traefikConfig.experimental.plugins.badger.version = "v1.5.0";
const updatedTraefikYaml = yaml.dump(traefikConfig);
fs.writeFileSync(traefikPath, updatedTraefikYaml, "utf8");
console.log(
"Updated the version of Badger in your Traefik configuration to v1.5.0"
);
} catch (e) {
console.log(
"We were unable to update the version of Badger in your Traefik configuration. Please update it manually. Check the release notes for this version for more information."
);
console.error(e);
}
console.log(`${version} migration complete`);
}
+104
View File
@@ -0,0 +1,104 @@
import { APP_PATH } from "@server/lib/consts";
import Database from "better-sqlite3";
import fs from "fs";
import yaml from "js-yaml";
import path from "path";
import z from "zod";
import { fromZodError } from "zod-validation-error";
const version = "1.21.0";
export default async function migration() {
console.log(`Running setup script ${version}...`);
const location = path.join(APP_PATH, "db", "db.sqlite");
const db = new Database(location);
try {
db.pragma("foreign_keys = OFF");
db.transaction(() => {
db.prepare(
`
ALTER TABLE 'resourceAccessToken' ADD 'userId' text REFERENCES user(id);
`
).run();
db.prepare(
`
ALTER TABLE 'resourceAccessToken' ADD 'persistSession' integer DEFAULT false NOT NULL;
`
).run();
db.prepare(
`
ALTER TABLE 'resources' ADD 'status' text DEFAULT 'approved';
`
).run();
db.prepare(
`
ALTER TABLE 'siteResources' ADD 'status' text DEFAULT 'approved';
`
).run();
db.prepare(
`
ALTER TABLE 'sites' ADD 'localEndpoints' text;
`
).run();
})();
db.pragma("foreign_keys = ON");
console.log("Migrated database");
} catch (e) {
console.log("Failed to migrate db:", e);
throw e;
}
try {
const traefikPath = path.join(
APP_PATH,
"traefik",
"traefik_config.yml"
);
const schema = z.object({
experimental: z.object({
plugins: z.object({
badger: z.object({
moduleName: z.string(),
version: z.string()
})
})
})
});
const traefikFileContents = fs.readFileSync(traefikPath, "utf8");
const traefikConfig = yaml.load(traefikFileContents) as any;
const parsedConfig = schema.safeParse(traefikConfig);
if (!parsedConfig.success) {
throw new Error(fromZodError(parsedConfig.error).toString());
}
traefikConfig.experimental.plugins.badger.version = "v1.5.0";
const updatedTraefikYaml = yaml.dump(traefikConfig);
fs.writeFileSync(traefikPath, updatedTraefikYaml, "utf8");
console.log(
"Updated the version of Badger in your Traefik configuration to v1.5.0"
);
} catch (e) {
console.log(
"We were unable to update the version of Badger in your Traefik configuration. Please update it manually. Check the release notes for this version for more information."
);
console.error(e);
}
console.log(`${version} migration complete`);
}
+33
View File
@@ -248,6 +248,39 @@ export async function loginProxy(
return await makeApiRequest<LoginResponse>(url, "POST", request);
}
export async function logoutProxy(): Promise<ResponseT<null>> {
const env = pullEnv();
const serverPort = process.env.SERVER_EXTERNAL_PORT;
const url = `http://localhost:${serverPort}/api/v1/auth/logout`;
const result = await makeApiRequest<null>(url, "POST");
try {
const headersList = await reqHeaders();
const host = headersList.get("host")?.split(":")[0];
const allCookies = await cookies();
const clearOptions = {
httpOnly: true,
secure: true,
sameSite: "lax" as const,
path: "/",
maxAge: 0
};
// Clear both host-only and domain-scoped variants.
allCookies.set(env.server.sessionCookieName, "", clearOptions);
if (host) {
allCookies.set(env.server.sessionCookieName, "", {
...clearOptions,
domain: host
});
}
} catch (cookieError) {
console.error("Failed to clear session cookie:", cookieError);
}
return result;
}
export async function securityKeyStartProxy(
request: SecurityKeyStartRequest,
forceLogin?: boolean
+99 -4
View File
@@ -52,7 +52,6 @@ import { ChevronsUpDown } from "lucide-react";
import { Checkbox } from "@app/components/ui/checkbox";
import { GenerateAccessTokenResponse } from "@server/routers/accessToken";
import { constructShareLink } from "@app/lib/shareLinks";
import { ShareLinkRow } from "@app/components/ShareLinksTable";
import { QRCodeCanvas, QRCodeSVG } from "qrcode.react";
import {
Collapsible,
@@ -63,11 +62,26 @@ import AccessTokenSection from "@app/components/AccessTokenUsage";
import { useTranslations } from "next-intl";
import { toUnicode } from "punycode";
import { ResourceSelector, type SelectedResource } from "./resource-selector";
import { UserSelector, type SelectedUser } from "@app/components/user-selector";
type CreatedShareLink = {
accessTokenId: string;
resourceId: number;
resourceName: string;
resourceNiceId: string;
title: string | null;
createdAt: number;
expiresAt: number | null;
userId?: string | null;
userName?: string | null;
username?: string | null;
userEmail?: string | null;
};
type FormProps = {
open: boolean;
setOpen: (open: boolean) => void;
onCreated?: (result: ShareLinkRow) => void;
onCreated?: (result: CreatedShareLink) => void;
};
export default function CreateShareLinkForm({
@@ -85,6 +99,8 @@ export default function CreateShareLinkForm({
const [accessToken, setAccessToken] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const [neverExpire, setNeverExpire] = useState(false);
const [persistSession, setPersistSession] = useState(false);
const [selectedUser, setSelectedUser] = useState<SelectedUser | null>(null);
const [isOpen, setIsOpen] = useState(false);
const t = useTranslations();
@@ -175,7 +191,9 @@ export default function CreateShareLinkForm({
values.resourceName ||
"Resource" + values.resourceId
}),
path: values.path
path: values.path,
persistSession,
userId: selectedUser?.id
}
)
.catch((e) => {
@@ -205,7 +223,11 @@ export default function CreateShareLinkForm({
resourceNiceId: selectedResource ? selectedResource.niceId : "",
title: token.title,
createdAt: token.createdAt,
expiresAt: token.expiresAt
expiresAt: token.expiresAt,
userId: token.userId,
userName: selectedUser?.text ?? null,
username: null,
userEmail: null
});
}
@@ -220,6 +242,9 @@ export default function CreateShareLinkForm({
setOpen(val);
setLink(null);
setLoading(false);
setNeverExpire(false);
setPersistSession(false);
setSelectedUser(null);
form.reset();
}}
>
@@ -344,6 +369,48 @@ export default function CreateShareLinkForm({
)}
/>
<div className="space-y-2">
<FormLabel>
{t(
"shareAssociateUserOptional"
)}
</FormLabel>
<Popover>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
className={cn(
"w-full justify-between",
!selectedUser &&
"text-muted-foreground"
)}
>
{selectedUser?.text
? selectedUser.text
: t("userSelect")}
<CaretSortIcon className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="p-0 w-[var(--radix-popover-trigger-width)]">
<UserSelector
orgId={org.org.orgId}
selectedUser={
selectedUser
}
onSelectUser={
setSelectedUser
}
/>
</PopoverContent>
</Popover>
<p className="text-sm text-muted-foreground">
{t(
"shareAssociateUserDescription"
)}
</p>
</div>
<div className="space-y-4">
<div className="space-y-2">
<FormLabel>
@@ -437,6 +504,34 @@ export default function CreateShareLinkForm({
</label>
</div>
<div className="flex items-start space-x-2">
<Checkbox
id="persist-session"
checked={persistSession}
onCheckedChange={(val) =>
setPersistSession(
val as boolean
)
}
className="mt-0.5"
/>
<div className="space-y-1">
<label
htmlFor="persist-session"
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
>
{t(
"sharePersistSession"
)}
</label>
<p className="text-sm text-muted-foreground">
{t(
"sharePersistSessionDescription"
)}
</p>
</div>
</div>
<p className="text-sm text-muted-foreground">
{t("shareExpireDescription")}
</p>
+15 -12
View File
@@ -12,8 +12,8 @@ import { Shield, ArrowRight } from "lucide-react";
import Link from "next/link";
import { useTranslations } from "next-intl";
import { useRouter } from "next/navigation";
import { createApiClient } from "@app/lib/api";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { useState } from "react";
import { logoutProxy } from "@app/actions/server";
type OrgPolicyRequiredProps = {
orgId: string;
@@ -40,21 +40,23 @@ export default function OrgPolicyRequired({
}: OrgPolicyRequiredProps) {
const t = useTranslations();
const router = useRouter();
const api = createApiClient(useEnvContext());
const [loading, setLoading] = useState(false);
const sessionExpired =
policies?.maxSessionLength &&
policies.maxSessionLength.compliant === false;
function reauthenticate() {
api.post("/auth/logout")
.catch(() => {})
.then(() => {
const destination = redirectAfterAuth ?? `/${orgId}`;
router.push(destination);
router.refresh();
});
async function reauthenticate() {
setLoading(true);
try {
await logoutProxy();
} catch (error) {
console.error("Error during logout:", error);
} finally {
const destination = redirectAfterAuth ?? `/${orgId}`;
router.push(destination);
router.refresh();
}
}
if (sessionExpired) {
@@ -76,6 +78,7 @@ export default function OrgPolicyRequired({
<Button
className="w-full"
onClick={reauthenticate}
loading={loading}
>
{t("reauthenticate")}
<ArrowRight className="ml-2 h-4 w-4" />
+40
View File
@@ -35,6 +35,7 @@ import moment from "moment";
import CreateShareLinkForm from "@app/components/CreateShareLinkForm";
import { constructShareLink } from "@app/lib/shareLinks";
import { useTranslations } from "next-intl";
import { getUserDisplayName } from "@app/lib/getUserDisplayName";
export type ShareLinkRow = {
accessTokenId: string;
@@ -44,6 +45,10 @@ export type ShareLinkRow = {
title: string | null;
createdAt: number;
expiresAt: number | null;
userId?: string | null;
userName?: string | null;
username?: string | null;
userEmail?: string | null;
};
type ShareLinksTableProps = {
@@ -155,6 +160,41 @@ export default function ShareLinksTable({
);
}
},
{
accessorKey: "userId",
friendlyName: t("user"),
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() =>
column.toggleSorting(column.getIsSorted() === "asc")
}
>
{t("user")}
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
},
cell: ({ row }) => {
const r = row.original;
if (!r.userId) {
return <span>-</span>;
}
return (
<Link href={`/${orgId}/settings/access/users/${r.userId}`}>
<Button variant="outline" size="sm">
{getUserDisplayName({
email: r.userEmail,
name: r.userName,
username: r.username
})}
<ArrowUpRight className="ml-2 h-3 w-3" />
</Button>
</Link>
);
}
},
// {
// accessorKey: "domain",
// header: "Link",
+106
View File
@@ -0,0 +1,106 @@
import { orgQueries } from "@app/lib/queries";
import { getUserDisplayName } from "@app/lib/getUserDisplayName";
import { useQuery } from "@tanstack/react-query";
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList
} from "./ui/command";
import { useMemo, useState } from "react";
import { useTranslations } from "next-intl";
import { CheckIcon } from "lucide-react";
import { cn } from "@app/lib/cn";
import { useDebounce } from "use-debounce";
import type { SelectedUser } from "./users-selector";
export type { SelectedUser };
export type UserSelectorProps = {
orgId: string;
selectedUser?: SelectedUser | null;
onSelectUser: (user: SelectedUser | null) => void;
allowClear?: boolean;
};
export function UserSelector({
orgId,
selectedUser,
onSelectUser,
allowClear = true
}: UserSelectorProps) {
const t = useTranslations();
const [userSearchQuery, setUserSearchQuery] = useState("");
const [debouncedValue] = useDebounce(userSearchQuery, 150);
const { data: users = [] } = useQuery(
orgQueries.users({ orgId, perPage: 10, query: debouncedValue })
);
const usersShown = useMemo(() => {
const allUsers: Array<SelectedUser> = users.map((u) => ({
id: u.id,
text: getUserDisplayName(u)
}));
if (
debouncedValue.trim().length === 0 &&
selectedUser &&
!allUsers.find((user) => user.id === selectedUser.id)
) {
allUsers.unshift(selectedUser);
}
return allUsers;
}, [users, selectedUser, debouncedValue]);
return (
<Command shouldFilter={false}>
<CommandInput
placeholder={t("userSearch")}
value={userSearchQuery}
onValueChange={setUserSearchQuery}
/>
<CommandList>
<CommandEmpty>{t("usersNotFound")}</CommandEmpty>
<CommandGroup>
{allowClear && (
<CommandItem
value="__none__"
onSelect={() => {
onSelectUser(null);
}}
>
<CheckIcon
className={cn(
"mr-2 h-4 w-4",
!selectedUser ? "opacity-100" : "opacity-0"
)}
/>
{t("none")}
</CommandItem>
)}
{usersShown.map((user) => (
<CommandItem
value={`${user.text}:${user.id}`}
key={user.id}
onSelect={() => {
onSelectUser(user);
}}
>
<CheckIcon
className={cn(
"mr-2 h-4 w-4",
user.id === selectedUser?.id
? "opacity-100"
: "opacity-0"
)}
/>
{user.text}
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
);
}