Merge branch 'dev' into transfer-resource-to-new-site

This commit is contained in:
Owen Schwartz 2025-02-01 17:03:05 -05:00
commit 962c5fb886
No known key found for this signature in database
GPG key ID: 8271FDFFD9E0CCBD
85 changed files with 3396 additions and 1197 deletions

3
.github/FUNDING.yml vendored Normal file
View file

@ -0,0 +1,3 @@
# These are supported funding model platforms
github: [fosrl]

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

@ -0,0 +1,83 @@
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 }}
BADGER_VERSION=${{ env.LATEST_BADGER_TAG }}
sed -i "s/config.PangolinVersion = \".*\"/config.PangolinVersion = \"$PANGOLIN_VERSION\"/" install/main.go
sed -i "s/config.GerbilVersion = \".*\"/config.GerbilVersion = \"$GERBIL_VERSION\"/" install/main.go
sed -i "s/config.BadgerVersion = \".*\"/config.BadgerVersion = \"$BADGER_VERSION\"/" install/main.go
echo "Updated install/main.go with Pangolin version $PANGOLIN_VERSION, Gerbil version $GERBIL_VERSION, and Badger version $BADGER_VERSION"
cat install/main.go
- name: Build installer
working-directory: install
run: |
make release
- name: Upload artifacts from /install/bin
uses: actions/upload-artifact@v4
with:
name: install-bin
path: install/bin/
- name: Build and push Docker images
run: |
TAG=${{ env.TAG }}
make build-release tag=$TAG

2
.gitignore vendored
View file

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

View file

