mirror of
https://github.com/fosrl/pangolin.git
synced 2026-08-31 08:21:45 +02:00
Compare commits
276 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c1ef5b4fbe | |||
| 8e14bdec95 | |||
| b26dfaf57f | |||
| 1a1c19b24e | |||
| 9d214b18af | |||
| e67b50b356 | |||
| 616caf76cb | |||
| 9a1db4948b | |||
| 5b814e37c4 | |||
| 8483616b04 | |||
| ffe198839a | |||
| db5d1d4a16 | |||
| ad7dcddf24 | |||
| 94408aad21 | |||
| b84a7996a9 | |||
| a9b0bd8b47 | |||
| a32acf7c69 | |||
| 322475fb5c | |||
| 2f124bffc4 | |||
| 86367383e7 | |||
| d22ba3566d | |||
| c74b423bae | |||
| f8a757c55f | |||
| 6aea3f1643 | |||
| 073dc34522 | |||
| 3f5970a1f9 | |||
| e2f2608358 | |||
| 6d17bb04c4 | |||
| 957e7ba127 | |||
| def710cba8 | |||
| 44da854575 | |||
| d3d2474855 | |||
| d7d37c6f6e | |||
| 3c80b9a229 | |||
| a998a35482 | |||
| 20e0e5ebd0 | |||
| 4d831effe1 | |||
| 80f4dd0e60 | |||
| eafa3076d8 | |||
| fef3cd8354 | |||
| 36ada0705e | |||
| 8ae3c06df7 | |||
| ba127a8536 | |||
| 5c024f3a3a | |||
| 4fdb8583f6 | |||
| 2946df3b8e | |||
| c3b0c4e5e9 | |||
| a79d0f1677 | |||
| bfd7a7f561 | |||
| 64c901d91f | |||
| cd9e56fdb7 | |||
| 1b6b112e92 | |||
| 0ff0e83c9f | |||
| 6d491b7bb9 | |||
| 06cc13c637 | |||
| 464d4990df | |||
| cb90672573 | |||
| e19b6ebc82 | |||
| 5a6de12f74 | |||
| 6e6c91a27c | |||
| cf12ab1ac3 | |||
| aa7004b2ff | |||
| eca87b66f0 | |||
| cc8c89eeae | |||
| 6ea4aa1920 | |||
| f12451b8f9 | |||
| 0d4bb65a92 | |||
| d47ad9ac40 | |||
| f81ae24ba7 | |||
| df1e28aabd | |||
| ddabfb5ca1 | |||
| ec0666a612 | |||
| bbf42c5802 | |||
| 6aa1d3b094 | |||
| f1ec1a2fb1 | |||
| 32fcf90467 | |||
| 5a53f88fd6 | |||
| 51971c7ef2 | |||
| 491096109a | |||
| 802a41b1bd | |||
| f59fbabede | |||
| 5a7d54058e | |||
| 5ef4490692 | |||
| 817e848d08 | |||
| 166c8326c5 | |||
| 673f1e93f4 | |||
| 4c1e1daf07 | |||
| 7c54df7ed1 | |||
| 9d77fcc457 | |||
| 454449ec8a | |||
| fe67e8e384 | |||
| 715b957660 | |||
| f1e4bf8d36 | |||
| 3539b9ddb4 | |||
| 4530aac4f3 | |||
| 6d4afd0953 | |||
| dee0ca6864 | |||
| ed73d089d0 | |||
| 35ad235f49 | |||
| 834672c846 | |||
| b8180d848a | |||
| fef7563e14 | |||
| 6337cf4359 | |||
| 73e9e830c3 | |||
| a6469e67a8 | |||
| 23ca3efbf4 | |||
| 0f9100fd3a | |||
| 832d45e32b | |||
| 69e3ac3cd4 | |||
| 50865f4265 | |||
| 0d1a8d9695 | |||
| 5d8486dd7f | |||
| 3c25932787 | |||
| 1d0e1eb126 | |||
| 57c0dc8618 | |||
| 526a147570 | |||
| 0938997548 | |||
| 0876b482f8 | |||
| d558c31f88 | |||
| 6010515da0 | |||
| 868bcd8e34 | |||
| 20c4904965 | |||
| 5a5536b38c | |||
| 53e2296de8 | |||
| d2423919e9 | |||
| 2250fcd177 | |||
| 2a33256d17 | |||
| 117aa750f8 | |||
| 15f161274f | |||
| 09779aca3e | |||
| 1d1f7cecf4 | |||
| dc00668cbe | |||
| 57701e13eb | |||
| 46545cb003 | |||
| e4fd2b656d | |||
| 987b5d580e | |||
| cb75ffc3b7 | |||
| 540f0a754d | |||
| 0f9a6fd968 | |||
| 82112abc34 | |||
| 75b5afd544 | |||
| 00e1675f7b | |||
| 2ddbdf977b | |||
| 4c8f0cc9ec | |||
| e822b681cd | |||
| e1583a58aa | |||
| 7d922ac95f | |||
| 795a3d351e | |||
| 4b4c86b4b7 | |||
| 013af49137 | |||
| a6ae9290f2 | |||
| de70d72e0d | |||
| 4e07e9c52c | |||
| 743621eb25 | |||
| e9df995e76 | |||
| 943923ff4b | |||
| 3f17f1a468 | |||
| 436996a43d | |||
| d42b6076d2 | |||
| 89cc99f915 | |||
| 0611ceb5c3 | |||
| c4b3656fad | |||
| 54c1dd3bae | |||
| a8f4d2b7d1 | |||
| 51f1693dbd | |||
| b33a6e6fac | |||
| fc2c13a686 | |||
| f4602a120e | |||
| 7ccceeea0d | |||
| f81f78f294 | |||
| 6cab223f12 | |||
| 7b05c02508 | |||
| 5922bfb1a0 | |||
| 43f2e32231 | |||
| 20ebdc6289 | |||
| a80ae49a33 | |||
| 660197eef1 | |||
| f286d66cbc | |||
| f3eb823bc3 | |||
| 61c13db090 | |||
| ccbd793f52 | |||
| d13e6896a8 | |||
| 83a36ead10 | |||
| b61b74b0b5 | |||
| 01b068c50f | |||
| fee44ce960 | |||
| 1906504a86 | |||
| 36bcba332c | |||
| 304ab1964c | |||
| b286096c7b | |||
| a22a4b6e74 | |||
| 9a680d2374 | |||
| f80e212b07 | |||
| 8a39b3fd45 | |||
| 61ec938b00 | |||
| 6686de6788 | |||
| 79636cbb30 | |||
| 90d6178a0b | |||
| 2fa1bc6cdc | |||
| c5f6d822ca | |||
| 4de4bf9625 | |||
| 5d956080f2 | |||
| f8e18de2fc | |||
| 884482ec35 | |||
| 9b43948fa4 | |||
| bcd6cd99cc | |||
| 37ceba6b81 | |||
| dfe42e9016 | |||
| 38aa2dace8 | |||
| 136c3eff0c | |||
| 642999c8b1 | |||
| c5fc49b4fa | |||
| cd5a38b1eb | |||
| 595842c2c9 | |||
| 82d5276ade | |||
| 51eb782831 | |||
| de2980e1bc | |||
| 8a3c0d9a08 | |||
| 1a5e9f1005 | |||
| f42c013f33 | |||
| 42c9bda939 | |||
| cbce9fae3a | |||
| e44b15ecd5 | |||
| 7f6ca31757 | |||
| a1eb248474 | |||
| be2b1fd1ce | |||
| 20b65f549e | |||
| 1dc8be373c | |||
| 22b2e6b3d4 | |||
| 89e7107a47 | |||
| 0a69131c38 | |||
| 590f2c29b3 | |||
| 0ddcce6fe1 | |||
| 8a54fb7f23 | |||
| 5c280b024e | |||
| 033cc62ce7 | |||
| 4c69b7a64e | |||
| e7ab9b3f37 | |||
| 3143662f82 | |||
| 18964ba2a3 | |||
| f862404c5c | |||
| c292578f80 | |||
| 7b02d4104d | |||
| 2ef5d90e13 | |||
| d6a8021613 | |||
| c5231d37f6 | |||
| 4d803a40c9 | |||
| 1d709b551a | |||
| 335411de4c | |||
| 0e4abdf4b6 | |||
| 267b40b73c | |||
| ba9a0c5e3c | |||
| 9e0b7ff0d7 | |||
| 003bf7fdf3 | |||
| c3fdda026b | |||
| a53363d064 | |||
| ee21e1faa7 | |||
| e409a34a09 | |||
| 7177ab7f77 | |||
| 801f6fb661 | |||
| 805d82b8d9 | |||
| bd6d790495 | |||
| 2305163474 | |||
| dda53dcb16 | |||
| 2c3e768867 | |||
| 8d682ed9ad | |||
| 47fe497ca1 | |||
| 4d5f364663 | |||
| c3db8b972f | |||
| cfced63ba1 | |||
| 51aa55f963 | |||
| e7df24841e | |||
| e6fd4c32c4 | |||
| f6590aedbd | |||
| 3cb9e02533 | |||
| 4d792350ef |
@@ -34,3 +34,4 @@ build.ts
|
||||
tsconfig.json
|
||||
Dockerfile*
|
||||
drizzle.config.ts
|
||||
allowedDevOrigins.json
|
||||
@@ -77,7 +77,7 @@ jobs:
|
||||
fi
|
||||
|
||||
- name: Log in to Docker Hub
|
||||
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
|
||||
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
|
||||
with:
|
||||
registry: docker.io
|
||||
username: ${{ secrets.DOCKER_HUB_USERNAME }}
|
||||
@@ -149,7 +149,7 @@ jobs:
|
||||
fi
|
||||
|
||||
- name: Log in to Docker Hub
|
||||
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
|
||||
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
|
||||
with:
|
||||
registry: docker.io
|
||||
username: ${{ secrets.DOCKER_HUB_USERNAME }}
|
||||
@@ -204,7 +204,7 @@ jobs:
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Log in to Docker Hub
|
||||
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
|
||||
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
|
||||
with:
|
||||
registry: docker.io
|
||||
username: ${{ secrets.DOCKER_HUB_USERNAME }}
|
||||
@@ -407,7 +407,7 @@ jobs:
|
||||
shell: bash
|
||||
|
||||
- name: Login to GitHub Container Registry (for cosign)
|
||||
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
|
||||
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
|
||||
@@ -24,7 +24,7 @@ jobs:
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
node-version: '24'
|
||||
|
||||
|
||||
@@ -23,7 +23,7 @@ jobs:
|
||||
skopeo --version
|
||||
|
||||
- name: Install cosign
|
||||
uses: sigstore/cosign-installer@cad07c2e89fa2edd6e2d7bab4c1aa38e53f76003 # v4.1.1
|
||||
uses: sigstore/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6 # v4.1.2
|
||||
|
||||
- name: Input check
|
||||
run: |
|
||||
|
||||
@@ -14,7 +14,7 @@ jobs:
|
||||
stale:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/stale@b5d41d4e1d5dceea10e7104786b73624c18a190f # v10.2.0
|
||||
- uses: actions/stale@eb5cf3af3ac0a1aa4c9c45633dd1ae542a27a899 # v10.3.0
|
||||
with:
|
||||
days-before-stale: 14
|
||||
days-before-close: 14
|
||||
|
||||
@@ -17,7 +17,7 @@ jobs:
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Install Node
|
||||
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
node-version: '24'
|
||||
|
||||
|
||||
+4
-2
@@ -17,9 +17,9 @@ yarn-error.log*
|
||||
*.tsbuildinfo
|
||||
next-env.d.ts
|
||||
*.db
|
||||
*.sqlite
|
||||
*.sqlite*
|
||||
!Dockerfile.sqlite
|
||||
*.sqlite3
|
||||
*.sqlite3*
|
||||
*.log
|
||||
.machinelogs*.json
|
||||
*-audit.json
|
||||
@@ -54,3 +54,5 @@ hydrateSaas.ts
|
||||
CLAUDE.md
|
||||
drizzle.config.ts
|
||||
server/setup/migrations.ts
|
||||
solo.yml
|
||||
allowedDevOrigins.json
|
||||
@@ -22,7 +22,8 @@ server:
|
||||
methods: ["GET", "POST", "PUT", "DELETE", "PATCH"]
|
||||
allowed_headers: ["X-CSRF-Token", "Content-Type"]
|
||||
credentials: false
|
||||
{{if .EnableGeoblocking}}maxmind_db_path: "./config/GeoLite2-Country.mmdb"{{end}}
|
||||
{{if .EnableMaxMind}}maxmind_db_path: "./config/GeoLite2-Country.mmdb"{{end}}
|
||||
{{if .EnableMaxMind}}maxmind_asn_path: "./config/GeoLite2-ASN.mmdb"{{end}}
|
||||
{{if .EnableEmail}}
|
||||
email:
|
||||
smtp_host: "{{.EmailSMTPHost}}"
|
||||
|
||||
+34
-19
@@ -54,7 +54,7 @@ type Config struct {
|
||||
InstallGerbil bool
|
||||
TraefikBouncerKey string
|
||||
DoCrowdsecInstall bool
|
||||
EnableGeoblocking bool
|
||||
EnableMaxMind bool
|
||||
Secret string
|
||||
IsEnterprise bool
|
||||
}
|
||||
@@ -123,11 +123,11 @@ func main() {
|
||||
|
||||
fmt.Println("\nConfiguration files created successfully!")
|
||||
|
||||
// Download MaxMind database if requested
|
||||
if config.EnableGeoblocking {
|
||||
fmt.Println("\n=== Downloading MaxMind Database ===")
|
||||
// Download MaxMind Country / ASN database if requested
|
||||
if config.EnableMaxMind {
|
||||
fmt.Println("\n=== Downloading MaxMind Country and ASN Databases ===")
|
||||
if err := downloadMaxMindDatabase(); err != nil {
|
||||
fmt.Printf("Error downloading MaxMind database: %v\n", err)
|
||||
fmt.Printf("Error downloading MaxMind databases: %v\n", err)
|
||||
fmt.Println("You can download it manually later if needed.")
|
||||
}
|
||||
}
|
||||
@@ -188,15 +188,15 @@ func main() {
|
||||
fmt.Println("\n=== MaxMind Database Update ===")
|
||||
if _, err := os.Stat("config/GeoLite2-Country.mmdb"); err == nil {
|
||||
fmt.Println("MaxMind GeoLite2 Country database found.")
|
||||
if readBool("Would you like to update the MaxMind database to the latest version?", false) {
|
||||
if readBool("Would you like to update the MaxMind databases (Country and ASN) to the latest version?", false) {
|
||||
if err := downloadMaxMindDatabase(); err != nil {
|
||||
fmt.Printf("Error updating MaxMind database: %v\n", err)
|
||||
fmt.Println("You can try updating it manually later if needed.")
|
||||
}
|
||||
}
|
||||
} else {
|
||||
fmt.Println("MaxMind GeoLite2 Country database not found.")
|
||||
if readBool("Would you like to download the MaxMind GeoLite2 database for geoblocking functionality?", false) {
|
||||
fmt.Println("MaxMind GeoLite2 Country and ASN databases not found.")
|
||||
if readBool("Would you like to download the MaxMind GeoLite2 databases for blocking functionality?", false) {
|
||||
if err := downloadMaxMindDatabase(); err != nil {
|
||||
fmt.Printf("Error downloading MaxMind database: %v\n", err)
|
||||
fmt.Println("You can try downloading it manually later if needed.")
|
||||
@@ -204,8 +204,10 @@ func main() {
|
||||
// Now you need to update your config file accordingly to enable geoblocking
|
||||
fmt.Print("Please remember to update your config/config.yml file to enable geoblocking! \n\n")
|
||||
// add maxmind_db_path: "./config/GeoLite2-Country.mmdb" under server
|
||||
fmt.Println("Add the following line under the 'server' section:")
|
||||
// add maxmind_asn_path: "./config/GeoLite2-ASN.mmdb" under server
|
||||
fmt.Println("Add the following lines under the 'server' section:")
|
||||
fmt.Println(" maxmind_db_path: \"./config/GeoLite2-Country.mmdb\"")
|
||||
fmt.Println(" maxmind_asn_path: \"./config/GeoLite2-ASN.mmdb\"")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -527,7 +529,7 @@ func collectUserInput() Config {
|
||||
fmt.Println("\n=== Advanced Configuration ===")
|
||||
|
||||
config.EnableIPv6 = readBool("Is your server IPv6 capable?", true)
|
||||
config.EnableGeoblocking = readBool("Do you want to download the MaxMind GeoLite2 database for geoblocking functionality?", true)
|
||||
config.EnableMaxMind = readBool("Do you want to download the MaxMind GeoLite2 Country and ADN databases for blocking functionality?", true)
|
||||
|
||||
if config.DashboardDomain == "" {
|
||||
fmt.Println("Error: Dashboard Domain name is required")
|
||||
@@ -780,29 +782,42 @@ func checkPortsAvailable(port int) error {
|
||||
}
|
||||
|
||||
func downloadMaxMindDatabase() error {
|
||||
fmt.Println("Downloading MaxMind GeoLite2 Country database...")
|
||||
fmt.Println("Downloading MaxMind GeoLite2 Country and ASN databases...")
|
||||
|
||||
// Download the GeoLite2 Country database
|
||||
// Download the GeoLite2 Country databases
|
||||
if err := run("curl", "-L", "-o", "GeoLite2-Country.tar.gz",
|
||||
"https://github.com/GitSquared/node-geolite2-redist/raw/refs/heads/master/redist/GeoLite2-Country.tar.gz"); err != nil {
|
||||
return fmt.Errorf("failed to download GeoLite2 database: %v", err)
|
||||
return fmt.Errorf("failed to download GeoLite2 Country database: %v", err)
|
||||
}
|
||||
if err := run("curl", "-L", "-o", "GeoLite2-ASN.tar.gz",
|
||||
"https://github.com/GitSquared/node-geolite2-redist/raw/refs/heads/master/redist/GeoLite2-ASN.tar.gz"); err != nil {
|
||||
return fmt.Errorf("failed to download GeoLite2 ASN database: %v", err)
|
||||
}
|
||||
|
||||
// Extract the database
|
||||
// Extract the Country database
|
||||
if err := run("tar", "-xzf", "GeoLite2-Country.tar.gz"); err != nil {
|
||||
return fmt.Errorf("failed to extract GeoLite2 database: %v", err)
|
||||
return fmt.Errorf("failed to extract GeoLite2 Country database: %v", err)
|
||||
}
|
||||
if err := run("tar", "-xzf", "GeoLite2-ASN.tar.gz"); err != nil {
|
||||
return fmt.Errorf("failed to extract GeoLite2 ASN database: %v", err)
|
||||
}
|
||||
|
||||
// Find the .mmdb file and move it to the config directory
|
||||
if err := run("bash", "-c", "mv GeoLite2-Country_*/GeoLite2-Country.mmdb config/"); err != nil {
|
||||
return fmt.Errorf("failed to move GeoLite2 database to config directory: %v", err)
|
||||
return fmt.Errorf("failed to move GeoLite2 Country database to config directory: %v", err)
|
||||
}
|
||||
if err := run("bash", "-c", "mv GeoLite2-ASN_*/GeoLite2-ASN.mmdb config/"); err != nil {
|
||||
return fmt.Errorf("failed to move GeoLite2 ASN database to config directory: %v", err)
|
||||
}
|
||||
|
||||
// Clean up the downloaded files
|
||||
if err := run("rm", "-rf", "GeoLite2-Country.tar.gz", "GeoLite2-Country_*"); err != nil {
|
||||
fmt.Printf("Warning: failed to clean up temporary files: %v\n", err)
|
||||
if err := run("sh", "-c", "rm -rf GeoLite2-Country.tar.gz GeoLite2-Country_*"); err != nil {
|
||||
fmt.Printf("Warning: failed to clean up temporary country files: %v\n", err)
|
||||
}
|
||||
if err := run("sh", "-c", "rm -rf GeoLite2-ASN.tar.gz GeoLite2-ASN_*"); err != nil {
|
||||
fmt.Printf("Warning: failed to clean up temporary ASN files: %v\n", err)
|
||||
}
|
||||
|
||||
fmt.Println("MaxMind GeoLite2 Country database downloaded successfully!")
|
||||
fmt.Println("MaxMind GeoLite2 Country and ASN database downloaded successfully!")
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -2049,6 +2049,7 @@
|
||||
"editInternalResourceDialogModeCidr": "CIDR",
|
||||
"editInternalResourceDialogModeHttp": "HTTP",
|
||||
"editInternalResourceDialogModeHttps": "HTTPS",
|
||||
"editInternalResourceDialogModeSsh": "SSH",
|
||||
"editInternalResourceDialogScheme": "Метод",
|
||||
"editInternalResourceDialogEnableSsl": "Активирайте TLS",
|
||||
"editInternalResourceDialogEnableSslDescription": "Активирайте SSL/TLS криптиране за сигурни HTTPS връзки към целта.",
|
||||
@@ -2098,6 +2099,7 @@
|
||||
"createInternalResourceDialogModeCidr": "CIDR",
|
||||
"createInternalResourceDialogModeHttp": "HTTP",
|
||||
"createInternalResourceDialogModeHttps": "HTTPS",
|
||||
"createInternalResourceDialogModeSsh": "SSH",
|
||||
"scheme": "Метод",
|
||||
"createInternalResourceDialogScheme": "Метод",
|
||||
"createInternalResourceDialogEnableSsl": "Активирайте TLS",
|
||||
|
||||
@@ -2049,6 +2049,7 @@
|
||||
"editInternalResourceDialogModeCidr": "CIDR",
|
||||
"editInternalResourceDialogModeHttp": "HTTP",
|
||||
"editInternalResourceDialogModeHttps": "HTTPS",
|
||||
"editInternalResourceDialogModeSsh": "SSH",
|
||||
"editInternalResourceDialogScheme": "Schéma",
|
||||
"editInternalResourceDialogEnableSsl": "Povolit SSL",
|
||||
"editInternalResourceDialogEnableSslDescription": "Povolit šifrování SSL/TLS pro zabezpečené HTTPS připojení k cíli.",
|
||||
@@ -2098,6 +2099,7 @@
|
||||
"createInternalResourceDialogModeCidr": "CIDR",
|
||||
"createInternalResourceDialogModeHttp": "HTTP",
|
||||
"createInternalResourceDialogModeHttps": "HTTPS",
|
||||
"createInternalResourceDialogModeSsh": "SSH",
|
||||
"scheme": "Schéma",
|
||||
"createInternalResourceDialogScheme": "Schéma",
|
||||
"createInternalResourceDialogEnableSsl": "Povolit SSL",
|
||||
|
||||
@@ -2049,6 +2049,7 @@
|
||||
"editInternalResourceDialogModeCidr": "CIDR",
|
||||
"editInternalResourceDialogModeHttp": "HTTP",
|
||||
"editInternalResourceDialogModeHttps": "HTTPS",
|
||||
"editInternalResourceDialogModeSsh": "SSH",
|
||||
"editInternalResourceDialogScheme": "Schema",
|
||||
"editInternalResourceDialogEnableSsl": "TLS aktivieren",
|
||||
"editInternalResourceDialogEnableSslDescription": "SSL/TLS-Verschlüsselung für sichere HTTPS-Verbindungen zum Ziel aktivieren.",
|
||||
@@ -2098,6 +2099,7 @@
|
||||
"createInternalResourceDialogModeCidr": "CIDR",
|
||||
"createInternalResourceDialogModeHttp": "HTTP",
|
||||
"createInternalResourceDialogModeHttps": "HTTPS",
|
||||
"createInternalResourceDialogModeSsh": "SSH",
|
||||
"scheme": "Schema",
|
||||
"createInternalResourceDialogScheme": "Schema",
|
||||
"createInternalResourceDialogEnableSsl": "TLS aktivieren",
|
||||
|
||||
+112
-8
@@ -176,6 +176,7 @@
|
||||
"shareErrorCreateDescription": "An error occurred while creating the share link",
|
||||
"shareCreateDescription": "Anyone with this link can access the resource",
|
||||
"shareTitleOptional": "Title (optional)",
|
||||
"sharePathOptional": "Path (optional)",
|
||||
"expireIn": "Expire In",
|
||||
"neverExpire": "Never expire",
|
||||
"shareExpireDescription": "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.",
|
||||
@@ -208,11 +209,33 @@
|
||||
"resourcesSearch": "Search resources...",
|
||||
"resourceAdd": "Add Resource",
|
||||
"resourceErrorDelte": "Error deleting resource",
|
||||
"resourcePoliciesTitle": "Manage Resource Policies",
|
||||
"resourcePoliciesAttachedResourcesColumnTitle": "Attached resources",
|
||||
"resourcePoliciesAttachedResources": "{count} resource(s)",
|
||||
"resourcePoliciesAttachedResourcesEmpty": "no resources",
|
||||
"resourcePoliciesDescription": "Create and manage authentication policies to control access to your resources",
|
||||
"resourcePoliciesSearch": "Search policies...",
|
||||
"resourcePoliciesAdd": "Add Policy",
|
||||
"resourcePoliciesDefaultBadgeText": "Default policy",
|
||||
"resourcePoliciesCreate": "Create Resource Policy",
|
||||
"resourcePoliciesCreateDescription": "Follow the steps below to create a new policy",
|
||||
"resourcePolicyName": "Policy Name",
|
||||
"resourcePolicyNameDescription": "Give this policy a name to identify it across your resources",
|
||||
"resourcePolicyNamePlaceholder": "e.g. Internal Access Policy",
|
||||
"resourcePoliciesSeeAll": "See All Policies",
|
||||
"resourcePolicyAuthMethodAdd": "Add Authentication Method",
|
||||
"resourcePolicyOtpEmailAdd": "Add OTP emails",
|
||||
"resourcePolicyRulesAdd": "Add Rules",
|
||||
"resourcePolicyAuthMethodsDescription": "Allow access to resources via additional auth methods",
|
||||
"resourcePolicyUsersRolesDescription": "Configure which users and roles can visit associated resources",
|
||||
"rulesResourcePolicyDescription": "Configure rules to control access resources associated to this policy",
|
||||
"authentication": "Authentication",
|
||||
"protected": "Protected",
|
||||
"notProtected": "Not Protected",
|
||||
"resourceMessageRemove": "Once removed, the resource will no longer be accessible. All targets associated with the resource will also be removed.",
|
||||
"resourceQuestionRemove": "Are you sure you want to remove the resource from the organization?",
|
||||
"resourcePolicyMessageRemove": "Once removed, the resource policy will no longer be accessible. All resources associated with the resource will be unlinked and left without authentication.",
|
||||
"resourcePolicyQuestionRemove": "Are you sure you want to remove the resource policy from the organization?",
|
||||
"resourceHTTP": "HTTPS Resource",
|
||||
"resourceHTTPDescription": "Proxy requests over HTTPS using a fully qualified domain name.",
|
||||
"resourceRaw": "Raw TCP/UDP Resource",
|
||||
@@ -220,8 +243,9 @@
|
||||
"resourceRawDescriptionCloud": "Proxy requests over raw TCP/UDP using a port number. Requires sites to connect to a remote node.",
|
||||
"resourceCreate": "Create Resource",
|
||||
"resourceCreateDescription": "Follow the steps below to create a new resource",
|
||||
"resourceCreateGeneralDescription": "Configure the basic resource settings including the name and the type",
|
||||
"resourceSeeAll": "See All Resources",
|
||||
"resourceInfo": "Resource Information",
|
||||
"resourceCreateGeneral": "General",
|
||||
"resourceNameDescription": "This is the display name for the resource.",
|
||||
"siteSelect": "Select site",
|
||||
"siteSearch": "Search site",
|
||||
@@ -231,12 +255,15 @@
|
||||
"noCountryFound": "No country found.",
|
||||
"siteSelectionDescription": "This site will provide connectivity to the target.",
|
||||
"resourceType": "Resource Type",
|
||||
"resourceTypeDescription": "Determine how to access the resource",
|
||||
"resourceTypeDescription": "This controls the resource protocol and how it will be rendered in the browser. This can’t be changed later.",
|
||||
"resourceDomainDescription": "The resource will be served at this fully qualified domain name.",
|
||||
"resourceHTTPSSettings": "HTTPS Settings",
|
||||
"resourceHTTPSSettingsDescription": "Configure how the resource will be accessed over HTTPS",
|
||||
"resourcePortDescription": "The external port on the Pangolin instance or node where the resource will be accessible.",
|
||||
"domainType": "Domain Type",
|
||||
"subdomain": "Subdomain",
|
||||
"baseDomain": "Base Domain",
|
||||
"configure": "Configure",
|
||||
"subdomnainDescription": "The subdomain where the resource will be accessible.",
|
||||
"resourceRawSettings": "TCP/UDP Settings",
|
||||
"resourceRawSettingsDescription": "Configure how the resource will be accessed over TCP/UDP",
|
||||
@@ -253,6 +280,8 @@
|
||||
"resourceLearnRaw": "Learn how to configure TCP/UDP resources",
|
||||
"resourceBack": "Back to Resources",
|
||||
"resourceGoTo": "Go to Resource",
|
||||
"resourcePolicyDelete": "Delete Resource Policy",
|
||||
"resourcePolicyDeleteConfirm": "Confirm Delete Resource Policy",
|
||||
"resourceDelete": "Delete Resource",
|
||||
"resourceDeleteConfirm": "Confirm Delete Resource",
|
||||
"labelDelete": "Delete Label",
|
||||
@@ -282,6 +311,8 @@
|
||||
"rules": "Rules",
|
||||
"resourceSettingDescription": "Configure the settings on the resource",
|
||||
"resourceSetting": "{resourceName} Settings",
|
||||
"resourcePolicySettingDescription": "Configure the settings on the resource policy",
|
||||
"resourcePolicySetting": "{policyName} Settings",
|
||||
"alwaysAllow": "Bypass Auth",
|
||||
"alwaysDeny": "Block Access",
|
||||
"passToAuth": "Pass to Auth",
|
||||
@@ -764,6 +795,16 @@
|
||||
"rulesNoOne": "No rules. Add a rule using the form.",
|
||||
"rulesOrder": "Rules are evaluated by priority in ascending order.",
|
||||
"rulesSubmit": "Save Rules",
|
||||
"policyErrorCreate": "Error creating policy",
|
||||
"policyErrorCreateDescription": "An error occurred when creating the policy",
|
||||
"policyErrorCreateMessageDescription": "An unexpected error occurred",
|
||||
"policyErrorUpdate": "Error updating policy",
|
||||
"policyErrorUpdateDescription": "An error occurred when updating the policy",
|
||||
"policyErrorUpdateMessageDescription": "An unexpected error occurred",
|
||||
"policyCreatedSuccess": "Resource policy succesfully created",
|
||||
"policyUpdatedSuccess": "Resource policy succesfully updated",
|
||||
"authMethodsSave": "Save auth methods",
|
||||
"rulesSave": "Save Rules",
|
||||
"resourceErrorCreate": "Error creating resource",
|
||||
"resourceErrorCreateDescription": "An error occurred when creating the resource",
|
||||
"resourceErrorCreateMessage": "Error creating resource:",
|
||||
@@ -827,6 +868,16 @@
|
||||
"pincodeAdd": "Add PIN Code",
|
||||
"pincodeRemove": "Remove PIN Code",
|
||||
"resourceAuthMethods": "Authentication Methods",
|
||||
"resourcePolicyAuthMethodsEmpty": "No authentication method",
|
||||
"resourcePolicyOtpEmpty": "No one time password",
|
||||
"resourcePolicyReadOnly": "This policy is Read only",
|
||||
"resourcePolicyReadOnlyDescription": "This resource policy is shared accross multiple resources, you cannot edit it on this page.",
|
||||
"resourcePolicyTypeSave": "Save Resource type",
|
||||
"resourcePolicySelect": "Select resource policy",
|
||||
"resourcePolicySelectError": "Select a resource policy",
|
||||
"resourcePolicyNotFound": "Policy not found",
|
||||
"resourcePolicySearch": "Search policies",
|
||||
"resourcePolicyRulesEmpty": "No authentication rules",
|
||||
"resourceAuthMethodsDescriptions": "Allow access to the resource via additional auth methods",
|
||||
"resourceAuthSettingsSave": "Saved successfully",
|
||||
"resourceAuthSettingsSaveDescription": "Authentication settings have been saved",
|
||||
@@ -862,6 +913,12 @@
|
||||
"resourcePincodeSetupTitle": "Set Pincode",
|
||||
"resourcePincodeSetupTitleDescription": "Set a pincode to protect this resource",
|
||||
"resourceRoleDescription": "Admins can always access this resource.",
|
||||
"resourcePolicySelectTitle": "Resource Access Policy",
|
||||
"resourcePolicySelectDescription": "Select the resource policy type for authentication",
|
||||
"resourcePolicyInline": "Inline Resource Policy",
|
||||
"resourcePolicyInlineDescription": "Access Policy scoped to only this resource",
|
||||
"resourcePolicyShared": "Shared Resource Policy",
|
||||
"resourcePolicySharedDescription": "Access Policy shared accross multiple resources",
|
||||
"resourceUsersRoles": "Access Controls",
|
||||
"resourceUsersRolesDescription": "Configure which users and roles can visit this resource",
|
||||
"resourceUsersRolesSubmit": "Save Access Controls",
|
||||
@@ -1403,6 +1460,8 @@
|
||||
"sidebarResources": "Resources",
|
||||
"sidebarProxyResources": "Public",
|
||||
"sidebarClientResources": "Private",
|
||||
"sidebarPolicies": "Policies",
|
||||
"sidebarResourcePolicies": "Resources",
|
||||
"sidebarAccessControl": "Access Control",
|
||||
"sidebarLogsAndAnalytics": "Logs & Analytics",
|
||||
"sidebarTeam": "Team",
|
||||
@@ -1586,7 +1645,8 @@
|
||||
"standaloneHcFilterSiteIdFallback": "Site {id}",
|
||||
"standaloneHcFilterResourceIdFallback": "Resource {id}",
|
||||
"blueprints": "Blueprints",
|
||||
"blueprintsDescription": "Apply declarative configurations and view previous runs",
|
||||
"blueprintsLog": "Blueprints Log",
|
||||
"blueprintsDescription": "View past blueprint applications and their results",
|
||||
"blueprintAdd": "Add Blueprint",
|
||||
"blueprintGoBack": "See all Blueprints",
|
||||
"blueprintCreate": "Create Blueprint",
|
||||
@@ -1604,7 +1664,17 @@
|
||||
"contents": "Contents",
|
||||
"parsedContents": "Parsed Contents (Read Only)",
|
||||
"enableDockerSocket": "Enable Docker Blueprint",
|
||||
"enableDockerSocketDescription": "Enable Docker Socket label scraping for blueprint labels. Socket path must be provided to Newt. Read about how this works in <docsLink>the documentation</docsLink>.",
|
||||
"enableDockerSocketDescription": "Enable Docker Socket label scraping for blueprint labels. Socket path must be provided to the site connector. Read about how this works in <docsLink>the documentation</docsLink>.",
|
||||
"newtAutoUpdate": "Enable Site Auto-Update",
|
||||
"newtAutoUpdateDescription": "When enabled, site connectors will automatically update to the latest version when a new release is available.",
|
||||
"siteAutoUpdate": "Site Auto-Update",
|
||||
"siteAutoUpdateLabel": "Enable Auto-Update",
|
||||
"siteAutoUpdateDescription": "Control whether this site's connector automatically downloads the latest version.",
|
||||
"siteAutoUpdateOrgDefault": "Organization default: {state}",
|
||||
"siteAutoUpdateOverriding": "Overriding organization setting",
|
||||
"siteAutoUpdateResetToOrg": "Reset to Organization Default",
|
||||
"siteAutoUpdateEnabled": "enabled",
|
||||
"siteAutoUpdateDisabled": "disabled",
|
||||
"viewDockerContainers": "View Docker Containers",
|
||||
"containersIn": "Containers in {siteName}",
|
||||
"selectContainerDescription": "Select any container to use as a hostname for this target. Click a port to use a port.",
|
||||
@@ -1876,6 +1946,7 @@
|
||||
"billingManageLicenseSubscription": "Manage your subscription for paid self-hosted license keys",
|
||||
"billingCurrentKeys": "Current Keys",
|
||||
"billingModifyCurrentPlan": "Modify Current Plan",
|
||||
"billingManageLicenseSubscriptionDescription": "Manage your subscription for paid self-hosted license keys and download invoices.",
|
||||
"billingConfirmUpgrade": "Confirm Upgrade",
|
||||
"billingConfirmDowngrade": "Confirm Downgrade",
|
||||
"billingConfirmUpgradeDescription": "You are about to upgrade your plan. Review the new limits and pricing below.",
|
||||
@@ -1973,7 +2044,36 @@
|
||||
"timeIsInSeconds": "Time is in seconds",
|
||||
"requireDeviceApproval": "Require Device Approvals",
|
||||
"requireDeviceApprovalDescription": "Users with this role need new devices approved by an admin before they can connect and access resources.",
|
||||
"sshAccess": "SSH Access",
|
||||
"sshSettings": "SSH Settings",
|
||||
"rdpSettings": "RDP Settings",
|
||||
"vncSettings": "VNC Settings",
|
||||
"sshServer": "SSH Server",
|
||||
"rdpServer": "RDP Server",
|
||||
"vncServer": "VNC Server",
|
||||
"sshServerDescription": "Set up the authentication method, daemon location, and server destination",
|
||||
"rdpServerDescription": "Configure the destination and port of the RDP server",
|
||||
"vncServerDescription": "Configure the destination and port of the VNC server",
|
||||
"sshServerMode": "Mode",
|
||||
"sshServerModeStandard": "Standard SSH Server",
|
||||
"sshServerModePangolin": "Pangolin SSH",
|
||||
"sshServerModeStandardDescription": "Routes commands over network to an SSH server such as OpenSSH.",
|
||||
"sshServerModeNative": "Native SSH Server",
|
||||
"sshServerModeNativeDescription": "Executes commands directly on the host via the Site Connector. No network config required.",
|
||||
"sshAuthenticationMethod": "Authentication Method",
|
||||
"sshAuthMethodManual": "Manual Authentication",
|
||||
"sshAuthMethodManualDescription": "Requires existing host credentials. Bypasses automatic provisioning.",
|
||||
"sshAuthMethodAutomated": "Automated Provisioning",
|
||||
"sshAuthMethodAutomatedDescription": "Automatically creates users, groups, and sudo permissions on host.",
|
||||
"sshAuthDaemonLocation": "Auth Daemon Location",
|
||||
"sshDaemonLocationSiteDescription": "Executes locally on the machine hosting the site connector.",
|
||||
"sshDaemonLocationRemote": "On Remote Host",
|
||||
"sshDaemonLocationRemoteDescription": "Executes on a separate target machine on the same network.",
|
||||
"sshDaemonDisclaimer": "Ensure your target host is properly configured to run the auth daemon before completing this setup, or provisioning will fail.",
|
||||
"sshDaemonPort": "Daemon Port",
|
||||
"sshServerDestination": "Server Destination",
|
||||
"sshServerDestinationDescription": "Configure the destination and port of the SSH server",
|
||||
"destination": "Destination",
|
||||
"bgTargetMultiSiteDisclaimer": "Selecting multiple sites enables resilient routing and failover for high availability.",
|
||||
"roleAllowSsh": "Allow SSH",
|
||||
"roleAllowSshAllow": "Allow",
|
||||
"roleAllowSshDisallow": "Disallow",
|
||||
@@ -1987,7 +2087,7 @@
|
||||
"sshSudoModeCommandsDescription": "User can run only the specified commands with sudo.",
|
||||
"sshSudo": "Allow sudo",
|
||||
"sshSudoCommands": "Sudo Commands",
|
||||
"sshSudoCommandsDescription": "Comma separated list of commands the user is allowed to run with sudo.",
|
||||
"sshSudoCommandsDescription": "Comma separated list of commands the user is allowed to run with sudo. Absolute paths must be used.",
|
||||
"sshCreateHomeDir": "Create Home Directory",
|
||||
"sshUnixGroups": "Unix Groups",
|
||||
"sshUnixGroupsDescription": "Comma separated Unix groups to add the user to on the target host.",
|
||||
@@ -2079,6 +2179,7 @@
|
||||
"editInternalResourceDialogModeCidr": "CIDR",
|
||||
"editInternalResourceDialogModeHttp": "HTTP",
|
||||
"editInternalResourceDialogModeHttps": "HTTPS",
|
||||
"editInternalResourceDialogModeSsh": "SSH",
|
||||
"editInternalResourceDialogScheme": "Scheme",
|
||||
"editInternalResourceDialogEnableSsl": "Enable TLS",
|
||||
"editInternalResourceDialogEnableSslDescription": "Enable SSL/TLS encryption for secure HTTPS connections to the destination.",
|
||||
@@ -2128,6 +2229,7 @@
|
||||
"createInternalResourceDialogModeCidr": "CIDR",
|
||||
"createInternalResourceDialogModeHttp": "HTTP",
|
||||
"createInternalResourceDialogModeHttps": "HTTPS",
|
||||
"createInternalResourceDialogModeSsh": "SSH",
|
||||
"scheme": "Scheme",
|
||||
"createInternalResourceDialogScheme": "Scheme",
|
||||
"createInternalResourceDialogEnableSsl": "Enable TLS",
|
||||
@@ -2967,7 +3069,7 @@
|
||||
"learnMore": "Learn more",
|
||||
"backToHome": "Go back to home",
|
||||
"needToSignInToOrg": "Need to use your organization's identity provider?",
|
||||
"maintenanceMode": "Maintenance Mode",
|
||||
"maintenanceMode": "Maintenance Page",
|
||||
"maintenanceModeDescription": "Display a maintenance page to visitors",
|
||||
"maintenanceModeType": "Maintenance Mode Type",
|
||||
"showMaintenancePage": "Show a maintenance page to visitors",
|
||||
@@ -2997,6 +3099,7 @@
|
||||
"maintenanceScreenEstimatedCompletion": "Estimated Completion:",
|
||||
"createInternalResourceDialogDestinationRequired": "Destination is required",
|
||||
"available": "Available",
|
||||
"disabledResourceDescription": "When disabled, the resource will be inaccessible by everyone.",
|
||||
"archived": "Archived",
|
||||
"noArchivedDevices": "No archived devices found",
|
||||
"deviceArchived": "Device archived",
|
||||
@@ -3326,5 +3429,6 @@
|
||||
"memberPortalResourceDisabled": "Resource Disabled",
|
||||
"memberPortalShowingResources": "Showing {start}-{end} of {total} resources",
|
||||
"memberPortalPrevious": "Previous",
|
||||
"memberPortalNext": "Next"
|
||||
"memberPortalNext": "Next",
|
||||
"httpSettings": "HTTP Settings"
|
||||
}
|
||||
|
||||
@@ -2049,6 +2049,7 @@
|
||||
"editInternalResourceDialogModeCidr": "CIDR",
|
||||
"editInternalResourceDialogModeHttp": "HTTP",
|
||||
"editInternalResourceDialogModeHttps": "HTTPS",
|
||||
"editInternalResourceDialogModeSsh": "SSH",
|
||||
"editInternalResourceDialogScheme": "Esquema",
|
||||
"editInternalResourceDialogEnableSsl": "Activar TLS",
|
||||
"editInternalResourceDialogEnableSslDescription": "Habilitar cifrado SSL/TLS para conexiones HTTPS seguras al destino.",
|
||||
@@ -2098,6 +2099,7 @@
|
||||
"createInternalResourceDialogModeCidr": "CIDR",
|
||||
"createInternalResourceDialogModeHttp": "HTTP",
|
||||
"createInternalResourceDialogModeHttps": "HTTPS",
|
||||
"createInternalResourceDialogModeSsh": "SSH",
|
||||
"scheme": "Esquema",
|
||||
"createInternalResourceDialogScheme": "Esquema",
|
||||
"createInternalResourceDialogEnableSsl": "Activar TLS",
|
||||
|
||||
@@ -2049,6 +2049,7 @@
|
||||
"editInternalResourceDialogModeCidr": "CIDR",
|
||||
"editInternalResourceDialogModeHttp": "HTTP",
|
||||
"editInternalResourceDialogModeHttps": "HTTPS",
|
||||
"editInternalResourceDialogModeSsh": "SSH",
|
||||
"editInternalResourceDialogScheme": "Méthode HTTP",
|
||||
"editInternalResourceDialogEnableSsl": "Activer TLS",
|
||||
"editInternalResourceDialogEnableSslDescription": "Activer le cryptage SSL/TLS pour des connexions HTTPS sécurisées vers la destination.",
|
||||
@@ -2098,6 +2099,7 @@
|
||||
"createInternalResourceDialogModeCidr": "CIDR",
|
||||
"createInternalResourceDialogModeHttp": "HTTP",
|
||||
"createInternalResourceDialogModeHttps": "HTTPS",
|
||||
"createInternalResourceDialogModeSsh": "SSH",
|
||||
"scheme": "Méthode HTTP",
|
||||
"createInternalResourceDialogScheme": "Méthode HTTP",
|
||||
"createInternalResourceDialogEnableSsl": "Activer TLS",
|
||||
|
||||
@@ -2049,6 +2049,7 @@
|
||||
"editInternalResourceDialogModeCidr": "CIDR",
|
||||
"editInternalResourceDialogModeHttp": "HTTP",
|
||||
"editInternalResourceDialogModeHttps": "HTTPS",
|
||||
"editInternalResourceDialogModeSsh": "SSH",
|
||||
"editInternalResourceDialogScheme": "Metodo HTTP",
|
||||
"editInternalResourceDialogEnableSsl": "Abilitare TLS",
|
||||
"editInternalResourceDialogEnableSslDescription": "Abilita la crittografia SSL/TLS per connessioni HTTPS sicure alla destinazione.",
|
||||
@@ -2098,6 +2099,7 @@
|
||||
"createInternalResourceDialogModeCidr": "CIDR",
|
||||
"createInternalResourceDialogModeHttp": "HTTP",
|
||||
"createInternalResourceDialogModeHttps": "HTTPS",
|
||||
"createInternalResourceDialogModeSsh": "SSH",
|
||||
"scheme": "Metodo HTTP",
|
||||
"createInternalResourceDialogScheme": "Metodo HTTP",
|
||||
"createInternalResourceDialogEnableSsl": "Abilitare TLS",
|
||||
|
||||
@@ -2049,6 +2049,7 @@
|
||||
"editInternalResourceDialogModeCidr": "CIDR",
|
||||
"editInternalResourceDialogModeHttp": "HTTP",
|
||||
"editInternalResourceDialogModeHttps": "HTTPS",
|
||||
"editInternalResourceDialogModeSsh": "SSH",
|
||||
"editInternalResourceDialogScheme": "스킴",
|
||||
"editInternalResourceDialogEnableSsl": "TLS 활성화",
|
||||
"editInternalResourceDialogEnableSslDescription": "목적지로의 안전한 HTTPS 연결을 위한 SSL/TLS 암호화 활성화.",
|
||||
@@ -2098,6 +2099,7 @@
|
||||
"createInternalResourceDialogModeCidr": "CIDR",
|
||||
"createInternalResourceDialogModeHttp": "HTTP",
|
||||
"createInternalResourceDialogModeHttps": "HTTPS",
|
||||
"createInternalResourceDialogModeSsh": "SSH",
|
||||
"scheme": "스킴",
|
||||
"createInternalResourceDialogScheme": "스킴",
|
||||
"createInternalResourceDialogEnableSsl": "TLS 활성화",
|
||||
|
||||
@@ -2049,6 +2049,7 @@
|
||||
"editInternalResourceDialogModeCidr": "CIDR",
|
||||
"editInternalResourceDialogModeHttp": "HTTP",
|
||||
"editInternalResourceDialogModeHttps": "HTTPS",
|
||||
"editInternalResourceDialogModeSsh": "SSH",
|
||||
"editInternalResourceDialogScheme": "Skjema",
|
||||
"editInternalResourceDialogEnableSsl": "Aktiver TLS",
|
||||
"editInternalResourceDialogEnableSslDescription": "Aktiver SSL/TLS-kryptering for sikre HTTPS-tilkoblinger til destinasjonen.",
|
||||
@@ -2098,6 +2099,7 @@
|
||||
"createInternalResourceDialogModeCidr": "CIDR",
|
||||
"createInternalResourceDialogModeHttp": "HTTP",
|
||||
"createInternalResourceDialogModeHttps": "HTTPS",
|
||||
"createInternalResourceDialogModeSsh": "SSH",
|
||||
"scheme": "Skjema",
|
||||
"createInternalResourceDialogScheme": "Skjema",
|
||||
"createInternalResourceDialogEnableSsl": "Aktiver TLS",
|
||||
|
||||
@@ -2049,6 +2049,7 @@
|
||||
"editInternalResourceDialogModeCidr": "CIDR",
|
||||
"editInternalResourceDialogModeHttp": "HTTP",
|
||||
"editInternalResourceDialogModeHttps": "HTTPS",
|
||||
"editInternalResourceDialogModeSsh": "SSH",
|
||||
"editInternalResourceDialogScheme": "Schema",
|
||||
"editInternalResourceDialogEnableSsl": "TLS inschakelen",
|
||||
"editInternalResourceDialogEnableSslDescription": "Schakel SSL/TLS-encryptie in voor beveiligde HTTPS-verbindingen met de bestemming.",
|
||||
@@ -2098,6 +2099,7 @@
|
||||
"createInternalResourceDialogModeCidr": "CIDR",
|
||||
"createInternalResourceDialogModeHttp": "HTTP",
|
||||
"createInternalResourceDialogModeHttps": "HTTPS",
|
||||
"createInternalResourceDialogModeSsh": "SSH",
|
||||
"scheme": "Schema",
|
||||
"createInternalResourceDialogScheme": "Schema",
|
||||
"createInternalResourceDialogEnableSsl": "TLS inschakelen",
|
||||
|
||||
@@ -2049,6 +2049,7 @@
|
||||
"editInternalResourceDialogModeCidr": "CIDR",
|
||||
"editInternalResourceDialogModeHttp": "HTTP",
|
||||
"editInternalResourceDialogModeHttps": "HTTPS",
|
||||
"editInternalResourceDialogModeSsh": "SSH",
|
||||
"editInternalResourceDialogScheme": "Schemat",
|
||||
"editInternalResourceDialogEnableSsl": "Włącz TLS",
|
||||
"editInternalResourceDialogEnableSslDescription": "Włącz szyfrowanie SSL/TLS dla bezpiecznych połączeń HTTPS z miejscem docelowym.",
|
||||
@@ -2098,6 +2099,7 @@
|
||||
"createInternalResourceDialogModeCidr": "CIDR",
|
||||
"createInternalResourceDialogModeHttp": "HTTP",
|
||||
"createInternalResourceDialogModeHttps": "HTTPS",
|
||||
"createInternalResourceDialogModeSsh": "SSH",
|
||||
"scheme": "Schemat",
|
||||
"createInternalResourceDialogScheme": "Schemat",
|
||||
"createInternalResourceDialogEnableSsl": "Włącz TLS",
|
||||
|
||||
@@ -2049,6 +2049,7 @@
|
||||
"editInternalResourceDialogModeCidr": "CIDR",
|
||||
"editInternalResourceDialogModeHttp": "HTTP",
|
||||
"editInternalResourceDialogModeHttps": "HTTPS",
|
||||
"editInternalResourceDialogModeSsh": "SSH",
|
||||
"editInternalResourceDialogScheme": "Esquema",
|
||||
"editInternalResourceDialogEnableSsl": "Ativar TLS",
|
||||
"editInternalResourceDialogEnableSslDescription": "Ativar criptografia SSL/TLS para conexões HTTPS seguras com o destino.",
|
||||
@@ -2098,6 +2099,7 @@
|
||||
"createInternalResourceDialogModeCidr": "CIDR",
|
||||
"createInternalResourceDialogModeHttp": "HTTP",
|
||||
"createInternalResourceDialogModeHttps": "HTTPS",
|
||||
"createInternalResourceDialogModeSsh": "SSH",
|
||||
"scheme": "Esquema",
|
||||
"createInternalResourceDialogScheme": "Esquema",
|
||||
"createInternalResourceDialogEnableSsl": "Ativar TLS",
|
||||
|
||||
@@ -2049,6 +2049,7 @@
|
||||
"editInternalResourceDialogModeCidr": "СИДР",
|
||||
"editInternalResourceDialogModeHttp": "HTTP",
|
||||
"editInternalResourceDialogModeHttps": "HTTPS",
|
||||
"editInternalResourceDialogModeSsh": "SSH",
|
||||
"editInternalResourceDialogScheme": "Схема",
|
||||
"editInternalResourceDialogEnableSsl": "Включить TLS",
|
||||
"editInternalResourceDialogEnableSslDescription": "Включите шифрование SSL/TLS для защищенных HTTPS соединений с конечной точкой.",
|
||||
@@ -2098,6 +2099,7 @@
|
||||
"createInternalResourceDialogModeCidr": "СИДР",
|
||||
"createInternalResourceDialogModeHttp": "HTTP",
|
||||
"createInternalResourceDialogModeHttps": "HTTPS",
|
||||
"createInternalResourceDialogModeSsh": "SSH",
|
||||
"scheme": "Схема",
|
||||
"createInternalResourceDialogScheme": "Схема",
|
||||
"createInternalResourceDialogEnableSsl": "Включить TLS",
|
||||
|
||||
@@ -2049,6 +2049,7 @@
|
||||
"editInternalResourceDialogModeCidr": "CIDR",
|
||||
"editInternalResourceDialogModeHttp": "HTTP",
|
||||
"editInternalResourceDialogModeHttps": "HTTPS",
|
||||
"editInternalResourceDialogModeSsh": "SSH",
|
||||
"editInternalResourceDialogScheme": "Şema",
|
||||
"editInternalResourceDialogEnableSsl": "TLS Etkinleştir",
|
||||
"editInternalResourceDialogEnableSslDescription": "Hedefe güvenli HTTPS bağlantıları için SSL/TLS şifrelemeyi etkinleştirin.",
|
||||
@@ -2098,6 +2099,7 @@
|
||||
"createInternalResourceDialogModeCidr": "CIDR",
|
||||
"createInternalResourceDialogModeHttp": "HTTP",
|
||||
"createInternalResourceDialogModeHttps": "HTTPS",
|
||||
"createInternalResourceDialogModeSsh": "SSH",
|
||||
"scheme": "Şema",
|
||||
"createInternalResourceDialogScheme": "Şema",
|
||||
"createInternalResourceDialogEnableSsl": "TLS'yi Etkinleştir",
|
||||
|
||||
@@ -2049,6 +2049,7 @@
|
||||
"editInternalResourceDialogModeCidr": "CIDR",
|
||||
"editInternalResourceDialogModeHttp": "HTTP",
|
||||
"editInternalResourceDialogModeHttps": "HTTPS",
|
||||
"editInternalResourceDialogModeSsh": "SSH",
|
||||
"editInternalResourceDialogScheme": "方案",
|
||||
"editInternalResourceDialogEnableSsl": "启用 TLS",
|
||||
"editInternalResourceDialogEnableSslDescription": "为目标的安全 HTTPS 连接启用 SSL/TLS 加密。",
|
||||
@@ -2098,6 +2099,7 @@
|
||||
"createInternalResourceDialogModeCidr": "CIDR",
|
||||
"createInternalResourceDialogModeHttp": "HTTP",
|
||||
"createInternalResourceDialogModeHttps": "HTTPS",
|
||||
"createInternalResourceDialogModeSsh": "SSH",
|
||||
"scheme": "方案",
|
||||
"createInternalResourceDialogScheme": "方案",
|
||||
"createInternalResourceDialogEnableSsl": "启用 TLS",
|
||||
|
||||
+18
-1
@@ -1,12 +1,29 @@
|
||||
import type { NextConfig } from "next";
|
||||
import createNextIntlPlugin from "next-intl/plugin";
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
|
||||
const withNextIntl = createNextIntlPlugin();
|
||||
// read allowedDevOrigins.json if it exists
|
||||
let allowedDevOrigins: string[] = [];
|
||||
const allowedDevOriginsPath = path.join(
|
||||
process.cwd(),
|
||||
"allowedDevOrigins.json"
|
||||
);
|
||||
if (fs.existsSync(allowedDevOriginsPath)) {
|
||||
try {
|
||||
const data = fs.readFileSync(allowedDevOriginsPath, "utf-8");
|
||||
allowedDevOrigins = JSON.parse(data);
|
||||
console.log("Loaded allowed development origins:", allowedDevOrigins);
|
||||
} catch {}
|
||||
}
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
reactStrictMode: false,
|
||||
reactCompiler: true,
|
||||
output: "standalone"
|
||||
transpilePackages: ["@novnc/novnc"],
|
||||
output: "standalone",
|
||||
allowedDevOrigins
|
||||
};
|
||||
|
||||
export default withNextIntl(nextConfig);
|
||||
|
||||
Generated
+642
-732
File diff suppressed because it is too large
Load Diff
+32
-28
@@ -33,12 +33,14 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@asteasolutions/zod-to-openapi": "8.5.0",
|
||||
"@aws-sdk/client-s3": "3.1047.0",
|
||||
"@faker-js/faker": "10.4.0",
|
||||
"@devolutions/iron-remote-desktop": "https://static.pangolin.net/packages/devolutions-iron-remote-desktop-0.0.0.tgz",
|
||||
"@devolutions/iron-remote-desktop-rdp": "https://static.pangolin.net/packages/devolutions-iron-remote-desktop-rdp-0.0.0.tgz",
|
||||
"@aws-sdk/client-s3": "3.1056.0",
|
||||
"@headlessui/react": "2.2.10",
|
||||
"@hookform/resolvers": "5.2.2",
|
||||
"@hookform/resolvers": "5.4.0",
|
||||
"@monaco-editor/react": "4.7.0",
|
||||
"@node-rs/argon2": "2.0.2",
|
||||
"@novnc/novnc": "^1.7.0",
|
||||
"@oslojs/crypto": "1.0.1",
|
||||
"@oslojs/encoding": "1.1.0",
|
||||
"@radix-ui/react-avatar": "1.1.11",
|
||||
@@ -64,10 +66,13 @@
|
||||
"@react-email/render": "2.0.8",
|
||||
"@react-email/tailwind": "2.0.7",
|
||||
"@simplewebauthn/browser": "13.3.0",
|
||||
"@simplewebauthn/server": "13.3.0",
|
||||
"@simplewebauthn/server": "13.3.1",
|
||||
"@tailwindcss/forms": "0.5.11",
|
||||
"@tanstack/react-query": "5.100.10",
|
||||
"@tanstack/react-query": "5.100.14",
|
||||
"@tanstack/react-table": "8.21.3",
|
||||
"@xterm/addon-fit": "^0.11.0",
|
||||
"@xterm/addon-web-links": "^0.12.0",
|
||||
"@xterm/xterm": "^6.0.0",
|
||||
"arctic": "3.7.0",
|
||||
"axios": "1.16.1",
|
||||
"better-sqlite3": "11.9.1",
|
||||
@@ -83,38 +88,37 @@
|
||||
"express": "5.2.1",
|
||||
"express-rate-limit": "8.5.2",
|
||||
"glob": "13.0.6",
|
||||
"helmet": "8.1.0",
|
||||
"helmet": "8.2.0",
|
||||
"http-errors": "2.0.1",
|
||||
"input-otp": "1.4.2",
|
||||
"ioredis": "5.10.1",
|
||||
"ioredis": "5.11.0",
|
||||
"jmespath": "0.16.0",
|
||||
"js-yaml": "4.1.1",
|
||||
"jsonwebtoken": "9.0.3",
|
||||
"lucide-react": "0.577.0",
|
||||
"lucide-react": "1.17.0",
|
||||
"maxmind": "5.0.6",
|
||||
"moment": "2.30.1",
|
||||
"next": "16.2.6",
|
||||
"next-intl": "4.12.0",
|
||||
"next-intl": "4.13.0",
|
||||
"next-themes": "0.4.6",
|
||||
"nextjs-toploader": "3.9.17",
|
||||
"node-cache": "5.1.2",
|
||||
"nodemailer": "8.0.7",
|
||||
"nodemailer": "8.0.9",
|
||||
"oslo": "1.2.1",
|
||||
"pg": "8.20.0",
|
||||
"posthog-node": "5.34.1",
|
||||
"pg": "8.21.0",
|
||||
"posthog-node": "5.35.6",
|
||||
"qrcode.react": "4.2.0",
|
||||
"react": "19.2.6",
|
||||
"react-day-picker": "9.14.0",
|
||||
"react-dom": "19.2.6",
|
||||
"react-easy-sort": "1.8.0",
|
||||
"react-hook-form": "7.75.0",
|
||||
"react-hook-form": "7.76.1",
|
||||
"react-icons": "5.6.0",
|
||||
"recharts": "3.8.1",
|
||||
"reodotdev": "1.1.0",
|
||||
"resend": "6.12.3",
|
||||
"semver": "7.8.0",
|
||||
"semver": "7.8.1",
|
||||
"sshpk": "1.18.0",
|
||||
"stripe": "20.4.1",
|
||||
"stripe": "22.2.0",
|
||||
"swagger-ui-express": "5.0.1",
|
||||
"tailwind-merge": "3.6.0",
|
||||
"topojson-client": "3.1.0",
|
||||
@@ -125,18 +129,18 @@
|
||||
"visionscarto-world-atlas": "1.0.0",
|
||||
"winston": "3.19.0",
|
||||
"winston-daily-rotate-file": "5.0.0",
|
||||
"ws": "8.20.1",
|
||||
"ws": "8.21.0",
|
||||
"yaml": "2.9.0",
|
||||
"yargs": "18.0.0",
|
||||
"zod": "4.4.3",
|
||||
"zod-validation-error": "5.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@dotenvx/dotenvx": "1.66.0",
|
||||
"@dotenvx/dotenvx": "1.69.1",
|
||||
"@esbuild-plugins/tsconfig-paths": "0.1.2",
|
||||
"@react-email/ui": "^6.1.4",
|
||||
"@react-email/ui": "^6.5.0",
|
||||
"@tailwindcss/postcss": "4.3.0",
|
||||
"@tanstack/react-query-devtools": "5.100.10",
|
||||
"@tanstack/react-query-devtools": "5.100.14",
|
||||
"@types/better-sqlite3": "7.6.13",
|
||||
"@types/cookie-parser": "1.4.10",
|
||||
"@types/cors": "2.8.19",
|
||||
@@ -147,11 +151,11 @@
|
||||
"@types/jmespath": "0.15.2",
|
||||
"@types/js-yaml": "4.0.9",
|
||||
"@types/jsonwebtoken": "9.0.10",
|
||||
"@types/node": "25.8.0",
|
||||
"@types/node": "25.9.1",
|
||||
"@types/nodemailer": "8.0.0",
|
||||
"@types/nprogress": "0.2.3",
|
||||
"@types/pg": "8.20.0",
|
||||
"@types/react": "19.2.14",
|
||||
"@types/react": "19.2.15",
|
||||
"@types/react-dom": "19.2.3",
|
||||
"@types/semver": "7.7.1",
|
||||
"@types/sshpk": "1.17.4",
|
||||
@@ -163,20 +167,20 @@
|
||||
"drizzle-kit": "0.31.10",
|
||||
"esbuild": "0.28.0",
|
||||
"esbuild-node-externals": "1.22.0",
|
||||
"eslint": "10.3.0",
|
||||
"eslint": "10.4.0",
|
||||
"eslint-config-next": "16.2.6",
|
||||
"postcss": "8.5.14",
|
||||
"postcss": "8.5.15",
|
||||
"prettier": "3.8.3",
|
||||
"react-email": "6.1.4",
|
||||
"react-email": "6.5.0",
|
||||
"tailwindcss": "4.3.0",
|
||||
"tsc-alias": "1.8.17",
|
||||
"tsx": "4.22.0",
|
||||
"tsx": "4.22.3",
|
||||
"typescript": "6.0.3",
|
||||
"typescript-eslint": "8.59.3"
|
||||
"typescript-eslint": "8.60.0"
|
||||
},
|
||||
"overrides": {
|
||||
"esbuild": "0.28.0",
|
||||
"dompurify": "3.4.0",
|
||||
"postcss": "8.5.14"
|
||||
"postcss": "8.5.15"
|
||||
}
|
||||
}
|
||||
|
||||
+39
-15
@@ -5,6 +5,7 @@ import { and, eq, inArray } from "drizzle-orm";
|
||||
import createHttpError from "http-errors";
|
||||
import HttpCode from "@server/types/HttpCode";
|
||||
import { getUserOrgRoleIds } from "@server/lib/userOrgRoles";
|
||||
import logger from "@server/logger";
|
||||
|
||||
export enum ActionsEnum {
|
||||
createOrgUser = "createOrgUser",
|
||||
@@ -158,7 +159,26 @@ export enum ActionsEnum {
|
||||
createHealthCheck = "createHealthCheck",
|
||||
updateHealthCheck = "updateHealthCheck",
|
||||
deleteHealthCheck = "deleteHealthCheck",
|
||||
listHealthChecks = "listHealthChecks"
|
||||
listHealthChecks = "listHealthChecks",
|
||||
createBrowserGatewayTarget = "createBrowserGatewayTarget",
|
||||
updateBrowserGatewayTarget = "updateBrowserGatewayTarget",
|
||||
deleteBrowserGatewayTarget = "deleteBrowserGatewayTarget",
|
||||
getBrowserGatewayTarget = "getBrowserGatewayTarget",
|
||||
listBrowserGatewayTargets = "listBrowserGatewayTargets",
|
||||
listResourcePolicies = "listResourcePolicies",
|
||||
getResourcePolicy = "getResourcePolicy",
|
||||
createResourcePolicy = "createResourcePolicy",
|
||||
updateResourcePolicy = "updateResourcePolicy",
|
||||
deleteResourcePolicy = "deleteResourcePolicy",
|
||||
listResourcePolicyRoles = "listResourcePolicyRoles",
|
||||
setResourcePolicyRoles = "setResourcePolicyRoles",
|
||||
listResourcePolicyUsers = "listResourcePolicyUsers",
|
||||
setResourcePolicyUsers = "setResourcePolicyUsers",
|
||||
setResourcePolicyPassword = "setResourcePolicyPassword",
|
||||
setResourcePolicyPincode = "setResourcePolicyPincode",
|
||||
setResourcePolicyHeaderAuth = "setResourcePolicyHeaderAuth",
|
||||
setResourcePolicyWhitelist = "setResourcePolicyWhitelist",
|
||||
setResourcePolicyRules = "setResourcePolicyRules"
|
||||
}
|
||||
|
||||
export async function checkUserActionPermission(
|
||||
@@ -191,6 +211,23 @@ export async function checkUserActionPermission(
|
||||
}
|
||||
}
|
||||
|
||||
// If no direct permission, check role-based permission (any of user's roles)
|
||||
const roleActionPermission = await db
|
||||
.select()
|
||||
.from(roleActions)
|
||||
.where(
|
||||
and(
|
||||
eq(roleActions.actionId, actionId),
|
||||
inArray(roleActions.roleId, userOrgRoleIds),
|
||||
eq(roleActions.orgId, req.userOrgId!)
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
if (roleActionPermission.length > 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check if the user has direct permission for the action in the current org
|
||||
const userActionPermission = await db
|
||||
.select()
|
||||
@@ -208,20 +245,7 @@ export async function checkUserActionPermission(
|
||||
return true;
|
||||
}
|
||||
|
||||
// If no direct permission, check role-based permission (any of user's roles)
|
||||
const roleActionPermission = await db
|
||||
.select()
|
||||
.from(roleActions)
|
||||
.where(
|
||||
and(
|
||||
eq(roleActions.actionId, actionId),
|
||||
inArray(roleActions.roleId, userOrgRoleIds),
|
||||
eq(roleActions.orgId, req.userOrgId!)
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
return roleActionPermission.length > 0;
|
||||
return false;
|
||||
} catch (error) {
|
||||
console.error("Error checking user action permission:", error);
|
||||
throw createHttpError(
|
||||
|
||||
+36
-1
@@ -1,6 +1,12 @@
|
||||
import { join } from "path";
|
||||
import { readFileSync } from "fs";
|
||||
import { clients, db, resources, siteResources } from "@server/db";
|
||||
import {
|
||||
clients,
|
||||
db,
|
||||
resourcePolicies,
|
||||
resources,
|
||||
siteResources
|
||||
} from "@server/db";
|
||||
import { randomInt } from "crypto";
|
||||
import { exitNodes, sites } from "@server/db";
|
||||
import { eq, and } from "drizzle-orm";
|
||||
@@ -107,6 +113,35 @@ export async function getUniqueResourceName(orgId: string): Promise<string> {
|
||||
}
|
||||
}
|
||||
|
||||
export async function getUniqueResourcePolicyName(
|
||||
orgId: string
|
||||
): Promise<string> {
|
||||
let loops = 0;
|
||||
while (true) {
|
||||
if (loops > 100) {
|
||||
throw new Error("Could not generate a unique name");
|
||||
}
|
||||
|
||||
const name = generateName();
|
||||
const policyCount = await db
|
||||
.select({
|
||||
niceId: resourcePolicies.niceId,
|
||||
orgId: resourcePolicies.orgId
|
||||
})
|
||||
.from(resourcePolicies)
|
||||
.where(
|
||||
and(
|
||||
eq(resourcePolicies.niceId, name),
|
||||
eq(resourcePolicies.orgId, orgId)
|
||||
)
|
||||
);
|
||||
if (policyCount.length === 0) {
|
||||
return name;
|
||||
}
|
||||
loops++;
|
||||
}
|
||||
}
|
||||
|
||||
export async function getUniqueSiteResourceName(
|
||||
orgId: string
|
||||
): Promise<string> {
|
||||
|
||||
@@ -580,6 +580,24 @@ export const trialNotifications = pgTable("trialNotifications", {
|
||||
sentAt: bigint("sentAt", { mode: "number" }).notNull()
|
||||
});
|
||||
|
||||
export const browserGatewayTarget = pgTable("browserGatewayTarget", {
|
||||
browserGatewayTargetId: serial("browserGatewayTargetId").primaryKey(),
|
||||
resourceId: integer("resourceId")
|
||||
.references(() => resources.resourceId, {
|
||||
onDelete: "cascade"
|
||||
})
|
||||
.notNull(),
|
||||
siteId: integer("siteId")
|
||||
.references(() => sites.siteId, {
|
||||
onDelete: "cascade"
|
||||
})
|
||||
.notNull(),
|
||||
authToken: varchar("authToken").notNull(),
|
||||
type: varchar("type").notNull(), // "ssh", "rdp", "vnc"
|
||||
destination: varchar("destination").notNull(),
|
||||
destinationPort: integer("destinationPort").notNull()
|
||||
});
|
||||
|
||||
export type Approval = InferSelectModel<typeof approvals>;
|
||||
export type Limit = InferSelectModel<typeof limits>;
|
||||
export type Account = InferSelectModel<typeof account>;
|
||||
@@ -627,3 +645,6 @@ export type AlertEmailRecipients = InferSelectModel<
|
||||
>;
|
||||
export type AlertWebhookActions = InferSelectModel<typeof alertWebhookActions>;
|
||||
export type TrialNotification = InferSelectModel<typeof trialNotifications>;
|
||||
export type BrowserGatewayTarget = InferSelectModel<
|
||||
typeof browserGatewayTarget
|
||||
>;
|
||||
|
||||
@@ -65,7 +65,12 @@ export const orgs = pgTable("orgs", {
|
||||
sshCaPrivateKey: text("sshCaPrivateKey"), // Encrypted SSH CA private key (PEM format)
|
||||
sshCaPublicKey: text("sshCaPublicKey"), // SSH CA public key (OpenSSH format)
|
||||
isBillingOrg: boolean("isBillingOrg"),
|
||||
billingOrgId: varchar("billingOrgId")
|
||||
billingOrgId: varchar("billingOrgId"),
|
||||
settingsEnableGlobalNewtAutoUpdate: boolean(
|
||||
"settingsEnableGlobalNewtAutoUpdate"
|
||||
)
|
||||
.notNull()
|
||||
.default(false)
|
||||
});
|
||||
|
||||
export const orgDomains = pgTable("orgDomains", {
|
||||
@@ -103,6 +108,10 @@ export const sites = pgTable("sites", {
|
||||
lastHolePunch: bigint("lastHolePunch", { mode: "number" }),
|
||||
listenPort: integer("listenPort"),
|
||||
dockerSocketEnabled: boolean("dockerSocketEnabled").notNull().default(true),
|
||||
autoUpdateEnabled: boolean("autoUpdateEnabled").notNull().default(false),
|
||||
autoUpdateOverrideOrg: boolean("autoUpdateOverrideOrg")
|
||||
.notNull()
|
||||
.default(false),
|
||||
status: varchar("status")
|
||||
.$type<"pending" | "approved">()
|
||||
.default("approved")
|
||||
@@ -110,6 +119,16 @@ export const sites = pgTable("sites", {
|
||||
|
||||
export const resources = pgTable("resources", {
|
||||
resourceId: serial("resourceId").primaryKey(),
|
||||
resourcePolicyId: integer("resourcePolicyId").references(
|
||||
() => resourcePolicies.resourcePolicyId,
|
||||
{ onDelete: "set null" }
|
||||
),
|
||||
defaultResourcePolicyId: integer("defaultResourcePolicyId").references(
|
||||
() => resourcePolicies.resourcePolicyId,
|
||||
{
|
||||
onDelete: "restrict"
|
||||
}
|
||||
),
|
||||
resourceGuid: varchar("resourceGuid", { length: 36 })
|
||||
.unique()
|
||||
.notNull()
|
||||
@@ -129,8 +148,6 @@ export const resources = pgTable("resources", {
|
||||
ssl: boolean("ssl").notNull().default(false),
|
||||
blockAccess: boolean("blockAccess").notNull().default(false),
|
||||
sso: boolean("sso").notNull().default(true),
|
||||
http: boolean("http").notNull().default(true),
|
||||
protocol: varchar("protocol").notNull(),
|
||||
proxyPort: integer("proxyPort"),
|
||||
emailWhitelistEnabled: boolean("emailWhitelistEnabled")
|
||||
.notNull()
|
||||
@@ -147,7 +164,6 @@ export const resources = pgTable("resources", {
|
||||
headers: text("headers"), // comma-separated list of headers to add to the request
|
||||
proxyProtocol: boolean("proxyProtocol").notNull().default(false),
|
||||
proxyProtocolVersion: integer("proxyProtocolVersion").default(1),
|
||||
|
||||
maintenanceModeEnabled: boolean("maintenanceModeEnabled")
|
||||
.notNull()
|
||||
.default(false),
|
||||
@@ -159,7 +175,15 @@ export const resources = pgTable("resources", {
|
||||
maintenanceEstimatedTime: text("maintenanceEstimatedTime"),
|
||||
postAuthPath: text("postAuthPath"),
|
||||
health: varchar("health").default("unknown"), // "healthy", "unhealthy", "unknown"
|
||||
wildcard: boolean("wildcard").notNull().default(false)
|
||||
wildcard: boolean("wildcard").notNull().default(false),
|
||||
mode: text("mode").default("http").notNull(), // rdp, ssh, http, vnc
|
||||
pamMode: varchar("pamMode", { length: 32 })
|
||||
.$type<"passthrough" | "push">()
|
||||
.default("passthrough"),
|
||||
authDaemonMode: varchar("authDaemonMode", { length: 32 })
|
||||
.$type<"site" | "remote" | "native">()
|
||||
.default("site"),
|
||||
authDaemonPort: integer("authDaemonPort").default(22123)
|
||||
});
|
||||
|
||||
export const labels = pgTable("labels", {
|
||||
@@ -339,11 +363,11 @@ export const siteResources = pgTable("siteResources", {
|
||||
niceId: varchar("niceId").notNull(),
|
||||
name: varchar("name").notNull(),
|
||||
ssl: boolean("ssl").notNull().default(false),
|
||||
mode: varchar("mode").$type<"host" | "cidr" | "http">().notNull(), // "host" | "cidr" | "http"
|
||||
mode: varchar("mode").$type<"host" | "cidr" | "http" | "ssh">().notNull(), // "host" | "cidr" | "http"
|
||||
scheme: varchar("scheme").$type<"http" | "https">(), // only for when we are doing https or http mode
|
||||
proxyPort: integer("proxyPort"), // only for port mode
|
||||
destinationPort: integer("destinationPort"), // only for port mode
|
||||
destination: varchar("destination").notNull(), // ip, cidr, hostname; validate against the mode
|
||||
destination: varchar("destination"), // ip, cidr, hostname; validate against the mode
|
||||
enabled: boolean("enabled").notNull().default(true),
|
||||
alias: varchar("alias"),
|
||||
aliasAddress: varchar("aliasAddress"),
|
||||
@@ -351,8 +375,11 @@ export const siteResources = pgTable("siteResources", {
|
||||
udpPortRangeString: varchar("udpPortRangeString").notNull().default("*"),
|
||||
disableIcmp: boolean("disableIcmp").notNull().default(false),
|
||||
authDaemonPort: integer("authDaemonPort").default(22123),
|
||||
pamMode: varchar("pamMode", { length: 32 })
|
||||
.$type<"passthrough" | "push">()
|
||||
.default("passthrough"),
|
||||
authDaemonMode: varchar("authDaemonMode", { length: 32 })
|
||||
.$type<"site" | "remote">()
|
||||
.$type<"site" | "remote" | "native">()
|
||||
.default("site"),
|
||||
domainId: varchar("domainId").references(() => domains.domainId, {
|
||||
onDelete: "set null"
|
||||
@@ -606,6 +633,38 @@ export const userResources = pgTable("userResources", {
|
||||
.references(() => resources.resourceId, { onDelete: "cascade" })
|
||||
});
|
||||
|
||||
export const rolePolicies = pgTable("rolePolicies", {
|
||||
roleId: integer("roleId")
|
||||
.notNull()
|
||||
.references(() => roles.roleId, { onDelete: "cascade" }),
|
||||
resourcePolicyId: integer("resourcePolicyId")
|
||||
.notNull()
|
||||
.references(() => resourcePolicies.resourcePolicyId, {
|
||||
onDelete: "cascade"
|
||||
})
|
||||
});
|
||||
|
||||
export const userPolicies = pgTable("userPolicies", {
|
||||
userId: varchar("userId")
|
||||
.notNull()
|
||||
.references(() => users.userId, { onDelete: "cascade" }),
|
||||
resourcePolicyId: integer("resourcePolicyId")
|
||||
.notNull()
|
||||
.references(() => resourcePolicies.resourcePolicyId, {
|
||||
onDelete: "cascade"
|
||||
})
|
||||
});
|
||||
|
||||
export const resourcePolicyWhiteList = pgTable("resourcePolicyWhitelist", {
|
||||
whitelistId: serial("id").primaryKey(),
|
||||
email: varchar("email").notNull(),
|
||||
resourcePolicyId: integer("resourcePolicyId")
|
||||
.notNull()
|
||||
.references(() => resourcePolicies.resourcePolicyId, {
|
||||
onDelete: "cascade"
|
||||
})
|
||||
});
|
||||
|
||||
export const userInvites = pgTable("userInvites", {
|
||||
inviteId: varchar("inviteId").primaryKey(),
|
||||
orgId: varchar("orgId")
|
||||
@@ -671,6 +730,40 @@ export const resourceHeaderAuthExtendedCompatibility = pgTable(
|
||||
}
|
||||
);
|
||||
|
||||
export const resourcePolicyPincode = pgTable("resourcePolicyPincode", {
|
||||
pincodeId: serial("pincodeId").primaryKey(),
|
||||
pincodeHash: varchar("pincodeHash").notNull(),
|
||||
digitLength: integer("digitLength").notNull(),
|
||||
resourcePolicyId: integer("resourcePolicyId")
|
||||
.notNull()
|
||||
.references(() => resourcePolicies.resourcePolicyId, {
|
||||
onDelete: "cascade"
|
||||
})
|
||||
});
|
||||
|
||||
export const resourcePolicyPassword = pgTable("resourcePolicyPassword", {
|
||||
passwordId: serial("passwordId").primaryKey(),
|
||||
passwordHash: varchar("passwordHash").notNull(),
|
||||
resourcePolicyId: integer("resourcePolicyId")
|
||||
.notNull()
|
||||
.references(() => resourcePolicies.resourcePolicyId, {
|
||||
onDelete: "cascade"
|
||||
})
|
||||
});
|
||||
|
||||
export const resourcePolicyHeaderAuth = pgTable("resourcePolicyHeaderAuth", {
|
||||
headerAuthId: serial("headerAuthId").primaryKey(),
|
||||
headerAuthHash: varchar("headerAuthHash").notNull(),
|
||||
extendedCompatibility: boolean("extendedCompatibility")
|
||||
.notNull()
|
||||
.default(true),
|
||||
resourcePolicyId: integer("resourcePolicyId")
|
||||
.notNull()
|
||||
.references(() => resourcePolicies.resourcePolicyId, {
|
||||
onDelete: "cascade"
|
||||
})
|
||||
});
|
||||
|
||||
export const resourceAccessToken = pgTable("resourceAccessToken", {
|
||||
accessTokenId: varchar("accessTokenId").primaryKey(),
|
||||
orgId: varchar("orgId")
|
||||
@@ -679,6 +772,7 @@ export const resourceAccessToken = pgTable("resourceAccessToken", {
|
||||
resourceId: integer("resourceId")
|
||||
.notNull()
|
||||
.references(() => resources.resourceId, { onDelete: "cascade" }),
|
||||
path: varchar("path"),
|
||||
tokenHash: varchar("tokenHash").notNull(),
|
||||
sessionLength: bigint("sessionLength", { mode: "number" }).notNull(),
|
||||
expiresAt: bigint("expiresAt", { mode: "number" }),
|
||||
@@ -764,6 +858,43 @@ export const resourceRules = pgTable("resourceRules", {
|
||||
value: varchar("value").notNull()
|
||||
});
|
||||
|
||||
export const resourcePolicyRules = pgTable("resourcePolicyRules", {
|
||||
ruleId: serial("ruleId").primaryKey(),
|
||||
resourcePolicyId: integer("resourcePolicyId")
|
||||
.notNull()
|
||||
.references(() => resourcePolicies.resourcePolicyId, {
|
||||
onDelete: "cascade"
|
||||
}),
|
||||
enabled: boolean("enabled").notNull().default(true),
|
||||
priority: integer("priority").notNull(),
|
||||
action: varchar("action").$type<"ACCEPT" | "DROP" | "PASS">().notNull(),
|
||||
match: varchar("match").$type<"CIDR" | "PATH" | "IP">().notNull(),
|
||||
value: varchar("value").notNull()
|
||||
});
|
||||
|
||||
export const resourcePolicies = pgTable("resourcePolicies", {
|
||||
resourcePolicyId: serial("resourcePolicyId").primaryKey(),
|
||||
sso: boolean("sso").notNull().default(true),
|
||||
applyRules: boolean("applyRules").notNull().default(false),
|
||||
scope: varchar("scope")
|
||||
.$type<"global" | "resource">()
|
||||
.notNull()
|
||||
.default("global"),
|
||||
emailWhitelistEnabled: boolean("emailWhitelistEnabled")
|
||||
.notNull()
|
||||
.default(false),
|
||||
idpId: integer("idpId").references(() => idp.idpId, {
|
||||
onDelete: "set null"
|
||||
}),
|
||||
niceId: text("niceId").notNull(),
|
||||
name: varchar("name").notNull(),
|
||||
orgId: varchar("orgId")
|
||||
.references(() => orgs.orgId, {
|
||||
onDelete: "cascade"
|
||||
})
|
||||
.notNull()
|
||||
});
|
||||
|
||||
export const supporterKey = pgTable("supporterKey", {
|
||||
keyId: serial("keyId").primaryKey(),
|
||||
key: varchar("key").notNull(),
|
||||
@@ -1276,3 +1407,6 @@ export type RoundTripMessageTracker = InferSelectModel<
|
||||
export type Network = InferSelectModel<typeof networks>;
|
||||
export type StatusHistory = InferSelectModel<typeof statusHistory>;
|
||||
export type Label = InferSelectModel<typeof labels>;
|
||||
export type ResourcePolicy = InferSelectModel<typeof resourcePolicies>;
|
||||
export type RolePolicy = InferSelectModel<typeof rolePolicies>;
|
||||
export type UserPolicy = InferSelectModel<typeof userPolicies>;
|
||||
|
||||
@@ -17,10 +17,13 @@ import {
|
||||
resourceHeaderAuth,
|
||||
ResourceHeaderAuth,
|
||||
resourceRules,
|
||||
resourcePolicyRules,
|
||||
resources,
|
||||
roleResources,
|
||||
rolePolicies,
|
||||
sessions,
|
||||
userResources,
|
||||
userPolicies,
|
||||
users,
|
||||
ResourceHeaderAuthExtendedCompatibility,
|
||||
resourceHeaderAuthExtendedCompatibility
|
||||
@@ -154,58 +157,126 @@ export async function getRoleName(roleId: number): Promise<string | null> {
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if role has access to resource
|
||||
* Check if role has access to resource (direct or via resource policy)
|
||||
*/
|
||||
export async function getRoleResourceAccess(
|
||||
resourceId: number,
|
||||
roleIds: number[]
|
||||
) {
|
||||
const roleResourceAccess = await db
|
||||
.select()
|
||||
.from(roleResources)
|
||||
.where(
|
||||
and(
|
||||
eq(roleResources.resourceId, resourceId),
|
||||
inArray(roleResources.roleId, roleIds)
|
||||
const [direct, viaPolicies] = await Promise.all([
|
||||
db
|
||||
.select()
|
||||
.from(roleResources)
|
||||
.where(
|
||||
and(
|
||||
eq(roleResources.resourceId, resourceId),
|
||||
inArray(roleResources.roleId, roleIds)
|
||||
)
|
||||
),
|
||||
db
|
||||
.select({
|
||||
roleId: rolePolicies.roleId,
|
||||
resourcePolicyId: rolePolicies.resourcePolicyId
|
||||
})
|
||||
.from(rolePolicies)
|
||||
.innerJoin(
|
||||
resources,
|
||||
eq(resources.resourcePolicyId, rolePolicies.resourcePolicyId)
|
||||
)
|
||||
);
|
||||
.where(
|
||||
and(
|
||||
eq(resources.resourceId, resourceId),
|
||||
inArray(rolePolicies.roleId, roleIds)
|
||||
)
|
||||
)
|
||||
]);
|
||||
|
||||
return roleResourceAccess.length > 0 ? roleResourceAccess : null;
|
||||
const combined = [...direct, ...viaPolicies];
|
||||
return combined.length > 0 ? combined : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if user has direct access to resource
|
||||
* Check if user has access to resource (direct or via resource policy)
|
||||
*/
|
||||
export async function getUserResourceAccess(
|
||||
userId: string,
|
||||
resourceId: number
|
||||
) {
|
||||
const userResourceAccess = await db
|
||||
.select()
|
||||
.from(userResources)
|
||||
.where(
|
||||
and(
|
||||
eq(userResources.userId, userId),
|
||||
eq(userResources.resourceId, resourceId)
|
||||
const [direct, viaPolicies] = await Promise.all([
|
||||
db
|
||||
.select()
|
||||
.from(userResources)
|
||||
.where(
|
||||
and(
|
||||
eq(userResources.userId, userId),
|
||||
eq(userResources.resourceId, resourceId)
|
||||
)
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
.limit(1),
|
||||
db
|
||||
.select({
|
||||
userId: userPolicies.userId,
|
||||
resourcePolicyId: userPolicies.resourcePolicyId
|
||||
})
|
||||
.from(userPolicies)
|
||||
.innerJoin(
|
||||
resources,
|
||||
eq(resources.resourcePolicyId, userPolicies.resourcePolicyId)
|
||||
)
|
||||
.where(
|
||||
and(
|
||||
eq(resources.resourceId, resourceId),
|
||||
eq(userPolicies.userId, userId)
|
||||
)
|
||||
)
|
||||
.limit(1)
|
||||
]);
|
||||
|
||||
return userResourceAccess.length > 0 ? userResourceAccess[0] : null;
|
||||
return direct[0] ?? viaPolicies[0] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get resource rules for a given resource
|
||||
* Get resource rules for a given resource (direct and via resource policy)
|
||||
*/
|
||||
export async function getResourceRules(
|
||||
resourceId: number
|
||||
): Promise<ResourceRule[]> {
|
||||
const rules = await db
|
||||
.select()
|
||||
.from(resourceRules)
|
||||
.where(eq(resourceRules.resourceId, resourceId));
|
||||
const [directRules, policyRules] = await Promise.all([
|
||||
db
|
||||
.select()
|
||||
.from(resourceRules)
|
||||
.where(eq(resourceRules.resourceId, resourceId)),
|
||||
db
|
||||
.select({
|
||||
ruleId: resourcePolicyRules.ruleId,
|
||||
resourceId: sql<number>`${resourceId}`,
|
||||
enabled: resourcePolicyRules.enabled,
|
||||
priority: resourcePolicyRules.priority,
|
||||
action: resourcePolicyRules.action,
|
||||
match: resourcePolicyRules.match,
|
||||
value: resourcePolicyRules.value
|
||||
})
|
||||
.from(resourcePolicyRules)
|
||||
.innerJoin(
|
||||
resources,
|
||||
eq(
|
||||
resources.resourcePolicyId,
|
||||
resourcePolicyRules.resourcePolicyId
|
||||
)
|
||||
)
|
||||
.where(eq(resources.resourceId, resourceId))
|
||||
]);
|
||||
|
||||
return rules;
|
||||
const maxDirectPriority = directRules.reduce(
|
||||
(max, r) => Math.max(max, r.priority),
|
||||
0
|
||||
);
|
||||
const offsetPolicyRules = policyRules.map((r) => ({
|
||||
...r,
|
||||
priority: maxDirectPriority + r.priority
|
||||
}));
|
||||
|
||||
return [...directRules, ...offsetPolicyRules] as ResourceRule[];
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -588,6 +588,26 @@ export const trialNotifications = sqliteTable("trialNotifications", {
|
||||
sentAt: integer("sentAt").notNull()
|
||||
});
|
||||
|
||||
export const browserGatewayTarget = sqliteTable("browserGatewayTarget", {
|
||||
browserGatewayTargetId: integer("browserGatewayTargetId").primaryKey({
|
||||
autoIncrement: true
|
||||
}),
|
||||
resourceId: integer("resourceId")
|
||||
.references(() => resources.resourceId, {
|
||||
onDelete: "cascade"
|
||||
})
|
||||
.notNull(),
|
||||
siteId: integer("siteId")
|
||||
.references(() => sites.siteId, {
|
||||
onDelete: "cascade"
|
||||
})
|
||||
.notNull(),
|
||||
authToken: text("authToken").notNull(),
|
||||
type: text("type").notNull(), // "ssh", "rdp", "vnc"
|
||||
destination: text("destination").notNull(),
|
||||
destinationPort: integer("destinationPort").notNull()
|
||||
});
|
||||
|
||||
export type Approval = InferSelectModel<typeof approvals>;
|
||||
export type Limit = InferSelectModel<typeof limits>;
|
||||
export type Account = InferSelectModel<typeof account>;
|
||||
@@ -627,3 +647,6 @@ export type AlertEmailAction = InferSelectModel<typeof alertEmailActions>;
|
||||
export type AlertEmailRecipient = InferSelectModel<typeof alertEmailRecipients>;
|
||||
export type AlertWebhookAction = InferSelectModel<typeof alertWebhookActions>;
|
||||
export type TrialNotification = InferSelectModel<typeof trialNotifications>;
|
||||
export type BrowserGatewayTarget = InferSelectModel<
|
||||
typeof browserGatewayTarget
|
||||
>;
|
||||
|
||||
@@ -62,7 +62,13 @@ export const orgs = sqliteTable("orgs", {
|
||||
sshCaPrivateKey: text("sshCaPrivateKey"), // Encrypted SSH CA private key (PEM format)
|
||||
sshCaPublicKey: text("sshCaPublicKey"), // SSH CA public key (OpenSSH format)
|
||||
isBillingOrg: integer("isBillingOrg", { mode: "boolean" }),
|
||||
billingOrgId: text("billingOrgId")
|
||||
billingOrgId: text("billingOrgId"),
|
||||
settingsEnableGlobalNewtAutoUpdate: integer(
|
||||
"settingsEnableGlobalNewtAutoUpdate",
|
||||
{ mode: "boolean" }
|
||||
)
|
||||
.notNull()
|
||||
.default(false)
|
||||
});
|
||||
|
||||
export const userDomains = sqliteTable("userDomains", {
|
||||
@@ -116,11 +122,29 @@ export const sites = sqliteTable("sites", {
|
||||
dockerSocketEnabled: integer("dockerSocketEnabled", { mode: "boolean" })
|
||||
.notNull()
|
||||
.default(true),
|
||||
autoUpdateEnabled: integer("autoUpdateEnabled", { mode: "boolean" })
|
||||
.notNull()
|
||||
.default(false),
|
||||
autoUpdateOverrideOrg: integer("autoUpdateOverrideOrg", {
|
||||
mode: "boolean"
|
||||
})
|
||||
.notNull()
|
||||
.default(false),
|
||||
status: text("status").$type<"pending" | "approved">().default("approved")
|
||||
});
|
||||
|
||||
export const resources = sqliteTable("resources", {
|
||||
resourceId: integer("resourceId").primaryKey({ autoIncrement: true }),
|
||||
resourcePolicyId: integer("resourcePolicyId").references(
|
||||
() => resourcePolicies.resourcePolicyId,
|
||||
{ onDelete: "set null" }
|
||||
),
|
||||
defaultResourcePolicyId: integer("defaultResourcePolicyId").references(
|
||||
() => resourcePolicies.resourcePolicyId,
|
||||
{
|
||||
onDelete: "restrict"
|
||||
}
|
||||
),
|
||||
resourceGuid: text("resourceGuid", { length: 36 })
|
||||
.unique()
|
||||
.notNull()
|
||||
@@ -142,8 +166,6 @@ export const resources = sqliteTable("resources", {
|
||||
.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()
|
||||
@@ -166,7 +188,6 @@ export const resources = sqliteTable("resources", {
|
||||
.notNull()
|
||||
.default(false),
|
||||
proxyProtocolVersion: integer("proxyProtocolVersion").default(1),
|
||||
|
||||
maintenanceModeEnabled: integer("maintenanceModeEnabled", {
|
||||
mode: "boolean"
|
||||
})
|
||||
@@ -180,7 +201,15 @@ export const resources = sqliteTable("resources", {
|
||||
maintenanceEstimatedTime: text("maintenanceEstimatedTime"),
|
||||
postAuthPath: text("postAuthPath"),
|
||||
health: text("health").default("unknown"), // "healthy", "unhealthy", "unknown"
|
||||
wildcard: integer("wildcard", { mode: "boolean" }).notNull().default(false)
|
||||
wildcard: integer("wildcard", { mode: "boolean" }).notNull().default(false),
|
||||
mode: text("mode").default("http").notNull(), // rdp, ssh, http, vnc
|
||||
pamMode: text("pamMode")
|
||||
.$type<"passthrough" | "push">()
|
||||
.default("passthrough"),
|
||||
authDaemonMode: text("authDaemonMode")
|
||||
.$type<"site" | "remote" | "native">()
|
||||
.default("site"),
|
||||
authDaemonPort: integer("authDaemonPort").default(22123)
|
||||
});
|
||||
|
||||
export const labels = sqliteTable("labels", {
|
||||
@@ -372,11 +401,11 @@ export const siteResources = sqliteTable("siteResources", {
|
||||
niceId: text("niceId").notNull(),
|
||||
name: text("name").notNull(),
|
||||
ssl: integer("ssl", { mode: "boolean" }).notNull().default(false),
|
||||
mode: text("mode").$type<"host" | "cidr" | "http">().notNull(), // "host" | "cidr" | "http"
|
||||
mode: text("mode").$type<"host" | "cidr" | "http" | "ssh">().notNull(), // "host" | "cidr" | "http"
|
||||
scheme: text("scheme").$type<"http" | "https">(), // only for when we are doing https or http mode
|
||||
proxyPort: integer("proxyPort"), // only for port mode
|
||||
destinationPort: integer("destinationPort"), // only for port mode
|
||||
destination: text("destination").notNull(), // ip, cidr, hostname
|
||||
destination: text("destination"), // ip, cidr, hostname
|
||||
enabled: integer("enabled", { mode: "boolean" }).notNull().default(true),
|
||||
alias: text("alias"),
|
||||
aliasAddress: text("aliasAddress"),
|
||||
@@ -386,8 +415,11 @@ export const siteResources = sqliteTable("siteResources", {
|
||||
.notNull()
|
||||
.default(false),
|
||||
authDaemonPort: integer("authDaemonPort").default(22123),
|
||||
pamMode: text("pamMode")
|
||||
.$type<"passthrough" | "push">()
|
||||
.default("passthrough"),
|
||||
authDaemonMode: text("authDaemonMode")
|
||||
.$type<"site" | "remote">()
|
||||
.$type<"site" | "remote" | "native">()
|
||||
.default("site"),
|
||||
domainId: text("domainId").references(() => domains.domainId, {
|
||||
onDelete: "set null"
|
||||
@@ -1000,6 +1032,47 @@ export const resourceHeaderAuth = sqliteTable("resourceHeaderAuth", {
|
||||
headerAuthHash: text("headerAuthHash").notNull()
|
||||
});
|
||||
|
||||
export const resourcePolicyPincode = sqliteTable("resourcePolicyPincode", {
|
||||
pincodeId: integer("pincodeId").primaryKey({ autoIncrement: true }),
|
||||
pincodeHash: text("pincodeHash").notNull(),
|
||||
digitLength: integer("digitLength").notNull(),
|
||||
resourcePolicyId: integer("resourcePolicyId")
|
||||
.notNull()
|
||||
.references(() => resourcePolicies.resourcePolicyId, {
|
||||
onDelete: "cascade"
|
||||
})
|
||||
});
|
||||
|
||||
export const resourcePolicyPassword = sqliteTable("resourcePolicyPassword", {
|
||||
passwordId: integer("passwordId").primaryKey({ autoIncrement: true }),
|
||||
passwordHash: text("passwordHash").notNull(),
|
||||
resourcePolicyId: integer("resourcePolicyId")
|
||||
.notNull()
|
||||
.references(() => resourcePolicies.resourcePolicyId, {
|
||||
onDelete: "cascade"
|
||||
})
|
||||
});
|
||||
|
||||
export const resourcePolicyHeaderAuth = sqliteTable(
|
||||
"resourcePolicyHeaderAuth",
|
||||
{
|
||||
headerAuthId: integer("headerAuthId").primaryKey({
|
||||
autoIncrement: true
|
||||
}),
|
||||
headerAuthHash: text("headerAuthHash").notNull(),
|
||||
extendedCompatibility: integer("extendedCompatibility", {
|
||||
mode: "boolean"
|
||||
})
|
||||
.notNull()
|
||||
.default(true),
|
||||
resourcePolicyId: integer("resourcePolicyId")
|
||||
.notNull()
|
||||
.references(() => resourcePolicies.resourcePolicyId, {
|
||||
onDelete: "cascade"
|
||||
})
|
||||
}
|
||||
);
|
||||
|
||||
export const resourceHeaderAuthExtendedCompatibility = sqliteTable(
|
||||
"resourceHeaderAuthExtendedCompatibility",
|
||||
{
|
||||
@@ -1028,6 +1101,7 @@ export const resourceAccessToken = sqliteTable("resourceAccessToken", {
|
||||
resourceId: integer("resourceId")
|
||||
.notNull()
|
||||
.references(() => resources.resourceId, { onDelete: "cascade" }),
|
||||
path: text("path"),
|
||||
tokenHash: text("tokenHash").notNull(),
|
||||
sessionLength: integer("sessionLength").notNull(),
|
||||
expiresAt: integer("expiresAt"),
|
||||
@@ -1114,6 +1188,77 @@ export const resourceRules = sqliteTable("resourceRules", {
|
||||
value: text("value").notNull()
|
||||
});
|
||||
|
||||
export const rolePolicies = sqliteTable("rolePolicies", {
|
||||
roleId: integer("roleId")
|
||||
.notNull()
|
||||
.references(() => roles.roleId, { onDelete: "cascade" }),
|
||||
resourcePolicyId: integer("resourcePolicyId")
|
||||
.notNull()
|
||||
.references(() => resourcePolicies.resourcePolicyId, {
|
||||
onDelete: "cascade"
|
||||
})
|
||||
});
|
||||
|
||||
export const userPolicies = sqliteTable("userPolicies", {
|
||||
userId: text("userId")
|
||||
.notNull()
|
||||
.references(() => users.userId, { onDelete: "cascade" }),
|
||||
resourcePolicyId: integer("resourcePolicyId")
|
||||
.notNull()
|
||||
.references(() => resourcePolicies.resourcePolicyId, {
|
||||
onDelete: "cascade"
|
||||
})
|
||||
});
|
||||
|
||||
export const resourcePolicyWhiteList = sqliteTable("resourcePolicyWhitelist", {
|
||||
whitelistId: integer("id").primaryKey({ autoIncrement: true }),
|
||||
email: text("email").notNull(),
|
||||
resourcePolicyId: integer("resourcePolicyId")
|
||||
.notNull()
|
||||
.references(() => resourcePolicies.resourcePolicyId, {
|
||||
onDelete: "cascade"
|
||||
})
|
||||
});
|
||||
|
||||
export const resourcePolicyRules = sqliteTable("resourcePolicyRules", {
|
||||
ruleId: integer("ruleId").primaryKey({ autoIncrement: true }),
|
||||
resourcePolicyId: integer("resourcePolicyId")
|
||||
.notNull()
|
||||
.references(() => resourcePolicies.resourcePolicyId, {
|
||||
onDelete: "cascade"
|
||||
}),
|
||||
enabled: integer("enabled", { mode: "boolean" }).notNull().default(true),
|
||||
priority: integer("priority").notNull(),
|
||||
action: text("action").$type<"ACCEPT" | "DROP" | "PASS">().notNull(),
|
||||
match: text("match").$type<"CIDR" | "PATH" | "IP">().notNull(),
|
||||
value: text("value").notNull()
|
||||
});
|
||||
|
||||
export const resourcePolicies = sqliteTable("resourcePolicies", {
|
||||
resourcePolicyId: integer("resourcePolicyId").primaryKey(),
|
||||
sso: integer("sso", { mode: "boolean" }).notNull().default(true),
|
||||
applyRules: integer("applyRules", { mode: "boolean" })
|
||||
.notNull()
|
||||
.default(false),
|
||||
scope: text("scope")
|
||||
.$type<"global" | "resource">()
|
||||
.notNull()
|
||||
.default("global"),
|
||||
emailWhitelistEnabled: integer("emailWhitelistEnabled", { mode: "boolean" })
|
||||
.notNull()
|
||||
.default(false),
|
||||
niceId: text("niceId").notNull(),
|
||||
idpId: integer("idpId").references(() => idp.idpId, {
|
||||
onDelete: "set null"
|
||||
}),
|
||||
name: text("name").notNull(),
|
||||
orgId: text("orgId")
|
||||
.references(() => orgs.orgId, {
|
||||
onDelete: "cascade"
|
||||
})
|
||||
.notNull()
|
||||
});
|
||||
|
||||
export const supporterKey = sqliteTable("supporterKey", {
|
||||
keyId: integer("keyId").primaryKey({ autoIncrement: true }),
|
||||
key: text("key").notNull(),
|
||||
@@ -1381,3 +1526,6 @@ export type RoundTripMessageTracker = InferSelectModel<
|
||||
>;
|
||||
export type StatusHistory = InferSelectModel<typeof statusHistory>;
|
||||
export type Label = InferSelectModel<typeof labels>;
|
||||
export type ResourcePolicy = InferSelectModel<typeof resourcePolicies>;
|
||||
export type RolePolicy = InferSelectModel<typeof rolePolicies>;
|
||||
export type UserPolicy = InferSelectModel<typeof userPolicies>;
|
||||
|
||||
@@ -221,10 +221,18 @@ async function handleResource(
|
||||
)
|
||||
.where(eq(targets.resourceId, resource.resourceId));
|
||||
|
||||
const monitoredTargets = otherTargets.filter(
|
||||
(t) => t.hcHealth !== "unknown"
|
||||
);
|
||||
|
||||
let health = "healthy";
|
||||
const allUnknown = otherTargets.every((t) => t.hcHealth === "unknown");
|
||||
const allHealthy = otherTargets.every((t) => t.hcHealth === "healthy");
|
||||
const allUnhealthy = otherTargets.every((t) => t.hcHealth === "unhealthy");
|
||||
const allUnknown = monitoredTargets.length === 0;
|
||||
const allHealthy = monitoredTargets.every(
|
||||
(t) => t.hcHealth === "healthy"
|
||||
);
|
||||
const allUnhealthy = monitoredTargets.every(
|
||||
(t) => t.hcHealth === "unhealthy"
|
||||
);
|
||||
|
||||
if (allUnknown) {
|
||||
logger.debug(
|
||||
|
||||
@@ -25,7 +25,9 @@ export enum TierFeature {
|
||||
StandaloneHealthChecks = "standaloneHealthChecks",
|
||||
AlertingRules = "alertingRules",
|
||||
WildcardSubdomain = "wildcardSubdomain",
|
||||
Labels = "labels"
|
||||
Labels = "labels",
|
||||
NewtAutoUpdate = "newtAutoUpdate",
|
||||
ResourcePolicies = "resourcePolicies"
|
||||
}
|
||||
|
||||
export const tierMatrix: Record<TierFeature, Tier[]> = {
|
||||
@@ -68,5 +70,7 @@ export const tierMatrix: Record<TierFeature, Tier[]> = {
|
||||
[TierFeature.DomainNamespaces]: ["tier1", "tier2", "tier3", "enterprise"],
|
||||
[TierFeature.StandaloneHealthChecks]: ["tier3", "enterprise"],
|
||||
[TierFeature.AlertingRules]: ["tier3", "enterprise"],
|
||||
[TierFeature.WildcardSubdomain]: ["tier1", "tier2", "tier3", "enterprise"]
|
||||
[TierFeature.WildcardSubdomain]: ["tier1", "tier2", "tier3", "enterprise"],
|
||||
[TierFeature.NewtAutoUpdate]: ["tier1", "tier2", "tier3", "enterprise"],
|
||||
[TierFeature.ResourcePolicies]: ["tier3", "enterprise"]
|
||||
};
|
||||
|
||||
@@ -16,14 +16,13 @@ import logger from "@server/logger";
|
||||
import { sites } from "@server/db";
|
||||
import { eq, and, isNotNull } from "drizzle-orm";
|
||||
import { addTargets as addProxyTargets } from "@server/routers/newt/targets";
|
||||
import { addTargets as addClientTargets } from "@server/routers/client/targets";
|
||||
import {
|
||||
ClientResourcesResults,
|
||||
updateClientResources
|
||||
} from "./clientResources";
|
||||
import { BlueprintSource } from "@server/routers/blueprints/types";
|
||||
import { stringify as stringifyYaml } from "yaml";
|
||||
import { faker } from "@faker-js/faker";
|
||||
import { generateName } from "@server/db/names";
|
||||
import { handleMessagingForUpdatedSiteResource } from "@server/routers/siteResource";
|
||||
import { rebuildClientAssociationsFromSiteResource } from "../rebuildClientAssociations";
|
||||
|
||||
@@ -106,7 +105,7 @@ export async function applyBlueprint({
|
||||
site.newt.newtId,
|
||||
[target],
|
||||
matchingHealthcheck ? [matchingHealthcheck] : [],
|
||||
result.proxyResource.protocol,
|
||||
result.proxyResource.mode === "udp" ? "udp" : "tcp",
|
||||
site.newt.version
|
||||
);
|
||||
}
|
||||
@@ -291,9 +290,7 @@ export async function applyBlueprint({
|
||||
.insert(blueprints)
|
||||
.values({
|
||||
orgId,
|
||||
name:
|
||||
name ??
|
||||
`${faker.word.adjective()}-${faker.word.adjective()}-${faker.word.noun()}`,
|
||||
name: name ?? generateName(),
|
||||
contents: stringifyYaml(configData),
|
||||
createdAt: Math.floor(Date.now() / 1000),
|
||||
succeeded: blueprintSucceeded,
|
||||
|
||||
@@ -1,10 +1,56 @@
|
||||
import { sendToClient } from "#dynamic/routers/ws";
|
||||
import { processContainerLabels } from "./parseDockerContainers";
|
||||
import { applyBlueprint } from "./applyBlueprint";
|
||||
import { PrivateResourceSchema, PublicResourceSchema } from "./types";
|
||||
import { db, sites } from "@server/db";
|
||||
import { eq } from "drizzle-orm";
|
||||
import logger from "@server/logger";
|
||||
|
||||
type BlueprintResult = ReturnType<typeof processContainerLabels>;
|
||||
|
||||
function filterInvalidResources(blueprint: BlueprintResult): {
|
||||
skippedCount: number;
|
||||
skippedKeys: string[];
|
||||
} {
|
||||
const skippedKeys: string[] = [];
|
||||
|
||||
for (const section of ["proxy-resources", "public-resources"] as const) {
|
||||
const resources = blueprint[section];
|
||||
for (const [key, value] of Object.entries(resources)) {
|
||||
const result = PublicResourceSchema.safeParse(value);
|
||||
if (!result.success) {
|
||||
const errors = result.error.issues
|
||||
.map((i) => `${i.path.join(".")}: ${i.message}`)
|
||||
.join("; ");
|
||||
logger.warn(
|
||||
`Skipping invalid Docker ${section} "${key}": ${errors}`
|
||||
);
|
||||
delete resources[key];
|
||||
skippedKeys.push(`${section}.${key}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const section of ["client-resources", "private-resources"] as const) {
|
||||
const resources = blueprint[section];
|
||||
for (const [key, value] of Object.entries(resources)) {
|
||||
const result = PrivateResourceSchema.safeParse(value);
|
||||
if (!result.success) {
|
||||
const errors = result.error.issues
|
||||
.map((i) => `${i.path.join(".")}: ${i.message}`)
|
||||
.join("; ");
|
||||
logger.warn(
|
||||
`Skipping invalid Docker ${section} "${key}": ${errors}`
|
||||
);
|
||||
delete resources[key];
|
||||
skippedKeys.push(`${section}.${key}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { skippedCount: skippedKeys.length, skippedKeys };
|
||||
}
|
||||
|
||||
export async function applyNewtDockerBlueprint(
|
||||
siteId: number,
|
||||
newtId: string,
|
||||
@@ -21,17 +67,24 @@ export async function applyNewtDockerBlueprint(
|
||||
return;
|
||||
}
|
||||
|
||||
// logger.debug(`Applying Docker blueprint to site: ${siteId}`);
|
||||
// logger.debug(`Containers: ${JSON.stringify(containers, null, 2)}`);
|
||||
let skippedCount = 0;
|
||||
let skippedKeys: string[] = [];
|
||||
|
||||
try {
|
||||
const blueprint = processContainerLabels(containers);
|
||||
|
||||
logger.debug(`Received Docker blueprint: ${JSON.stringify(blueprint)}`);
|
||||
logger.debug(
|
||||
`Received Docker blueprint with ${Object.keys(blueprint["proxy-resources"]).length} proxy, ${Object.keys(blueprint["client-resources"]).length} client resource(s)`
|
||||
);
|
||||
|
||||
// make sure this is not an empty object
|
||||
if (isEmptyObject(blueprint)) {
|
||||
return;
|
||||
const filterResult = filterInvalidResources(blueprint);
|
||||
skippedCount = filterResult.skippedCount;
|
||||
skippedKeys = filterResult.skippedKeys;
|
||||
|
||||
if (skippedCount > 0) {
|
||||
logger.warn(
|
||||
`Filtered ${skippedCount} invalid resource(s) from Docker blueprint: ${skippedKeys.join(", ")}`
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
@@ -40,6 +93,15 @@ export async function applyNewtDockerBlueprint(
|
||||
isEmptyObject(blueprint["public-resources"]) &&
|
||||
isEmptyObject(blueprint["private-resources"])
|
||||
) {
|
||||
if (skippedCount > 0) {
|
||||
await sendToClient(newtId, {
|
||||
type: "newt/blueprint/results",
|
||||
data: {
|
||||
success: false,
|
||||
message: `All resources were invalid and skipped: ${skippedKeys.join(", ")}`
|
||||
}
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -66,7 +128,10 @@ export async function applyNewtDockerBlueprint(
|
||||
type: "newt/blueprint/results",
|
||||
data: {
|
||||
success: true,
|
||||
message: "Config updated successfully"
|
||||
message:
|
||||
skippedCount > 0
|
||||
? `Config updated successfully. Skipped ${skippedCount} invalid resource(s): ${skippedKeys.join(", ")}`
|
||||
: "Config updated successfully"
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
clientSiteResources,
|
||||
domains,
|
||||
orgDomains,
|
||||
roleActions,
|
||||
roles,
|
||||
roleSiteResources,
|
||||
Site,
|
||||
@@ -19,6 +20,7 @@ import { sites } from "@server/db";
|
||||
import { eq, and, ne, inArray, or, isNotNull } from "drizzle-orm";
|
||||
import { Config } from "./types";
|
||||
import logger from "@server/logger";
|
||||
import { defaultRoleAllowedActions } from "@server/routers/role/createRole";
|
||||
import { getNextAvailableAliasAddress } from "../ip";
|
||||
import { createCertificate } from "#dynamic/routers/certificates/createCertificate";
|
||||
|
||||
@@ -225,7 +227,11 @@ export async function updateClientResources(
|
||||
: resourceData["udp-ports"],
|
||||
fullDomain: resourceData["full-domain"] || null,
|
||||
subdomain: domainInfo ? domainInfo.subdomain : null,
|
||||
domainId: domainInfo ? domainInfo.domainId : null
|
||||
domainId: domainInfo ? domainInfo.domainId : null,
|
||||
pamMode: resourceData["auth-daemon"]?.pam || "passthrough",
|
||||
authDaemonMode:
|
||||
resourceData["auth-daemon"]?.mode || "native",
|
||||
authDaemonPort: resourceData["auth-daemon"]?.port || 22123
|
||||
})
|
||||
.where(
|
||||
eq(
|
||||
@@ -332,8 +338,7 @@ export async function updateClientResources(
|
||||
}
|
||||
|
||||
if (resourceData.roles.length > 0) {
|
||||
// Re-add specified roles but we need to get the roleIds from the role name in the array
|
||||
const rolesToUpdate = await trx
|
||||
const existingRoles = await trx
|
||||
.select()
|
||||
.from(roles)
|
||||
.where(
|
||||
@@ -343,7 +348,28 @@ export async function updateClientResources(
|
||||
)
|
||||
);
|
||||
|
||||
const roleIds = rolesToUpdate.map((role) => role.roleId);
|
||||
const foundNames = new Set(existingRoles.map((r) => r.name));
|
||||
const missingNames = resourceData.roles.filter(
|
||||
(n) => !foundNames.has(n)
|
||||
);
|
||||
|
||||
for (const name of missingNames) {
|
||||
const [created] = await trx
|
||||
.insert(roles)
|
||||
.values({ name, orgId })
|
||||
.returning();
|
||||
await trx.insert(roleActions).values(
|
||||
defaultRoleAllowedActions.map((action) => ({
|
||||
roleId: created.roleId,
|
||||
actionId: action,
|
||||
orgId
|
||||
}))
|
||||
);
|
||||
existingRoles.push(created);
|
||||
logger.info(`Auto-created role "${name}" in org ${orgId} from blueprint`);
|
||||
}
|
||||
|
||||
const roleIds = existingRoles.map((role) => role.roleId);
|
||||
|
||||
await trx
|
||||
.insert(roleSiteResources)
|
||||
@@ -360,8 +386,14 @@ export async function updateClientResources(
|
||||
});
|
||||
} else {
|
||||
let aliasAddress: string | null = null;
|
||||
let releaseAliasLock: (() => Promise<void>) | null = null;
|
||||
if (resourceData.mode === "host" || resourceData.mode === "http") {
|
||||
aliasAddress = await getNextAvailableAliasAddress(orgId, trx);
|
||||
const { value, release } = await getNextAvailableAliasAddress(
|
||||
orgId,
|
||||
trx
|
||||
);
|
||||
aliasAddress = value;
|
||||
releaseAliasLock = release;
|
||||
}
|
||||
|
||||
let domainInfo:
|
||||
@@ -415,10 +447,16 @@ export async function updateClientResources(
|
||||
: resourceData["udp-ports"],
|
||||
fullDomain: resourceData["full-domain"] || null,
|
||||
subdomain: domainInfo ? domainInfo.subdomain : null,
|
||||
domainId: domainInfo ? domainInfo.domainId : null
|
||||
domainId: domainInfo ? domainInfo.domainId : null,
|
||||
pamMode: resourceData["auth-daemon"]?.pam || "passthrough",
|
||||
authDaemonMode:
|
||||
resourceData["auth-daemon"]?.mode || "native",
|
||||
authDaemonPort: resourceData["auth-daemon"]?.port || 22123
|
||||
})
|
||||
.returning();
|
||||
|
||||
await releaseAliasLock?.();
|
||||
|
||||
const siteResourceId = newResource.siteResourceId;
|
||||
|
||||
for (const site of allSites) {
|
||||
@@ -444,8 +482,7 @@ export async function updateClientResources(
|
||||
});
|
||||
|
||||
if (resourceData.roles.length > 0) {
|
||||
// get roleIds from role names
|
||||
const rolesToUpdate = await trx
|
||||
const existingRoles = await trx
|
||||
.select()
|
||||
.from(roles)
|
||||
.where(
|
||||
@@ -455,7 +492,28 @@ export async function updateClientResources(
|
||||
)
|
||||
);
|
||||
|
||||
const roleIds = rolesToUpdate.map((role) => role.roleId);
|
||||
const foundNames = new Set(existingRoles.map((r) => r.name));
|
||||
const missingNames = resourceData.roles.filter(
|
||||
(n) => !foundNames.has(n)
|
||||
);
|
||||
|
||||
for (const name of missingNames) {
|
||||
const [created] = await trx
|
||||
.insert(roles)
|
||||
.values({ name, orgId })
|
||||
.returning();
|
||||
await trx.insert(roleActions).values(
|
||||
defaultRoleAllowedActions.map((action) => ({
|
||||
roleId: created.roleId,
|
||||
actionId: action,
|
||||
orgId
|
||||
}))
|
||||
);
|
||||
existingRoles.push(created);
|
||||
logger.info(`Auto-created role "${name}" in org ${orgId} from blueprint`);
|
||||
}
|
||||
|
||||
const roleIds = existingRoles.map((role) => role.roleId);
|
||||
|
||||
await trx
|
||||
.insert(roleSiteResources)
|
||||
|
||||
+1023
-331
File diff suppressed because it is too large
Load Diff
@@ -82,7 +82,7 @@ export const RuleSchema = z
|
||||
.object({
|
||||
action: z.enum(["allow", "deny", "pass"]),
|
||||
match: z.enum(["cidr", "path", "ip", "country", "asn", "region"]),
|
||||
value: z.string(),
|
||||
value: z.coerce.string(),
|
||||
priority: z.int().optional()
|
||||
})
|
||||
.refine(
|
||||
@@ -161,11 +161,34 @@ export const HeaderSchema = z.object({
|
||||
value: z.string().min(1)
|
||||
});
|
||||
|
||||
export const AuthDaemonSchema = z
|
||||
.object({
|
||||
pam: z.enum(["passthrough", "push"]).optional().default("passthrough"),
|
||||
mode: z.enum(["site", "remote", "native"]).optional().default("site"),
|
||||
port: z.int().min(1).max(65535).optional()
|
||||
})
|
||||
.refine(
|
||||
(data) => {
|
||||
if (data.mode === "remote") {
|
||||
return data.port !== undefined;
|
||||
}
|
||||
return true;
|
||||
},
|
||||
{
|
||||
path: ["port"],
|
||||
message: "port is required when auth-daemon mode is 'remote'"
|
||||
}
|
||||
);
|
||||
|
||||
// Schema for individual resource
|
||||
export const ResourceSchema = z
|
||||
export const PublicResourceSchema = z
|
||||
.object({
|
||||
name: z.string().optional(),
|
||||
protocol: z.enum(["http", "tcp", "udp"]).optional(),
|
||||
protocol: z
|
||||
.enum(["http", "tcp", "udp", "ssh", "rdp", "vnc"])
|
||||
.optional(), // this was the old one and is now DEPRECATED in favor of the mode
|
||||
mode: z.enum(["http", "tcp", "udp", "ssh", "rdp", "vnc"]).optional(),
|
||||
policy: z.string().optional(),
|
||||
ssl: z.boolean().optional(),
|
||||
scheme: z.enum(["http", "https"]).optional(),
|
||||
"full-domain": z.string().optional(),
|
||||
@@ -177,7 +200,8 @@ export const ResourceSchema = z
|
||||
"tls-server-name": z.string().optional(),
|
||||
headers: z.array(HeaderSchema).optional(),
|
||||
rules: z.array(RuleSchema).optional(),
|
||||
maintenance: MaintenanceSchema.optional()
|
||||
maintenance: MaintenanceSchema.optional(),
|
||||
"auth-daemon": AuthDaemonSchema.optional()
|
||||
})
|
||||
.refine(
|
||||
(resource) => {
|
||||
@@ -185,9 +209,10 @@ export const ResourceSchema = z
|
||||
return true;
|
||||
}
|
||||
|
||||
// Otherwise, require name and protocol for full resource definition
|
||||
// Otherwise, require name and protocol/mode for full resource definition
|
||||
return (
|
||||
resource.name !== undefined && resource.protocol !== undefined
|
||||
resource.name !== undefined &&
|
||||
(resource.mode !== undefined || resource.protocol !== undefined)
|
||||
);
|
||||
},
|
||||
{
|
||||
@@ -201,8 +226,8 @@ export const ResourceSchema = z
|
||||
return true;
|
||||
}
|
||||
|
||||
// If protocol is http, all targets must have method field
|
||||
if (resource.protocol === "http") {
|
||||
// If protocol/mode is http, all targets must have method field
|
||||
if ((resource.mode ?? resource.protocol) === "http") {
|
||||
return resource.targets.every(
|
||||
(target) => target == null || target.method !== undefined
|
||||
);
|
||||
@@ -220,8 +245,9 @@ export const ResourceSchema = z
|
||||
return true;
|
||||
}
|
||||
|
||||
// If protocol is tcp or udp, no target should have method field
|
||||
if (resource.protocol === "tcp" || resource.protocol === "udp") {
|
||||
// If protocol/mode is tcp or udp, no target should have method field
|
||||
const effectiveProtocol1 = resource.mode ?? resource.protocol;
|
||||
if (effectiveProtocol1 === "tcp" || effectiveProtocol1 === "udp") {
|
||||
return resource.targets.every(
|
||||
(target) => target == null || target.method === undefined
|
||||
);
|
||||
@@ -239,8 +265,8 @@ export const ResourceSchema = z
|
||||
return true;
|
||||
}
|
||||
|
||||
// If protocol is http, it must have a full-domain
|
||||
if (resource.protocol === "http") {
|
||||
// If protocol/mode is http, it must have a full-domain
|
||||
if ((resource.mode ?? resource.protocol) === "http") {
|
||||
return (
|
||||
resource["full-domain"] !== undefined &&
|
||||
resource["full-domain"].length > 0
|
||||
@@ -259,8 +285,9 @@ export const ResourceSchema = z
|
||||
return true;
|
||||
}
|
||||
|
||||
// If protocol is tcp or udp, it must have both proxy-port
|
||||
if (resource.protocol === "tcp" || resource.protocol === "udp") {
|
||||
// If protocol/mode is tcp or udp, it must have both proxy-port
|
||||
const effectiveProtocol2 = resource.mode ?? resource.protocol;
|
||||
if (effectiveProtocol2 === "tcp" || effectiveProtocol2 === "udp") {
|
||||
return resource["proxy-port"] !== undefined;
|
||||
}
|
||||
return true;
|
||||
@@ -277,8 +304,9 @@ export const ResourceSchema = z
|
||||
return true;
|
||||
}
|
||||
|
||||
// If protocol is tcp or udp, it must not have auth
|
||||
if (resource.protocol === "tcp" || resource.protocol === "udp") {
|
||||
// If protocol/mode is tcp or udp, it must not have auth
|
||||
const effectiveProtocol3 = resource.mode ?? resource.protocol;
|
||||
if (effectiveProtocol3 === "tcp" || effectiveProtocol3 === "udp") {
|
||||
return resource.auth === undefined;
|
||||
}
|
||||
return true;
|
||||
@@ -340,7 +368,8 @@ export const ResourceSchema = z
|
||||
if (parts.includes("*", 1)) return false; // no further wildcards
|
||||
if (parts.length < 3) return false; // need at least *.label.tld
|
||||
|
||||
const labelRegex = /^[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?$|^[a-zA-Z0-9]$/;
|
||||
const labelRegex =
|
||||
/^[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?$|^[a-zA-Z0-9]$/;
|
||||
return parts.slice(1).every((label) => labelRegex.test(label));
|
||||
},
|
||||
{
|
||||
@@ -348,22 +377,29 @@ export const ResourceSchema = z
|
||||
message:
|
||||
'Wildcard full-domain must have "*" as the leftmost label only, followed by at least two valid hostname labels (e.g. "*.example.com" or "*.level1.example.com"). Patterns like "*example.com" or "level2.*.example.com" are not supported.'
|
||||
}
|
||||
);
|
||||
)
|
||||
.transform((resource) => {
|
||||
// Normalize: prefer mode, fall back to protocol for backwards compatibility
|
||||
if (resource.mode === undefined && resource.protocol !== undefined) {
|
||||
resource.mode = resource.protocol;
|
||||
}
|
||||
return resource;
|
||||
});
|
||||
|
||||
export function isTargetsOnlyResource(resource: any): boolean {
|
||||
return Object.keys(resource).length === 1 && resource.targets;
|
||||
}
|
||||
|
||||
export const ClientResourceSchema = z
|
||||
export const PrivateResourceSchema = z
|
||||
.object({
|
||||
name: z.string().min(1).max(255),
|
||||
mode: z.enum(["host", "cidr", "http"]),
|
||||
mode: z.enum(["host", "cidr", "http", "ssh"]),
|
||||
site: z.string().optional(), // DEPRECATED IN FAVOR OF sites
|
||||
sites: z.array(z.string()).optional().default([]),
|
||||
// protocol: z.enum(["tcp", "udp"]).optional(),
|
||||
// proxyPort: z.int().positive().optional(),
|
||||
"destination-port": z.int().positive().optional(),
|
||||
destination: z.string().min(1),
|
||||
destination: z.string().min(1).optional(),
|
||||
// enabled: z.boolean().default(true),
|
||||
"tcp-ports": portRangeStringSchema.optional().default("*"),
|
||||
"udp-ports": portRangeStringSchema.optional().default("*"),
|
||||
@@ -386,11 +422,31 @@ export const ClientResourceSchema = z
|
||||
error: "Admin role cannot be included in roles"
|
||||
}),
|
||||
users: z.array(z.string()).optional().default([]),
|
||||
machines: z.array(z.string()).optional().default([])
|
||||
machines: z.array(z.string()).optional().default([]),
|
||||
"auth-daemon": AuthDaemonSchema.optional()
|
||||
})
|
||||
.refine(
|
||||
(data) => {
|
||||
// destination is optional only for ssh+native; required for everything else
|
||||
const isNativeSSH =
|
||||
data.mode === "ssh" &&
|
||||
(data["auth-daemon"] === undefined ||
|
||||
data["auth-daemon"].mode === "native");
|
||||
if (!isNativeSSH && !data.destination) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
},
|
||||
{
|
||||
path: ["destination"],
|
||||
message:
|
||||
"destination is required unless mode is 'ssh' with auth-daemon mode 'native'"
|
||||
}
|
||||
)
|
||||
.refine(
|
||||
(data) => {
|
||||
if (data.mode === "host") {
|
||||
if (!data.destination) return true; // caught by the destination-required refine
|
||||
// Check if it's a valid IP address using zod (v4 or v6)
|
||||
const isValidIP = z
|
||||
.union([z.ipv4(), z.ipv6()])
|
||||
@@ -418,6 +474,7 @@ export const ClientResourceSchema = z
|
||||
.refine(
|
||||
(data) => {
|
||||
if (data.mode === "cidr") {
|
||||
if (!data.destination) return true; // caught by the destination-required refine
|
||||
// Check if it's a valid CIDR (v4 or v6)
|
||||
const isValidCIDR = z
|
||||
.union([z.cidrv4(), z.cidrv6()])
|
||||
@@ -435,19 +492,19 @@ export const ClientResourceSchema = z
|
||||
export const ConfigSchema = z
|
||||
.object({
|
||||
"proxy-resources": z
|
||||
.record(z.string(), ResourceSchema)
|
||||
.record(z.string(), PublicResourceSchema)
|
||||
.optional()
|
||||
.prefault({}),
|
||||
"public-resources": z
|
||||
.record(z.string(), ResourceSchema)
|
||||
.record(z.string(), PublicResourceSchema)
|
||||
.optional()
|
||||
.prefault({}),
|
||||
"client-resources": z
|
||||
.record(z.string(), ClientResourceSchema)
|
||||
.record(z.string(), PrivateResourceSchema)
|
||||
.optional()
|
||||
.prefault({}),
|
||||
"private-resources": z
|
||||
.record(z.string(), ClientResourceSchema)
|
||||
.record(z.string(), PrivateResourceSchema)
|
||||
.optional()
|
||||
.prefault({}),
|
||||
sites: z.record(z.string(), SiteSchema).optional().prefault({})
|
||||
@@ -472,10 +529,13 @@ export const ConfigSchema = z
|
||||
}
|
||||
|
||||
return data as {
|
||||
"proxy-resources": Record<string, z.infer<typeof ResourceSchema>>;
|
||||
"proxy-resources": Record<
|
||||
string,
|
||||
z.infer<typeof PublicResourceSchema>
|
||||
>;
|
||||
"client-resources": Record<
|
||||
string,
|
||||
z.infer<typeof ClientResourceSchema>
|
||||
z.infer<typeof PrivateResourceSchema>
|
||||
>;
|
||||
sites: Record<string, z.infer<typeof SiteSchema>>;
|
||||
};
|
||||
@@ -614,5 +674,5 @@ export const ConfigSchema = z
|
||||
// Type inference from the schema
|
||||
export type Site = z.infer<typeof SiteSchema>;
|
||||
export type Target = z.infer<typeof TargetSchema>;
|
||||
export type Resource = z.infer<typeof ResourceSchema>;
|
||||
export type Resource = z.infer<typeof PublicResourceSchema>;
|
||||
export type Config = z.infer<typeof ConfigSchema>;
|
||||
|
||||
@@ -331,16 +331,8 @@ export async function calculateUserClientsForOrgs(
|
||||
];
|
||||
|
||||
// Get next available subnet
|
||||
const newSubnet = await getNextAvailableClientSubnet(
|
||||
orgId,
|
||||
transaction
|
||||
);
|
||||
if (!newSubnet) {
|
||||
logger.warn(
|
||||
`Skipping org ${orgId} for OLM ${olm.olmId} (user ${userId}): no available subnet found`
|
||||
);
|
||||
continue;
|
||||
}
|
||||
const { value: newSubnet, release: releaseSubnetLock } =
|
||||
await getNextAvailableClientSubnet(orgId, transaction);
|
||||
|
||||
const subnet = newSubnet.split("/")[0];
|
||||
const updatedSubnet = `${subnet}/${org.subnet.split("/")[1]}`;
|
||||
@@ -370,6 +362,7 @@ export async function calculateUserClientsForOrgs(
|
||||
.insert(clients)
|
||||
.values(newClientData)
|
||||
.returning();
|
||||
await releaseSubnetLock();
|
||||
existingClientCache.set(
|
||||
getOrgOlmKey(orgId, olm.olmId),
|
||||
newClient
|
||||
|
||||
+139
-116
@@ -327,127 +327,145 @@ export function doCidrsOverlap(cidr1: string, cidr2: string): boolean {
|
||||
export async function getNextAvailableClientSubnet(
|
||||
orgId: string,
|
||||
transaction: Transaction | typeof db = db
|
||||
): Promise<string> {
|
||||
return await lockManager.withLock(
|
||||
`client-subnet-allocation:${orgId}`,
|
||||
async () => {
|
||||
const [org] = await transaction
|
||||
.select()
|
||||
.from(orgs)
|
||||
.where(eq(orgs.orgId, orgId));
|
||||
): Promise<{ value: string; release: () => Promise<void> }> {
|
||||
const lockKey = `client-subnet-allocation:${orgId}`;
|
||||
const acquired = await lockManager.acquireLockWithRetry(lockKey, 6000);
|
||||
if (!acquired) {
|
||||
throw new Error(`Failed to acquire lock: ${lockKey}`);
|
||||
}
|
||||
const release = () => lockManager.releaseLock(lockKey);
|
||||
|
||||
if (!org) {
|
||||
throw new Error(`Organization with ID ${orgId} not found`);
|
||||
}
|
||||
try {
|
||||
const [org] = await transaction
|
||||
.select()
|
||||
.from(orgs)
|
||||
.where(eq(orgs.orgId, orgId));
|
||||
|
||||
if (!org.subnet) {
|
||||
throw new Error(
|
||||
`Organization with ID ${orgId} has no subnet defined`
|
||||
);
|
||||
}
|
||||
|
||||
const existingAddressesSites = await transaction
|
||||
.select({
|
||||
address: sites.address
|
||||
})
|
||||
.from(sites)
|
||||
.where(and(isNotNull(sites.address), eq(sites.orgId, orgId)));
|
||||
|
||||
const existingAddressesClients = await transaction
|
||||
.select({
|
||||
address: clients.subnet
|
||||
})
|
||||
.from(clients)
|
||||
.where(
|
||||
and(isNotNull(clients.subnet), eq(clients.orgId, orgId))
|
||||
);
|
||||
|
||||
const addresses = [
|
||||
...existingAddressesSites.map(
|
||||
(site) => `${site.address?.split("/")[0]}/32`
|
||||
), // we are overriding the 32 so that we pick individual addresses in the subnet of the org for the site and the client even though they are stored with the /block_size of the org
|
||||
...existingAddressesClients.map(
|
||||
(client) => `${client.address.split("/")}/32`
|
||||
)
|
||||
].filter((address) => address !== null) as string[];
|
||||
|
||||
const subnet = findNextAvailableCidr(addresses, 32, org.subnet); // pick the sites address in the org
|
||||
if (!subnet) {
|
||||
throw new Error("No available subnets remaining in space");
|
||||
}
|
||||
|
||||
return subnet;
|
||||
if (!org) {
|
||||
throw new Error(`Organization with ID ${orgId} not found`);
|
||||
}
|
||||
);
|
||||
|
||||
if (!org.subnet) {
|
||||
throw new Error(
|
||||
`Organization with ID ${orgId} has no subnet defined`
|
||||
);
|
||||
}
|
||||
|
||||
const existingAddressesSites = await transaction
|
||||
.select({
|
||||
address: sites.address
|
||||
})
|
||||
.from(sites)
|
||||
.where(and(isNotNull(sites.address), eq(sites.orgId, orgId)));
|
||||
|
||||
const existingAddressesClients = await transaction
|
||||
.select({
|
||||
address: clients.subnet
|
||||
})
|
||||
.from(clients)
|
||||
.where(and(isNotNull(clients.subnet), eq(clients.orgId, orgId)));
|
||||
|
||||
const addresses = [
|
||||
...existingAddressesSites.map(
|
||||
(site) => `${site.address?.split("/")[0]}/32`
|
||||
), // we are overriding the 32 so that we pick individual addresses in the subnet of the org for the site and the client even though they are stored with the /block_size of the org
|
||||
...existingAddressesClients.map(
|
||||
(client) => `${client.address.split("/")[0]}/32`
|
||||
)
|
||||
].filter((address) => address !== null) as string[];
|
||||
|
||||
const subnet = findNextAvailableCidr(addresses, 32, org.subnet); // pick the sites address in the org
|
||||
if (!subnet) {
|
||||
throw new Error("No available subnets remaining in space");
|
||||
}
|
||||
|
||||
return { value: subnet, release };
|
||||
} catch (e) {
|
||||
await release();
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
export async function getNextAvailableAliasAddress(
|
||||
orgId: string,
|
||||
trx: Transaction | typeof db = db
|
||||
): Promise<string> {
|
||||
return await lockManager.withLock(
|
||||
`alias-address-allocation:${orgId}`,
|
||||
async () => {
|
||||
const [org] = await trx
|
||||
.select()
|
||||
.from(orgs)
|
||||
.where(eq(orgs.orgId, orgId));
|
||||
): Promise<{ value: string; release: () => Promise<void> }> {
|
||||
const lockKey = `alias-address-allocation:${orgId}`;
|
||||
const acquired = await lockManager.acquireLockWithRetry(lockKey, 6000);
|
||||
if (!acquired) {
|
||||
throw new Error(`Failed to acquire lock: ${lockKey}`);
|
||||
}
|
||||
const release = () => lockManager.releaseLock(lockKey);
|
||||
|
||||
if (!org) {
|
||||
throw new Error(`Organization with ID ${orgId} not found`);
|
||||
}
|
||||
try {
|
||||
const [org] = await trx
|
||||
.select()
|
||||
.from(orgs)
|
||||
.where(eq(orgs.orgId, orgId));
|
||||
|
||||
if (!org.subnet) {
|
||||
throw new Error(
|
||||
`Organization with ID ${orgId} has no subnet defined`
|
||||
);
|
||||
}
|
||||
|
||||
if (!org.utilitySubnet) {
|
||||
throw new Error(
|
||||
`Organization with ID ${orgId} has no utility subnet defined`
|
||||
);
|
||||
}
|
||||
|
||||
const existingAddresses = await trx
|
||||
.select({
|
||||
aliasAddress: siteResources.aliasAddress
|
||||
})
|
||||
.from(siteResources)
|
||||
.where(
|
||||
and(
|
||||
isNotNull(siteResources.aliasAddress),
|
||||
eq(siteResources.orgId, orgId)
|
||||
)
|
||||
);
|
||||
|
||||
const addresses = [
|
||||
...existingAddresses.map(
|
||||
(site) => `${site.aliasAddress?.split("/")[0]}/32`
|
||||
),
|
||||
// reserve a /29 for the dns server and other stuff
|
||||
`${org.utilitySubnet.split("/")[0]}/29`
|
||||
].filter((address) => address !== null) as string[];
|
||||
|
||||
let subnet = findNextAvailableCidr(
|
||||
addresses,
|
||||
32,
|
||||
org.utilitySubnet
|
||||
);
|
||||
if (!subnet) {
|
||||
throw new Error("No available subnets remaining in space");
|
||||
}
|
||||
|
||||
// remove the cidr
|
||||
subnet = subnet.split("/")[0];
|
||||
|
||||
return subnet;
|
||||
if (!org) {
|
||||
throw new Error(`Organization with ID ${orgId} not found`);
|
||||
}
|
||||
);
|
||||
|
||||
if (!org.subnet) {
|
||||
throw new Error(
|
||||
`Organization with ID ${orgId} has no subnet defined`
|
||||
);
|
||||
}
|
||||
|
||||
if (!org.utilitySubnet) {
|
||||
throw new Error(
|
||||
`Organization with ID ${orgId} has no utility subnet defined`
|
||||
);
|
||||
}
|
||||
|
||||
const existingAddresses = await trx
|
||||
.select({
|
||||
aliasAddress: siteResources.aliasAddress
|
||||
})
|
||||
.from(siteResources)
|
||||
.where(
|
||||
and(
|
||||
isNotNull(siteResources.aliasAddress),
|
||||
eq(siteResources.orgId, orgId)
|
||||
)
|
||||
);
|
||||
|
||||
const addresses = [
|
||||
...existingAddresses.map(
|
||||
(site) => `${site.aliasAddress?.split("/")[0]}/32`
|
||||
),
|
||||
// reserve a /29 for the dns server and other stuff
|
||||
`${org.utilitySubnet.split("/")[0]}/29`
|
||||
].filter((address) => address !== null) as string[];
|
||||
|
||||
let subnet = findNextAvailableCidr(addresses, 32, org.utilitySubnet);
|
||||
if (!subnet) {
|
||||
throw new Error("No available subnets remaining in space");
|
||||
}
|
||||
|
||||
// remove the cidr
|
||||
subnet = subnet.split("/")[0];
|
||||
|
||||
return { value: subnet, release };
|
||||
} catch (e) {
|
||||
await release();
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
export async function getNextAvailableOrgSubnet(): Promise<string> {
|
||||
return await lockManager.withLock("org-subnet-allocation", async () => {
|
||||
export async function getNextAvailableOrgSubnet(): Promise<{
|
||||
value: string;
|
||||
release: () => Promise<void>;
|
||||
}> {
|
||||
const lockKey = "org-subnet-allocation";
|
||||
const acquired = await lockManager.acquireLockWithRetry(lockKey, 6000);
|
||||
if (!acquired) {
|
||||
throw new Error(`Failed to acquire lock: ${lockKey}`);
|
||||
}
|
||||
const release = () => lockManager.releaseLock(lockKey);
|
||||
|
||||
try {
|
||||
const existingAddresses = await db
|
||||
.select({
|
||||
subnet: orgs.subnet
|
||||
@@ -466,8 +484,11 @@ export async function getNextAvailableOrgSubnet(): Promise<string> {
|
||||
throw new Error("No available subnets remaining in space");
|
||||
}
|
||||
|
||||
return subnet;
|
||||
});
|
||||
return { value: subnet, release };
|
||||
} catch (e) {
|
||||
await release();
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
export function generateRemoteSubnets(
|
||||
@@ -475,6 +496,8 @@ export function generateRemoteSubnets(
|
||||
): string[] {
|
||||
const remoteSubnets = allSiteResources
|
||||
.filter((sr) => {
|
||||
if (!sr.destination) return false;
|
||||
|
||||
if (sr.mode === "cidr") {
|
||||
// check if its a valid CIDR using zod
|
||||
const cidrSchema = z.union([z.cidrv4(), z.cidrv6()]);
|
||||
@@ -496,7 +519,7 @@ export function generateRemoteSubnets(
|
||||
}
|
||||
return ""; // This should never be reached due to filtering, but satisfies TypeScript
|
||||
})
|
||||
.filter((subnet) => subnet !== ""); // Remove empty strings just to be safe
|
||||
.filter((subnet): subnet is string => subnet !== "" && subnet !== null); // Remove invalid values just to be safe
|
||||
// remove duplicates
|
||||
return Array.from(new Set(remoteSubnets));
|
||||
}
|
||||
@@ -581,7 +604,7 @@ export function generateSubnetProxyTargets(
|
||||
targets.push({
|
||||
sourcePrefix: clientPrefix,
|
||||
destPrefix: `${siteResource.aliasAddress}/32`,
|
||||
rewriteTo: destination,
|
||||
rewriteTo: destination!,
|
||||
portRange,
|
||||
disableIcmp
|
||||
});
|
||||
@@ -589,7 +612,7 @@ export function generateSubnetProxyTargets(
|
||||
} else if (siteResource.mode == "cidr") {
|
||||
targets.push({
|
||||
sourcePrefix: clientPrefix,
|
||||
destPrefix: siteResource.destination,
|
||||
destPrefix: siteResource.destination!,
|
||||
portRange,
|
||||
disableIcmp
|
||||
});
|
||||
@@ -671,7 +694,7 @@ export async function generateSubnetProxyTargetV2(
|
||||
targets.push({
|
||||
sourcePrefixes: [],
|
||||
destPrefix: `${siteResource.aliasAddress}/32`,
|
||||
rewriteTo: destination,
|
||||
rewriteTo: destination!,
|
||||
portRange,
|
||||
disableIcmp,
|
||||
resourceId: siteResource.siteResourceId
|
||||
@@ -680,7 +703,7 @@ export async function generateSubnetProxyTargetV2(
|
||||
} else if (siteResource.mode == "cidr") {
|
||||
targets.push({
|
||||
sourcePrefixes: [],
|
||||
destPrefix: siteResource.destination,
|
||||
destPrefix: siteResource.destination!,
|
||||
portRange,
|
||||
disableIcmp,
|
||||
resourceId: siteResource.siteResourceId
|
||||
@@ -738,7 +761,7 @@ export async function generateSubnetProxyTargetV2(
|
||||
protocol: siteResource.ssl ? "https" : "http",
|
||||
httpTargets: [
|
||||
{
|
||||
destAddr: siteResource.destination,
|
||||
destAddr: siteResource.destination!,
|
||||
destPort: siteResource.destinationPort,
|
||||
scheme: siteResource.scheme
|
||||
}
|
||||
|
||||
@@ -890,6 +890,9 @@ async function handleSubnetProxyTargetUpdates(
|
||||
}
|
||||
|
||||
for (const client of removedClients) {
|
||||
if (!siteResource.destination) {
|
||||
continue;
|
||||
}
|
||||
// Check if this client still has access to another resource
|
||||
// on this specific site with the same destination. We scope
|
||||
// by siteId (via siteNetworks) rather than networkId because
|
||||
@@ -1563,6 +1566,9 @@ async function handleMessagesForClientResources(
|
||||
}
|
||||
|
||||
try {
|
||||
if (!resource.destination) {
|
||||
continue;
|
||||
}
|
||||
// Check if this client still has access to another resource
|
||||
// on this specific site with the same destination. We scope
|
||||
// by siteId (via siteNetworks) rather than networkId because
|
||||
@@ -1826,3 +1832,77 @@ export async function verifyClientAssociationsCache(
|
||||
extraSiteIds: extraSiteIds.sort((a, b) => a - b)
|
||||
};
|
||||
}
|
||||
|
||||
// cleanupSiteAssociations efficiently removes all client associations for a
|
||||
// site that is being deleted. Instead of calling
|
||||
// rebuildClientAssociationsFromSiteResource once per site resource (which is
|
||||
// O(resources) in DB round-trips and message fan-out), this function performs
|
||||
// a single bulk lookup of affected clients and site resources, deletes all
|
||||
// cache rows at once, and fires all peer/proxy removal messages in parallel.
|
||||
//
|
||||
// The caller is responsible for deleting the site row itself (and for sending
|
||||
// the newt/wg/terminate signal to the newt process).
|
||||
export async function cleanupSiteAssociations(
|
||||
site: Site,
|
||||
trx: Transaction | typeof db = db
|
||||
): Promise<void> {
|
||||
const siteId = site.siteId;
|
||||
|
||||
logger.debug(`cleanupSiteAssociations: START siteId=${siteId}`);
|
||||
|
||||
// 1. Find every client currently cached against this site.
|
||||
const cachedSiteClientRows = await trx
|
||||
.select({ clientId: clientSitesAssociationsCache.clientId })
|
||||
.from(clientSitesAssociationsCache)
|
||||
.where(eq(clientSitesAssociationsCache.siteId, siteId));
|
||||
|
||||
const cachedClientIds = cachedSiteClientRows.map((r) => r.clientId);
|
||||
|
||||
// 2. Load full client details (needed for WireGuard public-key references).
|
||||
const allClients =
|
||||
cachedClientIds.length > 0
|
||||
? await trx
|
||||
.select({
|
||||
clientId: clients.clientId,
|
||||
pubKey: clients.pubKey,
|
||||
subnet: clients.subnet
|
||||
})
|
||||
.from(clients)
|
||||
.where(inArray(clients.clientId, cachedClientIds))
|
||||
: [];
|
||||
|
||||
// 6. Bulk-delete all cache entries for this site. Do this before sending
|
||||
// destination-update messages so updateClientSiteDestinations computes
|
||||
// the correct (post-deletion) set of destinations.
|
||||
await trx
|
||||
.delete(clientSitesAssociationsCache)
|
||||
.where(eq(clientSitesAssociationsCache.siteId, siteId));
|
||||
|
||||
logger.debug(
|
||||
`cleanupSiteAssociations: siteId=${siteId} cache cleared. clients=${allClients.length}`
|
||||
);
|
||||
|
||||
// 7. Fire all removal messages in parallel.
|
||||
const jobs: Promise<any>[] = [];
|
||||
|
||||
for (const client of allClients) {
|
||||
// Tell each olm to drop the site's WireGuard peer.
|
||||
if (site.publicKey) {
|
||||
jobs.push(olmDeletePeer(client.clientId, siteId, site.publicKey));
|
||||
}
|
||||
|
||||
// Recompute and push updated relay destinations (now excluding this site).
|
||||
if (client.pubKey && client.subnet) {
|
||||
jobs.push(updateClientSiteDestinations(client, trx));
|
||||
}
|
||||
}
|
||||
|
||||
await Promise.all(jobs).catch((error) => {
|
||||
logger.error(
|
||||
`cleanupSiteAssociations: error sending cleanup messages for siteId=${siteId}:`,
|
||||
error
|
||||
);
|
||||
});
|
||||
|
||||
logger.debug(`cleanupSiteAssociations: DONE siteId=${siteId}`);
|
||||
}
|
||||
|
||||
+10
-4
@@ -2,7 +2,14 @@ import { PostHog } from "posthog-node";
|
||||
import config from "./config";
|
||||
import { getHostMeta } from "./hostMeta";
|
||||
import logger from "@server/logger";
|
||||
import { alertRules, apiKeys, blueprints, db, roles, siteResources } from "@server/db";
|
||||
import {
|
||||
alertRules,
|
||||
apiKeys,
|
||||
blueprints,
|
||||
db,
|
||||
roles,
|
||||
siteResources
|
||||
} from "@server/db";
|
||||
import { sites, users, orgs, resources, clients, idp } from "@server/db";
|
||||
import { eq, count, notInArray, and, isNotNull, isNull } from "drizzle-orm";
|
||||
import { APP_VERSION } from "./consts";
|
||||
@@ -143,8 +150,7 @@ class TelemetryClient {
|
||||
.select({
|
||||
name: resources.name,
|
||||
sso: resources.sso,
|
||||
protocol: resources.protocol,
|
||||
http: resources.http
|
||||
mode: resources.mode
|
||||
})
|
||||
.from(resources);
|
||||
|
||||
@@ -311,7 +317,7 @@ class TelemetryClient {
|
||||
(r) => r.sso
|
||||
).length,
|
||||
num_resources_non_http: stats.resources.filter(
|
||||
(r) => !r.http
|
||||
(r) => r.mode !== "http"
|
||||
).length,
|
||||
num_newt_sites: stats.sites.filter((s) => s.type === "newt")
|
||||
.length,
|
||||
|
||||
@@ -55,9 +55,7 @@ export async function getTraefikConfig(
|
||||
resourceName: resources.name,
|
||||
fullDomain: resources.fullDomain,
|
||||
ssl: resources.ssl,
|
||||
http: resources.http,
|
||||
proxyPort: resources.proxyPort,
|
||||
protocol: resources.protocol,
|
||||
subdomain: resources.subdomain,
|
||||
domainId: resources.domainId,
|
||||
enabled: resources.enabled,
|
||||
@@ -68,6 +66,7 @@ export async function getTraefikConfig(
|
||||
headers: resources.headers,
|
||||
proxyProtocol: resources.proxyProtocol,
|
||||
proxyProtocolVersion: resources.proxyProtocolVersion,
|
||||
mode: resources.mode,
|
||||
|
||||
// Target fields
|
||||
targetId: targets.targetId,
|
||||
@@ -115,8 +114,8 @@ export async function getTraefikConfig(
|
||||
),
|
||||
inArray(sites.type, siteTypes),
|
||||
allowRawResources
|
||||
? isNotNull(resources.http) // ignore the http check if allow_raw_resources is true
|
||||
: eq(resources.http, true)
|
||||
? inArray(resources.mode, ["http", "udp", "tcp"]) // allow all three
|
||||
: eq(resources.mode, "http")
|
||||
)
|
||||
)
|
||||
.orderBy(desc(targets.priority), targets.targetId); // stable ordering
|
||||
@@ -166,9 +165,8 @@ export async function getTraefikConfig(
|
||||
key: key,
|
||||
fullDomain: row.fullDomain,
|
||||
ssl: row.ssl,
|
||||
http: row.http,
|
||||
mode: row.mode,
|
||||
proxyPort: row.proxyPort,
|
||||
protocol: row.protocol,
|
||||
subdomain: row.subdomain,
|
||||
domainId: row.domainId,
|
||||
enabled: row.enabled,
|
||||
@@ -580,7 +578,7 @@ export async function getTraefikConfig(
|
||||
continue;
|
||||
}
|
||||
|
||||
const protocol = resource.protocol.toLowerCase();
|
||||
const protocol = resource.mode === "udp" ? "udp" : "tcp"; // all of the other ones are tcp
|
||||
const port = resource.proxyPort;
|
||||
|
||||
if (!port) {
|
||||
|
||||
@@ -244,4 +244,5 @@ try {
|
||||
runTests();
|
||||
} catch (error) {
|
||||
console.error("Test failed:", error);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
@@ -32,3 +32,4 @@ export * from "./verifySiteResourceAccess";
|
||||
export * from "./logActionAudit";
|
||||
export * from "./verifyOlmAccess";
|
||||
export * from "./verifyLimits";
|
||||
export * from "./verifyResourcePolicyAccess";
|
||||
|
||||
@@ -16,3 +16,4 @@ export * from "./verifyApiKeyClientAccess";
|
||||
export * from "./verifyApiKeySiteResourceAccess";
|
||||
export * from "./verifyApiKeyIdpAccess";
|
||||
export * from "./verifyApiKeyDomainAccess";
|
||||
export * from "./verifyApiKeyResourcePolicyAccess";
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
import { Request, Response, NextFunction } from "express";
|
||||
import { db } from "@server/db";
|
||||
import { resourcePolicies, apiKeyOrg } from "@server/db";
|
||||
import { eq, and } from "drizzle-orm";
|
||||
import createHttpError from "http-errors";
|
||||
import HttpCode from "@server/types/HttpCode";
|
||||
|
||||
export async function verifyApiKeyResourcePolicyAccess(
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction
|
||||
) {
|
||||
const apiKey = req.apiKey;
|
||||
const resourcePolicyId =
|
||||
req.params.resourcePolicyId ||
|
||||
req.body.resourcePolicyId ||
|
||||
req.query.resourcePolicyId;
|
||||
|
||||
if (!apiKey) {
|
||||
return next(
|
||||
createHttpError(HttpCode.UNAUTHORIZED, "Key not authenticated")
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
// Retrieve the resource policy
|
||||
const [policy] = await db
|
||||
.select()
|
||||
.from(resourcePolicies)
|
||||
.where(eq(resourcePolicies.resourcePolicyId, resourcePolicyId))
|
||||
.limit(1);
|
||||
|
||||
if (!policy) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.NOT_FOUND,
|
||||
`Resource policy with ID ${resourcePolicyId} not found`
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
if (apiKey.isRoot) {
|
||||
// Root keys can access any resource policy in any org
|
||||
return next();
|
||||
}
|
||||
|
||||
if (!policy.orgId) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.INTERNAL_SERVER_ERROR,
|
||||
`Resource policy with ID ${resourcePolicyId} does not have an organization ID`
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
// Verify that the API key is linked to the resource policy's organization
|
||||
if (!req.apiKeyOrg) {
|
||||
const apiKeyOrgResult = await db
|
||||
.select()
|
||||
.from(apiKeyOrg)
|
||||
.where(
|
||||
and(
|
||||
eq(apiKeyOrg.apiKeyId, apiKey.apiKeyId),
|
||||
eq(apiKeyOrg.orgId, policy.orgId)
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
if (apiKeyOrgResult.length > 0) {
|
||||
req.apiKeyOrg = apiKeyOrgResult[0];
|
||||
}
|
||||
}
|
||||
|
||||
if (!req.apiKeyOrg) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.FORBIDDEN,
|
||||
"Key does not have access to this organization"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
return next();
|
||||
} catch (error) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.INTERNAL_SERVER_ERROR,
|
||||
"Error verifying resource policy access"
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
import { Request, Response, NextFunction } from "express";
|
||||
import { db } from "@server/db";
|
||||
import { resourcePolicies, userOrgs } from "@server/db";
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import createHttpError from "http-errors";
|
||||
import HttpCode from "@server/types/HttpCode";
|
||||
import { checkOrgAccessPolicy } from "#dynamic/lib/checkOrgAccessPolicy";
|
||||
import { getUserOrgRoleIds } from "@server/lib/userOrgRoles";
|
||||
|
||||
export async function verifyResourcePolicyAccess(
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction
|
||||
) {
|
||||
const userId = req.user!.userId;
|
||||
const resourcePolicyIdStr =
|
||||
req.params?.resourcePolicyId ||
|
||||
req.body?.resourcePolicyId ||
|
||||
req.query?.resourcePolicyId;
|
||||
const niceId = req.params?.niceId || req.body?.niceId || req.query?.niceId;
|
||||
const orgId = req.params?.orgId || req.body?.orgId || req.query?.orgId;
|
||||
|
||||
try {
|
||||
if (!userId) {
|
||||
return next(
|
||||
createHttpError(HttpCode.UNAUTHORIZED, "User not authenticated")
|
||||
);
|
||||
}
|
||||
|
||||
let policy: typeof resourcePolicies.$inferSelect | null = null;
|
||||
|
||||
if (orgId && niceId) {
|
||||
const [policyRes] = await db
|
||||
.select()
|
||||
.from(resourcePolicies)
|
||||
.where(
|
||||
and(
|
||||
eq(resourcePolicies.niceId, niceId),
|
||||
eq(resourcePolicies.orgId, orgId)
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
policy = policyRes ?? null;
|
||||
} else {
|
||||
const resourcePolicyId = parseInt(resourcePolicyIdStr);
|
||||
if (isNaN(resourcePolicyId)) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
"Invalid resource policy ID"
|
||||
)
|
||||
);
|
||||
}
|
||||
const [policyRes] = await db
|
||||
.select()
|
||||
.from(resourcePolicies)
|
||||
.where(eq(resourcePolicies.resourcePolicyId, resourcePolicyId))
|
||||
.limit(1);
|
||||
policy = policyRes ?? null;
|
||||
}
|
||||
|
||||
if (!policy) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.NOT_FOUND,
|
||||
`Resource policy with ID ${resourcePolicyIdStr ?? niceId} not found`
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
if (!req.userOrg) {
|
||||
const userOrgRes = await db
|
||||
.select()
|
||||
.from(userOrgs)
|
||||
.where(
|
||||
and(
|
||||
eq(userOrgs.userId, userId),
|
||||
eq(userOrgs.orgId, policy.orgId)
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
req.userOrg = userOrgRes[0];
|
||||
}
|
||||
|
||||
if (!req.userOrg || req.userOrg.orgId !== policy.orgId) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.FORBIDDEN,
|
||||
"User does not have access to this organization"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
if (req.orgPolicyAllowed === undefined && req.userOrg.orgId) {
|
||||
const policyCheck = await checkOrgAccessPolicy({
|
||||
orgId: req.userOrg.orgId,
|
||||
userId,
|
||||
session: req.session
|
||||
});
|
||||
req.orgPolicyAllowed = policyCheck.allowed;
|
||||
if (!policyCheck.allowed || policyCheck.error) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.FORBIDDEN,
|
||||
"Failed organization access policy check: " +
|
||||
(policyCheck.error || "Unknown error")
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
req.userOrgRoleIds = await getUserOrgRoleIds(
|
||||
req.userOrg.userId,
|
||||
policy.orgId
|
||||
);
|
||||
req.userOrgId = policy.orgId;
|
||||
|
||||
return next();
|
||||
} catch (error) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.INTERNAL_SERVER_ERROR,
|
||||
"Error verifying resource policy access"
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -38,7 +38,7 @@ export function verifyUserCanSetUserOrgRoles() {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.FORBIDDEN,
|
||||
"User does not have permission perform this action"
|
||||
"User does not have permission to set user organization roles"
|
||||
)
|
||||
);
|
||||
} catch (error) {
|
||||
|
||||
@@ -7,6 +7,7 @@ export enum OpenAPITags {
|
||||
Org = "Organization",
|
||||
PublicResource = "Public Resource",
|
||||
PrivateResource = "Private Resource",
|
||||
Policy = "Policy",
|
||||
Role = "Role",
|
||||
User = "User",
|
||||
Invitation = "User Invitation",
|
||||
|
||||
@@ -780,9 +780,9 @@ async function syncAcmeCerts(acmeJsonPath: string): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
logger.debug(
|
||||
`acmeCertSync: cert for ${mainDomain} covers ${allDomains.size} domain(s): ${[...allDomains].join(", ")}`
|
||||
);
|
||||
// logger.debug(
|
||||
// `acmeCertSync: cert for ${mainDomain} covers ${allDomains.size} domain(s): ${[...allDomains].join(", ")}`
|
||||
// );
|
||||
|
||||
for (const domain of allDomains) {
|
||||
try {
|
||||
|
||||
@@ -36,8 +36,6 @@ export async function getValidCertificatesForDomains(
|
||||
domains: Set<string>,
|
||||
useCache: boolean = true
|
||||
): Promise<Array<CertificateResult>> {
|
||||
|
||||
|
||||
const finalResults: CertificateResult[] = [];
|
||||
const domainsToQuery = new Set<string>();
|
||||
|
||||
@@ -49,7 +47,26 @@ export async function getValidCertificatesForDomains(
|
||||
if (cachedCert) {
|
||||
finalResults.push(cachedCert); // Valid cache hit
|
||||
} else {
|
||||
domainsToQuery.add(domain); // Cache miss or expired
|
||||
// Also check for a wildcard cache entry covering this domain's parent
|
||||
const parts = domain.split(".");
|
||||
let wildcardHit = false;
|
||||
if (parts.length > 1) {
|
||||
const parentDomain = parts.slice(1).join(".");
|
||||
const wildcardCacheKey = `cert:*.${parentDomain}`;
|
||||
const cachedWildcard =
|
||||
await cache.get<CertificateResult>(wildcardCacheKey);
|
||||
if (cachedWildcard) {
|
||||
// Re-stamp queriedDomain so callers see the originally requested domain
|
||||
finalResults.push({
|
||||
...cachedWildcard,
|
||||
queriedDomain: domain
|
||||
});
|
||||
wildcardHit = true;
|
||||
}
|
||||
}
|
||||
if (!wildcardHit) {
|
||||
domainsToQuery.add(domain); // Cache miss or expired
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
@@ -59,7 +76,10 @@ export async function getValidCertificatesForDomains(
|
||||
|
||||
// 2. If all domains were resolved from the cache, return early
|
||||
if (domainsToQuery.size === 0) {
|
||||
const decryptedResults = decryptFinalResults(finalResults, config.getRawConfig().server.secret!);
|
||||
const decryptedResults = decryptFinalResults(
|
||||
finalResults,
|
||||
config.getRawConfig().server.secret!
|
||||
);
|
||||
return decryptedResults;
|
||||
}
|
||||
|
||||
@@ -78,7 +98,8 @@ export async function getValidCertificatesForDomains(
|
||||
const parentDomainsArray = Array.from(parentDomainsToQuery);
|
||||
|
||||
// Build wildcard variants: for each parent domain "example.com", also query "*.example.com"
|
||||
const wildcardPrefixedArray = build != "saas" ? parentDomainsArray.map((d) => `*.${d}`) : [];
|
||||
const wildcardPrefixedArray =
|
||||
build != "saas" ? parentDomainsArray.map((d) => `*.${d}`) : [];
|
||||
|
||||
// 4. Build and execute a single, efficient Drizzle query
|
||||
// This query fetches all potential exact and wildcard matches in one database round-trip.
|
||||
@@ -172,11 +193,24 @@ export async function getValidCertificatesForDomains(
|
||||
if (useCache) {
|
||||
const cacheKey = `cert:${domain}`;
|
||||
await cache.set(cacheKey, resultCert, 180);
|
||||
|
||||
// Also cache wildcard certs under a pattern key so other subdomains
|
||||
// can find them without a DB round-trip
|
||||
if (resultCert.wildcard) {
|
||||
const normalizedCertDomain = normalizeWildcardDomain(
|
||||
resultCert.domain
|
||||
);
|
||||
const wildcardCacheKey = `cert:*.${normalizedCertDomain}`;
|
||||
await cache.set(wildcardCacheKey, resultCert, 180);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const decryptedResults = decryptFinalResults(finalResults, config.getRawConfig().server.secret!);
|
||||
const decryptedResults = decryptFinalResults(
|
||||
finalResults,
|
||||
config.getRawConfig().server.secret!
|
||||
);
|
||||
return decryptedResults;
|
||||
}
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
*/
|
||||
|
||||
import {
|
||||
browserGatewayTarget,
|
||||
certificates,
|
||||
db,
|
||||
domainNamespaces,
|
||||
@@ -95,9 +96,7 @@ export async function getTraefikConfig(
|
||||
resourceName: resources.name,
|
||||
fullDomain: resources.fullDomain,
|
||||
ssl: resources.ssl,
|
||||
http: resources.http,
|
||||
proxyPort: resources.proxyPort,
|
||||
protocol: resources.protocol,
|
||||
subdomain: resources.subdomain,
|
||||
domainId: resources.domainId,
|
||||
enabled: resources.enabled,
|
||||
@@ -109,6 +108,7 @@ export async function getTraefikConfig(
|
||||
proxyProtocol: resources.proxyProtocol,
|
||||
proxyProtocolVersion: resources.proxyProtocolVersion,
|
||||
wildcard: resources.wildcard,
|
||||
mode: resources.mode,
|
||||
|
||||
maintenanceModeEnabled: resources.maintenanceModeEnabled,
|
||||
maintenanceModeType: resources.maintenanceModeType,
|
||||
@@ -171,8 +171,8 @@ export async function getTraefikConfig(
|
||||
),
|
||||
inArray(sites.type, siteTypes),
|
||||
allowRawResources
|
||||
? isNotNull(resources.http) // ignore the http check if allow_raw_resources is true
|
||||
: eq(resources.http, true)
|
||||
? inArray(resources.mode, ["http", "udp", "tcp"]) // allow all three
|
||||
: eq(resources.mode, "http")
|
||||
)
|
||||
)
|
||||
.orderBy(desc(targets.priority), targets.targetId); // stable ordering
|
||||
@@ -226,9 +226,8 @@ export async function getTraefikConfig(
|
||||
key: key,
|
||||
fullDomain: row.fullDomain,
|
||||
ssl: row.ssl,
|
||||
http: row.http,
|
||||
proxyPort: row.proxyPort,
|
||||
protocol: row.protocol,
|
||||
mode: row.mode,
|
||||
subdomain: row.subdomain,
|
||||
domainId: row.domainId,
|
||||
enabled: row.enabled,
|
||||
@@ -277,10 +276,119 @@ export async function getTraefikConfig(
|
||||
});
|
||||
});
|
||||
|
||||
// Query browser gateway targets for this exit node
|
||||
const browserGatewayRows = await db
|
||||
.select({
|
||||
// Resource fields
|
||||
resourceId: resources.resourceId,
|
||||
resourceName: resources.name,
|
||||
fullDomain: resources.fullDomain,
|
||||
ssl: resources.ssl,
|
||||
subdomain: resources.subdomain,
|
||||
domainId: resources.domainId,
|
||||
enabled: resources.enabled,
|
||||
wildcard: resources.wildcard,
|
||||
domainCertResolver: domains.certResolver,
|
||||
preferWildcardCert: domains.preferWildcardCert,
|
||||
domainNamespaceId: domainNamespaces.domainNamespaceId,
|
||||
// Browser gateway target fields
|
||||
browserGatewayTargetId: browserGatewayTarget.browserGatewayTargetId,
|
||||
bgType: browserGatewayTarget.type,
|
||||
// Site fields
|
||||
siteId: sites.siteId,
|
||||
siteType: sites.type,
|
||||
siteOnline: sites.online,
|
||||
subnet: sites.subnet,
|
||||
siteExitNodeId: sites.exitNodeId
|
||||
})
|
||||
.from(browserGatewayTarget)
|
||||
.innerJoin(sites, eq(sites.siteId, browserGatewayTarget.siteId))
|
||||
.innerJoin(
|
||||
resources,
|
||||
eq(resources.resourceId, browserGatewayTarget.resourceId)
|
||||
)
|
||||
.leftJoin(domains, eq(domains.domainId, resources.domainId))
|
||||
.leftJoin(
|
||||
domainNamespaces,
|
||||
eq(domainNamespaces.domainId, resources.domainId)
|
||||
)
|
||||
.where(
|
||||
and(
|
||||
eq(resources.enabled, true),
|
||||
or(
|
||||
eq(sites.exitNodeId, exitNodeId),
|
||||
and(
|
||||
isNull(sites.exitNodeId),
|
||||
sql`(${siteTypes.includes("local") ? 1 : 0} = 1)`,
|
||||
eq(sites.type, "local"),
|
||||
sql`(${build != "saas" ? 1 : 0} = 1)`
|
||||
)
|
||||
),
|
||||
inArray(sites.type, siteTypes)
|
||||
)
|
||||
);
|
||||
|
||||
// Group browser gateway targets by resource
|
||||
type BrowserGatewayResourceEntry = {
|
||||
resourceId: number;
|
||||
name: string;
|
||||
fullDomain: string | null;
|
||||
ssl: boolean | null;
|
||||
subdomain: string | null;
|
||||
domainId: string | null;
|
||||
enabled: boolean | null;
|
||||
wildcard: boolean | null;
|
||||
domainCertResolver: string | null;
|
||||
preferWildcardCert: boolean | null;
|
||||
targets: {
|
||||
browserGatewayTargetId: number;
|
||||
bgType: string;
|
||||
siteId: number;
|
||||
siteType: string;
|
||||
siteOnline: boolean | null;
|
||||
subnet: string | null;
|
||||
siteExitNodeId: number | null;
|
||||
}[];
|
||||
};
|
||||
const browserGatewayResourcesMap = new Map<
|
||||
number,
|
||||
BrowserGatewayResourceEntry
|
||||
>();
|
||||
|
||||
for (const row of browserGatewayRows) {
|
||||
if (filterOutNamespaceDomains && row.domainNamespaceId) {
|
||||
continue;
|
||||
}
|
||||
if (!browserGatewayResourcesMap.has(row.resourceId)) {
|
||||
browserGatewayResourcesMap.set(row.resourceId, {
|
||||
resourceId: row.resourceId,
|
||||
name: sanitize(row.resourceName) || "",
|
||||
fullDomain: row.fullDomain,
|
||||
ssl: row.ssl,
|
||||
subdomain: row.subdomain,
|
||||
domainId: row.domainId,
|
||||
enabled: row.enabled,
|
||||
wildcard: row.wildcard,
|
||||
domainCertResolver: row.domainCertResolver,
|
||||
preferWildcardCert: row.preferWildcardCert,
|
||||
targets: []
|
||||
});
|
||||
}
|
||||
browserGatewayResourcesMap.get(row.resourceId)!.targets.push({
|
||||
browserGatewayTargetId: row.browserGatewayTargetId,
|
||||
bgType: row.bgType,
|
||||
siteId: row.siteId,
|
||||
siteType: row.siteType,
|
||||
siteOnline: row.siteOnline,
|
||||
subnet: row.subnet,
|
||||
siteExitNodeId: row.siteExitNodeId
|
||||
});
|
||||
}
|
||||
|
||||
let siteResourcesWithFullDomain: {
|
||||
siteResourceId: number;
|
||||
fullDomain: string | null;
|
||||
mode: "http" | "host" | "cidr";
|
||||
mode: "http" | "host" | "cidr" | "ssh";
|
||||
}[] = [];
|
||||
if (build == "enterprise") {
|
||||
// we dont want to do this on the cloud
|
||||
@@ -324,6 +432,12 @@ export async function getTraefikConfig(
|
||||
domains.add(sr.fullDomain);
|
||||
}
|
||||
}
|
||||
// Include browser gateway resource domains
|
||||
for (const bgResource of browserGatewayResourcesMap.values()) {
|
||||
if (bgResource.enabled && bgResource.ssl && bgResource.fullDomain) {
|
||||
domains.add(bgResource.fullDomain);
|
||||
}
|
||||
}
|
||||
// get the valid certs for these domains
|
||||
validCerts = await getValidCertificatesForDomains(domains, true); // we are caching here because this is called often
|
||||
// logger.debug(`Valid certs for domains: ${JSON.stringify(validCerts)}`);
|
||||
@@ -589,7 +703,7 @@ export async function getTraefikConfig(
|
||||
resource.ssl ? entrypointHttps : entrypointHttp
|
||||
],
|
||||
service: maintenanceServiceName,
|
||||
rule: `${rule} && (PathPrefix(\`/_next\`) || PathRegexp(\`^/__nextjs*\`))`,
|
||||
rule: `${rule} && (PathPrefix(\`/_next\`) || PathRegexp(\`^/__nextjs*\`) || Path(\`/favicon.ico\`)) `,
|
||||
priority: 2001,
|
||||
...(resource.ssl ? { tls } : {})
|
||||
};
|
||||
@@ -830,7 +944,7 @@ export async function getTraefikConfig(
|
||||
continue;
|
||||
}
|
||||
|
||||
const protocol = resource.protocol.toLowerCase();
|
||||
const protocol = resource.mode == "udp" ? "udp" : "tcp";
|
||||
const port = resource.proxyPort;
|
||||
|
||||
if (!port) {
|
||||
@@ -925,6 +1039,185 @@ export async function getTraefikConfig(
|
||||
}
|
||||
}
|
||||
|
||||
// Generate Traefik config for browser gateway resources
|
||||
const browserGatewayPort = 39999;
|
||||
for (const [, bgResource] of browserGatewayResourcesMap.entries()) {
|
||||
if (!bgResource.enabled) continue;
|
||||
if (!bgResource.domainId) continue;
|
||||
if (!bgResource.fullDomain) continue;
|
||||
|
||||
if (!config_output.http.routers) config_output.http.routers = {};
|
||||
if (!config_output.http.services) config_output.http.services = {};
|
||||
|
||||
const fullDomain = bgResource.fullDomain;
|
||||
const additionalMiddlewares =
|
||||
config.getRawConfig().traefik.additional_middlewares || [];
|
||||
const routerMiddlewares = [
|
||||
badgerMiddlewareName,
|
||||
...additionalMiddlewares
|
||||
];
|
||||
|
||||
const hostRule = `Host(\`${fullDomain}\`)`;
|
||||
|
||||
// Build TLS config
|
||||
let tls = {};
|
||||
if (!privateConfig.getRawPrivateConfig().flags.use_pangolin_dns) {
|
||||
const domainParts = fullDomain.split(".");
|
||||
let wildCard: string;
|
||||
if (domainParts.length <= 2) {
|
||||
wildCard = `*.${domainParts.join(".")}`;
|
||||
} else {
|
||||
wildCard = `*.${domainParts.slice(1).join(".")}`;
|
||||
}
|
||||
if (!bgResource.subdomain) {
|
||||
wildCard = fullDomain;
|
||||
}
|
||||
|
||||
const globalDefaultResolver =
|
||||
config.getRawConfig().traefik.cert_resolver;
|
||||
const globalDefaultPreferWildcard =
|
||||
config.getRawConfig().traefik.prefer_wildcard_cert;
|
||||
const resolverName = bgResource.domainCertResolver
|
||||
? bgResource.domainCertResolver.trim()
|
||||
: globalDefaultResolver;
|
||||
const preferWildcard =
|
||||
bgResource.preferWildcardCert !== undefined &&
|
||||
bgResource.preferWildcardCert !== null
|
||||
? bgResource.preferWildcardCert
|
||||
: globalDefaultPreferWildcard;
|
||||
|
||||
tls = {
|
||||
certResolver: resolverName,
|
||||
...(preferWildcard ? { domains: [{ main: wildCard }] } : {})
|
||||
};
|
||||
} else {
|
||||
const matchingCert = validCerts.find(
|
||||
(cert) => cert.queriedDomain === fullDomain
|
||||
);
|
||||
if (!matchingCert) {
|
||||
logger.debug(
|
||||
`No matching certificate found for browser gateway domain: ${fullDomain}`
|
||||
);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
const bgUiServiceName = `bg-r${bgResource.resourceId}-ui-service`;
|
||||
|
||||
if (bgResource.ssl) {
|
||||
const redirectRouterName = `bg-r${bgResource.resourceId}-redirect`;
|
||||
config_output.http.routers![redirectRouterName] = {
|
||||
entryPoints: [config.getRawConfig().traefik.http_entrypoint],
|
||||
middlewares: [redirectHttpsMiddlewareName],
|
||||
service: bgUiServiceName,
|
||||
rule: hostRule,
|
||||
priority: 100
|
||||
};
|
||||
}
|
||||
|
||||
// Collect online sites for this resource (for any type)
|
||||
const anySiteOnline = bgResource.targets.some((t) => t.siteOnline);
|
||||
|
||||
// Group targets by type and generate per-type websocket routers and services
|
||||
const typeMap = new Map<string, typeof bgResource.targets>();
|
||||
for (const t of bgResource.targets) {
|
||||
if (!typeMap.has(t.bgType)) typeMap.set(t.bgType, []);
|
||||
typeMap.get(t.bgType)!.push(t);
|
||||
}
|
||||
|
||||
for (const [bgType, typedTargets] of typeMap.entries()) {
|
||||
const bgKey = `bg-r${bgResource.resourceId}-${bgType}`;
|
||||
const bgRouterName = `${bgKey}-router`;
|
||||
const bgServiceName = `${bgKey}-service`;
|
||||
const bgRule = `${hostRule} && PathPrefix(\`/gateway/${bgType}\`)`;
|
||||
|
||||
const servers = typedTargets
|
||||
.filter((t) => {
|
||||
if (!t.siteOnline && anySiteOnline) return false;
|
||||
if (t.siteType === "newt") return !!t.subnet;
|
||||
return false; // browser gateway only supported on newt sites
|
||||
})
|
||||
.map((t) => ({
|
||||
url: `http://${t.subnet!.split("/")[0]}:${browserGatewayPort}`
|
||||
}))
|
||||
.filter((v, i, a) => a.findIndex((u) => u.url === v.url) === i);
|
||||
|
||||
config_output.http.routers![bgRouterName] = {
|
||||
entryPoints: [
|
||||
bgResource.ssl
|
||||
? config.getRawConfig().traefik.https_entrypoint
|
||||
: config.getRawConfig().traefik.http_entrypoint
|
||||
],
|
||||
middlewares: routerMiddlewares,
|
||||
service: bgServiceName,
|
||||
rule: bgRule,
|
||||
priority: 110, // highest - websocket path takes precedence
|
||||
...(bgResource.ssl ? { tls } : {})
|
||||
};
|
||||
|
||||
config_output.http.services![bgServiceName] = {
|
||||
loadBalancer: {
|
||||
servers
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// UI: serve the browser gateway page from the internal pangolin instance.
|
||||
// The primary type is used for the path rewrite (e.g. /rdp), mirroring
|
||||
// how the maintenance page rewrites everything to /maintenance-screen.
|
||||
const primaryType = typeMap.keys().next().value as string;
|
||||
const internalHost = config.getRawConfig().server.internal_hostname;
|
||||
const internalPort = config.getRawConfig().server.next_port;
|
||||
const uiRewriteMiddlewareName = `bg-r${bgResource.resourceId}-ui-rewrite`;
|
||||
const entrypoint = bgResource.ssl
|
||||
? config.getRawConfig().traefik.https_entrypoint
|
||||
: config.getRawConfig().traefik.http_entrypoint;
|
||||
|
||||
if (!config_output.http.middlewares) {
|
||||
config_output.http.middlewares = {};
|
||||
}
|
||||
|
||||
config_output.http.middlewares![uiRewriteMiddlewareName] = {
|
||||
replacePathRegex: {
|
||||
regex: "^/(.*)",
|
||||
replacement: `/${primaryType}`
|
||||
}
|
||||
};
|
||||
|
||||
config_output.http.services![bgUiServiceName] = {
|
||||
loadBalancer: {
|
||||
servers: [
|
||||
{
|
||||
url: `http://${internalHost}:${internalPort}`
|
||||
}
|
||||
]
|
||||
}
|
||||
};
|
||||
|
||||
// Assets router at higher priority so /_next files load without rewrite
|
||||
config_output.http.routers![
|
||||
`bg-r${bgResource.resourceId}-assets-router`
|
||||
] = {
|
||||
entryPoints: [entrypoint],
|
||||
middlewares: routerMiddlewares,
|
||||
service: bgUiServiceName,
|
||||
rule: `${hostRule} && (PathPrefix(\`/_next\`) || PathRegexp(\`^/__nextjs*\`) || Path(\`/favicon.ico\`))`,
|
||||
priority: 101,
|
||||
...(bgResource.ssl ? { tls } : {})
|
||||
};
|
||||
|
||||
// Catch-all router rewrites everything on the domain to /{primaryType}
|
||||
config_output.http.routers![`bg-r${bgResource.resourceId}-ui-router`] =
|
||||
{
|
||||
entryPoints: [entrypoint],
|
||||
middlewares: [...routerMiddlewares, uiRewriteMiddlewareName],
|
||||
service: bgUiServiceName,
|
||||
rule: hostRule,
|
||||
priority: 100,
|
||||
...(bgResource.ssl ? { tls } : {})
|
||||
};
|
||||
}
|
||||
|
||||
// Add Traefik routes for siteResource aliases (HTTP mode + SSL) so that
|
||||
// Traefik generates TLS certificates for those domains even when no
|
||||
// matching resource exists yet.
|
||||
@@ -1040,7 +1333,7 @@ export async function getTraefikConfig(
|
||||
config_output.http.routers[`${siteResourceRouterName}-assets`] = {
|
||||
entryPoints: [config.getRawConfig().traefik.https_entrypoint],
|
||||
service: siteResourceServiceName,
|
||||
rule: `Host(\`${fullDomain}\`) && (PathPrefix(\`/_next\`) || PathRegexp(\`^/__nextjs*\`))`,
|
||||
rule: `Host(\`${fullDomain}\`) && (PathPrefix(\`/_next\`) || PathRegexp(\`^/__nextjs*\`) || Path(\`/favicon.ico\`))`,
|
||||
priority: 101,
|
||||
tls
|
||||
};
|
||||
@@ -1143,7 +1436,7 @@ export async function getTraefikConfig(
|
||||
config.getRawConfig().traefik.https_entrypoint
|
||||
],
|
||||
service: "landing-service",
|
||||
rule: `Host(\`${fullDomain}\`) && (PathRegexp(\`^/auth/resource/[^/]+$\`) || PathRegexp(\`^/auth/idp/[0-9]+/oidc/callback\`) || PathPrefix(\`/_next\`) || Path(\`/auth/org\`) || PathRegexp(\`^/__nextjs*\`))`,
|
||||
rule: `Host(\`${fullDomain}\`) && (PathRegexp(\`^/auth/resource/[^/]+$\`) || PathRegexp(\`^/auth/idp/[0-9]+/oidc/callback\`) || PathPrefix(\`/_next\`) || Path(\`/auth/org\`) || PathRegexp(\`^/__nextjs*\`) || Path(\`/favicon.ico\`))`,
|
||||
priority: 203,
|
||||
tls: tls
|
||||
};
|
||||
|
||||
@@ -93,6 +93,20 @@ export const queryAccessAuditLogsCombined = queryAccessAuditLogsQuery.merge(
|
||||
);
|
||||
type Q = z.infer<typeof queryAccessAuditLogsCombined>;
|
||||
|
||||
function sortNamedFilterOptions<T extends { id: number; name: string | null }>(
|
||||
items: T[]
|
||||
): T[] {
|
||||
return [...items].sort((a, b) => {
|
||||
const nameA = a.name ?? "";
|
||||
const nameB = b.name ?? "";
|
||||
|
||||
if (nameA < nameB) return -1;
|
||||
if (nameA > nameB) return 1;
|
||||
|
||||
return a.id - b.id;
|
||||
});
|
||||
}
|
||||
|
||||
function getWhere(data: Q) {
|
||||
return and(
|
||||
gt(accessAuditLog.timestamp, data.timeStart),
|
||||
@@ -308,7 +322,7 @@ async function queryUniqueFilterAttributes(
|
||||
actors: uniqueActors
|
||||
.map((row) => row.actor)
|
||||
.filter((actor): actor is string => actor !== null),
|
||||
resources: resourcesWithNames,
|
||||
resources: sortNamedFilterOptions(resourcesWithNames),
|
||||
locations: uniqueLocations
|
||||
.map((row) => row.locations)
|
||||
.filter((location): location is string => location !== null)
|
||||
|
||||
@@ -107,6 +107,20 @@ export const queryConnectionAuditLogsCombined =
|
||||
queryConnectionAuditLogsQuery.merge(queryConnectionAuditLogsParams);
|
||||
type Q = z.infer<typeof queryConnectionAuditLogsCombined>;
|
||||
|
||||
function sortNamedFilterOptions<T extends { id: number; name: string | null }>(
|
||||
items: T[]
|
||||
): T[] {
|
||||
return [...items].sort((a, b) => {
|
||||
const nameA = a.name ?? "";
|
||||
const nameB = b.name ?? "";
|
||||
|
||||
if (nameA < nameB) return -1;
|
||||
if (nameA > nameB) return 1;
|
||||
|
||||
return a.id - b.id;
|
||||
});
|
||||
}
|
||||
|
||||
function getWhere(data: Q) {
|
||||
return and(
|
||||
gt(connectionAuditLog.startedAt, data.timeStart),
|
||||
@@ -425,7 +439,7 @@ async function queryUniqueFilterAttributes(
|
||||
.map((row) => row.destAddr)
|
||||
.filter((addr): addr is string => addr !== null),
|
||||
clients: clientsWithNames,
|
||||
resources: resourcesWithNames,
|
||||
resources: sortNamedFilterOptions(resourcesWithNames),
|
||||
users: usersWithEmails
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,187 @@
|
||||
/*
|
||||
* This file is part of a proprietary work.
|
||||
*
|
||||
* Copyright (c) 2025-2026 Fossorial, Inc.
|
||||
* All rights reserved.
|
||||
*
|
||||
* This file is licensed under the Fossorial Commercial License.
|
||||
* You may not use this file except in compliance with the License.
|
||||
* Unauthorized use, copying, modification, or distribution is strictly prohibited.
|
||||
*
|
||||
* This file is not licensed under the AGPLv3.
|
||||
*/
|
||||
|
||||
import { Request, Response, NextFunction } from "express";
|
||||
import { z } from "zod";
|
||||
import {
|
||||
browserGatewayTarget,
|
||||
BrowserGatewayTarget,
|
||||
db,
|
||||
newts,
|
||||
resources,
|
||||
sites
|
||||
} from "@server/db";
|
||||
import { eq, and } from "drizzle-orm";
|
||||
import response from "@server/lib/response";
|
||||
import HttpCode from "@server/types/HttpCode";
|
||||
import createHttpError from "http-errors";
|
||||
import logger from "@server/logger";
|
||||
import { fromError } from "zod-validation-error";
|
||||
import { OpenAPITags, registry } from "@server/openApi";
|
||||
import { encrypt } from "@server/lib/crypto";
|
||||
import config from "@server/lib/config";
|
||||
import { sendBrowserGatewayTargets } from "@server/routers/newt/targets";
|
||||
import { generateId } from "@server/auth/sessions/app";
|
||||
|
||||
const paramsSchema = z.strictObject({
|
||||
orgId: z.string().nonempty(),
|
||||
resourceId: z.string().transform(Number).pipe(z.number().int().positive())
|
||||
});
|
||||
|
||||
const bodySchema = z.strictObject({
|
||||
siteId: z.number().int().positive(),
|
||||
type: z.enum(["ssh", "rdp", "vnc"]),
|
||||
destination: z.string().nonempty(),
|
||||
destinationPort: z.number().int().min(1).max(65535)
|
||||
});
|
||||
|
||||
export type CreateBrowserGatewayTargetResponse = BrowserGatewayTarget;
|
||||
|
||||
registry.registerPath({
|
||||
method: "put",
|
||||
path: "/org/{orgId}/resource/{resourceId}/browser-gateway-target",
|
||||
description: "Create a browser gateway target for a resource.",
|
||||
tags: [OpenAPITags.Org],
|
||||
request: {
|
||||
params: paramsSchema,
|
||||
body: {
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: bodySchema
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
responses: {}
|
||||
});
|
||||
|
||||
export async function createBrowserGatewayTarget(
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction
|
||||
): Promise<any> {
|
||||
try {
|
||||
const parsedParams = paramsSchema.safeParse(req.params);
|
||||
if (!parsedParams.success) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
fromError(parsedParams.error).toString()
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const { orgId, resourceId } = parsedParams.data;
|
||||
|
||||
const parsedBody = bodySchema.safeParse(req.body);
|
||||
if (!parsedBody.success) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
fromError(parsedBody.error).toString()
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const { siteId, type, destination, destinationPort } = parsedBody.data;
|
||||
|
||||
const [resource] = await db
|
||||
.select()
|
||||
.from(resources)
|
||||
.where(
|
||||
and(
|
||||
eq(resources.resourceId, resourceId),
|
||||
eq(resources.orgId, orgId)
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
if (!resource) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.NOT_FOUND,
|
||||
`Resource with ID ${resourceId} not found in organization ${orgId}`
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const [site] = await db
|
||||
.select()
|
||||
.from(sites)
|
||||
.where(and(eq(sites.siteId, siteId), eq(sites.orgId, orgId)))
|
||||
.limit(1);
|
||||
|
||||
if (!site) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.NOT_FOUND,
|
||||
`Site with ID ${siteId} not found in organization ${orgId}`
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const plainToken = generateId(48);
|
||||
const encryptedToken = encrypt(
|
||||
plainToken,
|
||||
config.getRawConfig().server.secret!
|
||||
);
|
||||
|
||||
const [record] = await db
|
||||
.insert(browserGatewayTarget)
|
||||
.values({
|
||||
resourceId,
|
||||
siteId,
|
||||
type,
|
||||
destination,
|
||||
destinationPort,
|
||||
authToken: encryptedToken
|
||||
})
|
||||
.returning();
|
||||
|
||||
if (site.type === "newt") {
|
||||
const [newt] = await db
|
||||
.select()
|
||||
.from(newts)
|
||||
.where(eq(newts.siteId, siteId))
|
||||
.limit(1);
|
||||
|
||||
if (newt) {
|
||||
await sendBrowserGatewayTargets(
|
||||
newt.newtId,
|
||||
[record],
|
||||
newt.version
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
logger.info(
|
||||
`Created browser gateway target ${record.browserGatewayTargetId} for resource ${resourceId}`
|
||||
);
|
||||
|
||||
return response<CreateBrowserGatewayTargetResponse>(res, {
|
||||
data: record,
|
||||
success: true,
|
||||
error: false,
|
||||
message: "Browser gateway target created successfully",
|
||||
status: HttpCode.CREATED
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error(error);
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.INTERNAL_SERVER_ERROR,
|
||||
"Failed to create browser gateway target"
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
/*
|
||||
* This file is part of a proprietary work.
|
||||
*
|
||||
* Copyright (c) 2025-2026 Fossorial, Inc.
|
||||
* All rights reserved.
|
||||
*
|
||||
* This file is licensed under the Fossorial Commercial License.
|
||||
* You may not use this file except in compliance with the License.
|
||||
* Unauthorized use, copying, modification, or distribution is strictly prohibited.
|
||||
*
|
||||
* This file is not licensed under the AGPLv3.
|
||||
*/
|
||||
|
||||
import { Request, Response, NextFunction } from "express";
|
||||
import { z } from "zod";
|
||||
import { browserGatewayTarget, db, newts, sites } from "@server/db";
|
||||
import { eq, and } from "drizzle-orm";
|
||||
import response from "@server/lib/response";
|
||||
import HttpCode from "@server/types/HttpCode";
|
||||
import createHttpError from "http-errors";
|
||||
import logger from "@server/logger";
|
||||
import { fromError } from "zod-validation-error";
|
||||
import { OpenAPITags, registry } from "@server/openApi";
|
||||
import { removeBrowserGatewayTarget } from "@server/routers/newt/targets";
|
||||
|
||||
const paramsSchema = z.strictObject({
|
||||
orgId: z.string().nonempty(),
|
||||
browserGatewayTargetId: z
|
||||
.string()
|
||||
.transform(Number)
|
||||
.pipe(z.number().int().positive())
|
||||
});
|
||||
|
||||
registry.registerPath({
|
||||
method: "delete",
|
||||
path: "/org/{orgId}/browser-gateway-target/{browserGatewayTargetId}",
|
||||
description: "Delete a browser gateway target.",
|
||||
tags: [OpenAPITags.Org],
|
||||
request: {
|
||||
params: paramsSchema
|
||||
},
|
||||
responses: {}
|
||||
});
|
||||
|
||||
export async function deleteBrowserGatewayTarget(
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction
|
||||
): Promise<any> {
|
||||
try {
|
||||
const parsedParams = paramsSchema.safeParse(req.params);
|
||||
if (!parsedParams.success) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
fromError(parsedParams.error).toString()
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const { orgId, browserGatewayTargetId } = parsedParams.data;
|
||||
|
||||
const [existing] = await db
|
||||
.select({ bgt: browserGatewayTarget, site: sites })
|
||||
.from(browserGatewayTarget)
|
||||
.innerJoin(sites, eq(sites.siteId, browserGatewayTarget.siteId))
|
||||
.where(
|
||||
and(
|
||||
eq(
|
||||
browserGatewayTarget.browserGatewayTargetId,
|
||||
browserGatewayTargetId
|
||||
),
|
||||
eq(sites.orgId, orgId)
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
if (!existing) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.NOT_FOUND,
|
||||
`Browser gateway target with ID ${browserGatewayTargetId} not found`
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
await db
|
||||
.delete(browserGatewayTarget)
|
||||
.where(
|
||||
eq(
|
||||
browserGatewayTarget.browserGatewayTargetId,
|
||||
browserGatewayTargetId
|
||||
)
|
||||
);
|
||||
|
||||
if (existing.site.type === "newt") {
|
||||
const [newt] = await db
|
||||
.select()
|
||||
.from(newts)
|
||||
.where(eq(newts.siteId, existing.bgt.siteId))
|
||||
.limit(1);
|
||||
|
||||
if (newt) {
|
||||
await removeBrowserGatewayTarget(
|
||||
newt.newtId,
|
||||
browserGatewayTargetId,
|
||||
newt.version
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
logger.info(`Deleted browser gateway target ${browserGatewayTargetId}`);
|
||||
|
||||
return response(res, {
|
||||
data: null,
|
||||
success: true,
|
||||
error: false,
|
||||
message: "Browser gateway target deleted successfully",
|
||||
status: HttpCode.OK
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error(error);
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.INTERNAL_SERVER_ERROR,
|
||||
"Failed to delete browser gateway target"
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
/*
|
||||
* This file is part of a proprietary work.
|
||||
*
|
||||
* Copyright (c) 2025-2026 Fossorial, Inc.
|
||||
* All rights reserved.
|
||||
*
|
||||
* This file is licensed under the Fossorial Commercial License.
|
||||
* You may not use this file except in compliance with the License.
|
||||
* Unauthorized use, copying, modification, or distribution is strictly prohibited.
|
||||
*
|
||||
* This file is not licensed under the AGPLv3.
|
||||
*/
|
||||
|
||||
import { Request, Response, NextFunction } from "express";
|
||||
import { z } from "zod";
|
||||
import {
|
||||
browserGatewayTarget,
|
||||
BrowserGatewayTarget,
|
||||
db,
|
||||
sites
|
||||
} from "@server/db";
|
||||
import { eq, and } from "drizzle-orm";
|
||||
import response from "@server/lib/response";
|
||||
import HttpCode from "@server/types/HttpCode";
|
||||
import createHttpError from "http-errors";
|
||||
import logger from "@server/logger";
|
||||
import { fromError } from "zod-validation-error";
|
||||
import { OpenAPITags, registry } from "@server/openApi";
|
||||
|
||||
const paramsSchema = z.strictObject({
|
||||
orgId: z.string().nonempty(),
|
||||
browserGatewayTargetId: z
|
||||
.string()
|
||||
.transform(Number)
|
||||
.pipe(z.number().int().positive())
|
||||
});
|
||||
|
||||
export type GetBrowserGatewayTargetResponse = BrowserGatewayTarget;
|
||||
|
||||
registry.registerPath({
|
||||
method: "get",
|
||||
path: "/org/{orgId}/browser-gateway-target/{browserGatewayTargetId}",
|
||||
description: "Get a browser gateway target.",
|
||||
tags: [OpenAPITags.Org],
|
||||
request: {
|
||||
params: paramsSchema
|
||||
},
|
||||
responses: {}
|
||||
});
|
||||
|
||||
export async function getBrowserGatewayTarget(
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction
|
||||
): Promise<any> {
|
||||
try {
|
||||
const parsedParams = paramsSchema.safeParse(req.params);
|
||||
if (!parsedParams.success) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
fromError(parsedParams.error).toString()
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const { orgId, browserGatewayTargetId } = parsedParams.data;
|
||||
|
||||
const [result] = await db
|
||||
.select({ bgt: browserGatewayTarget })
|
||||
.from(browserGatewayTarget)
|
||||
.innerJoin(sites, eq(sites.siteId, browserGatewayTarget.siteId))
|
||||
.where(
|
||||
and(
|
||||
eq(
|
||||
browserGatewayTarget.browserGatewayTargetId,
|
||||
browserGatewayTargetId
|
||||
),
|
||||
eq(sites.orgId, orgId)
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
if (!result) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.NOT_FOUND,
|
||||
`Browser gateway target with ID ${browserGatewayTargetId} not found`
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
return response<GetBrowserGatewayTargetResponse>(res, {
|
||||
data: result.bgt,
|
||||
success: true,
|
||||
error: false,
|
||||
message: "Browser gateway target retrieved successfully",
|
||||
status: HttpCode.OK
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error(error);
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.INTERNAL_SERVER_ERROR,
|
||||
"Failed to retrieve browser gateway target"
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
/*
|
||||
* This file is part of a proprietary work.
|
||||
*
|
||||
* Copyright (c) 2025-2026 Fossorial, Inc.
|
||||
* All rights reserved.
|
||||
*
|
||||
* This file is licensed under the Fossorial Commercial License.
|
||||
* You may not use this file except in compliance with the License.
|
||||
* Unauthorized use, copying, modification, or distribution is strictly prohibited.
|
||||
*
|
||||
* This file is not licensed under the AGPLv3.
|
||||
*/
|
||||
|
||||
export * from "./createBrowserGatewayTarget";
|
||||
export * from "./updateBrowserGatewayTarget";
|
||||
export * from "./deleteBrowserGatewayTarget";
|
||||
export * from "./getBrowserGatewayTarget";
|
||||
export * from "./listBrowserGatewayTargets";
|
||||
@@ -0,0 +1,148 @@
|
||||
/*
|
||||
* This file is part of a proprietary work.
|
||||
*
|
||||
* Copyright (c) 2025-2026 Fossorial, Inc.
|
||||
* All rights reserved.
|
||||
*
|
||||
* This file is licensed under the Fossorial Commercial License.
|
||||
* You may not use this file except in compliance with the License.
|
||||
* Unauthorized use, copying, modification, or distribution is strictly prohibited.
|
||||
*
|
||||
* This file is not licensed under the AGPLv3.
|
||||
*/
|
||||
|
||||
import { Request, Response, NextFunction } from "express";
|
||||
import { z } from "zod";
|
||||
import {
|
||||
browserGatewayTarget,
|
||||
BrowserGatewayTarget,
|
||||
db,
|
||||
resources,
|
||||
sites
|
||||
} from "@server/db";
|
||||
import { eq, and } from "drizzle-orm";
|
||||
import response from "@server/lib/response";
|
||||
import HttpCode from "@server/types/HttpCode";
|
||||
import createHttpError from "http-errors";
|
||||
import logger from "@server/logger";
|
||||
import { fromError } from "zod-validation-error";
|
||||
import { OpenAPITags, registry } from "@server/openApi";
|
||||
|
||||
const paramsSchema = z.strictObject({
|
||||
orgId: z.string().nonempty(),
|
||||
resourceId: z.string().transform(Number).pipe(z.number().int().positive())
|
||||
});
|
||||
|
||||
const querySchema = z.object({
|
||||
limit: z
|
||||
.string()
|
||||
.optional()
|
||||
.default("1000")
|
||||
.transform(Number)
|
||||
.pipe(z.number().int().positive()),
|
||||
offset: z
|
||||
.string()
|
||||
.optional()
|
||||
.default("0")
|
||||
.transform(Number)
|
||||
.pipe(z.number().int().nonnegative())
|
||||
});
|
||||
|
||||
export type ListBrowserGatewayTargetsResponse = {
|
||||
targets: BrowserGatewayTarget[];
|
||||
total: number;
|
||||
limit: number;
|
||||
offset: number;
|
||||
};
|
||||
|
||||
registry.registerPath({
|
||||
method: "get",
|
||||
path: "/org/{orgId}/resource/{resourceId}/browser-gateway-targets",
|
||||
description: "List browser gateway targets for a resource.",
|
||||
tags: [OpenAPITags.Org],
|
||||
request: {
|
||||
params: paramsSchema,
|
||||
query: querySchema
|
||||
},
|
||||
responses: {}
|
||||
});
|
||||
|
||||
export async function listBrowserGatewayTargets(
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction
|
||||
): Promise<any> {
|
||||
try {
|
||||
const parsedParams = paramsSchema.safeParse(req.params);
|
||||
if (!parsedParams.success) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
fromError(parsedParams.error).toString()
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const { orgId, resourceId } = parsedParams.data;
|
||||
|
||||
const parsedQuery = querySchema.safeParse(req.query);
|
||||
if (!parsedQuery.success) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
fromError(parsedQuery.error).toString()
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const { limit, offset } = parsedQuery.data;
|
||||
|
||||
const [resource] = await db
|
||||
.select()
|
||||
.from(resources)
|
||||
.where(
|
||||
and(
|
||||
eq(resources.resourceId, resourceId),
|
||||
eq(resources.orgId, orgId)
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
if (!resource) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.NOT_FOUND,
|
||||
`Resource with ID ${resourceId} not found in organization ${orgId}`
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const targets = await db
|
||||
.select()
|
||||
.from(browserGatewayTarget)
|
||||
.where(eq(browserGatewayTarget.resourceId, resourceId))
|
||||
.limit(limit)
|
||||
.offset(offset);
|
||||
|
||||
return response<ListBrowserGatewayTargetsResponse>(res, {
|
||||
data: {
|
||||
targets: targets,
|
||||
total: targets.length,
|
||||
limit,
|
||||
offset
|
||||
},
|
||||
success: true,
|
||||
error: false,
|
||||
message: "Browser gateway targets retrieved successfully",
|
||||
status: HttpCode.OK
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error(error);
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.INTERNAL_SERVER_ERROR,
|
||||
"Failed to list browser gateway targets"
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
/*
|
||||
* This file is part of a proprietary work.
|
||||
*
|
||||
* Copyright (c) 2025-2026 Fossorial, Inc.
|
||||
* All rights reserved.
|
||||
*
|
||||
* This file is licensed under the Fossorial Commercial License.
|
||||
* You may not use this file except in compliance with the License.
|
||||
* Unauthorized use, copying, modification, or distribution is strictly prohibited.
|
||||
*
|
||||
* This file is not licensed under the AGPLv3.
|
||||
*/
|
||||
|
||||
import { Request, Response, NextFunction } from "express";
|
||||
import { z } from "zod";
|
||||
import {
|
||||
browserGatewayTarget,
|
||||
BrowserGatewayTarget,
|
||||
db,
|
||||
newts,
|
||||
sites
|
||||
} from "@server/db";
|
||||
import { eq, and } from "drizzle-orm";
|
||||
import response from "@server/lib/response";
|
||||
import HttpCode from "@server/types/HttpCode";
|
||||
import createHttpError from "http-errors";
|
||||
import logger from "@server/logger";
|
||||
import { fromError } from "zod-validation-error";
|
||||
import { OpenAPITags, registry } from "@server/openApi";
|
||||
import { sendBrowserGatewayTargets } from "@server/routers/newt/targets";
|
||||
|
||||
const paramsSchema = z.strictObject({
|
||||
orgId: z.string().nonempty(),
|
||||
browserGatewayTargetId: z
|
||||
.string()
|
||||
.transform(Number)
|
||||
.pipe(z.number().int().positive())
|
||||
});
|
||||
|
||||
const bodySchema = z.strictObject({
|
||||
siteId: z.number().int().positive().optional(),
|
||||
type: z.enum(["ssh", "rdp", "vnc"]).optional(),
|
||||
destination: z.string().nonempty().optional(),
|
||||
destinationPort: z.number().int().min(1).max(65535).optional()
|
||||
});
|
||||
|
||||
export type UpdateBrowserGatewayTargetResponse = BrowserGatewayTarget;
|
||||
|
||||
registry.registerPath({
|
||||
method: "post",
|
||||
path: "/org/{orgId}/browser-gateway-target/{browserGatewayTargetId}",
|
||||
description: "Update a browser gateway target.",
|
||||
tags: [OpenAPITags.Org],
|
||||
request: {
|
||||
params: paramsSchema,
|
||||
body: {
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: bodySchema
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
responses: {}
|
||||
});
|
||||
|
||||
export async function updateBrowserGatewayTarget(
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction
|
||||
): Promise<any> {
|
||||
try {
|
||||
const parsedParams = paramsSchema.safeParse(req.params);
|
||||
if (!parsedParams.success) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
fromError(parsedParams.error).toString()
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const { orgId, browserGatewayTargetId } = parsedParams.data;
|
||||
|
||||
const parsedBody = bodySchema.safeParse(req.body);
|
||||
if (!parsedBody.success) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
fromError(parsedBody.error).toString()
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const { siteId, type, destination, destinationPort } = parsedBody.data;
|
||||
|
||||
const [existing] = await db
|
||||
.select({ bgt: browserGatewayTarget, site: sites })
|
||||
.from(browserGatewayTarget)
|
||||
.innerJoin(sites, eq(sites.siteId, browserGatewayTarget.siteId))
|
||||
.where(
|
||||
and(
|
||||
eq(
|
||||
browserGatewayTarget.browserGatewayTargetId,
|
||||
browserGatewayTargetId
|
||||
),
|
||||
eq(sites.orgId, orgId)
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
if (!existing) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.NOT_FOUND,
|
||||
`Browser gateway target with ID ${browserGatewayTargetId} not found`
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const updateValues: Partial<BrowserGatewayTarget> = {};
|
||||
if (siteId !== undefined) updateValues.siteId = siteId;
|
||||
if (type !== undefined) updateValues.type = type;
|
||||
if (destination !== undefined) updateValues.destination = destination;
|
||||
if (destinationPort !== undefined)
|
||||
updateValues.destinationPort = destinationPort;
|
||||
|
||||
const [updated] = await db
|
||||
.update(browserGatewayTarget)
|
||||
.set(updateValues)
|
||||
.where(
|
||||
eq(
|
||||
browserGatewayTarget.browserGatewayTargetId,
|
||||
browserGatewayTargetId
|
||||
)
|
||||
)
|
||||
.returning();
|
||||
|
||||
const targetSiteId = siteId ?? existing.bgt.siteId;
|
||||
const [site] = await db
|
||||
.select()
|
||||
.from(sites)
|
||||
.where(eq(sites.siteId, targetSiteId))
|
||||
.limit(1);
|
||||
|
||||
if (site && site.type === "newt") {
|
||||
const [newt] = await db
|
||||
.select()
|
||||
.from(newts)
|
||||
.where(eq(newts.siteId, targetSiteId))
|
||||
.limit(1);
|
||||
|
||||
if (newt) {
|
||||
await sendBrowserGatewayTargets(
|
||||
newt.newtId,
|
||||
[updated],
|
||||
newt.version
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
logger.info(`Updated browser gateway target ${browserGatewayTargetId}`);
|
||||
|
||||
return response<UpdateBrowserGatewayTargetResponse>(res, {
|
||||
data: updated,
|
||||
success: true,
|
||||
error: false,
|
||||
message: "Browser gateway target updated successfully",
|
||||
status: HttpCode.OK
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error(error);
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.INTERNAL_SERVER_ERROR,
|
||||
"Failed to update browser gateway target"
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -31,8 +31,11 @@ import * as siteProvisioning from "#private/routers/siteProvisioning";
|
||||
import * as eventStreamingDestination from "#private/routers/eventStreamingDestination";
|
||||
import * as alertRule from "#private/routers/alertRule";
|
||||
import * as healthChecks from "#private/routers/healthChecks";
|
||||
import * as browserGatewayTarget from "#private/routers/browserGatewayTarget";
|
||||
import * as labels from "#private/routers/labels";
|
||||
import * as client from "@server/routers/client";
|
||||
import * as resource from "#private/routers/resource";
|
||||
import * as policy from "#private/routers/policy";
|
||||
|
||||
import {
|
||||
verifyOrgAccess,
|
||||
@@ -46,7 +49,8 @@ import {
|
||||
verifyUserCanSetUserOrgRoles,
|
||||
verifySiteProvisioningKeyAccess,
|
||||
verifyIsLoggedInUser,
|
||||
verifyAdmin
|
||||
verifyAdmin,
|
||||
verifyResourcePolicyAccess
|
||||
} from "@server/middlewares";
|
||||
import { ActionsEnum } from "@server/auth/actions";
|
||||
import {
|
||||
@@ -384,6 +388,39 @@ authenticated.get(
|
||||
approval.countApprovals
|
||||
);
|
||||
|
||||
authenticated.delete(
|
||||
"/resource-policy/:resourcePolicyId",
|
||||
verifyResourcePolicyAccess,
|
||||
verifyValidLicense,
|
||||
verifyValidSubscription(tierMatrix.resourcePolicies),
|
||||
verifyLimits,
|
||||
verifyUserHasAction(ActionsEnum.deleteResourcePolicy),
|
||||
logActionAudit(ActionsEnum.deleteResourcePolicy),
|
||||
policy.deleteResourcePolicy
|
||||
);
|
||||
|
||||
authenticated.get(
|
||||
"/org/:orgId/resource-policies",
|
||||
verifyValidLicense,
|
||||
verifyValidSubscription(tierMatrix.resourcePolicies),
|
||||
verifyOrgAccess,
|
||||
verifyLimits,
|
||||
verifyUserHasAction(ActionsEnum.listResourcePolicies),
|
||||
logActionAudit(ActionsEnum.listResourcePolicies),
|
||||
policy.listResourcePolicies
|
||||
);
|
||||
|
||||
authenticated.post(
|
||||
"/org/:orgId/resource-policy",
|
||||
verifyValidLicense,
|
||||
verifyValidSubscription(tierMatrix.resourcePolicies),
|
||||
verifyOrgAccess,
|
||||
verifyLimits,
|
||||
verifyUserHasAction(ActionsEnum.createResourcePolicy),
|
||||
logActionAudit(ActionsEnum.createResourcePolicy),
|
||||
policy.createResourcePolicy
|
||||
);
|
||||
|
||||
authenticated.put(
|
||||
"/org/:orgId/approvals/:approvalId",
|
||||
verifyValidLicense,
|
||||
@@ -842,3 +879,48 @@ authenticated.post(
|
||||
verifyClientAccess,
|
||||
client.rebuildClientAssociationsCacheRoute
|
||||
);
|
||||
|
||||
authenticated.put(
|
||||
"/org/:orgId/resource/:resourceId/browser-gateway-target",
|
||||
verifyValidLicense,
|
||||
verifyOrgAccess,
|
||||
verifyLimits,
|
||||
verifyUserHasAction(ActionsEnum.createBrowserGatewayTarget),
|
||||
logActionAudit(ActionsEnum.createBrowserGatewayTarget),
|
||||
browserGatewayTarget.createBrowserGatewayTarget
|
||||
);
|
||||
|
||||
authenticated.get(
|
||||
"/org/:orgId/resource/:resourceId/browser-gateway-targets",
|
||||
verifyValidLicense,
|
||||
verifyOrgAccess,
|
||||
verifyUserHasAction(ActionsEnum.listBrowserGatewayTargets),
|
||||
browserGatewayTarget.listBrowserGatewayTargets
|
||||
);
|
||||
|
||||
authenticated.get(
|
||||
"/org/:orgId/browser-gateway-target/:browserGatewayTargetId",
|
||||
verifyValidLicense,
|
||||
verifyOrgAccess,
|
||||
verifyUserHasAction(ActionsEnum.getBrowserGatewayTarget),
|
||||
browserGatewayTarget.getBrowserGatewayTarget
|
||||
);
|
||||
|
||||
authenticated.post(
|
||||
"/org/:orgId/browser-gateway-target/:browserGatewayTargetId",
|
||||
verifyValidLicense,
|
||||
verifyOrgAccess,
|
||||
verifyLimits,
|
||||
verifyUserHasAction(ActionsEnum.updateBrowserGatewayTarget),
|
||||
logActionAudit(ActionsEnum.updateBrowserGatewayTarget),
|
||||
browserGatewayTarget.updateBrowserGatewayTarget
|
||||
);
|
||||
|
||||
authenticated.delete(
|
||||
"/org/:orgId/browser-gateway-target/:browserGatewayTargetId",
|
||||
verifyValidLicense,
|
||||
verifyOrgAccess,
|
||||
verifyUserHasAction(ActionsEnum.deleteBrowserGatewayTarget),
|
||||
logActionAudit(ActionsEnum.deleteBrowserGatewayTarget),
|
||||
browserGatewayTarget.deleteBrowserGatewayTarget
|
||||
);
|
||||
|
||||
@@ -45,8 +45,11 @@ import {
|
||||
users,
|
||||
userOrgs,
|
||||
roleResources,
|
||||
rolePolicies,
|
||||
userResources,
|
||||
userPolicies,
|
||||
resourceRules,
|
||||
resourcePolicyRules,
|
||||
userOrgRoles,
|
||||
roles
|
||||
} from "@server/db";
|
||||
@@ -430,7 +433,10 @@ hybridRouter.get(
|
||||
);
|
||||
|
||||
// Decrypt and save key file
|
||||
const decryptedKey = decrypt(cert.keyFile!, config.getRawConfig().server.secret!);
|
||||
const decryptedKey = decrypt(
|
||||
cert.keyFile!,
|
||||
config.getRawConfig().server.secret!
|
||||
);
|
||||
|
||||
// Return only the certificate data without org information
|
||||
return {
|
||||
@@ -531,7 +537,10 @@ hybridRouter.get(
|
||||
wildcardCandidates.length > 0
|
||||
? and(
|
||||
eq(resources.wildcard, true),
|
||||
inArray(resources.fullDomain, wildcardCandidates)
|
||||
inArray(
|
||||
resources.fullDomain,
|
||||
wildcardCandidates
|
||||
)
|
||||
)
|
||||
: sql`false`
|
||||
)
|
||||
@@ -545,10 +554,10 @@ hybridRouter.get(
|
||||
|
||||
if (
|
||||
result &&
|
||||
await checkExitNodeOrg(
|
||||
(await checkExitNodeOrg(
|
||||
remoteExitNode.exitNodeId,
|
||||
result.resources.orgId
|
||||
)
|
||||
))
|
||||
) {
|
||||
// If the exit node is not allowed for the org, return an error
|
||||
return next(
|
||||
@@ -1132,22 +1141,43 @@ hybridRouter.get(
|
||||
);
|
||||
}
|
||||
|
||||
const roleResourceAccess = await db
|
||||
.select()
|
||||
.from(roleResources)
|
||||
.where(
|
||||
and(
|
||||
eq(roleResources.resourceId, resourceId),
|
||||
eq(roleResources.roleId, roleId)
|
||||
const [direct, viaPolicies] = await Promise.all([
|
||||
db
|
||||
.select()
|
||||
.from(roleResources)
|
||||
.where(
|
||||
and(
|
||||
eq(roleResources.resourceId, resourceId),
|
||||
eq(roleResources.roleId, roleId)
|
||||
)
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
.limit(1),
|
||||
db
|
||||
.select({
|
||||
roleId: rolePolicies.roleId,
|
||||
resourcePolicyId: rolePolicies.resourcePolicyId
|
||||
})
|
||||
.from(rolePolicies)
|
||||
.innerJoin(
|
||||
resources,
|
||||
eq(
|
||||
resources.resourcePolicyId,
|
||||
rolePolicies.resourcePolicyId
|
||||
)
|
||||
)
|
||||
.where(
|
||||
and(
|
||||
eq(resources.resourceId, resourceId),
|
||||
eq(rolePolicies.roleId, roleId)
|
||||
)
|
||||
)
|
||||
.limit(1)
|
||||
]);
|
||||
|
||||
const result =
|
||||
roleResourceAccess.length > 0 ? roleResourceAccess[0] : null;
|
||||
const result = direct[0] ?? viaPolicies[0] ?? null;
|
||||
|
||||
return response<typeof roleResources.$inferSelect | null>(res, {
|
||||
data: result,
|
||||
data: result as any,
|
||||
success: true,
|
||||
error: false,
|
||||
message: result
|
||||
@@ -1222,21 +1252,44 @@ hybridRouter.get(
|
||||
);
|
||||
}
|
||||
|
||||
const roleResourceAccess = await db
|
||||
.select({
|
||||
resourceId: roleResources.resourceId,
|
||||
roleId: roleResources.roleId
|
||||
})
|
||||
.from(roleResources)
|
||||
.where(
|
||||
and(
|
||||
eq(roleResources.resourceId, resourceId),
|
||||
inArray(roleResources.roleId, roleIds)
|
||||
)
|
||||
);
|
||||
const [direct, viaPolicies] = await Promise.all([
|
||||
db
|
||||
.select({
|
||||
resourceId: roleResources.resourceId,
|
||||
roleId: roleResources.roleId
|
||||
})
|
||||
.from(roleResources)
|
||||
.where(
|
||||
and(
|
||||
eq(roleResources.resourceId, resourceId),
|
||||
inArray(roleResources.roleId, roleIds)
|
||||
)
|
||||
),
|
||||
roleIds.length > 0
|
||||
? db
|
||||
.select({
|
||||
resourceId: sql<number>`${resourceId}`,
|
||||
roleId: rolePolicies.roleId
|
||||
})
|
||||
.from(rolePolicies)
|
||||
.innerJoin(
|
||||
resources,
|
||||
eq(
|
||||
resources.resourcePolicyId,
|
||||
rolePolicies.resourcePolicyId
|
||||
)
|
||||
)
|
||||
.where(
|
||||
and(
|
||||
eq(resources.resourceId, resourceId),
|
||||
inArray(rolePolicies.roleId, roleIds)
|
||||
)
|
||||
)
|
||||
: Promise.resolve([])
|
||||
]);
|
||||
|
||||
const result =
|
||||
roleResourceAccess.length > 0 ? roleResourceAccess : null;
|
||||
const combined = [...direct, ...viaPolicies];
|
||||
const result = combined.length > 0 ? combined : null;
|
||||
|
||||
return response<{ resourceId: number; roleId: number }[] | null>(
|
||||
res,
|
||||
@@ -1397,10 +1450,45 @@ hybridRouter.get(
|
||||
);
|
||||
}
|
||||
|
||||
const rules = await db
|
||||
.select()
|
||||
.from(resourceRules)
|
||||
.where(eq(resourceRules.resourceId, resourceId));
|
||||
const [directRules, policyRules] = await Promise.all([
|
||||
db
|
||||
.select()
|
||||
.from(resourceRules)
|
||||
.where(eq(resourceRules.resourceId, resourceId)),
|
||||
db
|
||||
.select({
|
||||
ruleId: resourcePolicyRules.ruleId,
|
||||
resourceId: sql<number>`${resourceId}`,
|
||||
enabled: resourcePolicyRules.enabled,
|
||||
priority: resourcePolicyRules.priority,
|
||||
action: resourcePolicyRules.action,
|
||||
match: resourcePolicyRules.match,
|
||||
value: resourcePolicyRules.value
|
||||
})
|
||||
.from(resourcePolicyRules)
|
||||
.innerJoin(
|
||||
resources,
|
||||
eq(
|
||||
resources.resourcePolicyId,
|
||||
resourcePolicyRules.resourcePolicyId
|
||||
)
|
||||
)
|
||||
.where(eq(resources.resourceId, resourceId))
|
||||
]);
|
||||
|
||||
const maxDirectPriority = directRules.reduce(
|
||||
(max, r) => Math.max(max, r.priority),
|
||||
0
|
||||
);
|
||||
const offsetPolicyRules = policyRules.map((r) => ({
|
||||
...r,
|
||||
priority: maxDirectPriority + r.priority
|
||||
}));
|
||||
|
||||
const rules = [
|
||||
...directRules,
|
||||
...offsetPolicyRules
|
||||
] as (typeof resourceRules.$inferSelect)[];
|
||||
|
||||
// backward compatibility: COUNTRY -> GEOIP
|
||||
// TODO: remove this after a few versions once all exit nodes are updated
|
||||
|
||||
@@ -16,6 +16,7 @@ import * as org from "#private/routers/org";
|
||||
import * as logs from "#private/routers/auditLogs";
|
||||
import * as alertEvents from "#private/routers/alertEvents";
|
||||
import * as certificates from "#private/routers/certificates";
|
||||
import * as browserGatewayTarget from "#private/routers/browserGatewayTarget";
|
||||
|
||||
import {
|
||||
verifyApiKeyHasAction,
|
||||
@@ -215,3 +216,43 @@ authenticated.delete(
|
||||
logActionAudit(ActionsEnum.removeUserRole),
|
||||
user.removeUserRole
|
||||
);
|
||||
|
||||
authenticated.put(
|
||||
"/org/:orgId/resource/:resourceId/browser-gateway-target",
|
||||
verifyApiKeyOrgAccess,
|
||||
verifyLimits,
|
||||
verifyApiKeyHasAction(ActionsEnum.createBrowserGatewayTarget),
|
||||
logActionAudit(ActionsEnum.createBrowserGatewayTarget),
|
||||
browserGatewayTarget.createBrowserGatewayTarget
|
||||
);
|
||||
|
||||
authenticated.get(
|
||||
"/org/:orgId/resource/:resourceId/browser-gateway-targets",
|
||||
verifyApiKeyOrgAccess,
|
||||
verifyApiKeyHasAction(ActionsEnum.listBrowserGatewayTargets),
|
||||
browserGatewayTarget.listBrowserGatewayTargets
|
||||
);
|
||||
|
||||
authenticated.get(
|
||||
"/org/:orgId/browser-gateway-target/:browserGatewayTargetId",
|
||||
verifyApiKeyOrgAccess,
|
||||
verifyApiKeyHasAction(ActionsEnum.getBrowserGatewayTarget),
|
||||
browserGatewayTarget.getBrowserGatewayTarget
|
||||
);
|
||||
|
||||
authenticated.post(
|
||||
"/org/:orgId/browser-gateway-target/:browserGatewayTargetId",
|
||||
verifyApiKeyOrgAccess,
|
||||
verifyLimits,
|
||||
verifyApiKeyHasAction(ActionsEnum.updateBrowserGatewayTarget),
|
||||
logActionAudit(ActionsEnum.updateBrowserGatewayTarget),
|
||||
browserGatewayTarget.updateBrowserGatewayTarget
|
||||
);
|
||||
|
||||
authenticated.delete(
|
||||
"/org/:orgId/browser-gateway-target/:browserGatewayTargetId",
|
||||
verifyApiKeyOrgAccess,
|
||||
verifyApiKeyHasAction(ActionsEnum.deleteBrowserGatewayTarget),
|
||||
logActionAudit(ActionsEnum.deleteBrowserGatewayTarget),
|
||||
browserGatewayTarget.deleteBrowserGatewayTarget
|
||||
);
|
||||
|
||||
@@ -0,0 +1,417 @@
|
||||
/*
|
||||
* This file is part of a proprietary work.
|
||||
*
|
||||
* Copyright (c) 2025-2026 Fossorial, Inc.
|
||||
* All rights reserved.
|
||||
*
|
||||
* This file is licensed under the Fossorial Commercial License.
|
||||
* You may not use this file except in compliance with the License.
|
||||
* Unauthorized use, copying, modification, or distribution is strictly prohibited.
|
||||
*
|
||||
* This file is not licensed under the AGPLv3.
|
||||
*/
|
||||
|
||||
import { hashPassword } from "@server/auth/password";
|
||||
import {
|
||||
db,
|
||||
idp,
|
||||
idpOrg,
|
||||
orgs,
|
||||
resourcePolicies,
|
||||
resourcePolicyHeaderAuth,
|
||||
resourcePolicyPassword,
|
||||
resourcePolicyPincode,
|
||||
resourcePolicyRules,
|
||||
resourcePolicyWhiteList,
|
||||
rolePolicies,
|
||||
roles,
|
||||
userOrgs,
|
||||
userPolicies,
|
||||
users,
|
||||
type ResourcePolicy
|
||||
} from "@server/db";
|
||||
import { getUniqueResourcePolicyName } from "@server/db/names";
|
||||
import response from "@server/lib/response";
|
||||
import {
|
||||
isValidCIDR,
|
||||
isValidIP,
|
||||
isValidUrlGlobPattern
|
||||
} from "@server/lib/validators";
|
||||
import logger from "@server/logger";
|
||||
import { OpenAPITags, registry } from "@server/openApi";
|
||||
import HttpCode from "@server/types/HttpCode";
|
||||
import { and, eq, inArray, type InferInsertModel } from "drizzle-orm";
|
||||
import { NextFunction, Request, Response } from "express";
|
||||
import createHttpError from "http-errors";
|
||||
import z from "zod";
|
||||
import { fromError } from "zod-validation-error";
|
||||
|
||||
const createResourcePolicyParamsSchema = z.strictObject({
|
||||
orgId: z.string()
|
||||
});
|
||||
|
||||
const ruleSchema = z.strictObject({
|
||||
action: z.enum(["ACCEPT", "DROP", "PASS"]).openapi({
|
||||
type: "string",
|
||||
enum: ["ACCEPT", "DROP", "PASS"],
|
||||
description: "rule action"
|
||||
}),
|
||||
match: z.enum(["CIDR", "IP", "PATH"]).openapi({
|
||||
type: "string",
|
||||
enum: ["CIDR", "IP", "PATH"],
|
||||
description: "rule match"
|
||||
}),
|
||||
value: z.string().min(1),
|
||||
priority: z.int().openapi({
|
||||
type: "integer",
|
||||
description: "Rule priority"
|
||||
}),
|
||||
enabled: z.boolean().optional()
|
||||
});
|
||||
|
||||
const createResourcePolicyBodySchema = z.strictObject({
|
||||
name: z.string().min(1).max(255),
|
||||
// Access control
|
||||
sso: z.boolean().default(true),
|
||||
skipToIdpId: z
|
||||
.int()
|
||||
.positive()
|
||||
.optional()
|
||||
.nullable()
|
||||
.openapi({ type: "integer" }),
|
||||
roleIds: z
|
||||
.array(z.string().transform(Number).pipe(z.int().positive()))
|
||||
.optional()
|
||||
.default([]),
|
||||
userIds: z.array(z.string()).optional().default([]),
|
||||
// auth methods
|
||||
password: z.string().min(4).max(100).nullable().optional(),
|
||||
pincode: z
|
||||
.string()
|
||||
.regex(/^\d{6}$/)
|
||||
.or(z.null())
|
||||
.optional(),
|
||||
headerAuth: z
|
||||
.object({
|
||||
user: z.string().min(4).max(100),
|
||||
password: z.string().min(4).max(100),
|
||||
extendedCompatibility: z.boolean()
|
||||
})
|
||||
.nullable()
|
||||
.optional(),
|
||||
// email OTP
|
||||
emailWhitelistEnabled: z.boolean().optional().default(false),
|
||||
emails: z
|
||||
.array(
|
||||
z.email().or(
|
||||
z.string().regex(/^\*@[\w.-]+\.[a-zA-Z]{2,}$/, {
|
||||
error: "Invalid email address. Wildcard (*) must be the entire local part."
|
||||
})
|
||||
)
|
||||
)
|
||||
.max(50)
|
||||
.transform((v) => v.map((e) => e.toLowerCase()))
|
||||
.optional()
|
||||
.default([]),
|
||||
// rules
|
||||
applyRules: z.boolean().default(false),
|
||||
rules: z.array(ruleSchema).optional().default([])
|
||||
});
|
||||
|
||||
registry.registerPath({
|
||||
method: "post",
|
||||
path: "/org/{orgId}/resource-policy",
|
||||
description: "Create a resource policy.",
|
||||
tags: [OpenAPITags.Org, OpenAPITags.Policy],
|
||||
request: {
|
||||
params: createResourcePolicyParamsSchema,
|
||||
body: {
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: createResourcePolicyBodySchema
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
responses: {}
|
||||
});
|
||||
|
||||
export async function createResourcePolicy(
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction
|
||||
) {
|
||||
try {
|
||||
// Validate request params
|
||||
const parsedParams = createResourcePolicyParamsSchema.safeParse(
|
||||
req.params
|
||||
);
|
||||
if (!parsedParams.success) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
fromError(parsedParams.error).toString()
|
||||
)
|
||||
);
|
||||
}
|
||||
const { orgId } = parsedParams.data;
|
||||
|
||||
if (req.user && req.userOrgRoleIds?.length === 0) {
|
||||
return next(
|
||||
createHttpError(HttpCode.FORBIDDEN, "User does not have a role")
|
||||
);
|
||||
}
|
||||
|
||||
// get the org
|
||||
const org = await db
|
||||
.select()
|
||||
.from(orgs)
|
||||
.where(eq(orgs.orgId, orgId))
|
||||
.limit(1);
|
||||
|
||||
if (org.length === 0) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.NOT_FOUND,
|
||||
`Organization with ID ${orgId} not found`
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const parsedBody = createResourcePolicyBodySchema.safeParse(req.body);
|
||||
if (!parsedBody.success) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
fromError(parsedBody.error).toString()
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const {
|
||||
name,
|
||||
sso,
|
||||
userIds,
|
||||
roleIds,
|
||||
skipToIdpId,
|
||||
applyRules,
|
||||
emailWhitelistEnabled,
|
||||
password,
|
||||
pincode,
|
||||
headerAuth,
|
||||
emails,
|
||||
rules
|
||||
} = parsedBody.data;
|
||||
|
||||
// Check if Identity provider in `skipToIdpId` exists
|
||||
if (skipToIdpId) {
|
||||
const [provider] = await db
|
||||
.select()
|
||||
.from(idp)
|
||||
.innerJoin(idpOrg, eq(idpOrg.idpId, idp.idpId))
|
||||
.where(and(eq(idp.idpId, skipToIdpId), eq(idpOrg.orgId, orgId)))
|
||||
.limit(1);
|
||||
|
||||
if (!provider) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.INTERNAL_SERVER_ERROR,
|
||||
"Identity provider not found in this organization"
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const adminRole = await db
|
||||
.select()
|
||||
.from(roles)
|
||||
.where(and(eq(roles.isAdmin, true), eq(roles.orgId, orgId)))
|
||||
.limit(1);
|
||||
|
||||
if (adminRole.length === 0) {
|
||||
return next(
|
||||
createHttpError(HttpCode.NOT_FOUND, `Admin role not found`)
|
||||
);
|
||||
}
|
||||
|
||||
const existingRoles = await db
|
||||
.select()
|
||||
.from(roles)
|
||||
.where(and(inArray(roles.roleId, roleIds)));
|
||||
|
||||
const hasAdminRole = existingRoles.some((role) => role.isAdmin);
|
||||
|
||||
if (hasAdminRole) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
"Admin role cannot be assigned to resource policy"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const existingUsers = await db
|
||||
.select()
|
||||
.from(users)
|
||||
.innerJoin(userOrgs, eq(userOrgs.userId, users.userId))
|
||||
.where(
|
||||
and(eq(userOrgs.orgId, orgId), inArray(users.userId, userIds))
|
||||
);
|
||||
|
||||
const niceId = await getUniqueResourcePolicyName(orgId);
|
||||
|
||||
for (const rule of rules) {
|
||||
if (rule.match === "CIDR" && !isValidCIDR(rule.value)) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
"Invalid CIDR provided"
|
||||
)
|
||||
);
|
||||
} else if (rule.match === "IP" && !isValidIP(rule.value)) {
|
||||
return next(
|
||||
createHttpError(HttpCode.BAD_REQUEST, "Invalid IP provided")
|
||||
);
|
||||
} else if (
|
||||
rule.match === "PATH" &&
|
||||
!isValidUrlGlobPattern(rule.value)
|
||||
) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
"Invalid URL glob pattern provided"
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const policy = await db.transaction(async (trx) => {
|
||||
const [newPolicy] = await trx
|
||||
.insert(resourcePolicies)
|
||||
.values({
|
||||
niceId,
|
||||
orgId,
|
||||
name,
|
||||
sso,
|
||||
idpId: skipToIdpId,
|
||||
applyRules,
|
||||
emailWhitelistEnabled
|
||||
})
|
||||
.returning();
|
||||
|
||||
const rolesToAdd = [
|
||||
{
|
||||
roleId: adminRole[0].roleId,
|
||||
resourcePolicyId: newPolicy.resourcePolicyId
|
||||
}
|
||||
] satisfies InferInsertModel<typeof rolePolicies>[];
|
||||
|
||||
rolesToAdd.push(
|
||||
...existingRoles.map((role) => ({
|
||||
roleId: role.roleId,
|
||||
resourcePolicyId: newPolicy.resourcePolicyId
|
||||
}))
|
||||
);
|
||||
|
||||
await trx.insert(rolePolicies).values(rolesToAdd);
|
||||
|
||||
const usersToAdd: InferInsertModel<typeof userPolicies>[] = [];
|
||||
|
||||
if (
|
||||
req.user &&
|
||||
!req.userOrgRoleIds?.includes(adminRole[0].roleId)
|
||||
) {
|
||||
// make sure the user can access the policy
|
||||
usersToAdd.push({
|
||||
userId: req.user?.userId!,
|
||||
resourcePolicyId: newPolicy.resourcePolicyId
|
||||
});
|
||||
}
|
||||
|
||||
usersToAdd.push(
|
||||
...existingUsers.map(({ user }) => ({
|
||||
userId: user.userId,
|
||||
resourcePolicyId: newPolicy.resourcePolicyId
|
||||
}))
|
||||
);
|
||||
|
||||
if (usersToAdd.length > 0) {
|
||||
await trx.insert(userPolicies).values(usersToAdd);
|
||||
}
|
||||
|
||||
if (password) {
|
||||
const passwordHash = await hashPassword(password);
|
||||
|
||||
await trx.insert(resourcePolicyPassword).values({
|
||||
resourcePolicyId: newPolicy.resourcePolicyId,
|
||||
passwordHash
|
||||
});
|
||||
}
|
||||
|
||||
if (pincode) {
|
||||
const pincodeHash = await hashPassword(pincode);
|
||||
|
||||
await trx.insert(resourcePolicyPincode).values({
|
||||
resourcePolicyId: newPolicy.resourcePolicyId,
|
||||
pincodeHash,
|
||||
digitLength: 6
|
||||
});
|
||||
}
|
||||
|
||||
if (headerAuth) {
|
||||
const headerAuthHash = await hashPassword(
|
||||
Buffer.from(
|
||||
`${headerAuth.user}:${headerAuth.password}`
|
||||
).toString("base64")
|
||||
);
|
||||
|
||||
await trx.insert(resourcePolicyHeaderAuth).values({
|
||||
resourcePolicyId: newPolicy.resourcePolicyId,
|
||||
headerAuthHash,
|
||||
extendedCompatibility: headerAuth.extendedCompatibility
|
||||
});
|
||||
}
|
||||
|
||||
if (emailWhitelistEnabled && emails.length > 0) {
|
||||
await trx.insert(resourcePolicyWhiteList).values(
|
||||
emails.map((email) => ({
|
||||
email,
|
||||
resourcePolicyId: newPolicy.resourcePolicyId
|
||||
}))
|
||||
);
|
||||
}
|
||||
|
||||
if (rules.length > 0) {
|
||||
await trx.insert(resourcePolicyRules).values(
|
||||
rules.map((rule) => ({
|
||||
resourcePolicyId: newPolicy.resourcePolicyId,
|
||||
...rule
|
||||
}))
|
||||
);
|
||||
}
|
||||
|
||||
return newPolicy;
|
||||
});
|
||||
|
||||
if (!policy) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.INTERNAL_SERVER_ERROR,
|
||||
"Failed to create policy"
|
||||
)
|
||||
);
|
||||
}
|
||||
return response<ResourcePolicy>(res, {
|
||||
data: policy,
|
||||
success: true,
|
||||
error: false,
|
||||
message: "resource policy created successfully",
|
||||
status: HttpCode.CREATED
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error(error);
|
||||
return next(
|
||||
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
/*
|
||||
* This file is part of a proprietary work.
|
||||
*
|
||||
* Copyright (c) 2025-2026 Fossorial, Inc.
|
||||
* All rights reserved.
|
||||
*
|
||||
* This file is licensed under the Fossorial Commercial License.
|
||||
* You may not use this file except in compliance with the License.
|
||||
* Unauthorized use, copying, modification, or distribution is strictly prohibited.
|
||||
*
|
||||
* This file is not licensed under the AGPLv3.
|
||||
*/
|
||||
|
||||
import { db, resourcePolicies, resources } from "@server/db";
|
||||
import response from "@server/lib/response";
|
||||
import logger from "@server/logger";
|
||||
import { OpenAPITags, registry } from "@server/openApi";
|
||||
import HttpCode from "@server/types/HttpCode";
|
||||
import { eq } from "drizzle-orm";
|
||||
import type { NextFunction, Request, Response } from "express";
|
||||
import createHttpError from "http-errors";
|
||||
import z from "zod";
|
||||
import { fromError } from "zod-validation-error";
|
||||
|
||||
// Define Zod schema for request parameters validation
|
||||
const deleteResourcePolicySchema = z.strictObject({
|
||||
resourcePolicyId: z.string().transform(Number).pipe(z.int().positive())
|
||||
});
|
||||
|
||||
registry.registerPath({
|
||||
method: "delete",
|
||||
path: "/resource-policy/{resourcePolicyId}",
|
||||
description: "Delete a resource policy.",
|
||||
tags: [OpenAPITags.Policy],
|
||||
request: {
|
||||
params: deleteResourcePolicySchema
|
||||
},
|
||||
responses: {}
|
||||
});
|
||||
|
||||
export async function deleteResourcePolicy(
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction
|
||||
): Promise<any> {
|
||||
try {
|
||||
const parsedParams = deleteResourcePolicySchema.safeParse(req.params);
|
||||
if (!parsedParams.success) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
fromError(parsedParams.error).toString()
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const { resourcePolicyId } = parsedParams.data;
|
||||
|
||||
const [existingResource] = await db
|
||||
.select()
|
||||
.from(resourcePolicies)
|
||||
.where(eq(resourcePolicies.resourcePolicyId, resourcePolicyId));
|
||||
|
||||
if (!existingResource) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.NOT_FOUND,
|
||||
`Resource Policy with ID ${resourcePolicyId} not found`
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const totalAffectedResources = await db.$count(
|
||||
db
|
||||
.select()
|
||||
.from(resources)
|
||||
.where(eq(resources.resourcePolicyId, resourcePolicyId))
|
||||
);
|
||||
|
||||
if (totalAffectedResources > 0) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.FORBIDDEN,
|
||||
`Cannot delete Policy '${existingResource.name}' as it's being used by at least one resource`
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
// delete policy
|
||||
await db
|
||||
.delete(resourcePolicies)
|
||||
.where(eq(resourcePolicies.resourcePolicyId, resourcePolicyId));
|
||||
|
||||
return response(res, {
|
||||
data: null,
|
||||
success: true,
|
||||
error: false,
|
||||
message: "Resource Policy deleted successfully",
|
||||
status: HttpCode.OK
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error(error);
|
||||
return next(
|
||||
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
/*
|
||||
* This file is part of a proprietary work.
|
||||
*
|
||||
* Copyright (c) 2025-2026 Fossorial, Inc.
|
||||
* All rights reserved.
|
||||
*
|
||||
* This file is licensed under the Fossorial Commercial License.
|
||||
* You may not use this file except in compliance with the License.
|
||||
* Unauthorized use, copying, modification, or distribution is strictly prohibited.
|
||||
*
|
||||
* This file is not licensed under the AGPLv3.
|
||||
*/
|
||||
|
||||
export * from "./createResourcePolicy";
|
||||
export * from "./listResourcePolicies";
|
||||
export * from "./deleteResourcePolicy";
|
||||
@@ -0,0 +1,271 @@
|
||||
/*
|
||||
* This file is part of a proprietary work.
|
||||
*
|
||||
* Copyright (c) 2025-2026 Fossorial, Inc.
|
||||
* All rights reserved.
|
||||
*
|
||||
* This file is licensed under the Fossorial Commercial License.
|
||||
* You may not use this file except in compliance with the License.
|
||||
* Unauthorized use, copying, modification, or distribution is strictly prohibited.
|
||||
*
|
||||
* This file is not licensed under the AGPLv3.
|
||||
*/
|
||||
|
||||
import {
|
||||
db,
|
||||
resourcePolicies,
|
||||
resources,
|
||||
rolePolicies,
|
||||
userPolicies
|
||||
} from "@server/db";
|
||||
import response from "@server/lib/response";
|
||||
import logger from "@server/logger";
|
||||
import { OpenAPITags, registry } from "@server/openApi";
|
||||
import type {
|
||||
ListResourcePoliciesResponse,
|
||||
ResourcePolicyWithResources
|
||||
} from "@server/routers/resource/types";
|
||||
import HttpCode from "@server/types/HttpCode";
|
||||
import { and, asc, eq, inArray, like, or, sql } from "drizzle-orm";
|
||||
import { NextFunction, Request, Response } from "express";
|
||||
import createHttpError from "http-errors";
|
||||
import { z } from "zod";
|
||||
import { fromZodError } from "zod-validation-error";
|
||||
|
||||
const listResourcePoliciesParamsSchema = z.strictObject({
|
||||
orgId: z.string()
|
||||
});
|
||||
|
||||
const listResourcePoliciesSchema = z.object({
|
||||
pageSize: z.coerce
|
||||
.number<string>() // for prettier formatting
|
||||
.int()
|
||||
.positive()
|
||||
.optional()
|
||||
.catch(20)
|
||||
.default(20)
|
||||
.openapi({
|
||||
type: "integer",
|
||||
default: 20,
|
||||
description: "Number of items per page"
|
||||
}),
|
||||
page: z.coerce
|
||||
.number<string>() // for prettier formatting
|
||||
.int()
|
||||
.min(0)
|
||||
.optional()
|
||||
.catch(1)
|
||||
.default(1)
|
||||
.openapi({
|
||||
type: "integer",
|
||||
default: 1,
|
||||
description: "Page number to retrieve"
|
||||
}),
|
||||
query: z.string().optional()
|
||||
});
|
||||
|
||||
function queryResourcePoliciesBase() {
|
||||
return db
|
||||
.select({
|
||||
resourcePolicyId: resourcePolicies.resourcePolicyId,
|
||||
name: resourcePolicies.name,
|
||||
niceId: resourcePolicies.niceId,
|
||||
orgId: resourcePolicies.orgId
|
||||
})
|
||||
.from(resourcePolicies);
|
||||
}
|
||||
|
||||
registry.registerPath({
|
||||
method: "get",
|
||||
path: "/org/{orgId}/resource-policies",
|
||||
description: "List resource policies for an organization.",
|
||||
tags: [OpenAPITags.Org, OpenAPITags.Policy],
|
||||
request: {
|
||||
params: z.object({
|
||||
orgId: z.string()
|
||||
}),
|
||||
query: listResourcePoliciesSchema
|
||||
},
|
||||
responses: {}
|
||||
});
|
||||
|
||||
export async function listResourcePolicies(
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction
|
||||
): Promise<any> {
|
||||
try {
|
||||
const parsedQuery = listResourcePoliciesSchema.safeParse(req.query);
|
||||
if (!parsedQuery.success) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
fromZodError(parsedQuery.error)
|
||||
)
|
||||
);
|
||||
}
|
||||
const { page, pageSize, query } = parsedQuery.data;
|
||||
|
||||
const parsedParams = listResourcePoliciesParamsSchema.safeParse(
|
||||
req.params
|
||||
);
|
||||
if (!parsedParams.success) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
fromZodError(parsedParams.error)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const orgId =
|
||||
parsedParams.data.orgId ||
|
||||
req.userOrg?.orgId ||
|
||||
req.apiKeyOrg?.orgId;
|
||||
|
||||
if (!orgId) {
|
||||
return next(
|
||||
createHttpError(HttpCode.BAD_REQUEST, "Invalid organization ID")
|
||||
);
|
||||
}
|
||||
|
||||
if (req.user && orgId && orgId !== req.userOrgId) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.FORBIDDEN,
|
||||
"User does not have access to this organization"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
let accessibleResourcePolicies: Array<{ resourcePolicyId: number }>;
|
||||
if (req.user) {
|
||||
accessibleResourcePolicies = await db
|
||||
.select({
|
||||
resourcePolicyId: sql<number>`COALESCE(${userPolicies.resourcePolicyId}, ${rolePolicies.resourcePolicyId})`
|
||||
})
|
||||
.from(userPolicies)
|
||||
.fullJoin(
|
||||
rolePolicies,
|
||||
eq(
|
||||
userPolicies.resourcePolicyId,
|
||||
rolePolicies.resourcePolicyId
|
||||
)
|
||||
)
|
||||
.where(
|
||||
or(
|
||||
eq(userPolicies.userId, req.user!.userId),
|
||||
inArray(rolePolicies.roleId, req.userOrgRoleIds || [])
|
||||
)
|
||||
);
|
||||
} else {
|
||||
accessibleResourcePolicies = await db
|
||||
.select({
|
||||
resourcePolicyId: resourcePolicies.resourcePolicyId
|
||||
})
|
||||
.from(resourcePolicies)
|
||||
.where(eq(resourcePolicies.orgId, orgId));
|
||||
}
|
||||
|
||||
const accessibleResourceIds = accessibleResourcePolicies.map(
|
||||
(resource) => resource.resourcePolicyId
|
||||
);
|
||||
|
||||
const conditions = [
|
||||
and(
|
||||
inArray(
|
||||
resourcePolicies.resourcePolicyId,
|
||||
accessibleResourceIds
|
||||
),
|
||||
eq(resourcePolicies.orgId, orgId),
|
||||
eq(resourcePolicies.scope, "global")
|
||||
)
|
||||
];
|
||||
|
||||
if (query) {
|
||||
conditions.push(
|
||||
or(
|
||||
like(
|
||||
sql`LOWER(${resourcePolicies.name})`,
|
||||
"%" + query.toLowerCase() + "%"
|
||||
),
|
||||
like(
|
||||
sql`LOWER(${resourcePolicies.niceId})`,
|
||||
"%" + query.toLowerCase() + "%"
|
||||
)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const baseQuery = queryResourcePoliciesBase().where(and(...conditions));
|
||||
|
||||
// we need to add `as` so that drizzle filters the result as a subquery
|
||||
const countQuery = db.$count(baseQuery.as("filtered_policies"));
|
||||
|
||||
const [rows, totalCount] = await Promise.all([
|
||||
baseQuery
|
||||
.limit(pageSize)
|
||||
.offset(pageSize * (page - 1))
|
||||
.orderBy(asc(resourcePolicies.resourcePolicyId)),
|
||||
countQuery
|
||||
]);
|
||||
|
||||
const attachedResources =
|
||||
rows.length === 0
|
||||
? []
|
||||
: await db
|
||||
.select({
|
||||
resourceId: resources.resourceId,
|
||||
name: resources.name,
|
||||
fullDomain: resources.fullDomain,
|
||||
resourcePolicyId: resources.resourcePolicyId
|
||||
})
|
||||
.from(resources)
|
||||
.where(
|
||||
inArray(
|
||||
resources.resourcePolicyId,
|
||||
rows.map((row) => row.resourcePolicyId)
|
||||
)
|
||||
);
|
||||
|
||||
// avoids TS issues with reduce/never[]
|
||||
const map = new Map<number, ResourcePolicyWithResources>();
|
||||
|
||||
for (const row of rows) {
|
||||
let entry = map.get(row.resourcePolicyId);
|
||||
if (!entry) {
|
||||
entry = {
|
||||
...row,
|
||||
resources: []
|
||||
};
|
||||
map.set(row.resourcePolicyId, entry);
|
||||
}
|
||||
|
||||
entry.resources = attachedResources.filter(
|
||||
(r) => r.resourcePolicyId === entry?.resourcePolicyId
|
||||
);
|
||||
}
|
||||
|
||||
const policiesList = Array.from(map.values());
|
||||
|
||||
return response<ListResourcePoliciesResponse>(res, {
|
||||
data: {
|
||||
policies: policiesList,
|
||||
pagination: {
|
||||
total: totalCount,
|
||||
pageSize,
|
||||
page
|
||||
}
|
||||
},
|
||||
success: true,
|
||||
error: false,
|
||||
message: "Resources retrieved successfully",
|
||||
status: HttpCode.OK
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error(error);
|
||||
return next(
|
||||
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -23,7 +23,8 @@ import {
|
||||
roundTripMessageTracker,
|
||||
siteResources,
|
||||
siteNetworks,
|
||||
userOrgs
|
||||
userOrgs,
|
||||
sites
|
||||
} from "@server/db";
|
||||
import { logAccessAudit } from "#private/lib/logAccessAudit";
|
||||
import { isLicensedOrSubscribed } from "#private/lib/isLicencedOrSubscribed";
|
||||
@@ -48,7 +49,8 @@ const bodySchema = z
|
||||
.strictObject({
|
||||
publicKey: z.string().nonempty(),
|
||||
resourceId: z.number().int().positive().optional(),
|
||||
resource: z.string().nonempty().optional() // this is either the nice id or the alias
|
||||
resource: z.string().nonempty().optional(), // this is either the nice id or the alias
|
||||
username: z.string().nonempty().optional()
|
||||
})
|
||||
.refine(
|
||||
(data) => {
|
||||
@@ -63,19 +65,19 @@ const bodySchema = z
|
||||
);
|
||||
|
||||
export type SignSshKeyResponse = {
|
||||
certificate: string;
|
||||
certificate?: string;
|
||||
messageIds: number[];
|
||||
messageId: number;
|
||||
messageId?: number;
|
||||
sshUsername: string;
|
||||
sshHost: string;
|
||||
resourceId: number;
|
||||
siteIds: number[];
|
||||
siteId: number;
|
||||
keyId: string;
|
||||
validPrincipals: string[];
|
||||
validAfter: string;
|
||||
validBefore: string;
|
||||
expiresIn: number;
|
||||
keyId?: string;
|
||||
validPrincipals?: string[];
|
||||
validAfter?: string;
|
||||
validBefore?: string;
|
||||
expiresIn?: number;
|
||||
};
|
||||
|
||||
// registry.registerPath({
|
||||
@@ -126,7 +128,8 @@ export async function signSshKey(
|
||||
const {
|
||||
publicKey,
|
||||
resourceId,
|
||||
resource: resourceQueryString
|
||||
resource: resourceQueryString,
|
||||
username
|
||||
} = parsedBody.data;
|
||||
const userId = req.user?.userId;
|
||||
const roleIds = req.userOrgRoleIds ?? [];
|
||||
@@ -174,101 +177,6 @@ export async function signSshKey(
|
||||
);
|
||||
}
|
||||
|
||||
let usernameToUse;
|
||||
if (!userOrg.pamUsername) {
|
||||
if (req.user?.email) {
|
||||
// Extract username from email (first part before @)
|
||||
usernameToUse = req.user?.email
|
||||
.split("@")[0]
|
||||
.replace(/[^a-zA-Z0-9_-]/g, "");
|
||||
if (!usernameToUse) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
"Unable to extract username from email"
|
||||
)
|
||||
);
|
||||
}
|
||||
} else if (req.user?.username) {
|
||||
usernameToUse = req.user.username;
|
||||
// We need to clean out any spaces or special characters from the username to ensure it's valid for SSH certificates
|
||||
usernameToUse = usernameToUse.replace(/[^a-zA-Z0-9_-]/g, "-");
|
||||
if (!usernameToUse) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
"Username is not valid for SSH certificate"
|
||||
)
|
||||
);
|
||||
}
|
||||
} else {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
"User does not have a valid email or username for SSH certificate"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
// prefix with p-
|
||||
usernameToUse = `p-${usernameToUse}`;
|
||||
|
||||
// check if we have a existing user in this org with the same
|
||||
const [existingUserWithSameName] = await db
|
||||
.select()
|
||||
.from(userOrgs)
|
||||
.where(
|
||||
and(
|
||||
eq(userOrgs.orgId, orgId),
|
||||
eq(userOrgs.pamUsername, usernameToUse)
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
if (existingUserWithSameName) {
|
||||
let foundUniqueUsername = false;
|
||||
for (let attempt = 0; attempt < 20; attempt++) {
|
||||
const randomNum = Math.floor(Math.random() * 101); // 0 to 100
|
||||
const candidateUsername = `${usernameToUse}${randomNum}`;
|
||||
|
||||
const [existingUser] = await db
|
||||
.select()
|
||||
.from(userOrgs)
|
||||
.where(
|
||||
and(
|
||||
eq(userOrgs.orgId, orgId),
|
||||
eq(userOrgs.pamUsername, candidateUsername)
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
if (!existingUser) {
|
||||
usernameToUse = candidateUsername;
|
||||
foundUniqueUsername = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!foundUniqueUsername) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.CONFLICT,
|
||||
"Unable to generate a unique username for SSH certificate"
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
await db
|
||||
.update(userOrgs)
|
||||
.set({ pamUsername: usernameToUse })
|
||||
.where(
|
||||
and(eq(userOrgs.orgId, orgId), eq(userOrgs.userId, userId))
|
||||
);
|
||||
} else {
|
||||
usernameToUse = userOrg.pamUsername;
|
||||
}
|
||||
|
||||
// Get and decrypt the org's CA keys
|
||||
const caKeys = await getOrgCAKeys(
|
||||
orgId,
|
||||
@@ -361,90 +269,303 @@ export async function signSshKey(
|
||||
);
|
||||
}
|
||||
|
||||
const roleRows = await db
|
||||
.select({
|
||||
sshSudoCommands: roles.sshSudoCommands,
|
||||
sshUnixGroups: roles.sshUnixGroups,
|
||||
sshCreateHomeDir: roles.sshCreateHomeDir,
|
||||
sshSudoMode: roles.sshSudoMode
|
||||
})
|
||||
.from(roles)
|
||||
.innerJoin(
|
||||
roleSiteResources,
|
||||
eq(roleSiteResources.roleId, roles.roleId)
|
||||
)
|
||||
.where(
|
||||
and(
|
||||
inArray(roles.roleId, roleIds),
|
||||
eq(
|
||||
roleSiteResources.siteResourceId,
|
||||
resource.siteResourceId
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
const parsedSudoCommands: string[] = [];
|
||||
const parsedGroupsSet = new Set<string>();
|
||||
let homedir: boolean | null = null;
|
||||
const sudoModeOrder = { none: 0, commands: 1, full: 2 };
|
||||
let sudoMode: "none" | "commands" | "full" = "none";
|
||||
for (const roleRow of roleRows) {
|
||||
try {
|
||||
const cmds = JSON.parse(roleRow?.sshSudoCommands ?? "[]");
|
||||
if (Array.isArray(cmds)) parsedSudoCommands.push(...cmds);
|
||||
} catch {
|
||||
// skip
|
||||
}
|
||||
try {
|
||||
const grps = JSON.parse(roleRow?.sshUnixGroups ?? "[]");
|
||||
if (Array.isArray(grps))
|
||||
grps.forEach((g: string) => parsedGroupsSet.add(g));
|
||||
} catch {
|
||||
// skip
|
||||
}
|
||||
if (roleRow?.sshCreateHomeDir === true) homedir = true;
|
||||
const m = roleRow?.sshSudoMode ?? "none";
|
||||
if (
|
||||
sudoModeOrder[m as keyof typeof sudoModeOrder] >
|
||||
sudoModeOrder[sudoMode]
|
||||
) {
|
||||
sudoMode = m as "none" | "commands" | "full";
|
||||
}
|
||||
}
|
||||
const parsedGroups = Array.from(parsedGroupsSet);
|
||||
if (homedir === null && roleRows.length > 0) {
|
||||
homedir = roleRows[0].sshCreateHomeDir ?? null;
|
||||
}
|
||||
|
||||
const sites = await db
|
||||
const sitesFromNetworks = await db
|
||||
.select({ siteId: siteNetworks.siteId })
|
||||
.from(siteNetworks)
|
||||
.where(eq(siteNetworks.networkId, resource.networkId!));
|
||||
|
||||
const siteIds = sites.map((site) => site.siteId);
|
||||
const siteIds = sitesFromNetworks.map((site) => site.siteId);
|
||||
|
||||
// Sign the public key
|
||||
const now = BigInt(Math.floor(Date.now() / 1000));
|
||||
// only valid for 5 minutes
|
||||
const validFor = 300n;
|
||||
let expiresIn: number | undefined;
|
||||
let messageIds: number[] = [];
|
||||
let cert:
|
||||
| {
|
||||
certificate: string;
|
||||
keyId: string;
|
||||
validPrincipals: string[];
|
||||
validAfter: Date;
|
||||
validBefore: Date;
|
||||
}
|
||||
| undefined;
|
||||
// if the pam mode is push then we generate the user's pam username and use that or pull it from the userOrgs table
|
||||
// if the mode is passthrough then just use what was provided because the user will log in themselves
|
||||
let usernameToUse;
|
||||
if (resource.pamMode === "push") {
|
||||
if (!userOrg.pamUsername) {
|
||||
if (req.user?.email) {
|
||||
// Extract username from email (first part before @)
|
||||
usernameToUse = req.user?.email
|
||||
.split("@")[0]
|
||||
.replace(/[^a-zA-Z0-9_-]/g, "");
|
||||
if (!usernameToUse) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
"Unable to extract username from email"
|
||||
)
|
||||
);
|
||||
}
|
||||
} else if (req.user?.username) {
|
||||
usernameToUse = req.user.username;
|
||||
// We need to clean out any spaces or special characters from the username to ensure it's valid for SSH certificates
|
||||
usernameToUse = usernameToUse.replace(
|
||||
/[^a-zA-Z0-9_-]/g,
|
||||
"-"
|
||||
);
|
||||
if (!usernameToUse) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
"Username is not valid for SSH certificate"
|
||||
)
|
||||
);
|
||||
}
|
||||
} else {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
"User does not have a valid email or username for SSH certificate"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const cert = signPublicKey(caKeys.privateKeyPem, publicKey, {
|
||||
keyId: `${usernameToUse}@${resource.niceId}`,
|
||||
validPrincipals: [usernameToUse, resource.niceId],
|
||||
validAfter: now - 60n, // Start 1 min ago for clock skew
|
||||
validBefore: now + validFor
|
||||
});
|
||||
// prefix with p-
|
||||
usernameToUse = `p-${usernameToUse}`;
|
||||
|
||||
// check if we have a existing user in this org with the same
|
||||
const [existingUserWithSameName] = await db
|
||||
.select()
|
||||
.from(userOrgs)
|
||||
.where(
|
||||
and(
|
||||
eq(userOrgs.orgId, orgId),
|
||||
eq(userOrgs.pamUsername, usernameToUse)
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
if (existingUserWithSameName) {
|
||||
let foundUniqueUsername = false;
|
||||
for (let attempt = 0; attempt < 20; attempt++) {
|
||||
const randomNum = Math.floor(Math.random() * 101); // 0 to 100
|
||||
const candidateUsername = `${usernameToUse}${randomNum}`;
|
||||
|
||||
const [existingUser] = await db
|
||||
.select()
|
||||
.from(userOrgs)
|
||||
.where(
|
||||
and(
|
||||
eq(userOrgs.orgId, orgId),
|
||||
eq(userOrgs.pamUsername, candidateUsername)
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
if (!existingUser) {
|
||||
usernameToUse = candidateUsername;
|
||||
foundUniqueUsername = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!foundUniqueUsername) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.CONFLICT,
|
||||
"Unable to generate a unique username for SSH certificate"
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
await db
|
||||
.update(userOrgs)
|
||||
.set({ pamUsername: usernameToUse })
|
||||
.where(
|
||||
and(
|
||||
eq(userOrgs.orgId, orgId),
|
||||
eq(userOrgs.userId, userId)
|
||||
)
|
||||
);
|
||||
} else {
|
||||
usernameToUse = userOrg.pamUsername;
|
||||
}
|
||||
|
||||
const roleRows = await db
|
||||
.select({
|
||||
sshSudoCommands: roles.sshSudoCommands,
|
||||
sshUnixGroups: roles.sshUnixGroups,
|
||||
sshCreateHomeDir: roles.sshCreateHomeDir,
|
||||
sshSudoMode: roles.sshSudoMode
|
||||
})
|
||||
.from(roles)
|
||||
.innerJoin(
|
||||
roleSiteResources,
|
||||
eq(roleSiteResources.roleId, roles.roleId)
|
||||
)
|
||||
.where(
|
||||
and(
|
||||
inArray(roles.roleId, roleIds),
|
||||
eq(
|
||||
roleSiteResources.siteResourceId,
|
||||
resource.siteResourceId
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
const parsedSudoCommands: string[] = [];
|
||||
const parsedGroupsSet = new Set<string>();
|
||||
let homedir: boolean | null = null;
|
||||
const sudoModeOrder = { none: 0, commands: 1, full: 2 };
|
||||
let sudoMode: "none" | "commands" | "full" = "none";
|
||||
for (const roleRow of roleRows) {
|
||||
try {
|
||||
const cmds = JSON.parse(roleRow?.sshSudoCommands ?? "[]");
|
||||
if (Array.isArray(cmds)) parsedSudoCommands.push(...cmds);
|
||||
} catch {
|
||||
// skip
|
||||
}
|
||||
try {
|
||||
const grps = JSON.parse(roleRow?.sshUnixGroups ?? "[]");
|
||||
if (Array.isArray(grps))
|
||||
grps.forEach((g: string) => parsedGroupsSet.add(g));
|
||||
} catch {
|
||||
// skip
|
||||
}
|
||||
if (roleRow?.sshCreateHomeDir === true) homedir = true;
|
||||
const m = roleRow?.sshSudoMode ?? "none";
|
||||
if (
|
||||
sudoModeOrder[m as keyof typeof sudoModeOrder] >
|
||||
sudoModeOrder[sudoMode]
|
||||
) {
|
||||
sudoMode = m as "none" | "commands" | "full";
|
||||
}
|
||||
}
|
||||
const parsedGroups = Array.from(parsedGroupsSet);
|
||||
if (homedir === null && roleRows.length > 0) {
|
||||
homedir = roleRows[0].sshCreateHomeDir ?? null;
|
||||
}
|
||||
|
||||
// Sign the public key
|
||||
const now = BigInt(Math.floor(Date.now() / 1000));
|
||||
// only valid for 5 minutes
|
||||
const validFor = 300n;
|
||||
expiresIn = Number(validFor); // seconds
|
||||
|
||||
const cert = signPublicKey(caKeys.privateKeyPem, publicKey, {
|
||||
keyId: `${usernameToUse}@${resource.niceId}`,
|
||||
validPrincipals: [usernameToUse, resource.niceId],
|
||||
validAfter: now - 60n, // Start 1 min ago for clock skew
|
||||
validBefore: now + validFor
|
||||
});
|
||||
|
||||
const messageIds: number[] = [];
|
||||
for (const siteId of siteIds) {
|
||||
// get the site
|
||||
const [newt] = await db
|
||||
.select()
|
||||
.from(newts)
|
||||
.where(eq(newts.siteId, siteId))
|
||||
.limit(1);
|
||||
|
||||
if (!newt) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.INTERNAL_SERVER_ERROR,
|
||||
"Site associated with resource not found"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const [message] = await db
|
||||
.insert(roundTripMessageTracker)
|
||||
.values({
|
||||
wsClientId: newt.newtId,
|
||||
messageType: `newt/pam/connection`,
|
||||
sentAt: Math.floor(Date.now() / 1000)
|
||||
})
|
||||
.returning();
|
||||
|
||||
if (!message) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.INTERNAL_SERVER_ERROR,
|
||||
"Failed to create message tracker entry"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
messageIds.push(message.messageId);
|
||||
|
||||
await sendToClient(newt.newtId, {
|
||||
type: `newt/pam/connection`,
|
||||
data: {
|
||||
messageId: message.messageId,
|
||||
orgId: orgId,
|
||||
agentPort: resource.authDaemonPort ?? 22123,
|
||||
authDaemonMode: resource.authDaemonMode, // site, remote, native where native is the pty mode
|
||||
externalAuthDaemon:
|
||||
resource.authDaemonMode === "remote", // keep this for backward compatibility but new newts are using the authDaemonMode field
|
||||
agentHost: resource.destination,
|
||||
caCert: caKeys.publicKeyOpenSSH,
|
||||
username: usernameToUse,
|
||||
niceId: resource.niceId,
|
||||
metadata: {
|
||||
sudoMode: sudoMode,
|
||||
sudoCommands: parsedSudoCommands,
|
||||
homedir: homedir,
|
||||
groups: parsedGroups
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
} else if (resource.pamMode === "passthrough") {
|
||||
usernameToUse = username;
|
||||
if (!usernameToUse) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
"Username must be provided when PAM mode is passthrough"
|
||||
)
|
||||
);
|
||||
}
|
||||
} else {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.INTERNAL_SERVER_ERROR,
|
||||
"Invalid PAM mode configured for resource"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
let sshHost: string | undefined;
|
||||
if (
|
||||
resource.authDaemonMode === "site" ||
|
||||
resource.authDaemonMode === "remote"
|
||||
) {
|
||||
if (resource.alias && resource.alias != "") {
|
||||
sshHost = resource.alias;
|
||||
} else {
|
||||
sshHost = resource.destination || ""; // TODO: IF WE HAVE THE NATIVE SSH MODE WHAT SHOULD WE DO HERE?
|
||||
}
|
||||
} else if (resource.authDaemonMode === "native") {
|
||||
if (siteIds.length > 1) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.INTERNAL_SERVER_ERROR,
|
||||
"Multiple sites associated with resource, unable to determine SSH host when in native mode"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const messageIds: number[] = [];
|
||||
for (const siteId of siteIds) {
|
||||
// get the site
|
||||
const [newt] = await db
|
||||
const [site] = await db
|
||||
.select()
|
||||
.from(newts)
|
||||
.where(eq(newts.siteId, siteId))
|
||||
.from(sites)
|
||||
.where(eq(sites.siteId, siteIds[0]))
|
||||
.limit(1);
|
||||
|
||||
if (!newt) {
|
||||
if (!site) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.INTERNAL_SERVER_ERROR,
|
||||
@@ -453,54 +574,26 @@ export async function signSshKey(
|
||||
);
|
||||
}
|
||||
|
||||
const [message] = await db
|
||||
.insert(roundTripMessageTracker)
|
||||
.values({
|
||||
wsClientId: newt.newtId,
|
||||
messageType: `newt/pam/connection`,
|
||||
sentAt: Math.floor(Date.now() / 1000)
|
||||
})
|
||||
.returning();
|
||||
|
||||
if (!message) {
|
||||
if (!site.address) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.INTERNAL_SERVER_ERROR,
|
||||
"Failed to create message tracker entry"
|
||||
"Site address not configured, unable to determine SSH host when in native mode"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
messageIds.push(message.messageId);
|
||||
|
||||
await sendToClient(newt.newtId, {
|
||||
type: `newt/pam/connection`,
|
||||
data: {
|
||||
messageId: message.messageId,
|
||||
orgId: orgId,
|
||||
agentPort: resource.authDaemonPort ?? 22123,
|
||||
externalAuthDaemon: resource.authDaemonMode === "remote",
|
||||
agentHost: resource.destination,
|
||||
caCert: caKeys.publicKeyOpenSSH,
|
||||
username: usernameToUse,
|
||||
niceId: resource.niceId,
|
||||
metadata: {
|
||||
sudoMode: sudoMode,
|
||||
sudoCommands: parsedSudoCommands,
|
||||
homedir: homedir,
|
||||
groups: parsedGroups
|
||||
}
|
||||
}
|
||||
});
|
||||
// its the address but split off the cidr if there is one
|
||||
sshHost = site.address.split("/")[0];
|
||||
}
|
||||
|
||||
const expiresIn = Number(validFor); // seconds
|
||||
|
||||
let sshHost;
|
||||
if (resource.alias && resource.alias != "") {
|
||||
sshHost = resource.alias;
|
||||
} else {
|
||||
sshHost = resource.destination;
|
||||
if (!sshHost) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.INTERNAL_SERVER_ERROR,
|
||||
"Unable to determine SSH host for the resource"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
await logsDb.insert(actionAuditLog).values({
|
||||
@@ -527,7 +620,7 @@ export async function signSshKey(
|
||||
: undefined,
|
||||
metadata: {
|
||||
resourceName: resource.name,
|
||||
siteId: siteIds[0],
|
||||
siteIds: siteIds,
|
||||
sshUsername: usernameToUse,
|
||||
sshHost: sshHost
|
||||
},
|
||||
@@ -537,18 +630,18 @@ export async function signSshKey(
|
||||
|
||||
return response<SignSshKeyResponse>(res, {
|
||||
data: {
|
||||
certificate: cert.certificate,
|
||||
certificate: cert?.certificate,
|
||||
messageIds: messageIds,
|
||||
messageId: messageIds[0], // just pick the first one for backward compatibility
|
||||
messageId: messageIds[0], // just pick the first one for backward compatibility with older olms
|
||||
sshUsername: usernameToUse,
|
||||
sshHost: sshHost,
|
||||
sshHost: sshHost, // just pick the first one for backward compatibility with older olms
|
||||
resourceId: resource.siteResourceId,
|
||||
siteIds: siteIds,
|
||||
siteId: siteIds[0], // just pick the first one for backward compatibility
|
||||
keyId: cert.keyId,
|
||||
validPrincipals: cert.validPrincipals,
|
||||
validAfter: cert.validAfter.toISOString(),
|
||||
validBefore: cert.validBefore.toISOString(),
|
||||
siteId: siteIds[0], // just pick the first one for backward compatibility with older olms
|
||||
keyId: cert?.keyId,
|
||||
validPrincipals: cert?.validPrincipals,
|
||||
validAfter: cert?.validAfter.toISOString(),
|
||||
validBefore: cert?.validBefore.toISOString(),
|
||||
expiresIn
|
||||
},
|
||||
success: true,
|
||||
|
||||
@@ -522,13 +522,13 @@ const sendToClientLocal = async (
|
||||
|
||||
const messageString = JSON.stringify(messageWithVersion);
|
||||
if (options.compress) {
|
||||
logger.debug(
|
||||
`Message size before compression: ${messageString.length} bytes`
|
||||
);
|
||||
// logger.debug(
|
||||
// `Message size before compression: ${messageString.length} bytes`
|
||||
// );
|
||||
const compressed = zlib.gzipSync(Buffer.from(messageString, "utf8"));
|
||||
logger.debug(
|
||||
`Message size after compression: ${compressed.length} bytes`
|
||||
);
|
||||
// logger.debug(
|
||||
// `Message size after compression: ${compressed.length} bytes`
|
||||
// );
|
||||
clients.forEach((client) => {
|
||||
if (client.readyState === WebSocket.OPEN) {
|
||||
client.send(compressed);
|
||||
|
||||
@@ -27,6 +27,7 @@ import { OpenAPITags, registry } from "@server/openApi";
|
||||
export const generateAccessTokenBodySchema = z.strictObject({
|
||||
validForSeconds: z.int().positive().optional(), // seconds
|
||||
title: z.string().optional(),
|
||||
path: z.string().optional(),
|
||||
description: z.string().optional()
|
||||
});
|
||||
|
||||
@@ -85,7 +86,7 @@ export async function generateAccessToken(
|
||||
}
|
||||
|
||||
const { resourceId } = parsedParams.data;
|
||||
const { validForSeconds, title, description } = parsedBody.data;
|
||||
const { validForSeconds, title, path, description } = parsedBody.data;
|
||||
|
||||
const [resource] = await db
|
||||
.select()
|
||||
@@ -121,6 +122,7 @@ export async function generateAccessToken(
|
||||
expiresAt: expiresAt || null,
|
||||
sessionLength: sessionLength,
|
||||
title: title || null,
|
||||
path: path || null,
|
||||
description: description || null,
|
||||
createdAt: new Date().getTime()
|
||||
})
|
||||
@@ -131,6 +133,7 @@ export async function generateAccessToken(
|
||||
expiresAt: resourceAccessToken.expiresAt,
|
||||
sessionLength: resourceAccessToken.sessionLength,
|
||||
title: resourceAccessToken.title,
|
||||
path: resourceAccessToken.path,
|
||||
description: resourceAccessToken.description,
|
||||
createdAt: resourceAccessToken.createdAt
|
||||
})
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { logsDb, requestAuditLog, driver, primaryLogsDb } from "@server/db";
|
||||
import { logsDb, requestAuditLog, driver } from "@server/db";
|
||||
import { registry } from "@server/openApi";
|
||||
import { NextFunction } from "express";
|
||||
import { Request, Response } from "express";
|
||||
@@ -74,12 +74,12 @@ async function query(query: Q) {
|
||||
);
|
||||
}
|
||||
|
||||
const [all] = await primaryLogsDb
|
||||
const [all] = await logsDb
|
||||
.select({ total: count() })
|
||||
.from(requestAuditLog)
|
||||
.where(baseConditions);
|
||||
|
||||
const [blocked] = await primaryLogsDb
|
||||
const [blocked] = await logsDb
|
||||
.select({ total: count() })
|
||||
.from(requestAuditLog)
|
||||
.where(and(baseConditions, eq(requestAuditLog.action, false)));
|
||||
@@ -90,7 +90,7 @@ async function query(query: Q) {
|
||||
|
||||
const DISTINCT_LIMIT = 500;
|
||||
|
||||
const requestsPerCountry = await primaryLogsDb
|
||||
const requestsPerCountry = await logsDb
|
||||
.selectDistinct({
|
||||
code: requestAuditLog.location,
|
||||
count: totalQ
|
||||
@@ -118,7 +118,7 @@ async function query(query: Q) {
|
||||
const booleanTrue = driver === "pg" ? sql`true` : sql`1`;
|
||||
const booleanFalse = driver === "pg" ? sql`false` : sql`0`;
|
||||
|
||||
const requestsPerDay = await primaryLogsDb
|
||||
const requestsPerDay = await logsDb
|
||||
.select({
|
||||
day: groupByDayFunction.as("day"),
|
||||
allowedCount:
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { logsDb, primaryLogsDb, requestAuditLog, resources, siteResources, db, primaryDb } from "@server/db";
|
||||
import { logsDb, requestAuditLog, resources, siteResources, db, primaryDb } from "@server/db";
|
||||
import { registry } from "@server/openApi";
|
||||
import { NextFunction } from "express";
|
||||
import { Request, Response } from "express";
|
||||
@@ -86,6 +86,20 @@ export const queryRequestAuditLogsCombined = queryAccessAuditLogsQuery.merge(
|
||||
);
|
||||
type Q = z.infer<typeof queryRequestAuditLogsCombined>;
|
||||
|
||||
function sortNamedFilterOptions<T extends { id: number; name: string | null }>(
|
||||
items: T[]
|
||||
): T[] {
|
||||
return [...items].sort((a, b) => {
|
||||
const nameA = a.name ?? "";
|
||||
const nameB = b.name ?? "";
|
||||
|
||||
if (nameA < nameB) return -1;
|
||||
if (nameA > nameB) return 1;
|
||||
|
||||
return a.id - b.id;
|
||||
});
|
||||
}
|
||||
|
||||
function getWhere(data: Q) {
|
||||
return and(
|
||||
gt(requestAuditLog.timestamp, data.timeStart),
|
||||
@@ -110,7 +124,7 @@ function getWhere(data: Q) {
|
||||
}
|
||||
|
||||
export function queryRequest(data: Q) {
|
||||
return primaryLogsDb
|
||||
return logsDb
|
||||
.select({
|
||||
id: requestAuditLog.id,
|
||||
timestamp: requestAuditLog.timestamp,
|
||||
@@ -211,7 +225,7 @@ async function enrichWithResourceDetails(logs: Awaited<ReturnType<typeof queryRe
|
||||
}
|
||||
|
||||
export function countRequestQuery(data: Q) {
|
||||
const countQuery = primaryLogsDb
|
||||
const countQuery = logsDb
|
||||
.select({ count: count() })
|
||||
.from(requestAuditLog)
|
||||
.where(getWhere(data));
|
||||
@@ -254,34 +268,34 @@ async function queryUniqueFilterAttributes(
|
||||
uniqueResources,
|
||||
uniqueSiteResources
|
||||
] = await Promise.all([
|
||||
primaryLogsDb
|
||||
logsDb
|
||||
.selectDistinct({ actor: requestAuditLog.actor })
|
||||
.from(requestAuditLog)
|
||||
.where(baseConditions)
|
||||
.limit(DISTINCT_LIMIT + 1),
|
||||
primaryLogsDb
|
||||
logsDb
|
||||
.selectDistinct({ locations: requestAuditLog.location })
|
||||
.from(requestAuditLog)
|
||||
.where(baseConditions)
|
||||
.limit(DISTINCT_LIMIT + 1),
|
||||
primaryLogsDb
|
||||
logsDb
|
||||
.selectDistinct({ hosts: requestAuditLog.host })
|
||||
.from(requestAuditLog)
|
||||
.where(baseConditions)
|
||||
.limit(DISTINCT_LIMIT + 1),
|
||||
primaryLogsDb
|
||||
logsDb
|
||||
.selectDistinct({ paths: requestAuditLog.path })
|
||||
.from(requestAuditLog)
|
||||
.where(baseConditions)
|
||||
.limit(DISTINCT_LIMIT + 1),
|
||||
primaryLogsDb
|
||||
logsDb
|
||||
.selectDistinct({
|
||||
id: requestAuditLog.resourceId
|
||||
})
|
||||
.from(requestAuditLog)
|
||||
.where(baseConditions)
|
||||
.limit(DISTINCT_LIMIT + 1),
|
||||
primaryLogsDb
|
||||
logsDb
|
||||
.selectDistinct({
|
||||
id: requestAuditLog.siteResourceId
|
||||
})
|
||||
@@ -353,7 +367,7 @@ async function queryUniqueFilterAttributes(
|
||||
actors: uniqueActors
|
||||
.map((row) => row.actor)
|
||||
.filter((actor): actor is string => actor !== null),
|
||||
resources: resourcesWithNames,
|
||||
resources: sortNamedFilterOptions(resourcesWithNames),
|
||||
locations: uniqueLocations
|
||||
.map((row) => row.locations)
|
||||
.filter((location): location is string => location !== null),
|
||||
|
||||
@@ -671,7 +671,8 @@ export async function verifyResourceSession(
|
||||
resourceData.org
|
||||
);
|
||||
|
||||
localCache.set(userAccessCacheKey, allowedUserData, 5);
|
||||
// this is query intensive so let it cache a little longer
|
||||
localCache.set(userAccessCacheKey, allowedUserData, 12);
|
||||
}
|
||||
|
||||
if (
|
||||
@@ -1003,11 +1004,7 @@ async function checkRules(
|
||||
isIpInCidr(clientIp, rule.value)
|
||||
) {
|
||||
return rule.action as any;
|
||||
} else if (
|
||||
clientIp &&
|
||||
rule.match == "IP" &&
|
||||
clientIp == rule.value
|
||||
) {
|
||||
} else if (clientIp && rule.match == "IP" && clientIp == rule.value) {
|
||||
return rule.action as any;
|
||||
} else if (
|
||||
path &&
|
||||
@@ -1015,10 +1012,7 @@ async function checkRules(
|
||||
isPathAllowed(rule.value, path)
|
||||
) {
|
||||
return rule.action as any;
|
||||
} else if (
|
||||
clientIp &&
|
||||
rule.match == "COUNTRY"
|
||||
) {
|
||||
} else if (clientIp && rule.match == "COUNTRY") {
|
||||
// COUNTRY=ALL should not affect local/private/CGNAT addresses.
|
||||
if (
|
||||
rule.value.toUpperCase() === "ALL" &&
|
||||
@@ -1030,10 +1024,7 @@ async function checkRules(
|
||||
if (await isIpInGeoIP(ipCC, rule.value)) {
|
||||
return rule.action as any;
|
||||
}
|
||||
} else if (
|
||||
clientIp &&
|
||||
rule.match == "ASN"
|
||||
) {
|
||||
} else if (clientIp && rule.match == "ASN") {
|
||||
// ASN=ALL/AS0 should not affect local/private/CGNAT addresses.
|
||||
if (
|
||||
(rule.value.toUpperCase() === "ALL" ||
|
||||
@@ -1272,11 +1263,15 @@ export async function isIpInRegion(
|
||||
if (region.id === checkRegionCode) {
|
||||
for (const subregion of region.includes) {
|
||||
if (subregion.countries.includes(upperCode)) {
|
||||
logger.debug(`Country ${upperCode} is in region ${region.id} (${region.name})`);
|
||||
logger.debug(
|
||||
`Country ${upperCode} is in region ${region.id} (${region.name})`
|
||||
);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
logger.debug(`Country ${upperCode} is not in region ${region.id} (${region.name})`);
|
||||
logger.debug(
|
||||
`Country ${upperCode} is not in region ${region.id} (${region.name})`
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -1284,10 +1279,14 @@ export async function isIpInRegion(
|
||||
for (const subregion of region.includes) {
|
||||
if (subregion.id === checkRegionCode) {
|
||||
if (subregion.countries.includes(upperCode)) {
|
||||
logger.debug(`Country ${upperCode} is in region ${subregion.id} (${subregion.name})`);
|
||||
logger.debug(
|
||||
`Country ${upperCode} is in region ${subregion.id} (${subregion.name})`
|
||||
);
|
||||
return true;
|
||||
}
|
||||
logger.debug(`Country ${upperCode} is not in region ${subregion.id} (${subregion.name})`);
|
||||
logger.debug(
|
||||
`Country ${upperCode} is not in region ${subregion.id} (${subregion.name})`
|
||||
);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -51,7 +51,9 @@ export async function pickClientDefaults(
|
||||
const olmId = generateId(15);
|
||||
const secret = generateId(48);
|
||||
|
||||
const newSubnet = await getNextAvailableClientSubnet(orgId);
|
||||
const { value: newSubnet, release } =
|
||||
await getNextAvailableClientSubnet(orgId);
|
||||
await release(); // release immediately — this endpoint only previews the next available value
|
||||
if (!newSubnet) {
|
||||
return next(
|
||||
createHttpError(
|
||||
|
||||
@@ -3,6 +3,7 @@ import config from "@server/lib/config";
|
||||
import * as site from "./site";
|
||||
import * as org from "./org";
|
||||
import * as resource from "./resource";
|
||||
import * as policy from "./policy";
|
||||
import * as domain from "./domain";
|
||||
import * as target from "./target";
|
||||
import * as user from "./user";
|
||||
@@ -42,7 +43,8 @@ import {
|
||||
verifyUserIsOrgOwner,
|
||||
verifySiteResourceAccess,
|
||||
verifyOlmAccess,
|
||||
verifyLimits
|
||||
verifyLimits,
|
||||
verifyResourcePolicyAccess
|
||||
} from "@server/middlewares";
|
||||
import { ActionsEnum } from "@server/auth/actions";
|
||||
import rateLimit, { ipKeyGenerator } from "express-rate-limit";
|
||||
@@ -103,7 +105,6 @@ authenticated.put(
|
||||
site.createSite
|
||||
);
|
||||
|
||||
|
||||
authenticated.get(
|
||||
"/org/:orgId/sites",
|
||||
verifyOrgAccess,
|
||||
@@ -540,6 +541,7 @@ authenticated.get(
|
||||
verifyUserHasAction(ActionsEnum.getResource),
|
||||
resource.getResource
|
||||
);
|
||||
|
||||
authenticated.post(
|
||||
"/resource/:resourceId",
|
||||
verifyResourceAccess,
|
||||
@@ -646,6 +648,29 @@ authenticated.post(
|
||||
logActionAudit(ActionsEnum.updateRole),
|
||||
role.updateRole
|
||||
);
|
||||
|
||||
authenticated.get(
|
||||
"/org/:orgId/resource-policy/:niceId",
|
||||
verifyOrgAccess,
|
||||
verifyResourcePolicyAccess,
|
||||
verifyUserHasAction(ActionsEnum.getResourcePolicy),
|
||||
policy.getResourcePolicy
|
||||
);
|
||||
|
||||
authenticated.get(
|
||||
"/resource/:resourceId/policies",
|
||||
verifyResourceAccess,
|
||||
verifyUserHasAction(ActionsEnum.getResourcePolicy),
|
||||
resource.getResourcePolicies
|
||||
);
|
||||
|
||||
authenticated.put(
|
||||
"/resource-policy/:resourcePolicyId",
|
||||
verifyResourcePolicyAccess,
|
||||
verifyUserHasAction(ActionsEnum.updateResourcePolicy),
|
||||
policy.updateResourcePolicy
|
||||
);
|
||||
|
||||
// authenticated.get(
|
||||
// "/role/:roleId",
|
||||
// verifyRoleAccess,
|
||||
@@ -697,6 +722,59 @@ authenticated.post(
|
||||
resource.setResourceUsers
|
||||
);
|
||||
|
||||
authenticated.put(
|
||||
"/resource-policy/:resourcePolicyId/access-control",
|
||||
verifyResourcePolicyAccess,
|
||||
verifyUserHasAction(ActionsEnum.setResourcePolicyUsers),
|
||||
logActionAudit(ActionsEnum.setResourcePolicyUsers),
|
||||
policy.setResourcePolicyAccessControl
|
||||
);
|
||||
|
||||
authenticated.put(
|
||||
"/resource-policy/:resourcePolicyId/password",
|
||||
verifyResourcePolicyAccess,
|
||||
verifyLimits,
|
||||
verifyUserHasAction(ActionsEnum.setResourcePolicyPassword),
|
||||
logActionAudit(ActionsEnum.setResourcePolicyPassword),
|
||||
policy.setResourcePolicyPassword
|
||||
);
|
||||
|
||||
authenticated.put(
|
||||
"/resource-policy/:resourcePolicyId/pincode",
|
||||
verifyResourcePolicyAccess,
|
||||
verifyLimits,
|
||||
verifyUserHasAction(ActionsEnum.setResourcePolicyPincode),
|
||||
logActionAudit(ActionsEnum.setResourcePolicyPincode),
|
||||
policy.setResourcePolicyPincode
|
||||
);
|
||||
|
||||
authenticated.put(
|
||||
"/resource-policy/:resourcePolicyId/header-auth",
|
||||
verifyResourcePolicyAccess,
|
||||
verifyLimits,
|
||||
verifyUserHasAction(ActionsEnum.setResourcePolicyHeaderAuth),
|
||||
logActionAudit(ActionsEnum.setResourcePolicyHeaderAuth),
|
||||
policy.setResourcePolicyHeaderAuth
|
||||
);
|
||||
|
||||
authenticated.put(
|
||||
"/resource-policy/:resourcePolicyId/whitelist",
|
||||
verifyResourcePolicyAccess,
|
||||
verifyLimits,
|
||||
verifyUserHasAction(ActionsEnum.setResourcePolicyWhitelist),
|
||||
logActionAudit(ActionsEnum.setResourcePolicyWhitelist),
|
||||
policy.setResourcePolicyWhitelist
|
||||
);
|
||||
|
||||
authenticated.put(
|
||||
"/resource-policy/:resourcePolicyId/rules",
|
||||
verifyResourcePolicyAccess,
|
||||
verifyLimits,
|
||||
verifyUserHasAction(ActionsEnum.setResourcePolicyRules),
|
||||
logActionAudit(ActionsEnum.setResourcePolicyRules),
|
||||
policy.setResourcePolicyRules
|
||||
);
|
||||
|
||||
authenticated.post(
|
||||
`/resource/:resourceId/password`,
|
||||
verifyResourceAccess,
|
||||
@@ -1156,7 +1234,7 @@ export const authRouter = Router();
|
||||
unauthenticated.use("/auth", authRouter);
|
||||
authRouter.use(
|
||||
rateLimit({
|
||||
windowMs: config.getRawConfig().rate_limits.auth.window_minutes,
|
||||
windowMs: config.getRawConfig().rate_limits.auth.window_minutes * 60 * 1000,
|
||||
max: config.getRawConfig().rate_limits.auth.max_requests,
|
||||
keyGenerator: (req) =>
|
||||
`authRouterGlobal:${ipKeyGenerator(req.ip || "")}:${req.path}`,
|
||||
@@ -1231,6 +1309,22 @@ authRouter.post(
|
||||
newt.getNewtToken
|
||||
);
|
||||
|
||||
authRouter.post(
|
||||
"/newt/version",
|
||||
rateLimit({
|
||||
windowMs: 15 * 60 * 1000,
|
||||
max: 60,
|
||||
keyGenerator: (req) =>
|
||||
`newtVersion:${req.body.newtId || ipKeyGenerator(req.ip || "")}`,
|
||||
handler: (req, res, next) => {
|
||||
const message = `You can only check the Newt version ${60} times every ${15} minutes. Please try again later.`;
|
||||
return next(createHttpError(HttpCode.TOO_MANY_REQUESTS, message));
|
||||
},
|
||||
store: createStore()
|
||||
}),
|
||||
newt.getNewtVersion
|
||||
);
|
||||
|
||||
authRouter.post(
|
||||
"/newt/register",
|
||||
rateLimit({
|
||||
@@ -1252,7 +1346,7 @@ authRouter.post(
|
||||
windowMs: 15 * 60 * 1000,
|
||||
max: 900,
|
||||
keyGenerator: (req) =>
|
||||
`olmGetToken:${req.body.newtId || ipKeyGenerator(req.ip || "")}`,
|
||||
`olmGetToken:${req.body.olmId || ipKeyGenerator(req.ip || "")}`,
|
||||
handler: (req, res, next) => {
|
||||
const message = `You can only request an Olm token ${900} times every ${15} minutes. Please try again later.`;
|
||||
return next(createHttpError(HttpCode.TOO_MANY_REQUESTS, message));
|
||||
|
||||
@@ -2,6 +2,7 @@ import * as site from "./site";
|
||||
import * as org from "./org";
|
||||
import * as blueprints from "./blueprints";
|
||||
import * as resource from "./resource";
|
||||
import * as policy from "./policy";
|
||||
import * as domain from "./domain";
|
||||
import * as target from "./target";
|
||||
import * as user from "./user";
|
||||
@@ -29,7 +30,9 @@ import {
|
||||
verifyApiKeySiteResourceAccess,
|
||||
verifyApiKeySetResourceClients,
|
||||
verifyLimits,
|
||||
verifyApiKeyDomainAccess
|
||||
verifyApiKeyDomainAccess,
|
||||
verifyApiKeyResourcePolicyAccess,
|
||||
verifyUserHasAction
|
||||
} from "@server/middlewares";
|
||||
import HttpCode from "@server/types/HttpCode";
|
||||
import { Router } from "express";
|
||||
@@ -459,6 +462,20 @@ authenticated.get(
|
||||
resource.getResource
|
||||
);
|
||||
|
||||
authenticated.get(
|
||||
"/resource-policy/:resourcePolicyId",
|
||||
verifyApiKeyResourcePolicyAccess,
|
||||
verifyApiKeyHasAction(ActionsEnum.getResourcePolicy),
|
||||
policy.getResourcePolicy
|
||||
);
|
||||
|
||||
authenticated.get(
|
||||
"/resource/:resourceId/policies",
|
||||
verifyApiKeyResourceAccess,
|
||||
verifyApiKeyHasAction(ActionsEnum.getResourcePolicy),
|
||||
resource.getResourcePolicies
|
||||
);
|
||||
|
||||
authenticated.post(
|
||||
"/resource/:resourceId",
|
||||
verifyApiKeyResourceAccess,
|
||||
@@ -468,6 +485,13 @@ authenticated.post(
|
||||
resource.updateResource
|
||||
);
|
||||
|
||||
authenticated.put(
|
||||
"/resource-policy/:resourcePolicyId",
|
||||
verifyApiKeyResourcePolicyAccess,
|
||||
verifyApiKeyHasAction(ActionsEnum.updateResourcePolicy),
|
||||
policy.updateResourcePolicy
|
||||
);
|
||||
|
||||
authenticated.delete(
|
||||
"/resource/:resourceId",
|
||||
verifyApiKeyResourceAccess,
|
||||
@@ -619,6 +643,63 @@ authenticated.post(
|
||||
resource.setResourceUsers
|
||||
);
|
||||
|
||||
authenticated.put(
|
||||
"/resource-policy/:resourcePolicyId/access-control",
|
||||
verifyApiKeyResourcePolicyAccess,
|
||||
verifyApiKeyRoleAccess,
|
||||
verifyLimits,
|
||||
verifyUserHasAction(ActionsEnum.setResourcePolicyUsers),
|
||||
verifyUserHasAction(ActionsEnum.setResourcePolicyRoles),
|
||||
logActionAudit(ActionsEnum.setResourcePolicyUsers),
|
||||
logActionAudit(ActionsEnum.setResourcePolicyRoles),
|
||||
policy.setResourcePolicyAccessControl
|
||||
);
|
||||
|
||||
authenticated.put(
|
||||
"/resource-policy/:resourcePolicyId/password",
|
||||
verifyApiKeyResourcePolicyAccess,
|
||||
verifyLimits,
|
||||
verifyApiKeyHasAction(ActionsEnum.setResourcePolicyPassword),
|
||||
logActionAudit(ActionsEnum.setResourcePolicyPassword),
|
||||
policy.setResourcePolicyPassword
|
||||
);
|
||||
|
||||
authenticated.put(
|
||||
"/resource-policy/:resourcePolicyId/pincode",
|
||||
verifyApiKeyResourcePolicyAccess,
|
||||
verifyLimits,
|
||||
verifyApiKeyHasAction(ActionsEnum.setResourcePolicyPincode),
|
||||
logActionAudit(ActionsEnum.setResourcePolicyPincode),
|
||||
policy.setResourcePolicyPincode
|
||||
);
|
||||
|
||||
authenticated.put(
|
||||
"/resource-policy/:resourcePolicyId/header-auth",
|
||||
verifyApiKeyResourcePolicyAccess,
|
||||
verifyLimits,
|
||||
verifyApiKeyHasAction(ActionsEnum.setResourcePolicyHeaderAuth),
|
||||
logActionAudit(ActionsEnum.setResourcePolicyHeaderAuth),
|
||||
policy.setResourcePolicyHeaderAuth
|
||||
);
|
||||
|
||||
authenticated.put(
|
||||
"/resource-policy/:resourcePolicyId/whitelist",
|
||||
verifyApiKeyResourcePolicyAccess,
|
||||
verifyLimits,
|
||||
verifyApiKeyHasAction(ActionsEnum.setResourcePolicyWhitelist),
|
||||
logActionAudit(ActionsEnum.setResourcePolicyWhitelist),
|
||||
policy.setResourcePolicyWhitelist
|
||||
);
|
||||
|
||||
authenticated.put(
|
||||
"/resource-policy/:resourcePolicyId/rules",
|
||||
verifyApiKeyResourcePolicyAccess,
|
||||
verifyLimits,
|
||||
verifyApiKeyHasAction(ActionsEnum.setResourcePolicyRules),
|
||||
logActionAudit(ActionsEnum.setResourcePolicyRules),
|
||||
policy.setResourcePolicyRules
|
||||
);
|
||||
|
||||
authenticated.post(
|
||||
"/resource/:resourceId/roles/add",
|
||||
verifyApiKeyResourceAccess,
|
||||
|
||||
@@ -42,6 +42,8 @@ internalRouter.get("/idp", idp.listIdps);
|
||||
|
||||
internalRouter.get("/idp/:idpId", idp.getIdp);
|
||||
|
||||
internalRouter.get("/resource/browser-target", resource.getBrowserTarget);
|
||||
|
||||
// Gerbil routes
|
||||
const gerbilRouter = Router();
|
||||
internalRouter.use("/gerbil", gerbilRouter);
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import {
|
||||
browserGatewayTarget,
|
||||
BrowserGatewayTarget,
|
||||
clients,
|
||||
clientSiteResourcesAssociationsCache,
|
||||
clientSitesAssociationsCache,
|
||||
@@ -16,6 +18,7 @@ import logger from "@server/logger";
|
||||
import { initPeerAddHandshake, updatePeer } from "../olm/peers";
|
||||
import { eq, and } from "drizzle-orm";
|
||||
import config from "@server/lib/config";
|
||||
import { decrypt } from "@server/lib/crypto";
|
||||
import {
|
||||
formatEndpoint,
|
||||
generateSubnetProxyTargetV2,
|
||||
@@ -194,7 +197,7 @@ export async function buildTargetConfigurationForNewtClient(
|
||||
siteId: number,
|
||||
version?: string | null
|
||||
) {
|
||||
// Get all enabled targets with their resource protocol information
|
||||
// Get all enabled targets with their resource mode information
|
||||
const allTargets = await db
|
||||
.select({
|
||||
resourceId: targets.resourceId,
|
||||
@@ -204,7 +207,7 @@ export async function buildTargetConfigurationForNewtClient(
|
||||
port: targets.port,
|
||||
internalPort: targets.internalPort,
|
||||
enabled: targets.enabled,
|
||||
protocol: resources.protocol
|
||||
mode: resources.mode
|
||||
})
|
||||
.from(targets)
|
||||
.innerJoin(resources, eq(targets.resourceId, resources.resourceId))
|
||||
@@ -233,6 +236,11 @@ export async function buildTargetConfigurationForNewtClient(
|
||||
.from(targetHealthCheck)
|
||||
.where(eq(targetHealthCheck.siteId, siteId));
|
||||
|
||||
const allBrowserGatewayTargets = await db
|
||||
.select()
|
||||
.from(browserGatewayTarget)
|
||||
.where(eq(browserGatewayTarget.siteId, siteId));
|
||||
|
||||
const { tcpTargets, udpTargets } = allTargets.reduce(
|
||||
(acc, target) => {
|
||||
// Filter out invalid targets
|
||||
@@ -244,10 +252,11 @@ export async function buildTargetConfigurationForNewtClient(
|
||||
const formattedTarget = `${target.internalPort}:${formatEndpoint(target.ip, target.port)}`;
|
||||
|
||||
// Add to the appropriate protocol array
|
||||
if (target.protocol === "tcp") {
|
||||
acc.tcpTargets.push(formattedTarget);
|
||||
} else {
|
||||
if (target.mode === "udp") {
|
||||
acc.udpTargets.push(formattedTarget);
|
||||
} else {
|
||||
// all other modes are tcp
|
||||
acc.tcpTargets.push(formattedTarget);
|
||||
}
|
||||
|
||||
return acc;
|
||||
@@ -304,9 +313,22 @@ export async function buildTargetConfigurationForNewtClient(
|
||||
(target) => target !== null
|
||||
);
|
||||
|
||||
const serverSecret = config.getRawConfig().server.secret!;
|
||||
const browserGatewayTargets = allBrowserGatewayTargets.map((t) => {
|
||||
const decryptAuthToken = decrypt(t.authToken, serverSecret);
|
||||
return {
|
||||
id: t.browserGatewayTargetId,
|
||||
type: t.type,
|
||||
destination: t.destination,
|
||||
destinationPort: t.destinationPort,
|
||||
authToken: decryptAuthToken
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
validHealthCheckTargets,
|
||||
tcpTargets,
|
||||
udpTargets
|
||||
udpTargets,
|
||||
browserGatewayTargets
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,317 @@
|
||||
import { db, orgs, sites } from "@server/db";
|
||||
import { newts } from "@server/db";
|
||||
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 semver from "semver";
|
||||
import { verifyPassword } from "@server/auth/password";
|
||||
import response from "@server/lib/response";
|
||||
import HttpCode from "@server/types/HttpCode";
|
||||
import logger from "@server/logger";
|
||||
import cache from "#dynamic/lib/cache";
|
||||
import config from "@server/lib/config";
|
||||
|
||||
// Stale-while-revalidate in-memory fallback for the releases API.
|
||||
type ReleaseInfo = {
|
||||
version: string;
|
||||
// binary filename -> sha256 hex (sourced from asset `digest` field in GitHub API)
|
||||
assetDigests: Record<string, string>;
|
||||
};
|
||||
let staleReleaseInfo: ReleaseInfo | null = null;
|
||||
|
||||
/**
|
||||
* Fetches the latest stable newt release from GitHub and returns the version
|
||||
* tag together with a map of asset-name → sha256 hex digest.
|
||||
* Results are cached for one hour; stale data is returned on failure.
|
||||
*/
|
||||
async function getLatestReleaseInfo(): Promise<ReleaseInfo | null> {
|
||||
try {
|
||||
const cached = await cache.get<ReleaseInfo>("cache:newtReleaseInfo");
|
||||
if (cached) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), 5000);
|
||||
|
||||
const fetchResponse = await fetch(
|
||||
"https://api.github.com/repos/fosrl/newt/releases",
|
||||
{ signal: controller.signal }
|
||||
);
|
||||
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
if (!fetchResponse.ok) {
|
||||
logger.warn(
|
||||
`Failed to fetch Newt releases from GitHub: ${fetchResponse.status} ${fetchResponse.statusText}`
|
||||
);
|
||||
return staleReleaseInfo;
|
||||
}
|
||||
|
||||
let releases: any[] = await fetchResponse.json();
|
||||
if (!Array.isArray(releases) || releases.length === 0) {
|
||||
logger.warn("No releases found for Newt repository");
|
||||
return staleReleaseInfo;
|
||||
}
|
||||
|
||||
// Drop drafts, pre-releases, and anything with "rc" in the tag name.
|
||||
releases = releases.filter(
|
||||
(r: any) =>
|
||||
!r.draft &&
|
||||
!r.prerelease &&
|
||||
!r.tag_name.includes("rc") &&
|
||||
!r.tag_name.includes("v")
|
||||
);
|
||||
|
||||
// Sort descending by semver to find the true latest stable release.
|
||||
releases.sort((a: any, b: any) => {
|
||||
const va = semver.coerce(a.tag_name);
|
||||
const vb = semver.coerce(b.tag_name);
|
||||
if (!va && !vb) return 0;
|
||||
if (!va) return 1;
|
||||
if (!vb) return -1;
|
||||
return semver.rcompare(va, vb);
|
||||
});
|
||||
|
||||
if (releases.length === 0) {
|
||||
logger.warn("No stable releases found for Newt repository");
|
||||
return staleReleaseInfo;
|
||||
}
|
||||
|
||||
const latest = releases[0];
|
||||
const version: string = latest.tag_name;
|
||||
|
||||
// Build a map of binary filename → sha256 hex from the asset `digest`
|
||||
// field returned by the GitHub API (format: "sha256:<hex>").
|
||||
const assetDigests: Record<string, string> = {};
|
||||
if (Array.isArray(latest.assets)) {
|
||||
for (const asset of latest.assets) {
|
||||
if (
|
||||
typeof asset.name === "string" &&
|
||||
typeof asset.digest === "string" &&
|
||||
asset.digest.startsWith("sha256:")
|
||||
) {
|
||||
assetDigests[asset.name] = asset.digest.slice(
|
||||
"sha256:".length
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const info: ReleaseInfo = { version, assetDigests };
|
||||
staleReleaseInfo = info;
|
||||
await cache.set("cache:newtReleaseInfo", info, 3600);
|
||||
return info;
|
||||
} catch (error: any) {
|
||||
if (error.name === "AbortError") {
|
||||
logger.warn("Request to fetch Newt releases timed out (5s)");
|
||||
} else {
|
||||
logger.warn(
|
||||
"Error fetching Newt releases:",
|
||||
error.message || error
|
||||
);
|
||||
}
|
||||
return staleReleaseInfo;
|
||||
}
|
||||
}
|
||||
|
||||
const bodySchema = z.object({
|
||||
newtId: z.string(),
|
||||
secret: z.string(),
|
||||
platform: z.string() // e.g. "linux_amd64", "darwin_arm64"
|
||||
});
|
||||
|
||||
export type GetNewtVersionBody = z.infer<typeof bodySchema>;
|
||||
|
||||
export type GetNewtVersionResponse = {
|
||||
latestVersion: string;
|
||||
currentIsLatest: boolean;
|
||||
downloadUrl: string;
|
||||
sha256: string;
|
||||
};
|
||||
|
||||
export async function getNewtVersion(
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction
|
||||
): Promise<any> {
|
||||
const parsedBody = bodySchema.safeParse(req.body);
|
||||
|
||||
if (!parsedBody.success) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
fromError(parsedBody.error).toString()
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const { newtId, secret, platform } = parsedBody.data;
|
||||
|
||||
try {
|
||||
// Verify newt credentials
|
||||
const [existingNewt] = await db
|
||||
.select()
|
||||
.from(newts)
|
||||
.where(eq(newts.newtId, newtId))
|
||||
.limit(1);
|
||||
|
||||
if (!existingNewt) {
|
||||
if (config.getRawConfig().app.log_failed_attempts) {
|
||||
logger.info(
|
||||
`Newt version check: no newt found with ID ${newtId}. IP: ${req.ip}.`
|
||||
);
|
||||
}
|
||||
return next(
|
||||
createHttpError(HttpCode.UNAUTHORIZED, "Invalid credentials")
|
||||
);
|
||||
}
|
||||
|
||||
if (!existingNewt.siteId) {
|
||||
logger.warn(`Newt ${newtId} has no associated site`);
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.UNAUTHORIZED,
|
||||
"Not associated with a site"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const validSecret = await verifyPassword(
|
||||
secret,
|
||||
existingNewt.secretHash
|
||||
);
|
||||
if (!validSecret) {
|
||||
if (config.getRawConfig().app.log_failed_attempts) {
|
||||
logger.info(
|
||||
`Newt version check: invalid secret for newt ID ${newtId}. IP: ${req.ip}.`
|
||||
);
|
||||
}
|
||||
return next(
|
||||
createHttpError(HttpCode.UNAUTHORIZED, "Invalid credentials")
|
||||
);
|
||||
}
|
||||
|
||||
// check if udpates are enabled for the org or the site
|
||||
const [site] = await db
|
||||
.select()
|
||||
.from(sites)
|
||||
.where(eq(sites.siteId, existingNewt.siteId))
|
||||
.limit(1);
|
||||
|
||||
if (!site) {
|
||||
logger.warn(`Site with ID ${existingNewt.siteId} not found`);
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.INTERNAL_SERVER_ERROR,
|
||||
"Associated site not found"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const [org] = await db
|
||||
.select()
|
||||
.from(orgs)
|
||||
.where(eq(orgs.orgId, site.orgId))
|
||||
.limit(1);
|
||||
|
||||
if (!org) {
|
||||
logger.warn(`Org with ID ${site.orgId} not found`);
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.INTERNAL_SERVER_ERROR,
|
||||
"Associated organization not found"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
let doUpdate = false;
|
||||
|
||||
if (site.autoUpdateOverrideOrg) {
|
||||
doUpdate = site.autoUpdateEnabled;
|
||||
} else {
|
||||
doUpdate = org.settingsEnableGlobalNewtAutoUpdate;
|
||||
}
|
||||
|
||||
if (!doUpdate) {
|
||||
// return no content http code
|
||||
return response(res, {
|
||||
data: {
|
||||
latestVersion: existingNewt.version ?? "",
|
||||
currentIsLatest: true,
|
||||
downloadUrl: "",
|
||||
sha256: ""
|
||||
},
|
||||
success: true,
|
||||
error: false,
|
||||
message:
|
||||
"Auto-updates are disabled for this site and organization",
|
||||
status: HttpCode.NO_CONTENT
|
||||
});
|
||||
}
|
||||
|
||||
// Fetch latest release info (version + asset digests) in one API call.
|
||||
const releaseInfo = await getLatestReleaseInfo();
|
||||
|
||||
if (!releaseInfo) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.INTERNAL_SERVER_ERROR,
|
||||
"Unable to determine latest Newt version"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const latestVersion = releaseInfo.version;
|
||||
|
||||
// Binary name follows the get-newt.sh convention: newt_<platform>[.exe]
|
||||
const binaryName = platform.includes("windows")
|
||||
? `newt_${platform}.exe`
|
||||
: `newt_${platform}`;
|
||||
|
||||
const downloadUrl = `https://github.com/fosrl/newt/releases/download/${latestVersion}/${binaryName}`;
|
||||
|
||||
// Look up the SHA256 digest for this specific binary from the GitHub
|
||||
// release asset metadata (the `digest` field, format "sha256:<hex>").
|
||||
const sha256 = releaseInfo.assetDigests[binaryName] ?? "";
|
||||
|
||||
// Determine whether the newt that's asking is already up to date.
|
||||
// We store the current version on the newt row when it registers.
|
||||
const currentVersion = existingNewt.version ?? null;
|
||||
let currentIsLatest = false;
|
||||
if (currentVersion) {
|
||||
try {
|
||||
const latest = semver.coerce(latestVersion);
|
||||
const current = semver.coerce(currentVersion);
|
||||
if (latest && current) {
|
||||
currentIsLatest = !semver.lt(current, latest);
|
||||
}
|
||||
} catch {
|
||||
// If we can't compare, assume not latest
|
||||
}
|
||||
}
|
||||
|
||||
return response<GetNewtVersionResponse>(res, {
|
||||
data: {
|
||||
latestVersion,
|
||||
currentIsLatest,
|
||||
downloadUrl,
|
||||
sha256
|
||||
},
|
||||
success: true,
|
||||
error: false,
|
||||
message: "Version info retrieved successfully",
|
||||
status: HttpCode.OK
|
||||
});
|
||||
} catch (e) {
|
||||
logger.error(e);
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.INTERNAL_SERVER_ERROR,
|
||||
"Failed to retrieve version info"
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -43,8 +43,13 @@ export const handleNewtRegisterMessage: MessageHandler = async (context) => {
|
||||
|
||||
const siteId = newt.siteId;
|
||||
|
||||
const { publicKey, pingResults, newtVersion, backwardsCompatible, chainId } =
|
||||
message.data;
|
||||
const {
|
||||
publicKey,
|
||||
pingResults,
|
||||
newtVersion,
|
||||
backwardsCompatible,
|
||||
chainId
|
||||
} = message.data;
|
||||
if (!publicKey) {
|
||||
logger.warn("Public key not provided");
|
||||
return;
|
||||
@@ -191,8 +196,12 @@ export const handleNewtRegisterMessage: MessageHandler = async (context) => {
|
||||
.where(eq(newts.newtId, newt.newtId));
|
||||
}
|
||||
|
||||
const { tcpTargets, udpTargets, validHealthCheckTargets } =
|
||||
await buildTargetConfigurationForNewtClient(siteId, newtVersion);
|
||||
const {
|
||||
tcpTargets,
|
||||
udpTargets,
|
||||
validHealthCheckTargets,
|
||||
browserGatewayTargets
|
||||
} = await buildTargetConfigurationForNewtClient(siteId, newtVersion);
|
||||
|
||||
logger.debug(
|
||||
`Sending health check targets to newt ${newt.newtId}: ${JSON.stringify(validHealthCheckTargets)}`
|
||||
@@ -212,6 +221,7 @@ export const handleNewtRegisterMessage: MessageHandler = async (context) => {
|
||||
tcp: tcpTargets
|
||||
},
|
||||
healthCheckTargets: validHealthCheckTargets,
|
||||
browserGatewayTargets: browserGatewayTargets,
|
||||
chainId: chainId
|
||||
}
|
||||
},
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
export * from "./createNewt";
|
||||
export * from "./getNewtToken";
|
||||
export * from "./getNewtVersion";
|
||||
export * from "./handleNewtRegisterMessage";
|
||||
export * from "./handleReceiveBandwidthMessage";
|
||||
export * from "./handleNewtGetConfigMessage";
|
||||
|
||||
@@ -203,84 +203,82 @@ export async function registerNewt(
|
||||
|
||||
let newSiteId: number | undefined;
|
||||
|
||||
await db.transaction(async (trx) => {
|
||||
const newClientAddress = await getNextAvailableClientSubnet(orgId);
|
||||
if (!newClientAddress) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.INTERNAL_SERVER_ERROR,
|
||||
"No available subnet found"
|
||||
)
|
||||
);
|
||||
}
|
||||
const { value: newClientAddress, release: releaseSubnetLock } =
|
||||
await getNextAvailableClientSubnet(orgId);
|
||||
try {
|
||||
await db.transaction(async (trx) => {
|
||||
let clientAddress = newClientAddress.split("/")[0];
|
||||
clientAddress = `${clientAddress}/${org.subnet!.split("/")[1]}`; // we want the block size of the whole org
|
||||
|
||||
let clientAddress = newClientAddress.split("/")[0];
|
||||
clientAddress = `${clientAddress}/${org.subnet!.split("/")[1]}`; // we want the block size of the whole org
|
||||
// Create the site (type "newt", name = niceId)
|
||||
const [newSite] = await trx
|
||||
.insert(sites)
|
||||
.values({
|
||||
orgId,
|
||||
name: name || niceId,
|
||||
niceId,
|
||||
address: clientAddress,
|
||||
type: "newt",
|
||||
dockerSocketEnabled: true,
|
||||
status: keyRecord.approveNewSites
|
||||
? "approved"
|
||||
: "pending"
|
||||
})
|
||||
.returning();
|
||||
|
||||
// Create the site (type "newt", name = niceId)
|
||||
const [newSite] = await trx
|
||||
.insert(sites)
|
||||
.values({
|
||||
orgId,
|
||||
name: name || niceId,
|
||||
niceId,
|
||||
address: clientAddress,
|
||||
type: "newt",
|
||||
dockerSocketEnabled: true,
|
||||
status: keyRecord.approveNewSites ? "approved" : "pending"
|
||||
})
|
||||
.returning();
|
||||
await logsDb.insert(statusHistory).values({
|
||||
entityType: "site",
|
||||
entityId: newSite.siteId,
|
||||
orgId: orgId,
|
||||
status: "offline",
|
||||
timestamp: Math.floor(Date.now() / 1000)
|
||||
});
|
||||
|
||||
await logsDb.insert(statusHistory).values({
|
||||
entityType: "site",
|
||||
entityId: newSite.siteId,
|
||||
orgId: orgId,
|
||||
status: "offline",
|
||||
timestamp: Math.floor(Date.now() / 1000)
|
||||
newSiteId = newSite.siteId;
|
||||
|
||||
// Grant admin role access to the new site
|
||||
const [adminRole] = await trx
|
||||
.select()
|
||||
.from(roles)
|
||||
.where(and(eq(roles.isAdmin, true), eq(roles.orgId, orgId)))
|
||||
.limit(1);
|
||||
|
||||
if (!adminRole) {
|
||||
throw new Error(`Admin role not found for org ${orgId}`);
|
||||
}
|
||||
|
||||
await trx.insert(roleSites).values({
|
||||
roleId: adminRole.roleId,
|
||||
siteId: newSite.siteId
|
||||
});
|
||||
|
||||
// Create the newt for this site
|
||||
await trx.insert(newts).values({
|
||||
newtId,
|
||||
secretHash,
|
||||
siteId: newSite.siteId,
|
||||
dateCreated: moment().toISOString()
|
||||
});
|
||||
|
||||
// Consume the provisioning key - cascade removes siteProvisioningKeyOrg
|
||||
await trx
|
||||
.update(siteProvisioningKeys)
|
||||
.set({
|
||||
lastUsed: moment().toISOString(),
|
||||
numUsed: sql`${siteProvisioningKeys.numUsed} + 1`
|
||||
})
|
||||
.where(
|
||||
eq(
|
||||
siteProvisioningKeys.siteProvisioningKeyId,
|
||||
provisioningKeyId
|
||||
)
|
||||
);
|
||||
|
||||
await usageService.add(orgId, FeatureId.SITES, 1, trx);
|
||||
});
|
||||
|
||||
newSiteId = newSite.siteId;
|
||||
|
||||
// Grant admin role access to the new site
|
||||
const [adminRole] = await trx
|
||||
.select()
|
||||
.from(roles)
|
||||
.where(and(eq(roles.isAdmin, true), eq(roles.orgId, orgId)))
|
||||
.limit(1);
|
||||
|
||||
if (!adminRole) {
|
||||
throw new Error(`Admin role not found for org ${orgId}`);
|
||||
}
|
||||
|
||||
await trx.insert(roleSites).values({
|
||||
roleId: adminRole.roleId,
|
||||
siteId: newSite.siteId
|
||||
});
|
||||
|
||||
// Create the newt for this site
|
||||
await trx.insert(newts).values({
|
||||
newtId,
|
||||
secretHash,
|
||||
siteId: newSite.siteId,
|
||||
dateCreated: moment().toISOString()
|
||||
});
|
||||
|
||||
// Consume the provisioning key - cascade removes siteProvisioningKeyOrg
|
||||
await trx
|
||||
.update(siteProvisioningKeys)
|
||||
.set({
|
||||
lastUsed: moment().toISOString(),
|
||||
numUsed: sql`${siteProvisioningKeys.numUsed} + 1`
|
||||
})
|
||||
.where(
|
||||
eq(
|
||||
siteProvisioningKeys.siteProvisioningKeyId,
|
||||
provisioningKeyId
|
||||
)
|
||||
);
|
||||
|
||||
await usageService.add(orgId, FeatureId.SITES, 1, trx);
|
||||
});
|
||||
} finally {
|
||||
await releaseSubnetLock();
|
||||
}
|
||||
|
||||
logger.info(
|
||||
`Provisioned new site (ID: ${newSiteId}) and newt (ID: ${newtId}) for org ${orgId} via provisioning key ${provisioningKeyId}`
|
||||
|
||||
@@ -9,8 +9,12 @@ import {
|
||||
import { canCompress } from "@server/lib/clientVersionChecks";
|
||||
|
||||
export async function sendNewtSyncMessage(newt: Newt, site: Site) {
|
||||
const { tcpTargets, udpTargets, validHealthCheckTargets } =
|
||||
await buildTargetConfigurationForNewtClient(site.siteId);
|
||||
const {
|
||||
tcpTargets,
|
||||
udpTargets,
|
||||
validHealthCheckTargets,
|
||||
browserGatewayTargets
|
||||
} = await buildTargetConfigurationForNewtClient(site.siteId);
|
||||
|
||||
let exitNode: ExitNode | undefined;
|
||||
if (site.exitNodeId) {
|
||||
@@ -36,7 +40,8 @@ export async function sendNewtSyncMessage(newt: Newt, site: Site) {
|
||||
},
|
||||
healthCheckTargets: validHealthCheckTargets,
|
||||
peers: peers,
|
||||
clientTargets: targets
|
||||
clientTargets: targets,
|
||||
browserGatewayTargets: browserGatewayTargets
|
||||
}
|
||||
},
|
||||
{
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { Target, TargetHealthCheck } from "@server/db";
|
||||
import { BrowserGatewayTarget, Target, TargetHealthCheck } from "@server/db";
|
||||
import { sendToClient } from "#dynamic/routers/ws";
|
||||
import logger from "@server/logger";
|
||||
import { canCompress } from "@server/lib/clientVersionChecks";
|
||||
import { decrypt } from "@server/lib/crypto";
|
||||
import config from "@server/lib/config";
|
||||
|
||||
export async function addTargets(
|
||||
newtId: string,
|
||||
@@ -239,3 +241,55 @@ export async function removeTargets(
|
||||
{ incrementConfigVersion: true, compress: canCompress(version, "newt") }
|
||||
);
|
||||
}
|
||||
|
||||
export async function sendBrowserGatewayTargets(
|
||||
newtId: string,
|
||||
targets: BrowserGatewayTarget[],
|
||||
version?: string | null
|
||||
) {
|
||||
if (targets.length === 0) return;
|
||||
|
||||
const payload = targets.map((t) => {
|
||||
const decryptAuthToken = decrypt(
|
||||
t.authToken,
|
||||
config.getRawConfig().server.secret!
|
||||
);
|
||||
return {
|
||||
id: t.browserGatewayTargetId,
|
||||
resourceId: t.resourceId,
|
||||
siteId: t.siteId,
|
||||
type: t.type,
|
||||
destination: t.destination,
|
||||
destinationPort: t.destinationPort,
|
||||
authToken: decryptAuthToken
|
||||
};
|
||||
});
|
||||
|
||||
await sendToClient(
|
||||
newtId,
|
||||
{
|
||||
type: "newt/browsergateway/add",
|
||||
data: {
|
||||
targets: payload
|
||||
}
|
||||
},
|
||||
{ incrementConfigVersion: true, compress: canCompress(version, "newt") }
|
||||
);
|
||||
}
|
||||
|
||||
export async function removeBrowserGatewayTarget(
|
||||
newtId: string,
|
||||
browserGatewayTargetId: number,
|
||||
version?: string | null
|
||||
) {
|
||||
await sendToClient(
|
||||
newtId,
|
||||
{
|
||||
type: "newt/browsergateway/remove",
|
||||
data: {
|
||||
ids: [browserGatewayTargetId]
|
||||
}
|
||||
},
|
||||
{ incrementConfigVersion: true, compress: canCompress(version, "newt") }
|
||||
);
|
||||
}
|
||||
|
||||
@@ -40,7 +40,8 @@ const updateOrgBodySchema = z
|
||||
settingsLogRetentionDaysConnection: z
|
||||
.number()
|
||||
.min(build === "saas" ? 0 : -1)
|
||||
.optional()
|
||||
.optional(),
|
||||
settingsEnableGlobalNewtAutoUpdate: z.boolean().optional()
|
||||
})
|
||||
.refine((data) => Object.keys(data).length > 0, {
|
||||
error: "At least one field must be provided for update"
|
||||
@@ -118,6 +119,15 @@ export async function updateOrg(
|
||||
if (!hasPasswordExpirationFeature) {
|
||||
parsedBody.data.passwordExpiryDays = undefined;
|
||||
}
|
||||
|
||||
const hasNewtAutoUpdateFeature = await isLicensedOrSubscribed(
|
||||
orgId,
|
||||
tierMatrix[TierFeature.NewtAutoUpdate]
|
||||
);
|
||||
if (!hasNewtAutoUpdateFeature) {
|
||||
parsedBody.data.settingsEnableGlobalNewtAutoUpdate = false; // force it off
|
||||
}
|
||||
|
||||
if (build == "saas") {
|
||||
const { tier } = await getOrgTierData(orgId);
|
||||
|
||||
@@ -136,8 +146,10 @@ export async function updateOrg(
|
||||
|
||||
if (maxRetentionDays !== null) {
|
||||
if (
|
||||
parsedBody.data.settingsLogRetentionDaysRequest !== undefined &&
|
||||
parsedBody.data.settingsLogRetentionDaysRequest > maxRetentionDays
|
||||
parsedBody.data.settingsLogRetentionDaysRequest !==
|
||||
undefined &&
|
||||
parsedBody.data.settingsLogRetentionDaysRequest >
|
||||
maxRetentionDays
|
||||
) {
|
||||
return next(
|
||||
createHttpError(
|
||||
@@ -147,8 +159,10 @@ export async function updateOrg(
|
||||
);
|
||||
}
|
||||
if (
|
||||
parsedBody.data.settingsLogRetentionDaysAccess !== undefined &&
|
||||
parsedBody.data.settingsLogRetentionDaysAccess > maxRetentionDays
|
||||
parsedBody.data.settingsLogRetentionDaysAccess !==
|
||||
undefined &&
|
||||
parsedBody.data.settingsLogRetentionDaysAccess >
|
||||
maxRetentionDays
|
||||
) {
|
||||
return next(
|
||||
createHttpError(
|
||||
@@ -158,8 +172,10 @@ export async function updateOrg(
|
||||
);
|
||||
}
|
||||
if (
|
||||
parsedBody.data.settingsLogRetentionDaysAction !== undefined &&
|
||||
parsedBody.data.settingsLogRetentionDaysAction > maxRetentionDays
|
||||
parsedBody.data.settingsLogRetentionDaysAction !==
|
||||
undefined &&
|
||||
parsedBody.data.settingsLogRetentionDaysAction >
|
||||
maxRetentionDays
|
||||
) {
|
||||
return next(
|
||||
createHttpError(
|
||||
@@ -169,8 +185,10 @@ export async function updateOrg(
|
||||
);
|
||||
}
|
||||
if (
|
||||
parsedBody.data.settingsLogRetentionDaysConnection !== undefined &&
|
||||
parsedBody.data.settingsLogRetentionDaysConnection > maxRetentionDays
|
||||
parsedBody.data.settingsLogRetentionDaysConnection !==
|
||||
undefined &&
|
||||
parsedBody.data.settingsLogRetentionDaysConnection >
|
||||
maxRetentionDays
|
||||
) {
|
||||
return next(
|
||||
createHttpError(
|
||||
@@ -196,7 +214,9 @@ export async function updateOrg(
|
||||
settingsLogRetentionDaysAction:
|
||||
parsedBody.data.settingsLogRetentionDaysAction,
|
||||
settingsLogRetentionDaysConnection:
|
||||
parsedBody.data.settingsLogRetentionDaysConnection
|
||||
parsedBody.data.settingsLogRetentionDaysConnection,
|
||||
settingsEnableGlobalNewtAutoUpdate:
|
||||
parsedBody.data.settingsEnableGlobalNewtAutoUpdate
|
||||
})
|
||||
.where(eq(orgs.orgId, orgId))
|
||||
.returning();
|
||||
|
||||
@@ -0,0 +1,231 @@
|
||||
import {
|
||||
db,
|
||||
idp,
|
||||
resourcePolicyRules,
|
||||
resourcePolicies,
|
||||
resourcePolicyHeaderAuth,
|
||||
resourcePolicyPassword,
|
||||
resourcePolicyPincode,
|
||||
resourcePolicyWhiteList,
|
||||
rolePolicies,
|
||||
roles,
|
||||
userPolicies,
|
||||
users
|
||||
} from "@server/db";
|
||||
import response from "@server/lib/response";
|
||||
import logger from "@server/logger";
|
||||
import { OpenAPITags, registry } from "@server/openApi";
|
||||
import HttpCode from "@server/types/HttpCode";
|
||||
import { and, eq, isNull, not, or, type SQL } from "drizzle-orm";
|
||||
import type { NextFunction, Request, Response } from "express";
|
||||
import createHttpError from "http-errors";
|
||||
import z from "zod";
|
||||
import { fromError } from "zod-validation-error";
|
||||
|
||||
const getResourcePolicySchema = z
|
||||
.strictObject({
|
||||
niceId: z.string(),
|
||||
orgId: z.string()
|
||||
})
|
||||
.or(
|
||||
z.strictObject({
|
||||
resourcePolicyId: z.coerce
|
||||
.number<string>()
|
||||
.int()
|
||||
.positive()
|
||||
.openapi({
|
||||
type: "integer",
|
||||
description: "Resource policy ID"
|
||||
})
|
||||
})
|
||||
);
|
||||
|
||||
export async function queryResourcePolicy(
|
||||
params: z.infer<typeof getResourcePolicySchema>
|
||||
) {
|
||||
const conditions: SQL<unknown>[] = [];
|
||||
if ("resourcePolicyId" in params) {
|
||||
conditions.push(
|
||||
eq(resourcePolicies.resourcePolicyId, params.resourcePolicyId)
|
||||
);
|
||||
} else {
|
||||
conditions.push(
|
||||
eq(resourcePolicies.niceId, params.niceId),
|
||||
eq(resourcePolicies.orgId, params.orgId)
|
||||
);
|
||||
}
|
||||
|
||||
const [res] = await db
|
||||
.select({
|
||||
resourcePolicyId: resourcePolicies.resourcePolicyId,
|
||||
sso: resourcePolicies.sso,
|
||||
applyRules: resourcePolicies.applyRules,
|
||||
emailWhitelistEnabled: resourcePolicies.emailWhitelistEnabled,
|
||||
idpId: resourcePolicies.idpId,
|
||||
niceId: resourcePolicies.niceId,
|
||||
name: resourcePolicies.name,
|
||||
passwordId: resourcePolicyPassword.passwordId,
|
||||
pincodeId: resourcePolicyPincode.pincodeId,
|
||||
headerAuth: {
|
||||
id: resourcePolicyHeaderAuth.headerAuthId,
|
||||
extendedCompability:
|
||||
resourcePolicyHeaderAuth.extendedCompatibility
|
||||
}
|
||||
})
|
||||
.from(resourcePolicies)
|
||||
.leftJoin(
|
||||
resourcePolicyPassword,
|
||||
eq(
|
||||
resourcePolicyPassword.resourcePolicyId,
|
||||
resourcePolicies.resourcePolicyId
|
||||
)
|
||||
)
|
||||
.leftJoin(
|
||||
resourcePolicyPincode,
|
||||
eq(
|
||||
resourcePolicyPincode.resourcePolicyId,
|
||||
resourcePolicies.resourcePolicyId
|
||||
)
|
||||
)
|
||||
.leftJoin(
|
||||
resourcePolicyHeaderAuth,
|
||||
eq(
|
||||
resourcePolicyHeaderAuth.resourcePolicyId,
|
||||
resourcePolicies.resourcePolicyId
|
||||
)
|
||||
)
|
||||
.where(and(...conditions))
|
||||
.limit(1);
|
||||
|
||||
if (!res) return null;
|
||||
|
||||
const policyUsers = await db
|
||||
.select({
|
||||
userId: userPolicies.userId,
|
||||
email: users.email,
|
||||
name: users.name,
|
||||
username: users.username,
|
||||
type: users.type,
|
||||
idpName: idp.name
|
||||
})
|
||||
.from(userPolicies)
|
||||
.innerJoin(users, eq(userPolicies.userId, users.userId))
|
||||
.leftJoin(idp, eq(idp.idpId, users.idpId))
|
||||
.where(eq(userPolicies.resourcePolicyId, res.resourcePolicyId));
|
||||
|
||||
const policyRoles = await db
|
||||
.select({
|
||||
roleId: rolePolicies.roleId,
|
||||
name: roles.name
|
||||
})
|
||||
.from(rolePolicies)
|
||||
.innerJoin(
|
||||
roles,
|
||||
and(
|
||||
eq(rolePolicies.roleId, roles.roleId),
|
||||
or(isNull(roles.isAdmin), not(roles.isAdmin))
|
||||
)
|
||||
)
|
||||
.where(eq(rolePolicies.resourcePolicyId, res.resourcePolicyId));
|
||||
|
||||
const policyEmailWhiteList = await db
|
||||
.select({
|
||||
whiteListId: resourcePolicyWhiteList.whitelistId,
|
||||
email: resourcePolicyWhiteList.email
|
||||
})
|
||||
.from(resourcePolicyWhiteList)
|
||||
.where(
|
||||
eq(resourcePolicyWhiteList.resourcePolicyId, res.resourcePolicyId)
|
||||
);
|
||||
|
||||
const policyRules = await db
|
||||
.select({
|
||||
ruleId: resourcePolicyRules.ruleId,
|
||||
enabled: resourcePolicyRules.enabled,
|
||||
priority: resourcePolicyRules.priority,
|
||||
action: resourcePolicyRules.action,
|
||||
match: resourcePolicyRules.match,
|
||||
value: resourcePolicyRules.value
|
||||
})
|
||||
.from(resourcePolicyRules)
|
||||
.where(eq(resourcePolicyRules.resourcePolicyId, res.resourcePolicyId));
|
||||
|
||||
return {
|
||||
...res,
|
||||
roles: policyRoles,
|
||||
users: policyUsers,
|
||||
emailWhiteList: policyEmailWhiteList,
|
||||
rules: policyRules
|
||||
};
|
||||
}
|
||||
|
||||
export type GetResourcePolicyResponse = NonNullable<
|
||||
Awaited<ReturnType<typeof queryResourcePolicy>>
|
||||
>;
|
||||
|
||||
registry.registerPath({
|
||||
method: "get",
|
||||
path: "/org/{orgId}/resource-policy/{niceId}",
|
||||
description:
|
||||
"Get a resource policy by orgId and niceId. NiceId is a readable ID for the resource and unique on a per org basis.",
|
||||
tags: [OpenAPITags.Org, OpenAPITags.Policy],
|
||||
request: {
|
||||
params: z.object({
|
||||
orgId: z.string(),
|
||||
niceId: z.string()
|
||||
})
|
||||
},
|
||||
responses: {}
|
||||
});
|
||||
|
||||
registry.registerPath({
|
||||
method: "get",
|
||||
path: "/resource-policy/{resourcePolicyId}",
|
||||
description: "Get a resource policy by its resourcePolicyId.",
|
||||
tags: [OpenAPITags.Policy],
|
||||
request: {
|
||||
params: z.object({
|
||||
resourcePolicyId: z.number()
|
||||
})
|
||||
},
|
||||
responses: {}
|
||||
});
|
||||
|
||||
export async function getResourcePolicy(
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction
|
||||
): Promise<any> {
|
||||
try {
|
||||
const parsedParams = getResourcePolicySchema.safeParse(req.params);
|
||||
if (!parsedParams.success) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
fromError(parsedParams.error).toString()
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const policy = await queryResourcePolicy(parsedParams.data);
|
||||
|
||||
if (!policy) {
|
||||
return next(
|
||||
createHttpError(HttpCode.NOT_FOUND, "Resource policy not found")
|
||||
);
|
||||
}
|
||||
|
||||
return response<GetResourcePolicyResponse>(res, {
|
||||
data: policy,
|
||||
success: true,
|
||||
error: false,
|
||||
message: "Resource Policy retrieved successfully",
|
||||
status: HttpCode.OK
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error(error);
|
||||
return next(
|
||||
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
export * from "./getResourcePolicy";
|
||||
export * from "./updateResourcePolicy";
|
||||
export * from "./setResourcePolicyAccessControl";
|
||||
export * from "./setResourcePolicyPassword";
|
||||
export * from "./setResourcePolicyPincode";
|
||||
export * from "./setResourcePolicyHeaderAuth";
|
||||
export * from "./setResourcePolicyWhitelist";
|
||||
export * from "./setResourcePolicyRules";
|
||||
@@ -0,0 +1,237 @@
|
||||
import { Request, Response, NextFunction } from "express";
|
||||
import { z } from "zod";
|
||||
import {
|
||||
db,
|
||||
idp,
|
||||
idpOrg,
|
||||
resourcePolicies,
|
||||
rolePolicies,
|
||||
roles,
|
||||
userOrgs,
|
||||
users
|
||||
} from "@server/db";
|
||||
import { userPolicies } from "@server/db";
|
||||
import response from "@server/lib/response";
|
||||
import HttpCode from "@server/types/HttpCode";
|
||||
import createHttpError from "http-errors";
|
||||
import logger from "@server/logger";
|
||||
import { fromError } from "zod-validation-error";
|
||||
import { and, eq, inArray, ne } from "drizzle-orm";
|
||||
import { OpenAPITags, registry } from "@server/openApi";
|
||||
|
||||
const setResourcePolicyAcccessControlBodySchema = z.strictObject({
|
||||
sso: z.boolean(),
|
||||
userIds: z.array(z.string()),
|
||||
roleIds: z.array(z.int().positive()).openapi({
|
||||
type: "array"
|
||||
}),
|
||||
skipToIdpId: z.int().positive().optional().nullable().openapi({
|
||||
type: "integer",
|
||||
description: "Page number to retrieve"
|
||||
})
|
||||
});
|
||||
|
||||
const setResourcePolicyAccessControlParamsSchema = z.strictObject({
|
||||
resourcePolicyId: z.string().transform(Number).pipe(z.int().positive())
|
||||
});
|
||||
|
||||
registry.registerPath({
|
||||
method: "post",
|
||||
path: "/resource-policy/{resourceId}/access-control",
|
||||
description:
|
||||
"Set access control users for a resource policy, including SSO, users, roles, Identity provider.",
|
||||
tags: [OpenAPITags.Policy, OpenAPITags.User],
|
||||
request: {
|
||||
params: setResourcePolicyAccessControlParamsSchema,
|
||||
body: {
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: setResourcePolicyAcccessControlBodySchema
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
responses: {}
|
||||
});
|
||||
|
||||
export async function setResourcePolicyAccessControl(
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction
|
||||
): Promise<any> {
|
||||
try {
|
||||
const parsedBody = setResourcePolicyAcccessControlBodySchema.safeParse(
|
||||
req.body
|
||||
);
|
||||
if (!parsedBody.success) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
fromError(parsedBody.error).toString()
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const { userIds, roleIds, sso, skipToIdpId: idpId } = parsedBody.data;
|
||||
|
||||
const parsedParams =
|
||||
setResourcePolicyAccessControlParamsSchema.safeParse(req.params);
|
||||
if (!parsedParams.success) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
fromError(parsedParams.error).toString()
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const { resourcePolicyId } = parsedParams.data;
|
||||
|
||||
const [policy] = await db
|
||||
.select()
|
||||
.from(resourcePolicies)
|
||||
.where(eq(resourcePolicies.resourcePolicyId, resourcePolicyId))
|
||||
.limit(1);
|
||||
|
||||
if (!policy) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.INTERNAL_SERVER_ERROR,
|
||||
"Resource policy not found"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
// Check if Identity provider in `skipToIdpId` exists
|
||||
if (idpId) {
|
||||
const [provider] = await db
|
||||
.select()
|
||||
.from(idp)
|
||||
.innerJoin(idpOrg, eq(idpOrg.idpId, idp.idpId))
|
||||
.where(
|
||||
and(eq(idp.idpId, idpId), eq(idpOrg.orgId, policy.orgId))
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
if (!provider) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.INTERNAL_SERVER_ERROR,
|
||||
"Identity provider not found in this organization"
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Check if any of the roleIds are admin roles
|
||||
const rolesToCheck = await db
|
||||
.select()
|
||||
.from(roles)
|
||||
.where(
|
||||
and(
|
||||
inArray(roles.roleId, roleIds),
|
||||
eq(roles.orgId, policy.orgId)
|
||||
)
|
||||
);
|
||||
|
||||
const hasAdminRole = rolesToCheck.some((role) => role.isAdmin);
|
||||
|
||||
if (hasAdminRole) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
"Admin role cannot be assigned to resources"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
// Get all admin role IDs for this org to exclude from deletion
|
||||
const adminRoles = await db
|
||||
.select()
|
||||
.from(roles)
|
||||
.where(and(eq(roles.isAdmin, true), eq(roles.orgId, policy.orgId)));
|
||||
const adminRoleIds = adminRoles.map((role) => role.roleId);
|
||||
|
||||
const existingUsers = await db
|
||||
.select()
|
||||
.from(users)
|
||||
.innerJoin(userOrgs, eq(userOrgs.userId, users.userId))
|
||||
.where(
|
||||
and(
|
||||
eq(userOrgs.orgId, policy.orgId),
|
||||
inArray(users.userId, userIds)
|
||||
)
|
||||
);
|
||||
|
||||
const existingRoles = await db
|
||||
.select()
|
||||
.from(roles)
|
||||
.where(
|
||||
and(
|
||||
eq(roles.orgId, policy.orgId),
|
||||
inArray(roles.roleId, roleIds)
|
||||
)
|
||||
);
|
||||
|
||||
await db.transaction(async (trx) => {
|
||||
// Update SSO status
|
||||
await trx
|
||||
.update(resourcePolicies)
|
||||
.set({
|
||||
sso,
|
||||
idpId
|
||||
})
|
||||
.where(eq(resourcePolicies.resourcePolicyId, resourcePolicyId));
|
||||
|
||||
// Update roles
|
||||
if (adminRoleIds.length > 0) {
|
||||
await trx.delete(rolePolicies).where(
|
||||
and(
|
||||
eq(rolePolicies.resourcePolicyId, resourcePolicyId),
|
||||
ne(rolePolicies.roleId, adminRoleIds[0]) // delete all but the admin role
|
||||
)
|
||||
);
|
||||
} else {
|
||||
await trx
|
||||
.delete(rolePolicies)
|
||||
.where(eq(rolePolicies.resourcePolicyId, resourcePolicyId));
|
||||
}
|
||||
|
||||
const rolesToAdd = existingRoles.map(({ roleId }) => ({
|
||||
roleId,
|
||||
resourcePolicyId
|
||||
}));
|
||||
|
||||
if (rolesToAdd.length > 0) {
|
||||
await trx.insert(rolePolicies).values(rolesToAdd);
|
||||
}
|
||||
|
||||
// Update users
|
||||
await trx
|
||||
.delete(userPolicies)
|
||||
.where(eq(userPolicies.resourcePolicyId, resourcePolicyId));
|
||||
|
||||
const usersToAdd = existingUsers.map(({ user }) => ({
|
||||
userId: user.userId,
|
||||
resourcePolicyId: resourcePolicyId
|
||||
}));
|
||||
|
||||
if (usersToAdd.length > 0) {
|
||||
await trx.insert(userPolicies).values(usersToAdd);
|
||||
}
|
||||
});
|
||||
|
||||
return response(res, {
|
||||
data: {},
|
||||
success: true,
|
||||
error: false,
|
||||
message: "Resource policy succesfully updated",
|
||||
status: HttpCode.OK
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error(error);
|
||||
return next(
|
||||
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
import { Request, Response, NextFunction } from "express";
|
||||
import { z } from "zod";
|
||||
import { db, resourcePolicyHeaderAuth } from "@server/db";
|
||||
import { eq } from "drizzle-orm";
|
||||
import HttpCode from "@server/types/HttpCode";
|
||||
import createHttpError from "http-errors";
|
||||
import { fromError } from "zod-validation-error";
|
||||
import { response } from "@server/lib/response";
|
||||
import logger from "@server/logger";
|
||||
import { hashPassword } from "@server/auth/password";
|
||||
import { OpenAPITags, registry } from "@server/openApi";
|
||||
|
||||
const setResourcePolicyHeaderAuthParamsSchema = z.object({
|
||||
resourcePolicyId: z.string().transform(Number).pipe(z.int().positive())
|
||||
});
|
||||
|
||||
const setResourcePolicyHeaderAuthBodySchema = z.strictObject({
|
||||
headerAuth: z
|
||||
.object({
|
||||
user: z.string().min(4).max(100),
|
||||
password: z.string().min(4).max(100),
|
||||
extendedCompatibility: z.boolean()
|
||||
})
|
||||
.nullable()
|
||||
});
|
||||
|
||||
registry.registerPath({
|
||||
method: "put",
|
||||
path: "/resource-policy/{resourcePolicyId}/header-auth",
|
||||
description:
|
||||
"Set or update the header authentication for a resource policy. If user and password is not provided, it will remove the header authentication.",
|
||||
tags: [OpenAPITags.Policy],
|
||||
request: {
|
||||
params: setResourcePolicyHeaderAuthParamsSchema,
|
||||
body: {
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: setResourcePolicyHeaderAuthBodySchema
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
responses: {}
|
||||
});
|
||||
|
||||
export async function setResourcePolicyHeaderAuth(
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction
|
||||
): Promise<any> {
|
||||
try {
|
||||
const parsedParams = setResourcePolicyHeaderAuthParamsSchema.safeParse(
|
||||
req.params
|
||||
);
|
||||
if (!parsedParams.success) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
fromError(parsedParams.error).toString()
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const parsedBody = setResourcePolicyHeaderAuthBodySchema.safeParse(
|
||||
req.body
|
||||
);
|
||||
if (!parsedBody.success) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
fromError(parsedBody.error).toString()
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const { resourcePolicyId } = parsedParams.data;
|
||||
const { headerAuth } = parsedBody.data;
|
||||
|
||||
await db.transaction(async (trx) => {
|
||||
await trx
|
||||
.delete(resourcePolicyHeaderAuth)
|
||||
.where(
|
||||
eq(
|
||||
resourcePolicyHeaderAuth.resourcePolicyId,
|
||||
resourcePolicyId
|
||||
)
|
||||
);
|
||||
|
||||
if (headerAuth !== null) {
|
||||
const headerAuthHash = await hashPassword(
|
||||
Buffer.from(
|
||||
`${headerAuth.user}:${headerAuth.password}`
|
||||
).toString("base64")
|
||||
);
|
||||
|
||||
await trx.insert(resourcePolicyHeaderAuth).values({
|
||||
resourcePolicyId,
|
||||
headerAuthHash,
|
||||
extendedCompatibility: headerAuth.extendedCompatibility
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return response(res, {
|
||||
data: {},
|
||||
success: true,
|
||||
error: false,
|
||||
message: "Header Authentication set successfully",
|
||||
status: HttpCode.OK
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error(error);
|
||||
return next(
|
||||
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
import { Request, Response, NextFunction } from "express";
|
||||
import { z } from "zod";
|
||||
import { db } from "@server/db";
|
||||
import { resourcePolicyPassword } from "@server/db";
|
||||
import { eq } from "drizzle-orm";
|
||||
import HttpCode from "@server/types/HttpCode";
|
||||
import createHttpError from "http-errors";
|
||||
import { fromError } from "zod-validation-error";
|
||||
import { response } from "@server/lib/response";
|
||||
import logger from "@server/logger";
|
||||
import { hashPassword } from "@server/auth/password";
|
||||
import { OpenAPITags, registry } from "@server/openApi";
|
||||
|
||||
const setResourcePolicyPasswordParamsSchema = z.object({
|
||||
resourcePolicyId: z.string().transform(Number).pipe(z.int().positive())
|
||||
});
|
||||
|
||||
const setResourcePolicyPasswordBodySchema = z.strictObject({
|
||||
password: z.string().min(4).max(100).nullable()
|
||||
});
|
||||
|
||||
registry.registerPath({
|
||||
method: "put",
|
||||
path: "/resource-policy/{resourcePolicyId}/password",
|
||||
description:
|
||||
"Set the password for a resource policy. Setting the password to null will remove it.",
|
||||
tags: [OpenAPITags.Policy],
|
||||
request: {
|
||||
params: setResourcePolicyPasswordParamsSchema,
|
||||
body: {
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: setResourcePolicyPasswordBodySchema
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
responses: {}
|
||||
});
|
||||
|
||||
export async function setResourcePolicyPassword(
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction
|
||||
): Promise<any> {
|
||||
try {
|
||||
const parsedParams = setResourcePolicyPasswordParamsSchema.safeParse(
|
||||
req.params
|
||||
);
|
||||
if (!parsedParams.success) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
fromError(parsedParams.error).toString()
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const parsedBody = setResourcePolicyPasswordBodySchema.safeParse(
|
||||
req.body
|
||||
);
|
||||
if (!parsedBody.success) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
fromError(parsedBody.error).toString()
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const { resourcePolicyId } = parsedParams.data;
|
||||
const { password } = parsedBody.data;
|
||||
|
||||
await db.transaction(async (trx) => {
|
||||
await trx
|
||||
.delete(resourcePolicyPassword)
|
||||
.where(
|
||||
eq(
|
||||
resourcePolicyPassword.resourcePolicyId,
|
||||
resourcePolicyId
|
||||
)
|
||||
);
|
||||
|
||||
if (password) {
|
||||
const passwordHash = await hashPassword(password);
|
||||
|
||||
await trx
|
||||
.insert(resourcePolicyPassword)
|
||||
.values({ resourcePolicyId, passwordHash });
|
||||
}
|
||||
});
|
||||
|
||||
return response(res, {
|
||||
data: {},
|
||||
success: true,
|
||||
error: false,
|
||||
message: "Resource policy password set successfully",
|
||||
status: HttpCode.OK
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error(error);
|
||||
return next(
|
||||
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
import { Request, Response, NextFunction } from "express";
|
||||
import { z } from "zod";
|
||||
import { db } from "@server/db";
|
||||
import { resourcePolicyPincode } from "@server/db";
|
||||
import { eq } from "drizzle-orm";
|
||||
import HttpCode from "@server/types/HttpCode";
|
||||
import createHttpError from "http-errors";
|
||||
import { fromError } from "zod-validation-error";
|
||||
import { response } from "@server/lib/response";
|
||||
import logger from "@server/logger";
|
||||
import { hashPassword } from "@server/auth/password";
|
||||
import { OpenAPITags, registry } from "@server/openApi";
|
||||
|
||||
const setResourcePolicyPincodeParamsSchema = z.object({
|
||||
resourcePolicyId: z.string().transform(Number).pipe(z.int().positive())
|
||||
});
|
||||
|
||||
const setResourcePolicyPincodeBodySchema = z.strictObject({
|
||||
pincode: z
|
||||
.string()
|
||||
.regex(/^\d{6}$/)
|
||||
.or(z.null())
|
||||
});
|
||||
|
||||
registry.registerPath({
|
||||
method: "put",
|
||||
path: "/resource-policy/{resourcePolicyId}/pincode",
|
||||
description:
|
||||
"Set the PIN code for a resource policy. Setting the PIN code to null will remove it.",
|
||||
tags: [OpenAPITags.Policy],
|
||||
request: {
|
||||
params: setResourcePolicyPincodeParamsSchema,
|
||||
body: {
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: setResourcePolicyPincodeBodySchema
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
responses: {}
|
||||
});
|
||||
|
||||
export async function setResourcePolicyPincode(
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction
|
||||
): Promise<any> {
|
||||
try {
|
||||
const parsedParams = setResourcePolicyPincodeParamsSchema.safeParse(
|
||||
req.params
|
||||
);
|
||||
if (!parsedParams.success) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
fromError(parsedParams.error).toString()
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const parsedBody = setResourcePolicyPincodeBodySchema.safeParse(
|
||||
req.body
|
||||
);
|
||||
if (!parsedBody.success) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
fromError(parsedBody.error).toString()
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const { resourcePolicyId } = parsedParams.data;
|
||||
const { pincode } = parsedBody.data;
|
||||
|
||||
await db.transaction(async (trx) => {
|
||||
await trx
|
||||
.delete(resourcePolicyPincode)
|
||||
.where(
|
||||
eq(resourcePolicyPincode.resourcePolicyId, resourcePolicyId)
|
||||
);
|
||||
|
||||
if (pincode) {
|
||||
const pincodeHash = await hashPassword(pincode);
|
||||
|
||||
await trx
|
||||
.insert(resourcePolicyPincode)
|
||||
.values({ resourcePolicyId, pincodeHash, digitLength: 6 });
|
||||
}
|
||||
});
|
||||
|
||||
return response(res, {
|
||||
data: {},
|
||||
success: true,
|
||||
error: false,
|
||||
message: "Resource policy PIN code set successfully",
|
||||
status: HttpCode.OK
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error(error);
|
||||
return next(
|
||||
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
import { Request, Response, NextFunction } from "express";
|
||||
import { z } from "zod";
|
||||
import { db, resourcePolicyRules, resourcePolicies } from "@server/db";
|
||||
import { eq } from "drizzle-orm";
|
||||
import response from "@server/lib/response";
|
||||
import HttpCode from "@server/types/HttpCode";
|
||||
import createHttpError from "http-errors";
|
||||
import logger from "@server/logger";
|
||||
import { fromError } from "zod-validation-error";
|
||||
import {
|
||||
isValidCIDR,
|
||||
isValidIP,
|
||||
isValidUrlGlobPattern
|
||||
} from "@server/lib/validators";
|
||||
import { OpenAPITags, registry } from "@server/openApi";
|
||||
|
||||
const ruleSchema = z.strictObject({
|
||||
action: z.enum(["ACCEPT", "DROP", "PASS"]).openapi({
|
||||
type: "string",
|
||||
enum: ["ACCEPT", "DROP", "PASS"],
|
||||
description: "rule action"
|
||||
}),
|
||||
match: z.enum(["CIDR", "IP", "PATH"]).openapi({
|
||||
type: "string",
|
||||
enum: ["CIDR", "IP", "PATH"],
|
||||
description: "rule match"
|
||||
}),
|
||||
value: z.string().min(1),
|
||||
priority: z.int().openapi({
|
||||
type: "integer",
|
||||
description: "Rule priority"
|
||||
}),
|
||||
enabled: z.boolean().optional()
|
||||
});
|
||||
|
||||
const setResourcePolicyRulesBodySchema = z.strictObject({
|
||||
applyRules: z.boolean(),
|
||||
rules: z.array(ruleSchema)
|
||||
});
|
||||
|
||||
const setResourcePolicyRulesParamsSchema = z.strictObject({
|
||||
resourcePolicyId: z.string().transform(Number).pipe(z.int().positive())
|
||||
});
|
||||
|
||||
registry.registerPath({
|
||||
method: "put",
|
||||
path: "/resource-policy/{resourcePolicyId}/rules",
|
||||
description:
|
||||
"Set all rules for a resource policy at once. This will replace all existing rules.",
|
||||
tags: [OpenAPITags.Policy],
|
||||
request: {
|
||||
params: setResourcePolicyRulesParamsSchema,
|
||||
body: {
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: setResourcePolicyRulesBodySchema
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
responses: {}
|
||||
});
|
||||
|
||||
export async function setResourcePolicyRules(
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction
|
||||
): Promise<any> {
|
||||
try {
|
||||
const parsedParams = setResourcePolicyRulesParamsSchema.safeParse(
|
||||
req.params
|
||||
);
|
||||
if (!parsedParams.success) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
fromError(parsedParams.error).toString()
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const parsedBody = setResourcePolicyRulesBodySchema.safeParse(req.body);
|
||||
if (!parsedBody.success) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
fromError(parsedBody.error).toString()
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const { resourcePolicyId } = parsedParams.data;
|
||||
const { applyRules, rules } = parsedBody.data;
|
||||
|
||||
const [policy] = await db
|
||||
.select()
|
||||
.from(resourcePolicies)
|
||||
.where(eq(resourcePolicies.resourcePolicyId, resourcePolicyId))
|
||||
.limit(1);
|
||||
|
||||
if (!policy) {
|
||||
return next(
|
||||
createHttpError(HttpCode.NOT_FOUND, "Resource policy not found")
|
||||
);
|
||||
}
|
||||
|
||||
for (const rule of rules) {
|
||||
if (rule.match === "CIDR" && !isValidCIDR(rule.value)) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
"Invalid CIDR provided"
|
||||
)
|
||||
);
|
||||
} else if (rule.match === "IP" && !isValidIP(rule.value)) {
|
||||
return next(
|
||||
createHttpError(HttpCode.BAD_REQUEST, "Invalid IP provided")
|
||||
);
|
||||
} else if (
|
||||
rule.match === "PATH" &&
|
||||
!isValidUrlGlobPattern(rule.value)
|
||||
) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
"Invalid URL glob pattern provided"
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
await db.transaction(async (trx) => {
|
||||
await trx
|
||||
.update(resourcePolicies)
|
||||
.set({ applyRules })
|
||||
.where(eq(resourcePolicies.resourcePolicyId, resourcePolicyId));
|
||||
|
||||
await trx
|
||||
.delete(resourcePolicyRules)
|
||||
.where(
|
||||
eq(resourcePolicyRules.resourcePolicyId, resourcePolicyId)
|
||||
);
|
||||
|
||||
if (rules.length > 0) {
|
||||
await trx.insert(resourcePolicyRules).values(
|
||||
rules.map((rule) => ({
|
||||
resourcePolicyId,
|
||||
...rule
|
||||
}))
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
return response(res, {
|
||||
data: {},
|
||||
success: true,
|
||||
error: false,
|
||||
message: "Resource policy rules set successfully",
|
||||
status: HttpCode.OK
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error(error);
|
||||
return next(
|
||||
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
import { Request, Response, NextFunction } from "express";
|
||||
import { z } from "zod";
|
||||
import { db, resourcePolicies, resourcePolicyWhiteList } from "@server/db";
|
||||
import response from "@server/lib/response";
|
||||
import HttpCode from "@server/types/HttpCode";
|
||||
import createHttpError from "http-errors";
|
||||
import logger from "@server/logger";
|
||||
import { fromError } from "zod-validation-error";
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import { OpenAPITags, registry } from "@server/openApi";
|
||||
|
||||
const setResourcePolicyWhitelistBodySchema = z.strictObject({
|
||||
emailWhitelistEnabled: z.boolean(),
|
||||
emails: z
|
||||
.array(
|
||||
z.email().or(
|
||||
z.string().regex(/^\*@[\w.-]+\.[a-zA-Z]{2,}$/, {
|
||||
error: "Invalid email address. Wildcard (*) must be the entire local part."
|
||||
})
|
||||
)
|
||||
)
|
||||
.max(50)
|
||||
.transform((v) => v.map((e) => e.toLowerCase()))
|
||||
});
|
||||
|
||||
const setResourcePolicyWhitelistParamsSchema = z.strictObject({
|
||||
resourcePolicyId: z.string().transform(Number).pipe(z.int().positive())
|
||||
});
|
||||
|
||||
registry.registerPath({
|
||||
method: "put",
|
||||
path: "/resource-policy/{resourcePolicyId}/whitelist",
|
||||
description:
|
||||
"Set email whitelist for a resource policy. This will replace all existing emails.",
|
||||
tags: [OpenAPITags.Policy],
|
||||
request: {
|
||||
params: setResourcePolicyWhitelistParamsSchema,
|
||||
body: {
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: setResourcePolicyWhitelistBodySchema
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
responses: {}
|
||||
});
|
||||
|
||||
export async function setResourcePolicyWhitelist(
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction
|
||||
): Promise<any> {
|
||||
try {
|
||||
const parsedBody = setResourcePolicyWhitelistBodySchema.safeParse(
|
||||
req.body
|
||||
);
|
||||
if (!parsedBody.success) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
fromError(parsedBody.error).toString()
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const parsedParams = setResourcePolicyWhitelistParamsSchema.safeParse(
|
||||
req.params
|
||||
);
|
||||
if (!parsedParams.success) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
fromError(parsedParams.error).toString()
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const { resourcePolicyId } = parsedParams.data;
|
||||
const { emailWhitelistEnabled, emails } = parsedBody.data;
|
||||
|
||||
const [policy] = await db
|
||||
.select()
|
||||
.from(resourcePolicies)
|
||||
.where(eq(resourcePolicies.resourcePolicyId, resourcePolicyId));
|
||||
|
||||
if (!policy) {
|
||||
return next(
|
||||
createHttpError(HttpCode.NOT_FOUND, "Resource policy not found")
|
||||
);
|
||||
}
|
||||
|
||||
await db.transaction(async (trx) => {
|
||||
await trx
|
||||
.update(resourcePolicies)
|
||||
.set({ emailWhitelistEnabled })
|
||||
.where(eq(resourcePolicies.resourcePolicyId, resourcePolicyId));
|
||||
|
||||
// delete all whitelist emails
|
||||
await trx
|
||||
.delete(resourcePolicyWhiteList)
|
||||
.where(
|
||||
eq(
|
||||
resourcePolicyWhiteList.resourcePolicyId,
|
||||
resourcePolicyId
|
||||
)
|
||||
);
|
||||
|
||||
if (emailWhitelistEnabled && emails.length > 0) {
|
||||
await trx.insert(resourcePolicyWhiteList).values(
|
||||
emails.map((email) => ({
|
||||
email,
|
||||
resourcePolicyId
|
||||
}))
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
return response(res, {
|
||||
data: {},
|
||||
success: true,
|
||||
error: false,
|
||||
message: "Whitelist set for resource policy successfully",
|
||||
status: HttpCode.OK
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error(error);
|
||||
return next(
|
||||
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
import { Request, Response, NextFunction } from "express";
|
||||
import z from "zod";
|
||||
import { OpenAPITags, registry } from "@server/openApi";
|
||||
import HttpCode from "@server/types/HttpCode";
|
||||
import createHttpError from "http-errors";
|
||||
import { fromError } from "zod-validation-error";
|
||||
import { db, orgs, resourcePolicies, type ResourcePolicy } from "@server/db";
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import logger from "@server/logger";
|
||||
import response from "@server/lib/response";
|
||||
|
||||
const updateResourcePolicyParamsSchema = z.strictObject({
|
||||
resourcePolicyId: z.string().transform(Number).pipe(z.int().positive())
|
||||
});
|
||||
|
||||
const updateResourcePolicyBodySchema = z.strictObject({
|
||||
name: z.string().min(1).max(255).optional(),
|
||||
niceId: z.string().min(1).max(255).optional()
|
||||
});
|
||||
|
||||
registry.registerPath({
|
||||
method: "put",
|
||||
path: "/resource-policy/{resourcePolicyId}",
|
||||
description: "Update a resource policy.",
|
||||
tags: [OpenAPITags.Org, OpenAPITags.Policy],
|
||||
request: {
|
||||
params: updateResourcePolicyParamsSchema,
|
||||
body: {
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: updateResourcePolicyBodySchema
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
responses: {}
|
||||
});
|
||||
|
||||
export async function updateResourcePolicy(
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction
|
||||
) {
|
||||
try {
|
||||
const parsedParams = updateResourcePolicyParamsSchema.safeParse(
|
||||
req.params
|
||||
);
|
||||
if (!parsedParams.success) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
fromError(parsedParams.error).toString()
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
if (req.user && req.userOrgRoleIds?.length === 0) {
|
||||
return next(
|
||||
createHttpError(HttpCode.FORBIDDEN, "User does not have a role")
|
||||
);
|
||||
}
|
||||
|
||||
const { resourcePolicyId } = parsedParams.data;
|
||||
const [result] = await db
|
||||
.select()
|
||||
.from(resourcePolicies)
|
||||
.where(eq(resourcePolicies.resourcePolicyId, resourcePolicyId))
|
||||
.leftJoin(orgs, eq(resourcePolicies.orgId, orgs.orgId));
|
||||
|
||||
const policy = result?.resourcePolicies;
|
||||
const org = result?.orgs;
|
||||
|
||||
if (!policy || !org) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.NOT_FOUND,
|
||||
`Resource Policy with ID ${resourcePolicyId} not found`
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const parsedBody = updateResourcePolicyBodySchema.safeParse(req.body);
|
||||
if (!parsedBody.success) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
fromError(parsedBody.error).toString()
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const updateData = parsedBody.data;
|
||||
|
||||
if (updateData.niceId) {
|
||||
const [existingPolicy] = await db
|
||||
.select()
|
||||
.from(resourcePolicies)
|
||||
.where(
|
||||
and(
|
||||
eq(resourcePolicies.niceId, updateData.niceId),
|
||||
eq(resourcePolicies.orgId, policy.orgId)
|
||||
)
|
||||
);
|
||||
|
||||
if (
|
||||
existingPolicy &&
|
||||
existingPolicy.resourcePolicyId !== policy.resourcePolicyId
|
||||
) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.CONFLICT,
|
||||
`A resource policy with niceId "${updateData.niceId}" already exists`
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const updatedPolicy = await db.transaction(async (trx) => {
|
||||
const [updated] = await trx
|
||||
.update(resourcePolicies)
|
||||
.set({
|
||||
...updateData
|
||||
})
|
||||
.where(
|
||||
eq(
|
||||
resourcePolicies.resourcePolicyId,
|
||||
policy.resourcePolicyId
|
||||
)
|
||||
)
|
||||
.returning();
|
||||
|
||||
return updated;
|
||||
});
|
||||
|
||||
if (!updatedPolicy) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.INTERNAL_SERVER_ERROR,
|
||||
"Failed to update policy"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
return response<ResourcePolicy>(res, {
|
||||
data: updatedPolicy,
|
||||
success: true,
|
||||
error: false,
|
||||
message: "Resource policy updated successfully",
|
||||
status: HttpCode.OK
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error(error);
|
||||
return next(
|
||||
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -167,7 +167,12 @@ export async function authWithAccessToken(
|
||||
|
||||
let redirectUrl = `${resource.ssl ? "https" : "http"}://${resource.fullDomain}`;
|
||||
const postAuthPath = normalizePostAuthPath(resource.postAuthPath);
|
||||
if (postAuthPath) {
|
||||
if (tokenItem.path) {
|
||||
// add the path from the access token to the redirect URL, ensuring there is exactly one slash between the domain and the path
|
||||
redirectUrl =
|
||||
redirectUrl.replace(/\/?$/, "/") +
|
||||
tokenItem.path.replace(/^\/?/, "");
|
||||
} else if (postAuthPath) {
|
||||
redirectUrl = redirectUrl + postAuthPath;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,15 +1,19 @@
|
||||
import { Request, Response, NextFunction } from "express";
|
||||
import { z } from "zod";
|
||||
import { db, domainNamespaces, loginPage } from "@server/db";
|
||||
import { build } from "@server/build";
|
||||
import {
|
||||
domains,
|
||||
orgDomains,
|
||||
db,
|
||||
loginPage,
|
||||
orgs,
|
||||
Resource,
|
||||
resources,
|
||||
resourcePolicies,
|
||||
roleResources,
|
||||
rolePolicies,
|
||||
roles,
|
||||
userResources
|
||||
userPolicies,
|
||||
userResources,
|
||||
domainNamespaces
|
||||
} from "@server/db";
|
||||
import response from "@server/lib/response";
|
||||
import HttpCode from "@server/types/HttpCode";
|
||||
@@ -20,27 +24,75 @@ import logger from "@server/logger";
|
||||
import { subdomainSchema, wildcardSubdomainSchema } from "@server/lib/schemas";
|
||||
import config from "@server/lib/config";
|
||||
import { OpenAPITags, registry } from "@server/openApi";
|
||||
import { build } from "@server/build";
|
||||
import { createCertificate } from "#dynamic/routers/certificates/createCertificate";
|
||||
import { getUniqueResourceName } from "@server/db/names";
|
||||
import { validateAndConstructDomain, checkWildcardDomainConflict } from "@server/lib/domainUtils";
|
||||
import {
|
||||
validateAndConstructDomain,
|
||||
checkWildcardDomainConflict
|
||||
} from "@server/lib/domainUtils";
|
||||
import { isSubscribed } from "#dynamic/lib/isSubscribed";
|
||||
import { isLicensedOrSubscribed } from "#dynamic/lib/isLicencedOrSubscribed";
|
||||
import { tierMatrix } from "@server/lib/billing/tierMatrix";
|
||||
import {
|
||||
getUniqueResourceName,
|
||||
getUniqueResourcePolicyName
|
||||
} from "@server/db/names";
|
||||
|
||||
const createResourceParamsSchema = z.strictObject({
|
||||
orgId: z.string()
|
||||
});
|
||||
|
||||
function resolveModeFromLegacyFields(data: {
|
||||
mode?: "http" | "ssh" | "rdp" | "vnc" | "tcp" | "udp";
|
||||
http?: boolean;
|
||||
protocol?: "tcp" | "udp";
|
||||
}): {
|
||||
mode?: "http" | "ssh" | "rdp" | "vnc" | "tcp" | "udp";
|
||||
error?: string;
|
||||
} {
|
||||
if (data.mode) {
|
||||
return { mode: data.mode };
|
||||
}
|
||||
|
||||
if (typeof data.http === "boolean" && data.protocol) {
|
||||
if (data.http && data.protocol === "tcp") {
|
||||
return { mode: "http" };
|
||||
}
|
||||
if (!data.http && data.protocol === "tcp") {
|
||||
return { mode: "tcp" };
|
||||
}
|
||||
if (!data.http && data.protocol === "udp") {
|
||||
return { mode: "udp" };
|
||||
}
|
||||
return {
|
||||
error: "Invalid deprecated http/protocol combination"
|
||||
};
|
||||
}
|
||||
|
||||
return { mode: undefined };
|
||||
}
|
||||
|
||||
const createHttpResourceSchema = z
|
||||
.strictObject({
|
||||
name: z.string().min(1).max(255),
|
||||
subdomain: z.string().nullable().optional(),
|
||||
http: z.boolean(),
|
||||
protocol: z.enum(["tcp", "udp"]),
|
||||
http: z.boolean().optional().openapi({
|
||||
deprecated: true,
|
||||
description:
|
||||
"Deprecated. Use `mode` instead. Legacy compatibility only."
|
||||
}),
|
||||
protocol: z.enum(["tcp", "udp"]).optional().openapi({
|
||||
deprecated: true,
|
||||
description:
|
||||
"Deprecated. Use `mode` instead. Legacy compatibility only."
|
||||
}),
|
||||
domainId: z.string(),
|
||||
stickySession: z.boolean().optional(),
|
||||
postAuthPath: z.string().nullable().optional()
|
||||
postAuthPath: z.string().nullable().optional(),
|
||||
mode: z.enum(["http", "ssh", "rdp", "vnc", "tcp", "udp"]).optional(),
|
||||
// SSH Settings
|
||||
pamMode: z.enum(["passthrough", "push"]).optional(),
|
||||
authDaemonPort: z.int().positive().optional(),
|
||||
authDaemonMode: z.enum(["site", "remote", "native"]).optional()
|
||||
})
|
||||
.refine(
|
||||
(data) => {
|
||||
@@ -60,13 +112,27 @@ const createHttpResourceSchema = z
|
||||
const createRawResourceSchema = z
|
||||
.strictObject({
|
||||
name: z.string().min(1).max(255),
|
||||
http: z.boolean(),
|
||||
protocol: z.enum(["tcp", "udp"]),
|
||||
http: z.boolean().optional().openapi({
|
||||
deprecated: true,
|
||||
description:
|
||||
"Deprecated. Use `mode` instead. Legacy compatibility only."
|
||||
}),
|
||||
protocol: z.enum(["tcp", "udp"]).optional().openapi({
|
||||
deprecated: true,
|
||||
description:
|
||||
"Deprecated. Use `mode` instead. Legacy compatibility only."
|
||||
}),
|
||||
mode: z.enum(["tcp", "udp"]).optional(),
|
||||
proxyPort: z.int().min(1).max(65535)
|
||||
// enableProxy: z.boolean().default(true) // always true now
|
||||
})
|
||||
.refine(
|
||||
(data) => {
|
||||
const resolved = resolveModeFromLegacyFields(data);
|
||||
if (resolved.error || !resolved.mode) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!config.getRawConfig().flags?.allow_raw_resources) {
|
||||
if (data.proxyPort !== undefined) {
|
||||
return false;
|
||||
@@ -143,17 +209,18 @@ export async function createResource(
|
||||
);
|
||||
}
|
||||
|
||||
if (typeof req.body.http !== "boolean") {
|
||||
const resolvedMode = resolveModeFromLegacyFields(req.body);
|
||||
if (resolvedMode.error) {
|
||||
return next(
|
||||
createHttpError(HttpCode.BAD_REQUEST, "http field is required")
|
||||
createHttpError(HttpCode.BAD_REQUEST, resolvedMode.error)
|
||||
);
|
||||
}
|
||||
|
||||
const { http } = req.body;
|
||||
if (resolvedMode.mode) {
|
||||
req.body.mode = resolvedMode.mode;
|
||||
}
|
||||
|
||||
if (http) {
|
||||
return await createHttpResource({ req, res, next }, { orgId });
|
||||
} else {
|
||||
if (typeof req.body.proxyPort === "number") {
|
||||
if (
|
||||
!config.getRawConfig().flags?.allow_raw_resources &&
|
||||
build == "oss"
|
||||
@@ -167,6 +234,17 @@ export async function createResource(
|
||||
}
|
||||
return await createRawResource({ req, res, next }, { orgId });
|
||||
}
|
||||
|
||||
if (req.body.mode) {
|
||||
return await createHttpResource({ req, res, next }, { orgId });
|
||||
} else {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
"mode is required when deprecated fields are not provided"
|
||||
)
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(error);
|
||||
return next(
|
||||
@@ -198,7 +276,15 @@ async function createHttpResource(
|
||||
);
|
||||
}
|
||||
|
||||
const { name, domainId, postAuthPath } = parsedBody.data;
|
||||
const {
|
||||
name,
|
||||
domainId,
|
||||
postAuthPath,
|
||||
mode,
|
||||
authDaemonPort,
|
||||
authDaemonMode,
|
||||
pamMode
|
||||
} = parsedBody.data;
|
||||
const subdomain = parsedBody.data.subdomain;
|
||||
const stickySession = parsedBody.data.stickySession;
|
||||
|
||||
@@ -311,28 +397,10 @@ async function createHttpResource(
|
||||
let resource: Resource | undefined;
|
||||
|
||||
const niceId = await getUniqueResourceName(orgId);
|
||||
const policyNiceId = await getUniqueResourcePolicyName(orgId);
|
||||
|
||||
await db.transaction(async (trx) => {
|
||||
const newResource = await trx
|
||||
.insert(resources)
|
||||
.values({
|
||||
niceId,
|
||||
fullDomain,
|
||||
domainId,
|
||||
orgId,
|
||||
name,
|
||||
subdomain: finalSubdomain,
|
||||
http: true,
|
||||
protocol: "tcp",
|
||||
ssl: true,
|
||||
stickySession: stickySession,
|
||||
postAuthPath: postAuthPath,
|
||||
wildcard,
|
||||
health: "unknown"
|
||||
})
|
||||
.returning();
|
||||
|
||||
const adminRole = await db
|
||||
const adminRole = await trx
|
||||
.select()
|
||||
.from(roles)
|
||||
.where(and(eq(roles.isAdmin, true), eq(roles.orgId, orgId)))
|
||||
@@ -344,6 +412,53 @@ async function createHttpResource(
|
||||
);
|
||||
}
|
||||
|
||||
const [defaultPolicy] = await trx
|
||||
.insert(resourcePolicies)
|
||||
.values({
|
||||
niceId: policyNiceId,
|
||||
orgId,
|
||||
name: `default policy for ${niceId}`,
|
||||
sso: true,
|
||||
scope: "resource"
|
||||
})
|
||||
.returning();
|
||||
|
||||
// make this policy visible by the admin role
|
||||
await trx.insert(rolePolicies).values({
|
||||
roleId: adminRole[0].roleId,
|
||||
resourcePolicyId: defaultPolicy.resourcePolicyId
|
||||
});
|
||||
|
||||
// make this policy visible by the current user
|
||||
if (req.user && !req.userOrgRoleIds?.includes(adminRole[0].roleId)) {
|
||||
await trx.insert(userPolicies).values({
|
||||
userId: req.user?.userId!,
|
||||
resourcePolicyId: defaultPolicy.resourcePolicyId
|
||||
});
|
||||
}
|
||||
|
||||
const newResource = await trx
|
||||
.insert(resources)
|
||||
.values({
|
||||
niceId,
|
||||
fullDomain,
|
||||
domainId,
|
||||
orgId,
|
||||
name,
|
||||
subdomain: finalSubdomain,
|
||||
mode: mode,
|
||||
pamMode: pamMode,
|
||||
authDaemonMode: authDaemonMode,
|
||||
authDaemonPort: authDaemonPort,
|
||||
ssl: true,
|
||||
stickySession: stickySession,
|
||||
postAuthPath: postAuthPath,
|
||||
wildcard,
|
||||
health: "unknown",
|
||||
defaultResourcePolicyId: defaultPolicy.resourcePolicyId
|
||||
})
|
||||
.returning();
|
||||
|
||||
await trx.insert(roleResources).values({
|
||||
roleId: adminRole[0].roleId,
|
||||
resourceId: newResource[0].resourceId
|
||||
@@ -369,7 +484,7 @@ async function createHttpResource(
|
||||
);
|
||||
}
|
||||
|
||||
if (build != "oss") {
|
||||
if (build !== "oss") {
|
||||
await createCertificate(domainId, fullDomain, db);
|
||||
}
|
||||
|
||||
@@ -405,27 +520,25 @@ async function createRawResource(
|
||||
);
|
||||
}
|
||||
|
||||
const { name, http, protocol, proxyPort } = parsedBody.data;
|
||||
const { name, proxyPort } = parsedBody.data;
|
||||
const resolvedMode = resolveModeFromLegacyFields(parsedBody.data);
|
||||
if (resolvedMode.error || !resolvedMode.mode) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
resolvedMode.error ||
|
||||
"mode is required when deprecated fields are not provided"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
let resource: Resource | undefined;
|
||||
|
||||
const niceId = await getUniqueResourceName(orgId);
|
||||
const policyNiceId = await getUniqueResourcePolicyName(orgId);
|
||||
|
||||
await db.transaction(async (trx) => {
|
||||
const newResource = await trx
|
||||
.insert(resources)
|
||||
.values({
|
||||
niceId,
|
||||
orgId,
|
||||
name,
|
||||
http,
|
||||
protocol,
|
||||
proxyPort
|
||||
// enableProxy
|
||||
})
|
||||
.returning();
|
||||
|
||||
const adminRole = await db
|
||||
const adminRole = await trx
|
||||
.select()
|
||||
.from(roles)
|
||||
.where(and(eq(roles.isAdmin, true), eq(roles.orgId, orgId)))
|
||||
@@ -437,6 +550,43 @@ async function createRawResource(
|
||||
);
|
||||
}
|
||||
|
||||
const [defaultPolicy] = await trx
|
||||
.insert(resourcePolicies)
|
||||
.values({
|
||||
niceId: policyNiceId,
|
||||
orgId,
|
||||
name: `default policy for ${niceId}`,
|
||||
sso: true,
|
||||
scope: "resource"
|
||||
})
|
||||
.returning();
|
||||
|
||||
// make this policy visible by the admin role
|
||||
await trx.insert(rolePolicies).values({
|
||||
roleId: adminRole[0].roleId,
|
||||
resourcePolicyId: defaultPolicy.resourcePolicyId
|
||||
});
|
||||
|
||||
// make this policy visible by the current user
|
||||
if (req.user && !req.userOrgRoleIds?.includes(adminRole[0].roleId)) {
|
||||
await trx.insert(userPolicies).values({
|
||||
userId: req.user?.userId!,
|
||||
resourcePolicyId: defaultPolicy.resourcePolicyId
|
||||
});
|
||||
}
|
||||
|
||||
const newResource = await trx
|
||||
.insert(resources)
|
||||
.values({
|
||||
niceId,
|
||||
orgId,
|
||||
name,
|
||||
mode: resolvedMode.mode,
|
||||
proxyPort,
|
||||
defaultResourcePolicyId: defaultPolicy.resourcePolicyId
|
||||
})
|
||||
.returning();
|
||||
|
||||
await trx.insert(roleResources).values({
|
||||
roleId: adminRole[0].roleId,
|
||||
resourceId: newResource[0].resourceId
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user