Merge pull request #111 from fosrl/dev

major changes for 1.0.0-beta.9
This commit is contained in:
Milo Schwartz 2025-01-30 11:07:52 -05:00 committed by GitHub
commit 16b131970b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
77 changed files with 2456 additions and 878 deletions

82
.github/workflows/cicd.yml vendored Normal file
View file

@ -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

2
.gitignore vendored
View file

@ -31,3 +31,5 @@ dist
installer installer
*.tar *.tar
bin bin
.secrets
test_event.json

View file

@ -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). - 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. - Built-in support for any WireGuard client.
- Automated **SSL certificates** (https) via [LetsEncrypt](https://letsencrypt.org/). - Automated **SSL certificates** (https) via [LetsEncrypt](https://letsencrypt.org/).
- Support for HTTP/HTTPS and **raw TCP/UDP services**.
### Identity & Access Management ### Identity & Access Management

View file

@ -1,27 +1,27 @@
app: app:
dashboard_url: http://localhost:3002 dashboard_url: "http://localhost:3002"
base_domain: localhost base_domain: "localhost"
log_level: info log_level: "info"
save_logs: false save_logs: false
server: server:
external_port: 3000 external_port: 3000
internal_port: 3001 internal_port: 3001
next_port: 3002 next_port: 3002
internal_hostname: pangolin internal_hostname: "pangolin"
secure_cookies: true secure_cookies: true
session_cookie_name: p_session session_cookie_name: "p_session_token"
resource_session_cookie_name: p_resource_session resource_access_token_param: "p_token"
resource_access_token_param: p_token resource_session_request_param: "p_session_request"
traefik: traefik:
cert_resolver: letsencrypt cert_resolver: "letsencrypt"
http_entrypoint: web http_entrypoint: "web"
https_entrypoint: websecure https_entrypoint: "websecure"
gerbil: gerbil:
start_port: 51820 start_port: 51820
base_endpoint: localhost base_endpoint: "localhost"
block_size: 24 block_size: 24
site_block_size: 30 site_block_size: 30
subnet_group: 100.89.137.0/20 subnet_group: 100.89.137.0/20
@ -34,10 +34,11 @@ rate_limits:
users: users:
server_admin: server_admin:
email: admin@example.com email: "admin@example.com"
password: Password123! password: "Password123!"
flags: flags:
require_email_verification: false require_email_verification: false
disable_signup_without_invite: true disable_signup_without_invite: true
disable_user_create_org: true disable_user_create_org: true
allow_raw_resources: true

View file

@ -3,7 +3,6 @@ http:
redirect-to-https: redirect-to-https:
redirectScheme: redirectScheme:
scheme: https scheme: https
permanent: true
routers: routers:
# HTTP to HTTPS redirect router # HTTP to HTTPS redirect router

View file

@ -4,7 +4,13 @@ api:
providers: providers:
http: 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" pollInterval: "5s"
file: file:
filename: "/etc/traefik/dynamic_config.yml" filename: "/etc/traefik/dynamic_config.yml"
@ -13,7 +19,7 @@ experimental:
plugins: plugins:
badger: badger:
moduleName: "github.com/fosrl/badger" moduleName: "github.com/fosrl/badger"
version: "v1.0.0-beta.2" version: "v1.0.0-beta.3"
log: log:
level: "INFO" level: "INFO"
@ -33,6 +39,9 @@ entryPoints:
address: ":80" address: ":80"
websecure: websecure:
address: ":443" address: ":443"
transport:
respondingTimeouts:
readTimeout: "30m"
http: http:
tls: tls:
certResolver: "letsencrypt" certResolver: "letsencrypt"

View file

@ -1,4 +1,3 @@
all: build all: build
build: build:

View file

@ -1,18 +1,18 @@
app: app:
dashboard_url: https://{{.DashboardDomain}} dashboard_url: "https://{{.DashboardDomain}}"
base_domain: {{.BaseDomain}} base_domain: "{{.BaseDomain}}"
log_level: info log_level: "info"
save_logs: false save_logs: false
server: server:
external_port: 3000 external_port: 3000
internal_port: 3001 internal_port: 3001
next_port: 3002 next_port: 3002
internal_hostname: pangolin internal_hostname: "pangolin"
secure_cookies: true secure_cookies: true
session_cookie_name: p_session session_cookie_name: "p_session_token"
resource_session_cookie_name: p_resource_session resource_access_token_param: "p_token"
resource_access_token_param: p_token resource_session_request_param: "p_session_request"
cors: cors:
origins: ["https://{{.DashboardDomain}}"] origins: ["https://{{.DashboardDomain}}"]
methods: ["GET", "POST", "PUT", "DELETE", "PATCH"] methods: ["GET", "POST", "PUT", "DELETE", "PATCH"]
@ -20,14 +20,14 @@ server:
credentials: false credentials: false
traefik: traefik:
cert_resolver: letsencrypt cert_resolver: "letsencrypt"
http_entrypoint: web http_entrypoint: "web"
https_entrypoint: websecure https_entrypoint: "websecure"
prefer_wildcard_cert: false prefer_wildcard_cert: false
gerbil: gerbil:
start_port: 51820 start_port: 51820
base_endpoint: {{.DashboardDomain}} base_endpoint: "{{.DashboardDomain}}"
use_subdomain: false use_subdomain: false
block_size: 24 block_size: 24
site_block_size: 30 site_block_size: 30
@ -39,18 +39,19 @@ rate_limits:
max_requests: 100 max_requests: 100
{{if .EnableEmail}} {{if .EnableEmail}}
email: email:
smtp_host: {{.EmailSMTPHost}} smtp_host: "{{.EmailSMTPHost}}"
smtp_port: {{.EmailSMTPPort}} smtp_port: "{{.EmailSMTPPort}}"
smtp_user: {{.EmailSMTPUser}} smtp_user: "{{.EmailSMTPUser}}"
smtp_pass: {{.EmailSMTPPass}} smtp_pass: "{{.EmailSMTPPass}}"
no_reply: {{.EmailNoReply}} no_reply: "{{.EmailNoReply}}"
{{end}} {{end}}
users: users:
server_admin: server_admin:
email: {{.AdminUserEmail}} email: "{{.AdminUserEmail}}"
password: {{.AdminUserPassword}} password: "{{.AdminUserPassword}}"
flags: flags:
require_email_verification: {{.EnableEmail}} require_email_verification: {{.EnableEmail}}
disable_signup_without_invite: {{.DisableSignupWithoutInvite}} disable_signup_without_invite: {{.DisableSignupWithoutInvite}}
disable_user_create_org: {{.DisableUserCreateOrg}} disable_user_create_org: {{.DisableUserCreateOrg}}
allow_raw_resources: true

View file

@ -3,7 +3,6 @@ http:
redirect-to-https: redirect-to-https:
redirectScheme: redirectScheme:
scheme: https scheme: https
permanent: true
routers: routers:
# HTTP to HTTPS redirect router # HTTP to HTTPS redirect router

View file

@ -4,7 +4,13 @@ api:
providers: providers:
http: 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" pollInterval: "5s"
file: file:
filename: "/etc/traefik/dynamic_config.yml" filename: "/etc/traefik/dynamic_config.yml"
@ -13,7 +19,7 @@ experimental:
plugins: plugins:
badger: badger:
moduleName: "github.com/fosrl/badger" moduleName: "github.com/fosrl/badger"
version: "v1.0.0-beta.2" version: "{{.BadgerVersion}}"
log: log:
level: "INFO" level: "INFO"
@ -33,6 +39,9 @@ entryPoints:
address: ":80" address: ":80"
websecure: websecure:
address: ":443" address: ":443"
transport:
respondingTimeouts:
readTimeout: "30m"
http: http:
tls: tls:
certResolver: "letsencrypt" certResolver: "letsencrypt"

View file

@ -17,9 +17,11 @@ import (
"golang.org/x/term" "golang.org/x/term"
) )
// DO NOT EDIT THIS FUNCTION; IT MATCHED BY REGEX IN CICD
func loadVersions(config *Config) { func loadVersions(config *Config) {
config.PangolinVersion = "1.0.0-beta.8" config.PangolinVersion = "replaceme"
config.GerbilVersion = "1.0.0-beta.3" config.GerbilVersion = "replaceme"
config.BadgerVersion = "replaceme"
} }
//go:embed fs/* //go:embed fs/*
@ -28,6 +30,7 @@ var configFiles embed.FS
type Config struct { type Config struct {
PangolinVersion string PangolinVersion string
GerbilVersion string GerbilVersion string
BadgerVersion string
BaseDomain string BaseDomain string
DashboardDomain string DashboardDomain string
LetsEncryptEmail string LetsEncryptEmail string
@ -271,6 +274,11 @@ func createConfigFiles(config Config) error {
// Get the relative path by removing the "fs/" prefix // Get the relative path by removing the "fs/" prefix
relPath := strings.TrimPrefix(path, "fs/") relPath := strings.TrimPrefix(path, "fs/")
// skip .DS_Store
if strings.Contains(relPath, ".DS_Store") {
return nil
}
// Create the full output path under "config/" // Create the full output path under "config/"
outPath := filepath.Join("config", relPath) outPath := filepath.Join("config", relPath)
@ -432,29 +440,53 @@ func isDockerInstalled() bool {
return true return true
} }
func getCommandString(useNewStyle bool) string {
if useNewStyle {
return "'docker compose'"
}
return "'docker-compose'"
}
func pullAndStartContainers() error { func pullAndStartContainers() error {
fmt.Println("Starting containers...") fmt.Println("Starting containers...")
// First try docker compose (new style) // Check which docker compose command is available
cmd := exec.Command("docker", "compose", "-f", "docker-compose.yml", "pull") var useNewStyle bool
cmd.Stdout = os.Stdout checkCmd := exec.Command("docker", "compose", "version")
cmd.Stderr = os.Stderr if err := checkCmd.Run(); err == nil {
err := cmd.Run() useNewStyle = true
} else {
if err != nil { // Check if docker-compose (old style) is available
fmt.Println("Failed to start containers using docker compose, falling back to docker-compose command") checkCmd = exec.Command("docker-compose", "version")
os.Exit(1) 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") // 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.Stdout = os.Stdout
cmd.Stderr = os.Stderr cmd.Stderr = os.Stderr
err = cmd.Run() return cmd.Run()
if err != nil {
fmt.Println("Failed to start containers using docker-compose command")
os.Exit(1)
} }
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
} }

View file

@ -1,6 +1,6 @@
{ {
"name": "@fosrl/pangolin", "name": "@fosrl/pangolin",
"version": "1.0.0-beta.8", "version": "1.0.0-beta.9",
"private": true, "private": true,
"type": "module", "type": "module",
"description": "Tunneled Reverse Proxy Management Server with Identity and Access Control and Dashboard UI", "description": "Tunneled Reverse Proxy Management Server with Identity and Access Control and Dashboard UI",
@ -64,6 +64,7 @@
"moment": "2.30.1", "moment": "2.30.1",
"next": "15.1.3", "next": "15.1.3",
"next-themes": "0.4.4", "next-themes": "0.4.4",
"node-cache": "5.1.2",
"node-fetch": "3.3.2", "node-fetch": "3.3.2",
"nodemailer": "6.9.16", "nodemailer": "6.9.16",
"oslo": "1.2.1", "oslo": "1.2.1",

View file

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

View file

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

View file

@ -3,7 +3,13 @@ import {
encodeHexLowerCase encodeHexLowerCase
} from "@oslojs/encoding"; } from "@oslojs/encoding";
import { sha256 } from "@oslojs/crypto/sha2"; 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 db from "@server/db";
import { eq } from "drizzle-orm"; import { eq } from "drizzle-orm";
import config from "@server/lib/config"; import config from "@server/lib/config";
@ -13,9 +19,14 @@ import logger from "@server/logger";
export const SESSION_COOKIE_NAME = export const SESSION_COOKIE_NAME =
config.getRawConfig().server.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 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 { export function generateSessionToken(): string {
const bytes = new Uint8Array(20); const bytes = new Uint8Array(20);
@ -65,12 +76,21 @@ export async function validateSessionToken(
session.expiresAt = new Date( session.expiresAt = new Date(
Date.now() + SESSION_COOKIE_EXPIRES Date.now() + SESSION_COOKIE_EXPIRES
).getTime(); ).getTime();
await db await db.transaction(async (trx) => {
await trx
.update(sessions) .update(sessions)
.set({ .set({
expiresAt: session.expiresAt expiresAt: session.expiresAt
}) })
.where(eq(sessions.sessionId, session.sessionId)); .where(eq(sessions.sessionId, session.sessionId));
await trx
.update(resourceSessions)
.set({
expiresAt: session.expiresAt
})
.where(eq(resourceSessions.userSessionId, session.sessionId));
});
} }
return { session, user }; return { session, user };
} }
@ -90,9 +110,9 @@ export function serializeSessionCookie(
if (isSecure) { if (isSecure) {
logger.debug("Setting cookie for secure origin"); logger.debug("Setting cookie for secure origin");
if (SECURE_COOKIES) { 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 { } 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 { } else {
return `${SESSION_COOKIE_NAME}=${token}; HttpOnly; SameSite=Lax; Max-Age=${SESSION_COOKIE_EXPIRES}; Path=/;`; return `${SESSION_COOKIE_NAME}=${token}; HttpOnly; SameSite=Lax; Max-Age=${SESSION_COOKIE_EXPIRES}; Path=/;`;

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -37,9 +37,11 @@ const configSchema = z.object({
base_domain: hostnameSchema base_domain: hostnameSchema
.optional() .optional()
.transform(getEnvOrYaml("APP_BASEDOMAIN")) .transform(getEnvOrYaml("APP_BASEDOMAIN"))
.pipe(hostnameSchema), .pipe(hostnameSchema)
.transform((url) => url.toLowerCase()),
log_level: z.enum(["debug", "info", "warn", "error"]), 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({ server: z.object({
external_port: portSchema external_port: portSchema
@ -60,8 +62,20 @@ const configSchema = z.object({
internal_hostname: z.string().transform((url) => url.toLowerCase()), internal_hostname: z.string().transform((url) => url.toLowerCase()),
secure_cookies: z.boolean(), secure_cookies: z.boolean(),
session_cookie_name: z.string(), session_cookie_name: z.string(),
resource_session_cookie_name: z.string(),
resource_access_token_param: 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 cors: z
.object({ .object({
origins: z.array(z.string()).optional(), origins: z.array(z.string()).optional(),
@ -76,7 +90,8 @@ const configSchema = z.object({
http_entrypoint: z.string(), http_entrypoint: z.string(),
https_entrypoint: z.string().optional(), https_entrypoint: z.string().optional(),
cert_resolver: 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({ gerbil: z.object({
start_port: portSchema start_port: portSchema
@ -109,11 +124,12 @@ const configSchema = z.object({
}), }),
email: z email: z
.object({ .object({
smtp_host: z.string(), smtp_host: z.string().optional(),
smtp_port: portSchema, smtp_port: portSchema.optional(),
smtp_user: z.string(), smtp_user: z.string().optional(),
smtp_pass: z.string(), smtp_pass: z.string().optional(),
no_reply: z.string().email() smtp_secure: z.boolean().optional(),
no_reply: z.string().email().optional()
}) })
.optional(), .optional(),
users: z.object({ users: z.object({
@ -123,7 +139,8 @@ const configSchema = z.object({
.email() .email()
.optional() .optional()
.transform(getEnvOrYaml("USERS_SERVERADMIN_EMAIL")) .transform(getEnvOrYaml("USERS_SERVERADMIN_EMAIL"))
.pipe(z.string().email()), .pipe(z.string().email())
.transform((v) => v.toLowerCase()),
password: passwordSchema password: passwordSchema
.optional() .optional()
.transform(getEnvOrYaml("USERS_SERVERADMIN_PASSWORD")) .transform(getEnvOrYaml("USERS_SERVERADMIN_PASSWORD"))
@ -134,7 +151,8 @@ const configSchema = z.object({
.object({ .object({
require_email_verification: z.boolean().optional(), require_email_verification: z.boolean().optional(),
disable_signup_without_invite: 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() .optional()
}); });
@ -237,10 +255,12 @@ export class Config {
?.require_email_verification ?.require_email_verification
? "true" ? "true"
: "false"; : "false";
process.env.FLAGS_ALLOW_RAW_RESOURCES = parsedConfig.data.flags
?.allow_raw_resources
? "true"
: "false";
process.env.SESSION_COOKIE_NAME = process.env.SESSION_COOKIE_NAME =
parsedConfig.data.server.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.EMAIL_ENABLED = parsedConfig.data.email ? "true" : "false";
process.env.DISABLE_SIGNUP_WITHOUT_INVITE = parsedConfig.data.flags process.env.DISABLE_SIGNUP_WITHOUT_INVITE = parsedConfig.data.flags
?.disable_signup_without_invite ?.disable_signup_without_invite
@ -252,6 +272,8 @@ export class Config {
: "false"; : "false";
process.env.RESOURCE_ACCESS_TOKEN_PARAM = process.env.RESOURCE_ACCESS_TOKEN_PARAM =
parsedConfig.data.server.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; this.rawConfig = parsedConfig.data;
} }
@ -264,6 +286,12 @@ export class Config {
return this.rawConfig.app.base_domain; return this.rawConfig.app.base_domain;
} }
public getNoReplyEmail(): string | undefined {
return (
this.rawConfig.email?.no_reply || this.rawConfig.email?.smtp_user
);
}
private createTraefikConfig() { private createTraefikConfig() {
try { try {
// check if traefik_config.yml and dynamic_config.yml exists in APP_PATH/traefik // check if traefik_config.yml and dynamic_config.yml exists in APP_PATH/traefik

View file

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

View file

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

View file

@ -79,6 +79,11 @@ export async function disable2fa(
); );
if (!validOTP) { 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( return next(
createHttpError( createHttpError(
HttpCode.BAD_REQUEST, HttpCode.BAD_REQUEST,

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -96,6 +96,11 @@ export async function verifyTotp(
} }
if (!valid) { 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( return next(
createHttpError( createHttpError(
HttpCode.BAD_REQUEST, HttpCode.BAD_REQUEST,

View file

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

View file

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

View file

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

View file

@ -1,9 +1,12 @@
import { Router } from "express"; import { Router } from "express";
import * as gerbil from "@server/routers/gerbil"; import * as gerbil from "@server/routers/gerbil";
import * as badger from "@server/routers/badger";
import * as traefik from "@server/routers/traefik"; import * as traefik from "@server/routers/traefik";
import * as auth from "@server/routers/auth"; import * as auth from "@server/routers/auth";
import HttpCode from "@server/types/HttpCode"; 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 // Root routes
const internalRouter = Router(); const internalRouter = Router();
@ -13,9 +16,17 @@ internalRouter.get("/", (_, res) => {
}); });
internalRouter.get("/traefik-config", traefik.traefikConfigProvider); internalRouter.get("/traefik-config", traefik.traefikConfigProvider);
internalRouter.get( internalRouter.get(
"/resource-session/:resourceId/:token", "/resource-session/:resourceId/:token",
auth.checkResourceSession, auth.checkResourceSession
);
internalRouter.post(
`/resource/:resourceId/get-exchange-token`,
verifySessionUserMiddleware,
verifyResourceAccess,
getExchangeToken
); );
// Gerbil routes // Gerbil routes
@ -29,6 +40,7 @@ gerbilRouter.post("/receive-bandwidth", gerbil.receiveBandwidth);
const badgerRouter = Router(); const badgerRouter = Router();
internalRouter.use("/badger", badgerRouter); internalRouter.use("/badger", badgerRouter);
badgerRouter.post("/verify-session", badger.verifyResourceSession); badgerRouter.post("/verify-session", verifyResourceSession);
badgerRouter.post("/exchange-session", exchangeSession);
export default internalRouter; export default internalRouter;

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -3,7 +3,6 @@ import db from "@server/db";
import { import {
orgs, orgs,
resourceOtp, resourceOtp,
resourcePassword,
resources, resources,
resourceWhitelist resourceWhitelist
} from "@server/db/schema"; } from "@server/db/schema";
@ -14,17 +13,17 @@ import { NextFunction, Request, Response } from "express";
import createHttpError from "http-errors"; import createHttpError from "http-errors";
import { z } from "zod"; import { z } from "zod";
import { fromError } from "zod-validation-error"; import { fromError } from "zod-validation-error";
import { import { createResourceSession } from "@server/auth/sessions/resource";
createResourceSession,
serializeResourceSessionCookie
} from "@server/auth/sessions/resource";
import config from "@server/lib/config";
import { isValidOtp, sendResourceOtpEmail } from "@server/auth/resourceOtp"; import { isValidOtp, sendResourceOtpEmail } from "@server/auth/resourceOtp";
import logger from "@server/logger"; import logger from "@server/logger";
import config from "@server/lib/config";
const authWithWhitelistBodySchema = z const authWithWhitelistBodySchema = z
.object({ .object({
email: z.string().email(), email: z
.string()
.email()
.transform((v) => v.toLowerCase()),
otp: z.string().optional() otp: z.string().optional()
}) })
.strict(); .strict();
@ -90,11 +89,43 @@ export async function authWithWhitelist(
.leftJoin(orgs, eq(orgs.orgId, resources.orgId)) .leftJoin(orgs, eq(orgs.orgId, resources.orgId))
.limit(1); .limit(1);
const resource = result?.resources; let resource = result?.resources;
const org = result?.orgs; let org = result?.orgs;
const whitelistedEmail = result?.resourceWhitelist; let whitelistedEmail = result?.resourceWhitelist;
if (!whitelistedEmail) { if (!whitelistedEmail) {
// 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( return next(
createHttpError( createHttpError(
HttpCode.UNAUTHORIZED, HttpCode.UNAUTHORIZED,
@ -105,6 +136,7 @@ export async function authWithWhitelist(
) )
); );
} }
}
if (!org) { if (!org) {
return next( return next(
@ -125,6 +157,11 @@ export async function authWithWhitelist(
otp otp
); );
if (!isValidCode) { 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( return next(
createHttpError(HttpCode.UNAUTHORIZED, "Incorrect OTP") createHttpError(HttpCode.UNAUTHORIZED, "Incorrect OTP")
); );
@ -175,11 +212,12 @@ export async function authWithWhitelist(
await createResourceSession({ await createResourceSession({
resourceId, resourceId,
token, token,
whitelistId: whitelistedEmail.whitelistId whitelistId: whitelistedEmail.whitelistId,
isRequestToken: true,
expiresAt: Date.now() + 1000 * 30, // 30 seconds
sessionLength: 1000 * 30,
doNotExtend: true
}); });
const cookieName = `${config.getRawConfig().server.resource_session_cookie_name}_${resource.resourceId}`;
const cookie = serializeResourceSessionCookie(cookieName, token);
res.appendHeader("Set-Cookie", cookie);
return response<AuthWithWhitelistResponse>(res, { return response<AuthWithWhitelistResponse>(res, {
data: { data: {

View file

@ -16,8 +16,8 @@ import createHttpError from "http-errors";
import { eq, and } from "drizzle-orm"; import { eq, and } from "drizzle-orm";
import stoi from "@server/lib/stoi"; import stoi from "@server/lib/stoi";
import { fromError } from "zod-validation-error"; import { fromError } from "zod-validation-error";
import { subdomainSchema } from "@server/schemas/subdomainSchema";
import logger from "@server/logger"; import logger from "@server/logger";
import { subdomainSchema } from "@server/schemas/subdomainSchema";
const createResourceParamsSchema = z const createResourceParamsSchema = z
.object({ .object({
@ -28,10 +28,42 @@ const createResourceParamsSchema = z
const createResourceSchema = z const createResourceSchema = z
.object({ .object({
subdomain: z.string().optional(),
name: z.string().min(1).max(255), 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; 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 // Validate request params
const parsedParams = createResourceParamsSchema.safeParse(req.params); const parsedParams = createResourceParamsSchema.safeParse(req.params);
@ -89,15 +121,64 @@ export async function createResource(
} }
const fullDomain = `${subdomain}.${org[0].domain}`; 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) => { await db.transaction(async (trx) => {
const newResource = await trx const newResource = await trx
.insert(resources) .insert(resources)
.values({ .values({
siteId, siteId,
fullDomain, fullDomain: http ? fullDomain : null,
orgId, orgId,
name, name,
subdomain, subdomain,
http,
protocol,
proxyPort,
ssl: true ssl: true
}) })
.returning(); .returning();
@ -135,18 +216,6 @@ export async function createResource(
}); });
}); });
} catch (error) { } 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); logger.error(error);
return next( return next(
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")

View file

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

View file

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

View file

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

View file

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

View file

@ -11,7 +11,20 @@ import { and, eq } from "drizzle-orm";
const setResourceWhitelistBodySchema = z const setResourceWhitelistBodySchema = z
.object({ .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(); .strict();

View file

@ -26,8 +26,8 @@ const updateResourceBodySchema = z
ssl: z.boolean().optional(), ssl: z.boolean().optional(),
sso: z.boolean().optional(), sso: z.boolean().optional(),
blockAccess: z.boolean().optional(), blockAccess: z.boolean().optional(),
proxyPort: z.number().int().min(1).max(65535).optional(),
emailWhitelistEnabled: z.boolean().optional() emailWhitelistEnabled: z.boolean().optional()
// siteId: z.number(),
}) })
.strict() .strict()
.refine((data) => Object.keys(data).length > 0, { .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, { return response(res, {
data: updatedResource[0], data: updatedResource[0],
success: true, success: true,

View file

@ -53,9 +53,8 @@ const createTargetParamsSchema = z
const createTargetSchema = z const createTargetSchema = z
.object({ .object({
ip: domainSchema, ip: domainSchema,
method: z.string().min(1).max(10), method: z.string().optional().nullable(),
port: z.number().int().min(1).max(65535), port: z.number().int().min(1).max(65535),
protocol: z.string().optional(),
enabled: z.boolean().default(true) enabled: z.boolean().default(true)
}) })
.strict(); .strict();
@ -94,9 +93,7 @@ export async function createTarget(
// get the resource // get the resource
const [resource] = await db const [resource] = await db
.select({ .select()
siteId: resources.siteId
})
.from(resources) .from(resources)
.where(eq(resources.resourceId, resourceId)); .where(eq(resources.resourceId, resourceId));
@ -130,7 +127,6 @@ export async function createTarget(
.insert(targets) .insert(targets)
.values({ .values({
resourceId, resourceId,
protocol: "tcp", // hard code for now
...targetData ...targetData
}) })
.returning(); .returning();
@ -163,7 +159,6 @@ export async function createTarget(
.insert(targets) .insert(targets)
.values({ .values({
resourceId, resourceId,
protocol: "tcp", // hard code for now
internalPort, internalPort,
...targetData ...targetData
}) })
@ -186,7 +181,7 @@ export async function createTarget(
.where(eq(newts.siteId, site.siteId)) .where(eq(newts.siteId, site.siteId))
.limit(1); .limit(1);
addTargets(newt.newtId, newTarget); addTargets(newt.newtId, newTarget, resource.protocol);
} }
} }
} }

View file

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

View file

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

View file

@ -49,7 +49,7 @@ const updateTargetParamsSchema = z
const updateTargetBodySchema = z const updateTargetBodySchema = z
.object({ .object({
ip: domainSchema.optional(), 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(), port: z.number().int().min(1).max(65535).optional(),
enabled: z.boolean().optional() enabled: z.boolean().optional()
}) })
@ -103,9 +103,7 @@ export async function updateTarget(
// get the resource // get the resource
const [resource] = await db const [resource] = await db
.select({ .select()
siteId: resources.siteId
})
.from(resources) .from(resources)
.where(eq(resources.resourceId, target.resourceId!)); .where(eq(resources.resourceId, target.resourceId!));
@ -167,7 +165,7 @@ export async function updateTarget(
.where(eq(newts.siteId, site.siteId)) .where(eq(newts.siteId, site.siteId))
.limit(1); .limit(1);
addTargets(newt.newtId, [updatedTarget]); addTargets(newt.newtId, [updatedTarget], resource.protocol);
} }
} }
return response(res, { return response(res, {

View file

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

View file

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

View file

@ -11,7 +11,7 @@ import m2 from "./scripts/1.0.0-beta2";
import m3 from "./scripts/1.0.0-beta3"; import m3 from "./scripts/1.0.0-beta3";
import m4 from "./scripts/1.0.0-beta5"; import m4 from "./scripts/1.0.0-beta5";
import m5 from "./scripts/1.0.0-beta6"; 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 // THIS CANNOT IMPORT ANYTHING FROM THE SERVER
// EXCEPT FOR THE DATABASE AND THE SCHEMA // 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.2", run: m2 },
{ version: "1.0.0-beta.3", run: m3 }, { version: "1.0.0-beta.3", run: m3 },
{ version: "1.0.0-beta.5", run: m4 }, { 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 // Add new migrations here as they are created
] as const; ] as const;
@ -30,6 +31,7 @@ const migrations = [
await runMigrations(); await runMigrations();
export async function runMigrations() { export async function runMigrations() {
try {
const appVersion = loadAppVersion(); const appVersion = loadAppVersion();
if (!appVersion) { if (!appVersion) {
throw new Error("APP_VERSION is not set in the environment"); throw new Error("APP_VERSION is not set in the environment");
@ -56,6 +58,10 @@ export async function runMigrations() {
}) })
.execute(); .execute();
} }
} catch (e) {
console.error("Error running migrations:", e);
await new Promise((resolve) => setTimeout(resolve, 1000 * 60 * 60 * 24 * 1));
}
} }
async function executeScripts() { async function executeScripts() {

View file

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

View file

@ -17,7 +17,7 @@ import { useOrgContext } from "@app/hooks/useOrgContext";
import { useToast } from "@app/hooks/useToast"; import { useToast } from "@app/hooks/useToast";
import Link from "next/link"; import Link from "next/link";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { formatAxiosError } from "@app/lib/api";; import { formatAxiosError } from "@app/lib/api";
import { createApiClient } from "@app/lib/api"; import { createApiClient } from "@app/lib/api";
import { useEnvContext } from "@app/hooks/useEnvContext"; import { useEnvContext } from "@app/hooks/useEnvContext";
import { useUserContext } from "@app/hooks/useUserContext"; import { useUserContext } from "@app/hooks/useUserContext";
@ -75,14 +75,14 @@ export default function UsersTable({ users: u }: UsersTableProps) {
</Button> </Button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent align="end"> <DropdownMenuContent align="end">
<DropdownMenuItem>
<Link <Link
href={`/${org?.org.orgId}/settings/access/users/${userRow.id}`} href={`/${org?.org.orgId}/settings/access/users/${userRow.id}`}
className="block w-full" className="block w-full"
> >
<DropdownMenuItem>
Manage User Manage User
</Link>
</DropdownMenuItem> </DropdownMenuItem>
</Link>
{userRow.email !== user?.email && ( {userRow.email !== user?.email && (
<DropdownMenuItem <DropdownMenuItem
onClick={() => { onClick={() => {

View file

@ -45,21 +45,64 @@ import {
} from "@app/components/ui/command"; } from "@app/components/ui/command";
import { CaretSortIcon } from "@radix-ui/react-icons"; import { CaretSortIcon } from "@radix-ui/react-icons";
import CustomDomainInput from "./[resourceId]/CustomDomainInput"; import CustomDomainInput from "./[resourceId]/CustomDomainInput";
import { Axios, AxiosResponse } from "axios"; import { AxiosResponse } from "axios";
import { Resource } from "@server/db/schema"; import { Resource } from "@server/db/schema";
import { useOrgContext } from "@app/hooks/useOrgContext"; import { useOrgContext } from "@app/hooks/useOrgContext";
import { subdomainSchema } from "@server/schemas/subdomainSchema";
import { createApiClient } from "@app/lib/api"; import { createApiClient } from "@app/lib/api";
import { useEnvContext } from "@app/hooks/useEnvContext"; import { useEnvContext } from "@app/hooks/useEnvContext";
import { cn } from "@app/lib/cn"; 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({ const createResourceFormSchema = z
subdomain: subdomainSchema, .object({
name: z.string(), subdomain: z.string().optional(),
siteId: z.number() 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<typeof accountFormSchema>; type CreateResourceFormValues = z.infer<typeof createResourceFormSchema>;
type CreateResourceFormProps = { type CreateResourceFormProps = {
open: boolean; open: boolean;
@ -81,15 +124,18 @@ export default function CreateResourceForm({
const router = useRouter(); const router = useRouter();
const { org } = useOrgContext(); const { org } = useOrgContext();
const { env } = useEnvContext();
const [sites, setSites] = useState<ListSitesResponse["sites"]>([]); const [sites, setSites] = useState<ListSitesResponse["sites"]>([]);
const [domainSuffix, setDomainSuffix] = useState<string>(org.org.domain); const [domainSuffix, setDomainSuffix] = useState<string>(org.org.domain);
const form = useForm<AccountFormValues>({ const form = useForm<CreateResourceFormValues>({
resolver: zodResolver(accountFormSchema), resolver: zodResolver(createResourceFormSchema),
defaultValues: { defaultValues: {
subdomain: "", subdomain: "",
name: "My Resource" name: "My Resource",
http: true,
protocol: "tcp"
} }
}); });
@ -112,7 +158,7 @@ export default function CreateResourceForm({
fetchSites(); fetchSites();
}, [open]); }, [open]);
async function onSubmit(data: AccountFormValues) { async function onSubmit(data: CreateResourceFormValues) {
console.log(data); console.log(data);
const res = await api const res = await api
@ -120,8 +166,11 @@ export default function CreateResourceForm({
`/org/${orgId}/site/${data.siteId}/resource/`, `/org/${orgId}/site/${data.siteId}/resource/`,
{ {
name: data.name, name: data.name,
subdomain: data.subdomain subdomain: data.http ? data.subdomain : undefined,
// subdomain: data.subdomain, http: data.http,
protocol: data.protocol,
proxyPort: data.http ? undefined : data.proxyPort,
siteId: data.siteId
} }
) )
.catch((e) => { .catch((e) => {
@ -188,6 +237,37 @@ export default function CreateResourceForm({
</FormItem> </FormItem>
)} )}
/> />
{!env.flags.allowRawResources || (
<FormField
control={form.control}
name="http"
render={({ field }) => (
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-4">
<div className="space-y-0.5">
<FormLabel className="text-base">
HTTP Resource
</FormLabel>
<FormDescription>
Toggle if this is an
HTTP resource or a raw
TCP/UDP resource
</FormDescription>
</div>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={
field.onChange
}
/>
</FormControl>
</FormItem>
)}
/>
)}
{form.watch("http") && (
<FormField <FormField
control={form.control} control={form.control}
name="subdomain" name="subdomain"
@ -196,8 +276,12 @@ export default function CreateResourceForm({
<FormLabel>Subdomain</FormLabel> <FormLabel>Subdomain</FormLabel>
<FormControl> <FormControl>
<CustomDomainInput <CustomDomainInput
value={field.value} value={
domainSuffix={domainSuffix} field.value ?? ""
}
domainSuffix={
domainSuffix
}
placeholder="Enter subdomain" placeholder="Enter subdomain"
onChange={(value) => onChange={(value) =>
form.setValue( form.setValue(
@ -209,13 +293,109 @@ export default function CreateResourceForm({
</FormControl> </FormControl>
<FormDescription> <FormDescription>
This is the fully qualified This is the fully qualified
domain name that will be used to domain name that will be
access the resource. used to access the resource.
</FormDescription> </FormDescription>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
)} )}
/> />
)}
{!form.watch("http") && (
<Link
className="text-sm text-primary flex items-center gap-1"
href="https://docs.fossorial.io/Getting%20Started/tcp-udp"
target="_blank"
rel="noopener noreferrer"
>
<span>
Learn how to configure TCP/UDP resources
</span>
<SquareArrowOutUpRight size={14} />
</Link>
)}
{!form.watch("http") && (
<>
<FormField
control={form.control}
name="protocol"
render={({ field }) => (
<FormItem>
<FormLabel>
Protocol
</FormLabel>
<Select
value={field.value}
onValueChange={
field.onChange
}
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select a protocol" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="tcp">
TCP
</SelectItem>
<SelectItem value="udp">
UDP
</SelectItem>
</SelectContent>
</Select>
<FormDescription>
The protocol to use for
the resource
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="proxyPort"
render={({ field }) => (
<FormItem>
<FormLabel>
Port Number
</FormLabel>
<FormControl>
<Input
type="number"
placeholder="Enter port number"
value={
field.value ??
""
}
onChange={(e) =>
field.onChange(
e.target
.value
? parseInt(
e
.target
.value
)
: null
)
}
/>
</FormControl>
<FormDescription>
The port number to proxy
requests to (required
for non-HTTP resources)
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</>
)}
<FormField <FormField
control={form.control} control={form.control}
name="siteId" name="siteId"
@ -257,7 +437,7 @@ export default function CreateResourceForm({
(site) => ( (site) => (
<CommandItem <CommandItem
value={ value={
site.name site.niceId
} }
key={ key={
site.siteId site.siteId

View file

@ -25,7 +25,7 @@ import CreateResourceForm from "./CreateResourceForm";
import { useState } from "react"; import { useState } from "react";
import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog"; import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog";
import { set } from "zod"; import { set } from "zod";
import { formatAxiosError } from "@app/lib/api";; import { formatAxiosError } from "@app/lib/api";
import { useToast } from "@app/hooks/useToast"; import { useToast } from "@app/hooks/useToast";
import { createApiClient } from "@app/lib/api"; import { createApiClient } from "@app/lib/api";
import { useEnvContext } from "@app/hooks/useEnvContext"; import { useEnvContext } from "@app/hooks/useEnvContext";
@ -39,6 +39,9 @@ export type ResourceRow = {
site: string; site: string;
siteId: string; siteId: string;
hasAuth: boolean; hasAuth: boolean;
http: boolean;
protocol: string;
proxyPort: number | null;
}; };
type ResourcesTableProps = { type ResourcesTableProps = {
@ -91,14 +94,14 @@ export default function SitesTable({ resources, orgId }: ResourcesTableProps) {
</Button> </Button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent align="end"> <DropdownMenuContent align="end">
<DropdownMenuItem>
<Link <Link
className="block w-full" className="block w-full"
href={`/${resourceRow.orgId}/settings/resources/${resourceRow.id}`} href={`/${resourceRow.orgId}/settings/resources/${resourceRow.id}`}
> >
<DropdownMenuItem>
View settings View settings
</Link>
</DropdownMenuItem> </DropdownMenuItem>
</Link>
<DropdownMenuItem <DropdownMenuItem
onClick={() => { onClick={() => {
setSelectedResource(resourceRow); setSelectedResource(resourceRow);
@ -146,24 +149,40 @@ export default function SitesTable({ resources, orgId }: ResourcesTableProps) {
cell: ({ row }) => { cell: ({ row }) => {
const resourceRow = row.original; const resourceRow = row.original;
return ( return (
<Button variant="outline">
<Link <Link
href={`/${resourceRow.orgId}/settings/sites/${resourceRow.siteId}`} href={`/${resourceRow.orgId}/settings/sites/${resourceRow.siteId}`}
> >
<Button variant="outline">
{resourceRow.site} {resourceRow.site}
</Link>
<ArrowUpRight className="ml-2 h-4 w-4" /> <ArrowUpRight className="ml-2 h-4 w-4" />
</Button> </Button>
</Link>
);
}
},
{
accessorKey: "protocol",
header: "Protocol",
cell: ({ row }) => {
const resourceRow = row.original;
return (
<span>{resourceRow.protocol.toUpperCase()}</span>
); );
} }
}, },
{ {
accessorKey: "domain", accessorKey: "domain",
header: "Full URL", header: "Access",
cell: ({ row }) => { cell: ({ row }) => {
const resourceRow = row.original; const resourceRow = row.original;
return ( return (
<div>
{!resourceRow.http ? (
<CopyToClipboard text={resourceRow.proxyPort!.toString()} isLink={false} />
) : (
<CopyToClipboard text={resourceRow.domain} isLink={true} /> <CopyToClipboard text={resourceRow.domain} isLink={true} />
)}
</div>
); );
} }
}, },
@ -186,7 +205,12 @@ export default function SitesTable({ resources, orgId }: ResourcesTableProps) {
const resourceRow = row.original; const resourceRow = row.original;
return ( return (
<div> <div>
{resourceRow.hasAuth ? (
{!resourceRow.http ? (
<span>--</span>
) :
resourceRow.hasAuth ? (
<span className="text-green-500 flex items-center space-x-2"> <span className="text-green-500 flex items-center space-x-2">
<ShieldCheck className="w-4 h-4" /> <ShieldCheck className="w-4 h-4" />
<span>Protected</span> <span>Protected</span>
@ -196,7 +220,8 @@ export default function SitesTable({ resources, orgId }: ResourcesTableProps) {
<ShieldOff className="w-4 h-4" /> <ShieldOff className="w-4 h-4" />
<span>Not Protected</span> <span>Not Protected</span>
</span> </span>
)} )
}
</div> </div>
); );
} }

View file

@ -2,12 +2,8 @@
import { useState } from "react"; import { useState } from "react";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { Button } from "@/components/ui/button";
import { import {
InfoIcon, InfoIcon,
LinkIcon,
CheckIcon,
CopyIcon,
ShieldCheck, ShieldCheck,
ShieldOff ShieldOff
} from "lucide-react"; } from "lucide-react";
@ -42,8 +38,12 @@ export default function ResourceInfoBox({}: ResourceInfoBoxType) {
</AlertTitle> </AlertTitle>
<AlertDescription className="mt-4"> <AlertDescription className="mt-4">
<InfoSections> <InfoSections>
{resource.http ? (
<>
<InfoSection> <InfoSection>
<InfoSectionTitle>Authentication</InfoSectionTitle> <InfoSectionTitle>
Authentication
</InfoSectionTitle>
<InfoSectionContent> <InfoSectionContent>
{authInfo.password || {authInfo.password ||
authInfo.pincode || authInfo.pincode ||
@ -52,8 +52,8 @@ export default function ResourceInfoBox({}: ResourceInfoBoxType) {
<div className="flex items-start space-x-2 text-green-500"> <div className="flex items-start space-x-2 text-green-500">
<ShieldCheck className="w-4 h-4 mt-0.5" /> <ShieldCheck className="w-4 h-4 mt-0.5" />
<span> <span>
This resource is protected with at least This resource is protected with
one auth method. at least one auth method.
</span> </span>
</div> </div>
) : ( ) : (
@ -70,9 +70,33 @@ export default function ResourceInfoBox({}: ResourceInfoBoxType) {
<InfoSection> <InfoSection>
<InfoSectionTitle>URL</InfoSectionTitle> <InfoSectionTitle>URL</InfoSectionTitle>
<InfoSectionContent> <InfoSectionContent>
<CopyToClipboard text={fullUrl} isLink={true} /> <CopyToClipboard
text={fullUrl}
isLink={true}
/>
</InfoSectionContent> </InfoSectionContent>
</InfoSection> </InfoSection>
</>
) : (
<>
<InfoSection>
<InfoSectionTitle>Protocol</InfoSectionTitle>
<InfoSectionContent>
<span>{resource.protocol.toUpperCase()}</span>
</InfoSectionContent>
</InfoSection>
<Separator orientation="vertical" />
<InfoSection>
<InfoSectionTitle>Port</InfoSectionTitle>
<InfoSectionContent>
<CopyToClipboard
text={resource.proxyPort!.toString()}
isLink={false}
/>
</InfoSectionContent>
</InfoSection>
</>
)}
</InfoSections> </InfoSections>
</AlertDescription> </AlertDescription>
</Alert> </Alert>

View file

@ -48,6 +48,7 @@ import {
SettingsSectionFooter SettingsSectionFooter
} from "@app/components/Settings"; } from "@app/components/Settings";
import { SwitchInput } from "@app/components/SwitchInput"; import { SwitchInput } from "@app/components/SwitchInput";
import { InfoPopup } from "@app/components/ui/info-popup";
const UsersRolesFormSchema = z.object({ const UsersRolesFormSchema = z.object({
roles: z.array( roles: z.array(
@ -665,10 +666,12 @@ export default function ResourceAuthenticationPage() {
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel> <FormLabel>
Whitelisted Emails <InfoPopup
text="Whitelisted Emails"
info="Only users with these email addresses will be able to access this resource. They will be prompted to enter a one-time password sent to their email. Wildcards (*@example.com) can be used to allow any email address from a domain."
/>
</FormLabel> </FormLabel>
<FormControl> <FormControl>
{/* @ts-ignore */}
{/* @ts-ignore */} {/* @ts-ignore */}
<TagInput <TagInput
{...field} {...field}
@ -681,6 +684,17 @@ export default function ResourceAuthenticationPage() {
return z return z
.string() .string()
.email() .email()
.or(
z
.string()
.regex(
/^\*@[\w.-]+\.[a-zA-Z]{2,}$/,
{
message:
"Invalid email address. Wildcard (*) must be the entire local part."
}
)
)
.safeParse( .safeParse(
tag tag
).success; ).success;

View file

@ -63,6 +63,7 @@ import {
} from "@app/components/Settings"; } from "@app/components/Settings";
import { SwitchInput } from "@app/components/SwitchInput"; import { SwitchInput } from "@app/components/SwitchInput";
import { useSiteContext } from "@app/hooks/useSiteContext"; import { useSiteContext } from "@app/hooks/useSiteContext";
import { InfoPopup } from "@app/components/ui/info-popup";
// Regular expressions for validation // Regular expressions for validation
const DOMAIN_REGEX = const DOMAIN_REGEX =
@ -94,7 +95,7 @@ const domainSchema = z
const addTargetSchema = z.object({ const addTargetSchema = z.object({
ip: domainSchema, ip: domainSchema,
method: z.string(), method: z.string().nullable(),
port: z.coerce.number().int().positive() port: z.coerce.number().int().positive()
// protocol: z.string(), // protocol: z.string(),
}); });
@ -129,9 +130,9 @@ export default function ReverseProxyTargets(props: {
const addTargetForm = useForm({ const addTargetForm = useForm({
resolver: zodResolver(addTargetSchema), resolver: zodResolver(addTargetSchema),
defaultValues: { defaultValues: {
ip: "", ip: "localhost",
method: "http", method: resource.http ? "http" : null,
port: 80 port: resource.http ? 80 : resource.proxyPort || 1234
// protocol: "TCP", // protocol: "TCP",
} }
}); });
@ -321,7 +322,7 @@ export default function ReverseProxyTargets(props: {
}); });
setSslEnabled(val); setSslEnabled(val);
updateResource({ ssl: sslEnabled }); updateResource({ ssl: val });
toast({ toast({
title: "SSL Configuration", title: "SSL Configuration",
@ -330,26 +331,6 @@ export default function ReverseProxyTargets(props: {
} }
const columns: ColumnDef<LocalTarget>[] = [ const columns: ColumnDef<LocalTarget>[] = [
{
accessorKey: "method",
header: "Method",
cell: ({ row }) => (
<Select
defaultValue={row.original.method}
onValueChange={(value) =>
updateTarget(row.original.targetId, { method: value })
}
>
<SelectTrigger className="min-w-[100px]">
{row.original.method}
</SelectTrigger>
<SelectContent>
<SelectItem value="http">http</SelectItem>
<SelectItem value="https">https</SelectItem>
</SelectContent>
</Select>
)
},
{ {
accessorKey: "ip", accessorKey: "ip",
header: "IP / Hostname", header: "IP / Hostname",
@ -436,6 +417,32 @@ export default function ReverseProxyTargets(props: {
} }
]; ];
if (resource.http) {
const methodCol: ColumnDef<LocalTarget> = {
accessorKey: "method",
header: "Method",
cell: ({ row }) => (
<Select
defaultValue={row.original.method ?? ""}
onValueChange={(value) =>
updateTarget(row.original.targetId, { method: value })
}
>
<SelectTrigger className="min-w-[100px]">
{row.original.method}
</SelectTrigger>
<SelectContent>
<SelectItem value="http">http</SelectItem>
<SelectItem value="https">https</SelectItem>
</SelectContent>
</Select>
)
};
// add this to the first column
columns.unshift(methodCol);
}
const table = useReactTable({ const table = useReactTable({
data: targets, data: targets,
columns, columns,
@ -451,15 +458,15 @@ export default function ReverseProxyTargets(props: {
return ( return (
<SettingsContainer> <SettingsContainer>
{/* SSL Section */} {resource.http && (
<SettingsSection> <SettingsSection>
<SettingsSectionHeader> <SettingsSectionHeader>
<SettingsSectionTitle> <SettingsSectionTitle>
SSL Configuration SSL Configuration
</SettingsSectionTitle> </SettingsSectionTitle>
<SettingsSectionDescription> <SettingsSectionDescription>
Setup SSL to secure your connections with LetsEncrypt Setup SSL to secure your connections with
certificates LetsEncrypt certificates
</SettingsSectionDescription> </SettingsSectionDescription>
</SettingsSectionHeader> </SettingsSectionHeader>
<SettingsSectionBody> <SettingsSectionBody>
@ -473,7 +480,7 @@ export default function ReverseProxyTargets(props: {
/> />
</SettingsSectionBody> </SettingsSectionBody>
</SettingsSection> </SettingsSection>
)}
{/* Targets Section */} {/* Targets Section */}
<SettingsSection> <SettingsSection>
<SettingsSectionHeader> <SettingsSectionHeader>
@ -491,6 +498,7 @@ export default function ReverseProxyTargets(props: {
className="space-y-4" className="space-y-4"
> >
<div className="grid grid-cols-2 md:grid-cols-3 gap-4"> <div className="grid grid-cols-2 md:grid-cols-3 gap-4">
{resource.http && (
<FormField <FormField
control={addTargetForm.control} control={addTargetForm.control}
name="method" name="method"
@ -499,8 +507,13 @@ export default function ReverseProxyTargets(props: {
<FormLabel>Method</FormLabel> <FormLabel>Method</FormLabel>
<FormControl> <FormControl>
<Select <Select
{...field} value={
onValueChange={(value) => { field.value ||
undefined
}
onValueChange={(
value
) => {
addTargetForm.setValue( addTargetForm.setValue(
"method", "method",
value value
@ -524,6 +537,8 @@ export default function ReverseProxyTargets(props: {
</FormItem> </FormItem>
)} )}
/> />
)}
<FormField <FormField
control={addTargetForm.control} control={addTargetForm.control}
name="ip" name="ip"
@ -637,6 +652,9 @@ export default function ReverseProxyTargets(props: {
</TableBody> </TableBody>
</Table> </Table>
</TableContainer> </TableContainer>
<p className="text-sm text-muted-foreground">
Adding more than one target above will enable load balancing.
</p>
</SettingsSectionBody> </SettingsSectionBody>
<SettingsSectionFooter> <SettingsSectionFooter>
<Button <Button

View file

@ -13,22 +13,7 @@ import {
FormLabel, FormLabel,
FormMessage FormMessage
} from "@/components/ui/form"; } from "@/components/ui/form";
import { CaretSortIcon, CheckIcon } from "@radix-ui/react-icons";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList
} from "@/components/ui/command";
import {
Popover,
PopoverContent,
PopoverTrigger
} from "@/components/ui/popover";
import { useResourceContext } from "@app/hooks/useResourceContext"; import { useResourceContext } from "@app/hooks/useResourceContext";
import { ListSitesResponse } from "@server/routers/site"; import { ListSitesResponse } from "@server/routers/site";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
@ -49,16 +34,46 @@ import {
} from "@app/components/Settings"; } from "@app/components/Settings";
import { useOrgContext } from "@app/hooks/useOrgContext"; import { useOrgContext } from "@app/hooks/useOrgContext";
import CustomDomainInput from "../CustomDomainInput"; import CustomDomainInput from "../CustomDomainInput";
import ResourceInfoBox from "../ResourceInfoBox";
import { subdomainSchema } from "@server/schemas/subdomainSchema";
import { createApiClient } from "@app/lib/api"; import { createApiClient } from "@app/lib/api";
import { useEnvContext } from "@app/hooks/useEnvContext"; import { useEnvContext } from "@app/hooks/useEnvContext";
import { subdomainSchema } from "@server/schemas/subdomainSchema";
const GeneralFormSchema = z.object({ const GeneralFormSchema = z
name: z.string(), .object({
subdomain: subdomainSchema subdomain: z.string().optional(),
// siteId: z.number(), name: z.string().min(1).max(255),
}); proxyPort: z.number().optional(),
http: z.boolean()
})
.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 GeneralFormValues = z.infer<typeof GeneralFormSchema>; type GeneralFormValues = z.infer<typeof GeneralFormSchema>;
@ -81,8 +96,9 @@ export default function GeneralForm() {
resolver: zodResolver(GeneralFormSchema), resolver: zodResolver(GeneralFormSchema),
defaultValues: { defaultValues: {
name: resource.name, name: resource.name,
subdomain: resource.subdomain subdomain: resource.subdomain ? resource.subdomain : undefined,
// siteId: resource.siteId!, proxyPort: resource.proxyPort ? resource.proxyPort : undefined,
http: resource.http
}, },
mode: "onChange" mode: "onChange"
}); });
@ -169,6 +185,7 @@ export default function GeneralForm() {
)} )}
/> />
{resource.http ? (
<FormField <FormField
control={form.control} control={form.control}
name="subdomain" name="subdomain"
@ -177,8 +194,12 @@ export default function GeneralForm() {
<FormLabel>Subdomain</FormLabel> <FormLabel>Subdomain</FormLabel>
<FormControl> <FormControl>
<CustomDomainInput <CustomDomainInput
value={field.value} value={
domainSuffix={domainSuffix} field.value || ""
}
domainSuffix={
domainSuffix
}
placeholder="Enter subdomain" placeholder="Enter subdomain"
onChange={(value) => onChange={(value) =>
form.setValue( form.setValue(
@ -189,13 +210,53 @@ export default function GeneralForm() {
/> />
</FormControl> </FormControl>
<FormDescription> <FormDescription>
This is the subdomain that will This is the subdomain that
be used to access the resource. will be used to access the
resource.
</FormDescription> </FormDescription>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
)} )}
/> />
) : (
<FormField
control={form.control}
name="proxyPort"
render={({ field }) => (
<FormItem>
<FormLabel>
Port Number
</FormLabel>
<FormControl>
<Input
type="number"
placeholder="Enter port number"
value={
field.value ?? ""
}
onChange={(e) =>
field.onChange(
e.target.value
? parseInt(
e
.target
.value
)
: null
)
}
/>
</FormControl>
<FormDescription>
This is the port that will
be used to access the
resource.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
)}
</form> </form>
</Form> </Form>
</SettingsSectionForm> </SettingsSectionForm>

View file

@ -90,13 +90,16 @@ export default async function ResourceLayout(props: ResourceLayoutProps) {
title: "Connectivity", title: "Connectivity",
href: `/{orgId}/settings/resources/{resourceId}/connectivity` href: `/{orgId}/settings/resources/{resourceId}/connectivity`
// icon: <Cloud className="w-4 h-4" />, // icon: <Cloud className="w-4 h-4" />,
}, }
{ ];
if (resource.http) {
sidebarNavItems.push({
title: "Authentication", title: "Authentication",
href: `/{orgId}/settings/resources/{resourceId}/authentication` href: `/{orgId}/settings/resources/{resourceId}/authentication`
// icon: <Shield className="w-4 h-4" />, // icon: <Shield className="w-4 h-4" />,
});
} }
];
return ( return (
<> <>

View file

@ -53,6 +53,9 @@ export default async function ResourcesPage(props: ResourcesPageProps) {
domain: `${resource.ssl ? "https://" : "http://"}${resource.fullDomain}`, domain: `${resource.ssl ? "https://" : "http://"}${resource.fullDomain}`,
site: resource.siteName || "None", site: resource.siteName || "None",
siteId: resource.siteId || "Unknown", siteId: resource.siteId || "Unknown",
protocol: resource.protocol,
proxyPort: resource.proxyPort,
http: resource.http,
hasAuth: hasAuth:
resource.sso || resource.sso ||
resource.pincodeId !== null || resource.pincodeId !== null ||

View file

@ -153,7 +153,9 @@ export default function CreateShareLinkForm({
if (res?.status === 200) { if (res?.status === 200) {
setResources( setResources(
res.data.data.resources.map((r) => ({ res.data.data.resources.filter((r) => {
return r.http;
}).map((r) => ({
resourceId: r.resourceId, resourceId: r.resourceId,
name: r.name, name: r.name,
resourceUrl: `${r.ssl ? "https://" : "http://"}${r.fullDomain}/` resourceUrl: `${r.ssl ? "https://" : "http://"}${r.fullDomain}/`
@ -318,7 +320,7 @@ export default function CreateShareLinkForm({
) => ( ) => (
<CommandItem <CommandItem
value={ value={
r.name r.resourceId.toString()
} }
key={ key={
r.resourceId r.resourceId

View file

@ -145,14 +145,12 @@ export default function ShareLinksTable({
cell: ({ row }) => { cell: ({ row }) => {
const r = row.original; const r = row.original;
return ( return (
<Link href={`/${orgId}/settings/resources/${r.resourceId}`}>
<Button variant="outline"> <Button variant="outline">
<Link
href={`/${orgId}/settings/resources/${r.resourceId}`}
>
{r.resourceName} {r.resourceName}
</Link>
<ArrowUpRight className="ml-2 h-4 w-4" /> <ArrowUpRight className="ml-2 h-4 w-4" />
</Button> </Button>
</Link>
); );
} }
}, },

View file

@ -92,14 +92,14 @@ export default function SitesTable({ sites, orgId }: SitesTableProps) {
</Button> </Button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent align="end"> <DropdownMenuContent align="end">
<DropdownMenuItem>
<Link <Link
className="block w-full" className="block w-full"
href={`/${siteRow.orgId}/settings/sites/${siteRow.nice}`} href={`/${siteRow.orgId}/settings/sites/${siteRow.nice}`}
> >
<DropdownMenuItem>
View settings View settings
</Link>
</DropdownMenuItem> </DropdownMenuItem>
</Link>
<DropdownMenuItem <DropdownMenuItem
onClick={() => { onClick={() => {
setSelectedSite(siteRow); setSelectedSite(siteRow);

View file

@ -5,14 +5,12 @@ import { Button } from "@app/components/ui/button";
import { import {
Card, Card,
CardContent, CardContent,
CardFooter,
CardHeader, CardHeader,
CardTitle CardTitle
} from "@app/components/ui/card"; } from "@app/components/ui/card";
import { useEnvContext } from "@app/hooks/useEnvContext"; import { useEnvContext } from "@app/hooks/useEnvContext";
import { AuthWithAccessTokenResponse } from "@server/routers/resource"; import { AuthWithAccessTokenResponse } from "@server/routers/resource";
import { AxiosResponse } from "axios"; import { AxiosResponse } from "axios";
import { Loader2 } from "lucide-react";
import Link from "next/link"; import Link from "next/link";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
@ -32,7 +30,17 @@ export default function AccessToken({
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [isValid, setIsValid] = useState(false); 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(() => { useEffect(() => {
if (!accessTokenId || !accessToken) { if (!accessTokenId || !accessToken) {
@ -51,7 +59,10 @@ export default function AccessToken({
if (res.data.data.session) { if (res.data.data.session) {
setIsValid(true); setIsValid(true);
window.location.href = redirectUrl; window.location.href = appendRequestToken(
redirectUrl,
res.data.data.session
);
} }
} catch (e) { } catch (e) {
console.error("Error checking access token", e); console.error("Error checking access token", e);

View file

@ -19,7 +19,7 @@ export default function ResourceAccessDenied() {
</CardTitle> </CardTitle>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
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. please contact the administrator.
<div className="text-center mt-4"> <div className="text-center mt-4">
<Button> <Button>

View file

@ -1,6 +1,6 @@
"use client"; "use client";
import { useEffect, useState, useSyncExternalStore } from "react"; import { useState } from "react";
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import * as z from "zod"; import * as z from "zod";
@ -8,7 +8,6 @@ import {
Card, Card,
CardContent, CardContent,
CardDescription, CardDescription,
CardFooter,
CardHeader, CardHeader,
CardTitle CardTitle
} from "@/components/ui/card"; } from "@/components/ui/card";
@ -30,9 +29,6 @@ import {
Key, Key,
User, User,
Send, Send,
ArrowLeft,
ArrowRight,
Lock,
AtSign AtSign
} from "lucide-react"; } from "lucide-react";
import { import {
@ -47,10 +43,8 @@ import { AxiosResponse } from "axios";
import LoginForm from "@app/components/LoginForm"; import LoginForm from "@app/components/LoginForm";
import { import {
AuthWithPasswordResponse, AuthWithPasswordResponse,
AuthWithAccessTokenResponse,
AuthWithWhitelistResponse AuthWithWhitelistResponse
} from "@server/routers/resource"; } from "@server/routers/resource";
import { redirect } from "next/dist/server/api-utils";
import ResourceAccessDenied from "./ResourceAccessDenied"; import ResourceAccessDenied from "./ResourceAccessDenied";
import { createApiClient } from "@app/lib/api"; import { createApiClient } from "@app/lib/api";
import { useEnvContext } from "@app/hooks/useEnvContext"; 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 [otpState, setOtpState] = useState<"idle" | "otp_sent">("idle");
const api = createApiClient(useEnvContext()); const { env } = useEnvContext();
const api = createApiClient({ env });
function getDefaultSelectedMethod() { function getDefaultSelectedMethod() {
if (props.methods.sso) { 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) => { const onWhitelistSubmit = (values: any) => {
setLoadingLogin(true); setLoadingLogin(true);
api.post<AxiosResponse<AuthWithWhitelistResponse>>( api.post<AxiosResponse<AuthWithWhitelistResponse>>(
@ -190,7 +195,7 @@ export default function ResourceAuthPortal(props: ResourceAuthPortalProps) {
const session = res.data.data.session; const session = res.data.data.session;
if (session) { if (session) {
window.location.href = props.redirect; window.location.href = appendRequestToken(props.redirect, session);
} }
}) })
.catch((e) => { .catch((e) => {
@ -212,7 +217,7 @@ export default function ResourceAuthPortal(props: ResourceAuthPortalProps) {
setPincodeError(null); setPincodeError(null);
const session = res.data.data.session; const session = res.data.data.session;
if (session) { if (session) {
window.location.href = props.redirect; window.location.href = appendRequestToken(props.redirect, session);
} }
}) })
.catch((e) => { .catch((e) => {
@ -237,7 +242,7 @@ export default function ResourceAuthPortal(props: ResourceAuthPortalProps) {
setPasswordError(null); setPasswordError(null);
const session = res.data.data.session; const session = res.data.data.session;
if (session) { if (session) {
window.location.href = props.redirect; window.location.href = appendRequestToken(props.redirect, session);
} }
}) })
.catch((e) => { .catch((e) => {
@ -619,16 +624,6 @@ export default function ResourceAuthPortal(props: ResourceAuthPortalProps) {
</Tabs> </Tabs>
</CardContent> </CardContent>
</Card> </Card>
{/* {activeTab === "sso" && (
<div className="flex justify-center mt-4">
<p className="text-sm text-muted-foreground">
Don't have an account?{" "}
<a href="#" className="underline">
Sign up
</a>
</p>
</div>
)} */}
</div> </div>
) : ( ) : (
<ResourceAccessDenied /> <ResourceAccessDenied />

View file

@ -1,7 +1,6 @@
import { import {
AuthWithAccessTokenResponse,
GetResourceAuthInfoResponse, GetResourceAuthInfoResponse,
GetResourceResponse GetExchangeTokenResponse
} from "@server/routers/resource"; } from "@server/routers/resource";
import ResourceAuthPortal from "./ResourceAuthPortal"; import ResourceAuthPortal from "./ResourceAuthPortal";
import { internal, priv } from "@app/lib/api"; import { internal, priv } from "@app/lib/api";
@ -12,9 +11,6 @@ import { verifySession } from "@app/lib/auth/verifySession";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import ResourceNotFound from "./ResourceNotFound"; import ResourceNotFound from "./ResourceNotFound";
import ResourceAccessDenied from "./ResourceAccessDenied"; import ResourceAccessDenied from "./ResourceAccessDenied";
import { cookies } from "next/headers";
import { CheckResourceSessionResponse } from "@server/routers/auth";
import AccessTokenInvalid from "./AccessToken";
import AccessToken from "./AccessToken"; import AccessToken from "./AccessToken";
import { pullEnv } from "@app/lib/pullEnv"; import { pullEnv } from "@app/lib/pullEnv";
@ -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<CheckResourceSessionResponse>
>(`/resource-session/${params.resourceId}/${sessionId}`);
if (res && res.data.data.valid) {
doRedirect = true;
}
} catch (e) {}
if (doRedirect) {
redirect(redirectUrl);
}
}
if (!hasAuth) { if (!hasAuth) {
// no authentication so always go straight to the resource // no authentication so always go straight to the resource
redirect(redirectUrl); redirect(redirectUrl);
} }
// convert the dashboard token into a resource session token
let userIsUnauthorized = false; let userIsUnauthorized = false;
if (user && authInfo.sso) { if (user && authInfo.sso) {
let doRedirect = false; let redirectToUrl: string | undefined;
try { try {
const res = await internal.get<AxiosResponse<GetResourceResponse>>( const res = await priv.post<
`/resource/${params.resourceId}`, AxiosResponse<GetExchangeTokenResponse>
>(
`/resource/${params.resourceId}/get-exchange-token`,
{},
await authCookieHeader() 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) { } catch (e) {
userIsUnauthorized = true; userIsUnauthorized = true;
} }
if (doRedirect) { if (redirectToUrl) {
redirect(redirectUrl); redirect(redirectToUrl);
} }
} }

View file

@ -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 (
<div className="flex items-center space-x-2">
<span>{text}</span>
<Popover>
<PopoverTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-6 w-6 rounded-full p-0"
>
<Info className="h-4 w-4" />
<span className="sr-only">Show info</span>
</Button>
</PopoverTrigger>
<PopoverContent className="w-80">
<p className="text-sm text-muted-foreground">{info}</p>
</PopoverContent>
</Popover>
</div>
);
}

View file

@ -6,8 +6,8 @@ export function pullEnv(): Env {
nextPort: process.env.NEXT_PORT as string, nextPort: process.env.NEXT_PORT as string,
externalPort: process.env.SERVER_EXTERNAL_PORT as string, externalPort: process.env.SERVER_EXTERNAL_PORT as string,
sessionCookieName: process.env.SESSION_COOKIE_NAME 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: { app: {
environment: process.env.ENVIRONMENT as string, environment: process.env.ENVIRONMENT as string,
@ -26,7 +26,9 @@ export function pullEnv(): Env {
emailVerificationRequired: emailVerificationRequired:
process.env.FLAGS_EMAIL_VERIFICATION_REQUIRED === "true" process.env.FLAGS_EMAIL_VERIFICATION_REQUIRED === "true"
? true ? true
: false : false,
allowRawResources:
process.env.FLAGS_ALLOW_RAW_RESOURCES === "true" ? true : false,
} }
}; };
} }

View file

@ -7,8 +7,8 @@ export type Env = {
externalPort: string; externalPort: string;
nextPort: string; nextPort: string;
sessionCookieName: string; sessionCookieName: string;
resourceSessionCookieName: string;
resourceAccessTokenParam: string; resourceAccessTokenParam: string;
resourceSessionRequestParam: string;
}, },
email: { email: {
emailEnabled: boolean; emailEnabled: boolean;
@ -17,5 +17,6 @@ export type Env = {
disableSignupWithoutInvite: boolean; disableSignupWithoutInvite: boolean;
disableUserCreateOrg: boolean; disableUserCreateOrg: boolean;
emailVerificationRequired: boolean; emailVerificationRequired: boolean;
allowRawResources: boolean;
} }
}; };