From 5f92b0bbc1af4cf469b5af42ea3341f83e91e61f Mon Sep 17 00:00:00 2001 From: Milo Schwartz Date: Tue, 21 Jan 2025 18:36:50 -0500 Subject: [PATCH 01/29] make all emails lowercase closes #89 --- server/lib/config.ts | 6 ++- server/routers/auth/login.ts | 5 ++- server/routers/auth/requestPasswordReset.ts | 10 ++--- server/routers/auth/resetPassword.ts | 5 ++- server/routers/auth/signup.ts | 2 +- server/routers/resource/authWithWhitelist.ts | 5 ++- .../routers/resource/setResourceWhitelist.ts | 5 ++- server/routers/user/inviteUser.ts | 5 ++- server/setup/migrations.ts | 5 ++- server/setup/scripts/1.0.0-beta9.ts | 40 +++++++++++++++++++ 10 files changed, 73 insertions(+), 15 deletions(-) create mode 100644 server/setup/scripts/1.0.0-beta9.ts diff --git a/server/lib/config.ts b/server/lib/config.ts index 49287339..53dac62e 100644 --- a/server/lib/config.ts +++ b/server/lib/config.ts @@ -37,7 +37,8 @@ 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() }), @@ -123,7 +124,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")) diff --git a/server/routers/auth/login.ts b/server/routers/auth/login.ts index 328caeb9..7ee0d927 100644 --- a/server/routers/auth/login.ts +++ b/server/routers/auth/login.ts @@ -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() }) diff --git a/server/routers/auth/requestPasswordReset.ts b/server/routers/auth/requestPasswordReset.ts index a223e5f2..36cfed87 100644 --- a/server/routers/auth/requestPasswordReset.ts +++ b/server/routers/auth/requestPasswordReset.ts @@ -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) diff --git a/server/routers/auth/resetPassword.ts b/server/routers/auth/resetPassword.ts index 6f36d006..45c8652b 100644 --- a/server/routers/auth/resetPassword.ts +++ b/server/routers/auth/resetPassword.ts @@ -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 diff --git a/server/routers/auth/signup.ts b/server/routers/auth/signup.ts index 9710d858..2a4bb127 100644 --- a/server/routers/auth/signup.ts +++ b/server/routers/auth/signup.ts @@ -23,7 +23,7 @@ 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() diff --git a/server/routers/resource/authWithWhitelist.ts b/server/routers/resource/authWithWhitelist.ts index cc73410c..54c43f62 100644 --- a/server/routers/resource/authWithWhitelist.ts +++ b/server/routers/resource/authWithWhitelist.ts @@ -24,7 +24,10 @@ import logger from "@server/logger"; const authWithWhitelistBodySchema = z .object({ - email: z.string().email(), + email: z + .string() + .email() + .transform((v) => v.toLowerCase()), otp: z.string().optional() }) .strict(); diff --git a/server/routers/resource/setResourceWhitelist.ts b/server/routers/resource/setResourceWhitelist.ts index 5eefbd53..8931d4ff 100644 --- a/server/routers/resource/setResourceWhitelist.ts +++ b/server/routers/resource/setResourceWhitelist.ts @@ -11,7 +11,10 @@ import { and, eq } from "drizzle-orm"; const setResourceWhitelistBodySchema = z .object({ - emails: z.array(z.string().email()).max(50) + emails: z + .array(z.string().email()) + .max(50) + .transform((v) => v.map((e) => e.toLowerCase())) }) .strict(); diff --git a/server/routers/user/inviteUser.ts b/server/routers/user/inviteUser.ts index 3031e399..45240ed2 100644 --- a/server/routers/user/inviteUser.ts +++ b/server/routers/user/inviteUser.ts @@ -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() diff --git a/server/setup/migrations.ts b/server/setup/migrations.ts index 0e9e4819..6d2766ee 100644 --- a/server/setup/migrations.ts +++ b/server/setup/migrations.ts @@ -11,7 +11,7 @@ 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"; // THIS CANNOT IMPORT ANYTHING FROM THE SERVER // EXCEPT FOR THE DATABASE AND THE SCHEMA @@ -22,7 +22,8 @@ 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 } // Add new migrations here as they are created ] as const; diff --git a/server/setup/scripts/1.0.0-beta9.ts b/server/setup/scripts/1.0.0-beta9.ts new file mode 100644 index 00000000..b5bd7838 --- /dev/null +++ b/server/setup/scripts/1.0.0-beta9.ts @@ -0,0 +1,40 @@ +import db from "@server/db"; +import { + emailVerificationCodes, + passwordResetTokens, + resourceOtp, + resourceWhitelist, + userInvites, + users +} from "@server/db/schema"; +import { sql } from "drizzle-orm"; + +export default async function migration() { + console.log("Running setup script 1.0.0-beta.9..."); + + try { + await db.transaction(async (trx) => { + await db.transaction(async (trx) => { + 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);`); + }); + }); + } catch (error) { + console.log( + "We were unable to make all emails lower case in the database." + ); + console.error(error); + } + + console.log("Done."); +} From 9a831e8e34a6bff81d286e355a5258f43df7777a Mon Sep 17 00:00:00 2001 From: Milo Schwartz Date: Thu, 23 Jan 2025 21:26:59 -0500 Subject: [PATCH 02/29] use id for data-value closes #86 --- src/app/[orgId]/settings/resources/CreateResourceForm.tsx | 2 +- src/app/[orgId]/settings/share-links/CreateShareLinkForm.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/app/[orgId]/settings/resources/CreateResourceForm.tsx b/src/app/[orgId]/settings/resources/CreateResourceForm.tsx index 912f7be1..4ec4e8d5 100644 --- a/src/app/[orgId]/settings/resources/CreateResourceForm.tsx +++ b/src/app/[orgId]/settings/resources/CreateResourceForm.tsx @@ -257,7 +257,7 @@ export default function CreateResourceForm({ (site) => ( ( Date: Thu, 23 Jan 2025 22:23:50 -0500 Subject: [PATCH 03/29] use quotes around strings in yaml closes #96 --- config/config.example.yml | 26 +++++++++++++------------- install/fs/config.yml | 36 ++++++++++++++++++------------------ install/main.go | 9 +++++++-- 3 files changed, 38 insertions(+), 33 deletions(-) diff --git a/config/config.example.yml b/config/config.example.yml index d1d299b7..aca7bfe0 100644 --- a/config/config.example.yml +++ b/config/config.example.yml @@ -1,27 +1,27 @@ app: - dashboard_url: http://localhost:3002 - base_domain: localhost - log_level: info + dashboard_url: "http://localhost:3002" + base_domain: "localhost" + log_level: "info" save_logs: false server: external_port: 3000 internal_port: 3001 next_port: 3002 - internal_hostname: pangolin + internal_hostname: "pangolin" secure_cookies: true - session_cookie_name: p_session - resource_session_cookie_name: p_resource_session - resource_access_token_param: p_token + session_cookie_name: "p_session" + resource_session_cookie_name: "p_resource_session" + resource_access_token_param: "p_token" traefik: - cert_resolver: letsencrypt - http_entrypoint: web - https_entrypoint: websecure + cert_resolver: "letsencrypt" + http_entrypoint: "web" + https_entrypoint: "websecure" gerbil: start_port: 51820 - base_endpoint: localhost + base_endpoint: "localhost" block_size: 24 site_block_size: 30 subnet_group: 100.89.137.0/20 @@ -34,8 +34,8 @@ rate_limits: users: server_admin: - email: admin@example.com - password: Password123! + email: "admin@example.com" + password: "Password123!" flags: require_email_verification: false diff --git a/install/fs/config.yml b/install/fs/config.yml index 91d67019..6cea3f13 100644 --- a/install/fs/config.yml +++ b/install/fs/config.yml @@ -1,18 +1,18 @@ app: - dashboard_url: https://{{.DashboardDomain}} - base_domain: {{.BaseDomain}} - log_level: info + dashboard_url: "https://{{.DashboardDomain}}" + base_domain: "{{.BaseDomain}}" + log_level: "info" save_logs: false server: external_port: 3000 internal_port: 3001 next_port: 3002 - internal_hostname: pangolin + internal_hostname: "pangolin" secure_cookies: true - session_cookie_name: p_session - resource_session_cookie_name: p_resource_session - resource_access_token_param: p_token + session_cookie_name: "p_session" + resource_session_cookie_name: "p_resource_session" + resource_access_token_param: "p_token" cors: origins: ["https://{{.DashboardDomain}}"] methods: ["GET", "POST", "PUT", "DELETE", "PATCH"] @@ -20,14 +20,14 @@ server: credentials: false traefik: - cert_resolver: letsencrypt - http_entrypoint: web - https_entrypoint: websecure + cert_resolver: "letsencrypt" + http_entrypoint: "web" + https_entrypoint: "websecure" prefer_wildcard_cert: false gerbil: start_port: 51820 - base_endpoint: {{.DashboardDomain}} + base_endpoint: "{{.DashboardDomain}}" use_subdomain: false block_size: 24 site_block_size: 30 @@ -39,16 +39,16 @@ rate_limits: max_requests: 100 {{if .EnableEmail}} email: - smtp_host: {{.EmailSMTPHost}} - smtp_port: {{.EmailSMTPPort}} - smtp_user: {{.EmailSMTPUser}} - smtp_pass: {{.EmailSMTPPass}} - no_reply: {{.EmailNoReply}} + smtp_host: "{{.EmailSMTPHost}}" + smtp_port: "{{.EmailSMTPPort}}" + smtp_user: "{{.EmailSMTPUser}}" + smtp_pass: "{{.EmailSMTPPass}}" + no_reply: "{{.EmailNoReply}}" {{end}} users: server_admin: - email: {{.AdminUserEmail}} - password: {{.AdminUserPassword}} + email: "{{.AdminUserEmail}}" + password: "{{.AdminUserPassword}}" flags: require_email_verification: {{.EnableEmail}} diff --git a/install/main.go b/install/main.go index 2f1f48ac..ba111b7f 100644 --- a/install/main.go +++ b/install/main.go @@ -271,6 +271,11 @@ func createConfigFiles(config Config) error { // Get the relative path by removing the "fs/" prefix relPath := strings.TrimPrefix(path, "fs/") + // skip .DS_Store + if strings.Contains(relPath, ".DS_Store") { + return nil + } + // Create the full output path under "config/" outPath := filepath.Join("config", relPath) @@ -374,7 +379,7 @@ func installDocker() error { switch { case strings.Contains(osRelease, "ID=ubuntu"): installCmd = exec.Command("bash", "-c", fmt.Sprintf(` - apt-get update && + apt-get update && apt-get install -y apt-transport-https ca-certificates curl software-properties-common && curl -fsSL https://download.docker.com/linux/ubuntu/gpg | gpg --dearmor -o /usr/share/keyrings/docker-archive-keyring.gpg && echo "deb [arch=%s signed-by=/usr/share/keyrings/docker-archive-keyring.gpg] https://download.docker.com/linux/ubuntu $(lsb_release -cs) stable" > /etc/apt/sources.list.d/docker.list && @@ -383,7 +388,7 @@ func installDocker() error { `, dockerArch)) case strings.Contains(osRelease, "ID=debian"): installCmd = exec.Command("bash", "-c", fmt.Sprintf(` - apt-get update && + apt-get update && apt-get install -y apt-transport-https ca-certificates curl software-properties-common && curl -fsSL https://download.docker.com/linux/debian/gpg | gpg --dearmor -o /usr/share/keyrings/docker-archive-keyring.gpg && echo "deb [arch=%s signed-by=/usr/share/keyrings/docker-archive-keyring.gpg] https://download.docker.com/linux/debian $(lsb_release -cs) stable" > /etc/apt/sources.list.d/docker.list && From 02b5f4d3906be405012f251aca4ec15cbfa318fc Mon Sep 17 00:00:00 2001 From: Milo Schwartz Date: Thu, 23 Jan 2025 22:34:12 -0500 Subject: [PATCH 04/29] increase hitbox for links in buttons --- .../settings/access/users/UsersTable.tsx | 16 +++++----- .../settings/resources/ResourcesTable.tsx | 30 +++++++++---------- .../settings/share-links/ShareLinksTable.tsx | 12 ++++---- src/app/[orgId]/settings/sites/SitesTable.tsx | 14 ++++----- 4 files changed, 35 insertions(+), 37 deletions(-) diff --git a/src/app/[orgId]/settings/access/users/UsersTable.tsx b/src/app/[orgId]/settings/access/users/UsersTable.tsx index 5b365e8d..03454bf1 100644 --- a/src/app/[orgId]/settings/access/users/UsersTable.tsx +++ b/src/app/[orgId]/settings/access/users/UsersTable.tsx @@ -17,7 +17,7 @@ import { useOrgContext } from "@app/hooks/useOrgContext"; import { useToast } from "@app/hooks/useToast"; import Link from "next/link"; import { useRouter } from "next/navigation"; -import { formatAxiosError } from "@app/lib/api";; +import { formatAxiosError } from "@app/lib/api"; import { createApiClient } from "@app/lib/api"; import { useEnvContext } from "@app/hooks/useEnvContext"; import { useUserContext } from "@app/hooks/useUserContext"; @@ -75,14 +75,14 @@ export default function UsersTable({ users: u }: UsersTableProps) { - - + + Manage User - - + + {userRow.email !== user?.email && ( { diff --git a/src/app/[orgId]/settings/resources/ResourcesTable.tsx b/src/app/[orgId]/settings/resources/ResourcesTable.tsx index e80fa39e..e065b38e 100644 --- a/src/app/[orgId]/settings/resources/ResourcesTable.tsx +++ b/src/app/[orgId]/settings/resources/ResourcesTable.tsx @@ -25,7 +25,7 @@ import CreateResourceForm from "./CreateResourceForm"; import { useState } from "react"; import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog"; import { set } from "zod"; -import { formatAxiosError } from "@app/lib/api";; +import { formatAxiosError } from "@app/lib/api"; import { useToast } from "@app/hooks/useToast"; import { createApiClient } from "@app/lib/api"; import { useEnvContext } from "@app/hooks/useEnvContext"; @@ -91,14 +91,14 @@ export default function SitesTable({ resources, orgId }: ResourcesTableProps) { - - + + View settings - - + + { setSelectedResource(resourceRow); @@ -146,14 +146,14 @@ export default function SitesTable({ resources, orgId }: ResourcesTableProps) { cell: ({ row }) => { const resourceRow = row.original; return ( - + + + ); } }, diff --git a/src/app/[orgId]/settings/share-links/ShareLinksTable.tsx b/src/app/[orgId]/settings/share-links/ShareLinksTable.tsx index 451bec9f..8fadbd10 100644 --- a/src/app/[orgId]/settings/share-links/ShareLinksTable.tsx +++ b/src/app/[orgId]/settings/share-links/ShareLinksTable.tsx @@ -145,14 +145,12 @@ export default function ShareLinksTable({ cell: ({ row }) => { const r = row.original; return ( - + + + ); } }, diff --git a/src/app/[orgId]/settings/sites/SitesTable.tsx b/src/app/[orgId]/settings/sites/SitesTable.tsx index e76203a3..eaa1ca6c 100644 --- a/src/app/[orgId]/settings/sites/SitesTable.tsx +++ b/src/app/[orgId]/settings/sites/SitesTable.tsx @@ -92,14 +92,14 @@ export default function SitesTable({ sites, orgId }: SitesTableProps) { - - + + View settings - - + + { setSelectedSite(siteRow); From 2d0a367f1a392afa2016890a4e4a789df34829a7 Mon Sep 17 00:00:00 2001 From: Milo Schwartz Date: Thu, 23 Jan 2025 22:38:35 -0500 Subject: [PATCH 05/29] fix link in resource alert not updating when changing ssl --- .../settings/resources/[resourceId]/connectivity/page.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/app/[orgId]/settings/resources/[resourceId]/connectivity/page.tsx b/src/app/[orgId]/settings/resources/[resourceId]/connectivity/page.tsx index 30a645f0..4b12f661 100644 --- a/src/app/[orgId]/settings/resources/[resourceId]/connectivity/page.tsx +++ b/src/app/[orgId]/settings/resources/[resourceId]/connectivity/page.tsx @@ -321,7 +321,7 @@ export default function ReverseProxyTargets(props: { }); setSslEnabled(val); - updateResource({ ssl: sslEnabled }); + updateResource({ ssl: val }); toast({ title: "SSL Configuration", From 8e5330fb82c8fecac49f62f82bab1178ecbfd50f Mon Sep 17 00:00:00 2001 From: Milo Schwartz Date: Fri, 24 Jan 2025 23:18:27 -0500 Subject: [PATCH 06/29] add cicd --- .github/workflows/cicd.yml | 75 ++++++++++++++++++++++++++++++++++++++ .gitignore | 2 + install/Makefile | 1 - install/main.go | 5 ++- package.json | 2 +- 5 files changed, 81 insertions(+), 4 deletions(-) create mode 100644 .github/workflows/cicd.yml diff --git a/.github/workflows/cicd.yml b/.github/workflows/cicd.yml new file mode 100644 index 00000000..b2e9bdf3 --- /dev/null +++ b/.github/workflows/cicd.yml @@ -0,0 +1,75 @@ +name: CI/CD Pipeline + +on: + push: + tags: + - "*" + +jobs: + release: + name: Build and Release + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v3 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v2 + + - name: Log in to Docker Hub + uses: docker/login-action@v2 + with: + username: ${{ secrets.DOCKER_HUB_USERNAME }} + password: ${{ secrets.DOCKER_HUB_ACCESS_TOKEN }} + + - name: Extract tag name + id: get-tag + run: echo "TAG=${GITHUB_REF#refs/tags/}" >> $GITHUB_ENV + + - name: Install Go + uses: actions/setup-go@v4 + with: + go-version: 1.23.0 + + - name: Update version in package.json + run: | + TAG=${{ env.TAG }} + if [ -f package.json ]; then + jq --arg version "$TAG" '.version = $version' package.json > package.tmp.json && mv package.tmp.json package.json + echo "Updated package.json with version $TAG" + else + echo "package.json not found" + fi + cat package.json + + - name: Pull latest Gerbil version + id: get-gerbil-tag + run: | + LATEST_TAG=$(curl -s https://api.github.com/repos/fosrl/gerbil/tags | jq -r '.[0].name') + echo "LATEST_GERBIL_TAG=$LATEST_TAG" >> $GITHUB_ENV + + - name: Update install/main.go + run: | + PANGOLIN_VERSION=${{ env.TAG }} + GERBIL_VERSION=${{ env.LATEST_GERBIL_TAG }} + sed -i "s/config.PangolinVersion = \".*\"/config.PangolinVersion = \"$PANGOLIN_VERSION\"/" install/main.go + sed -i "s/config.GerbilVersion = \".*\"/config.GerbilVersion = \"$GERBIL_VERSION\"/" install/main.go + echo "Updated install/main.go with Pangolin version $PANGOLIN_VERSION and Gerbil version $GERBIL_VERSION" + cat install/main.go + + - name: Build and push Docker images + run: | + TAG=${{ env.TAG }} + make build-release tag=$TAG + + - name: Build installer + working-directory: install + run: | + make release + + - name: Upload artifacts from /install/bin + uses: actions/upload-artifact@v3 + with: + name: install-bin + path: install/bin/ diff --git a/.gitignore b/.gitignore index dacf66a1..63742d1c 100644 --- a/.gitignore +++ b/.gitignore @@ -31,3 +31,5 @@ dist installer *.tar bin +.secrets +test_event.json diff --git a/install/Makefile b/install/Makefile index 45ad5968..e8e9cd2e 100644 --- a/install/Makefile +++ b/install/Makefile @@ -1,4 +1,3 @@ - all: build build: diff --git a/install/main.go b/install/main.go index ba111b7f..b21d8df5 100644 --- a/install/main.go +++ b/install/main.go @@ -17,9 +17,10 @@ import ( "golang.org/x/term" ) +// DO NOT EDIT THIS FUNCTION; IT MATCHED BY REGEX IN CICD func loadVersions(config *Config) { - config.PangolinVersion = "1.0.0-beta.8" - config.GerbilVersion = "1.0.0-beta.3" + config.PangolinVersion = "replaceme" + config.GerbilVersion = "replaceme" } //go:embed fs/* diff --git a/package.json b/package.json index f5650f81..c67718ad 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@fosrl/pangolin", - "version": "1.0.0-beta.8", + "version": "0.0.0", "private": true, "type": "module", "description": "Tunneled Reverse Proxy Management Server with Identity and Access Control and Dashboard UI", From 6cc6b0c23952cb51578b15b57560f8400a15a05e Mon Sep 17 00:00:00 2001 From: Owen Schwartz Date: Sat, 25 Jan 2025 12:27:27 -0500 Subject: [PATCH 07/29] docker-compose vs docker compose; Resolves #83 --- install/main.go | 62 ++++++++++++++++++++++++++++++++++--------------- 1 file changed, 43 insertions(+), 19 deletions(-) diff --git a/install/main.go b/install/main.go index b21d8df5..7436c553 100644 --- a/install/main.go +++ b/install/main.go @@ -438,29 +438,53 @@ func isDockerInstalled() bool { return true } +func getCommandString(useNewStyle bool) string { + if useNewStyle { + return "'docker compose'" + } + return "'docker-compose'" +} + func pullAndStartContainers() error { fmt.Println("Starting containers...") - // First try docker compose (new style) - cmd := exec.Command("docker", "compose", "-f", "docker-compose.yml", "pull") - cmd.Stdout = os.Stdout - cmd.Stderr = os.Stderr - err := cmd.Run() - - if err != nil { - fmt.Println("Failed to start containers using docker compose, falling back to docker-compose command") - os.Exit(1) + // Check which docker compose command is available + var useNewStyle bool + checkCmd := exec.Command("docker", "compose", "version") + if err := checkCmd.Run(); err == nil { + useNewStyle = true + } else { + // Check if docker-compose (old style) is available + checkCmd = exec.Command("docker-compose", "version") + if err := checkCmd.Run(); err != nil { + return fmt.Errorf("neither 'docker compose' nor 'docker-compose' command is available: %v", err) + } } - cmd = exec.Command("docker", "compose", "-f", "docker-compose.yml", "up", "-d") - cmd.Stdout = os.Stdout - cmd.Stderr = os.Stderr - err = cmd.Run() - - if err != nil { - fmt.Println("Failed to start containers using docker-compose command") - os.Exit(1) + // Helper function to execute docker compose commands + executeCommand := func(args ...string) error { + var cmd *exec.Cmd + if useNewStyle { + cmd = exec.Command("docker", append([]string{"compose"}, args...)...) + } else { + cmd = exec.Command("docker-compose", args...) + } + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + return cmd.Run() } - return err -} + // Pull containers + fmt.Printf("Using %s command to pull containers...\n", getCommandString(useNewStyle)) + if err := executeCommand("-f", "docker-compose.yml", "pull"); err != nil { + return fmt.Errorf("failed to pull containers: %v", err) + } + + // Start containers + fmt.Printf("Using %s command to start containers...\n", getCommandString(useNewStyle)) + if err := executeCommand("-f", "docker-compose.yml", "up", "-d"); err != nil { + return fmt.Errorf("failed to start containers: %v", err) + } + + return nil +} \ No newline at end of file From d284d36c243707a01d070682e86705b6869ebd3b Mon Sep 17 00:00:00 2001 From: Owen Schwartz Date: Sat, 25 Jan 2025 12:55:19 -0500 Subject: [PATCH 08/29] Remove double transaction --- server/setup/scripts/1.0.0-beta9.ts | 24 ++++++++++-------------- 1 file changed, 10 insertions(+), 14 deletions(-) diff --git a/server/setup/scripts/1.0.0-beta9.ts b/server/setup/scripts/1.0.0-beta9.ts index b5bd7838..e4f62659 100644 --- a/server/setup/scripts/1.0.0-beta9.ts +++ b/server/setup/scripts/1.0.0-beta9.ts @@ -14,20 +14,16 @@ export default async function migration() { try { await db.transaction(async (trx) => { - await db.transaction(async (trx) => { - 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);`); - }); + 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);`); }); } catch (error) { console.log( From 72f16863954ce2ce15237e6f197361cf99a1d695 Mon Sep 17 00:00:00 2001 From: Milo Schwartz Date: Sat, 25 Jan 2025 13:23:36 -0500 Subject: [PATCH 09/29] remove permanent redirect for https --- config/traefik/dynamic_config.example.yml | 1 - install/fs/traefik/dynamic_config.yml | 1 - package.json | 2 +- server/routers/traefik/getTraefikConfig.ts | 3 +-- 4 files changed, 2 insertions(+), 5 deletions(-) diff --git a/config/traefik/dynamic_config.example.yml b/config/traefik/dynamic_config.example.yml index bd76851b..cca0ea18 100644 --- a/config/traefik/dynamic_config.example.yml +++ b/config/traefik/dynamic_config.example.yml @@ -3,7 +3,6 @@ http: redirect-to-https: redirectScheme: scheme: https - permanent: true routers: # HTTP to HTTPS redirect router diff --git a/install/fs/traefik/dynamic_config.yml b/install/fs/traefik/dynamic_config.yml index 770c30ba..8fcf8e55 100644 --- a/install/fs/traefik/dynamic_config.yml +++ b/install/fs/traefik/dynamic_config.yml @@ -3,7 +3,6 @@ http: redirect-to-https: redirectScheme: scheme: https - permanent: true routers: # HTTP to HTTPS redirect router diff --git a/package.json b/package.json index c67718ad..cc28ab10 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@fosrl/pangolin", - "version": "0.0.0", + "version": "1.0.0-beta.9", "private": true, "type": "module", "description": "Tunneled Reverse Proxy Management Server with Identity and Access Control and Dashboard UI", diff --git a/server/routers/traefik/getTraefikConfig.ts b/server/routers/traefik/getTraefikConfig.ts index fb775ac4..1153a532 100644 --- a/server/routers/traefik/getTraefikConfig.ts +++ b/server/routers/traefik/getTraefikConfig.ts @@ -62,8 +62,7 @@ export async function traefikConfigProvider( }, [redirectMiddlewareName]: { redirectScheme: { - scheme: "https", - permanent: true, + scheme: "https" }, }, }, From 9f1f2910e421fe7b79fb16a3d4faeb82b6c71b82 Mon Sep 17 00:00:00 2001 From: Milo Schwartz Date: Sun, 26 Jan 2025 14:42:02 -0500 Subject: [PATCH 10/29] refactor auth to work cross domain and with http resources closes #100 --- config/config.example.yml | 4 +- install/fs/config.yml | 4 +- package.json | 1 + server/auth/sessions/app.ts | 42 +++-- server/auth/sessions/resource.ts | 41 ++-- server/db/schema.ts | 4 + server/index.ts | 3 +- server/lib/config.ts | 18 +- server/routers/auth/logout.ts | 11 +- server/routers/badger/exchangeSession.ts | 175 ++++++++++++++++++ server/routers/badger/index.ts | 1 + server/routers/badger/verifySession.ts | 175 ++++++++++++------ server/routers/internal.ts | 12 +- .../routers/resource/authWithAccessToken.ts | 16 +- server/routers/resource/authWithPassword.ts | 11 +- server/routers/resource/authWithPincode.ts | 18 +- server/routers/resource/authWithWhitelist.ts | 12 +- server/routers/resource/getExchangeToken.ts | 109 +++++++++++ server/routers/resource/index.ts | 1 + server/routers/resource/updateResource.ts | 4 + server/routers/traefik/getTraefikConfig.ts | 12 +- server/setup/scripts/1.0.0-beta9.ts | 97 ++++++++++ .../resource/[resourceId]/AccessToken.tsx | 19 +- .../[resourceId]/ResourceAuthPortal.tsx | 37 ++-- src/app/auth/resource/[resourceId]/page.tsx | 56 +++--- src/lib/pullEnv.ts | 4 +- src/lib/types/env.ts | 2 +- 27 files changed, 688 insertions(+), 201 deletions(-) create mode 100644 server/routers/badger/exchangeSession.ts create mode 100644 server/routers/resource/getExchangeToken.ts diff --git a/config/config.example.yml b/config/config.example.yml index aca7bfe0..df4170a2 100644 --- a/config/config.example.yml +++ b/config/config.example.yml @@ -10,9 +10,9 @@ server: next_port: 3002 internal_hostname: "pangolin" secure_cookies: true - session_cookie_name: "p_session" - resource_session_cookie_name: "p_resource_session" + session_cookie_name: "p_session_token" resource_access_token_param: "p_token" + resource_session_request_param: "p_session_request" traefik: cert_resolver: "letsencrypt" diff --git a/install/fs/config.yml b/install/fs/config.yml index 6cea3f13..e7325562 100644 --- a/install/fs/config.yml +++ b/install/fs/config.yml @@ -10,9 +10,9 @@ server: next_port: 3002 internal_hostname: "pangolin" secure_cookies: true - session_cookie_name: "p_session" - resource_session_cookie_name: "p_resource_session" + session_cookie_name: "p_session_token" resource_access_token_param: "p_token" + resource_session_request_param: "p_session_request" cors: origins: ["https://{{.DashboardDomain}}"] methods: ["GET", "POST", "PUT", "DELETE", "PATCH"] diff --git a/package.json b/package.json index cc28ab10..446e3d37 100644 --- a/package.json +++ b/package.json @@ -64,6 +64,7 @@ "moment": "2.30.1", "next": "15.1.3", "next-themes": "0.4.4", + "node-cache": "5.1.2", "node-fetch": "3.3.2", "nodemailer": "6.9.16", "oslo": "1.2.1", diff --git a/server/auth/sessions/app.ts b/server/auth/sessions/app.ts index ac91fc1f..29c54eee 100644 --- a/server/auth/sessions/app.ts +++ b/server/auth/sessions/app.ts @@ -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,14 @@ 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 SESSION_COOKIE_EXPIRES = + 1000 * + 60 * + 60 * + config.getRawConfig().server.dashboard_session_length_hours; export const SECURE_COOKIES = config.getRawConfig().server.secure_cookies; -export const COOKIE_DOMAIN = "." + config.getBaseDomain(); +export const COOKIE_DOMAIN = + "." + new URL(config.getRawConfig().app.dashboard_url).hostname; export function generateSessionToken(): string { const bytes = new Uint8Array(20); @@ -65,12 +76,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 }; } @@ -90,9 +110,9 @@ export function serializeSessionCookie( 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}`; + 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=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=/; Domain=${COOKIE_DOMAIN}`; } } else { return `${SESSION_COOKIE_NAME}=${token}; HttpOnly; SameSite=Lax; Max-Age=${SESSION_COOKIE_EXPIRES}; Path=/;`; diff --git a/server/auth/sessions/resource.ts b/server/auth/sessions/resource.ts index e9b32416..e9dd9b96 100644 --- a/server/auth/sessions/resource.ts +++ b/server/auth/sessions/resource.ts @@ -6,19 +6,20 @@ 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; + config.getRawConfig().server.session_cookie_name; +export const SESSION_COOKIE_EXPIRES = + 1000 * 60 * 60 * config.getRawConfig().server.resource_session_length_hours; export const SECURE_COOKIES = config.getRawConfig().server.secure_cookies; -export const COOKIE_DOMAIN = "." + config.getBaseDomain(); 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 +28,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 +49,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 +166,25 @@ 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 (SECURE_COOKIES && !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 ): string { if (SECURE_COOKIES) { - return `${cookieName}=; HttpOnly; SameSite=Strict; Max-Age=0; Path=/; Secure; Domain=${COOKIE_DOMAIN}`; + 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}`; } } diff --git a/server/db/schema.ts b/server/db/schema.ts index fd9956b5..acdda169 100644 --- a/server/db/schema.ts +++ b/server/db/schema.ts @@ -313,6 +313,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, { diff --git a/server/index.ts b/server/index.ts index 364db07a..fd56f4eb 100644 --- a/server/index.ts +++ b/server/index.ts @@ -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; diff --git a/server/lib/config.ts b/server/lib/config.ts index 53dac62e..2c1ef7f0 100644 --- a/server/lib/config.ts +++ b/server/lib/config.ts @@ -61,8 +61,20 @@ const configSchema = z.object({ 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(), @@ -241,8 +253,6 @@ export class Config { : "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 @@ -254,6 +264,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; } diff --git a/server/routers/auth/logout.ts b/server/routers/auth/logout.ts index 3b466767..e9e51a31 100644 --- a/server/routers/auth/logout.ts +++ b/server/routers/auth/logout.ts @@ -5,18 +5,17 @@ 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"; export async function logout( req: Request, res: Response, next: NextFunction ): Promise { - const sessionId = req.cookies[SESSION_COOKIE_NAME]; - - if (!sessionId) { + const { user, session } = await verifySession(req); + if (!user || !session) { return next( createHttpError( HttpCode.BAD_REQUEST, @@ -26,7 +25,7 @@ export async function logout( } try { - await invalidateSession(sessionId); + await invalidateSession(session.sessionId); const isSecure = req.protocol === "https"; res.setHeader("Set-Cookie", createBlankSessionTokenCookie(isSecure)); diff --git a/server/routers/badger/exchangeSession.ts b/server/routers/badger/exchangeSession.ts new file mode 100644 index 00000000..eaf47e6a --- /dev/null +++ b/server/routers/badger/exchangeSession.ts @@ -0,0 +1,175 @@ +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 } from "@server/auth"; +import { 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() +}); + +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 { + 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 } = parsedBody.data; + + 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) { + return next( + createHttpError(HttpCode.UNAUTHORIZED, "Invalid request token") + ); + } + + if (!requestSession.isRequestToken) { + 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(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" + ) + ); + } +} diff --git a/server/routers/badger/index.ts b/server/routers/badger/index.ts index 7af4684a..09f3a79b 100644 --- a/server/routers/badger/index.ts +++ b/server/routers/badger/index.ts @@ -1 +1,2 @@ export * from "./verifySession"; +export * from "./exchangeSession"; diff --git a/server/routers/badger/verifySession.ts b/server/routers/badger/verifySession.ts index 0593e3b4..ae6d2edc 100644 --- a/server/routers/badger/verifySession.ts +++ b/server/routers/badger/verifySession.ts @@ -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"; @@ -27,6 +27,12 @@ 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"; + +// 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(), @@ -53,7 +59,7 @@ export async function verifyResourceSession( res: Response, next: NextFunction ): Promise { - 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 +73,52 @@ export async function verifyResourceSession( } try { - const { sessions, host, originalRequestURL, accessToken: token } = - parsedBody.data; + const { + sessions, + host, + originalRequestURL, + 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 resourceCacheKey = `resource:${host}`; + let resourceData: + | { + resource: Resource | null; + pincode: ResourcePincode | null; + password: ResourcePassword | null; + } + | undefined = cache.get(resourceCacheKey); - const resource = result?.resources; - const pincode = result?.resourcePincode; - const password = result?.resourcePassword; + 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); @@ -145,37 +177,31 @@ export async function verifyResourceSession( 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 (isAllowed) { - logger.debug( - "Resource allowed because user session is valid" - ); - return allowed(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" + ); + return notAllowed(res); + } if (resourceSession) { if (pincode && resourceSession.pincodeId) { @@ -208,6 +234,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); + } + } } } @@ -272,10 +321,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(res, { data: { valid: true }, success: true, @@ -286,9 +340,22 @@ async function createAccessTokenSession( } async function isUserAllowedToAccessResource( - user: User, + userSessionId: string, resource: Resource ): Promise { + 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 diff --git a/server/routers/internal.ts b/server/routers/internal.ts index fb0e1809..30f7fd6d 100644 --- a/server/routers/internal.ts +++ b/server/routers/internal.ts @@ -3,7 +3,9 @@ import * as gerbil from "@server/routers/gerbil"; import * as badger from "@server/routers/badger"; import * as traefik from "@server/routers/traefik"; import * as auth from "@server/routers/auth"; +import * as resource from "@server/routers/resource"; import HttpCode from "@server/types/HttpCode"; +import { verifyResourceAccess, verifySessionUserMiddleware } from "@server/middlewares"; // Root routes const internalRouter = Router(); @@ -15,7 +17,14 @@ 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 +39,6 @@ const badgerRouter = Router(); internalRouter.use("/badger", badgerRouter); badgerRouter.post("/verify-session", badger.verifyResourceSession); +badgerRouter.post("/exchange-session", badger.exchangeSession); export default internalRouter; diff --git a/server/routers/resource/authWithAccessToken.ts b/server/routers/resource/authWithAccessToken.ts index a4340f77..add5f275 100644 --- a/server/routers/resource/authWithAccessToken.ts +++ b/server/routers/resource/authWithAccessToken.ts @@ -1,18 +1,16 @@ 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 logger from "@server/logger"; import { verifyResourceAccessToken } from "@server/auth/verifyResourceAccessToken"; @@ -108,13 +106,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(res, { data: { diff --git a/server/routers/resource/authWithPassword.ts b/server/routers/resource/authWithPassword.ts index dc7c41a9..1aa5d632 100644 --- a/server/routers/resource/authWithPassword.ts +++ b/server/routers/resource/authWithPassword.ts @@ -11,9 +11,7 @@ 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 logger from "@server/logger"; import { verifyPassword } from "@server/auth/password"; @@ -120,11 +118,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(res, { data: { diff --git a/server/routers/resource/authWithPincode.ts b/server/routers/resource/authWithPincode.ts index 97a0b13c..6d83ba22 100644 --- a/server/routers/resource/authWithPincode.ts +++ b/server/routers/resource/authWithPincode.ts @@ -1,28 +1,21 @@ -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 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 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"; export const authWithPincodeBodySchema = z @@ -128,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(res, { data: { diff --git a/server/routers/resource/authWithWhitelist.ts b/server/routers/resource/authWithWhitelist.ts index 54c43f62..c58c7efd 100644 --- a/server/routers/resource/authWithWhitelist.ts +++ b/server/routers/resource/authWithWhitelist.ts @@ -3,7 +3,6 @@ import db from "@server/db"; import { orgs, resourceOtp, - resourcePassword, resources, resourceWhitelist } from "@server/db/schema"; @@ -16,9 +15,7 @@ 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 { isValidOtp, sendResourceOtpEmail } from "@server/auth/resourceOtp"; import logger from "@server/logger"; @@ -178,11 +175,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(res, { data: { diff --git a/server/routers/resource/getExchangeToken.ts b/server/routers/resource/getExchangeToken.ts new file mode 100644 index 00000000..f80a0833 --- /dev/null +++ b/server/routers/resource/getExchangeToken.ts @@ -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 { + 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(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") + ); + } +} diff --git a/server/routers/resource/index.ts b/server/routers/resource/index.ts index 7dbee1bf..ca06dfc3 100644 --- a/server/routers/resource/index.ts +++ b/server/routers/resource/index.ts @@ -16,3 +16,4 @@ export * from "./setResourceWhitelist"; export * from "./getResourceWhitelist"; export * from "./authWithWhitelist"; export * from "./authWithAccessToken"; +export * from "./getExchangeToken"; diff --git a/server/routers/resource/updateResource.ts b/server/routers/resource/updateResource.ts index 67b68123..6a0e7301 100644 --- a/server/routers/resource/updateResource.ts +++ b/server/routers/resource/updateResource.ts @@ -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, diff --git a/server/routers/traefik/getTraefikConfig.ts b/server/routers/traefik/getTraefikConfig.ts index 1153a532..bfc86405 100644 --- a/server/routers/traefik/getTraefikConfig.ts +++ b/server/routers/traefik/getTraefikConfig.ts @@ -39,7 +39,7 @@ export async function traefikConfigProvider( } const badgerMiddlewareName = "badger"; - const redirectMiddlewareName = "redirect-to-https"; + const redirectHttpsMiddlewareName = "redirect-to-https"; const http: any = { routers: {}, @@ -52,19 +52,18 @@ export async function traefikConfigProvider( "/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, + resourceSessionRequestParam: config.getRawConfig().server.resource_session_request_param }, }, }, - [redirectMiddlewareName]: { + [redirectHttpsMiddlewareName]: { redirectScheme: { scheme: "https" }, - }, + } }, }; for (const item of all) { @@ -120,10 +119,9 @@ export async function traefikConfigProvider( }; 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], + middlewares: [redirectHttpsMiddlewareName], service: serviceName, rule: `Host(\`${fullDomain}\`)`, }; diff --git a/server/setup/scripts/1.0.0-beta9.ts b/server/setup/scripts/1.0.0-beta9.ts index e4f62659..9cccd554 100644 --- a/server/setup/scripts/1.0.0-beta9.ts +++ b/server/setup/scripts/1.0.0-beta9.ts @@ -7,7 +7,13 @@ import { userInvites, users } from "@server/db/schema"; +import { APP_PATH, configFilePath1, configFilePath2 } 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"; export default async function migration() { console.log("Running setup script 1.0.0-beta.9..."); @@ -32,5 +38,96 @@ export default async function migration() { console.error(error); } + 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; + + // 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` + ); + 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.0.0-beta.3"; + + const updatedTraefikYaml = yaml.dump(traefikConfig); + + fs.writeFileSync(traefikPath, updatedTraefikYaml, "utf8"); + + console.log( + "Updated the version of Badger in your Traefik configuration to v1.0.0-beta.3." + ); + } catch (e) { + console.log( + "We were unable to update the version of Badger in your Traefik configuration. Please update it manually." + ); + console.error(e); + } + + try { + await db.transaction(async (trx) => { + trx.run(sql`ALTER TABLE 'resourceSessions' ADD 'isRequestToken' integer;`); + trx.run(sql`ALTER TABLE 'resourceSessions' ADD 'userSessionId' text REFERENCES session(id);`); + }); + } catch (e) { + console.log( + "We were unable to add columns to the resourceSessions table." + ); + throw e; + } + console.log("Done."); } diff --git a/src/app/auth/resource/[resourceId]/AccessToken.tsx b/src/app/auth/resource/[resourceId]/AccessToken.tsx index 408a187b..b70d75cb 100644 --- a/src/app/auth/resource/[resourceId]/AccessToken.tsx +++ b/src/app/auth/resource/[resourceId]/AccessToken.tsx @@ -5,14 +5,12 @@ import { Button } from "@app/components/ui/button"; import { Card, CardContent, - CardFooter, CardHeader, CardTitle } from "@app/components/ui/card"; import { useEnvContext } from "@app/hooks/useEnvContext"; import { AuthWithAccessTokenResponse } from "@server/routers/resource"; import { AxiosResponse } from "axios"; -import { Loader2 } from "lucide-react"; import Link from "next/link"; import { useEffect, useState } from "react"; @@ -32,7 +30,17 @@ export default function AccessToken({ const [loading, setLoading] = useState(true); const [isValid, setIsValid] = useState(false); - const api = createApiClient(useEnvContext()); + const { env } = useEnvContext(); + const api = createApiClient({ env }); + + function appendRequestToken(url: string, token: string) { + const fullUrl = new URL(url); + fullUrl.searchParams.append( + env.server.resourceSessionRequestParam, + token + ); + return fullUrl.toString(); + } useEffect(() => { if (!accessTokenId || !accessToken) { @@ -51,7 +59,10 @@ export default function AccessToken({ if (res.data.data.session) { setIsValid(true); - window.location.href = redirectUrl; + window.location.href = appendRequestToken( + redirectUrl, + res.data.data.session + ); } } catch (e) { console.error("Error checking access token", e); diff --git a/src/app/auth/resource/[resourceId]/ResourceAuthPortal.tsx b/src/app/auth/resource/[resourceId]/ResourceAuthPortal.tsx index c23403a8..1aec64d9 100644 --- a/src/app/auth/resource/[resourceId]/ResourceAuthPortal.tsx +++ b/src/app/auth/resource/[resourceId]/ResourceAuthPortal.tsx @@ -1,6 +1,6 @@ "use client"; -import { useEffect, useState, useSyncExternalStore } from "react"; +import { useState } from "react"; import { zodResolver } from "@hookform/resolvers/zod"; import { useForm } from "react-hook-form"; import * as z from "zod"; @@ -8,7 +8,6 @@ import { Card, CardContent, CardDescription, - CardFooter, CardHeader, CardTitle } from "@/components/ui/card"; @@ -30,9 +29,6 @@ import { Key, User, Send, - ArrowLeft, - ArrowRight, - Lock, AtSign } from "lucide-react"; import { @@ -47,10 +43,8 @@ import { AxiosResponse } from "axios"; import LoginForm from "@app/components/LoginForm"; import { AuthWithPasswordResponse, - AuthWithAccessTokenResponse, AuthWithWhitelistResponse } from "@server/routers/resource"; -import { redirect } from "next/dist/server/api-utils"; import ResourceAccessDenied from "./ResourceAccessDenied"; import { createApiClient } from "@app/lib/api"; import { useEnvContext } from "@app/hooks/useEnvContext"; @@ -118,7 +112,9 @@ export default function ResourceAuthPortal(props: ResourceAuthPortalProps) { const [otpState, setOtpState] = useState<"idle" | "otp_sent">("idle"); - const api = createApiClient(useEnvContext()); + const { env } = useEnvContext(); + + const api = createApiClient({ env }); function getDefaultSelectedMethod() { if (props.methods.sso) { @@ -169,6 +165,15 @@ export default function ResourceAuthPortal(props: ResourceAuthPortalProps) { } }); + function appendRequestToken(url: string, token: string) { + const fullUrl = new URL(url); + fullUrl.searchParams.append( + env.server.resourceSessionRequestParam, + token + ); + return fullUrl.toString(); + } + const onWhitelistSubmit = (values: any) => { setLoadingLogin(true); api.post>( @@ -190,7 +195,7 @@ export default function ResourceAuthPortal(props: ResourceAuthPortalProps) { const session = res.data.data.session; if (session) { - window.location.href = props.redirect; + window.location.href = appendRequestToken(props.redirect, session); } }) .catch((e) => { @@ -212,7 +217,7 @@ export default function ResourceAuthPortal(props: ResourceAuthPortalProps) { setPincodeError(null); const session = res.data.data.session; if (session) { - window.location.href = props.redirect; + window.location.href = appendRequestToken(props.redirect, session); } }) .catch((e) => { @@ -237,7 +242,7 @@ export default function ResourceAuthPortal(props: ResourceAuthPortalProps) { setPasswordError(null); const session = res.data.data.session; if (session) { - window.location.href = props.redirect; + window.location.href = appendRequestToken(props.redirect, session); } }) .catch((e) => { @@ -619,16 +624,6 @@ export default function ResourceAuthPortal(props: ResourceAuthPortalProps) { - {/* {activeTab === "sso" && ( -
-

