diff --git a/.github/workflows/cicd.yml b/.github/workflows/cicd.yml new file mode 100644 index 00000000..eec6d32b --- /dev/null +++ b/.github/workflows/cicd.yml @@ -0,0 +1,82 @@ +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: Pull latest Badger version + id: get-badger-tag + run: | + LATEST_TAG=$(curl -s https://api.github.com/repos/fosrl/badger/tags | jq -r '.[0].name') + echo "LATEST_BADGER_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 + sed -i "s/config.BadgerVersion = \".*\"/config.BadgerVersion = \"$BADGER_VERSION\"/" install/main.go + echo "Updated install/main.go with Pangolin version $PANGOLIN_VERSION, Gerbil version $GERBIL_VERSION, and Badger version $BADGER_VERSION" + cat install/main.go + + - name: Build installer + working-directory: install + run: | + make release + + - name: Upload artifacts from /install/bin + uses: actions/upload-artifact@v4 + with: + name: install-bin + path: install/bin/ + + - name: Build and push Docker images + run: | + TAG=${{ env.TAG }} + make build-release tag=$TAG 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/README.md b/README.md index 657e7994..6911213f 100644 --- a/README.md +++ b/README.md @@ -32,6 +32,7 @@ _Sites page of Pangolin dashboard (dark mode) showing multiple tunnels connected - Secure and easy to configure site-to-site connectivity via a custom **user space WireGuard client**, [Newt](https://github.com/fosrl/newt). - Built-in support for any WireGuard client. - Automated **SSL certificates** (https) via [LetsEncrypt](https://letsencrypt.org/). +- Support for HTTP/HTTPS and **raw TCP/UDP services**. ### Identity & Access Management diff --git a/config/config.example.yml b/config/config.example.yml index d1d299b7..e62af16d 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_token" + resource_access_token_param: "p_token" + resource_session_request_param: "p_session_request" 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,10 +34,11 @@ rate_limits: users: server_admin: - email: admin@example.com - password: Password123! + email: "admin@example.com" + password: "Password123!" flags: require_email_verification: false disable_signup_without_invite: true disable_user_create_org: true + allow_raw_resources: true 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/config/traefik/traefik_config.example.yml b/config/traefik/traefik_config.example.yml index 3b4512d5..b822f493 100644 --- a/config/traefik/traefik_config.example.yml +++ b/config/traefik/traefik_config.example.yml @@ -4,7 +4,13 @@ api: providers: http: - endpoint: "http://pangolin:{{.INTERNAL_PORT}}/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" @@ -13,7 +19,7 @@ experimental: plugins: badger: moduleName: "github.com/fosrl/badger" - version: "v1.0.0-beta.2" + version: "v1.0.0-beta.3" log: level: "INFO" @@ -33,6 +39,9 @@ entryPoints: address: ":80" websecure: address: ":443" + transport: + respondingTimeouts: + readTimeout: "30m" http: tls: certResolver: "letsencrypt" 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/fs/config.yml b/install/fs/config.yml index 91d67019..3ccec1e5 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_token" + resource_access_token_param: "p_token" + resource_session_request_param: "p_session_request" 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,18 +39,19 @@ 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}} disable_signup_without_invite: {{.DisableSignupWithoutInvite}} disable_user_create_org: {{.DisableUserCreateOrg}} + allow_raw_resources: true 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/install/fs/traefik/traefik_config.yml b/install/fs/traefik/traefik_config.yml index de104a2f..bb97b3ca 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" @@ -13,7 +19,7 @@ experimental: plugins: badger: moduleName: "github.com/fosrl/badger" - version: "v1.0.0-beta.2" + version: "{{.BadgerVersion}}" log: level: "INFO" @@ -33,6 +39,9 @@ entryPoints: address: ":80" websecure: address: ":443" + transport: + respondingTimeouts: + readTimeout: "30m" http: tls: certResolver: "letsencrypt" diff --git a/install/main.go b/install/main.go index 2f1f48ac..4f2deb3a 100644 --- a/install/main.go +++ b/install/main.go @@ -17,9 +17,11 @@ 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" + config.BadgerVersion = "replaceme" } //go:embed fs/* @@ -28,6 +30,7 @@ var configFiles embed.FS type Config struct { PangolinVersion string GerbilVersion string + BadgerVersion string BaseDomain string DashboardDomain string LetsEncryptEmail string @@ -271,6 +274,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 +382,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 +391,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 && @@ -432,29 +440,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 } diff --git a/package.json b/package.json index f5650f81..446e3d37 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@fosrl/pangolin", - "version": "1.0.0-beta.8", + "version": "1.0.0-beta.9", "private": true, "type": "module", "description": "Tunneled Reverse Proxy Management Server with Identity and Access Control and Dashboard UI", @@ -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/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/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..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) }); @@ -313,6 +315,10 @@ export const resourceSessions = sqliteTable("resourceSessions", { doNotExtend: integer("doNotExtend", { mode: "boolean" }) .notNull() .default(false), + isRequestToken: integer("isRequestToken", { mode: "boolean" }), + userSessionId: text("userSessionId").references(() => sessions.sessionId, { + onDelete: "cascade" + }), passwordId: integer("passwordId").references( () => resourcePassword.passwordId, { diff --git a/server/emails/index.ts b/server/emails/index.ts index 10ee4933..c1c2bc87 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) { + 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/emails/templates/ResourceOTPCode.tsx b/server/emails/templates/ResourceOTPCode.tsx index a672235a..086dc444 100644 --- a/server/emails/templates/ResourceOTPCode.tsx +++ b/server/emails/templates/ResourceOTPCode.tsx @@ -44,7 +44,7 @@ export const ResourceOTPCode = ({ - Your One-Time Password for {resourceName} + Your One-Time Code for {resourceName} Hi {email || "there"}, 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 49287339..66dd6764 100644 --- a/server/lib/config.ts +++ b/server/lib/config.ts @@ -37,9 +37,11 @@ const configSchema = z.object({ base_domain: hostnameSchema .optional() .transform(getEnvOrYaml("APP_BASEDOMAIN")) - .pipe(hostnameSchema), + .pipe(hostnameSchema) + .transform((url) => url.toLowerCase()), log_level: z.enum(["debug", "info", "warn", "error"]), - save_logs: z.boolean() + save_logs: z.boolean(), + log_failed_attempts: z.boolean().optional() }), server: z.object({ external_port: portSchema @@ -60,8 +62,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(), @@ -76,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 @@ -109,11 +124,12 @@ const configSchema = z.object({ }), email: z .object({ - smtp_host: z.string(), - smtp_port: portSchema, - smtp_user: z.string(), - smtp_pass: z.string(), - no_reply: z.string().email() + smtp_host: z.string().optional(), + smtp_port: portSchema.optional(), + smtp_user: z.string().optional(), + smtp_pass: z.string().optional(), + smtp_secure: z.boolean().optional(), + no_reply: z.string().email().optional() }) .optional(), users: z.object({ @@ -123,7 +139,8 @@ const configSchema = z.object({ .email() .optional() .transform(getEnvOrYaml("USERS_SERVERADMIN_EMAIL")) - .pipe(z.string().email()), + .pipe(z.string().email()) + .transform((v) => v.toLowerCase()), password: passwordSchema .optional() .transform(getEnvOrYaml("USERS_SERVERADMIN_PASSWORD")) @@ -134,7 +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() }); @@ -237,10 +255,12 @@ export class Config { ?.require_email_verification ? "true" : "false"; + process.env.FLAGS_ALLOW_RAW_RESOURCES = parsedConfig.data.flags + ?.allow_raw_resources + ? "true" + : "false"; process.env.SESSION_COOKIE_NAME = parsedConfig.data.server.session_cookie_name; - process.env.RESOURCE_SESSION_COOKIE_NAME = - parsedConfig.data.server.resource_session_cookie_name; process.env.EMAIL_ENABLED = parsedConfig.data.email ? "true" : "false"; process.env.DISABLE_SIGNUP_WITHOUT_INVITE = parsedConfig.data.flags ?.disable_signup_without_invite @@ -252,6 +272,8 @@ export class Config { : "false"; process.env.RESOURCE_ACCESS_TOKEN_PARAM = parsedConfig.data.server.resource_access_token_param; + process.env.RESOURCE_SESSION_REQUEST_PARAM = + parsedConfig.data.server.resource_session_request_param; this.rawConfig = parsedConfig.data; } @@ -264,6 +286,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/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/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 328caeb9..09bd9661 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() }) @@ -68,6 +71,11 @@ export async function login( .from(users) .where(eq(users.email, email)); if (!existingUserRes || !existingUserRes.length) { + if (config.getRawConfig().app.log_failed_attempts) { + logger.info( + `Username or password incorrect. Email: ${email}. IP: ${req.ip}.` + ); + } return next( createHttpError( HttpCode.BAD_REQUEST, @@ -83,6 +91,11 @@ export async function login( existingUser.passwordHash ); if (!validPassword) { + if (config.getRawConfig().app.log_failed_attempts) { + logger.info( + `Username or password incorrect. Email: ${email}. IP: ${req.ip}.` + ); + } return next( createHttpError( HttpCode.BAD_REQUEST, @@ -109,6 +122,11 @@ export async function login( ); if (!validOTP) { + if (config.getRawConfig().app.log_failed_attempts) { + logger.info( + `Two-factor code incorrect. Email: ${email}. IP: ${req.ip}.` + ); + } return next( createHttpError( HttpCode.BAD_REQUEST, diff --git a/server/routers/auth/logout.ts b/server/routers/auth/logout.ts index 3b466767..a2e0a7f1 100644 --- a/server/routers/auth/logout.ts +++ b/server/routers/auth/logout.ts @@ -5,18 +5,23 @@ import response from "@server/lib/response"; import logger from "@server/logger"; import { createBlankSessionTokenCookie, - invalidateSession, - SESSION_COOKIE_NAME + invalidateSession } from "@server/auth/sessions/app"; +import { verifySession } from "@server/auth/sessions/verifySession"; +import config from "@server/lib/config"; export async function logout( req: Request, res: Response, next: NextFunction ): Promise { - const sessionId = req.cookies[SESSION_COOKIE_NAME]; - - if (!sessionId) { + const { user, session } = await verifySession(req); + if (!user || !session) { + if (config.getRawConfig().app.log_failed_attempts) { + logger.info( + `Log out failed because missing or invalid session. IP: ${req.ip}.` + ); + } return next( createHttpError( HttpCode.BAD_REQUEST, @@ -26,7 +31,7 @@ export async function logout( } try { - await invalidateSession(sessionId); + await invalidateSession(session.sessionId); const isSecure = req.protocol === "https"; res.setHeader("Set-Cookie", createBlankSessionTokenCookie(isSecure)); diff --git a/server/routers/auth/requestPasswordReset.ts b/server/routers/auth/requestPasswordReset.ts index a223e5f2..08d563ad 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) @@ -84,6 +84,10 @@ export async function requestPasswordReset( const url = `${config.getRawConfig().app.dashboard_url}/auth/reset-password?email=${email}&token=${token}`; + if (!config.getRawConfig().email) { + logger.info(`Password reset requested for ${email}. Token: ${token}.`); + } + await sendEmail( ResetPasswordCode({ email, @@ -91,7 +95,7 @@ export async function requestPasswordReset( link: url }), { - from: config.getRawConfig().email?.no_reply, + from: config.getNoReplyEmail(), to: email, subject: "Reset your password" } diff --git a/server/routers/auth/resetPassword.ts b/server/routers/auth/resetPassword.ts index 6f36d006..97b283c6 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 @@ -57,6 +60,11 @@ export async function resetPassword( .where(eq(passwordResetTokens.email, email)); if (!resetRequest || !resetRequest.length) { + if (config.getRawConfig().app.log_failed_attempts) { + logger.info( + `Password reset code is incorrect. Email: ${email}. IP: ${req.ip}.` + ); + } return next( createHttpError( HttpCode.BAD_REQUEST, @@ -106,6 +114,11 @@ export async function resetPassword( ); if (!validOTP) { + if (config.getRawConfig().app.log_failed_attempts) { + logger.info( + `Two-factor authentication code is incorrect. Email: ${email}. IP: ${req.ip}.` + ); + } return next( createHttpError( HttpCode.BAD_REQUEST, @@ -121,6 +134,11 @@ export async function resetPassword( ); if (!isTokenValid) { + if (config.getRawConfig().app.log_failed_attempts) { + logger.info( + `Password reset code is incorrect. Email: ${email}. IP: ${req.ip}.` + ); + } return next( createHttpError( HttpCode.BAD_REQUEST, @@ -145,7 +163,7 @@ export async function resetPassword( }); await sendEmail(ConfirmPasswordReset({ email }), { - from: config.getRawConfig().email?.no_reply, + from: config.getNoReplyEmail(), to: email, subject: "Password Reset Confirmation" }); diff --git a/server/routers/auth/signup.ts b/server/routers/auth/signup.ts index 9710d858..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(), + 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 new file mode 100644 index 00000000..18925279 --- /dev/null +++ b/server/routers/badger/exchangeSession.ts @@ -0,0 +1,188 @@ +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(), + requestIp: z.string().optional() +}); + +export type ExchangeSessionBodySchema = z.infer< + typeof exchangeSessionBodySchema +>; + +export type ExchangeSessionResponse = { + valid: boolean; + cookie?: string; +}; + +export async function exchangeSession( + req: Request, + res: Response, + next: NextFunction +): Promise { + logger.debug("Exchange session: Badger sent", req.body); + + const parsedBody = exchangeSessionBodySchema.safeParse(req.body); + + if (!parsedBody.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedBody.error).toString() + ) + ); + } + + try { + const { requestToken, host, requestIp } = parsedBody.data; + + const clientIp = requestIp?.split(":")[0]; + + const [resource] = await db + .select() + .from(resources) + .where(eq(resources.fullDomain, host)) + .limit(1); + + if (!resource) { + return next( + createHttpError( + HttpCode.NOT_FOUND, + `Resource with host ${host} not found` + ) + ); + } + + const { resourceSession: requestSession } = + await validateResourceSessionToken( + requestToken, + resource.resourceId + ); + + if (!requestSession) { + if (config.getRawConfig().app.log_failed_attempts) { + logger.info( + `Exchange token is invalid. Resource ID: ${resource.resourceId}. IP: ${clientIp}.` + ); + } + return next( + createHttpError(HttpCode.UNAUTHORIZED, "Invalid request token") + ); + } + + if (!requestSession.isRequestToken) { + if (config.getRawConfig().app.log_failed_attempts) { + logger.info( + `Exchange token is invalid. Resource ID: ${resource.resourceId}. IP: ${clientIp}.` + ); + } + return next( + createHttpError(HttpCode.UNAUTHORIZED, "Invalid request token") + ); + } + + await db.delete(sessions).where(eq(sessions.sessionId, requestToken)); + + const token = generateSessionToken(); + + if (requestSession.userSessionId) { + const [res] = await db + .select() + .from(sessions) + .where(eq(sessions.sessionId, requestSession.userSessionId)) + .limit(1); + if (res) { + await createResourceSession({ + token, + resourceId: resource.resourceId, + isRequestToken: false, + userSessionId: requestSession.userSessionId, + doNotExtend: false, + expiresAt: res.expiresAt, + sessionLength: SESSION_COOKIE_EXPIRES + }); + } + } else if (requestSession.accessTokenId) { + const [res] = await db + .select() + .from(resourceAccessToken) + .where( + eq( + resourceAccessToken.accessTokenId, + requestSession.accessTokenId + ) + ) + .limit(1); + if (res) { + await createResourceSession({ + token, + resourceId: resource.resourceId, + isRequestToken: false, + accessTokenId: requestSession.accessTokenId, + doNotExtend: true, + expiresAt: res.expiresAt, + sessionLength: res.sessionLength + }); + } + } else { + await createResourceSession({ + token, + resourceId: resource.resourceId, + isRequestToken: false, + passwordId: requestSession.passwordId, + pincodeId: requestSession.pincodeId, + userSessionId: requestSession.userSessionId, + whitelistId: requestSession.whitelistId, + accessTokenId: requestSession.accessTokenId, + doNotExtend: false, + expiresAt: new Date( + Date.now() + SESSION_COOKIE_EXPIRES + ).getTime(), + sessionLength: RESOURCE_SESSION_COOKIE_EXPIRES + }); + } + + const cookieName = `${config.getRawConfig().server.session_cookie_name}`; + const cookie = serializeResourceSessionCookie( + cookieName, + resource.fullDomain!, + token, + !resource.ssl + ); + + logger.debug(JSON.stringify("Exchange cookie: " + cookie)); + return response(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..b1a6fb12 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(), @@ -36,7 +42,8 @@ const verifyResourceSessionSchema = z.object({ path: z.string(), method: z.string(), accessToken: z.string().optional(), - tls: z.boolean() + tls: z.boolean(), + requestIp: z.string().optional() }); export type VerifyResourceSessionSchema = z.infer< @@ -53,7 +60,7 @@ export async function verifyResourceSession( res: Response, next: NextFunction ): Promise { - logger.debug("Badger sent", req.body); // remove when done testing + logger.debug("Verify session: Badger sent", req.body); // remove when done testing const parsedBody = verifyResourceSessionSchema.safeParse(req.body); @@ -67,26 +74,55 @@ export async function verifyResourceSession( } try { - const { sessions, host, originalRequestURL, accessToken: token } = - parsedBody.data; + const { + sessions, + host, + originalRequestURL, + requestIp, + accessToken: token + } = parsedBody.data; - const [result] = await db - .select() - .from(resources) - .leftJoin( - resourcePincode, - eq(resourcePincode.resourceId, resources.resourceId) - ) - .leftJoin( - resourcePassword, - eq(resourcePassword.resourceId, resources.resourceId) - ) - .where(eq(resources.fullDomain, host)) - .limit(1); + const clientIp = requestIp?.split(":")[0]; - const resource = result?.resources; - const pincode = result?.resourcePincode; - const password = result?.resourcePassword; + const resourceCacheKey = `resource:${host}`; + let resourceData: + | { + resource: Resource | null; + pincode: ResourcePincode | null; + password: ResourcePassword | null; + } + | undefined = cache.get(resourceCacheKey); + + if (!resourceData) { + const [result] = await db + .select() + .from(resources) + .leftJoin( + resourcePincode, + eq(resourcePincode.resourceId, resources.resourceId) + ) + .leftJoin( + resourcePassword, + eq(resourcePassword.resourceId, resources.resourceId) + ) + .where(eq(resources.fullDomain, host)) + .limit(1); + + if (!result) { + logger.debug("Resource not found", host); + return notAllowed(res); + } + + resourceData = { + resource: result.resources, + pincode: result.resourcePincode, + password: result.resourcePassword + }; + + cache.set(resourceCacheKey, resourceData); + } + + const { resource, pincode, password } = resourceData; if (!resource) { logger.debug("Resource not found", host); @@ -128,6 +164,14 @@ export async function verifyResourceSession( logger.debug("Access token invalid: " + error); } + if (!valid) { + if (config.getRawConfig().app.log_failed_attempts) { + logger.info( + `Resource access token is invalid. Resource ID: ${resource.resourceId}. IP: ${clientIp}.` + ); + } + } + if (valid && tokenItem) { validAccessToken = tokenItem; @@ -142,40 +186,44 @@ export async function verifyResourceSession( } if (!sessions) { - return notAllowed(res); - } - - const sessionToken = - sessions[config.getRawConfig().server.session_cookie_name]; - - // check for unified login - if (sso && sessionToken) { - const { session, user } = await validateSessionToken(sessionToken); - if (session && user) { - const isAllowed = await isUserAllowedToAccessResource( - user, - resource + if (config.getRawConfig().app.log_failed_attempts) { + logger.info( + `Missing resource sessions. Resource ID: ${resource.resourceId}. IP: ${clientIp}.` ); - - if (isAllowed) { - logger.debug( - "Resource allowed because user session is valid" - ); - return allowed(res); - } } + return notAllowed(res); } const resourceSessionToken = sessions[ - `${config.getRawConfig().server.resource_session_cookie_name}_${resource.resourceId}` + `${config.getRawConfig().server.session_cookie_name}${resource.ssl ? "_s" : ""}` ]; if (resourceSessionToken) { - const { resourceSession } = await validateResourceSessionToken( - resourceSessionToken, - resource.resourceId - ); + const sessionCacheKey = `session:${resourceSessionToken}`; + let resourceSession: any = cache.get(sessionCacheKey); + + if (!resourceSession) { + const result = await validateResourceSessionToken( + resourceSessionToken, + resource.resourceId + ); + + resourceSession = result?.resourceSession; + cache.set(sessionCacheKey, resourceSession); + } + + if (resourceSession?.isRequestToken) { + logger.debug( + "Resource not allowed because session is a temporary request token" + ); + if (config.getRawConfig().app.log_failed_attempts) { + logger.info( + `Resource session is an exchange token. Resource ID: ${resource.resourceId}. IP: ${clientIp}.` + ); + } + return notAllowed(res); + } if (resourceSession) { if (pincode && resourceSession.pincodeId) { @@ -208,6 +256,29 @@ export async function verifyResourceSession( ); return allowed(res); } + + if (resourceSession.userSessionId && sso) { + const userAccessCacheKey = `userAccess:${resourceSession.userSessionId}:${resource.resourceId}`; + + let isAllowed: boolean | undefined = + cache.get(userAccessCacheKey); + + if (isAllowed === undefined) { + isAllowed = await isUserAllowedToAccessResource( + resourceSession.userSessionId, + resource + ); + + cache.set(userAccessCacheKey, isAllowed); + } + + if (isAllowed) { + logger.debug( + "Resource allowed because user session is valid" + ); + return allowed(res); + } + } } } @@ -222,6 +293,12 @@ export async function verifyResourceSession( } logger.debug("No more auth to check, resource not allowed"); + + if (config.getRawConfig().app.log_failed_attempts) { + logger.info( + `Resource access not allowed. Resource ID: ${resource.resourceId}. IP: ${clientIp}.` + ); + } return notAllowed(res, redirectUrl); } catch (e) { console.error(e); @@ -272,10 +349,15 @@ async function createAccessTokenSession( expiresAt: tokenItem.expiresAt, doNotExtend: tokenItem.expiresAt ? true : false }); - const cookieName = `${config.getRawConfig().server.resource_session_cookie_name}_${resource.resourceId}`; - const cookie = serializeResourceSessionCookie(cookieName, token); + const cookieName = `${config.getRawConfig().server.session_cookie_name}`; + const cookie = serializeResourceSessionCookie( + cookieName, + resource.fullDomain!, + token, + !resource.ssl + ); res.appendHeader("Set-Cookie", cookie); - logger.debug("Access token is valid, creating new session") + logger.debug("Access token is valid, creating new session"); return response(res, { data: { valid: true }, success: true, @@ -286,9 +368,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..2d68b368 100644 --- a/server/routers/internal.ts +++ b/server/routers/internal.ts @@ -1,9 +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 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(); @@ -13,9 +16,17 @@ internalRouter.get("/", (_, res) => { }); internalRouter.get("/traefik-config", traefik.traefikConfigProvider); + internalRouter.get( "/resource-session/:resourceId/:token", - auth.checkResourceSession, + auth.checkResourceSession +); + +internalRouter.post( + `/resource/:resourceId/get-exchange-token`, + verifySessionUserMiddleware, + verifyResourceAccess, + getExchangeToken ); // Gerbil routes @@ -29,6 +40,7 @@ gerbilRouter.post("/receive-bandwidth", gerbil.receiveBandwidth); const badgerRouter = Router(); internalRouter.use("/badger", badgerRouter); -badgerRouter.post("/verify-session", badger.verifyResourceSession); +badgerRouter.post("/verify-session", verifyResourceSession); +badgerRouter.post("/exchange-session", exchangeSession); export default internalRouter; 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/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/authWithAccessToken.ts b/server/routers/resource/authWithAccessToken.ts index a4340f77..03dac735 100644 --- a/server/routers/resource/authWithAccessToken.ts +++ b/server/routers/resource/authWithAccessToken.ts @@ -1,20 +1,17 @@ import { generateSessionToken } from "@server/auth/sessions/app"; import db from "@server/db"; -import { resourceAccessToken, resources } from "@server/db/schema"; +import { resources } from "@server/db/schema"; import HttpCode from "@server/types/HttpCode"; import response from "@server/lib/response"; -import { eq, and } from "drizzle-orm"; +import { eq } from "drizzle-orm"; import { NextFunction, Request, Response } from "express"; import createHttpError from "http-errors"; import { z } from "zod"; import { fromError } from "zod-validation-error"; -import { - createResourceSession, - serializeResourceSessionCookie -} from "@server/auth/sessions/resource"; -import config from "@server/lib/config"; +import { createResourceSession } from "@server/auth/sessions/resource"; import logger from "@server/logger"; import { verifyResourceAccessToken } from "@server/auth/verifyResourceAccessToken"; +import config from "@server/lib/config"; const authWithAccessTokenBodySchema = z .object({ @@ -86,6 +83,11 @@ export async function authWithAccessToken( }); if (!valid) { + if (config.getRawConfig().app.log_failed_attempts) { + logger.info( + `Resource access token invalid. Resource ID: ${resource.resourceId}. IP: ${req.ip}.` + ); + } return next( createHttpError( HttpCode.UNAUTHORIZED, @@ -108,13 +110,11 @@ export async function authWithAccessToken( resourceId, token, accessTokenId: tokenItem.accessTokenId, - sessionLength: tokenItem.sessionLength, - expiresAt: tokenItem.expiresAt, - doNotExtend: tokenItem.expiresAt ? true : false + isRequestToken: true, + expiresAt: Date.now() + 1000 * 30, // 30 seconds + sessionLength: 1000 * 30, + doNotExtend: true }); - const cookieName = `${config.getRawConfig().server.resource_session_cookie_name}_${resource.resourceId}`; - const cookie = serializeResourceSessionCookie(cookieName, token); - res.appendHeader("Set-Cookie", cookie); return response(res, { data: { diff --git a/server/routers/resource/authWithPassword.ts b/server/routers/resource/authWithPassword.ts index dc7c41a9..a69d42b6 100644 --- a/server/routers/resource/authWithPassword.ts +++ b/server/routers/resource/authWithPassword.ts @@ -9,13 +9,10 @@ import { NextFunction, Request, Response } from "express"; import createHttpError from "http-errors"; import { z } from "zod"; import { fromError } from "zod-validation-error"; -import { - createResourceSession, - serializeResourceSessionCookie -} from "@server/auth/sessions/resource"; -import config from "@server/lib/config"; +import { createResourceSession } from "@server/auth/sessions/resource"; import logger from "@server/logger"; import { verifyPassword } from "@server/auth/password"; +import config from "@server/lib/config"; export const authWithPasswordBodySchema = z .object({ @@ -84,7 +81,7 @@ export async function authWithPassword( if (!org) { return next( - createHttpError(HttpCode.BAD_REQUEST, "Resource does not exist") + createHttpError(HttpCode.BAD_REQUEST, "Org does not exist") ); } @@ -111,6 +108,11 @@ export async function authWithPassword( definedPassword.passwordHash ); if (!validPassword) { + if (config.getRawConfig().app.log_failed_attempts) { + logger.info( + `Resource password incorrect. Resource ID: ${resource.resourceId}. IP: ${req.ip}.` + ); + } return next( createHttpError(HttpCode.UNAUTHORIZED, "Incorrect password") ); @@ -120,11 +122,12 @@ export async function authWithPassword( await createResourceSession({ resourceId, token, - passwordId: definedPassword.passwordId + passwordId: definedPassword.passwordId, + isRequestToken: true, + expiresAt: Date.now() + 1000 * 30, // 30 seconds + sessionLength: 1000 * 30, + doNotExtend: true }); - const cookieName = `${config.getRawConfig().server.resource_session_cookie_name}_${resource.resourceId}`; - const cookie = serializeResourceSessionCookie(cookieName, token); - res.appendHeader("Set-Cookie", cookie); return response(res, { data: { diff --git a/server/routers/resource/authWithPincode.ts b/server/routers/resource/authWithPincode.ts index 97a0b13c..c648e36c 100644 --- a/server/routers/resource/authWithPincode.ts +++ b/server/routers/resource/authWithPincode.ts @@ -1,29 +1,17 @@ -import { verify } from "@node-rs/argon2"; import { generateSessionToken } from "@server/auth/sessions/app"; import db from "@server/db"; -import { - orgs, - resourceOtp, - resourcePincode, - resources, - resourceWhitelist -} from "@server/db/schema"; +import { orgs, resourcePincode, resources } from "@server/db/schema"; import HttpCode from "@server/types/HttpCode"; import response from "@server/lib/response"; -import { and, eq } from "drizzle-orm"; +import { eq } from "drizzle-orm"; import { NextFunction, Request, Response } from "express"; import createHttpError from "http-errors"; import { z } from "zod"; import { fromError } from "zod-validation-error"; -import { - createResourceSession, - serializeResourceSessionCookie -} from "@server/auth/sessions/resource"; +import { createResourceSession } from "@server/auth/sessions/resource"; import logger from "@server/logger"; -import config from "@server/lib/config"; -import { AuthWithPasswordResponse } from "./authWithPassword"; -import { isValidOtp, sendResourceOtpEmail } from "@server/auth/resourceOtp"; import { verifyPassword } from "@server/auth/password"; +import config from "@server/lib/config"; export const authWithPincodeBodySchema = z .object({ @@ -119,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") ); @@ -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 cc73410c..147e2ba9 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"; @@ -14,17 +13,17 @@ import { NextFunction, Request, Response } from "express"; import createHttpError from "http-errors"; import { z } from "zod"; import { fromError } from "zod-validation-error"; -import { - createResourceSession, - serializeResourceSessionCookie -} from "@server/auth/sessions/resource"; -import config from "@server/lib/config"; +import { createResourceSession } from "@server/auth/sessions/resource"; import { isValidOtp, sendResourceOtpEmail } from "@server/auth/resourceOtp"; import logger from "@server/logger"; +import config from "@server/lib/config"; const authWithWhitelistBodySchema = z .object({ - email: z.string().email(), + email: z + .string() + .email() + .transform((v) => v.toLowerCase()), otp: z.string().optional() }) .strict(); @@ -90,20 +89,53 @@ export async function authWithWhitelist( .leftJoin(orgs, eq(orgs.orgId, resources.orgId)) .limit(1); - const resource = result?.resources; - const org = result?.orgs; - const whitelistedEmail = result?.resourceWhitelist; + let resource = result?.resources; + let org = result?.orgs; + let whitelistedEmail = result?.resourceWhitelist; if (!whitelistedEmail) { - return next( - createHttpError( - HttpCode.UNAUTHORIZED, - createHttpError( - HttpCode.BAD_REQUEST, - "Email is not whitelisted" + // if email is not found, check for wildcard email + const wildcard = "*@" + email.split("@")[1]; + + logger.debug("Checking for wildcard email: " + wildcard); + + const [result] = await db + .select() + .from(resourceWhitelist) + .where( + and( + eq(resourceWhitelist.resourceId, resourceId), + eq(resourceWhitelist.email, wildcard) ) ) - ); + .leftJoin( + resources, + eq(resources.resourceId, resourceWhitelist.resourceId) + ) + .leftJoin(orgs, eq(orgs.orgId, resources.orgId)) + .limit(1); + + resource = result?.resources; + org = result?.orgs; + whitelistedEmail = result?.resourceWhitelist; + + // if wildcard is still not found, return unauthorized + if (!whitelistedEmail) { + if (config.getRawConfig().app.log_failed_attempts) { + logger.info( + `Email is not whitelisted. Email: ${email}. IP: ${req.ip}.` + ); + } + return next( + createHttpError( + HttpCode.UNAUTHORIZED, + createHttpError( + HttpCode.BAD_REQUEST, + "Email is not whitelisted" + ) + ) + ); + } } if (!org) { @@ -125,6 +157,11 @@ export async function authWithWhitelist( otp ); if (!isValidCode) { + if (config.getRawConfig().app.log_failed_attempts) { + logger.info( + `Resource email otp incorrect. Resource ID: ${resource.resourceId}. Email: ${email}. IP: ${req.ip}.` + ); + } return next( createHttpError(HttpCode.UNAUTHORIZED, "Incorrect OTP") ); @@ -175,11 +212,12 @@ export async function authWithWhitelist( await createResourceSession({ resourceId, token, - whitelistId: whitelistedEmail.whitelistId + whitelistId: whitelistedEmail.whitelistId, + isRequestToken: true, + expiresAt: Date.now() + 1000 * 30, // 30 seconds + sessionLength: 1000 * 30, + doNotExtend: true }); - const cookieName = `${config.getRawConfig().server.resource_session_cookie_name}_${resource.resourceId}`; - const cookie = serializeResourceSessionCookie(cookieName, token); - res.appendHeader("Set-Cookie", cookie); return response(res, { data: { diff --git a/server/routers/resource/createResource.ts b/server/routers/resource/createResource.ts index 00cad947..e687cc02 100644 --- a/server/routers/resource/createResource.ts +++ b/server/routers/resource/createResource.ts @@ -16,8 +16,8 @@ import createHttpError from "http-errors"; import { eq, and } from "drizzle-orm"; import stoi from "@server/lib/stoi"; import { fromError } from "zod-validation-error"; -import { subdomainSchema } from "@server/schemas/subdomainSchema"; import logger from "@server/logger"; +import { subdomainSchema } from "@server/schemas/subdomainSchema"; const createResourceParamsSchema = z .object({ @@ -28,10 +28,42 @@ const createResourceParamsSchema = z const createResourceSchema = z .object({ + subdomain: z.string().optional(), name: z.string().min(1).max(255), - subdomain: subdomainSchema + siteId: z.number(), + http: z.boolean(), + protocol: z.string(), + proxyPort: z.number().optional() }) - .strict(); + .refine( + (data) => { + if (!data.http) { + return z + .number() + .int() + .min(1) + .max(65535) + .safeParse(data.proxyPort).success; + } + return true; + }, + { + message: "Invalid port number", + path: ["proxyPort"] + } + ) + .refine( + (data) => { + if (data.http) { + return subdomainSchema.safeParse(data.subdomain).success; + } + return true; + }, + { + message: "Invalid subdomain", + path: ["subdomain"] + } + ); export type CreateResourceResponse = Resource; @@ -51,7 +83,7 @@ export async function createResource( ); } - let { name, subdomain } = parsedBody.data; + let { name, subdomain, protocol, proxyPort, http } = parsedBody.data; // Validate request params const parsedParams = createResourceParamsSchema.safeParse(req.params); @@ -89,15 +121,64 @@ export async function createResource( } const fullDomain = `${subdomain}.${org[0].domain}`; + // if http is false check to see if there is already a resource with the same port and protocol + if (!http) { + const existingResource = await db + .select() + .from(resources) + .where( + and( + eq(resources.protocol, protocol), + eq(resources.proxyPort, proxyPort!) + ) + ); + + if (existingResource.length > 0) { + return next( + createHttpError( + HttpCode.CONFLICT, + "Resource with that protocol and port already exists" + ) + ); + } + } else { + if (proxyPort === 443 || proxyPort === 80) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "Port 80 and 443 are reserved for https resources" + ) + ); + } + + // make sure the full domain is unique + const existingResource = await db + .select() + .from(resources) + .where(eq(resources.fullDomain, fullDomain)); + + if (existingResource.length > 0) { + return next( + createHttpError( + HttpCode.CONFLICT, + "Resource with that domain already exists" + ) + ); + } + } + await db.transaction(async (trx) => { const newResource = await trx .insert(resources) .values({ siteId, - fullDomain, + fullDomain: http ? fullDomain : null, orgId, name, subdomain, + http, + protocol, + proxyPort, ssl: true }) .returning(); @@ -135,18 +216,6 @@ export async function createResource( }); }); } catch (error) { - if ( - error instanceof SqliteError && - error.code === "SQLITE_CONSTRAINT_UNIQUE" - ) { - return next( - createHttpError( - HttpCode.CONFLICT, - "Resource with that subdomain already exists" - ) - ); - } - logger.error(error); return next( createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") 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/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/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/setResourceWhitelist.ts b/server/routers/resource/setResourceWhitelist.ts index 5eefbd53..d60d079d 100644 --- a/server/routers/resource/setResourceWhitelist.ts +++ b/server/routers/resource/setResourceWhitelist.ts @@ -11,7 +11,20 @@ import { and, eq } from "drizzle-orm"; const setResourceWhitelistBodySchema = z .object({ - emails: z.array(z.string().email()).max(50) + emails: z + .array( + z + .string() + .email() + .or( + z.string().regex(/^\*@[\w.-]+\.[a-zA-Z]{2,}$/, { + message: + "Invalid email address. Wildcard (*) must be the entire local part." + }) + ) + ) + .max(50) + .transform((v) => v.map((e) => e.toLowerCase())) }) .strict(); diff --git a/server/routers/resource/updateResource.ts b/server/routers/resource/updateResource.ts index 67b68123..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, { @@ -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/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 fb775ac4..93eddde4 100644 --- a/server/routers/traefik/getTraefikConfig.ts +++ b/server/routers/traefik/getTraefikConfig.ts @@ -1,174 +1,269 @@ 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 redirectMiddlewareName = "redirect-to-https"; + const redirectHttpsMiddlewareName = "redirect-to-https"; - const http: any = { - routers: {}, - services: {}, - middlewares: { - [badgerMiddlewareName]: { - plugin: { - [badgerMiddlewareName]: { - apiBaseUrl: new URL( - "/api/v1", - `http://${config.getRawConfig().server.internal_hostname}:${config.getRawConfig().server.internal_port}`, - ).href, - resourceSessionCookieName: - config.getRawConfig().server.resource_session_cookie_name, - userSessionCookieName: - config.getRawConfig().server.session_cookie_name, - accessTokenQueryParam: config.getRawConfig().server.resource_access_token_param, - }, + const config_output: any = { + http: { + middlewares: { + [badgerMiddlewareName]: { + plugin: { + [badgerMiddlewareName]: { + apiBaseUrl: new URL( + "/api/v1", + `http://${config.getRawConfig().server.internal_hostname}:${ + config.getRawConfig().server + .internal_port + }` + ).href, + userSessionCookieName: + config.getRawConfig().server + .session_cookie_name, + accessTokenQueryParam: + config.getRawConfig().server + .resource_access_token_param, + resourceSessionRequestParam: + config.getRawConfig().server + .resource_session_request_param + } + } }, - }, - [redirectMiddlewareName]: { - redirectScheme: { - scheme: "https", - permanent: true, - }, - }, - }, + [redirectHttpsMiddlewareName]: { + redirectScheme: { + scheme: "https" + } + } + } + } }; - for (const item of all) { - const target = item.targets; - const resource = item.resources; - const site = item.sites; - const org = item.orgs; - const routerName = `${target.targetId}-router`; - const serviceName = `${target.targetId}-service`; + for (const resource of allResources) { + const targets = JSON.parse(resource.targets); + const site = resource.site; + const org = resource.org; - if (!resource || !resource.subdomain) { - continue; - } - - if (!org || !org.domain) { + if (!org.domain) { continue; } + const routerName = `${resource.resourceId}-router`; + const serviceName = `${resource.resourceId}-service`; const fullDomain = `${resource.subdomain}.${org.domain}`; - const domainParts = fullDomain.split("."); - let wildCard; - if (domainParts.length <= 2) { - wildCard = `*.${domainParts.join(".")}`; - } else { - wildCard = `*.${domainParts.slice(1).join(".")}`; - } + if (resource.http) { + // HTTP configuration remains the same + if (!resource.subdomain) { + continue; + } - const tls = { - certResolver: config.getRawConfig().traefik.cert_resolver, - ...(config.getRawConfig().traefik.prefer_wildcard_cert - ? { - domains: [ - { - main: wildCard, - }, - ], - } - : {}), - }; + if ( + targets.filter( + (target: Target) => target.internalPort != null + ).length == 0 + ) { + continue; + } - 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 } : {}), - }; + // add routers and services empty objects if they don't exist + if (!config_output.http.routers) { + config_output.http.routers = {}; + } - 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], + if (!config_output.http.services) { + config_output.http.services = {}; + } + + const domainParts = fullDomain.split("."); + let wildCard; + if (domainParts.length <= 2) { + wildCard = `*.${domainParts.join(".")}`; + } else { + wildCard = `*.${domainParts.slice(1).join(".")}`; + } + + const tls = { + certResolver: config.getRawConfig().traefik.cert_resolver, + ...(config.getRawConfig().traefik.prefer_wildcard_cert + ? { + domains: [ + { + main: wildCard + } + ] + } + : {}) + }; + + const additionalMiddlewares = config.getRawConfig().traefik.additional_middlewares || []; + + config_output.http.routers![routerName] = { + entryPoints: [ + resource.ssl + ? config.getRawConfig().traefik.https_entrypoint + : config.getRawConfig().traefik.http_entrypoint + ], + middlewares: [badgerMiddlewareName, ...additionalMiddlewares], service: serviceName, rule: `Host(\`${fullDomain}\`)`, + ...(resource.ssl ? { tls } : {}) }; - } - if (site.type === "newt") { - const ip = site.subnet.split("/")[0]; - http.services![serviceName] = { - loadBalancer: { - servers: [ - { - url: `${target.method}://${ip}:${target.internalPort}`, - }, + if (resource.ssl) { + config_output.http.routers![routerName + "-redirect"] = { + entryPoints: [ + config.getRawConfig().traefik.http_entrypoint ], - }, + middlewares: [redirectHttpsMiddlewareName], + service: serviceName, + rule: `Host(\`${fullDomain}\`)` + }; + } + + config_output.http.services![serviceName] = { + loadBalancer: { + servers: targets + .filter( + (target: Target) => 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/routers/user/inviteUser.ts b/server/routers/user/inviteUser.ts index 3031e399..5bf7e17f 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() @@ -165,7 +168,7 @@ export async function inviteUser( }), { to: email, - from: config.getRawConfig().email?.no_reply, + from: config.getNoReplyEmail(), subject: "You're invited to join a Fossorial organization" } ); diff --git a/server/setup/migrations.ts b/server/setup/migrations.ts index 0e9e4819..7d1d7da4 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; @@ -30,31 +31,36 @@ const migrations = [ await runMigrations(); export async function runMigrations() { - const appVersion = loadAppVersion(); - if (!appVersion) { - throw new Error("APP_VERSION is not set in the environment"); - } - - if (exists) { - await executeScripts(); - } else { - console.log("Running migrations..."); - try { - migrate(db, { - migrationsFolder: path.join(__DIRNAME, "init") // put here during the docker build - }); - console.log("Migrations completed successfully."); - } catch (error) { - console.error("Error running migrations:", error); + try { + const appVersion = loadAppVersion(); + if (!appVersion) { + throw new Error("APP_VERSION is not set in the environment"); } - await db + if (exists) { + await executeScripts(); + } else { + console.log("Running migrations..."); + try { + migrate(db, { + migrationsFolder: path.join(__DIRNAME, "init") // put here during the docker build + }); + console.log("Migrations completed successfully."); + } catch (error) { + console.error("Error running migrations:", error); + } + + await db .insert(versionMigrations) .values({ version: appVersion, executedAt: Date.now() }) .execute(); + } + } catch (e) { + console.error("Error running migrations:", e); + await new Promise((resolve) => setTimeout(resolve, 1000 * 60 * 60 * 24 * 1)); } } 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..c883bd60 --- /dev/null +++ b/server/setup/scripts/1.0.0-beta9.ts @@ -0,0 +1,291 @@ +import db from "@server/db"; +import { + emailVerificationCodes, + passwordResetTokens, + resourceOtp, + resources, + resourceWhitelist, + targets, + userInvites, + users +} from "@server/db/schema"; +import { APP_PATH, configFilePath1, configFilePath2 } from "@server/lib/consts"; +import { eq, sql } from "drizzle-orm"; +import fs from "fs"; +import yaml from "js-yaml"; +import path from "path"; +import { z } from "zod"; +import { fromZodError } from "zod-validation-error"; + +export default async function migration() { + console.log("Running setup script 1.0.0-beta.9..."); + + // make dir config/db/backups + const appPath = APP_PATH; + const dbDir = path.join(appPath, "db"); + + const backupsDir = path.join(dbDir, "backups"); + + // check if the backups directory exists and create it if it doesn't + if (!fs.existsSync(backupsDir)) { + fs.mkdirSync(backupsDir, { recursive: true }); + } + + // copy the db.sqlite file to backups + // add the date to the filename + const date = new Date(); + const dateString = `${date.getFullYear()}-${date.getMonth()}-${date.getDate()}_${date.getHours()}-${date.getMinutes()}-${date.getSeconds()}`; + const dbPath = path.join(dbDir, "db.sqlite"); + const backupPath = path.join(backupsDir, `db_${dateString}.sqlite`); + fs.copyFileSync(dbPath, backupPath); + + await db.transaction(async (trx) => { + try { + // Determine which config file exists + const filePaths = [configFilePath1, configFilePath2]; + let filePath = ""; + for (const path of filePaths) { + if (fs.existsSync(path)) { + filePath = path; + break; + } + } + + if (!filePath) { + throw new Error( + `No config file found (expected config.yml or config.yaml).` + ); + } + + // Read and parse the YAML file + let rawConfig: any; + const fileContents = fs.readFileSync(filePath, "utf8"); + rawConfig = yaml.load(fileContents); + + rawConfig.server.resource_session_request_param = + "p_session_request"; + rawConfig.server.session_cookie_name = "p_session_token"; // rename to prevent conflicts + delete rawConfig.server.resource_session_cookie_name; + + if (!rawConfig.flags) { + rawConfig.flags = {}; + } + + rawConfig.flags.allow_raw_resources = true; + + // Write the updated YAML back to the file + const updatedYaml = yaml.dump(rawConfig); + fs.writeFileSync(filePath, updatedYaml, "utf8"); + } catch (e) { + console.log( + `Failed to add resource_session_request_param to config. Please add it manually. https://docs.fossorial.io/Pangolin/Configuration/config` + ); + trx.rollback(); + return; + } + + try { + const traefikPath = path.join( + APP_PATH, + "traefik", + "traefik_config.yml" + ); + + // Define schema for traefik config validation + const schema = z.object({ + entryPoints: z + .object({ + websecure: z + .object({ + address: z.string(), + transport: z + .object({ + respondingTimeouts: z.object({ + readTimeout: z.string() + }) + }) + .optional() + }) + .optional() + }) + .optional(), + experimental: z.object({ + plugins: z.object({ + badger: z.object({ + moduleName: z.string(), + version: z.string() + }) + }) + }) + }); + + const traefikFileContents = fs.readFileSync(traefikPath, "utf8"); + const traefikConfig = yaml.load(traefikFileContents) as any; + + let parsedConfig: any = schema.safeParse(traefikConfig); + + if (parsedConfig.success) { + // Ensure websecure entrypoint exists + if (traefikConfig.entryPoints?.websecure) { + // Add transport configuration + traefikConfig.entryPoints.websecure.transport = { + respondingTimeouts: { + readTimeout: "30m" + } + }; + } + + traefikConfig.experimental.plugins.badger.version = + "v1.0.0-beta.3"; + + const updatedTraefikYaml = yaml.dump(traefikConfig); + fs.writeFileSync(traefikPath, updatedTraefikYaml, "utf8"); + + console.log("Updated Badger version in Traefik config."); + } else { + console.log(fromZodError(parsedConfig.error)); + console.log( + "We were unable to update the version of Badger in your Traefik configuration. Please update it manually to at least v1.0.0-beta.3. https://github.com/fosrl/badger" + ); + } + } catch (e) { + console.log( + "We were unable to update the version of Badger in your Traefik configuration. Please update it manually to at least v1.0.0-beta.3. https://github.com/fosrl/badger" + ); + trx.rollback(); + return; + } + + try { + const traefikPath = path.join( + APP_PATH, + "traefik", + "dynamic_config.yml" + ); + + const schema = z.object({ + http: z.object({ + middlewares: z.object({ + "redirect-to-https": z.object({ + redirectScheme: z.object({ + scheme: z.string(), + permanent: z.boolean() + }) + }) + }) + }) + }); + + const traefikFileContents = fs.readFileSync(traefikPath, "utf8"); + const traefikConfig = yaml.load(traefikFileContents) as any; + + let parsedConfig: any = schema.safeParse(traefikConfig); + + if (parsedConfig.success) { + // delete permanent from redirect-to-https middleware + delete traefikConfig.http.middlewares["redirect-to-https"].redirectScheme.permanent; + + const updatedTraefikYaml = yaml.dump(traefikConfig); + fs.writeFileSync(traefikPath, updatedTraefikYaml, "utf8"); + + console.log("Deleted permanent from redirect-to-https middleware."); + } else { + console.log(fromZodError(parsedConfig.error)); + console.log( + "We were unable to delete the permanent field from the redirect-to-https middleware in your Traefik configuration. Please delete it manually." + ); + } + } catch (e) { + console.log( + "We were unable to delete the permanent field from the redirect-to-https middleware in your Traefik configuration. Please delete it manually. Note that this is not a critical change but recommended." + ); + } + + trx.run(sql`UPDATE ${users} SET email = LOWER(email);`); + trx.run( + sql`UPDATE ${emailVerificationCodes} SET email = LOWER(email);` + ); + trx.run(sql`UPDATE ${passwordResetTokens} SET email = LOWER(email);`); + trx.run(sql`UPDATE ${userInvites} SET email = LOWER(email);`); + trx.run(sql`UPDATE ${resourceWhitelist} SET email = LOWER(email);`); + trx.run(sql`UPDATE ${resourceOtp} SET email = LOWER(email);`); + + const resourcesAll = await trx + .select({ + resourceId: resources.resourceId, + fullDomain: resources.fullDomain, + subdomain: resources.subdomain + }) + .from(resources); + + trx.run(`DROP INDEX resources_fullDomain_unique;`); + trx.run(`ALTER TABLE resources + DROP COLUMN fullDomain; + `); + trx.run(`ALTER TABLE resources + DROP COLUMN subdomain; + `); + trx.run(sql`ALTER TABLE resources + ADD COLUMN fullDomain TEXT; + `); + trx.run(sql`ALTER TABLE resources + ADD COLUMN subdomain TEXT; + `); + trx.run(sql`ALTER TABLE resources + ADD COLUMN http INTEGER DEFAULT true NOT NULL; + `); + trx.run(sql`ALTER TABLE resources + ADD COLUMN protocol TEXT DEFAULT 'tcp' NOT NULL; + `); + trx.run(sql`ALTER TABLE resources + ADD COLUMN proxyPort INTEGER; + `); + + // write the new fullDomain and subdomain values back to the database + for (const resource of resourcesAll) { + await trx + .update(resources) + .set({ + fullDomain: resource.fullDomain, + subdomain: resource.subdomain + }) + .where(eq(resources.resourceId, resource.resourceId)); + } + + const targetsAll = await trx + .select({ + targetId: targets.targetId, + method: targets.method + }) + .from(targets); + + trx.run(`ALTER TABLE targets + DROP COLUMN method; + `); + trx.run(`ALTER TABLE targets + DROP COLUMN protocol; + `); + trx.run(sql`ALTER TABLE targets + ADD COLUMN method TEXT; + `); + + // write the new method and protocol values back to the database + for (const target of targetsAll) { + await trx + .update(targets) + .set({ + method: target.method + }) + .where(eq(targets.targetId, target.targetId)); + } + + trx.run( + sql`ALTER TABLE 'resourceSessions' ADD 'isRequestToken' integer;` + ); + trx.run( + sql`ALTER TABLE 'resourceSessions' ADD 'userSessionId' text REFERENCES session(id);` + ); + }); + + console.log("Done."); +} 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/CreateResourceForm.tsx b/src/app/[orgId]/settings/resources/CreateResourceForm.tsx index 912f7be1..753d843d 100644 --- a/src/app/[orgId]/settings/resources/CreateResourceForm.tsx +++ b/src/app/[orgId]/settings/resources/CreateResourceForm.tsx @@ -45,21 +45,64 @@ 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"; +import { subdomainSchema } from "@server/schemas/subdomainSchema"; +import Link from "next/link"; +import { SquareArrowOutUpRight } from "lucide-react"; -const accountFormSchema = z.object({ - subdomain: subdomainSchema, - name: z.string(), - siteId: z.number() -}); +const createResourceFormSchema = z + .object({ + subdomain: z.string().optional(), + name: z.string().min(1).max(255), + siteId: z.number(), + http: z.boolean(), + protocol: z.string(), + proxyPort: z.number().optional() + }) + .refine( + (data) => { + if (!data.http) { + return z + .number() + .int() + .min(1) + .max(65535) + .safeParse(data.proxyPort).success; + } + return true; + }, + { + message: "Invalid port number", + path: ["proxyPort"] + } + ) + .refine( + (data) => { + if (data.http) { + return subdomainSchema.safeParse(data.subdomain).success; + } + return true; + }, + { + message: "Invalid subdomain", + path: ["subdomain"] + } + ); -type AccountFormValues = z.infer; +type CreateResourceFormValues = z.infer; type CreateResourceFormProps = { open: boolean; @@ -81,15 +124,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 +158,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 +166,11 @@ 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, + siteId: data.siteId } ) .catch((e) => { @@ -188,34 +237,165 @@ 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") && ( + + + Learn how to configure TCP/UDP resources + + + + )} + + {!form.watch("http") && ( + <> + ( + + + Protocol + + + + + + + + + + TCP + + + UDP + + + + + 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) + + + + )} + /> + > + )} + ( - - + + View settings - - + + { setSelectedResource(resourceRow); @@ -146,24 +149,40 @@ export default function SitesTable({ resources, orgId }: ResourcesTableProps) { cell: ({ row }) => { const resourceRow = row.original; return ( - - + + {resourceRow.site} - - - + + + + ); + } + }, + { + accessorKey: "protocol", + header: "Protocol", + cell: ({ row }) => { + const resourceRow = row.original; + return ( + {resourceRow.protocol.toUpperCase()} ); } }, { accessorKey: "domain", - header: "Full URL", + 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]/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 */} [] = [ - { - accessorKey: "method", - header: "Method", - cell: ({ row }) => ( - - updateTarget(row.original.targetId, { method: value }) - } - > - - {row.original.method} - - - http - https - - - ) - }, { accessorKey: "ip", header: "IP / Hostname", @@ -436,6 +417,32 @@ export default function ReverseProxyTargets(props: { } ]; + if (resource.http) { + const methodCol: ColumnDef = { + accessorKey: "method", + header: "Method", + cell: ({ row }) => ( + + updateTarget(row.original.targetId, { method: value }) + } + > + + {row.original.method} + + + http + https + + + ) + }; + + // add this to the first column + columns.unshift(methodCol); + } + const table = useReactTable({ data: targets, columns, @@ -451,29 +458,29 @@ export default function ReverseProxyTargets(props: { return ( - {/* SSL Section */} - - - - SSL Configuration - - - Setup SSL to secure your connections with LetsEncrypt - certificates - - - - { - await saveSsl(val); - }} - /> - - - + {resource.http && ( + + + + SSL Configuration + + + Setup SSL to secure your connections with + LetsEncrypt certificates + + + + { + await saveSsl(val); + }} + /> + + + )} {/* Targets Section */} @@ -491,39 +498,47 @@ export default function ReverseProxyTargets(props: { className="space-y-4" > - ( - - Method - - { - addTargetForm.setValue( - "method", + {resource.http && ( + ( + + Method + + - - - - - - http - - - https - - - - - - - )} - /> + ) => { + addTargetForm.setValue( + "method", + value + ); + }} + > + + + + + + http + + + https + + + + + + + )} + /> + )} + + + Adding more than one target above will enable load balancing. + { + if (!data.http) { + return z + .number() + .int() + .min(1) + .max(65535) + .safeParse(data.proxyPort).success; + } + return true; + }, + { + message: "Invalid port number", + path: ["proxyPort"] + } + ) + .refine( + (data) => { + if (data.http) { + return subdomainSchema.safeParse(data.subdomain).success; + } + return true; + }, + { + message: "Invalid subdomain", + path: ["subdomain"] + } + ); type GeneralFormValues = z.infer; @@ -81,8 +96,9 @@ export default function GeneralForm() { resolver: zodResolver(GeneralFormSchema), defaultValues: { name: resource.name, - subdomain: resource.subdomain - // siteId: resource.siteId!, + subdomain: resource.subdomain ? resource.subdomain : undefined, + proxyPort: resource.proxyPort ? resource.proxyPort : undefined, + http: resource.http }, mode: "onChange" }); @@ -169,33 +185,78 @@ export default function GeneralForm() { )} /> - ( - - Subdomain - - - form.setValue( - "subdomain", - value - ) - } - /> - - - This is the subdomain that will - be used to access the resource. - - - - )} - /> + {resource.http ? ( + ( + + Subdomain + + + form.setValue( + "subdomain", + value + ) + } + /> + + + This is the subdomain that + will be used to access the + resource. + + + + )} + /> + ) : ( + ( + + + Port Number + + + + 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/app/[orgId]/settings/share-links/CreateShareLinkForm.tsx b/src/app/[orgId]/settings/share-links/CreateShareLinkForm.tsx index 1bbf6e78..0c36c57e 100644 --- a/src/app/[orgId]/settings/share-links/CreateShareLinkForm.tsx +++ b/src/app/[orgId]/settings/share-links/CreateShareLinkForm.tsx @@ -153,7 +153,9 @@ export default function CreateShareLinkForm({ if (res?.status === 200) { setResources( - res.data.data.resources.map((r) => ({ + res.data.data.resources.filter((r) => { + return r.http; + }).map((r) => ({ resourceId: r.resourceId, name: r.name, resourceUrl: `${r.ssl ? "https://" : "http://"}${r.fullDomain}/` @@ -318,7 +320,7 @@ export default function CreateShareLinkForm({ ) => ( { const r = row.original; return ( - - + + {r.resourceName} - - - + + + ); } }, 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); 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]/ResourceAccessDenied.tsx b/src/app/auth/resource/[resourceId]/ResourceAccessDenied.tsx index b47b976f..088782a5 100644 --- a/src/app/auth/resource/[resourceId]/ResourceAccessDenied.tsx +++ b/src/app/auth/resource/[resourceId]/ResourceAccessDenied.tsx @@ -19,7 +19,7 @@ export default function ResourceAccessDenied() { - You're not alowed to access this resource. If this is a mistake, + You're not allowed to access this resource. If this is a mistake, please contact the administrator. 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/components/ui/info-popup.tsx b/src/components/ui/info-popup.tsx new file mode 100644 index 00000000..cf8f06c3 --- /dev/null +++ b/src/components/ui/info-popup.tsx @@ -0,0 +1,38 @@ +"use client"; + +import React from "react"; +import { Info } from "lucide-react"; +import { + Popover, + PopoverContent, + PopoverTrigger +} from "@/components/ui/popover"; +import { Button } from "@/components/ui/button"; + +interface InfoPopupProps { + text: string; + info: string; +} + +export function InfoPopup({ text, info }: InfoPopupProps) { + return ( + + {text} + + + + + Show info + + + + {info} + + + + ); +} diff --git a/src/lib/pullEnv.ts b/src/lib/pullEnv.ts index d335d703..368df440 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, @@ -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 559bb531..14efd1be 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; @@ -17,5 +17,6 @@ export type Env = { disableSignupWithoutInvite: boolean; disableUserCreateOrg: boolean; emailVerificationRequired: boolean; + allowRawResources: boolean; } };
+ Adding more than one target above will enable load balancing. +
- Don't have an account?{" "} - - Sign up - -
{info}