@ -6,7 +6,7 @@
[![Discord](https://img.shields.io/discord/1325658630518865980?logo=discord&style=flat-square)](https://discord.gg/HCJR8Xhme4)
[![Youtube](https://img.shields.io/badge/YouTube-red?logo=youtube&logoColor=white&style=flat-square)](https://www.youtube.com/@fossorial-app)
Pangolin is a self-hosted tunneled reverse proxy management server with identity and access management, designed to securely expose private resources through use with the Traefik reverse proxy and WireGuard tunnel clients like Newt. With Pangolin, you retain full control over your infrastructure while providing a user-friendly and feature-rich solution for managing proxies, authentication, and access, and simplifying complex network setups, all with a clean and simple UI.
Pangolin is a self-hosted tunneled reverse proxy management server with identity and access control, designed to securely expose private resources through use with the Traefik reverse proxy and WireGuard tunnel clients like Newt. With Pangolin, you retain full control over your infrastructure while providing a user-friendly and feature-rich solution for managing proxies, authentication, and access, and simplifying complex network setups, all with a clean and simple UI.
### Installation and Documentation
@ -32,6 +32,7 @@ _Sites page of Pangolin dashboard (dark mode) showing multiple tunnels connected
- Secure and easy to configure site-to-site connectivity via a custom **user space WireGuard client**, [Newt](https://github.com/fosrl/newt).
- Built-in support for any WireGuard client.
- Automated **SSL certificates** (https) via [LetsEncrypt](https://letsencrypt.org/).
- Support for HTTP/HTTPS and **raw TCP/UDP services**.
### Identity & Access Management
@ -128,6 +129,10 @@ Pangolin was inspired by several existing projects and concepts:
- **Authentik and Authelia**:
These projects inspired Pangolins centralized authentication system for proxies, enabling robust user and role management.
## Project Development / Roadmap
Pangolin is under active development, and we are continuously adding new features and improvements. View the [project board](https://github.com/orgs/fosrl/projects/1) for more detailed info.
## Licensing
Pangolin is dual licensed under the AGPLv3 and the Fossorial Commercial license. For inquiries about commercial licensing, please contact us.

View file

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

View file

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

View file

@ -13,7 +13,7 @@ experimental:
plugins:
badger:
moduleName: "github.com/fosrl/badger"
version: "v1.0.0-beta.2"
version: "v1.0.0-beta.3"
log:
level: "INFO"
@ -33,6 +33,9 @@ entryPoints:
address: ":80"
websecure:
address: ":443"
transport:
respondingTimeouts:
readTimeout: "30m"
http:
tls:
certResolver: "letsencrypt"

View file

@ -1,4 +1,3 @@
all: build
build:
@ -9,6 +8,6 @@ release:
CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -o bin/installer_linux_arm64
clean:
rm bin/installer
rm bin/installer_linux_amd64
rm bin/installer_linux_arm64
rm -f bin/installer
rm -f bin/installer_linux_amd64
rm -f bin/installer_linux_arm64

View file

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

View file

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

View file

@ -13,7 +13,7 @@ experimental:
plugins:
badger:
moduleName: "github.com/fosrl/badger"
version: "v1.0.0-beta.2"
version: "{{.BadgerVersion}}"
log:
level: "INFO"
@ -33,6 +33,9 @@ entryPoints:
address: ":80"
websecure:
address: ":443"
transport:
respondingTimeouts:
readTimeout: "30m"
http:
tls:
certResolver: "letsencrypt"

View file

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

267
internationalization/de.md Normal file
View file

@ -0,0 +1,267 @@
## Login site
| EN | DE | Notes |
| --------------------- | ---------------------------------- | ----------- |
| Welcome to Pangolin | Willkommen bei Pangolin | |
| Log in to get started | Melden Sie sich an, um zu beginnen | |
| Email | E-Mail | |
| Enter your email | Geben Sie Ihre E-Mail-Adresse ein | placeholder |
| Password | Passwort | |
| Enter your password | Geben Sie Ihr Passwort ein | placeholder |
| Forgot your password? | Passwort vergessen? | |
| Log in | Anmelden | |
# Ogranization site after successful login
| EN | DE | Notes |
| ----------------------------------------- | -------------------------------------------- | ----- |
| Welcome to Pangolin | Willkommen bei Pangolin | |
| You're a member of {number} organization. | Sie sind Mitglied von {number} Organisation. | |
## Shared Header, Navbar and Footer
##### Header
| EN | DE | Notes |
| ------------------- | ------------------- | ----- |
| Documentation | Dokumentation | |
| Support | Support | |
| Organization {name} | Organisation {name} | |
##### Organization selector
| EN | DE | Notes |
| ---------------- | ----------------- | ----- |
| Search… | Suchen… | |
| Create | Erstellen | |
| New Organization | Neue Organisation | |
| Organizations | Organisationen | |
##### Navbar
| EN | DE | Notes |
| --------------- | ----------------- | ----- |
| Sites | Websites | |
| Resources | Ressourcen | |
| User & Roles | Benutzer & Rollen | |
| Shareable Links | Teilbare Links | |
| General | Allgemein | |
##### Footer
| EN | DE | |
| ------------------------- | --------------------------- | ------------------- |
| Page {number} of {number} | Seite {number} von {number} | |
| Rows per page | Zeilen pro Seite | |
| Pangolin | Pangolin | unten auf der Seite |
| Built by Fossorial | Erstellt von Fossorial | unten auf der Seite |
| Open Source | Open Source | unten auf der Seite |
| Documentation | Dokumentation | unten auf der Seite |
| {version} | {version} | unten auf der Seite |
## Main “Sites”
##### “Hero” section
| EN | DE | Notes |
| ------------------------------------------------------------ | ------------------------------------------------------------ | ----- |
| Newt (Recommended) | Newt (empfohlen) | |
| For the best user experience, use Newt. It uses WireGuard under the hood and allows you to address your private resources by their LAN address on your private network from within the Pangolin dashboard. | Für das beste Benutzererlebnis verwenden Sie Newt. Es nutzt WireGuard im Hintergrund und ermöglicht es Ihnen, auf Ihre privaten Ressourcen über ihre LAN-Adresse in Ihrem privaten Netzwerk direkt aus dem Pangolin-Dashboard zuzugreifen. | |
| Runs in Docker | Läuft in Docker | |
| Runs in shell on macOS, Linux, and Windows | Läuft in der Shell auf macOS, Linux und Windows | |
| Install Newt | Newt installieren | |
| Basic WireGuard<br> | Verwenden Sie einen beliebigen WireGuard-Client, um eine Verbindung herzustellen. Sie müssen auf Ihre internen Ressourcen über die Peer-IP-Adresse zugreifen. | |
| Compatible with all WireGuard clients<br> | Kompatibel mit allen WireGuard-Clients<br> | |
| Manual configuration required | Manuelle Konfiguration erforderlich<br> | |
##### Content
| EN | DE | Notes |
| --------------------------------------------------------- | ------------------------------------------------------------ | -------------------------------- |
| Manage Sites | Seiten verwalten | |
| Allow connectivity to your network through secure tunnels | Ermöglichen Sie die Verbindung zu Ihrem Netzwerk über ein sicheren Tunnel | |
| Search sites | Seiten suchen | placeholder |
| Add Site | Seite hinzufügen | |
| Name | Name | table header |
| Online | Status | table header |
| Site | Seite | table header |
| Data In | Eingehende Daten | table header |
| Data Out | Ausgehende Daten | table header |
| Connection Type | Verbindungstyp | table header |
| Online | Online | site state |
| Offline | Offline | site state |
| Edit → | Bearbeiten → | |
| View settings | Einstellungen anzeigen | Popup after clicking “…” on site |
| Delete | Löschen | Popup after clicking “…” on site |
##### Add Site Popup
| EN | DE | Notes |
| ------------------------------------------------------ | ----------------------------------------------------------- | ----------- |
| Create Site | Seite erstellen | |
| Create a new site to start connection for this site | Erstellen Sie eine neue Seite, um die Verbindung zu starten | |
| Name | Name | |
| Site name | Seiten-Name | placeholder |
| This is the name that will be displayed for this site. | So wird Ihre Seite angezeigt | desc |
| Method | Methode | |
| Local | Lokal | |
| Newt | Newt | |
| WireGuard | WireGuard | |
| This is how you will expose connections. | So werden Verbindungen freigegeben. | |
| You will only be able to see the configuration once. | Diese Konfiguration können Sie nur einmal sehen. | |
| Learn how to install Newt on your system | Erfahren Sie, wie Sie Newt auf Ihrem System installieren | |
| I have copied the config | Ich habe die Konfiguration kopiert | |
| Create Site | Website erstellen | |
| Close | Schließen | |
## Main “Resources”
##### “Hero” section
| EN | DE | Notes |
| ------------------------------------------------------------ | ------------------------------------------------------------ | ----- |
| Resources | Ressourcen | |
| Ressourcen sind Proxy-Server für Anwendungen, die in Ihrem privaten Netzwerk laufen. Erstellen Sie eine Ressource für jede HTTP- oder HTTPS-Anwendung in Ihrem privaten Netzwerk. Jede Ressource muss mit einer Website verbunden sein, um eine private und sichere Verbindung über den verschlüsselten WireGuard-Tunnel zu ermöglichen. | Ressourcen sind Proxy-Server für Anwendungen, die in Ihrem privaten Netzwerk laufen. Erstellen Sie eine Ressource für jede HTTP- oder HTTPS-Anwendung in Ihrem privaten Netzwerk. Jede Ressource muss mit einer Website verbunden sein, um eine private und sichere Verbindung über den verschlüsselten WireGuard-Tunnel zu ermöglichen. | |
| Secure connectivity with WireGuard encryption | Sichere Verbindung mit WireGuard-Verschlüsselung | |
| Configure multiple authentication methods | Konfigurieren Sie mehrere Authentifizierungsmethoden | |
| User and role-based access control | Benutzer- und rollenbasierte Zugriffskontrolle | |
##### Content
| EN | DE | Notes |
| -------------------------------------------------- | ---------------------------------------------------------- | -------------------- |
| Manage Resources | Ressourcen verwalten | |
| Create secure proxies to your private applications | Erstellen Sie sichere Proxys für Ihre privaten Anwendungen | |
| Search resources | Ressourcen durchsuchen | placeholder |
| Name | Name | |
| Site | Website | |
| Full URL | Vollständige URL | |
| Authentication | Authentifizierung | |
| Not Protected | Nicht geschützt | authentication state |
| Protected | Geschützt | authentication state |
| Edit → | Bearbeiten → | |
| Add Resource | Ressource hinzufügen | |
##### Add Resource Popup
| EN | DE | Notes |
| ------------------------------------------------------------ | ------------------------------------------------------------ | ------------------- |
| Create Resource | Ressource erstellen | |
| Create a new resource to proxy request to your app | Erstellen Sie eine neue Ressource, um Anfragen an Ihre App zu proxen | |
| Name | Name | |
| My Resource | Neue Ressource | name placeholder |
| This is the name that will be displayed for this resource. | Dies ist der Name, der für diese Ressource angezeigt wird | |
| Subdomain | Subdomain | |
| Enter subdomain | Subdomain eingeben | |
| This is the fully qualified domain name that will be used to access the resource. | Dies ist der vollständige Domainname, der für den Zugriff auf die Ressource verwendet wird. | |
| Site | Website | |
| Search site… | Website suchen… | Site selector popup |
| This is the site that will be used in the dashboard. | Dies ist die Website, die im Dashboard verwendet wird. | |
| Create Resource | Ressource erstellen | |
| Close | Schließen | |
## Main “User & Roles”
##### Content
| EN | DE | Notes |
| ------------------------------------------------------------ | ------------------------------------------------------------ | ----------------------------- |
| Manage User & Roles | Benutzer & Rollen verwalten | |
| Invite users and add them to roles to manage access to your organization | Laden Sie Benutzer ein und weisen Sie ihnen Rollen zu, um den Zugriff auf Ihre Organisation zu verwalten | |
| Users | Benutzer | sidebar item |
| Roles | Rollen | sidebar item |
| **User tab** | | |
| Search users | Benutzer suchen | placeholder |
| Invite User | Benutzer einladen | addbutton |
| Email | E-Mail | table header |
| Status | Status | table header |
| Role | Rolle | table header |
| Confirmed | Bestätigt | account status |
| Not confirmed (?) | Nicht bestätigt (?) | unknown for me account status |
| Owner | Besitzer | role |
| Admin | Administrator | role |
| Member | Mitglied | role |
| **Roles Tab** | | |
| Search roles | Rollen suchen | placeholder |
| Add Role | Rolle hinzufügen | addbutton |
| Name | Name | table header |
| Description | Beschreibung | table header |
| Admin | Administrator | role |
| Member | Mitglied | role |
| Admin role with the most permissions | Administratorrolle mit den meisten Berechtigungen | admin role desc |
| Members can only view resources | Mitglieder können nur Ressourcen anzeigen | member role desc |
##### Invite User popup
| EN | DE | Notes |
| ----------------- | ------------------------------------------------------- | ----------- |
| Invite User | Geben Sie neuen Benutzern Zugriff auf Ihre Organisation | |
| Email | E-Mail | |
| Enter an email | E-Mail eingeben | placeholder |
| Role | Rolle | |
| Select role | Rolle auswählen | placeholder |
| Gültig für | Gültig bis | |
| 1 day | Tag | |
| 2 days | 2 Tage | |
| 3 days | 3 Tage | |
| 4 days | 4 Tage | |
| 5 days | 5 Tage | |
| 6 days | 6 Tage | |
| 7 days | 7 Tage | |
| Create Invitation | Einladung erstellen | |
| Close | Schließen | |
## Main “Shareable Links”
##### “Hero” section
| EN | DE | Notes |
| ------------------------------------------------------------ | ------------------------------------------------------------ | ----- |
| Shareable Links | Teilbare Links | |
| Create shareable links to your resources. Links provide temporary or unlimited access to your resource. You can configure the expiration duration of the link when you create one. | Erstellen Sie teilbare Links zu Ihren Ressourcen. Links bieten temporären oder unbegrenzten Zugriff auf Ihre Ressource. Sie können die Gültigkeitsdauer des Links beim Erstellen konfigurieren. | |
| Easy to create and share | Einfach zu erstellen und zu teilen | |
| Configurable expiration duration | Konfigurierbare Gültigkeitsdauer | |
| Secure and revocable | Sicher und widerrufbar | |
##### Content
| EN | DE | Notes |
| ------------------------------------------------------------ | ------------------------------------------------------------ | ----------------- |
| Manage Shareable Links | Teilbare Links verwalten | |
| Create shareable links to grant temporary or permanent access to your resources | Erstellen Sie teilbare Links, um temporären oder permanenten Zugriff auf Ihre Ressourcen zu gewähren | |
| Search links | Links suchen | placeholder |
| Create Share Link | Neuen Link erstellen | addbutton |
| Resource | Ressource | table header |
| Title | Titel | table header |
| Created | Erstellt | table header |
| Expires | Gültig bis | table header |
| No links. Create one to get started. | Keine Links. Erstellen Sie einen, um zu beginnen. | table placeholder |
##### Create Shareable Link popup
| EN | DE | Notes |
| ------------------------------------------------------------ | ------------------------------------------------------------ | ----------------------- |
| Create Shareable Link | Teilbaren Link erstellen | |
| Anyone with this link can access the resource | Jeder mit diesem Link kann auf die Ressource zugreifen | |
| Resource | Ressource | |
| Select resource | Ressource auswählen | |
| Search resources… | Ressourcen suchen… | resource selector popup |
| Title (optional) | Titel (optional) | |
| Enter title | Titel eingeben | placeholder |
| Expire in | Gültig bis | |
| Minutes | Minuten | |
| Hours | Stunden | |
| Days | Tage | |
| Months | Monate | |
| Years | Jahre | |
| Never expire | Nie ablaufen | |
| Expiration time is how long the link will be usable and provide access to the resource. After this time, the link will no longer work, and users who used this link will lose access to the resource. | Die Gültigkeitsdauer bestimmt, wie lange der Link nutzbar ist und Zugriff auf die Ressource bietet. Nach Ablauf dieser Zeit funktioniert der Link nicht mehr, und Benutzer, die diesen Link verwendet haben, verlieren den Zugriff auf die Ressource. | |
| Create Link | Link erstellen | |
| Close | Schließen | |
## Main “General”
| EN | DE | Notes |
| ------------------------------------------------------------ | ------------------------------------------------------------ | ------------ |
| General | Allgemein | |
| Configure your organizations general settings | Konfigurieren Sie die allgemeinen Einstellungen Ihrer Organisation | |
| General | Allgemein | sidebar item |
| Organization Settings | Organisationseinstellungen | |
| Manage your organization details and configuration | Verwalten Sie die Details und Konfiguration Ihrer Organisation | |
| Name | Name | |
| This is the display name of the org | Dies ist der Anzeigename Ihrer Organisation | |
| Save Settings | Einstellungen speichern | |
| Danger Zone | Gefahrenzone | |
| Once you delete this org, there is no going back. Please be certain. | Wenn Sie diese Organisation löschen, gibt es kein Zurück. Bitte seien Sie sicher. | |
| Delete Organization Data | Organisationsdaten löschen | |

287
internationalization/pl.md Normal file
View file

@ -0,0 +1,287 @@
## Authentication Site
| EN | PL | Notes |
| -------------------------------------------------------- | ------------------------------------------------------------ | ---------- |
| Powered by [Pangolin](https://github.com/fosrl/pangolin) | Zasilane przez [Pangolin](https://github.com/fosrl/pangolin) | |
| Authentication Required | Wymagane uwierzytelnienie | |
| Choose your preferred method to access {resource} | Wybierz preferowaną metodę dostępu do {resource} | |
| PIN | PIN | |
| User | Zaloguj | |
| 6-digit PIN Code | 6-cyfrowy kod PIN | pin login |
| Login in with PIN | Zaloguj się PINem | pin login |
| Email | Email | user login |
| Enter your email | Wprowadź swój email | user login |
| Password | Hasło | user login |
| Enter your password | Wprowadź swoje hasło | user login |
| Forgot your password? | Zapomniałeś hasła? | user login |
| Log in | Zaloguj | user login |
## Login site
| EN | PL | Notes |
| --------------------- | ------------------------------ | ----------- |
| Welcome to Pangolin | Witaj w Pangolin | |
| Log in to get started | Zaloguj się, aby rozpocząć<br> | |
| Email | Email | |
| Enter your email | Wprowadź swój adres e-mail<br> | placeholder |
| Password | Hasło | |
| Enter your password | Wprowadź swoje hasło | placeholder |
| Forgot your password? | Nie pamiętasz hasła? | |
| Log in | Zaloguj | |
# Ogranization site after successful login
| EN | PL | Notes |
| ----------------------------------------- | ------------------------------------------ | ----- |
| Welcome to Pangolin | Witaj w Pangolin | |
| You're a member of {number} organization. | Jesteś użytkownikiem {number} organizacji. | |
## Shared Header, Navbar and Footer
##### Header
| EN | PL | Notes |
| ------------------- | ------------------ | ----- |
| Documentation | Dokumentacja | |
| Support | Wsparcie | |
| Organization {name} | Organizacja {name} | |
##### Organization selector
| EN | PL | Notes |
| ---------------- | ---------------- | ----- |
| Search… | Szukaj… | |
| Create | Utwórz | |
| New Organization | Nowa organizacja | |
| Organizations | Organizacje | |
##### Navbar
| EN | PL | Notes |
| --------------- | ---------------------- | ----- |
| Sites | Witryny | |
| Resources | Zasoby | |
| User & Roles | Użytkownicy i Role | |
| Shareable Links | Łącza do udostępniania | |
| General | Ogólne | |
##### Footer
| EN | PL | |
| ------------------------- | -------------------------- | -------------- |
| Page {number} of {number} | Strona {number} z {number} | |
| Rows per page | Wierszy na stronę | |
| Pangolin | Pangolin | bottom of site |
| Built by Fossorial | Stworzone przez Fossorial | bottom of site |
| Open Source | Open source | bottom of site |
| Documentation | Dokumentacja | bottom of site |
| {version} | {version} | bottom of site |
## Main “Sites”
##### “Hero” section
| EN | PL | Notes |
| ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ----- |
| Newt (Recommended) | Newt (zalecane) | |
| For the best user experience, use Newt. It uses WireGuard under the hood and allows you to address your private resources by their LAN address on your private network from within the Pangolin dashboard. | Aby zapewnić najlepsze doświadczenie użytkownika, korzystaj z Newt. Wykorzystuje on technologię WireGuard w tle i pozwala na dostęp do Twoich prywatnych zasobów za pomocą ich adresu LAN w prywatnej sieci bezpośrednio z poziomu pulpitu nawigacyjnego Pangolin. | |
| Runs in Docker | Działa w Dockerze | |
| Runs in shell on macOS, Linux, and Windows | Działa w powłoce na systemach macOS, Linux i Windows | |
| Install Newt | Zainstaluj Newt | |
| Podstawowy WireGuard<br> | Użyj dowolnego klienta WireGuard, aby się połączyć. Będziesz musiał uzyskiwać dostęp do swoich wewnętrznych zasobów za pomocą adresu IP równorzędnego | |
| Compatible with all WireGuard clients<br> | Kompatybilny ze wszystkimi klientami WireGuard<br> | |
| Manual configuration required | Wymagana ręczna konfiguracja<br> | |
##### Content
| EN | PL | Notes |
| --------------------------------------------------------- | ------------------------------------------------------------------------ | -------------------------------- |
| Manage Sites | Zarządzanie witrynami | |
| Allow connectivity to your network through secure tunnels | Zezwalaj na łączność z Twoją siecią za pośrednictwem bezpiecznych tuneli | |
| Search sites | Szukaj witryny | placeholder |
| Add Site | Dodaj witrynę | |
| Name | Nazwa | table header |
| Online | Status | table header |
| Site | Witryna | table header |
| Data In | Dane wchodzące | table header |
| Data Out | Dane wychodzące | table header |
| Connection Type | Typ połączenia | table header |
| Online | Online | site state |
| Offline | Poza siecią | site state |
| Edit → | Edytuj → | |
| View settings | Pokaż ustawienia | Popup after clicking “…” on site |
| Delete | Usuń | Popup after clicking “…” on site |
##### Add Site Popup
| EN | PL | Notes |
| ------------------------------------------------------ | --------------------------------------------------- | ----------- |
| Create Site | Utwórz witrynę | |
| Create a new site to start connection for this site | Utwórz nową witrynę aby rozpocząć połączenie | |
| Name | Nazwa | |
| Site name | Nazwa witryny | placeholder |
| This is the name that will be displayed for this site. | Tak będzie wyświetlana twoja witryna | desc |
| Method | Metoda | |
| Local | Lokalna | |
| Newt | Newt | |
| WireGuard | WireGuard | |
| This is how you will expose connections. | Tak będą eksponowane połączenie. | |
| You will only be able to see the configuration once. | Tą konfigurację możesz zobaczyć tylko raz. | |
| Learn how to install Newt on your system | Dowiedz się jak zainstalować Newt na twoim systemie | |
| I have copied the config | Skopiowałem konfigurację | |
| Create Site | Utwórz witrynę | |
| Close | Zamknij | |
## Main “Resources”
##### “Hero” section
| EN | PL | Notes |
| ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ----- |
| Resources | Zasoby | |
| Zasoby to serwery proxy dla aplikacji działających w Twojej prywatnej sieci. Utwórz zasób dla dowolnej aplikacji HTTP lub HTTPS w swojej prywatnej sieci. Każdy zasób musi być połączony z witryną, aby umożliwić prywatne i bezpieczne połączenie przez szyfrowany tunel WireGuard. | Zasoby to serwery proxy dla aplikacji działających w Twojej prywatnej sieci. Utwórz zasób dla dowolnej aplikacji HTTP lub HTTPS w swojej prywatnej sieci. Każdy zasób musi być połączony z witryną, aby umożliwić prywatne i bezpieczne połączenie przez szyfrowany tunel WireGuard. | |
| Secure connectivity with WireGuard encryption | Bezpieczna łączność z szyfrowaniem WireGuard | |
| Configure multiple authentication methods | Konfigurowanie wielu metod uwierzytelniania | |
| User and role-based access control | Kontrola dostępu oparta na użytkownikach i rolach | |
##### Content
| EN | PL | Notes |
| -------------------------------------------------- | -------------------------------------------------------------- | -------------------- |
| Manage Resources | Zarządzaj zasobami | |
| Create secure proxies to your private applications | Twórz bezpieczne serwery proxy dla swoich prywatnych aplikacji | |
| Search resources | Szukaj w zasobach | placeholder |
| Name | Nazwa | |
| Site | Witryna | |
| Full URL | Pełny URL | |
| Authentication | Uwierzytelnianie | |
| Not Protected | Niezabezpieczony | authentication state |
| Protected | Zabezpieczony | authentication state |
| Edit → | Edytuj → | |
| Add Resource | Dodaj zasób | |
##### Add Resource Popup
| EN | PL | Notes |
| --------------------------------------------------------------------------------- | ---------------------------------------------------------------------- | ------------------- |
| Create Resource | Utwórz zasób | |
| Create a new resource to proxy request to your app | Utwórz nowy zasób, aby przekazywać żądania do swojej aplikacji | |
| Name | Nazwa | |
| My Resource | Nowy zasób | name placeholder |
| This is the name that will be displayed for this resource. | To jest nazwa, która będzie wyświetlana dla tego zasobu | |
| Subdomain | Subdomena | |
| Enter subdomain | Wprowadź subdomenę | |
| This is the fully qualified domain name that will be used to access the resource. | To jest pełna nazwa domeny, która będzie używana do dostępu do zasobu. | |
| Site | Witryna | |
| Search site… | Szukaj witryny… | Site selector popup |
| This is the site that will be used in the dashboard. | To jest witryna, która będzie używana w pulpicie nawigacyjnym. | |
| Create Resource | Utwórz zasób | |
| Close | Zamknij | |
## Main “User & Roles”
##### Content
| EN | PL | Notes |
| ------------------------------------------------------------------------ | ------------------------------------------------------------------------------------ | ----------------------------- |
| Manage User & Roles | Zarządzanie użytkownikami i rolami | |
| Invite users and add them to roles to manage access to your organization | Zaproś użytkowników i przypisz im role, aby zarządzać dostępem do Twojej organizacji | |
| Users | Użytkownicy | sidebar item |
| Roles | Role | sidebar item |
| **User tab** | | |
| Search users | Wyszukaj użytkownika | placeholder |
| Invite User | Zaproś użytkownika | addbutton |
| Email | Email | table header |
| Status | Status | table header |
| Role | Rola | table header |
| Confirmed | Zatwierdzony | account status |
| Not confirmed (?) | Niezatwierdzony (?) | unknown for me account status |
| Owner | Właściciel | role |
| Admin | Administrator | role |
| Member | Użytkownik | role |
| **Roles Tab** | | |
| Search roles | Wyszukaj role | placeholder |
| Add Role | Dodaj role | addbutton |
| Name | Nazwa | table header |
| Description | Opis | table header |
| Admin | Administrator | role |
| Member | Użytkownik | role |
| Admin role with the most permissions | Rola administratora z najszerszymi uprawnieniami | admin role desc |
| Members can only view resources | Członkowie mogą jedynie przeglądać zasoby | member role desc |
##### Invite User popup
| EN | PL | Notes |
| ----------------- | ------------------------------------------ | ----------- |
| Invite User | Give new users access to your organization | |
| Email | Email | |
| Enter an email | Wprowadź email | placeholder |
| Role | Rola | |
| Select role | Wybierz role | placeholder |
| Vaild for | Ważne do | |
| 1 day | Dzień | |
| 2 days | 2 dni | |
| 3 days | 3 dni | |
| 4 days | 4 dni | |
| 5 days | 5 dni | |
| 6 days | 6 dni | |
| 7 days | 7 dni | |
| Create Invitation | Utwórz zaproszenie | |
| Close | Zamknij | |
## Main “Shareable Links”
##### “Hero” section
| EN | PL | Notes |
| ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----- |
| Shareable Links | Łącza do udostępniania | |
| Create shareable links to your resources. Links provide temporary or unlimited access to your resource. You can configure the expiration duration of the link when you create one. | Twórz linki do udostępniania swoich zasobów. Linki zapewniają tymczasowy lub nieograniczony dostęp do zasobu. Możesz skonfigurować czas wygaśnięcia linku podczas jego tworzenia. | |
| Easy to create and share | Łatwe tworzenie i udostępnianie | |
| Configurable expiration duration | Konfigurowalny czas wygaśnięcia | |
| Secure and revocable | Bezpieczne i odwołalne | |
##### Content
| EN | PL | Notes |
| ------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------- | ----------------- |
| Manage Shareable Links | Zarządzaj łączami do udostępniania | |
| Create shareable links to grant temporary or permament access to your resources | Utwórz łącze do udostępniania w celu przyznania tymczasowego lub stałego dostępu do zasobów | |
| Search links | Szukaj łączy | placeholder |
| Create Share Link | Utwórz nowe łącze | addbutton |
| Resource | Zasób | table header |
| Title | Tytuł | table header |
| Created | Utworzone | table header |
| Expires | Wygasa | table header |
| No links. Create one to get started. | Brak łączy. Utwórz, aby rozpocząć. | table placeholder |
##### Create Shareable Link popup
| EN | PL | Notes |
| ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------- |
| Create Shareable Link | Utwórz łącze do udostępnienia | |
| Anyone with this link can access the resource | Każdy kto ma ten link może korzystać z zasobu | |
| Resource | Zasób | |
| Select resource | Wybierz zasób | |
| Search resources… | Szukaj zasobów… | resource selector popup |
| Title (optional) | Tytuł (opcjonalny) | |
| Enter title | Wprowadź tytuł | placeholder |
| Expire in | Wygasa za | |
| Minutes | Minut | |
| Hours | Godzin | |
| Days | Dni | |
| Months | Miesięcy | |
| Years | Lat | |
| Never expire | Nie wygasa | |
| Expiration time is how long the link will be usable and provide access to the resource. After this time, the link will no longer work, and users who used this link will lose access to the resource. | Czas wygaśnięcia to okres, przez który link będzie aktywny i zapewni dostęp do zasobu. Po upływie tego czasu link przestanie działać, a użytkownicy, którzy go użyli, stracą dostęp do zasobu. | |
| Create Link | Utwórz łącze | |
| Close | Zamknij | |
## Main “General”
| EN | PL | Notes |
| -------------------------------------------------------------------- | ------------------------------------------------------------------- | ------------ |
| General | Ogólne | |
| Configure your organizations general settings | Zarządzaj ogólnymi ustawieniami twoich organizacji | |
| General | Ogólne | sidebar item |
| Organization Settings | Ustawienia organizacji | |
| Manage your organization details and configuration | Zarządzaj szczegółami i konfiguracją organizacji | |
| Name | Nazwa | |
| This is the display name of the org | To jest wyświetlana nazwa Twojej organizacji | |
| Save Settings | Zapisz ustawienia | |
| Danger Zone | Niebezpieczna strefa | |
| Once you delete this org, there is no going back. Please be certain. | Jeśli usuniesz swoją tą organizację, nie ma odwrotu. Bądź ostrożny! | |
| Delete Organization Data | Usuń dane organizacji | |

View file

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

View file

@ -1,118 +0,0 @@
import {
encodeBase32LowerCaseNoPadding,
encodeHexLowerCase,
} from "@oslojs/encoding";
import { sha256 } from "@oslojs/crypto/sha2";
import { Session, sessions, User, users } from "@server/db/schema";
import db from "@server/db";
import { eq } from "drizzle-orm";
import config from "@server/lib/config";
import type { RandomReader } from "@oslojs/crypto/random";
import { generateRandomString } from "@oslojs/crypto/random";
export const SESSION_COOKIE_NAME = config.getRawConfig().server.session_cookie_name;
export const SESSION_COOKIE_EXPIRES = 1000 * 60 * 60 * 24 * 30;
export const SECURE_COOKIES = config.getRawConfig().server.secure_cookies;
export const COOKIE_DOMAIN = "." + config.getBaseDomain();
export function generateSessionToken(): string {
const bytes = new Uint8Array(20);
crypto.getRandomValues(bytes);
const token = encodeBase32LowerCaseNoPadding(bytes);
return token;
}
export async function createSession(
token: string,
userId: string,
): Promise<Session> {
const sessionId = encodeHexLowerCase(
sha256(new TextEncoder().encode(token)),
);
const session: Session = {
sessionId: sessionId,
userId,
expiresAt: new Date(Date.now() + SESSION_COOKIE_EXPIRES).getTime(),
};
await db.insert(sessions).values(session);
return session;
}
export async function validateSessionToken(
token: string,
): Promise<SessionValidationResult> {
const sessionId = encodeHexLowerCase(
sha256(new TextEncoder().encode(token)),
);
const result = await db
.select({ user: users, session: sessions })
.from(sessions)
.innerJoin(users, eq(sessions.userId, users.userId))
.where(eq(sessions.sessionId, sessionId));
if (result.length < 1) {
return { session: null, user: null };
}
const { user, session } = result[0];
if (Date.now() >= session.expiresAt) {
await db
.delete(sessions)
.where(eq(sessions.sessionId, session.sessionId));
return { session: null, user: null };
}
if (Date.now() >= session.expiresAt - SESSION_COOKIE_EXPIRES / 2) {
session.expiresAt = new Date(
Date.now() + SESSION_COOKIE_EXPIRES,
).getTime();
await db
.update(sessions)
.set({
expiresAt: session.expiresAt,
})
.where(eq(sessions.sessionId, session.sessionId));
}
return { session, user };
}
export async function invalidateSession(sessionId: string): Promise<void> {
await db.delete(sessions).where(eq(sessions.sessionId, sessionId));
}
export async function invalidateAllSessions(userId: string): Promise<void> {
await db.delete(sessions).where(eq(sessions.userId, userId));
}
export function serializeSessionCookie(token: string): string {
if (SECURE_COOKIES) {
return `${SESSION_COOKIE_NAME}=${token}; HttpOnly; SameSite=Strict; Max-Age=${SESSION_COOKIE_EXPIRES}; Path=/; Secure; Domain=${COOKIE_DOMAIN}`;
} else {
return `${SESSION_COOKIE_NAME}=${token}; HttpOnly; SameSite=Strict; Max-Age=${SESSION_COOKIE_EXPIRES}; Path=/; Domain=${COOKIE_DOMAIN}`;
}
}
export function createBlankSessionTokenCookie(): string {
if (SECURE_COOKIES) {
return `${SESSION_COOKIE_NAME}=; HttpOnly; SameSite=Strict; Max-Age=0; Path=/; Secure; Domain=${COOKIE_DOMAIN}`;
} else {
return `${SESSION_COOKIE_NAME}=; HttpOnly; SameSite=Strict; Max-Age=0; Path=/; Domain=${COOKIE_DOMAIN}`;
}
}
const random: RandomReader = {
read(bytes: Uint8Array): void {
crypto.getRandomValues(bytes);
},
};
export function generateId(length: number): string {
const alphabet = "abcdefghijklmnopqrstuvwxyz0123456789";
return generateRandomString(random, alphabet, length);
}
export function generateIdFromEntropySize(size: number): string {
const buffer = crypto.getRandomValues(new Uint8Array(size));
return encodeBase32LowerCaseNoPadding(buffer);
}
export type SessionValidationResult =
| { session: Session; user: User }
| { session: null; user: null };

View file

@ -4,7 +4,7 @@ export const passwordSchema = z
.string()
.min(8, { message: "Password must be at least 8 characters long" })
.max(64, { message: "Password must be at most 64 characters long" })
.regex(/^(?=.*?[A-Z])(?=.*?[a-z])(?=.*?[0-9])(?=.*?[#?!@$%^&*-]).*$/, {
.regex(/^(?=.*?[A-Z])(?=.*?[a-z])(?=.*?[0-9])(?=.*?[,#?!@$%^&*-]).*$/, {
message: `Your password must meet the following conditions:
at least one uppercase English letter,
at least one lowercase English letter,

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -7,10 +7,11 @@ import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors";
import logger from "@server/logger";
import { addPeer } from "../gerbil/peers";
import { eq, and } from "drizzle-orm";
import { isIpInCidr } from "@server/lib/ip";
import { fromError } from "zod-validation-error";
import { addTargets } from "../newt/targets";
import { eq } from "drizzle-orm";
import { pickPort } from "./ports";
// Regular expressions for validation
const DOMAIN_REGEX =
@ -52,9 +53,8 @@ const createTargetParamsSchema = z
const createTargetSchema = z
.object({
ip: domainSchema,
method: z.string().min(1).max(10),
method: z.string().optional().nullable(),
port: z.number().int().min(1).max(65535),
protocol: z.string().optional(),
enabled: z.boolean().default(true)
})
.strict();
@ -93,9 +93,7 @@ export async function createTarget(
// get the resource
const [resource] = await db
.select({
siteId: resources.siteId
})
.select()
.from(resources)
.where(eq(resources.resourceId, resourceId));
@ -129,7 +127,6 @@ export async function createTarget(
.insert(targets)
.values({
resourceId,
protocol: "tcp", // hard code for now
...targetData
})
.returning();
@ -147,37 +144,7 @@ export async function createTarget(
);
}
// Fetch resources for this site
const resourcesRes = await db.query.resources.findMany({
where: eq(resources.siteId, site.siteId)
});
// TODO: is this all inefficient?
// Fetch targets for all resources of this site
let targetIps: string[] = [];
let targetInternalPorts: number[] = [];
await Promise.all(
resourcesRes.map(async (resource) => {
const targetsRes = await db.query.targets.findMany({
where: eq(targets.resourceId, resource.resourceId)
});
targetsRes.forEach((target) => {
targetIps.push(`${target.ip}/32`);
if (target.internalPort) {
targetInternalPorts.push(target.internalPort);
}
});
})
);
let internalPort!: number;
// pick a port
for (let i = 40000; i < 65535; i++) {
if (!targetInternalPorts.includes(i)) {
internalPort = i;
break;
}
}
const { internalPort, targetIps } = await pickPort(site.siteId!);
if (!internalPort) {
return next(
@ -192,7 +159,6 @@ export async function createTarget(
.insert(targets)
.values({
resourceId,
protocol: "tcp", // hard code for now
internalPort,
...targetData
})
@ -215,7 +181,7 @@ export async function createTarget(
.where(eq(newts.siteId, site.siteId))
.limit(1);
addTargets(newt.newtId, newTarget);
addTargets(newt.newtId, newTarget, resource.protocol);
}
}
}

View file

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

View file

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

View file

@ -0,0 +1,48 @@
import { db } from "@server/db";
import { resources, targets } from "@server/db/schema";
import { eq } from "drizzle-orm";
let currentBannedPorts: number[] = [];
export async function pickPort(siteId: number): Promise<{
internalPort: number;
targetIps: string[];
}> {
// Fetch resources for this site
const resourcesRes = await db.query.resources.findMany({
where: eq(resources.siteId, siteId)
});
// TODO: is this all inefficient?
// Fetch targets for all resources of this site
let targetIps: string[] = [];
let targetInternalPorts: number[] = [];
await Promise.all(
resourcesRes.map(async (resource) => {
const targetsRes = await db.query.targets.findMany({
where: eq(targets.resourceId, resource.resourceId)
});
targetsRes.forEach((target) => {
targetIps.push(`${target.ip}/32`);
if (target.internalPort) {
targetInternalPorts.push(target.internalPort);
}
});
})
);
let internalPort!: number;
// pick a port random port from 40000 to 65535 that is not in use
for (let i = 0; i < 1000; i++) {
internalPort = Math.floor(Math.random() * 25535) + 40000;
if (
!targetInternalPorts.includes(internalPort) &&
!currentBannedPorts.includes(internalPort)
) {
break;
}
}
currentBannedPorts.push(internalPort);
return { internalPort, targetIps };
}

View file

@ -10,6 +10,7 @@ import logger from "@server/logger";
import { fromError } from "zod-validation-error";
import { addPeer } from "../gerbil/peers";
import { addTargets } from "../newt/targets";
import { pickPort } from "./ports";
// Regular expressions for validation
const DOMAIN_REGEX =
@ -48,7 +49,7 @@ const updateTargetParamsSchema = z
const updateTargetBodySchema = z
.object({
ip: domainSchema.optional(),
method: z.string().min(1).max(10).optional(),
method: z.string().min(1).max(10).optional().nullable(),
port: z.number().int().min(1).max(65535).optional(),
enabled: z.boolean().optional()
})
@ -84,15 +85,14 @@ export async function updateTarget(
}
const { targetId } = parsedParams.data;
const updateData = parsedBody.data;
const [updatedTarget] = await db
.update(targets)
.set(updateData)
const [target] = await db
.select()
.from(targets)
.where(eq(targets.targetId, targetId))
.returning();
.limit(1);
if (!updatedTarget) {
if (!target) {
return next(
createHttpError(
HttpCode.NOT_FOUND,
@ -103,17 +103,15 @@ export async function updateTarget(
// get the resource
const [resource] = await db
.select({
siteId: resources.siteId
})
.select()
.from(resources)
.where(eq(resources.resourceId, updatedTarget.resourceId!));
.where(eq(resources.resourceId, target.resourceId!));
if (!resource) {
return next(
createHttpError(
HttpCode.NOT_FOUND,
`Resource with ID ${updatedTarget.resourceId} not found`
`Resource with ID ${target.resourceId} not found`
)
);
}
@ -132,24 +130,29 @@ export async function updateTarget(
)
);
}
const { internalPort, targetIps } = await pickPort(site.siteId!);
if (!internalPort) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
`No available internal port`
)
);
}
const [updatedTarget] = await db
.update(targets)
.set({
...parsedBody.data,
internalPort
})
.where(eq(targets.targetId, targetId))
.returning();
if (site.pubKey) {
if (site.type == "wireguard") {
// TODO: is this all inefficient?
// Fetch resources for this site
const resourcesRes = await db.query.resources.findMany({
where: eq(resources.siteId, site.siteId)
});
// Fetch targets for all resources of this site
const targetIps = await Promise.all(
resourcesRes.map(async (resource) => {
const targetsRes = await db.query.targets.findMany({
where: eq(targets.resourceId, resource.resourceId)
});
return targetsRes.map((target) => `${target.ip}/32`);
})
);
await addPeer(site.exitNodeId!, {
publicKey: site.pubKey,
allowedIps: targetIps.flat()
@ -162,7 +165,7 @@ export async function updateTarget(
.where(eq(newts.siteId, site.siteId))
.limit(1);
addTargets(newt.newtId, [updatedTarget]);
addTargets(newt.newtId, [updatedTarget], resource.protocol);
}
}
return response(res, {

View file

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

View file

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

View file

@ -3,15 +3,16 @@ import db, { exists } from "@server/db";
import path from "path";
import semver from "semver";
import { versionMigrations } from "@server/db/schema";
import { desc } from "drizzle-orm";
import { __DIRNAME, APP_PATH } from "@server/lib/consts";
import { __DIRNAME } from "@server/lib/consts";
import { loadAppVersion } from "@server/lib/loadAppVersion";
import { SqliteError } from "better-sqlite3";
import m1 from "./scripts/1.0.0-beta1";
import m2 from "./scripts/1.0.0-beta2";
import m3 from "./scripts/1.0.0-beta3";
import m4 from "./scripts/1.0.0-beta5";
import m5 from "./scripts/1.0.0-beta6";
import { existsSync, mkdirSync } from "fs";
import m6 from "./scripts/1.0.0-beta9";
import m7 from "./scripts/1.0.0-beta10";
// THIS CANNOT IMPORT ANYTHING FROM THE SERVER
// EXCEPT FOR THE DATABASE AND THE SCHEMA
@ -22,7 +23,9 @@ const migrations = [
{ version: "1.0.0-beta.2", run: m2 },
{ version: "1.0.0-beta.3", run: m3 },
{ version: "1.0.0-beta.5", run: m4 },
{ version: "1.0.0-beta.6", run: m5 }
{ version: "1.0.0-beta.6", run: m5 },
{ version: "1.0.0-beta.9", run: m6 },
{ version: "1.0.0-beta.10", run: m7 }
// Add new migrations here as they are created
] as const;
@ -30,6 +33,7 @@ const migrations = [
await runMigrations();
export async function runMigrations() {
try {
const appVersion = loadAppVersion();
if (!appVersion) {
throw new Error("APP_VERSION is not set in the environment");
@ -56,27 +60,37 @@ export async function runMigrations() {
})
.execute();
}
} catch (e) {
console.error("Error running migrations:", e);
await new Promise((resolve) =>
setTimeout(resolve, 1000 * 60 * 60 * 24 * 1)
);
}
}
async function executeScripts() {
try {
// Get the last executed version from the database
const lastExecuted = await db
.select()
.from(versionMigrations)
.orderBy(desc(versionMigrations.version))
.limit(1);
const startVersion = lastExecuted[0]?.version ?? "0.0.0";
console.log(`Starting migrations from version ${startVersion}`);
const lastExecuted = await db.select().from(versionMigrations);
// Filter and sort migrations
const pendingMigrations = migrations
.filter((migration) => semver.gt(migration.version, startVersion))
.sort((a, b) => semver.compare(a.version, b.version));
const pendingMigrations = lastExecuted
.map((m) => m)
.sort((a, b) => semver.compare(b.version, a.version));
const startVersion = pendingMigrations[0]?.version ?? "0.0.0";
console.log(`Starting migrations from version ${startVersion}`);
const migrationsToRun = migrations.filter((migration) =>
semver.gt(migration.version, startVersion)
);
console.log(
"Migrations to run:",
migrationsToRun.map((m) => m.version).join(", ")
);
// Run migrations in order
for (const migration of pendingMigrations) {
for (const migration of migrationsToRun) {
console.log(`Running migration ${migration.version}`);
try {
@ -94,12 +108,16 @@ async function executeScripts() {
console.log(
`Successfully completed migration ${migration.version}`
);
} catch (error) {
} catch (e) {
if (e instanceof SqliteError && e.code === "SQLITE_CONSTRAINT_UNIQUE") {
console.error("Migration has already run! Skipping...");
continue;
}
console.error(
`Failed to run migration ${migration.version}:`,
error
e
);
throw error; // Re-throw to stop migration process
throw e; // Re-throw to stop migration process
}
}

View file

@ -0,0 +1,45 @@
import { configFilePath1, configFilePath2 } from "@server/lib/consts";
import fs from "fs";
import yaml from "js-yaml";
export default async function migration() {
console.log("Running setup script 1.0.0-beta.10...");
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);
delete rawConfig.server.secure_cookies;
// Write the updated YAML back to the file
const updatedYaml = yaml.dump(rawConfig);
fs.writeFileSync(filePath, updatedYaml, "utf8");
console.log(`Removed deprecated config option: secure_cookies.`);
} catch (e) {
console.log(
`Was unable to remove deprecated config option: secure_cookies. Error: ${e}`
);
return;
}
console.log("Done.");
}

View file

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

View file

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

View file

@ -45,21 +45,65 @@ import {
} from "@app/components/ui/command";
import { CaretSortIcon } from "@radix-ui/react-icons";
import CustomDomainInput from "./[resourceId]/CustomDomainInput";
import { Axios, AxiosResponse } from "axios";
import { AxiosResponse } from "axios";
import { Resource } from "@server/db/schema";
import { useOrgContext } from "@app/hooks/useOrgContext";
import { subdomainSchema } from "@server/schemas/subdomainSchema";
import { createApiClient } from "@app/lib/api";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { cn } from "@app/lib/cn";
import { Switch } from "@app/components/ui/switch";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue
} from "@app/components/ui/select";
import { subdomainSchema } from "@server/schemas/subdomainSchema";
import Link from "next/link";
import { SquareArrowOutUpRight } from "lucide-react";
import CopyTextBox from "@app/components/CopyTextBox";
const accountFormSchema = z.object({
subdomain: subdomainSchema,
name: z.string(),
siteId: z.number()
});
const createResourceFormSchema = z
.object({
subdomain: z.string().optional(),
name: z.string().min(1).max(255),
siteId: z.number(),
http: z.boolean(),
protocol: z.string(),
proxyPort: z.number().optional()
})
.refine(
(data) => {
if (!data.http) {
return z
.number()
.int()
.min(1)
.max(65535)
.safeParse(data.proxyPort).success;
}
return true;
},
{
message: "Invalid port number",
path: ["proxyPort"]
}
)
.refine(
(data) => {
if (data.http) {
return subdomainSchema.safeParse(data.subdomain).success;
}
return true;
},
{
message: "Invalid subdomain",
path: ["subdomain"]
}
);
type AccountFormValues = z.infer<typeof accountFormSchema>;
type CreateResourceFormValues = z.infer<typeof createResourceFormSchema>;
type CreateResourceFormProps = {
open: boolean;
@ -81,15 +125,22 @@ export default function CreateResourceForm({
const router = useRouter();
const { org } = useOrgContext();
const { env } = useEnvContext();
const [sites, setSites] = useState<ListSitesResponse["sites"]>([]);
const [domainSuffix, setDomainSuffix] = useState<string>(org.org.domain);
const form = useForm<AccountFormValues>({
resolver: zodResolver(accountFormSchema),
const [showSnippets, setShowSnippets] = useState(false);
const [resourceId, setResourceId] = useState<number | null>(null);
const form = useForm<CreateResourceFormValues>({
resolver: zodResolver(createResourceFormSchema),
defaultValues: {
subdomain: "",
name: "My Resource"
name: "My Resource",
http: true,
protocol: "tcp"
}
});
@ -112,16 +163,17 @@ export default function CreateResourceForm({
fetchSites();
}, [open]);
async function onSubmit(data: AccountFormValues) {
console.log(data);
async function onSubmit(data: CreateResourceFormValues) {
const res = await api
.put<AxiosResponse<Resource>>(
`/org/${orgId}/site/${data.siteId}/resource/`,
{
name: data.name,
subdomain: data.subdomain
// subdomain: data.subdomain,
subdomain: data.http ? data.subdomain : undefined,
http: data.http,
protocol: data.protocol,
proxyPort: data.http ? undefined : data.proxyPort,
siteId: data.siteId
}
)
.catch((e) => {
@ -137,10 +189,20 @@ export default function CreateResourceForm({
if (res && res.status === 201) {
const id = res.data.data.resourceId;
// navigate to the resource page
router.push(`/${orgId}/settings/resources/${id}`);
setResourceId(id);
if (data.http) {
goToResource(id);
} else {
setShowSnippets(true);
}
}
}
function goToResource(id?: number) {
// navigate to the resource page
router.push(`/${orgId}/settings/resources/${id || resourceId}`);
}
return (
<>
@ -162,6 +224,7 @@ export default function CreateResourceForm({
</CredenzaDescription>
</CredenzaHeader>
<CredenzaBody>
{!showSnippets && (
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
@ -181,23 +244,64 @@ export default function CreateResourceForm({
/>
</FormControl>
<FormDescription>
This is the name that will be
displayed for this resource.
This is the name that will
be displayed for this
resource.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
{!env.flags.allowRawResources || (
<FormField
control={form.control}
name="http"
render={({ field }) => (
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-4">
<div className="space-y-0.5">
<FormLabel className="text-base">
HTTP Resource
</FormLabel>
<FormDescription>
Toggle if this is an
HTTP resource or a
raw TCP/UDP resource
</FormDescription>
</div>
<FormControl>
<Switch
checked={
field.value
}
onCheckedChange={
field.onChange
}
/>
</FormControl>
</FormItem>
)}
/>
)}
{form.watch("http") && (
<FormField
control={form.control}
name="subdomain"
render={({ field }) => (
<FormItem>
<FormLabel>Subdomain</FormLabel>
<FormLabel>
Subdomain
</FormLabel>
<FormControl>
<CustomDomainInput
value={field.value}
domainSuffix={domainSuffix}
value={
field.value ??
""
}
domainSuffix={
domainSuffix
}
placeholder="Enter subdomain"
onChange={(value) =>
form.setValue(
@ -208,14 +312,113 @@ export default function CreateResourceForm({
/>
</FormControl>
<FormDescription>
This is the fully qualified
domain name that will be used to
This is the fully
qualified domain name
that will be used to
access the resource.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
)}
{!form.watch("http") && (
<Link
className="text-sm text-primary flex items-center gap-1"
href="https://docs.fossorial.io/Getting%20Started/tcp-udp"
target="_blank"
rel="noopener noreferrer"
>
<span>
Learn how to configure TCP/UDP
resources
</span>
<SquareArrowOutUpRight size={14} />
</Link>
)}
{!form.watch("http") && (
<>
<FormField
control={form.control}
name="protocol"
render={({ field }) => (
<FormItem>
<FormLabel>
Protocol
</FormLabel>
<Select
value={field.value}
onValueChange={
field.onChange
}
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select a protocol" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="tcp">
TCP
</SelectItem>
<SelectItem value="udp">
UDP
</SelectItem>
</SelectContent>
</Select>
<FormDescription>
The protocol to use
for the resource
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="proxyPort"
render={({ field }) => (
<FormItem>
<FormLabel>
Port Number
</FormLabel>
<FormControl>
<Input
type="number"
placeholder="Enter port number"
value={
field.value ??
""
}
onChange={(e) =>
field.onChange(
e.target
.value
? parseInt(
e
.target
.value
)
: null
)
}
/>
</FormControl>
<FormDescription>
The port number to
proxy requests to
(required for
non-HTTP resources)
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</>
)}
<FormField
control={form.control}
name="siteId"
@ -236,7 +439,9 @@ export default function CreateResourceForm({
>
{field.value
? sites.find(
(site) =>
(
site
) =>
site.siteId ===
field.value
)?.name
@ -250,14 +455,17 @@ export default function CreateResourceForm({
<CommandInput placeholder="Search site..." />
<CommandList>
<CommandEmpty>
No site found.
No site
found.
</CommandEmpty>
<CommandGroup>
{sites.map(
(site) => (
(
site
) => (
<CommandItem
value={
site.name
site.niceId
}
key={
site.siteId
@ -290,8 +498,8 @@ export default function CreateResourceForm({
</PopoverContent>
</Popover>
<FormDescription>
This is the site that will be
used in the dashboard.
This is the site that will
be used in the dashboard.
</FormDescription>
<FormMessage />
</FormItem>
@ -299,16 +507,75 @@ export default function CreateResourceForm({
/>
</form>
</Form>
)}
{showSnippets && (
<div>
<div className="flex items-start space-x-4 mb-6 last:mb-0">
<div className="flex-shrink-0 w-8 h-8 bg-muted text-primary-foreground rounded-full flex items-center justify-center font-bold">
1
</div>
<div className="flex-grow">
<h3 className="text-lg font-semibold mb-3">
Traefik: Add Entrypoints
</h3>
<CopyTextBox
text={`entryPoints:
${form.getValues("protocol")}-${form.getValues("proxyPort")}:
address: ":${form.getValues("proxyPort")}/${form.getValues("protocol")}"`}
wrapText={false}
/>
</div>
</div>
<div className="flex items-start space-x-4 mb-6 last:mb-0">
<div className="flex-shrink-0 w-8 h-8 bg-muted text-primary-foreground rounded-full flex items-center justify-center font-bold">
2
</div>
<div className="flex-grow">
<h3 className="text-lg font-semibold mb-3">
Gerbil: Expose Ports in Docker
Compose
</h3>
<CopyTextBox
text={`ports:
- ${form.getValues("proxyPort")}:${form.getValues("proxyPort")}${form.getValues("protocol") === "tcp" ? "" : "/" + form.getValues("protocol")}`}
wrapText={false}
/>
</div>
</div>
<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>
Make sure to follow the full guide
</span>
<SquareArrowOutUpRight size={14} />
</Link>
</div>
)}
</CredenzaBody>
<CredenzaFooter>
<Button
{!showSnippets && <Button
type="submit"
form="create-resource-form"
loading={loading}
disabled={loading}
>
Create Resource
</Button>
</Button>}
{showSnippets && <Button
loading={loading}
onClick={() => goToResource()}
>
Go to Resource
</Button>}
<CredenzaClose asChild>
<Button variant="outline">Close</Button>
</CredenzaClose>

View file

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

View file

@ -37,7 +37,7 @@ export default function CustomDomainInput({
className="rounded-r-none flex-grow"
/>
<div className="inline-flex items-center px-3 rounded-r-md border border-l-0 border-input bg-muted text-muted-foreground">
<span className="text-sm">{domainSuffix}</span>
<span className="text-sm">.{domainSuffix}</span>
</div>
</div>
</div>

View file

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

View file

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

View file

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

View file

@ -13,7 +13,6 @@ import {
FormLabel,
FormMessage
} from "@/components/ui/form";
import { CaretSortIcon, CheckIcon } from "@radix-ui/react-icons";
import { Input } from "@/components/ui/input";
import {
Command,
@ -21,10 +20,8 @@ import {
CommandGroup,
CommandInput,
CommandItem,
CommandList
} from "@/components/ui/command";
import { cn } from "@app/lib/cn";
import {
Popover,
PopoverContent,
@ -50,16 +47,47 @@ import {
} from "@app/components/Settings";
import { useOrgContext } from "@app/hooks/useOrgContext";
import CustomDomainInput from "../CustomDomainInput";
import ResourceInfoBox from "../ResourceInfoBox";
import { subdomainSchema } from "@server/schemas/subdomainSchema";
import { createApiClient } from "@app/lib/api";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { subdomainSchema } from "@server/schemas/subdomainSchema";
import { CaretSortIcon, CheckIcon } from "@radix-ui/react-icons";
const GeneralFormSchema = z.object({
name: z.string(),
subdomain: subdomainSchema
// siteId: z.number(),
});
const GeneralFormSchema = z
.object({
subdomain: z.string().optional(),
name: z.string().min(1).max(255),
proxyPort: z.number().optional(),
http: z.boolean()
})
.refine(
(data) => {
if (!data.http) {
return z
.number()
.int()
.min(1)
.max(65535)
.safeParse(data.proxyPort).success;
}
return true;
},
{
message: "Invalid port number",
path: ["proxyPort"]
}
)
.refine(
(data) => {
if (data.http) {
return subdomainSchema.safeParse(data.subdomain).success;
}
return true;
},
{
message: "Invalid subdomain",
path: ["subdomain"]
}
);
const TransferFormSchema = z.object({
siteId: z.number()
@ -89,8 +117,9 @@ export default function GeneralForm() {
resolver: zodResolver(GeneralFormSchema),
defaultValues: {
name: resource.name,
subdomain: resource.subdomain
// siteId: resource.siteId!,
subdomain: resource.subdomain ? resource.subdomain : undefined,
proxyPort: resource.proxyPort ? resource.proxyPort : undefined,
http: resource.http
},
mode: "onChange"
});
@ -211,6 +240,7 @@ export default function GeneralForm() {
)}
/>
{resource.http ? (
<FormField
control={form.control}
name="subdomain"
@ -219,8 +249,12 @@ export default function GeneralForm() {
<FormLabel>Subdomain</FormLabel>
<FormControl>
<CustomDomainInput
value={field.value}
domainSuffix={domainSuffix}
value={
field.value || ""
}
domainSuffix={
domainSuffix
}
placeholder="Enter subdomain"
onChange={(value) =>
form.setValue(
@ -231,13 +265,53 @@ export default function GeneralForm() {
/>
</FormControl>
<FormDescription>
This is the subdomain that will
be used to access the resource.
This is the subdomain that
will be used to access the
resource.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
) : (
<FormField
control={form.control}
name="proxyPort"
render={({ field }) => (
<FormItem>
<FormLabel>
Port Number
</FormLabel>
<FormControl>
<Input
type="number"
placeholder="Enter port number"
value={
field.value ?? ""
}
onChange={(e) =>
field.onChange(
e.target.value
? parseInt(
e
.target
.value
)
: null
)
}
/>
</FormControl>
<FormDescription>
This is the port that will
be used to access the
resource.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
)}
</form>
</Form>
</SettingsSectionForm>

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -19,7 +19,7 @@ export default function ResourceAccessDenied() {
</CardTitle>
</CardHeader>
<CardContent>
You're not alowed to access this resource. If this is a mistake,
You're not allowed to access this resource. If this is a mistake,
please contact the administrator.
<div className="text-center mt-4">
<Button>

View file

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

View file

@ -1,7 +1,6 @@
import {
AuthWithAccessTokenResponse,
GetResourceAuthInfoResponse,
GetResourceResponse
GetExchangeTokenResponse
} from "@server/routers/resource";
import ResourceAuthPortal from "./ResourceAuthPortal";
import { internal, priv } from "@app/lib/api";
@ -12,9 +11,6 @@ import { verifySession } from "@app/lib/auth/verifySession";
import { redirect } from "next/navigation";
import ResourceNotFound from "./ResourceNotFound";
import ResourceAccessDenied from "./ResourceAccessDenied";
import { cookies } from "next/headers";
import { CheckResourceSessionResponse } from "@server/routers/auth";
import AccessTokenInvalid from "./AccessToken";
import AccessToken from "./AccessToken";
import { pullEnv } from "@app/lib/pullEnv";
@ -83,49 +79,41 @@ export default async function ResourceAuthPage(props: {
);
}
const allCookies = await cookies();
const cookieName =
env.server.resourceSessionCookieName + `_${params.resourceId}`;
const sessionId = allCookies.get(cookieName)?.value ?? null;
if (sessionId) {
let doRedirect = false;
try {
const res = await priv.get<
AxiosResponse<CheckResourceSessionResponse>
>(`/resource-session/${params.resourceId}/${sessionId}`);
if (res && res.data.data.valid) {
doRedirect = true;
}
} catch (e) {}
if (doRedirect) {
redirect(redirectUrl);
}
}
if (!hasAuth) {
// no authentication so always go straight to the resource
redirect(redirectUrl);
}
// convert the dashboard token into a resource session token
let userIsUnauthorized = false;
if (user && authInfo.sso) {
let doRedirect = false;
let redirectToUrl: string | undefined;
try {
const res = await internal.get<AxiosResponse<GetResourceResponse>>(
`/resource/${params.resourceId}`,
const res = await priv.post<
AxiosResponse<GetExchangeTokenResponse>
>(
`/resource/${params.resourceId}/get-exchange-token`,
{},
await authCookieHeader()
);
doRedirect = true;
if (res.data.data.requestToken) {
const paramName = env.server.resourceSessionRequestParam;
// append the param with the token to the redirect url
const fullUrl = new URL(redirectUrl);
fullUrl.searchParams.append(
paramName,
res.data.data.requestToken
);
redirectToUrl = fullUrl.toString();
}
} catch (e) {
userIsUnauthorized = true;
}
if (doRedirect) {
redirect(redirectUrl);
if (redirectToUrl) {
redirect(redirectToUrl);
}
}

View file

@ -0,0 +1,38 @@
"use client";
import React from "react";
import { Info } from "lucide-react";
import {
Popover,
PopoverContent,
PopoverTrigger
} from "@/components/ui/popover";
import { Button } from "@/components/ui/button";
interface InfoPopupProps {
text: string;
info: string;
}
export function InfoPopup({ text, info }: InfoPopupProps) {
return (
<div className="flex items-center space-x-2">
<span>{text}</span>
<Popover>
<PopoverTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-6 w-6 rounded-full p-0"
>
<Info className="h-4 w-4" />
<span className="sr-only">Show info</span>
</Button>
</PopoverTrigger>
<PopoverContent className="w-80">
<p className="text-sm text-muted-foreground">{info}</p>
</PopoverContent>
</Popover>
</div>
);
}

View file

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

View file

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