mirror of
https://github.com/fosrl/pangolin.git
synced 2025-12-16 13:06:27 +00:00
Add basic blueprints
This commit is contained in:
118
server/lib/blueprints/applyBlueprint.ts
Normal file
118
server/lib/blueprints/applyBlueprint.ts
Normal file
@@ -0,0 +1,118 @@
|
|||||||
|
import { db, newts, Target } from "@server/db";
|
||||||
|
import { Config, ConfigSchema } from "./types";
|
||||||
|
import { ResourcesResults, updateResources } from "./resources";
|
||||||
|
import { fromError } from "zod-validation-error";
|
||||||
|
import logger from "@server/logger";
|
||||||
|
import { resources, targets, sites } from "@server/db";
|
||||||
|
import { eq, and, asc, or, ne, count, isNotNull } from "drizzle-orm";
|
||||||
|
import { addTargets } from "@server/routers/newt/targets";
|
||||||
|
|
||||||
|
export async function applyBlueprint(
|
||||||
|
orgId: string,
|
||||||
|
configData: unknown,
|
||||||
|
siteId?: number
|
||||||
|
): Promise<void> {
|
||||||
|
// Validate the input data
|
||||||
|
const validationResult = ConfigSchema.safeParse(configData);
|
||||||
|
if (!validationResult.success) {
|
||||||
|
throw new Error(fromError(validationResult.error).toString());
|
||||||
|
}
|
||||||
|
|
||||||
|
const config: Config = validationResult.data;
|
||||||
|
|
||||||
|
try {
|
||||||
|
let resourcesResults: ResourcesResults = [];
|
||||||
|
await db.transaction(async (trx) => {
|
||||||
|
resourcesResults = await updateResources(orgId, config, trx, siteId);
|
||||||
|
});
|
||||||
|
|
||||||
|
logger.debug(
|
||||||
|
`Successfully updated resources for org ${orgId}: ${JSON.stringify(resourcesResults)}`
|
||||||
|
);
|
||||||
|
|
||||||
|
// We need to update the targets on the newts from the successfully updated information
|
||||||
|
for (const result of resourcesResults) {
|
||||||
|
for (const target of result.targetsToUpdate) {
|
||||||
|
const [site] = await db
|
||||||
|
.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);
|
||||||
|
|
||||||
|
if (site) {
|
||||||
|
logger.debug(
|
||||||
|
`Updating target ${target.targetId} on site ${site.sites.siteId}`
|
||||||
|
);
|
||||||
|
|
||||||
|
await addTargets(
|
||||||
|
site.newt.newtId,
|
||||||
|
[target],
|
||||||
|
result.resource.protocol,
|
||||||
|
result.resource.proxyPort
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`Failed to update database from config: ${error}`);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// await updateDatabaseFromConfig("org_i21aifypnlyxur2", {
|
||||||
|
// resources: {
|
||||||
|
// "resource-nice-id": {
|
||||||
|
// name: "this is my resource",
|
||||||
|
// protocol: "http",
|
||||||
|
// "full-domain": "level1.test.example.com",
|
||||||
|
// "host-header": "example.com",
|
||||||
|
// "tls-server-name": "example.com",
|
||||||
|
// auth: {
|
||||||
|
// pincode: 123456,
|
||||||
|
// password: "sadfasdfadsf",
|
||||||
|
// "sso-enabled": true,
|
||||||
|
// "sso-roles": ["Member"],
|
||||||
|
// "sso-users": ["owen@fossorial.io"],
|
||||||
|
// "whitelist-users": ["owen@fossorial.io"]
|
||||||
|
// },
|
||||||
|
// targets: [
|
||||||
|
// {
|
||||||
|
// site: "glossy-plains-viscacha-rat",
|
||||||
|
// hostname: "localhost",
|
||||||
|
// method: "http",
|
||||||
|
// port: 8000,
|
||||||
|
// healthcheck: {
|
||||||
|
// port: 8000,
|
||||||
|
// hostname: "localhost"
|
||||||
|
// }
|
||||||
|
// },
|
||||||
|
// {
|
||||||
|
// site: "glossy-plains-viscacha-rat",
|
||||||
|
// hostname: "localhost",
|
||||||
|
// method: "http",
|
||||||
|
// port: 8001
|
||||||
|
// }
|
||||||
|
// ]
|
||||||
|
// },
|
||||||
|
// "resource-nice-id2": {
|
||||||
|
// name: "http server",
|
||||||
|
// protocol: "tcp",
|
||||||
|
// "proxy-port": 3000,
|
||||||
|
// targets: [
|
||||||
|
// {
|
||||||
|
// site: "glossy-plains-viscacha-rat",
|
||||||
|
// hostname: "localhost",
|
||||||
|
// port: 3000,
|
||||||
|
// }
|
||||||
|
// ]
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
// });
|
||||||
53
server/lib/blueprints/applyNewtDockerBlueprint.ts
Normal file
53
server/lib/blueprints/applyNewtDockerBlueprint.ts
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
import { sendToClient } from "@server/routers/ws";
|
||||||
|
import { processContainerLabels } from "./parseDockerContainers";
|
||||||
|
import { applyBlueprint } from "./applyBlueprint";
|
||||||
|
import { db, sites } from "@server/db";
|
||||||
|
import { eq } from "drizzle-orm";
|
||||||
|
import logger from "@server/logger";
|
||||||
|
|
||||||
|
export async function applyNewtDockerBlueprint(
|
||||||
|
siteId: number,
|
||||||
|
newtId: string,
|
||||||
|
containers: any
|
||||||
|
) {
|
||||||
|
const [site] = await db
|
||||||
|
.select()
|
||||||
|
.from(sites)
|
||||||
|
.where(eq(sites.siteId, siteId))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!site) {
|
||||||
|
logger.warn("Site not found in applyNewtDockerBlueprint");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// logger.debug(`Applying Docker blueprint to site: ${siteId}`);
|
||||||
|
// logger.debug(`Containers: ${JSON.stringify(containers, null, 2)}`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const blueprint = processContainerLabels(containers);
|
||||||
|
|
||||||
|
logger.debug(`Received Docker blueprint: ${JSON.stringify(blueprint)}`);
|
||||||
|
|
||||||
|
// Update the blueprint in the database
|
||||||
|
await applyBlueprint(site.orgId, blueprint, site.siteId);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`Failed to update database from config: ${error}`);
|
||||||
|
await sendToClient(newtId, {
|
||||||
|
type: "newt/blueprint/results",
|
||||||
|
data: {
|
||||||
|
success: false,
|
||||||
|
message: `Failed to update database from config: ${error}`
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await sendToClient(newtId, {
|
||||||
|
type: "newt/blueprint/results",
|
||||||
|
data: {
|
||||||
|
success: true,
|
||||||
|
message: "Config updated successfully"
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
284
server/lib/blueprints/parseDockerContainers.ts
Normal file
284
server/lib/blueprints/parseDockerContainers.ts
Normal file
@@ -0,0 +1,284 @@
|
|||||||
|
import logger from "@server/logger";
|
||||||
|
import { setNestedProperty } from "./parseDotNotation";
|
||||||
|
|
||||||
|
export type DockerLabels = {
|
||||||
|
[key: string]: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ParsedObject = {
|
||||||
|
[key: string]: any;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ContainerPort = {
|
||||||
|
privatePort: number;
|
||||||
|
publicPort: number;
|
||||||
|
type: string;
|
||||||
|
ip: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type Container = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
image: string;
|
||||||
|
state: string;
|
||||||
|
status: string;
|
||||||
|
ports: ContainerPort[] | null;
|
||||||
|
labels: DockerLabels;
|
||||||
|
created: number;
|
||||||
|
networks: { [key: string]: any };
|
||||||
|
hostname: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type Target = {
|
||||||
|
hostname?: string;
|
||||||
|
port?: number;
|
||||||
|
method?: string;
|
||||||
|
enabled?: boolean;
|
||||||
|
[key: string]: any;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ResourceConfig = {
|
||||||
|
[key: string]: any;
|
||||||
|
targets?: (Target | null)[];
|
||||||
|
};
|
||||||
|
|
||||||
|
function getContainerPort(container: Container): number | null {
|
||||||
|
if (!container.ports || container.ports.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
// Return the first port's privatePort
|
||||||
|
return container.ports[0].privatePort;
|
||||||
|
// return container.ports[0].publicPort;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function processContainerLabels(containers: Container[]): {
|
||||||
|
resources: { [key: string]: ResourceConfig };
|
||||||
|
} {
|
||||||
|
const result: { resources: { [key: string]: ResourceConfig } } = {
|
||||||
|
resources: {}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Process each container
|
||||||
|
containers.forEach((container) => {
|
||||||
|
if (container.state !== "running") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const resourceLabels: DockerLabels = {};
|
||||||
|
|
||||||
|
// Filter labels that start with "pangolin.resources."
|
||||||
|
Object.entries(container.labels).forEach(([key, value]) => {
|
||||||
|
if (key.startsWith("pangolin.resources.")) {
|
||||||
|
// remove the pangolin. prefix
|
||||||
|
const strippedKey = key.replace("pangolin.", "");
|
||||||
|
resourceLabels[strippedKey] = value;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Skip containers with no resource labels
|
||||||
|
if (Object.keys(resourceLabels).length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse the labels using the existing parseDockerLabels logic
|
||||||
|
const tempResult: ParsedObject = {};
|
||||||
|
Object.entries(resourceLabels).forEach(([key, value]) => {
|
||||||
|
setNestedProperty(tempResult, key, value);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Merge into main result
|
||||||
|
if (tempResult.resources) {
|
||||||
|
Object.entries(tempResult.resources).forEach(
|
||||||
|
([resourceKey, resourceConfig]: [string, any]) => {
|
||||||
|
// Initialize resource if it doesn't exist
|
||||||
|
if (!result.resources[resourceKey]) {
|
||||||
|
result.resources[resourceKey] = {};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Merge all properties except targets
|
||||||
|
Object.entries(resourceConfig).forEach(
|
||||||
|
([propKey, propValue]) => {
|
||||||
|
if (propKey !== "targets") {
|
||||||
|
result.resources[resourceKey][propKey] =
|
||||||
|
propValue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// Handle targets specially
|
||||||
|
if (
|
||||||
|
resourceConfig.targets &&
|
||||||
|
Array.isArray(resourceConfig.targets)
|
||||||
|
) {
|
||||||
|
const resource = result.resources[resourceKey];
|
||||||
|
if (resource) {
|
||||||
|
if (!resource.targets) {
|
||||||
|
resource.targets = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
resourceConfig.targets.forEach(
|
||||||
|
(target: any, targetIndex: number) => {
|
||||||
|
// check if the target is an empty object
|
||||||
|
if (
|
||||||
|
typeof target === "object" &&
|
||||||
|
Object.keys(target).length === 0
|
||||||
|
) {
|
||||||
|
logger.debug(
|
||||||
|
`Skipping null target at index ${targetIndex} for resource ${resourceKey}`
|
||||||
|
);
|
||||||
|
resource.targets!.push(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure targets array is long enough
|
||||||
|
while (
|
||||||
|
resource.targets!.length <= targetIndex
|
||||||
|
) {
|
||||||
|
resource.targets!.push({});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set default hostname and port if not provided
|
||||||
|
const finalTarget = { ...target };
|
||||||
|
if (!finalTarget.hostname) {
|
||||||
|
finalTarget.hostname =
|
||||||
|
container.name ||
|
||||||
|
container.hostname;
|
||||||
|
}
|
||||||
|
if (!finalTarget.port) {
|
||||||
|
const containerPort =
|
||||||
|
getContainerPort(container);
|
||||||
|
if (containerPort !== null) {
|
||||||
|
finalTarget.port = containerPort;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Merge with existing target data
|
||||||
|
resource.targets![targetIndex] = {
|
||||||
|
...resource.targets![targetIndex],
|
||||||
|
...finalTarget
|
||||||
|
};
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
// // Test example
|
||||||
|
// const testContainers: Container[] = [
|
||||||
|
// {
|
||||||
|
// id: "57e056cb0e3a",
|
||||||
|
// name: "nginx1",
|
||||||
|
// image: "nginxdemos/hello",
|
||||||
|
// state: "running",
|
||||||
|
// status: "Up 4 days",
|
||||||
|
// ports: [
|
||||||
|
// {
|
||||||
|
// privatePort: 80,
|
||||||
|
// publicPort: 8000,
|
||||||
|
// type: "tcp",
|
||||||
|
// ip: "0.0.0.0"
|
||||||
|
// }
|
||||||
|
// ],
|
||||||
|
// labels: {
|
||||||
|
// "resources.nginx.name": "nginx",
|
||||||
|
// "resources.nginx.full-domain": "nginx.example.com",
|
||||||
|
// "resources.nginx.protocol": "http",
|
||||||
|
// "resources.nginx.targets[0].enabled": "true"
|
||||||
|
// },
|
||||||
|
// created: 1756942725,
|
||||||
|
// networks: {
|
||||||
|
// owen_default: {
|
||||||
|
// networkId:
|
||||||
|
// "cb131c0f1d5d8ef7158660e77fc370508f5a563e1f9829b53a1945ae3725b58c"
|
||||||
|
// }
|
||||||
|
// },
|
||||||
|
// hostname: "57e056cb0e3a"
|
||||||
|
// },
|
||||||
|
// {
|
||||||
|
// id: "58e056cb0e3b",
|
||||||
|
// name: "nginx2",
|
||||||
|
// image: "nginxdemos/hello",
|
||||||
|
// state: "running",
|
||||||
|
// status: "Up 4 days",
|
||||||
|
// ports: [
|
||||||
|
// {
|
||||||
|
// privatePort: 80,
|
||||||
|
// publicPort: 8001,
|
||||||
|
// type: "tcp",
|
||||||
|
// ip: "0.0.0.0"
|
||||||
|
// }
|
||||||
|
// ],
|
||||||
|
// labels: {
|
||||||
|
// "resources.nginx.name": "nginx",
|
||||||
|
// "resources.nginx.full-domain": "nginx.example.com",
|
||||||
|
// "resources.nginx.protocol": "http",
|
||||||
|
// "resources.nginx.targets[1].enabled": "true"
|
||||||
|
// },
|
||||||
|
// created: 1756942726,
|
||||||
|
// networks: {
|
||||||
|
// owen_default: {
|
||||||
|
// networkId:
|
||||||
|
// "cb131c0f1d5d8ef7158660e77fc370508f5a563e1f9829b53a1945ae3725b58c"
|
||||||
|
// }
|
||||||
|
// },
|
||||||
|
// hostname: "58e056cb0e3b"
|
||||||
|
// },
|
||||||
|
// {
|
||||||
|
// id: "59e056cb0e3c",
|
||||||
|
// name: "api-server",
|
||||||
|
// image: "my-api:latest",
|
||||||
|
// state: "running",
|
||||||
|
// status: "Up 2 days",
|
||||||
|
// ports: [
|
||||||
|
// {
|
||||||
|
// privatePort: 3000,
|
||||||
|
// publicPort: 3000,
|
||||||
|
// type: "tcp",
|
||||||
|
// ip: "0.0.0.0"
|
||||||
|
// }
|
||||||
|
// ],
|
||||||
|
// labels: {
|
||||||
|
// "resources.api.name": "API Server",
|
||||||
|
// "resources.api.protocol": "http",
|
||||||
|
// "resources.api.targets[0].enabled": "true",
|
||||||
|
// "resources.api.targets[0].hostname": "custom-host",
|
||||||
|
// "resources.api.targets[0].port": "3001"
|
||||||
|
// },
|
||||||
|
// created: 1756942727,
|
||||||
|
// networks: {
|
||||||
|
// owen_default: {
|
||||||
|
// networkId:
|
||||||
|
// "cb131c0f1d5d8ef7158660e77fc370508f5a563e1f9829b53a1945ae3725b58c"
|
||||||
|
// }
|
||||||
|
// },
|
||||||
|
// hostname: "59e056cb0e3c"
|
||||||
|
// },
|
||||||
|
// {
|
||||||
|
// id: "d0e29b08361c",
|
||||||
|
// name: "beautiful_wilson",
|
||||||
|
// image: "bolkedebruin/rdpgw:latest",
|
||||||
|
// state: "exited",
|
||||||
|
// status: "Exited (0) 4 hours ago",
|
||||||
|
// ports: null,
|
||||||
|
// labels: {},
|
||||||
|
// created: 1757359039,
|
||||||
|
// networks: {
|
||||||
|
// bridge: {
|
||||||
|
// networkId:
|
||||||
|
// "ea7f56dfc9cc476b8a3560b5b570d0fe8a6a2bc5e8343ab1ed37822086e89687"
|
||||||
|
// }
|
||||||
|
// },
|
||||||
|
// hostname: "d0e29b08361c"
|
||||||
|
// }
|
||||||
|
// ];
|
||||||
|
|
||||||
|
// // Test the function
|
||||||
|
// const result = processContainerLabels(testContainers);
|
||||||
|
// console.log("Processed result:");
|
||||||
|
// console.log(JSON.stringify(result, null, 2));
|
||||||
109
server/lib/blueprints/parseDotNotation.ts
Normal file
109
server/lib/blueprints/parseDotNotation.ts
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
export function setNestedProperty(obj: any, path: string, value: string): void {
|
||||||
|
const keys = path.split(".");
|
||||||
|
let current = obj;
|
||||||
|
|
||||||
|
for (let i = 0; i < keys.length - 1; i++) {
|
||||||
|
const key = keys[i];
|
||||||
|
|
||||||
|
// Handle array notation like "targets[0]"
|
||||||
|
const arrayMatch = key.match(/^(.+)\[(\d+)\]$/);
|
||||||
|
|
||||||
|
if (arrayMatch) {
|
||||||
|
const [, arrayKey, indexStr] = arrayMatch;
|
||||||
|
const index = parseInt(indexStr, 10);
|
||||||
|
|
||||||
|
// Initialize array if it doesn't exist
|
||||||
|
if (!current[arrayKey]) {
|
||||||
|
current[arrayKey] = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure array is long enough
|
||||||
|
while (current[arrayKey].length <= index) {
|
||||||
|
current[arrayKey].push({});
|
||||||
|
}
|
||||||
|
|
||||||
|
current = current[arrayKey][index];
|
||||||
|
} else {
|
||||||
|
// Regular object property
|
||||||
|
if (!current[key]) {
|
||||||
|
current[key] = {};
|
||||||
|
}
|
||||||
|
current = current[key];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set the final value
|
||||||
|
const finalKey = keys[keys.length - 1];
|
||||||
|
const arrayMatch = finalKey.match(/^(.+)\[(\d+)\]$/);
|
||||||
|
|
||||||
|
if (arrayMatch) {
|
||||||
|
const [, arrayKey, indexStr] = arrayMatch;
|
||||||
|
const index = parseInt(indexStr, 10);
|
||||||
|
|
||||||
|
if (!current[arrayKey]) {
|
||||||
|
current[arrayKey] = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure array is long enough
|
||||||
|
while (current[arrayKey].length <= index) {
|
||||||
|
current[arrayKey].push(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
current[arrayKey][index] = convertValue(value);
|
||||||
|
} else {
|
||||||
|
current[finalKey] = convertValue(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper function to convert string values to appropriate types
|
||||||
|
export function convertValue(value: string): any {
|
||||||
|
// Convert boolean strings
|
||||||
|
if (value === "true") return true;
|
||||||
|
if (value === "false") return false;
|
||||||
|
|
||||||
|
// Convert numeric strings
|
||||||
|
if (/^\d+$/.test(value)) {
|
||||||
|
const num = parseInt(value, 10);
|
||||||
|
return num;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (/^\d*\.\d+$/.test(value)) {
|
||||||
|
const num = parseFloat(value);
|
||||||
|
return num;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return as string
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
// // Example usage:
|
||||||
|
// const dockerLabels: DockerLabels = {
|
||||||
|
// "resources.resource-nice-id.name": "this is my resource",
|
||||||
|
// "resources.resource-nice-id.protocol": "http",
|
||||||
|
// "resources.resource-nice-id.full-domain": "level1.test3.example.com",
|
||||||
|
// "resources.resource-nice-id.host-header": "example.com",
|
||||||
|
// "resources.resource-nice-id.tls-server-name": "example.com",
|
||||||
|
// "resources.resource-nice-id.auth.pincode": "123456",
|
||||||
|
// "resources.resource-nice-id.auth.password": "sadfasdfadsf",
|
||||||
|
// "resources.resource-nice-id.auth.sso-enabled": "true",
|
||||||
|
// "resources.resource-nice-id.auth.sso-roles[0]": "Member",
|
||||||
|
// "resources.resource-nice-id.auth.sso-users[0]": "owen@fossorial.io",
|
||||||
|
// "resources.resource-nice-id.auth.whitelist-users[0]": "owen@fossorial.io",
|
||||||
|
// "resources.resource-nice-id.targets[0].hostname": "localhost",
|
||||||
|
// "resources.resource-nice-id.targets[0].method": "http",
|
||||||
|
// "resources.resource-nice-id.targets[0].port": "8000",
|
||||||
|
// "resources.resource-nice-id.targets[0].healthcheck.port": "8000",
|
||||||
|
// "resources.resource-nice-id.targets[0].healthcheck.hostname": "localhost",
|
||||||
|
// "resources.resource-nice-id.targets[1].hostname": "localhost",
|
||||||
|
// "resources.resource-nice-id.targets[1].method": "http",
|
||||||
|
// "resources.resource-nice-id.targets[1].port": "8001",
|
||||||
|
// "resources.resource-nice-id2.name": "this is other resource",
|
||||||
|
// "resources.resource-nice-id2.protocol": "tcp",
|
||||||
|
// "resources.resource-nice-id2.proxy-port": "3000",
|
||||||
|
// "resources.resource-nice-id2.targets[0].hostname": "localhost",
|
||||||
|
// "resources.resource-nice-id2.targets[0].port": "3000"
|
||||||
|
// };
|
||||||
|
|
||||||
|
// // Parse the labels
|
||||||
|
// const parsed = parseDockerLabels(dockerLabels);
|
||||||
|
// console.log(JSON.stringify(parsed, null, 2));
|
||||||
745
server/lib/blueprints/resources.ts
Normal file
745
server/lib/blueprints/resources.ts
Normal file
@@ -0,0 +1,745 @@
|
|||||||
|
import {
|
||||||
|
domains,
|
||||||
|
orgDomains,
|
||||||
|
Resource,
|
||||||
|
resourcePincode,
|
||||||
|
resourceWhitelist,
|
||||||
|
roleResources,
|
||||||
|
roles,
|
||||||
|
Target,
|
||||||
|
Transaction,
|
||||||
|
userOrgs,
|
||||||
|
userResources,
|
||||||
|
users
|
||||||
|
} 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 { pickPort } from "@server/routers/target/helpers";
|
||||||
|
import { resourcePassword } from "@server/db";
|
||||||
|
import { hashPassword } from "@server/auth/password";
|
||||||
|
|
||||||
|
export type ResourcesResults = {
|
||||||
|
resource: Resource;
|
||||||
|
targetsToUpdate: Target[];
|
||||||
|
}[];
|
||||||
|
|
||||||
|
export async function updateResources(
|
||||||
|
orgId: string,
|
||||||
|
config: Config,
|
||||||
|
trx: Transaction,
|
||||||
|
siteId?: number
|
||||||
|
): Promise<ResourcesResults> {
|
||||||
|
let results: ResourcesResults = [];
|
||||||
|
|
||||||
|
for (const [resourceNiceId, resourceData] of Object.entries(
|
||||||
|
config.resources
|
||||||
|
)) {
|
||||||
|
let targetsToUpdate: Target[] = [];
|
||||||
|
let resource: Resource;
|
||||||
|
|
||||||
|
async function createTarget( // reusable function to create a target
|
||||||
|
resourceId: number,
|
||||||
|
targetData: TargetData
|
||||||
|
) {
|
||||||
|
let targetSiteId = targetData.site;
|
||||||
|
let site;
|
||||||
|
|
||||||
|
if (targetSiteId) {
|
||||||
|
// Look up site by niceId
|
||||||
|
[site] = await trx
|
||||||
|
.select({ siteId: sites.siteId })
|
||||||
|
.from(sites)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(sites.niceId, targetSiteId),
|
||||||
|
eq(sites.orgId, orgId)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.limit(1);
|
||||||
|
} else if (siteId) {
|
||||||
|
// Use the provided siteId directly, but verify it belongs to the org
|
||||||
|
[site] = await trx
|
||||||
|
.select({ siteId: sites.siteId })
|
||||||
|
.from(sites)
|
||||||
|
.where(
|
||||||
|
and(eq(sites.siteId, siteId), eq(sites.orgId, orgId))
|
||||||
|
)
|
||||||
|
.limit(1);
|
||||||
|
} else {
|
||||||
|
throw new Error(`Target site ID is required`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!site) {
|
||||||
|
throw new Error(
|
||||||
|
`Site not found: ${targetSiteId} in org ${orgId}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let internalPortToCreate;
|
||||||
|
if (!targetData["internal-port"]) {
|
||||||
|
const { internalPort, targetIps } = await pickPort(
|
||||||
|
site.siteId!,
|
||||||
|
trx
|
||||||
|
);
|
||||||
|
internalPortToCreate = internalPort;
|
||||||
|
} else {
|
||||||
|
internalPortToCreate = targetData["internal-port"];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create target
|
||||||
|
const [newTarget] = await trx
|
||||||
|
.insert(targets)
|
||||||
|
.values({
|
||||||
|
resourceId: resourceId,
|
||||||
|
siteId: site.siteId,
|
||||||
|
ip: targetData.hostname,
|
||||||
|
method: targetData.method,
|
||||||
|
port: targetData.port,
|
||||||
|
enabled: targetData.enabled,
|
||||||
|
internalPort: internalPortToCreate
|
||||||
|
})
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
targetsToUpdate.push(newTarget);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find existing resource by niceId and orgId
|
||||||
|
const [existingResource] = await trx
|
||||||
|
.select()
|
||||||
|
.from(resources)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(resources.niceId, resourceNiceId),
|
||||||
|
eq(resources.orgId, orgId)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
const http = resourceData.protocol == "http";
|
||||||
|
const protocol =
|
||||||
|
resourceData.protocol == "http" ? "tcp" : resourceData.protocol;
|
||||||
|
|
||||||
|
if (existingResource) {
|
||||||
|
let domain;
|
||||||
|
if (http) {
|
||||||
|
domain = await getDomain(
|
||||||
|
existingResource.resourceId,
|
||||||
|
resourceData["full-domain"]!,
|
||||||
|
orgId,
|
||||||
|
trx
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// check if the only key in the resource is targets, if so, skip the update
|
||||||
|
if (
|
||||||
|
isTargetsOnlyResource(resourceData)
|
||||||
|
) {
|
||||||
|
logger.debug(
|
||||||
|
`Skipping update for resource ${existingResource.resourceId} as only targets are provided`
|
||||||
|
);
|
||||||
|
resource = existingResource;
|
||||||
|
} else {
|
||||||
|
// Update existing resource
|
||||||
|
[resource] = await trx
|
||||||
|
.update(resources)
|
||||||
|
.set({
|
||||||
|
name: resourceData.name || "Unnamed Resource",
|
||||||
|
protocol: protocol || "http",
|
||||||
|
http: http,
|
||||||
|
proxyPort: http ? null : resourceData["proxy-port"],
|
||||||
|
fullDomain: http ? resourceData["full-domain"] : null,
|
||||||
|
subdomain: domain ? domain.subdomain : null,
|
||||||
|
domainId: domain ? domain.domainId : null,
|
||||||
|
enabled: resourceData.enabled ? true : false,
|
||||||
|
sso: resourceData.auth?.["sso-enabled"] || false,
|
||||||
|
ssl: resourceData.ssl ? true : false,
|
||||||
|
setHostHeader: resourceData["host-header"] || null,
|
||||||
|
tlsServerName: resourceData["tls-server-name"] || null,
|
||||||
|
emailWhitelistEnabled: resourceData.auth?.[
|
||||||
|
"whitelist-users"
|
||||||
|
]
|
||||||
|
? resourceData.auth["whitelist-users"].length > 0
|
||||||
|
: false
|
||||||
|
})
|
||||||
|
.where(
|
||||||
|
eq(resources.resourceId, existingResource.resourceId)
|
||||||
|
)
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
await trx
|
||||||
|
.delete(resourcePassword)
|
||||||
|
.where(
|
||||||
|
eq(
|
||||||
|
resourcePassword.resourceId,
|
||||||
|
existingResource.resourceId
|
||||||
|
)
|
||||||
|
);
|
||||||
|
if (resourceData.auth?.password) {
|
||||||
|
const passwordHash = await hashPassword(
|
||||||
|
resourceData.auth.password
|
||||||
|
);
|
||||||
|
|
||||||
|
await trx.insert(resourcePassword).values({
|
||||||
|
resourceId: existingResource.resourceId,
|
||||||
|
passwordHash
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await trx
|
||||||
|
.delete(resourcePincode)
|
||||||
|
.where(
|
||||||
|
eq(
|
||||||
|
resourcePincode.resourceId,
|
||||||
|
existingResource.resourceId
|
||||||
|
)
|
||||||
|
);
|
||||||
|
if (resourceData.auth?.pincode) {
|
||||||
|
const pincodeHash = await hashPassword(
|
||||||
|
resourceData.auth.pincode.toString()
|
||||||
|
);
|
||||||
|
|
||||||
|
await trx.insert(resourcePincode).values({
|
||||||
|
resourceId: existingResource.resourceId,
|
||||||
|
pincodeHash,
|
||||||
|
digitLength: 6
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (resourceData.auth?.["sso-roles"]) {
|
||||||
|
const ssoRoles = resourceData.auth?.["sso-roles"];
|
||||||
|
await syncRoleResources(
|
||||||
|
existingResource.resourceId,
|
||||||
|
ssoRoles,
|
||||||
|
orgId,
|
||||||
|
trx
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (resourceData.auth?.["sso-users"]) {
|
||||||
|
const ssoUsers = resourceData.auth?.["sso-users"];
|
||||||
|
await syncUserResources(
|
||||||
|
existingResource.resourceId,
|
||||||
|
ssoUsers,
|
||||||
|
orgId,
|
||||||
|
trx
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (resourceData.auth?.["whitelist-users"]) {
|
||||||
|
const whitelistUsers =
|
||||||
|
resourceData.auth?.["whitelist-users"];
|
||||||
|
await syncWhitelistUsers(
|
||||||
|
existingResource.resourceId,
|
||||||
|
whitelistUsers,
|
||||||
|
orgId,
|
||||||
|
trx
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const existingResourceTargets = await trx
|
||||||
|
.select()
|
||||||
|
.from(targets)
|
||||||
|
.where(eq(targets.resourceId, existingResource.resourceId))
|
||||||
|
.orderBy(asc(targets.targetId));
|
||||||
|
|
||||||
|
// Create new targets
|
||||||
|
for (const [index, targetData] of resourceData.targets.entries()) {
|
||||||
|
if (!targetData) {
|
||||||
|
// If targetData is null or an empty object, we can skip it
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const existingTarget = existingResourceTargets[index];
|
||||||
|
if (existingTarget) {
|
||||||
|
let targetSiteId = targetData.site;
|
||||||
|
let site;
|
||||||
|
|
||||||
|
if (targetSiteId) {
|
||||||
|
// Look up site by niceId
|
||||||
|
[site] = await trx
|
||||||
|
.select({ siteId: sites.siteId })
|
||||||
|
.from(sites)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(sites.niceId, targetSiteId),
|
||||||
|
eq(sites.orgId, orgId)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.limit(1);
|
||||||
|
} else if (siteId) {
|
||||||
|
// Use the provided siteId directly, but verify it belongs to the org
|
||||||
|
[site] = await trx
|
||||||
|
.select({ siteId: sites.siteId })
|
||||||
|
.from(sites)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(sites.siteId, siteId),
|
||||||
|
eq(sites.orgId, orgId)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.limit(1);
|
||||||
|
} else {
|
||||||
|
throw new Error(`Target site ID is required`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!site) {
|
||||||
|
throw new Error(
|
||||||
|
`Site not found: ${targetSiteId} in org ${orgId}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// update this target
|
||||||
|
const [updatedTarget] = await trx
|
||||||
|
.update(targets)
|
||||||
|
.set({
|
||||||
|
siteId: site.siteId,
|
||||||
|
ip: targetData.hostname,
|
||||||
|
method: http ? targetData.method : null,
|
||||||
|
port: targetData.port,
|
||||||
|
enabled: targetData.enabled
|
||||||
|
})
|
||||||
|
.where(eq(targets.targetId, existingTarget.targetId))
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
if (checkIfTargetChanged(existingTarget, updatedTarget)) {
|
||||||
|
let internalPortToUpdate;
|
||||||
|
if (!targetData["internal-port"]) {
|
||||||
|
const { internalPort, targetIps } = await pickPort(
|
||||||
|
site.siteId!,
|
||||||
|
trx
|
||||||
|
);
|
||||||
|
internalPortToUpdate = internalPort;
|
||||||
|
} else {
|
||||||
|
internalPortToUpdate = targetData["internal-port"];
|
||||||
|
}
|
||||||
|
|
||||||
|
const [finalUpdatedTarget] = await trx // this double is so we can check the whole target before and after
|
||||||
|
.update(targets)
|
||||||
|
.set({
|
||||||
|
internalPort: internalPortToUpdate
|
||||||
|
})
|
||||||
|
.where(
|
||||||
|
eq(targets.targetId, existingTarget.targetId)
|
||||||
|
)
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
targetsToUpdate.push(finalUpdatedTarget);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
await createTarget(existingResource.resourceId, targetData);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (existingResourceTargets.length > resourceData.targets.length) {
|
||||||
|
const targetsToDelete = existingResourceTargets.slice(
|
||||||
|
resourceData.targets.length
|
||||||
|
);
|
||||||
|
for (const target of targetsToDelete) {
|
||||||
|
await trx
|
||||||
|
.delete(targets)
|
||||||
|
.where(eq(targets.targetId, target.targetId));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.debug(`Updated resource ${existingResource.resourceId}`);
|
||||||
|
} else {
|
||||||
|
// create a brand new resource
|
||||||
|
let domain;
|
||||||
|
if (http) {
|
||||||
|
domain = await getDomain(
|
||||||
|
undefined,
|
||||||
|
resourceData["full-domain"]!,
|
||||||
|
orgId,
|
||||||
|
trx
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create new resource
|
||||||
|
const [newResource] = await trx
|
||||||
|
.insert(resources)
|
||||||
|
.values({
|
||||||
|
orgId,
|
||||||
|
niceId: resourceNiceId,
|
||||||
|
name: resourceData.name || "Unnamed Resource",
|
||||||
|
protocol: resourceData.protocol || "http",
|
||||||
|
http: http,
|
||||||
|
proxyPort: http ? null : resourceData["proxy-port"],
|
||||||
|
fullDomain: http ? resourceData["full-domain"] : null,
|
||||||
|
subdomain: domain ? domain.subdomain : null,
|
||||||
|
domainId: domain ? domain.domainId : null,
|
||||||
|
enabled: resourceData.enabled ? true : false,
|
||||||
|
sso: resourceData.auth?.["sso-enabled"] || false,
|
||||||
|
setHostHeader: resourceData["host-header"] || null,
|
||||||
|
tlsServerName: resourceData["tls-server-name"] || null,
|
||||||
|
ssl: resourceData.ssl ? true : false
|
||||||
|
})
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
if (resourceData.auth?.password) {
|
||||||
|
const passwordHash = await hashPassword(
|
||||||
|
resourceData.auth.password
|
||||||
|
);
|
||||||
|
|
||||||
|
await trx.insert(resourcePassword).values({
|
||||||
|
resourceId: newResource.resourceId,
|
||||||
|
passwordHash
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (resourceData.auth?.pincode) {
|
||||||
|
const pincodeHash = await hashPassword(
|
||||||
|
resourceData.auth.pincode.toString()
|
||||||
|
);
|
||||||
|
|
||||||
|
await trx.insert(resourcePincode).values({
|
||||||
|
resourceId: newResource.resourceId,
|
||||||
|
pincodeHash,
|
||||||
|
digitLength: 6
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
resource = newResource;
|
||||||
|
|
||||||
|
const [adminRole] = await trx
|
||||||
|
.select()
|
||||||
|
.from(roles)
|
||||||
|
.where(and(eq(roles.isAdmin, true), eq(roles.orgId, orgId)))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!adminRole) {
|
||||||
|
throw new Error(`Admin role not found`);
|
||||||
|
}
|
||||||
|
|
||||||
|
await trx.insert(roleResources).values({
|
||||||
|
roleId: adminRole.roleId,
|
||||||
|
resourceId: newResource.resourceId
|
||||||
|
});
|
||||||
|
|
||||||
|
if (resourceData.auth?.["sso-roles"]) {
|
||||||
|
const ssoRoles = resourceData.auth?.["sso-roles"];
|
||||||
|
await syncRoleResources(
|
||||||
|
newResource.resourceId,
|
||||||
|
ssoRoles,
|
||||||
|
orgId,
|
||||||
|
trx
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (resourceData.auth?.["sso-users"]) {
|
||||||
|
const ssoUsers = resourceData.auth?.["sso-users"];
|
||||||
|
await syncUserResources(
|
||||||
|
newResource.resourceId,
|
||||||
|
ssoUsers,
|
||||||
|
orgId,
|
||||||
|
trx
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (resourceData.auth?.["whitelist-users"]) {
|
||||||
|
const whitelistUsers = resourceData.auth?.["whitelist-users"];
|
||||||
|
await syncWhitelistUsers(
|
||||||
|
newResource.resourceId,
|
||||||
|
whitelistUsers,
|
||||||
|
orgId,
|
||||||
|
trx
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create new targets
|
||||||
|
for (const targetData of resourceData.targets) {
|
||||||
|
if (!targetData) {
|
||||||
|
// If targetData is null or an empty object, we can skip it
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
await createTarget(newResource.resourceId, targetData);
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.debug(`Created resource ${newResource.resourceId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
results.push({
|
||||||
|
resource: resource,
|
||||||
|
targetsToUpdate,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function syncRoleResources(
|
||||||
|
resourceId: number,
|
||||||
|
ssoRoles: string[],
|
||||||
|
orgId: string,
|
||||||
|
trx: Transaction
|
||||||
|
) {
|
||||||
|
const existingRoleResources = await trx
|
||||||
|
.select()
|
||||||
|
.from(roleResources)
|
||||||
|
.where(eq(roleResources.resourceId, resourceId));
|
||||||
|
|
||||||
|
for (const roleName of ssoRoles) {
|
||||||
|
if (roleName === "Admin") {
|
||||||
|
continue; // never add admin access
|
||||||
|
}
|
||||||
|
|
||||||
|
const [role] = await trx
|
||||||
|
.select()
|
||||||
|
.from(roles)
|
||||||
|
.where(and(eq(roles.name, roleName), eq(roles.orgId, orgId)))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!role) {
|
||||||
|
throw new Error(`Role not found: ${roleName} in org ${orgId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const existingRoleResource = existingRoleResources.find(
|
||||||
|
(rr) => rr.roleId === role.roleId
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!existingRoleResource) {
|
||||||
|
await trx.insert(roleResources).values({
|
||||||
|
roleId: role.roleId,
|
||||||
|
resourceId: resourceId
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const existingRoleResource of existingRoleResources) {
|
||||||
|
const [role] = await trx
|
||||||
|
.select()
|
||||||
|
.from(roles)
|
||||||
|
.where(eq(roles.roleId, existingRoleResource.roleId))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (role.isAdmin) {
|
||||||
|
continue; // never remove admin access
|
||||||
|
}
|
||||||
|
|
||||||
|
if (role && !ssoRoles.includes(role.name)) {
|
||||||
|
await trx
|
||||||
|
.delete(roleResources)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(roleResources.roleId, existingRoleResource.roleId),
|
||||||
|
eq(roleResources.resourceId, resourceId)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function syncUserResources(
|
||||||
|
resourceId: number,
|
||||||
|
ssoUsers: string[],
|
||||||
|
orgId: string,
|
||||||
|
trx: Transaction
|
||||||
|
) {
|
||||||
|
const existingUserResources = await trx
|
||||||
|
.select()
|
||||||
|
.from(userResources)
|
||||||
|
.where(eq(userResources.resourceId, resourceId));
|
||||||
|
|
||||||
|
for (const email of ssoUsers) {
|
||||||
|
const [user] = await trx
|
||||||
|
.select()
|
||||||
|
.from(users)
|
||||||
|
.innerJoin(userOrgs, eq(users.userId, userOrgs.userId))
|
||||||
|
.where(and(eq(users.email, email), eq(userOrgs.orgId, orgId)))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
throw new Error(`User not found: ${email} in org ${orgId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const existingUserResource = existingUserResources.find(
|
||||||
|
(rr) => rr.userId === user.user.userId
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!existingUserResource) {
|
||||||
|
await trx.insert(userResources).values({
|
||||||
|
userId: user.user.userId,
|
||||||
|
resourceId: resourceId
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const existingUserResource of existingUserResources) {
|
||||||
|
const [user] = await trx
|
||||||
|
.select()
|
||||||
|
.from(users)
|
||||||
|
.innerJoin(userOrgs, eq(users.userId, userOrgs.userId))
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(users.userId, existingUserResource.userId),
|
||||||
|
eq(userOrgs.orgId, orgId)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (user && user.user.email && !ssoUsers.includes(user.user.email)) {
|
||||||
|
await trx
|
||||||
|
.delete(userResources)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(userResources.userId, existingUserResource.userId),
|
||||||
|
eq(userResources.resourceId, resourceId)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function syncWhitelistUsers(
|
||||||
|
resourceId: number,
|
||||||
|
whitelistUsers: string[],
|
||||||
|
orgId: string,
|
||||||
|
trx: Transaction
|
||||||
|
) {
|
||||||
|
const existingWhitelist = await trx
|
||||||
|
.select()
|
||||||
|
.from(resourceWhitelist)
|
||||||
|
.where(eq(resourceWhitelist.resourceId, resourceId));
|
||||||
|
|
||||||
|
for (const email of whitelistUsers) {
|
||||||
|
const [user] = await trx
|
||||||
|
.select()
|
||||||
|
.from(users)
|
||||||
|
.innerJoin(userOrgs, eq(users.userId, userOrgs.userId))
|
||||||
|
.where(and(eq(users.email, email), eq(userOrgs.orgId, orgId)))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
throw new Error(`User not found: ${email} in org ${orgId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const existingWhitelistEntry = existingWhitelist.find(
|
||||||
|
(w) => w.email === email
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!existingWhitelistEntry) {
|
||||||
|
await trx.insert(resourceWhitelist).values({
|
||||||
|
email,
|
||||||
|
resourceId: resourceId
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const existingWhitelistEntry of existingWhitelist) {
|
||||||
|
if (!whitelistUsers.includes(existingWhitelistEntry.email)) {
|
||||||
|
await trx
|
||||||
|
.delete(resourceWhitelist)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(resourceWhitelist.resourceId, resourceId),
|
||||||
|
eq(
|
||||||
|
resourceWhitelist.email,
|
||||||
|
existingWhitelistEntry.email
|
||||||
|
)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function checkIfTargetChanged(
|
||||||
|
existing: Target | undefined,
|
||||||
|
incoming: Target | undefined
|
||||||
|
): boolean {
|
||||||
|
if (!existing && incoming) return true;
|
||||||
|
if (existing && !incoming) return true;
|
||||||
|
if (!existing || !incoming) return false;
|
||||||
|
|
||||||
|
if (existing.ip !== incoming.ip) return true;
|
||||||
|
if (existing.port !== incoming.port) return true;
|
||||||
|
if (existing.siteId !== incoming.siteId) return true;
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getDomain(
|
||||||
|
resourceId: number | undefined,
|
||||||
|
fullDomain: string,
|
||||||
|
orgId: string,
|
||||||
|
trx: Transaction
|
||||||
|
) {
|
||||||
|
const [fullDomainExists] = await trx
|
||||||
|
.select({ resourceId: resources.resourceId })
|
||||||
|
.from(resources)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(resources.fullDomain, fullDomain),
|
||||||
|
eq(resources.orgId, orgId),
|
||||||
|
resourceId
|
||||||
|
? ne(resources.resourceId, resourceId)
|
||||||
|
: isNotNull(resources.resourceId)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (fullDomainExists) {
|
||||||
|
throw new Error(
|
||||||
|
`Resource already exists: ${fullDomain} in org ${orgId}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const domain = await getDomainId(orgId, fullDomain, trx);
|
||||||
|
|
||||||
|
if (!domain) {
|
||||||
|
throw new Error(
|
||||||
|
`Domain not found for full-domain: ${fullDomain} in org ${orgId}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return domain;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getDomainId(
|
||||||
|
orgId: string,
|
||||||
|
fullDomain: string,
|
||||||
|
trx: Transaction
|
||||||
|
): Promise<{ subdomain: string | null; domainId: string } | null> {
|
||||||
|
const possibleDomains = await trx
|
||||||
|
.select()
|
||||||
|
.from(domains)
|
||||||
|
.innerJoin(orgDomains, eq(domains.domainId, orgDomains.domainId))
|
||||||
|
.where(and(eq(orgDomains.orgId, orgId), eq(domains.verified, true)))
|
||||||
|
.execute();
|
||||||
|
|
||||||
|
if (possibleDomains.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const validDomains = possibleDomains.filter((domain) => {
|
||||||
|
if (domain.domains.type == "ns") {
|
||||||
|
return (
|
||||||
|
fullDomain === domain.domains.baseDomain ||
|
||||||
|
fullDomain.endsWith(`.${domain.domains.baseDomain}`)
|
||||||
|
);
|
||||||
|
} else if (domain.domains.type == "cname") {
|
||||||
|
return fullDomain === domain.domains.baseDomain;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (validDomains.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const domainSelection = validDomains[0].domains;
|
||||||
|
const baseDomain = domainSelection.baseDomain;
|
||||||
|
|
||||||
|
// remove the base domain of the domain
|
||||||
|
let subdomain = null;
|
||||||
|
if (domainSelection.type == "ns") {
|
||||||
|
if (fullDomain != baseDomain) {
|
||||||
|
subdomain = fullDomain.replace(`.${baseDomain}`, "");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return the first valid domain
|
||||||
|
return {
|
||||||
|
subdomain: subdomain,
|
||||||
|
domainId: domainSelection.domainId
|
||||||
|
};
|
||||||
|
}
|
||||||
232
server/lib/blueprints/types.ts
Normal file
232
server/lib/blueprints/types.ts
Normal file
@@ -0,0 +1,232 @@
|
|||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
export const SiteSchema = z.object({
|
||||||
|
name: z.string().min(1).max(100),
|
||||||
|
"docker-socket-enabled": z.boolean().optional().default(true)
|
||||||
|
});
|
||||||
|
|
||||||
|
// Schema for individual target within a resource
|
||||||
|
export const TargetSchema = z.object({
|
||||||
|
site: z.string().optional(),
|
||||||
|
method: z.enum(["http", "https", "h2c"]).optional(),
|
||||||
|
hostname: z.string(),
|
||||||
|
port: z.number().int().min(1).max(65535),
|
||||||
|
enabled: z.boolean().optional().default(true),
|
||||||
|
"internal-port": z.number().int().min(1).max(65535).optional(),
|
||||||
|
});
|
||||||
|
export type TargetData = z.infer<typeof TargetSchema>;
|
||||||
|
|
||||||
|
export const AuthSchema = z.object({
|
||||||
|
// pincode has to have 6 digits
|
||||||
|
pincode: z.number().min(100000).max(999999).optional(),
|
||||||
|
password: z.string().min(1).optional(),
|
||||||
|
"sso-enabled": z.boolean().optional().default(false),
|
||||||
|
"sso-roles": z
|
||||||
|
.array(z.string())
|
||||||
|
.optional()
|
||||||
|
.default([])
|
||||||
|
.refine((roles) => !roles.includes("Admin"), {
|
||||||
|
message: "Admin role cannot be included in sso-roles"
|
||||||
|
}),
|
||||||
|
"sso-users": z.array(z.string().email()).optional().default([]),
|
||||||
|
"whitelist-users": z.array(z.string().email()).optional().default([])
|
||||||
|
});
|
||||||
|
|
||||||
|
// Schema for individual resource
|
||||||
|
export const ResourceSchema = z
|
||||||
|
.object({
|
||||||
|
name: z.string().optional(),
|
||||||
|
protocol: z.enum(["http", "tcp", "udp"]).optional(),
|
||||||
|
ssl: z.boolean().optional(),
|
||||||
|
"full-domain": z.string().optional(),
|
||||||
|
"proxy-port": z.number().int().min(1).max(65535).optional(),
|
||||||
|
enabled: z.boolean().optional(),
|
||||||
|
targets: z.array(TargetSchema.nullable()).optional().default([]),
|
||||||
|
auth: AuthSchema.optional(),
|
||||||
|
"host-header": z.string().optional(),
|
||||||
|
"tls-server-name": z.string().optional()
|
||||||
|
})
|
||||||
|
.refine(
|
||||||
|
(resource) => {
|
||||||
|
if (isTargetsOnlyResource(resource)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Otherwise, require name and protocol for full resource definition
|
||||||
|
return (
|
||||||
|
resource.name !== undefined && resource.protocol !== undefined
|
||||||
|
);
|
||||||
|
},
|
||||||
|
{
|
||||||
|
message:
|
||||||
|
"Resource must either be targets-only (only 'targets' field) or have both 'name' and 'protocol' fields at a minimum",
|
||||||
|
path: ["name", "protocol"]
|
||||||
|
}
|
||||||
|
)
|
||||||
|
.refine(
|
||||||
|
(resource) => {
|
||||||
|
if (isTargetsOnlyResource(resource)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If protocol is http, all targets must have method field
|
||||||
|
if (resource.protocol === "http") {
|
||||||
|
return resource.targets.every(
|
||||||
|
(target) => target == null || target.method !== undefined
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// If protocol is tcp or udp, no target should have method field
|
||||||
|
if (resource.protocol === "tcp" || resource.protocol === "udp") {
|
||||||
|
return resource.targets.every(
|
||||||
|
(target) => target == null || target.method === undefined
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
(resource) => {
|
||||||
|
if (resource.protocol === "http") {
|
||||||
|
return {
|
||||||
|
message:
|
||||||
|
"When protocol is 'http', all targets must have a 'method' field",
|
||||||
|
path: ["targets"]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
message:
|
||||||
|
"When protocol is 'tcp' or 'udp', targets must not have a 'method' field",
|
||||||
|
path: ["targets"]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
)
|
||||||
|
.refine(
|
||||||
|
(resource) => {
|
||||||
|
if (isTargetsOnlyResource(resource)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If protocol is http, it must have a full-domain
|
||||||
|
if (resource.protocol === "http") {
|
||||||
|
return (
|
||||||
|
resource["full-domain"] !== undefined &&
|
||||||
|
resource["full-domain"].length > 0
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
{
|
||||||
|
message:
|
||||||
|
"When protocol is 'http', a 'full-domain' must be provided",
|
||||||
|
path: ["full-domain"]
|
||||||
|
}
|
||||||
|
)
|
||||||
|
.refine(
|
||||||
|
(resource) => {
|
||||||
|
if (isTargetsOnlyResource(resource)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If protocol is tcp or udp, it must have both proxy-port
|
||||||
|
if (resource.protocol === "tcp" || resource.protocol === "udp") {
|
||||||
|
return resource["proxy-port"] !== undefined;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
{
|
||||||
|
message:
|
||||||
|
"When protocol is 'tcp' or 'udp', 'proxy-port' must be provided",
|
||||||
|
path: ["proxy-port", "exit-node"]
|
||||||
|
}
|
||||||
|
)
|
||||||
|
.refine(
|
||||||
|
(resource) => {
|
||||||
|
// Skip validation for targets-only resources
|
||||||
|
if (isTargetsOnlyResource(resource)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If protocol is tcp or udp, it must not have auth
|
||||||
|
if (resource.protocol === "tcp" || resource.protocol === "udp") {
|
||||||
|
return resource.auth === undefined;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
{
|
||||||
|
message:
|
||||||
|
"When protocol is 'tcp' or 'udp', 'auth' must not be provided",
|
||||||
|
path: ["auth"]
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
export function isTargetsOnlyResource(resource: any): boolean {
|
||||||
|
return Object.keys(resource).length === 1 && resource.targets;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Schema for the entire configuration object
|
||||||
|
export const ConfigSchema = z
|
||||||
|
.object({
|
||||||
|
resources: z.record(z.string(), ResourceSchema).optional().default({}),
|
||||||
|
sites: z.record(z.string(), SiteSchema).optional().default({})
|
||||||
|
})
|
||||||
|
.refine(
|
||||||
|
// Enforce the full-domain uniqueness across resources in the same stack
|
||||||
|
(config) => {
|
||||||
|
// Extract all full-domain values with their resource keys
|
||||||
|
const fullDomainMap = new Map<string, string[]>();
|
||||||
|
|
||||||
|
Object.entries(config.resources).forEach(
|
||||||
|
([resourceKey, resource]) => {
|
||||||
|
const fullDomain = resource["full-domain"];
|
||||||
|
if (fullDomain) {
|
||||||
|
// Only process if full-domain is defined
|
||||||
|
if (!fullDomainMap.has(fullDomain)) {
|
||||||
|
fullDomainMap.set(fullDomain, []);
|
||||||
|
}
|
||||||
|
fullDomainMap.get(fullDomain)!.push(resourceKey);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// Find duplicates
|
||||||
|
const duplicates = Array.from(fullDomainMap.entries()).filter(
|
||||||
|
([_, resourceKeys]) => resourceKeys.length > 1
|
||||||
|
);
|
||||||
|
|
||||||
|
return duplicates.length === 0;
|
||||||
|
},
|
||||||
|
(config) => {
|
||||||
|
// Extract duplicates for error message
|
||||||
|
const fullDomainMap = new Map<string, string[]>();
|
||||||
|
|
||||||
|
Object.entries(config.resources).forEach(
|
||||||
|
([resourceKey, resource]) => {
|
||||||
|
const fullDomain = resource["full-domain"];
|
||||||
|
if (fullDomain) {
|
||||||
|
// Only process if full-domain is defined
|
||||||
|
if (!fullDomainMap.has(fullDomain)) {
|
||||||
|
fullDomainMap.set(fullDomain, []);
|
||||||
|
}
|
||||||
|
fullDomainMap.get(fullDomain)!.push(resourceKey);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const duplicates = Array.from(fullDomainMap.entries())
|
||||||
|
.filter(([_, resourceKeys]) => resourceKeys.length > 1)
|
||||||
|
.map(
|
||||||
|
([fullDomain, resourceKeys]) =>
|
||||||
|
`'${fullDomain}' used by resources: ${resourceKeys.join(", ")}`
|
||||||
|
)
|
||||||
|
.join("; ");
|
||||||
|
|
||||||
|
return {
|
||||||
|
message: `Duplicate 'full-domain' values found: ${duplicates}`,
|
||||||
|
path: ["resources"]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// Type inference from the schema
|
||||||
|
export type Site = z.infer<typeof SiteSchema>;
|
||||||
|
export type Target = z.infer<typeof TargetSchema>;
|
||||||
|
export type Resource = z.infer<typeof ResourceSchema>;
|
||||||
|
export type Config = z.infer<typeof ConfigSchema>;
|
||||||
73
server/routers/newt/handleApplyBlueprintMessage.ts
Normal file
73
server/routers/newt/handleApplyBlueprintMessage.ts
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
import { db, newts } from "@server/db";
|
||||||
|
import { MessageHandler } from "../ws";
|
||||||
|
import { exitNodes, Newt, resources, sites, Target, targets } from "@server/db";
|
||||||
|
import { eq, and, sql, inArray } from "drizzle-orm";
|
||||||
|
import logger from "@server/logger";
|
||||||
|
import { applyBlueprint } from "@server/lib/blueprints/applyBlueprint";
|
||||||
|
|
||||||
|
export const handleApplyBlueprintMessage: MessageHandler = async (context) => {
|
||||||
|
const { message, client, sendToClient } = context;
|
||||||
|
const newt = client as Newt;
|
||||||
|
|
||||||
|
logger.debug("Handling apply blueprint message!");
|
||||||
|
|
||||||
|
if (!newt) {
|
||||||
|
logger.warn("Newt not found");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!newt.siteId) {
|
||||||
|
logger.warn("Newt has no site!"); // TODO: Maybe we create the site here?
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// get the site
|
||||||
|
const [site] = await db
|
||||||
|
.select()
|
||||||
|
.from(sites)
|
||||||
|
.where(eq(sites.siteId, newt.siteId));
|
||||||
|
|
||||||
|
if (!site) {
|
||||||
|
logger.warn("Site not found for newt");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { blueprint } = message.data;
|
||||||
|
if (!blueprint) {
|
||||||
|
logger.warn("No blueprint provided");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.debug(`Received blueprint: ${blueprint}`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const blueprintParsed = JSON.parse(blueprint);
|
||||||
|
// Update the blueprint in the database
|
||||||
|
await applyBlueprint(site.orgId, blueprintParsed, site.siteId);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`Failed to update database from config: ${error}`);
|
||||||
|
return {
|
||||||
|
message: {
|
||||||
|
type: "newt/blueprint/results",
|
||||||
|
data: {
|
||||||
|
success: false,
|
||||||
|
message: `Failed to update database from config: ${error}`
|
||||||
|
}
|
||||||
|
},
|
||||||
|
broadcast: false, // Send to all clients
|
||||||
|
excludeSender: false // Include sender in broadcast
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
message: {
|
||||||
|
type: "newt/blueprint/results",
|
||||||
|
data: {
|
||||||
|
success: true,
|
||||||
|
message: "Config updated successfully"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
broadcast: false, // Send to all clients
|
||||||
|
excludeSender: false // Include sender in broadcast
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
getNextAvailableClientSubnet
|
getNextAvailableClientSubnet
|
||||||
} from "@server/lib/ip";
|
} from "@server/lib/ip";
|
||||||
import { selectBestExitNode, verifyExitNodeOrgAccess } from "@server/lib/exitNodes";
|
import { selectBestExitNode, verifyExitNodeOrgAccess } from "@server/lib/exitNodes";
|
||||||
|
import { fetchContainers } from "./dockerSocket";
|
||||||
|
|
||||||
export type ExitNodePingResult = {
|
export type ExitNodePingResult = {
|
||||||
exitNodeId: number;
|
exitNodeId: number;
|
||||||
@@ -76,6 +77,15 @@ export const handleNewtRegisterMessage: MessageHandler = async (context) => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
logger.debug(`Docker socket enabled: ${oldSite.dockerSocketEnabled}`);
|
||||||
|
|
||||||
|
if (oldSite.dockerSocketEnabled) {
|
||||||
|
logger.debug(
|
||||||
|
"Site has docker socket enabled - requesting docker containers"
|
||||||
|
);
|
||||||
|
fetchContainers(newt.newtId);
|
||||||
|
}
|
||||||
|
|
||||||
let siteSubnet = oldSite.subnet;
|
let siteSubnet = oldSite.subnet;
|
||||||
let exitNodeIdToQuery = oldSite.exitNodeId;
|
let exitNodeIdToQuery = oldSite.exitNodeId;
|
||||||
if (exitNodeId && (oldSite.exitNodeId !== exitNodeId || !oldSite.subnet)) {
|
if (exitNodeId && (oldSite.exitNodeId !== exitNodeId || !oldSite.subnet)) {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { MessageHandler } from "../ws";
|
|||||||
import logger from "@server/logger";
|
import logger from "@server/logger";
|
||||||
import { dockerSocketCache } from "./dockerSocket";
|
import { dockerSocketCache } from "./dockerSocket";
|
||||||
import { Newt } from "@server/db";
|
import { Newt } from "@server/db";
|
||||||
|
import { applyNewtDockerBlueprint } from "@server/lib/blueprints/applyNewtDockerBlueprint";
|
||||||
|
|
||||||
export const handleDockerStatusMessage: MessageHandler = async (context) => {
|
export const handleDockerStatusMessage: MessageHandler = async (context) => {
|
||||||
const { message, client, sendToClient } = context;
|
const { message, client, sendToClient } = context;
|
||||||
@@ -57,4 +58,15 @@ export const handleDockerContainersMessage: MessageHandler = async (
|
|||||||
} else {
|
} else {
|
||||||
logger.warn(`Newt ${newt.newtId} does not have Docker containers`);
|
logger.warn(`Newt ${newt.newtId} does not have Docker containers`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!newt.siteId) {
|
||||||
|
logger.warn("Newt has no site!");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await applyNewtDockerBlueprint(
|
||||||
|
newt.siteId,
|
||||||
|
newt.newtId,
|
||||||
|
containers
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -5,3 +5,4 @@ export * from "./handleReceiveBandwidthMessage";
|
|||||||
export * from "./handleGetConfigMessage";
|
export * from "./handleGetConfigMessage";
|
||||||
export * from "./handleSocketMessages";
|
export * from "./handleSocketMessages";
|
||||||
export * from "./handleNewtPingRequestMessage";
|
export * from "./handleNewtPingRequestMessage";
|
||||||
|
export * from "./handleApplyBlueprintMessage";
|
||||||
@@ -4,7 +4,8 @@ import {
|
|||||||
handleGetConfigMessage,
|
handleGetConfigMessage,
|
||||||
handleDockerStatusMessage,
|
handleDockerStatusMessage,
|
||||||
handleDockerContainersMessage,
|
handleDockerContainersMessage,
|
||||||
handleNewtPingRequestMessage
|
handleNewtPingRequestMessage,
|
||||||
|
handleApplyBlueprintMessage
|
||||||
} from "../newt";
|
} from "../newt";
|
||||||
import {
|
import {
|
||||||
handleOlmRegisterMessage,
|
handleOlmRegisterMessage,
|
||||||
@@ -23,7 +24,8 @@ export const messageHandlers: Record<string, MessageHandler> = {
|
|||||||
"olm/ping": handleOlmPingMessage,
|
"olm/ping": handleOlmPingMessage,
|
||||||
"newt/socket/status": handleDockerStatusMessage,
|
"newt/socket/status": handleDockerStatusMessage,
|
||||||
"newt/socket/containers": handleDockerContainersMessage,
|
"newt/socket/containers": handleDockerContainersMessage,
|
||||||
"newt/ping/request": handleNewtPingRequestMessage
|
"newt/ping/request": handleNewtPingRequestMessage,
|
||||||
|
"newt/blueprint/apply": handleApplyBlueprintMessage,
|
||||||
};
|
};
|
||||||
|
|
||||||
startOlmOfflineChecker(); // this is to handle the offline check for olms
|
startOlmOfflineChecker(); // this is to handle the offline check for olms
|
||||||
|
|||||||
Reference in New Issue
Block a user