mirror of
https://github.com/fosrl/pangolin.git
synced 2025-08-17 16:01:22 +02:00
commit
16b131970b
77 changed files with 2456 additions and 878 deletions
82
.github/workflows/cicd.yml
vendored
Normal file
82
.github/workflows/cicd.yml
vendored
Normal 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
2
.gitignore
vendored
|
@ -31,3 +31,5 @@ dist
|
||||||
installer
|
installer
|
||||||
*.tar
|
*.tar
|
||||||
bin
|
bin
|
||||||
|
.secrets
|
||||||
|
test_event.json
|
||||||
|
|
|
@ -32,6 +32,7 @@ _Sites page of Pangolin dashboard (dark mode) showing multiple tunnels connected
|
||||||
- Secure and easy to configure site-to-site connectivity via a custom **user space WireGuard client**, [Newt](https://github.com/fosrl/newt).
|
- Secure and easy to configure site-to-site connectivity via a custom **user space WireGuard client**, [Newt](https://github.com/fosrl/newt).
|
||||||
- Built-in support for any WireGuard client.
|
- Built-in support for any WireGuard client.
|
||||||
- Automated **SSL certificates** (https) via [LetsEncrypt](https://letsencrypt.org/).
|
- Automated **SSL certificates** (https) via [LetsEncrypt](https://letsencrypt.org/).
|
||||||
|
- Support for HTTP/HTTPS and **raw TCP/UDP services**.
|
||||||
|
|
||||||
### Identity & Access Management
|
### Identity & Access Management
|
||||||
|
|
||||||
|
|
|
@ -1,27 +1,27 @@
|
||||||
app:
|
app:
|
||||||
dashboard_url: http://localhost:3002
|
dashboard_url: "http://localhost:3002"
|
||||||
base_domain: localhost
|
base_domain: "localhost"
|
||||||
log_level: info
|
log_level: "info"
|
||||||
save_logs: false
|
save_logs: false
|
||||||
|
|
||||||
server:
|
server:
|
||||||
external_port: 3000
|
external_port: 3000
|
||||||
internal_port: 3001
|
internal_port: 3001
|
||||||
next_port: 3002
|
next_port: 3002
|
||||||
internal_hostname: pangolin
|
internal_hostname: "pangolin"
|
||||||
secure_cookies: true
|
secure_cookies: true
|
||||||
session_cookie_name: p_session
|
session_cookie_name: "p_session_token"
|
||||||
resource_session_cookie_name: p_resource_session
|
resource_access_token_param: "p_token"
|
||||||
resource_access_token_param: p_token
|
resource_session_request_param: "p_session_request"
|
||||||
|
|
||||||
traefik:
|
traefik:
|
||||||
cert_resolver: letsencrypt
|
cert_resolver: "letsencrypt"
|
||||||
http_entrypoint: web
|
http_entrypoint: "web"
|
||||||
https_entrypoint: websecure
|
https_entrypoint: "websecure"
|
||||||
|
|
||||||
gerbil:
|
gerbil:
|
||||||
start_port: 51820
|
start_port: 51820
|
||||||
base_endpoint: localhost
|
base_endpoint: "localhost"
|
||||||
block_size: 24
|
block_size: 24
|
||||||
site_block_size: 30
|
site_block_size: 30
|
||||||
subnet_group: 100.89.137.0/20
|
subnet_group: 100.89.137.0/20
|
||||||
|
@ -34,10 +34,11 @@ rate_limits:
|
||||||
|
|
||||||
users:
|
users:
|
||||||
server_admin:
|
server_admin:
|
||||||
email: admin@example.com
|
email: "admin@example.com"
|
||||||
password: Password123!
|
password: "Password123!"
|
||||||
|
|
||||||
flags:
|
flags:
|
||||||
require_email_verification: false
|
require_email_verification: false
|
||||||
disable_signup_without_invite: true
|
disable_signup_without_invite: true
|
||||||
disable_user_create_org: true
|
disable_user_create_org: true
|
||||||
|
allow_raw_resources: true
|
||||||
|
|
|
@ -3,7 +3,6 @@ http:
|
||||||
redirect-to-https:
|
redirect-to-https:
|
||||||
redirectScheme:
|
redirectScheme:
|
||||||
scheme: https
|
scheme: https
|
||||||
permanent: true
|
|
||||||
|
|
||||||
routers:
|
routers:
|
||||||
# HTTP to HTTPS redirect router
|
# HTTP to HTTPS redirect router
|
||||||
|
|
|
@ -4,7 +4,13 @@ api:
|
||||||
|
|
||||||
providers:
|
providers:
|
||||||
http:
|
http:
|
||||||
endpoint: "http://pangolin:{{.INTERNAL_PORT}}/api/v1/traefik-config"
|
endpoint: "http://pangolin:3001/api/v1/traefik-config/http"
|
||||||
|
pollInterval: "5s"
|
||||||
|
udp:
|
||||||
|
endpoint: "http://pangolin:3001/api/v1/traefik-config/udp"
|
||||||
|
pollInterval: "5s"
|
||||||
|
tcp:
|
||||||
|
endpoint: "http://pangolin:3001/api/v1/traefik-config/tcp"
|
||||||
pollInterval: "5s"
|
pollInterval: "5s"
|
||||||
file:
|
file:
|
||||||
filename: "/etc/traefik/dynamic_config.yml"
|
filename: "/etc/traefik/dynamic_config.yml"
|
||||||
|
@ -13,7 +19,7 @@ experimental:
|
||||||
plugins:
|
plugins:
|
||||||
badger:
|
badger:
|
||||||
moduleName: "github.com/fosrl/badger"
|
moduleName: "github.com/fosrl/badger"
|
||||||
version: "v1.0.0-beta.2"
|
version: "v1.0.0-beta.3"
|
||||||
|
|
||||||
log:
|
log:
|
||||||
level: "INFO"
|
level: "INFO"
|
||||||
|
@ -33,6 +39,9 @@ entryPoints:
|
||||||
address: ":80"
|
address: ":80"
|
||||||
websecure:
|
websecure:
|
||||||
address: ":443"
|
address: ":443"
|
||||||
|
transport:
|
||||||
|
respondingTimeouts:
|
||||||
|
readTimeout: "30m"
|
||||||
http:
|
http:
|
||||||
tls:
|
tls:
|
||||||
certResolver: "letsencrypt"
|
certResolver: "letsencrypt"
|
||||||
|
|
|
@ -1,4 +1,3 @@
|
||||||
|
|
||||||
all: build
|
all: build
|
||||||
|
|
||||||
build:
|
build:
|
||||||
|
|
|
@ -1,18 +1,18 @@
|
||||||
app:
|
app:
|
||||||
dashboard_url: https://{{.DashboardDomain}}
|
dashboard_url: "https://{{.DashboardDomain}}"
|
||||||
base_domain: {{.BaseDomain}}
|
base_domain: "{{.BaseDomain}}"
|
||||||
log_level: info
|
log_level: "info"
|
||||||
save_logs: false
|
save_logs: false
|
||||||
|
|
||||||
server:
|
server:
|
||||||
external_port: 3000
|
external_port: 3000
|
||||||
internal_port: 3001
|
internal_port: 3001
|
||||||
next_port: 3002
|
next_port: 3002
|
||||||
internal_hostname: pangolin
|
internal_hostname: "pangolin"
|
||||||
secure_cookies: true
|
secure_cookies: true
|
||||||
session_cookie_name: p_session
|
session_cookie_name: "p_session_token"
|
||||||
resource_session_cookie_name: p_resource_session
|
resource_access_token_param: "p_token"
|
||||||
resource_access_token_param: p_token
|
resource_session_request_param: "p_session_request"
|
||||||
cors:
|
cors:
|
||||||
origins: ["https://{{.DashboardDomain}}"]
|
origins: ["https://{{.DashboardDomain}}"]
|
||||||
methods: ["GET", "POST", "PUT", "DELETE", "PATCH"]
|
methods: ["GET", "POST", "PUT", "DELETE", "PATCH"]
|
||||||
|
@ -20,14 +20,14 @@ server:
|
||||||
credentials: false
|
credentials: false
|
||||||
|
|
||||||
traefik:
|
traefik:
|
||||||
cert_resolver: letsencrypt
|
cert_resolver: "letsencrypt"
|
||||||
http_entrypoint: web
|
http_entrypoint: "web"
|
||||||
https_entrypoint: websecure
|
https_entrypoint: "websecure"
|
||||||
prefer_wildcard_cert: false
|
prefer_wildcard_cert: false
|
||||||
|
|
||||||
gerbil:
|
gerbil:
|
||||||
start_port: 51820
|
start_port: 51820
|
||||||
base_endpoint: {{.DashboardDomain}}
|
base_endpoint: "{{.DashboardDomain}}"
|
||||||
use_subdomain: false
|
use_subdomain: false
|
||||||
block_size: 24
|
block_size: 24
|
||||||
site_block_size: 30
|
site_block_size: 30
|
||||||
|
@ -39,18 +39,19 @@ rate_limits:
|
||||||
max_requests: 100
|
max_requests: 100
|
||||||
{{if .EnableEmail}}
|
{{if .EnableEmail}}
|
||||||
email:
|
email:
|
||||||
smtp_host: {{.EmailSMTPHost}}
|
smtp_host: "{{.EmailSMTPHost}}"
|
||||||
smtp_port: {{.EmailSMTPPort}}
|
smtp_port: "{{.EmailSMTPPort}}"
|
||||||
smtp_user: {{.EmailSMTPUser}}
|
smtp_user: "{{.EmailSMTPUser}}"
|
||||||
smtp_pass: {{.EmailSMTPPass}}
|
smtp_pass: "{{.EmailSMTPPass}}"
|
||||||
no_reply: {{.EmailNoReply}}
|
no_reply: "{{.EmailNoReply}}"
|
||||||
{{end}}
|
{{end}}
|
||||||
users:
|
users:
|
||||||
server_admin:
|
server_admin:
|
||||||
email: {{.AdminUserEmail}}
|
email: "{{.AdminUserEmail}}"
|
||||||
password: {{.AdminUserPassword}}
|
password: "{{.AdminUserPassword}}"
|
||||||
|
|
||||||
flags:
|
flags:
|
||||||
require_email_verification: {{.EnableEmail}}
|
require_email_verification: {{.EnableEmail}}
|
||||||
disable_signup_without_invite: {{.DisableSignupWithoutInvite}}
|
disable_signup_without_invite: {{.DisableSignupWithoutInvite}}
|
||||||
disable_user_create_org: {{.DisableUserCreateOrg}}
|
disable_user_create_org: {{.DisableUserCreateOrg}}
|
||||||
|
allow_raw_resources: true
|
||||||
|
|
|
@ -3,7 +3,6 @@ http:
|
||||||
redirect-to-https:
|
redirect-to-https:
|
||||||
redirectScheme:
|
redirectScheme:
|
||||||
scheme: https
|
scheme: https
|
||||||
permanent: true
|
|
||||||
|
|
||||||
routers:
|
routers:
|
||||||
# HTTP to HTTPS redirect router
|
# HTTP to HTTPS redirect router
|
||||||
|
|
|
@ -4,7 +4,13 @@ api:
|
||||||
|
|
||||||
providers:
|
providers:
|
||||||
http:
|
http:
|
||||||
endpoint: "http://pangolin:3001/api/v1/traefik-config"
|
endpoint: "http://pangolin:3001/api/v1/traefik-config/http"
|
||||||
|
pollInterval: "5s"
|
||||||
|
udp:
|
||||||
|
endpoint: "http://pangolin:3001/api/v1/traefik-config/udp"
|
||||||
|
pollInterval: "5s"
|
||||||
|
tcp:
|
||||||
|
endpoint: "http://pangolin:3001/api/v1/traefik-config/tcp"
|
||||||
pollInterval: "5s"
|
pollInterval: "5s"
|
||||||
file:
|
file:
|
||||||
filename: "/etc/traefik/dynamic_config.yml"
|
filename: "/etc/traefik/dynamic_config.yml"
|
||||||
|
@ -13,7 +19,7 @@ experimental:
|
||||||
plugins:
|
plugins:
|
||||||
badger:
|
badger:
|
||||||
moduleName: "github.com/fosrl/badger"
|
moduleName: "github.com/fosrl/badger"
|
||||||
version: "v1.0.0-beta.2"
|
version: "{{.BadgerVersion}}"
|
||||||
|
|
||||||
log:
|
log:
|
||||||
level: "INFO"
|
level: "INFO"
|
||||||
|
@ -33,6 +39,9 @@ entryPoints:
|
||||||
address: ":80"
|
address: ":80"
|
||||||
websecure:
|
websecure:
|
||||||
address: ":443"
|
address: ":443"
|
||||||
|
transport:
|
||||||
|
respondingTimeouts:
|
||||||
|
readTimeout: "30m"
|
||||||
http:
|
http:
|
||||||
tls:
|
tls:
|
||||||
certResolver: "letsencrypt"
|
certResolver: "letsencrypt"
|
||||||
|
|
|
@ -17,9 +17,11 @@ import (
|
||||||
"golang.org/x/term"
|
"golang.org/x/term"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// DO NOT EDIT THIS FUNCTION; IT MATCHED BY REGEX IN CICD
|
||||||
func loadVersions(config *Config) {
|
func loadVersions(config *Config) {
|
||||||
config.PangolinVersion = "1.0.0-beta.8"
|
config.PangolinVersion = "replaceme"
|
||||||
config.GerbilVersion = "1.0.0-beta.3"
|
config.GerbilVersion = "replaceme"
|
||||||
|
config.BadgerVersion = "replaceme"
|
||||||
}
|
}
|
||||||
|
|
||||||
//go:embed fs/*
|
//go:embed fs/*
|
||||||
|
@ -28,6 +30,7 @@ var configFiles embed.FS
|
||||||
type Config struct {
|
type Config struct {
|
||||||
PangolinVersion string
|
PangolinVersion string
|
||||||
GerbilVersion string
|
GerbilVersion string
|
||||||
|
BadgerVersion string
|
||||||
BaseDomain string
|
BaseDomain string
|
||||||
DashboardDomain string
|
DashboardDomain string
|
||||||
LetsEncryptEmail string
|
LetsEncryptEmail string
|
||||||
|
@ -271,6 +274,11 @@ func createConfigFiles(config Config) error {
|
||||||
// Get the relative path by removing the "fs/" prefix
|
// Get the relative path by removing the "fs/" prefix
|
||||||
relPath := strings.TrimPrefix(path, "fs/")
|
relPath := strings.TrimPrefix(path, "fs/")
|
||||||
|
|
||||||
|
// skip .DS_Store
|
||||||
|
if strings.Contains(relPath, ".DS_Store") {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// Create the full output path under "config/"
|
// Create the full output path under "config/"
|
||||||
outPath := filepath.Join("config", relPath)
|
outPath := filepath.Join("config", relPath)
|
||||||
|
|
||||||
|
@ -432,29 +440,53 @@ func isDockerInstalled() bool {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func getCommandString(useNewStyle bool) string {
|
||||||
|
if useNewStyle {
|
||||||
|
return "'docker compose'"
|
||||||
|
}
|
||||||
|
return "'docker-compose'"
|
||||||
|
}
|
||||||
|
|
||||||
func pullAndStartContainers() error {
|
func pullAndStartContainers() error {
|
||||||
fmt.Println("Starting containers...")
|
fmt.Println("Starting containers...")
|
||||||
|
|
||||||
// First try docker compose (new style)
|
// Check which docker compose command is available
|
||||||
cmd := exec.Command("docker", "compose", "-f", "docker-compose.yml", "pull")
|
var useNewStyle bool
|
||||||
cmd.Stdout = os.Stdout
|
checkCmd := exec.Command("docker", "compose", "version")
|
||||||
cmd.Stderr = os.Stderr
|
if err := checkCmd.Run(); err == nil {
|
||||||
err := cmd.Run()
|
useNewStyle = true
|
||||||
|
} else {
|
||||||
if err != nil {
|
// Check if docker-compose (old style) is available
|
||||||
fmt.Println("Failed to start containers using docker compose, falling back to docker-compose command")
|
checkCmd = exec.Command("docker-compose", "version")
|
||||||
os.Exit(1)
|
if err := checkCmd.Run(); err != nil {
|
||||||
|
return fmt.Errorf("neither 'docker compose' nor 'docker-compose' command is available: %v", err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
cmd = exec.Command("docker", "compose", "-f", "docker-compose.yml", "up", "-d")
|
// Helper function to execute docker compose commands
|
||||||
|
executeCommand := func(args ...string) error {
|
||||||
|
var cmd *exec.Cmd
|
||||||
|
if useNewStyle {
|
||||||
|
cmd = exec.Command("docker", append([]string{"compose"}, args...)...)
|
||||||
|
} else {
|
||||||
|
cmd = exec.Command("docker-compose", args...)
|
||||||
|
}
|
||||||
cmd.Stdout = os.Stdout
|
cmd.Stdout = os.Stdout
|
||||||
cmd.Stderr = os.Stderr
|
cmd.Stderr = os.Stderr
|
||||||
err = cmd.Run()
|
return cmd.Run()
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
fmt.Println("Failed to start containers using docker-compose command")
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return err
|
// Pull containers
|
||||||
|
fmt.Printf("Using %s command to pull containers...\n", getCommandString(useNewStyle))
|
||||||
|
if err := executeCommand("-f", "docker-compose.yml", "pull"); err != nil {
|
||||||
|
return fmt.Errorf("failed to pull containers: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start containers
|
||||||
|
fmt.Printf("Using %s command to start containers...\n", getCommandString(useNewStyle))
|
||||||
|
if err := executeCommand("-f", "docker-compose.yml", "up", "-d"); err != nil {
|
||||||
|
return fmt.Errorf("failed to start containers: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "@fosrl/pangolin",
|
"name": "@fosrl/pangolin",
|
||||||
"version": "1.0.0-beta.8",
|
"version": "1.0.0-beta.9",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"description": "Tunneled Reverse Proxy Management Server with Identity and Access Control and Dashboard UI",
|
"description": "Tunneled Reverse Proxy Management Server with Identity and Access Control and Dashboard UI",
|
||||||
|
@ -64,6 +64,7 @@
|
||||||
"moment": "2.30.1",
|
"moment": "2.30.1",
|
||||||
"next": "15.1.3",
|
"next": "15.1.3",
|
||||||
"next-themes": "0.4.4",
|
"next-themes": "0.4.4",
|
||||||
|
"node-cache": "5.1.2",
|
||||||
"node-fetch": "3.3.2",
|
"node-fetch": "3.3.2",
|
||||||
"nodemailer": "6.9.16",
|
"nodemailer": "6.9.16",
|
||||||
"oslo": "1.2.1",
|
"oslo": "1.2.1",
|
||||||
|
|
|
@ -26,7 +26,7 @@ export async function sendResourceOtpEmail(
|
||||||
}),
|
}),
|
||||||
{
|
{
|
||||||
to: email,
|
to: email,
|
||||||
from: config.getRawConfig().email?.no_reply,
|
from: config.getNoReplyEmail(),
|
||||||
subject: `Your one-time code to access ${resourceName}`
|
subject: `Your one-time code to access ${resourceName}`
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
|
@ -21,7 +21,7 @@ export async function sendEmailVerificationCode(
|
||||||
}),
|
}),
|
||||||
{
|
{
|
||||||
to: email,
|
to: email,
|
||||||
from: config.getRawConfig().email?.no_reply,
|
from: config.getNoReplyEmail(),
|
||||||
subject: "Verify your email address"
|
subject: "Verify your email address"
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
|
@ -3,7 +3,13 @@ import {
|
||||||
encodeHexLowerCase
|
encodeHexLowerCase
|
||||||
} from "@oslojs/encoding";
|
} from "@oslojs/encoding";
|
||||||
import { sha256 } from "@oslojs/crypto/sha2";
|
import { sha256 } from "@oslojs/crypto/sha2";
|
||||||
import { Session, sessions, User, users } from "@server/db/schema";
|
import {
|
||||||
|
resourceSessions,
|
||||||
|
Session,
|
||||||
|
sessions,
|
||||||
|
User,
|
||||||
|
users
|
||||||
|
} from "@server/db/schema";
|
||||||
import db from "@server/db";
|
import db from "@server/db";
|
||||||
import { eq } from "drizzle-orm";
|
import { eq } from "drizzle-orm";
|
||||||
import config from "@server/lib/config";
|
import config from "@server/lib/config";
|
||||||
|
@ -13,9 +19,14 @@ import logger from "@server/logger";
|
||||||
|
|
||||||
export const SESSION_COOKIE_NAME =
|
export const SESSION_COOKIE_NAME =
|
||||||
config.getRawConfig().server.session_cookie_name;
|
config.getRawConfig().server.session_cookie_name;
|
||||||
export const SESSION_COOKIE_EXPIRES = 1000 * 60 * 60 * 24 * 30;
|
export const SESSION_COOKIE_EXPIRES =
|
||||||
|
1000 *
|
||||||
|
60 *
|
||||||
|
60 *
|
||||||
|
config.getRawConfig().server.dashboard_session_length_hours;
|
||||||
export const SECURE_COOKIES = config.getRawConfig().server.secure_cookies;
|
export const SECURE_COOKIES = config.getRawConfig().server.secure_cookies;
|
||||||
export const COOKIE_DOMAIN = "." + config.getBaseDomain();
|
export const COOKIE_DOMAIN =
|
||||||
|
"." + new URL(config.getRawConfig().app.dashboard_url).hostname;
|
||||||
|
|
||||||
export function generateSessionToken(): string {
|
export function generateSessionToken(): string {
|
||||||
const bytes = new Uint8Array(20);
|
const bytes = new Uint8Array(20);
|
||||||
|
@ -65,12 +76,21 @@ export async function validateSessionToken(
|
||||||
session.expiresAt = new Date(
|
session.expiresAt = new Date(
|
||||||
Date.now() + SESSION_COOKIE_EXPIRES
|
Date.now() + SESSION_COOKIE_EXPIRES
|
||||||
).getTime();
|
).getTime();
|
||||||
await db
|
await db.transaction(async (trx) => {
|
||||||
|
await trx
|
||||||
.update(sessions)
|
.update(sessions)
|
||||||
.set({
|
.set({
|
||||||
expiresAt: session.expiresAt
|
expiresAt: session.expiresAt
|
||||||
})
|
})
|
||||||
.where(eq(sessions.sessionId, session.sessionId));
|
.where(eq(sessions.sessionId, session.sessionId));
|
||||||
|
|
||||||
|
await trx
|
||||||
|
.update(resourceSessions)
|
||||||
|
.set({
|
||||||
|
expiresAt: session.expiresAt
|
||||||
|
})
|
||||||
|
.where(eq(resourceSessions.userSessionId, session.sessionId));
|
||||||
|
});
|
||||||
}
|
}
|
||||||
return { session, user };
|
return { session, user };
|
||||||
}
|
}
|
||||||
|
@ -90,9 +110,9 @@ export function serializeSessionCookie(
|
||||||
if (isSecure) {
|
if (isSecure) {
|
||||||
logger.debug("Setting cookie for secure origin");
|
logger.debug("Setting cookie for secure origin");
|
||||||
if (SECURE_COOKIES) {
|
if (SECURE_COOKIES) {
|
||||||
return `${SESSION_COOKIE_NAME}=${token}; HttpOnly; SameSite=Strict; Max-Age=${SESSION_COOKIE_EXPIRES}; Path=/; Secure; Domain=${COOKIE_DOMAIN}`;
|
return `${SESSION_COOKIE_NAME}=${token}; HttpOnly; SameSite=Strict; Max-Age=${SESSION_COOKIE_EXPIRES / 1000}; Path=/; Secure; Domain=${COOKIE_DOMAIN}`;
|
||||||
} else {
|
} else {
|
||||||
return `${SESSION_COOKIE_NAME}=${token}; HttpOnly; SameSite=Strict; Max-Age=${SESSION_COOKIE_EXPIRES}; Path=/; Domain=${COOKIE_DOMAIN}`;
|
return `${SESSION_COOKIE_NAME}=${token}; HttpOnly; SameSite=Strict; Max-Age=${SESSION_COOKIE_EXPIRES / 1000}; Path=/; Domain=${COOKIE_DOMAIN}`;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
return `${SESSION_COOKIE_NAME}=${token}; HttpOnly; SameSite=Lax; Max-Age=${SESSION_COOKIE_EXPIRES}; Path=/;`;
|
return `${SESSION_COOKIE_NAME}=${token}; HttpOnly; SameSite=Lax; Max-Age=${SESSION_COOKIE_EXPIRES}; Path=/;`;
|
||||||
|
|
|
@ -6,19 +6,20 @@ import { eq, and } from "drizzle-orm";
|
||||||
import config from "@server/lib/config";
|
import config from "@server/lib/config";
|
||||||
|
|
||||||
export const SESSION_COOKIE_NAME =
|
export const SESSION_COOKIE_NAME =
|
||||||
config.getRawConfig().server.resource_session_cookie_name;
|
config.getRawConfig().server.session_cookie_name;
|
||||||
export const SESSION_COOKIE_EXPIRES = 1000 * 60 * 60 * 24 * 30;
|
export const SESSION_COOKIE_EXPIRES =
|
||||||
|
1000 * 60 * 60 * config.getRawConfig().server.resource_session_length_hours;
|
||||||
export const SECURE_COOKIES = config.getRawConfig().server.secure_cookies;
|
export const SECURE_COOKIES = config.getRawConfig().server.secure_cookies;
|
||||||
export const COOKIE_DOMAIN = "." + config.getBaseDomain();
|
|
||||||
|
|
||||||
export async function createResourceSession(opts: {
|
export async function createResourceSession(opts: {
|
||||||
token: string;
|
token: string;
|
||||||
resourceId: number;
|
resourceId: number;
|
||||||
passwordId?: number;
|
isRequestToken?: boolean;
|
||||||
pincodeId?: number;
|
passwordId?: number | null;
|
||||||
whitelistId?: number;
|
pincodeId?: number | null;
|
||||||
accessTokenId?: string;
|
userSessionId?: string | null;
|
||||||
usedOtp?: boolean;
|
whitelistId?: number | null;
|
||||||
|
accessTokenId?: string | null;
|
||||||
doNotExtend?: boolean;
|
doNotExtend?: boolean;
|
||||||
expiresAt?: number | null;
|
expiresAt?: number | null;
|
||||||
sessionLength?: number | null;
|
sessionLength?: number | null;
|
||||||
|
@ -27,7 +28,8 @@ export async function createResourceSession(opts: {
|
||||||
!opts.passwordId &&
|
!opts.passwordId &&
|
||||||
!opts.pincodeId &&
|
!opts.pincodeId &&
|
||||||
!opts.whitelistId &&
|
!opts.whitelistId &&
|
||||||
!opts.accessTokenId
|
!opts.accessTokenId &&
|
||||||
|
!opts.userSessionId
|
||||||
) {
|
) {
|
||||||
throw new Error("Auth method must be provided");
|
throw new Error("Auth method must be provided");
|
||||||
}
|
}
|
||||||
|
@ -47,7 +49,9 @@ export async function createResourceSession(opts: {
|
||||||
pincodeId: opts.pincodeId || null,
|
pincodeId: opts.pincodeId || null,
|
||||||
whitelistId: opts.whitelistId || null,
|
whitelistId: opts.whitelistId || null,
|
||||||
doNotExtend: opts.doNotExtend || false,
|
doNotExtend: opts.doNotExtend || false,
|
||||||
accessTokenId: opts.accessTokenId || null
|
accessTokenId: opts.accessTokenId || null,
|
||||||
|
isRequestToken: opts.isRequestToken || false,
|
||||||
|
userSessionId: opts.userSessionId || null
|
||||||
};
|
};
|
||||||
|
|
||||||
await db.insert(resourceSessions).values(session);
|
await db.insert(resourceSessions).values(session);
|
||||||
|
@ -162,22 +166,25 @@ export async function invalidateAllSessions(
|
||||||
|
|
||||||
export function serializeResourceSessionCookie(
|
export function serializeResourceSessionCookie(
|
||||||
cookieName: string,
|
cookieName: string,
|
||||||
token: string
|
domain: string,
|
||||||
|
token: string,
|
||||||
|
isHttp: boolean = false
|
||||||
): string {
|
): string {
|
||||||
if (SECURE_COOKIES) {
|
if (SECURE_COOKIES && !isHttp) {
|
||||||
return `${cookieName}=${token}; HttpOnly; SameSite=Strict; Max-Age=${SESSION_COOKIE_EXPIRES}; Path=/; Secure; Domain=${COOKIE_DOMAIN}`;
|
return `${cookieName}_s=${token}; HttpOnly; SameSite=Strict; Max-Age=${SESSION_COOKIE_EXPIRES / 1000}; Path=/; Secure; Domain=${"." + domain}`;
|
||||||
} else {
|
} else {
|
||||||
return `${cookieName}=${token}; HttpOnly; SameSite=Strict; Max-Age=${SESSION_COOKIE_EXPIRES}; Path=/; Domain=${COOKIE_DOMAIN}`;
|
return `${cookieName}=${token}; HttpOnly; SameSite=Strict; Max-Age=${SESSION_COOKIE_EXPIRES / 1000}; Path=/; Domain=${"." + domain}`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createBlankResourceSessionTokenCookie(
|
export function createBlankResourceSessionTokenCookie(
|
||||||
cookieName: string
|
cookieName: string,
|
||||||
|
domain: string
|
||||||
): string {
|
): string {
|
||||||
if (SECURE_COOKIES) {
|
if (SECURE_COOKIES) {
|
||||||
return `${cookieName}=; HttpOnly; SameSite=Strict; Max-Age=0; Path=/; Secure; Domain=${COOKIE_DOMAIN}`;
|
return `${cookieName}_s=; HttpOnly; SameSite=Strict; Max-Age=0; Path=/; Secure; Domain=${"." + domain}`;
|
||||||
} else {
|
} else {
|
||||||
return `${cookieName}=; HttpOnly; SameSite=Strict; Max-Age=0; Path=/; Domain=${COOKIE_DOMAIN}`;
|
return `${cookieName}=; HttpOnly; SameSite=Strict; Max-Age=0; Path=/; Domain=${"." + domain}`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -41,13 +41,16 @@ export const resources = sqliteTable("resources", {
|
||||||
})
|
})
|
||||||
.notNull(),
|
.notNull(),
|
||||||
name: text("name").notNull(),
|
name: text("name").notNull(),
|
||||||
subdomain: text("subdomain").notNull(),
|
subdomain: text("subdomain"),
|
||||||
fullDomain: text("fullDomain").notNull().unique(),
|
fullDomain: text("fullDomain"),
|
||||||
ssl: integer("ssl", { mode: "boolean" }).notNull().default(false),
|
ssl: integer("ssl", { mode: "boolean" }).notNull().default(false),
|
||||||
blockAccess: integer("blockAccess", { mode: "boolean" })
|
blockAccess: integer("blockAccess", { mode: "boolean" })
|
||||||
.notNull()
|
.notNull()
|
||||||
.default(false),
|
.default(false),
|
||||||
sso: integer("sso", { mode: "boolean" }).notNull().default(true),
|
sso: integer("sso", { mode: "boolean" }).notNull().default(true),
|
||||||
|
http: integer("http", { mode: "boolean" }).notNull().default(true),
|
||||||
|
protocol: text("protocol").notNull(),
|
||||||
|
proxyPort: integer("proxyPort"),
|
||||||
emailWhitelistEnabled: integer("emailWhitelistEnabled", { mode: "boolean" })
|
emailWhitelistEnabled: integer("emailWhitelistEnabled", { mode: "boolean" })
|
||||||
.notNull()
|
.notNull()
|
||||||
.default(false)
|
.default(false)
|
||||||
|
@ -61,10 +64,9 @@ export const targets = sqliteTable("targets", {
|
||||||
})
|
})
|
||||||
.notNull(),
|
.notNull(),
|
||||||
ip: text("ip").notNull(),
|
ip: text("ip").notNull(),
|
||||||
method: text("method").notNull(),
|
method: text("method"),
|
||||||
port: integer("port").notNull(),
|
port: integer("port").notNull(),
|
||||||
internalPort: integer("internalPort"),
|
internalPort: integer("internalPort"),
|
||||||
protocol: text("protocol"),
|
|
||||||
enabled: integer("enabled", { mode: "boolean" }).notNull().default(true)
|
enabled: integer("enabled", { mode: "boolean" }).notNull().default(true)
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -313,6 +315,10 @@ export const resourceSessions = sqliteTable("resourceSessions", {
|
||||||
doNotExtend: integer("doNotExtend", { mode: "boolean" })
|
doNotExtend: integer("doNotExtend", { mode: "boolean" })
|
||||||
.notNull()
|
.notNull()
|
||||||
.default(false),
|
.default(false),
|
||||||
|
isRequestToken: integer("isRequestToken", { mode: "boolean" }),
|
||||||
|
userSessionId: text("userSessionId").references(() => sessions.sessionId, {
|
||||||
|
onDelete: "cascade"
|
||||||
|
}),
|
||||||
passwordId: integer("passwordId").references(
|
passwordId: integer("passwordId").references(
|
||||||
() => resourcePassword.passwordId,
|
() => resourcePassword.passwordId,
|
||||||
{
|
{
|
||||||
|
|
|
@ -6,26 +6,21 @@ import logger from "@server/logger";
|
||||||
|
|
||||||
function createEmailClient() {
|
function createEmailClient() {
|
||||||
const emailConfig = config.getRawConfig().email;
|
const emailConfig = config.getRawConfig().email;
|
||||||
if (
|
if (!emailConfig) {
|
||||||
!emailConfig?.smtp_host ||
|
|
||||||
!emailConfig?.smtp_pass ||
|
|
||||||
!emailConfig?.smtp_port ||
|
|
||||||
!emailConfig?.smtp_user
|
|
||||||
) {
|
|
||||||
logger.warn(
|
logger.warn(
|
||||||
"Email SMTP configuration is missing. Emails will not be sent.",
|
"Email SMTP configuration is missing. Emails will not be sent."
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
return nodemailer.createTransport({
|
return nodemailer.createTransport({
|
||||||
host: emailConfig.smtp_host,
|
host: emailConfig.smtp_host,
|
||||||
port: emailConfig.smtp_port,
|
port: emailConfig.smtp_port,
|
||||||
secure: false,
|
secure: emailConfig.smtp_secure || false,
|
||||||
auth: {
|
auth: {
|
||||||
user: emailConfig.smtp_user,
|
user: emailConfig.smtp_user,
|
||||||
pass: emailConfig.smtp_pass,
|
pass: emailConfig.smtp_pass
|
||||||
},
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -44,7 +44,7 @@ export const ResourceOTPCode = ({
|
||||||
<EmailLetterHead />
|
<EmailLetterHead />
|
||||||
|
|
||||||
<EmailHeading>
|
<EmailHeading>
|
||||||
Your One-Time Password for {resourceName}
|
Your One-Time Code for {resourceName}
|
||||||
</EmailHeading>
|
</EmailHeading>
|
||||||
|
|
||||||
<EmailGreeting>Hi {email || "there"},</EmailGreeting>
|
<EmailGreeting>Hi {email || "there"},</EmailGreeting>
|
||||||
|
|
|
@ -2,7 +2,7 @@ import { runSetupFunctions } from "./setup";
|
||||||
import { createApiServer } from "./apiServer";
|
import { createApiServer } from "./apiServer";
|
||||||
import { createNextServer } from "./nextServer";
|
import { createNextServer } from "./nextServer";
|
||||||
import { createInternalServer } from "./internalServer";
|
import { createInternalServer } from "./internalServer";
|
||||||
import { User, UserOrg } from "./db/schema";
|
import { Session, User, UserOrg } from "./db/schema";
|
||||||
|
|
||||||
async function startServers() {
|
async function startServers() {
|
||||||
await runSetupFunctions();
|
await runSetupFunctions();
|
||||||
|
@ -24,6 +24,7 @@ declare global {
|
||||||
namespace Express {
|
namespace Express {
|
||||||
interface Request {
|
interface Request {
|
||||||
user?: User;
|
user?: User;
|
||||||
|
session?: Session;
|
||||||
userOrg?: UserOrg;
|
userOrg?: UserOrg;
|
||||||
userOrgRoleId?: number;
|
userOrgRoleId?: number;
|
||||||
userOrgId?: string;
|
userOrgId?: string;
|
||||||
|
|
|
@ -37,9 +37,11 @@ const configSchema = z.object({
|
||||||
base_domain: hostnameSchema
|
base_domain: hostnameSchema
|
||||||
.optional()
|
.optional()
|
||||||
.transform(getEnvOrYaml("APP_BASEDOMAIN"))
|
.transform(getEnvOrYaml("APP_BASEDOMAIN"))
|
||||||
.pipe(hostnameSchema),
|
.pipe(hostnameSchema)
|
||||||
|
.transform((url) => url.toLowerCase()),
|
||||||
log_level: z.enum(["debug", "info", "warn", "error"]),
|
log_level: z.enum(["debug", "info", "warn", "error"]),
|
||||||
save_logs: z.boolean()
|
save_logs: z.boolean(),
|
||||||
|
log_failed_attempts: z.boolean().optional()
|
||||||
}),
|
}),
|
||||||
server: z.object({
|
server: z.object({
|
||||||
external_port: portSchema
|
external_port: portSchema
|
||||||
|
@ -60,8 +62,20 @@ const configSchema = z.object({
|
||||||
internal_hostname: z.string().transform((url) => url.toLowerCase()),
|
internal_hostname: z.string().transform((url) => url.toLowerCase()),
|
||||||
secure_cookies: z.boolean(),
|
secure_cookies: z.boolean(),
|
||||||
session_cookie_name: z.string(),
|
session_cookie_name: z.string(),
|
||||||
resource_session_cookie_name: z.string(),
|
|
||||||
resource_access_token_param: z.string(),
|
resource_access_token_param: z.string(),
|
||||||
|
resource_session_request_param: z.string(),
|
||||||
|
dashboard_session_length_hours: z
|
||||||
|
.number()
|
||||||
|
.positive()
|
||||||
|
.gt(0)
|
||||||
|
.optional()
|
||||||
|
.default(720),
|
||||||
|
resource_session_length_hours: z
|
||||||
|
.number()
|
||||||
|
.positive()
|
||||||
|
.gt(0)
|
||||||
|
.optional()
|
||||||
|
.default(720),
|
||||||
cors: z
|
cors: z
|
||||||
.object({
|
.object({
|
||||||
origins: z.array(z.string()).optional(),
|
origins: z.array(z.string()).optional(),
|
||||||
|
@ -76,7 +90,8 @@ const configSchema = z.object({
|
||||||
http_entrypoint: z.string(),
|
http_entrypoint: z.string(),
|
||||||
https_entrypoint: z.string().optional(),
|
https_entrypoint: z.string().optional(),
|
||||||
cert_resolver: z.string().optional(),
|
cert_resolver: z.string().optional(),
|
||||||
prefer_wildcard_cert: z.boolean().optional()
|
prefer_wildcard_cert: z.boolean().optional(),
|
||||||
|
additional_middlewares: z.array(z.string()).optional()
|
||||||
}),
|
}),
|
||||||
gerbil: z.object({
|
gerbil: z.object({
|
||||||
start_port: portSchema
|
start_port: portSchema
|
||||||
|
@ -109,11 +124,12 @@ const configSchema = z.object({
|
||||||
}),
|
}),
|
||||||
email: z
|
email: z
|
||||||
.object({
|
.object({
|
||||||
smtp_host: z.string(),
|
smtp_host: z.string().optional(),
|
||||||
smtp_port: portSchema,
|
smtp_port: portSchema.optional(),
|
||||||
smtp_user: z.string(),
|
smtp_user: z.string().optional(),
|
||||||
smtp_pass: z.string(),
|
smtp_pass: z.string().optional(),
|
||||||
no_reply: z.string().email()
|
smtp_secure: z.boolean().optional(),
|
||||||
|
no_reply: z.string().email().optional()
|
||||||
})
|
})
|
||||||
.optional(),
|
.optional(),
|
||||||
users: z.object({
|
users: z.object({
|
||||||
|
@ -123,7 +139,8 @@ const configSchema = z.object({
|
||||||
.email()
|
.email()
|
||||||
.optional()
|
.optional()
|
||||||
.transform(getEnvOrYaml("USERS_SERVERADMIN_EMAIL"))
|
.transform(getEnvOrYaml("USERS_SERVERADMIN_EMAIL"))
|
||||||
.pipe(z.string().email()),
|
.pipe(z.string().email())
|
||||||
|
.transform((v) => v.toLowerCase()),
|
||||||
password: passwordSchema
|
password: passwordSchema
|
||||||
.optional()
|
.optional()
|
||||||
.transform(getEnvOrYaml("USERS_SERVERADMIN_PASSWORD"))
|
.transform(getEnvOrYaml("USERS_SERVERADMIN_PASSWORD"))
|
||||||
|
@ -134,7 +151,8 @@ const configSchema = z.object({
|
||||||
.object({
|
.object({
|
||||||
require_email_verification: z.boolean().optional(),
|
require_email_verification: z.boolean().optional(),
|
||||||
disable_signup_without_invite: z.boolean().optional(),
|
disable_signup_without_invite: z.boolean().optional(),
|
||||||
disable_user_create_org: z.boolean().optional()
|
disable_user_create_org: z.boolean().optional(),
|
||||||
|
allow_raw_resources: z.boolean().optional()
|
||||||
})
|
})
|
||||||
.optional()
|
.optional()
|
||||||
});
|
});
|
||||||
|
@ -237,10 +255,12 @@ export class Config {
|
||||||
?.require_email_verification
|
?.require_email_verification
|
||||||
? "true"
|
? "true"
|
||||||
: "false";
|
: "false";
|
||||||
|
process.env.FLAGS_ALLOW_RAW_RESOURCES = parsedConfig.data.flags
|
||||||
|
?.allow_raw_resources
|
||||||
|
? "true"
|
||||||
|
: "false";
|
||||||
process.env.SESSION_COOKIE_NAME =
|
process.env.SESSION_COOKIE_NAME =
|
||||||
parsedConfig.data.server.session_cookie_name;
|
parsedConfig.data.server.session_cookie_name;
|
||||||
process.env.RESOURCE_SESSION_COOKIE_NAME =
|
|
||||||
parsedConfig.data.server.resource_session_cookie_name;
|
|
||||||
process.env.EMAIL_ENABLED = parsedConfig.data.email ? "true" : "false";
|
process.env.EMAIL_ENABLED = parsedConfig.data.email ? "true" : "false";
|
||||||
process.env.DISABLE_SIGNUP_WITHOUT_INVITE = parsedConfig.data.flags
|
process.env.DISABLE_SIGNUP_WITHOUT_INVITE = parsedConfig.data.flags
|
||||||
?.disable_signup_without_invite
|
?.disable_signup_without_invite
|
||||||
|
@ -252,6 +272,8 @@ export class Config {
|
||||||
: "false";
|
: "false";
|
||||||
process.env.RESOURCE_ACCESS_TOKEN_PARAM =
|
process.env.RESOURCE_ACCESS_TOKEN_PARAM =
|
||||||
parsedConfig.data.server.resource_access_token_param;
|
parsedConfig.data.server.resource_access_token_param;
|
||||||
|
process.env.RESOURCE_SESSION_REQUEST_PARAM =
|
||||||
|
parsedConfig.data.server.resource_session_request_param;
|
||||||
|
|
||||||
this.rawConfig = parsedConfig.data;
|
this.rawConfig = parsedConfig.data;
|
||||||
}
|
}
|
||||||
|
@ -264,6 +286,12 @@ export class Config {
|
||||||
return this.rawConfig.app.base_domain;
|
return this.rawConfig.app.base_domain;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public getNoReplyEmail(): string | undefined {
|
||||||
|
return (
|
||||||
|
this.rawConfig.email?.no_reply || this.rawConfig.email?.smtp_user
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
private createTraefikConfig() {
|
private createTraefikConfig() {
|
||||||
try {
|
try {
|
||||||
// check if traefik_config.yml and dynamic_config.yml exists in APP_PATH/traefik
|
// check if traefik_config.yml and dynamic_config.yml exists in APP_PATH/traefik
|
||||||
|
|
|
@ -13,7 +13,7 @@ export async function verifyAdmin(
|
||||||
const userId = req.user?.userId;
|
const userId = req.user?.userId;
|
||||||
const orgId = req.userOrgId;
|
const orgId = req.userOrgId;
|
||||||
|
|
||||||
if (!userId) {
|
if (!orgId) {
|
||||||
return next(
|
return next(
|
||||||
createHttpError(HttpCode.UNAUTHORIZED, "User does not have orgId")
|
createHttpError(HttpCode.UNAUTHORIZED, "User does not have orgId")
|
||||||
);
|
);
|
||||||
|
|
|
@ -8,6 +8,7 @@ import HttpCode from "@server/types/HttpCode";
|
||||||
import config from "@server/lib/config";
|
import config from "@server/lib/config";
|
||||||
import { verifySession } from "@server/auth/sessions/verifySession";
|
import { verifySession } from "@server/auth/sessions/verifySession";
|
||||||
import { unauthorized } from "@server/auth/unauthorizedResponse";
|
import { unauthorized } from "@server/auth/unauthorizedResponse";
|
||||||
|
import logger from "@server/logger";
|
||||||
|
|
||||||
export const verifySessionUserMiddleware = async (
|
export const verifySessionUserMiddleware = async (
|
||||||
req: any,
|
req: any,
|
||||||
|
@ -16,6 +17,9 @@ export const verifySessionUserMiddleware = async (
|
||||||
) => {
|
) => {
|
||||||
const { session, user } = await verifySession(req);
|
const { session, user } = await verifySession(req);
|
||||||
if (!session || !user) {
|
if (!session || !user) {
|
||||||
|
if (config.getRawConfig().app.log_failed_attempts) {
|
||||||
|
logger.info(`User session not found. IP: ${req.ip}.`);
|
||||||
|
}
|
||||||
return next(unauthorized());
|
return next(unauthorized());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -25,6 +29,9 @@ export const verifySessionUserMiddleware = async (
|
||||||
.where(eq(users.userId, user.userId));
|
.where(eq(users.userId, user.userId));
|
||||||
|
|
||||||
if (!existingUser || !existingUser[0]) {
|
if (!existingUser || !existingUser[0]) {
|
||||||
|
if (config.getRawConfig().app.log_failed_attempts) {
|
||||||
|
logger.info(`User session not found. IP: ${req.ip}.`);
|
||||||
|
}
|
||||||
return next(
|
return next(
|
||||||
createHttpError(HttpCode.BAD_REQUEST, "User does not exist")
|
createHttpError(HttpCode.BAD_REQUEST, "User does not exist")
|
||||||
);
|
);
|
||||||
|
|
|
@ -79,6 +79,11 @@ export async function disable2fa(
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!validOTP) {
|
if (!validOTP) {
|
||||||
|
if (config.getRawConfig().app.log_failed_attempts) {
|
||||||
|
logger.info(
|
||||||
|
`Two-factor authentication code is incorrect. Email: ${user.email}. IP: ${req.ip}.`
|
||||||
|
);
|
||||||
|
}
|
||||||
return next(
|
return next(
|
||||||
createHttpError(
|
createHttpError(
|
||||||
HttpCode.BAD_REQUEST,
|
HttpCode.BAD_REQUEST,
|
||||||
|
|
|
@ -20,7 +20,10 @@ import { verifySession } from "@server/auth/sessions/verifySession";
|
||||||
|
|
||||||
export const loginBodySchema = z
|
export const loginBodySchema = z
|
||||||
.object({
|
.object({
|
||||||
email: z.string().email(),
|
email: z
|
||||||
|
.string()
|
||||||
|
.email()
|
||||||
|
.transform((v) => v.toLowerCase()),
|
||||||
password: z.string(),
|
password: z.string(),
|
||||||
code: z.string().optional()
|
code: z.string().optional()
|
||||||
})
|
})
|
||||||
|
@ -68,6 +71,11 @@ export async function login(
|
||||||
.from(users)
|
.from(users)
|
||||||
.where(eq(users.email, email));
|
.where(eq(users.email, email));
|
||||||
if (!existingUserRes || !existingUserRes.length) {
|
if (!existingUserRes || !existingUserRes.length) {
|
||||||
|
if (config.getRawConfig().app.log_failed_attempts) {
|
||||||
|
logger.info(
|
||||||
|
`Username or password incorrect. Email: ${email}. IP: ${req.ip}.`
|
||||||
|
);
|
||||||
|
}
|
||||||
return next(
|
return next(
|
||||||
createHttpError(
|
createHttpError(
|
||||||
HttpCode.BAD_REQUEST,
|
HttpCode.BAD_REQUEST,
|
||||||
|
@ -83,6 +91,11 @@ export async function login(
|
||||||
existingUser.passwordHash
|
existingUser.passwordHash
|
||||||
);
|
);
|
||||||
if (!validPassword) {
|
if (!validPassword) {
|
||||||
|
if (config.getRawConfig().app.log_failed_attempts) {
|
||||||
|
logger.info(
|
||||||
|
`Username or password incorrect. Email: ${email}. IP: ${req.ip}.`
|
||||||
|
);
|
||||||
|
}
|
||||||
return next(
|
return next(
|
||||||
createHttpError(
|
createHttpError(
|
||||||
HttpCode.BAD_REQUEST,
|
HttpCode.BAD_REQUEST,
|
||||||
|
@ -109,6 +122,11 @@ export async function login(
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!validOTP) {
|
if (!validOTP) {
|
||||||
|
if (config.getRawConfig().app.log_failed_attempts) {
|
||||||
|
logger.info(
|
||||||
|
`Two-factor code incorrect. Email: ${email}. IP: ${req.ip}.`
|
||||||
|
);
|
||||||
|
}
|
||||||
return next(
|
return next(
|
||||||
createHttpError(
|
createHttpError(
|
||||||
HttpCode.BAD_REQUEST,
|
HttpCode.BAD_REQUEST,
|
||||||
|
|
|
@ -5,18 +5,23 @@ import response from "@server/lib/response";
|
||||||
import logger from "@server/logger";
|
import logger from "@server/logger";
|
||||||
import {
|
import {
|
||||||
createBlankSessionTokenCookie,
|
createBlankSessionTokenCookie,
|
||||||
invalidateSession,
|
invalidateSession
|
||||||
SESSION_COOKIE_NAME
|
|
||||||
} from "@server/auth/sessions/app";
|
} from "@server/auth/sessions/app";
|
||||||
|
import { verifySession } from "@server/auth/sessions/verifySession";
|
||||||
|
import config from "@server/lib/config";
|
||||||
|
|
||||||
export async function logout(
|
export async function logout(
|
||||||
req: Request,
|
req: Request,
|
||||||
res: Response,
|
res: Response,
|
||||||
next: NextFunction
|
next: NextFunction
|
||||||
): Promise<any> {
|
): Promise<any> {
|
||||||
const sessionId = req.cookies[SESSION_COOKIE_NAME];
|
const { user, session } = await verifySession(req);
|
||||||
|
if (!user || !session) {
|
||||||
if (!sessionId) {
|
if (config.getRawConfig().app.log_failed_attempts) {
|
||||||
|
logger.info(
|
||||||
|
`Log out failed because missing or invalid session. IP: ${req.ip}.`
|
||||||
|
);
|
||||||
|
}
|
||||||
return next(
|
return next(
|
||||||
createHttpError(
|
createHttpError(
|
||||||
HttpCode.BAD_REQUEST,
|
HttpCode.BAD_REQUEST,
|
||||||
|
@ -26,7 +31,7 @@ export async function logout(
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await invalidateSession(sessionId);
|
await invalidateSession(session.sessionId);
|
||||||
const isSecure = req.protocol === "https";
|
const isSecure = req.protocol === "https";
|
||||||
res.setHeader("Set-Cookie", createBlankSessionTokenCookie(isSecure));
|
res.setHeader("Set-Cookie", createBlankSessionTokenCookie(isSecure));
|
||||||
|
|
||||||
|
|
|
@ -20,7 +20,10 @@ import { hashPassword } from "@server/auth/password";
|
||||||
|
|
||||||
export const requestPasswordResetBody = z
|
export const requestPasswordResetBody = z
|
||||||
.object({
|
.object({
|
||||||
email: z.string().email()
|
email: z
|
||||||
|
.string()
|
||||||
|
.email()
|
||||||
|
.transform((v) => v.toLowerCase())
|
||||||
})
|
})
|
||||||
.strict();
|
.strict();
|
||||||
|
|
||||||
|
@ -63,10 +66,7 @@ export async function requestPasswordReset(
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const token = generateRandomString(
|
const token = generateRandomString(8, alphabet("0-9", "A-Z", "a-z"));
|
||||||
8,
|
|
||||||
alphabet("0-9", "A-Z", "a-z")
|
|
||||||
);
|
|
||||||
await db.transaction(async (trx) => {
|
await db.transaction(async (trx) => {
|
||||||
await trx
|
await trx
|
||||||
.delete(passwordResetTokens)
|
.delete(passwordResetTokens)
|
||||||
|
@ -84,6 +84,10 @@ export async function requestPasswordReset(
|
||||||
|
|
||||||
const url = `${config.getRawConfig().app.dashboard_url}/auth/reset-password?email=${email}&token=${token}`;
|
const url = `${config.getRawConfig().app.dashboard_url}/auth/reset-password?email=${email}&token=${token}`;
|
||||||
|
|
||||||
|
if (!config.getRawConfig().email) {
|
||||||
|
logger.info(`Password reset requested for ${email}. Token: ${token}.`);
|
||||||
|
}
|
||||||
|
|
||||||
await sendEmail(
|
await sendEmail(
|
||||||
ResetPasswordCode({
|
ResetPasswordCode({
|
||||||
email,
|
email,
|
||||||
|
@ -91,7 +95,7 @@ export async function requestPasswordReset(
|
||||||
link: url
|
link: url
|
||||||
}),
|
}),
|
||||||
{
|
{
|
||||||
from: config.getRawConfig().email?.no_reply,
|
from: config.getNoReplyEmail(),
|
||||||
to: email,
|
to: email,
|
||||||
subject: "Reset your password"
|
subject: "Reset your password"
|
||||||
}
|
}
|
||||||
|
|
|
@ -19,7 +19,10 @@ import { passwordSchema } from "@server/auth/passwordSchema";
|
||||||
|
|
||||||
export const resetPasswordBody = z
|
export const resetPasswordBody = z
|
||||||
.object({
|
.object({
|
||||||
email: z.string().email(),
|
email: z
|
||||||
|
.string()
|
||||||
|
.email()
|
||||||
|
.transform((v) => v.toLowerCase()),
|
||||||
token: z.string(), // reset secret code
|
token: z.string(), // reset secret code
|
||||||
newPassword: passwordSchema,
|
newPassword: passwordSchema,
|
||||||
code: z.string().optional() // 2fa code
|
code: z.string().optional() // 2fa code
|
||||||
|
@ -57,6 +60,11 @@ export async function resetPassword(
|
||||||
.where(eq(passwordResetTokens.email, email));
|
.where(eq(passwordResetTokens.email, email));
|
||||||
|
|
||||||
if (!resetRequest || !resetRequest.length) {
|
if (!resetRequest || !resetRequest.length) {
|
||||||
|
if (config.getRawConfig().app.log_failed_attempts) {
|
||||||
|
logger.info(
|
||||||
|
`Password reset code is incorrect. Email: ${email}. IP: ${req.ip}.`
|
||||||
|
);
|
||||||
|
}
|
||||||
return next(
|
return next(
|
||||||
createHttpError(
|
createHttpError(
|
||||||
HttpCode.BAD_REQUEST,
|
HttpCode.BAD_REQUEST,
|
||||||
|
@ -106,6 +114,11 @@ export async function resetPassword(
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!validOTP) {
|
if (!validOTP) {
|
||||||
|
if (config.getRawConfig().app.log_failed_attempts) {
|
||||||
|
logger.info(
|
||||||
|
`Two-factor authentication code is incorrect. Email: ${email}. IP: ${req.ip}.`
|
||||||
|
);
|
||||||
|
}
|
||||||
return next(
|
return next(
|
||||||
createHttpError(
|
createHttpError(
|
||||||
HttpCode.BAD_REQUEST,
|
HttpCode.BAD_REQUEST,
|
||||||
|
@ -121,6 +134,11 @@ export async function resetPassword(
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!isTokenValid) {
|
if (!isTokenValid) {
|
||||||
|
if (config.getRawConfig().app.log_failed_attempts) {
|
||||||
|
logger.info(
|
||||||
|
`Password reset code is incorrect. Email: ${email}. IP: ${req.ip}.`
|
||||||
|
);
|
||||||
|
}
|
||||||
return next(
|
return next(
|
||||||
createHttpError(
|
createHttpError(
|
||||||
HttpCode.BAD_REQUEST,
|
HttpCode.BAD_REQUEST,
|
||||||
|
@ -145,7 +163,7 @@ export async function resetPassword(
|
||||||
});
|
});
|
||||||
|
|
||||||
await sendEmail(ConfirmPasswordReset({ email }), {
|
await sendEmail(ConfirmPasswordReset({ email }), {
|
||||||
from: config.getRawConfig().email?.no_reply,
|
from: config.getNoReplyEmail(),
|
||||||
to: email,
|
to: email,
|
||||||
subject: "Password Reset Confirmation"
|
subject: "Password Reset Confirmation"
|
||||||
});
|
});
|
||||||
|
|
|
@ -23,7 +23,10 @@ import { checkValidInvite } from "@server/auth/checkValidInvite";
|
||||||
import { passwordSchema } from "@server/auth/passwordSchema";
|
import { passwordSchema } from "@server/auth/passwordSchema";
|
||||||
|
|
||||||
export const signupBodySchema = z.object({
|
export const signupBodySchema = z.object({
|
||||||
email: z.string().email(),
|
email: z
|
||||||
|
.string()
|
||||||
|
.email()
|
||||||
|
.transform((v) => v.toLowerCase()),
|
||||||
password: passwordSchema,
|
password: passwordSchema,
|
||||||
inviteToken: z.string().optional(),
|
inviteToken: z.string().optional(),
|
||||||
inviteId: z.string().optional()
|
inviteId: z.string().optional()
|
||||||
|
@ -60,6 +63,11 @@ export async function signup(
|
||||||
|
|
||||||
if (config.getRawConfig().flags?.disable_signup_without_invite) {
|
if (config.getRawConfig().flags?.disable_signup_without_invite) {
|
||||||
if (!inviteToken || !inviteId) {
|
if (!inviteToken || !inviteId) {
|
||||||
|
if (config.getRawConfig().app.log_failed_attempts) {
|
||||||
|
logger.info(
|
||||||
|
`Signup blocked without invite. Email: ${email}. IP: ${req.ip}.`
|
||||||
|
);
|
||||||
|
}
|
||||||
return next(
|
return next(
|
||||||
createHttpError(
|
createHttpError(
|
||||||
HttpCode.BAD_REQUEST,
|
HttpCode.BAD_REQUEST,
|
||||||
|
@ -84,6 +92,11 @@ export async function signup(
|
||||||
}
|
}
|
||||||
|
|
||||||
if (existingInvite.email !== email) {
|
if (existingInvite.email !== email) {
|
||||||
|
if (config.getRawConfig().app.log_failed_attempts) {
|
||||||
|
logger.info(
|
||||||
|
`User attempted to use an invite for another user. Email: ${email}. IP: ${req.ip}.`
|
||||||
|
);
|
||||||
|
}
|
||||||
return next(
|
return next(
|
||||||
createHttpError(
|
createHttpError(
|
||||||
HttpCode.BAD_REQUEST,
|
HttpCode.BAD_REQUEST,
|
||||||
|
@ -185,6 +198,11 @@ export async function signup(
|
||||||
});
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (e instanceof SqliteError && e.code === "SQLITE_CONSTRAINT_UNIQUE") {
|
if (e instanceof SqliteError && e.code === "SQLITE_CONSTRAINT_UNIQUE") {
|
||||||
|
if (config.getRawConfig().app.log_failed_attempts) {
|
||||||
|
logger.info(
|
||||||
|
`Account already exists with that email. Email: ${email}. IP: ${req.ip}.`
|
||||||
|
);
|
||||||
|
}
|
||||||
return next(
|
return next(
|
||||||
createHttpError(
|
createHttpError(
|
||||||
HttpCode.BAD_REQUEST,
|
HttpCode.BAD_REQUEST,
|
||||||
|
|
|
@ -75,6 +75,11 @@ export async function verifyEmail(
|
||||||
.where(eq(users.userId, user.userId));
|
.where(eq(users.userId, user.userId));
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
|
if (config.getRawConfig().app.log_failed_attempts) {
|
||||||
|
logger.info(
|
||||||
|
`Email verification code incorrect. Email: ${user.email}. IP: ${req.ip}.`
|
||||||
|
);
|
||||||
|
}
|
||||||
return next(
|
return next(
|
||||||
createHttpError(
|
createHttpError(
|
||||||
HttpCode.BAD_REQUEST,
|
HttpCode.BAD_REQUEST,
|
||||||
|
|
|
@ -96,6 +96,11 @@ export async function verifyTotp(
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!valid) {
|
if (!valid) {
|
||||||
|
if (config.getRawConfig().app.log_failed_attempts) {
|
||||||
|
logger.info(
|
||||||
|
`Two-factor authentication code is incorrect. Email: ${user.email}. IP: ${req.ip}.`
|
||||||
|
);
|
||||||
|
}
|
||||||
return next(
|
return next(
|
||||||
createHttpError(
|
createHttpError(
|
||||||
HttpCode.BAD_REQUEST,
|
HttpCode.BAD_REQUEST,
|
||||||
|
|
188
server/routers/badger/exchangeSession.ts
Normal file
188
server/routers/badger/exchangeSession.ts
Normal 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"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
|
@ -1 +1,2 @@
|
||||||
export * from "./verifySession";
|
export * from "./verifySession";
|
||||||
|
export * from "./exchangeSession";
|
||||||
|
|
|
@ -4,17 +4,17 @@ import createHttpError from "http-errors";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { fromError } from "zod-validation-error";
|
import { fromError } from "zod-validation-error";
|
||||||
import { response } from "@server/lib/response";
|
import { response } from "@server/lib/response";
|
||||||
import { validateSessionToken } from "@server/auth/sessions/app";
|
|
||||||
import db from "@server/db";
|
import db from "@server/db";
|
||||||
import {
|
import {
|
||||||
ResourceAccessToken,
|
ResourceAccessToken,
|
||||||
resourceAccessToken,
|
ResourcePassword,
|
||||||
resourcePassword,
|
resourcePassword,
|
||||||
|
ResourcePincode,
|
||||||
resourcePincode,
|
resourcePincode,
|
||||||
resources,
|
resources,
|
||||||
resourceWhitelist,
|
sessions,
|
||||||
User,
|
userOrgs,
|
||||||
userOrgs
|
users
|
||||||
} from "@server/db/schema";
|
} from "@server/db/schema";
|
||||||
import { and, eq } from "drizzle-orm";
|
import { and, eq } from "drizzle-orm";
|
||||||
import config from "@server/lib/config";
|
import config from "@server/lib/config";
|
||||||
|
@ -27,6 +27,12 @@ import { Resource, roleResources, userResources } from "@server/db/schema";
|
||||||
import logger from "@server/logger";
|
import logger from "@server/logger";
|
||||||
import { verifyResourceAccessToken } from "@server/auth/verifyResourceAccessToken";
|
import { verifyResourceAccessToken } from "@server/auth/verifyResourceAccessToken";
|
||||||
import { generateSessionToken } from "@server/auth";
|
import { generateSessionToken } from "@server/auth";
|
||||||
|
import NodeCache from "node-cache";
|
||||||
|
|
||||||
|
// We'll see if this speeds anything up
|
||||||
|
const cache = new NodeCache({
|
||||||
|
stdTTL: 5 // seconds
|
||||||
|
});
|
||||||
|
|
||||||
const verifyResourceSessionSchema = z.object({
|
const verifyResourceSessionSchema = z.object({
|
||||||
sessions: z.record(z.string()).optional(),
|
sessions: z.record(z.string()).optional(),
|
||||||
|
@ -36,7 +42,8 @@ const verifyResourceSessionSchema = z.object({
|
||||||
path: z.string(),
|
path: z.string(),
|
||||||
method: z.string(),
|
method: z.string(),
|
||||||
accessToken: z.string().optional(),
|
accessToken: z.string().optional(),
|
||||||
tls: z.boolean()
|
tls: z.boolean(),
|
||||||
|
requestIp: z.string().optional()
|
||||||
});
|
});
|
||||||
|
|
||||||
export type VerifyResourceSessionSchema = z.infer<
|
export type VerifyResourceSessionSchema = z.infer<
|
||||||
|
@ -53,7 +60,7 @@ export async function verifyResourceSession(
|
||||||
res: Response,
|
res: Response,
|
||||||
next: NextFunction
|
next: NextFunction
|
||||||
): Promise<any> {
|
): Promise<any> {
|
||||||
logger.debug("Badger sent", req.body); // remove when done testing
|
logger.debug("Verify session: Badger sent", req.body); // remove when done testing
|
||||||
|
|
||||||
const parsedBody = verifyResourceSessionSchema.safeParse(req.body);
|
const parsedBody = verifyResourceSessionSchema.safeParse(req.body);
|
||||||
|
|
||||||
|
@ -67,9 +74,26 @@ export async function verifyResourceSession(
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { sessions, host, originalRequestURL, accessToken: token } =
|
const {
|
||||||
parsedBody.data;
|
sessions,
|
||||||
|
host,
|
||||||
|
originalRequestURL,
|
||||||
|
requestIp,
|
||||||
|
accessToken: token
|
||||||
|
} = parsedBody.data;
|
||||||
|
|
||||||
|
const clientIp = requestIp?.split(":")[0];
|
||||||
|
|
||||||
|
const resourceCacheKey = `resource:${host}`;
|
||||||
|
let resourceData:
|
||||||
|
| {
|
||||||
|
resource: Resource | null;
|
||||||
|
pincode: ResourcePincode | null;
|
||||||
|
password: ResourcePassword | null;
|
||||||
|
}
|
||||||
|
| undefined = cache.get(resourceCacheKey);
|
||||||
|
|
||||||
|
if (!resourceData) {
|
||||||
const [result] = await db
|
const [result] = await db
|
||||||
.select()
|
.select()
|
||||||
.from(resources)
|
.from(resources)
|
||||||
|
@ -84,9 +108,21 @@ export async function verifyResourceSession(
|
||||||
.where(eq(resources.fullDomain, host))
|
.where(eq(resources.fullDomain, host))
|
||||||
.limit(1);
|
.limit(1);
|
||||||
|
|
||||||
const resource = result?.resources;
|
if (!result) {
|
||||||
const pincode = result?.resourcePincode;
|
logger.debug("Resource not found", host);
|
||||||
const password = result?.resourcePassword;
|
return notAllowed(res);
|
||||||
|
}
|
||||||
|
|
||||||
|
resourceData = {
|
||||||
|
resource: result.resources,
|
||||||
|
pincode: result.resourcePincode,
|
||||||
|
password: result.resourcePassword
|
||||||
|
};
|
||||||
|
|
||||||
|
cache.set(resourceCacheKey, resourceData);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { resource, pincode, password } = resourceData;
|
||||||
|
|
||||||
if (!resource) {
|
if (!resource) {
|
||||||
logger.debug("Resource not found", host);
|
logger.debug("Resource not found", host);
|
||||||
|
@ -128,6 +164,14 @@ export async function verifyResourceSession(
|
||||||
logger.debug("Access token invalid: " + error);
|
logger.debug("Access token invalid: " + error);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!valid) {
|
||||||
|
if (config.getRawConfig().app.log_failed_attempts) {
|
||||||
|
logger.info(
|
||||||
|
`Resource access token is invalid. Resource ID: ${resource.resourceId}. IP: ${clientIp}.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (valid && tokenItem) {
|
if (valid && tokenItem) {
|
||||||
validAccessToken = tokenItem;
|
validAccessToken = tokenItem;
|
||||||
|
|
||||||
|
@ -142,41 +186,45 @@ export async function verifyResourceSession(
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!sessions) {
|
if (!sessions) {
|
||||||
|
if (config.getRawConfig().app.log_failed_attempts) {
|
||||||
|
logger.info(
|
||||||
|
`Missing resource sessions. Resource ID: ${resource.resourceId}. IP: ${clientIp}.`
|
||||||
|
);
|
||||||
|
}
|
||||||
return notAllowed(res);
|
return notAllowed(res);
|
||||||
}
|
}
|
||||||
|
|
||||||
const sessionToken =
|
|
||||||
sessions[config.getRawConfig().server.session_cookie_name];
|
|
||||||
|
|
||||||
// check for unified login
|
|
||||||
if (sso && sessionToken) {
|
|
||||||
const { session, user } = await validateSessionToken(sessionToken);
|
|
||||||
if (session && user) {
|
|
||||||
const isAllowed = await isUserAllowedToAccessResource(
|
|
||||||
user,
|
|
||||||
resource
|
|
||||||
);
|
|
||||||
|
|
||||||
if (isAllowed) {
|
|
||||||
logger.debug(
|
|
||||||
"Resource allowed because user session is valid"
|
|
||||||
);
|
|
||||||
return allowed(res);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const resourceSessionToken =
|
const resourceSessionToken =
|
||||||
sessions[
|
sessions[
|
||||||
`${config.getRawConfig().server.resource_session_cookie_name}_${resource.resourceId}`
|
`${config.getRawConfig().server.session_cookie_name}${resource.ssl ? "_s" : ""}`
|
||||||
];
|
];
|
||||||
|
|
||||||
if (resourceSessionToken) {
|
if (resourceSessionToken) {
|
||||||
const { resourceSession } = await validateResourceSessionToken(
|
const sessionCacheKey = `session:${resourceSessionToken}`;
|
||||||
|
let resourceSession: any = cache.get(sessionCacheKey);
|
||||||
|
|
||||||
|
if (!resourceSession) {
|
||||||
|
const result = await validateResourceSessionToken(
|
||||||
resourceSessionToken,
|
resourceSessionToken,
|
||||||
resource.resourceId
|
resource.resourceId
|
||||||
);
|
);
|
||||||
|
|
||||||
|
resourceSession = result?.resourceSession;
|
||||||
|
cache.set(sessionCacheKey, resourceSession);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (resourceSession?.isRequestToken) {
|
||||||
|
logger.debug(
|
||||||
|
"Resource not allowed because session is a temporary request token"
|
||||||
|
);
|
||||||
|
if (config.getRawConfig().app.log_failed_attempts) {
|
||||||
|
logger.info(
|
||||||
|
`Resource session is an exchange token. Resource ID: ${resource.resourceId}. IP: ${clientIp}.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return notAllowed(res);
|
||||||
|
}
|
||||||
|
|
||||||
if (resourceSession) {
|
if (resourceSession) {
|
||||||
if (pincode && resourceSession.pincodeId) {
|
if (pincode && resourceSession.pincodeId) {
|
||||||
logger.debug(
|
logger.debug(
|
||||||
|
@ -208,6 +256,29 @@ export async function verifyResourceSession(
|
||||||
);
|
);
|
||||||
return allowed(res);
|
return allowed(res);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (resourceSession.userSessionId && sso) {
|
||||||
|
const userAccessCacheKey = `userAccess:${resourceSession.userSessionId}:${resource.resourceId}`;
|
||||||
|
|
||||||
|
let isAllowed: boolean | undefined =
|
||||||
|
cache.get(userAccessCacheKey);
|
||||||
|
|
||||||
|
if (isAllowed === undefined) {
|
||||||
|
isAllowed = await isUserAllowedToAccessResource(
|
||||||
|
resourceSession.userSessionId,
|
||||||
|
resource
|
||||||
|
);
|
||||||
|
|
||||||
|
cache.set(userAccessCacheKey, isAllowed);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isAllowed) {
|
||||||
|
logger.debug(
|
||||||
|
"Resource allowed because user session is valid"
|
||||||
|
);
|
||||||
|
return allowed(res);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -222,6 +293,12 @@ export async function verifyResourceSession(
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.debug("No more auth to check, resource not allowed");
|
logger.debug("No more auth to check, resource not allowed");
|
||||||
|
|
||||||
|
if (config.getRawConfig().app.log_failed_attempts) {
|
||||||
|
logger.info(
|
||||||
|
`Resource access not allowed. Resource ID: ${resource.resourceId}. IP: ${clientIp}.`
|
||||||
|
);
|
||||||
|
}
|
||||||
return notAllowed(res, redirectUrl);
|
return notAllowed(res, redirectUrl);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(e);
|
console.error(e);
|
||||||
|
@ -272,10 +349,15 @@ async function createAccessTokenSession(
|
||||||
expiresAt: tokenItem.expiresAt,
|
expiresAt: tokenItem.expiresAt,
|
||||||
doNotExtend: tokenItem.expiresAt ? true : false
|
doNotExtend: tokenItem.expiresAt ? true : false
|
||||||
});
|
});
|
||||||
const cookieName = `${config.getRawConfig().server.resource_session_cookie_name}_${resource.resourceId}`;
|
const cookieName = `${config.getRawConfig().server.session_cookie_name}`;
|
||||||
const cookie = serializeResourceSessionCookie(cookieName, token);
|
const cookie = serializeResourceSessionCookie(
|
||||||
|
cookieName,
|
||||||
|
resource.fullDomain!,
|
||||||
|
token,
|
||||||
|
!resource.ssl
|
||||||
|
);
|
||||||
res.appendHeader("Set-Cookie", cookie);
|
res.appendHeader("Set-Cookie", cookie);
|
||||||
logger.debug("Access token is valid, creating new session")
|
logger.debug("Access token is valid, creating new session");
|
||||||
return response<VerifyUserResponse>(res, {
|
return response<VerifyUserResponse>(res, {
|
||||||
data: { valid: true },
|
data: { valid: true },
|
||||||
success: true,
|
success: true,
|
||||||
|
@ -286,9 +368,22 @@ async function createAccessTokenSession(
|
||||||
}
|
}
|
||||||
|
|
||||||
async function isUserAllowedToAccessResource(
|
async function isUserAllowedToAccessResource(
|
||||||
user: User,
|
userSessionId: string,
|
||||||
resource: Resource
|
resource: Resource
|
||||||
): Promise<boolean> {
|
): Promise<boolean> {
|
||||||
|
const [res] = await db
|
||||||
|
.select()
|
||||||
|
.from(sessions)
|
||||||
|
.leftJoin(users, eq(users.userId, sessions.userId))
|
||||||
|
.where(eq(sessions.sessionId, userSessionId));
|
||||||
|
|
||||||
|
const user = res.user;
|
||||||
|
const session = res.session;
|
||||||
|
|
||||||
|
if (!user || !session) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
config.getRawConfig().flags?.require_email_verification &&
|
config.getRawConfig().flags?.require_email_verification &&
|
||||||
!user.emailVerified
|
!user.emailVerified
|
||||||
|
|
|
@ -1,9 +1,12 @@
|
||||||
import { Router } from "express";
|
import { Router } from "express";
|
||||||
import * as gerbil from "@server/routers/gerbil";
|
import * as gerbil from "@server/routers/gerbil";
|
||||||
import * as badger from "@server/routers/badger";
|
|
||||||
import * as traefik from "@server/routers/traefik";
|
import * as traefik from "@server/routers/traefik";
|
||||||
import * as auth from "@server/routers/auth";
|
import * as auth from "@server/routers/auth";
|
||||||
import HttpCode from "@server/types/HttpCode";
|
import HttpCode from "@server/types/HttpCode";
|
||||||
|
import { verifyResourceAccess, verifySessionUserMiddleware } from "@server/middlewares";
|
||||||
|
import { getExchangeToken } from "./resource/getExchangeToken";
|
||||||
|
import { verifyResourceSession } from "./badger";
|
||||||
|
import { exchangeSession } from "./badger/exchangeSession";
|
||||||
|
|
||||||
// Root routes
|
// Root routes
|
||||||
const internalRouter = Router();
|
const internalRouter = Router();
|
||||||
|
@ -13,9 +16,17 @@ internalRouter.get("/", (_, res) => {
|
||||||
});
|
});
|
||||||
|
|
||||||
internalRouter.get("/traefik-config", traefik.traefikConfigProvider);
|
internalRouter.get("/traefik-config", traefik.traefikConfigProvider);
|
||||||
|
|
||||||
internalRouter.get(
|
internalRouter.get(
|
||||||
"/resource-session/:resourceId/:token",
|
"/resource-session/:resourceId/:token",
|
||||||
auth.checkResourceSession,
|
auth.checkResourceSession
|
||||||
|
);
|
||||||
|
|
||||||
|
internalRouter.post(
|
||||||
|
`/resource/:resourceId/get-exchange-token`,
|
||||||
|
verifySessionUserMiddleware,
|
||||||
|
verifyResourceAccess,
|
||||||
|
getExchangeToken
|
||||||
);
|
);
|
||||||
|
|
||||||
// Gerbil routes
|
// Gerbil routes
|
||||||
|
@ -29,6 +40,7 @@ gerbilRouter.post("/receive-bandwidth", gerbil.receiveBandwidth);
|
||||||
const badgerRouter = Router();
|
const badgerRouter = Router();
|
||||||
internalRouter.use("/badger", badgerRouter);
|
internalRouter.use("/badger", badgerRouter);
|
||||||
|
|
||||||
badgerRouter.post("/verify-session", badger.verifyResourceSession);
|
badgerRouter.post("/verify-session", verifyResourceSession);
|
||||||
|
badgerRouter.post("/exchange-session", exchangeSession);
|
||||||
|
|
||||||
export default internalRouter;
|
export default internalRouter;
|
||||||
|
|
|
@ -1,6 +1,4 @@
|
||||||
import {
|
import { generateSessionToken } from "@server/auth/sessions/app";
|
||||||
generateSessionToken,
|
|
||||||
} from "@server/auth/sessions/app";
|
|
||||||
import db from "@server/db";
|
import db from "@server/db";
|
||||||
import { newts } from "@server/db/schema";
|
import { newts } from "@server/db/schema";
|
||||||
import HttpCode from "@server/types/HttpCode";
|
import HttpCode from "@server/types/HttpCode";
|
||||||
|
@ -10,8 +8,13 @@ import { NextFunction, Request, Response } from "express";
|
||||||
import createHttpError from "http-errors";
|
import createHttpError from "http-errors";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { fromError } from "zod-validation-error";
|
import { fromError } from "zod-validation-error";
|
||||||
import { createNewtSession, validateNewtSessionToken } from "@server/auth/sessions/newt";
|
import {
|
||||||
|
createNewtSession,
|
||||||
|
validateNewtSessionToken
|
||||||
|
} from "@server/auth/sessions/newt";
|
||||||
import { verifyPassword } from "@server/auth/password";
|
import { verifyPassword } from "@server/auth/password";
|
||||||
|
import logger from "@server/logger";
|
||||||
|
import config from "@server/lib/config";
|
||||||
|
|
||||||
export const newtGetTokenBodySchema = z.object({
|
export const newtGetTokenBodySchema = z.object({
|
||||||
newtId: z.string(),
|
newtId: z.string(),
|
||||||
|
@ -43,6 +46,11 @@ export async function getToken(
|
||||||
if (token) {
|
if (token) {
|
||||||
const { session, newt } = await validateNewtSessionToken(token);
|
const { session, newt } = await validateNewtSessionToken(token);
|
||||||
if (session) {
|
if (session) {
|
||||||
|
if (config.getRawConfig().app.log_failed_attempts) {
|
||||||
|
logger.info(
|
||||||
|
`Newt session already valid. Newt ID: ${newtId}. IP: ${req.ip}.`
|
||||||
|
);
|
||||||
|
}
|
||||||
return response<null>(res, {
|
return response<null>(res, {
|
||||||
data: null,
|
data: null,
|
||||||
success: true,
|
success: true,
|
||||||
|
@ -73,6 +81,11 @@ export async function getToken(
|
||||||
existingNewt.secretHash
|
existingNewt.secretHash
|
||||||
);
|
);
|
||||||
if (!validSecret) {
|
if (!validSecret) {
|
||||||
|
if (config.getRawConfig().app.log_failed_attempts) {
|
||||||
|
logger.info(
|
||||||
|
`Newt id or secret is incorrect. Newt: ID ${newtId}. IP: ${req.ip}.`
|
||||||
|
);
|
||||||
|
}
|
||||||
return next(
|
return next(
|
||||||
createHttpError(HttpCode.BAD_REQUEST, "Secret is incorrect")
|
createHttpError(HttpCode.BAD_REQUEST, "Secret is incorrect")
|
||||||
);
|
);
|
||||||
|
|
|
@ -1,7 +1,13 @@
|
||||||
import db from "@server/db";
|
import db from "@server/db";
|
||||||
import { MessageHandler } from "../ws";
|
import { MessageHandler } from "../ws";
|
||||||
import { exitNodes, resources, sites, targets } from "@server/db/schema";
|
import {
|
||||||
import { eq, inArray } from "drizzle-orm";
|
exitNodes,
|
||||||
|
resources,
|
||||||
|
sites,
|
||||||
|
Target,
|
||||||
|
targets
|
||||||
|
} from "@server/db/schema";
|
||||||
|
import { eq, and, sql } from "drizzle-orm";
|
||||||
import { addPeer, deletePeer } from "../gerbil/peers";
|
import { addPeer, deletePeer } from "../gerbil/peers";
|
||||||
import logger from "@server/logger";
|
import logger from "@server/logger";
|
||||||
|
|
||||||
|
@ -69,37 +75,67 @@ export const handleRegisterMessage: MessageHandler = async (context) => {
|
||||||
allowedIps: [site.subnet]
|
allowedIps: [site.subnet]
|
||||||
});
|
});
|
||||||
|
|
||||||
const siteResources = await db
|
const allResources = await db
|
||||||
.select()
|
.select({
|
||||||
|
// Resource fields
|
||||||
|
resourceId: resources.resourceId,
|
||||||
|
subdomain: resources.subdomain,
|
||||||
|
fullDomain: resources.fullDomain,
|
||||||
|
ssl: resources.ssl,
|
||||||
|
blockAccess: resources.blockAccess,
|
||||||
|
sso: resources.sso,
|
||||||
|
emailWhitelistEnabled: resources.emailWhitelistEnabled,
|
||||||
|
http: resources.http,
|
||||||
|
proxyPort: resources.proxyPort,
|
||||||
|
protocol: resources.protocol,
|
||||||
|
// Targets as a subquery
|
||||||
|
targets: sql<string>`json_group_array(json_object(
|
||||||
|
'targetId', ${targets.targetId},
|
||||||
|
'ip', ${targets.ip},
|
||||||
|
'method', ${targets.method},
|
||||||
|
'port', ${targets.port},
|
||||||
|
'internalPort', ${targets.internalPort},
|
||||||
|
'enabled', ${targets.enabled}
|
||||||
|
))`.as("targets")
|
||||||
|
})
|
||||||
.from(resources)
|
.from(resources)
|
||||||
.where(eq(resources.siteId, siteId));
|
.leftJoin(
|
||||||
|
targets,
|
||||||
|
and(
|
||||||
|
eq(targets.resourceId, resources.resourceId),
|
||||||
|
eq(targets.enabled, true)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.groupBy(resources.resourceId);
|
||||||
|
|
||||||
// get the targets from the resourceIds
|
let tcpTargets: string[] = [];
|
||||||
const siteTargets = await db
|
let udpTargets: string[] = [];
|
||||||
.select()
|
|
||||||
.from(targets)
|
for (const resource of allResources) {
|
||||||
.where(
|
const targets = JSON.parse(resource.targets);
|
||||||
inArray(
|
if (!targets || targets.length === 0) {
|
||||||
targets.resourceId,
|
continue;
|
||||||
siteResources.map((resource) => resource.resourceId)
|
}
|
||||||
|
if (resource.protocol === "tcp") {
|
||||||
|
tcpTargets = tcpTargets.concat(
|
||||||
|
targets.map(
|
||||||
|
(target: Target) =>
|
||||||
|
`${
|
||||||
|
target.internalPort ? target.internalPort + ":" : ""
|
||||||
|
}${target.ip}:${target.port}`
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
} else {
|
||||||
const udpTargets = siteTargets
|
udpTargets = tcpTargets.concat(
|
||||||
.filter((target) => target.protocol === "udp")
|
targets.map(
|
||||||
.map((target) => {
|
(target: Target) =>
|
||||||
return `${target.internalPort ? target.internalPort + ":" : ""}${
|
`${
|
||||||
target.ip
|
target.internalPort ? target.internalPort + ":" : ""
|
||||||
}:${target.port}`;
|
}${target.ip}:${target.port}`
|
||||||
});
|
)
|
||||||
|
);
|
||||||
const tcpTargets = siteTargets
|
}
|
||||||
.filter((target) => target.protocol === "tcp")
|
}
|
||||||
.map((target) => {
|
|
||||||
return `${target.internalPort ? target.internalPort + ":" : ""}${
|
|
||||||
target.ip
|
|
||||||
}:${target.port}`;
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
message: {
|
message: {
|
||||||
|
|
|
@ -1,73 +1,44 @@
|
||||||
import { Target } from "@server/db/schema";
|
import { Target } from "@server/db/schema";
|
||||||
import { sendToClient } from "../ws";
|
import { sendToClient } from "../ws";
|
||||||
|
|
||||||
export async function addTargets(newtId: string, targets: Target[]): Promise<void> {
|
export async function addTargets(
|
||||||
|
newtId: string,
|
||||||
|
targets: Target[],
|
||||||
|
protocol: string
|
||||||
|
): Promise<void> {
|
||||||
//create a list of udp and tcp targets
|
//create a list of udp and tcp targets
|
||||||
const udpTargets = targets
|
const payloadTargets = targets.map((target) => {
|
||||||
.filter((target) => target.protocol === "udp")
|
return `${target.internalPort ? target.internalPort + ":" : ""}${
|
||||||
.map((target) => {
|
target.ip
|
||||||
return `${target.internalPort ? target.internalPort + ":" : ""}${target.ip}:${target.port}`;
|
}:${target.port}`;
|
||||||
});
|
});
|
||||||
|
|
||||||
const tcpTargets = targets
|
|
||||||
.filter((target) => target.protocol === "tcp")
|
|
||||||
.map((target) => {
|
|
||||||
return `${target.internalPort ? target.internalPort + ":" : ""}${target.ip}:${target.port}`;
|
|
||||||
});
|
|
||||||
|
|
||||||
if (udpTargets.length > 0) {
|
|
||||||
const payload = {
|
const payload = {
|
||||||
type: `newt/udp/add`,
|
type: `newt/${protocol}/add`,
|
||||||
data: {
|
data: {
|
||||||
targets: udpTargets,
|
targets: payloadTargets
|
||||||
},
|
}
|
||||||
};
|
};
|
||||||
sendToClient(newtId, payload);
|
sendToClient(newtId, payload);
|
||||||
}
|
|
||||||
|
|
||||||
if (tcpTargets.length > 0) {
|
|
||||||
const payload = {
|
|
||||||
type: `newt/tcp/add`,
|
|
||||||
data: {
|
|
||||||
targets: tcpTargets,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
sendToClient(newtId, payload);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function removeTargets(
|
||||||
export async function removeTargets(newtId: string, targets: Target[]): Promise<void> {
|
newtId: string,
|
||||||
|
targets: Target[],
|
||||||
|
protocol: string
|
||||||
|
): Promise<void> {
|
||||||
//create a list of udp and tcp targets
|
//create a list of udp and tcp targets
|
||||||
const udpTargets = targets
|
const payloadTargets = targets.map((target) => {
|
||||||
.filter((target) => target.protocol === "udp")
|
return `${target.internalPort ? target.internalPort + ":" : ""}${
|
||||||
.map((target) => {
|
target.ip
|
||||||
return `${target.internalPort ? target.internalPort + ":" : ""}${target.ip}:${target.port}`;
|
}:${target.port}`;
|
||||||
});
|
});
|
||||||
|
|
||||||
const tcpTargets = targets
|
|
||||||
.filter((target) => target.protocol === "tcp")
|
|
||||||
.map((target) => {
|
|
||||||
return `${target.internalPort ? target.internalPort + ":" : ""}${target.ip}:${target.port}`;
|
|
||||||
});
|
|
||||||
|
|
||||||
if (udpTargets.length > 0) {
|
|
||||||
const payload = {
|
const payload = {
|
||||||
type: `newt/udp/remove`,
|
type: `newt/${protocol}/remove`,
|
||||||
data: {
|
data: {
|
||||||
targets: udpTargets,
|
targets: payloadTargets
|
||||||
},
|
}
|
||||||
};
|
};
|
||||||
sendToClient(newtId, payload);
|
sendToClient(newtId, payload);
|
||||||
}
|
|
||||||
|
|
||||||
if (tcpTargets.length > 0) {
|
|
||||||
const payload = {
|
|
||||||
type: `newt/tcp/remove`,
|
|
||||||
data: {
|
|
||||||
targets: tcpTargets,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
sendToClient(newtId, payload);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,20 +1,17 @@
|
||||||
import { generateSessionToken } from "@server/auth/sessions/app";
|
import { generateSessionToken } from "@server/auth/sessions/app";
|
||||||
import db from "@server/db";
|
import db from "@server/db";
|
||||||
import { resourceAccessToken, resources } from "@server/db/schema";
|
import { resources } from "@server/db/schema";
|
||||||
import HttpCode from "@server/types/HttpCode";
|
import HttpCode from "@server/types/HttpCode";
|
||||||
import response from "@server/lib/response";
|
import response from "@server/lib/response";
|
||||||
import { eq, and } from "drizzle-orm";
|
import { eq } from "drizzle-orm";
|
||||||
import { NextFunction, Request, Response } from "express";
|
import { NextFunction, Request, Response } from "express";
|
||||||
import createHttpError from "http-errors";
|
import createHttpError from "http-errors";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { fromError } from "zod-validation-error";
|
import { fromError } from "zod-validation-error";
|
||||||
import {
|
import { createResourceSession } from "@server/auth/sessions/resource";
|
||||||
createResourceSession,
|
|
||||||
serializeResourceSessionCookie
|
|
||||||
} from "@server/auth/sessions/resource";
|
|
||||||
import config from "@server/lib/config";
|
|
||||||
import logger from "@server/logger";
|
import logger from "@server/logger";
|
||||||
import { verifyResourceAccessToken } from "@server/auth/verifyResourceAccessToken";
|
import { verifyResourceAccessToken } from "@server/auth/verifyResourceAccessToken";
|
||||||
|
import config from "@server/lib/config";
|
||||||
|
|
||||||
const authWithAccessTokenBodySchema = z
|
const authWithAccessTokenBodySchema = z
|
||||||
.object({
|
.object({
|
||||||
|
@ -86,6 +83,11 @@ export async function authWithAccessToken(
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!valid) {
|
if (!valid) {
|
||||||
|
if (config.getRawConfig().app.log_failed_attempts) {
|
||||||
|
logger.info(
|
||||||
|
`Resource access token invalid. Resource ID: ${resource.resourceId}. IP: ${req.ip}.`
|
||||||
|
);
|
||||||
|
}
|
||||||
return next(
|
return next(
|
||||||
createHttpError(
|
createHttpError(
|
||||||
HttpCode.UNAUTHORIZED,
|
HttpCode.UNAUTHORIZED,
|
||||||
|
@ -108,13 +110,11 @@ export async function authWithAccessToken(
|
||||||
resourceId,
|
resourceId,
|
||||||
token,
|
token,
|
||||||
accessTokenId: tokenItem.accessTokenId,
|
accessTokenId: tokenItem.accessTokenId,
|
||||||
sessionLength: tokenItem.sessionLength,
|
isRequestToken: true,
|
||||||
expiresAt: tokenItem.expiresAt,
|
expiresAt: Date.now() + 1000 * 30, // 30 seconds
|
||||||
doNotExtend: tokenItem.expiresAt ? true : false
|
sessionLength: 1000 * 30,
|
||||||
|
doNotExtend: true
|
||||||
});
|
});
|
||||||
const cookieName = `${config.getRawConfig().server.resource_session_cookie_name}_${resource.resourceId}`;
|
|
||||||
const cookie = serializeResourceSessionCookie(cookieName, token);
|
|
||||||
res.appendHeader("Set-Cookie", cookie);
|
|
||||||
|
|
||||||
return response<AuthWithAccessTokenResponse>(res, {
|
return response<AuthWithAccessTokenResponse>(res, {
|
||||||
data: {
|
data: {
|
||||||
|
|
|
@ -9,13 +9,10 @@ import { NextFunction, Request, Response } from "express";
|
||||||
import createHttpError from "http-errors";
|
import createHttpError from "http-errors";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { fromError } from "zod-validation-error";
|
import { fromError } from "zod-validation-error";
|
||||||
import {
|
import { createResourceSession } from "@server/auth/sessions/resource";
|
||||||
createResourceSession,
|
|
||||||
serializeResourceSessionCookie
|
|
||||||
} from "@server/auth/sessions/resource";
|
|
||||||
import config from "@server/lib/config";
|
|
||||||
import logger from "@server/logger";
|
import logger from "@server/logger";
|
||||||
import { verifyPassword } from "@server/auth/password";
|
import { verifyPassword } from "@server/auth/password";
|
||||||
|
import config from "@server/lib/config";
|
||||||
|
|
||||||
export const authWithPasswordBodySchema = z
|
export const authWithPasswordBodySchema = z
|
||||||
.object({
|
.object({
|
||||||
|
@ -84,7 +81,7 @@ export async function authWithPassword(
|
||||||
|
|
||||||
if (!org) {
|
if (!org) {
|
||||||
return next(
|
return next(
|
||||||
createHttpError(HttpCode.BAD_REQUEST, "Resource does not exist")
|
createHttpError(HttpCode.BAD_REQUEST, "Org does not exist")
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -111,6 +108,11 @@ export async function authWithPassword(
|
||||||
definedPassword.passwordHash
|
definedPassword.passwordHash
|
||||||
);
|
);
|
||||||
if (!validPassword) {
|
if (!validPassword) {
|
||||||
|
if (config.getRawConfig().app.log_failed_attempts) {
|
||||||
|
logger.info(
|
||||||
|
`Resource password incorrect. Resource ID: ${resource.resourceId}. IP: ${req.ip}.`
|
||||||
|
);
|
||||||
|
}
|
||||||
return next(
|
return next(
|
||||||
createHttpError(HttpCode.UNAUTHORIZED, "Incorrect password")
|
createHttpError(HttpCode.UNAUTHORIZED, "Incorrect password")
|
||||||
);
|
);
|
||||||
|
@ -120,11 +122,12 @@ export async function authWithPassword(
|
||||||
await createResourceSession({
|
await createResourceSession({
|
||||||
resourceId,
|
resourceId,
|
||||||
token,
|
token,
|
||||||
passwordId: definedPassword.passwordId
|
passwordId: definedPassword.passwordId,
|
||||||
|
isRequestToken: true,
|
||||||
|
expiresAt: Date.now() + 1000 * 30, // 30 seconds
|
||||||
|
sessionLength: 1000 * 30,
|
||||||
|
doNotExtend: true
|
||||||
});
|
});
|
||||||
const cookieName = `${config.getRawConfig().server.resource_session_cookie_name}_${resource.resourceId}`;
|
|
||||||
const cookie = serializeResourceSessionCookie(cookieName, token);
|
|
||||||
res.appendHeader("Set-Cookie", cookie);
|
|
||||||
|
|
||||||
return response<AuthWithPasswordResponse>(res, {
|
return response<AuthWithPasswordResponse>(res, {
|
||||||
data: {
|
data: {
|
||||||
|
|
|
@ -1,29 +1,17 @@
|
||||||
import { verify } from "@node-rs/argon2";
|
|
||||||
import { generateSessionToken } from "@server/auth/sessions/app";
|
import { generateSessionToken } from "@server/auth/sessions/app";
|
||||||
import db from "@server/db";
|
import db from "@server/db";
|
||||||
import {
|
import { orgs, resourcePincode, resources } from "@server/db/schema";
|
||||||
orgs,
|
|
||||||
resourceOtp,
|
|
||||||
resourcePincode,
|
|
||||||
resources,
|
|
||||||
resourceWhitelist
|
|
||||||
} from "@server/db/schema";
|
|
||||||
import HttpCode from "@server/types/HttpCode";
|
import HttpCode from "@server/types/HttpCode";
|
||||||
import response from "@server/lib/response";
|
import response from "@server/lib/response";
|
||||||
import { and, eq } from "drizzle-orm";
|
import { eq } from "drizzle-orm";
|
||||||
import { NextFunction, Request, Response } from "express";
|
import { NextFunction, Request, Response } from "express";
|
||||||
import createHttpError from "http-errors";
|
import createHttpError from "http-errors";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { fromError } from "zod-validation-error";
|
import { fromError } from "zod-validation-error";
|
||||||
import {
|
import { createResourceSession } from "@server/auth/sessions/resource";
|
||||||
createResourceSession,
|
|
||||||
serializeResourceSessionCookie
|
|
||||||
} from "@server/auth/sessions/resource";
|
|
||||||
import logger from "@server/logger";
|
import logger from "@server/logger";
|
||||||
import config from "@server/lib/config";
|
|
||||||
import { AuthWithPasswordResponse } from "./authWithPassword";
|
|
||||||
import { isValidOtp, sendResourceOtpEmail } from "@server/auth/resourceOtp";
|
|
||||||
import { verifyPassword } from "@server/auth/password";
|
import { verifyPassword } from "@server/auth/password";
|
||||||
|
import config from "@server/lib/config";
|
||||||
|
|
||||||
export const authWithPincodeBodySchema = z
|
export const authWithPincodeBodySchema = z
|
||||||
.object({
|
.object({
|
||||||
|
@ -119,6 +107,11 @@ export async function authWithPincode(
|
||||||
definedPincode.pincodeHash
|
definedPincode.pincodeHash
|
||||||
);
|
);
|
||||||
if (!validPincode) {
|
if (!validPincode) {
|
||||||
|
if (config.getRawConfig().app.log_failed_attempts) {
|
||||||
|
logger.info(
|
||||||
|
`Resource pin code incorrect. Resource ID: ${resource.resourceId}. IP: ${req.ip}.`
|
||||||
|
);
|
||||||
|
}
|
||||||
return next(
|
return next(
|
||||||
createHttpError(HttpCode.UNAUTHORIZED, "Incorrect PIN")
|
createHttpError(HttpCode.UNAUTHORIZED, "Incorrect PIN")
|
||||||
);
|
);
|
||||||
|
@ -128,11 +121,12 @@ export async function authWithPincode(
|
||||||
await createResourceSession({
|
await createResourceSession({
|
||||||
resourceId,
|
resourceId,
|
||||||
token,
|
token,
|
||||||
pincodeId: definedPincode.pincodeId
|
pincodeId: definedPincode.pincodeId,
|
||||||
|
isRequestToken: true,
|
||||||
|
expiresAt: Date.now() + 1000 * 30, // 30 seconds
|
||||||
|
sessionLength: 1000 * 30,
|
||||||
|
doNotExtend: true
|
||||||
});
|
});
|
||||||
const cookieName = `${config.getRawConfig().server.resource_session_cookie_name}_${resource.resourceId}`;
|
|
||||||
const cookie = serializeResourceSessionCookie(cookieName, token);
|
|
||||||
res.appendHeader("Set-Cookie", cookie);
|
|
||||||
|
|
||||||
return response<AuthWithPincodeResponse>(res, {
|
return response<AuthWithPincodeResponse>(res, {
|
||||||
data: {
|
data: {
|
||||||
|
|
|
@ -3,7 +3,6 @@ import db from "@server/db";
|
||||||
import {
|
import {
|
||||||
orgs,
|
orgs,
|
||||||
resourceOtp,
|
resourceOtp,
|
||||||
resourcePassword,
|
|
||||||
resources,
|
resources,
|
||||||
resourceWhitelist
|
resourceWhitelist
|
||||||
} from "@server/db/schema";
|
} from "@server/db/schema";
|
||||||
|
@ -14,17 +13,17 @@ import { NextFunction, Request, Response } from "express";
|
||||||
import createHttpError from "http-errors";
|
import createHttpError from "http-errors";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { fromError } from "zod-validation-error";
|
import { fromError } from "zod-validation-error";
|
||||||
import {
|
import { createResourceSession } from "@server/auth/sessions/resource";
|
||||||
createResourceSession,
|
|
||||||
serializeResourceSessionCookie
|
|
||||||
} from "@server/auth/sessions/resource";
|
|
||||||
import config from "@server/lib/config";
|
|
||||||
import { isValidOtp, sendResourceOtpEmail } from "@server/auth/resourceOtp";
|
import { isValidOtp, sendResourceOtpEmail } from "@server/auth/resourceOtp";
|
||||||
import logger from "@server/logger";
|
import logger from "@server/logger";
|
||||||
|
import config from "@server/lib/config";
|
||||||
|
|
||||||
const authWithWhitelistBodySchema = z
|
const authWithWhitelistBodySchema = z
|
||||||
.object({
|
.object({
|
||||||
email: z.string().email(),
|
email: z
|
||||||
|
.string()
|
||||||
|
.email()
|
||||||
|
.transform((v) => v.toLowerCase()),
|
||||||
otp: z.string().optional()
|
otp: z.string().optional()
|
||||||
})
|
})
|
||||||
.strict();
|
.strict();
|
||||||
|
@ -90,11 +89,43 @@ export async function authWithWhitelist(
|
||||||
.leftJoin(orgs, eq(orgs.orgId, resources.orgId))
|
.leftJoin(orgs, eq(orgs.orgId, resources.orgId))
|
||||||
.limit(1);
|
.limit(1);
|
||||||
|
|
||||||
const resource = result?.resources;
|
let resource = result?.resources;
|
||||||
const org = result?.orgs;
|
let org = result?.orgs;
|
||||||
const whitelistedEmail = result?.resourceWhitelist;
|
let whitelistedEmail = result?.resourceWhitelist;
|
||||||
|
|
||||||
if (!whitelistedEmail) {
|
if (!whitelistedEmail) {
|
||||||
|
// if email is not found, check for wildcard email
|
||||||
|
const wildcard = "*@" + email.split("@")[1];
|
||||||
|
|
||||||
|
logger.debug("Checking for wildcard email: " + wildcard);
|
||||||
|
|
||||||
|
const [result] = await db
|
||||||
|
.select()
|
||||||
|
.from(resourceWhitelist)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(resourceWhitelist.resourceId, resourceId),
|
||||||
|
eq(resourceWhitelist.email, wildcard)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.leftJoin(
|
||||||
|
resources,
|
||||||
|
eq(resources.resourceId, resourceWhitelist.resourceId)
|
||||||
|
)
|
||||||
|
.leftJoin(orgs, eq(orgs.orgId, resources.orgId))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
resource = result?.resources;
|
||||||
|
org = result?.orgs;
|
||||||
|
whitelistedEmail = result?.resourceWhitelist;
|
||||||
|
|
||||||
|
// if wildcard is still not found, return unauthorized
|
||||||
|
if (!whitelistedEmail) {
|
||||||
|
if (config.getRawConfig().app.log_failed_attempts) {
|
||||||
|
logger.info(
|
||||||
|
`Email is not whitelisted. Email: ${email}. IP: ${req.ip}.`
|
||||||
|
);
|
||||||
|
}
|
||||||
return next(
|
return next(
|
||||||
createHttpError(
|
createHttpError(
|
||||||
HttpCode.UNAUTHORIZED,
|
HttpCode.UNAUTHORIZED,
|
||||||
|
@ -105,6 +136,7 @@ export async function authWithWhitelist(
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (!org) {
|
if (!org) {
|
||||||
return next(
|
return next(
|
||||||
|
@ -125,6 +157,11 @@ export async function authWithWhitelist(
|
||||||
otp
|
otp
|
||||||
);
|
);
|
||||||
if (!isValidCode) {
|
if (!isValidCode) {
|
||||||
|
if (config.getRawConfig().app.log_failed_attempts) {
|
||||||
|
logger.info(
|
||||||
|
`Resource email otp incorrect. Resource ID: ${resource.resourceId}. Email: ${email}. IP: ${req.ip}.`
|
||||||
|
);
|
||||||
|
}
|
||||||
return next(
|
return next(
|
||||||
createHttpError(HttpCode.UNAUTHORIZED, "Incorrect OTP")
|
createHttpError(HttpCode.UNAUTHORIZED, "Incorrect OTP")
|
||||||
);
|
);
|
||||||
|
@ -175,11 +212,12 @@ export async function authWithWhitelist(
|
||||||
await createResourceSession({
|
await createResourceSession({
|
||||||
resourceId,
|
resourceId,
|
||||||
token,
|
token,
|
||||||
whitelistId: whitelistedEmail.whitelistId
|
whitelistId: whitelistedEmail.whitelistId,
|
||||||
|
isRequestToken: true,
|
||||||
|
expiresAt: Date.now() + 1000 * 30, // 30 seconds
|
||||||
|
sessionLength: 1000 * 30,
|
||||||
|
doNotExtend: true
|
||||||
});
|
});
|
||||||
const cookieName = `${config.getRawConfig().server.resource_session_cookie_name}_${resource.resourceId}`;
|
|
||||||
const cookie = serializeResourceSessionCookie(cookieName, token);
|
|
||||||
res.appendHeader("Set-Cookie", cookie);
|
|
||||||
|
|
||||||
return response<AuthWithWhitelistResponse>(res, {
|
return response<AuthWithWhitelistResponse>(res, {
|
||||||
data: {
|
data: {
|
||||||
|
|
|
@ -16,8 +16,8 @@ import createHttpError from "http-errors";
|
||||||
import { eq, and } from "drizzle-orm";
|
import { eq, and } from "drizzle-orm";
|
||||||
import stoi from "@server/lib/stoi";
|
import stoi from "@server/lib/stoi";
|
||||||
import { fromError } from "zod-validation-error";
|
import { fromError } from "zod-validation-error";
|
||||||
import { subdomainSchema } from "@server/schemas/subdomainSchema";
|
|
||||||
import logger from "@server/logger";
|
import logger from "@server/logger";
|
||||||
|
import { subdomainSchema } from "@server/schemas/subdomainSchema";
|
||||||
|
|
||||||
const createResourceParamsSchema = z
|
const createResourceParamsSchema = z
|
||||||
.object({
|
.object({
|
||||||
|
@ -28,10 +28,42 @@ const createResourceParamsSchema = z
|
||||||
|
|
||||||
const createResourceSchema = z
|
const createResourceSchema = z
|
||||||
.object({
|
.object({
|
||||||
|
subdomain: z.string().optional(),
|
||||||
name: z.string().min(1).max(255),
|
name: z.string().min(1).max(255),
|
||||||
subdomain: subdomainSchema
|
siteId: z.number(),
|
||||||
|
http: z.boolean(),
|
||||||
|
protocol: z.string(),
|
||||||
|
proxyPort: z.number().optional()
|
||||||
})
|
})
|
||||||
.strict();
|
.refine(
|
||||||
|
(data) => {
|
||||||
|
if (!data.http) {
|
||||||
|
return z
|
||||||
|
.number()
|
||||||
|
.int()
|
||||||
|
.min(1)
|
||||||
|
.max(65535)
|
||||||
|
.safeParse(data.proxyPort).success;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
{
|
||||||
|
message: "Invalid port number",
|
||||||
|
path: ["proxyPort"]
|
||||||
|
}
|
||||||
|
)
|
||||||
|
.refine(
|
||||||
|
(data) => {
|
||||||
|
if (data.http) {
|
||||||
|
return subdomainSchema.safeParse(data.subdomain).success;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
{
|
||||||
|
message: "Invalid subdomain",
|
||||||
|
path: ["subdomain"]
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
export type CreateResourceResponse = Resource;
|
export type CreateResourceResponse = Resource;
|
||||||
|
|
||||||
|
@ -51,7 +83,7 @@ export async function createResource(
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
let { name, subdomain } = parsedBody.data;
|
let { name, subdomain, protocol, proxyPort, http } = parsedBody.data;
|
||||||
|
|
||||||
// Validate request params
|
// Validate request params
|
||||||
const parsedParams = createResourceParamsSchema.safeParse(req.params);
|
const parsedParams = createResourceParamsSchema.safeParse(req.params);
|
||||||
|
@ -89,15 +121,64 @@ export async function createResource(
|
||||||
}
|
}
|
||||||
|
|
||||||
const fullDomain = `${subdomain}.${org[0].domain}`;
|
const fullDomain = `${subdomain}.${org[0].domain}`;
|
||||||
|
// if http is false check to see if there is already a resource with the same port and protocol
|
||||||
|
if (!http) {
|
||||||
|
const existingResource = await db
|
||||||
|
.select()
|
||||||
|
.from(resources)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(resources.protocol, protocol),
|
||||||
|
eq(resources.proxyPort, proxyPort!)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (existingResource.length > 0) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.CONFLICT,
|
||||||
|
"Resource with that protocol and port already exists"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (proxyPort === 443 || proxyPort === 80) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.BAD_REQUEST,
|
||||||
|
"Port 80 and 443 are reserved for https resources"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// make sure the full domain is unique
|
||||||
|
const existingResource = await db
|
||||||
|
.select()
|
||||||
|
.from(resources)
|
||||||
|
.where(eq(resources.fullDomain, fullDomain));
|
||||||
|
|
||||||
|
if (existingResource.length > 0) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.CONFLICT,
|
||||||
|
"Resource with that domain already exists"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
await db.transaction(async (trx) => {
|
await db.transaction(async (trx) => {
|
||||||
const newResource = await trx
|
const newResource = await trx
|
||||||
.insert(resources)
|
.insert(resources)
|
||||||
.values({
|
.values({
|
||||||
siteId,
|
siteId,
|
||||||
fullDomain,
|
fullDomain: http ? fullDomain : null,
|
||||||
orgId,
|
orgId,
|
||||||
name,
|
name,
|
||||||
subdomain,
|
subdomain,
|
||||||
|
http,
|
||||||
|
protocol,
|
||||||
|
proxyPort,
|
||||||
ssl: true
|
ssl: true
|
||||||
})
|
})
|
||||||
.returning();
|
.returning();
|
||||||
|
@ -135,18 +216,6 @@ export async function createResource(
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (
|
|
||||||
error instanceof SqliteError &&
|
|
||||||
error.code === "SQLITE_CONSTRAINT_UNIQUE"
|
|
||||||
) {
|
|
||||||
return next(
|
|
||||||
createHttpError(
|
|
||||||
HttpCode.CONFLICT,
|
|
||||||
"Resource with that subdomain already exists"
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.error(error);
|
logger.error(error);
|
||||||
return next(
|
return next(
|
||||||
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
|
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
|
||||||
|
|
|
@ -103,7 +103,7 @@ export async function deleteResource(
|
||||||
.where(eq(newts.siteId, site.siteId))
|
.where(eq(newts.siteId, site.siteId))
|
||||||
.limit(1);
|
.limit(1);
|
||||||
|
|
||||||
removeTargets(newt.newtId, targetsToBeRemoved);
|
removeTargets(newt.newtId, targetsToBeRemoved, deletedResource.protocol);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
109
server/routers/resource/getExchangeToken.ts
Normal file
109
server/routers/resource/getExchangeToken.ts
Normal 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")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
|
@ -16,3 +16,4 @@ export * from "./setResourceWhitelist";
|
||||||
export * from "./getResourceWhitelist";
|
export * from "./getResourceWhitelist";
|
||||||
export * from "./authWithWhitelist";
|
export * from "./authWithWhitelist";
|
||||||
export * from "./authWithAccessToken";
|
export * from "./authWithAccessToken";
|
||||||
|
export * from "./getExchangeToken";
|
||||||
|
|
|
@ -63,7 +63,10 @@ function queryResources(
|
||||||
passwordId: resourcePassword.passwordId,
|
passwordId: resourcePassword.passwordId,
|
||||||
pincodeId: resourcePincode.pincodeId,
|
pincodeId: resourcePincode.pincodeId,
|
||||||
sso: resources.sso,
|
sso: resources.sso,
|
||||||
whitelist: resources.emailWhitelistEnabled
|
whitelist: resources.emailWhitelistEnabled,
|
||||||
|
http: resources.http,
|
||||||
|
protocol: resources.protocol,
|
||||||
|
proxyPort: resources.proxyPort
|
||||||
})
|
})
|
||||||
.from(resources)
|
.from(resources)
|
||||||
.leftJoin(sites, eq(resources.siteId, sites.siteId))
|
.leftJoin(sites, eq(resources.siteId, sites.siteId))
|
||||||
|
@ -93,7 +96,10 @@ function queryResources(
|
||||||
passwordId: resourcePassword.passwordId,
|
passwordId: resourcePassword.passwordId,
|
||||||
sso: resources.sso,
|
sso: resources.sso,
|
||||||
pincodeId: resourcePincode.pincodeId,
|
pincodeId: resourcePincode.pincodeId,
|
||||||
whitelist: resources.emailWhitelistEnabled
|
whitelist: resources.emailWhitelistEnabled,
|
||||||
|
http: resources.http,
|
||||||
|
protocol: resources.protocol,
|
||||||
|
proxyPort: resources.proxyPort
|
||||||
})
|
})
|
||||||
.from(resources)
|
.from(resources)
|
||||||
.leftJoin(sites, eq(resources.siteId, sites.siteId))
|
.leftJoin(sites, eq(resources.siteId, sites.siteId))
|
||||||
|
|
|
@ -11,7 +11,20 @@ import { and, eq } from "drizzle-orm";
|
||||||
|
|
||||||
const setResourceWhitelistBodySchema = z
|
const setResourceWhitelistBodySchema = z
|
||||||
.object({
|
.object({
|
||||||
emails: z.array(z.string().email()).max(50)
|
emails: z
|
||||||
|
.array(
|
||||||
|
z
|
||||||
|
.string()
|
||||||
|
.email()
|
||||||
|
.or(
|
||||||
|
z.string().regex(/^\*@[\w.-]+\.[a-zA-Z]{2,}$/, {
|
||||||
|
message:
|
||||||
|
"Invalid email address. Wildcard (*) must be the entire local part."
|
||||||
|
})
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.max(50)
|
||||||
|
.transform((v) => v.map((e) => e.toLowerCase()))
|
||||||
})
|
})
|
||||||
.strict();
|
.strict();
|
||||||
|
|
||||||
|
|
|
@ -26,8 +26,8 @@ const updateResourceBodySchema = z
|
||||||
ssl: z.boolean().optional(),
|
ssl: z.boolean().optional(),
|
||||||
sso: z.boolean().optional(),
|
sso: z.boolean().optional(),
|
||||||
blockAccess: z.boolean().optional(),
|
blockAccess: z.boolean().optional(),
|
||||||
|
proxyPort: z.number().int().min(1).max(65535).optional(),
|
||||||
emailWhitelistEnabled: z.boolean().optional()
|
emailWhitelistEnabled: z.boolean().optional()
|
||||||
// siteId: z.number(),
|
|
||||||
})
|
})
|
||||||
.strict()
|
.strict()
|
||||||
.refine((data) => Object.keys(data).length > 0, {
|
.refine((data) => Object.keys(data).length > 0, {
|
||||||
|
@ -111,6 +111,10 @@ export async function updateResource(
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (resource[0].resources.ssl !== updatedResource[0].ssl) {
|
||||||
|
// invalidate all sessions?
|
||||||
|
}
|
||||||
|
|
||||||
return response(res, {
|
return response(res, {
|
||||||
data: updatedResource[0],
|
data: updatedResource[0],
|
||||||
success: true,
|
success: true,
|
||||||
|
|
|
@ -53,9 +53,8 @@ const createTargetParamsSchema = z
|
||||||
const createTargetSchema = z
|
const createTargetSchema = z
|
||||||
.object({
|
.object({
|
||||||
ip: domainSchema,
|
ip: domainSchema,
|
||||||
method: z.string().min(1).max(10),
|
method: z.string().optional().nullable(),
|
||||||
port: z.number().int().min(1).max(65535),
|
port: z.number().int().min(1).max(65535),
|
||||||
protocol: z.string().optional(),
|
|
||||||
enabled: z.boolean().default(true)
|
enabled: z.boolean().default(true)
|
||||||
})
|
})
|
||||||
.strict();
|
.strict();
|
||||||
|
@ -94,9 +93,7 @@ export async function createTarget(
|
||||||
|
|
||||||
// get the resource
|
// get the resource
|
||||||
const [resource] = await db
|
const [resource] = await db
|
||||||
.select({
|
.select()
|
||||||
siteId: resources.siteId
|
|
||||||
})
|
|
||||||
.from(resources)
|
.from(resources)
|
||||||
.where(eq(resources.resourceId, resourceId));
|
.where(eq(resources.resourceId, resourceId));
|
||||||
|
|
||||||
|
@ -130,7 +127,6 @@ export async function createTarget(
|
||||||
.insert(targets)
|
.insert(targets)
|
||||||
.values({
|
.values({
|
||||||
resourceId,
|
resourceId,
|
||||||
protocol: "tcp", // hard code for now
|
|
||||||
...targetData
|
...targetData
|
||||||
})
|
})
|
||||||
.returning();
|
.returning();
|
||||||
|
@ -163,7 +159,6 @@ export async function createTarget(
|
||||||
.insert(targets)
|
.insert(targets)
|
||||||
.values({
|
.values({
|
||||||
resourceId,
|
resourceId,
|
||||||
protocol: "tcp", // hard code for now
|
|
||||||
internalPort,
|
internalPort,
|
||||||
...targetData
|
...targetData
|
||||||
})
|
})
|
||||||
|
@ -186,7 +181,7 @@ export async function createTarget(
|
||||||
.where(eq(newts.siteId, site.siteId))
|
.where(eq(newts.siteId, site.siteId))
|
||||||
.limit(1);
|
.limit(1);
|
||||||
|
|
||||||
addTargets(newt.newtId, newTarget);
|
addTargets(newt.newtId, newTarget, resource.protocol);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -50,9 +50,7 @@ export async function deleteTarget(
|
||||||
}
|
}
|
||||||
// get the resource
|
// get the resource
|
||||||
const [resource] = await db
|
const [resource] = await db
|
||||||
.select({
|
.select()
|
||||||
siteId: resources.siteId
|
|
||||||
})
|
|
||||||
.from(resources)
|
.from(resources)
|
||||||
.where(eq(resources.resourceId, deletedTarget.resourceId!));
|
.where(eq(resources.resourceId, deletedTarget.resourceId!));
|
||||||
|
|
||||||
|
@ -110,7 +108,7 @@ export async function deleteTarget(
|
||||||
.where(eq(newts.siteId, site.siteId))
|
.where(eq(newts.siteId, site.siteId))
|
||||||
.limit(1);
|
.limit(1);
|
||||||
|
|
||||||
removeTargets(newt.newtId, [deletedTarget]);
|
removeTargets(newt.newtId, [deletedTarget], resource.protocol);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -40,7 +40,6 @@ function queryTargets(resourceId: number) {
|
||||||
ip: targets.ip,
|
ip: targets.ip,
|
||||||
method: targets.method,
|
method: targets.method,
|
||||||
port: targets.port,
|
port: targets.port,
|
||||||
protocol: targets.protocol,
|
|
||||||
enabled: targets.enabled,
|
enabled: targets.enabled,
|
||||||
resourceId: targets.resourceId
|
resourceId: targets.resourceId
|
||||||
// resourceName: resources.name,
|
// resourceName: resources.name,
|
||||||
|
|
|
@ -49,7 +49,7 @@ const updateTargetParamsSchema = z
|
||||||
const updateTargetBodySchema = z
|
const updateTargetBodySchema = z
|
||||||
.object({
|
.object({
|
||||||
ip: domainSchema.optional(),
|
ip: domainSchema.optional(),
|
||||||
method: z.string().min(1).max(10).optional(),
|
method: z.string().min(1).max(10).optional().nullable(),
|
||||||
port: z.number().int().min(1).max(65535).optional(),
|
port: z.number().int().min(1).max(65535).optional(),
|
||||||
enabled: z.boolean().optional()
|
enabled: z.boolean().optional()
|
||||||
})
|
})
|
||||||
|
@ -103,9 +103,7 @@ export async function updateTarget(
|
||||||
|
|
||||||
// get the resource
|
// get the resource
|
||||||
const [resource] = await db
|
const [resource] = await db
|
||||||
.select({
|
.select()
|
||||||
siteId: resources.siteId
|
|
||||||
})
|
|
||||||
.from(resources)
|
.from(resources)
|
||||||
.where(eq(resources.resourceId, target.resourceId!));
|
.where(eq(resources.resourceId, target.resourceId!));
|
||||||
|
|
||||||
|
@ -167,7 +165,7 @@ export async function updateTarget(
|
||||||
.where(eq(newts.siteId, site.siteId))
|
.where(eq(newts.siteId, site.siteId))
|
||||||
.limit(1);
|
.limit(1);
|
||||||
|
|
||||||
addTargets(newt.newtId, [updatedTarget]);
|
addTargets(newt.newtId, [updatedTarget], resource.protocol);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return response(res, {
|
return response(res, {
|
||||||
|
|
|
@ -1,92 +1,140 @@
|
||||||
import { Request, Response } from "express";
|
import { Request, Response } from "express";
|
||||||
import db from "@server/db";
|
import db from "@server/db";
|
||||||
import * as schema from "@server/db/schema";
|
import { and, eq } from "drizzle-orm";
|
||||||
import { and, eq, isNotNull } from "drizzle-orm";
|
|
||||||
import logger from "@server/logger";
|
import logger from "@server/logger";
|
||||||
import HttpCode from "@server/types/HttpCode";
|
import HttpCode from "@server/types/HttpCode";
|
||||||
import config from "@server/lib/config";
|
import config from "@server/lib/config";
|
||||||
|
import { orgs, resources, sites, Target, targets } from "@server/db/schema";
|
||||||
|
import { sql } from "drizzle-orm";
|
||||||
|
|
||||||
export async function traefikConfigProvider(
|
export async function traefikConfigProvider(
|
||||||
_: Request,
|
_: Request,
|
||||||
res: Response,
|
res: Response
|
||||||
): Promise<any> {
|
): Promise<any> {
|
||||||
try {
|
try {
|
||||||
const all = await db
|
const allResources = await db
|
||||||
.select()
|
.select({
|
||||||
.from(schema.targets)
|
// Resource fields
|
||||||
.innerJoin(
|
resourceId: resources.resourceId,
|
||||||
schema.resources,
|
subdomain: resources.subdomain,
|
||||||
eq(schema.targets.resourceId, schema.resources.resourceId),
|
fullDomain: resources.fullDomain,
|
||||||
)
|
ssl: resources.ssl,
|
||||||
.innerJoin(
|
blockAccess: resources.blockAccess,
|
||||||
schema.orgs,
|
sso: resources.sso,
|
||||||
eq(schema.resources.orgId, schema.orgs.orgId),
|
emailWhitelistEnabled: resources.emailWhitelistEnabled,
|
||||||
)
|
http: resources.http,
|
||||||
.innerJoin(
|
proxyPort: resources.proxyPort,
|
||||||
schema.sites,
|
protocol: resources.protocol,
|
||||||
eq(schema.sites.siteId, schema.resources.siteId),
|
// Site fields
|
||||||
)
|
site: {
|
||||||
.where(
|
siteId: sites.siteId,
|
||||||
|
type: sites.type,
|
||||||
|
subnet: sites.subnet
|
||||||
|
},
|
||||||
|
// Org fields
|
||||||
|
org: {
|
||||||
|
orgId: orgs.orgId,
|
||||||
|
domain: orgs.domain
|
||||||
|
},
|
||||||
|
// Targets as a subquery
|
||||||
|
targets: sql<string>`json_group_array(json_object(
|
||||||
|
'targetId', ${targets.targetId},
|
||||||
|
'ip', ${targets.ip},
|
||||||
|
'method', ${targets.method},
|
||||||
|
'port', ${targets.port},
|
||||||
|
'internalPort', ${targets.internalPort},
|
||||||
|
'enabled', ${targets.enabled}
|
||||||
|
))`.as("targets")
|
||||||
|
})
|
||||||
|
.from(resources)
|
||||||
|
.innerJoin(sites, eq(sites.siteId, resources.siteId))
|
||||||
|
.innerJoin(orgs, eq(resources.orgId, orgs.orgId))
|
||||||
|
.leftJoin(
|
||||||
|
targets,
|
||||||
and(
|
and(
|
||||||
eq(schema.targets.enabled, true),
|
eq(targets.resourceId, resources.resourceId),
|
||||||
isNotNull(schema.resources.subdomain),
|
eq(targets.enabled, true)
|
||||||
isNotNull(schema.orgs.domain),
|
)
|
||||||
),
|
)
|
||||||
);
|
.groupBy(resources.resourceId);
|
||||||
|
|
||||||
if (!all.length) {
|
if (!allResources.length) {
|
||||||
return res.status(HttpCode.OK).json({});
|
return res.status(HttpCode.OK).json({});
|
||||||
}
|
}
|
||||||
|
|
||||||
const badgerMiddlewareName = "badger";
|
const badgerMiddlewareName = "badger";
|
||||||
const redirectMiddlewareName = "redirect-to-https";
|
const redirectHttpsMiddlewareName = "redirect-to-https";
|
||||||
|
|
||||||
const http: any = {
|
const config_output: any = {
|
||||||
routers: {},
|
http: {
|
||||||
services: {},
|
|
||||||
middlewares: {
|
middlewares: {
|
||||||
[badgerMiddlewareName]: {
|
[badgerMiddlewareName]: {
|
||||||
plugin: {
|
plugin: {
|
||||||
[badgerMiddlewareName]: {
|
[badgerMiddlewareName]: {
|
||||||
apiBaseUrl: new URL(
|
apiBaseUrl: new URL(
|
||||||
"/api/v1",
|
"/api/v1",
|
||||||
`http://${config.getRawConfig().server.internal_hostname}:${config.getRawConfig().server.internal_port}`,
|
`http://${config.getRawConfig().server.internal_hostname}:${
|
||||||
|
config.getRawConfig().server
|
||||||
|
.internal_port
|
||||||
|
}`
|
||||||
).href,
|
).href,
|
||||||
resourceSessionCookieName:
|
|
||||||
config.getRawConfig().server.resource_session_cookie_name,
|
|
||||||
userSessionCookieName:
|
userSessionCookieName:
|
||||||
config.getRawConfig().server.session_cookie_name,
|
config.getRawConfig().server
|
||||||
accessTokenQueryParam: config.getRawConfig().server.resource_access_token_param,
|
.session_cookie_name,
|
||||||
|
accessTokenQueryParam:
|
||||||
|
config.getRawConfig().server
|
||||||
|
.resource_access_token_param,
|
||||||
|
resourceSessionRequestParam:
|
||||||
|
config.getRawConfig().server
|
||||||
|
.resource_session_request_param
|
||||||
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
},
|
[redirectHttpsMiddlewareName]: {
|
||||||
},
|
|
||||||
[redirectMiddlewareName]: {
|
|
||||||
redirectScheme: {
|
redirectScheme: {
|
||||||
scheme: "https",
|
scheme: "https"
|
||||||
permanent: true,
|
}
|
||||||
},
|
}
|
||||||
},
|
}
|
||||||
},
|
}
|
||||||
};
|
};
|
||||||
for (const item of all) {
|
|
||||||
const target = item.targets;
|
|
||||||
const resource = item.resources;
|
|
||||||
const site = item.sites;
|
|
||||||
const org = item.orgs;
|
|
||||||
|
|
||||||
const routerName = `${target.targetId}-router`;
|
for (const resource of allResources) {
|
||||||
const serviceName = `${target.targetId}-service`;
|
const targets = JSON.parse(resource.targets);
|
||||||
|
const site = resource.site;
|
||||||
|
const org = resource.org;
|
||||||
|
|
||||||
if (!resource || !resource.subdomain) {
|
if (!org.domain) {
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!org || !org.domain) {
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const routerName = `${resource.resourceId}-router`;
|
||||||
|
const serviceName = `${resource.resourceId}-service`;
|
||||||
const fullDomain = `${resource.subdomain}.${org.domain}`;
|
const fullDomain = `${resource.subdomain}.${org.domain}`;
|
||||||
|
|
||||||
|
if (resource.http) {
|
||||||
|
// HTTP configuration remains the same
|
||||||
|
if (!resource.subdomain) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
targets.filter(
|
||||||
|
(target: Target) => target.internalPort != null
|
||||||
|
).length == 0
|
||||||
|
) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// add routers and services empty objects if they don't exist
|
||||||
|
if (!config_output.http.routers) {
|
||||||
|
config_output.http.routers = {};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!config_output.http.services) {
|
||||||
|
config_output.http.services = {};
|
||||||
|
}
|
||||||
|
|
||||||
const domainParts = fullDomain.split(".");
|
const domainParts = fullDomain.split(".");
|
||||||
let wildCard;
|
let wildCard;
|
||||||
if (domainParts.length <= 2) {
|
if (domainParts.length <= 2) {
|
||||||
|
@ -101,74 +149,121 @@ export async function traefikConfigProvider(
|
||||||
? {
|
? {
|
||||||
domains: [
|
domains: [
|
||||||
{
|
{
|
||||||
main: wildCard,
|
main: wildCard
|
||||||
},
|
|
||||||
],
|
|
||||||
}
|
}
|
||||||
: {}),
|
]
|
||||||
|
}
|
||||||
|
: {})
|
||||||
};
|
};
|
||||||
|
|
||||||
http.routers![routerName] = {
|
const additionalMiddlewares = config.getRawConfig().traefik.additional_middlewares || [];
|
||||||
|
|
||||||
|
config_output.http.routers![routerName] = {
|
||||||
entryPoints: [
|
entryPoints: [
|
||||||
resource.ssl
|
resource.ssl
|
||||||
? config.getRawConfig().traefik.https_entrypoint
|
? config.getRawConfig().traefik.https_entrypoint
|
||||||
: config.getRawConfig().traefik.http_entrypoint,
|
: config.getRawConfig().traefik.http_entrypoint
|
||||||
],
|
],
|
||||||
middlewares: [badgerMiddlewareName],
|
middlewares: [badgerMiddlewareName, ...additionalMiddlewares],
|
||||||
service: serviceName,
|
service: serviceName,
|
||||||
rule: `Host(\`${fullDomain}\`)`,
|
rule: `Host(\`${fullDomain}\`)`,
|
||||||
...(resource.ssl ? { tls } : {}),
|
...(resource.ssl ? { tls } : {})
|
||||||
};
|
};
|
||||||
|
|
||||||
if (resource.ssl) {
|
if (resource.ssl) {
|
||||||
// this is a redirect router; all it does is redirect to the https version if tls is enabled
|
config_output.http.routers![routerName + "-redirect"] = {
|
||||||
http.routers![routerName + "-redirect"] = {
|
entryPoints: [
|
||||||
entryPoints: [config.getRawConfig().traefik.http_entrypoint],
|
config.getRawConfig().traefik.http_entrypoint
|
||||||
middlewares: [redirectMiddlewareName],
|
],
|
||||||
|
middlewares: [redirectHttpsMiddlewareName],
|
||||||
service: serviceName,
|
service: serviceName,
|
||||||
rule: `Host(\`${fullDomain}\`)`,
|
rule: `Host(\`${fullDomain}\`)`
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
if (site.type === "newt") {
|
config_output.http.services![serviceName] = {
|
||||||
|
loadBalancer: {
|
||||||
|
servers: targets
|
||||||
|
.filter(
|
||||||
|
(target: Target) => target.internalPort != null
|
||||||
|
)
|
||||||
|
.map((target: Target) => {
|
||||||
|
if (
|
||||||
|
site.type === "local" ||
|
||||||
|
site.type === "wireguard"
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
url: `${target.method}://${target.ip}:${target.port}`
|
||||||
|
};
|
||||||
|
} else if (site.type === "newt") {
|
||||||
const ip = site.subnet.split("/")[0];
|
const ip = site.subnet.split("/")[0];
|
||||||
http.services![serviceName] = {
|
return {
|
||||||
loadBalancer: {
|
url: `${target.method}://${ip}:${target.internalPort}`
|
||||||
servers: [
|
|
||||||
{
|
|
||||||
url: `${target.method}://${ip}:${target.internalPort}`,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
};
|
|
||||||
} else if (site.type === "wireguard") {
|
|
||||||
http.services![serviceName] = {
|
|
||||||
loadBalancer: {
|
|
||||||
servers: [
|
|
||||||
{
|
|
||||||
url: `${target.method}://${target.ip}:${target.port}`,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
};
|
|
||||||
} else if (site.type === "local") {
|
|
||||||
http.services![serviceName] = {
|
|
||||||
loadBalancer: {
|
|
||||||
servers: [
|
|
||||||
{
|
|
||||||
url: `${target.method}://${target.ip}:${target.port}`,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
// Non-HTTP (TCP/UDP) configuration
|
||||||
|
const protocol = resource.protocol.toLowerCase();
|
||||||
|
const port = resource.proxyPort;
|
||||||
|
|
||||||
|
if (!port) {
|
||||||
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
return res.status(HttpCode.OK).json({ http });
|
if (
|
||||||
|
targets.filter(
|
||||||
|
(target: Target) => target.internalPort != null
|
||||||
|
).length == 0
|
||||||
|
) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!config_output[protocol]) {
|
||||||
|
config_output[protocol] = {
|
||||||
|
routers: {},
|
||||||
|
services: {}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
config_output[protocol].routers[routerName] = {
|
||||||
|
entryPoints: [`${protocol}-${port}`],
|
||||||
|
service: serviceName,
|
||||||
|
...(protocol === "tcp" ? { rule: "HostSNI(`*`)" } : {})
|
||||||
|
};
|
||||||
|
|
||||||
|
config_output[protocol].services[serviceName] = {
|
||||||
|
loadBalancer: {
|
||||||
|
servers: targets
|
||||||
|
.filter(
|
||||||
|
(target: Target) => target.internalPort != null
|
||||||
|
)
|
||||||
|
.map((target: Target) => {
|
||||||
|
if (
|
||||||
|
site.type === "local" ||
|
||||||
|
site.type === "wireguard"
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
address: `${target.ip}:${target.port}`
|
||||||
|
};
|
||||||
|
} else if (site.type === "newt") {
|
||||||
|
const ip = site.subnet.split("/")[0];
|
||||||
|
return {
|
||||||
|
address: `${ip}:${target.internalPort}`
|
||||||
|
};
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return res.status(HttpCode.OK).json(config_output);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
logger.error(`Failed to build traefik config: ${e}`);
|
logger.error(`Failed to build traefik config: ${e}`);
|
||||||
return res.status(HttpCode.INTERNAL_SERVER_ERROR).json({
|
return res.status(HttpCode.INTERNAL_SERVER_ERROR).json({
|
||||||
error: "Failed to build traefik config",
|
error: "Failed to build traefik config"
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -23,7 +23,10 @@ const inviteUserParamsSchema = z
|
||||||
|
|
||||||
const inviteUserBodySchema = z
|
const inviteUserBodySchema = z
|
||||||
.object({
|
.object({
|
||||||
email: z.string().email(),
|
email: z
|
||||||
|
.string()
|
||||||
|
.email()
|
||||||
|
.transform((v) => v.toLowerCase()),
|
||||||
roleId: z.number(),
|
roleId: z.number(),
|
||||||
validHours: z.number().gt(0).lte(168),
|
validHours: z.number().gt(0).lte(168),
|
||||||
sendEmail: z.boolean().optional()
|
sendEmail: z.boolean().optional()
|
||||||
|
@ -165,7 +168,7 @@ export async function inviteUser(
|
||||||
}),
|
}),
|
||||||
{
|
{
|
||||||
to: email,
|
to: email,
|
||||||
from: config.getRawConfig().email?.no_reply,
|
from: config.getNoReplyEmail(),
|
||||||
subject: "You're invited to join a Fossorial organization"
|
subject: "You're invited to join a Fossorial organization"
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
|
@ -11,7 +11,7 @@ import m2 from "./scripts/1.0.0-beta2";
|
||||||
import m3 from "./scripts/1.0.0-beta3";
|
import m3 from "./scripts/1.0.0-beta3";
|
||||||
import m4 from "./scripts/1.0.0-beta5";
|
import m4 from "./scripts/1.0.0-beta5";
|
||||||
import m5 from "./scripts/1.0.0-beta6";
|
import m5 from "./scripts/1.0.0-beta6";
|
||||||
import { existsSync, mkdirSync } from "fs";
|
import m6 from "./scripts/1.0.0-beta9";
|
||||||
|
|
||||||
// THIS CANNOT IMPORT ANYTHING FROM THE SERVER
|
// THIS CANNOT IMPORT ANYTHING FROM THE SERVER
|
||||||
// EXCEPT FOR THE DATABASE AND THE SCHEMA
|
// EXCEPT FOR THE DATABASE AND THE SCHEMA
|
||||||
|
@ -22,7 +22,8 @@ const migrations = [
|
||||||
{ version: "1.0.0-beta.2", run: m2 },
|
{ version: "1.0.0-beta.2", run: m2 },
|
||||||
{ version: "1.0.0-beta.3", run: m3 },
|
{ version: "1.0.0-beta.3", run: m3 },
|
||||||
{ version: "1.0.0-beta.5", run: m4 },
|
{ version: "1.0.0-beta.5", run: m4 },
|
||||||
{ version: "1.0.0-beta.6", run: m5 }
|
{ version: "1.0.0-beta.6", run: m5 },
|
||||||
|
{ version: "1.0.0-beta.9", run: m6 }
|
||||||
// Add new migrations here as they are created
|
// Add new migrations here as they are created
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
|
@ -30,6 +31,7 @@ const migrations = [
|
||||||
await runMigrations();
|
await runMigrations();
|
||||||
|
|
||||||
export async function runMigrations() {
|
export async function runMigrations() {
|
||||||
|
try {
|
||||||
const appVersion = loadAppVersion();
|
const appVersion = loadAppVersion();
|
||||||
if (!appVersion) {
|
if (!appVersion) {
|
||||||
throw new Error("APP_VERSION is not set in the environment");
|
throw new Error("APP_VERSION is not set in the environment");
|
||||||
|
@ -56,6 +58,10 @@ export async function runMigrations() {
|
||||||
})
|
})
|
||||||
.execute();
|
.execute();
|
||||||
}
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Error running migrations:", e);
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 1000 * 60 * 60 * 24 * 1));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function executeScripts() {
|
async function executeScripts() {
|
||||||
|
|
291
server/setup/scripts/1.0.0-beta9.ts
Normal file
291
server/setup/scripts/1.0.0-beta9.ts
Normal 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.");
|
||||||
|
}
|
|
@ -17,7 +17,7 @@ import { useOrgContext } from "@app/hooks/useOrgContext";
|
||||||
import { useToast } from "@app/hooks/useToast";
|
import { useToast } from "@app/hooks/useToast";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { formatAxiosError } from "@app/lib/api";;
|
import { formatAxiosError } from "@app/lib/api";
|
||||||
import { createApiClient } from "@app/lib/api";
|
import { createApiClient } from "@app/lib/api";
|
||||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||||
import { useUserContext } from "@app/hooks/useUserContext";
|
import { useUserContext } from "@app/hooks/useUserContext";
|
||||||
|
@ -75,14 +75,14 @@ export default function UsersTable({ users: u }: UsersTableProps) {
|
||||||
</Button>
|
</Button>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent align="end">
|
<DropdownMenuContent align="end">
|
||||||
<DropdownMenuItem>
|
|
||||||
<Link
|
<Link
|
||||||
href={`/${org?.org.orgId}/settings/access/users/${userRow.id}`}
|
href={`/${org?.org.orgId}/settings/access/users/${userRow.id}`}
|
||||||
className="block w-full"
|
className="block w-full"
|
||||||
>
|
>
|
||||||
|
<DropdownMenuItem>
|
||||||
Manage User
|
Manage User
|
||||||
</Link>
|
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
|
</Link>
|
||||||
{userRow.email !== user?.email && (
|
{userRow.email !== user?.email && (
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
|
|
|
@ -45,21 +45,64 @@ import {
|
||||||
} from "@app/components/ui/command";
|
} from "@app/components/ui/command";
|
||||||
import { CaretSortIcon } from "@radix-ui/react-icons";
|
import { CaretSortIcon } from "@radix-ui/react-icons";
|
||||||
import CustomDomainInput from "./[resourceId]/CustomDomainInput";
|
import CustomDomainInput from "./[resourceId]/CustomDomainInput";
|
||||||
import { Axios, AxiosResponse } from "axios";
|
import { AxiosResponse } from "axios";
|
||||||
import { Resource } from "@server/db/schema";
|
import { Resource } from "@server/db/schema";
|
||||||
import { useOrgContext } from "@app/hooks/useOrgContext";
|
import { useOrgContext } from "@app/hooks/useOrgContext";
|
||||||
import { subdomainSchema } from "@server/schemas/subdomainSchema";
|
|
||||||
import { createApiClient } from "@app/lib/api";
|
import { createApiClient } from "@app/lib/api";
|
||||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||||
import { cn } from "@app/lib/cn";
|
import { cn } from "@app/lib/cn";
|
||||||
|
import { Switch } from "@app/components/ui/switch";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue
|
||||||
|
} from "@app/components/ui/select";
|
||||||
|
import { subdomainSchema } from "@server/schemas/subdomainSchema";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { SquareArrowOutUpRight } from "lucide-react";
|
||||||
|
|
||||||
const accountFormSchema = z.object({
|
const createResourceFormSchema = z
|
||||||
subdomain: subdomainSchema,
|
.object({
|
||||||
name: z.string(),
|
subdomain: z.string().optional(),
|
||||||
siteId: z.number()
|
name: z.string().min(1).max(255),
|
||||||
});
|
siteId: z.number(),
|
||||||
|
http: z.boolean(),
|
||||||
|
protocol: z.string(),
|
||||||
|
proxyPort: z.number().optional()
|
||||||
|
})
|
||||||
|
.refine(
|
||||||
|
(data) => {
|
||||||
|
if (!data.http) {
|
||||||
|
return z
|
||||||
|
.number()
|
||||||
|
.int()
|
||||||
|
.min(1)
|
||||||
|
.max(65535)
|
||||||
|
.safeParse(data.proxyPort).success;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
{
|
||||||
|
message: "Invalid port number",
|
||||||
|
path: ["proxyPort"]
|
||||||
|
}
|
||||||
|
)
|
||||||
|
.refine(
|
||||||
|
(data) => {
|
||||||
|
if (data.http) {
|
||||||
|
return subdomainSchema.safeParse(data.subdomain).success;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
{
|
||||||
|
message: "Invalid subdomain",
|
||||||
|
path: ["subdomain"]
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
type AccountFormValues = z.infer<typeof accountFormSchema>;
|
type CreateResourceFormValues = z.infer<typeof createResourceFormSchema>;
|
||||||
|
|
||||||
type CreateResourceFormProps = {
|
type CreateResourceFormProps = {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
|
@ -81,15 +124,18 @@ export default function CreateResourceForm({
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
const { org } = useOrgContext();
|
const { org } = useOrgContext();
|
||||||
|
const { env } = useEnvContext();
|
||||||
|
|
||||||
const [sites, setSites] = useState<ListSitesResponse["sites"]>([]);
|
const [sites, setSites] = useState<ListSitesResponse["sites"]>([]);
|
||||||
const [domainSuffix, setDomainSuffix] = useState<string>(org.org.domain);
|
const [domainSuffix, setDomainSuffix] = useState<string>(org.org.domain);
|
||||||
|
|
||||||
const form = useForm<AccountFormValues>({
|
const form = useForm<CreateResourceFormValues>({
|
||||||
resolver: zodResolver(accountFormSchema),
|
resolver: zodResolver(createResourceFormSchema),
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
subdomain: "",
|
subdomain: "",
|
||||||
name: "My Resource"
|
name: "My Resource",
|
||||||
|
http: true,
|
||||||
|
protocol: "tcp"
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -112,7 +158,7 @@ export default function CreateResourceForm({
|
||||||
fetchSites();
|
fetchSites();
|
||||||
}, [open]);
|
}, [open]);
|
||||||
|
|
||||||
async function onSubmit(data: AccountFormValues) {
|
async function onSubmit(data: CreateResourceFormValues) {
|
||||||
console.log(data);
|
console.log(data);
|
||||||
|
|
||||||
const res = await api
|
const res = await api
|
||||||
|
@ -120,8 +166,11 @@ export default function CreateResourceForm({
|
||||||
`/org/${orgId}/site/${data.siteId}/resource/`,
|
`/org/${orgId}/site/${data.siteId}/resource/`,
|
||||||
{
|
{
|
||||||
name: data.name,
|
name: data.name,
|
||||||
subdomain: data.subdomain
|
subdomain: data.http ? data.subdomain : undefined,
|
||||||
// subdomain: data.subdomain,
|
http: data.http,
|
||||||
|
protocol: data.protocol,
|
||||||
|
proxyPort: data.http ? undefined : data.proxyPort,
|
||||||
|
siteId: data.siteId
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
.catch((e) => {
|
.catch((e) => {
|
||||||
|
@ -188,6 +237,37 @@ export default function CreateResourceForm({
|
||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{!env.flags.allowRawResources || (
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="http"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-4">
|
||||||
|
<div className="space-y-0.5">
|
||||||
|
<FormLabel className="text-base">
|
||||||
|
HTTP Resource
|
||||||
|
</FormLabel>
|
||||||
|
<FormDescription>
|
||||||
|
Toggle if this is an
|
||||||
|
HTTP resource or a raw
|
||||||
|
TCP/UDP resource
|
||||||
|
</FormDescription>
|
||||||
|
</div>
|
||||||
|
<FormControl>
|
||||||
|
<Switch
|
||||||
|
checked={field.value}
|
||||||
|
onCheckedChange={
|
||||||
|
field.onChange
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{form.watch("http") && (
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="subdomain"
|
name="subdomain"
|
||||||
|
@ -196,8 +276,12 @@ export default function CreateResourceForm({
|
||||||
<FormLabel>Subdomain</FormLabel>
|
<FormLabel>Subdomain</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<CustomDomainInput
|
<CustomDomainInput
|
||||||
value={field.value}
|
value={
|
||||||
domainSuffix={domainSuffix}
|
field.value ?? ""
|
||||||
|
}
|
||||||
|
domainSuffix={
|
||||||
|
domainSuffix
|
||||||
|
}
|
||||||
placeholder="Enter subdomain"
|
placeholder="Enter subdomain"
|
||||||
onChange={(value) =>
|
onChange={(value) =>
|
||||||
form.setValue(
|
form.setValue(
|
||||||
|
@ -209,13 +293,109 @@ export default function CreateResourceForm({
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormDescription>
|
<FormDescription>
|
||||||
This is the fully qualified
|
This is the fully qualified
|
||||||
domain name that will be used to
|
domain name that will be
|
||||||
access the resource.
|
used to access the resource.
|
||||||
</FormDescription>
|
</FormDescription>
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!form.watch("http") && (
|
||||||
|
<Link
|
||||||
|
className="text-sm text-primary flex items-center gap-1"
|
||||||
|
href="https://docs.fossorial.io/Getting%20Started/tcp-udp"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
>
|
||||||
|
<span>
|
||||||
|
Learn how to configure TCP/UDP resources
|
||||||
|
</span>
|
||||||
|
<SquareArrowOutUpRight size={14} />
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!form.watch("http") && (
|
||||||
|
<>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="protocol"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>
|
||||||
|
Protocol
|
||||||
|
</FormLabel>
|
||||||
|
<Select
|
||||||
|
value={field.value}
|
||||||
|
onValueChange={
|
||||||
|
field.onChange
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<FormControl>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="Select a protocol" />
|
||||||
|
</SelectTrigger>
|
||||||
|
</FormControl>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="tcp">
|
||||||
|
TCP
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem value="udp">
|
||||||
|
UDP
|
||||||
|
</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<FormDescription>
|
||||||
|
The protocol to use for
|
||||||
|
the resource
|
||||||
|
</FormDescription>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="proxyPort"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>
|
||||||
|
Port Number
|
||||||
|
</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
placeholder="Enter port number"
|
||||||
|
value={
|
||||||
|
field.value ??
|
||||||
|
""
|
||||||
|
}
|
||||||
|
onChange={(e) =>
|
||||||
|
field.onChange(
|
||||||
|
e.target
|
||||||
|
.value
|
||||||
|
? parseInt(
|
||||||
|
e
|
||||||
|
.target
|
||||||
|
.value
|
||||||
|
)
|
||||||
|
: null
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormDescription>
|
||||||
|
The port number to proxy
|
||||||
|
requests to (required
|
||||||
|
for non-HTTP resources)
|
||||||
|
</FormDescription>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="siteId"
|
name="siteId"
|
||||||
|
@ -257,7 +437,7 @@ export default function CreateResourceForm({
|
||||||
(site) => (
|
(site) => (
|
||||||
<CommandItem
|
<CommandItem
|
||||||
value={
|
value={
|
||||||
site.name
|
site.niceId
|
||||||
}
|
}
|
||||||
key={
|
key={
|
||||||
site.siteId
|
site.siteId
|
||||||
|
|
|
@ -25,7 +25,7 @@ import CreateResourceForm from "./CreateResourceForm";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog";
|
import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog";
|
||||||
import { set } from "zod";
|
import { set } from "zod";
|
||||||
import { formatAxiosError } from "@app/lib/api";;
|
import { formatAxiosError } from "@app/lib/api";
|
||||||
import { useToast } from "@app/hooks/useToast";
|
import { useToast } from "@app/hooks/useToast";
|
||||||
import { createApiClient } from "@app/lib/api";
|
import { createApiClient } from "@app/lib/api";
|
||||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||||
|
@ -39,6 +39,9 @@ export type ResourceRow = {
|
||||||
site: string;
|
site: string;
|
||||||
siteId: string;
|
siteId: string;
|
||||||
hasAuth: boolean;
|
hasAuth: boolean;
|
||||||
|
http: boolean;
|
||||||
|
protocol: string;
|
||||||
|
proxyPort: number | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
type ResourcesTableProps = {
|
type ResourcesTableProps = {
|
||||||
|
@ -91,14 +94,14 @@ export default function SitesTable({ resources, orgId }: ResourcesTableProps) {
|
||||||
</Button>
|
</Button>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent align="end">
|
<DropdownMenuContent align="end">
|
||||||
<DropdownMenuItem>
|
|
||||||
<Link
|
<Link
|
||||||
className="block w-full"
|
className="block w-full"
|
||||||
href={`/${resourceRow.orgId}/settings/resources/${resourceRow.id}`}
|
href={`/${resourceRow.orgId}/settings/resources/${resourceRow.id}`}
|
||||||
>
|
>
|
||||||
|
<DropdownMenuItem>
|
||||||
View settings
|
View settings
|
||||||
</Link>
|
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
|
</Link>
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setSelectedResource(resourceRow);
|
setSelectedResource(resourceRow);
|
||||||
|
@ -146,24 +149,40 @@ export default function SitesTable({ resources, orgId }: ResourcesTableProps) {
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
const resourceRow = row.original;
|
const resourceRow = row.original;
|
||||||
return (
|
return (
|
||||||
<Button variant="outline">
|
|
||||||
<Link
|
<Link
|
||||||
href={`/${resourceRow.orgId}/settings/sites/${resourceRow.siteId}`}
|
href={`/${resourceRow.orgId}/settings/sites/${resourceRow.siteId}`}
|
||||||
>
|
>
|
||||||
|
<Button variant="outline">
|
||||||
{resourceRow.site}
|
{resourceRow.site}
|
||||||
</Link>
|
|
||||||
<ArrowUpRight className="ml-2 h-4 w-4" />
|
<ArrowUpRight className="ml-2 h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "protocol",
|
||||||
|
header: "Protocol",
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const resourceRow = row.original;
|
||||||
|
return (
|
||||||
|
<span>{resourceRow.protocol.toUpperCase()}</span>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
accessorKey: "domain",
|
accessorKey: "domain",
|
||||||
header: "Full URL",
|
header: "Access",
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
const resourceRow = row.original;
|
const resourceRow = row.original;
|
||||||
return (
|
return (
|
||||||
|
<div>
|
||||||
|
{!resourceRow.http ? (
|
||||||
|
<CopyToClipboard text={resourceRow.proxyPort!.toString()} isLink={false} />
|
||||||
|
) : (
|
||||||
<CopyToClipboard text={resourceRow.domain} isLink={true} />
|
<CopyToClipboard text={resourceRow.domain} isLink={true} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
@ -186,7 +205,12 @@ export default function SitesTable({ resources, orgId }: ResourcesTableProps) {
|
||||||
const resourceRow = row.original;
|
const resourceRow = row.original;
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
{resourceRow.hasAuth ? (
|
|
||||||
|
|
||||||
|
{!resourceRow.http ? (
|
||||||
|
<span>--</span>
|
||||||
|
) :
|
||||||
|
resourceRow.hasAuth ? (
|
||||||
<span className="text-green-500 flex items-center space-x-2">
|
<span className="text-green-500 flex items-center space-x-2">
|
||||||
<ShieldCheck className="w-4 h-4" />
|
<ShieldCheck className="w-4 h-4" />
|
||||||
<span>Protected</span>
|
<span>Protected</span>
|
||||||
|
@ -196,7 +220,8 @@ export default function SitesTable({ resources, orgId }: ResourcesTableProps) {
|
||||||
<ShieldOff className="w-4 h-4" />
|
<ShieldOff className="w-4 h-4" />
|
||||||
<span>Not Protected</span>
|
<span>Not Protected</span>
|
||||||
</span>
|
</span>
|
||||||
)}
|
)
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -2,12 +2,8 @@
|
||||||
|
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import {
|
import {
|
||||||
InfoIcon,
|
InfoIcon,
|
||||||
LinkIcon,
|
|
||||||
CheckIcon,
|
|
||||||
CopyIcon,
|
|
||||||
ShieldCheck,
|
ShieldCheck,
|
||||||
ShieldOff
|
ShieldOff
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
@ -42,8 +38,12 @@ export default function ResourceInfoBox({}: ResourceInfoBoxType) {
|
||||||
</AlertTitle>
|
</AlertTitle>
|
||||||
<AlertDescription className="mt-4">
|
<AlertDescription className="mt-4">
|
||||||
<InfoSections>
|
<InfoSections>
|
||||||
|
{resource.http ? (
|
||||||
|
<>
|
||||||
<InfoSection>
|
<InfoSection>
|
||||||
<InfoSectionTitle>Authentication</InfoSectionTitle>
|
<InfoSectionTitle>
|
||||||
|
Authentication
|
||||||
|
</InfoSectionTitle>
|
||||||
<InfoSectionContent>
|
<InfoSectionContent>
|
||||||
{authInfo.password ||
|
{authInfo.password ||
|
||||||
authInfo.pincode ||
|
authInfo.pincode ||
|
||||||
|
@ -52,8 +52,8 @@ export default function ResourceInfoBox({}: ResourceInfoBoxType) {
|
||||||
<div className="flex items-start space-x-2 text-green-500">
|
<div className="flex items-start space-x-2 text-green-500">
|
||||||
<ShieldCheck className="w-4 h-4 mt-0.5" />
|
<ShieldCheck className="w-4 h-4 mt-0.5" />
|
||||||
<span>
|
<span>
|
||||||
This resource is protected with at least
|
This resource is protected with
|
||||||
one auth method.
|
at least one auth method.
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
|
@ -70,9 +70,33 @@ export default function ResourceInfoBox({}: ResourceInfoBoxType) {
|
||||||
<InfoSection>
|
<InfoSection>
|
||||||
<InfoSectionTitle>URL</InfoSectionTitle>
|
<InfoSectionTitle>URL</InfoSectionTitle>
|
||||||
<InfoSectionContent>
|
<InfoSectionContent>
|
||||||
<CopyToClipboard text={fullUrl} isLink={true} />
|
<CopyToClipboard
|
||||||
|
text={fullUrl}
|
||||||
|
isLink={true}
|
||||||
|
/>
|
||||||
</InfoSectionContent>
|
</InfoSectionContent>
|
||||||
</InfoSection>
|
</InfoSection>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<InfoSection>
|
||||||
|
<InfoSectionTitle>Protocol</InfoSectionTitle>
|
||||||
|
<InfoSectionContent>
|
||||||
|
<span>{resource.protocol.toUpperCase()}</span>
|
||||||
|
</InfoSectionContent>
|
||||||
|
</InfoSection>
|
||||||
|
<Separator orientation="vertical" />
|
||||||
|
<InfoSection>
|
||||||
|
<InfoSectionTitle>Port</InfoSectionTitle>
|
||||||
|
<InfoSectionContent>
|
||||||
|
<CopyToClipboard
|
||||||
|
text={resource.proxyPort!.toString()}
|
||||||
|
isLink={false}
|
||||||
|
/>
|
||||||
|
</InfoSectionContent>
|
||||||
|
</InfoSection>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</InfoSections>
|
</InfoSections>
|
||||||
</AlertDescription>
|
</AlertDescription>
|
||||||
</Alert>
|
</Alert>
|
||||||
|
|
|
@ -48,6 +48,7 @@ import {
|
||||||
SettingsSectionFooter
|
SettingsSectionFooter
|
||||||
} from "@app/components/Settings";
|
} from "@app/components/Settings";
|
||||||
import { SwitchInput } from "@app/components/SwitchInput";
|
import { SwitchInput } from "@app/components/SwitchInput";
|
||||||
|
import { InfoPopup } from "@app/components/ui/info-popup";
|
||||||
|
|
||||||
const UsersRolesFormSchema = z.object({
|
const UsersRolesFormSchema = z.object({
|
||||||
roles: z.array(
|
roles: z.array(
|
||||||
|
@ -665,10 +666,12 @@ export default function ResourceAuthenticationPage() {
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>
|
<FormLabel>
|
||||||
Whitelisted Emails
|
<InfoPopup
|
||||||
|
text="Whitelisted Emails"
|
||||||
|
info="Only users with these email addresses will be able to access this resource. They will be prompted to enter a one-time password sent to their email. Wildcards (*@example.com) can be used to allow any email address from a domain."
|
||||||
|
/>
|
||||||
</FormLabel>
|
</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
{/* @ts-ignore */}
|
|
||||||
{/* @ts-ignore */}
|
{/* @ts-ignore */}
|
||||||
<TagInput
|
<TagInput
|
||||||
{...field}
|
{...field}
|
||||||
|
@ -681,6 +684,17 @@ export default function ResourceAuthenticationPage() {
|
||||||
return z
|
return z
|
||||||
.string()
|
.string()
|
||||||
.email()
|
.email()
|
||||||
|
.or(
|
||||||
|
z
|
||||||
|
.string()
|
||||||
|
.regex(
|
||||||
|
/^\*@[\w.-]+\.[a-zA-Z]{2,}$/,
|
||||||
|
{
|
||||||
|
message:
|
||||||
|
"Invalid email address. Wildcard (*) must be the entire local part."
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
.safeParse(
|
.safeParse(
|
||||||
tag
|
tag
|
||||||
).success;
|
).success;
|
||||||
|
|
|
@ -63,6 +63,7 @@ import {
|
||||||
} from "@app/components/Settings";
|
} from "@app/components/Settings";
|
||||||
import { SwitchInput } from "@app/components/SwitchInput";
|
import { SwitchInput } from "@app/components/SwitchInput";
|
||||||
import { useSiteContext } from "@app/hooks/useSiteContext";
|
import { useSiteContext } from "@app/hooks/useSiteContext";
|
||||||
|
import { InfoPopup } from "@app/components/ui/info-popup";
|
||||||
|
|
||||||
// Regular expressions for validation
|
// Regular expressions for validation
|
||||||
const DOMAIN_REGEX =
|
const DOMAIN_REGEX =
|
||||||
|
@ -94,7 +95,7 @@ const domainSchema = z
|
||||||
|
|
||||||
const addTargetSchema = z.object({
|
const addTargetSchema = z.object({
|
||||||
ip: domainSchema,
|
ip: domainSchema,
|
||||||
method: z.string(),
|
method: z.string().nullable(),
|
||||||
port: z.coerce.number().int().positive()
|
port: z.coerce.number().int().positive()
|
||||||
// protocol: z.string(),
|
// protocol: z.string(),
|
||||||
});
|
});
|
||||||
|
@ -129,9 +130,9 @@ export default function ReverseProxyTargets(props: {
|
||||||
const addTargetForm = useForm({
|
const addTargetForm = useForm({
|
||||||
resolver: zodResolver(addTargetSchema),
|
resolver: zodResolver(addTargetSchema),
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
ip: "",
|
ip: "localhost",
|
||||||
method: "http",
|
method: resource.http ? "http" : null,
|
||||||
port: 80
|
port: resource.http ? 80 : resource.proxyPort || 1234
|
||||||
// protocol: "TCP",
|
// protocol: "TCP",
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
@ -321,7 +322,7 @@ export default function ReverseProxyTargets(props: {
|
||||||
});
|
});
|
||||||
|
|
||||||
setSslEnabled(val);
|
setSslEnabled(val);
|
||||||
updateResource({ ssl: sslEnabled });
|
updateResource({ ssl: val });
|
||||||
|
|
||||||
toast({
|
toast({
|
||||||
title: "SSL Configuration",
|
title: "SSL Configuration",
|
||||||
|
@ -330,26 +331,6 @@ export default function ReverseProxyTargets(props: {
|
||||||
}
|
}
|
||||||
|
|
||||||
const columns: ColumnDef<LocalTarget>[] = [
|
const columns: ColumnDef<LocalTarget>[] = [
|
||||||
{
|
|
||||||
accessorKey: "method",
|
|
||||||
header: "Method",
|
|
||||||
cell: ({ row }) => (
|
|
||||||
<Select
|
|
||||||
defaultValue={row.original.method}
|
|
||||||
onValueChange={(value) =>
|
|
||||||
updateTarget(row.original.targetId, { method: value })
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<SelectTrigger className="min-w-[100px]">
|
|
||||||
{row.original.method}
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="http">http</SelectItem>
|
|
||||||
<SelectItem value="https">https</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
)
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
accessorKey: "ip",
|
accessorKey: "ip",
|
||||||
header: "IP / Hostname",
|
header: "IP / Hostname",
|
||||||
|
@ -436,6 +417,32 @@ export default function ReverseProxyTargets(props: {
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
|
if (resource.http) {
|
||||||
|
const methodCol: ColumnDef<LocalTarget> = {
|
||||||
|
accessorKey: "method",
|
||||||
|
header: "Method",
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<Select
|
||||||
|
defaultValue={row.original.method ?? ""}
|
||||||
|
onValueChange={(value) =>
|
||||||
|
updateTarget(row.original.targetId, { method: value })
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="min-w-[100px]">
|
||||||
|
{row.original.method}
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="http">http</SelectItem>
|
||||||
|
<SelectItem value="https">https</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
)
|
||||||
|
};
|
||||||
|
|
||||||
|
// add this to the first column
|
||||||
|
columns.unshift(methodCol);
|
||||||
|
}
|
||||||
|
|
||||||
const table = useReactTable({
|
const table = useReactTable({
|
||||||
data: targets,
|
data: targets,
|
||||||
columns,
|
columns,
|
||||||
|
@ -451,15 +458,15 @@ export default function ReverseProxyTargets(props: {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SettingsContainer>
|
<SettingsContainer>
|
||||||
{/* SSL Section */}
|
{resource.http && (
|
||||||
<SettingsSection>
|
<SettingsSection>
|
||||||
<SettingsSectionHeader>
|
<SettingsSectionHeader>
|
||||||
<SettingsSectionTitle>
|
<SettingsSectionTitle>
|
||||||
SSL Configuration
|
SSL Configuration
|
||||||
</SettingsSectionTitle>
|
</SettingsSectionTitle>
|
||||||
<SettingsSectionDescription>
|
<SettingsSectionDescription>
|
||||||
Setup SSL to secure your connections with LetsEncrypt
|
Setup SSL to secure your connections with
|
||||||
certificates
|
LetsEncrypt certificates
|
||||||
</SettingsSectionDescription>
|
</SettingsSectionDescription>
|
||||||
</SettingsSectionHeader>
|
</SettingsSectionHeader>
|
||||||
<SettingsSectionBody>
|
<SettingsSectionBody>
|
||||||
|
@ -473,7 +480,7 @@ export default function ReverseProxyTargets(props: {
|
||||||
/>
|
/>
|
||||||
</SettingsSectionBody>
|
</SettingsSectionBody>
|
||||||
</SettingsSection>
|
</SettingsSection>
|
||||||
|
)}
|
||||||
{/* Targets Section */}
|
{/* Targets Section */}
|
||||||
<SettingsSection>
|
<SettingsSection>
|
||||||
<SettingsSectionHeader>
|
<SettingsSectionHeader>
|
||||||
|
@ -491,6 +498,7 @@ export default function ReverseProxyTargets(props: {
|
||||||
className="space-y-4"
|
className="space-y-4"
|
||||||
>
|
>
|
||||||
<div className="grid grid-cols-2 md:grid-cols-3 gap-4">
|
<div className="grid grid-cols-2 md:grid-cols-3 gap-4">
|
||||||
|
{resource.http && (
|
||||||
<FormField
|
<FormField
|
||||||
control={addTargetForm.control}
|
control={addTargetForm.control}
|
||||||
name="method"
|
name="method"
|
||||||
|
@ -499,8 +507,13 @@ export default function ReverseProxyTargets(props: {
|
||||||
<FormLabel>Method</FormLabel>
|
<FormLabel>Method</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Select
|
<Select
|
||||||
{...field}
|
value={
|
||||||
onValueChange={(value) => {
|
field.value ||
|
||||||
|
undefined
|
||||||
|
}
|
||||||
|
onValueChange={(
|
||||||
|
value
|
||||||
|
) => {
|
||||||
addTargetForm.setValue(
|
addTargetForm.setValue(
|
||||||
"method",
|
"method",
|
||||||
value
|
value
|
||||||
|
@ -524,6 +537,8 @@ export default function ReverseProxyTargets(props: {
|
||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
<FormField
|
<FormField
|
||||||
control={addTargetForm.control}
|
control={addTargetForm.control}
|
||||||
name="ip"
|
name="ip"
|
||||||
|
@ -637,6 +652,9 @@ export default function ReverseProxyTargets(props: {
|
||||||
</TableBody>
|
</TableBody>
|
||||||
</Table>
|
</Table>
|
||||||
</TableContainer>
|
</TableContainer>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Adding more than one target above will enable load balancing.
|
||||||
|
</p>
|
||||||
</SettingsSectionBody>
|
</SettingsSectionBody>
|
||||||
<SettingsSectionFooter>
|
<SettingsSectionFooter>
|
||||||
<Button
|
<Button
|
||||||
|
|
|
@ -13,22 +13,7 @@ import {
|
||||||
FormLabel,
|
FormLabel,
|
||||||
FormMessage
|
FormMessage
|
||||||
} from "@/components/ui/form";
|
} from "@/components/ui/form";
|
||||||
import { CaretSortIcon, CheckIcon } from "@radix-ui/react-icons";
|
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import {
|
|
||||||
Command,
|
|
||||||
CommandEmpty,
|
|
||||||
CommandGroup,
|
|
||||||
CommandInput,
|
|
||||||
CommandItem,
|
|
||||||
CommandList
|
|
||||||
} from "@/components/ui/command";
|
|
||||||
|
|
||||||
import {
|
|
||||||
Popover,
|
|
||||||
PopoverContent,
|
|
||||||
PopoverTrigger
|
|
||||||
} from "@/components/ui/popover";
|
|
||||||
import { useResourceContext } from "@app/hooks/useResourceContext";
|
import { useResourceContext } from "@app/hooks/useResourceContext";
|
||||||
import { ListSitesResponse } from "@server/routers/site";
|
import { ListSitesResponse } from "@server/routers/site";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
|
@ -49,16 +34,46 @@ import {
|
||||||
} from "@app/components/Settings";
|
} from "@app/components/Settings";
|
||||||
import { useOrgContext } from "@app/hooks/useOrgContext";
|
import { useOrgContext } from "@app/hooks/useOrgContext";
|
||||||
import CustomDomainInput from "../CustomDomainInput";
|
import CustomDomainInput from "../CustomDomainInput";
|
||||||
import ResourceInfoBox from "../ResourceInfoBox";
|
|
||||||
import { subdomainSchema } from "@server/schemas/subdomainSchema";
|
|
||||||
import { createApiClient } from "@app/lib/api";
|
import { createApiClient } from "@app/lib/api";
|
||||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||||
|
import { subdomainSchema } from "@server/schemas/subdomainSchema";
|
||||||
|
|
||||||
const GeneralFormSchema = z.object({
|
const GeneralFormSchema = z
|
||||||
name: z.string(),
|
.object({
|
||||||
subdomain: subdomainSchema
|
subdomain: z.string().optional(),
|
||||||
// siteId: z.number(),
|
name: z.string().min(1).max(255),
|
||||||
});
|
proxyPort: z.number().optional(),
|
||||||
|
http: z.boolean()
|
||||||
|
})
|
||||||
|
.refine(
|
||||||
|
(data) => {
|
||||||
|
if (!data.http) {
|
||||||
|
return z
|
||||||
|
.number()
|
||||||
|
.int()
|
||||||
|
.min(1)
|
||||||
|
.max(65535)
|
||||||
|
.safeParse(data.proxyPort).success;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
{
|
||||||
|
message: "Invalid port number",
|
||||||
|
path: ["proxyPort"]
|
||||||
|
}
|
||||||
|
)
|
||||||
|
.refine(
|
||||||
|
(data) => {
|
||||||
|
if (data.http) {
|
||||||
|
return subdomainSchema.safeParse(data.subdomain).success;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
{
|
||||||
|
message: "Invalid subdomain",
|
||||||
|
path: ["subdomain"]
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
type GeneralFormValues = z.infer<typeof GeneralFormSchema>;
|
type GeneralFormValues = z.infer<typeof GeneralFormSchema>;
|
||||||
|
|
||||||
|
@ -81,8 +96,9 @@ export default function GeneralForm() {
|
||||||
resolver: zodResolver(GeneralFormSchema),
|
resolver: zodResolver(GeneralFormSchema),
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
name: resource.name,
|
name: resource.name,
|
||||||
subdomain: resource.subdomain
|
subdomain: resource.subdomain ? resource.subdomain : undefined,
|
||||||
// siteId: resource.siteId!,
|
proxyPort: resource.proxyPort ? resource.proxyPort : undefined,
|
||||||
|
http: resource.http
|
||||||
},
|
},
|
||||||
mode: "onChange"
|
mode: "onChange"
|
||||||
});
|
});
|
||||||
|
@ -169,6 +185,7 @@ export default function GeneralForm() {
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{resource.http ? (
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="subdomain"
|
name="subdomain"
|
||||||
|
@ -177,8 +194,12 @@ export default function GeneralForm() {
|
||||||
<FormLabel>Subdomain</FormLabel>
|
<FormLabel>Subdomain</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<CustomDomainInput
|
<CustomDomainInput
|
||||||
value={field.value}
|
value={
|
||||||
domainSuffix={domainSuffix}
|
field.value || ""
|
||||||
|
}
|
||||||
|
domainSuffix={
|
||||||
|
domainSuffix
|
||||||
|
}
|
||||||
placeholder="Enter subdomain"
|
placeholder="Enter subdomain"
|
||||||
onChange={(value) =>
|
onChange={(value) =>
|
||||||
form.setValue(
|
form.setValue(
|
||||||
|
@ -189,13 +210,53 @@ export default function GeneralForm() {
|
||||||
/>
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormDescription>
|
<FormDescription>
|
||||||
This is the subdomain that will
|
This is the subdomain that
|
||||||
be used to access the resource.
|
will be used to access the
|
||||||
|
resource.
|
||||||
</FormDescription>
|
</FormDescription>
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
) : (
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="proxyPort"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>
|
||||||
|
Port Number
|
||||||
|
</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
placeholder="Enter port number"
|
||||||
|
value={
|
||||||
|
field.value ?? ""
|
||||||
|
}
|
||||||
|
onChange={(e) =>
|
||||||
|
field.onChange(
|
||||||
|
e.target.value
|
||||||
|
? parseInt(
|
||||||
|
e
|
||||||
|
.target
|
||||||
|
.value
|
||||||
|
)
|
||||||
|
: null
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormDescription>
|
||||||
|
This is the port that will
|
||||||
|
be used to access the
|
||||||
|
resource.
|
||||||
|
</FormDescription>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</form>
|
</form>
|
||||||
</Form>
|
</Form>
|
||||||
</SettingsSectionForm>
|
</SettingsSectionForm>
|
||||||
|
|
|
@ -90,13 +90,16 @@ export default async function ResourceLayout(props: ResourceLayoutProps) {
|
||||||
title: "Connectivity",
|
title: "Connectivity",
|
||||||
href: `/{orgId}/settings/resources/{resourceId}/connectivity`
|
href: `/{orgId}/settings/resources/{resourceId}/connectivity`
|
||||||
// icon: <Cloud className="w-4 h-4" />,
|
// icon: <Cloud className="w-4 h-4" />,
|
||||||
},
|
}
|
||||||
{
|
];
|
||||||
|
|
||||||
|
if (resource.http) {
|
||||||
|
sidebarNavItems.push({
|
||||||
title: "Authentication",
|
title: "Authentication",
|
||||||
href: `/{orgId}/settings/resources/{resourceId}/authentication`
|
href: `/{orgId}/settings/resources/{resourceId}/authentication`
|
||||||
// icon: <Shield className="w-4 h-4" />,
|
// icon: <Shield className="w-4 h-4" />,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
];
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
|
|
@ -53,6 +53,9 @@ export default async function ResourcesPage(props: ResourcesPageProps) {
|
||||||
domain: `${resource.ssl ? "https://" : "http://"}${resource.fullDomain}`,
|
domain: `${resource.ssl ? "https://" : "http://"}${resource.fullDomain}`,
|
||||||
site: resource.siteName || "None",
|
site: resource.siteName || "None",
|
||||||
siteId: resource.siteId || "Unknown",
|
siteId: resource.siteId || "Unknown",
|
||||||
|
protocol: resource.protocol,
|
||||||
|
proxyPort: resource.proxyPort,
|
||||||
|
http: resource.http,
|
||||||
hasAuth:
|
hasAuth:
|
||||||
resource.sso ||
|
resource.sso ||
|
||||||
resource.pincodeId !== null ||
|
resource.pincodeId !== null ||
|
||||||
|
|
|
@ -153,7 +153,9 @@ export default function CreateShareLinkForm({
|
||||||
|
|
||||||
if (res?.status === 200) {
|
if (res?.status === 200) {
|
||||||
setResources(
|
setResources(
|
||||||
res.data.data.resources.map((r) => ({
|
res.data.data.resources.filter((r) => {
|
||||||
|
return r.http;
|
||||||
|
}).map((r) => ({
|
||||||
resourceId: r.resourceId,
|
resourceId: r.resourceId,
|
||||||
name: r.name,
|
name: r.name,
|
||||||
resourceUrl: `${r.ssl ? "https://" : "http://"}${r.fullDomain}/`
|
resourceUrl: `${r.ssl ? "https://" : "http://"}${r.fullDomain}/`
|
||||||
|
@ -318,7 +320,7 @@ export default function CreateShareLinkForm({
|
||||||
) => (
|
) => (
|
||||||
<CommandItem
|
<CommandItem
|
||||||
value={
|
value={
|
||||||
r.name
|
r.resourceId.toString()
|
||||||
}
|
}
|
||||||
key={
|
key={
|
||||||
r.resourceId
|
r.resourceId
|
||||||
|
|
|
@ -145,14 +145,12 @@ export default function ShareLinksTable({
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
const r = row.original;
|
const r = row.original;
|
||||||
return (
|
return (
|
||||||
|
<Link href={`/${orgId}/settings/resources/${r.resourceId}`}>
|
||||||
<Button variant="outline">
|
<Button variant="outline">
|
||||||
<Link
|
|
||||||
href={`/${orgId}/settings/resources/${r.resourceId}`}
|
|
||||||
>
|
|
||||||
{r.resourceName}
|
{r.resourceName}
|
||||||
</Link>
|
|
||||||
<ArrowUpRight className="ml-2 h-4 w-4" />
|
<ArrowUpRight className="ml-2 h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
|
</Link>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
|
@ -92,14 +92,14 @@ export default function SitesTable({ sites, orgId }: SitesTableProps) {
|
||||||
</Button>
|
</Button>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent align="end">
|
<DropdownMenuContent align="end">
|
||||||
<DropdownMenuItem>
|
|
||||||
<Link
|
<Link
|
||||||
className="block w-full"
|
className="block w-full"
|
||||||
href={`/${siteRow.orgId}/settings/sites/${siteRow.nice}`}
|
href={`/${siteRow.orgId}/settings/sites/${siteRow.nice}`}
|
||||||
>
|
>
|
||||||
|
<DropdownMenuItem>
|
||||||
View settings
|
View settings
|
||||||
</Link>
|
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
|
</Link>
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setSelectedSite(siteRow);
|
setSelectedSite(siteRow);
|
||||||
|
|
|
@ -5,14 +5,12 @@ import { Button } from "@app/components/ui/button";
|
||||||
import {
|
import {
|
||||||
Card,
|
Card,
|
||||||
CardContent,
|
CardContent,
|
||||||
CardFooter,
|
|
||||||
CardHeader,
|
CardHeader,
|
||||||
CardTitle
|
CardTitle
|
||||||
} from "@app/components/ui/card";
|
} from "@app/components/ui/card";
|
||||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||||
import { AuthWithAccessTokenResponse } from "@server/routers/resource";
|
import { AuthWithAccessTokenResponse } from "@server/routers/resource";
|
||||||
import { AxiosResponse } from "axios";
|
import { AxiosResponse } from "axios";
|
||||||
import { Loader2 } from "lucide-react";
|
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
|
@ -32,7 +30,17 @@ export default function AccessToken({
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [isValid, setIsValid] = useState(false);
|
const [isValid, setIsValid] = useState(false);
|
||||||
|
|
||||||
const api = createApiClient(useEnvContext());
|
const { env } = useEnvContext();
|
||||||
|
const api = createApiClient({ env });
|
||||||
|
|
||||||
|
function appendRequestToken(url: string, token: string) {
|
||||||
|
const fullUrl = new URL(url);
|
||||||
|
fullUrl.searchParams.append(
|
||||||
|
env.server.resourceSessionRequestParam,
|
||||||
|
token
|
||||||
|
);
|
||||||
|
return fullUrl.toString();
|
||||||
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!accessTokenId || !accessToken) {
|
if (!accessTokenId || !accessToken) {
|
||||||
|
@ -51,7 +59,10 @@ export default function AccessToken({
|
||||||
|
|
||||||
if (res.data.data.session) {
|
if (res.data.data.session) {
|
||||||
setIsValid(true);
|
setIsValid(true);
|
||||||
window.location.href = redirectUrl;
|
window.location.href = appendRequestToken(
|
||||||
|
redirectUrl,
|
||||||
|
res.data.data.session
|
||||||
|
);
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("Error checking access token", e);
|
console.error("Error checking access token", e);
|
||||||
|
|
|
@ -19,7 +19,7 @@ export default function ResourceAccessDenied() {
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
You're not alowed to access this resource. If this is a mistake,
|
You're not allowed to access this resource. If this is a mistake,
|
||||||
please contact the administrator.
|
please contact the administrator.
|
||||||
<div className="text-center mt-4">
|
<div className="text-center mt-4">
|
||||||
<Button>
|
<Button>
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useState, useSyncExternalStore } from "react";
|
import { useState } from "react";
|
||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
import * as z from "zod";
|
import * as z from "zod";
|
||||||
|
@ -8,7 +8,6 @@ import {
|
||||||
Card,
|
Card,
|
||||||
CardContent,
|
CardContent,
|
||||||
CardDescription,
|
CardDescription,
|
||||||
CardFooter,
|
|
||||||
CardHeader,
|
CardHeader,
|
||||||
CardTitle
|
CardTitle
|
||||||
} from "@/components/ui/card";
|
} from "@/components/ui/card";
|
||||||
|
@ -30,9 +29,6 @@ import {
|
||||||
Key,
|
Key,
|
||||||
User,
|
User,
|
||||||
Send,
|
Send,
|
||||||
ArrowLeft,
|
|
||||||
ArrowRight,
|
|
||||||
Lock,
|
|
||||||
AtSign
|
AtSign
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import {
|
import {
|
||||||
|
@ -47,10 +43,8 @@ import { AxiosResponse } from "axios";
|
||||||
import LoginForm from "@app/components/LoginForm";
|
import LoginForm from "@app/components/LoginForm";
|
||||||
import {
|
import {
|
||||||
AuthWithPasswordResponse,
|
AuthWithPasswordResponse,
|
||||||
AuthWithAccessTokenResponse,
|
|
||||||
AuthWithWhitelistResponse
|
AuthWithWhitelistResponse
|
||||||
} from "@server/routers/resource";
|
} from "@server/routers/resource";
|
||||||
import { redirect } from "next/dist/server/api-utils";
|
|
||||||
import ResourceAccessDenied from "./ResourceAccessDenied";
|
import ResourceAccessDenied from "./ResourceAccessDenied";
|
||||||
import { createApiClient } from "@app/lib/api";
|
import { createApiClient } from "@app/lib/api";
|
||||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||||
|
@ -118,7 +112,9 @@ export default function ResourceAuthPortal(props: ResourceAuthPortalProps) {
|
||||||
|
|
||||||
const [otpState, setOtpState] = useState<"idle" | "otp_sent">("idle");
|
const [otpState, setOtpState] = useState<"idle" | "otp_sent">("idle");
|
||||||
|
|
||||||
const api = createApiClient(useEnvContext());
|
const { env } = useEnvContext();
|
||||||
|
|
||||||
|
const api = createApiClient({ env });
|
||||||
|
|
||||||
function getDefaultSelectedMethod() {
|
function getDefaultSelectedMethod() {
|
||||||
if (props.methods.sso) {
|
if (props.methods.sso) {
|
||||||
|
@ -169,6 +165,15 @@ export default function ResourceAuthPortal(props: ResourceAuthPortalProps) {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function appendRequestToken(url: string, token: string) {
|
||||||
|
const fullUrl = new URL(url);
|
||||||
|
fullUrl.searchParams.append(
|
||||||
|
env.server.resourceSessionRequestParam,
|
||||||
|
token
|
||||||
|
);
|
||||||
|
return fullUrl.toString();
|
||||||
|
}
|
||||||
|
|
||||||
const onWhitelistSubmit = (values: any) => {
|
const onWhitelistSubmit = (values: any) => {
|
||||||
setLoadingLogin(true);
|
setLoadingLogin(true);
|
||||||
api.post<AxiosResponse<AuthWithWhitelistResponse>>(
|
api.post<AxiosResponse<AuthWithWhitelistResponse>>(
|
||||||
|
@ -190,7 +195,7 @@ export default function ResourceAuthPortal(props: ResourceAuthPortalProps) {
|
||||||
|
|
||||||
const session = res.data.data.session;
|
const session = res.data.data.session;
|
||||||
if (session) {
|
if (session) {
|
||||||
window.location.href = props.redirect;
|
window.location.href = appendRequestToken(props.redirect, session);
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch((e) => {
|
.catch((e) => {
|
||||||
|
@ -212,7 +217,7 @@ export default function ResourceAuthPortal(props: ResourceAuthPortalProps) {
|
||||||
setPincodeError(null);
|
setPincodeError(null);
|
||||||
const session = res.data.data.session;
|
const session = res.data.data.session;
|
||||||
if (session) {
|
if (session) {
|
||||||
window.location.href = props.redirect;
|
window.location.href = appendRequestToken(props.redirect, session);
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch((e) => {
|
.catch((e) => {
|
||||||
|
@ -237,7 +242,7 @@ export default function ResourceAuthPortal(props: ResourceAuthPortalProps) {
|
||||||
setPasswordError(null);
|
setPasswordError(null);
|
||||||
const session = res.data.data.session;
|
const session = res.data.data.session;
|
||||||
if (session) {
|
if (session) {
|
||||||
window.location.href = props.redirect;
|
window.location.href = appendRequestToken(props.redirect, session);
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch((e) => {
|
.catch((e) => {
|
||||||
|
@ -619,16 +624,6 @@ export default function ResourceAuthPortal(props: ResourceAuthPortalProps) {
|
||||||
</Tabs>
|
</Tabs>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
{/* {activeTab === "sso" && (
|
|
||||||
<div className="flex justify-center mt-4">
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
Don't have an account?{" "}
|
|
||||||
<a href="#" className="underline">
|
|
||||||
Sign up
|
|
||||||
</a>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)} */}
|
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<ResourceAccessDenied />
|
<ResourceAccessDenied />
|
||||||
|
|
|
@ -1,7 +1,6 @@
|
||||||
import {
|
import {
|
||||||
AuthWithAccessTokenResponse,
|
|
||||||
GetResourceAuthInfoResponse,
|
GetResourceAuthInfoResponse,
|
||||||
GetResourceResponse
|
GetExchangeTokenResponse
|
||||||
} from "@server/routers/resource";
|
} from "@server/routers/resource";
|
||||||
import ResourceAuthPortal from "./ResourceAuthPortal";
|
import ResourceAuthPortal from "./ResourceAuthPortal";
|
||||||
import { internal, priv } from "@app/lib/api";
|
import { internal, priv } from "@app/lib/api";
|
||||||
|
@ -12,9 +11,6 @@ import { verifySession } from "@app/lib/auth/verifySession";
|
||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import ResourceNotFound from "./ResourceNotFound";
|
import ResourceNotFound from "./ResourceNotFound";
|
||||||
import ResourceAccessDenied from "./ResourceAccessDenied";
|
import ResourceAccessDenied from "./ResourceAccessDenied";
|
||||||
import { cookies } from "next/headers";
|
|
||||||
import { CheckResourceSessionResponse } from "@server/routers/auth";
|
|
||||||
import AccessTokenInvalid from "./AccessToken";
|
|
||||||
import AccessToken from "./AccessToken";
|
import AccessToken from "./AccessToken";
|
||||||
import { pullEnv } from "@app/lib/pullEnv";
|
import { pullEnv } from "@app/lib/pullEnv";
|
||||||
|
|
||||||
|
@ -83,49 +79,41 @@ export default async function ResourceAuthPage(props: {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const allCookies = await cookies();
|
|
||||||
const cookieName =
|
|
||||||
env.server.resourceSessionCookieName + `_${params.resourceId}`;
|
|
||||||
const sessionId = allCookies.get(cookieName)?.value ?? null;
|
|
||||||
|
|
||||||
if (sessionId) {
|
|
||||||
let doRedirect = false;
|
|
||||||
try {
|
|
||||||
const res = await priv.get<
|
|
||||||
AxiosResponse<CheckResourceSessionResponse>
|
|
||||||
>(`/resource-session/${params.resourceId}/${sessionId}`);
|
|
||||||
|
|
||||||
if (res && res.data.data.valid) {
|
|
||||||
doRedirect = true;
|
|
||||||
}
|
|
||||||
} catch (e) {}
|
|
||||||
|
|
||||||
if (doRedirect) {
|
|
||||||
redirect(redirectUrl);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!hasAuth) {
|
if (!hasAuth) {
|
||||||
// no authentication so always go straight to the resource
|
// no authentication so always go straight to the resource
|
||||||
redirect(redirectUrl);
|
redirect(redirectUrl);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// convert the dashboard token into a resource session token
|
||||||
let userIsUnauthorized = false;
|
let userIsUnauthorized = false;
|
||||||
if (user && authInfo.sso) {
|
if (user && authInfo.sso) {
|
||||||
let doRedirect = false;
|
let redirectToUrl: string | undefined;
|
||||||
try {
|
try {
|
||||||
const res = await internal.get<AxiosResponse<GetResourceResponse>>(
|
const res = await priv.post<
|
||||||
`/resource/${params.resourceId}`,
|
AxiosResponse<GetExchangeTokenResponse>
|
||||||
|
>(
|
||||||
|
`/resource/${params.resourceId}/get-exchange-token`,
|
||||||
|
{},
|
||||||
await authCookieHeader()
|
await authCookieHeader()
|
||||||
);
|
);
|
||||||
|
|
||||||
doRedirect = true;
|
if (res.data.data.requestToken) {
|
||||||
|
const paramName = env.server.resourceSessionRequestParam;
|
||||||
|
// append the param with the token to the redirect url
|
||||||
|
const fullUrl = new URL(redirectUrl);
|
||||||
|
fullUrl.searchParams.append(
|
||||||
|
paramName,
|
||||||
|
res.data.data.requestToken
|
||||||
|
);
|
||||||
|
redirectToUrl = fullUrl.toString();
|
||||||
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
userIsUnauthorized = true;
|
userIsUnauthorized = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (doRedirect) {
|
if (redirectToUrl) {
|
||||||
redirect(redirectUrl);
|
redirect(redirectToUrl);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
38
src/components/ui/info-popup.tsx
Normal file
38
src/components/ui/info-popup.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
|
@ -6,8 +6,8 @@ export function pullEnv(): Env {
|
||||||
nextPort: process.env.NEXT_PORT as string,
|
nextPort: process.env.NEXT_PORT as string,
|
||||||
externalPort: process.env.SERVER_EXTERNAL_PORT as string,
|
externalPort: process.env.SERVER_EXTERNAL_PORT as string,
|
||||||
sessionCookieName: process.env.SESSION_COOKIE_NAME as string,
|
sessionCookieName: process.env.SESSION_COOKIE_NAME as string,
|
||||||
resourceSessionCookieName: process.env.RESOURCE_SESSION_COOKIE_NAME as string,
|
resourceAccessTokenParam: process.env.RESOURCE_ACCESS_TOKEN_PARAM as string,
|
||||||
resourceAccessTokenParam: process.env.RESOURCE_ACCESS_TOKEN_PARAM as string
|
resourceSessionRequestParam: process.env.RESOURCE_SESSION_REQUEST_PARAM as string
|
||||||
},
|
},
|
||||||
app: {
|
app: {
|
||||||
environment: process.env.ENVIRONMENT as string,
|
environment: process.env.ENVIRONMENT as string,
|
||||||
|
@ -26,7 +26,9 @@ export function pullEnv(): Env {
|
||||||
emailVerificationRequired:
|
emailVerificationRequired:
|
||||||
process.env.FLAGS_EMAIL_VERIFICATION_REQUIRED === "true"
|
process.env.FLAGS_EMAIL_VERIFICATION_REQUIRED === "true"
|
||||||
? true
|
? true
|
||||||
: false
|
: false,
|
||||||
|
allowRawResources:
|
||||||
|
process.env.FLAGS_ALLOW_RAW_RESOURCES === "true" ? true : false,
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
@ -7,8 +7,8 @@ export type Env = {
|
||||||
externalPort: string;
|
externalPort: string;
|
||||||
nextPort: string;
|
nextPort: string;
|
||||||
sessionCookieName: string;
|
sessionCookieName: string;
|
||||||
resourceSessionCookieName: string;
|
|
||||||
resourceAccessTokenParam: string;
|
resourceAccessTokenParam: string;
|
||||||
|
resourceSessionRequestParam: string;
|
||||||
},
|
},
|
||||||
email: {
|
email: {
|
||||||
emailEnabled: boolean;
|
emailEnabled: boolean;
|
||||||
|
@ -17,5 +17,6 @@ export type Env = {
|
||||||
disableSignupWithoutInvite: boolean;
|
disableSignupWithoutInvite: boolean;
|
||||||
disableUserCreateOrg: boolean;
|
disableUserCreateOrg: boolean;
|
||||||
emailVerificationRequired: boolean;
|
emailVerificationRequired: boolean;
|
||||||
|
allowRawResources: boolean;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue