feat(mothership): add cloudflare custom domain updater and remove HandleMigrateCnamesToDomains

This commit is contained in:
Ben Allfree 2025-07-19 06:58:10 -07:00
parent 9c81a2923e
commit 13aa539e8a
10 changed files with 231 additions and 124 deletions

View File

@ -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;

View File

@ -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

View File

@ -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,

View File

@ -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')

View File

@ -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'

View File

@ -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`)

View File

@ -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}"`)
}

View File

@ -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)
}
}

View File

@ -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)
}
}
}

View File

@ -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(', ')}`)
}
}