mirror of
https://github.com/fosrl/pangolin.git
synced 2026-09-01 16:59:22 +02:00
Compare commits
22 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 774a0730b9 | |||
| d6d923e972 | |||
| 41139f2fd0 | |||
| ebcdeab414 | |||
| 2cc7d03ace | |||
| 34671c6b13 | |||
| 8dfc95347f | |||
| 0dece5fef1 | |||
| e7f38c089f | |||
| dd0a5a359a | |||
| 1650ece0c3 | |||
| bc56a2bed0 | |||
| 49dcc590ce | |||
| da3e3ff33f | |||
| 69d539f107 | |||
| 872e0f9ae1 | |||
| 5b3713a72f | |||
| f02be1fdbf | |||
| 0bf04cf0cd | |||
| 7cda28d685 | |||
| 60bc74c4df | |||
| 48ab6c501f |
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
FROM node:24.18.1-alpine
|
FROM node:26.8.1-alpine
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
|
|||||||
@@ -99,6 +99,19 @@ Access private resources like SSH servers, databases, RDP, and entire network ra
|
|||||||
|
|
||||||
<img src="public/screenshots/private-resources.png" alt="Private resources" width="100%" />
|
<img src="public/screenshots/private-resources.png" alt="Private resources" width="100%" />
|
||||||
|
|
||||||
|
### Identity-aware AI gateway
|
||||||
|
|
||||||
|
Put an identity-aware proxy in front of public cloud (OpenAI, Anthropic, Gemini, etc.) and self-hosted model servers (Ollama, vLLM, Mistral, etc.) so coding agents and AI clients call a single Pangolin URL. Publish it as a public resource with personal API keys, or keep it private on a client tunnel where the connected client is the credential for keyless access. Budgets, session history, and usage analytics sit in front of every call.
|
||||||
|
|
||||||
|
* Access self-hosted models (vLLM, Ollama, etc) alongside cloud models (OpenAI, Anthropic, etc) in one place
|
||||||
|
* Keyless access by authenticating users with the Pangolin desktop client
|
||||||
|
* Or, provide users with personal API keys
|
||||||
|
* Control costs and token usage by setting budgets
|
||||||
|
* Audit with detailed session history and analytics
|
||||||
|
* Integrate AI clients and coding agents (Claude Code, Codex, OpenCode, etc)
|
||||||
|
|
||||||
|
<img src="public/screenshots/expanded-session-logs.png" alt="AI Session Logs" width="100%" />
|
||||||
|
|
||||||
### Give users and roles access to resources
|
### Give users and roles access to resources
|
||||||
|
|
||||||
Use Pangolin's built-in users or bring your own identity provider and set up role-based access control (RBAC). Grant users access to specific resources, not entire networks. Unlike traditional VPNs that expose full network access, Pangolin's zero-trust model ensures users can only reach the applications, services, and routes you explicitly define.
|
Use Pangolin's built-in users or bring your own identity provider and set up role-based access control (RBAC). Grant users access to specific resources, not entire networks. Unlike traditional VPNs that expose full network access, Pangolin's zero-trust model ensures users can only reach the applications, services, and routes you explicitly define.
|
||||||
|
|||||||
Generated
+70
-26
@@ -7569,6 +7569,72 @@
|
|||||||
"node": ">=14.0.0"
|
"node": ">=14.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/core": {
|
||||||
|
"version": "1.10.0",
|
||||||
|
"dev": true,
|
||||||
|
"inBundle": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"dependencies": {
|
||||||
|
"@emnapi/wasi-threads": "1.2.1",
|
||||||
|
"tslib": "^2.4.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/runtime": {
|
||||||
|
"version": "1.10.0",
|
||||||
|
"dev": true,
|
||||||
|
"inBundle": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"dependencies": {
|
||||||
|
"tslib": "^2.4.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/wasi-threads": {
|
||||||
|
"version": "1.2.1",
|
||||||
|
"dev": true,
|
||||||
|
"inBundle": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"dependencies": {
|
||||||
|
"tslib": "^2.4.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@napi-rs/wasm-runtime": {
|
||||||
|
"version": "1.1.4",
|
||||||
|
"dev": true,
|
||||||
|
"inBundle": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"dependencies": {
|
||||||
|
"@tybys/wasm-util": "^0.10.1"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@emnapi/core": "^1.7.1",
|
||||||
|
"@emnapi/runtime": "^1.7.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@tybys/wasm-util": {
|
||||||
|
"version": "0.10.1",
|
||||||
|
"dev": true,
|
||||||
|
"inBundle": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"dependencies": {
|
||||||
|
"tslib": "^2.4.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/tslib": {
|
||||||
|
"version": "2.8.1",
|
||||||
|
"dev": true,
|
||||||
|
"inBundle": true,
|
||||||
|
"license": "0BSD",
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
"node_modules/@tailwindcss/oxide-win32-arm64-msvc": {
|
"node_modules/@tailwindcss/oxide-win32-arm64-msvc": {
|
||||||
"version": "4.3.0",
|
"version": "4.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.0.tgz",
|
||||||
@@ -17131,36 +17197,14 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/socket.io-adapter": {
|
"node_modules/socket.io-adapter": {
|
||||||
"version": "2.5.6",
|
"version": "2.5.8",
|
||||||
"resolved": "https://registry.npmjs.org/socket.io-adapter/-/socket.io-adapter-2.5.6.tgz",
|
"resolved": "https://registry.npmjs.org/socket.io-adapter/-/socket.io-adapter-2.5.8.tgz",
|
||||||
"integrity": "sha512-DkkO/dz7MGln0dHn5bmN3pPy+JmywNICWrJqVWiVOyvXjWQFIv9c2h24JrQLLFJ2aQVQf/Cvl1vblnd4r2apLQ==",
|
"integrity": "sha512-6Oy52pbg+kvdCVvjcN+FnY7BvxZ7cIHNScbvztT/It5d0vbwoJoVZmF2gjJmnV0/4WlXRfG15zc45ySk9Ah8bw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"debug": "~4.4.1",
|
"debug": "~4.4.1",
|
||||||
"ws": "~8.18.3"
|
"ws": "~8.21.0"
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/socket.io-adapter/node_modules/ws": {
|
|
||||||
"version": "8.18.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz",
|
|
||||||
"integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=10.0.0"
|
|
||||||
},
|
|
||||||
"peerDependencies": {
|
|
||||||
"bufferutil": "^4.0.1",
|
|
||||||
"utf-8-validate": ">=5.0.2"
|
|
||||||
},
|
|
||||||
"peerDependenciesMeta": {
|
|
||||||
"bufferutil": {
|
|
||||||
"optional": true
|
|
||||||
},
|
|
||||||
"utf-8-validate": {
|
|
||||||
"optional": true
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/socket.io-parser": {
|
"node_modules/socket.io-parser": {
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 790 KiB |
@@ -262,7 +262,9 @@ export const resourceAiModels = pgTable(
|
|||||||
(t) => [primaryKey({ columns: [t.resourceId, t.modelId] })]
|
(t) => [primaryKey({ columns: [t.resourceId, t.modelId] })]
|
||||||
);
|
);
|
||||||
|
|
||||||
export const labels = pgTable("labels", {
|
export const labels = pgTable(
|
||||||
|
"labels",
|
||||||
|
{
|
||||||
labelId: serial("labelId").primaryKey(),
|
labelId: serial("labelId").primaryKey(),
|
||||||
name: varchar("name").notNull(),
|
name: varchar("name").notNull(),
|
||||||
color: varchar("color").notNull(),
|
color: varchar("color").notNull(),
|
||||||
@@ -271,7 +273,9 @@ export const labels = pgTable("labels", {
|
|||||||
onDelete: "cascade"
|
onDelete: "cascade"
|
||||||
})
|
})
|
||||||
.notNull()
|
.notNull()
|
||||||
});
|
},
|
||||||
|
(t) => [index("idx_labels_orgid").on(t.orgId)]
|
||||||
|
);
|
||||||
|
|
||||||
export const launcherViews = pgTable("launcherViews", {
|
export const launcherViews = pgTable("launcherViews", {
|
||||||
viewId: serial("viewId").primaryKey(),
|
viewId: serial("viewId").primaryKey(),
|
||||||
@@ -693,7 +697,9 @@ export const twoFactorBackupCodes = pgTable("twoFactorBackupCodes", {
|
|||||||
codeHash: varchar("codeHash").notNull()
|
codeHash: varchar("codeHash").notNull()
|
||||||
});
|
});
|
||||||
|
|
||||||
export const sessions = pgTable("session", {
|
export const sessions = pgTable(
|
||||||
|
"session",
|
||||||
|
{
|
||||||
sessionId: varchar("id").primaryKey(),
|
sessionId: varchar("id").primaryKey(),
|
||||||
userId: varchar("userId")
|
userId: varchar("userId")
|
||||||
.notNull()
|
.notNull()
|
||||||
@@ -701,7 +707,9 @@ export const sessions = pgTable("session", {
|
|||||||
expiresAt: bigint("expiresAt", { mode: "number" }).notNull(),
|
expiresAt: bigint("expiresAt", { mode: "number" }).notNull(),
|
||||||
issuedAt: bigint("issuedAt", { mode: "number" }),
|
issuedAt: bigint("issuedAt", { mode: "number" }),
|
||||||
deviceAuthUsed: boolean("deviceAuthUsed").notNull().default(false)
|
deviceAuthUsed: boolean("deviceAuthUsed").notNull().default(false)
|
||||||
});
|
},
|
||||||
|
(t) => [index("idx_sessions_userid").on(t.userId)]
|
||||||
|
);
|
||||||
|
|
||||||
export const newtSessions = pgTable("newtSession", {
|
export const newtSessions = pgTable("newtSession", {
|
||||||
sessionId: varchar("id").primaryKey(),
|
sessionId: varchar("id").primaryKey(),
|
||||||
@@ -711,7 +719,9 @@ export const newtSessions = pgTable("newtSession", {
|
|||||||
expiresAt: bigint("expiresAt", { mode: "number" }).notNull()
|
expiresAt: bigint("expiresAt", { mode: "number" }).notNull()
|
||||||
});
|
});
|
||||||
|
|
||||||
export const userOrgs = pgTable("userOrgs", {
|
export const userOrgs = pgTable(
|
||||||
|
"userOrgs",
|
||||||
|
{
|
||||||
userId: varchar("userId")
|
userId: varchar("userId")
|
||||||
.notNull()
|
.notNull()
|
||||||
.references(() => users.userId, { onDelete: "cascade" }),
|
.references(() => users.userId, { onDelete: "cascade" }),
|
||||||
@@ -723,7 +733,12 @@ export const userOrgs = pgTable("userOrgs", {
|
|||||||
isOwner: boolean("isOwner").notNull().default(false),
|
isOwner: boolean("isOwner").notNull().default(false),
|
||||||
autoProvisioned: boolean("autoProvisioned").default(false),
|
autoProvisioned: boolean("autoProvisioned").default(false),
|
||||||
pamUsername: varchar("pamUsername") // cleaned username for ssh and such
|
pamUsername: varchar("pamUsername") // cleaned username for ssh and such
|
||||||
});
|
},
|
||||||
|
(t) => [
|
||||||
|
index("idx_userOrgs_userid").on(t.userId),
|
||||||
|
index("idx_userOrgs_orgid").on(t.orgId)
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
export const emailVerificationCodes = pgTable("emailVerificationCodes", {
|
export const emailVerificationCodes = pgTable("emailVerificationCodes", {
|
||||||
codeId: serial("id").primaryKey(),
|
codeId: serial("id").primaryKey(),
|
||||||
@@ -751,7 +766,9 @@ export const actions = pgTable("actions", {
|
|||||||
description: varchar("description")
|
description: varchar("description")
|
||||||
});
|
});
|
||||||
|
|
||||||
export const roles = pgTable("roles", {
|
export const roles = pgTable(
|
||||||
|
"roles",
|
||||||
|
{
|
||||||
roleId: serial("roleId").primaryKey(),
|
roleId: serial("roleId").primaryKey(),
|
||||||
orgId: varchar("orgId")
|
orgId: varchar("orgId")
|
||||||
.references(() => orgs.orgId, {
|
.references(() => orgs.orgId, {
|
||||||
@@ -766,7 +783,9 @@ export const roles = pgTable("roles", {
|
|||||||
sshSudoCommands: text("sshSudoCommands").default("[]"),
|
sshSudoCommands: text("sshSudoCommands").default("[]"),
|
||||||
sshCreateHomeDir: boolean("sshCreateHomeDir").default(true),
|
sshCreateHomeDir: boolean("sshCreateHomeDir").default(true),
|
||||||
sshUnixGroups: text("sshUnixGroups").default("[]")
|
sshUnixGroups: text("sshUnixGroups").default("[]")
|
||||||
});
|
},
|
||||||
|
(t) => [index("idx_roles_orgid").on(t.orgId)]
|
||||||
|
);
|
||||||
|
|
||||||
export const userOrgRoles = pgTable(
|
export const userOrgRoles = pgTable(
|
||||||
"userOrgRoles",
|
"userOrgRoles",
|
||||||
@@ -1409,7 +1428,10 @@ export const olms = pgTable(
|
|||||||
}),
|
}),
|
||||||
archived: boolean("archived").notNull().default(false)
|
archived: boolean("archived").notNull().default(false)
|
||||||
},
|
},
|
||||||
(t) => [index("idx_olms_clientid").on(t.clientId)]
|
(t) => [
|
||||||
|
index("idx_olms_clientid").on(t.clientId),
|
||||||
|
index("idx_olms_userid").on(t.userId)
|
||||||
|
]
|
||||||
);
|
);
|
||||||
|
|
||||||
export const currentFingerprint = pgTable("currentFingerprint", {
|
export const currentFingerprint = pgTable("currentFingerprint", {
|
||||||
|
|||||||
@@ -99,7 +99,9 @@ export const orgDomains = sqliteTable("orgDomains", {
|
|||||||
.references(() => domains.domainId, { onDelete: "cascade" })
|
.references(() => domains.domainId, { onDelete: "cascade" })
|
||||||
});
|
});
|
||||||
|
|
||||||
export const sites = sqliteTable("sites", {
|
export const sites = sqliteTable(
|
||||||
|
"sites",
|
||||||
|
{
|
||||||
siteId: integer("siteId").primaryKey({ autoIncrement: true }),
|
siteId: integer("siteId").primaryKey({ autoIncrement: true }),
|
||||||
orgId: text("orgId")
|
orgId: text("orgId")
|
||||||
.references(() => orgs.orgId, {
|
.references(() => orgs.orgId, {
|
||||||
@@ -141,10 +143,18 @@ export const sites = sqliteTable("sites", {
|
|||||||
})
|
})
|
||||||
.notNull()
|
.notNull()
|
||||||
.default(false),
|
.default(false),
|
||||||
status: text("status").$type<"pending" | "approved">().default("approved")
|
status: text("status")
|
||||||
});
|
.$type<"pending" | "approved">()
|
||||||
|
.default("approved")
|
||||||
|
},
|
||||||
|
(table) => [
|
||||||
|
index("idx_sites_orgId").on(table.orgId)
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
export const resources = sqliteTable("resources", {
|
export const resources = sqliteTable(
|
||||||
|
"resources",
|
||||||
|
{
|
||||||
resourceId: integer("resourceId").primaryKey({ autoIncrement: true }),
|
resourceId: integer("resourceId").primaryKey({ autoIncrement: true }),
|
||||||
resourcePolicyId: integer("resourcePolicyId").references(
|
resourcePolicyId: integer("resourcePolicyId").references(
|
||||||
() => resourcePolicies.resourcePolicyId,
|
() => resourcePolicies.resourcePolicyId,
|
||||||
@@ -222,8 +232,14 @@ export const resources = sqliteTable("resources", {
|
|||||||
.$type<"site" | "remote" | "native">()
|
.$type<"site" | "remote" | "native">()
|
||||||
.default("site"),
|
.default("site"),
|
||||||
authDaemonPort: integer("authDaemonPort").default(22123),
|
authDaemonPort: integer("authDaemonPort").default(22123),
|
||||||
status: text("status").$type<"pending" | "approved">().default("approved")
|
status: text("status")
|
||||||
});
|
.$type<"pending" | "approved">()
|
||||||
|
.default("approved")
|
||||||
|
},
|
||||||
|
(table) => [
|
||||||
|
index("idx_resources_orgId").on(table.orgId)
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
export const resourceAiProviders = sqliteTable(
|
export const resourceAiProviders = sqliteTable(
|
||||||
"resourceAiProviders",
|
"resourceAiProviders",
|
||||||
@@ -260,7 +276,9 @@ export const resourceAiModels = sqliteTable(
|
|||||||
(t) => [primaryKey({ columns: [t.resourceId, t.modelId] })]
|
(t) => [primaryKey({ columns: [t.resourceId, t.modelId] })]
|
||||||
);
|
);
|
||||||
|
|
||||||
export const labels = sqliteTable("labels", {
|
export const labels = sqliteTable(
|
||||||
|
"labels",
|
||||||
|
{
|
||||||
labelId: integer("labelId").primaryKey({ autoIncrement: true }),
|
labelId: integer("labelId").primaryKey({ autoIncrement: true }),
|
||||||
name: text("name").notNull(),
|
name: text("name").notNull(),
|
||||||
color: text("color").notNull(),
|
color: text("color").notNull(),
|
||||||
@@ -269,7 +287,11 @@ export const labels = sqliteTable("labels", {
|
|||||||
onDelete: "cascade"
|
onDelete: "cascade"
|
||||||
})
|
})
|
||||||
.notNull()
|
.notNull()
|
||||||
});
|
},
|
||||||
|
(table) => [
|
||||||
|
index("idx_labels_orgId").on(table.orgId)
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
export const launcherViews = sqliteTable("launcherViews", {
|
export const launcherViews = sqliteTable("launcherViews", {
|
||||||
viewId: integer("viewId").primaryKey({ autoIncrement: true }),
|
viewId: integer("viewId").primaryKey({ autoIncrement: true }),
|
||||||
@@ -366,14 +388,18 @@ export const clientLabels = sqliteTable(
|
|||||||
(t) => [unique("client_label_uniq").on(t.clientId, t.labelId)]
|
(t) => [unique("client_label_uniq").on(t.clientId, t.labelId)]
|
||||||
);
|
);
|
||||||
|
|
||||||
export const targets = sqliteTable("targets", {
|
export const targets = sqliteTable(
|
||||||
|
"targets",
|
||||||
|
{
|
||||||
targetId: integer("targetId").primaryKey({ autoIncrement: true }),
|
targetId: integer("targetId").primaryKey({ autoIncrement: true }),
|
||||||
resourceId: integer("resourceId").references(() => resources.resourceId, {
|
resourceId: integer("resourceId").references(
|
||||||
onDelete: "cascade"
|
() => resources.resourceId,
|
||||||
}),
|
{ onDelete: "cascade" }
|
||||||
providerId: integer("providerId").references(() => aiProviders.providerId, {
|
),
|
||||||
onDelete: "cascade"
|
providerId: integer("providerId").references(
|
||||||
}),
|
() => aiProviders.providerId,
|
||||||
|
{ onDelete: "cascade" }
|
||||||
|
),
|
||||||
siteId: integer("siteId")
|
siteId: integer("siteId")
|
||||||
.references(() => sites.siteId, {
|
.references(() => sites.siteId, {
|
||||||
onDelete: "cascade"
|
onDelete: "cascade"
|
||||||
@@ -394,7 +420,12 @@ export const targets = sqliteTable("targets", {
|
|||||||
.notNull()
|
.notNull()
|
||||||
.default("http"),
|
.default("http"),
|
||||||
authToken: text("authToken")
|
authToken: text("authToken")
|
||||||
});
|
},
|
||||||
|
(table) => [
|
||||||
|
index("idx_targets_resourceId").on(table.resourceId),
|
||||||
|
index("idx_targets_siteId").on(table.siteId)
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
export const targetHealthCheck = sqliteTable("targetHealthCheck", {
|
export const targetHealthCheck = sqliteTable("targetHealthCheck", {
|
||||||
targetHealthCheckId: integer("targetHealthCheckId").primaryKey({
|
targetHealthCheckId: integer("targetHealthCheckId").primaryKey({
|
||||||
@@ -663,7 +694,9 @@ export const setupTokens = sqliteTable("setupTokens", {
|
|||||||
dateUsed: text("dateUsed")
|
dateUsed: text("dateUsed")
|
||||||
});
|
});
|
||||||
|
|
||||||
export const newts = sqliteTable("newt", {
|
export const newts = sqliteTable(
|
||||||
|
"newt",
|
||||||
|
{
|
||||||
newtId: text("id").primaryKey(),
|
newtId: text("id").primaryKey(),
|
||||||
secretHash: text("secretHash").notNull(),
|
secretHash: text("secretHash").notNull(),
|
||||||
dateCreated: text("dateCreated").notNull(),
|
dateCreated: text("dateCreated").notNull(),
|
||||||
@@ -671,9 +704,15 @@ export const newts = sqliteTable("newt", {
|
|||||||
siteId: integer("siteId").references(() => sites.siteId, {
|
siteId: integer("siteId").references(() => sites.siteId, {
|
||||||
onDelete: "cascade"
|
onDelete: "cascade"
|
||||||
})
|
})
|
||||||
});
|
},
|
||||||
|
(table) => [
|
||||||
|
index("idx_newts_siteId").on(table.siteId)
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
export const clients = sqliteTable("clients", {
|
export const clients = sqliteTable(
|
||||||
|
"clients",
|
||||||
|
{
|
||||||
clientId: integer("clientId").primaryKey({ autoIncrement: true }),
|
clientId: integer("clientId").primaryKey({ autoIncrement: true }),
|
||||||
orgId: text("orgId")
|
orgId: text("orgId")
|
||||||
.references(() => orgs.orgId, {
|
.references(() => orgs.orgId, {
|
||||||
@@ -706,7 +745,12 @@ export const clients = sqliteTable("clients", {
|
|||||||
approvalState: text("approvalState").$type<
|
approvalState: text("approvalState").$type<
|
||||||
"pending" | "approved" | "denied"
|
"pending" | "approved" | "denied"
|
||||||
>()
|
>()
|
||||||
});
|
},
|
||||||
|
(table) => [
|
||||||
|
index("idx_clients_orgId").on(table.orgId),
|
||||||
|
index("idx_clients_userId").on(table.userId)
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
export const clientSitesAssociationsCache = sqliteTable(
|
export const clientSitesAssociationsCache = sqliteTable(
|
||||||
"clientSitesAssociationsCache",
|
"clientSitesAssociationsCache",
|
||||||
@@ -734,7 +778,9 @@ export const clientSiteResourcesAssociationsCache = sqliteTable(
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
export const olms = sqliteTable("olms", {
|
export const olms = sqliteTable(
|
||||||
|
"olms",
|
||||||
|
{
|
||||||
olmId: text("id").primaryKey(),
|
olmId: text("id").primaryKey(),
|
||||||
secretHash: text("secretHash").notNull(),
|
secretHash: text("secretHash").notNull(),
|
||||||
dateCreated: text("dateCreated").notNull(),
|
dateCreated: text("dateCreated").notNull(),
|
||||||
@@ -750,7 +796,11 @@ export const olms = sqliteTable("olms", {
|
|||||||
onDelete: "cascade"
|
onDelete: "cascade"
|
||||||
}),
|
}),
|
||||||
archived: integer("archived", { mode: "boolean" }).notNull().default(false)
|
archived: integer("archived", { mode: "boolean" }).notNull().default(false)
|
||||||
});
|
},
|
||||||
|
(table) => [
|
||||||
|
index("idx_olms_userId").on(table.userId)
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
export const currentFingerprint = sqliteTable("currentFingerprint", {
|
export const currentFingerprint = sqliteTable("currentFingerprint", {
|
||||||
fingerprintId: integer("id").primaryKey({ autoIncrement: true }),
|
fingerprintId: integer("id").primaryKey({ autoIncrement: true }),
|
||||||
@@ -912,7 +962,9 @@ export const twoFactorBackupCodes = sqliteTable("twoFactorBackupCodes", {
|
|||||||
codeHash: text("codeHash").notNull()
|
codeHash: text("codeHash").notNull()
|
||||||
});
|
});
|
||||||
|
|
||||||
export const sessions = sqliteTable("session", {
|
export const sessions = sqliteTable(
|
||||||
|
"session",
|
||||||
|
{
|
||||||
sessionId: text("id").primaryKey(),
|
sessionId: text("id").primaryKey(),
|
||||||
userId: text("userId")
|
userId: text("userId")
|
||||||
.notNull()
|
.notNull()
|
||||||
@@ -922,7 +974,11 @@ export const sessions = sqliteTable("session", {
|
|||||||
deviceAuthUsed: integer("deviceAuthUsed", { mode: "boolean" })
|
deviceAuthUsed: integer("deviceAuthUsed", { mode: "boolean" })
|
||||||
.notNull()
|
.notNull()
|
||||||
.default(false)
|
.default(false)
|
||||||
});
|
},
|
||||||
|
(table) => [
|
||||||
|
index("idx_sessions_userId").on(table.userId)
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
export const newtSessions = sqliteTable("newtSession", {
|
export const newtSessions = sqliteTable("newtSession", {
|
||||||
sessionId: text("id").primaryKey(),
|
sessionId: text("id").primaryKey(),
|
||||||
@@ -940,7 +996,9 @@ export const olmSessions = sqliteTable("clientSession", {
|
|||||||
expiresAt: integer("expiresAt").notNull()
|
expiresAt: integer("expiresAt").notNull()
|
||||||
});
|
});
|
||||||
|
|
||||||
export const userOrgs = sqliteTable("userOrgs", {
|
export const userOrgs = sqliteTable(
|
||||||
|
"userOrgs",
|
||||||
|
{
|
||||||
userId: text("userId")
|
userId: text("userId")
|
||||||
.notNull()
|
.notNull()
|
||||||
.references(() => users.userId, { onDelete: "cascade" }),
|
.references(() => users.userId, { onDelete: "cascade" }),
|
||||||
@@ -954,7 +1012,12 @@ export const userOrgs = sqliteTable("userOrgs", {
|
|||||||
mode: "boolean"
|
mode: "boolean"
|
||||||
}).default(false),
|
}).default(false),
|
||||||
pamUsername: text("pamUsername") // cleaned username for ssh and such
|
pamUsername: text("pamUsername") // cleaned username for ssh and such
|
||||||
});
|
},
|
||||||
|
(table) => [
|
||||||
|
index("idx_userOrgs_userId").on(table.userId),
|
||||||
|
index("idx_userOrgs_orgId").on(table.orgId)
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
export const emailVerificationCodes = sqliteTable("emailVerificationCodes", {
|
export const emailVerificationCodes = sqliteTable("emailVerificationCodes", {
|
||||||
codeId: integer("id").primaryKey({ autoIncrement: true }),
|
codeId: integer("id").primaryKey({ autoIncrement: true }),
|
||||||
@@ -982,7 +1045,9 @@ export const actions = sqliteTable("actions", {
|
|||||||
description: text("description")
|
description: text("description")
|
||||||
});
|
});
|
||||||
|
|
||||||
export const roles = sqliteTable("roles", {
|
export const roles = sqliteTable(
|
||||||
|
"roles",
|
||||||
|
{
|
||||||
roleId: integer("roleId").primaryKey({ autoIncrement: true }),
|
roleId: integer("roleId").primaryKey({ autoIncrement: true }),
|
||||||
orgId: text("orgId")
|
orgId: text("orgId")
|
||||||
.references(() => orgs.orgId, {
|
.references(() => orgs.orgId, {
|
||||||
@@ -1001,7 +1066,11 @@ export const roles = sqliteTable("roles", {
|
|||||||
true
|
true
|
||||||
),
|
),
|
||||||
sshUnixGroups: text("sshUnixGroups").default("[]")
|
sshUnixGroups: text("sshUnixGroups").default("[]")
|
||||||
});
|
},
|
||||||
|
(table) => [
|
||||||
|
index("idx_roles_orgId").on(table.orgId)
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
export const userOrgRoles = sqliteTable(
|
export const userOrgRoles = sqliteTable(
|
||||||
"userOrgRoles",
|
"userOrgRoles",
|
||||||
|
|||||||
@@ -101,7 +101,7 @@ export const AuthSchema = z.object({
|
|||||||
export const RuleSchema = z
|
export const RuleSchema = z
|
||||||
.object({
|
.object({
|
||||||
action: z.enum(["allow", "deny", "pass"]),
|
action: z.enum(["allow", "deny", "pass"]),
|
||||||
match: z.enum(["cidr", "path", "ip", "country", "asn", "region"]),
|
match: z.enum(["cidr", "path", "ip", "country", "country_is_not", "asn", "region"]),
|
||||||
value: z.coerce.string(),
|
value: z.coerce.string(),
|
||||||
priority: z.int().optional(),
|
priority: z.int().optional(),
|
||||||
enabled: z.boolean().optional().default(true)
|
enabled: z.boolean().optional().default(true)
|
||||||
@@ -136,7 +136,7 @@ export const RuleSchema = z
|
|||||||
)
|
)
|
||||||
.refine(
|
.refine(
|
||||||
(rule) => {
|
(rule) => {
|
||||||
if (rule.match === "country") {
|
if (rule.match === "country" || rule.match === "country_is_not") {
|
||||||
if (!hasMaxmindCountryDb) {
|
if (!hasMaxmindCountryDb) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,7 +31,6 @@ export async function validateAndConstructDomain(
|
|||||||
subdomain?: string | null
|
subdomain?: string | null
|
||||||
): Promise<DomainValidationResult> {
|
): Promise<DomainValidationResult> {
|
||||||
try {
|
try {
|
||||||
// Query domain with organization access check
|
|
||||||
const [domainRes] = await db
|
const [domainRes] = await db
|
||||||
.select()
|
.select()
|
||||||
.from(domains)
|
.from(domains)
|
||||||
@@ -42,6 +41,10 @@ export async function validateAndConstructDomain(
|
|||||||
eq(orgDomains.orgId, orgId),
|
eq(orgDomains.orgId, orgId),
|
||||||
eq(orgDomains.domainId, domainId)
|
eq(orgDomains.domainId, domainId)
|
||||||
)
|
)
|
||||||
|
)
|
||||||
|
.leftJoin(
|
||||||
|
domainNamespaces,
|
||||||
|
eq(domainNamespaces.domainId, domainId)
|
||||||
);
|
);
|
||||||
|
|
||||||
// Check if domain exists
|
// Check if domain exists
|
||||||
@@ -52,7 +55,7 @@ export async function validateAndConstructDomain(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!domainRes.orgDomains) {
|
if (!domainRes.orgDomains && !domainRes.domainNamespaces) {
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
error: `Organization does not have access to domain with ID ${domainId}`
|
error: `Organization does not have access to domain with ID ${domainId}`
|
||||||
@@ -83,20 +86,12 @@ export async function validateAndConstructDomain(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Wildcard subdomains are not allowed on namespace (provided/free) domains
|
// Wildcard subdomains are not allowed on namespace (provided/free) domains
|
||||||
if (isWildcard) {
|
if (isWildcard && domainRes.domainNamespaces) {
|
||||||
const [namespaceDomain] = await db
|
|
||||||
.select()
|
|
||||||
.from(domainNamespaces)
|
|
||||||
.where(eq(domainNamespaces.domainId, domainId))
|
|
||||||
.limit(1);
|
|
||||||
|
|
||||||
if (namespaceDomain) {
|
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
error: "Wildcard subdomains are not supported for provided or free domains. Use a specific subdomain instead."
|
error: "Wildcard subdomains are not supported for provided or free domains. Use a specific subdomain instead."
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
if (
|
||||||
isWildcard &&
|
isWildcard &&
|
||||||
|
|||||||
+50
-1
@@ -3,6 +3,8 @@ import config from "./config";
|
|||||||
import { getHostMeta } from "./hostMeta";
|
import { getHostMeta } from "./hostMeta";
|
||||||
import logger from "@server/logger";
|
import logger from "@server/logger";
|
||||||
import {
|
import {
|
||||||
|
aiProviders,
|
||||||
|
aiUsageRecords,
|
||||||
alertRules,
|
alertRules,
|
||||||
apiKeys,
|
apiKeys,
|
||||||
blueprints,
|
blueprints,
|
||||||
@@ -11,7 +13,16 @@ import {
|
|||||||
siteResources
|
siteResources
|
||||||
} from "@server/db";
|
} from "@server/db";
|
||||||
import { sites, users, orgs, resources, clients, idp } from "@server/db";
|
import { sites, users, orgs, resources, clients, idp } from "@server/db";
|
||||||
import { eq, count, notInArray, and, isNotNull, isNull } from "drizzle-orm";
|
import {
|
||||||
|
eq,
|
||||||
|
count,
|
||||||
|
countDistinct,
|
||||||
|
notInArray,
|
||||||
|
and,
|
||||||
|
isNotNull,
|
||||||
|
isNull,
|
||||||
|
gte
|
||||||
|
} from "drizzle-orm";
|
||||||
import { APP_VERSION } from "./consts";
|
import { APP_VERSION } from "./consts";
|
||||||
import crypto from "crypto";
|
import crypto from "crypto";
|
||||||
import { UserType } from "@server/types/UserTypes";
|
import { UserType } from "@server/types/UserTypes";
|
||||||
@@ -172,6 +183,25 @@ class TelemetryClient {
|
|||||||
.select({ count: count() })
|
.select({ count: count() })
|
||||||
.from(blueprints);
|
.from(blueprints);
|
||||||
|
|
||||||
|
const [aiProvidersCount] = await db
|
||||||
|
.select({ count: count() })
|
||||||
|
.from(aiProviders);
|
||||||
|
const [orgsWithAiProviders] = await db
|
||||||
|
.select({ count: countDistinct(aiProviders.orgId) })
|
||||||
|
.from(aiProviders);
|
||||||
|
|
||||||
|
const usageWindowStart =
|
||||||
|
Math.floor(Date.now() / 1000) -
|
||||||
|
this.collectionIntervalDays * 24 * 60 * 60;
|
||||||
|
const [aiUsageRecordsRecent] = await db
|
||||||
|
.select({ count: count() })
|
||||||
|
.from(aiUsageRecords)
|
||||||
|
.where(gte(aiUsageRecords.createdAt, usageWindowStart));
|
||||||
|
const [orgsWithRecentAiUsage] = await db
|
||||||
|
.select({ count: countDistinct(aiUsageRecords.orgId) })
|
||||||
|
.from(aiUsageRecords)
|
||||||
|
.where(gte(aiUsageRecords.createdAt, usageWindowStart));
|
||||||
|
|
||||||
const supporterKey = config.getSupporterData();
|
const supporterKey = config.getSupporterData();
|
||||||
|
|
||||||
const allPrivateResources = await db.select().from(siteResources);
|
const allPrivateResources = await db.select().from(siteResources);
|
||||||
@@ -182,6 +212,7 @@ class TelemetryClient {
|
|||||||
let numPrivResourceCidr = 0;
|
let numPrivResourceCidr = 0;
|
||||||
let numPrivResourceHttp = 0;
|
let numPrivResourceHttp = 0;
|
||||||
let numPrivResourceSsh = 0;
|
let numPrivResourceSsh = 0;
|
||||||
|
let numPrivResourceInference = 0;
|
||||||
for (const res of allPrivateResources) {
|
for (const res of allPrivateResources) {
|
||||||
if (res.mode === "host") {
|
if (res.mode === "host") {
|
||||||
numPrivResourceHosts += 1;
|
numPrivResourceHosts += 1;
|
||||||
@@ -191,6 +222,8 @@ class TelemetryClient {
|
|||||||
numPrivResourceHttp += 1;
|
numPrivResourceHttp += 1;
|
||||||
} else if (res.mode === "ssh") {
|
} else if (res.mode === "ssh") {
|
||||||
numPrivResourceSsh += 1;
|
numPrivResourceSsh += 1;
|
||||||
|
} else if (res.mode === "inference") {
|
||||||
|
numPrivResourceInference += 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (res.alias) {
|
if (res.alias) {
|
||||||
@@ -211,6 +244,11 @@ class TelemetryClient {
|
|||||||
numPrivateResourceCidr: numPrivResourceCidr,
|
numPrivateResourceCidr: numPrivResourceCidr,
|
||||||
numPrivateResourceHttp: numPrivResourceHttp,
|
numPrivateResourceHttp: numPrivResourceHttp,
|
||||||
numPrivateResourceSsh: numPrivResourceSsh,
|
numPrivateResourceSsh: numPrivResourceSsh,
|
||||||
|
numPrivateResourceInference: numPrivResourceInference,
|
||||||
|
numAiProviders: aiProvidersCount.count,
|
||||||
|
numOrgsWithAiProviders: orgsWithAiProviders.count,
|
||||||
|
numAiUsageRecordsRecent: aiUsageRecordsRecent.count,
|
||||||
|
numOrgsWithRecentAiUsage: orgsWithRecentAiUsage.count,
|
||||||
numAlertRules: numAlertRules.count,
|
numAlertRules: numAlertRules.count,
|
||||||
numUserDevices: userDevicesCount.count,
|
numUserDevices: userDevicesCount.count,
|
||||||
numMachineClients: machineClients.count,
|
numMachineClients: machineClients.count,
|
||||||
@@ -323,6 +361,17 @@ class TelemetryClient {
|
|||||||
num_resources_non_http: stats.resources.filter(
|
num_resources_non_http: stats.resources.filter(
|
||||||
(r) => r.mode !== "http"
|
(r) => r.mode !== "http"
|
||||||
).length,
|
).length,
|
||||||
|
num_resources_ai_gateway: stats.resources.filter(
|
||||||
|
(r) => r.mode === "inference"
|
||||||
|
).length,
|
||||||
|
num_private_resources_ai_gateway:
|
||||||
|
stats.numPrivateResourceInference,
|
||||||
|
num_ai_providers: stats.numAiProviders,
|
||||||
|
num_orgs_with_ai_providers: stats.numOrgsWithAiProviders,
|
||||||
|
num_ai_usage_records_recent:
|
||||||
|
stats.numAiUsageRecordsRecent,
|
||||||
|
num_orgs_with_recent_ai_usage:
|
||||||
|
stats.numOrgsWithRecentAiUsage,
|
||||||
num_newt_sites: stats.sites.filter((s) => s.type === "newt")
|
num_newt_sites: stats.sites.filter((s) => s.type === "newt")
|
||||||
.length,
|
.length,
|
||||||
num_local_sites: stats.sites.filter(
|
num_local_sites: stats.sites.filter(
|
||||||
|
|||||||
@@ -68,6 +68,11 @@ export async function verifyApiKeyAccessTokenAccess(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (apiKey.isRoot) {
|
||||||
|
// Root keys can access any access token in any org
|
||||||
|
return next();
|
||||||
|
}
|
||||||
|
|
||||||
if (!resource.orgId) {
|
if (!resource.orgId) {
|
||||||
return next(
|
return next(
|
||||||
createHttpError(
|
createHttpError(
|
||||||
|
|||||||
@@ -149,12 +149,8 @@ LQIDAQAB
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Count used sites and users for license comparison
|
// Count used sites and users for license comparison
|
||||||
const [siteCountRes] = await db
|
const [siteCountRes] = await db.select({ value: count() }).from(sites);
|
||||||
.select({ value: count() })
|
const [userCountRes] = await db.select({ value: count() }).from(users);
|
||||||
.from(sites);
|
|
||||||
const [userCountRes] = await db
|
|
||||||
.select({ value: count() })
|
|
||||||
.from(users);
|
|
||||||
|
|
||||||
const status: LicenseStatus = {
|
const status: LicenseStatus = {
|
||||||
hostId: this.hostMeta.hostMetaId,
|
hostId: this.hostMeta.hostMetaId,
|
||||||
@@ -276,10 +272,13 @@ LQIDAQAB
|
|||||||
logger.error(
|
logger.error(
|
||||||
`Allowing failure. Will retry one more time at next run interval.`
|
`Allowing failure. Will retry one more time at next run interval.`
|
||||||
);
|
);
|
||||||
// return last known good status
|
// Fall back to last known good status if we have
|
||||||
return this.statusCache.get(
|
// one cached; otherwise return the freshly built
|
||||||
|
// status (with defaults) rather than undefined.
|
||||||
|
const lastKnownStatus = this.statusCache.get(
|
||||||
this.statusKey
|
this.statusKey
|
||||||
) as LicenseStatus;
|
) as LicenseStatus | undefined;
|
||||||
|
return lastKnownStatus ?? status;
|
||||||
} else {
|
} else {
|
||||||
// Subsequent failures: fail abruptly
|
// Subsequent failures: fail abruptly
|
||||||
throw e;
|
throw e;
|
||||||
@@ -368,10 +367,7 @@ LQIDAQAB
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Only consider quantity if defined and >= 0 (quantity = users, quantity_2 = sites)
|
// Only consider quantity if defined and >= 0 (quantity = users, quantity_2 = sites)
|
||||||
if (
|
if (cached.quantity_2 !== undefined && cached.quantity_2 >= 0) {
|
||||||
cached.quantity_2 !== undefined &&
|
|
||||||
cached.quantity_2 >= 0
|
|
||||||
) {
|
|
||||||
status.maxSites =
|
status.maxSites =
|
||||||
(status.maxSites ?? 0) + cached.quantity_2;
|
(status.maxSites ?? 0) + cached.quantity_2;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -50,8 +50,6 @@ import {
|
|||||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||||
import { usePaidStatus } from "@app/hooks/usePaidStatus";
|
import { usePaidStatus } from "@app/hooks/usePaidStatus";
|
||||||
import { toast } from "@app/hooks/useToast";
|
import { toast } from "@app/hooks/useToast";
|
||||||
import { PaidFeaturesAlert } from "@app/components/PaidFeaturesAlert";
|
|
||||||
import { tierMatrix, TierFeature } from "@server/lib/billing/tierMatrix";
|
|
||||||
import { createApiClient, formatAxiosError } from "@app/lib/api";
|
import { createApiClient, formatAxiosError } from "@app/lib/api";
|
||||||
import {
|
import {
|
||||||
createBrowserGatewayTargetFormSchema,
|
createBrowserGatewayTargetFormSchema,
|
||||||
@@ -59,7 +57,6 @@ import {
|
|||||||
selectedSiteSchema,
|
selectedSiteSchema,
|
||||||
type SshSettingsFormValues
|
type SshSettingsFormValues
|
||||||
} from "@app/lib/browserGatewayTargetFormSchema";
|
} from "@app/lib/browserGatewayTargetFormSchema";
|
||||||
import { DockerManager, DockerState } from "@app/lib/docker";
|
|
||||||
import { orgQueries } from "@app/lib/queries";
|
import { orgQueries } from "@app/lib/queries";
|
||||||
import { finalizeSubdomainSanitize } from "@app/lib/subdomain-utils";
|
import { finalizeSubdomainSanitize } from "@app/lib/subdomain-utils";
|
||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
@@ -328,19 +325,20 @@ export default function Page() {
|
|||||||
const rawResourcesAllowed =
|
const rawResourcesAllowed =
|
||||||
env.flags.allowRawResources &&
|
env.flags.allowRawResources &&
|
||||||
(build !== "saas" || remoteExitNodes.length > 0);
|
(build !== "saas" || remoteExitNodes.length > 0);
|
||||||
const enterpriseModesAllowed =
|
|
||||||
!env.flags.disableEnterpriseFeatures;
|
|
||||||
|
|
||||||
const availableTypes = useMemo((): NewResourceType[] => {
|
const availableTypes = useMemo((): NewResourceType[] => {
|
||||||
const base: NewResourceType[] = ["http", "inference"];
|
const base: NewResourceType[] = [
|
||||||
if (enterpriseModesAllowed) {
|
"http",
|
||||||
base.push("ssh", "rdp", "vnc");
|
"inference",
|
||||||
}
|
"ssh",
|
||||||
|
"rdp",
|
||||||
|
"vnc"
|
||||||
|
];
|
||||||
if (rawResourcesAllowed) {
|
if (rawResourcesAllowed) {
|
||||||
base.push("tcp", "udp");
|
base.push("tcp", "udp");
|
||||||
}
|
}
|
||||||
return base;
|
return base;
|
||||||
}, [enterpriseModesAllowed, rawResourcesAllowed]);
|
}, [rawResourcesAllowed]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!availableTypes.includes(resourceType)) {
|
if (!availableTypes.includes(resourceType)) {
|
||||||
|
|||||||
Reference in New Issue
Block a user