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

View file

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

View file

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

View file

@ -4,7 +4,13 @@ api:
providers:
http:
endpoint: "http://pangolin:{{.INTERNAL_PORT}}/api/v1/traefik-config"
endpoint: "http://pangolin:3001/api/v1/traefik-config/http"
pollInterval: "5s"
udp:
endpoint: "http://pangolin:3001/api/v1/traefik-config/udp"
pollInterval: "5s"
tcp:
endpoint: "http://pangolin:3001/api/v1/traefik-config/tcp"
pollInterval: "5s"
file:
filename: "/etc/traefik/dynamic_config.yml"
@ -13,7 +19,7 @@ experimental:
plugins:
badger:
moduleName: "github.com/fosrl/badger"
version: "v1.0.0-beta.2"
version: "v1.0.0-beta.3"
log:
level: "INFO"
@ -33,6 +39,9 @@ entryPoints:
address: ":80"
websecure:
address: ":443"
transport:
respondingTimeouts:
readTimeout: "30m"
http:
tls:
certResolver: "letsencrypt"

View file

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

View file

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

View file

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

View file

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

View file

@ -17,9 +17,11 @@ import (
"golang.org/x/term"
)
// DO NOT EDIT THIS FUNCTION; IT MATCHED BY REGEX IN CICD
func loadVersions(config *Config) {
config.PangolinVersion = "1.0.0-beta.8"
config.GerbilVersion = "1.0.0-beta.3"
config.PangolinVersion = "replaceme"
config.GerbilVersion = "replaceme"
config.BadgerVersion = "replaceme"
}
//go:embed fs/*
@ -28,6 +30,7 @@ var configFiles embed.FS
type Config struct {
PangolinVersion string
GerbilVersion string
BadgerVersion string
BaseDomain string
DashboardDomain string
LetsEncryptEmail string
@ -271,6 +274,11 @@ func createConfigFiles(config Config) error {
// Get the relative path by removing the "fs/" prefix
relPath := strings.TrimPrefix(path, "fs/")
// skip .DS_Store
if strings.Contains(relPath, ".DS_Store") {
return nil
}
// Create the full output path under "config/"
outPath := filepath.Join("config", relPath)
@ -374,7 +382,7 @@ func installDocker() error {
switch {
case strings.Contains(osRelease, "ID=ubuntu"):
installCmd = exec.Command("bash", "-c", fmt.Sprintf(`
apt-get update &&
apt-get update &&
apt-get install -y apt-transport-https ca-certificates curl software-properties-common &&
curl -fsSL https://download.docker.com/linux/ubuntu/gpg | gpg --dearmor -o /usr/share/keyrings/docker-archive-keyring.gpg &&
echo "deb [arch=%s signed-by=/usr/share/keyrings/docker-archive-keyring.gpg] https://download.docker.com/linux/ubuntu $(lsb_release -cs) stable" > /etc/apt/sources.list.d/docker.list &&
@ -383,7 +391,7 @@ func installDocker() error {
`, dockerArch))
case strings.Contains(osRelease, "ID=debian"):
installCmd = exec.Command("bash", "-c", fmt.Sprintf(`
apt-get update &&
apt-get update &&
apt-get install -y apt-transport-https ca-certificates curl software-properties-common &&
curl -fsSL https://download.docker.com/linux/debian/gpg | gpg --dearmor -o /usr/share/keyrings/docker-archive-keyring.gpg &&
echo "deb [arch=%s signed-by=/usr/share/keyrings/docker-archive-keyring.gpg] https://download.docker.com/linux/debian $(lsb_release -cs) stable" > /etc/apt/sources.list.d/docker.list &&
@ -432,29 +440,53 @@ func isDockerInstalled() bool {
return true
}
func getCommandString(useNewStyle bool) string {
if useNewStyle {
return "'docker compose'"
}
return "'docker-compose'"
}
func pullAndStartContainers() error {
fmt.Println("Starting containers...")
// First try docker compose (new style)
cmd := exec.Command("docker", "compose", "-f", "docker-compose.yml", "pull")
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
err := cmd.Run()
if err != nil {
fmt.Println("Failed to start containers using docker compose, falling back to docker-compose command")
os.Exit(1)
// Check which docker compose command is available
var useNewStyle bool
checkCmd := exec.Command("docker", "compose", "version")
if err := checkCmd.Run(); err == nil {
useNewStyle = true
} else {
// Check if docker-compose (old style) is available
checkCmd = exec.Command("docker-compose", "version")
if err := checkCmd.Run(); err != nil {
return fmt.Errorf("neither 'docker compose' nor 'docker-compose' command is available: %v", err)
}
}
cmd = exec.Command("docker", "compose", "-f", "docker-compose.yml", "up", "-d")
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
err = cmd.Run()
if err != nil {
fmt.Println("Failed to start containers using docker-compose command")
os.Exit(1)
// Helper function to execute docker compose commands
executeCommand := func(args ...string) error {
var cmd *exec.Cmd
if useNewStyle {
cmd = exec.Command("docker", append([]string{"compose"}, args...)...)
} else {
cmd = exec.Command("docker-compose", args...)
}
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
return cmd.Run()
}
return err
// Pull containers
fmt.Printf("Using %s command to pull containers...\n", getCommandString(useNewStyle))
if err := executeCommand("-f", "docker-compose.yml", "pull"); err != nil {
return fmt.Errorf("failed to pull containers: %v", err)
}
// Start containers
fmt.Printf("Using %s command to start containers...\n", getCommandString(useNewStyle))
if err := executeCommand("-f", "docker-compose.yml", "up", "-d"); err != nil {
return fmt.Errorf("failed to start containers: %v", err)
}
return nil
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,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 "./exchangeSession";

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -11,7 +11,7 @@ import m2 from "./scripts/1.0.0-beta2";
import m3 from "./scripts/1.0.0-beta3";
import m4 from "./scripts/1.0.0-beta5";
import m5 from "./scripts/1.0.0-beta6";
import { existsSync, mkdirSync } from "fs";
import m6 from "./scripts/1.0.0-beta9";
// THIS CANNOT IMPORT ANYTHING FROM THE SERVER
// EXCEPT FOR THE DATABASE AND THE SCHEMA
@ -22,7 +22,8 @@ const migrations = [
{ version: "1.0.0-beta.2", run: m2 },
{ version: "1.0.0-beta.3", run: m3 },
{ version: "1.0.0-beta.5", run: m4 },
{ version: "1.0.0-beta.6", run: m5 }
{ version: "1.0.0-beta.6", run: m5 },
{ version: "1.0.0-beta.9", run: m6 }
// Add new migrations here as they are created
] as const;
@ -30,31 +31,36 @@ const migrations = [
await runMigrations();
export async function runMigrations() {
const appVersion = loadAppVersion();
if (!appVersion) {
throw new Error("APP_VERSION is not set in the environment");
}
if (exists) {
await executeScripts();
} else {
console.log("Running migrations...");
try {
migrate(db, {
migrationsFolder: path.join(__DIRNAME, "init") // put here during the docker build
});
console.log("Migrations completed successfully.");
} catch (error) {
console.error("Error running migrations:", error);
try {
const appVersion = loadAppVersion();
if (!appVersion) {
throw new Error("APP_VERSION is not set in the environment");
}
await db
if (exists) {
await executeScripts();
} else {
console.log("Running migrations...");
try {
migrate(db, {
migrationsFolder: path.join(__DIRNAME, "init") // put here during the docker build
});
console.log("Migrations completed successfully.");
} catch (error) {
console.error("Error running migrations:", error);
}
await db
.insert(versionMigrations)
.values({
version: appVersion,
executedAt: Date.now()
})
.execute();
}
} catch (e) {
console.error("Error running migrations:", e);
await new Promise((resolve) => setTimeout(resolve, 1000 * 60 * 60 * 24 * 1));
}
}

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

View file

@ -45,21 +45,64 @@ import {
} from "@app/components/ui/command";
import { CaretSortIcon } from "@radix-ui/react-icons";
import CustomDomainInput from "./[resourceId]/CustomDomainInput";
import { Axios, AxiosResponse } from "axios";
import { AxiosResponse } from "axios";
import { Resource } from "@server/db/schema";
import { useOrgContext } from "@app/hooks/useOrgContext";
import { subdomainSchema } from "@server/schemas/subdomainSchema";
import { createApiClient } from "@app/lib/api";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { cn } from "@app/lib/cn";
import { Switch } from "@app/components/ui/switch";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue
} from "@app/components/ui/select";
import { subdomainSchema } from "@server/schemas/subdomainSchema";
import Link from "next/link";
import { SquareArrowOutUpRight } from "lucide-react";
const accountFormSchema = z.object({
subdomain: subdomainSchema,
name: z.string(),
siteId: z.number()
});
const createResourceFormSchema = z
.object({
subdomain: z.string().optional(),
name: z.string().min(1).max(255),
siteId: z.number(),
http: z.boolean(),
protocol: z.string(),
proxyPort: z.number().optional()
})
.refine(
(data) => {
if (!data.http) {
return z
.number()
.int()
.min(1)
.max(65535)
.safeParse(data.proxyPort).success;
}
return true;
},
{
message: "Invalid port number",
path: ["proxyPort"]
}
)
.refine(
(data) => {
if (data.http) {
return subdomainSchema.safeParse(data.subdomain).success;
}
return true;
},
{
message: "Invalid subdomain",
path: ["subdomain"]
}
);
type AccountFormValues = z.infer<typeof accountFormSchema>;
type CreateResourceFormValues = z.infer<typeof createResourceFormSchema>;
type CreateResourceFormProps = {
open: boolean;
@ -81,15 +124,18 @@ export default function CreateResourceForm({
const router = useRouter();
const { org } = useOrgContext();
const { env } = useEnvContext();
const [sites, setSites] = useState<ListSitesResponse["sites"]>([]);
const [domainSuffix, setDomainSuffix] = useState<string>(org.org.domain);
const form = useForm<AccountFormValues>({
resolver: zodResolver(accountFormSchema),
const form = useForm<CreateResourceFormValues>({
resolver: zodResolver(createResourceFormSchema),
defaultValues: {
subdomain: "",
name: "My Resource"
name: "My Resource",
http: true,
protocol: "tcp"
}
});
@ -112,7 +158,7 @@ export default function CreateResourceForm({
fetchSites();
}, [open]);
async function onSubmit(data: AccountFormValues) {
async function onSubmit(data: CreateResourceFormValues) {
console.log(data);
const res = await api
@ -120,8 +166,11 @@ export default function CreateResourceForm({
`/org/${orgId}/site/${data.siteId}/resource/`,
{
name: data.name,
subdomain: data.subdomain
// subdomain: data.subdomain,
subdomain: data.http ? data.subdomain : undefined,
http: data.http,
protocol: data.protocol,
proxyPort: data.http ? undefined : data.proxyPort,
siteId: data.siteId
}
)
.catch((e) => {
@ -188,34 +237,165 @@ export default function CreateResourceForm({
</FormItem>
)}
/>
<FormField
control={form.control}
name="subdomain"
render={({ field }) => (
<FormItem>
<FormLabel>Subdomain</FormLabel>
<FormControl>
<CustomDomainInput
value={field.value}
domainSuffix={domainSuffix}
placeholder="Enter subdomain"
onChange={(value) =>
form.setValue(
"subdomain",
value
)
}
/>
</FormControl>
<FormDescription>
This is the fully qualified
domain name that will be used to
access the resource.
</FormDescription>
<FormMessage />
</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
control={form.control}
name="subdomain"
render={({ field }) => (
<FormItem>
<FormLabel>Subdomain</FormLabel>
<FormControl>
<CustomDomainInput
value={
field.value ?? ""
}
domainSuffix={
domainSuffix
}
placeholder="Enter subdomain"
onChange={(value) =>
form.setValue(
"subdomain",
value
)
}
/>
</FormControl>
<FormDescription>
This is the fully qualified
domain name that will be
used to access the resource.
</FormDescription>
<FormMessage />
</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
control={form.control}
name="siteId"
@ -257,7 +437,7 @@ export default function CreateResourceForm({
(site) => (
<CommandItem
value={
site.name
site.niceId
}
key={
site.siteId

View file

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

View file

@ -2,12 +2,8 @@
import { useState } from "react";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { Button } from "@/components/ui/button";
import {
InfoIcon,
LinkIcon,
CheckIcon,
CopyIcon,
ShieldCheck,
ShieldOff
} from "lucide-react";
@ -42,37 +38,65 @@ export default function ResourceInfoBox({}: ResourceInfoBoxType) {
</AlertTitle>
<AlertDescription className="mt-4">
<InfoSections>
<InfoSection>
<InfoSectionTitle>Authentication</InfoSectionTitle>
<InfoSectionContent>
{authInfo.password ||
authInfo.pincode ||
authInfo.sso ||
authInfo.whitelist ? (
<div className="flex items-start space-x-2 text-green-500">
<ShieldCheck className="w-4 h-4 mt-0.5" />
<span>
This resource is protected with at least
one auth method.
</span>
</div>
) : (
<div className="flex items-center space-x-2 text-yellow-500">
<ShieldOff className="w-4 h-4" />
<span>
Anyone can access this resource.
</span>
</div>
)}
</InfoSectionContent>
</InfoSection>
<Separator orientation="vertical" />
<InfoSection>
<InfoSectionTitle>URL</InfoSectionTitle>
<InfoSectionContent>
<CopyToClipboard text={fullUrl} isLink={true} />
</InfoSectionContent>
</InfoSection>
{resource.http ? (
<>
<InfoSection>
<InfoSectionTitle>
Authentication
</InfoSectionTitle>
<InfoSectionContent>
{authInfo.password ||
authInfo.pincode ||
authInfo.sso ||
authInfo.whitelist ? (
<div className="flex items-start space-x-2 text-green-500">
<ShieldCheck className="w-4 h-4 mt-0.5" />
<span>
This resource is protected with
at least one auth method.
</span>
</div>
) : (
<div className="flex items-center space-x-2 text-yellow-500">
<ShieldOff className="w-4 h-4" />
<span>
Anyone can access this resource.
</span>
</div>
)}
</InfoSectionContent>
</InfoSection>
<Separator orientation="vertical" />
<InfoSection>
<InfoSectionTitle>URL</InfoSectionTitle>
<InfoSectionContent>
<CopyToClipboard
text={fullUrl}
isLink={true}
/>
</InfoSectionContent>
</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>
</AlertDescription>
</Alert>

View file

@ -48,6 +48,7 @@ import {
SettingsSectionFooter
} from "@app/components/Settings";
import { SwitchInput } from "@app/components/SwitchInput";
import { InfoPopup } from "@app/components/ui/info-popup";
const UsersRolesFormSchema = z.object({
roles: z.array(
@ -665,10 +666,12 @@ export default function ResourceAuthenticationPage() {
render={({ field }) => (
<FormItem>
<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>
<FormControl>
{/* @ts-ignore */}
{/* @ts-ignore */}
<TagInput
{...field}
@ -681,6 +684,17 @@ export default function ResourceAuthenticationPage() {
return z
.string()
.email()
.or(
z
.string()
.regex(
/^\*@[\w.-]+\.[a-zA-Z]{2,}$/,
{
message:
"Invalid email address. Wildcard (*) must be the entire local part."
}
)
)
.safeParse(
tag
).success;

View file

@ -63,6 +63,7 @@ import {
} from "@app/components/Settings";
import { SwitchInput } from "@app/components/SwitchInput";
import { useSiteContext } from "@app/hooks/useSiteContext";
import { InfoPopup } from "@app/components/ui/info-popup";
// Regular expressions for validation
const DOMAIN_REGEX =
@ -94,7 +95,7 @@ const domainSchema = z
const addTargetSchema = z.object({
ip: domainSchema,
method: z.string(),
method: z.string().nullable(),
port: z.coerce.number().int().positive()
// protocol: z.string(),
});
@ -129,9 +130,9 @@ export default function ReverseProxyTargets(props: {
const addTargetForm = useForm({
resolver: zodResolver(addTargetSchema),
defaultValues: {
ip: "",
method: "http",
port: 80
ip: "localhost",
method: resource.http ? "http" : null,
port: resource.http ? 80 : resource.proxyPort || 1234
// protocol: "TCP",
}
});
@ -321,7 +322,7 @@ export default function ReverseProxyTargets(props: {
});
setSslEnabled(val);
updateResource({ ssl: sslEnabled });
updateResource({ ssl: val });
toast({
title: "SSL Configuration",
@ -330,26 +331,6 @@ export default function ReverseProxyTargets(props: {
}
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",
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({
data: targets,
columns,
@ -451,29 +458,29 @@ export default function ReverseProxyTargets(props: {
return (
<SettingsContainer>
{/* SSL Section */}
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>
SSL Configuration
</SettingsSectionTitle>
<SettingsSectionDescription>
Setup SSL to secure your connections with LetsEncrypt
certificates
</SettingsSectionDescription>
</SettingsSectionHeader>
<SettingsSectionBody>
<SwitchInput
id="ssl-toggle"
label="Enable SSL (https)"
defaultChecked={resource.ssl}
onCheckedChange={async (val) => {
await saveSsl(val);
}}
/>
</SettingsSectionBody>
</SettingsSection>
{resource.http && (
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>
SSL Configuration
</SettingsSectionTitle>
<SettingsSectionDescription>
Setup SSL to secure your connections with
LetsEncrypt certificates
</SettingsSectionDescription>
</SettingsSectionHeader>
<SettingsSectionBody>
<SwitchInput
id="ssl-toggle"
label="Enable SSL (https)"
defaultChecked={resource.ssl}
onCheckedChange={async (val) => {
await saveSsl(val);
}}
/>
</SettingsSectionBody>
</SettingsSection>
)}
{/* Targets Section */}
<SettingsSection>
<SettingsSectionHeader>
@ -491,39 +498,47 @@ export default function ReverseProxyTargets(props: {
className="space-y-4"
>
<div className="grid grid-cols-2 md:grid-cols-3 gap-4">
<FormField
control={addTargetForm.control}
name="method"
render={({ field }) => (
<FormItem>
<FormLabel>Method</FormLabel>
<FormControl>
<Select
{...field}
onValueChange={(value) => {
addTargetForm.setValue(
"method",
{resource.http && (
<FormField
control={addTargetForm.control}
name="method"
render={({ field }) => (
<FormItem>
<FormLabel>Method</FormLabel>
<FormControl>
<Select
value={
field.value ||
undefined
}
onValueChange={(
value
);
}}
>
<SelectTrigger id="method">
<SelectValue placeholder="Select method" />
</SelectTrigger>
<SelectContent>
<SelectItem value="http">
http
</SelectItem>
<SelectItem value="https">
https
</SelectItem>
</SelectContent>
</Select>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
) => {
addTargetForm.setValue(
"method",
value
);
}}
>
<SelectTrigger id="method">
<SelectValue placeholder="Select method" />
</SelectTrigger>
<SelectContent>
<SelectItem value="http">
http
</SelectItem>
<SelectItem value="https">
https
</SelectItem>
</SelectContent>
</Select>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
)}
<FormField
control={addTargetForm.control}
name="ip"
@ -637,6 +652,9 @@ export default function ReverseProxyTargets(props: {
</TableBody>
</Table>
</TableContainer>
<p className="text-sm text-muted-foreground">
Adding more than one target above will enable load balancing.
</p>
</SettingsSectionBody>
<SettingsSectionFooter>
<Button

View file

@ -13,22 +13,7 @@ import {
FormLabel,
FormMessage
} from "@/components/ui/form";
import { CaretSortIcon, CheckIcon } from "@radix-ui/react-icons";
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 { ListSitesResponse } from "@server/routers/site";
import { useEffect, useState } from "react";
@ -49,16 +34,46 @@ import {
} from "@app/components/Settings";
import { useOrgContext } from "@app/hooks/useOrgContext";
import CustomDomainInput from "../CustomDomainInput";
import ResourceInfoBox from "../ResourceInfoBox";
import { subdomainSchema } from "@server/schemas/subdomainSchema";
import { createApiClient } from "@app/lib/api";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { subdomainSchema } from "@server/schemas/subdomainSchema";
const GeneralFormSchema = z.object({
name: z.string(),
subdomain: subdomainSchema
// siteId: z.number(),
});
const GeneralFormSchema = z
.object({
subdomain: z.string().optional(),
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>;
@ -81,8 +96,9 @@ export default function GeneralForm() {
resolver: zodResolver(GeneralFormSchema),
defaultValues: {
name: resource.name,
subdomain: resource.subdomain
// siteId: resource.siteId!,
subdomain: resource.subdomain ? resource.subdomain : undefined,
proxyPort: resource.proxyPort ? resource.proxyPort : undefined,
http: resource.http
},
mode: "onChange"
});
@ -169,33 +185,78 @@ export default function GeneralForm() {
)}
/>
<FormField
control={form.control}
name="subdomain"
render={({ field }) => (
<FormItem>
<FormLabel>Subdomain</FormLabel>
<FormControl>
<CustomDomainInput
value={field.value}
domainSuffix={domainSuffix}
placeholder="Enter subdomain"
onChange={(value) =>
form.setValue(
"subdomain",
value
)
}
/>
</FormControl>
<FormDescription>
This is the subdomain that will
be used to access the resource.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
{resource.http ? (
<FormField
control={form.control}
name="subdomain"
render={({ field }) => (
<FormItem>
<FormLabel>Subdomain</FormLabel>
<FormControl>
<CustomDomainInput
value={
field.value || ""
}
domainSuffix={
domainSuffix
}
placeholder="Enter subdomain"
onChange={(value) =>
form.setValue(
"subdomain",
value
)
}
/>
</FormControl>
<FormDescription>
This is the subdomain that
will be used to access 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>
This is the port that will
be used to access the
resource.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
)}
</form>
</Form>
</SettingsSectionForm>

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,6 +1,6 @@
"use client";
import { useEffect, useState, useSyncExternalStore } from "react";
import { useState } from "react";
import { zodResolver } from "@hookform/resolvers/zod";
import { useForm } from "react-hook-form";
import * as z from "zod";
@ -8,7 +8,6 @@ import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle
} from "@/components/ui/card";
@ -30,9 +29,6 @@ import {
Key,
User,
Send,
ArrowLeft,
ArrowRight,
Lock,
AtSign
} from "lucide-react";
import {
@ -47,10 +43,8 @@ import { AxiosResponse } from "axios";
import LoginForm from "@app/components/LoginForm";
import {
AuthWithPasswordResponse,
AuthWithAccessTokenResponse,
AuthWithWhitelistResponse
} from "@server/routers/resource";
import { redirect } from "next/dist/server/api-utils";
import ResourceAccessDenied from "./ResourceAccessDenied";
import { createApiClient } from "@app/lib/api";
import { useEnvContext } from "@app/hooks/useEnvContext";
@ -118,7 +112,9 @@ export default function ResourceAuthPortal(props: ResourceAuthPortalProps) {
const [otpState, setOtpState] = useState<"idle" | "otp_sent">("idle");
const api = createApiClient(useEnvContext());
const { env } = useEnvContext();
const api = createApiClient({ env });
function getDefaultSelectedMethod() {
if (props.methods.sso) {
@ -169,6 +165,15 @@ export default function ResourceAuthPortal(props: ResourceAuthPortalProps) {
}
});
function appendRequestToken(url: string, token: string) {
const fullUrl = new URL(url);
fullUrl.searchParams.append(
env.server.resourceSessionRequestParam,
token
);
return fullUrl.toString();
}
const onWhitelistSubmit = (values: any) => {
setLoadingLogin(true);
api.post<AxiosResponse<AuthWithWhitelistResponse>>(
@ -190,7 +195,7 @@ export default function ResourceAuthPortal(props: ResourceAuthPortalProps) {
const session = res.data.data.session;
if (session) {
window.location.href = props.redirect;
window.location.href = appendRequestToken(props.redirect, session);
}
})
.catch((e) => {
@ -212,7 +217,7 @@ export default function ResourceAuthPortal(props: ResourceAuthPortalProps) {
setPincodeError(null);
const session = res.data.data.session;
if (session) {
window.location.href = props.redirect;
window.location.href = appendRequestToken(props.redirect, session);
}
})
.catch((e) => {
@ -237,7 +242,7 @@ export default function ResourceAuthPortal(props: ResourceAuthPortalProps) {
setPasswordError(null);
const session = res.data.data.session;
if (session) {
window.location.href = props.redirect;
window.location.href = appendRequestToken(props.redirect, session);
}
})
.catch((e) => {
@ -619,16 +624,6 @@ export default function ResourceAuthPortal(props: ResourceAuthPortalProps) {
</Tabs>
</CardContent>
</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>
) : (
<ResourceAccessDenied />

View file

@ -1,7 +1,6 @@
import {
AuthWithAccessTokenResponse,
GetResourceAuthInfoResponse,
GetResourceResponse
GetExchangeTokenResponse
} from "@server/routers/resource";
import ResourceAuthPortal from "./ResourceAuthPortal";
import { internal, priv } from "@app/lib/api";
@ -12,9 +11,6 @@ import { verifySession } from "@app/lib/auth/verifySession";
import { redirect } from "next/navigation";
import ResourceNotFound from "./ResourceNotFound";
import ResourceAccessDenied from "./ResourceAccessDenied";
import { cookies } from "next/headers";
import { CheckResourceSessionResponse } from "@server/routers/auth";
import AccessTokenInvalid from "./AccessToken";
import AccessToken from "./AccessToken";
import { pullEnv } from "@app/lib/pullEnv";
@ -48,7 +44,7 @@ export default async function ResourceAuthPage(props: {
// TODO: fix this
return (
<div className="w-full max-w-md">
{/* @ts-ignore */}
{/* @ts-ignore */}
<ResourceNotFound />
</div>
);
@ -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) {
// no authentication so always go straight to the resource
redirect(redirectUrl);
}
// convert the dashboard token into a resource session token
let userIsUnauthorized = false;
if (user && authInfo.sso) {
let doRedirect = false;
let redirectToUrl: string | undefined;
try {
const res = await internal.get<AxiosResponse<GetResourceResponse>>(
`/resource/${params.resourceId}`,
const res = await priv.post<
AxiosResponse<GetExchangeTokenResponse>
>(
`/resource/${params.resourceId}/get-exchange-token`,
{},
await authCookieHeader()
);
doRedirect = true;
if (res.data.data.requestToken) {
const paramName = env.server.resourceSessionRequestParam;
// append the param with the token to the redirect url
const fullUrl = new URL(redirectUrl);
fullUrl.searchParams.append(
paramName,
res.data.data.requestToken
);
redirectToUrl = fullUrl.toString();
}
} catch (e) {
userIsUnauthorized = true;
}
if (doRedirect) {
redirect(redirectUrl);
if (redirectToUrl) {
redirect(redirectToUrl);
}
}

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

View file

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