Merge branch 'dev' into transfer-resource-to-new-site

This commit is contained in:
Owen Schwartz 2025-02-01 17:03:05 -05:00
commit 962c5fb886
No known key found for this signature in database
GPG key ID: 8271FDFFD9E0CCBD
85 changed files with 3396 additions and 1197 deletions

View file

@ -1,118 +0,0 @@
import {
encodeBase32LowerCaseNoPadding,
encodeHexLowerCase,
} from "@oslojs/encoding";
import { sha256 } from "@oslojs/crypto/sha2";
import { Session, sessions, User, users } from "@server/db/schema";
import db from "@server/db";
import { eq } from "drizzle-orm";
import config from "@server/lib/config";
import type { RandomReader } from "@oslojs/crypto/random";
import { generateRandomString } from "@oslojs/crypto/random";
export const SESSION_COOKIE_NAME = config.getRawConfig().server.session_cookie_name;
export const SESSION_COOKIE_EXPIRES = 1000 * 60 * 60 * 24 * 30;
export const SECURE_COOKIES = config.getRawConfig().server.secure_cookies;
export const COOKIE_DOMAIN = "." + config.getBaseDomain();
export function generateSessionToken(): string {
const bytes = new Uint8Array(20);
crypto.getRandomValues(bytes);
const token = encodeBase32LowerCaseNoPadding(bytes);
return token;
}
export async function createSession(
token: string,
userId: string,
): Promise<Session> {
const sessionId = encodeHexLowerCase(
sha256(new TextEncoder().encode(token)),
);
const session: Session = {
sessionId: sessionId,
userId,
expiresAt: new Date(Date.now() + SESSION_COOKIE_EXPIRES).getTime(),
};
await db.insert(sessions).values(session);
return session;
}
export async function validateSessionToken(
token: string,
): Promise<SessionValidationResult> {
const sessionId = encodeHexLowerCase(
sha256(new TextEncoder().encode(token)),
);
const result = await db
.select({ user: users, session: sessions })
.from(sessions)
.innerJoin(users, eq(sessions.userId, users.userId))
.where(eq(sessions.sessionId, sessionId));
if (result.length < 1) {
return { session: null, user: null };
}
const { user, session } = result[0];
if (Date.now() >= session.expiresAt) {
await db
.delete(sessions)
.where(eq(sessions.sessionId, session.sessionId));
return { session: null, user: null };
}
if (Date.now() >= session.expiresAt - SESSION_COOKIE_EXPIRES / 2) {
session.expiresAt = new Date(
Date.now() + SESSION_COOKIE_EXPIRES,
).getTime();
await db
.update(sessions)
.set({
expiresAt: session.expiresAt,
})
.where(eq(sessions.sessionId, session.sessionId));
}
return { session, user };
}
export async function invalidateSession(sessionId: string): Promise<void> {
await db.delete(sessions).where(eq(sessions.sessionId, sessionId));
}
export async function invalidateAllSessions(userId: string): Promise<void> {
await db.delete(sessions).where(eq(sessions.userId, userId));
}
export function serializeSessionCookie(token: string): string {
if (SECURE_COOKIES) {
return `${SESSION_COOKIE_NAME}=${token}; HttpOnly; SameSite=Strict; Max-Age=${SESSION_COOKIE_EXPIRES}; Path=/; Secure; Domain=${COOKIE_DOMAIN}`;
} else {
return `${SESSION_COOKIE_NAME}=${token}; HttpOnly; SameSite=Strict; Max-Age=${SESSION_COOKIE_EXPIRES}; Path=/; Domain=${COOKIE_DOMAIN}`;
}
}
export function createBlankSessionTokenCookie(): string {
if (SECURE_COOKIES) {
return `${SESSION_COOKIE_NAME}=; HttpOnly; SameSite=Strict; Max-Age=0; Path=/; Secure; Domain=${COOKIE_DOMAIN}`;
} else {
return `${SESSION_COOKIE_NAME}=; HttpOnly; SameSite=Strict; Max-Age=0; Path=/; Domain=${COOKIE_DOMAIN}`;
}
}
const random: RandomReader = {
read(bytes: Uint8Array): void {
crypto.getRandomValues(bytes);
},
};
export function generateId(length: number): string {
const alphabet = "abcdefghijklmnopqrstuvwxyz0123456789";
return generateRandomString(random, alphabet, length);
}
export function generateIdFromEntropySize(size: number): string {
const buffer = crypto.getRandomValues(new Uint8Array(size));
return encodeBase32LowerCaseNoPadding(buffer);
}
export type SessionValidationResult =
| { session: Session; user: User }
| { session: null; user: null };

View file

