mirror of
https://github.com/fosrl/pangolin.git
synced 2026-08-02 10:40:25 +02:00
Compare commits
16 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 071cf68682 | |||
| 146c287aba | |||
| 0e4cd3a5ba | |||
| aeed271f3e | |||
| d41e9bbe68 | |||
| 904a5520b8 | |||
| 0f43ae4871 | |||
| bd0cf98319 | |||
| fc7d6bbaf9 | |||
| d5a56fb71d | |||
| b5518d029e | |||
| 0da96b06ea | |||
| 6649f15498 | |||
| 8a68b899f5 | |||
| c92d5096c4 | |||
| 72f179578b |
@@ -62,7 +62,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
|
||||
- name: Monitor storage space
|
||||
run: |
|
||||
@@ -77,7 +77,7 @@ jobs:
|
||||
fi
|
||||
|
||||
- name: Log in to Docker Hub
|
||||
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
|
||||
uses: docker/login-action@abd2ef45e78c5afb21d64d4ca52ee8550d9572c7 # v4.5.1
|
||||
with:
|
||||
registry: docker.io
|
||||
username: ${{ secrets.DOCKER_HUB_USERNAME }}
|
||||
@@ -134,7 +134,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
|
||||
- name: Monitor storage space
|
||||
run: |
|
||||
@@ -149,7 +149,7 @@ jobs:
|
||||
fi
|
||||
|
||||
- name: Log in to Docker Hub
|
||||
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
|
||||
uses: docker/login-action@abd2ef45e78c5afb21d64d4ca52ee8550d9572c7 # v4.5.1
|
||||
with:
|
||||
registry: docker.io
|
||||
username: ${{ secrets.DOCKER_HUB_USERNAME }}
|
||||
@@ -201,10 +201,10 @@ jobs:
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
|
||||
- name: Log in to Docker Hub
|
||||
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
|
||||
uses: docker/login-action@abd2ef45e78c5afb21d64d4ca52ee8550d9572c7 # v4.5.1
|
||||
with:
|
||||
registry: docker.io
|
||||
username: ${{ secrets.DOCKER_HUB_USERNAME }}
|
||||
@@ -256,7 +256,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
|
||||
- name: Extract tag name
|
||||
id: get-tag
|
||||
@@ -264,7 +264,7 @@ jobs:
|
||||
shell: bash
|
||||
|
||||
- name: Install Go
|
||||
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
|
||||
uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
|
||||
with:
|
||||
go-version: 1.25
|
||||
|
||||
@@ -407,7 +407,7 @@ jobs:
|
||||
shell: bash
|
||||
|
||||
- name: Login to GitHub Container Registry (for cosign)
|
||||
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
|
||||
uses: docker/login-action@abd2ef45e78c5afb21d64d4ca52ee8550d9572c7 # v4.5.1
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
|
||||
@@ -21,10 +21,10 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
with:
|
||||
node-version: '24'
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@ jobs:
|
||||
stale:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/stale@eb5cf3af3ac0a1aa4c9c45633dd1ae542a27a899 # v10.3.0
|
||||
- uses: actions/stale@1e223db275d687790206a7acac4d1a11bd6fe629 # v10.4.0
|
||||
with:
|
||||
days-before-stale: 14
|
||||
days-before-close: 14
|
||||
|
||||
@@ -14,10 +14,10 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
|
||||
- name: Install Node
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
with:
|
||||
node-version: '24'
|
||||
|
||||
@@ -62,7 +62,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
|
||||
- name: Build Docker image sqlite
|
||||
run: make dev-build-sqlite
|
||||
@@ -71,7 +71,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
|
||||
- name: Build Docker image pg
|
||||
run: make dev-build-pg
|
||||
|
||||
+2
-2
@@ -5,7 +5,7 @@ go 1.25.0
|
||||
require (
|
||||
github.com/charmbracelet/huh v1.0.0
|
||||
github.com/charmbracelet/lipgloss v1.1.0
|
||||
golang.org/x/term v0.44.0
|
||||
golang.org/x/term v0.45.0
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
)
|
||||
|
||||
@@ -33,6 +33,6 @@ require (
|
||||
github.com/rivo/uniseg v0.4.7 // indirect
|
||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
|
||||
golang.org/x/sync v0.15.0 // indirect
|
||||
golang.org/x/sys v0.46.0 // indirect
|
||||
golang.org/x/sys v0.47.0 // indirect
|
||||
golang.org/x/text v0.23.0 // indirect
|
||||
)
|
||||
|
||||
+4
-4
@@ -69,10 +69,10 @@ golang.org/x/sync v0.15.0 h1:KWH3jNZsfyT6xfAfKiz6MRNmd46ByHDYaZ7KSkCtdW8=
|
||||
golang.org/x/sync v0.15.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
|
||||
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw=
|
||||
golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/term v0.44.0 h1:0rLvDRCtNj0gZkyIXhCyOb2OAzEhLVqc4B+hrsBhrmc=
|
||||
golang.org/x/term v0.44.0/go.mod h1:7ze4MdzUzLXpSAoFP1H0bOI9aXDqveSvatT5vKcFh2Y=
|
||||
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
|
||||
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0=
|
||||
golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w=
|
||||
golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY=
|
||||
golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
||||
|
||||
@@ -1424,16 +1424,6 @@
|
||||
"actionDeleteSite": "Delete Site",
|
||||
"actionGetSite": "Get Site",
|
||||
"actionListSites": "List Sites",
|
||||
"actionCreateAiProvider": "Create AI Provider",
|
||||
"actionDeleteAiProvider": "Delete AI Provider",
|
||||
"actionGetAiProvider": "Get AI Provider",
|
||||
"actionListAiProviders": "List AI Providers",
|
||||
"actionUpdateAiProvider": "Update AI Provider",
|
||||
"actionCreateAiModel": "Create AI Model",
|
||||
"actionDeleteAiModel": "Delete AI Model",
|
||||
"actionGetAiModel": "Get AI Model",
|
||||
"actionListAiModels": "List AI Models",
|
||||
"actionUpdateAiModel": "Update AI Model",
|
||||
"actionApplyBlueprint": "Apply Blueprint",
|
||||
"actionListBlueprints": "List Blueprints",
|
||||
"actionGetBlueprint": "Get Blueprint",
|
||||
@@ -2256,7 +2246,6 @@
|
||||
"requireDeviceApproval": "Require Device Approvals",
|
||||
"requireDeviceApprovalDescription": "Users with this role need new devices approved by an admin before they can connect and access resources.",
|
||||
"sshSettings": "SSH Settings",
|
||||
"inferenceSettings": "Inference Settings",
|
||||
"sshAccess": "SSH Access",
|
||||
"rdpSettings": "RDP Settings",
|
||||
"vncSettings": "VNC Settings",
|
||||
@@ -2412,7 +2401,6 @@
|
||||
"editInternalResourceDialogModeCidr": "CIDR",
|
||||
"editInternalResourceDialogModeHttp": "HTTP",
|
||||
"editInternalResourceDialogModeHttps": "HTTPS",
|
||||
"editInternalResourceDialogModeInference": "Inference",
|
||||
"editInternalResourceDialogModeSsh": "SSH",
|
||||
"editInternalResourceDialogScheme": "Scheme",
|
||||
"editInternalResourceDialogEnableSsl": "Enable TLS",
|
||||
|
||||
Generated
+1976
-3079
File diff suppressed because it is too large
Load Diff
+65
-65
@@ -32,49 +32,49 @@
|
||||
"format": "prettier --write ."
|
||||
},
|
||||
"dependencies": {
|
||||
"@asteasolutions/zod-to-openapi": "8.5.0",
|
||||
"@asteasolutions/zod-to-openapi": "9.1.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.1.tgz",
|
||||
"@aws-sdk/client-s3": "3.1056.0",
|
||||
"@aws-sdk/client-s3": "3.1098.0",
|
||||
"@headlessui/react": "2.2.10",
|
||||
"@hookform/resolvers": "5.4.0",
|
||||
"@hookform/resolvers": "5.5.7",
|
||||
"@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",
|
||||
"@radix-ui/react-checkbox": "1.3.3",
|
||||
"@radix-ui/react-collapsible": "1.1.12",
|
||||
"@radix-ui/react-dialog": "1.1.15",
|
||||
"@radix-ui/react-dropdown-menu": "2.1.16",
|
||||
"@radix-ui/react-avatar": "1.2.6",
|
||||
"@radix-ui/react-checkbox": "1.3.11",
|
||||
"@radix-ui/react-collapsible": "1.1.20",
|
||||
"@radix-ui/react-dialog": "1.1.23",
|
||||
"@radix-ui/react-dropdown-menu": "2.1.24",
|
||||
"@radix-ui/react-icons": "1.3.2",
|
||||
"@radix-ui/react-label": "2.1.8",
|
||||
"@radix-ui/react-popover": "1.1.15",
|
||||
"@radix-ui/react-progress": "1.1.8",
|
||||
"@radix-ui/react-radio-group": "1.3.8",
|
||||
"@radix-ui/react-scroll-area": "1.2.10",
|
||||
"@radix-ui/react-select": "2.2.6",
|
||||
"@radix-ui/react-separator": "1.1.8",
|
||||
"@radix-ui/react-slot": "1.2.4",
|
||||
"@radix-ui/react-switch": "1.2.6",
|
||||
"@radix-ui/react-tabs": "1.1.13",
|
||||
"@radix-ui/react-toast": "1.2.15",
|
||||
"@radix-ui/react-tooltip": "1.2.8",
|
||||
"@radix-ui/react-label": "2.1.15",
|
||||
"@radix-ui/react-popover": "1.1.23",
|
||||
"@radix-ui/react-progress": "1.1.16",
|
||||
"@radix-ui/react-radio-group": "1.4.7",
|
||||
"@radix-ui/react-scroll-area": "1.2.18",
|
||||
"@radix-ui/react-select": "2.3.7",
|
||||
"@radix-ui/react-separator": "1.1.15",
|
||||
"@radix-ui/react-slot": "1.3.3",
|
||||
"@radix-ui/react-switch": "1.3.7",
|
||||
"@radix-ui/react-tabs": "1.1.21",
|
||||
"@radix-ui/react-toast": "1.2.23",
|
||||
"@radix-ui/react-tooltip": "1.2.16",
|
||||
"@react-email/body": "0.3.0",
|
||||
"@react-email/components": "1.0.12",
|
||||
"@react-email/render": "2.0.8",
|
||||
"@react-email/render": "2.1.0",
|
||||
"@react-email/tailwind": "2.0.7",
|
||||
"@simplewebauthn/browser": "13.3.0",
|
||||
"@simplewebauthn/server": "13.3.1",
|
||||
"@simplewebauthn/server": "13.3.2",
|
||||
"@tailwindcss/forms": "0.5.11",
|
||||
"@tanstack/react-query": "5.100.14",
|
||||
"@tanstack/react-query": "5.101.4",
|
||||
"@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",
|
||||
"axios": "1.19.0",
|
||||
"better-sqlite3": "11.9.1",
|
||||
"canvas-confetti": "1.9.4",
|
||||
"class-variance-authority": "0.7.1",
|
||||
@@ -86,61 +86,61 @@
|
||||
"d3": "7.9.0",
|
||||
"drizzle-orm": "0.45.2",
|
||||
"express": "5.2.1",
|
||||
"express-rate-limit": "8.5.2",
|
||||
"express-rate-limit": "8.6.1",
|
||||
"glob": "13.0.6",
|
||||
"helmet": "8.2.0",
|
||||
"helmet": "8.3.0",
|
||||
"http-errors": "2.0.1",
|
||||
"input-otp": "1.4.2",
|
||||
"ioredis": "5.11.0",
|
||||
"ioredis": "5.11.1",
|
||||
"jmespath": "0.16.0",
|
||||
"js-yaml": "4.2.0",
|
||||
"js-yaml": "5.2.2",
|
||||
"jsonwebtoken": "9.0.3",
|
||||
"lucide-react": "1.17.0",
|
||||
"lucide-react": "1.27.0",
|
||||
"maxmind": "5.0.6",
|
||||
"moment": "2.30.1",
|
||||
"next": "16.2.6",
|
||||
"next-intl": "4.13.0",
|
||||
"next": "16.2.12",
|
||||
"next-intl": "4.13.4",
|
||||
"next-themes": "0.4.6",
|
||||
"nextjs-toploader": "3.9.17",
|
||||
"node-cache": "5.1.2",
|
||||
"nodemailer": "9.0.1",
|
||||
"nodemailer": "9.0.3",
|
||||
"oslo": "1.2.1",
|
||||
"pg": "8.21.0",
|
||||
"posthog-node": "5.35.6",
|
||||
"pg": "8.22.0",
|
||||
"posthog-node": "5.46.1",
|
||||
"qrcode.react": "4.2.0",
|
||||
"react": "19.2.6",
|
||||
"react-day-picker": "9.14.0",
|
||||
"react-dom": "19.2.6",
|
||||
"react": "19.2.8",
|
||||
"react-day-picker": "10.0.1",
|
||||
"react-dom": "19.2.8",
|
||||
"react-easy-sort": "1.8.0",
|
||||
"react-hook-form": "7.76.1",
|
||||
"react-icons": "5.6.0",
|
||||
"recharts": "3.8.1",
|
||||
"react-hook-form": "7.83.0",
|
||||
"react-icons": "5.7.0",
|
||||
"recharts": "3.10.1",
|
||||
"reodotdev": "1.1.0",
|
||||
"semver": "7.8.1",
|
||||
"semver": "7.8.5",
|
||||
"sshpk": "1.18.0",
|
||||
"stripe": "22.2.0",
|
||||
"stripe": "22.4.0",
|
||||
"swagger-ui-express": "5.0.1",
|
||||
"tailwind-merge": "3.6.0",
|
||||
"topojson-client": "3.1.0",
|
||||
"tw-animate-css": "1.4.0",
|
||||
"use-debounce": "10.1.1",
|
||||
"uuid": "14.0.0",
|
||||
"uuid": "14.0.1",
|
||||
"vaul": "1.1.2",
|
||||
"visionscarto-world-atlas": "1.0.0",
|
||||
"winston": "3.19.0",
|
||||
"winston-daily-rotate-file": "5.0.0",
|
||||
"ws": "8.21.0",
|
||||
"ws": "8.21.1",
|
||||
"yaml": "2.9.0",
|
||||
"yargs": "18.0.0",
|
||||
"yargs": "18.1.0",
|
||||
"zod": "4.4.3",
|
||||
"zod-validation-error": "5.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@dotenvx/dotenvx": "1.69.1",
|
||||
"@dotenvx/dotenvx": "2.19.1",
|
||||
"@esbuild-plugins/tsconfig-paths": "0.1.2",
|
||||
"@react-email/ui": "^6.5.0",
|
||||
"@tailwindcss/postcss": "4.3.0",
|
||||
"@tanstack/react-query-devtools": "5.100.14",
|
||||
"@react-email/ui": "^6.9.1",
|
||||
"@tailwindcss/postcss": "4.3.3",
|
||||
"@tanstack/react-query-devtools": "5.101.4",
|
||||
"@types/better-sqlite3": "7.6.13",
|
||||
"@types/cookie-parser": "1.4.10",
|
||||
"@types/cors": "2.8.19",
|
||||
@@ -151,14 +151,14 @@
|
||||
"@types/jmespath": "0.15.2",
|
||||
"@types/js-yaml": "4.0.9",
|
||||
"@types/jsonwebtoken": "9.0.10",
|
||||
"@types/node": "25.9.1",
|
||||
"@types/nodemailer": "8.0.0",
|
||||
"@types/node": "26.1.2",
|
||||
"@types/nodemailer": "8.0.1",
|
||||
"@types/nprogress": "0.2.3",
|
||||
"@types/pg": "8.20.0",
|
||||
"@types/react": "19.2.15",
|
||||
"@types/react": "19.2.17",
|
||||
"@types/react-dom": "19.2.3",
|
||||
"@types/semver": "7.7.1",
|
||||
"@types/sshpk": "1.17.4",
|
||||
"@types/sshpk": "1.17.5",
|
||||
"@types/swagger-ui-express": "4.1.8",
|
||||
"@types/topojson-client": "3.1.5",
|
||||
"@types/ws": "8.18.1",
|
||||
@@ -166,21 +166,21 @@
|
||||
"babel-plugin-react-compiler": "1.0.0",
|
||||
"drizzle-kit": "0.31.10",
|
||||
"esbuild": "0.28.1",
|
||||
"esbuild-node-externals": "1.22.0",
|
||||
"eslint": "10.4.0",
|
||||
"eslint-config-next": "16.2.6",
|
||||
"postcss": "8.5.15",
|
||||
"prettier": "3.8.3",
|
||||
"react-email": "6.5.0",
|
||||
"tailwindcss": "4.3.0",
|
||||
"tsc-alias": "1.8.17",
|
||||
"tsx": "4.22.3",
|
||||
"typescript": "6.0.3",
|
||||
"typescript-eslint": "8.60.0"
|
||||
"esbuild-node-externals": "1.23.1",
|
||||
"eslint": "10.8.0",
|
||||
"eslint-config-next": "16.2.12",
|
||||
"postcss": "8.5.25",
|
||||
"prettier": "3.9.6",
|
||||
"react-email": "6.9.1",
|
||||
"tailwindcss": "4.3.3",
|
||||
"tsc-alias": "1.9.1",
|
||||
"tsx": "4.23.1",
|
||||
"typescript": "7.0.2",
|
||||
"typescript-eslint": "8.65.0"
|
||||
},
|
||||
"overrides": {
|
||||
"esbuild": "0.28.1",
|
||||
"dompurify": "3.4.0",
|
||||
"postcss": "8.5.15"
|
||||
"postcss": "8.5.25"
|
||||
}
|
||||
}
|
||||
|
||||
+1
-11
@@ -182,17 +182,7 @@ export enum ActionsEnum {
|
||||
setResourcePolicyHeaderAuth = "setResourcePolicyHeaderAuth",
|
||||
setResourcePolicyWhitelist = "setResourcePolicyWhitelist",
|
||||
setResourcePolicyRules = "setResourcePolicyRules",
|
||||
createOrgWideLauncherView = "createOrgWideLauncherView",
|
||||
createAiProvider = "createAiProvider",
|
||||
deleteAiProvider = "deleteAiProvider",
|
||||
getAiProvider = "getAiProvider",
|
||||
listAiProviders = "listAiProviders",
|
||||
updateAiProvider = "updateAiProvider",
|
||||
createAiModel = "createAiModel",
|
||||
deleteAiModel = "deleteAiModel",
|
||||
getAiModel = "getAiModel",
|
||||
listAiModels = "listAiModels",
|
||||
updateAiModel = "updateAiModel"
|
||||
createOrgWideLauncherView = "createOrgWideLauncherView"
|
||||
}
|
||||
|
||||
export async function checkUserActionPermission(
|
||||
|
||||
@@ -99,7 +99,6 @@ export const sites = pgTable(
|
||||
name: varchar("name").notNull(),
|
||||
pubKey: varchar("pubKey"),
|
||||
subnet: varchar("subnet"),
|
||||
exitNodeSubnet: text("exitNodeSubnet"), // this is the subnet when connecting to an exit node
|
||||
megabytesIn: real("bytesIn").default(0),
|
||||
megabytesOut: real("bytesOut").default(0),
|
||||
lastBandwidthUpdate: varchar("lastBandwidthUpdate"),
|
||||
@@ -195,12 +194,7 @@ export const resources = pgTable(
|
||||
postAuthPath: text("postAuthPath"),
|
||||
health: varchar("health").default("unknown"), // "healthy", "unhealthy", "unknown"
|
||||
wildcard: boolean("wildcard").notNull().default(false),
|
||||
mode: text("mode")
|
||||
.default("http")
|
||||
.$type<
|
||||
"rdp" | "ssh" | "http" | "vnc" | "inference" | "tcp" | "udp"
|
||||
>()
|
||||
.notNull(),
|
||||
mode: text("mode").default("http").notNull(), // rdp, ssh, http, vnc
|
||||
pamMode: varchar("pamMode", { length: 32 })
|
||||
.$type<"passthrough" | "push">()
|
||||
.default("passthrough"),
|
||||
@@ -323,18 +317,11 @@ export const targets = pgTable(
|
||||
"targets",
|
||||
{
|
||||
targetId: serial("targetId").primaryKey(),
|
||||
resourceId: integer("resourceId").references(
|
||||
() => resources.resourceId,
|
||||
{
|
||||
resourceId: integer("resourceId")
|
||||
.references(() => resources.resourceId, {
|
||||
onDelete: "cascade"
|
||||
}
|
||||
),
|
||||
providerId: integer("providerId").references(
|
||||
() => aiProviders.providerId,
|
||||
{
|
||||
onDelete: "cascade"
|
||||
}
|
||||
),
|
||||
})
|
||||
.notNull(),
|
||||
siteId: integer("siteId")
|
||||
.references(() => sites.siteId, {
|
||||
onDelete: "cascade"
|
||||
@@ -358,7 +345,6 @@ export const targets = pgTable(
|
||||
},
|
||||
(t) => [
|
||||
index("idx_targets_resourceid_siteid").on(t.resourceId, t.siteId),
|
||||
index("idx_targets_providerid_siteid").on(t.providerId, t.siteId),
|
||||
index("idx_targets_site_enabled_priority_target_resource")
|
||||
.on(t.siteId, t.priority.desc(), t.targetId, t.resourceId)
|
||||
.where(sql`${t.enabled} = true`)
|
||||
@@ -438,14 +424,11 @@ export const siteResources = pgTable(
|
||||
onDelete: "restrict"
|
||||
}
|
||||
),
|
||||
requiresExitNodeConnection: boolean("requiresExitNodeConnection")
|
||||
.notNull()
|
||||
.default(false),
|
||||
niceId: varchar("niceId").notNull(),
|
||||
name: varchar("name").notNull(),
|
||||
ssl: boolean("ssl").notNull().default(false),
|
||||
mode: varchar("mode")
|
||||
.$type<"host" | "cidr" | "http" | "ssh" | "inference">()
|
||||
.$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
|
||||
@@ -1198,7 +1181,7 @@ export const clients = pgTable(
|
||||
olmId: text("olmId"), // to lock it to a specific olm optionally
|
||||
name: varchar("name").notNull(),
|
||||
pubKey: varchar("pubKey"),
|
||||
exitNodeSubnet: varchar("exitNodeSubnet").notNull(),
|
||||
subnet: varchar("subnet").notNull(),
|
||||
megabytesIn: real("bytesIn"),
|
||||
megabytesOut: real("bytesOut"),
|
||||
lastBandwidthUpdate: varchar("lastBandwidthUpdate"),
|
||||
@@ -1557,61 +1540,6 @@ export const statusHistory = pgTable(
|
||||
]
|
||||
);
|
||||
|
||||
export const aiProviders = pgTable("aiProviders", {
|
||||
providerId: serial("providerId").primaryKey(),
|
||||
orgId: varchar("orgId")
|
||||
.notNull()
|
||||
.references(() => orgs.orgId, { onDelete: "cascade" }),
|
||||
name: varchar("name").notNull(),
|
||||
type: varchar("type")
|
||||
.$type<
|
||||
| "openai"
|
||||
| "anthropic"
|
||||
| "googleGemini"
|
||||
| "vertexAi"
|
||||
| "bedrock"
|
||||
| "microsoftFoundry"
|
||||
| "openRouter"
|
||||
| "vercelAiGateway"
|
||||
| "custom"
|
||||
>()
|
||||
.notNull(),
|
||||
upstreamUrl: text("upstreamUrl"),
|
||||
apiKey: text("apiKey"),
|
||||
apiKeyLastChars: varchar("apiKeyLastChars"),
|
||||
authType: varchar("authType").$type<"bearer">(),
|
||||
routingMode: varchar("routingMode")
|
||||
.$type<"url" | "target">()
|
||||
.notNull()
|
||||
.default("url"),
|
||||
skipTlsVerification: boolean("skipTlsVerification")
|
||||
.notNull()
|
||||
.default(false),
|
||||
budgetAmount: real("budgetAmount"),
|
||||
budgetUnit: varchar("budgetUnit").$type<"usd" | "tokens">(),
|
||||
enabled: boolean("enabled").notNull().default(true),
|
||||
createdAt: bigint("createdAt", { mode: "number" }).notNull(),
|
||||
updatedAt: bigint("updatedAt", { mode: "number" }).notNull()
|
||||
});
|
||||
|
||||
export const aiModels = pgTable(
|
||||
"aiModels",
|
||||
{
|
||||
modelId: serial("modelId").primaryKey(),
|
||||
providerId: integer("providerId")
|
||||
.notNull()
|
||||
.references(() => aiProviders.providerId, { onDelete: "cascade" }),
|
||||
modelKey: varchar("modelKey").notNull(),
|
||||
name: varchar("name").notNull(),
|
||||
budgetAmount: real("budgetAmount"),
|
||||
budgetUnit: varchar("budgetUnit").$type<"usd" | "tokens">(),
|
||||
enabled: boolean("enabled").notNull().default(true),
|
||||
createdAt: bigint("createdAt", { mode: "number" }).notNull(),
|
||||
updatedAt: bigint("updatedAt", { mode: "number" }).notNull()
|
||||
},
|
||||
(t) => [unique("ai_model_provider_key_uniq").on(t.providerId, t.modelKey)]
|
||||
);
|
||||
|
||||
export type Org = InferSelectModel<typeof orgs>;
|
||||
export type User = InferSelectModel<typeof users>;
|
||||
export type Site = InferSelectModel<typeof sites>;
|
||||
@@ -1696,5 +1624,3 @@ export type ResourcePolicy = InferSelectModel<typeof resourcePolicies>;
|
||||
export type RolePolicy = InferSelectModel<typeof rolePolicies>;
|
||||
export type UserPolicy = InferSelectModel<typeof userPolicies>;
|
||||
export type ResourcePolicyRule = InferSelectModel<typeof resourcePolicyRules>;
|
||||
export type AiProvider = InferSelectModel<typeof aiProviders>;
|
||||
export type AiModel = InferSelectModel<typeof aiModels>;
|
||||
|
||||
@@ -4,7 +4,6 @@ import {
|
||||
index,
|
||||
integer,
|
||||
primaryKey,
|
||||
real,
|
||||
sqliteTable,
|
||||
text,
|
||||
unique
|
||||
@@ -108,7 +107,7 @@ export const sites = sqliteTable("sites", {
|
||||
}),
|
||||
name: text("name").notNull(),
|
||||
pubKey: text("pubKey"),
|
||||
exitNodeSubnet: text("exitNodeSubnet"),
|
||||
subnet: text("subnet"),
|
||||
megabytesIn: integer("bytesIn").default(0),
|
||||
megabytesOut: integer("bytesOut").default(0),
|
||||
lastBandwidthUpdate: text("lastBandwidthUpdate"),
|
||||
@@ -204,10 +203,7 @@ export const resources = sqliteTable("resources", {
|
||||
postAuthPath: text("postAuthPath"),
|
||||
health: text("health").default("unknown"), // "healthy", "unhealthy", "unknown"
|
||||
wildcard: integer("wildcard", { mode: "boolean" }).notNull().default(false),
|
||||
mode: text("mode")
|
||||
.default("http")
|
||||
.$type<"rdp" | "ssh" | "http" | "vnc" | "inference" | "tcp" | "udp">()
|
||||
.notNull(), // rdp, ssh, http, vnc, inference
|
||||
mode: text("mode").default("http").notNull(), // rdp, ssh, http, vnc
|
||||
pamMode: text("pamMode")
|
||||
.$type<"passthrough" | "push">()
|
||||
.default("passthrough"),
|
||||
@@ -326,12 +322,11 @@ export const clientLabels = sqliteTable(
|
||||
|
||||
export const targets = sqliteTable("targets", {
|
||||
targetId: integer("targetId").primaryKey({ autoIncrement: true }),
|
||||
resourceId: integer("resourceId").references(() => resources.resourceId, {
|
||||
onDelete: "cascade"
|
||||
}),
|
||||
providerId: integer("providerId").references(() => aiProviders.providerId, {
|
||||
onDelete: "cascade"
|
||||
}),
|
||||
resourceId: integer("resourceId")
|
||||
.references(() => resources.resourceId, {
|
||||
onDelete: "cascade"
|
||||
})
|
||||
.notNull(),
|
||||
siteId: integer("siteId")
|
||||
.references(() => sites.siteId, {
|
||||
onDelete: "cascade"
|
||||
@@ -427,17 +422,10 @@ export const siteResources = sqliteTable("siteResources", {
|
||||
() => networks.networkId,
|
||||
{ onDelete: "restrict" }
|
||||
),
|
||||
requiresExitNodeConnection: integer("requiresExitNodeConnection", {
|
||||
mode: "boolean"
|
||||
})
|
||||
.notNull()
|
||||
.default(false),
|
||||
niceId: text("niceId").notNull(),
|
||||
name: text("name").notNull(),
|
||||
ssl: integer("ssl", { mode: "boolean" }).notNull().default(false),
|
||||
mode: text("mode")
|
||||
.$type<"host" | "cidr" | "http" | "ssh" | "inference">()
|
||||
.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
|
||||
@@ -611,7 +599,6 @@ export const clients = sqliteTable("clients", {
|
||||
pubKey: text("pubKey"),
|
||||
olmId: text("olmId"), // to lock it to a specific olm optionally
|
||||
subnet: text("subnet").notNull(),
|
||||
exitNodeSubnet: text("exitNodeSubnet"), // this is the subnet when connecting to an exit node
|
||||
megabytesIn: integer("bytesIn"),
|
||||
megabytesOut: integer("bytesOut"),
|
||||
lastBandwidthUpdate: text("lastBandwidthUpdate"),
|
||||
@@ -1539,63 +1526,6 @@ export const statusHistory = sqliteTable(
|
||||
]
|
||||
);
|
||||
|
||||
export const aiProviders = sqliteTable("aiProviders", {
|
||||
providerId: integer("providerId").primaryKey({ autoIncrement: true }),
|
||||
orgId: text("orgId")
|
||||
.notNull()
|
||||
.references(() => orgs.orgId, { onDelete: "cascade" }),
|
||||
name: text("name").notNull(),
|
||||
type: text("type")
|
||||
.$type<
|
||||
| "openai"
|
||||
| "anthropic"
|
||||
| "googleGemini"
|
||||
| "vertexAi"
|
||||
| "bedrock"
|
||||
| "microsoftFoundry"
|
||||
| "openRouter"
|
||||
| "vercelAiGateway"
|
||||
| "custom"
|
||||
>()
|
||||
.notNull(),
|
||||
upstreamUrl: text("upstreamUrl"),
|
||||
apiKey: text("apiKey"),
|
||||
apiKeyLastChars: text("apiKeyLastChars"),
|
||||
authType: text("authType").$type<"bearer">(),
|
||||
routingMode: text("routingMode")
|
||||
.$type<"url" | "target">()
|
||||
.notNull()
|
||||
.default("url"),
|
||||
skipTlsVerification: integer("skipTlsVerification", { mode: "boolean" })
|
||||
.notNull()
|
||||
.default(false),
|
||||
budgetAmount: real("budgetAmount"),
|
||||
budgetUnit: text("budgetUnit").$type<"usd" | "tokens">(),
|
||||
enabled: integer("enabled", { mode: "boolean" }).notNull().default(true),
|
||||
createdAt: integer("createdAt").notNull(),
|
||||
updatedAt: integer("updatedAt").notNull()
|
||||
});
|
||||
|
||||
export const aiModels = sqliteTable(
|
||||
"aiModels",
|
||||
{
|
||||
modelId: integer("modelId").primaryKey({ autoIncrement: true }),
|
||||
providerId: integer("providerId")
|
||||
.notNull()
|
||||
.references(() => aiProviders.providerId, { onDelete: "cascade" }),
|
||||
modelKey: text("modelKey").notNull(),
|
||||
name: text("name").notNull(),
|
||||
budgetAmount: real("budgetAmount"),
|
||||
budgetUnit: text("budgetUnit").$type<"usd" | "tokens">(),
|
||||
enabled: integer("enabled", { mode: "boolean" })
|
||||
.notNull()
|
||||
.default(true),
|
||||
createdAt: integer("createdAt").notNull(),
|
||||
updatedAt: integer("updatedAt").notNull()
|
||||
},
|
||||
(t) => [unique("ai_model_provider_key_uniq").on(t.providerId, t.modelKey)]
|
||||
);
|
||||
|
||||
export type Org = InferSelectModel<typeof orgs>;
|
||||
export type User = InferSelectModel<typeof users>;
|
||||
export type Site = InferSelectModel<typeof sites>;
|
||||
@@ -1678,5 +1608,3 @@ export type ResourcePolicyHeaderAuth = InferSelectModel<
|
||||
>;
|
||||
export type RolePolicy = InferSelectModel<typeof rolePolicies>;
|
||||
export type UserPolicy = InferSelectModel<typeof userPolicies>;
|
||||
export type AiProvider = InferSelectModel<typeof aiProviders>;
|
||||
export type AiModel = InferSelectModel<typeof aiModels>;
|
||||
|
||||
@@ -9,8 +9,6 @@ import { createIntegrationApiServer } from "./integrationApiServer";
|
||||
import {
|
||||
ApiKey,
|
||||
ApiKeyOrg,
|
||||
AiModel,
|
||||
AiProvider,
|
||||
RemoteExitNode,
|
||||
Session,
|
||||
SiteResource,
|
||||
@@ -85,8 +83,6 @@ declare global {
|
||||
userOrgIds?: string[];
|
||||
remoteExitNode?: RemoteExitNode;
|
||||
siteResource?: SiteResource;
|
||||
aiProvider?: AiProvider;
|
||||
aiModel?: AiModel;
|
||||
orgPolicyAllowed?: boolean;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,106 +0,0 @@
|
||||
export type AiProviderType =
|
||||
| "openai"
|
||||
| "anthropic"
|
||||
| "googleGemini"
|
||||
| "vertexAi"
|
||||
| "bedrock"
|
||||
| "microsoftFoundry"
|
||||
| "openRouter"
|
||||
| "vercelAiGateway"
|
||||
| "custom";
|
||||
|
||||
export type AiProviderAuthType = "bearer";
|
||||
export type AiBudgetUnit = "usd" | "tokens";
|
||||
export type AiProviderRoutingMode = "url" | "target";
|
||||
|
||||
type AiProviderDefaults = {
|
||||
upstreamUrl: string | null;
|
||||
authType: AiProviderAuthType;
|
||||
};
|
||||
|
||||
export const AI_PROVIDER_DEFAULTS: Record<
|
||||
Exclude<AiProviderType, "custom">,
|
||||
AiProviderDefaults
|
||||
> = {
|
||||
openai: {
|
||||
upstreamUrl: "https://api.openai.com/v1",
|
||||
authType: "bearer"
|
||||
},
|
||||
anthropic: {
|
||||
upstreamUrl: "https://api.anthropic.com",
|
||||
authType: "bearer"
|
||||
},
|
||||
googleGemini: {
|
||||
upstreamUrl: "https://generativelanguage.googleapis.com/v1beta/openai/",
|
||||
authType: "bearer"
|
||||
},
|
||||
vertexAi: {
|
||||
upstreamUrl: null,
|
||||
authType: "bearer"
|
||||
},
|
||||
bedrock: {
|
||||
upstreamUrl: "https://bedrock-runtime.us-east-1.amazonaws.com",
|
||||
authType: "bearer"
|
||||
},
|
||||
microsoftFoundry: {
|
||||
upstreamUrl: null,
|
||||
authType: "bearer"
|
||||
},
|
||||
openRouter: {
|
||||
upstreamUrl: "https://openrouter.ai/api/v1",
|
||||
authType: "bearer"
|
||||
},
|
||||
vercelAiGateway: {
|
||||
upstreamUrl: "https://ai-gateway.vercel.sh/v1",
|
||||
authType: "bearer"
|
||||
}
|
||||
};
|
||||
|
||||
export function providerRequiresUpstreamUrl(
|
||||
type: AiProviderType,
|
||||
routingMode: AiProviderRoutingMode = "url"
|
||||
): boolean {
|
||||
if (routingMode === "target") {
|
||||
return false;
|
||||
}
|
||||
if (type === "custom") {
|
||||
return true;
|
||||
}
|
||||
return AI_PROVIDER_DEFAULTS[type].upstreamUrl === null;
|
||||
}
|
||||
|
||||
export function resolveAiProviderConfig(input: {
|
||||
type: AiProviderType;
|
||||
upstreamUrl: string | null;
|
||||
authType: AiProviderAuthType | null;
|
||||
routingMode?: AiProviderRoutingMode | null;
|
||||
}): {
|
||||
upstreamUrl: string | null;
|
||||
authType: AiProviderAuthType | null;
|
||||
routingMode: AiProviderRoutingMode;
|
||||
} {
|
||||
const routingMode = input.routingMode ?? "url";
|
||||
|
||||
if (routingMode === "target") {
|
||||
return {
|
||||
upstreamUrl: null,
|
||||
authType: input.authType ?? "bearer",
|
||||
routingMode
|
||||
};
|
||||
}
|
||||
|
||||
if (input.type === "custom") {
|
||||
return {
|
||||
upstreamUrl: input.upstreamUrl,
|
||||
authType: input.authType,
|
||||
routingMode
|
||||
};
|
||||
}
|
||||
|
||||
const defaults = AI_PROVIDER_DEFAULTS[input.type];
|
||||
return {
|
||||
upstreamUrl: input.upstreamUrl ?? defaults.upstreamUrl,
|
||||
authType: input.authType ?? defaults.authType,
|
||||
routingMode
|
||||
};
|
||||
}
|
||||
@@ -202,10 +202,6 @@ async function handleResource(
|
||||
return;
|
||||
}
|
||||
|
||||
if (!target.resourceId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const [resource] = await trx
|
||||
.select()
|
||||
.from(resources)
|
||||
@@ -231,7 +227,9 @@ async function handleResource(
|
||||
|
||||
let health = "healthy";
|
||||
const allUnknown = monitoredTargets.length === 0;
|
||||
const allHealthy = monitoredTargets.every((t) => t.hcHealth === "healthy");
|
||||
const allHealthy = monitoredTargets.every(
|
||||
(t) => t.hcHealth === "healthy"
|
||||
);
|
||||
const allUnhealthy = monitoredTargets.every(
|
||||
(t) => t.hcHealth === "unhealthy"
|
||||
);
|
||||
|
||||
@@ -339,6 +339,19 @@ export async function calculateUserClientsForOrgs(
|
||||
continue;
|
||||
}
|
||||
|
||||
// Get exit nodes for this org
|
||||
const exitNodesList = await getExitNodes(orgId);
|
||||
|
||||
if (exitNodesList.length === 0) {
|
||||
logger.warn(
|
||||
`Skipping org ${orgId} for OLM ${olm.olmId} (user ${userId}): no exit nodes found`
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
const randomExitNode =
|
||||
exitNodesList[Math.floor(Math.random() * exitNodesList.length)];
|
||||
|
||||
// Get next available subnet
|
||||
const { value: newSubnet, release: releaseSubnetLock } =
|
||||
await getNextAvailableClientSubnet(orgId, trx);
|
||||
@@ -357,6 +370,7 @@ export async function calculateUserClientsForOrgs(
|
||||
const newClientData: InferInsertModel<typeof clients> = {
|
||||
userId,
|
||||
orgId: userOrg.orgId,
|
||||
exitNodeId: randomExitNode.exitNodeId,
|
||||
name: olm.name || "User Client",
|
||||
subnet: updatedSubnet,
|
||||
olmId: olm.olmId,
|
||||
|
||||
@@ -64,20 +64,13 @@ export async function performDeleteResources(
|
||||
|
||||
const targetsByResourceId = new Map<number, Target[]>();
|
||||
for (const target of targetsToBeRemoved) {
|
||||
if (target.resourceId == null) {
|
||||
continue;
|
||||
}
|
||||
const existing = targetsByResourceId.get(target.resourceId) ?? [];
|
||||
existing.push(target);
|
||||
targetsByResourceId.set(target.resourceId, existing);
|
||||
}
|
||||
|
||||
const targetIdToResourceId = new Map(
|
||||
targetsToBeRemoved.flatMap((target) =>
|
||||
target.resourceId == null
|
||||
? []
|
||||
: [[target.targetId, target.resourceId] as const]
|
||||
)
|
||||
targetsToBeRemoved.map((target) => [target.targetId, target.resourceId])
|
||||
);
|
||||
|
||||
const healthChecksByResourceId = new Map<number, TargetHealthCheck[]>();
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { and, eq, inArray, isNotNull, sql } from "drizzle-orm";
|
||||
import { and, eq, inArray, sql } from "drizzle-orm";
|
||||
import {
|
||||
db,
|
||||
resources,
|
||||
@@ -33,11 +33,9 @@ export async function getResourceIdsForSite(
|
||||
const rows = await trx
|
||||
.selectDistinct({ resourceId: targets.resourceId })
|
||||
.from(targets)
|
||||
.where(and(eq(targets.siteId, siteId), isNotNull(targets.resourceId)));
|
||||
.where(eq(targets.siteId, siteId));
|
||||
|
||||
return rows
|
||||
.map((row) => row.resourceId)
|
||||
.filter((resourceId): resourceId is number => resourceId != null);
|
||||
return rows.map((row) => row.resourceId);
|
||||
}
|
||||
|
||||
export async function getSiteResourceIdsForSite(
|
||||
|
||||
@@ -1,40 +0,0 @@
|
||||
import { db, sites, clients } from "@server/db";
|
||||
import { and, eq, count } from "drizzle-orm";
|
||||
|
||||
// (MAX_CONNECTIONS - current_connections) / MAX_CONNECTIONS)
|
||||
// higher = more desirable
|
||||
// like saying, this node has x% of its capacity left
|
||||
export async function calculateExitNodeWeight(
|
||||
exitNodeId: number,
|
||||
maxConnections: number | null | undefined
|
||||
): Promise<number | null> {
|
||||
if (maxConnections === null || maxConnections === undefined) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
const [[siteConnections], [clientConnections]] = await Promise.all([
|
||||
db
|
||||
.select({ count: count() })
|
||||
.from(sites)
|
||||
.where(
|
||||
and(eq(sites.exitNodeId, exitNodeId), eq(sites.online, true))
|
||||
),
|
||||
db
|
||||
.select({ count: count() })
|
||||
.from(clients)
|
||||
.where(
|
||||
and(
|
||||
eq(clients.exitNodeId, exitNodeId),
|
||||
eq(clients.online, true)
|
||||
)
|
||||
)
|
||||
]);
|
||||
|
||||
const currentConnections = siteConnections.count + clientConnections.count;
|
||||
|
||||
if (currentConnections >= maxConnections) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (maxConnections - currentConnections) / maxConnections;
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { db, exitNodes, Transaction } from "@server/db";
|
||||
import logger from "@server/logger";
|
||||
import { ExitNodePingResult } from "@server/routers/newt";
|
||||
import { eq } from "drizzle-orm";
|
||||
|
||||
export async function verifyExitNodeOrgAccess(
|
||||
@@ -51,16 +52,6 @@ export async function listExitNodes(
|
||||
return allExitNodes;
|
||||
}
|
||||
|
||||
export type ExitNodePingResult = {
|
||||
exitNodeId: number;
|
||||
latencyMs: number;
|
||||
weight: number;
|
||||
error?: string;
|
||||
exitNodeName: string;
|
||||
endpoint: string;
|
||||
wasPreviouslyConnected: boolean;
|
||||
};
|
||||
|
||||
export function selectBestExitNode(
|
||||
pingResults: ExitNodePingResult[]
|
||||
): ExitNodePingResult | null {
|
||||
|
||||
@@ -1,41 +0,0 @@
|
||||
import { db, ExitNode, Transaction, sites, clients } from "@server/db";
|
||||
import { eq } from "drizzle-orm";
|
||||
import config from "@server/lib/config";
|
||||
import { findNextAvailableCidr } from "@server/lib/ip";
|
||||
import { lockManager } from "#dynamic/lib/lock";
|
||||
|
||||
export async function getUniqueSubnetForExitNode(
|
||||
exitNode: ExitNode,
|
||||
trx: Transaction | typeof db = db
|
||||
): Promise<string | null> {
|
||||
const lockKey = `subnet-allocation:${exitNode.exitNodeId}`;
|
||||
|
||||
return await lockManager.withLock(
|
||||
lockKey,
|
||||
async () => {
|
||||
const [sitesQuery, clientsQuery] = await Promise.all([
|
||||
trx
|
||||
.select({ subnet: sites.exitNodeSubnet })
|
||||
.from(sites)
|
||||
.where(eq(sites.exitNodeId, exitNode.exitNodeId)),
|
||||
trx
|
||||
.select({ subnet: clients.exitNodeSubnet })
|
||||
.from(clients)
|
||||
.where(eq(clients.exitNodeId, exitNode.exitNodeId))
|
||||
]);
|
||||
|
||||
const blockSize = config.getRawConfig().gerbil.site_block_size;
|
||||
const subnets = [...sitesQuery, ...clientsQuery]
|
||||
.map((row) => row.subnet)
|
||||
.filter(
|
||||
(subnet): subnet is string =>
|
||||
!!subnet &&
|
||||
/^(\d{1,3}\.){3}\d{1,3}\/\d{1,2}$/.test(subnet)
|
||||
);
|
||||
subnets.push(exitNode.address.replace(/\/\d+$/, `/${blockSize}`));
|
||||
|
||||
return findNextAvailableCidr(subnets, blockSize, exitNode.address);
|
||||
},
|
||||
5000 // 5 second lock TTL - subnet allocation should be quick
|
||||
);
|
||||
}
|
||||
@@ -2,5 +2,3 @@ export * from "./exitNodes";
|
||||
export * from "./exitNodeComms";
|
||||
export * from "./subnet";
|
||||
export * from "./getCurrentExitNodeId";
|
||||
export * from "./calculateExitNodeWeight";
|
||||
export * from "./getUniqueSubnetForExitNode";
|
||||
|
||||
+8
-21
@@ -528,10 +528,7 @@ export function generateRemoteSubnets(
|
||||
|
||||
export type Alias = { alias: string | null; aliasAddress: string | null };
|
||||
|
||||
export function generateAliasConfig(
|
||||
allSiteResources: SiteResource[],
|
||||
overrideIp?: string
|
||||
): Alias[] {
|
||||
export function generateAliasConfig(allSiteResources: SiteResource[]): Alias[] {
|
||||
return allSiteResources
|
||||
.filter(
|
||||
(sr) =>
|
||||
@@ -542,7 +539,7 @@ export function generateAliasConfig(
|
||||
)
|
||||
.map((sr) => ({
|
||||
alias: sr.alias || sr.fullDomain,
|
||||
aliasAddress: overrideIp || sr.aliasAddress
|
||||
aliasAddress: sr.aliasAddress
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -663,10 +660,9 @@ export type CertRef = { id: string; cert: string; key: string };
|
||||
* certificate (e.g. a wildcard cert used by thousands of site resources)
|
||||
* only need that certificate sent once per sync message.
|
||||
*/
|
||||
export function dedupeCertsForTargets(targetsV2: SubnetProxyTargetV2[]): {
|
||||
targets: SubnetProxyTargetV2[];
|
||||
certs: CertRef[];
|
||||
} {
|
||||
export function dedupeCertsForTargets(
|
||||
targetsV2: SubnetProxyTargetV2[]
|
||||
): { targets: SubnetProxyTargetV2[]; certs: CertRef[] } {
|
||||
const idByContent = new Map<string, string>();
|
||||
const certs: CertRef[] = [];
|
||||
|
||||
@@ -678,10 +674,7 @@ export function dedupeCertsForTargets(targetsV2: SubnetProxyTargetV2[]): {
|
||||
const contentKey = `${target.tlsCert}|${target.tlsKey}`;
|
||||
let id = idByContent.get(contentKey);
|
||||
if (!id) {
|
||||
id = createHash("sha1")
|
||||
.update(contentKey)
|
||||
.digest("hex")
|
||||
.slice(0, 16);
|
||||
id = createHash("sha1").update(contentKey).digest("hex").slice(0, 16);
|
||||
idByContent.set(contentKey, id);
|
||||
certs.push({ id, cert: target.tlsCert, key: target.tlsKey });
|
||||
}
|
||||
@@ -715,9 +708,7 @@ export async function batchFetchCertsForSiteResources(
|
||||
): Promise<CertByDomain> {
|
||||
const domains = new Set(
|
||||
allSiteResources
|
||||
.filter(
|
||||
(r) => r.enabled && r.mode === "http" && r.ssl && r.fullDomain
|
||||
)
|
||||
.filter((r) => r.enabled && r.mode === "http" && r.ssl && r.fullDomain)
|
||||
.map((r) => r.fullDomain as string)
|
||||
);
|
||||
|
||||
@@ -861,11 +852,7 @@ export async function generateSubnetProxyTargetV2(
|
||||
new Set([siteResource.fullDomain]),
|
||||
true
|
||||
);
|
||||
if (
|
||||
certs.length > 0 &&
|
||||
certs[0].certFile &&
|
||||
certs[0].keyFile
|
||||
) {
|
||||
if (certs.length > 0 && certs[0].certFile && certs[0].keyFile) {
|
||||
tlsCert = certs[0].certFile;
|
||||
tlsKey = certs[0].keyFile;
|
||||
} else {
|
||||
|
||||
@@ -19,7 +19,7 @@ import {
|
||||
userOrgRoles,
|
||||
userSiteResources
|
||||
} from "@server/db";
|
||||
import { and, count, eq, inArray, isNotNull, ne } from "drizzle-orm";
|
||||
import { and, count, eq, inArray, ne } from "drizzle-orm";
|
||||
|
||||
import { deletePeersBatch as newtDeletePeersBatch } from "@server/routers/newt/peers";
|
||||
import {
|
||||
@@ -27,9 +27,6 @@ import {
|
||||
deletePeersBatch as olmDeletePeersBatch
|
||||
} from "@server/routers/olm/peers";
|
||||
import { sendToExitNode } from "#dynamic/lib/exitNodes";
|
||||
import { sendToClientsBatch } from "#dynamic/routers/ws";
|
||||
import { canCompress } from "@server/lib/clientVersionChecks";
|
||||
import config from "@server/lib/config";
|
||||
import logger from "@server/logger";
|
||||
import {
|
||||
generateAliasConfig,
|
||||
@@ -190,12 +187,7 @@ export async function getClientSiteResourceAccess(
|
||||
`rebuildClientAssociations: [getClientSiteResourceAccess] siteResourceId=${siteResource.siteResourceId} networkId=${siteResource.networkId} siteCount=${sitesList.length} siteIds=[${sitesList.map((s) => s.siteId).join(", ")}]`
|
||||
);
|
||||
|
||||
if (sitesList.length === 0 && siteResource.networkId !== null) {
|
||||
// A site resource with a networkId is expected to have at least one
|
||||
// site attached via siteNetworks. Resources with no networkId (e.g.
|
||||
// inference-mode resources, which connect clients directly to the
|
||||
// exit node instead of any site) are expected to have no sites, so
|
||||
// don't warn for those.
|
||||
if (sitesList.length === 0) {
|
||||
logger.warn(
|
||||
`No sites found for siteResource ${siteResource.siteResourceId} with networkId ${siteResource.networkId}`
|
||||
);
|
||||
@@ -695,22 +687,6 @@ async function rebuildClientAssociationsFromSiteResourceImpl(
|
||||
clientSiteResourcesToRemove,
|
||||
trx
|
||||
);
|
||||
|
||||
// If this resource requires clients to be connected to the exit node
|
||||
// (e.g. an inference resource), re-sync the connect/disconnect state for
|
||||
// every client whose access to it may have changed - both those who
|
||||
// currently have access and those who just lost it.
|
||||
if (siteResource.requiresExitNodeConnection) {
|
||||
await syncClientExitNodeConnections(
|
||||
Array.from(
|
||||
new Set([
|
||||
...mergedAllClientIds,
|
||||
...existingClientSiteResourceIds
|
||||
])
|
||||
),
|
||||
trx
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleMessagesForSiteClients(
|
||||
@@ -990,7 +966,7 @@ export async function updateClientSiteDestinations(
|
||||
.where(eq(clientSitesAssociationsCache.clientId, client.clientId));
|
||||
|
||||
for (const site of sitesData) {
|
||||
if (!site.sites.exitNodeSubnet) {
|
||||
if (!site.sites.subnet) {
|
||||
logger.debug(`Site ${site.sites.siteId} has no subnet, skipping`);
|
||||
continue;
|
||||
}
|
||||
@@ -1026,7 +1002,7 @@ export async function updateClientSiteDestinations(
|
||||
sourcePort: parsedEndpoint.port,
|
||||
destinations: [
|
||||
{
|
||||
destinationIP: site.sites.exitNodeSubnet.split("/")[0],
|
||||
destinationIP: site.sites.subnet.split("/")[0],
|
||||
destinationPort: site.sites.listenPort || 1 // this satisfies gerbil for now but should be reevaluated
|
||||
}
|
||||
]
|
||||
@@ -1034,7 +1010,7 @@ export async function updateClientSiteDestinations(
|
||||
} else {
|
||||
// add to the existing destinations
|
||||
destinations.destinations.push({
|
||||
destinationIP: site.sites.exitNodeSubnet.split("/")[0],
|
||||
destinationIP: site.sites.subnet.split("/")[0],
|
||||
destinationPort: site.sites.listenPort || 1 // this satisfies gerbil for now but should be reevaluated
|
||||
});
|
||||
}
|
||||
@@ -1076,236 +1052,6 @@ export async function updateClientSiteDestinations(
|
||||
}
|
||||
}
|
||||
|
||||
// Determines, for each of the given clients, whether they currently have
|
||||
// access to any enabled site resource with requiresExitNodeConnection set
|
||||
// (e.g. an inference-mode resource) and tells the client's olm to connect to
|
||||
// or disconnect from its assigned exit node accordingly. Site resources with
|
||||
// requiresExitNodeConnection don't belong to any site/network, so this can't
|
||||
// be derived from the per-site peer logic above - it has to be recomputed
|
||||
// from the client's full current resource access every time that access
|
||||
// changes.
|
||||
async function syncClientExitNodeConnections(
|
||||
clientIds: number[],
|
||||
trx: Transaction | typeof db = db
|
||||
): Promise<void> {
|
||||
const uniqueClientIds = Array.from(new Set(clientIds));
|
||||
if (uniqueClientIds.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Only clients with an exit node assigned can be told to connect/disconnect.
|
||||
const clientsData = await trx
|
||||
.select({
|
||||
clientId: clients.clientId,
|
||||
exitNodeId: clients.exitNodeId,
|
||||
exitNodeSubnet: clients.exitNodeSubnet
|
||||
})
|
||||
.from(clients)
|
||||
.where(
|
||||
and(
|
||||
inArray(clients.clientId, uniqueClientIds),
|
||||
isNotNull(clients.exitNodeId)
|
||||
)
|
||||
);
|
||||
|
||||
if (clientsData.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const clientIdsWithExitNode = clientsData.map((c) => c.clientId);
|
||||
|
||||
const requiresExitNodeRows = await trx
|
||||
.select({
|
||||
clientId: clientSiteResourcesAssociationsCache.clientId
|
||||
})
|
||||
.from(clientSiteResourcesAssociationsCache)
|
||||
.innerJoin(
|
||||
siteResources,
|
||||
eq(
|
||||
clientSiteResourcesAssociationsCache.siteResourceId,
|
||||
siteResources.siteResourceId
|
||||
)
|
||||
)
|
||||
.where(
|
||||
and(
|
||||
inArray(
|
||||
clientSiteResourcesAssociationsCache.clientId,
|
||||
clientIdsWithExitNode
|
||||
),
|
||||
eq(siteResources.enabled, true),
|
||||
eq(siteResources.requiresExitNodeConnection, true)
|
||||
)
|
||||
);
|
||||
|
||||
const needsConnectSet = new Set(
|
||||
requiresExitNodeRows.map((r) => r.clientId)
|
||||
);
|
||||
|
||||
const exitNodeIds = Array.from(
|
||||
new Set(
|
||||
clientsData
|
||||
.map((c) => c.exitNodeId)
|
||||
.filter((id): id is number => id !== null)
|
||||
)
|
||||
);
|
||||
|
||||
const exitNodeRows =
|
||||
exitNodeIds.length > 0
|
||||
? await trx
|
||||
.select()
|
||||
.from(exitNodes)
|
||||
.where(inArray(exitNodes.exitNodeId, exitNodeIds))
|
||||
: [];
|
||||
const exitNodeById = new Map(exitNodeRows.map((n) => [n.exitNodeId, n]));
|
||||
|
||||
const olmRows = await trx
|
||||
.select({
|
||||
clientId: olms.clientId,
|
||||
olmId: olms.olmId,
|
||||
version: olms.version
|
||||
})
|
||||
.from(olms)
|
||||
.where(inArray(olms.clientId, clientIdsWithExitNode));
|
||||
const olmByClientId = new Map(
|
||||
olmRows
|
||||
.filter((r) => r.clientId !== null)
|
||||
.map((r) => [r.clientId as number, r])
|
||||
);
|
||||
|
||||
const relayPort = config.getRawConfig().gerbil.clients_start_port;
|
||||
|
||||
const connectPayloads: {
|
||||
clientId: string;
|
||||
message: { type: string; data: any };
|
||||
options: { compress: boolean };
|
||||
}[] = [];
|
||||
const disconnectPayloads: {
|
||||
clientId: string;
|
||||
message: { type: string; data: any };
|
||||
options: { compress: boolean };
|
||||
}[] = [];
|
||||
|
||||
for (const client of clientsData) {
|
||||
const olm = olmByClientId.get(client.clientId);
|
||||
if (!olm) {
|
||||
// No olm registered for this client yet/anymore, nothing to send.
|
||||
continue;
|
||||
}
|
||||
|
||||
const needsConnect = needsConnectSet.has(client.clientId);
|
||||
|
||||
if (needsConnect) {
|
||||
const exitNode = client.exitNodeId
|
||||
? exitNodeById.get(client.exitNodeId)
|
||||
: undefined;
|
||||
if (!exitNode || !client.exitNodeSubnet) {
|
||||
logger.warn(
|
||||
`rebuildClientAssociations: [syncClientExitNodeConnections] client ${client.clientId} needs an exit node connection but has no exit node or subnet assigned`
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
connectPayloads.push({
|
||||
clientId: olm.olmId,
|
||||
message: {
|
||||
type: "olm/wg/exitnode/connect",
|
||||
data: {
|
||||
connect: true,
|
||||
endpoint: `${exitNode.endpoint}:${exitNode.listenPort}`,
|
||||
relayPort,
|
||||
publicKey: exitNode.publicKey,
|
||||
serverIP: exitNode.address.split("/")[0],
|
||||
tunnelIP: client.exitNodeSubnet.split("/")[0]
|
||||
}
|
||||
},
|
||||
options: { compress: canCompress(olm.version, "olm") }
|
||||
});
|
||||
} else {
|
||||
disconnectPayloads.push({
|
||||
clientId: olm.olmId,
|
||||
message: {
|
||||
type: "olm/wg/exitnode/disconnect",
|
||||
data: {}
|
||||
},
|
||||
options: { compress: canCompress(olm.version, "olm") }
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (connectPayloads.length > 0) {
|
||||
await sendToClientsBatch(connectPayloads).catch((error) => {
|
||||
logger.error(
|
||||
`rebuildClientAssociations: Error sending exit node connect messages:`,
|
||||
error
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
if (disconnectPayloads.length > 0) {
|
||||
await sendToClientsBatch(disconnectPayloads).catch((error) => {
|
||||
logger.error(
|
||||
`rebuildClientAssociations: Error sending exit node disconnect messages:`,
|
||||
error
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Notifies the olms of every given client that the alias of the site resource
|
||||
// they're using an exit node connection for has changed, via the dedicated
|
||||
// exit node data-update message. Unlike syncClientExitNodeConnections, this
|
||||
// doesn't touch connect/disconnect state - it's purely a rename for clients
|
||||
// that are (and remain) connected to the exit node for this resource.
|
||||
async function syncClientExitNodeAliasUpdate(
|
||||
clientIds: number[],
|
||||
oldAlias: string | null,
|
||||
newAlias: string | null,
|
||||
trx: Transaction | typeof db = db
|
||||
): Promise<void> {
|
||||
const uniqueClientIds = Array.from(new Set(clientIds));
|
||||
if (uniqueClientIds.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const oldAliases = oldAlias ? [oldAlias] : [];
|
||||
const newAliases = newAlias ? [newAlias] : [];
|
||||
if (oldAliases.length === 0 && newAliases.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const olmRows = await trx
|
||||
.select({
|
||||
clientId: olms.clientId,
|
||||
olmId: olms.olmId,
|
||||
version: olms.version
|
||||
})
|
||||
.from(olms)
|
||||
.where(inArray(olms.clientId, uniqueClientIds));
|
||||
|
||||
const updatePayloads = olmRows
|
||||
.filter((r) => r.clientId !== null)
|
||||
.map((olm) => ({
|
||||
clientId: olm.olmId,
|
||||
message: {
|
||||
type: "olm/wg/exitnode/data/update",
|
||||
data: {
|
||||
oldAliases,
|
||||
newAliases
|
||||
}
|
||||
},
|
||||
options: { compress: canCompress(olm.version, "olm") }
|
||||
}));
|
||||
|
||||
if (updatePayloads.length > 0) {
|
||||
await sendToClientsBatch(updatePayloads).catch((error) => {
|
||||
logger.error(
|
||||
`rebuildClientAssociations: Error sending exit node alias update messages:`,
|
||||
error
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSubnetProxyTargetUpdates(
|
||||
siteResource: SiteResource,
|
||||
sitesList: Site[],
|
||||
@@ -1536,7 +1282,7 @@ export async function handleMessagingForUpdatedSiteResource(
|
||||
`handleMessagingForUpdatedSiteResource: fetched newts for ${newtsForSites.length}/${allSiteIds.length} site(s)`
|
||||
);
|
||||
|
||||
// !!!!!!!!!!!!!!!!!! WARNING: THIS RELIES ON THE CACHE TABLES BEING UP TO DATE, SO CALL THIS AFTER THE ASSOCIATION CACHE IS UPDATED !!!!!!!!!!!!!!!!!!
|
||||
// WARNING: THIS RELIES ON THE CACHE TABLES BEING UP TO DATE, SO CALL THIS AFTER THE ASSOCIATION CACHE IS UPDATED
|
||||
const mergedAllClients = await trx
|
||||
.select({
|
||||
clientId: clientSiteResourcesAssociationsCache.clientId,
|
||||
@@ -1963,38 +1709,6 @@ export async function handleMessagingForUpdatedSiteResource(
|
||||
);
|
||||
}
|
||||
|
||||
// For a resource that stays on an exit node connection across the update,
|
||||
// the alias is the only field that affects already-connected clients (the
|
||||
// exit node itself, its endpoint, etc. are not per-resource). Tell those
|
||||
// clients' olms about the rename directly via the exit node data-update
|
||||
// message rather than a full connect/disconnect cycle.
|
||||
if (
|
||||
existingSiteResource?.requiresExitNodeConnection &&
|
||||
updatedSiteResource.requiresExitNodeConnection &&
|
||||
aliasChanged
|
||||
) {
|
||||
await syncClientExitNodeAliasUpdate(
|
||||
mergedAllClients.map((c) => c.clientId),
|
||||
existingSiteResource.alias,
|
||||
updatedSiteResource.alias,
|
||||
trx
|
||||
);
|
||||
}
|
||||
|
||||
// If this resource requires (or required) clients to be connected to the
|
||||
// exit node (e.g. an inference resource), re-sync connect/disconnect
|
||||
// state for every client currently associated with it - covers toggling
|
||||
// requiresExitNodeConnection on update as well as enabling/disabling it.
|
||||
if (
|
||||
updatedSiteResource.requiresExitNodeConnection ||
|
||||
existingSiteResource?.requiresExitNodeConnection
|
||||
) {
|
||||
await syncClientExitNodeConnections(
|
||||
mergedAllClients.map((c) => c.clientId),
|
||||
trx
|
||||
);
|
||||
}
|
||||
|
||||
logger.debug(
|
||||
`handleMessagingForUpdatedSiteResource: DONE siteResourceId=${updatedSiteResource.siteResourceId}`
|
||||
);
|
||||
@@ -2276,10 +1990,6 @@ async function rebuildClientAssociationsFromClientImpl(
|
||||
resourcesToRemove,
|
||||
trx
|
||||
);
|
||||
|
||||
// Re-sync exit node connect/disconnect state based on this client's
|
||||
// current full set of resource access (e.g. inference resources).
|
||||
await syncClientExitNodeConnections([client.clientId], trx);
|
||||
}
|
||||
|
||||
async function handleMessagesForClientSites(
|
||||
|
||||
@@ -87,7 +87,7 @@ export async function getTraefikConfig(
|
||||
siteId: sites.siteId,
|
||||
siteType: sites.type,
|
||||
siteOnline: sites.online,
|
||||
subnet: sites.exitNodeSubnet,
|
||||
subnet: sites.subnet,
|
||||
exitNodeId: sites.exitNodeId,
|
||||
// Domain cert resolver fields
|
||||
domainCertResolver: domains.certResolver,
|
||||
|
||||
@@ -27,8 +27,6 @@ export * from "./verifyUserHasAction";
|
||||
export * from "./verifyApiKeyAccess";
|
||||
export * from "./verifySiteProvisioningKeyAccess";
|
||||
export * from "./verifyDomainAccess";
|
||||
export * from "./verifyAiProviderAccess";
|
||||
export * from "./verifyAiModelAccess";
|
||||
export * from "./verifyUserIsOrgOwner";
|
||||
export * from "./verifyUserFromResourceSession";
|
||||
export * from "./verifySiteResourceAccess";
|
||||
|
||||
@@ -16,7 +16,5 @@ export * from "./verifyApiKeyClientAccess";
|
||||
export * from "./verifyApiKeySiteResourceAccess";
|
||||
export * from "./verifyApiKeyIdpAccess";
|
||||
export * from "./verifyApiKeyDomainAccess";
|
||||
export * from "./verifyApiKeyAiProviderAccess";
|
||||
export * from "./verifyApiKeyAiModelAccess";
|
||||
export * from "./verifyApiKeyResourcePolicyAccess";
|
||||
export * from "./verifyApiKeySiteProvisioningKeyAccess";
|
||||
|
||||
@@ -1,94 +0,0 @@
|
||||
import { Request, Response, NextFunction } from "express";
|
||||
import { aiModels, aiProviders, apiKeyOrg, db } from "@server/db";
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import createHttpError from "http-errors";
|
||||
import HttpCode from "@server/types/HttpCode";
|
||||
import { getFirstString } from "@server/lib/requestParams";
|
||||
|
||||
export async function verifyApiKeyAiModelAccess(
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction
|
||||
) {
|
||||
try {
|
||||
const apiKey = req.apiKey;
|
||||
const modelIdRaw = getFirstString(req.params.modelId);
|
||||
const modelId = Number.parseInt(modelIdRaw ?? "", 10);
|
||||
|
||||
if (!apiKey) {
|
||||
return next(
|
||||
createHttpError(HttpCode.UNAUTHORIZED, "Key not authenticated")
|
||||
);
|
||||
}
|
||||
|
||||
if (Number.isNaN(modelId)) {
|
||||
return next(
|
||||
createHttpError(HttpCode.BAD_REQUEST, "Invalid model ID")
|
||||
);
|
||||
}
|
||||
|
||||
const [row] = await db
|
||||
.select({
|
||||
model: aiModels,
|
||||
provider: aiProviders
|
||||
})
|
||||
.from(aiModels)
|
||||
.innerJoin(
|
||||
aiProviders,
|
||||
eq(aiModels.providerId, aiProviders.providerId)
|
||||
)
|
||||
.where(eq(aiModels.modelId, modelId))
|
||||
.limit(1);
|
||||
|
||||
if (!row) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.NOT_FOUND,
|
||||
`AI model with ID ${modelId} not found`
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
if (apiKey.isRoot) {
|
||||
req.aiProvider = row.provider;
|
||||
req.aiModel = row.model;
|
||||
return next();
|
||||
}
|
||||
|
||||
const orgId = row.provider.orgId;
|
||||
|
||||
if (!req.apiKeyOrg || req.apiKeyOrg.orgId !== orgId) {
|
||||
const apiKeyOrgRes = await db
|
||||
.select()
|
||||
.from(apiKeyOrg)
|
||||
.where(
|
||||
and(
|
||||
eq(apiKeyOrg.apiKeyId, apiKey.apiKeyId),
|
||||
eq(apiKeyOrg.orgId, orgId)
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
req.apiKeyOrg = apiKeyOrgRes[0];
|
||||
}
|
||||
|
||||
if (!req.apiKeyOrg) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.FORBIDDEN,
|
||||
"Key does not have access to this organization"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
req.aiProvider = row.provider;
|
||||
req.aiModel = row.model;
|
||||
return next();
|
||||
} catch (error) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.INTERNAL_SERVER_ERROR,
|
||||
"Error verifying AI model access"
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,85 +0,0 @@
|
||||
import { Request, Response, NextFunction } from "express";
|
||||
import { aiProviders, apiKeyOrg, db } from "@server/db";
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import createHttpError from "http-errors";
|
||||
import HttpCode from "@server/types/HttpCode";
|
||||
import { getFirstString } from "@server/lib/requestParams";
|
||||
|
||||
export async function verifyApiKeyAiProviderAccess(
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction
|
||||
) {
|
||||
try {
|
||||
const apiKey = req.apiKey;
|
||||
const providerIdRaw = getFirstString(req.params.providerId);
|
||||
const providerId = Number.parseInt(providerIdRaw ?? "", 10);
|
||||
|
||||
if (!apiKey) {
|
||||
return next(
|
||||
createHttpError(HttpCode.UNAUTHORIZED, "Key not authenticated")
|
||||
);
|
||||
}
|
||||
|
||||
if (Number.isNaN(providerId)) {
|
||||
return next(
|
||||
createHttpError(HttpCode.BAD_REQUEST, "Invalid provider ID")
|
||||
);
|
||||
}
|
||||
|
||||
const [provider] = await db
|
||||
.select()
|
||||
.from(aiProviders)
|
||||
.where(eq(aiProviders.providerId, providerId))
|
||||
.limit(1);
|
||||
|
||||
if (!provider) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.NOT_FOUND,
|
||||
`AI provider with ID ${providerId} not found`
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
if (apiKey.isRoot) {
|
||||
req.aiProvider = provider;
|
||||
return next();
|
||||
}
|
||||
|
||||
const orgId = provider.orgId;
|
||||
|
||||
if (!req.apiKeyOrg || req.apiKeyOrg.orgId !== orgId) {
|
||||
const apiKeyOrgRes = await db
|
||||
.select()
|
||||
.from(apiKeyOrg)
|
||||
.where(
|
||||
and(
|
||||
eq(apiKeyOrg.apiKeyId, apiKey.apiKeyId),
|
||||
eq(apiKeyOrg.orgId, orgId)
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
req.apiKeyOrg = apiKeyOrgRes[0];
|
||||
}
|
||||
|
||||
if (!req.apiKeyOrg) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.FORBIDDEN,
|
||||
"Key does not have access to this organization"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
req.aiProvider = provider;
|
||||
return next();
|
||||
} catch (error) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.INTERNAL_SERVER_ERROR,
|
||||
"Error verifying AI provider access"
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Request, Response, NextFunction } from "express";
|
||||
import { db } from "@server/db";
|
||||
import { aiProviders, resources, targets, apiKeyOrg } from "@server/db";
|
||||
import { resources, targets, apiKeyOrg } from "@server/db";
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import createHttpError from "http-errors";
|
||||
import HttpCode from "@server/types/HttpCode";
|
||||
@@ -43,65 +43,43 @@ export async function verifyApiKeyTargetAccess(
|
||||
);
|
||||
}
|
||||
|
||||
const { resourceId, providerId } = target;
|
||||
if ((!resourceId && !providerId) || (resourceId && providerId)) {
|
||||
const resourceId = target.resourceId;
|
||||
if (!resourceId) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.INTERNAL_SERVER_ERROR,
|
||||
`Target with ID ${targetId} has invalid ownership`
|
||||
`Target with ID ${targetId} does not have a resource ID`
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const [resource] = await db
|
||||
.select()
|
||||
.from(resources)
|
||||
.where(eq(resources.resourceId, resourceId))
|
||||
.limit(1);
|
||||
|
||||
if (!resource) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.NOT_FOUND,
|
||||
`Resource with ID ${resourceId} not found`
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
if (apiKey.isRoot) {
|
||||
// Root keys can access any target
|
||||
// Root keys can access any key in any org
|
||||
return next();
|
||||
}
|
||||
|
||||
let orgId: string;
|
||||
if (resourceId) {
|
||||
const [resource] = await db
|
||||
.select()
|
||||
.from(resources)
|
||||
.where(eq(resources.resourceId, resourceId))
|
||||
.limit(1);
|
||||
|
||||
if (!resource) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.NOT_FOUND,
|
||||
`Resource with ID ${resourceId} not found`
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
if (!resource.orgId) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.INTERNAL_SERVER_ERROR,
|
||||
`Resource with ID ${resourceId} does not have an organization ID`
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
orgId = resource.orgId;
|
||||
} else {
|
||||
const [provider] = await db
|
||||
.select()
|
||||
.from(aiProviders)
|
||||
.where(eq(aiProviders.providerId, providerId!))
|
||||
.limit(1);
|
||||
|
||||
if (!provider) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.NOT_FOUND,
|
||||
`AI provider with ID ${providerId} not found`
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
orgId = provider.orgId;
|
||||
if (!resource.orgId) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.INTERNAL_SERVER_ERROR,
|
||||
`Resource with ID ${resourceId} does not have an organization ID`
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
if (!req.apiKeyOrg) {
|
||||
@@ -111,7 +89,7 @@ export async function verifyApiKeyTargetAccess(
|
||||
.where(
|
||||
and(
|
||||
eq(apiKeyOrg.apiKeyId, apiKey.apiKeyId),
|
||||
eq(apiKeyOrg.orgId, orgId)
|
||||
eq(apiKeyOrg.orgId, resource.orgId)
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
@@ -120,7 +98,7 @@ export async function verifyApiKeyTargetAccess(
|
||||
}
|
||||
}
|
||||
|
||||
if (!req.apiKeyOrg || req.apiKeyOrg.orgId !== orgId) {
|
||||
if (!req.apiKeyOrg) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.FORBIDDEN,
|
||||
|
||||
@@ -1,107 +0,0 @@
|
||||
import { Request, Response, NextFunction } from "express";
|
||||
import { aiModels, aiProviders, db, 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";
|
||||
import { getFirstString } from "@server/lib/requestParams";
|
||||
|
||||
export async function verifyAiModelAccess(
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction
|
||||
) {
|
||||
try {
|
||||
const userId = req.user!.userId;
|
||||
const modelIdRaw = getFirstString(req.params.modelId);
|
||||
const modelId = Number.parseInt(modelIdRaw ?? "", 10);
|
||||
|
||||
if (!userId) {
|
||||
return next(
|
||||
createHttpError(HttpCode.UNAUTHORIZED, "User not authenticated")
|
||||
);
|
||||
}
|
||||
|
||||
if (Number.isNaN(modelId)) {
|
||||
return next(
|
||||
createHttpError(HttpCode.BAD_REQUEST, "Invalid model ID")
|
||||
);
|
||||
}
|
||||
|
||||
const [row] = await db
|
||||
.select({
|
||||
model: aiModels,
|
||||
provider: aiProviders
|
||||
})
|
||||
.from(aiModels)
|
||||
.innerJoin(
|
||||
aiProviders,
|
||||
eq(aiModels.providerId, aiProviders.providerId)
|
||||
)
|
||||
.where(eq(aiModels.modelId, modelId))
|
||||
.limit(1);
|
||||
|
||||
if (!row) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.NOT_FOUND,
|
||||
`AI model with ID ${modelId} not found`
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const orgId = row.provider.orgId;
|
||||
|
||||
if (!req.userOrg || req.userOrg.orgId !== orgId) {
|
||||
const userOrgRole = await db
|
||||
.select()
|
||||
.from(userOrgs)
|
||||
.where(
|
||||
and(eq(userOrgs.userId, userId), eq(userOrgs.orgId, orgId))
|
||||
)
|
||||
.limit(1);
|
||||
req.userOrg = userOrgRole[0];
|
||||
}
|
||||
|
||||
if (!req.userOrg) {
|
||||
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,
|
||||
"" + (policyCheck.error || "Unknown error")
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
req.userOrgId = orgId;
|
||||
req.userOrgRoleIds = await getUserOrgRoleIds(req.userOrg.userId, orgId);
|
||||
req.aiProvider = row.provider;
|
||||
req.aiModel = row.model;
|
||||
|
||||
return next();
|
||||
} catch (error) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.INTERNAL_SERVER_ERROR,
|
||||
"Error verifying AI model access"
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,99 +0,0 @@
|
||||
import { Request, Response, NextFunction } from "express";
|
||||
import { aiProviders, db, 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";
|
||||
import { getFirstString } from "@server/lib/requestParams";
|
||||
|
||||
export async function verifyAiProviderAccess(
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction
|
||||
) {
|
||||
try {
|
||||
const userId = req.user!.userId;
|
||||
const providerIdRaw = getFirstString(req.params.providerId);
|
||||
const providerId = Number.parseInt(providerIdRaw ?? "", 10);
|
||||
|
||||
if (!userId) {
|
||||
return next(
|
||||
createHttpError(HttpCode.UNAUTHORIZED, "User not authenticated")
|
||||
);
|
||||
}
|
||||
|
||||
if (Number.isNaN(providerId)) {
|
||||
return next(
|
||||
createHttpError(HttpCode.BAD_REQUEST, "Invalid provider ID")
|
||||
);
|
||||
}
|
||||
|
||||
const [provider] = await db
|
||||
.select()
|
||||
.from(aiProviders)
|
||||
.where(eq(aiProviders.providerId, providerId))
|
||||
.limit(1);
|
||||
|
||||
if (!provider) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.NOT_FOUND,
|
||||
`AI provider with ID ${providerId} not found`
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const orgId = provider.orgId;
|
||||
|
||||
if (!req.userOrg || req.userOrg.orgId !== orgId) {
|
||||
const userOrgRole = await db
|
||||
.select()
|
||||
.from(userOrgs)
|
||||
.where(
|
||||
and(eq(userOrgs.userId, userId), eq(userOrgs.orgId, orgId))
|
||||
)
|
||||
.limit(1);
|
||||
req.userOrg = userOrgRole[0];
|
||||
}
|
||||
|
||||
if (!req.userOrg) {
|
||||
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,
|
||||
"" + (policyCheck.error || "Unknown error")
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
req.userOrgId = orgId;
|
||||
req.userOrgRoleIds = await getUserOrgRoleIds(req.userOrg.userId, orgId);
|
||||
req.aiProvider = provider;
|
||||
|
||||
return next();
|
||||
} catch (error) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.INTERNAL_SERVER_ERROR,
|
||||
"Error verifying AI provider access"
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Request, Response, NextFunction } from "express";
|
||||
import { db } from "@server/db";
|
||||
import { aiProviders, resources, targets, userOrgs } from "@server/db";
|
||||
import { resources, targets, userOrgs } from "@server/db";
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import createHttpError from "http-errors";
|
||||
import HttpCode from "@server/types/HttpCode";
|
||||
@@ -25,7 +25,9 @@ export async function verifyTargetAccess(
|
||||
}
|
||||
|
||||
if (isNaN(targetId)) {
|
||||
return next(createHttpError(HttpCode.BAD_REQUEST, "Invalid target ID"));
|
||||
return next(
|
||||
createHttpError(HttpCode.BAD_REQUEST, "Invalid organization ID")
|
||||
);
|
||||
}
|
||||
|
||||
const target = await db
|
||||
@@ -43,88 +45,73 @@ export async function verifyTargetAccess(
|
||||
);
|
||||
}
|
||||
|
||||
const { resourceId, providerId } = target[0];
|
||||
const resourceId = target[0].resourceId;
|
||||
|
||||
if ((!resourceId && !providerId) || (resourceId && providerId)) {
|
||||
if (!resourceId) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.INTERNAL_SERVER_ERROR,
|
||||
`Target with ID ${targetId} has invalid ownership`
|
||||
`Target with ID ${targetId} does not have a resource ID`
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
let orgId: string;
|
||||
const resource = await db
|
||||
.select()
|
||||
.from(resources)
|
||||
.where(eq(resources.resourceId, resourceId!))
|
||||
.limit(1);
|
||||
|
||||
if (resourceId) {
|
||||
const [resource] = await db
|
||||
.select()
|
||||
.from(resources)
|
||||
.where(eq(resources.resourceId, resourceId))
|
||||
.limit(1);
|
||||
if (resource.length === 0) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.NOT_FOUND,
|
||||
`Resource with ID ${resourceId} not found`
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
if (!resource) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.NOT_FOUND,
|
||||
`Resource with ID ${resourceId} not found`
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
if (!resource.orgId) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.INTERNAL_SERVER_ERROR,
|
||||
`Resource with ID ${resourceId} does not have an organization ID`
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
orgId = resource.orgId;
|
||||
} else {
|
||||
const [provider] = await db
|
||||
.select()
|
||||
.from(aiProviders)
|
||||
.where(eq(aiProviders.providerId, providerId!))
|
||||
.limit(1);
|
||||
|
||||
if (!provider) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.NOT_FOUND,
|
||||
`AI provider with ID ${providerId} not found`
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
orgId = provider.orgId;
|
||||
if (!resource[0].orgId) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.INTERNAL_SERVER_ERROR,
|
||||
`resource with ID ${resourceId} does not have an organization ID`
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
if (!req.userOrg) {
|
||||
const userOrgResult = await db
|
||||
const res = await db
|
||||
.select()
|
||||
.from(userOrgs)
|
||||
.where(
|
||||
and(eq(userOrgs.userId, userId), eq(userOrgs.orgId, orgId))
|
||||
and(
|
||||
eq(userOrgs.userId, userId),
|
||||
eq(userOrgs.orgId, resource[0].orgId)
|
||||
)
|
||||
);
|
||||
req.userOrg = userOrgResult[0];
|
||||
req.userOrg = res[0];
|
||||
}
|
||||
|
||||
if (!req.userOrg || req.userOrg.orgId !== orgId) {
|
||||
return next(
|
||||
if (!req.userOrg) {
|
||||
next(
|
||||
createHttpError(
|
||||
HttpCode.FORBIDDEN,
|
||||
"User does not have access to this organization"
|
||||
)
|
||||
);
|
||||
} else {
|
||||
req.userOrgRoleIds = await getUserOrgRoleIds(
|
||||
req.userOrg.userId,
|
||||
resource[0].orgId!
|
||||
);
|
||||
req.userOrgId = resource[0].orgId!;
|
||||
}
|
||||
|
||||
req.userOrgRoleIds = await getUserOrgRoleIds(req.userOrg.userId, orgId);
|
||||
req.userOrgId = orgId;
|
||||
const orgId = req.userOrg.orgId;
|
||||
|
||||
if (req.orgPolicyAllowed === undefined) {
|
||||
if (req.orgPolicyAllowed === undefined && orgId) {
|
||||
const policyCheck = await checkOrgAccessPolicy({
|
||||
orgId,
|
||||
userId,
|
||||
@@ -141,24 +128,22 @@ export async function verifyTargetAccess(
|
||||
}
|
||||
}
|
||||
|
||||
if (resourceId) {
|
||||
const resourceAllowed = await canUserAccessResource({
|
||||
userId,
|
||||
resourceId,
|
||||
roleIds: req.userOrgRoleIds ?? []
|
||||
});
|
||||
const resourceAllowed = await canUserAccessResource({
|
||||
userId,
|
||||
resourceId,
|
||||
roleIds: req.userOrgRoleIds ?? []
|
||||
});
|
||||
|
||||
if (!resourceAllowed) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.FORBIDDEN,
|
||||
"User does not have access to this resource"
|
||||
)
|
||||
);
|
||||
}
|
||||
if (!resourceAllowed) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.FORBIDDEN,
|
||||
"User does not have access to this resource"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
return next();
|
||||
next();
|
||||
} catch (e) {
|
||||
return next(
|
||||
createHttpError(
|
||||
|
||||
+1
-3
@@ -28,9 +28,7 @@ export enum OpenAPITags {
|
||||
HealthCheck = "Health Check",
|
||||
PublicResourcePolicyLegacy = "Public Resource Policy (Legacy)",
|
||||
PublicResourceLegacy = "Public Resource (Legacy)",
|
||||
PrivateResourceLegacy = "Private Resource (Legacy)",
|
||||
AiProvider = "AI Provider",
|
||||
AiModel = "AI Model"
|
||||
PrivateResourceLegacy = "Private Resource (Legacy)"
|
||||
}
|
||||
|
||||
// Order here controls the order tags are displayed in Swagger UI
|
||||
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
Transaction
|
||||
} from "@server/db";
|
||||
import logger from "@server/logger";
|
||||
import { ExitNodePingResult } from "@server/routers/newt";
|
||||
import { eq, and, or, ne, isNull, inArray } from "drizzle-orm";
|
||||
import axios from "axios";
|
||||
import config from "../config";
|
||||
@@ -329,16 +330,6 @@ export async function listExitNodes(
|
||||
return exitNodesList;
|
||||
}
|
||||
|
||||
export type ExitNodePingResult = {
|
||||
exitNodeId: number;
|
||||
latencyMs: number;
|
||||
weight: number;
|
||||
error?: string;
|
||||
exitNodeName: string;
|
||||
endpoint: string;
|
||||
wasPreviouslyConnected: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Selects the most suitable exit node from a list of ping results.
|
||||
*
|
||||
|
||||
@@ -18,7 +18,6 @@ import {
|
||||
domains,
|
||||
exitNodes,
|
||||
loginPage,
|
||||
SiteResource,
|
||||
targetHealthCheck
|
||||
} from "@server/db";
|
||||
import {
|
||||
@@ -135,7 +134,7 @@ export async function getTraefikConfig(
|
||||
siteId: sites.siteId,
|
||||
siteType: sites.type,
|
||||
siteOnline: sites.online,
|
||||
subnet: sites.exitNodeSubnet,
|
||||
subnet: sites.subnet,
|
||||
exitNodeId: sites.exitNodeId,
|
||||
// Namespace
|
||||
domainNamespaceId: domainNamespaces.domainNamespaceId,
|
||||
@@ -360,7 +359,7 @@ export async function getTraefikConfig(
|
||||
let siteResourcesWithFullDomain: {
|
||||
siteResourceId: number;
|
||||
fullDomain: string | null;
|
||||
mode: SiteResource["mode"];
|
||||
mode: "http" | "host" | "cidr" | "ssh";
|
||||
}[] = [];
|
||||
if (
|
||||
build == "enterprise" &&
|
||||
|
||||
@@ -46,7 +46,7 @@ const getCertificateQuerySchema = z.object({
|
||||
|
||||
async function query(orgId: string, domainList: string[]) {
|
||||
// Try to get CNAME certificates first
|
||||
const existingCertificates = await db
|
||||
let existingCertificates = await db
|
||||
.select({
|
||||
certId: certificates.certId,
|
||||
domain: certificates.domain,
|
||||
@@ -73,19 +73,16 @@ async function query(orgId: string, domainList: string[]) {
|
||||
.where(and(inArray(certificates.domain, domainList)));
|
||||
|
||||
// All non resolved domain certificates might be `ns` or `wildcard`,
|
||||
// which means exact domain certificates do not exist
|
||||
const foundDomains = new Set(
|
||||
existingCertificates.map((cert) => cert.domain)
|
||||
);
|
||||
const domainsWithMissingCertificates = domainList.filter(
|
||||
(domain) => !foundDomains.has(domain)
|
||||
);
|
||||
// which means exact domain certificates do not
|
||||
const nonAvailableCertificates = existingCertificates
|
||||
.filter((cert) => !domainList.includes(cert.domain))
|
||||
.map((cert) => cert.domain);
|
||||
|
||||
if (domainsWithMissingCertificates.length > 0) {
|
||||
if (nonAvailableCertificates.length > 0) {
|
||||
const domainLevelDownSet = new Set<string>();
|
||||
const wildcardDomainSet = new Set<string>();
|
||||
|
||||
for (const domain of domainsWithMissingCertificates) {
|
||||
for (const domain of nonAvailableCertificates) {
|
||||
const domainLevelDown = domain.split(".").slice(1).join(".");
|
||||
const wildcardPrefixed = `*.${domainLevelDown}`;
|
||||
domainLevelDownSet.add(domainLevelDown);
|
||||
@@ -134,7 +131,6 @@ async function query(orgId: string, domainList: string[]) {
|
||||
for (const domain of domainList) {
|
||||
const domainLevelDown = domain.split(".").slice(1).join(".");
|
||||
const wildcardPrefixed = `*.${domainLevelDown}`;
|
||||
|
||||
certificateMap[domain] =
|
||||
existingCertificates.find(
|
||||
(cert) =>
|
||||
|
||||
@@ -178,7 +178,7 @@ export async function reGenerateSiteSecret(
|
||||
);
|
||||
}
|
||||
|
||||
if (site.exitNodeId && site.exitNodeSubnet) {
|
||||
if (site.exitNodeId && site.subnet) {
|
||||
await deletePeer(site.exitNodeId, site.pubKey!); // the old pubkey
|
||||
await addPeer(site.exitNodeId, {
|
||||
publicKey: pubKey,
|
||||
|
||||
@@ -1,151 +0,0 @@
|
||||
import { Request, Response, NextFunction } from "express";
|
||||
import { z } from "zod";
|
||||
import { aiModels, aiProviders, db } 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 { OpenAPITags, registry } from "@server/openApi";
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import type { CreateOrEditAiModelResponse } from "@server/routers/aiProvider/types";
|
||||
import {
|
||||
aiBudgetUnitSchema,
|
||||
refineBudgetFields
|
||||
} from "@server/routers/aiProvider/validation";
|
||||
|
||||
const paramsSchema = z.strictObject({
|
||||
providerId: z.coerce.number().int().positive()
|
||||
});
|
||||
|
||||
const bodySchema = z
|
||||
.strictObject({
|
||||
modelKey: z.string().nonempty(),
|
||||
name: z.string().nonempty(),
|
||||
budgetAmount: z.number().positive().optional().nullable(),
|
||||
budgetUnit: aiBudgetUnitSchema.optional().nullable(),
|
||||
enabled: z.boolean().optional()
|
||||
})
|
||||
.superRefine((data, ctx) => {
|
||||
refineBudgetFields(data, ctx);
|
||||
});
|
||||
|
||||
registry.registerPath({
|
||||
method: "put",
|
||||
path: "/ai-provider/{providerId}/model",
|
||||
description: "Create an AI model under a provider.",
|
||||
tags: [OpenAPITags.AiModel],
|
||||
request: {
|
||||
params: paramsSchema,
|
||||
body: {
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: bodySchema
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
responses: {
|
||||
201: {
|
||||
description: "Successful response"
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
export async function createAiModel(
|
||||
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 parsedBody = bodySchema.safeParse(req.body);
|
||||
if (!parsedBody.success) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
fromError(parsedBody.error).toString()
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const { providerId } = parsedParams.data;
|
||||
const { modelKey, name, budgetAmount, budgetUnit, enabled } =
|
||||
parsedBody.data;
|
||||
|
||||
const [provider] =
|
||||
req.aiProvider && req.aiProvider.providerId === providerId
|
||||
? [req.aiProvider]
|
||||
: await db
|
||||
.select()
|
||||
.from(aiProviders)
|
||||
.where(eq(aiProviders.providerId, providerId))
|
||||
.limit(1);
|
||||
|
||||
if (!provider) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.NOT_FOUND,
|
||||
`AI provider with ID ${providerId} not found`
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const [existing] = await db
|
||||
.select({ modelId: aiModels.modelId })
|
||||
.from(aiModels)
|
||||
.where(
|
||||
and(
|
||||
eq(aiModels.providerId, providerId),
|
||||
eq(aiModels.modelKey, modelKey)
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
if (existing) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.CONFLICT,
|
||||
`Model with key ${modelKey} already exists for this provider`
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
const [model] = await db
|
||||
.insert(aiModels)
|
||||
.values({
|
||||
providerId,
|
||||
modelKey,
|
||||
name,
|
||||
budgetAmount: budgetAmount ?? null,
|
||||
budgetUnit: budgetUnit ?? null,
|
||||
enabled: enabled ?? true,
|
||||
createdAt: now,
|
||||
updatedAt: now
|
||||
})
|
||||
.returning();
|
||||
|
||||
return response<CreateOrEditAiModelResponse>(res, {
|
||||
data: { model },
|
||||
success: true,
|
||||
error: false,
|
||||
message: "AI model created successfully",
|
||||
status: HttpCode.CREATED
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error(error);
|
||||
return next(
|
||||
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,150 +0,0 @@
|
||||
import { Request, Response, NextFunction } from "express";
|
||||
import { z } from "zod";
|
||||
import { aiProviders, db } 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 { OpenAPITags, registry } from "@server/openApi";
|
||||
import { encrypt } from "@server/lib/crypto";
|
||||
import config from "@server/lib/config";
|
||||
import type { CreateOrEditAiProviderResponse } from "@server/routers/aiProvider/types";
|
||||
import { toPublicAiProvider } from "@server/routers/aiProvider/types";
|
||||
import {
|
||||
aiAuthTypeSchema,
|
||||
aiBudgetUnitSchema,
|
||||
aiProviderTypeSchema,
|
||||
aiRoutingModeSchema,
|
||||
refineBudgetFields,
|
||||
refineProviderUpstreamFields
|
||||
} from "@server/routers/aiProvider/validation";
|
||||
|
||||
const paramsSchema = z.strictObject({
|
||||
orgId: z.string().nonempty()
|
||||
});
|
||||
|
||||
const bodySchema = z
|
||||
.strictObject({
|
||||
name: z.string().nonempty(),
|
||||
type: aiProviderTypeSchema,
|
||||
upstreamUrl: z.url().optional().nullable(),
|
||||
apiKey: z.string().optional(),
|
||||
authType: aiAuthTypeSchema.optional().nullable(),
|
||||
routingMode: aiRoutingModeSchema.optional(),
|
||||
skipTlsVerification: z.boolean().optional(),
|
||||
budgetAmount: z.number().positive().optional().nullable(),
|
||||
budgetUnit: aiBudgetUnitSchema.optional().nullable(),
|
||||
enabled: z.boolean().optional()
|
||||
})
|
||||
.superRefine((data, ctx) => {
|
||||
refineProviderUpstreamFields(data, ctx);
|
||||
refineBudgetFields(data, ctx);
|
||||
});
|
||||
|
||||
registry.registerPath({
|
||||
method: "put",
|
||||
path: "/org/{orgId}/ai-provider",
|
||||
description: "Create an AI provider for an organization.",
|
||||
tags: [OpenAPITags.AiProvider],
|
||||
request: {
|
||||
params: paramsSchema,
|
||||
body: {
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: bodySchema
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
responses: {
|
||||
201: {
|
||||
description: "Successful response"
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
export async function createAiProvider(
|
||||
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 parsedBody = bodySchema.safeParse(req.body);
|
||||
if (!parsedBody.success) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
fromError(parsedBody.error).toString()
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const { orgId } = parsedParams.data;
|
||||
const {
|
||||
name,
|
||||
type,
|
||||
upstreamUrl,
|
||||
apiKey,
|
||||
authType,
|
||||
routingMode,
|
||||
skipTlsVerification,
|
||||
budgetAmount,
|
||||
budgetUnit,
|
||||
enabled
|
||||
} = parsedBody.data;
|
||||
|
||||
const key = config.getRawConfig().server.secret!;
|
||||
const encryptedApiKey = apiKey ? encrypt(apiKey, key) : null;
|
||||
const apiKeyLastChars = apiKey ? apiKey.slice(-4) : null;
|
||||
const now = Date.now();
|
||||
const resolvedRoutingMode =
|
||||
type === "custom" ? (routingMode ?? "url") : "url";
|
||||
|
||||
const [provider] = await db
|
||||
.insert(aiProviders)
|
||||
.values({
|
||||
orgId,
|
||||
name,
|
||||
type,
|
||||
upstreamUrl:
|
||||
resolvedRoutingMode === "target"
|
||||
? null
|
||||
: (upstreamUrl ?? null),
|
||||
apiKey: encryptedApiKey,
|
||||
apiKeyLastChars,
|
||||
authType: authType ?? null,
|
||||
routingMode: resolvedRoutingMode,
|
||||
skipTlsVerification: skipTlsVerification ?? false,
|
||||
budgetAmount: budgetAmount ?? null,
|
||||
budgetUnit: budgetUnit ?? null,
|
||||
enabled: enabled ?? true,
|
||||
createdAt: now,
|
||||
updatedAt: now
|
||||
})
|
||||
.returning();
|
||||
|
||||
return response<CreateOrEditAiProviderResponse>(res, {
|
||||
data: { provider: toPublicAiProvider(provider) },
|
||||
success: true,
|
||||
error: false,
|
||||
message: "AI provider created successfully",
|
||||
status: HttpCode.CREATED
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error(error);
|
||||
return next(
|
||||
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,79 +0,0 @@
|
||||
import { Request, Response, NextFunction } from "express";
|
||||
import { z } from "zod";
|
||||
import { aiModels, db } 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 { OpenAPITags, registry } from "@server/openApi";
|
||||
import { eq } from "drizzle-orm";
|
||||
|
||||
const paramsSchema = z.strictObject({
|
||||
modelId: z.coerce.number().int().positive()
|
||||
});
|
||||
|
||||
registry.registerPath({
|
||||
method: "delete",
|
||||
path: "/ai-model/{modelId}",
|
||||
description: "Delete an AI model.",
|
||||
tags: [OpenAPITags.AiModel],
|
||||
request: {
|
||||
params: paramsSchema
|
||||
},
|
||||
responses: {
|
||||
200: {
|
||||
description: "Successful response"
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
export async function deleteAiModel(
|
||||
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 { modelId } = parsedParams.data;
|
||||
|
||||
const [existing] = await db
|
||||
.select({ modelId: aiModels.modelId })
|
||||
.from(aiModels)
|
||||
.where(eq(aiModels.modelId, modelId))
|
||||
.limit(1);
|
||||
|
||||
if (!existing) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.NOT_FOUND,
|
||||
`AI model with ID ${modelId} not found`
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
await db.delete(aiModels).where(eq(aiModels.modelId, modelId));
|
||||
|
||||
return response(res, {
|
||||
data: null,
|
||||
success: true,
|
||||
error: false,
|
||||
message: "AI model deleted successfully",
|
||||
status: HttpCode.OK
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error(error);
|
||||
return next(
|
||||
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,81 +0,0 @@
|
||||
import { Request, Response, NextFunction } from "express";
|
||||
import { z } from "zod";
|
||||
import { aiProviders, db } 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 { OpenAPITags, registry } from "@server/openApi";
|
||||
import { eq } from "drizzle-orm";
|
||||
|
||||
const paramsSchema = z.strictObject({
|
||||
providerId: z.coerce.number().int().positive()
|
||||
});
|
||||
|
||||
registry.registerPath({
|
||||
method: "delete",
|
||||
path: "/ai-provider/{providerId}",
|
||||
description: "Delete an AI provider.",
|
||||
tags: [OpenAPITags.AiProvider],
|
||||
request: {
|
||||
params: paramsSchema
|
||||
},
|
||||
responses: {
|
||||
200: {
|
||||
description: "Successful response"
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
export async function deleteAiProvider(
|
||||
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 { providerId } = parsedParams.data;
|
||||
|
||||
const [existing] = await db
|
||||
.select({ providerId: aiProviders.providerId })
|
||||
.from(aiProviders)
|
||||
.where(eq(aiProviders.providerId, providerId))
|
||||
.limit(1);
|
||||
|
||||
if (!existing) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.NOT_FOUND,
|
||||
`AI provider with ID ${providerId} not found`
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
await db
|
||||
.delete(aiProviders)
|
||||
.where(eq(aiProviders.providerId, providerId));
|
||||
|
||||
return response(res, {
|
||||
data: null,
|
||||
success: true,
|
||||
error: false,
|
||||
message: "AI provider deleted successfully",
|
||||
status: HttpCode.OK
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error(error);
|
||||
return next(
|
||||
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,81 +0,0 @@
|
||||
import { Request, Response, NextFunction } from "express";
|
||||
import { z } from "zod";
|
||||
import { aiModels, db } 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 { OpenAPITags, registry } from "@server/openApi";
|
||||
import { eq } from "drizzle-orm";
|
||||
import type { GetAiModelResponse } from "@server/routers/aiProvider/types";
|
||||
|
||||
const paramsSchema = z.strictObject({
|
||||
modelId: z.coerce.number().int().positive()
|
||||
});
|
||||
|
||||
registry.registerPath({
|
||||
method: "get",
|
||||
path: "/ai-model/{modelId}",
|
||||
description: "Get an AI model by ID.",
|
||||
tags: [OpenAPITags.AiModel],
|
||||
request: {
|
||||
params: paramsSchema
|
||||
},
|
||||
responses: {
|
||||
200: {
|
||||
description: "Successful response"
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
export async function getAiModel(
|
||||
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 { modelId } = parsedParams.data;
|
||||
|
||||
const [model] =
|
||||
req.aiModel && req.aiModel.modelId === modelId
|
||||
? [req.aiModel]
|
||||
: await db
|
||||
.select()
|
||||
.from(aiModels)
|
||||
.where(eq(aiModels.modelId, modelId))
|
||||
.limit(1);
|
||||
|
||||
if (!model) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.NOT_FOUND,
|
||||
`AI model with ID ${modelId} not found`
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
return response<GetAiModelResponse>(res, {
|
||||
data: { model },
|
||||
success: true,
|
||||
error: false,
|
||||
message: "AI model retrieved successfully",
|
||||
status: HttpCode.OK
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error(error);
|
||||
return next(
|
||||
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,82 +0,0 @@
|
||||
import { Request, Response, NextFunction } from "express";
|
||||
import { z } from "zod";
|
||||
import { aiProviders, db } 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 { OpenAPITags, registry } from "@server/openApi";
|
||||
import { eq } from "drizzle-orm";
|
||||
import type { GetAiProviderResponse } from "@server/routers/aiProvider/types";
|
||||
import { toPublicAiProvider } from "@server/routers/aiProvider/types";
|
||||
|
||||
const paramsSchema = z.strictObject({
|
||||
providerId: z.coerce.number().int().positive()
|
||||
});
|
||||
|
||||
registry.registerPath({
|
||||
method: "get",
|
||||
path: "/ai-provider/{providerId}",
|
||||
description: "Get an AI provider by ID.",
|
||||
tags: [OpenAPITags.AiProvider],
|
||||
request: {
|
||||
params: paramsSchema
|
||||
},
|
||||
responses: {
|
||||
200: {
|
||||
description: "Successful response"
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
export async function getAiProvider(
|
||||
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 { providerId } = parsedParams.data;
|
||||
|
||||
const [provider] =
|
||||
req.aiProvider && req.aiProvider.providerId === providerId
|
||||
? [req.aiProvider]
|
||||
: await db
|
||||
.select()
|
||||
.from(aiProviders)
|
||||
.where(eq(aiProviders.providerId, providerId))
|
||||
.limit(1);
|
||||
|
||||
if (!provider) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.NOT_FOUND,
|
||||
`AI provider with ID ${providerId} not found`
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
return response<GetAiProviderResponse>(res, {
|
||||
data: { provider: toPublicAiProvider(provider) },
|
||||
success: true,
|
||||
error: false,
|
||||
message: "AI provider retrieved successfully",
|
||||
status: HttpCode.OK
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error(error);
|
||||
return next(
|
||||
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
export * from "./createAiProvider";
|
||||
export * from "./listAiProviders";
|
||||
export * from "./getAiProvider";
|
||||
export * from "./updateAiProvider";
|
||||
export * from "./deleteAiProvider";
|
||||
export * from "./createAiModel";
|
||||
export * from "./listAiModels";
|
||||
export * from "./getAiModel";
|
||||
export * from "./updateAiModel";
|
||||
export * from "./deleteAiModel";
|
||||
export * from "./types";
|
||||
@@ -1,160 +0,0 @@
|
||||
import { Request, Response, NextFunction } from "express";
|
||||
import { z } from "zod";
|
||||
import { aiModels, aiProviders, db } 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 { OpenAPITags, registry } from "@server/openApi";
|
||||
import { and, asc, eq, like, sql } from "drizzle-orm";
|
||||
import type { ListAiModelsResponse } from "@server/routers/aiProvider/types";
|
||||
|
||||
const paramsSchema = z.strictObject({
|
||||
providerId: z.coerce.number().int().positive()
|
||||
});
|
||||
|
||||
const listSchema = z.object({
|
||||
pageSize: z.coerce
|
||||
.number<string>()
|
||||
.int()
|
||||
.positive()
|
||||
.optional()
|
||||
.catch(20)
|
||||
.default(20)
|
||||
.openapi({
|
||||
type: "integer",
|
||||
default: 20,
|
||||
description: "Number of items per page"
|
||||
}),
|
||||
page: z.coerce
|
||||
.number<string>()
|
||||
.int()
|
||||
.min(0)
|
||||
.optional()
|
||||
.catch(1)
|
||||
.default(1)
|
||||
.openapi({
|
||||
type: "integer",
|
||||
default: 1,
|
||||
description: "Page number to retrieve"
|
||||
}),
|
||||
query: z.string().optional()
|
||||
});
|
||||
|
||||
registry.registerPath({
|
||||
method: "get",
|
||||
path: "/ai-provider/{providerId}/models",
|
||||
description: "List AI models for a provider.",
|
||||
tags: [OpenAPITags.AiModel],
|
||||
request: {
|
||||
params: paramsSchema,
|
||||
query: listSchema
|
||||
},
|
||||
responses: {
|
||||
200: {
|
||||
description: "Successful response"
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
export async function listAiModels(
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction
|
||||
): Promise<any> {
|
||||
try {
|
||||
const parsedQuery = listSchema.safeParse(req.query);
|
||||
if (!parsedQuery.success) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
fromError(parsedQuery.error).toString()
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const parsedParams = paramsSchema.safeParse(req.params);
|
||||
if (!parsedParams.success) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
fromError(parsedParams.error).toString()
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const { providerId } = parsedParams.data;
|
||||
|
||||
const [provider] =
|
||||
req.aiProvider && req.aiProvider.providerId === providerId
|
||||
? [req.aiProvider]
|
||||
: await db
|
||||
.select({ providerId: aiProviders.providerId })
|
||||
.from(aiProviders)
|
||||
.where(eq(aiProviders.providerId, providerId))
|
||||
.limit(1);
|
||||
|
||||
if (!provider) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.NOT_FOUND,
|
||||
`AI provider with ID ${providerId} not found`
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const { pageSize, page, query } = parsedQuery.data;
|
||||
const conditions = [eq(aiModels.providerId, providerId)];
|
||||
|
||||
if (query) {
|
||||
conditions.push(
|
||||
like(
|
||||
sql`LOWER(${aiModels.name})`,
|
||||
"%" + query.toLowerCase() + "%"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const baseQuery = db
|
||||
.select()
|
||||
.from(aiModels)
|
||||
.where(and(...conditions));
|
||||
|
||||
const countQuery = db.$count(
|
||||
db
|
||||
.select()
|
||||
.from(aiModels)
|
||||
.where(and(...conditions))
|
||||
.as("filtered_ai_models")
|
||||
);
|
||||
|
||||
const [totalCount, rows] = await Promise.all([
|
||||
countQuery,
|
||||
baseQuery
|
||||
.limit(pageSize)
|
||||
.offset(pageSize * (page - 1))
|
||||
.orderBy(asc(aiModels.name))
|
||||
]);
|
||||
|
||||
return response<ListAiModelsResponse>(res, {
|
||||
data: {
|
||||
models: rows,
|
||||
pagination: {
|
||||
total: totalCount,
|
||||
pageSize,
|
||||
page
|
||||
}
|
||||
},
|
||||
success: true,
|
||||
error: false,
|
||||
message: "AI models retrieved successfully",
|
||||
status: HttpCode.OK
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error(error);
|
||||
return next(
|
||||
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,152 +0,0 @@
|
||||
import { Request, Response, NextFunction } from "express";
|
||||
import { z } from "zod";
|
||||
import { aiProviders, db } 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 { OpenAPITags, registry } from "@server/openApi";
|
||||
import { and, asc, eq, like, sql } from "drizzle-orm";
|
||||
import type { ListAiProvidersResponse } from "@server/routers/aiProvider/types";
|
||||
import { toPublicAiProvider } from "@server/routers/aiProvider/types";
|
||||
|
||||
const paramsSchema = z.strictObject({
|
||||
orgId: z.string().nonempty()
|
||||
});
|
||||
|
||||
const listSchema = z.object({
|
||||
pageSize: z.coerce
|
||||
.number<string>()
|
||||
.int()
|
||||
.positive()
|
||||
.optional()
|
||||
.catch(20)
|
||||
.default(20)
|
||||
.openapi({
|
||||
type: "integer",
|
||||
default: 20,
|
||||
description: "Number of items per page"
|
||||
}),
|
||||
page: z.coerce
|
||||
.number<string>()
|
||||
.int()
|
||||
.min(0)
|
||||
.optional()
|
||||
.catch(1)
|
||||
.default(1)
|
||||
.openapi({
|
||||
type: "integer",
|
||||
default: 1,
|
||||
description: "Page number to retrieve"
|
||||
}),
|
||||
query: z.string().optional()
|
||||
});
|
||||
|
||||
registry.registerPath({
|
||||
method: "get",
|
||||
path: "/org/{orgId}/ai-providers",
|
||||
description: "List AI providers for an organization.",
|
||||
tags: [OpenAPITags.AiProvider],
|
||||
request: {
|
||||
params: paramsSchema,
|
||||
query: listSchema
|
||||
},
|
||||
responses: {
|
||||
200: {
|
||||
description: "Successful response"
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
export async function listAiProviders(
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction
|
||||
): Promise<any> {
|
||||
try {
|
||||
const parsedQuery = listSchema.safeParse(req.query);
|
||||
if (!parsedQuery.success) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
fromError(parsedQuery.error).toString()
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const parsedParams = paramsSchema.safeParse(req.params);
|
||||
if (!parsedParams.success) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
fromError(parsedParams.error).toString()
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const { orgId } = parsedParams.data;
|
||||
|
||||
if (req.user && orgId && orgId !== req.userOrgId) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.FORBIDDEN,
|
||||
"User does not have access to this organization"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const { pageSize, page, query } = parsedQuery.data;
|
||||
const conditions = [eq(aiProviders.orgId, orgId)];
|
||||
|
||||
if (query) {
|
||||
conditions.push(
|
||||
like(
|
||||
sql`LOWER(${aiProviders.name})`,
|
||||
"%" + query.toLowerCase() + "%"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const baseQuery = db
|
||||
.select()
|
||||
.from(aiProviders)
|
||||
.where(and(...conditions));
|
||||
|
||||
const countQuery = db.$count(
|
||||
db
|
||||
.select()
|
||||
.from(aiProviders)
|
||||
.where(and(...conditions))
|
||||
.as("filtered_ai_providers")
|
||||
);
|
||||
|
||||
const [totalCount, rows] = await Promise.all([
|
||||
countQuery,
|
||||
baseQuery
|
||||
.limit(pageSize)
|
||||
.offset(pageSize * (page - 1))
|
||||
.orderBy(asc(aiProviders.name))
|
||||
]);
|
||||
|
||||
return response<ListAiProvidersResponse>(res, {
|
||||
data: {
|
||||
providers: rows.map(toPublicAiProvider),
|
||||
pagination: {
|
||||
total: totalCount,
|
||||
pageSize,
|
||||
page
|
||||
}
|
||||
},
|
||||
success: true,
|
||||
error: false,
|
||||
message: "AI providers retrieved successfully",
|
||||
status: HttpCode.OK
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error(error);
|
||||
return next(
|
||||
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,53 +0,0 @@
|
||||
import type { AiModel, AiProvider } from "@server/db";
|
||||
import type { PaginatedResponse } from "@server/types/Pagination";
|
||||
import {
|
||||
resolveAiProviderConfig,
|
||||
type AiProviderAuthType,
|
||||
type AiProviderRoutingMode,
|
||||
type AiProviderType
|
||||
} from "@server/lib/aiProviderDefaults";
|
||||
|
||||
export type AiProviderPublic = Omit<AiProvider, "apiKey"> & {
|
||||
effectiveUpstreamUrl: string | null;
|
||||
effectiveAuthType: AiProviderAuthType | null;
|
||||
};
|
||||
|
||||
export type ListAiProvidersResponse = PaginatedResponse<{
|
||||
providers: AiProviderPublic[];
|
||||
}>;
|
||||
|
||||
export type GetAiProviderResponse = {
|
||||
provider: AiProviderPublic;
|
||||
};
|
||||
|
||||
export type CreateOrEditAiProviderResponse = {
|
||||
provider: AiProviderPublic;
|
||||
};
|
||||
|
||||
export type ListAiModelsResponse = PaginatedResponse<{
|
||||
models: AiModel[];
|
||||
}>;
|
||||
|
||||
export type GetAiModelResponse = {
|
||||
model: AiModel;
|
||||
};
|
||||
|
||||
export type CreateOrEditAiModelResponse = {
|
||||
model: AiModel;
|
||||
};
|
||||
|
||||
export function toPublicAiProvider(provider: AiProvider): AiProviderPublic {
|
||||
const { apiKey: _apiKey, ...rest } = provider;
|
||||
const resolved = resolveAiProviderConfig({
|
||||
type: provider.type as AiProviderType,
|
||||
upstreamUrl: provider.upstreamUrl,
|
||||
authType: provider.authType as AiProviderAuthType | null,
|
||||
routingMode: provider.routingMode as AiProviderRoutingMode | null
|
||||
});
|
||||
|
||||
return {
|
||||
...rest,
|
||||
effectiveUpstreamUrl: resolved.upstreamUrl,
|
||||
effectiveAuthType: resolved.authType
|
||||
};
|
||||
}
|
||||
@@ -1,167 +0,0 @@
|
||||
import { Request, Response, NextFunction } from "express";
|
||||
import { z } from "zod";
|
||||
import { aiModels, db } 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 { OpenAPITags, registry } from "@server/openApi";
|
||||
import { and, eq, ne } from "drizzle-orm";
|
||||
import type { CreateOrEditAiModelResponse } from "@server/routers/aiProvider/types";
|
||||
import {
|
||||
aiBudgetUnitSchema,
|
||||
refineBudgetFields
|
||||
} from "@server/routers/aiProvider/validation";
|
||||
|
||||
const paramsSchema = z.strictObject({
|
||||
modelId: z.coerce.number().int().positive()
|
||||
});
|
||||
|
||||
const bodySchema = z
|
||||
.strictObject({
|
||||
modelKey: z.string().nonempty().optional(),
|
||||
name: z.string().nonempty().optional(),
|
||||
budgetAmount: z.number().positive().optional().nullable(),
|
||||
budgetUnit: aiBudgetUnitSchema.optional().nullable(),
|
||||
enabled: z.boolean().optional()
|
||||
})
|
||||
.superRefine((data, ctx) => {
|
||||
refineBudgetFields(data, ctx);
|
||||
});
|
||||
|
||||
registry.registerPath({
|
||||
method: "post",
|
||||
path: "/ai-model/{modelId}",
|
||||
description: "Update an AI model.",
|
||||
tags: [OpenAPITags.AiModel],
|
||||
request: {
|
||||
params: paramsSchema,
|
||||
body: {
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: bodySchema
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
responses: {
|
||||
200: {
|
||||
description: "Successful response"
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
export async function updateAiModel(
|
||||
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 parsedBody = bodySchema.safeParse(req.body);
|
||||
if (!parsedBody.success) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
fromError(parsedBody.error).toString()
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const { modelId } = parsedParams.data;
|
||||
const body = parsedBody.data;
|
||||
|
||||
const [existing] =
|
||||
req.aiModel && req.aiModel.modelId === modelId
|
||||
? [req.aiModel]
|
||||
: await db
|
||||
.select()
|
||||
.from(aiModels)
|
||||
.where(eq(aiModels.modelId, modelId))
|
||||
.limit(1);
|
||||
|
||||
if (!existing) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.NOT_FOUND,
|
||||
`AI model with ID ${modelId} not found`
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
body.modelKey !== undefined &&
|
||||
body.modelKey !== existing.modelKey
|
||||
) {
|
||||
const [conflict] = await db
|
||||
.select({ modelId: aiModels.modelId })
|
||||
.from(aiModels)
|
||||
.where(
|
||||
and(
|
||||
eq(aiModels.providerId, existing.providerId),
|
||||
eq(aiModels.modelKey, body.modelKey),
|
||||
ne(aiModels.modelId, modelId)
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
if (conflict) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.CONFLICT,
|
||||
`Model with key ${body.modelKey} already exists for this provider`
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const updateData: Partial<typeof aiModels.$inferInsert> = {
|
||||
updatedAt: Date.now()
|
||||
};
|
||||
|
||||
if (body.modelKey !== undefined) {
|
||||
updateData.modelKey = body.modelKey;
|
||||
}
|
||||
if (body.name !== undefined) {
|
||||
updateData.name = body.name;
|
||||
}
|
||||
if (body.budgetAmount !== undefined) {
|
||||
updateData.budgetAmount = body.budgetAmount;
|
||||
}
|
||||
if (body.budgetUnit !== undefined) {
|
||||
updateData.budgetUnit = body.budgetUnit;
|
||||
}
|
||||
if (body.enabled !== undefined) {
|
||||
updateData.enabled = body.enabled;
|
||||
}
|
||||
|
||||
const [model] = await db
|
||||
.update(aiModels)
|
||||
.set(updateData)
|
||||
.where(eq(aiModels.modelId, modelId))
|
||||
.returning();
|
||||
|
||||
return response<CreateOrEditAiModelResponse>(res, {
|
||||
data: { model },
|
||||
success: true,
|
||||
error: false,
|
||||
message: "AI model updated successfully",
|
||||
status: HttpCode.OK
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error(error);
|
||||
return next(
|
||||
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,208 +0,0 @@
|
||||
import { Request, Response, NextFunction } from "express";
|
||||
import { z } from "zod";
|
||||
import { aiProviders, db } 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 { OpenAPITags, registry } from "@server/openApi";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { encrypt } from "@server/lib/crypto";
|
||||
import config from "@server/lib/config";
|
||||
import type { CreateOrEditAiProviderResponse } from "@server/routers/aiProvider/types";
|
||||
import { toPublicAiProvider } from "@server/routers/aiProvider/types";
|
||||
import {
|
||||
aiAuthTypeSchema,
|
||||
aiBudgetUnitSchema,
|
||||
aiProviderTypeSchema,
|
||||
aiRoutingModeSchema,
|
||||
refineBudgetFields,
|
||||
refineProviderUpstreamFields
|
||||
} from "@server/routers/aiProvider/validation";
|
||||
import type {
|
||||
AiProviderRoutingMode,
|
||||
AiProviderType
|
||||
} from "@server/lib/aiProviderDefaults";
|
||||
|
||||
const paramsSchema = z.strictObject({
|
||||
providerId: z.coerce.number().int().positive()
|
||||
});
|
||||
|
||||
const bodySchema = z
|
||||
.strictObject({
|
||||
name: z.string().nonempty().optional(),
|
||||
upstreamUrl: z.url().optional().nullable(),
|
||||
apiKey: z.string().optional(),
|
||||
authType: aiAuthTypeSchema.optional().nullable(),
|
||||
routingMode: aiRoutingModeSchema.optional(),
|
||||
skipTlsVerification: z.boolean().optional(),
|
||||
budgetAmount: z.number().positive().optional().nullable(),
|
||||
budgetUnit: aiBudgetUnitSchema.optional().nullable(),
|
||||
enabled: z.boolean().optional()
|
||||
})
|
||||
.superRefine((data, ctx) => {
|
||||
refineBudgetFields(data, ctx);
|
||||
});
|
||||
|
||||
registry.registerPath({
|
||||
method: "post",
|
||||
path: "/ai-provider/{providerId}",
|
||||
description: "Update an AI provider.",
|
||||
tags: [OpenAPITags.AiProvider],
|
||||
request: {
|
||||
params: paramsSchema,
|
||||
body: {
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: bodySchema
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
responses: {
|
||||
200: {
|
||||
description: "Successful response"
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
export async function updateAiProvider(
|
||||
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 parsedBody = bodySchema.safeParse(req.body);
|
||||
if (!parsedBody.success) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
fromError(parsedBody.error).toString()
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const { providerId } = parsedParams.data;
|
||||
const body = parsedBody.data;
|
||||
|
||||
const [existing] =
|
||||
req.aiProvider && req.aiProvider.providerId === providerId
|
||||
? [req.aiProvider]
|
||||
: await db
|
||||
.select()
|
||||
.from(aiProviders)
|
||||
.where(eq(aiProviders.providerId, providerId))
|
||||
.limit(1);
|
||||
|
||||
if (!existing) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.NOT_FOUND,
|
||||
`AI provider with ID ${providerId} not found`
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const providerType = existing.type as AiProviderType;
|
||||
const nextRoutingMode: AiProviderRoutingMode =
|
||||
providerType === "custom"
|
||||
? ((body.routingMode ??
|
||||
existing.routingMode) as AiProviderRoutingMode)
|
||||
: "url";
|
||||
const nextUpstreamUrl =
|
||||
body.upstreamUrl !== undefined
|
||||
? body.upstreamUrl
|
||||
: existing.upstreamUrl;
|
||||
const nextAuthType =
|
||||
body.authType !== undefined ? body.authType : existing.authType;
|
||||
|
||||
const validation = z
|
||||
.object({
|
||||
type: aiProviderTypeSchema,
|
||||
upstreamUrl: z.string().nullable().optional(),
|
||||
authType: aiAuthTypeSchema.nullable().optional(),
|
||||
routingMode: aiRoutingModeSchema.optional()
|
||||
})
|
||||
.superRefine((data, ctx) => refineProviderUpstreamFields(data, ctx))
|
||||
.safeParse({
|
||||
type: providerType,
|
||||
upstreamUrl: nextUpstreamUrl,
|
||||
authType: nextAuthType,
|
||||
routingMode: nextRoutingMode
|
||||
});
|
||||
|
||||
if (!validation.success) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
fromError(validation.error).toString()
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const updateData: Partial<typeof aiProviders.$inferInsert> = {
|
||||
updatedAt: Date.now(),
|
||||
routingMode: nextRoutingMode
|
||||
};
|
||||
|
||||
if (body.name !== undefined) {
|
||||
updateData.name = body.name;
|
||||
}
|
||||
if (body.skipTlsVerification !== undefined) {
|
||||
updateData.skipTlsVerification = body.skipTlsVerification;
|
||||
}
|
||||
if (body.enabled !== undefined) {
|
||||
updateData.enabled = body.enabled;
|
||||
}
|
||||
if (body.budgetAmount !== undefined) {
|
||||
updateData.budgetAmount = body.budgetAmount;
|
||||
}
|
||||
if (body.budgetUnit !== undefined) {
|
||||
updateData.budgetUnit = body.budgetUnit;
|
||||
}
|
||||
if (nextRoutingMode === "target") {
|
||||
updateData.upstreamUrl = null;
|
||||
} else if (body.upstreamUrl !== undefined) {
|
||||
updateData.upstreamUrl = body.upstreamUrl;
|
||||
}
|
||||
if (body.authType !== undefined) {
|
||||
updateData.authType = body.authType;
|
||||
}
|
||||
|
||||
if (body.apiKey !== undefined) {
|
||||
const key = config.getRawConfig().server.secret!;
|
||||
updateData.apiKey = encrypt(body.apiKey, key);
|
||||
updateData.apiKeyLastChars = body.apiKey.slice(-4);
|
||||
}
|
||||
|
||||
const [provider] = await db
|
||||
.update(aiProviders)
|
||||
.set(updateData)
|
||||
.where(eq(aiProviders.providerId, providerId))
|
||||
.returning();
|
||||
|
||||
return response<CreateOrEditAiProviderResponse>(res, {
|
||||
data: { provider: toPublicAiProvider(provider) },
|
||||
success: true,
|
||||
error: false,
|
||||
message: "AI provider updated successfully",
|
||||
status: HttpCode.OK
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error(error);
|
||||
return next(
|
||||
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,85 +0,0 @@
|
||||
import { z } from "zod";
|
||||
import {
|
||||
providerRequiresUpstreamUrl,
|
||||
type AiBudgetUnit,
|
||||
type AiProviderRoutingMode,
|
||||
type AiProviderType
|
||||
} from "@server/lib/aiProviderDefaults";
|
||||
|
||||
export const aiProviderTypeSchema = z.enum([
|
||||
"openai",
|
||||
"anthropic",
|
||||
"googleGemini",
|
||||
"vertexAi",
|
||||
"bedrock",
|
||||
"microsoftFoundry",
|
||||
"openRouter",
|
||||
"vercelAiGateway",
|
||||
"custom"
|
||||
]);
|
||||
|
||||
export const aiBudgetUnitSchema = z.enum(["usd", "tokens"]);
|
||||
|
||||
export const aiAuthTypeSchema = z.enum(["bearer"]);
|
||||
|
||||
export const aiRoutingModeSchema = z.enum(["url", "target"]);
|
||||
|
||||
export function refineBudgetFields(
|
||||
data: {
|
||||
budgetAmount?: number | null;
|
||||
budgetUnit?: AiBudgetUnit | null;
|
||||
},
|
||||
ctx: z.RefinementCtx
|
||||
) {
|
||||
const hasAmount =
|
||||
data.budgetAmount !== undefined && data.budgetAmount !== null;
|
||||
const hasUnit = data.budgetUnit !== undefined && data.budgetUnit !== null;
|
||||
|
||||
if (hasAmount !== hasUnit) {
|
||||
ctx.addIssue({
|
||||
code: "custom",
|
||||
message:
|
||||
"budgetAmount and budgetUnit must both be set or both omitted",
|
||||
path: hasAmount ? ["budgetUnit"] : ["budgetAmount"]
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export function refineProviderUpstreamFields(
|
||||
data: {
|
||||
type: AiProviderType;
|
||||
upstreamUrl?: string | null;
|
||||
authType?: "bearer" | null;
|
||||
routingMode?: AiProviderRoutingMode | null;
|
||||
},
|
||||
ctx: z.RefinementCtx
|
||||
) {
|
||||
const routingMode = data.routingMode ?? "url";
|
||||
|
||||
if (data.type !== "custom" && routingMode === "target") {
|
||||
ctx.addIssue({
|
||||
code: "custom",
|
||||
message: "routingMode target is only allowed for custom providers",
|
||||
path: ["routingMode"]
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
providerRequiresUpstreamUrl(data.type, routingMode) &&
|
||||
!data.upstreamUrl
|
||||
) {
|
||||
ctx.addIssue({
|
||||
code: "custom",
|
||||
message: `upstreamUrl is required for ${data.type} providers`,
|
||||
path: ["upstreamUrl"]
|
||||
});
|
||||
}
|
||||
|
||||
if (data.type === "custom" && routingMode === "url" && !data.authType) {
|
||||
ctx.addIssue({
|
||||
code: "custom",
|
||||
message: "authType is required for custom providers",
|
||||
path: ["authType"]
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -255,6 +255,20 @@ export async function createClient(
|
||||
|
||||
let newClient: Client | null = null;
|
||||
await db.transaction(async (trx) => {
|
||||
// TODO: more intelligent way to pick the exit node
|
||||
const exitNodesList = await listExitNodes(orgId);
|
||||
const randomExitNode =
|
||||
exitNodesList[Math.floor(Math.random() * exitNodesList.length)];
|
||||
|
||||
if (!randomExitNode) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.NOT_FOUND,
|
||||
`No exit nodes available. ${build == "saas" ? "Please contact support." : "You need to install gerbil to use the clients."}`
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const [adminRole] = await trx
|
||||
.select()
|
||||
.from(roles)
|
||||
@@ -273,6 +287,7 @@ export async function createClient(
|
||||
.insert(clients)
|
||||
.values({
|
||||
niceId,
|
||||
exitNodeId: randomExitNode.exitNodeId,
|
||||
orgId,
|
||||
name,
|
||||
subnet: updatedSubnet,
|
||||
|
||||
@@ -222,6 +222,11 @@ export async function createUserClient(
|
||||
|
||||
let newClient: Client | null = null;
|
||||
await db.transaction(async (trx) => {
|
||||
// TODO: more intelligent way to pick the exit node
|
||||
const exitNodesList = await listExitNodes(orgId);
|
||||
const randomExitNode =
|
||||
exitNodesList[Math.floor(Math.random() * exitNodesList.length)];
|
||||
|
||||
const [adminRole] = await trx
|
||||
.select()
|
||||
.from(roles)
|
||||
@@ -239,6 +244,7 @@ export async function createUserClient(
|
||||
[newClient] = await trx
|
||||
.insert(clients)
|
||||
.values({
|
||||
exitNodeId: randomExitNode.exitNodeId,
|
||||
orgId,
|
||||
niceId,
|
||||
name,
|
||||
|
||||
@@ -67,12 +67,12 @@ const listUserDevicesSchema = z.strictObject({
|
||||
}),
|
||||
query: z.string().optional(),
|
||||
sort_by: z
|
||||
.enum(["megabytesIn", "megabytesOut", "firstSeen", "lastSeen"])
|
||||
.enum(["megabytesIn", "megabytesOut"])
|
||||
.optional()
|
||||
.catch(undefined)
|
||||
.openapi({
|
||||
type: "string",
|
||||
enum: ["megabytesIn", "megabytesOut", "firstSeen", "lastSeen"],
|
||||
enum: ["megabytesIn", "megabytesOut"],
|
||||
description: "Field to sort by"
|
||||
}),
|
||||
order: z
|
||||
@@ -183,9 +183,7 @@ function queryUserDevicesBase() {
|
||||
fingerprintArch: currentFingerprint.arch,
|
||||
fingerprintSerialNumber: currentFingerprint.serialNumber,
|
||||
fingerprintUsername: currentFingerprint.username,
|
||||
fingerprintHostname: currentFingerprint.hostname,
|
||||
firstSeen: currentFingerprint.firstSeen,
|
||||
lastSeen: currentFingerprint.lastSeen
|
||||
fingerprintHostname: currentFingerprint.hostname
|
||||
})
|
||||
.from(clients)
|
||||
.leftJoin(orgs, eq(clients.orgId, orgs.orgId))
|
||||
@@ -391,23 +389,14 @@ export async function listUserDevices(
|
||||
|
||||
const countQuery = db.$count(baseQuery.as("filtered_clients"));
|
||||
|
||||
const sortColumn =
|
||||
sort_by === "firstSeen"
|
||||
? currentFingerprint.firstSeen
|
||||
: sort_by === "lastSeen"
|
||||
? currentFingerprint.lastSeen
|
||||
: sort_by
|
||||
? clients[sort_by]
|
||||
: undefined;
|
||||
|
||||
const listDevicesQuery = baseQuery
|
||||
.limit(pageSize)
|
||||
.offset(pageSize * (page - 1))
|
||||
.orderBy(
|
||||
sortColumn
|
||||
sort_by
|
||||
? order === "asc"
|
||||
? asc(sortColumn)
|
||||
: desc(sortColumn)
|
||||
? asc(clients[sort_by])
|
||||
: desc(clients[sort_by])
|
||||
: asc(clients.clientId)
|
||||
);
|
||||
|
||||
|
||||
@@ -45,9 +45,7 @@ import {
|
||||
verifySiteResourceAccess,
|
||||
verifyOlmAccess,
|
||||
verifyLimits,
|
||||
verifyResourcePolicyAccess,
|
||||
verifyAiProviderAccess,
|
||||
verifyAiModelAccess
|
||||
verifyResourcePolicyAccess
|
||||
} from "@server/middlewares";
|
||||
import { ActionsEnum } from "@server/auth/actions";
|
||||
import rateLimit, { ipKeyGenerator } from "express-rate-limit";
|
||||
@@ -57,7 +55,6 @@ import { createStore } from "#dynamic/lib/rateLimitStore";
|
||||
import { logActionAudit } from "#dynamic/middlewares";
|
||||
import { checkRoundTripMessage } from "./ws";
|
||||
import * as labels from "@server/routers/labels";
|
||||
import * as aiProvider from "@server/routers/aiProvider";
|
||||
|
||||
// Root routes
|
||||
export const unauthenticated = Router();
|
||||
@@ -1369,99 +1366,6 @@ authenticated.get(
|
||||
|
||||
authenticated.get("/ws/round-trip-message/:messageId", checkRoundTripMessage);
|
||||
|
||||
authenticated.put(
|
||||
"/org/:orgId/ai-provider",
|
||||
verifyOrgAccess,
|
||||
verifyUserHasAction(ActionsEnum.createAiProvider),
|
||||
logActionAudit(ActionsEnum.createAiProvider),
|
||||
aiProvider.createAiProvider
|
||||
);
|
||||
|
||||
authenticated.get(
|
||||
"/org/:orgId/ai-providers",
|
||||
verifyOrgAccess,
|
||||
verifyUserHasAction(ActionsEnum.listAiProviders),
|
||||
aiProvider.listAiProviders
|
||||
);
|
||||
|
||||
authenticated.get(
|
||||
"/ai-provider/:providerId",
|
||||
verifyAiProviderAccess,
|
||||
verifyUserHasAction(ActionsEnum.getAiProvider),
|
||||
aiProvider.getAiProvider
|
||||
);
|
||||
|
||||
authenticated.put(
|
||||
"/ai-provider/:providerId/target",
|
||||
verifyAiProviderAccess,
|
||||
verifySiteAccess,
|
||||
verifyLimits,
|
||||
verifyUserHasAction(ActionsEnum.createTarget),
|
||||
logActionAudit(ActionsEnum.createTarget),
|
||||
target.createTarget
|
||||
);
|
||||
|
||||
authenticated.get(
|
||||
"/ai-provider/:providerId/targets",
|
||||
verifyAiProviderAccess,
|
||||
verifyUserHasAction(ActionsEnum.listTargets),
|
||||
target.listTargets
|
||||
);
|
||||
|
||||
authenticated.post(
|
||||
"/ai-provider/:providerId",
|
||||
verifyAiProviderAccess,
|
||||
verifyUserHasAction(ActionsEnum.updateAiProvider),
|
||||
logActionAudit(ActionsEnum.updateAiProvider),
|
||||
aiProvider.updateAiProvider
|
||||
);
|
||||
|
||||
authenticated.delete(
|
||||
"/ai-provider/:providerId",
|
||||
verifyAiProviderAccess,
|
||||
verifyUserHasAction(ActionsEnum.deleteAiProvider),
|
||||
logActionAudit(ActionsEnum.deleteAiProvider),
|
||||
aiProvider.deleteAiProvider
|
||||
);
|
||||
|
||||
authenticated.put(
|
||||
"/ai-provider/:providerId/model",
|
||||
verifyAiProviderAccess,
|
||||
verifyUserHasAction(ActionsEnum.createAiModel),
|
||||
logActionAudit(ActionsEnum.createAiModel),
|
||||
aiProvider.createAiModel
|
||||
);
|
||||
|
||||
authenticated.get(
|
||||
"/ai-provider/:providerId/models",
|
||||
verifyAiProviderAccess,
|
||||
verifyUserHasAction(ActionsEnum.listAiModels),
|
||||
aiProvider.listAiModels
|
||||
);
|
||||
|
||||
authenticated.get(
|
||||
"/ai-model/:modelId",
|
||||
verifyAiModelAccess,
|
||||
verifyUserHasAction(ActionsEnum.getAiModel),
|
||||
aiProvider.getAiModel
|
||||
);
|
||||
|
||||
authenticated.post(
|
||||
"/ai-model/:modelId",
|
||||
verifyAiModelAccess,
|
||||
verifyUserHasAction(ActionsEnum.updateAiModel),
|
||||
logActionAudit(ActionsEnum.updateAiModel),
|
||||
aiProvider.updateAiModel
|
||||
);
|
||||
|
||||
authenticated.delete(
|
||||
"/ai-model/:modelId",
|
||||
verifyAiModelAccess,
|
||||
verifyUserHasAction(ActionsEnum.deleteAiModel),
|
||||
logActionAudit(ActionsEnum.deleteAiModel),
|
||||
aiProvider.deleteAiModel
|
||||
);
|
||||
|
||||
authenticated.get(
|
||||
"/org/:orgId/labels",
|
||||
verifyOrgAccess,
|
||||
|
||||
@@ -100,7 +100,7 @@ export async function generateRelayMappings(exitNode: ExitNode) {
|
||||
// Filter to sites with the required fields up front so the rest of the
|
||||
// function can safely treat endpoint/subnet/listenPort as defined.
|
||||
const validSites = sitesRes.filter(
|
||||
(s) => s.endpoint && s.exitNodeSubnet && s.listenPort
|
||||
(s) => s.endpoint && s.subnet && s.listenPort
|
||||
);
|
||||
|
||||
if (validSites.length === 0) {
|
||||
@@ -136,7 +136,7 @@ export async function generateRelayMappings(exitNode: ExitNode) {
|
||||
if (
|
||||
peer.orgId == null ||
|
||||
!peer.endpoint ||
|
||||
!peer.exitNodeSubnet ||
|
||||
!peer.subnet ||
|
||||
!peer.listenPort
|
||||
) {
|
||||
continue;
|
||||
@@ -183,7 +183,7 @@ export async function generateRelayMappings(exitNode: ExitNode) {
|
||||
// Process each site using the pre-fetched data.
|
||||
for (const site of validSites) {
|
||||
const siteDestination: PeerDestination = {
|
||||
destinationIP: site.exitNodeSubnet!.split("/")[0],
|
||||
destinationIP: site.subnet!.split("/")[0],
|
||||
destinationPort: site.listenPort! || 1 // this satisfies gerbil for now but should be reevaluated
|
||||
};
|
||||
|
||||
@@ -207,7 +207,7 @@ export async function generateRelayMappings(exitNode: ExitNode) {
|
||||
continue;
|
||||
}
|
||||
addDestination(site.endpoint!, {
|
||||
destinationIP: peer.exitNodeSubnet!.split("/")[0],
|
||||
destinationIP: peer.subnet!.split("/")[0],
|
||||
destinationPort: peer.listenPort! || 1 // this satisfies gerbil for now but should be reevaluated
|
||||
});
|
||||
}
|
||||
|
||||
@@ -89,7 +89,7 @@ export async function generateGerbilConfig(exitNode: ExitNode) {
|
||||
and(
|
||||
eq(sites.exitNodeId, exitNode.exitNodeId),
|
||||
isNotNull(sites.pubKey),
|
||||
isNotNull(sites.exitNodeSubnet)
|
||||
isNotNull(sites.subnet)
|
||||
)
|
||||
);
|
||||
|
||||
@@ -103,7 +103,7 @@ export async function generateGerbilConfig(exitNode: ExitNode) {
|
||||
} else if (site.type === "newt") {
|
||||
return {
|
||||
publicKey: site.pubKey,
|
||||
allowedIps: [site.exitNodeSubnet!]
|
||||
allowedIps: [site.subnet!]
|
||||
};
|
||||
}
|
||||
return {
|
||||
|
||||
@@ -186,7 +186,7 @@ export async function updateAndGenerateEndpointDestinations(
|
||||
.select({
|
||||
siteId: sites.siteId,
|
||||
newtId: newts.newtId,
|
||||
subnet: sites.exitNodeSubnet,
|
||||
subnet: sites.subnet,
|
||||
listenPort: sites.listenPort,
|
||||
publicKey: sites.publicKey,
|
||||
endpoint: clientSitesAssociationsCache.endpoint,
|
||||
|
||||
@@ -13,7 +13,6 @@ import * as apiKeys from "./apiKeys";
|
||||
import * as idp from "./idp";
|
||||
import * as logs from "./auditLogs";
|
||||
import * as siteResource from "./siteResource";
|
||||
import * as aiProvider from "./aiProvider";
|
||||
import {
|
||||
verifyApiKey,
|
||||
verifyApiKeyOrgAccess,
|
||||
@@ -32,8 +31,6 @@ import {
|
||||
verifyLimits,
|
||||
verifyApiKeyDomainAccess,
|
||||
verifyApiKeyResourcePolicyAccess,
|
||||
verifyApiKeyAiProviderAccess,
|
||||
verifyApiKeyAiModelAccess,
|
||||
verifyUserHasAction
|
||||
} from "@server/middlewares";
|
||||
import HttpCode from "@server/types/HttpCode";
|
||||
@@ -1369,95 +1366,3 @@ authenticated.get(
|
||||
verifyApiKeyHasAction(ActionsEnum.listResources),
|
||||
resource.listAllResourceNames
|
||||
);
|
||||
|
||||
authenticated.put(
|
||||
"/org/:orgId/ai-provider",
|
||||
verifyApiKeyOrgAccess,
|
||||
verifyApiKeyHasAction(ActionsEnum.createAiProvider),
|
||||
logActionAudit(ActionsEnum.createAiProvider),
|
||||
aiProvider.createAiProvider
|
||||
);
|
||||
|
||||
authenticated.get(
|
||||
"/org/:orgId/ai-providers",
|
||||
verifyApiKeyOrgAccess,
|
||||
verifyApiKeyHasAction(ActionsEnum.listAiProviders),
|
||||
aiProvider.listAiProviders
|
||||
);
|
||||
|
||||
authenticated.get(
|
||||
"/ai-provider/:providerId",
|
||||
verifyApiKeyAiProviderAccess,
|
||||
verifyApiKeyHasAction(ActionsEnum.getAiProvider),
|
||||
aiProvider.getAiProvider
|
||||
);
|
||||
|
||||
authenticated.put(
|
||||
"/ai-provider/:providerId/target",
|
||||
verifyApiKeyAiProviderAccess,
|
||||
verifyLimits,
|
||||
verifyApiKeyHasAction(ActionsEnum.createTarget),
|
||||
logActionAudit(ActionsEnum.createTarget),
|
||||
target.createTarget
|
||||
);
|
||||
|
||||
authenticated.get(
|
||||
"/ai-provider/:providerId/targets",
|
||||
verifyApiKeyAiProviderAccess,
|
||||
verifyApiKeyHasAction(ActionsEnum.listTargets),
|
||||
target.listTargets
|
||||
);
|
||||
|
||||
authenticated.post(
|
||||
"/ai-provider/:providerId",
|
||||
verifyApiKeyAiProviderAccess,
|
||||
verifyApiKeyHasAction(ActionsEnum.updateAiProvider),
|
||||
logActionAudit(ActionsEnum.updateAiProvider),
|
||||
aiProvider.updateAiProvider
|
||||
);
|
||||
|
||||
authenticated.delete(
|
||||
"/ai-provider/:providerId",
|
||||
verifyApiKeyAiProviderAccess,
|
||||
verifyApiKeyHasAction(ActionsEnum.deleteAiProvider),
|
||||
logActionAudit(ActionsEnum.deleteAiProvider),
|
||||
aiProvider.deleteAiProvider
|
||||
);
|
||||
|
||||
authenticated.put(
|
||||
"/ai-provider/:providerId/model",
|
||||
verifyApiKeyAiProviderAccess,
|
||||
verifyApiKeyHasAction(ActionsEnum.createAiModel),
|
||||
logActionAudit(ActionsEnum.createAiModel),
|
||||
aiProvider.createAiModel
|
||||
);
|
||||
|
||||
authenticated.get(
|
||||
"/ai-provider/:providerId/models",
|
||||
verifyApiKeyAiProviderAccess,
|
||||
verifyApiKeyHasAction(ActionsEnum.listAiModels),
|
||||
aiProvider.listAiModels
|
||||
);
|
||||
|
||||
authenticated.get(
|
||||
"/ai-model/:modelId",
|
||||
verifyApiKeyAiModelAccess,
|
||||
verifyApiKeyHasAction(ActionsEnum.getAiModel),
|
||||
aiProvider.getAiModel
|
||||
);
|
||||
|
||||
authenticated.post(
|
||||
"/ai-model/:modelId",
|
||||
verifyApiKeyAiModelAccess,
|
||||
verifyApiKeyHasAction(ActionsEnum.updateAiModel),
|
||||
logActionAudit(ActionsEnum.updateAiModel),
|
||||
aiProvider.updateAiModel
|
||||
);
|
||||
|
||||
authenticated.delete(
|
||||
"/ai-model/:modelId",
|
||||
verifyApiKeyAiModelAccess,
|
||||
verifyApiKeyHasAction(ActionsEnum.deleteAiModel),
|
||||
logActionAudit(ActionsEnum.deleteAiModel),
|
||||
aiProvider.deleteAiModel
|
||||
);
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import { SiteResource } from "@server/db";
|
||||
import { formatEndpoint, parseEndpoint } from "@server/lib/ip";
|
||||
|
||||
export type SiteResourceDestinationInput = {
|
||||
mode: SiteResource["mode"];
|
||||
mode: "host" | "cidr" | "http" | "ssh";
|
||||
destination: string | null;
|
||||
destinationPort: number | null;
|
||||
scheme: "http" | "https" | null;
|
||||
|
||||
@@ -15,7 +15,7 @@ import {
|
||||
} from "@server/db";
|
||||
import logger from "@server/logger";
|
||||
import { initPeerAddHandshake, updatePeer } from "../olm/peers";
|
||||
import { eq, and, inArray, or, isNotNull, sql } from "drizzle-orm";
|
||||
import { eq, and, inArray } from "drizzle-orm";
|
||||
import config from "@server/lib/config";
|
||||
import { decrypt } from "@server/lib/crypto";
|
||||
import {
|
||||
@@ -211,8 +211,7 @@ export async function buildClientConfigurationForNewtClient(
|
||||
// call rather than letting each resource fetch its own — with thousands
|
||||
// of resources this avoids a concurrent DB/cache stampede for what is
|
||||
// often the very same (e.g. wildcard) certificate.
|
||||
const certByDomain =
|
||||
await batchFetchCertsForSiteResources(allSiteResources);
|
||||
const certByDomain = await batchFetchCertsForSiteResources(allSiteResources);
|
||||
|
||||
const resourceTargetsArr = await Promise.all(
|
||||
allSiteResources.map((resource) =>
|
||||
@@ -241,7 +240,7 @@ export async function buildTargetConfigurationForNewtClient(
|
||||
version?: string | null,
|
||||
remoteExitNodeId?: string
|
||||
) {
|
||||
// Get enabled HTTP/TCP/UDP targets for resources and AI providers
|
||||
// Get all enabled targets with their resource mode information
|
||||
const allTargets = await db
|
||||
.select({
|
||||
resourceId: targets.resourceId,
|
||||
@@ -251,18 +250,15 @@ export async function buildTargetConfigurationForNewtClient(
|
||||
port: targets.port,
|
||||
internalPort: targets.internalPort,
|
||||
enabled: targets.enabled,
|
||||
mode: sql<string>`COALESCE(${resources.mode}, ${targets.mode})`.mapWith(
|
||||
String
|
||||
)
|
||||
mode: resources.mode
|
||||
})
|
||||
.from(targets)
|
||||
.leftJoin(resources, eq(targets.resourceId, resources.resourceId))
|
||||
.innerJoin(resources, eq(targets.resourceId, resources.resourceId))
|
||||
.where(
|
||||
and(
|
||||
eq(targets.siteId, siteId),
|
||||
eq(targets.enabled, true),
|
||||
inArray(targets.mode, ["http", "udp", "tcp"]),
|
||||
or(isNotNull(targets.resourceId), isNotNull(targets.providerId))
|
||||
inArray(targets.mode, ["http", "udp", "tcp"])
|
||||
)
|
||||
);
|
||||
|
||||
|
||||
@@ -95,16 +95,16 @@ export const handleNewtGetConfigMessage: MessageHandler = async (context) => {
|
||||
.limit(1);
|
||||
if (
|
||||
exitNode.reachableAt &&
|
||||
existingSite.exitNodeSubnet &&
|
||||
existingSite.subnet &&
|
||||
existingSite.listenPort
|
||||
) {
|
||||
const payload = {
|
||||
oldDestination: {
|
||||
destinationIP: existingSite.exitNodeSubnet?.split("/")[0],
|
||||
destinationIP: existingSite.subnet?.split("/")[0],
|
||||
destinationPort: existingSite.listenPort || 1 // this satisfies gerbil for now but should be reevaluated
|
||||
},
|
||||
newDestination: {
|
||||
destinationIP: site.exitNodeSubnet?.split("/")[0],
|
||||
destinationIP: site.subnet?.split("/")[0],
|
||||
destinationPort: site.listenPort || 1 // this satisfies gerbil for now but should be reevaluated
|
||||
}
|
||||
};
|
||||
@@ -132,10 +132,7 @@ export const handleNewtGetConfigMessage: MessageHandler = async (context) => {
|
||||
({ targets: dedupedTargets, certs } = dedupeCertsForTargets(targets));
|
||||
}
|
||||
|
||||
const targetsToSend = await convertTargetsIfNecessary(
|
||||
newt.newtId,
|
||||
dedupedTargets
|
||||
); // for backward compatibility with old newt versions that don't support the new target format
|
||||
const targetsToSend = await convertTargetsIfNecessary(newt.newtId, dedupedTargets); // for backward compatibility with old newt versions that don't support the new target format
|
||||
|
||||
return {
|
||||
message: {
|
||||
|
||||
+28
-12
@@ -2,17 +2,14 @@ import { db, sites } from "@server/db";
|
||||
import { MessageHandler } from "@server/routers/ws";
|
||||
import { exitNodes, Newt } from "@server/db";
|
||||
import logger from "@server/logger";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { ne, eq, or, and, count } from "drizzle-orm";
|
||||
import { listExitNodes } from "#dynamic/lib/exitNodes";
|
||||
import { calculateExitNodeWeight } from "@server/lib/exitNodes";
|
||||
|
||||
export const handleNewtExitNodesRequestMessage: MessageHandler = async (
|
||||
context
|
||||
) => {
|
||||
export const handleNewtPingRequestMessage: MessageHandler = async (context) => {
|
||||
const { message, client, sendToClient } = context;
|
||||
const newt = client as Newt;
|
||||
|
||||
logger.info("Handling exit nodes request newt message!");
|
||||
logger.info("Handling ping request newt message!");
|
||||
|
||||
if (!newt) {
|
||||
logger.warn("Newt not found");
|
||||
@@ -57,13 +54,32 @@ export const handleNewtExitNodesRequestMessage: MessageHandler = async (
|
||||
|
||||
const exitNodesPayload = await Promise.all(
|
||||
exitNodesList.map(async (node) => {
|
||||
const weight = await calculateExitNodeWeight(
|
||||
node.exitNodeId,
|
||||
node.maxConnections
|
||||
);
|
||||
// (MAX_CONNECTIONS - current_connections) / MAX_CONNECTIONS)
|
||||
// higher = more desirable
|
||||
// like saying, this node has x% of its capacity left
|
||||
|
||||
if (weight === null) {
|
||||
return null;
|
||||
let weight = 1;
|
||||
const maxConnections = node.maxConnections;
|
||||
if (maxConnections !== null && maxConnections !== undefined) {
|
||||
const [currentConnections] = await db
|
||||
.select({
|
||||
count: count()
|
||||
})
|
||||
.from(sites)
|
||||
.where(
|
||||
and(
|
||||
eq(sites.exitNodeId, node.exitNodeId),
|
||||
eq(sites.online, true)
|
||||
)
|
||||
);
|
||||
|
||||
if (currentConnections.count >= maxConnections) {
|
||||
return null;
|
||||
}
|
||||
|
||||
weight =
|
||||
(maxConnections - currentConnections.count) /
|
||||
maxConnections;
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -1,20 +1,30 @@
|
||||
import { db, newts, remoteExitNodes } from "@server/db";
|
||||
import { db, ExitNode, newts, remoteExitNodes, Transaction } from "@server/db";
|
||||
import { MessageHandler } from "@server/routers/ws";
|
||||
import { exitNodes, Newt, sites } from "@server/db";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { addPeer, deletePeer } from "../gerbil/peers";
|
||||
import logger from "@server/logger";
|
||||
import config from "@server/lib/config";
|
||||
import { findNextAvailableCidr } from "@server/lib/ip";
|
||||
import {
|
||||
ExitNodePingResult,
|
||||
selectBestExitNode,
|
||||
verifyExitNodeOrgAccess
|
||||
} from "#dynamic/lib/exitNodes";
|
||||
import { getUniqueSubnetForExitNode } from "@server/lib/exitNodes";
|
||||
import { fetchContainers } from "./dockerSocket";
|
||||
import { lockManager } from "#dynamic/lib/lock";
|
||||
import { buildTargetConfigurationForNewtClient } from "./buildConfiguration";
|
||||
import { canCompress } from "@server/lib/clientVersionChecks";
|
||||
|
||||
export type ExitNodePingResult = {
|
||||
exitNodeId: number;
|
||||
latencyMs: number;
|
||||
weight: number;
|
||||
error?: string;
|
||||
exitNodeName: string;
|
||||
endpoint: string;
|
||||
wasPreviouslyConnected: boolean;
|
||||
};
|
||||
|
||||
export const handleNewtRegisterMessage: MessageHandler = async (context) => {
|
||||
const { message, client, sendToClient } = context;
|
||||
const newt = client as Newt;
|
||||
@@ -84,12 +94,9 @@ export const handleNewtRegisterMessage: MessageHandler = async (context) => {
|
||||
fetchContainers(newt.newtId);
|
||||
}
|
||||
|
||||
let siteSubnet = oldSite.exitNodeSubnet;
|
||||
let siteSubnet = oldSite.subnet;
|
||||
let exitNodeIdToQuery = oldSite.exitNodeId;
|
||||
if (
|
||||
exitNodeId &&
|
||||
(oldSite.exitNodeId !== exitNodeId || !oldSite.exitNodeSubnet)
|
||||
) {
|
||||
if (exitNodeId && (oldSite.exitNodeId !== exitNodeId || !oldSite.subnet)) {
|
||||
// This effectively moves the exit node to the new one
|
||||
exitNodeIdToQuery = exitNodeId; // Use the provided exitNodeId if it differs from the site's exitNodeId
|
||||
|
||||
@@ -108,7 +115,7 @@ export const handleNewtRegisterMessage: MessageHandler = async (context) => {
|
||||
return;
|
||||
}
|
||||
|
||||
const newSubnet = await getUniqueSubnetForExitNode(exitNode);
|
||||
const newSubnet = await getUniqueSubnetForSite(exitNode);
|
||||
|
||||
if (!newSubnet) {
|
||||
logger.error(
|
||||
@@ -124,7 +131,7 @@ export const handleNewtRegisterMessage: MessageHandler = async (context) => {
|
||||
.set({
|
||||
pubKey: publicKey,
|
||||
exitNodeId: exitNodeId,
|
||||
exitNodeSubnet: newSubnet
|
||||
subnet: newSubnet
|
||||
})
|
||||
.where(eq(sites.siteId, siteId))
|
||||
.returning();
|
||||
@@ -243,3 +250,40 @@ export const handleNewtRegisterMessage: MessageHandler = async (context) => {
|
||||
excludeSender: false // Include sender in broadcast
|
||||
};
|
||||
};
|
||||
|
||||
async function getUniqueSubnetForSite(
|
||||
exitNode: ExitNode,
|
||||
trx: Transaction | typeof db = db
|
||||
): Promise<string | null> {
|
||||
const lockKey = `subnet-allocation:${exitNode.exitNodeId}`;
|
||||
|
||||
return await lockManager.withLock(
|
||||
lockKey,
|
||||
async () => {
|
||||
const sitesQuery = await trx
|
||||
.select({
|
||||
subnet: sites.subnet
|
||||
})
|
||||
.from(sites)
|
||||
.where(eq(sites.exitNodeId, exitNode.exitNodeId));
|
||||
|
||||
const blockSize = config.getRawConfig().gerbil.site_block_size;
|
||||
const subnets = sitesQuery
|
||||
.map((site) => site.subnet)
|
||||
.filter(
|
||||
(subnet) =>
|
||||
subnet &&
|
||||
/^(\d{1,3}\.){3}\d{1,3}\/\d{1,2}$/.test(subnet)
|
||||
)
|
||||
.filter((subnet) => subnet !== null);
|
||||
subnets.push(exitNode.address.replace(/\/\d+$/, `/${blockSize}`));
|
||||
const newSubnet = findNextAvailableCidr(
|
||||
subnets,
|
||||
blockSize,
|
||||
exitNode.address
|
||||
);
|
||||
return newSubnet;
|
||||
},
|
||||
5000 // 5 second lock TTL - subnet allocation should be quick
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ export * from "./handleNewtRegisterMessage";
|
||||
export * from "./handleReceiveBandwidthMessage";
|
||||
export * from "./handleNewtGetConfigMessage";
|
||||
export * from "./handleSocketMessages";
|
||||
export * from "./handleNewtExitNodesRequestMessage";
|
||||
export * from "./handleNewtPingRequestMessage";
|
||||
export * from "./handleApplyBlueprintMessage";
|
||||
export * from "./handleNewtPingMessage";
|
||||
export * from "./handleNewtDisconnectingMessage";
|
||||
|
||||
@@ -19,7 +19,6 @@ import logger from "@server/logger";
|
||||
import { and, eq, inArray } from "drizzle-orm";
|
||||
import { addPeer, deletePeer } from "../newt/peers";
|
||||
import config from "@server/lib/config";
|
||||
import { SiR } from "react-icons/si";
|
||||
|
||||
export async function buildSiteConfigurationForOlmClient(
|
||||
client: Client,
|
||||
@@ -39,8 +38,6 @@ export async function buildSiteConfigurationForOlmClient(
|
||||
aliases: Alias[];
|
||||
}[] = [];
|
||||
|
||||
let exitNodeAliases: string[] = [];
|
||||
|
||||
// Get all sites data
|
||||
const sitesData = await db
|
||||
.select()
|
||||
@@ -51,6 +48,10 @@ export async function buildSiteConfigurationForOlmClient(
|
||||
)
|
||||
.where(eq(clientSitesAssociationsCache.clientId, client.clientId));
|
||||
|
||||
if (sitesData.length === 0) {
|
||||
return siteConfigurations;
|
||||
}
|
||||
|
||||
// Batch-fetch every site resource this client has access to across ALL sites
|
||||
// in a single query, then group by siteId in memory. This avoids issuing one
|
||||
// query per site (which would be N round-trips for N sites).
|
||||
@@ -67,8 +68,8 @@ export async function buildSiteConfigurationForOlmClient(
|
||||
clientSiteResourcesAssociationsCache.siteResourceId
|
||||
)
|
||||
)
|
||||
.leftJoin(networks, eq(siteResources.networkId, networks.networkId))
|
||||
.leftJoin(siteNetworks, eq(networks.networkId, siteNetworks.networkId))
|
||||
.innerJoin(networks, eq(siteResources.networkId, networks.networkId))
|
||||
.innerJoin(siteNetworks, eq(networks.networkId, siteNetworks.networkId))
|
||||
.where(
|
||||
and(
|
||||
eq(
|
||||
@@ -80,15 +81,7 @@ export async function buildSiteConfigurationForOlmClient(
|
||||
);
|
||||
|
||||
const siteResourcesBySiteId = new Map<number, SiteResource[]>();
|
||||
let siteResourcesForExitNode = [];
|
||||
for (const row of allClientSiteResources) {
|
||||
if (row.siteResource.requiresExitNodeConnection) {
|
||||
siteResourcesForExitNode.push(row.siteResource);
|
||||
}
|
||||
if (!row.siteId) {
|
||||
// because we are doing a leftJoin above to get the inference resources without a network / sites
|
||||
continue;
|
||||
}
|
||||
const arr = siteResourcesBySiteId.get(row.siteId);
|
||||
if (arr) {
|
||||
arr.push(row.siteResource);
|
||||
@@ -97,17 +90,6 @@ export async function buildSiteConfigurationForOlmClient(
|
||||
}
|
||||
}
|
||||
|
||||
exitNodeAliases = siteResourcesForExitNode
|
||||
.map((sr) => sr.alias)
|
||||
.filter((a) => a != null);
|
||||
|
||||
if (sitesData.length == 0) {
|
||||
return {
|
||||
siteConfigurations,
|
||||
exitNodeAliases
|
||||
};
|
||||
}
|
||||
|
||||
// Batch-fetch exit nodes for all sites in one query (only needed in relay mode).
|
||||
const exitNodesById = new Map<number, typeof exitNodes.$inferSelect>();
|
||||
if (!jitMode && relay) {
|
||||
@@ -185,7 +167,7 @@ export async function buildSiteConfigurationForOlmClient(
|
||||
peerOps.push(deletePeer(site.siteId, client.pubKey!));
|
||||
}
|
||||
|
||||
if (!site.exitNodeSubnet) {
|
||||
if (!site.subnet) {
|
||||
logger.debug(`Site ${site.siteId} has no subnet, skipping`);
|
||||
continue;
|
||||
}
|
||||
@@ -244,8 +226,5 @@ export async function buildSiteConfigurationForOlmClient(
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
siteConfigurations,
|
||||
exitNodeAliases
|
||||
};
|
||||
return siteConfigurations;
|
||||
}
|
||||
|
||||
@@ -1,93 +0,0 @@
|
||||
import { db, clients } from "@server/db";
|
||||
import { MessageHandler } from "@server/routers/ws";
|
||||
import { exitNodes, Olm } from "@server/db";
|
||||
import logger from "@server/logger";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { listExitNodes } from "#dynamic/lib/exitNodes";
|
||||
import { calculateExitNodeWeight } from "@server/lib/exitNodes";
|
||||
|
||||
export const handleOlmExitNodesRequestMessage: MessageHandler = async (
|
||||
context
|
||||
) => {
|
||||
const { message, client: olmClient, sendToClient } = context;
|
||||
const olm = olmClient as Olm;
|
||||
|
||||
logger.info("Handling exit nodes request olm message!");
|
||||
|
||||
if (!olm) {
|
||||
logger.warn("olm not found");
|
||||
return;
|
||||
}
|
||||
|
||||
// Get the olm's orgId through the client relationship
|
||||
if (!olm.clientId) {
|
||||
logger.warn("olm clientId not found");
|
||||
return;
|
||||
}
|
||||
|
||||
const [client] = await db
|
||||
.select({ orgId: clients.orgId })
|
||||
.from(clients)
|
||||
.where(eq(clients.clientId, olm.clientId))
|
||||
.limit(1);
|
||||
|
||||
if (!client || !client.orgId) {
|
||||
logger.warn("client not found");
|
||||
return;
|
||||
}
|
||||
|
||||
const { noCloud, chainId } = message.data;
|
||||
|
||||
const exitNodesList = await listExitNodes(
|
||||
client.orgId,
|
||||
true,
|
||||
noCloud || false,
|
||||
olm.clientId
|
||||
); // filter for only the online ones
|
||||
|
||||
let lastExitNodeId = null;
|
||||
if (olm.clientId) {
|
||||
const [lastExitNode] = await db
|
||||
.select()
|
||||
.from(clients)
|
||||
.where(eq(clients.clientId, olm.clientId))
|
||||
.limit(1);
|
||||
lastExitNodeId = lastExitNode?.exitNodeId || null;
|
||||
}
|
||||
|
||||
const exitNodesPayload = await Promise.all(
|
||||
exitNodesList.map(async (node) => {
|
||||
const weight = await calculateExitNodeWeight(
|
||||
node.exitNodeId,
|
||||
node.maxConnections
|
||||
);
|
||||
|
||||
if (weight === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
exitNodeId: node.exitNodeId,
|
||||
exitNodeName: node.name,
|
||||
endpoint: node.endpoint,
|
||||
weight,
|
||||
wasPreviouslyConnected: node.exitNodeId === lastExitNodeId
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
// filter out null values
|
||||
const filteredExitNodes = exitNodesPayload.filter((node) => node !== null);
|
||||
|
||||
return {
|
||||
message: {
|
||||
type: "olm/ping/exitNodes",
|
||||
data: {
|
||||
exitNodes: filteredExitNodes,
|
||||
chainId: chainId
|
||||
}
|
||||
},
|
||||
broadcast: false, // Send to all clients
|
||||
excludeSender: false // Include sender in broadcast
|
||||
};
|
||||
};
|
||||
@@ -1,4 +1,4 @@
|
||||
import { db, ExitNode, exitNodes, orgs, primaryDb } from "@server/db";
|
||||
import { db, orgs, primaryDb } from "@server/db";
|
||||
import { MessageHandler } from "@server/routers/ws";
|
||||
import {
|
||||
clients,
|
||||
@@ -22,12 +22,6 @@ import { canCompress } from "@server/lib/clientVersionChecks";
|
||||
import config from "@server/lib/config";
|
||||
import cache from "#dynamic/lib/cache"; // not using regional here because we need this in the register message handler before we know where the client is
|
||||
import { waitForClientRebuildIdle } from "@server/lib/rebuildClientAssociations";
|
||||
import {
|
||||
ExitNodePingResult,
|
||||
selectBestExitNode,
|
||||
verifyExitNodeOrgAccess
|
||||
} from "#dynamic/lib/exitNodes";
|
||||
import { getUniqueSubnetForExitNode } from "@server/lib/exitNodes";
|
||||
|
||||
const HOLEPUNCH_STALE_CHAIN_THRESHOLD = 18;
|
||||
const HOLEPUNCH_STALE_CHAIN_TTL_SECONDS = 1800;
|
||||
@@ -55,20 +49,11 @@ export const handleOlmRegisterMessage: MessageHandler = async (context) => {
|
||||
olmAgent,
|
||||
orgId,
|
||||
userToken,
|
||||
pingResults,
|
||||
fingerprint,
|
||||
postures,
|
||||
backwardsCompatible,
|
||||
chainId
|
||||
} = message.data;
|
||||
|
||||
if (backwardsCompatible) {
|
||||
logger.debug(
|
||||
"[handleOlmRegisterMessage] Backwards compatible mode detected - not sending connect message and waiting for ping response."
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!olm.clientId) {
|
||||
logger.warn("[handleOlmRegisterMessage] Olm client ID not found");
|
||||
sendOlmError(OlmErrorCodes.CLIENT_ID_NOT_FOUND, olm.olmId);
|
||||
@@ -299,64 +284,7 @@ export const handleOlmRegisterMessage: MessageHandler = async (context) => {
|
||||
return;
|
||||
}
|
||||
|
||||
let exitNodeId: number | undefined;
|
||||
if (pingResults) {
|
||||
const bestPingResult = selectBestExitNode(
|
||||
pingResults as ExitNodePingResult[]
|
||||
);
|
||||
if (!bestPingResult) {
|
||||
logger.warn("No suitable exit node found based on ping results");
|
||||
}
|
||||
exitNodeId = bestPingResult?.exitNodeId;
|
||||
}
|
||||
|
||||
let clientSubnet = client.exitNodeSubnet;
|
||||
if (
|
||||
exitNodeId &&
|
||||
(client.exitNodeId !== exitNodeId || !client.exitNodeSubnet)
|
||||
) {
|
||||
const { exitNode, hasAccess } = await verifyExitNodeOrgAccess(
|
||||
exitNodeId,
|
||||
client.orgId
|
||||
);
|
||||
|
||||
if (!exitNode) {
|
||||
logger.warn("[handleOlmRegisterMessage] Exit node not found", {
|
||||
orgId: client.orgId,
|
||||
clientId: client.clientId
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (!hasAccess) {
|
||||
logger.warn(
|
||||
"[handleOlmRegisterMessage] Not authorized to use this exit node",
|
||||
{ orgId: client.orgId, clientId: client.clientId }
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// TODO: IF WE DO NOT HAVE AN INFERENCE RESOURCE DO WE NEED TO BE HOLDING A SUBNET ON THE CLIENT?
|
||||
|
||||
const newSubnet = await getUniqueSubnetForExitNode(exitNode);
|
||||
|
||||
if (!newSubnet) {
|
||||
logger.error(
|
||||
`[handleOlmRegisterMessage] No available subnets found for exit node id ${exitNodeId} and client id ${client.clientId}`,
|
||||
{ orgId: client.orgId, clientId: client.clientId }
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
clientSubnet = newSubnet;
|
||||
}
|
||||
|
||||
if (
|
||||
client.pubKey !== publicKey ||
|
||||
client.archived ||
|
||||
client.exitNodeId !== exitNodeId ||
|
||||
client.exitNodeSubnet !== clientSubnet
|
||||
) {
|
||||
if (client.pubKey !== publicKey || client.archived) {
|
||||
logger.info(
|
||||
"[handleOlmRegisterMessage] Public key mismatch. Updating public key and clearing session info...",
|
||||
{ orgId: client.orgId, clientId: client.clientId }
|
||||
@@ -366,9 +294,7 @@ export const handleOlmRegisterMessage: MessageHandler = async (context) => {
|
||||
.update(clients)
|
||||
.set({
|
||||
pubKey: publicKey,
|
||||
archived: false,
|
||||
exitNodeId: exitNodeId, // this can be undefined if no exit node was selected, which is fine just means we cant talk to the node or connect to it
|
||||
exitNodeSubnet: clientSubnet
|
||||
archived: false
|
||||
})
|
||||
.where(eq(clients.clientId, client.clientId));
|
||||
|
||||
@@ -450,28 +376,14 @@ export const handleOlmRegisterMessage: MessageHandler = async (context) => {
|
||||
return;
|
||||
}
|
||||
|
||||
let exitNode: ExitNode | null = null;
|
||||
if (exitNodeId) {
|
||||
[exitNode] = await db
|
||||
.select()
|
||||
.from(exitNodes)
|
||||
.where(eq(exitNodes.exitNodeId, exitNodeId))
|
||||
.limit(1);
|
||||
}
|
||||
|
||||
// NOTE: its important that the client here is the old client and the public key is the new key
|
||||
await waitForClientRebuildIdle(olm.clientId);
|
||||
|
||||
const { siteConfigurations, exitNodeAliases } =
|
||||
await buildSiteConfigurationForOlmClient(
|
||||
client,
|
||||
publicKey,
|
||||
relay,
|
||||
jitMode
|
||||
);
|
||||
|
||||
logger.info(
|
||||
`+++++++++++++++++++++++++++++++ ExitNode Aliases: ${exitNodeAliases}`
|
||||
const siteConfigurations = await buildSiteConfigurationForOlmClient(
|
||||
client,
|
||||
publicKey,
|
||||
relay,
|
||||
jitMode
|
||||
);
|
||||
|
||||
// Return connect message with all site configurations
|
||||
@@ -482,17 +394,6 @@ export const handleOlmRegisterMessage: MessageHandler = async (context) => {
|
||||
sites: siteConfigurations,
|
||||
tunnelIP: client.subnet,
|
||||
utilitySubnet: org.utilitySubnet,
|
||||
exitNode:
|
||||
exitNode && client.exitNodeSubnet
|
||||
? {
|
||||
aliases: exitNodeAliases,
|
||||
connect: exitNodeAliases.length > 0, // we do not need to connect to the exit node if we do not have inference resources and right now all site resources on the exit node have an alias
|
||||
endpoint: `${exitNode.endpoint}:${exitNode.listenPort}`,
|
||||
publicKey: exitNode.publicKey,
|
||||
serverIP: exitNode.address.split("/")[0],
|
||||
tunnelIP: client.exitNodeSubnet.split("/")[0]
|
||||
}
|
||||
: undefined,
|
||||
chainId: chainId
|
||||
}
|
||||
},
|
||||
|
||||
@@ -15,4 +15,3 @@ export * from "./handleOlmServerInitAddPeerHandshake";
|
||||
export * from "./offlineChecker";
|
||||
export * from "./handleOlmUnLocalMessage";
|
||||
export * from "./handleOlmLocalMessage";
|
||||
export * from "./handleOlmExitNodesRequestMessage";
|
||||
|
||||
@@ -44,11 +44,11 @@ const createResourceParamsSchema = z.strictObject({
|
||||
});
|
||||
|
||||
function resolveModeFromLegacyFields(data: {
|
||||
mode?: "http" | "ssh" | "rdp" | "vnc" | "tcp" | "udp" | "inference";
|
||||
mode?: "http" | "ssh" | "rdp" | "vnc" | "tcp" | "udp";
|
||||
http?: boolean;
|
||||
protocol?: "tcp" | "udp";
|
||||
}): {
|
||||
mode?: "http" | "ssh" | "rdp" | "vnc" | "tcp" | "udp" | "inference";
|
||||
mode?: "http" | "ssh" | "rdp" | "vnc" | "tcp" | "udp";
|
||||
error?: string;
|
||||
} {
|
||||
if (data.mode) {
|
||||
|
||||
@@ -637,12 +637,11 @@ export async function listResources(
|
||||
${resourcePassword.passwordId}
|
||||
)
|
||||
`;
|
||||
const browserGatewayModes = ["http", "ssh", "rdp", "vnc"] as const;
|
||||
const browserGatewayModes = ["http", "ssh", "rdp", "vnc"];
|
||||
|
||||
switch (authState) {
|
||||
case "none":
|
||||
conditions.push(
|
||||
// TODO: Does inference belong here?
|
||||
or(eq(resources.mode, "tcp"), eq(resources.mode, "udp"))
|
||||
);
|
||||
break;
|
||||
|
||||
@@ -311,13 +311,13 @@ export async function createSite(
|
||||
// lets also make sure there is no overlap with other sites on the exit node
|
||||
const sitesQuery = await db
|
||||
.select({
|
||||
subnet: sites.exitNodeSubnet
|
||||
subnet: sites.subnet
|
||||
})
|
||||
.from(sites)
|
||||
.where(
|
||||
and(
|
||||
eq(sites.exitNodeId, exitNodeId),
|
||||
eq(sites.exitNodeSubnet, subnet)
|
||||
eq(sites.subnet, subnet)
|
||||
)
|
||||
);
|
||||
|
||||
@@ -427,7 +427,7 @@ export async function createSite(
|
||||
exitNodeId,
|
||||
name,
|
||||
niceId: updatedNiceId!,
|
||||
exitNodeSubnet: subnet,
|
||||
subnet,
|
||||
type,
|
||||
pubKey: pubKey || null,
|
||||
status: "approved"
|
||||
@@ -444,7 +444,7 @@ export async function createSite(
|
||||
type,
|
||||
dockerSocketEnabled: false,
|
||||
online: true,
|
||||
exitNodeSubnet: "0.0.0.0/32",
|
||||
subnet: "0.0.0.0/32",
|
||||
status: "approved"
|
||||
})
|
||||
.returning();
|
||||
|
||||
@@ -125,7 +125,7 @@ function querySitesBase() {
|
||||
niceId: sites.niceId,
|
||||
name: sites.name,
|
||||
pubKey: sites.pubKey,
|
||||
subnet: sites.exitNodeSubnet,
|
||||
subnet: sites.subnet,
|
||||
megabytesIn: sites.megabytesIn,
|
||||
megabytesOut: sites.megabytesOut,
|
||||
orgName: orgs.name,
|
||||
|
||||
@@ -43,6 +43,7 @@ const PickSiteDefaultsResponseDataSchema = z.object({
|
||||
clientAddress: z.string().optional()
|
||||
});
|
||||
|
||||
|
||||
registry.registerPath({
|
||||
method: "get",
|
||||
path: "/org/{orgId}/pick-site-defaults",
|
||||
@@ -59,9 +60,7 @@ registry.registerPath({
|
||||
description: "Successful response",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: createApiResponseSchema(
|
||||
PickSiteDefaultsResponseDataSchema
|
||||
)
|
||||
schema: createApiResponseSchema(PickSiteDefaultsResponseDataSchema)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -109,7 +108,7 @@ export async function pickSiteDefaults(
|
||||
// list all of the sites on that exit node
|
||||
const sitesQuery = await db
|
||||
.select({
|
||||
subnet: sites.exitNodeSubnet
|
||||
subnet: sites.subnet
|
||||
})
|
||||
.from(sites)
|
||||
.where(eq(sites.exitNodeId, randomExitNode.exitNodeId));
|
||||
|
||||
@@ -29,7 +29,7 @@ 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, inArray, ne } from "drizzle-orm";
|
||||
import { and, eq, inArray } from "drizzle-orm";
|
||||
import { NextFunction, Request, Response } from "express";
|
||||
import createHttpError from "http-errors";
|
||||
import { z } from "zod";
|
||||
@@ -49,7 +49,7 @@ const createSiteResourceSchema = z
|
||||
name: z.string().min(1).max(255),
|
||||
niceId: z.string().optional(),
|
||||
// protocol: z.enum(["tcp", "udp"]).optional(),
|
||||
mode: z.enum(["host", "cidr", "http", "ssh", "inference"]),
|
||||
mode: z.enum(["host", "cidr", "http", "ssh"]),
|
||||
ssl: z.boolean().optional(), // only used for http mode
|
||||
scheme: z.enum(["http", "https"]).optional(),
|
||||
siteIds: z.array(z.int()).optional(),
|
||||
@@ -171,9 +171,6 @@ const createSiteResourceSchema = z
|
||||
)
|
||||
.refine(
|
||||
(data) => {
|
||||
if (data.mode == "inference") {
|
||||
return true;
|
||||
}
|
||||
return (
|
||||
(data.siteIds !== undefined && data.siteIds.length > 0) ||
|
||||
data.siteId !== undefined
|
||||
@@ -490,11 +487,7 @@ export async function createSiteResource(
|
||||
.where(
|
||||
and(
|
||||
eq(siteResources.orgId, orgId),
|
||||
eq(siteResources.alias, alias.trim()),
|
||||
ne(
|
||||
siteResources.requiresExitNodeConnection,
|
||||
mode == "inference"
|
||||
) // exclude looking at the ones on exit nodes if this is an inference resource
|
||||
eq(siteResources.alias, alias.trim())
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
@@ -531,7 +524,6 @@ export async function createSiteResource(
|
||||
let aliasAddress: string | null = null;
|
||||
let releaseAliasLock: (() => Promise<void>) | null = null;
|
||||
if (mode === "host" || mode === "http" || mode === "ssh") {
|
||||
// no alias address but we do have an alias for inference
|
||||
const { value, release } =
|
||||
await getNextAvailableAliasAddress(orgId);
|
||||
aliasAddress = value;
|
||||
@@ -541,24 +533,21 @@ export async function createSiteResource(
|
||||
let newSiteResource: SiteResource | undefined;
|
||||
try {
|
||||
await db.transaction(async (trx) => {
|
||||
let network: typeof networks.$inferSelect | undefined;
|
||||
if (mode !== "inference") {
|
||||
[network] = await trx
|
||||
.insert(networks)
|
||||
.values({
|
||||
scope: "resource",
|
||||
orgId: orgId
|
||||
})
|
||||
.returning();
|
||||
const [network] = await trx
|
||||
.insert(networks)
|
||||
.values({
|
||||
scope: "resource",
|
||||
orgId: orgId
|
||||
})
|
||||
.returning();
|
||||
|
||||
if (!network) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.INTERNAL_SERVER_ERROR,
|
||||
`Failed to create network`
|
||||
)
|
||||
);
|
||||
}
|
||||
if (!network) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.INTERNAL_SERVER_ERROR,
|
||||
`Failed to create network`
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
let tcpPortRangeStringAdjusted = tcpPortRangeString;
|
||||
@@ -577,7 +566,7 @@ export async function createSiteResource(
|
||||
name,
|
||||
mode,
|
||||
ssl,
|
||||
networkId: network ? network.networkId : null,
|
||||
networkId: network.networkId,
|
||||
destination: destination, // the ssh can be null
|
||||
scheme,
|
||||
destinationPort,
|
||||
@@ -593,8 +582,7 @@ export async function createSiteResource(
|
||||
(mode == "http" || mode == "ssh" ? true : false), // default to true for http resources, otherwise false
|
||||
domainId,
|
||||
subdomain: finalSubdomain,
|
||||
fullDomain,
|
||||
requiresExitNodeConnection: mode === "inference" // in the future we might want to have different modes that do this
|
||||
fullDomain
|
||||
};
|
||||
if (isLicensedSshPam) {
|
||||
if (authDaemonPort !== undefined)
|
||||
@@ -612,13 +600,11 @@ export async function createSiteResource(
|
||||
|
||||
//////////////////// update the associations ////////////////////
|
||||
|
||||
if (network) {
|
||||
for (const siteId of siteIds) {
|
||||
await trx.insert(siteNetworks).values({
|
||||
siteId: siteId,
|
||||
networkId: network.networkId
|
||||
});
|
||||
}
|
||||
for (const siteId of siteIds) {
|
||||
await trx.insert(siteNetworks).values({
|
||||
siteId: siteId,
|
||||
networkId: network.networkId
|
||||
});
|
||||
}
|
||||
|
||||
const [adminRole] = await trx
|
||||
|
||||
@@ -50,7 +50,7 @@ const updateSiteResourceSchema = z
|
||||
)
|
||||
.optional(),
|
||||
// mode: z.enum(["host", "cidr", "port"]).optional(),
|
||||
mode: z.enum(["host", "cidr", "http", "ssh", "inference"]).optional(),
|
||||
mode: z.enum(["host", "cidr", "http", "ssh"]).optional(),
|
||||
ssl: z.boolean().optional(),
|
||||
scheme: z.enum(["http", "https"]).nullish(),
|
||||
destinationPort: z.int().positive().nullish(),
|
||||
@@ -173,9 +173,6 @@ const updateSiteResourceSchema = z
|
||||
)
|
||||
.refine(
|
||||
(data) => {
|
||||
if (data.mode == "inference") {
|
||||
return true;
|
||||
}
|
||||
// if neither is provided, the existing site associations are left unchanged
|
||||
if (data.siteIds === undefined && data.siteId === undefined) {
|
||||
return true;
|
||||
@@ -507,11 +504,7 @@ export async function updateSiteResource(
|
||||
and(
|
||||
eq(siteResources.orgId, existingSiteResource.orgId),
|
||||
eq(siteResources.alias, alias.trim()),
|
||||
ne(siteResources.siteResourceId, siteResourceId), // exclude self
|
||||
ne(
|
||||
siteResources.requiresExitNodeConnection,
|
||||
mode == "inference"
|
||||
) // exclude looking at the ones on exit nodes if this is an inference resource
|
||||
ne(siteResources.siteResourceId, siteResourceId) // exclude self
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
@@ -586,14 +579,13 @@ export async function updateSiteResource(
|
||||
disableIcmp:
|
||||
mode !== undefined
|
||||
? disableIcmp ||
|
||||
(mode == "http" || mode == "ssh" ? true : false)
|
||||
(mode == "http" || mode == "ssh"
|
||||
? true
|
||||
: false)
|
||||
: disableIcmp,
|
||||
domainId,
|
||||
subdomain: finalSubdomain,
|
||||
fullDomain,
|
||||
networkId: mode === "inference" ? null : undefined,
|
||||
requiresExitNodeConnection:
|
||||
mode !== undefined ? mode === "inference" : undefined,
|
||||
...sshPamSet
|
||||
})
|
||||
.where(and(eq(siteResources.siteResourceId, siteResourceId)))
|
||||
@@ -601,20 +593,7 @@ export async function updateSiteResource(
|
||||
|
||||
//////////////////// update the associations ////////////////////
|
||||
|
||||
if (mode === "inference") {
|
||||
// inference resources are not attached to any site network
|
||||
if (existingSiteResource.networkId) {
|
||||
await trx
|
||||
.delete(siteNetworks)
|
||||
.where(
|
||||
eq(
|
||||
siteNetworks.networkId,
|
||||
existingSiteResource.networkId
|
||||
)
|
||||
);
|
||||
}
|
||||
updatedSiteIds = [];
|
||||
} else if (siteIds !== undefined) {
|
||||
if (siteIds !== undefined) {
|
||||
// delete the site - site resources associations
|
||||
await trx
|
||||
.delete(siteNetworks)
|
||||
|
||||
@@ -6,14 +6,7 @@ import {
|
||||
TargetHealthCheck,
|
||||
targetHealthCheck
|
||||
} from "@server/db";
|
||||
import {
|
||||
aiProviders,
|
||||
newts,
|
||||
resources,
|
||||
sites,
|
||||
Target,
|
||||
targets
|
||||
} from "@server/db";
|
||||
import { newts, resources, sites, Target, targets } from "@server/db";
|
||||
import response from "@server/lib/response";
|
||||
import HttpCode from "@server/types/HttpCode";
|
||||
import createHttpError from "http-errors";
|
||||
@@ -36,19 +29,10 @@ import { generateId } from "@server/auth/sessions/app";
|
||||
import config from "@server/lib/config";
|
||||
import { sendBrowserGatewayTargets } from "@server/routers/newt/targets";
|
||||
|
||||
const resourceTargetParamsSchema = z.strictObject({
|
||||
const createTargetParamsSchema = z.strictObject({
|
||||
resourceId: z.coerce.number().int().positive()
|
||||
});
|
||||
|
||||
const providerTargetParamsSchema = z.strictObject({
|
||||
providerId: z.coerce.number().int().positive()
|
||||
});
|
||||
|
||||
const createTargetParamsSchema = z.union([
|
||||
resourceTargetParamsSchema,
|
||||
providerTargetParamsSchema
|
||||
]);
|
||||
|
||||
const createTargetSchema = z
|
||||
.strictObject({
|
||||
siteId: z.int().positive(),
|
||||
@@ -111,7 +95,7 @@ registry.registerPath({
|
||||
description: "Create a target for a resource.",
|
||||
tags: [OpenAPITags.PublicResourceLegacy],
|
||||
request: {
|
||||
params: resourceTargetParamsSchema,
|
||||
params: createTargetParamsSchema,
|
||||
body: {
|
||||
content: {
|
||||
"application/json": {
|
||||
@@ -144,40 +128,7 @@ registry.registerPath({
|
||||
description: "Create a target for a resource.",
|
||||
tags: [OpenAPITags.PublicResource, OpenAPITags.Target],
|
||||
request: {
|
||||
params: resourceTargetParamsSchema,
|
||||
body: {
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: createTargetSchema
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
responses: {
|
||||
200: {
|
||||
description: "Successful response",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: z.object({
|
||||
data: z.record(z.string(), z.any()).nullable(),
|
||||
success: z.boolean(),
|
||||
error: z.boolean(),
|
||||
message: z.string(),
|
||||
status: z.number()
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
registry.registerPath({
|
||||
method: "put",
|
||||
path: "/ai-provider/{providerId}/target",
|
||||
description: "Create a target for an AI provider.",
|
||||
tags: [OpenAPITags.AiProvider],
|
||||
request: {
|
||||
params: providerTargetParamsSchema,
|
||||
params: createTargetParamsSchema,
|
||||
body: {
|
||||
content: {
|
||||
"application/json": {
|
||||
@@ -232,74 +183,21 @@ export async function createTarget(
|
||||
);
|
||||
}
|
||||
|
||||
let resource: typeof resources.$inferSelect | undefined;
|
||||
let provider: typeof aiProviders.$inferSelect | undefined;
|
||||
const { resourceId } = parsedParams.data;
|
||||
|
||||
if ("providerId" in parsedParams.data) {
|
||||
const { providerId } = parsedParams.data;
|
||||
[provider] =
|
||||
req.aiProvider && req.aiProvider.providerId === providerId
|
||||
? [req.aiProvider]
|
||||
: await db
|
||||
.select()
|
||||
.from(aiProviders)
|
||||
.where(eq(aiProviders.providerId, providerId))
|
||||
.limit(1);
|
||||
// get the resource
|
||||
const [resource] = await db
|
||||
.select()
|
||||
.from(resources)
|
||||
.where(eq(resources.resourceId, resourceId));
|
||||
|
||||
if (!provider) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.NOT_FOUND,
|
||||
`AI provider with ID ${providerId} not found`
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
if (provider.routingMode !== "target") {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
"AI provider must use target routing mode"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
if (provider.type !== "custom") {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
"Only custom AI providers support targets"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
targetData.method &&
|
||||
!["http", "https"].includes(targetData.method.toLowerCase())
|
||||
) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
"AI provider target method must be http or https"
|
||||
)
|
||||
);
|
||||
}
|
||||
} else {
|
||||
const { resourceId } = parsedParams.data;
|
||||
[resource] = await db
|
||||
.select()
|
||||
.from(resources)
|
||||
.where(eq(resources.resourceId, resourceId))
|
||||
.limit(1);
|
||||
|
||||
if (!resource) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.NOT_FOUND,
|
||||
`Resource with ID ${resourceId} not found`
|
||||
)
|
||||
);
|
||||
}
|
||||
if (!resource) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.NOT_FOUND,
|
||||
`Resource with ID ${resourceId} not found`
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const siteId = targetData.siteId;
|
||||
@@ -319,24 +217,6 @@ export async function createTarget(
|
||||
);
|
||||
}
|
||||
|
||||
if (provider && site.orgId && site.orgId !== provider.orgId) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
"Site must belong to the AI provider organization"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const resourceId = resource?.resourceId ?? null;
|
||||
const providerId = provider?.providerId ?? null;
|
||||
const targetMode = provider
|
||||
? "http"
|
||||
: (targetData.mode ?? resource?.mode ?? "http");
|
||||
const targetMethod = provider
|
||||
? (targetData.method?.toLowerCase() ?? "https")
|
||||
: targetData.method;
|
||||
|
||||
const plainToken = generateId(48);
|
||||
const encryptedToken = encrypt(
|
||||
plainToken,
|
||||
@@ -350,24 +230,20 @@ export async function createTarget(
|
||||
const existingTargets = await trx
|
||||
.select()
|
||||
.from(targets)
|
||||
.where(
|
||||
providerId
|
||||
? eq(targets.providerId, providerId)
|
||||
: eq(targets.resourceId, resourceId!)
|
||||
);
|
||||
.where(eq(targets.resourceId, resourceId));
|
||||
|
||||
const existingTarget = existingTargets.find(
|
||||
(target) =>
|
||||
target.ip === targetData.ip &&
|
||||
target.port === targetData.port &&
|
||||
target.method === targetMethod &&
|
||||
target.method === targetData.method &&
|
||||
target.siteId === targetData.siteId
|
||||
);
|
||||
|
||||
if (existingTarget) {
|
||||
// log a warning
|
||||
logger.warn(
|
||||
`Target with IP ${targetData.ip}, port ${targetData.port}, method ${targetMethod} already exists for ${providerId ? `AI provider ID ${providerId}` : `resource ID ${resourceId}`}`
|
||||
`Target with IP ${targetData.ip}, port ${targetData.port}, method ${targetData.method} already exists for resource ID ${resourceId}`
|
||||
);
|
||||
}
|
||||
|
||||
@@ -376,10 +252,10 @@ export async function createTarget(
|
||||
.insert(targets)
|
||||
.values({
|
||||
resourceId,
|
||||
providerId,
|
||||
...targetData,
|
||||
mode: targetMode as Target["mode"],
|
||||
method: targetMethod,
|
||||
mode: (targetData.mode ??
|
||||
resource.mode ??
|
||||
"http") as Target["mode"],
|
||||
priority: targetData.priority || 100
|
||||
})
|
||||
.returning();
|
||||
@@ -387,7 +263,7 @@ export async function createTarget(
|
||||
// make sure the target is within the site subnet
|
||||
if (
|
||||
site.type == "wireguard" &&
|
||||
!isIpInCidr(targetData.ip, site.exitNodeSubnet!)
|
||||
!isIpInCidr(targetData.ip, site.subnet!)
|
||||
) {
|
||||
return next(
|
||||
createHttpError(
|
||||
@@ -413,12 +289,13 @@ export async function createTarget(
|
||||
.insert(targets)
|
||||
.values({
|
||||
resourceId,
|
||||
providerId,
|
||||
siteId: site.siteId,
|
||||
ip: targetData.ip,
|
||||
mode: targetMode as Target["mode"],
|
||||
mode: (targetData.mode ??
|
||||
resource.mode ??
|
||||
"http") as Target["mode"],
|
||||
authToken: encryptedToken,
|
||||
method: targetMethod,
|
||||
method: targetData.method,
|
||||
port: targetData.port,
|
||||
internalPort,
|
||||
enabled: targetData.enabled,
|
||||
@@ -444,12 +321,10 @@ export async function createTarget(
|
||||
healthCheck = await trx
|
||||
.insert(targetHealthCheck)
|
||||
.values({
|
||||
orgId: provider?.orgId ?? resource!.orgId,
|
||||
orgId: resource.orgId,
|
||||
targetId: newTarget[0].targetId,
|
||||
siteId: targetData.siteId,
|
||||
name: provider
|
||||
? `AI Provider ${provider.name} - ${targetData.ip}:${targetData.port}`
|
||||
: `Resource ${resource!.name} - ${targetData.ip}:${targetData.port}`,
|
||||
name: `Resource ${resource.name} - ${targetData.ip}:${targetData.port}`,
|
||||
hcEnabled: targetData.hcEnabled ?? false,
|
||||
hcPath: targetData.hcPath ?? null,
|
||||
hcScheme: targetData.hcScheme ?? null,
|
||||
@@ -524,17 +399,10 @@ export async function createTarget(
|
||||
newt.newtId,
|
||||
newTarget,
|
||||
healthCheck,
|
||||
provider
|
||||
? "tcp"
|
||||
: (resource!.mode as string) === "udp"
|
||||
? "udp"
|
||||
: "tcp",
|
||||
resource.mode === "udp" ? "udp" : "tcp",
|
||||
newt.version
|
||||
);
|
||||
} else if (
|
||||
!provider &&
|
||||
["ssh", "rdp", "vnc"].includes(newTarget[0].mode)
|
||||
) {
|
||||
} else if (["ssh", "rdp", "vnc"].includes(newTarget[0].mode)) {
|
||||
await sendBrowserGatewayTargets(
|
||||
newt.newtId,
|
||||
newTarget,
|
||||
|
||||
@@ -79,54 +79,38 @@ export async function deleteTarget(
|
||||
)
|
||||
);
|
||||
}
|
||||
// get the resource
|
||||
const [resource] = await db
|
||||
.select()
|
||||
.from(resources)
|
||||
.where(eq(resources.resourceId, deletedTarget.resourceId!));
|
||||
|
||||
if (
|
||||
(!deletedTarget.resourceId && !deletedTarget.providerId) ||
|
||||
(deletedTarget.resourceId && deletedTarget.providerId)
|
||||
) {
|
||||
if (!resource) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.INTERNAL_SERVER_ERROR,
|
||||
`Target with ID ${targetId} has invalid ownership`
|
||||
HttpCode.NOT_FOUND,
|
||||
`Resource with ID ${deletedTarget.resourceId} not found`
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
let resource: typeof resources.$inferSelect | undefined;
|
||||
if (deletedTarget.resourceId) {
|
||||
[resource] = await db
|
||||
.select()
|
||||
.from(resources)
|
||||
.where(eq(resources.resourceId, deletedTarget.resourceId))
|
||||
.limit(1);
|
||||
// check if there are other targets on the resource
|
||||
const otherTargets = await db
|
||||
.select()
|
||||
.from(targets)
|
||||
.where(
|
||||
and(
|
||||
eq(targets.resourceId, resource.resourceId),
|
||||
ne(targets.targetId, targetId)
|
||||
)
|
||||
);
|
||||
|
||||
if (!resource) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.NOT_FOUND,
|
||||
`Resource with ID ${deletedTarget.resourceId} not found`
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
// check if there are other targets on the resource
|
||||
const otherTargets = await db
|
||||
.select()
|
||||
.from(targets)
|
||||
.where(
|
||||
and(
|
||||
eq(targets.resourceId, resource.resourceId),
|
||||
ne(targets.targetId, targetId)
|
||||
)
|
||||
);
|
||||
|
||||
if (otherTargets.length == 0) {
|
||||
// set the resource status
|
||||
await db
|
||||
.update(resources)
|
||||
.set({ health: "unknown" })
|
||||
.where(eq(resources.resourceId, resource.resourceId));
|
||||
}
|
||||
if (otherTargets.length == 0) {
|
||||
// set the resource status
|
||||
await db
|
||||
.update(resources)
|
||||
.set({ health: "unknown" })
|
||||
.where(eq(resources.resourceId, resource.resourceId));
|
||||
}
|
||||
|
||||
const [site] = await db
|
||||
@@ -153,26 +137,16 @@ export async function deleteTarget(
|
||||
.where(eq(newts.siteId, site.siteId))
|
||||
.limit(1);
|
||||
|
||||
if (
|
||||
deletedTarget.providerId ||
|
||||
["http", "tcp", "udp"].includes(deletedTarget.mode)
|
||||
) {
|
||||
if (["http", "tcp", "udp"].includes(deletedTarget.mode)) {
|
||||
await removeTargets(
|
||||
newt.newtId,
|
||||
// [deletedTarget],
|
||||
[], // deleting the target from newt causes issues because we cant unbind the port. this needs to be fixed in newt before we can do this
|
||||
[deletedHealthCheck],
|
||||
deletedTarget.providerId
|
||||
? "tcp"
|
||||
: (resource!.mode as string) === "udp"
|
||||
? "udp"
|
||||
: "tcp",
|
||||
resource.mode === "udp" ? "udp" : "tcp",
|
||||
newt.version
|
||||
);
|
||||
} else if (
|
||||
!deletedTarget.providerId &&
|
||||
["ssh", "rdp", "vnc"].includes(deletedTarget.mode)
|
||||
) {
|
||||
} else if (["ssh", "rdp", "vnc"].includes(deletedTarget.mode)) {
|
||||
await removeBrowserGatewayTarget(
|
||||
newt.newtId,
|
||||
deletedTarget.targetId,
|
||||
|
||||
@@ -10,19 +10,10 @@ import { fromError } from "zod-validation-error";
|
||||
import logger from "@server/logger";
|
||||
import { OpenAPITags, registry } from "@server/openApi";
|
||||
|
||||
const resourceTargetsParamsSchema = z.strictObject({
|
||||
const listTargetsParamsSchema = z.strictObject({
|
||||
resourceId: z.coerce.number().int().positive()
|
||||
});
|
||||
|
||||
const providerTargetsParamsSchema = z.strictObject({
|
||||
providerId: z.coerce.number().int().positive()
|
||||
});
|
||||
|
||||
const listTargetsParamsSchema = z.union([
|
||||
resourceTargetsParamsSchema,
|
||||
providerTargetsParamsSchema
|
||||
]);
|
||||
|
||||
const listTargetsSchema = z.strictObject({
|
||||
limit: z
|
||||
.string()
|
||||
@@ -38,7 +29,7 @@ const listTargetsSchema = z.strictObject({
|
||||
.pipe(z.int().nonnegative())
|
||||
});
|
||||
|
||||
function queryTargets(owner: { resourceId: number } | { providerId: number }) {
|
||||
function queryTargets(resourceId: number) {
|
||||
const baseQuery = db
|
||||
.select({
|
||||
targetId: targets.targetId,
|
||||
@@ -48,7 +39,6 @@ function queryTargets(owner: { resourceId: number } | { providerId: number }) {
|
||||
port: targets.port,
|
||||
enabled: targets.enabled,
|
||||
resourceId: targets.resourceId,
|
||||
providerId: targets.providerId,
|
||||
siteId: targets.siteId,
|
||||
siteType: sites.type,
|
||||
siteName: sites.name,
|
||||
@@ -81,11 +71,7 @@ function queryTargets(owner: { resourceId: number } | { providerId: number }) {
|
||||
targetHealthCheck,
|
||||
eq(targetHealthCheck.targetId, targets.targetId)
|
||||
)
|
||||
.where(
|
||||
"providerId" in owner
|
||||
? eq(targets.providerId, owner.providerId)
|
||||
: eq(targets.resourceId, owner.resourceId)
|
||||
);
|
||||
.where(eq(targets.resourceId, resourceId));
|
||||
|
||||
return baseQuery;
|
||||
}
|
||||
@@ -108,7 +94,7 @@ registry.registerPath({
|
||||
description: "List targets for a resource.",
|
||||
tags: [OpenAPITags.PublicResourceLegacy],
|
||||
request: {
|
||||
params: resourceTargetsParamsSchema,
|
||||
params: listTargetsParamsSchema,
|
||||
query: listTargetsSchema
|
||||
},
|
||||
responses: {
|
||||
@@ -135,34 +121,7 @@ registry.registerPath({
|
||||
description: "List targets for a resource.",
|
||||
tags: [OpenAPITags.PublicResource, OpenAPITags.Target],
|
||||
request: {
|
||||
params: resourceTargetsParamsSchema,
|
||||
query: listTargetsSchema
|
||||
},
|
||||
responses: {
|
||||
200: {
|
||||
description: "Successful response",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: z.object({
|
||||
data: z.record(z.string(), z.any()).nullable(),
|
||||
success: z.boolean(),
|
||||
error: z.boolean(),
|
||||
message: z.string(),
|
||||
status: z.number()
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
registry.registerPath({
|
||||
method: "get",
|
||||
path: "/ai-provider/{providerId}/targets",
|
||||
description: "List targets for an AI provider.",
|
||||
tags: [OpenAPITags.AiProvider],
|
||||
request: {
|
||||
params: providerTargetsParamsSchema,
|
||||
params: listTargetsParamsSchema,
|
||||
query: listTargetsSchema
|
||||
},
|
||||
responses: {
|
||||
@@ -209,18 +168,14 @@ export async function listTargets(
|
||||
)
|
||||
);
|
||||
}
|
||||
const owner = parsedParams.data;
|
||||
const ownerCondition =
|
||||
"providerId" in owner
|
||||
? eq(targets.providerId, owner.providerId)
|
||||
: eq(targets.resourceId, owner.resourceId);
|
||||
const { resourceId } = parsedParams.data;
|
||||
|
||||
const baseQuery = queryTargets(owner);
|
||||
const baseQuery = queryTargets(resourceId);
|
||||
|
||||
const countQuery = db
|
||||
.select({ count: sql<number>`cast(count(*) as integer)` })
|
||||
.from(targets)
|
||||
.where(ownerCondition);
|
||||
.where(eq(targets.resourceId, resourceId));
|
||||
|
||||
const targetsList = await baseQuery.limit(limit).offset(offset);
|
||||
const totalCountResult = await countQuery;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Request, Response, NextFunction } from "express";
|
||||
import { z } from "zod";
|
||||
import { db, targetHealthCheck } from "@server/db";
|
||||
import { aiProviders, newts, resources, sites, targets } from "@server/db";
|
||||
import { newts, resources, sites, targets } from "@server/db";
|
||||
import { eq } from "drizzle-orm";
|
||||
import response from "@server/lib/response";
|
||||
import HttpCode from "@server/types/HttpCode";
|
||||
@@ -147,68 +147,21 @@ export async function updateTarget(
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
(!target.resourceId && !target.providerId) ||
|
||||
(target.resourceId && target.providerId)
|
||||
) {
|
||||
// get the resource
|
||||
const [resource] = await db
|
||||
.select()
|
||||
.from(resources)
|
||||
.where(eq(resources.resourceId, target.resourceId!));
|
||||
|
||||
if (!resource) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.INTERNAL_SERVER_ERROR,
|
||||
`Target with ID ${targetId} has invalid ownership`
|
||||
HttpCode.NOT_FOUND,
|
||||
`Resource with ID ${target.resourceId} not found`
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
let resource: typeof resources.$inferSelect | undefined;
|
||||
let provider: typeof aiProviders.$inferSelect | undefined;
|
||||
|
||||
if (target.resourceId) {
|
||||
[resource] = await db
|
||||
.select()
|
||||
.from(resources)
|
||||
.where(eq(resources.resourceId, target.resourceId))
|
||||
.limit(1);
|
||||
|
||||
if (!resource) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.NOT_FOUND,
|
||||
`Resource with ID ${target.resourceId} not found`
|
||||
)
|
||||
);
|
||||
}
|
||||
} else {
|
||||
[provider] = await db
|
||||
.select()
|
||||
.from(aiProviders)
|
||||
.where(eq(aiProviders.providerId, target.providerId!))
|
||||
.limit(1);
|
||||
|
||||
if (!provider) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.NOT_FOUND,
|
||||
`AI provider with ID ${target.providerId} not found`
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
parsedBody.data.method !== undefined &&
|
||||
(!parsedBody.data.method ||
|
||||
!["http", "https"].includes(
|
||||
parsedBody.data.method.toLowerCase()
|
||||
))
|
||||
) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
"AI provider target method must be http or https"
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const [site] = await db
|
||||
.select()
|
||||
.from(sites)
|
||||
@@ -224,15 +177,6 @@ export async function updateTarget(
|
||||
);
|
||||
}
|
||||
|
||||
if (provider && site.orgId && site.orgId !== provider.orgId) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
"Site must belong to the AI provider organization"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const { internalPort, targetIps } = await pickPort(site.siteId!, db);
|
||||
|
||||
if (!internalPort) {
|
||||
@@ -277,13 +221,8 @@ export async function updateTarget(
|
||||
}
|
||||
|
||||
const pathMatchTypeRemoved = parsedBody.data.pathMatchType === null;
|
||||
const nextMode = provider
|
||||
? parsedBody.data.mode !== undefined
|
||||
? "http"
|
||||
: undefined
|
||||
: parsedBody.data.mode === null
|
||||
? undefined
|
||||
: parsedBody.data.mode;
|
||||
const nextMode =
|
||||
parsedBody.data.mode === null ? undefined : parsedBody.data.mode;
|
||||
|
||||
let updatedTarget: any;
|
||||
let updatedHc: any;
|
||||
@@ -294,10 +233,7 @@ export async function updateTarget(
|
||||
siteId: parsedBody.data.siteId,
|
||||
ip: parsedBody.data.ip,
|
||||
mode: nextMode,
|
||||
method:
|
||||
provider && parsedBody.data.method
|
||||
? parsedBody.data.method.toLowerCase()
|
||||
: parsedBody.data.method,
|
||||
method: parsedBody.data.method,
|
||||
port: parsedBody.data.port,
|
||||
internalPort,
|
||||
enabled: parsedBody.data.enabled,
|
||||
@@ -432,25 +368,15 @@ export async function updateTarget(
|
||||
.where(eq(newts.siteId, site.siteId))
|
||||
.limit(1);
|
||||
|
||||
if (
|
||||
provider ||
|
||||
["http", "tcp", "udp"].includes(updatedTarget.mode)
|
||||
) {
|
||||
if (["http", "tcp", "udp"].includes(updatedTarget.mode)) {
|
||||
await addTargets(
|
||||
newt.newtId,
|
||||
[updatedTarget],
|
||||
[updatedHc],
|
||||
provider
|
||||
? "tcp"
|
||||
: (resource!.mode as string) === "udp"
|
||||
? "udp"
|
||||
: "tcp",
|
||||
resource.mode === "udp" ? "udp" : "tcp",
|
||||
newt.version
|
||||
);
|
||||
} else if (
|
||||
!provider &&
|
||||
["ssh", "rdp", "vnc"].includes(updatedTarget.mode)
|
||||
) {
|
||||
} else if (["ssh", "rdp", "vnc"].includes(updatedTarget.mode)) {
|
||||
await sendBrowserGatewayTargets(
|
||||
newt.newtId,
|
||||
[updatedTarget],
|
||||
|
||||
@@ -5,7 +5,7 @@ import {
|
||||
handleNewtGetConfigMessage,
|
||||
handleDockerStatusMessage,
|
||||
handleDockerContainersMessage,
|
||||
handleNewtExitNodesRequestMessage,
|
||||
handleNewtPingRequestMessage,
|
||||
handleApplyBlueprintMessage,
|
||||
handleNewtPingMessage,
|
||||
startNewtOfflineChecker,
|
||||
@@ -22,8 +22,7 @@ import {
|
||||
handleOlmDisconnectingMessage,
|
||||
handleOlmServerInitAddPeerHandshake,
|
||||
handleOlmLocalMessage,
|
||||
handleOlmUnLocalMessage,
|
||||
handleOlmExitNodesRequestMessage
|
||||
handleOlmUnLocalMessage
|
||||
} from "../olm";
|
||||
import { handleHealthcheckStatusMessage } from "../target";
|
||||
import { handleRoundTripMessage } from "./handleRoundTripMessage";
|
||||
@@ -38,7 +37,6 @@ export const messageHandlers: Record<string, MessageHandler> = {
|
||||
"olm/wg/local": handleOlmLocalMessage,
|
||||
"olm/wg/unlocal": handleOlmUnLocalMessage,
|
||||
"olm/ping": handleOlmPingMessage,
|
||||
"olm/ping/request": handleOlmExitNodesRequestMessage,
|
||||
"olm/disconnecting": handleOlmDisconnectingMessage,
|
||||
"newt/disconnecting": handleNewtDisconnectingMessage,
|
||||
"newt/ping": handleNewtPingMessage,
|
||||
@@ -47,7 +45,7 @@ export const messageHandlers: Record<string, MessageHandler> = {
|
||||
"newt/receive-bandwidth": handleReceiveBandwidthMessage,
|
||||
"newt/socket/status": handleDockerStatusMessage,
|
||||
"newt/socket/containers": handleDockerContainersMessage,
|
||||
"newt/ping/request": handleNewtExitNodesRequestMessage,
|
||||
"newt/ping/request": handleNewtPingRequestMessage,
|
||||
"newt/blueprint/apply": handleApplyBlueprintMessage,
|
||||
"newt/healthcheck/status": handleHealthcheckStatusMessage,
|
||||
"ws/round-trip/complete": handleRoundTripMessage
|
||||
|
||||
@@ -104,9 +104,7 @@ export default async function ClientsPage(props: ClientsPageProps) {
|
||||
archived: Boolean(client.archived),
|
||||
blocked: Boolean(client.blocked),
|
||||
approvalState: client.approvalState,
|
||||
fingerprint,
|
||||
firstSeen: client.firstSeen ?? null,
|
||||
lastSeen: client.lastSeen ?? null
|
||||
fingerprint
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -1,107 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
SettingsContainer,
|
||||
SettingsFormCell,
|
||||
SettingsFormGrid,
|
||||
SettingsSection,
|
||||
SettingsSectionBody,
|
||||
SettingsSectionDescription,
|
||||
SettingsSectionFooter,
|
||||
SettingsSectionForm,
|
||||
SettingsSectionHeader,
|
||||
SettingsSectionTitle
|
||||
} from "@app/components/Settings";
|
||||
import { Button } from "@app/components/ui/button";
|
||||
import { Form } from "@app/components/ui/form";
|
||||
import { createInferenceFormSchema } from "@app/lib/privateResourceForm";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useActionState, useMemo, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { z } from "zod";
|
||||
import { PrivateResourceSitesField } from "@app/components/PrivateResourceSitesField";
|
||||
import { PrivateResourceInferenceDestinationFields } from "@app/components/PrivateResourceDestinationFields";
|
||||
import { PrivateResourcePortRanges } from "@app/components/PrivateResourcePortRanges";
|
||||
import { useSaveSiteResource } from "@app/hooks/useSaveSiteResource";
|
||||
import {
|
||||
asAnyControl,
|
||||
asAnySetValue,
|
||||
asAnyWatch
|
||||
} from "@app/lib/formControlUtils";
|
||||
import { buildSelectedSitesForResource } from "@app/lib/privateResourceUtils";
|
||||
|
||||
export default function PrivateResourceInferencePage() {
|
||||
const t = useTranslations();
|
||||
const { save, siteResource } = useSaveSiteResource();
|
||||
const [selectedSites, setSelectedSites] = useState(() =>
|
||||
buildSelectedSitesForResource(siteResource)
|
||||
);
|
||||
|
||||
const formSchema = useMemo(() => createInferenceFormSchema(t), [t]);
|
||||
type FormValues = z.infer<typeof formSchema>;
|
||||
|
||||
const form = useForm<FormValues>({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: {
|
||||
mode: "inference",
|
||||
alias: siteResource.alias ?? null
|
||||
}
|
||||
});
|
||||
|
||||
const [, formAction, saveLoading] = useActionState(async () => {
|
||||
const isValid = await form.trigger();
|
||||
if (!isValid) return;
|
||||
|
||||
const data = form.getValues();
|
||||
await save({
|
||||
mode: "inference",
|
||||
alias: data.alias
|
||||
});
|
||||
}, null);
|
||||
|
||||
return (
|
||||
<SettingsContainer>
|
||||
<SettingsSection>
|
||||
<SettingsSectionHeader>
|
||||
<SettingsSectionTitle>
|
||||
{t("hostSettings")}
|
||||
</SettingsSectionTitle>
|
||||
<SettingsSectionDescription>
|
||||
{t("editInternalResourceDialogDestinationDescription")}
|
||||
</SettingsSectionDescription>
|
||||
</SettingsSectionHeader>
|
||||
|
||||
<SettingsSectionBody>
|
||||
<SettingsSectionForm variant="half">
|
||||
<Form {...form}>
|
||||
<form
|
||||
action={formAction}
|
||||
id="private-resource-host-form"
|
||||
>
|
||||
<SettingsFormGrid>
|
||||
<SettingsFormCell span="full">
|
||||
<PrivateResourceInferenceDestinationFields
|
||||
control={asAnyControl(form.control)}
|
||||
watch={asAnyWatch(form.watch)}
|
||||
/>
|
||||
</SettingsFormCell>
|
||||
</SettingsFormGrid>
|
||||
</form>
|
||||
</Form>
|
||||
</SettingsSectionForm>
|
||||
</SettingsSectionBody>
|
||||
|
||||
<SettingsSectionFooter>
|
||||
<Button
|
||||
type="submit"
|
||||
form="private-resource-host-form"
|
||||
loading={saveLoading}
|
||||
>
|
||||
{t("saveSettings")}
|
||||
</Button>
|
||||
</SettingsSectionFooter>
|
||||
</SettingsSection>
|
||||
</SettingsContainer>
|
||||
);
|
||||
}
|
||||
@@ -52,8 +52,7 @@ export default async function PrivateResourceLayout(
|
||||
| "hostSettings"
|
||||
| "cidrSettings"
|
||||
| "httpSettings"
|
||||
| "sshSettings"
|
||||
| "inferenceSettings";
|
||||
| "sshSettings";
|
||||
|
||||
const navItems = [
|
||||
{
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import PrivateResourcesBanner from "@app/components/PrivateResourcesBanner";
|
||||
import type { PrivateResourceRow } from "@app/components/PrivateResourcesTable";
|
||||
import type { InternalResourceRow } from "@app/components/PrivateResourcesTable";
|
||||
import PrivateResourcesTable from "@app/components/PrivateResourcesTable";
|
||||
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
|
||||
import { internal } from "@app/lib/api";
|
||||
@@ -61,7 +61,7 @@ export default async function ClientResourcesPage(
|
||||
redirect(`/${params.orgId}/settings/resources`);
|
||||
}
|
||||
|
||||
const internalResourceRows: PrivateResourceRow[] = siteResources.map(
|
||||
const internalResourceRows: InternalResourceRow[] = siteResources.map(
|
||||
(siteResource) => {
|
||||
return {
|
||||
id: siteResource.siteResourceId,
|
||||
|
||||
@@ -596,7 +596,6 @@ export function ProxyResourceTargetsForm({
|
||||
priority: 100,
|
||||
enabled: true,
|
||||
resourceId: resource?.resourceId ?? 0,
|
||||
providerId: null,
|
||||
hcEnabled: false,
|
||||
hcPath: null,
|
||||
hcMethod: null,
|
||||
|
||||
@@ -206,7 +206,7 @@ function createAddTargetSchema(t: TranslateFn) {
|
||||
);
|
||||
}
|
||||
|
||||
type NewResourceType = "http" | "ssh" | "rdp" | "vnc" | "tcp" | "udp" | "inference";
|
||||
type NewResourceType = "http" | "ssh" | "rdp" | "vnc" | "tcp" | "udp";
|
||||
|
||||
type CreateBgTargetFormValues = SshSettingsFormValues;
|
||||
|
||||
|
||||
@@ -72,7 +72,8 @@ export default function CredentialsPage() {
|
||||
const { data: latestVersions } = useQuery(
|
||||
productUpdatesQueries.latestVersion(true)
|
||||
);
|
||||
const newtVersion = latestVersions?.data?.newt?.latestVersion ?? "latest";
|
||||
const newtVersion =
|
||||
latestVersions?.data?.newt?.latestVersion ?? "latest";
|
||||
|
||||
// Fetch site defaults for wireguard sites to show in obfuscated config
|
||||
useEffect(() => {
|
||||
@@ -353,7 +354,7 @@ export default function CredentialsPage() {
|
||||
text={generateObfuscatedWireGuardConfig(
|
||||
{
|
||||
subnet:
|
||||
site?.exitNodeSubnet ||
|
||||
site?.subnet ||
|
||||
siteDefaults?.subnet ||
|
||||
null,
|
||||
address:
|
||||
|
||||
@@ -91,7 +91,6 @@ export default async function Page(props: {
|
||||
let loginIdps: LoginFormIDP[] = [];
|
||||
let lastUsedIdpForSmartLogin: (LoginFormIDP & { orgId?: string }) | null =
|
||||
null;
|
||||
|
||||
if (!useSmartLogin) {
|
||||
// Load IdPs for DashboardLoginForm (OSS or org-only IdP mode)
|
||||
if (build === "oss" || env.app.identityProviderMode !== "org") {
|
||||
@@ -118,12 +117,12 @@ export default async function Page(props: {
|
||||
`/idp/${persistedData.idpId}`
|
||||
);
|
||||
|
||||
const res = idpRes.data.data;
|
||||
const idp = idpRes.data.data.idp;
|
||||
|
||||
lastUsedIdpForSmartLogin = {
|
||||
idpId: res.idp.idpId,
|
||||
name: res.idp.name,
|
||||
variant: res.idpOidcConfig?.variant ?? res.idp.type,
|
||||
idpId: idp.idpId,
|
||||
name: idp.name,
|
||||
variant: idp.type,
|
||||
orgId: persistedData.orgId,
|
||||
lastUsed: true
|
||||
};
|
||||
|
||||
@@ -114,52 +114,59 @@ export default function IdpLoginButtons({
|
||||
|
||||
<div className="space-y-4">
|
||||
{params.get("gotoapp") ? (
|
||||
<Button
|
||||
type="button"
|
||||
className="w-full"
|
||||
onClick={() => {
|
||||
goToApp();
|
||||
}}
|
||||
>
|
||||
{t("continueToApplication")}
|
||||
</Button>
|
||||
<>
|
||||
<Button
|
||||
type="button"
|
||||
className="w-full"
|
||||
onClick={() => {
|
||||
goToApp();
|
||||
}}
|
||||
>
|
||||
{t("continueToApplication")}
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
idps.map((idp) => {
|
||||
const effectiveType =
|
||||
idp.variant || idp.name.toLowerCase();
|
||||
<>
|
||||
{idps.map((idp) => {
|
||||
const effectiveType =
|
||||
idp.variant || idp.name.toLowerCase();
|
||||
|
||||
return (
|
||||
<div className="w-full relative" key={idp.idpId}>
|
||||
<Button
|
||||
return (
|
||||
<div
|
||||
className="w-full relative"
|
||||
key={idp.idpId}
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="w-full inline-flex items-center space-x-2 after:absolute after:inset-0 after:z-10"
|
||||
onClick={() => {
|
||||
startTransition(() =>
|
||||
loginWithIdp(idp.idpId)
|
||||
);
|
||||
}}
|
||||
disabled={loading}
|
||||
loading={loading}
|
||||
>
|
||||
<IdpTypeIcon
|
||||
type={effectiveType}
|
||||
size={16}
|
||||
/>
|
||||
<span>{idp.name}</span>
|
||||
</Button>
|
||||
<Button
|
||||
key={idp.idpId}
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="w-full inline-flex items-center space-x-2 after:absolute after:inset-0 after:z-10"
|
||||
onClick={() => {
|
||||
startTransition(() =>
|
||||
loginWithIdp(idp.idpId)
|
||||
);
|
||||
}}
|
||||
disabled={loading}
|
||||
loading={loading}
|
||||
>
|
||||
<IdpTypeIcon
|
||||
type={effectiveType}
|
||||
size={16}
|
||||
/>
|
||||
<span>{idp.name}</span>
|
||||
</Button>
|
||||
|
||||
{idp.lastUsed && (
|
||||
<div className="absolute inset-0">
|
||||
<span className="absolute top-0 right-0 text-xs bg-primary text-primary-foreground rounded-bl-sm rounded-tr-sm px-2 py-0.5">
|
||||
{t("idpLastUsed")}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})
|
||||
{idp.lastUsed && (
|
||||
<div className="absolute inset-0">
|
||||
<span className="absolute top-0 right-0 text-xs bg-primary text-primary-foreground rounded-bl-sm rounded-tr-sm px-2 py-0.5">
|
||||
{t("idpLastUsed")}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -23,8 +23,6 @@ export default function IdpTypeIcon({
|
||||
}: Props) {
|
||||
const effectiveType = (variant || type || "").toLowerCase();
|
||||
|
||||
console.log(`[IdpTypeIcon]`, { effectiveType, variant, type });
|
||||
|
||||
let src: string | null = null;
|
||||
let defaultAlt = "";
|
||||
|
||||
|
||||
@@ -1,6 +1,17 @@
|
||||
"use client";
|
||||
|
||||
import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog";
|
||||
import { ColumnDef } from "@tanstack/react-table";
|
||||
import { ExtendedColumnDef } from "@app/components/ui/data-table";
|
||||
import { IdpDataTable } from "@app/components/OrgIdpDataTable";
|
||||
import { Button } from "@app/components/ui/button";
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList
|
||||
} from "@app/components/ui/command";
|
||||
import {
|
||||
Credenza,
|
||||
CredenzaBody,
|
||||
@@ -11,42 +22,37 @@ import {
|
||||
CredenzaHeader,
|
||||
CredenzaTitle
|
||||
} from "@app/components/Credenza";
|
||||
import { isIdpGlobalModeBannerVisible } from "@app/components/IdpGlobalModeBanner";
|
||||
import IdpTypeBadge from "@app/components/IdpTypeBadge";
|
||||
import IdpTypeIcon from "@app/components/IdpTypeIcon";
|
||||
import { IdpDataTable } from "@app/components/OrgIdpDataTable";
|
||||
import { Badge } from "@app/components/ui/badge";
|
||||
import { Button } from "@app/components/ui/button";
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList
|
||||
} from "@app/components/ui/command";
|
||||
import { ExtendedColumnDef } from "@app/components/ui/data-table";
|
||||
ArrowRight,
|
||||
ArrowUpDown,
|
||||
MoreHorizontal
|
||||
} from "lucide-react";
|
||||
import { useMemo, useState } from "react";
|
||||
import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog";
|
||||
import { toast } from "@app/hooks/useToast";
|
||||
import { formatAxiosError } from "@app/lib/api";
|
||||
import { createApiClient } from "@app/lib/api";
|
||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||
import { useUserContext } from "@app/hooks/useUserContext";
|
||||
import { useRouter } from "next/navigation";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger
|
||||
} from "@app/components/ui/dropdown-menu";
|
||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||
import { usePaidStatus } from "@app/hooks/usePaidStatus";
|
||||
import { toast } from "@app/hooks/useToast";
|
||||
import { useUserContext } from "@app/hooks/useUserContext";
|
||||
import { createApiClient, formatAxiosError } from "@app/lib/api";
|
||||
import { cn } from "@app/lib/cn";
|
||||
import { tierMatrix } from "@server/lib/billing/tierMatrix";
|
||||
import type { ListUserAdminOrgIdpsResponse } from "@server/routers/orgIdp/types";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { ArrowRight, ArrowUpDown, MoreHorizontal } from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useMemo, useState } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import IdpTypeBadge from "@app/components/IdpTypeBadge";
|
||||
import IdpTypeIcon from "@app/components/IdpTypeIcon";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useDebounce } from "use-debounce";
|
||||
import type { ListUserAdminOrgIdpsResponse } from "@server/routers/orgIdp/types";
|
||||
import { cn } from "@app/lib/cn";
|
||||
import { Badge } from "@app/components/ui/badge";
|
||||
import { usePaidStatus } from "@app/hooks/usePaidStatus";
|
||||
import { tierMatrix } from "@server/lib/billing/tierMatrix";
|
||||
import { isIdpGlobalModeBannerVisible } from "@app/components/IdpGlobalModeBanner";
|
||||
|
||||
export type IdpRow = {
|
||||
idpId: number;
|
||||
@@ -477,17 +483,15 @@ export default function IdpTable({ idps, orgId }: Props) {
|
||||
{group.name}
|
||||
</div>
|
||||
<div className="mt-1 flex flex-wrap gap-1">
|
||||
{group.sources.map(
|
||||
(src) => (
|
||||
<Badge
|
||||
key={src.orgId}
|
||||
variant="secondary"
|
||||
className="max-w-full truncate font-normal"
|
||||
>
|
||||
{src.orgName}
|
||||
</Badge>
|
||||
)
|
||||
)}
|
||||
{group.sources.map((src) => (
|
||||
<Badge
|
||||
key={src.orgId}
|
||||
variant="secondary"
|
||||
className="max-w-full truncate font-normal"
|
||||
>
|
||||
{src.orgName}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</CommandItem>
|
||||
|
||||
@@ -150,22 +150,6 @@ function getActionsCategories(root: boolean) {
|
||||
[t("actionListSiteProvisioningKeys")]: "listSiteProvisioningKeys",
|
||||
[t("actionUpdateSiteProvisioningKey")]: "updateSiteProvisioningKey",
|
||||
[t("actionDeleteSiteProvisioningKey")]: "deleteSiteProvisioningKey"
|
||||
},
|
||||
|
||||
"AI Provider": {
|
||||
[t("actionCreateAiProvider")]: "createAiProvider",
|
||||
[t("actionDeleteAiProvider")]: "deleteAiProvider",
|
||||
[t("actionGetAiProvider")]: "getAiProvider",
|
||||
[t("actionListAiProviders")]: "listAiProviders",
|
||||
[t("actionUpdateAiProvider")]: "updateAiProvider"
|
||||
},
|
||||
|
||||
"AI Model": {
|
||||
[t("actionCreateAiModel")]: "createAiModel",
|
||||
[t("actionDeleteAiModel")]: "deleteAiModel",
|
||||
[t("actionGetAiModel")]: "getAiModel",
|
||||
[t("actionListAiModels")]: "listAiModels",
|
||||
[t("actionUpdateAiModel")]: "updateAiModel"
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -76,37 +76,6 @@ type PrivateResourceHostDestinationFieldsProps = {
|
||||
hideAlias?: boolean;
|
||||
};
|
||||
|
||||
type PrivateResourceInferenceDestinationFieldsProps = {
|
||||
control: Control<any>;
|
||||
watch: UseFormWatch<any>;
|
||||
labelPrefix?: "create" | "edit";
|
||||
hideAlias?: boolean;
|
||||
};
|
||||
|
||||
export function PrivateResourceInferenceDestinationFields({
|
||||
control,
|
||||
watch,
|
||||
labelPrefix = "edit"
|
||||
}: PrivateResourceInferenceDestinationFieldsProps) {
|
||||
const t = useTranslations();
|
||||
const destinationLabelKey =
|
||||
labelPrefix === "create"
|
||||
? "createInternalResourceDialogDestination"
|
||||
: "editInternalResourceDialogDestination";
|
||||
|
||||
return (
|
||||
<SettingsFormGrid>
|
||||
<SettingsFormCell span="half">
|
||||
<PrivateResourceAliasField
|
||||
control={control}
|
||||
watch={watch}
|
||||
labelPrefix={labelPrefix}
|
||||
/>
|
||||
</SettingsFormCell>
|
||||
</SettingsFormGrid>
|
||||
);
|
||||
}
|
||||
|
||||
export function PrivateResourceHostDestinationFields({
|
||||
control,
|
||||
watch,
|
||||
|
||||
@@ -69,21 +69,20 @@ import { LabelColumnFilterButton } from "./LabelColumnFilterButton";
|
||||
import { LabelsTableCell } from "./LabelsTableCell";
|
||||
import { ControlledDataTable } from "./ui/controlled-data-table";
|
||||
import { SitesColumnFilterButton } from "./SitesColumnFilterButton";
|
||||
import { SiteResource } from "@server/db";
|
||||
|
||||
export type PrivateResourceSiteRow = ResourceSiteRow;
|
||||
export type InternalResourceSiteRow = ResourceSiteRow;
|
||||
|
||||
export type PrivateResourceRow = {
|
||||
export type InternalResourceRow = {
|
||||
id: number;
|
||||
name: string;
|
||||
orgId: string;
|
||||
sites: PrivateResourceSiteRow[];
|
||||
sites: InternalResourceSiteRow[];
|
||||
siteNames: string[];
|
||||
siteAddresses: (string | null)[];
|
||||
siteIds: number[];
|
||||
siteNiceIds: string[];
|
||||
// mode: "host" | "cidr" | "port";
|
||||
mode: SiteResource["mode"];
|
||||
mode: "host" | "cidr" | "http" | "ssh";
|
||||
scheme: "http" | "https" | null;
|
||||
ssl: boolean;
|
||||
// protocol: string | null;
|
||||
@@ -110,7 +109,7 @@ export type PrivateResourceRow = {
|
||||
}>;
|
||||
};
|
||||
|
||||
function formatDestinationDisplay(row: PrivateResourceRow): string {
|
||||
function formatDestinationDisplay(row: InternalResourceRow): string {
|
||||
return formatSiteResourceDestinationDisplay({
|
||||
mode: row.mode,
|
||||
destination: row.destination,
|
||||
@@ -134,7 +133,7 @@ const booleanSearchFilterSchema = z
|
||||
.catch(undefined);
|
||||
|
||||
type ClientResourcesTableProps = {
|
||||
internalResources: PrivateResourceRow[];
|
||||
internalResources: InternalResourceRow[];
|
||||
orgId: string;
|
||||
pagination: PaginationState;
|
||||
rowCount: number;
|
||||
@@ -165,10 +164,10 @@ export default function PrivateResourcesTable({
|
||||
const [isNavigatingToAddPage, startNavigation] = useTransition();
|
||||
|
||||
const [selectedInternalResource, setSelectedInternalResource] =
|
||||
useState<PrivateResourceRow | null>(null);
|
||||
useState<InternalResourceRow | null>(null);
|
||||
const [isEditDialogOpen, setIsEditDialogOpen] = useState(false);
|
||||
const [editingResource, setEditingResource] =
|
||||
useState<PrivateResourceRow | null>();
|
||||
useState<InternalResourceRow | null>();
|
||||
const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false);
|
||||
|
||||
const [isRefreshing, startRefreshTransition] = useTransition();
|
||||
@@ -242,9 +241,9 @@ export default function PrivateResourcesTable({
|
||||
};
|
||||
|
||||
const internalColumns = useMemo<
|
||||
ExtendedColumnDef<PrivateResourceRow>[]
|
||||
ExtendedColumnDef<InternalResourceRow>[]
|
||||
>(() => {
|
||||
const cols: ExtendedColumnDef<PrivateResourceRow>[] = [
|
||||
const cols: ExtendedColumnDef<InternalResourceRow>[] = [
|
||||
{
|
||||
accessorKey: "name",
|
||||
enableHiding: false,
|
||||
@@ -365,12 +364,15 @@ export default function PrivateResourcesTable({
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const resourceRow = row.original;
|
||||
const modeLabels: Record<SiteResource["mode"], string> = {
|
||||
const modeLabels: Record<
|
||||
"host" | "cidr" | "port" | "http" | "ssh",
|
||||
string
|
||||
> = {
|
||||
host: t("editInternalResourceDialogModeHost"),
|
||||
cidr: t("editInternalResourceDialogModeCidr"),
|
||||
port: t("editInternalResourceDialogModePort"),
|
||||
http: t("editInternalResourceDialogModeHttp"),
|
||||
ssh: t("editInternalResourceDialogModeSsh"),
|
||||
inference: t("editInternalResourceDialogModeInference")
|
||||
ssh: t("editInternalResourceDialogModeSsh")
|
||||
};
|
||||
return <span>{modeLabels[resourceRow.mode]}</span>;
|
||||
}
|
||||
@@ -519,7 +521,7 @@ export default function PrivateResourcesTable({
|
||||
className="p-3"
|
||||
/>
|
||||
),
|
||||
cell: ({ row }: { row: { original: PrivateResourceRow } }) => (
|
||||
cell: ({ row }: { row: { original: InternalResourceRow } }) => (
|
||||
<ClientResourceLabelCell
|
||||
resource={row.original}
|
||||
orgId={orgId}
|
||||
@@ -695,7 +697,7 @@ export default function PrivateResourcesTable({
|
||||
}
|
||||
|
||||
type ClientResourceLabelCellProps = {
|
||||
resource: PrivateResourceRow;
|
||||
resource: InternalResourceRow;
|
||||
orgId: string;
|
||||
};
|
||||
|
||||
@@ -721,7 +723,7 @@ function ClientResourceLabelCell({
|
||||
}
|
||||
|
||||
type InternalResourceEnabledFormProps = {
|
||||
resource: PrivateResourceRow;
|
||||
resource: InternalResourceRow;
|
||||
onToggleInternalResourceEnabled: (
|
||||
val: boolean,
|
||||
resourceId: number
|
||||
|
||||
@@ -93,8 +93,7 @@ export function SiteResourceInfoSections({
|
||||
host: t("editInternalResourceDialogModeHost"),
|
||||
cidr: t("editInternalResourceDialogModeCidr"),
|
||||
http: t("editInternalResourceDialogModeHttp"),
|
||||
ssh: t("editInternalResourceDialogModeSsh"),
|
||||
inference: t("editInternalResourceDialogModeInference")
|
||||
ssh: t("editInternalResourceDialogModeSsh")
|
||||
};
|
||||
|
||||
const destination = formatSiteResourceDestinationDisplay({
|
||||
|
||||
@@ -70,8 +70,7 @@ function PrivateResourceMeta({ row }: { row: SiteResourceRow }) {
|
||||
host: t("editInternalResourceDialogModeHost"),
|
||||
cidr: t("editInternalResourceDialogModeCidr"),
|
||||
http: t("editInternalResourceDialogModeHttp"),
|
||||
ssh: t("editInternalResourceDialogModeSsh"),
|
||||
inference: t("editInternalResourceDialogModeInference")
|
||||
ssh: t("editInternalResourceDialogModeSsh")
|
||||
};
|
||||
const dest = formatSiteResourceDestinationDisplay({
|
||||
mode: row.mode,
|
||||
|
||||
@@ -134,9 +134,7 @@ export default function UptimeBar({
|
||||
|
||||
if (!data) return null;
|
||||
|
||||
const allNoData = data.days.every(
|
||||
(d) => d.status === "no_data" || d.status === "unknown"
|
||||
);
|
||||
const allNoData = data.days.every((d) => d.status === "no_data");
|
||||
|
||||
return (
|
||||
<div className={cn("space-y-3", className)}>
|
||||
|
||||
@@ -124,9 +124,7 @@ export function UptimeMiniBar({
|
||||
|
||||
if (!data) return null;
|
||||
|
||||
const allNoData = data.days.every(
|
||||
(d) => d.status === "no_data" || d.status === "unknown"
|
||||
);
|
||||
const allNoData = data.days.every((d) => d.status === "no_data");
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
|
||||
@@ -77,8 +77,6 @@ export type ClientRow = {
|
||||
username: string | null;
|
||||
hostname: string | null;
|
||||
} | null;
|
||||
firstSeen: number | null;
|
||||
lastSeen: number | null;
|
||||
};
|
||||
|
||||
type ClientTableProps = {
|
||||
@@ -114,9 +112,7 @@ export default function UserDevicesTable({
|
||||
|
||||
const defaultUserColumnVisibility = {
|
||||
subnet: false,
|
||||
niceId: false,
|
||||
firstSeen: false,
|
||||
lastSeen: false
|
||||
niceId: false
|
||||
};
|
||||
|
||||
const refreshData = () => {
|
||||
@@ -625,68 +621,6 @@ export default function UserDevicesTable({
|
||||
accessorKey: "subnet",
|
||||
friendlyName: t("address"),
|
||||
header: () => <span className="px-3">{t("address")}</span>
|
||||
},
|
||||
{
|
||||
accessorKey: "firstSeen",
|
||||
friendlyName: t("firstSeen"),
|
||||
header: () => {
|
||||
const firstSeenOrder = getSortDirection(
|
||||
"firstSeen",
|
||||
searchParams
|
||||
);
|
||||
|
||||
const Icon =
|
||||
firstSeenOrder === "asc"
|
||||
? ArrowDown01Icon
|
||||
: firstSeenOrder === "desc"
|
||||
? ArrowUp10Icon
|
||||
: ChevronsUpDownIcon;
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => toggleSort("firstSeen")}
|
||||
>
|
||||
{t("firstSeen")}
|
||||
<Icon className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
);
|
||||
},
|
||||
cell: ({ row }) => {
|
||||
const firstSeen = row.original.firstSeen;
|
||||
if (!firstSeen) return "-";
|
||||
return new Date(firstSeen * 1000).toLocaleString();
|
||||
}
|
||||
},
|
||||
{
|
||||
accessorKey: "lastSeen",
|
||||
friendlyName: t("lastSeen"),
|
||||
header: () => {
|
||||
const lastSeenOrder = getSortDirection(
|
||||
"lastSeen",
|
||||
searchParams
|
||||
);
|
||||
|
||||
const Icon =
|
||||
lastSeenOrder === "asc"
|
||||
? ArrowDown01Icon
|
||||
: lastSeenOrder === "desc"
|
||||
? ArrowUp10Icon
|
||||
: ChevronsUpDownIcon;
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => toggleSort("lastSeen")}
|
||||
>
|
||||
{t("lastSeen")}
|
||||
<Icon className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
);
|
||||
},
|
||||
cell: ({ row }) => {
|
||||
const lastSeen = row.original.lastSeen;
|
||||
if (!lastSeen) return "-";
|
||||
return new Date(lastSeen * 1000).toLocaleString();
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user