- Don't have an account?{" "} - - Sign up - -

-
- )} */} ) : ( diff --git a/src/app/auth/resource/[resourceId]/page.tsx b/src/app/auth/resource/[resourceId]/page.tsx index 49041a0d..cc576a03 100644 --- a/src/app/auth/resource/[resourceId]/page.tsx +++ b/src/app/auth/resource/[resourceId]/page.tsx @@ -1,7 +1,6 @@ import { - AuthWithAccessTokenResponse, GetResourceAuthInfoResponse, - GetResourceResponse + GetExchangeTokenResponse } from "@server/routers/resource"; import ResourceAuthPortal from "./ResourceAuthPortal"; import { internal, priv } from "@app/lib/api"; @@ -12,9 +11,6 @@ import { verifySession } from "@app/lib/auth/verifySession"; import { redirect } from "next/navigation"; import ResourceNotFound from "./ResourceNotFound"; import ResourceAccessDenied from "./ResourceAccessDenied"; -import { cookies } from "next/headers"; -import { CheckResourceSessionResponse } from "@server/routers/auth"; -import AccessTokenInvalid from "./AccessToken"; import AccessToken from "./AccessToken"; import { pullEnv } from "@app/lib/pullEnv"; @@ -48,7 +44,7 @@ export default async function ResourceAuthPage(props: { // TODO: fix this return (
- {/* @ts-ignore */} + {/* @ts-ignore */}
); @@ -83,49 +79,41 @@ export default async function ResourceAuthPage(props: { ); } - const allCookies = await cookies(); - const cookieName = - env.server.resourceSessionCookieName + `_${params.resourceId}`; - const sessionId = allCookies.get(cookieName)?.value ?? null; - - if (sessionId) { - let doRedirect = false; - try { - const res = await priv.get< - AxiosResponse - >(`/resource-session/${params.resourceId}/${sessionId}`); - - if (res && res.data.data.valid) { - doRedirect = true; - } - } catch (e) {} - - if (doRedirect) { - redirect(redirectUrl); - } - } - if (!hasAuth) { // no authentication so always go straight to the resource redirect(redirectUrl); } + + // convert the dashboard token into a resource session token let userIsUnauthorized = false; if (user && authInfo.sso) { - let doRedirect = false; + let redirectToUrl: string | undefined; try { - const res = await internal.get>( - `/resource/${params.resourceId}`, + const res = await priv.post< + AxiosResponse + >( + `/resource/${params.resourceId}/get-exchange-token`, + {}, await authCookieHeader() ); - doRedirect = true; + if (res.data.data.requestToken) { + const paramName = env.server.resourceSessionRequestParam; + // append the param with the token to the redirect url + const fullUrl = new URL(redirectUrl); + fullUrl.searchParams.append( + paramName, + res.data.data.requestToken + ); + redirectToUrl = fullUrl.toString(); + } } catch (e) { userIsUnauthorized = true; } - if (doRedirect) { - redirect(redirectUrl); + if (redirectToUrl) { + redirect(redirectToUrl); } } diff --git a/src/lib/pullEnv.ts b/src/lib/pullEnv.ts index d335d703..564ba0bc 100644 --- a/src/lib/pullEnv.ts +++ b/src/lib/pullEnv.ts @@ -6,8 +6,8 @@ export function pullEnv(): Env { nextPort: process.env.NEXT_PORT as string, externalPort: process.env.SERVER_EXTERNAL_PORT as string, sessionCookieName: process.env.SESSION_COOKIE_NAME as string, - resourceSessionCookieName: process.env.RESOURCE_SESSION_COOKIE_NAME as string, - resourceAccessTokenParam: process.env.RESOURCE_ACCESS_TOKEN_PARAM as string + resourceAccessTokenParam: process.env.RESOURCE_ACCESS_TOKEN_PARAM as string, + resourceSessionRequestParam: process.env.RESOURCE_SESSION_REQUEST_PARAM as string }, app: { environment: process.env.ENVIRONMENT as string, diff --git a/src/lib/types/env.ts b/src/lib/types/env.ts index 559bb531..9a2e5b93 100644 --- a/src/lib/types/env.ts +++ b/src/lib/types/env.ts @@ -7,8 +7,8 @@ export type Env = { externalPort: string; nextPort: string; sessionCookieName: string; - resourceSessionCookieName: string; resourceAccessTokenParam: string; + resourceSessionRequestParam: string; }, email: { emailEnabled: boolean; From 61b34c8b16550d06dfe7e193c08716e62dd2f32d Mon Sep 17 00:00:00 2001 From: Milo Schwartz Date: Sun, 26 Jan 2025 18:12:30 -0500 Subject: [PATCH 11/29] allow wildcard emails in email whitelist --- server/routers/resource/authWithWhitelist.ts | 52 ++++++++++++++----- .../routers/resource/setResourceWhitelist.ts | 12 ++++- .../[resourceId]/authentication/page.tsx | 18 ++++++- src/components/ui/info-popup.tsx | 38 ++++++++++++++ 4 files changed, 104 insertions(+), 16 deletions(-) create mode 100644 src/components/ui/info-popup.tsx diff --git a/server/routers/resource/authWithWhitelist.ts b/server/routers/resource/authWithWhitelist.ts index c58c7efd..f97b3035 100644 --- a/server/routers/resource/authWithWhitelist.ts +++ b/server/routers/resource/authWithWhitelist.ts @@ -13,9 +13,7 @@ import { NextFunction, Request, Response } from "express"; import createHttpError from "http-errors"; import { z } from "zod"; import { fromError } from "zod-validation-error"; -import { - createResourceSession, -} from "@server/auth/sessions/resource"; +import { createResourceSession } from "@server/auth/sessions/resource"; import { isValidOtp, sendResourceOtpEmail } from "@server/auth/resourceOtp"; import logger from "@server/logger"; @@ -90,20 +88,48 @@ 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) { + return next( + createHttpError( + HttpCode.UNAUTHORIZED, + createHttpError( + HttpCode.BAD_REQUEST, + "Email is not whitelisted" + ) + ) + ); + } } if (!org) { diff --git a/server/routers/resource/setResourceWhitelist.ts b/server/routers/resource/setResourceWhitelist.ts index 8931d4ff..d60d079d 100644 --- a/server/routers/resource/setResourceWhitelist.ts +++ b/server/routers/resource/setResourceWhitelist.ts @@ -12,7 +12,17 @@ import { and, eq } from "drizzle-orm"; const setResourceWhitelistBodySchema = z .object({ emails: z - .array(z.string().email()) + .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())) }) diff --git a/src/app/[orgId]/settings/resources/[resourceId]/authentication/page.tsx b/src/app/[orgId]/settings/resources/[resourceId]/authentication/page.tsx index c59806b5..64464d60 100644 --- a/src/app/[orgId]/settings/resources/[resourceId]/authentication/page.tsx +++ b/src/app/[orgId]/settings/resources/[resourceId]/authentication/page.tsx @@ -48,6 +48,7 @@ import { SettingsSectionFooter } from "@app/components/Settings"; import { SwitchInput } from "@app/components/SwitchInput"; +import { InfoPopup } from "@app/components/ui/info-popup"; const UsersRolesFormSchema = z.object({ roles: z.array( @@ -665,10 +666,12 @@ export default function ResourceAuthenticationPage() { render={({ field }) => ( - Whitelisted Emails + - {/* @ts-ignore */} {/* @ts-ignore */} + {text} + + + + + +

{info}

+
+
+ + ); +} From fdb1ab4bd9f31f8008fbf0885664e5beb365efdd Mon Sep 17 00:00:00 2001 From: Milo Schwartz Date: Mon, 27 Jan 2025 19:59:52 -0500 Subject: [PATCH 12/29] allow setting secure for smtp in config --- server/emails/index.ts | 23 +++++++++-------------- server/lib/config.ts | 9 +++++---- 2 files changed, 14 insertions(+), 18 deletions(-) diff --git a/server/emails/index.ts b/server/emails/index.ts index 10ee4933..156d491b 100644 --- a/server/emails/index.ts +++ b/server/emails/index.ts @@ -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 || !emailConfig.no_reply) { + 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 + } }); } diff --git a/server/lib/config.ts b/server/lib/config.ts index 2c1ef7f0..e44cff5c 100644 --- a/server/lib/config.ts +++ b/server/lib/config.ts @@ -122,10 +122,11 @@ const configSchema = z.object({ }), email: z .object({ - smtp_host: z.string(), - smtp_port: portSchema, - smtp_user: z.string(), - smtp_pass: z.string(), + 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(), From 0bd8217d9ef6bc685d46480aefca7982a2a34a78 Mon Sep 17 00:00:00 2001 From: Milo Schwartz Date: Mon, 27 Jan 2025 22:43:32 -0500 Subject: [PATCH 13/29] add failed auth logging --- server/lib/config.ts | 3 +- server/middlewares/verifyUser.ts | 7 +++++ server/routers/auth/disable2fa.ts | 5 ++++ server/routers/auth/login.ts | 15 ++++++++++ server/routers/auth/logout.ts | 6 ++++ server/routers/auth/resetPassword.ts | 15 ++++++++++ server/routers/auth/signup.ts | 20 ++++++++++++- server/routers/auth/verifyEmail.ts | 5 ++++ server/routers/auth/verifyTotp.ts | 5 ++++ server/routers/badger/exchangeSession.ts | 17 +++++++++-- server/routers/badger/verifySession.ts | 30 ++++++++++++++++++- server/routers/newt/getToken.ts | 21 ++++++++++--- .../routers/resource/authWithAccessToken.ts | 10 +++++-- server/routers/resource/authWithPassword.ts | 12 +++++--- server/routers/resource/authWithPincode.ts | 16 +++++----- server/routers/resource/authWithWhitelist.ts | 13 +++++++- 16 files changed, 175 insertions(+), 25 deletions(-) diff --git a/server/lib/config.ts b/server/lib/config.ts index e44cff5c..9476b505 100644 --- a/server/lib/config.ts +++ b/server/lib/config.ts @@ -40,7 +40,8 @@ const configSchema = z.object({ .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 diff --git a/server/middlewares/verifyUser.ts b/server/middlewares/verifyUser.ts index 0f52f2e3..72680352 100644 --- a/server/middlewares/verifyUser.ts +++ b/server/middlewares/verifyUser.ts @@ -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") ); diff --git a/server/routers/auth/disable2fa.ts b/server/routers/auth/disable2fa.ts index e27d75df..b93e9cc3 100644 --- a/server/routers/auth/disable2fa.ts +++ b/server/routers/auth/disable2fa.ts @@ -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, diff --git a/server/routers/auth/login.ts b/server/routers/auth/login.ts index 7ee0d927..09bd9661 100644 --- a/server/routers/auth/login.ts +++ b/server/routers/auth/login.ts @@ -71,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, @@ -86,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, @@ -112,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, diff --git a/server/routers/auth/logout.ts b/server/routers/auth/logout.ts index e9e51a31..a2e0a7f1 100644 --- a/server/routers/auth/logout.ts +++ b/server/routers/auth/logout.ts @@ -8,6 +8,7 @@ import { 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, @@ -16,6 +17,11 @@ export async function logout( ): Promise { 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, diff --git a/server/routers/auth/resetPassword.ts b/server/routers/auth/resetPassword.ts index 45c8652b..d112e98b 100644 --- a/server/routers/auth/resetPassword.ts +++ b/server/routers/auth/resetPassword.ts @@ -60,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, @@ -109,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, @@ -124,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, diff --git a/server/routers/auth/signup.ts b/server/routers/auth/signup.ts index 2a4bb127..4bb5394e 100644 --- a/server/routers/auth/signup.ts +++ b/server/routers/auth/signup.ts @@ -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().transform((v) => v.toLowerCase()), + 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, diff --git a/server/routers/auth/verifyEmail.ts b/server/routers/auth/verifyEmail.ts index 45ef4230..e189e9a6 100644 --- a/server/routers/auth/verifyEmail.ts +++ b/server/routers/auth/verifyEmail.ts @@ -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, diff --git a/server/routers/auth/verifyTotp.ts b/server/routers/auth/verifyTotp.ts index f7b8eb38..36bbf348 100644 --- a/server/routers/auth/verifyTotp.ts +++ b/server/routers/auth/verifyTotp.ts @@ -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, diff --git a/server/routers/badger/exchangeSession.ts b/server/routers/badger/exchangeSession.ts index eaf47e6a..1af960aa 100644 --- a/server/routers/badger/exchangeSession.ts +++ b/server/routers/badger/exchangeSession.ts @@ -20,7 +20,8 @@ import { response } from "@server/lib"; const exchangeSessionBodySchema = z.object({ requestToken: z.string(), - host: z.string() + host: z.string(), + requestIp: z.string().optional() }); export type ExchangeSessionBodySchema = z.infer< @@ -51,7 +52,9 @@ export async function exchangeSession( } try { - const { requestToken, host } = parsedBody.data; + const { requestToken, host, requestIp } = parsedBody.data; + + const clientIp = requestIp?.split(":")[0]; const [resource] = await db .select() @@ -75,12 +78,22 @@ export async function exchangeSession( ); 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") ); diff --git a/server/routers/badger/verifySession.ts b/server/routers/badger/verifySession.ts index ae6d2edc..a71e6b3c 100644 --- a/server/routers/badger/verifySession.ts +++ b/server/routers/badger/verifySession.ts @@ -42,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< @@ -77,9 +78,12 @@ export async function verifyResourceSession( sessions, host, originalRequestURL, + requestIp, accessToken: token } = parsedBody.data; + const clientIp = requestIp?.split(":")[0]; + const resourceCacheKey = `resource:${host}`; let resourceData: | { @@ -160,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; @@ -174,6 +186,11 @@ export async function verifyResourceSession( } if (!sessions) { + if (config.getRawConfig().app.log_failed_attempts) { + logger.info( + `Missing resource sessions. Resource ID: ${resource.resourceId}. IP: ${clientIp}.` + ); + } return notAllowed(res); } @@ -200,6 +217,11 @@ export async function verifyResourceSession( 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); } @@ -271,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); diff --git a/server/routers/newt/getToken.ts b/server/routers/newt/getToken.ts index f13cbac0..e6ae0cd6 100644 --- a/server/routers/newt/getToken.ts +++ b/server/routers/newt/getToken.ts @@ -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(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") ); diff --git a/server/routers/resource/authWithAccessToken.ts b/server/routers/resource/authWithAccessToken.ts index add5f275..03dac735 100644 --- a/server/routers/resource/authWithAccessToken.ts +++ b/server/routers/resource/authWithAccessToken.ts @@ -8,11 +8,10 @@ import { NextFunction, Request, Response } from "express"; import createHttpError from "http-errors"; import { z } from "zod"; import { fromError } from "zod-validation-error"; -import { - createResourceSession, -} from "@server/auth/sessions/resource"; +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({ @@ -84,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, diff --git a/server/routers/resource/authWithPassword.ts b/server/routers/resource/authWithPassword.ts index 1aa5d632..a69d42b6 100644 --- a/server/routers/resource/authWithPassword.ts +++ b/server/routers/resource/authWithPassword.ts @@ -9,11 +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, -} from "@server/auth/sessions/resource"; +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({ @@ -82,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") ); } @@ -109,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") ); diff --git a/server/routers/resource/authWithPincode.ts b/server/routers/resource/authWithPincode.ts index 6d83ba22..c648e36c 100644 --- a/server/routers/resource/authWithPincode.ts +++ b/server/routers/resource/authWithPincode.ts @@ -1,10 +1,6 @@ import { generateSessionToken } from "@server/auth/sessions/app"; import db from "@server/db"; -import { - orgs, - resourcePincode, - resources, -} 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 { eq } from "drizzle-orm"; @@ -12,11 +8,10 @@ import { NextFunction, Request, Response } from "express"; import createHttpError from "http-errors"; import { z } from "zod"; import { fromError } from "zod-validation-error"; -import { - createResourceSession, -} from "@server/auth/sessions/resource"; +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 authWithPincodeBodySchema = z .object({ @@ -112,6 +107,11 @@ export async function authWithPincode( 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") ); diff --git a/server/routers/resource/authWithWhitelist.ts b/server/routers/resource/authWithWhitelist.ts index f97b3035..dae36b24 100644 --- a/server/routers/resource/authWithWhitelist.ts +++ b/server/routers/resource/authWithWhitelist.ts @@ -16,6 +16,7 @@ import { fromError } from "zod-validation-error"; 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({ @@ -96,7 +97,7 @@ export async function authWithWhitelist( // if email is not found, check for wildcard email const wildcard = "*@" + email.split("@")[1]; - logger.debug("Checking for wildcard email: " + wildcard) + logger.debug("Checking for wildcard email: " + wildcard); const [result] = await db .select() @@ -120,6 +121,11 @@ export async function authWithWhitelist( // if wildcard is still not found, return unauthorized if (!whitelistedEmail) { + if (config.getRawConfig().app.log_failed_attempts) { + logger.info( + `Email is not whitelisted. Resource ID: ${resource?.resourceId}. Email: ${email}. IP: ${req.ip}.` + ); + } return next( createHttpError( HttpCode.UNAUTHORIZED, @@ -151,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") ); From a57f0ab3606a2dfd936273f168a573750ec835b9 Mon Sep 17 00:00:00 2001 From: Milo Schwartz Date: Tue, 28 Jan 2025 21:23:19 -0500 Subject: [PATCH 14/29] log password reset token if no smtp to allow reset password --- server/routers/auth/requestPasswordReset.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/server/routers/auth/requestPasswordReset.ts b/server/routers/auth/requestPasswordReset.ts index 36cfed87..e4105f68 100644 --- a/server/routers/auth/requestPasswordReset.ts +++ b/server/routers/auth/requestPasswordReset.ts @@ -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, From 60110350aa8b075eb1220a89f4e6cda08348803e Mon Sep 17 00:00:00 2001 From: Milo Schwartz Date: Tue, 28 Jan 2025 21:26:34 -0500 Subject: [PATCH 15/29] use smtp user if no no-reply set --- server/auth/resourceOtp.ts | 2 +- server/auth/sendEmailVerificationCode.ts | 2 +- server/lib/config.ts | 10 ++++++++-- server/routers/auth/requestPasswordReset.ts | 2 +- server/routers/auth/resetPassword.ts | 2 +- server/routers/user/inviteUser.ts | 2 +- 6 files changed, 13 insertions(+), 7 deletions(-) diff --git a/server/auth/resourceOtp.ts b/server/auth/resourceOtp.ts index 8c23e508..33675aa5 100644 --- a/server/auth/resourceOtp.ts +++ b/server/auth/resourceOtp.ts @@ -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}` } ); diff --git a/server/auth/sendEmailVerificationCode.ts b/server/auth/sendEmailVerificationCode.ts index 5fe2b280..54318760 100644 --- a/server/auth/sendEmailVerificationCode.ts +++ b/server/auth/sendEmailVerificationCode.ts @@ -21,7 +21,7 @@ export async function sendEmailVerificationCode( }), { to: email, - from: config.getRawConfig().email?.no_reply, + from: config.getNoReplyEmail(), subject: "Verify your email address" } ); diff --git a/server/lib/config.ts b/server/lib/config.ts index 9476b505..1d91b9f6 100644 --- a/server/lib/config.ts +++ b/server/lib/config.ts @@ -41,7 +41,7 @@ const configSchema = z.object({ .transform((url) => url.toLowerCase()), log_level: z.enum(["debug", "info", "warn", "error"]), save_logs: z.boolean(), - log_failed_attempts: z.boolean().optional(), + log_failed_attempts: z.boolean().optional() }), server: z.object({ external_port: portSchema @@ -128,7 +128,7 @@ const configSchema = z.object({ smtp_user: z.string().optional(), smtp_pass: z.string().optional(), smtp_secure: z.boolean().optional(), - no_reply: z.string().email() + no_reply: z.string().email().optional() }) .optional(), users: z.object({ @@ -280,6 +280,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 diff --git a/server/routers/auth/requestPasswordReset.ts b/server/routers/auth/requestPasswordReset.ts index e4105f68..08d563ad 100644 --- a/server/routers/auth/requestPasswordReset.ts +++ b/server/routers/auth/requestPasswordReset.ts @@ -95,7 +95,7 @@ export async function requestPasswordReset( link: url }), { - from: config.getRawConfig().email?.no_reply, + from: config.getNoReplyEmail(), to: email, subject: "Reset your password" } diff --git a/server/routers/auth/resetPassword.ts b/server/routers/auth/resetPassword.ts index d112e98b..97b283c6 100644 --- a/server/routers/auth/resetPassword.ts +++ b/server/routers/auth/resetPassword.ts @@ -163,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" }); diff --git a/server/routers/user/inviteUser.ts b/server/routers/user/inviteUser.ts index 45240ed2..5bf7e17f 100644 --- a/server/routers/user/inviteUser.ts +++ b/server/routers/user/inviteUser.ts @@ -168,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" } ); From 397036640ea1be517845ac53a38760c00daf2a00 Mon Sep 17 00:00:00 2001 From: Milo Schwartz Date: Tue, 28 Jan 2025 21:39:17 -0500 Subject: [PATCH 16/29] add additional_middlewares --- server/lib/config.ts | 3 ++- server/routers/traefik/getTraefikConfig.ts | 4 +++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/server/lib/config.ts b/server/lib/config.ts index 1d91b9f6..9ef4ca9b 100644 --- a/server/lib/config.ts +++ b/server/lib/config.ts @@ -90,7 +90,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 diff --git a/server/routers/traefik/getTraefikConfig.ts b/server/routers/traefik/getTraefikConfig.ts index bfc86405..d8832c61 100644 --- a/server/routers/traefik/getTraefikConfig.ts +++ b/server/routers/traefik/getTraefikConfig.ts @@ -106,13 +106,15 @@ export async function traefikConfigProvider( : {}), }; + const additionalMiddlewares = config.getRawConfig().traefik.additional_middlewares || []; + http.routers![routerName] = { entryPoints: [ resource.ssl ? config.getRawConfig().traefik.https_entrypoint : config.getRawConfig().traefik.http_entrypoint, ], - middlewares: [badgerMiddlewareName], + middlewares: [badgerMiddlewareName, ...additionalMiddlewares], service: serviceName, rule: `Host(\`${fullDomain}\`)`, ...(resource.ssl ? { tls } : {}), From f874449d367a48b116660de9b5b2d821f508deed Mon Sep 17 00:00:00 2001 From: Milo Schwartz Date: Tue, 28 Jan 2025 22:13:46 -0500 Subject: [PATCH 17/29] remove no reply check in send email --- server/emails/index.ts | 2 +- server/routers/resource/authWithWhitelist.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/server/emails/index.ts b/server/emails/index.ts index 156d491b..c1c2bc87 100644 --- a/server/emails/index.ts +++ b/server/emails/index.ts @@ -6,7 +6,7 @@ import logger from "@server/logger"; function createEmailClient() { const emailConfig = config.getRawConfig().email; - if (!emailConfig || !emailConfig.no_reply) { + if (!emailConfig) { logger.warn( "Email SMTP configuration is missing. Emails will not be sent." ); diff --git a/server/routers/resource/authWithWhitelist.ts b/server/routers/resource/authWithWhitelist.ts index dae36b24..147e2ba9 100644 --- a/server/routers/resource/authWithWhitelist.ts +++ b/server/routers/resource/authWithWhitelist.ts @@ -123,7 +123,7 @@ export async function authWithWhitelist( if (!whitelistedEmail) { if (config.getRawConfig().app.log_failed_attempts) { logger.info( - `Email is not whitelisted. Resource ID: ${resource?.resourceId}. Email: ${email}. IP: ${req.ip}.` + `Email is not whitelisted. Email: ${email}. IP: ${req.ip}.` ); } return next( From 0e04e82b880f29163953aa97b568fd7cedaf1fbd Mon Sep 17 00:00:00 2001 From: Owen Schwartz Date: Tue, 28 Jan 2025 22:26:45 -0500 Subject: [PATCH 18/29] Squashed commit of the following: commit c276d2193da5dbe7af5197bdf7e2bcce6f87b0cf Author: Owen Schwartz Date: Tue Jan 28 22:06:04 2025 -0500 Okay actually now commit 9afdc0aadc3f4fb4e811930bacff70a9e17eab9f Author: Owen Schwartz Date: Tue Jan 28 21:58:44 2025 -0500 Migrations working finally commit a7336b3b2466fe74d650b9c253ecadbe1eff749d Merge: e7c7203 fdb1ab4 Author: Owen Schwartz Date: Mon Jan 27 22:19:15 2025 -0500 Merge branch 'dev' into tcp-udp-traffic commit e7c7203330b1b08e570048b10ef314b55068e466 Author: Owen Schwartz Date: Mon Jan 27 22:18:09 2025 -0500 Working on migration commit a4704dfd44b10647257c7c7054c0dae806d315bb Author: Owen Schwartz Date: Mon Jan 27 21:40:52 2025 -0500 Add flag to allow raw resources commit d74f7a57ed11e2a6bf1a7e0c28c29fb07eb573a0 Merge: 6817788 d791b9b Author: Owen Schwartz Date: Mon Jan 27 21:28:50 2025 -0500 Merge branch 'tcp-udp-traffic' of https://github.com/fosrl/pangolin into tcp-udp-traffic commit 68177882781b54ef30b62cca7dee8bbed7c5a2fa Author: Owen Schwartz Date: Mon Jan 27 21:28:32 2025 -0500 Get everything working commit d791b9b47f9f6ca050d6edfd1d674438f8562d99 Author: Milo Schwartz Date: Mon Jan 27 17:46:19 2025 -0500 fix orgId check in verifyAdmin commit 6ac30afd7a449a126190d311bd98d7f1048f73a4 Author: Owen Schwartz Date: Sun Jan 26 23:19:33 2025 -0500 Trying to figure out traefik... commit 9886b42272882f8bb6baff2efdbe26cee7cac2b6 Merge: 786e67e 85e9129 Author: Owen Schwartz Date: Sun Jan 26 21:53:32 2025 -0500 Merge branch 'tcp-udp-traffic' of https://github.com/fosrl/pangolin into tcp-udp-traffic commit 786e67eadd6df1ee8df24e77aed20c1f1fc9ca67 Author: Owen Schwartz Date: Sun Jan 26 21:51:37 2025 -0500 Bug fixing commit 85e9129ae313b2e4a460a8bc53a0af9f9fbbafb2 Author: Milo Schwartz Date: Sun Jan 26 18:35:24 2025 -0500 rethrow errors in migration and remove permanent redirect commit bd82699505fc7510c27f72cd80ea0ce815d8c5ef Author: Owen Schwartz Date: Sun Jan 26 17:49:12 2025 -0500 Fix merge issue commit 933dbf3a02b1f19fd1f627410b2407fdf05cd9bf Author: Owen Schwartz Date: Sun Jan 26 17:46:13 2025 -0500 Add sql to update resources and targets commit f19437bad847c8dbf57fddd2c48cd17bab20ddb0 Merge: 58980eb 9f1f291 Author: Owen Schwartz Date: Sun Jan 26 17:19:51 2025 -0500 Merge branch 'dev' into tcp-udp-traffic commit 58980ebb64d1040b4d224c76beb38c2254f3c5d9 Merge: 1de682a d284d36 Author: Owen Schwartz Date: Sun Jan 26 17:10:09 2025 -0500 Merge branch 'dev' into tcp-udp-traffic commit 1de682a9f6039f40e05c8901c7381a94b0d018ed Author: Owen Schwartz Date: Sun Jan 26 17:08:29 2025 -0500 Working on migrations commit dc853d2bc02b11997be5c3c7ea789402716fb4c2 Author: Owen Schwartz Date: Sun Jan 26 16:56:49 2025 -0500 Finish config of resource pages commit 37c681c08d7ab73d2cad41e7ef1dbe3a8852e1f2 Author: Owen Schwartz Date: Sun Jan 26 16:07:25 2025 -0500 Finish up table commit 461c6650bbea0d7439cc042971ec13fdb52a7431 Author: Owen Schwartz Date: Sun Jan 26 15:54:46 2025 -0500 Working toward having dual resource types commit f0894663627375e16ce6994370cb30b298efc2dc Author: Owen Schwartz Date: Sat Jan 25 22:31:25 2025 -0500 Add qutoes commit edc535b79b94c2e65b290cd90a69fe17d27245e9 Author: Owen Schwartz Date: Sat Jan 25 22:28:45 2025 -0500 Add readTimeout to allow long file uploads commit 194892fa14b505bd7c2b31873dc13d4b8996c0e1 Author: Owen Schwartz Date: Sat Jan 25 20:37:34 2025 -0500 Rework traefik config generation commit ad3f896b5333e4706d610c3198f29dcd67610365 Author: Owen Schwartz Date: Sat Jan 25 13:01:47 2025 -0500 Add proxy port to api commit ca6013b2ffda0924a696ec3141825a54a4e5297d Author: Owen Schwartz Date: Sat Jan 25 12:58:01 2025 -0500 Add migration commit 2258d76cb3a49d3db7f05f76d8b8a9f1c248b5e4 Author: Owen Schwartz Date: Sat Jan 25 12:55:02 2025 -0500 Add new proxy port --- config/config.example.yml | 1 + config/traefik/traefik_config.example.yml | 3 + install/fs/config.yml | 1 + install/fs/traefik/traefik_config.yml | 11 +- server/db/schema.ts | 10 +- server/lib/config.ts | 7 +- server/middlewares/verifyAdmin.ts | 2 +- server/routers/badger/exchangeSession.ts | 2 +- server/routers/badger/verifySession.ts | 2 +- server/routers/internal.ts | 12 +- server/routers/newt/handleRegisterMessage.ts | 94 +++-- server/routers/newt/targets.ts | 97 ++--- server/routers/resource/createResource.ts | 100 ++++- server/routers/resource/deleteResource.ts | 2 +- server/routers/resource/listResources.ts | 10 +- server/routers/resource/updateResource.ts | 2 +- server/routers/target/createTarget.ts | 11 +- server/routers/target/deleteTarget.ts | 6 +- server/routers/target/listTargets.ts | 1 - server/routers/target/updateTarget.ts | 8 +- server/routers/traefik/getTraefikConfig.ts | 342 +++++++++++------- server/routers/traefik/index.ts | 2 +- server/setup/scripts/1.0.0-beta9.ts | 118 +++++- .../settings/resources/CreateResourceForm.tsx | 241 +++++++++--- .../settings/resources/ResourcesTable.tsx | 51 ++- .../[resourceId]/ResourceInfoBox.tsx | 94 +++-- .../[resourceId]/connectivity/page.tsx | 67 ++-- .../resources/[resourceId]/general/page.tsx | 138 ++++--- .../resources/[resourceId]/layout.tsx | 11 +- src/app/[orgId]/settings/resources/page.tsx | 3 + src/lib/pullEnv.ts | 4 +- src/lib/types/env.ts | 1 + 32 files changed, 1003 insertions(+), 451 deletions(-) diff --git a/config/config.example.yml b/config/config.example.yml index df4170a2..788b5943 100644 --- a/config/config.example.yml +++ b/config/config.example.yml @@ -41,3 +41,4 @@ flags: require_email_verification: false disable_signup_without_invite: true disable_user_create_org: true + allow_raw_resources: true \ No newline at end of file diff --git a/config/traefik/traefik_config.example.yml b/config/traefik/traefik_config.example.yml index 3b4512d5..8a2263ad 100644 --- a/config/traefik/traefik_config.example.yml +++ b/config/traefik/traefik_config.example.yml @@ -33,6 +33,9 @@ entryPoints: address: ":80" websecure: address: ":443" + transport: + respondingTimeouts: + readTimeout: "30m" http: tls: certResolver: "letsencrypt" diff --git a/install/fs/config.yml b/install/fs/config.yml index e7325562..3ccec1e5 100644 --- a/install/fs/config.yml +++ b/install/fs/config.yml @@ -54,3 +54,4 @@ flags: require_email_verification: {{.EnableEmail}} disable_signup_without_invite: {{.DisableSignupWithoutInvite}} disable_user_create_org: {{.DisableUserCreateOrg}} + allow_raw_resources: true diff --git a/install/fs/traefik/traefik_config.yml b/install/fs/traefik/traefik_config.yml index de104a2f..4a68fdd5 100644 --- a/install/fs/traefik/traefik_config.yml +++ b/install/fs/traefik/traefik_config.yml @@ -4,7 +4,13 @@ api: providers: http: - endpoint: "http://pangolin:3001/api/v1/traefik-config" + endpoint: "http://pangolin:3001/api/v1/traefik-config/http" + pollInterval: "5s" + udp: + endpoint: "http://pangolin:3001/api/v1/traefik-config/udp" + pollInterval: "5s" + tcp: + endpoint: "http://pangolin:3001/api/v1/traefik-config/tcp" pollInterval: "5s" file: filename: "/etc/traefik/dynamic_config.yml" @@ -33,6 +39,9 @@ entryPoints: address: ":80" websecure: address: ":443" + transport: + respondingTimeouts: + readTimeout: "30m" http: tls: certResolver: "letsencrypt" diff --git a/server/db/schema.ts b/server/db/schema.ts index acdda169..b87acd91 100644 --- a/server/db/schema.ts +++ b/server/db/schema.ts @@ -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) }); diff --git a/server/lib/config.ts b/server/lib/config.ts index 9ef4ca9b..66dd6764 100644 --- a/server/lib/config.ts +++ b/server/lib/config.ts @@ -151,7 +151,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() }); @@ -254,6 +255,10 @@ 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.EMAIL_ENABLED = parsedConfig.data.email ? "true" : "false"; diff --git a/server/middlewares/verifyAdmin.ts b/server/middlewares/verifyAdmin.ts index 08ab3d09..b2a773e1 100644 --- a/server/middlewares/verifyAdmin.ts +++ b/server/middlewares/verifyAdmin.ts @@ -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") ); diff --git a/server/routers/badger/exchangeSession.ts b/server/routers/badger/exchangeSession.ts index 1af960aa..18925279 100644 --- a/server/routers/badger/exchangeSession.ts +++ b/server/routers/badger/exchangeSession.ts @@ -163,7 +163,7 @@ export async function exchangeSession( const cookieName = `${config.getRawConfig().server.session_cookie_name}`; const cookie = serializeResourceSessionCookie( cookieName, - resource.fullDomain, + resource.fullDomain!, token, !resource.ssl ); diff --git a/server/routers/badger/verifySession.ts b/server/routers/badger/verifySession.ts index a71e6b3c..b1a6fb12 100644 --- a/server/routers/badger/verifySession.ts +++ b/server/routers/badger/verifySession.ts @@ -352,7 +352,7 @@ async function createAccessTokenSession( const cookieName = `${config.getRawConfig().server.session_cookie_name}`; const cookie = serializeResourceSessionCookie( cookieName, - resource.fullDomain, + resource.fullDomain!, token, !resource.ssl ); diff --git a/server/routers/internal.ts b/server/routers/internal.ts index 30f7fd6d..2d68b368 100644 --- a/server/routers/internal.ts +++ b/server/routers/internal.ts @@ -1,11 +1,12 @@ 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 auth from "@server/routers/auth"; -import * as resource from "@server/routers/resource"; import HttpCode from "@server/types/HttpCode"; import { verifyResourceAccess, verifySessionUserMiddleware } from "@server/middlewares"; +import { getExchangeToken } from "./resource/getExchangeToken"; +import { verifyResourceSession } from "./badger"; +import { exchangeSession } from "./badger/exchangeSession"; // Root routes const internalRouter = Router(); @@ -15,6 +16,7 @@ internalRouter.get("/", (_, res) => { }); internalRouter.get("/traefik-config", traefik.traefikConfigProvider); + internalRouter.get( "/resource-session/:resourceId/:token", auth.checkResourceSession @@ -24,7 +26,7 @@ internalRouter.post( `/resource/:resourceId/get-exchange-token`, verifySessionUserMiddleware, verifyResourceAccess, - resource.getExchangeToken + getExchangeToken ); // Gerbil routes @@ -38,7 +40,7 @@ gerbilRouter.post("/receive-bandwidth", gerbil.receiveBandwidth); const badgerRouter = Router(); internalRouter.use("/badger", badgerRouter); -badgerRouter.post("/verify-session", badger.verifyResourceSession); -badgerRouter.post("/exchange-session", badger.exchangeSession); +badgerRouter.post("/verify-session", verifyResourceSession); +badgerRouter.post("/exchange-session", exchangeSession); export default internalRouter; diff --git a/server/routers/newt/handleRegisterMessage.ts b/server/routers/newt/handleRegisterMessage.ts index 2721ec86..704382eb 100644 --- a/server/routers/newt/handleRegisterMessage.ts +++ b/server/routers/newt/handleRegisterMessage.ts @@ -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,67 @@ 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`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) ) - ); + ) + .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: { diff --git a/server/routers/newt/targets.ts b/server/routers/newt/targets.ts index 1d456329..e5f7855c 100644 --- a/server/routers/newt/targets.ts +++ b/server/routers/newt/targets.ts @@ -1,73 +1,44 @@ import { Target } from "@server/db/schema"; import { sendToClient } from "../ws"; -export async function addTargets(newtId: string, targets: Target[]): Promise { +export async function addTargets( + newtId: string, + targets: Target[], + protocol: string +): Promise { //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 { +export async function removeTargets( + newtId: string, + targets: Target[], + protocol: string +): Promise { //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); } diff --git a/server/routers/resource/createResource.ts b/server/routers/resource/createResource.ts index 00cad947..ee392e5f 100644 --- a/server/routers/resource/createResource.ts +++ b/server/routers/resource/createResource.ts @@ -16,7 +16,6 @@ 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"; const createResourceParamsSchema = z @@ -28,11 +27,36 @@ const createResourceParamsSchema = z const createResourceSchema = z .object({ + subdomain: z + .union([ + z + .string() + .regex( + /^(?!:\/\/)([a-zA-Z0-9-_]+\.)*[a-zA-Z0-9-_]+$/, + "Invalid subdomain format" + ) + .min(1, "Subdomain must be at least 1 character long") + .transform((val) => val.toLowerCase()), + z.string().optional() + ]) + .optional(), name: z.string().min(1).max(255), - subdomain: subdomainSchema - }) - .strict(); - + http: z.boolean(), + protocol: z.string(), + proxyPort: z.number().int().min(1).max(65535).optional(), + }).refine( + (data) => { + if (data.http === true) { + return true; + } + return !!data.proxyPort; + }, + { + message: "Port number is required for non-HTTP resources", + path: ["proxyPort"] + } + ); + export type CreateResourceResponse = Resource; export async function createResource( @@ -51,7 +75,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 +113,65 @@ 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 +209,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") diff --git a/server/routers/resource/deleteResource.ts b/server/routers/resource/deleteResource.ts index c9046017..ed0fc95f 100644 --- a/server/routers/resource/deleteResource.ts +++ b/server/routers/resource/deleteResource.ts @@ -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); } } diff --git a/server/routers/resource/listResources.ts b/server/routers/resource/listResources.ts index 74c1cff3..50aebb76 100644 --- a/server/routers/resource/listResources.ts +++ b/server/routers/resource/listResources.ts @@ -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)) diff --git a/server/routers/resource/updateResource.ts b/server/routers/resource/updateResource.ts index 6a0e7301..9a80fb7d 100644 --- a/server/routers/resource/updateResource.ts +++ b/server/routers/resource/updateResource.ts @@ -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, { diff --git a/server/routers/target/createTarget.ts b/server/routers/target/createTarget.ts index 9ade677f..3d5e8d0e 100644 --- a/server/routers/target/createTarget.ts +++ b/server/routers/target/createTarget.ts @@ -53,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(); @@ -94,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)); @@ -130,7 +127,6 @@ export async function createTarget( .insert(targets) .values({ resourceId, - protocol: "tcp", // hard code for now ...targetData }) .returning(); @@ -163,7 +159,6 @@ export async function createTarget( .insert(targets) .values({ resourceId, - protocol: "tcp", // hard code for now internalPort, ...targetData }) @@ -186,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); } } } diff --git a/server/routers/target/deleteTarget.ts b/server/routers/target/deleteTarget.ts index 06be64e2..97dab71c 100644 --- a/server/routers/target/deleteTarget.ts +++ b/server/routers/target/deleteTarget.ts @@ -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); } } diff --git a/server/routers/target/listTargets.ts b/server/routers/target/listTargets.ts index e430ed18..0efea3ec 100644 --- a/server/routers/target/listTargets.ts +++ b/server/routers/target/listTargets.ts @@ -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, diff --git a/server/routers/target/updateTarget.ts b/server/routers/target/updateTarget.ts index 5bc7ad53..4125fd9c 100644 --- a/server/routers/target/updateTarget.ts +++ b/server/routers/target/updateTarget.ts @@ -49,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() }) @@ -103,9 +103,7 @@ export async function updateTarget( // get the resource const [resource] = await db - .select({ - siteId: resources.siteId - }) + .select() .from(resources) .where(eq(resources.resourceId, target.resourceId!)); @@ -167,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, { diff --git a/server/routers/traefik/getTraefikConfig.ts b/server/routers/traefik/getTraefikConfig.ts index d8832c61..4b760274 100644 --- a/server/routers/traefik/getTraefikConfig.ts +++ b/server/routers/traefik/getTraefikConfig.ts @@ -1,173 +1,267 @@ 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 { 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`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 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, - userSessionCookieName: - config.getRawConfig().server.session_cookie_name, - accessTokenQueryParam: config.getRawConfig().server.resource_access_token_param, - resourceSessionRequestParam: config.getRawConfig().server.resource_session_request_param - }, - }, - }, - [redirectHttpsMiddlewareName]: { - redirectScheme: { - scheme: "https" + 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 + } + } }, + [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, - }, - ], - } - : {}), - }; + if ( + targets.filter( + (target: Target) => target.internalPort != null + ).length == 0 + ) { + continue; + } - const additionalMiddlewares = config.getRawConfig().traefik.additional_middlewares || []; + // 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, ...additionalMiddlewares], - service: serviceName, - rule: `Host(\`${fullDomain}\`)`, - ...(resource.ssl ? { tls } : {}), - }; + if (!config_output.http.services) { + config_output.http.services = {}; + } - if (resource.ssl) { - http.routers![routerName + "-redirect"] = { - entryPoints: [config.getRawConfig().traefik.http_entrypoint], - middlewares: [redirectHttpsMiddlewareName], + 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 + } + ] + } + : {}) + }; + + config_output.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 (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) => target.internalPort != null + ) + .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 ( + targets.filter( + (target: Target) => target.internalPort != null + ).length == 0 + ) { + 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) => target.internalPort != null + ) + .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}`); return res.status(HttpCode.INTERNAL_SERVER_ERROR).json({ - error: "Failed to build traefik config", + error: "Failed to build traefik config" }); } } diff --git a/server/routers/traefik/index.ts b/server/routers/traefik/index.ts index 0fc483fa..5630028c 100644 --- a/server/routers/traefik/index.ts +++ b/server/routers/traefik/index.ts @@ -1 +1 @@ -export * from "./getTraefikConfig"; +export * from "./getTraefikConfig"; \ No newline at end of file diff --git a/server/setup/scripts/1.0.0-beta9.ts b/server/setup/scripts/1.0.0-beta9.ts index 9cccd554..f82f7012 100644 --- a/server/setup/scripts/1.0.0-beta9.ts +++ b/server/setup/scripts/1.0.0-beta9.ts @@ -3,12 +3,14 @@ import { emailVerificationCodes, passwordResetTokens, resourceOtp, + resources, resourceWhitelist, + targets, userInvites, users } from "@server/db/schema"; import { APP_PATH, configFilePath1, configFilePath2 } from "@server/lib/consts"; -import { sql } from "drizzle-orm"; +import { eq, sql } from "drizzle-orm"; import fs from "fs"; import yaml from "js-yaml"; import path from "path"; @@ -33,11 +35,81 @@ export default async function migration() { }); } catch (error) { console.log( - "We were unable to make all emails lower case in the database." + "We were unable to make all emails lower case in the database. You can safely ignore this error." ); console.error(error); } + try { + await db.transaction(async (trx) => { + + 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)); + } + + }); + } catch (error) { + console.log( + "We were unable to make the changes to the targets and resources tables." + ); + throw error; + } + try { // Determine which config file exists const filePaths = [configFilePath1, configFilePath2]; @@ -81,7 +153,24 @@ export default async function migration() { "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({ @@ -101,26 +190,39 @@ export default async function migration() { throw new Error(fromZodError(parsedConfig.error).toString()); } + // 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 the version of Badger in your Traefik configuration to v1.0.0-beta.3." + "Updated the version of Badger in your Traefik configuration to v1.0.0-beta.3 and added readTimeout to websecure entrypoint in your Traefik configuration.." ); } catch (e) { console.log( - "We were unable to update the version of Badger in your Traefik configuration. Please update it manually." + "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" ); - console.error(e); + throw e; } try { await db.transaction(async (trx) => { - trx.run(sql`ALTER TABLE 'resourceSessions' ADD 'isRequestToken' integer;`); - trx.run(sql`ALTER TABLE 'resourceSessions' ADD 'userSessionId' text REFERENCES session(id);`); + trx.run( + sql`ALTER TABLE 'resourceSessions' ADD 'isRequestToken' integer;` + ); + trx.run( + sql`ALTER TABLE 'resourceSessions' ADD 'userSessionId' text REFERENCES session(id);` + ); }); } catch (e) { console.log( diff --git a/src/app/[orgId]/settings/resources/CreateResourceForm.tsx b/src/app/[orgId]/settings/resources/CreateResourceForm.tsx index 4ec4e8d5..7a111c06 100644 --- a/src/app/[orgId]/settings/resources/CreateResourceForm.tsx +++ b/src/app/[orgId]/settings/resources/CreateResourceForm.tsx @@ -45,21 +45,56 @@ import { } from "@app/components/ui/command"; import { CaretSortIcon } from "@radix-ui/react-icons"; import CustomDomainInput from "./[resourceId]/CustomDomainInput"; -import { Axios, AxiosResponse } from "axios"; +import { AxiosResponse } from "axios"; import { Resource } from "@server/db/schema"; import { useOrgContext } from "@app/hooks/useOrgContext"; -import { subdomainSchema } from "@server/schemas/subdomainSchema"; import { createApiClient } from "@app/lib/api"; import { useEnvContext } from "@app/hooks/useEnvContext"; import { cn } from "@app/lib/cn"; +import { Switch } from "@app/components/ui/switch"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue +} from "@app/components/ui/select"; -const accountFormSchema = z.object({ - subdomain: subdomainSchema, - name: z.string(), - siteId: z.number() -}); +const createResourceFormSchema = z + .object({ + subdomain: z + .union([ + z + .string() + .regex( + /^(?!:\/\/)([a-zA-Z0-9-_]+\.)*[a-zA-Z0-9-_]+$/, + "Invalid subdomain format" + ) + .min(1, "Subdomain must be at least 1 character long") + .transform((val) => val.toLowerCase()), + z.string().optional() + ]) + .optional(), + name: z.string().min(1).max(255), + siteId: z.number(), + http: z.boolean(), + protocol: z.string(), + proxyPort: z.number().int().min(1).max(65535).optional() + }) + .refine( + (data) => { + if (data.http === true) { + return true; + } + return !!data.proxyPort; + }, + { + message: "Port number is required for non-HTTP resources", + path: ["proxyPort"] + } + ); -type AccountFormValues = z.infer; +type CreateResourceFormValues = z.infer; type CreateResourceFormProps = { open: boolean; @@ -81,15 +116,18 @@ export default function CreateResourceForm({ const router = useRouter(); const { org } = useOrgContext(); + const { env } = useEnvContext(); const [sites, setSites] = useState([]); const [domainSuffix, setDomainSuffix] = useState(org.org.domain); - const form = useForm({ - resolver: zodResolver(accountFormSchema), + const form = useForm({ + resolver: zodResolver(createResourceFormSchema), defaultValues: { subdomain: "", - name: "My Resource" + name: "My Resource", + http: true, + protocol: "tcp" } }); @@ -112,7 +150,7 @@ export default function CreateResourceForm({ fetchSites(); }, [open]); - async function onSubmit(data: AccountFormValues) { + async function onSubmit(data: CreateResourceFormValues) { console.log(data); const res = await api @@ -120,8 +158,10 @@ export default function CreateResourceForm({ `/org/${orgId}/site/${data.siteId}/resource/`, { name: data.name, - subdomain: data.subdomain - // subdomain: data.subdomain, + subdomain: data.http ? data.subdomain : undefined, + http: data.http, + protocol: data.protocol, + proxyPort: data.http ? undefined : data.proxyPort } ) .catch((e) => { @@ -188,34 +228,151 @@ export default function CreateResourceForm({
)} /> - ( - - Subdomain - - - form.setValue( - "subdomain", - value - ) - } - /> - - - This is the fully qualified - domain name that will be used to - access the resource. - - - - )} - /> + + {!env.flags.allowRawResources || ( + ( + +
+ + HTTP Resource + + + Toggle if this is an + HTTP resource or a raw + TCP/UDP resource + +
+ + + +
+ )} + /> + )} + + {form.watch("http") && ( + ( + + Subdomain + + + form.setValue( + "subdomain", + value + ) + } + /> + + + This is the fully qualified + domain name that will be + used to access the resource. + + + + )} + /> + )} + + {!form.watch("http") && ( + <> + ( + + + Protocol + + + + The protocol to use for + the resource + + + + )} + /> + ( + + + Port Number + + + + field.onChange( + e.target + .value + ? parseInt( + e + .target + .value + ) + : null + ) + } + /> + + + The port number to proxy + requests to (required + for non-HTTP resources) + + + + )} + /> + + )} + { const resourceRow = row.original; return ( + {resourceRow.protocol.toUpperCase()} + ); + } + }, + { + accessorKey: "domain", + header: "Access", + cell: ({ row }) => { + const resourceRow = row.original; + return ( +
+ {!resourceRow.http ? ( + + ) : ( + )} +
); } }, @@ -186,17 +205,23 @@ export default function SitesTable({ resources, orgId }: ResourcesTableProps) { const resourceRow = row.original; return (
- {resourceRow.hasAuth ? ( - - - Protected - - ) : ( - - - Not Protected - - )} + + + {!resourceRow.http ? ( + -- + ) : + resourceRow.hasAuth ? ( + + + Protected + + ) : ( + + + Not Protected + + ) + }
); } diff --git a/src/app/[orgId]/settings/resources/[resourceId]/ResourceInfoBox.tsx b/src/app/[orgId]/settings/resources/[resourceId]/ResourceInfoBox.tsx index a4d8289f..c7f51622 100644 --- a/src/app/[orgId]/settings/resources/[resourceId]/ResourceInfoBox.tsx +++ b/src/app/[orgId]/settings/resources/[resourceId]/ResourceInfoBox.tsx @@ -2,12 +2,8 @@ import { useState } from "react"; import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; -import { Button } from "@/components/ui/button"; import { InfoIcon, - LinkIcon, - CheckIcon, - CopyIcon, ShieldCheck, ShieldOff } from "lucide-react"; @@ -42,37 +38,65 @@ export default function ResourceInfoBox({}: ResourceInfoBoxType) { - - Authentication - - {authInfo.password || - authInfo.pincode || - authInfo.sso || - authInfo.whitelist ? ( -
- - - This resource is protected with at least - one auth method. - -
- ) : ( -
- - - Anyone can access this resource. - -
- )} -
-
- - - URL - - - - + {resource.http ? ( + <> + + + Authentication + + + {authInfo.password || + authInfo.pincode || + authInfo.sso || + authInfo.whitelist ? ( +
+ + + This resource is protected with + at least one auth method. + +
+ ) : ( +
+ + + Anyone can access this resource. + +
+ )} +
+
+ + + URL + + + + + + ) : ( + <> + + Protocol + + {resource.protocol.toUpperCase()} + + + + + Port + + + + + + )}
diff --git a/src/app/[orgId]/settings/resources/[resourceId]/connectivity/page.tsx b/src/app/[orgId]/settings/resources/[resourceId]/connectivity/page.tsx index 4b12f661..2e38b193 100644 --- a/src/app/[orgId]/settings/resources/[resourceId]/connectivity/page.tsx +++ b/src/app/[orgId]/settings/resources/[resourceId]/connectivity/page.tsx @@ -94,7 +94,7 @@ const domainSchema = z const addTargetSchema = z.object({ ip: domainSchema, - method: z.string(), + method: z.string().nullable(), port: z.coerce.number().int().positive() // protocol: z.string(), }); @@ -129,9 +129,9 @@ export default function ReverseProxyTargets(props: { const addTargetForm = useForm({ resolver: zodResolver(addTargetSchema), defaultValues: { - ip: "", - method: "http", - port: 80 + ip: "localhost", + method: resource.http ? "http" : null, + port: resource.http ? 80 : resource.proxyPort || 1234 // protocol: "TCP", } }); @@ -330,26 +330,6 @@ export default function ReverseProxyTargets(props: { } const columns: ColumnDef[] = [ - { - accessorKey: "method", - header: "Method", - cell: ({ row }) => ( - - ) - }, { accessorKey: "ip", header: "IP / Hostname", @@ -436,6 +416,32 @@ export default function ReverseProxyTargets(props: { } ]; + if (resource.http) { + const methodCol: ColumnDef = { + accessorKey: "method", + header: "Method", + cell: ({ row }) => ( + + ) + }; + + // add this to the first column + columns.unshift(methodCol); + } + const table = useReactTable({ data: targets, columns, @@ -451,7 +457,7 @@ export default function ReverseProxyTargets(props: { return ( - {/* SSL Section */} + {resource.http && ( @@ -473,7 +479,7 @@ export default function ReverseProxyTargets(props: { /> - +)} {/* Targets Section */} @@ -491,6 +497,8 @@ export default function ReverseProxyTargets(props: { className="space-y-4" >
+ {resource.http && ( + Method + field.onChange( + e.target.value + ? parseInt( + e + .target + .value + ) + : null + ) + } + /> + + + This is the port that will + be used to access the + resource. + + + + )} + /> + )} diff --git a/src/app/[orgId]/settings/resources/[resourceId]/layout.tsx b/src/app/[orgId]/settings/resources/[resourceId]/layout.tsx index 5f5b90fa..5506866e 100644 --- a/src/app/[orgId]/settings/resources/[resourceId]/layout.tsx +++ b/src/app/[orgId]/settings/resources/[resourceId]/layout.tsx @@ -90,13 +90,16 @@ export default async function ResourceLayout(props: ResourceLayoutProps) { title: "Connectivity", href: `/{orgId}/settings/resources/{resourceId}/connectivity` // icon: , - }, - { + } + ]; + + if (resource.http) { + sidebarNavItems.push({ title: "Authentication", href: `/{orgId}/settings/resources/{resourceId}/authentication` // icon: , - } - ]; + }); + } return ( <> diff --git a/src/app/[orgId]/settings/resources/page.tsx b/src/app/[orgId]/settings/resources/page.tsx index c8348b5d..f9b5558b 100644 --- a/src/app/[orgId]/settings/resources/page.tsx +++ b/src/app/[orgId]/settings/resources/page.tsx @@ -53,6 +53,9 @@ export default async function ResourcesPage(props: ResourcesPageProps) { domain: `${resource.ssl ? "https://" : "http://"}${resource.fullDomain}`, site: resource.siteName || "None", siteId: resource.siteId || "Unknown", + protocol: resource.protocol, + proxyPort: resource.proxyPort, + http: resource.http, hasAuth: resource.sso || resource.pincodeId !== null || diff --git a/src/lib/pullEnv.ts b/src/lib/pullEnv.ts index 564ba0bc..368df440 100644 --- a/src/lib/pullEnv.ts +++ b/src/lib/pullEnv.ts @@ -26,7 +26,9 @@ export function pullEnv(): Env { emailVerificationRequired: process.env.FLAGS_EMAIL_VERIFICATION_REQUIRED === "true" ? true - : false + : false, + allowRawResources: + process.env.FLAGS_ALLOW_RAW_RESOURCES === "true" ? true : false, } }; } diff --git a/src/lib/types/env.ts b/src/lib/types/env.ts index 9a2e5b93..14efd1be 100644 --- a/src/lib/types/env.ts +++ b/src/lib/types/env.ts @@ -17,5 +17,6 @@ export type Env = { disableSignupWithoutInvite: boolean; disableUserCreateOrg: boolean; emailVerificationRequired: boolean; + allowRawResources: boolean; } }; From 20f659db8955475ab278bc5c0aafe45c0a7da014 Mon Sep 17 00:00:00 2001 From: Milo Schwartz Date: Wed, 29 Jan 2025 00:03:10 -0500 Subject: [PATCH 19/29] fix zod schemas --- config/config.example.yml | 2 +- config/traefik/traefik_config.example.yml | 2 +- server/routers/resource/createResource.ts | 53 ++++---- server/routers/traefik/getTraefikConfig.ts | 4 +- .../settings/resources/CreateResourceForm.tsx | 39 +++--- .../[resourceId]/connectivity/page.tsx | 123 +++++++++--------- .../resources/[resourceId]/general/page.tsx | 57 +++++--- 7 files changed, 158 insertions(+), 122 deletions(-) diff --git a/config/config.example.yml b/config/config.example.yml index 788b5943..e62af16d 100644 --- a/config/config.example.yml +++ b/config/config.example.yml @@ -41,4 +41,4 @@ flags: require_email_verification: false disable_signup_without_invite: true disable_user_create_org: true - allow_raw_resources: true \ No newline at end of file + allow_raw_resources: true diff --git a/config/traefik/traefik_config.example.yml b/config/traefik/traefik_config.example.yml index 8a2263ad..01d05903 100644 --- a/config/traefik/traefik_config.example.yml +++ b/config/traefik/traefik_config.example.yml @@ -13,7 +13,7 @@ experimental: plugins: badger: moduleName: "github.com/fosrl/badger" - version: "v1.0.0-beta.2" + version: "v1.0.0-beta.3" log: level: "INFO" diff --git a/server/routers/resource/createResource.ts b/server/routers/resource/createResource.ts index ee392e5f..e687cc02 100644 --- a/server/routers/resource/createResource.ts +++ b/server/routers/resource/createResource.ts @@ -17,6 +17,7 @@ import { eq, and } from "drizzle-orm"; import stoi from "@server/lib/stoi"; import { fromError } from "zod-validation-error"; import logger from "@server/logger"; +import { subdomainSchema } from "@server/schemas/subdomainSchema"; const createResourceParamsSchema = z .object({ @@ -27,36 +28,43 @@ const createResourceParamsSchema = z const createResourceSchema = z .object({ - subdomain: z - .union([ - z - .string() - .regex( - /^(?!:\/\/)([a-zA-Z0-9-_]+\.)*[a-zA-Z0-9-_]+$/, - "Invalid subdomain format" - ) - .min(1, "Subdomain must be at least 1 character long") - .transform((val) => val.toLowerCase()), - z.string().optional() - ]) - .optional(), + subdomain: z.string().optional(), name: z.string().min(1).max(255), + siteId: z.number(), http: z.boolean(), protocol: z.string(), - proxyPort: z.number().int().min(1).max(65535).optional(), - }).refine( + proxyPort: z.number().optional() + }) + .refine( (data) => { - if (data.http === true) { - return true; + if (!data.http) { + return z + .number() + .int() + .min(1) + .max(65535) + .safeParse(data.proxyPort).success; } - return !!data.proxyPort; + return true; }, { - message: "Port number is required for non-HTTP resources", + 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; export async function createResource( @@ -134,7 +142,6 @@ export async function createResource( ); } } else { - if (proxyPort === 443 || proxyPort === 80) { return next( createHttpError( @@ -149,7 +156,7 @@ export async function createResource( .select() .from(resources) .where(eq(resources.fullDomain, fullDomain)); - + if (existingResource.length > 0) { return next( createHttpError( @@ -165,7 +172,7 @@ export async function createResource( .insert(resources) .values({ siteId, - fullDomain: http? fullDomain : null, + fullDomain: http ? fullDomain : null, orgId, name, subdomain, diff --git a/server/routers/traefik/getTraefikConfig.ts b/server/routers/traefik/getTraefikConfig.ts index 4b760274..93eddde4 100644 --- a/server/routers/traefik/getTraefikConfig.ts +++ b/server/routers/traefik/getTraefikConfig.ts @@ -156,13 +156,15 @@ export async function traefikConfigProvider( : {}) }; + 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], + middlewares: [badgerMiddlewareName, ...additionalMiddlewares], service: serviceName, rule: `Host(\`${fullDomain}\`)`, ...(resource.ssl ? { tls } : {}) diff --git a/src/app/[orgId]/settings/resources/CreateResourceForm.tsx b/src/app/[orgId]/settings/resources/CreateResourceForm.tsx index 7a111c06..d92a3792 100644 --- a/src/app/[orgId]/settings/resources/CreateResourceForm.tsx +++ b/src/app/[orgId]/settings/resources/CreateResourceForm.tsx @@ -59,38 +59,39 @@ import { SelectTrigger, SelectValue } from "@app/components/ui/select"; +import { subdomainSchema } from "@server/schemas/subdomainSchema"; const createResourceFormSchema = z .object({ - subdomain: z - .union([ - z - .string() - .regex( - /^(?!:\/\/)([a-zA-Z0-9-_]+\.)*[a-zA-Z0-9-_]+$/, - "Invalid subdomain format" - ) - .min(1, "Subdomain must be at least 1 character long") - .transform((val) => val.toLowerCase()), - z.string().optional() - ]) - .optional(), + subdomain: z.string().optional(), name: z.string().min(1).max(255), siteId: z.number(), http: z.boolean(), protocol: z.string(), - proxyPort: z.number().int().min(1).max(65535).optional() + proxyPort: z.number().optional(), }) .refine( (data) => { - if (data.http === true) { - return true; + if (!data.http) { + return z.number().int().min(1).max(65535).safeParse(data.proxyPort).success; } - return !!data.proxyPort; + return true; }, { - message: "Port number is required for non-HTTP resources", - path: ["proxyPort"] + message: "Invalid port number", + path: ["proxyPort"], + } + ) + .refine( + (data) => { + if (data.http) { + return subdomainSchema.safeParse(data.subdomain).success; + } + return true; + }, + { + message: "Invalid subdomain", + path: ["subdomain"], } ); diff --git a/src/app/[orgId]/settings/resources/[resourceId]/connectivity/page.tsx b/src/app/[orgId]/settings/resources/[resourceId]/connectivity/page.tsx index 2e38b193..c40102e3 100644 --- a/src/app/[orgId]/settings/resources/[resourceId]/connectivity/page.tsx +++ b/src/app/[orgId]/settings/resources/[resourceId]/connectivity/page.tsx @@ -63,6 +63,7 @@ import { } from "@app/components/Settings"; import { SwitchInput } from "@app/components/SwitchInput"; import { useSiteContext } from "@app/hooks/useSiteContext"; +import { InfoPopup } from "@app/components/ui/info-popup"; // Regular expressions for validation const DOMAIN_REGEX = @@ -458,28 +459,28 @@ export default function ReverseProxyTargets(props: { return ( {resource.http && ( - - - - SSL Configuration - - - Setup SSL to secure your connections with LetsEncrypt - certificates - - - - { - await saveSsl(val); - }} - /> - - -)} + + + + SSL Configuration + + + Setup SSL to secure your connections with + LetsEncrypt certificates + + + + { + await saveSsl(val); + }} + /> + + + )} {/* Targets Section */} @@ -498,41 +499,45 @@ export default function ReverseProxyTargets(props: { >
{resource.http && ( - - ( - - Method - - - - - - - - http - - - https - - - - - - - )} - /> - )} + ) => { + addTargetForm.setValue( + "method", + value + ); + }} + > + + + + + + http + + + https + + + + + + + )} + /> + )} - - Multiple targets will get load balanced by Traefik. You can use this for high availability. - +

+ Adding more than one target above will enable load balancing. +