mirror of
https://github.com/fosrl/pangolin.git
synced 2026-10-10 14:49:04 +02:00
Merge upstream/dev into feature-response-headers
Resolve conflicts against upstream's refactors: - server/db/sqlite/schema/schema.ts: adopt upstream's reindented sqliteTable(name, cols, indexes) form for sites/resources, re-applying the headers -> requestHeaders/responseHeaders split. Kept in sync with the Postgres schema. - server/lib/traefik/headersMiddleware.ts: extend upstream's extracted buildCustomHeadersMiddleware helper to take requestHeaders and responseHeaders and emit both customRequestHeaders and customResponseHeaders. - server/lib/traefik/getTraefikConfig.ts and server/private/lib/traefik/getTraefikConfig.ts: keep upstream's helper extraction and appendPathMatch refactor, dropping the superseded inline blocks. Also carry the feature forward onto code that moved upstream: - The resource settings UI moved from resources/proxy/[niceId]/proxy to resources/public/[niceId]/http, which dropped this branch's changes in the previous merge. Re-add the request/response header inputs there and rename the vestigial headers field on the tcp page. - messages/da-DK.json is new upstream and still had the old customHeaders key; rename it in line with the other locales. Per the contributing docs, versioned migrations are intentionally omitted so maintainers can write them at release time. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,89 @@
|
||||
import { eq } from "drizzle-orm";
|
||||
import { aiBudgets, Transaction } from "@server/db";
|
||||
|
||||
export type BlueprintAiBudgetInput = {
|
||||
amount: number;
|
||||
unit: "usd" | "tokens";
|
||||
period:
|
||||
| "monthly"
|
||||
| "yearly"
|
||||
| "lifetime"
|
||||
| "daily"
|
||||
| "hourly"
|
||||
| "weekly";
|
||||
enforcement: "hard" | "soft";
|
||||
enabled: boolean;
|
||||
};
|
||||
|
||||
type SyncAiBudgetsInput = {
|
||||
orgId: string;
|
||||
trx: Transaction;
|
||||
budgets: BlueprintAiBudgetInput[];
|
||||
} & (
|
||||
| { scope: "public"; resourceId: number }
|
||||
| { scope: "site"; siteResourceId: number }
|
||||
);
|
||||
|
||||
/**
|
||||
* Fully declarative: makes the resource's/site resource's AI budgets match
|
||||
* exactly what the blueprint declares (omitted unit/period budgets are removed).
|
||||
*/
|
||||
export async function syncAiBudgets(input: SyncAiBudgetsInput): Promise<void> {
|
||||
const { orgId, trx, budgets } = input;
|
||||
|
||||
const existing = await trx
|
||||
.select()
|
||||
.from(aiBudgets)
|
||||
.where(
|
||||
input.scope === "public"
|
||||
? eq(aiBudgets.resourceId, input.resourceId)
|
||||
: eq(aiBudgets.siteResourceId, input.siteResourceId)
|
||||
);
|
||||
|
||||
const existingByKey = new Map(
|
||||
existing.map((b) => [`${b.unit}::${b.period}`, b])
|
||||
);
|
||||
|
||||
const seenKeys = new Set<string>();
|
||||
const now = Date.now();
|
||||
|
||||
for (const budget of budgets) {
|
||||
const key = `${budget.unit}::${budget.period}`;
|
||||
seenKeys.add(key);
|
||||
const existingBudget = existingByKey.get(key);
|
||||
|
||||
if (existingBudget) {
|
||||
await trx
|
||||
.update(aiBudgets)
|
||||
.set({
|
||||
amount: budget.amount,
|
||||
enforcement: budget.enforcement,
|
||||
enabled: budget.enabled,
|
||||
updatedAt: now
|
||||
})
|
||||
.where(eq(aiBudgets.budgetId, existingBudget.budgetId));
|
||||
} else {
|
||||
await trx.insert(aiBudgets).values({
|
||||
orgId,
|
||||
resourceId: input.scope === "public" ? input.resourceId : null,
|
||||
siteResourceId:
|
||||
input.scope === "site" ? input.siteResourceId : null,
|
||||
amount: budget.amount,
|
||||
unit: budget.unit,
|
||||
period: budget.period,
|
||||
enforcement: budget.enforcement,
|
||||
enabled: budget.enabled,
|
||||
createdAt: now,
|
||||
updatedAt: now
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
for (const [key, existingBudget] of existingByKey) {
|
||||
if (!seenKeys.has(key)) {
|
||||
await trx
|
||||
.delete(aiBudgets)
|
||||
.where(eq(aiBudgets.budgetId, existingBudget.budgetId));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,280 @@
|
||||
import { and, eq, inArray } from "drizzle-orm";
|
||||
import {
|
||||
aiModels,
|
||||
aiProviders,
|
||||
resourceAiModels,
|
||||
siteResourceAiModels,
|
||||
Transaction
|
||||
} from "@server/db";
|
||||
import {
|
||||
AccessMode,
|
||||
ModelListType,
|
||||
clearPublicResourceAiConfig,
|
||||
clearSiteResourceAiConfig,
|
||||
isInferenceFieldsError,
|
||||
resolveProviderAttachments,
|
||||
setPublicResourceAiProviders,
|
||||
setSiteResourceAiProviders
|
||||
} from "@server/lib/aiInferenceResource";
|
||||
|
||||
export type BlueprintAiModelInput = {
|
||||
model: string;
|
||||
listType: ModelListType;
|
||||
};
|
||||
|
||||
export type BlueprintAiProviderInput = {
|
||||
provider: string;
|
||||
accessMode: AccessMode;
|
||||
enabled: boolean;
|
||||
models: string[];
|
||||
};
|
||||
|
||||
async function resolveProviderNiceIds(
|
||||
orgId: string,
|
||||
niceIds: string[],
|
||||
trx: Transaction
|
||||
): Promise<Map<string, number>> {
|
||||
const unique = [...new Set(niceIds)];
|
||||
if (unique.length === 0) {
|
||||
return new Map();
|
||||
}
|
||||
|
||||
const rows = await trx
|
||||
.select({
|
||||
providerId: aiProviders.providerId,
|
||||
niceId: aiProviders.niceId
|
||||
})
|
||||
.from(aiProviders)
|
||||
.where(
|
||||
and(
|
||||
eq(aiProviders.orgId, orgId),
|
||||
inArray(aiProviders.niceId, unique)
|
||||
)
|
||||
);
|
||||
|
||||
const byNiceId = new Map(rows.map((r) => [r.niceId, r.providerId]));
|
||||
const missing = unique.filter((id) => !byNiceId.has(id));
|
||||
if (missing.length > 0) {
|
||||
throw new Error(
|
||||
`AI provider(s) not found in this org: ${missing.join(", ")}`
|
||||
);
|
||||
}
|
||||
|
||||
return byNiceId;
|
||||
}
|
||||
|
||||
async function resolveModelKeys(
|
||||
providers: BlueprintAiProviderInput[],
|
||||
providerIdByNiceId: Map<string, number>,
|
||||
trx: Transaction
|
||||
): Promise<Map<string, number>> {
|
||||
const providerIds = [
|
||||
...new Set(
|
||||
providers
|
||||
.filter((p) => p.models.length > 0)
|
||||
.map((p) => providerIdByNiceId.get(p.provider)!)
|
||||
)
|
||||
];
|
||||
|
||||
if (providerIds.length === 0) {
|
||||
return new Map();
|
||||
}
|
||||
|
||||
const rows = await trx
|
||||
.select({
|
||||
modelId: aiModels.modelId,
|
||||
modelKey: aiModels.modelKey,
|
||||
providerId: aiModels.providerId
|
||||
})
|
||||
.from(aiModels)
|
||||
.where(inArray(aiModels.providerId, providerIds));
|
||||
|
||||
const byProviderAndKey = new Map<string, number>();
|
||||
for (const row of rows) {
|
||||
byProviderAndKey.set(`${row.providerId}::${row.modelKey}`, row.modelId);
|
||||
}
|
||||
|
||||
const modelIdByEntryKey = new Map<string, number>();
|
||||
const missing: string[] = [];
|
||||
for (const provider of providers) {
|
||||
const providerId = providerIdByNiceId.get(provider.provider)!;
|
||||
for (const m of provider.models) {
|
||||
const modelId = byProviderAndKey.get(`${providerId}::${m}`);
|
||||
if (modelId === undefined) {
|
||||
missing.push(`${provider.provider}/${m}`);
|
||||
continue;
|
||||
}
|
||||
modelIdByEntryKey.set(`${provider.provider}::${m}`, modelId);
|
||||
}
|
||||
}
|
||||
|
||||
if (missing.length > 0) {
|
||||
throw new Error(`AI model(s) not found: ${missing.join(", ")}`);
|
||||
}
|
||||
|
||||
return modelIdByEntryKey;
|
||||
}
|
||||
|
||||
async function validateModelEntries(input: {
|
||||
orgId: string;
|
||||
entries: { modelId: number }[];
|
||||
selectProviderIds: number[];
|
||||
trx: Transaction;
|
||||
}): Promise<void> {
|
||||
if (input.entries.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (input.selectProviderIds.length === 0) {
|
||||
throw new Error(
|
||||
"Set at least one attached AI provider to access-mode 'select' before declaring models"
|
||||
);
|
||||
}
|
||||
|
||||
const modelIds = input.entries.map((e) => e.modelId);
|
||||
const catalogRows = await input.trx
|
||||
.select({
|
||||
modelId: aiModels.modelId,
|
||||
listType: aiModels.listType,
|
||||
providerId: aiModels.providerId,
|
||||
enabled: aiModels.enabled
|
||||
})
|
||||
.from(aiModels)
|
||||
.innerJoin(aiProviders, eq(aiModels.providerId, aiProviders.providerId))
|
||||
.where(
|
||||
and(
|
||||
inArray(aiModels.modelId, modelIds),
|
||||
inArray(aiModels.providerId, input.selectProviderIds),
|
||||
eq(aiProviders.orgId, input.orgId)
|
||||
)
|
||||
);
|
||||
|
||||
const catalogById = new Map(catalogRows.map((row) => [row.modelId, row]));
|
||||
for (const entry of input.entries) {
|
||||
const catalog = catalogById.get(entry.modelId);
|
||||
if (!catalog) {
|
||||
throw new Error(
|
||||
`Model ${entry.modelId} does not exist or does not belong to a select-mode attached provider`
|
||||
);
|
||||
}
|
||||
if (!catalog.enabled) {
|
||||
throw new Error(
|
||||
`Model ${entry.modelId} is disabled on its provider`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type SyncInferenceAiConfigInput = {
|
||||
orgId: string;
|
||||
trx: Transaction;
|
||||
mode: string;
|
||||
providers: BlueprintAiProviderInput[];
|
||||
} & (
|
||||
| { scope: "public"; resourceId: number }
|
||||
| { scope: "site"; siteResourceId: number }
|
||||
);
|
||||
|
||||
/**
|
||||
* Fully declarative: makes the resource's attached AI providers/models match
|
||||
* exactly what the blueprint declares (omitted providers/models are removed).
|
||||
* Non-inference resources have any leftover AI config cleared.
|
||||
*/
|
||||
export async function syncInferenceAiConfig(
|
||||
input: SyncInferenceAiConfigInput
|
||||
): Promise<void> {
|
||||
const { orgId, trx, mode } = input;
|
||||
|
||||
if (mode !== "inference") {
|
||||
if (input.scope === "public") {
|
||||
await clearPublicResourceAiConfig(input.resourceId, trx);
|
||||
} else {
|
||||
await clearSiteResourceAiConfig(input.siteResourceId, trx);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const providerIdByNiceId = await resolveProviderNiceIds(
|
||||
orgId,
|
||||
input.providers.map((p) => p.provider),
|
||||
trx
|
||||
);
|
||||
|
||||
const resolvedAttachments = await resolveProviderAttachments({
|
||||
orgId,
|
||||
attachments: input.providers.map((p) => ({
|
||||
providerId: providerIdByNiceId.get(p.provider)!,
|
||||
accessMode: p.accessMode,
|
||||
enabled: p.enabled
|
||||
})),
|
||||
requireAtLeastOne: false
|
||||
});
|
||||
if (isInferenceFieldsError(resolvedAttachments)) {
|
||||
throw new Error(resolvedAttachments.error);
|
||||
}
|
||||
|
||||
if (input.scope === "public") {
|
||||
await setPublicResourceAiProviders(
|
||||
input.resourceId,
|
||||
resolvedAttachments,
|
||||
trx
|
||||
);
|
||||
} else {
|
||||
await setSiteResourceAiProviders(
|
||||
input.siteResourceId,
|
||||
resolvedAttachments,
|
||||
trx
|
||||
);
|
||||
}
|
||||
|
||||
const modelIdByEntryKey = await resolveModelKeys(
|
||||
input.providers,
|
||||
providerIdByNiceId,
|
||||
trx
|
||||
);
|
||||
|
||||
const modelEntries = input.providers.flatMap((p) =>
|
||||
p.models.map((m) => ({
|
||||
modelId: modelIdByEntryKey.get(`${p.provider}::${m}`)!
|
||||
}))
|
||||
);
|
||||
|
||||
const selectProviderIds = resolvedAttachments
|
||||
.filter((a) => a.accessMode === "select")
|
||||
.map((a) => a.providerId);
|
||||
|
||||
await validateModelEntries({
|
||||
orgId,
|
||||
entries: modelEntries,
|
||||
selectProviderIds,
|
||||
trx
|
||||
});
|
||||
|
||||
if (input.scope === "public") {
|
||||
await trx
|
||||
.delete(resourceAiModels)
|
||||
.where(eq(resourceAiModels.resourceId, input.resourceId));
|
||||
if (modelEntries.length > 0) {
|
||||
await trx.insert(resourceAiModels).values(
|
||||
modelEntries.map((m) => ({
|
||||
resourceId: input.resourceId,
|
||||
modelId: m.modelId
|
||||
}))
|
||||
);
|
||||
}
|
||||
} else {
|
||||
await trx
|
||||
.delete(siteResourceAiModels)
|
||||
.where(
|
||||
eq(siteResourceAiModels.siteResourceId, input.siteResourceId)
|
||||
);
|
||||
if (modelEntries.length > 0) {
|
||||
await trx.insert(siteResourceAiModels).values(
|
||||
modelEntries.map((m) => ({
|
||||
siteResourceId: input.siteResourceId,
|
||||
modelId: m.modelId
|
||||
}))
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import {
|
||||
db,
|
||||
primaryDb,
|
||||
newts,
|
||||
blueprints,
|
||||
Blueprint,
|
||||
@@ -34,12 +35,6 @@ import {
|
||||
rebuildClientAssociationsFromSiteResource,
|
||||
waitForSiteResourceRebuildIdle
|
||||
} from "../rebuildClientAssociations";
|
||||
import { build } from "@server/build";
|
||||
import HttpCode from "@server/types/HttpCode";
|
||||
import createHttpError from "http-errors";
|
||||
import next from "next";
|
||||
import { LimitId } from "../billing";
|
||||
import { usageService } from "../billing/usageService";
|
||||
|
||||
type ApplyBlueprintArgs = {
|
||||
orgId: string;
|
||||
@@ -86,93 +81,103 @@ export async function applyBlueprint({
|
||||
trx,
|
||||
siteId
|
||||
);
|
||||
});
|
||||
|
||||
// We need to update the targets on the newts from the successfully updated information
|
||||
for (const result of publicResourcesResults) {
|
||||
for (const target of result.targetsToUpdate) {
|
||||
const [site] = await trx
|
||||
.select()
|
||||
.from(sites)
|
||||
.innerJoin(newts, eq(sites.siteId, newts.siteId))
|
||||
.where(
|
||||
and(
|
||||
eq(sites.siteId, target.siteId),
|
||||
eq(sites.orgId, orgId),
|
||||
eq(sites.type, "newt"),
|
||||
isNotNull(sites.pubKey)
|
||||
)
|
||||
// Push updates to newts/clients only after the transaction has
|
||||
// committed. Doing this while the transaction is still open can
|
||||
// race with the writes (e.g. newts requesting config before the
|
||||
// new targets/resources are actually visible), leaving them out
|
||||
// of sync until manually toggled.
|
||||
|
||||
// We need to update the targets on the newts from the successfully updated information
|
||||
for (const result of publicResourcesResults) {
|
||||
for (const target of result.targetsToUpdate) {
|
||||
// read from the primary: this determines whether/how we push
|
||||
// the just-created target to the newt, so a lagging replica
|
||||
// returning stale or missing data here would silently skip
|
||||
// the push
|
||||
const [site] = await primaryDb
|
||||
.select()
|
||||
.from(sites)
|
||||
.innerJoin(newts, eq(sites.siteId, newts.siteId))
|
||||
.where(
|
||||
and(
|
||||
eq(sites.siteId, target.siteId),
|
||||
eq(sites.orgId, orgId),
|
||||
eq(sites.type, "newt"),
|
||||
isNotNull(sites.pubKey)
|
||||
)
|
||||
.limit(1);
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
if (site) {
|
||||
logger.debug(
|
||||
`Updating target ${target.targetId} on site ${site.sites.siteId}`
|
||||
if (site) {
|
||||
logger.debug(
|
||||
`Updating target ${target.targetId} on site ${site.sites.siteId}`
|
||||
);
|
||||
|
||||
// see if you can find a matching target health check from the healthchecksToUpdate array
|
||||
const matchingHealthcheck =
|
||||
result.healthchecksToUpdate.find(
|
||||
(hc) => hc.targetId === target.targetId
|
||||
);
|
||||
|
||||
// see if you can find a matching target health check from the healthchecksToUpdate array
|
||||
const matchingHealthcheck =
|
||||
result.healthchecksToUpdate.find(
|
||||
(hc) => hc.targetId === target.targetId
|
||||
);
|
||||
|
||||
if (["http", "tcp", "udp"].includes(target.mode)) {
|
||||
await addProxyTargets(
|
||||
site.newt.newtId,
|
||||
[target],
|
||||
matchingHealthcheck
|
||||
? [matchingHealthcheck]
|
||||
: [],
|
||||
result.proxyResource.mode === "udp"
|
||||
? "udp"
|
||||
: "tcp",
|
||||
site.newt.version
|
||||
);
|
||||
} else if (
|
||||
["ssh", "rdp", "vnc"].includes(target.mode)
|
||||
) {
|
||||
await sendBrowserGatewayTargets(
|
||||
site.newt.newtId,
|
||||
[target],
|
||||
site.newt.version
|
||||
);
|
||||
}
|
||||
if (["http", "tcp", "udp"].includes(target.mode)) {
|
||||
await addProxyTargets(
|
||||
site.newt.newtId,
|
||||
[target],
|
||||
matchingHealthcheck
|
||||
? [matchingHealthcheck]
|
||||
: [],
|
||||
result.proxyResource.mode === "udp"
|
||||
? "udp"
|
||||
: "tcp",
|
||||
site.newt.version
|
||||
);
|
||||
} else if (
|
||||
["ssh", "rdp", "vnc"].includes(target.mode)
|
||||
) {
|
||||
await sendBrowserGatewayTargets(
|
||||
site.newt.newtId,
|
||||
[target],
|
||||
site.newt.version
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
logger.debug(
|
||||
`Successfully updated public resources for org ${orgId}: ${JSON.stringify(publicResourcesResults)}`
|
||||
);
|
||||
logger.debug(
|
||||
`Successfully updated public resources for org ${orgId}: ${JSON.stringify(publicResourcesResults)}`
|
||||
);
|
||||
|
||||
// We need to update the targets on the newts from the successfully updated information
|
||||
for (const result of privateResourcesResults) {
|
||||
rebuildClientAssociationsFromSiteResource(
|
||||
result.newSiteResource
|
||||
// We need to update the targets on the newts from the successfully updated information
|
||||
for (const result of privateResourcesResults) {
|
||||
rebuildClientAssociationsFromSiteResource(
|
||||
result.newSiteResource
|
||||
)
|
||||
.then(() =>
|
||||
waitForSiteResourceRebuildIdle(
|
||||
result.newSiteResource.siteResourceId
|
||||
)
|
||||
)
|
||||
.then(() =>
|
||||
waitForSiteResourceRebuildIdle(
|
||||
result.newSiteResource.siteResourceId
|
||||
)
|
||||
.then(() =>
|
||||
handleMessagingForUpdatedSiteResource(
|
||||
result.oldSiteResource,
|
||||
result.newSiteResource,
|
||||
result.oldSites.map((s) => s.siteId),
|
||||
result.newSites.map((s) => s.siteId)
|
||||
)
|
||||
.then(() =>
|
||||
handleMessagingForUpdatedSiteResource(
|
||||
result.oldSiteResource,
|
||||
result.newSiteResource,
|
||||
result.oldSites.map((s) => s.siteId),
|
||||
result.newSites.map((s) => s.siteId)
|
||||
)
|
||||
)
|
||||
.catch((e) => {
|
||||
logger.error(
|
||||
`Failed to rebuild and handle messaging for site resource ${result.newSiteResource.siteResourceId}. Error: ${e}`
|
||||
);
|
||||
});
|
||||
}
|
||||
)
|
||||
.catch((e) => {
|
||||
logger.error(
|
||||
`Failed to rebuild and handle messaging for site resource ${result.newSiteResource.siteResourceId}. Error: ${e}`
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
logger.debug(
|
||||
`Successfully updated private resources for org ${orgId}: ${JSON.stringify(privateResourcesResults)}`
|
||||
);
|
||||
});
|
||||
logger.debug(
|
||||
`Successfully updated private resources for org ${orgId}: ${JSON.stringify(privateResourcesResults)}`
|
||||
);
|
||||
|
||||
blueprintSucceeded = true;
|
||||
blueprintMessage = "Blueprint applied successfully";
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
import {
|
||||
labels,
|
||||
resourceLabels,
|
||||
siteResourceLabels,
|
||||
Transaction
|
||||
} from "@server/db";
|
||||
import logger from "@server/logger";
|
||||
import { and, eq, sql } from "drizzle-orm";
|
||||
|
||||
// Matches the "gray" swatch in the label color palette used by the UI
|
||||
// (src/components/labels-selector.tsx), used as the default for labels
|
||||
// auto-created from a blueprint where no color is specified.
|
||||
const DEFAULT_LABEL_COLOR = "#b4b4b4";
|
||||
|
||||
/**
|
||||
* Looks up labels by name (case-insensitive) within an org, auto-creating
|
||||
* any that don't already exist. Returns the resolved, de-duplicated labelIds.
|
||||
*/
|
||||
export async function getOrCreateLabelIds(
|
||||
orgId: string,
|
||||
labelNames: string[],
|
||||
trx: Transaction
|
||||
): Promise<number[]> {
|
||||
const labelIds = new Set<number>();
|
||||
|
||||
for (const name of labelNames) {
|
||||
let [label] = await trx
|
||||
.select({ labelId: labels.labelId })
|
||||
.from(labels)
|
||||
.where(
|
||||
and(
|
||||
eq(labels.orgId, orgId),
|
||||
sql`LOWER(${labels.name}) = ${name.toLowerCase()}`
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
if (!label) {
|
||||
[label] = await trx
|
||||
.insert(labels)
|
||||
.values({ name, color: DEFAULT_LABEL_COLOR, orgId })
|
||||
.returning({ labelId: labels.labelId });
|
||||
logger.info(
|
||||
`Auto-created label "${name}" in org ${orgId} from blueprint`
|
||||
);
|
||||
}
|
||||
|
||||
labelIds.add(label.labelId);
|
||||
}
|
||||
|
||||
return Array.from(labelIds);
|
||||
}
|
||||
|
||||
export async function syncResourceLabels(
|
||||
resourceId: number,
|
||||
labelIds: number[],
|
||||
trx: Transaction
|
||||
) {
|
||||
await trx
|
||||
.delete(resourceLabels)
|
||||
.where(eq(resourceLabels.resourceId, resourceId));
|
||||
|
||||
if (labelIds.length > 0) {
|
||||
await trx
|
||||
.insert(resourceLabels)
|
||||
.values(labelIds.map((labelId) => ({ resourceId, labelId })));
|
||||
}
|
||||
}
|
||||
|
||||
export async function syncSiteResourceLabels(
|
||||
siteResourceId: number,
|
||||
labelIds: number[],
|
||||
trx: Transaction
|
||||
) {
|
||||
await trx
|
||||
.delete(siteResourceLabels)
|
||||
.where(eq(siteResourceLabels.siteResourceId, siteResourceId));
|
||||
|
||||
if (labelIds.length > 0) {
|
||||
await trx
|
||||
.insert(siteResourceLabels)
|
||||
.values(
|
||||
labelIds.map((labelId) => ({ siteResourceId, labelId }))
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -19,23 +19,22 @@ import {
|
||||
import { sites } from "@server/db";
|
||||
import { eq, and, ne, inArray, or, isNotNull } from "drizzle-orm";
|
||||
import { Config } from "./types";
|
||||
import { getOrCreateLabelIds, syncSiteResourceLabels } from "./labels";
|
||||
import logger from "@server/logger";
|
||||
import { defaultRoleAllowedActions } from "@server/routers/role/createRole";
|
||||
import { getNextAvailableAliasAddress } from "../ip";
|
||||
import { createCertificate } from "#dynamic/routers/certificates/createCertificate";
|
||||
import { isLicensedOrSubscribed } from "#dynamic/lib/isLicencedOrSubscribed";
|
||||
import { tierMatrix } from "../billing/tierMatrix";
|
||||
import { createCertificate } from "@server/routers/certificates/createCertificate";
|
||||
import { build } from "@server/build";
|
||||
import HttpCode from "@server/types/HttpCode";
|
||||
import createHttpError from "http-errors";
|
||||
import next from "next";
|
||||
import { LimitId } from "../billing";
|
||||
import { usageService } from "../billing/usageService";
|
||||
import { syncInferenceAiConfig } from "./aiProviders";
|
||||
import { syncAiBudgets } from "./aiBudgets";
|
||||
|
||||
async function getDomainForSiteResource(
|
||||
siteResourceId: number | undefined,
|
||||
fullDomain: string,
|
||||
orgId: string,
|
||||
isInference: boolean,
|
||||
trx: Transaction
|
||||
): Promise<{ subdomain: string | null; domainId: string }> {
|
||||
const [fullDomainExists] = await trx
|
||||
@@ -45,6 +44,11 @@ async function getDomainForSiteResource(
|
||||
and(
|
||||
eq(siteResources.fullDomain, fullDomain),
|
||||
eq(siteResources.orgId, orgId),
|
||||
// exclude looking at the ones on exit nodes if this is an inference resource,
|
||||
// and vice versa, so inference and non-inference resources can share a full-domain
|
||||
isInference
|
||||
? ne(siteResources.mode, "inference")
|
||||
: eq(siteResources.mode, "inference"),
|
||||
siteResourceId
|
||||
? ne(siteResources.siteResourceId, siteResourceId)
|
||||
: isNotNull(siteResources.siteResourceId)
|
||||
@@ -122,30 +126,6 @@ export async function updatePrivateResources(
|
||||
for (const [resourceNiceId, resourceData] of Object.entries(
|
||||
config["client-resources"]
|
||||
)) {
|
||||
if (resourceData.mode === "http") {
|
||||
const hasHttpFeature = await isLicensedOrSubscribed(
|
||||
orgId,
|
||||
tierMatrix.advancedPrivateResources
|
||||
);
|
||||
if (!hasHttpFeature) {
|
||||
throw new Error(
|
||||
"HTTP private resources are not included in your current plan. Please upgrade."
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (resourceData.mode === "ssh") {
|
||||
const hasSshFeature = await isLicensedOrSubscribed(
|
||||
orgId,
|
||||
tierMatrix.advancedPrivateResources
|
||||
);
|
||||
if (!hasSshFeature) {
|
||||
throw new Error(
|
||||
"SSH private resources are not included in your current plan. Please upgrade."
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const [existingResource] = await trx
|
||||
.select()
|
||||
.from(siteResources)
|
||||
@@ -201,60 +181,109 @@ export async function updatePrivateResources(
|
||||
}
|
||||
}
|
||||
|
||||
let resourceStatusFromSite: "approved" | "pending" = "approved";
|
||||
if (siteId && allSites.length === 0) {
|
||||
// only add if there are not provided sites
|
||||
// Use the provided siteId directly, but verify it belongs to the org
|
||||
const [siteSingle] = await trx
|
||||
.select({ siteId: sites.siteId })
|
||||
.select({ siteId: sites.siteId, status: sites.status })
|
||||
.from(sites)
|
||||
.where(and(eq(sites.siteId, siteId), eq(sites.orgId, orgId)))
|
||||
.limit(1);
|
||||
if (siteSingle) {
|
||||
allSites.push(siteSingle);
|
||||
}
|
||||
resourceStatusFromSite = siteSingle.status ?? "approved";
|
||||
}
|
||||
|
||||
if (allSites.length === 0) {
|
||||
if (resourceData.mode !== "inference" && allSites.length === 0) {
|
||||
throw new Error(
|
||||
`No valid sites found for private private resource ${resourceNiceId} in org ${orgId}`
|
||||
);
|
||||
}
|
||||
|
||||
const resourceEnabled =
|
||||
resourceData.enabled == undefined || resourceData.enabled == null
|
||||
? true
|
||||
: resourceStatusFromSite === "pending"
|
||||
? false
|
||||
: resourceData.enabled;
|
||||
|
||||
const resourceSsl =
|
||||
resourceData.mode === "inference" || resourceData.mode === "http"
|
||||
? resourceData.ssl == undefined || resourceData.ssl == null
|
||||
? true
|
||||
: resourceData.ssl
|
||||
: resourceData.ssl;
|
||||
|
||||
if (existingResource) {
|
||||
let domainInfo:
|
||||
| { subdomain: string | null; domainId: string }
|
||||
| undefined;
|
||||
if (resourceData["full-domain"] && resourceData.mode === "http") {
|
||||
if (
|
||||
resourceData["full-domain"] &&
|
||||
(resourceData.mode === "http" ||
|
||||
resourceData.mode === "inference")
|
||||
) {
|
||||
domainInfo = await getDomainForSiteResource(
|
||||
existingResource.siteResourceId,
|
||||
resourceData["full-domain"],
|
||||
orgId,
|
||||
resourceData.mode === "inference",
|
||||
trx
|
||||
);
|
||||
}
|
||||
|
||||
if (resourceData.alias) {
|
||||
const [aliasConflict] = await trx
|
||||
.select({
|
||||
siteResourceId: siteResources.siteResourceId
|
||||
})
|
||||
.from(siteResources)
|
||||
.where(
|
||||
and(
|
||||
eq(siteResources.orgId, orgId),
|
||||
eq(siteResources.alias, resourceData.alias),
|
||||
ne(
|
||||
siteResources.siteResourceId,
|
||||
existingResource.siteResourceId
|
||||
)
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
if (aliasConflict) {
|
||||
throw new Error(
|
||||
`Alias ${resourceData.alias} already in use by another site resource in org ${orgId}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const isInference = resourceData.mode === "inference";
|
||||
|
||||
// Update existing resource
|
||||
const [updatedResource] = await trx
|
||||
.update(siteResources)
|
||||
.set({
|
||||
name: resourceData.name || resourceNiceId,
|
||||
mode: resourceData.mode,
|
||||
ssl: resourceData.ssl,
|
||||
ssl: resourceSsl,
|
||||
scheme: resourceData.scheme,
|
||||
destination: resourceData.destination,
|
||||
destinationPort: resourceData["destination-port"],
|
||||
enabled: true, // hardcoded for now
|
||||
// enabled: resourceData.enabled ?? true,
|
||||
enabled: resourceEnabled,
|
||||
alias: resourceData.alias || null,
|
||||
disableIcmp:
|
||||
resourceData["disable-icmp"] ||
|
||||
(resourceData.mode == "http" ? true : false), // default to true for http resources, otherwise false
|
||||
(resourceData.mode == "http" || isInference
|
||||
? true
|
||||
: false), // default to true for http/inference resources, otherwise false
|
||||
tcpPortRangeString:
|
||||
resourceData.mode == "http"
|
||||
resourceData.mode == "http" || isInference
|
||||
? "443,80"
|
||||
: resourceData["tcp-ports"],
|
||||
udpPortRangeString:
|
||||
resourceData.mode == "http"
|
||||
resourceData.mode == "http" || isInference
|
||||
? ""
|
||||
: resourceData["udp-ports"],
|
||||
fullDomain: resourceData["full-domain"] || null,
|
||||
@@ -263,7 +292,10 @@ export async function updatePrivateResources(
|
||||
pamMode: resourceData["auth-daemon"]?.pam || "passthrough",
|
||||
authDaemonMode:
|
||||
resourceData["auth-daemon"]?.mode || "native",
|
||||
authDaemonPort: resourceData["auth-daemon"]?.port || 22123
|
||||
authDaemonPort: resourceData["auth-daemon"]?.port || 22123,
|
||||
status: resourceStatusFromSite,
|
||||
networkId: isInference ? null : undefined,
|
||||
requiresExitNodeConnection: isInference
|
||||
})
|
||||
.where(
|
||||
eq(
|
||||
@@ -275,7 +307,19 @@ export async function updatePrivateResources(
|
||||
|
||||
const siteResourceId = existingResource.siteResourceId;
|
||||
|
||||
if (updatedResource.networkId) {
|
||||
if (isInference) {
|
||||
// inference resources are not attached to any site network
|
||||
if (existingResource.networkId) {
|
||||
await trx
|
||||
.delete(siteNetworks)
|
||||
.where(
|
||||
eq(
|
||||
siteNetworks.networkId,
|
||||
existingResource.networkId
|
||||
)
|
||||
);
|
||||
}
|
||||
} else if (updatedResource.networkId) {
|
||||
await trx
|
||||
.delete(siteNetworks)
|
||||
.where(
|
||||
@@ -290,6 +334,28 @@ export async function updatePrivateResources(
|
||||
}
|
||||
}
|
||||
|
||||
await syncInferenceAiConfig({
|
||||
orgId,
|
||||
trx,
|
||||
mode: resourceData.mode,
|
||||
scope: "site",
|
||||
siteResourceId,
|
||||
providers: resourceData["ai-providers"].map((p) => ({
|
||||
provider: p.provider,
|
||||
accessMode: p["access-mode"],
|
||||
enabled: p.enabled,
|
||||
models: p.models
|
||||
}))
|
||||
});
|
||||
|
||||
await syncAiBudgets({
|
||||
orgId,
|
||||
trx,
|
||||
scope: "site",
|
||||
siteResourceId,
|
||||
budgets: resourceData["ai-budget"]
|
||||
});
|
||||
|
||||
await trx
|
||||
.delete(clientSiteResources)
|
||||
.where(eq(clientSiteResources.siteResourceId, siteResourceId));
|
||||
@@ -412,6 +478,13 @@ export async function updatePrivateResources(
|
||||
);
|
||||
}
|
||||
|
||||
const labelIds = await getOrCreateLabelIds(
|
||||
orgId,
|
||||
resourceData.labels,
|
||||
trx
|
||||
);
|
||||
await syncSiteResourceLabels(siteResourceId, labelIds, trx);
|
||||
|
||||
results.push({
|
||||
newSiteResource: updatedResource,
|
||||
oldSiteResource: existingResource,
|
||||
@@ -462,25 +535,55 @@ export async function updatePrivateResources(
|
||||
releaseAliasLock = release;
|
||||
}
|
||||
|
||||
const isInference = resourceData.mode === "inference";
|
||||
|
||||
let domainInfo:
|
||||
| { subdomain: string | null; domainId: string }
|
||||
| undefined;
|
||||
if (resourceData["full-domain"] && resourceData.mode === "http") {
|
||||
if (
|
||||
resourceData["full-domain"] &&
|
||||
(resourceData.mode === "http" || isInference)
|
||||
) {
|
||||
domainInfo = await getDomainForSiteResource(
|
||||
undefined,
|
||||
resourceData["full-domain"],
|
||||
orgId,
|
||||
isInference,
|
||||
trx
|
||||
);
|
||||
}
|
||||
|
||||
const [network] = await trx
|
||||
.insert(networks)
|
||||
.values({
|
||||
scope: "resource",
|
||||
orgId: orgId
|
||||
})
|
||||
.returning();
|
||||
if (resourceData.alias) {
|
||||
const [aliasConflict] = await trx
|
||||
.select({
|
||||
siteResourceId: siteResources.siteResourceId
|
||||
})
|
||||
.from(siteResources)
|
||||
.where(
|
||||
and(
|
||||
eq(siteResources.orgId, orgId),
|
||||
eq(siteResources.alias, resourceData.alias)
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
if (aliasConflict) {
|
||||
throw new Error(
|
||||
`Alias ${resourceData.alias} already in use by another site resource in org ${orgId}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
let network: typeof networks.$inferSelect | undefined;
|
||||
if (!isInference) {
|
||||
[network] = await trx
|
||||
.insert(networks)
|
||||
.values({
|
||||
scope: "resource",
|
||||
orgId: orgId
|
||||
})
|
||||
.returning();
|
||||
}
|
||||
|
||||
// Create new resource
|
||||
const [newResource] = await trx
|
||||
@@ -488,27 +591,28 @@ export async function updatePrivateResources(
|
||||
.values({
|
||||
orgId: orgId,
|
||||
niceId: resourceNiceId,
|
||||
networkId: network.networkId,
|
||||
defaultNetworkId: network.networkId,
|
||||
networkId: network ? network.networkId : null,
|
||||
defaultNetworkId: network ? network.networkId : null,
|
||||
name: resourceData.name || resourceNiceId,
|
||||
mode: resourceData.mode,
|
||||
ssl: resourceData.ssl,
|
||||
ssl: resourceSsl,
|
||||
scheme: resourceData.scheme,
|
||||
destination: resourceData.destination,
|
||||
destinationPort: resourceData["destination-port"],
|
||||
enabled: true, // hardcoded for now
|
||||
// enabled: resourceData.enabled ?? true,
|
||||
enabled: resourceEnabled,
|
||||
alias: resourceData.alias || null,
|
||||
aliasAddress: aliasAddress,
|
||||
disableIcmp:
|
||||
resourceData["disable-icmp"] ||
|
||||
(resourceData.mode == "http" ? true : false), // default to true for http resources, otherwise false
|
||||
(resourceData.mode == "http" || isInference
|
||||
? true
|
||||
: false), // default to true for http/inference resources, otherwise false
|
||||
tcpPortRangeString:
|
||||
resourceData.mode == "http"
|
||||
resourceData.mode == "http" || isInference
|
||||
? "443,80"
|
||||
: resourceData["tcp-ports"],
|
||||
udpPortRangeString:
|
||||
resourceData.mode == "http"
|
||||
resourceData.mode == "http" || isInference
|
||||
? ""
|
||||
: resourceData["udp-ports"],
|
||||
fullDomain: resourceData["full-domain"] || null,
|
||||
@@ -517,7 +621,9 @@ export async function updatePrivateResources(
|
||||
pamMode: resourceData["auth-daemon"]?.pam || "passthrough",
|
||||
authDaemonMode:
|
||||
resourceData["auth-daemon"]?.mode || "native",
|
||||
authDaemonPort: resourceData["auth-daemon"]?.port || 22123
|
||||
authDaemonPort: resourceData["auth-daemon"]?.port || 22123,
|
||||
status: resourceStatusFromSite,
|
||||
requiresExitNodeConnection: isInference
|
||||
})
|
||||
.returning();
|
||||
|
||||
@@ -525,13 +631,37 @@ export async function updatePrivateResources(
|
||||
|
||||
const siteResourceId = newResource.siteResourceId;
|
||||
|
||||
for (const site of allSites) {
|
||||
await trx.insert(siteNetworks).values({
|
||||
siteId: site.siteId,
|
||||
networkId: network.networkId
|
||||
});
|
||||
if (network) {
|
||||
for (const site of allSites) {
|
||||
await trx.insert(siteNetworks).values({
|
||||
siteId: site.siteId,
|
||||
networkId: network.networkId
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
await syncInferenceAiConfig({
|
||||
orgId,
|
||||
trx,
|
||||
mode: resourceData.mode,
|
||||
scope: "site",
|
||||
siteResourceId,
|
||||
providers: resourceData["ai-providers"].map((p) => ({
|
||||
provider: p.provider,
|
||||
accessMode: p["access-mode"],
|
||||
enabled: p.enabled,
|
||||
models: p.models
|
||||
}))
|
||||
});
|
||||
|
||||
await syncAiBudgets({
|
||||
orgId,
|
||||
trx,
|
||||
scope: "site",
|
||||
siteResourceId,
|
||||
budgets: resourceData["ai-budget"]
|
||||
});
|
||||
|
||||
const [adminRole] = await trx
|
||||
.select()
|
||||
.from(roles)
|
||||
@@ -645,6 +775,13 @@ export async function updatePrivateResources(
|
||||
|
||||
await usageService.add(orgId, LimitId.PRIVATE_RESOURCES, 1, trx);
|
||||
|
||||
const labelIds = await getOrCreateLabelIds(
|
||||
orgId,
|
||||
resourceData.labels,
|
||||
trx
|
||||
);
|
||||
await syncSiteResourceLabels(siteResourceId, labelIds, trx);
|
||||
|
||||
results.push({
|
||||
newSiteResource: newResource,
|
||||
newSites: allSites,
|
||||
|
||||
@@ -1,61 +1,60 @@
|
||||
import { isLicensedOrSubscribed } from "#dynamic/lib/isLicencedOrSubscribed";
|
||||
import { createCertificate } from "@server/routers/certificates/createCertificate";
|
||||
import { hashPassword } from "@server/auth/password";
|
||||
import { generateId } from "@server/auth/sessions/app";
|
||||
import { build } from "@server/build";
|
||||
import {
|
||||
domains,
|
||||
domainNamespaces,
|
||||
domains,
|
||||
orgDomains,
|
||||
Resource,
|
||||
resourceHeaderAuth,
|
||||
resourceHeaderAuthExtendedCompatibility,
|
||||
resourcePassword,
|
||||
resourcePincode,
|
||||
resourcePolicies,
|
||||
resourcePolicyHeaderAuth,
|
||||
resourcePolicyPassword,
|
||||
resourcePolicyPincode,
|
||||
resourcePolicyRules,
|
||||
resourcePolicyWhiteList,
|
||||
resourceRules,
|
||||
resources,
|
||||
resourceWhitelist,
|
||||
roleActions,
|
||||
rolePolicies,
|
||||
roleResources,
|
||||
roles,
|
||||
Site,
|
||||
sites,
|
||||
Target,
|
||||
TargetHealthCheck,
|
||||
targetHealthCheck,
|
||||
targets,
|
||||
Transaction,
|
||||
userOrgs,
|
||||
userPolicies,
|
||||
userResources,
|
||||
users,
|
||||
resourcePolicies,
|
||||
resourcePolicyPassword,
|
||||
resourcePolicyPincode,
|
||||
resourcePolicyHeaderAuth,
|
||||
resourcePolicyRules,
|
||||
resourcePolicyWhiteList,
|
||||
rolePolicies,
|
||||
userPolicies
|
||||
type ResourceRule
|
||||
} from "@server/db";
|
||||
import { resources, targets, sites } from "@server/db";
|
||||
import { eq, and, asc, or, ne, count, isNotNull } from "drizzle-orm";
|
||||
import {
|
||||
Config,
|
||||
ConfigSchema,
|
||||
isTargetsOnlyResource,
|
||||
TargetData
|
||||
} from "./types";
|
||||
import logger from "@server/logger";
|
||||
import { createCertificate } from "#dynamic/routers/certificates/createCertificate";
|
||||
import { pickPort } from "@server/routers/target/helpers";
|
||||
import { resourcePassword } from "@server/db";
|
||||
import { getUniqueResourcePolicyName } from "@server/db/names";
|
||||
import { hashPassword } from "@server/auth/password";
|
||||
import { isValidCIDR, isValidIP, isValidUrlGlobPattern } from "../validators";
|
||||
import { isValidRegionId } from "@server/db/regions";
|
||||
import { isLicensedOrSubscribed } from "#dynamic/lib/isLicencedOrSubscribed";
|
||||
import { fireHealthCheckUnknownAlert } from "@server/lib/alerts";
|
||||
import { tierMatrix } from "../billing/tierMatrix";
|
||||
import { defaultRoleAllowedActions } from "@server/routers/role/createRole";
|
||||
import { build } from "@server/build";
|
||||
import { encrypt } from "@server/lib/crypto";
|
||||
import { generateId } from "@server/auth/sessions/app";
|
||||
import serverConfig from "@server/lib/config";
|
||||
import HttpCode from "@server/types/HttpCode";
|
||||
import createHttpError from "http-errors";
|
||||
import next from "next";
|
||||
import { encrypt } from "@server/lib/crypto";
|
||||
import logger from "@server/logger";
|
||||
import { defaultRoleAllowedActions } from "@server/routers/role/createRole";
|
||||
import { pickPort } from "@server/routers/target/helpers";
|
||||
import { and, asc, eq, isNotNull, ne, or } from "drizzle-orm";
|
||||
import { tierMatrix } from "../billing/tierMatrix";
|
||||
import { isValidCIDR, isValidIP, isValidUrlGlobPattern } from "../validators";
|
||||
import { Config, isTargetsOnlyResource, TargetData } from "./types";
|
||||
import { getOrCreateLabelIds, syncResourceLabels } from "./labels";
|
||||
import { LimitId } from "../billing";
|
||||
import { usageService } from "../billing/usageService";
|
||||
import { syncInferenceAiConfig } from "./aiProviders";
|
||||
import { syncAiBudgets } from "./aiBudgets";
|
||||
|
||||
export type PublicResourcesResults = {
|
||||
proxyResource: Resource;
|
||||
@@ -76,19 +75,40 @@ export async function updatePublicResources(
|
||||
)) {
|
||||
const targetsToUpdate: Target[] = [];
|
||||
const healthchecksToUpdate: TargetHealthCheck[] = [];
|
||||
|
||||
let resource: Resource;
|
||||
let resourceStatusFromSite: "approved" | "pending" = "approved";
|
||||
let providedSite: Partial<Site> | undefined;
|
||||
if (siteId) {
|
||||
// Use the provided siteId directly, but verify it belongs to the org
|
||||
[providedSite] = await trx
|
||||
.select({
|
||||
siteId: sites.siteId,
|
||||
type: sites.type,
|
||||
status: sites.status
|
||||
})
|
||||
.from(sites)
|
||||
.where(and(eq(sites.siteId, siteId), eq(sites.orgId, orgId)))
|
||||
.limit(1);
|
||||
|
||||
resourceStatusFromSite = providedSite?.status ?? "approved";
|
||||
}
|
||||
|
||||
async function createTarget( // reusable function to create a target
|
||||
resourceId: number,
|
||||
targetData: TargetData
|
||||
) {
|
||||
const targetSiteId = targetData.site;
|
||||
let site;
|
||||
let site: Partial<Site> | undefined;
|
||||
|
||||
if (targetSiteId) {
|
||||
// Look up site by niceId
|
||||
[site] = await trx
|
||||
.select({ siteId: sites.siteId, type: sites.type })
|
||||
.select({
|
||||
siteId: sites.siteId,
|
||||
type: sites.type,
|
||||
status: sites.status
|
||||
})
|
||||
.from(sites)
|
||||
.where(
|
||||
and(
|
||||
@@ -97,15 +117,9 @@ export async function updatePublicResources(
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
} else if (siteId) {
|
||||
} else if (siteId && providedSite) {
|
||||
// Use the provided siteId directly, but verify it belongs to the org
|
||||
[site] = await trx
|
||||
.select({ siteId: sites.siteId, type: sites.type })
|
||||
.from(sites)
|
||||
.where(
|
||||
and(eq(sites.siteId, siteId), eq(sites.orgId, orgId))
|
||||
)
|
||||
.limit(1);
|
||||
site = providedSite;
|
||||
} else {
|
||||
throw new Error(`Target site is required`);
|
||||
}
|
||||
@@ -141,7 +155,7 @@ export async function updatePublicResources(
|
||||
.insert(targets)
|
||||
.values({
|
||||
resourceId: resourceId,
|
||||
siteId: site.siteId,
|
||||
siteId: site.siteId!,
|
||||
ip: targetData.hostname,
|
||||
mode: resourceData.mode as Target["mode"],
|
||||
method: targetData.method,
|
||||
@@ -174,7 +188,7 @@ export async function updatePublicResources(
|
||||
.insert(targetHealthCheck)
|
||||
.values({
|
||||
name: `${targetData.hostname}:${targetData.port}`,
|
||||
siteId: site.siteId,
|
||||
siteId: site.siteId!,
|
||||
targetId: newTarget.targetId,
|
||||
orgId: orgId,
|
||||
hcEnabled: healthcheckData?.enabled || false,
|
||||
@@ -232,7 +246,10 @@ export async function updatePublicResources(
|
||||
const resourceEnabled =
|
||||
resourceData.enabled == undefined || resourceData.enabled == null
|
||||
? true
|
||||
: resourceData.enabled;
|
||||
: resourceStatusFromSite === "pending"
|
||||
? false
|
||||
: resourceData.enabled;
|
||||
|
||||
const resourceSsl =
|
||||
resourceData.ssl == undefined || resourceData.ssl == null
|
||||
? true
|
||||
@@ -250,18 +267,6 @@ export async function updatePublicResources(
|
||||
? JSON.stringify(resourceData.responseHeaders)
|
||||
: null;
|
||||
|
||||
if (["ssh", "rdp", "vnc"].includes(resourceData.mode || "")) {
|
||||
const isLicensed = await isLicensedOrSubscribed(
|
||||
orgId,
|
||||
tierMatrix.advancedPublicResources
|
||||
);
|
||||
if (!isLicensed) {
|
||||
throw new Error(
|
||||
"Your current subscription does not support browser gateway resources. Please upgrade to access this feature."
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (resourceData.policy) {
|
||||
const isLicensed = await isLicensedOrSubscribed(
|
||||
orgId,
|
||||
@@ -277,7 +282,9 @@ export async function updatePublicResources(
|
||||
if (existingResource) {
|
||||
let domain;
|
||||
if (
|
||||
["http", "ssh", "rdp", "vnc"].includes(resourceData.mode || "")
|
||||
["http", "ssh", "rdp", "vnc", "inference"].includes(
|
||||
resourceData.mode || ""
|
||||
)
|
||||
) {
|
||||
if (resourceData["full-domain"]?.startsWith("*.")) {
|
||||
const isLicensed = await isLicensedOrSubscribed(
|
||||
@@ -295,6 +302,7 @@ export async function updatePublicResources(
|
||||
existingResource.resourceId,
|
||||
resourceData["full-domain"]!,
|
||||
orgId,
|
||||
resourceData.mode === "inference",
|
||||
trx
|
||||
);
|
||||
|
||||
@@ -316,7 +324,7 @@ export async function updatePublicResources(
|
||||
|
||||
const isLicensed = await isLicensedOrSubscribed(
|
||||
orgId,
|
||||
tierMatrix.maintencePage
|
||||
tierMatrix.maintenancePage
|
||||
);
|
||||
if (!isLicensed) {
|
||||
resourceData.maintenance = undefined;
|
||||
@@ -361,14 +369,22 @@ export async function updatePublicResources(
|
||||
name: resourceData.name || "Unnamed Resource",
|
||||
|
||||
mode: resourceData.mode,
|
||||
proxyPort: ["http", "ssh", "rdp", "vnc"].includes(
|
||||
resourceData.mode || ""
|
||||
)
|
||||
proxyPort: [
|
||||
"http",
|
||||
"ssh",
|
||||
"rdp",
|
||||
"vnc",
|
||||
"inference"
|
||||
].includes(resourceData.mode || "")
|
||||
? null
|
||||
: resourceData["proxy-port"],
|
||||
fullDomain: ["http", "ssh", "rdp", "vnc"].includes(
|
||||
resourceData.mode || ""
|
||||
)
|
||||
fullDomain: [
|
||||
"http",
|
||||
"ssh",
|
||||
"rdp",
|
||||
"vnc",
|
||||
"inference"
|
||||
].includes(resourceData.mode || "")
|
||||
? resourceData["full-domain"]
|
||||
: null,
|
||||
subdomain: domain ? domain.subdomain : null,
|
||||
@@ -417,7 +433,8 @@ export async function updatePublicResources(
|
||||
? (resourceData["proxy-protocol-version"] ??
|
||||
1)
|
||||
: 1,
|
||||
resourcePolicyId: sharedPolicy.resourcePolicyId
|
||||
resourcePolicyId: sharedPolicy.resourcePolicyId,
|
||||
status: resourceStatusFromSite
|
||||
})
|
||||
.where(
|
||||
eq(
|
||||
@@ -557,14 +574,23 @@ export async function updatePublicResources(
|
||||
.update(resources)
|
||||
.set({
|
||||
name: resourceData.name || "Unnamed Resource",
|
||||
proxyPort: ["http", "ssh", "rdp", "vnc"].includes(
|
||||
resourceData.mode || ""
|
||||
)
|
||||
mode: resourceData.mode,
|
||||
proxyPort: [
|
||||
"http",
|
||||
"ssh",
|
||||
"rdp",
|
||||
"vnc",
|
||||
"inference"
|
||||
].includes(resourceData.mode || "")
|
||||
? null
|
||||
: resourceData["proxy-port"],
|
||||
fullDomain: ["http", "ssh", "rdp", "vnc"].includes(
|
||||
resourceData.mode || ""
|
||||
)
|
||||
fullDomain: [
|
||||
"http",
|
||||
"ssh",
|
||||
"rdp",
|
||||
"vnc",
|
||||
"inference"
|
||||
].includes(resourceData.mode || "")
|
||||
? resourceData["full-domain"]
|
||||
: null,
|
||||
subdomain: domain ? domain.subdomain : null,
|
||||
@@ -602,7 +628,8 @@ export async function updatePublicResources(
|
||||
authDaemonPort:
|
||||
resourceData["auth-daemon"]?.port || 22123,
|
||||
resourcePolicyId: null,
|
||||
defaultResourcePolicyId: inlinePolicyId
|
||||
defaultResourcePolicyId: inlinePolicyId,
|
||||
status: resourceStatusFromSite
|
||||
})
|
||||
.where(
|
||||
eq(
|
||||
@@ -664,6 +691,30 @@ export async function updatePublicResources(
|
||||
trx
|
||||
);
|
||||
}
|
||||
|
||||
await syncInferenceAiConfig({
|
||||
orgId,
|
||||
trx,
|
||||
mode: resourceData.mode || "",
|
||||
scope: "public",
|
||||
resourceId: existingResource.resourceId,
|
||||
providers: (resourceData["ai-providers"] || []).map(
|
||||
(p) => ({
|
||||
provider: p.provider,
|
||||
accessMode: p["access-mode"],
|
||||
enabled: p.enabled,
|
||||
models: p.models
|
||||
})
|
||||
)
|
||||
});
|
||||
|
||||
await syncAiBudgets({
|
||||
orgId,
|
||||
trx,
|
||||
scope: "public",
|
||||
resourceId: existingResource.resourceId,
|
||||
budgets: resourceData["ai-budget"] || []
|
||||
});
|
||||
}
|
||||
|
||||
const existingResourceTargets = await trx
|
||||
@@ -744,7 +795,7 @@ export async function updatePublicResources(
|
||||
: undefined),
|
||||
rewritePathType: targetData["rewrite-match"],
|
||||
priority: targetData.priority,
|
||||
mode: resourceData.mode
|
||||
mode: resourceData.mode as Target["mode"]
|
||||
})
|
||||
.where(eq(targets.targetId, existingTarget.targetId))
|
||||
.returning();
|
||||
@@ -919,7 +970,7 @@ export async function updatePublicResources(
|
||||
.update(resourceRules)
|
||||
.set({
|
||||
action: getRuleAction(rule.action),
|
||||
match: rule.match.toUpperCase(),
|
||||
match: rule.match.toUpperCase() as ResourceRule["match"],
|
||||
value: getRuleValue(
|
||||
rule.match.toUpperCase(),
|
||||
rule.value
|
||||
@@ -938,7 +989,7 @@ export async function updatePublicResources(
|
||||
await trx.insert(resourceRules).values({
|
||||
resourceId: existingResource.resourceId,
|
||||
action: getRuleAction(rule.action),
|
||||
match: rule.match.toUpperCase(),
|
||||
match: rule.match.toUpperCase() as ResourceRule["match"],
|
||||
value: getRuleValue(
|
||||
rule.match.toUpperCase(),
|
||||
rule.value
|
||||
@@ -1021,7 +1072,7 @@ export async function updatePublicResources(
|
||||
} else {
|
||||
// create a brand new resource
|
||||
|
||||
if (build == "saas") {
|
||||
if (build === "saas") {
|
||||
const usage = await usageService.getUsage(
|
||||
orgId,
|
||||
LimitId.PUBLIC_RESOURCES
|
||||
@@ -1049,7 +1100,9 @@ export async function updatePublicResources(
|
||||
|
||||
let domain;
|
||||
if (
|
||||
["http", "ssh", "rdp", "vnc"].includes(resourceData.mode || "")
|
||||
["http", "ssh", "rdp", "vnc", "inference"].includes(
|
||||
resourceData.mode || ""
|
||||
)
|
||||
) {
|
||||
if (resourceData["full-domain"]?.startsWith("*.")) {
|
||||
const isLicensed = await isLicensedOrSubscribed(
|
||||
@@ -1067,6 +1120,7 @@ export async function updatePublicResources(
|
||||
undefined,
|
||||
resourceData["full-domain"]!,
|
||||
orgId,
|
||||
resourceData.mode === "inference",
|
||||
trx
|
||||
);
|
||||
|
||||
@@ -1079,7 +1133,7 @@ export async function updatePublicResources(
|
||||
|
||||
const isLicensed = await isLicensedOrSubscribed(
|
||||
orgId,
|
||||
tierMatrix.maintencePage
|
||||
tierMatrix.maintenancePage
|
||||
);
|
||||
if (!isLicensed) {
|
||||
resourceData.maintenance = undefined;
|
||||
@@ -1143,16 +1197,25 @@ export async function updatePublicResources(
|
||||
.values({
|
||||
orgId,
|
||||
niceId: resourceNiceId,
|
||||
status: resourceStatusFromSite,
|
||||
name: resourceData.name || "Unnamed Resource",
|
||||
mode: resourceData.mode,
|
||||
proxyPort: ["http", "ssh", "rdp", "vnc"].includes(
|
||||
resourceData.mode || ""
|
||||
)
|
||||
proxyPort: [
|
||||
"http",
|
||||
"ssh",
|
||||
"rdp",
|
||||
"vnc",
|
||||
"inference"
|
||||
].includes(resourceData.mode || "")
|
||||
? null
|
||||
: resourceData["proxy-port"],
|
||||
fullDomain: ["http", "ssh", "rdp", "vnc"].includes(
|
||||
resourceData.mode || ""
|
||||
)
|
||||
fullDomain: [
|
||||
"http",
|
||||
"ssh",
|
||||
"rdp",
|
||||
"vnc",
|
||||
"inference"
|
||||
].includes(resourceData.mode || "")
|
||||
? resourceData["full-domain"]
|
||||
: null,
|
||||
subdomain: domain ? domain.subdomain : null,
|
||||
@@ -1208,6 +1271,28 @@ export async function updatePublicResources(
|
||||
|
||||
resource = newResource;
|
||||
|
||||
await syncInferenceAiConfig({
|
||||
orgId,
|
||||
trx,
|
||||
mode: resourceData.mode || "",
|
||||
scope: "public",
|
||||
resourceId: newResource.resourceId,
|
||||
providers: (resourceData["ai-providers"] || []).map((p) => ({
|
||||
provider: p.provider,
|
||||
accessMode: p["access-mode"],
|
||||
enabled: p.enabled,
|
||||
models: p.models
|
||||
}))
|
||||
});
|
||||
|
||||
await syncAiBudgets({
|
||||
orgId,
|
||||
trx,
|
||||
scope: "public",
|
||||
resourceId: newResource.resourceId,
|
||||
budgets: resourceData["ai-budget"] || []
|
||||
});
|
||||
|
||||
await trx.insert(roleResources).values({
|
||||
roleId: adminRole.roleId,
|
||||
resourceId: newResource.resourceId
|
||||
@@ -1303,7 +1388,7 @@ export async function updatePublicResources(
|
||||
await trx.insert(resourceRules).values({
|
||||
resourceId: newResource.resourceId,
|
||||
action: getRuleAction(rule.action),
|
||||
match: rule.match.toUpperCase(),
|
||||
match: rule.match.toUpperCase() as ResourceRule["match"],
|
||||
value: getRuleValue(
|
||||
rule.match.toUpperCase(),
|
||||
rule.value
|
||||
@@ -1342,6 +1427,15 @@ export async function updatePublicResources(
|
||||
logger.debug(`Created resource ${newResource.resourceId}`);
|
||||
}
|
||||
|
||||
if (!isTargetsOnlyResource(resourceData)) {
|
||||
const labelIds = await getOrCreateLabelIds(
|
||||
orgId,
|
||||
resourceData.labels || [],
|
||||
trx
|
||||
);
|
||||
await syncResourceLabels(resource.resourceId, labelIds, trx);
|
||||
}
|
||||
|
||||
results.push({
|
||||
proxyResource: resource,
|
||||
targetsToUpdate,
|
||||
@@ -1366,7 +1460,7 @@ function getRuleAction(input: string) {
|
||||
|
||||
function getRuleValue(match: string, value: string) {
|
||||
// if the match is a country, uppercase the value
|
||||
if (match == "COUNTRY") {
|
||||
if (match === "COUNTRY" || match === "COUNTRY_IS_NOT") {
|
||||
return value.toUpperCase();
|
||||
}
|
||||
return value;
|
||||
@@ -2056,6 +2150,7 @@ export async function getDomain(
|
||||
resourceId: number | undefined,
|
||||
fullDomain: string,
|
||||
orgId: string,
|
||||
isInference: boolean,
|
||||
trx: Transaction
|
||||
) {
|
||||
const [fullDomainExists] = await trx
|
||||
@@ -2065,6 +2160,14 @@ export async function getDomain(
|
||||
and(
|
||||
eq(resources.fullDomain, fullDomain),
|
||||
eq(resources.orgId, orgId),
|
||||
// Inference resources route through the central AI gateway
|
||||
// rather than normal target-based proxying, so they're
|
||||
// allowed to share a full-domain with a non-inference
|
||||
// resource (and vice versa) - only conflicts within the
|
||||
// same routing category are rejected.
|
||||
isInference
|
||||
? ne(resources.mode, "inference")
|
||||
: eq(resources.mode, "inference"),
|
||||
resourceId
|
||||
? ne(resources.resourceId, resourceId)
|
||||
: isNotNull(resources.resourceId)
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import {
|
||||
db,
|
||||
idp,
|
||||
idpOrg,
|
||||
resourcePolicies,
|
||||
resourcePolicyHeaderAuth,
|
||||
resourcePolicyPassword,
|
||||
@@ -20,6 +18,7 @@ import { Config, ResourcePolicyData } from "./types";
|
||||
import logger from "@server/logger";
|
||||
import { getUniqueResourcePolicyName } from "@server/db/names";
|
||||
import { hashPassword } from "@server/auth/password";
|
||||
import { idpExistsForOrg } from "@server/lib/idp/idpExistsForOrg";
|
||||
import { isValidCIDR, isValidIP, isValidUrlGlobPattern } from "../validators";
|
||||
import { isLicensedOrSubscribed } from "#dynamic/lib/isLicencedOrSubscribed";
|
||||
import { tierMatrix } from "../billing/tierMatrix";
|
||||
@@ -71,19 +70,13 @@ export async function updateResourcePolicies(
|
||||
|
||||
// Validate auto-login-idp if provided
|
||||
if (policyData["auto-login-idp"]) {
|
||||
const [provider] = await trx
|
||||
.select()
|
||||
.from(idp)
|
||||
.innerJoin(idpOrg, eq(idpOrg.idpId, idp.idpId))
|
||||
.where(
|
||||
and(
|
||||
eq(idp.idpId, policyData["auto-login-idp"]),
|
||||
eq(idpOrg.orgId, orgId)
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
const providerExists = await idpExistsForOrg(
|
||||
policyData["auto-login-idp"],
|
||||
orgId,
|
||||
trx
|
||||
);
|
||||
|
||||
if (!provider) {
|
||||
if (!providerExists) {
|
||||
throw new Error(
|
||||
`Identity provider not found for policy '${policyNiceId}' in this organization`
|
||||
);
|
||||
@@ -347,12 +340,13 @@ function getRuleAction(input: string): "ACCEPT" | "DROP" | "PASS" {
|
||||
|
||||
function getRuleMatch(
|
||||
input: string
|
||||
): "CIDR" | "IP" | "PATH" | "COUNTRY" | "ASN" | "REGION" {
|
||||
): "CIDR" | "IP" | "PATH" | "COUNTRY" | "COUNTRY_IS_NOT" | "ASN" | "REGION" {
|
||||
return input.toUpperCase() as
|
||||
| "CIDR"
|
||||
| "IP"
|
||||
| "PATH"
|
||||
| "COUNTRY"
|
||||
| "COUNTRY_IS_NOT"
|
||||
| "ASN"
|
||||
| "REGION";
|
||||
}
|
||||
|
||||
+135
-15
@@ -5,6 +5,11 @@ import { MaintenanceSchema } from "#dynamic/lib/blueprints/MaintenanceSchema";
|
||||
import { isValidRegionId } from "@server/db/regions";
|
||||
import { wildcardSubdomainSchema } from "@server/lib/schemas";
|
||||
import config from "@server/lib/config";
|
||||
import {
|
||||
aiBudgetEnforcementSchema,
|
||||
aiBudgetPeriodSchema,
|
||||
aiBudgetUnitSchema
|
||||
} from "@server/routers/aiBudget/validation";
|
||||
|
||||
const maxmindDbPath = config.getRawConfig().server.maxmind_db_path;
|
||||
const maxmindAsnPath = config.getRawConfig().server.maxmind_asn_path;
|
||||
@@ -28,7 +33,7 @@ export const TargetHealthCheckSchema = z.object({
|
||||
hostname: z.string(),
|
||||
port: z.int().min(1).max(65535),
|
||||
enabled: z.boolean().optional().default(true),
|
||||
path: z.string().optional(),
|
||||
path: z.string().optional().default("/"),
|
||||
scheme: z.string().optional(),
|
||||
mode: z.string().default("http"),
|
||||
interval: z.int().default(30),
|
||||
@@ -96,7 +101,7 @@ export const AuthSchema = z.object({
|
||||
export const RuleSchema = z
|
||||
.object({
|
||||
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(),
|
||||
priority: z.int().optional(),
|
||||
enabled: z.boolean().optional().default(true)
|
||||
@@ -131,7 +136,7 @@ export const RuleSchema = z
|
||||
)
|
||||
.refine(
|
||||
(rule) => {
|
||||
if (rule.match === "country") {
|
||||
if (rule.match === "country" || rule.match === "country_is_not") {
|
||||
if (!hasMaxmindCountryDb) {
|
||||
return false;
|
||||
}
|
||||
@@ -183,6 +188,56 @@ export const HeaderSchema = z.object({
|
||||
value: z.string().min(1)
|
||||
});
|
||||
|
||||
export const AiProviderAttachmentSchema = z
|
||||
.object({
|
||||
provider: z.string().min(1),
|
||||
"access-mode": z
|
||||
.enum(["inherit", "select"])
|
||||
.optional()
|
||||
.default("inherit"),
|
||||
enabled: z.boolean().optional().default(true),
|
||||
models: z.array(z.string()).optional().default([])
|
||||
})
|
||||
.refine(
|
||||
(provider) => {
|
||||
if (provider.models.length === 0) {
|
||||
return true;
|
||||
}
|
||||
return provider["access-mode"] === "select";
|
||||
},
|
||||
{
|
||||
path: ["models"],
|
||||
error: "'models' can only be set on a provider with access-mode 'select'"
|
||||
}
|
||||
);
|
||||
|
||||
export const AiBudgetSchema = z.object({
|
||||
amount: z.number().positive(),
|
||||
unit: aiBudgetUnitSchema,
|
||||
period: aiBudgetPeriodSchema.optional().default("monthly"),
|
||||
enforcement: aiBudgetEnforcementSchema.optional().default("hard"),
|
||||
enabled: z.boolean().optional().default(true)
|
||||
});
|
||||
|
||||
const aiBudgetArraySchema = z.array(AiBudgetSchema).refine(
|
||||
(budgets) => {
|
||||
const keys = budgets.map((b) => `${b.unit}::${b.period}`);
|
||||
return keys.length === new Set(keys).size;
|
||||
},
|
||||
{
|
||||
message:
|
||||
"'ai-budget' entries must not overlap: only one budget per unit/period combination is allowed"
|
||||
}
|
||||
);
|
||||
|
||||
// No default here: an object with only 'targets' set must remain
|
||||
// recognized as a targets-only resource by isTargetsOnlyResource().
|
||||
export const AiBudgetListSchema = aiBudgetArraySchema.optional();
|
||||
|
||||
export const AiBudgetListSchemaWithDefault = aiBudgetArraySchema
|
||||
.optional()
|
||||
.default([]);
|
||||
|
||||
export const AuthDaemonSchema = z
|
||||
.object({
|
||||
pam: z.enum(["passthrough", "push"]).optional().default("passthrough"),
|
||||
@@ -209,7 +264,9 @@ export const PublicResourceSchema = z
|
||||
protocol: z
|
||||
.enum(["http", "tcp", "udp", "ssh", "rdp", "vnc"])
|
||||
.optional(), // this was the old one and is now DEPRECATED in favor of the mode
|
||||
mode: z.enum(["http", "tcp", "udp", "ssh", "rdp", "vnc"]).optional(),
|
||||
mode: z
|
||||
.enum(["http", "tcp", "udp", "ssh", "rdp", "vnc", "inference"])
|
||||
.optional(),
|
||||
policy: z.string().optional(),
|
||||
ssl: z.boolean().optional(),
|
||||
scheme: z.enum(["http", "https"]).optional(),
|
||||
@@ -227,7 +284,10 @@ export const PublicResourceSchema = z
|
||||
maintenance: MaintenanceSchema.optional(),
|
||||
"auth-daemon": AuthDaemonSchema.optional(),
|
||||
"proxy-protocol": z.boolean().optional(),
|
||||
"proxy-protocol-version": z.int().min(1).optional()
|
||||
"proxy-protocol-version": z.int().min(1).optional(),
|
||||
labels: z.array(z.string().min(1)).optional(),
|
||||
"ai-providers": z.array(AiProviderAttachmentSchema).optional(),
|
||||
"ai-budget": AiBudgetListSchema
|
||||
})
|
||||
.refine(
|
||||
(resource) => {
|
||||
@@ -316,11 +376,13 @@ export const PublicResourceSchema = z
|
||||
return true;
|
||||
}
|
||||
|
||||
// If protocol/mode is http, ssh, rdp, or vnc, it must have a full-domain
|
||||
// If protocol/mode is http, ssh, rdp, vnc, or inference, it must have a full-domain
|
||||
const effectiveProtocol = resource.mode ?? resource.protocol;
|
||||
if (
|
||||
effectiveProtocol !== undefined &&
|
||||
["http", "ssh", "rdp", "vnc"].includes(effectiveProtocol)
|
||||
["http", "ssh", "rdp", "vnc", "inference"].includes(
|
||||
effectiveProtocol
|
||||
)
|
||||
) {
|
||||
return (
|
||||
resource["full-domain"] !== undefined &&
|
||||
@@ -331,7 +393,43 @@ export const PublicResourceSchema = z
|
||||
},
|
||||
{
|
||||
path: ["full-domain"],
|
||||
error: "When protocol is 'http', 'ssh', 'rdp', or 'vnc', a 'full-domain' must be provided"
|
||||
error: "When protocol is 'http', 'ssh', 'rdp', 'vnc', or 'inference', a 'full-domain' must be provided"
|
||||
}
|
||||
)
|
||||
.refine(
|
||||
(resource) => {
|
||||
if (isTargetsOnlyResource(resource)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const effectiveMode = resource.mode ?? resource.protocol;
|
||||
if (effectiveMode !== "inference") {
|
||||
return true;
|
||||
}
|
||||
|
||||
return resource.targets.every((target) => target == null);
|
||||
},
|
||||
{
|
||||
path: ["targets"],
|
||||
error: "When mode is 'inference', 'targets' must not be provided"
|
||||
}
|
||||
)
|
||||
.refine(
|
||||
(resource) => {
|
||||
if (isTargetsOnlyResource(resource)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const effectiveMode = resource.mode ?? resource.protocol;
|
||||
if (effectiveMode === "inference") {
|
||||
return true;
|
||||
}
|
||||
|
||||
return (resource["ai-providers"]?.length ?? 0) === 0;
|
||||
},
|
||||
{
|
||||
path: ["ai-providers"],
|
||||
error: "'ai-providers' can only be set when mode is 'inference'"
|
||||
}
|
||||
)
|
||||
.refine(
|
||||
@@ -465,14 +563,14 @@ export function isTargetsOnlyResource(resource: any): boolean {
|
||||
export const PrivateResourceSchema = z
|
||||
.object({
|
||||
name: z.string().min(1).max(255),
|
||||
mode: z.enum(["host", "cidr", "http", "ssh"]),
|
||||
mode: z.enum(["host", "cidr", "http", "ssh", "inference"]),
|
||||
site: z.string().optional(), // DEPRECATED IN FAVOR OF sites
|
||||
sites: z.array(z.string()).optional().default([]),
|
||||
// protocol: z.enum(["tcp", "udp"]).optional(),
|
||||
// proxyPort: z.int().positive().optional(),
|
||||
"destination-port": z.int().positive().optional(),
|
||||
destination: z.string().min(1).optional(),
|
||||
// enabled: z.boolean().default(true),
|
||||
enabled: z.boolean().default(true),
|
||||
"tcp-ports": portRangeStringSchema.optional().default("*"),
|
||||
"udp-ports": portRangeStringSchema.optional().default("*"),
|
||||
"disable-icmp": z.boolean().optional().default(false),
|
||||
@@ -495,16 +593,26 @@ export const PrivateResourceSchema = z
|
||||
}),
|
||||
users: z.array(z.string()).optional().default([]),
|
||||
machines: z.array(z.string()).optional().default([]),
|
||||
"auth-daemon": AuthDaemonSchema.optional()
|
||||
labels: z.array(z.string().min(1)).optional().default([]),
|
||||
"auth-daemon": AuthDaemonSchema.optional(),
|
||||
"ai-providers": z
|
||||
.array(AiProviderAttachmentSchema)
|
||||
.optional()
|
||||
.default([]),
|
||||
"ai-budget": AiBudgetListSchemaWithDefault
|
||||
})
|
||||
.refine(
|
||||
(data) => {
|
||||
// destination is optional only for ssh+native; required for everything else
|
||||
// destination is optional only for ssh+native or inference; required for everything else
|
||||
const isNativeSSH =
|
||||
data.mode === "ssh" &&
|
||||
(data["auth-daemon"] === undefined ||
|
||||
data["auth-daemon"].mode === "native");
|
||||
if (!isNativeSSH && !data.destination) {
|
||||
if (
|
||||
data.mode !== "inference" &&
|
||||
!isNativeSSH &&
|
||||
!data.destination
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
@@ -512,7 +620,19 @@ export const PrivateResourceSchema = z
|
||||
{
|
||||
path: ["destination"],
|
||||
message:
|
||||
"destination is required unless mode is 'ssh' with auth-daemon mode 'native'"
|
||||
"destination is required unless mode is 'ssh' with auth-daemon mode 'native', or mode is 'inference'"
|
||||
}
|
||||
)
|
||||
.refine(
|
||||
(data) => {
|
||||
if (data.mode === "inference") {
|
||||
return true;
|
||||
}
|
||||
return (data["ai-providers"]?.length ?? 0) === 0;
|
||||
},
|
||||
{
|
||||
path: ["ai-providers"],
|
||||
error: "'ai-providers' can only be set when mode is 'inference'"
|
||||
}
|
||||
)
|
||||
.refine(
|
||||
@@ -634,7 +754,6 @@ export const ResourcePolicySchema = z.object({
|
||||
})
|
||||
)
|
||||
)
|
||||
.max(50)
|
||||
.transform((v) => v.map((e) => e.toLowerCase()))
|
||||
.optional()
|
||||
.default([]),
|
||||
@@ -840,3 +959,4 @@ export type Target = z.infer<typeof TargetSchema>;
|
||||
export type Resource = z.infer<typeof PublicResourceSchema>;
|
||||
export type Config = z.infer<typeof ConfigSchema>;
|
||||
export type BlueprintResourcePolicy = z.infer<typeof ResourcePolicySchema>;
|
||||
export type BlueprintAiBudget = z.infer<typeof AiBudgetSchema>;
|
||||
|
||||
Reference in New Issue
Block a user