@ -4,7 +4,7 @@ export const passwordSchema = z
.string()
.min(8, { message: "Password must be at least 8 characters long" })
.max(64, { message: "Password must be at most 64 characters long" })
.regex(/^(?=.*?[A-Z])(?=.*?[a-z])(?=.*?[0-9])(?=.*?[#?!@$%^&*-]).*$/, {
.regex(/^(?=.*?[A-Z])(?=.*?[a-z])(?=.*?[0-9])(?=.*?[,#?!@$%^&*-]).*$/, {
message: `Your password must meet the following conditions:
at least one uppercase English letter,
at least one lowercase English letter,

View file

@ -26,7 +26,7 @@ export async function sendResourceOtpEmail(
}),
{
to: email,
from: config.getRawConfig().email?.no_reply,
from: config.getNoReplyEmail(),
subject: `Your one-time code to access ${resourceName}`
}
);

View file

@ -21,7 +21,7 @@ export async function sendEmailVerificationCode(
}),
{
to: email,
from: config.getRawConfig().email?.no_reply,
from: config.getNoReplyEmail(),
subject: "Verify your email address"
}
);

View file

@ -3,7 +3,13 @@ import {
encodeHexLowerCase
} from "@oslojs/encoding";
import { sha256 } from "@oslojs/crypto/sha2";
import { Session, sessions, User, users } from "@server/db/schema";
import {
resourceSessions,
Session,
sessions,
User,
users
} from "@server/db/schema";
import db from "@server/db";
import { eq } from "drizzle-orm";
import config from "@server/lib/config";
@ -13,9 +19,13 @@ import logger from "@server/logger";
export const SESSION_COOKIE_NAME =
config.getRawConfig().server.session_cookie_name;
export const SESSION_COOKIE_EXPIRES = 1000 * 60 * 60 * 24 * 30;
export const SECURE_COOKIES = config.getRawConfig().server.secure_cookies;
export const COOKIE_DOMAIN = "." + config.getBaseDomain();
export const SESSION_COOKIE_EXPIRES =
1000 *
60 *
60 *
config.getRawConfig().server.dashboard_session_length_hours;
export const COOKIE_DOMAIN =
"." + new URL(config.getRawConfig().app.dashboard_url).hostname;
export function generateSessionToken(): string {
const bytes = new Uint8Array(20);
@ -65,12 +75,21 @@ export async function validateSessionToken(
session.expiresAt = new Date(
Date.now() + SESSION_COOKIE_EXPIRES
).getTime();
await db
.update(sessions)
.set({
expiresAt: session.expiresAt
})
.where(eq(sessions.sessionId, session.sessionId));
await db.transaction(async (trx) => {
await trx
.update(sessions)
.set({
expiresAt: session.expiresAt
})
.where(eq(sessions.sessionId, session.sessionId));
await trx
.update(resourceSessions)
.set({
expiresAt: session.expiresAt
})
.where(eq(resourceSessions.userSessionId, session.sessionId));
});
}
return { session, user };
}
@ -88,12 +107,7 @@ export function serializeSessionCookie(
isSecure: boolean
): string {
if (isSecure) {
logger.debug("Setting cookie for secure origin");
if (SECURE_COOKIES) {
return `${SESSION_COOKIE_NAME}=${token}; HttpOnly; SameSite=Strict; Max-Age=${SESSION_COOKIE_EXPIRES}; Path=/; Secure; Domain=${COOKIE_DOMAIN}`;
} else {
return `${SESSION_COOKIE_NAME}=${token}; HttpOnly; SameSite=Strict; Max-Age=${SESSION_COOKIE_EXPIRES}; Path=/; Domain=${COOKIE_DOMAIN}`;
}
return `${SESSION_COOKIE_NAME}=${token}; HttpOnly; SameSite=Strict; Max-Age=${SESSION_COOKIE_EXPIRES / 1000}; Path=/; Secure; Domain=${COOKIE_DOMAIN}`;
} else {
return `${SESSION_COOKIE_NAME}=${token}; HttpOnly; SameSite=Lax; Max-Age=${SESSION_COOKIE_EXPIRES}; Path=/;`;
}
@ -101,11 +115,7 @@ export function serializeSessionCookie(
export function createBlankSessionTokenCookie(isSecure: boolean): string {
if (isSecure) {
if (SECURE_COOKIES) {
return `${SESSION_COOKIE_NAME}=; HttpOnly; SameSite=Strict; Max-Age=0; Path=/; Secure; Domain=${COOKIE_DOMAIN}`;
} else {
return `${SESSION_COOKIE_NAME}=; HttpOnly; SameSite=Strict; Max-Age=0; Path=/; Domain=${COOKIE_DOMAIN}`;
}
return `${SESSION_COOKIE_NAME}=; HttpOnly; SameSite=Strict; Max-Age=0; Path=/; Secure; Domain=${COOKIE_DOMAIN}`;
} else {
return `${SESSION_COOKIE_NAME}=; HttpOnly; SameSite=Lax; Max-Age=0; Path=/;`;
}

View file

@ -6,19 +6,19 @@ import { eq, and } from "drizzle-orm";
import config from "@server/lib/config";
export const SESSION_COOKIE_NAME =
config.getRawConfig().server.resource_session_cookie_name;
export const SESSION_COOKIE_EXPIRES = 1000 * 60 * 60 * 24 * 30;
export const SECURE_COOKIES = config.getRawConfig().server.secure_cookies;
export const COOKIE_DOMAIN = "." + config.getBaseDomain();
config.getRawConfig().server.session_cookie_name;
export const SESSION_COOKIE_EXPIRES =
1000 * 60 * 60 * config.getRawConfig().server.resource_session_length_hours;
export async function createResourceSession(opts: {
token: string;
resourceId: number;
passwordId?: number;
pincodeId?: number;
whitelistId?: number;
accessTokenId?: string;
usedOtp?: boolean;
isRequestToken?: boolean;
passwordId?: number | null;
pincodeId?: number | null;
userSessionId?: string | null;
whitelistId?: number | null;
accessTokenId?: string | null;
doNotExtend?: boolean;
expiresAt?: number | null;
sessionLength?: number | null;
@ -27,7 +27,8 @@ export async function createResourceSession(opts: {
!opts.passwordId &&
!opts.pincodeId &&
!opts.whitelistId &&
!opts.accessTokenId
!opts.accessTokenId &&
!opts.userSessionId
) {
throw new Error("Auth method must be provided");
}
@ -47,7 +48,9 @@ export async function createResourceSession(opts: {
pincodeId: opts.pincodeId || null,
whitelistId: opts.whitelistId || null,
doNotExtend: opts.doNotExtend || false,
accessTokenId: opts.accessTokenId || null
accessTokenId: opts.accessTokenId || null,
isRequestToken: opts.isRequestToken || false,
userSessionId: opts.userSessionId || null
};
await db.insert(resourceSessions).values(session);
@ -162,22 +165,26 @@ export async function invalidateAllSessions(
export function serializeResourceSessionCookie(
cookieName: string,
token: string
domain: string,
token: string,
isHttp: boolean = false
): string {
if (SECURE_COOKIES) {
return `${cookieName}=${token}; HttpOnly; SameSite=Strict; Max-Age=${SESSION_COOKIE_EXPIRES}; Path=/; Secure; Domain=${COOKIE_DOMAIN}`;
if (!isHttp) {
return `${cookieName}_s=${token}; HttpOnly; SameSite=Strict; Max-Age=${SESSION_COOKIE_EXPIRES / 1000}; Path=/; Secure; Domain=${"." + domain}`;
} else {
return `${cookieName}=${token}; HttpOnly; SameSite=Strict; Max-Age=${SESSION_COOKIE_EXPIRES}; Path=/; Domain=${COOKIE_DOMAIN}`;
return `${cookieName}=${token}; HttpOnly; SameSite=Strict; Max-Age=${SESSION_COOKIE_EXPIRES / 1000}; Path=/; Domain=${"." + domain}`;
}
}
export function createBlankResourceSessionTokenCookie(
cookieName: string
cookieName: string,
domain: string,
isHttp: boolean = false
): string {
if (SECURE_COOKIES) {
return `${cookieName}=; HttpOnly; SameSite=Strict; Max-Age=0; Path=/; Secure; Domain=${COOKIE_DOMAIN}`;
if (!isHttp) {
return `${cookieName}_s=; HttpOnly; SameSite=Strict; Max-Age=0; Path=/; Secure; Domain=${"." + domain}`;
} else {
return `${cookieName}=; HttpOnly; SameSite=Strict; Max-Age=0; Path=/; Domain=${COOKIE_DOMAIN}`;
return `${cookieName}=; HttpOnly; SameSite=Strict; Max-Age=0; Path=/; Domain=${"." + domain}`;
}
}

View file

@ -41,13 +41,16 @@ export const resources = sqliteTable("resources", {
})
.notNull(),
name: text("name").notNull(),
subdomain: text("subdomain").notNull(),
fullDomain: text("fullDomain").notNull().unique(),
subdomain: text("subdomain"),
fullDomain: text("fullDomain"),
ssl: integer("ssl", { mode: "boolean" }).notNull().default(false),
blockAccess: integer("blockAccess", { mode: "boolean" })
.notNull()
.default(false),
sso: integer("sso", { mode: "boolean" }).notNull().default(true),
http: integer("http", { mode: "boolean" }).notNull().default(true),
protocol: text("protocol").notNull(),
proxyPort: integer("proxyPort"),
emailWhitelistEnabled: integer("emailWhitelistEnabled", { mode: "boolean" })
.notNull()
.default(false)
@ -61,10 +64,9 @@ export const targets = sqliteTable("targets", {
})
.notNull(),
ip: text("ip").notNull(),
method: text("method").notNull(),
method: text("method"),
port: integer("port").notNull(),
internalPort: integer("internalPort"),
protocol: text("protocol"),
enabled: integer("enabled", { mode: "boolean" }).notNull().default(true)
});
@ -313,6 +315,10 @@ export const resourceSessions = sqliteTable("resourceSessions", {
doNotExtend: integer("doNotExtend", { mode: "boolean" })
.notNull()
.default(false),
isRequestToken: integer("isRequestToken", { mode: "boolean" }),
userSessionId: text("userSessionId").references(() => sessions.sessionId, {
onDelete: "cascade"
}),
passwordId: integer("passwordId").references(
() => resourcePassword.passwordId,
{

View file

@ -6,26 +6,21 @@ import logger from "@server/logger";
function createEmailClient() {
const emailConfig = config.getRawConfig().email;
if (
!emailConfig?.smtp_host ||
!emailConfig?.smtp_pass ||
!emailConfig?.smtp_port ||
!emailConfig?.smtp_user
) {
logger.warn(
"Email SMTP configuration is missing. Emails will not be sent.",
);
return;
}
if (!emailConfig) {
logger.warn(
"Email SMTP configuration is missing. Emails will not be sent."
);
return;
}
return nodemailer.createTransport({
host: emailConfig.smtp_host,
port: emailConfig.smtp_port,
secure: false,
secure: emailConfig.smtp_secure || false,
auth: {
user: emailConfig.smtp_user,
pass: emailConfig.smtp_pass,
},
pass: emailConfig.smtp_pass
}
});
}

View file

@ -44,7 +44,7 @@ export const ResourceOTPCode = ({
<EmailLetterHead />
<EmailHeading>
Your One-Time Password for {resourceName}
Your One-Time Code for {resourceName}
</EmailHeading>
<EmailGreeting>Hi {email || "there"},</EmailGreeting>

View file

@ -2,7 +2,7 @@ import { runSetupFunctions } from "./setup";
import { createApiServer } from "./apiServer";
import { createNextServer } from "./nextServer";
import { createInternalServer } from "./internalServer";
import { User, UserOrg } from "./db/schema";
import { Session, User, UserOrg } from "./db/schema";
async function startServers() {
await runSetupFunctions();
@ -24,6 +24,7 @@ declare global {
namespace Express {
interface Request {
user?: User;
session?: Session;
userOrg?: UserOrg;
userOrgRoleId?: number;
userOrgId?: string;

View file

@ -37,9 +37,11 @@ const configSchema = z.object({
base_domain: hostnameSchema
.optional()
.transform(getEnvOrYaml("APP_BASEDOMAIN"))
.pipe(hostnameSchema),
.pipe(hostnameSchema)
.transform((url) => url.toLowerCase()),
log_level: z.enum(["debug", "info", "warn", "error"]),
save_logs: z.boolean()
save_logs: z.boolean(),
log_failed_attempts: z.boolean().optional()
}),
server: z.object({
external_port: portSchema
@ -58,10 +60,21 @@ const configSchema = z.object({
.transform(stoi)
.pipe(portSchema),
internal_hostname: z.string().transform((url) => url.toLowerCase()),
secure_cookies: z.boolean(),
session_cookie_name: z.string(),
resource_session_cookie_name: z.string(),
resource_access_token_param: z.string(),
resource_session_request_param: z.string(),
dashboard_session_length_hours: z
.number()
.positive()
.gt(0)
.optional()
.default(720),
resource_session_length_hours: z
.number()
.positive()
.gt(0)
.optional()
.default(720),
cors: z
.object({
origins: z.array(z.string()).optional(),
@ -76,7 +89,8 @@ const configSchema = z.object({
http_entrypoint: z.string(),
https_entrypoint: z.string().optional(),
cert_resolver: z.string().optional(),
prefer_wildcard_cert: z.boolean().optional()
prefer_wildcard_cert: z.boolean().optional(),
additional_middlewares: z.array(z.string()).optional()
}),
gerbil: z.object({
start_port: portSchema
@ -109,11 +123,12 @@ const configSchema = z.object({
}),
email: z
.object({
smtp_host: z.string(),
smtp_port: portSchema,
smtp_user: z.string(),
smtp_pass: z.string(),
no_reply: z.string().email()
smtp_host: z.string().optional(),
smtp_port: portSchema.optional(),
smtp_user: z.string().optional(),
smtp_pass: z.string().optional(),
smtp_secure: z.boolean().optional(),
no_reply: z.string().email().optional()
})
.optional(),
users: z.object({
@ -123,7 +138,8 @@ const configSchema = z.object({
.email()
.optional()
.transform(getEnvOrYaml("USERS_SERVERADMIN_EMAIL"))
.pipe(z.string().email()),
.pipe(z.string().email())
.transform((v) => v.toLowerCase()),
password: passwordSchema
.optional()
.transform(getEnvOrYaml("USERS_SERVERADMIN_PASSWORD"))
@ -134,7 +150,8 @@ const configSchema = z.object({
.object({
require_email_verification: z.boolean().optional(),
disable_signup_without_invite: z.boolean().optional(),
disable_user_create_org: z.boolean().optional()
disable_user_create_org: z.boolean().optional(),
allow_raw_resources: z.boolean().optional()
})
.optional()
});
@ -237,10 +254,12 @@ export class Config {
?.require_email_verification
? "true"
: "false";
process.env.FLAGS_ALLOW_RAW_RESOURCES = parsedConfig.data.flags
?.allow_raw_resources
? "true"
: "false";
process.env.SESSION_COOKIE_NAME =
parsedConfig.data.server.session_cookie_name;
process.env.RESOURCE_SESSION_COOKIE_NAME =
parsedConfig.data.server.resource_session_cookie_name;
process.env.EMAIL_ENABLED = parsedConfig.data.email ? "true" : "false";
process.env.DISABLE_SIGNUP_WITHOUT_INVITE = parsedConfig.data.flags
?.disable_signup_without_invite
@ -252,6 +271,8 @@ export class Config {
: "false";
process.env.RESOURCE_ACCESS_TOKEN_PARAM =
parsedConfig.data.server.resource_access_token_param;
process.env.RESOURCE_SESSION_REQUEST_PARAM =
parsedConfig.data.server.resource_session_request_param;
this.rawConfig = parsedConfig.data;
}
@ -264,6 +285,12 @@ export class Config {
return this.rawConfig.app.base_domain;
}
public getNoReplyEmail(): string | undefined {
return (
this.rawConfig.email?.no_reply || this.rawConfig.email?.smtp_user
);
}
private createTraefikConfig() {
try {
// check if traefik_config.yml and dynamic_config.yml exists in APP_PATH/traefik

View file

@ -13,7 +13,7 @@ export async function verifyAdmin(
const userId = req.user?.userId;
const orgId = req.userOrgId;
if (!userId) {
if (!orgId) {
return next(
createHttpError(HttpCode.UNAUTHORIZED, "User does not have orgId")
);

View file

@ -8,6 +8,7 @@ import HttpCode from "@server/types/HttpCode";
import config from "@server/lib/config";
import { verifySession } from "@server/auth/sessions/verifySession";
import { unauthorized } from "@server/auth/unauthorizedResponse";
import logger from "@server/logger";
export const verifySessionUserMiddleware = async (
req: any,
@ -16,6 +17,9 @@ export const verifySessionUserMiddleware = async (
) => {
const { session, user } = await verifySession(req);
if (!session || !user) {
if (config.getRawConfig().app.log_failed_attempts) {
logger.info(`User session not found. IP: ${req.ip}.`);
}
return next(unauthorized());
}
@ -25,6 +29,9 @@ export const verifySessionUserMiddleware = async (
.where(eq(users.userId, user.userId));
if (!existingUser || !existingUser[0]) {
if (config.getRawConfig().app.log_failed_attempts) {
logger.info(`User session not found. IP: ${req.ip}.`);
}
return next(
createHttpError(HttpCode.BAD_REQUEST, "User does not exist")
);

View file

@ -79,6 +79,11 @@ export async function disable2fa(
);
if (!validOTP) {
if (config.getRawConfig().app.log_failed_attempts) {
logger.info(
`Two-factor authentication code is incorrect. Email: ${user.email}. IP: ${req.ip}.`
);
}
return next(
createHttpError(
HttpCode.BAD_REQUEST,

View file

@ -20,7 +20,10 @@ import { verifySession } from "@server/auth/sessions/verifySession";
export const loginBodySchema = z
.object({
email: z.string().email(),
email: z
.string()
.email()
.transform((v) => v.toLowerCase()),
password: z.string(),
code: z.string().optional()
})
@ -68,6 +71,11 @@ export async function login(
.from(users)
.where(eq(users.email, email));
if (!existingUserRes || !existingUserRes.length) {
if (config.getRawConfig().app.log_failed_attempts) {
logger.info(
`Username or password incorrect. Email: ${email}. IP: ${req.ip}.`
);
}
return next(
createHttpError(
HttpCode.BAD_REQUEST,
@ -83,6 +91,11 @@ export async function login(
existingUser.passwordHash
);
if (!validPassword) {
if (config.getRawConfig().app.log_failed_attempts) {
logger.info(
`Username or password incorrect. Email: ${email}. IP: ${req.ip}.`
);
}
return next(
createHttpError(
HttpCode.BAD_REQUEST,
@ -109,6 +122,11 @@ export async function login(
);
if (!validOTP) {
if (config.getRawConfig().app.log_failed_attempts) {
logger.info(
`Two-factor code incorrect. Email: ${email}. IP: ${req.ip}.`
);
}
return next(
createHttpError(
HttpCode.BAD_REQUEST,

View file

@ -5,18 +5,23 @@ import response from "@server/lib/response";
import logger from "@server/logger";
import {
createBlankSessionTokenCookie,
invalidateSession,
SESSION_COOKIE_NAME
invalidateSession
} from "@server/auth/sessions/app";
import { verifySession } from "@server/auth/sessions/verifySession";
import config from "@server/lib/config";
export async function logout(
req: Request,
res: Response,
next: NextFunction
): Promise<any> {
const sessionId = req.cookies[SESSION_COOKIE_NAME];
if (!sessionId) {
const { user, session } = await verifySession(req);
if (!user || !session) {
if (config.getRawConfig().app.log_failed_attempts) {
logger.info(
`Log out failed because missing or invalid session. IP: ${req.ip}.`
);
}
return next(
createHttpError(
HttpCode.BAD_REQUEST,
@ -26,7 +31,7 @@ export async function logout(
}
try {
await invalidateSession(sessionId);
await invalidateSession(session.sessionId);
const isSecure = req.protocol === "https";
res.setHeader("Set-Cookie", createBlankSessionTokenCookie(isSecure));

View file

@ -20,7 +20,10 @@ import { hashPassword } from "@server/auth/password";
export const requestPasswordResetBody = z
.object({
email: z.string().email()
email: z
.string()
.email()
.transform((v) => v.toLowerCase())
})
.strict();
@ -63,10 +66,7 @@ export async function requestPasswordReset(
);
}
const token = generateRandomString(
8,
alphabet("0-9", "A-Z", "a-z")
);
const token = generateRandomString(8, alphabet("0-9", "A-Z", "a-z"));
await db.transaction(async (trx) => {
await trx
.delete(passwordResetTokens)
@ -84,6 +84,10 @@ export async function requestPasswordReset(
const url = `${config.getRawConfig().app.dashboard_url}/auth/reset-password?email=${email}&token=${token}`;
if (!config.getRawConfig().email) {
logger.info(`Password reset requested for ${email}. Token: ${token}.`);
}
await sendEmail(
ResetPasswordCode({
email,
@ -91,7 +95,7 @@ export async function requestPasswordReset(
link: url
}),
{
from: config.getRawConfig().email?.no_reply,
from: config.getNoReplyEmail(),
to: email,
subject: "Reset your password"
}

View file

@ -19,7 +19,10 @@ import { passwordSchema } from "@server/auth/passwordSchema";
export const resetPasswordBody = z
.object({
email: z.string().email(),
email: z
.string()
.email()
.transform((v) => v.toLowerCase()),
token: z.string(), // reset secret code
newPassword: passwordSchema,
code: z.string().optional() // 2fa code
@ -57,6 +60,11 @@ export async function resetPassword(
.where(eq(passwordResetTokens.email, email));
if (!resetRequest || !resetRequest.length) {
if (config.getRawConfig().app.log_failed_attempts) {
logger.info(
`Password reset code is incorrect. Email: ${email}. IP: ${req.ip}.`
);
}
return next(
createHttpError(
HttpCode.BAD_REQUEST,
@ -106,6 +114,11 @@ export async function resetPassword(
);
if (!validOTP) {
if (config.getRawConfig().app.log_failed_attempts) {
logger.info(
`Two-factor authentication code is incorrect. Email: ${email}. IP: ${req.ip}.`
);
}
return next(
createHttpError(
HttpCode.BAD_REQUEST,
@ -121,6 +134,11 @@ export async function resetPassword(
);
if (!isTokenValid) {
if (config.getRawConfig().app.log_failed_attempts) {
logger.info(
`Password reset code is incorrect. Email: ${email}. IP: ${req.ip}.`
);
}
return next(
createHttpError(
HttpCode.BAD_REQUEST,
@ -145,7 +163,7 @@ export async function resetPassword(
});
await sendEmail(ConfirmPasswordReset({ email }), {
from: config.getRawConfig().email?.no_reply,
from: config.getNoReplyEmail(),
to: email,
subject: "Password Reset Confirmation"
});

View file

@ -23,7 +23,10 @@ import { checkValidInvite } from "@server/auth/checkValidInvite";
import { passwordSchema } from "@server/auth/passwordSchema";
export const signupBodySchema = z.object({
email: z.string().email(),
email: z
.string()
.email()
.transform((v) => v.toLowerCase()),
password: passwordSchema,
inviteToken: z.string().optional(),
inviteId: z.string().optional()
@ -60,6 +63,11 @@ export async function signup(
if (config.getRawConfig().flags?.disable_signup_without_invite) {
if (!inviteToken || !inviteId) {
if (config.getRawConfig().app.log_failed_attempts) {
logger.info(
`Signup blocked without invite. Email: ${email}. IP: ${req.ip}.`
);
}
return next(
createHttpError(
HttpCode.BAD_REQUEST,
@ -84,6 +92,11 @@ export async function signup(
}
if (existingInvite.email !== email) {
if (config.getRawConfig().app.log_failed_attempts) {
logger.info(
`User attempted to use an invite for another user. Email: ${email}. IP: ${req.ip}.`
);
}
return next(
createHttpError(
HttpCode.BAD_REQUEST,
@ -185,6 +198,11 @@ export async function signup(
});
} catch (e) {
if (e instanceof SqliteError && e.code === "SQLITE_CONSTRAINT_UNIQUE") {
if (config.getRawConfig().app.log_failed_attempts) {
logger.info(
`Account already exists with that email. Email: ${email}. IP: ${req.ip}.`
);
}
return next(
createHttpError(
HttpCode.BAD_REQUEST,

View file

@ -75,6 +75,11 @@ export async function verifyEmail(
.where(eq(users.userId, user.userId));
});
} else {
if (config.getRawConfig().app.log_failed_attempts) {
logger.info(
`Email verification code incorrect. Email: ${user.email}. IP: ${req.ip}.`
);
}
return next(
createHttpError(
HttpCode.BAD_REQUEST,

View file

@ -96,6 +96,11 @@ export async function verifyTotp(
}
if (!valid) {
if (config.getRawConfig().app.log_failed_attempts) {
logger.info(
`Two-factor authentication code is incorrect. Email: ${user.email}. IP: ${req.ip}.`
);
}
return next(
createHttpError(
HttpCode.BAD_REQUEST,

View file

@ -0,0 +1,187 @@
import HttpCode from "@server/types/HttpCode";
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 { resourceAccessToken, resources, sessions } from "@server/db/schema";
import db from "@server/db";
import { eq } from "drizzle-orm";
import {
createResourceSession,
serializeResourceSessionCookie,
validateResourceSessionToken
} from "@server/auth/sessions/resource";
import { generateSessionToken, SESSION_COOKIE_EXPIRES } from "@server/auth/sessions/app";
import { SESSION_COOKIE_EXPIRES as RESOURCE_SESSION_COOKIE_EXPIRES } from "@server/auth/sessions/resource";
import config from "@server/lib/config";
import { response } from "@server/lib";
const exchangeSessionBodySchema = z.object({
requestToken: z.string(),
host: z.string(),
requestIp: z.string().optional()
});
export type ExchangeSessionBodySchema = z.infer<
typeof exchangeSessionBodySchema
>;
export type ExchangeSessionResponse = {
valid: boolean;
cookie?: string;
};
export async function exchangeSession(
req: Request,
res: Response,
next: NextFunction
): Promise<any> {
logger.debug("Exchange session: Badger sent", req.body);
const parsedBody = exchangeSessionBodySchema.safeParse(req.body);
if (!parsedBody.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedBody.error).toString()
)
);
}
try {
const { requestToken, host, requestIp } = parsedBody.data;
const clientIp = requestIp?.split(":")[0];
const [resource] = await db
.select()
.from(resources)
.where(eq(resources.fullDomain, host))
.limit(1);
if (!resource) {
return next(
createHttpError(
HttpCode.NOT_FOUND,
`Resource with host ${host} not found`
)
);
}
const { resourceSession: requestSession } =
await validateResourceSessionToken(
requestToken,
resource.resourceId
);
if (!requestSession) {
if (config.getRawConfig().app.log_failed_attempts) {
logger.info(
`Exchange token is invalid. Resource ID: ${resource.resourceId}. IP: ${clientIp}.`
);
}
return next(
createHttpError(HttpCode.UNAUTHORIZED, "Invalid request token")
);
}
if (!requestSession.isRequestToken) {
if (config.getRawConfig().app.log_failed_attempts) {
logger.info(
`Exchange token is invalid. Resource ID: ${resource.resourceId}. IP: ${clientIp}.`
);
}
return next(
createHttpError(HttpCode.UNAUTHORIZED, "Invalid request token")
);
}
await db.delete(sessions).where(eq(sessions.sessionId, requestToken));
const token = generateSessionToken();
if (requestSession.userSessionId) {
const [res] = await db
.select()
.from(sessions)
.where(eq(sessions.sessionId, requestSession.userSessionId))
.limit(1);
if (res) {
await createResourceSession({
token,
resourceId: resource.resourceId,
isRequestToken: false,
userSessionId: requestSession.userSessionId,
doNotExtend: false,
expiresAt: res.expiresAt,
sessionLength: SESSION_COOKIE_EXPIRES
});
}
} else if (requestSession.accessTokenId) {
const [res] = await db
.select()
.from(resourceAccessToken)
.where(
eq(
resourceAccessToken.accessTokenId,
requestSession.accessTokenId
)
)
.limit(1);
if (res) {
await createResourceSession({
token,
resourceId: resource.resourceId,
isRequestToken: false,
accessTokenId: requestSession.accessTokenId,
doNotExtend: true,
expiresAt: res.expiresAt,
sessionLength: res.sessionLength
});
}
} else {
await createResourceSession({
token,
resourceId: resource.resourceId,
isRequestToken: false,
passwordId: requestSession.passwordId,
pincodeId: requestSession.pincodeId,
userSessionId: requestSession.userSessionId,
whitelistId: requestSession.whitelistId,
accessTokenId: requestSession.accessTokenId,
doNotExtend: false,
expiresAt: new Date(
Date.now() + SESSION_COOKIE_EXPIRES
).getTime(),
sessionLength: RESOURCE_SESSION_COOKIE_EXPIRES
});
}
const cookieName = `${config.getRawConfig().server.session_cookie_name}`;
const cookie = serializeResourceSessionCookie(
cookieName,
resource.fullDomain!,
token,
!resource.ssl
);
logger.debug(JSON.stringify("Exchange cookie: " + cookie));
return response<ExchangeSessionResponse>(res, {
data: { valid: true, cookie },
success: true,
error: false,
message: "Session exchanged successfully",
status: HttpCode.OK
});
} catch (e) {
console.error(e);
return next(
createHttpError(
HttpCode.INTERNAL_SERVER_ERROR,
"Failed to exchange session"
)
);
}
}

View file

@ -1 +1,2 @@
export * from "./verifySession";
export * from "./exchangeSession";

View file

@ -4,17 +4,17 @@ import createHttpError from "http-errors";
import { z } from "zod";
import { fromError } from "zod-validation-error";
import { response } from "@server/lib/response";
import { validateSessionToken } from "@server/auth/sessions/app";
import db from "@server/db";
import {
ResourceAccessToken,
resourceAccessToken,
ResourcePassword,
resourcePassword,
ResourcePincode,
resourcePincode,
resources,
resourceWhitelist,
User,
userOrgs
sessions,
userOrgs,
users
} from "@server/db/schema";
import { and, eq } from "drizzle-orm";
import config from "@server/lib/config";
@ -26,7 +26,13 @@ import {
import { Resource, roleResources, userResources } from "@server/db/schema";
import logger from "@server/logger";
import { verifyResourceAccessToken } from "@server/auth/verifyResourceAccessToken";
import { generateSessionToken } from "@server/auth";
import NodeCache from "node-cache";
import { generateSessionToken } from "@server/auth/sessions/app";
// We'll see if this speeds anything up
const cache = new NodeCache({
stdTTL: 5 // seconds
});
const verifyResourceSessionSchema = z.object({
sessions: z.record(z.string()).optional(),
@ -36,7 +42,8 @@ const verifyResourceSessionSchema = z.object({
path: z.string(),
method: z.string(),
accessToken: z.string().optional(),
tls: z.boolean()
tls: z.boolean(),
requestIp: z.string().optional()
});
export type VerifyResourceSessionSchema = z.infer<
@ -53,7 +60,7 @@ export async function verifyResourceSession(
res: Response,
next: NextFunction
): Promise<any> {
logger.debug("Badger sent", req.body); // remove when done testing
logger.debug("Verify session: Badger sent", req.body); // remove when done testing
const parsedBody = verifyResourceSessionSchema.safeParse(req.body);
@ -67,26 +74,55 @@ export async function verifyResourceSession(
}
try {
const { sessions, host, originalRequestURL, accessToken: token } =
parsedBody.data;
const {
sessions,
host,
originalRequestURL,
requestIp,
accessToken: token
} = parsedBody.data;
const [result] = await db
.select()
.from(resources)
.leftJoin(
resourcePincode,
eq(resourcePincode.resourceId, resources.resourceId)
)
.leftJoin(
resourcePassword,
eq(resourcePassword.resourceId, resources.resourceId)
)
.where(eq(resources.fullDomain, host))
.limit(1);
const clientIp = requestIp?.split(":")[0];
const resource = result?.resources;
const pincode = result?.resourcePincode;
const password = result?.resourcePassword;
const resourceCacheKey = `resource:${host}`;
let resourceData:
| {
resource: Resource | null;
pincode: ResourcePincode | null;
password: ResourcePassword | null;
}
| undefined = cache.get(resourceCacheKey);
if (!resourceData) {
const [result] = await db
.select()
.from(resources)
.leftJoin(
resourcePincode,
eq(resourcePincode.resourceId, resources.resourceId)
)
.leftJoin(
resourcePassword,
eq(resourcePassword.resourceId, resources.resourceId)
)
.where(eq(resources.fullDomain, host))
.limit(1);
if (!result) {
logger.debug("Resource not found", host);
return notAllowed(res);
}
resourceData = {
resource: result.resources,
pincode: result.resourcePincode,
password: result.resourcePassword
};
cache.set(resourceCacheKey, resourceData);
}
const { resource, pincode, password } = resourceData;
if (!resource) {
logger.debug("Resource not found", host);
@ -128,6 +164,14 @@ export async function verifyResourceSession(
logger.debug("Access token invalid: " + error);
}
if (!valid) {
if (config.getRawConfig().app.log_failed_attempts) {
logger.info(
`Resource access token is invalid. Resource ID: ${resource.resourceId}. IP: ${clientIp}.`
);
}
}
if (valid && tokenItem) {
validAccessToken = tokenItem;
@ -142,40 +186,44 @@ export async function verifyResourceSession(
}
if (!sessions) {
return notAllowed(res);
}
const sessionToken =
sessions[config.getRawConfig().server.session_cookie_name];
// check for unified login
if (sso && sessionToken) {
const { session, user } = await validateSessionToken(sessionToken);
if (session && user) {
const isAllowed = await isUserAllowedToAccessResource(
user,
resource
if (config.getRawConfig().app.log_failed_attempts) {
logger.info(
`Missing resource sessions. Resource ID: ${resource.resourceId}. IP: ${clientIp}.`
);
if (isAllowed) {
logger.debug(
"Resource allowed because user session is valid"
);
return allowed(res);
}
}
return notAllowed(res);
}
const resourceSessionToken =
sessions[
`${config.getRawConfig().server.resource_session_cookie_name}_${resource.resourceId}`
`${config.getRawConfig().server.session_cookie_name}${resource.ssl ? "_s" : ""}`
];
if (resourceSessionToken) {
const { resourceSession } = await validateResourceSessionToken(
resourceSessionToken,
resource.resourceId
);
const sessionCacheKey = `session:${resourceSessionToken}`;
let resourceSession: any = cache.get(sessionCacheKey);
if (!resourceSession) {
const result = await validateResourceSessionToken(
resourceSessionToken,
resource.resourceId
);
resourceSession = result?.resourceSession;
cache.set(sessionCacheKey, resourceSession);
}
if (resourceSession?.isRequestToken) {
logger.debug(
"Resource not allowed because session is a temporary request token"
);
if (config.getRawConfig().app.log_failed_attempts) {
logger.info(
`Resource session is an exchange token. Resource ID: ${resource.resourceId}. IP: ${clientIp}.`
);
}
return notAllowed(res);
}
if (resourceSession) {
if (pincode && resourceSession.pincodeId) {
@ -208,6 +256,29 @@ export async function verifyResourceSession(
);
return allowed(res);
}
if (resourceSession.userSessionId && sso) {
const userAccessCacheKey = `userAccess:${resourceSession.userSessionId}:${resource.resourceId}`;
let isAllowed: boolean | undefined =
cache.get(userAccessCacheKey);
if (isAllowed === undefined) {
isAllowed = await isUserAllowedToAccessResource(
resourceSession.userSessionId,
resource
);
cache.set(userAccessCacheKey, isAllowed);
}
if (isAllowed) {
logger.debug(
"Resource allowed because user session is valid"
);
return allowed(res);
}
}
}
}
@ -222,6 +293,12 @@ export async function verifyResourceSession(
}
logger.debug("No more auth to check, resource not allowed");
if (config.getRawConfig().app.log_failed_attempts) {
logger.info(
`Resource access not allowed. Resource ID: ${resource.resourceId}. IP: ${clientIp}.`
);
}
return notAllowed(res, redirectUrl);
} catch (e) {
console.error(e);
@ -272,10 +349,15 @@ async function createAccessTokenSession(
expiresAt: tokenItem.expiresAt,
doNotExtend: tokenItem.expiresAt ? true : false
});
const cookieName = `${config.getRawConfig().server.resource_session_cookie_name}_${resource.resourceId}`;
const cookie = serializeResourceSessionCookie(cookieName, token);
const cookieName = `${config.getRawConfig().server.session_cookie_name}`;
const cookie = serializeResourceSessionCookie(
cookieName,
resource.fullDomain!,
token,
!resource.ssl
);
res.appendHeader("Set-Cookie", cookie);
logger.debug("Access token is valid, creating new session")
logger.debug("Access token is valid, creating new session");
return response<VerifyUserResponse>(res, {
data: { valid: true },
success: true,
@ -286,9 +368,22 @@ async function createAccessTokenSession(
}
async function isUserAllowedToAccessResource(
user: User,
userSessionId: string,
resource: Resource
): Promise<boolean> {
const [res] = await db
.select()
.from(sessions)
.leftJoin(users, eq(users.userId, sessions.userId))
.where(eq(sessions.sessionId, userSessionId));
const user = res.user;
const session = res.session;
if (!user || !session) {
return false;
}
if (
config.getRawConfig().flags?.require_email_verification &&
!user.emailVerified

View file

@ -1,9 +1,11 @@
import { Router } from "express";
import * as gerbil from "@server/routers/gerbil";
import * as badger from "@server/routers/badger";
import * as traefik from "@server/routers/traefik";
import * as resource from "./resource";
import * as badger from "./badger";
import * as auth from "@server/routers/auth";
import HttpCode from "@server/types/HttpCode";
import { verifyResourceAccess, verifySessionUserMiddleware } from "@server/middlewares";
// Root routes
const internalRouter = Router();
@ -13,9 +15,17 @@ internalRouter.get("/", (_, res) => {
});
internalRouter.get("/traefik-config", traefik.traefikConfigProvider);
internalRouter.get(
"/resource-session/:resourceId/:token",
auth.checkResourceSession,
auth.checkResourceSession
);
internalRouter.post(
`/resource/:resourceId/get-exchange-token`,
verifySessionUserMiddleware,
verifyResourceAccess,
resource.getExchangeToken
);
// Gerbil routes
@ -30,5 +40,6 @@ const badgerRouter = Router();
internalRouter.use("/badger", badgerRouter);
badgerRouter.post("/verify-session", badger.verifyResourceSession);
badgerRouter.post("/exchange-session", badger.exchangeSession);
export default internalRouter;

View file

@ -1,6 +1,4 @@
import {
generateSessionToken,
} from "@server/auth/sessions/app";
import { generateSessionToken } from "@server/auth/sessions/app";
import db from "@server/db";
import { newts } from "@server/db/schema";
import HttpCode from "@server/types/HttpCode";
@ -10,8 +8,13 @@ import { NextFunction, Request, Response } from "express";
import createHttpError from "http-errors";
import { z } from "zod";
import { fromError } from "zod-validation-error";
import { createNewtSession, validateNewtSessionToken } from "@server/auth/sessions/newt";
import {
createNewtSession,
validateNewtSessionToken
} from "@server/auth/sessions/newt";
import { verifyPassword } from "@server/auth/password";
import logger from "@server/logger";
import config from "@server/lib/config";
export const newtGetTokenBodySchema = z.object({
newtId: z.string(),
@ -43,6 +46,11 @@ export async function getToken(
if (token) {
const { session, newt } = await validateNewtSessionToken(token);
if (session) {
if (config.getRawConfig().app.log_failed_attempts) {
logger.info(
`Newt session already valid. Newt ID: ${newtId}. IP: ${req.ip}.`
);
}
return response<null>(res, {
data: null,
success: true,
@ -73,6 +81,11 @@ export async function getToken(
existingNewt.secretHash
);
if (!validSecret) {
if (config.getRawConfig().app.log_failed_attempts) {
logger.info(
`Newt id or secret is incorrect. Newt: ID ${newtId}. IP: ${req.ip}.`
);
}
return next(
createHttpError(HttpCode.BAD_REQUEST, "Secret is incorrect")
);

View file

@ -1,7 +1,13 @@
import db from "@server/db";
import { MessageHandler } from "../ws";
import { exitNodes, resources, sites, targets } from "@server/db/schema";
import { eq, inArray } from "drizzle-orm";
import {
exitNodes,
resources,
sites,
Target,
targets
} from "@server/db/schema";
import { eq, and, sql } from "drizzle-orm";
import { addPeer, deletePeer } from "../gerbil/peers";
import logger from "@server/logger";
@ -69,37 +75,68 @@ export const handleRegisterMessage: MessageHandler = async (context) => {
allowedIps: [site.subnet]
});
const siteResources = await db
.select()
const allResources = await db
.select({
// Resource fields
resourceId: resources.resourceId,
subdomain: resources.subdomain,
fullDomain: resources.fullDomain,
ssl: resources.ssl,
blockAccess: resources.blockAccess,
sso: resources.sso,
emailWhitelistEnabled: resources.emailWhitelistEnabled,
http: resources.http,
proxyPort: resources.proxyPort,
protocol: resources.protocol,
// Targets as a subquery
targets: sql<string>`json_group_array(json_object(
'targetId', ${targets.targetId},
'ip', ${targets.ip},
'method', ${targets.method},
'port', ${targets.port},
'internalPort', ${targets.internalPort},
'enabled', ${targets.enabled}
))`.as("targets")
})
.from(resources)
.where(eq(resources.siteId, siteId));
// get the targets from the resourceIds
const siteTargets = await db
.select()
.from(targets)
.where(
inArray(
targets.resourceId,
siteResources.map((resource) => resource.resourceId)
.leftJoin(
targets,
and(
eq(targets.resourceId, resources.resourceId),
eq(targets.enabled, true)
)
);
)
.where(eq(resources.siteId, siteId))
.groupBy(resources.resourceId);
const udpTargets = siteTargets
.filter((target) => target.protocol === "udp")
.map((target) => {
return `${target.internalPort ? target.internalPort + ":" : ""}${
target.ip
}:${target.port}`;
});
let tcpTargets: string[] = [];
let udpTargets: string[] = [];
const tcpTargets = siteTargets
.filter((target) => target.protocol === "tcp")
.map((target) => {
return `${target.internalPort ? target.internalPort + ":" : ""}${
target.ip
}:${target.port}`;
});
for (const resource of allResources) {
const targets = JSON.parse(resource.targets);
if (!targets || targets.length === 0) {
continue;
}
if (resource.protocol === "tcp") {
tcpTargets = tcpTargets.concat(
targets.map(
(target: Target) =>
`${
target.internalPort ? target.internalPort + ":" : ""
}${target.ip}:${target.port}`
)
);
} else {
udpTargets = tcpTargets.concat(
targets.map(
(target: Target) =>
`${
target.internalPort ? target.internalPort + ":" : ""
}${target.ip}:${target.port}`
)
);
}
}
return {
message: {

View file

@ -1,73 +1,44 @@
import { Target } from "@server/db/schema";
import { sendToClient } from "../ws";
export async function addTargets(newtId: string, targets: Target[]): Promise<void> {
export async function addTargets(
newtId: string,
targets: Target[],
protocol: string
): Promise<void> {
//create a list of udp and tcp targets
const udpTargets = targets
.filter((target) => target.protocol === "udp")
.map((target) => {
return `${target.internalPort ? target.internalPort + ":" : ""}${target.ip}:${target.port}`;
});
const payloadTargets = targets.map((target) => {
return `${target.internalPort ? target.internalPort + ":" : ""}${
target.ip
}:${target.port}`;
});
const tcpTargets = targets
.filter((target) => target.protocol === "tcp")
.map((target) => {
return `${target.internalPort ? target.internalPort + ":" : ""}${target.ip}:${target.port}`;
});
if (udpTargets.length > 0) {
const payload = {
type: `newt/udp/add`,
data: {
targets: udpTargets,
},
};
sendToClient(newtId, payload);
}
if (tcpTargets.length > 0) {
const payload = {
type: `newt/tcp/add`,
data: {
targets: tcpTargets,
},
};
sendToClient(newtId, payload);
}
const payload = {
type: `newt/${protocol}/add`,
data: {
targets: payloadTargets
}
};
sendToClient(newtId, payload);
}
export async function removeTargets(newtId: string, targets: Target[]): Promise<void> {
export async function removeTargets(
newtId: string,
targets: Target[],
protocol: string
): Promise<void> {
//create a list of udp and tcp targets
const udpTargets = targets
.filter((target) => target.protocol === "udp")
.map((target) => {
return `${target.internalPort ? target.internalPort + ":" : ""}${target.ip}:${target.port}`;
});
const payloadTargets = targets.map((target) => {
return `${target.internalPort ? target.internalPort + ":" : ""}${
target.ip
}:${target.port}`;
});
const tcpTargets = targets
.filter((target) => target.protocol === "tcp")
.map((target) => {
return `${target.internalPort ? target.internalPort + ":" : ""}${target.ip}:${target.port}`;
});
if (udpTargets.length > 0) {
const payload = {
type: `newt/udp/remove`,
data: {
targets: udpTargets,
},
};
sendToClient(newtId, payload);
}
if (tcpTargets.length > 0) {
const payload = {
type: `newt/tcp/remove`,
data: {
targets: tcpTargets,
},
};
sendToClient(newtId, payload);
}
const payload = {
type: `newt/${protocol}/remove`,
data: {
targets: payloadTargets
}
};
sendToClient(newtId, payload);
}

View file

@ -1,20 +1,17 @@
import { generateSessionToken } from "@server/auth/sessions/app";
import db from "@server/db";
import { resourceAccessToken, resources } from "@server/db/schema";
import { resources } from "@server/db/schema";
import HttpCode from "@server/types/HttpCode";
import response from "@server/lib/response";
import { eq, and } from "drizzle-orm";
import { 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 {
createResourceSession,
serializeResourceSessionCookie
} from "@server/auth/sessions/resource";
import config from "@server/lib/config";
import { createResourceSession } from "@server/auth/sessions/resource";
import logger from "@server/logger";
import { verifyResourceAccessToken } from "@server/auth/verifyResourceAccessToken";
import config from "@server/lib/config";
const authWithAccessTokenBodySchema = z
.object({
@ -86,6 +83,11 @@ export async function authWithAccessToken(
});
if (!valid) {
if (config.getRawConfig().app.log_failed_attempts) {
logger.info(
`Resource access token invalid. Resource ID: ${resource.resourceId}. IP: ${req.ip}.`
);
}
return next(
createHttpError(
HttpCode.UNAUTHORIZED,
@ -108,13 +110,11 @@ export async function authWithAccessToken(
resourceId,
token,
accessTokenId: tokenItem.accessTokenId,
sessionLength: tokenItem.sessionLength,
expiresAt: tokenItem.expiresAt,
doNotExtend: tokenItem.expiresAt ? true : false
isRequestToken: true,
expiresAt: Date.now() + 1000 * 30, // 30 seconds
sessionLength: 1000 * 30,
doNotExtend: true
});
const cookieName = `${config.getRawConfig().server.resource_session_cookie_name}_${resource.resourceId}`;
const cookie = serializeResourceSessionCookie(cookieName, token);
res.appendHeader("Set-Cookie", cookie);
return response<AuthWithAccessTokenResponse>(res, {
data: {

View file

@ -9,13 +9,10 @@ import { NextFunction, Request, Response } from "express";
import createHttpError from "http-errors";
import { z } from "zod";
import { fromError } from "zod-validation-error";
import {
createResourceSession,
serializeResourceSessionCookie
} from "@server/auth/sessions/resource";
import config from "@server/lib/config";
import { createResourceSession } from "@server/auth/sessions/resource";
import logger from "@server/logger";
import { verifyPassword } from "@server/auth/password";
import config from "@server/lib/config";
export const authWithPasswordBodySchema = z
.object({
@ -84,7 +81,7 @@ export async function authWithPassword(
if (!org) {
return next(
createHttpError(HttpCode.BAD_REQUEST, "Resource does not exist")
createHttpError(HttpCode.BAD_REQUEST, "Org does not exist")
);
}
@ -111,6 +108,11 @@ export async function authWithPassword(
definedPassword.passwordHash
);
if (!validPassword) {
if (config.getRawConfig().app.log_failed_attempts) {
logger.info(
`Resource password incorrect. Resource ID: ${resource.resourceId}. IP: ${req.ip}.`
);
}
return next(
createHttpError(HttpCode.UNAUTHORIZED, "Incorrect password")
);
@ -120,11 +122,12 @@ export async function authWithPassword(
await createResourceSession({
resourceId,
token,
passwordId: definedPassword.passwordId
passwordId: definedPassword.passwordId,
isRequestToken: true,
expiresAt: Date.now() + 1000 * 30, // 30 seconds
sessionLength: 1000 * 30,
doNotExtend: true
});
const cookieName = `${config.getRawConfig().server.resource_session_cookie_name}_${resource.resourceId}`;
const cookie = serializeResourceSessionCookie(cookieName, token);
res.appendHeader("Set-Cookie", cookie);
return response<AuthWithPasswordResponse>(res, {
data: {

View file

@ -1,29 +1,17 @@
import { verify } from "@node-rs/argon2";
import { generateSessionToken } from "@server/auth/sessions/app";
import db from "@server/db";
import {
orgs,
resourceOtp,
resourcePincode,
resources,
resourceWhitelist
} from "@server/db/schema";
import { orgs, resourcePincode, resources } from "@server/db/schema";
import HttpCode from "@server/types/HttpCode";
import response from "@server/lib/response";
import { and, eq } from "drizzle-orm";
import { 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 {
createResourceSession,
serializeResourceSessionCookie
} from "@server/auth/sessions/resource";
import { createResourceSession } from "@server/auth/sessions/resource";
import logger from "@server/logger";
import config from "@server/lib/config";
import { AuthWithPasswordResponse } from "./authWithPassword";
import { isValidOtp, sendResourceOtpEmail } from "@server/auth/resourceOtp";
import { verifyPassword } from "@server/auth/password";
import config from "@server/lib/config";
export const authWithPincodeBodySchema = z
.object({
@ -109,19 +97,21 @@ export async function authWithPincode(
return next(
createHttpError(
HttpCode.UNAUTHORIZED,
createHttpError(
HttpCode.BAD_REQUEST,
"Resource has no pincode protection"
)
"Resource has no pincode protection"
)
);
}
const validPincode = verifyPassword(
const validPincode = await verifyPassword(
pincode,
definedPincode.pincodeHash
);
if (!validPincode) {
if (config.getRawConfig().app.log_failed_attempts) {
logger.info(
`Resource pin code incorrect. Resource ID: ${resource.resourceId}. IP: ${req.ip}.`
);
}
return next(
createHttpError(HttpCode.UNAUTHORIZED, "Incorrect PIN")
);
@ -131,11 +121,12 @@ export async function authWithPincode(
await createResourceSession({
resourceId,
token,
pincodeId: definedPincode.pincodeId
pincodeId: definedPincode.pincodeId,
isRequestToken: true,
expiresAt: Date.now() + 1000 * 30, // 30 seconds
sessionLength: 1000 * 30,
doNotExtend: true
});
const cookieName = `${config.getRawConfig().server.resource_session_cookie_name}_${resource.resourceId}`;
const cookie = serializeResourceSessionCookie(cookieName, token);
res.appendHeader("Set-Cookie", cookie);
return response<AuthWithPincodeResponse>(res, {
data: {

View file

@ -3,7 +3,6 @@ import db from "@server/db";
import {
orgs,
resourceOtp,
resourcePassword,
resources,
resourceWhitelist
} from "@server/db/schema";
@ -14,17 +13,17 @@ import { NextFunction, Request, Response } from "express";
import createHttpError from "http-errors";
import { z } from "zod";
import { fromError } from "zod-validation-error";
import {
createResourceSession,
serializeResourceSessionCookie
} from "@server/auth/sessions/resource";
import config from "@server/lib/config";
import { createResourceSession } from "@server/auth/sessions/resource";
import { isValidOtp, sendResourceOtpEmail } from "@server/auth/resourceOtp";
import logger from "@server/logger";
import config from "@server/lib/config";
const authWithWhitelistBodySchema = z
.object({
email: z.string().email(),
email: z
.string()
.email()
.transform((v) => v.toLowerCase()),
otp: z.string().optional()
})
.strict();
@ -90,20 +89,53 @@ export async function authWithWhitelist(
.leftJoin(orgs, eq(orgs.orgId, resources.orgId))
.limit(1);
const resource = result?.resources;
const org = result?.orgs;
const whitelistedEmail = result?.resourceWhitelist;
let resource = result?.resources;
let org = result?.orgs;
let whitelistedEmail = result?.resourceWhitelist;
if (!whitelistedEmail) {
return next(
createHttpError(
HttpCode.UNAUTHORIZED,
createHttpError(
HttpCode.BAD_REQUEST,
"Email is not whitelisted"
// if email is not found, check for wildcard email
const wildcard = "*@" + email.split("@")[1];
logger.debug("Checking for wildcard email: " + wildcard);
const [result] = await db
.select()
.from(resourceWhitelist)
.where(
and(
eq(resourceWhitelist.resourceId, resourceId),
eq(resourceWhitelist.email, wildcard)
)
)
);
.leftJoin(
resources,
eq(resources.resourceId, resourceWhitelist.resourceId)
)
.leftJoin(orgs, eq(orgs.orgId, resources.orgId))
.limit(1);
resource = result?.resources;
org = result?.orgs;
whitelistedEmail = result?.resourceWhitelist;
// if wildcard is still not found, return unauthorized
if (!whitelistedEmail) {
if (config.getRawConfig().app.log_failed_attempts) {
logger.info(
`Email is not whitelisted. Email: ${email}. IP: ${req.ip}.`
);
}
return next(
createHttpError(
HttpCode.UNAUTHORIZED,
createHttpError(
HttpCode.BAD_REQUEST,
"Email is not whitelisted"
)
)
);
}
}
if (!org) {
@ -125,6 +157,11 @@ export async function authWithWhitelist(
otp
);
if (!isValidCode) {
if (config.getRawConfig().app.log_failed_attempts) {
logger.info(
`Resource email otp incorrect. Resource ID: ${resource.resourceId}. Email: ${email}. IP: ${req.ip}.`
);
}
return next(
createHttpError(HttpCode.UNAUTHORIZED, "Incorrect OTP")
);
@ -175,11 +212,12 @@ export async function authWithWhitelist(
await createResourceSession({
resourceId,
token,
whitelistId: whitelistedEmail.whitelistId
whitelistId: whitelistedEmail.whitelistId,
isRequestToken: true,
expiresAt: Date.now() + 1000 * 30, // 30 seconds
sessionLength: 1000 * 30,
doNotExtend: true
});
const cookieName = `${config.getRawConfig().server.resource_session_cookie_name}_${resource.resourceId}`;
const cookie = serializeResourceSessionCookie(cookieName, token);
res.appendHeader("Set-Cookie", cookie);
return response<AuthWithWhitelistResponse>(res, {
data: {

View file

@ -16,8 +16,8 @@ import createHttpError from "http-errors";
import { eq, and } from "drizzle-orm";
import stoi from "@server/lib/stoi";
import { fromError } from "zod-validation-error";
import { subdomainSchema } from "@server/schemas/subdomainSchema";
import logger from "@server/logger";
import { subdomainSchema } from "@server/schemas/subdomainSchema";
const createResourceParamsSchema = z
.object({
@ -28,10 +28,42 @@ const createResourceParamsSchema = z
const createResourceSchema = z
.object({
subdomain: z.string().optional(),
name: z.string().min(1).max(255),
subdomain: subdomainSchema
siteId: z.number(),
http: z.boolean(),
protocol: z.string(),
proxyPort: z.number().optional()
})
.strict();
.refine(
(data) => {
if (!data.http) {
return z
.number()
.int()
.min(1)
.max(65535)
.safeParse(data.proxyPort).success;
}
return true;
},
{
message: "Invalid port number",
path: ["proxyPort"]
}
)
.refine(
(data) => {
if (data.http) {
return subdomainSchema.safeParse(data.subdomain).success;
}
return true;
},
{
message: "Invalid subdomain",
path: ["subdomain"]
}
);
export type CreateResourceResponse = Resource;
@ -51,7 +83,7 @@ export async function createResource(
);
}
let { name, subdomain } = parsedBody.data;
let { name, subdomain, protocol, proxyPort, http } = parsedBody.data;
// Validate request params
const parsedParams = createResourceParamsSchema.safeParse(req.params);
@ -89,15 +121,64 @@ export async function createResource(
}
const fullDomain = `${subdomain}.${org[0].domain}`;
// if http is false check to see if there is already a resource with the same port and protocol
if (!http) {
const existingResource = await db
.select()
.from(resources)
.where(
and(
eq(resources.protocol, protocol),
eq(resources.proxyPort, proxyPort!)
)
);
if (existingResource.length > 0) {
return next(
createHttpError(
HttpCode.CONFLICT,
"Resource with that protocol and port already exists"
)
);
}
} else {
if (proxyPort === 443 || proxyPort === 80) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
"Port 80 and 443 are reserved for https resources"
)
);
}
// make sure the full domain is unique
const existingResource = await db
.select()
.from(resources)
.where(eq(resources.fullDomain, fullDomain));
if (existingResource.length > 0) {
return next(
createHttpError(
HttpCode.CONFLICT,
"Resource with that domain already exists"
)
);
}
}
await db.transaction(async (trx) => {
const newResource = await trx
.insert(resources)
.values({
siteId,
fullDomain,
fullDomain: http ? fullDomain : null,
orgId,
name,
subdomain,
http,
protocol,
proxyPort,
ssl: true
})
.returning();
@ -135,18 +216,6 @@ export async function createResource(
});
});
} catch (error) {
if (
error instanceof SqliteError &&
error.code === "SQLITE_CONSTRAINT_UNIQUE"
) {
return next(
createHttpError(
HttpCode.CONFLICT,
"Resource with that subdomain already exists"
)
);
}
logger.error(error);
return next(
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")

View file

@ -103,7 +103,7 @@ export async function deleteResource(
.where(eq(newts.siteId, site.siteId))
.limit(1);
removeTargets(newt.newtId, targetsToBeRemoved);
removeTargets(newt.newtId, targetsToBeRemoved, deletedResource.protocol);
}
}

View file

@ -0,0 +1,109 @@
import { Request, Response, NextFunction } from "express";
import { z } from "zod";
import { db } from "@server/db";
import { resources } from "@server/db/schema";
import { eq } 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 { generateSessionToken } from "@server/auth/sessions/app";
import config from "@server/lib/config";
import {
encodeHexLowerCase
} from "@oslojs/encoding";
import { sha256 } from "@oslojs/crypto/sha2";
import { response } from "@server/lib";
const getExchangeTokenParams = z
.object({
resourceId: z
.string()
.transform(Number)
.pipe(z.number().int().positive())
})
.strict();
export type GetExchangeTokenResponse = {
requestToken: string;
};
export async function getExchangeToken(
req: Request,
res: Response,
next: NextFunction
): Promise<any> {
try {
const parsedParams = getExchangeTokenParams.safeParse(req.params);
if (!parsedParams.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedParams.error).toString()
)
);
}
const { resourceId } = parsedParams.data;
const resource = await db
.select()
.from(resources)
.where(eq(resources.resourceId, resourceId))
.limit(1);
if (resource.length === 0) {
return next(
createHttpError(
HttpCode.NOT_FOUND,
`Resource with ID ${resourceId} not found`
)
);
}
const ssoSession =
req.cookies[config.getRawConfig().server.session_cookie_name];
if (!ssoSession) {
logger.debug(ssoSession);
return next(
createHttpError(
HttpCode.UNAUTHORIZED,
"Missing SSO session cookie"
)
);
}
const sessionId = encodeHexLowerCase(
sha256(new TextEncoder().encode(ssoSession))
);
const token = generateSessionToken();
await createResourceSession({
resourceId,
token,
userSessionId: sessionId,
isRequestToken: true,
expiresAt: Date.now() + 1000 * 30, // 30 seconds
sessionLength: 1000 * 30,
doNotExtend: true
});
logger.debug("Request token created successfully");
return response<GetExchangeTokenResponse>(res, {
data: {
requestToken: token
},
success: true,
error: false,
message: "Request token created successfully",
status: HttpCode.OK
});
} catch (error) {
logger.error(error);
return next(
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
);
}
}

View file

@ -17,3 +17,4 @@ export * from "./getResourceWhitelist";
export * from "./authWithWhitelist";
export * from "./authWithAccessToken";
export * from "./transferResource";
export * from "./getExchangeToken";

View file

@ -63,7 +63,10 @@ function queryResources(
passwordId: resourcePassword.passwordId,
pincodeId: resourcePincode.pincodeId,
sso: resources.sso,
whitelist: resources.emailWhitelistEnabled
whitelist: resources.emailWhitelistEnabled,
http: resources.http,
protocol: resources.protocol,
proxyPort: resources.proxyPort
})
.from(resources)
.leftJoin(sites, eq(resources.siteId, sites.siteId))
@ -93,7 +96,10 @@ function queryResources(
passwordId: resourcePassword.passwordId,
sso: resources.sso,
pincodeId: resourcePincode.pincodeId,
whitelist: resources.emailWhitelistEnabled
whitelist: resources.emailWhitelistEnabled,
http: resources.http,
protocol: resources.protocol,
proxyPort: resources.proxyPort
})
.from(resources)
.leftJoin(sites, eq(resources.siteId, sites.siteId))

View file

@ -11,7 +11,20 @@ import { and, eq } from "drizzle-orm";
const setResourceWhitelistBodySchema = z
.object({
emails: z.array(z.string().email()).max(50)
emails: z
.array(
z
.string()
.email()
.or(
z.string().regex(/^\*@[\w.-]+\.[a-zA-Z]{2,}$/, {
message:
"Invalid email address. Wildcard (*) must be the entire local part."
})
)
)
.max(50)
.transform((v) => v.map((e) => e.toLowerCase()))
})
.strict();

View file

@ -26,8 +26,8 @@ const updateResourceBodySchema = z
ssl: z.boolean().optional(),
sso: z.boolean().optional(),
blockAccess: z.boolean().optional(),
proxyPort: z.number().int().min(1).max(65535).optional(),
emailWhitelistEnabled: z.boolean().optional()
// siteId: z.number(),
})
.strict()
.refine((data) => Object.keys(data).length > 0, {
@ -111,6 +111,10 @@ export async function updateResource(
);
}
if (resource[0].resources.ssl !== updatedResource[0].ssl) {
// invalidate all sessions?
}
return response(res, {
data: updatedResource[0],
success: true,

View file

@ -7,10 +7,11 @@ import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors";
import logger from "@server/logger";
import { addPeer } from "../gerbil/peers";
import { eq, and } from "drizzle-orm";
import { isIpInCidr } from "@server/lib/ip";
import { fromError } from "zod-validation-error";
import { addTargets } from "../newt/targets";
import { eq } from "drizzle-orm";
import { pickPort } from "./ports";
// Regular expressions for validation
const DOMAIN_REGEX =
@ -52,9 +53,8 @@ const createTargetParamsSchema = z
const createTargetSchema = z
.object({
ip: domainSchema,
method: z.string().min(1).max(10),
method: z.string().optional().nullable(),
port: z.number().int().min(1).max(65535),
protocol: z.string().optional(),
enabled: z.boolean().default(true)
})
.strict();
@ -93,9 +93,7 @@ export async function createTarget(
// get the resource
const [resource] = await db
.select({
siteId: resources.siteId
})
.select()
.from(resources)
.where(eq(resources.resourceId, resourceId));
@ -129,7 +127,6 @@ export async function createTarget(
.insert(targets)
.values({
resourceId,
protocol: "tcp", // hard code for now
...targetData
})
.returning();
@ -147,37 +144,7 @@ export async function createTarget(
);
}
// Fetch resources for this site
const resourcesRes = await db.query.resources.findMany({
where: eq(resources.siteId, site.siteId)
});
// TODO: is this all inefficient?
// Fetch targets for all resources of this site
let targetIps: string[] = [];
let targetInternalPorts: number[] = [];
await Promise.all(
resourcesRes.map(async (resource) => {
const targetsRes = await db.query.targets.findMany({
where: eq(targets.resourceId, resource.resourceId)
});
targetsRes.forEach((target) => {
targetIps.push(`${target.ip}/32`);
if (target.internalPort) {
targetInternalPorts.push(target.internalPort);
}
});
})
);
let internalPort!: number;
// pick a port
for (let i = 40000; i < 65535; i++) {
if (!targetInternalPorts.includes(i)) {
internalPort = i;
break;
}
}
const { internalPort, targetIps } = await pickPort(site.siteId!);
if (!internalPort) {
return next(
@ -192,7 +159,6 @@ export async function createTarget(
.insert(targets)
.values({
resourceId,
protocol: "tcp", // hard code for now
internalPort,
...targetData
})
@ -215,7 +181,7 @@ export async function createTarget(
.where(eq(newts.siteId, site.siteId))
.limit(1);
addTargets(newt.newtId, newTarget);
addTargets(newt.newtId, newTarget, resource.protocol);
}
}
}

View file

@ -50,9 +50,7 @@ export async function deleteTarget(
}
// get the resource
const [resource] = await db
.select({
siteId: resources.siteId
})
.select()
.from(resources)
.where(eq(resources.resourceId, deletedTarget.resourceId!));
@ -110,7 +108,7 @@ export async function deleteTarget(
.where(eq(newts.siteId, site.siteId))
.limit(1);
removeTargets(newt.newtId, [deletedTarget]);
removeTargets(newt.newtId, [deletedTarget], resource.protocol);
}
}

View file

@ -40,7 +40,6 @@ function queryTargets(resourceId: number) {
ip: targets.ip,
method: targets.method,
port: targets.port,
protocol: targets.protocol,
enabled: targets.enabled,
resourceId: targets.resourceId
// resourceName: resources.name,

View file

@ -0,0 +1,48 @@
import { db } from "@server/db";
import { resources, targets } from "@server/db/schema";
import { eq } from "drizzle-orm";
let currentBannedPorts: number[] = [];
export async function pickPort(siteId: number): Promise<{
internalPort: number;
targetIps: string[];
}> {
// Fetch resources for this site
const resourcesRes = await db.query.resources.findMany({
where: eq(resources.siteId, siteId)
});
// TODO: is this all inefficient?
// Fetch targets for all resources of this site
let targetIps: string[] = [];
let targetInternalPorts: number[] = [];
await Promise.all(
resourcesRes.map(async (resource) => {
const targetsRes = await db.query.targets.findMany({
where: eq(targets.resourceId, resource.resourceId)
});
targetsRes.forEach((target) => {
targetIps.push(`${target.ip}/32`);
if (target.internalPort) {
targetInternalPorts.push(target.internalPort);
}
});
})
);
let internalPort!: number;
// pick a port random port from 40000 to 65535 that is not in use
for (let i = 0; i < 1000; i++) {
internalPort = Math.floor(Math.random() * 25535) + 40000;
if (
!targetInternalPorts.includes(internalPort) &&
!currentBannedPorts.includes(internalPort)
) {
break;
}
}
currentBannedPorts.push(internalPort);
return { internalPort, targetIps };
}

View file

@ -10,6 +10,7 @@ import logger from "@server/logger";
import { fromError } from "zod-validation-error";
import { addPeer } from "../gerbil/peers";
import { addTargets } from "../newt/targets";
import { pickPort } from "./ports";
// Regular expressions for validation
const DOMAIN_REGEX =
@ -48,7 +49,7 @@ const updateTargetParamsSchema = z
const updateTargetBodySchema = z
.object({
ip: domainSchema.optional(),
method: z.string().min(1).max(10).optional(),
method: z.string().min(1).max(10).optional().nullable(),
port: z.number().int().min(1).max(65535).optional(),
enabled: z.boolean().optional()
})
@ -84,15 +85,14 @@ export async function updateTarget(
}
const { targetId } = parsedParams.data;
const updateData = parsedBody.data;
const [updatedTarget] = await db
.update(targets)
.set(updateData)
const [target] = await db
.select()
.from(targets)
.where(eq(targets.targetId, targetId))
.returning();
.limit(1);
if (!updatedTarget) {
if (!target) {
return next(
createHttpError(
HttpCode.NOT_FOUND,
@ -103,17 +103,15 @@ export async function updateTarget(
// get the resource
const [resource] = await db
.select({
siteId: resources.siteId
})
.select()
.from(resources)
.where(eq(resources.resourceId, updatedTarget.resourceId!));
.where(eq(resources.resourceId, target.resourceId!));
if (!resource) {
return next(
createHttpError(
HttpCode.NOT_FOUND,
`Resource with ID ${updatedTarget.resourceId} not found`
`Resource with ID ${target.resourceId} not found`
)
);
}
@ -132,24 +130,29 @@ export async function updateTarget(
)
);
}
const { internalPort, targetIps } = await pickPort(site.siteId!);
if (!internalPort) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
`No available internal port`
)
);
}
const [updatedTarget] = await db
.update(targets)
.set({
...parsedBody.data,
internalPort
})
.where(eq(targets.targetId, targetId))
.returning();
if (site.pubKey) {
if (site.type == "wireguard") {
// TODO: is this all inefficient?
// Fetch resources for this site
const resourcesRes = await db.query.resources.findMany({
where: eq(resources.siteId, site.siteId)
});
// Fetch targets for all resources of this site
const targetIps = await Promise.all(
resourcesRes.map(async (resource) => {
const targetsRes = await db.query.targets.findMany({
where: eq(targets.resourceId, resource.resourceId)
});
return targetsRes.map((target) => `${target.ip}/32`);
})
);
await addPeer(site.exitNodeId!, {
publicKey: site.pubKey,
allowedIps: targetIps.flat()
@ -162,7 +165,7 @@ export async function updateTarget(
.where(eq(newts.siteId, site.siteId))
.limit(1);
addTargets(newt.newtId, [updatedTarget]);
addTargets(newt.newtId, [updatedTarget], resource.protocol);
}
}
return response(res, {

View file

@ -1,174 +1,294 @@
import { Request, Response } from "express";
import db from "@server/db";
import * as schema from "@server/db/schema";
import { and, eq, isNotNull } from "drizzle-orm";
import { and, eq } from "drizzle-orm";
import logger from "@server/logger";
import HttpCode from "@server/types/HttpCode";
import config from "@server/lib/config";
import { orgs, resources, sites, Target, targets } from "@server/db/schema";
import { sql } from "drizzle-orm";
export async function traefikConfigProvider(
_: Request,
res: Response,
res: Response
): Promise<any> {
try {
const all = await db
.select()
.from(schema.targets)
.innerJoin(
schema.resources,
eq(schema.targets.resourceId, schema.resources.resourceId),
)
.innerJoin(
schema.orgs,
eq(schema.resources.orgId, schema.orgs.orgId),
)
.innerJoin(
schema.sites,
eq(schema.sites.siteId, schema.resources.siteId),
)
.where(
const allResources = await db
.select({
// Resource fields
resourceId: resources.resourceId,
subdomain: resources.subdomain,
fullDomain: resources.fullDomain,
ssl: resources.ssl,
blockAccess: resources.blockAccess,
sso: resources.sso,
emailWhitelistEnabled: resources.emailWhitelistEnabled,
http: resources.http,
proxyPort: resources.proxyPort,
protocol: resources.protocol,
// Site fields
site: {
siteId: sites.siteId,
type: sites.type,
subnet: sites.subnet
},
// Org fields
org: {
orgId: orgs.orgId,
domain: orgs.domain
},
// Targets as a subquery
targets: sql<string>`json_group_array(json_object(
'targetId', ${targets.targetId},
'ip', ${targets.ip},
'method', ${targets.method},
'port', ${targets.port},
'internalPort', ${targets.internalPort},
'enabled', ${targets.enabled}
))`.as("targets")
})
.from(resources)
.innerJoin(sites, eq(sites.siteId, resources.siteId))
.innerJoin(orgs, eq(resources.orgId, orgs.orgId))
.leftJoin(
targets,
and(
eq(schema.targets.enabled, true),
isNotNull(schema.resources.subdomain),
isNotNull(schema.orgs.domain),
),
);
eq(targets.resourceId, resources.resourceId),
eq(targets.enabled, true)
)
)
.groupBy(resources.resourceId);
if (!all.length) {
if (!allResources.length) {
return res.status(HttpCode.OK).json({});
}
const badgerMiddlewareName = "badger";
const redirectMiddlewareName = "redirect-to-https";
const redirectHttpsMiddlewareName = "redirect-to-https";
const http: any = {
routers: {},
services: {},
middlewares: {
[badgerMiddlewareName]: {
plugin: {
[badgerMiddlewareName]: {
apiBaseUrl: new URL(
"/api/v1",
`http://${config.getRawConfig().server.internal_hostname}:${config.getRawConfig().server.internal_port}`,
).href,
resourceSessionCookieName:
config.getRawConfig().server.resource_session_cookie_name,
userSessionCookieName:
config.getRawConfig().server.session_cookie_name,
accessTokenQueryParam: config.getRawConfig().server.resource_access_token_param,
},
const config_output: any = {
http: {
middlewares: {
[badgerMiddlewareName]: {
plugin: {
[badgerMiddlewareName]: {
apiBaseUrl: new URL(
"/api/v1",
`http://${config.getRawConfig().server.internal_hostname}:${
config.getRawConfig().server
.internal_port
}`
).href,
userSessionCookieName:
config.getRawConfig().server
.session_cookie_name,
accessTokenQueryParam:
config.getRawConfig().server
.resource_access_token_param,
resourceSessionRequestParam:
config.getRawConfig().server
.resource_session_request_param
}
}
},
},
[redirectMiddlewareName]: {
redirectScheme: {
scheme: "https",
permanent: true,
},
},
},
[redirectHttpsMiddlewareName]: {
redirectScheme: {
scheme: "https"
}
}
}
}
};
for (const item of all) {
const target = item.targets;
const resource = item.resources;
const site = item.sites;
const org = item.orgs;
const routerName = `${target.targetId}-router`;
const serviceName = `${target.targetId}-service`;
for (const resource of allResources) {
const targets = JSON.parse(resource.targets);
const site = resource.site;
const org = resource.org;
if (!resource || !resource.subdomain) {
continue;
}
if (!org || !org.domain) {
if (!org.domain) {
continue;
}
const routerName = `${resource.resourceId}-router`;
const serviceName = `${resource.resourceId}-service`;
const fullDomain = `${resource.subdomain}.${org.domain}`;
const domainParts = fullDomain.split(".");
let wildCard;
if (domainParts.length <= 2) {
wildCard = `*.${domainParts.join(".")}`;
} else {
wildCard = `*.${domainParts.slice(1).join(".")}`;
}
if (resource.http) {
// HTTP configuration remains the same
if (!resource.subdomain) {
continue;
}
const tls = {
certResolver: config.getRawConfig().traefik.cert_resolver,
...(config.getRawConfig().traefik.prefer_wildcard_cert
? {
domains: [
{
main: wildCard,
},
],
}
: {}),
};
// add routers and services empty objects if they don't exist
if (!config_output.http.routers) {
config_output.http.routers = {};
}
http.routers![routerName] = {
entryPoints: [
resource.ssl
? config.getRawConfig().traefik.https_entrypoint
: config.getRawConfig().traefik.http_entrypoint,
],
middlewares: [badgerMiddlewareName],
service: serviceName,
rule: `Host(\`${fullDomain}\`)`,
...(resource.ssl ? { tls } : {}),
};
if (!config_output.http.services) {
config_output.http.services = {};
}
if (resource.ssl) {
// this is a redirect router; all it does is redirect to the https version if tls is enabled
http.routers![routerName + "-redirect"] = {
entryPoints: [config.getRawConfig().traefik.http_entrypoint],
middlewares: [redirectMiddlewareName],
const domainParts = fullDomain.split(".");
let wildCard;
if (domainParts.length <= 2) {
wildCard = `*.${domainParts.join(".")}`;
} else {
wildCard = `*.${domainParts.slice(1).join(".")}`;
}
const tls = {
certResolver: config.getRawConfig().traefik.cert_resolver,
...(config.getRawConfig().traefik.prefer_wildcard_cert
? {
domains: [
{
main: wildCard
}
]
}
: {})
};
const additionalMiddlewares =
config.getRawConfig().traefik.additional_middlewares || [];
config_output.http.routers![routerName] = {
entryPoints: [
resource.ssl
? config.getRawConfig().traefik.https_entrypoint
: config.getRawConfig().traefik.http_entrypoint
],
middlewares: [
badgerMiddlewareName,
...additionalMiddlewares
],
service: serviceName,
rule: `Host(\`${fullDomain}\`)`,
...(resource.ssl ? { tls } : {})
};
}
if (site.type === "newt") {
const ip = site.subnet.split("/")[0];
http.services![serviceName] = {
loadBalancer: {
servers: [
{
url: `${target.method}://${ip}:${target.internalPort}`,
},
if (resource.ssl) {
config_output.http.routers![routerName + "-redirect"] = {
entryPoints: [
config.getRawConfig().traefik.http_entrypoint
],
},
middlewares: [redirectHttpsMiddlewareName],
service: serviceName,
rule: `Host(\`${fullDomain}\`)`
};
}
config_output.http.services![serviceName] = {
loadBalancer: {
servers: targets
.filter((target: Target) => {
if (!target.enabled) {
return false;
}
if (
site.type === "local" ||
site.type === "wireguard"
) {
if (
!target.ip ||
!target.port ||
!target.method
) {
return false;
}
} else if (site.type === "newt") {
if (
!target.internalPort ||
!target.method
) {
return false;
}
}
return true;
})
.map((target: Target) => {
if (
site.type === "local" ||
site.type === "wireguard"
) {
return {
url: `${target.method}://${target.ip}:${target.port}`
};
} else if (site.type === "newt") {
const ip = site.subnet.split("/")[0];
return {
url: `${target.method}://${ip}:${target.internalPort}`
};
}
})
}
};
} else if (site.type === "wireguard") {
http.services![serviceName] = {
loadBalancer: {
servers: [
{
url: `${target.method}://${target.ip}:${target.port}`,
},
],
},
} else {
// Non-HTTP (TCP/UDP) configuration
const protocol = resource.protocol.toLowerCase();
const port = resource.proxyPort;
if (!port) {
continue;
}
if (!config_output[protocol]) {
config_output[protocol] = {
routers: {},
services: {}
};
}
config_output[protocol].routers[routerName] = {
entryPoints: [`${protocol}-${port}`],
service: serviceName,
...(protocol === "tcp" ? { rule: "HostSNI(`*`)" } : {})
};
} else if (site.type === "local") {
http.services![serviceName] = {
config_output[protocol].services[serviceName] = {
loadBalancer: {
servers: [
{
url: `${target.method}://${target.ip}:${target.port}`,
},
],
},
servers: targets
.filter((target: Target) => {
if (!target.enabled) {
return false;
}
if (
site.type === "local" ||
site.type === "wireguard"
) {
if (!target.ip || !target.port) {
return false;
}
} else if (site.type === "newt") {
if (!target.internalPort) {
return false;
}
}
return true;
})
.map((target: Target) => {
if (
site.type === "local" ||
site.type === "wireguard"
) {
return {
address: `${target.ip}:${target.port}`
};
} else if (site.type === "newt") {
const ip = site.subnet.split("/")[0];
return {
address: `${ip}:${target.internalPort}`
};
}
})
}
};
}
}
return res.status(HttpCode.OK).json({ http });
return res.status(HttpCode.OK).json(config_output);
} catch (e) {
logger.error(`Failed to build traefik config: ${e}`);
logger.error(`Failed to build Traefik config: ${e}`);
return res.status(HttpCode.INTERNAL_SERVER_ERROR).json({
error: "Failed to build traefik config",
error: "Failed to build Traefik config"
});
}
}

View file

@ -1 +1 @@
export * from "./getTraefikConfig";
export * from "./getTraefikConfig";

View file

@ -23,7 +23,10 @@ const inviteUserParamsSchema = z
const inviteUserBodySchema = z
.object({
email: z.string().email(),
email: z
.string()
.email()
.transform((v) => v.toLowerCase()),
roleId: z.number(),
validHours: z.number().gt(0).lte(168),
sendEmail: z.boolean().optional()
@ -165,7 +168,7 @@ export async function inviteUser(
}),
{
to: email,
from: config.getRawConfig().email?.no_reply,
from: config.getNoReplyEmail(),
subject: "You're invited to join a Fossorial organization"
}
);

View file

@ -3,15 +3,16 @@ import db, { exists } from "@server/db";
import path from "path";
import semver from "semver";
import { versionMigrations } from "@server/db/schema";
import { desc } from "drizzle-orm";
import { __DIRNAME, APP_PATH } from "@server/lib/consts";
import { __DIRNAME } from "@server/lib/consts";
import { loadAppVersion } from "@server/lib/loadAppVersion";
import { SqliteError } from "better-sqlite3";
import m1 from "./scripts/1.0.0-beta1";
import m2 from "./scripts/1.0.0-beta2";
import m3 from "./scripts/1.0.0-beta3";
import m4 from "./scripts/1.0.0-beta5";
import m5 from "./scripts/1.0.0-beta6";
import { existsSync, mkdirSync } from "fs";
import m6 from "./scripts/1.0.0-beta9";
import m7 from "./scripts/1.0.0-beta10";
// THIS CANNOT IMPORT ANYTHING FROM THE SERVER
// EXCEPT FOR THE DATABASE AND THE SCHEMA
@ -22,7 +23,9 @@ const migrations = [
{ version: "1.0.0-beta.2", run: m2 },
{ version: "1.0.0-beta.3", run: m3 },
{ version: "1.0.0-beta.5", run: m4 },
{ version: "1.0.0-beta.6", run: m5 }
{ version: "1.0.0-beta.6", run: m5 },
{ version: "1.0.0-beta.9", run: m6 },
{ version: "1.0.0-beta.10", run: m7 }
// Add new migrations here as they are created
] as const;
@ -30,53 +33,64 @@ const migrations = [
await runMigrations();
export async function runMigrations() {
const appVersion = loadAppVersion();
if (!appVersion) {
throw new Error("APP_VERSION is not set in the environment");
}
if (exists) {
await executeScripts();
} else {
console.log("Running migrations...");
try {
migrate(db, {
migrationsFolder: path.join(__DIRNAME, "init") // put here during the docker build
});
console.log("Migrations completed successfully.");
} catch (error) {
console.error("Error running migrations:", error);
try {
const appVersion = loadAppVersion();
if (!appVersion) {
throw new Error("APP_VERSION is not set in the environment");
}
await db
.insert(versionMigrations)
.values({
version: appVersion,
executedAt: Date.now()
})
.execute();
if (exists) {
await executeScripts();
} else {
console.log("Running migrations...");
try {
migrate(db, {
migrationsFolder: path.join(__DIRNAME, "init") // put here during the docker build
});
console.log("Migrations completed successfully.");
} catch (error) {
console.error("Error running migrations:", error);
}
await db
.insert(versionMigrations)
.values({
version: appVersion,
executedAt: Date.now()
})
.execute();
}
} catch (e) {
console.error("Error running migrations:", e);
await new Promise((resolve) =>
setTimeout(resolve, 1000 * 60 * 60 * 24 * 1)
);
}
}
async function executeScripts() {
try {
// Get the last executed version from the database
const lastExecuted = await db
.select()
.from(versionMigrations)
.orderBy(desc(versionMigrations.version))
.limit(1);
const startVersion = lastExecuted[0]?.version ?? "0.0.0";
console.log(`Starting migrations from version ${startVersion}`);
const lastExecuted = await db.select().from(versionMigrations);
// Filter and sort migrations
const pendingMigrations = migrations
.filter((migration) => semver.gt(migration.version, startVersion))
.sort((a, b) => semver.compare(a.version, b.version));
const pendingMigrations = lastExecuted
.map((m) => m)
.sort((a, b) => semver.compare(b.version, a.version));
const startVersion = pendingMigrations[0]?.version ?? "0.0.0";
console.log(`Starting migrations from version ${startVersion}`);
const migrationsToRun = migrations.filter((migration) =>
semver.gt(migration.version, startVersion)
);
console.log(
"Migrations to run:",
migrationsToRun.map((m) => m.version).join(", ")
);
// Run migrations in order
for (const migration of pendingMigrations) {
for (const migration of migrationsToRun) {
console.log(`Running migration ${migration.version}`);
try {
@ -94,12 +108,16 @@ async function executeScripts() {
console.log(
`Successfully completed migration ${migration.version}`
);
} catch (error) {
} catch (e) {
if (e instanceof SqliteError && e.code === "SQLITE_CONSTRAINT_UNIQUE") {
console.error("Migration has already run! Skipping...");
continue;
}
console.error(
`Failed to run migration ${migration.version}:`,
error
e
);
throw error; // Re-throw to stop migration process
throw e; // Re-throw to stop migration process
}
}

View file

@ -0,0 +1,45 @@
import { configFilePath1, configFilePath2 } from "@server/lib/consts";
import fs from "fs";
import yaml from "js-yaml";
export default async function migration() {
console.log("Running setup script 1.0.0-beta.10...");
try {
// Determine which config file exists
const filePaths = [configFilePath1, configFilePath2];
let filePath = "";
for (const path of filePaths) {
if (fs.existsSync(path)) {
filePath = path;
break;
}
}
if (!filePath) {
throw new Error(
`No config file found (expected config.yml or config.yaml).`
);
}
// Read and parse the YAML file
let rawConfig: any;
const fileContents = fs.readFileSync(filePath, "utf8");
rawConfig = yaml.load(fileContents);
delete rawConfig.server.secure_cookies;
// Write the updated YAML back to the file
const updatedYaml = yaml.dump(rawConfig);
fs.writeFileSync(filePath, updatedYaml, "utf8");
console.log(`Removed deprecated config option: secure_cookies.`);
} catch (e) {
console.log(
`Was unable to remove deprecated config option: secure_cookies. Error: ${e}`
);
return;
}
console.log("Done.");
}

View file

@ -0,0 +1,291 @@
import db from "@server/db";
import {
emailVerificationCodes,
passwordResetTokens,
resourceOtp,
resources,
resourceWhitelist,
targets,
userInvites,
users
} from "@server/db/schema";
import { APP_PATH, configFilePath1, configFilePath2 } from "@server/lib/consts";
import { eq, 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";
export default async function migration() {
console.log("Running setup script 1.0.0-beta.9...");
// make dir config/db/backups
const appPath = APP_PATH;
const dbDir = path.join(appPath, "db");
const backupsDir = path.join(dbDir, "backups");
// check if the backups directory exists and create it if it doesn't
if (!fs.existsSync(backupsDir)) {
fs.mkdirSync(backupsDir, { recursive: true });
}
// copy the db.sqlite file to backups
// add the date to the filename
const date = new Date();
const dateString = `${date.getFullYear()}-${date.getMonth()}-${date.getDate()}_${date.getHours()}-${date.getMinutes()}-${date.getSeconds()}`;
const dbPath = path.join(dbDir, "db.sqlite");
const backupPath = path.join(backupsDir, `db_${dateString}.sqlite`);
fs.copyFileSync(dbPath, backupPath);
await db.transaction(async (trx) => {
try {
// Determine which config file exists
const filePaths = [configFilePath1, configFilePath2];
let filePath = "";
for (const path of filePaths) {
if (fs.existsSync(path)) {
filePath = path;
break;
}
}
if (!filePath) {
throw new Error(
`No config file found (expected config.yml or config.yaml).`
);
}
// Read and parse the YAML file
let rawConfig: any;
const fileContents = fs.readFileSync(filePath, "utf8");
rawConfig = yaml.load(fileContents);
rawConfig.server.resource_session_request_param =
"p_session_request";
rawConfig.server.session_cookie_name = "p_session_token"; // rename to prevent conflicts
delete rawConfig.server.resource_session_cookie_name;
if (!rawConfig.flags) {
rawConfig.flags = {};
}
rawConfig.flags.allow_raw_resources = true;
// Write the updated YAML back to the file
const updatedYaml = yaml.dump(rawConfig);
fs.writeFileSync(filePath, updatedYaml, "utf8");
} catch (e) {
console.log(
`Failed to add resource_session_request_param to config. Please add it manually. https://docs.fossorial.io/Pangolin/Configuration/config`
);
trx.rollback();
return;
}
try {
const traefikPath = path.join(
APP_PATH,
"traefik",
"traefik_config.yml"
);
// Define schema for traefik config validation
const schema = z.object({
entryPoints: z
.object({
websecure: z
.object({
address: z.string(),
transport: z
.object({
respondingTimeouts: z.object({
readTimeout: z.string()
})
})
.optional()
})
.optional()
})
.optional(),
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;
let parsedConfig: any = schema.safeParse(traefikConfig);
if (parsedConfig.success) {
// Ensure websecure entrypoint exists
if (traefikConfig.entryPoints?.websecure) {
// Add transport configuration
traefikConfig.entryPoints.websecure.transport = {
respondingTimeouts: {
readTimeout: "30m"
}
};
}
traefikConfig.experimental.plugins.badger.version =
"v1.0.0-beta.3";
const updatedTraefikYaml = yaml.dump(traefikConfig);
fs.writeFileSync(traefikPath, updatedTraefikYaml, "utf8");
console.log("Updated Badger version in Traefik config.");
} else {
console.log(fromZodError(parsedConfig.error));
console.log(
"We were unable to update the version of Badger in your Traefik configuration. Please update it manually to at least v1.0.0-beta.3. https://github.com/fosrl/badger"
);
}
} catch (e) {
console.log(
"We were unable to update the version of Badger in your Traefik configuration. Please update it manually to at least v1.0.0-beta.3. https://github.com/fosrl/badger"
);
trx.rollback();
return;
}
try {
const traefikPath = path.join(
APP_PATH,
"traefik",
"dynamic_config.yml"
);
const schema = z.object({
http: z.object({
middlewares: z.object({
"redirect-to-https": z.object({
redirectScheme: z.object({
scheme: z.string(),
permanent: z.boolean()
})
})
})
})
});
const traefikFileContents = fs.readFileSync(traefikPath, "utf8");
const traefikConfig = yaml.load(traefikFileContents) as any;
let parsedConfig: any = schema.safeParse(traefikConfig);
if (parsedConfig.success) {
// delete permanent from redirect-to-https middleware
delete traefikConfig.http.middlewares["redirect-to-https"].redirectScheme.permanent;
const updatedTraefikYaml = yaml.dump(traefikConfig);
fs.writeFileSync(traefikPath, updatedTraefikYaml, "utf8");
console.log("Deleted permanent from redirect-to-https middleware.");
} else {
console.log(fromZodError(parsedConfig.error));
console.log(
"We were unable to delete the permanent field from the redirect-to-https middleware in your Traefik configuration. Please delete it manually."
);
}
} catch (e) {
console.log(
"We were unable to delete the permanent field from the redirect-to-https middleware in your Traefik configuration. Please delete it manually. Note that this is not a critical change but recommended."
);
}
trx.run(sql`UPDATE ${users} SET email = LOWER(email);`);
trx.run(
sql`UPDATE ${emailVerificationCodes} SET email = LOWER(email);`
);
trx.run(sql`UPDATE ${passwordResetTokens} SET email = LOWER(email);`);
trx.run(sql`UPDATE ${userInvites} SET email = LOWER(email);`);
trx.run(sql`UPDATE ${resourceWhitelist} SET email = LOWER(email);`);
trx.run(sql`UPDATE ${resourceOtp} SET email = LOWER(email);`);
const resourcesAll = await trx
.select({
resourceId: resources.resourceId,
fullDomain: resources.fullDomain,
subdomain: resources.subdomain
})
.from(resources);
trx.run(`DROP INDEX resources_fullDomain_unique;`);
trx.run(`ALTER TABLE resources
DROP COLUMN fullDomain;
`);
trx.run(`ALTER TABLE resources
DROP COLUMN subdomain;
`);
trx.run(sql`ALTER TABLE resources
ADD COLUMN fullDomain TEXT;
`);
trx.run(sql`ALTER TABLE resources
ADD COLUMN subdomain TEXT;
`);
trx.run(sql`ALTER TABLE resources
ADD COLUMN http INTEGER DEFAULT true NOT NULL;
`);
trx.run(sql`ALTER TABLE resources
ADD COLUMN protocol TEXT DEFAULT 'tcp' NOT NULL;
`);
trx.run(sql`ALTER TABLE resources
ADD COLUMN proxyPort INTEGER;
`);
// write the new fullDomain and subdomain values back to the database
for (const resource of resourcesAll) {
await trx
.update(resources)
.set({
fullDomain: resource.fullDomain,
subdomain: resource.subdomain
})
.where(eq(resources.resourceId, resource.resourceId));
}
const targetsAll = await trx
.select({
targetId: targets.targetId,
method: targets.method
})
.from(targets);
trx.run(`ALTER TABLE targets
DROP COLUMN method;
`);
trx.run(`ALTER TABLE targets
DROP COLUMN protocol;
`);
trx.run(sql`ALTER TABLE targets
ADD COLUMN method TEXT;
`);
// write the new method and protocol values back to the database
for (const target of targetsAll) {
await trx
.update(targets)
.set({
method: target.method
})
.where(eq(targets.targetId, target.targetId));
}
trx.run(
sql`ALTER TABLE 'resourceSessions' ADD 'isRequestToken' integer;`
);
trx.run(
sql`ALTER TABLE 'resourceSessions' ADD 'userSessionId' text REFERENCES session(id);`
);
});
console.log("Done.");
}