From 13aa539e8a1ce430d64d3271d94c76de941caf0a Mon Sep 17 00:00:00 2001 From: Ben Allfree Date: Sat, 19 Jul 2025 06:58:10 -0700 Subject: [PATCH] feat(mothership): add cloudflare custom domain updater and remove HandleMigrateCnamesToDomains --- .../src/mothership-app/pb_hooks/mothership.js | 136 ++++++++++++------ .../mothership-app/pb_hooks/mothership.pb.js | 18 +-- .../instance/api/HandleInstanceUpdate.ts | 71 +++++++++ .../src/lib/handlers/instance/hooks.ts | 24 ++-- .../src/lib/handlers/instance/index.ts | 6 +- ...reate.ts => AfterCreate_notify_discord.ts} | 2 +- .../instance/model/BeforeUpdate_cname.ts | 32 +++++ .../instance/model/BeforeUpdate_version.ts | 16 +++ .../model/HandleInstanceBeforeUpdate.ts | 40 ------ .../model/HandleInstanceVersionValidation.ts | 10 -- 10 files changed, 231 insertions(+), 124 deletions(-) rename packages/pockethost/src/mothership-app/src/lib/handlers/instance/model/{HandleNotifyDiscordAfterCreate.ts => AfterCreate_notify_discord.ts} (91%) create mode 100644 packages/pockethost/src/mothership-app/src/lib/handlers/instance/model/BeforeUpdate_cname.ts create mode 100644 packages/pockethost/src/mothership-app/src/lib/handlers/instance/model/BeforeUpdate_version.ts delete mode 100644 packages/pockethost/src/mothership-app/src/lib/handlers/instance/model/HandleInstanceBeforeUpdate.ts delete mode 100644 packages/pockethost/src/mothership-app/src/lib/handlers/instance/model/HandleInstanceVersionValidation.ts diff --git a/packages/pockethost/src/mothership-app/pb_hooks/mothership.js b/packages/pockethost/src/mothership-app/pb_hooks/mothership.js index 4c024b34..056ca54f 100644 --- a/packages/pockethost/src/mothership-app/pb_hooks/mothership.js +++ b/packages/pockethost/src/mothership-app/pb_hooks/mothership.js @@ -91,7 +91,7 @@ const HandleInstanceDelete = (c) => { const HandleInstanceResolve = (c) => { const dao = $app.dao(); const log = mkLog(`GET:instance/resolve`); - log(`***TOP OF GET`); + log(`TOP OF GET`); const host = c.queryParam("host"); if (!host) throw new BadRequestError(`Host is required when resolving an instance.`); const instance = (() => { @@ -203,6 +203,43 @@ const removeEmptyKeys = (obj) => { //#endregion //#region src/lib/handlers/instance/api/HandleInstanceUpdate.ts +const callCloudflareAPI = (endpoint, method, body, log) => { + const apiToken = $os.getenv("MOTHERSHIP_CLOUDFLARE_API_TOKEN"); + const zoneId = $os.getenv("MOTHERSHIP_CLOUDFLARE_ZONE_ID"); + if (!apiToken || !zoneId) { + if (log) log("Cloudflare API credentials not configured - skipping Cloudflare operations"); + return null; + } + const url = `https://api.cloudflare.com/client/v4/zones/${zoneId}/${endpoint}`; + try { + const config = { + url, + method, + headers: { + Authorization: `Bearer ${apiToken}`, + "Content-Type": "application/json" + }, + timeout: 30 + }; + if (body) config.body = JSON.stringify(body); + if (log) log(`Making Cloudflare API call: ${method} ${url}`, config); + const response = $http.send(config); + if (log) log(`Cloudflare API response:`, response); + return response; + } catch (error$1) { + if (log) log(`Cloudflare API error:`, error$1); + return null; + } +}; +const createCloudflareCustomHostname = (hostname, log) => { + return callCloudflareAPI("custom_hostnames", "POST", { + hostname, + ssl: { + method: "http", + type: "dv" + } + }, log); +}; const HandleInstanceUpdate = (c) => { const dao = $app.dao(); const log = mkLog(`PUT:instance`); @@ -239,6 +276,14 @@ const HandleInstanceUpdate = (c) => { log(`authRecord`, JSON.stringify(authRecord)); if (!authRecord) throw new Error(`Expected authRecord here`); if (record.get("uid") !== authRecord.id) throw new BadRequestError(`Not authorized`); + const oldCname = record.getString("cname").trim(); + const newCname = cname ? cname.trim() : ""; + const cnameChanged = oldCname !== newCname; + if (cnameChanged && newCname) { + log(`CNAME changed from "${oldCname}" to "${newCname}" - adding to Cloudflare`); + const createResponse = createCloudflareCustomHostname(newCname, log); + if (createResponse) log(`Cloudflare API call completed for "${newCname}" - frontend will poll for health`); + } const sanitized = removeEmptyKeys({ subdomain, version, @@ -385,45 +430,6 @@ const HandleMigrateRegions = (e) => { log(`Migrated regions`); }; -//#endregion -//#region src/lib/handlers/instance/model/HandleInstanceBeforeUpdate.ts -const HandleInstanceBeforeUpdate = (e) => { - const dao = e.dao || $app.dao(); - const log = mkLog(`instances-validate-before-update`); - const id = e.model.getId(); - const version = e.model.get("version"); - if (!versions.includes(version)) { - const msg = `[ERROR] Invalid version '${version}' for [${id}]. Version must be one of: ${versions.join(", ")}`; - log(`${msg}`); - throw new BadRequestError(msg); - } - const cname = e.model.get("cname"); - if (cname.length > 0) { - const result = new DynamicModel({ id: "" }); - const inUse = (() => { - try { - dao.db().newQuery(`select id from instances where cname='${cname}' and id <> '${id}'`).one(result); - } catch (e$1) { - return false; - } - return true; - })(); - if (inUse) { - const msg = `[ERROR] [${id}] Custom domain ${cname} already in use.`; - log(`${msg}`); - throw new BadRequestError(msg); - } - } -}; - -//#endregion -//#region src/lib/handlers/instance/model/HandleInstanceVersionValidation.ts -const HandleInstanceVersionValidation = (e) => { - const dao = e.dao || $app.dao(); - const version = e.model.get("version"); - if (!versions.includes(version)) throw new BadRequestError(`Invalid version ${version}. Version must be one of: ${versions.join(", ")}`); -}; - //#endregion //#region src/lib/util/mkAudit.ts const mkAudit = (log, dao) => { @@ -439,8 +445,8 @@ const mkAudit = (log, dao) => { }; //#endregion -//#region src/lib/handlers/instance/model/HandleNotifyDiscordAfterCreate.ts -const HandleNotifyDiscordAfterCreate = (e) => { +//#region src/lib/handlers/instance/model/AfterCreate_notify_discord.ts +const AfterCreate_notify_discord = (e) => { const dao = e.dao || $app.dao(); const log = mkLog(`instances:create:discord:notify`); const audit = mkAudit(log, dao); @@ -460,6 +466,46 @@ const HandleNotifyDiscordAfterCreate = (e) => { } }; +//#endregion +//#region src/lib/handlers/instance/model/BeforeUpdate_cname.ts +const BeforeUpdate_cname = (e) => { + const dao = e.dao || $app.dao(); + const log = mkLog(`BeforeUpdate_cname`); + const id = e.model.getId(); + const newCname = e.model.get("cname").trim(); + if (newCname.length > 0) { + const result = new DynamicModel({ id: "" }); + const inUse = (() => { + try { + dao.db().newQuery(`select id from instances where cname='${newCname}' and id <> '${id}'`).one(result); + } catch (e$1) { + return false; + } + return true; + })(); + if (inUse) { + const msg = `[ERROR] [${id}] Custom domain ${newCname} already in use.`; + log(`${msg}`); + throw new BadRequestError(msg); + } + } + log(`CNAME validation passed for: "${newCname}"`); +}; + +//#endregion +//#region src/lib/handlers/instance/model/BeforeUpdate_version.ts +const BeforeUpdate_version = (e) => { + const dao = e.dao || $app.dao(); + const log = mkLog(`BeforeUpdate_version`); + const version = e.model.get("version"); + log(`Validating version ${version}`); + if (!versions.includes(version)) { + const msg = `Invalid version ${version}. Version must be one of: ${versions.join(", ")}`; + log(`[ERROR] ${msg}`); + throw new BadRequestError(msg); + } +}; + //#endregion //#region src/lib/util/mkNotifier.ts const mkNotifier = (log, dao) => (channel, template, user_id, context = {}) => { @@ -3088,12 +3134,13 @@ const HandleVersionsRequest = (c) => { }; //#endregion -exports.HandleInstanceBeforeUpdate = HandleInstanceBeforeUpdate; +exports.AfterCreate_notify_discord = AfterCreate_notify_discord; +exports.BeforeUpdate_cname = BeforeUpdate_cname; +exports.BeforeUpdate_version = BeforeUpdate_version; exports.HandleInstanceCreate = HandleInstanceCreate; exports.HandleInstanceDelete = HandleInstanceDelete; exports.HandleInstanceResolve = HandleInstanceResolve; exports.HandleInstanceUpdate = HandleInstanceUpdate; -exports.HandleInstanceVersionValidation = HandleInstanceVersionValidation; exports.HandleInstancesResetIdle = HandleInstancesResetIdle; exports.HandleLemonSqueezySale = HandleLemonSqueezySale; exports.HandleMailSend = HandleMailSend; @@ -3102,7 +3149,6 @@ exports.HandleMigrateCnamesToDomains = HandleMigrateCnamesToDomains; exports.HandleMigrateInstanceVersions = HandleMigrateInstanceVersions; exports.HandleMigrateRegions = HandleMigrateRegions; exports.HandleMirrorData = HandleMirrorData; -exports.HandleNotifyDiscordAfterCreate = HandleNotifyDiscordAfterCreate; exports.HandleProcessNotification = HandleProcessNotification; exports.HandleProcessSingleNotification = HandleProcessSingleNotification; exports.HandleSesError = HandleSesError; diff --git a/packages/pockethost/src/mothership-app/pb_hooks/mothership.pb.js b/packages/pockethost/src/mothership-app/pb_hooks/mothership.pb.js index 414eb0f4..fa6ccca7 100644 --- a/packages/pockethost/src/mothership-app/pb_hooks/mothership.pb.js +++ b/packages/pockethost/src/mothership-app/pb_hooks/mothership.pb.js @@ -13,24 +13,20 @@ routerAdd("GET", "/api/instance/resolve", (c) => { return require(`${__hooks}/mothership`).HandleInstanceResolve(c); }, $apis.requireAdminAuth()); /** Validate instance version */ -onModelBeforeCreate((e) => { - return require(`${__hooks}/mothership`).HandleInstanceVersionValidation(e); +onModelBeforeUpdate((e) => { + return require(`${__hooks}/mothership`).BeforeUpdate_version(e); }, "instances"); -onModelAfterCreate((e) => {}, "instances"); +/** Validate cname */ +onModelBeforeUpdate((e) => { + return require(`${__hooks}/mothership`).BeforeUpdate_cname(e); +}, "instances"); +/** Notify discord on instance create */ onAfterBootstrap((e) => {}); onAfterBootstrap((e) => {}); /** Reset instance status to idle on start */ onAfterBootstrap((e) => { return require(`${__hooks}/mothership`).HandleInstancesResetIdle(e); }); -/** Migrate existing cnames to domains table */ -onAfterBootstrap((e) => { - return require(`${__hooks}/mothership`).HandleMigrateCnamesToDomains(e); -}); -/** Validate instance version */ -onModelBeforeUpdate((e) => { - return require(`${__hooks}/mothership`).HandleInstanceBeforeUpdate(e); -}, "instances"); //#endregion //#region src/lib/handlers/lemon/hooks.ts diff --git a/packages/pockethost/src/mothership-app/src/lib/handlers/instance/api/HandleInstanceUpdate.ts b/packages/pockethost/src/mothership-app/src/lib/handlers/instance/api/HandleInstanceUpdate.ts index 976d9b67..dfa6de80 100644 --- a/packages/pockethost/src/mothership-app/src/lib/handlers/instance/api/HandleInstanceUpdate.ts +++ b/packages/pockethost/src/mothership-app/src/lib/handlers/instance/api/HandleInstanceUpdate.ts @@ -1,6 +1,62 @@ import { mkLog, StringKvLookup } from '$util/Logger' import { removeEmptyKeys } from '$util/removeEmptyKeys' +// Helper function to make Cloudflare API calls +const callCloudflareAPI = (endpoint: string, method: string, body?: any, log?: any) => { + const apiToken = $os.getenv('MOTHERSHIP_CLOUDFLARE_API_TOKEN') + const zoneId = $os.getenv('MOTHERSHIP_CLOUDFLARE_ZONE_ID') + + if (!apiToken || !zoneId) { + if (log) log('Cloudflare API credentials not configured - skipping Cloudflare operations') + return null + } + + const url = `https://api.cloudflare.com/client/v4/zones/${zoneId}/${endpoint}` + + try { + const config: any = { + url: url, + method: method, + headers: { + Authorization: `Bearer ${apiToken}`, + 'Content-Type': 'application/json', + }, + timeout: 30, + } + + if (body) { + config.body = JSON.stringify(body) + } + + if (log) log(`Making Cloudflare API call: ${method} ${url}`, config) + + const response = $http.send(config) + + if (log) log(`Cloudflare API response:`, response) + + return response + } catch (error) { + if (log) log(`Cloudflare API error:`, error) + return null + } +} + +// Helper to create custom hostname in Cloudflare +const createCloudflareCustomHostname = (hostname: string, log: any) => { + return callCloudflareAPI( + 'custom_hostnames', + 'POST', + { + hostname: hostname, + ssl: { + method: 'http', + type: 'dv', + }, + }, + log + ) +} + export const HandleInstanceUpdate = (c: echo.Context) => { const dao = $app.dao() const log = mkLog(`PUT:instance`) @@ -67,6 +123,21 @@ export const HandleInstanceUpdate = (c: echo.Context) => { throw new BadRequestError(`Not authorized`) } + // Check if CNAME changed and handle Cloudflare + const oldCname = record.getString('cname').trim() + const newCname = cname ? cname.trim() : '' + const cnameChanged = oldCname !== newCname + + if (cnameChanged && newCname) { + log(`CNAME changed from "${oldCname}" to "${newCname}" - adding to Cloudflare`) + + // Blindly add to Cloudflare + const createResponse = createCloudflareCustomHostname(newCname, log) + if (createResponse) { + log(`Cloudflare API call completed for "${newCname}" - frontend will poll for health`) + } + } + const sanitized = removeEmptyKeys({ subdomain, version, diff --git a/packages/pockethost/src/mothership-app/src/lib/handlers/instance/hooks.ts b/packages/pockethost/src/mothership-app/src/lib/handlers/instance/hooks.ts index b6da8431..05d77876 100644 --- a/packages/pockethost/src/mothership-app/src/lib/handlers/instance/hooks.ts +++ b/packages/pockethost/src/mothership-app/src/lib/handlers/instance/hooks.ts @@ -31,14 +31,20 @@ routerAdd( $apis.requireAdminAuth() ) /** Validate instance version */ -onModelBeforeCreate((e) => { - return require(`${__hooks}/mothership`).HandleInstanceVersionValidation(e) +onModelBeforeUpdate((e) => { + return require(`${__hooks}/mothership`).BeforeUpdate_version(e) }, 'instances') -onModelAfterCreate((e) => { - // return require(`${__hooks}/mothership`).HandleNotifyDiscordAfterCreate(e) +/** Validate cname */ +onModelBeforeUpdate((e) => { + return require(`${__hooks}/mothership`).BeforeUpdate_cname(e) }, 'instances') +/** Notify discord on instance create */ +// onModelAfterCreate((e) => { +// return require(`${__hooks}/mothership`).AfterCreate_notify_discord(e) +// }, 'instances') + onAfterBootstrap((e) => { // return require(`${__hooks}/mothership`).HandleMigrateInstanceVersions(e) }) @@ -51,13 +57,3 @@ onAfterBootstrap((e) => { onAfterBootstrap((e) => { return require(`${__hooks}/mothership`).HandleInstancesResetIdle(e) }) - -/** Migrate existing cnames to domains table */ -onAfterBootstrap((e) => { - return require(`${__hooks}/mothership`).HandleMigrateCnamesToDomains(e) -}) - -/** Validate instance version */ -onModelBeforeUpdate((e) => { - return require(`${__hooks}/mothership`).HandleInstanceBeforeUpdate(e) -}, 'instances') diff --git a/packages/pockethost/src/mothership-app/src/lib/handlers/instance/index.ts b/packages/pockethost/src/mothership-app/src/lib/handlers/instance/index.ts index 910ef2f7..db8b3fa8 100644 --- a/packages/pockethost/src/mothership-app/src/lib/handlers/instance/index.ts +++ b/packages/pockethost/src/mothership-app/src/lib/handlers/instance/index.ts @@ -6,6 +6,6 @@ export * from './bootstrap/HandleInstancesResetIdle' export * from './bootstrap/HandleMigrateCnamesToDomains' export * from './bootstrap/HandleMigrateInstanceVersions' export * from './bootstrap/HandleMigrateRegions' -export * from './model/HandleInstanceBeforeUpdate' -export * from './model/HandleInstanceVersionValidation' -export * from './model/HandleNotifyDiscordAfterCreate' +export * from './model/AfterCreate_notify_discord' +export * from './model/BeforeUpdate_cname' +export * from './model/BeforeUpdate_version' diff --git a/packages/pockethost/src/mothership-app/src/lib/handlers/instance/model/HandleNotifyDiscordAfterCreate.ts b/packages/pockethost/src/mothership-app/src/lib/handlers/instance/model/AfterCreate_notify_discord.ts similarity index 91% rename from packages/pockethost/src/mothership-app/src/lib/handlers/instance/model/HandleNotifyDiscordAfterCreate.ts rename to packages/pockethost/src/mothership-app/src/lib/handlers/instance/model/AfterCreate_notify_discord.ts index 7d51daad..4bd7d91f 100644 --- a/packages/pockethost/src/mothership-app/src/lib/handlers/instance/model/HandleNotifyDiscordAfterCreate.ts +++ b/packages/pockethost/src/mothership-app/src/lib/handlers/instance/model/AfterCreate_notify_discord.ts @@ -1,7 +1,7 @@ import { mkLog } from '$util/Logger' import { mkAudit } from '$util/mkAudit' -export const HandleNotifyDiscordAfterCreate = (e: core.ModelEvent) => { +export const AfterCreate_notify_discord = (e: core.ModelEvent) => { const dao = e.dao || $app.dao() const log = mkLog(`instances:create:discord:notify`) diff --git a/packages/pockethost/src/mothership-app/src/lib/handlers/instance/model/BeforeUpdate_cname.ts b/packages/pockethost/src/mothership-app/src/lib/handlers/instance/model/BeforeUpdate_cname.ts new file mode 100644 index 00000000..3034f40a --- /dev/null +++ b/packages/pockethost/src/mothership-app/src/lib/handlers/instance/model/BeforeUpdate_cname.ts @@ -0,0 +1,32 @@ +import { mkLog } from '$util/Logger' + +export const BeforeUpdate_cname = (e: core.ModelEvent) => { + const dao = e.dao || $app.dao() + const log = mkLog(`BeforeUpdate_cname`) + const id = e.model.getId() + const newCname = e.model.get('cname').trim() + + // Only check if cname is already in use locally + if (newCname.length > 0) { + const result = new DynamicModel({ + id: '', + }) + + const inUse = (() => { + try { + dao.db().newQuery(`select id from instances where cname='${newCname}' and id <> '${id}'`).one(result) + } catch (e) { + return false + } + return true + })() + + if (inUse) { + const msg = `[ERROR] [${id}] Custom domain ${newCname} already in use.` + log(`${msg}`) + throw new BadRequestError(msg) + } + } + + log(`CNAME validation passed for: "${newCname}"`) +} diff --git a/packages/pockethost/src/mothership-app/src/lib/handlers/instance/model/BeforeUpdate_version.ts b/packages/pockethost/src/mothership-app/src/lib/handlers/instance/model/BeforeUpdate_version.ts new file mode 100644 index 00000000..2eaaa23f --- /dev/null +++ b/packages/pockethost/src/mothership-app/src/lib/handlers/instance/model/BeforeUpdate_version.ts @@ -0,0 +1,16 @@ +import { mkLog } from '$util/Logger' +import { versions } from '$util/versions' + +export const BeforeUpdate_version = (e: core.ModelEvent) => { + const dao = e.dao || $app.dao() + + const log = mkLog(`BeforeUpdate_version`) + + const version = e.model.get('version') + log(`Validating version ${version}`) + if (!versions.includes(version)) { + const msg = `Invalid version ${version}. Version must be one of: ${versions.join(', ')}` + log(`[ERROR] ${msg}`) + throw new BadRequestError(msg) + } +} diff --git a/packages/pockethost/src/mothership-app/src/lib/handlers/instance/model/HandleInstanceBeforeUpdate.ts b/packages/pockethost/src/mothership-app/src/lib/handlers/instance/model/HandleInstanceBeforeUpdate.ts deleted file mode 100644 index 6d2c4c2d..00000000 --- a/packages/pockethost/src/mothership-app/src/lib/handlers/instance/model/HandleInstanceBeforeUpdate.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { mkLog } from '$util/Logger' -import { versions } from '$util/versions' - -export const HandleInstanceBeforeUpdate = (e: core.ModelEvent) => { - const dao = e.dao || $app.dao() - - const log = mkLog(`instances-validate-before-update`) - - const id = e.model.getId() - - const version = e.model.get('version') - if (!versions.includes(version)) { - const msg = `[ERROR] Invalid version '${version}' for [${id}]. Version must be one of: ${versions.join(', ')}` - log(`${msg}`) - throw new BadRequestError(msg) - } - - const cname = e.model.get('cname') - if (cname.length > 0) { - const result = new DynamicModel({ - id: '', - }) - - const inUse = (() => { - try { - dao.db().newQuery(`select id from instances where cname='${cname}' and id <> '${id}'`).one(result) - } catch (e) { - // log(` cname OK ${cname}`) - return false - } - return true - })() - - if (inUse) { - const msg = `[ERROR] [${id}] Custom domain ${cname} already in use.` - log(`${msg}`) - throw new BadRequestError(msg) - } - } -} diff --git a/packages/pockethost/src/mothership-app/src/lib/handlers/instance/model/HandleInstanceVersionValidation.ts b/packages/pockethost/src/mothership-app/src/lib/handlers/instance/model/HandleInstanceVersionValidation.ts deleted file mode 100644 index 0b7bb3d3..00000000 --- a/packages/pockethost/src/mothership-app/src/lib/handlers/instance/model/HandleInstanceVersionValidation.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { versions } from '$util/versions' - -export const HandleInstanceVersionValidation = (e: core.ModelEvent) => { - const dao = e.dao || $app.dao() - - const version = e.model.get('version') - if (!versions.includes(version)) { - throw new BadRequestError(`Invalid version ${version}. Version must be one of: ${versions.join(', ')}`) - } -}