305 lines
10 KiB
TypeScript
305 lines
10 KiB
TypeScript
/**
|
|
* dcrouter migration runner.
|
|
*
|
|
* Uses @push.rocks/smartmigration via dynamic import so smartmigration's type
|
|
* chain (which pulls in mongodb 7.x and related types) doesn't leak into
|
|
* compile-time type checking for this folder.
|
|
*/
|
|
|
|
/** Matches the subset of IMigrationRunResult we actually log. */
|
|
export interface IMigrationRunResult {
|
|
stepsApplied: Array<unknown>;
|
|
wasFreshInstall: boolean;
|
|
currentVersionBefore: string | null;
|
|
currentVersionAfter: string;
|
|
totalDurationMs: number;
|
|
}
|
|
|
|
export interface IMigrationRunner {
|
|
run(): Promise<IMigrationRunResult>;
|
|
}
|
|
|
|
type TMigrationSecurity = Record<string, any>;
|
|
|
|
function mergeMigrationSecurityFields(
|
|
base: TMigrationSecurity | undefined,
|
|
override: TMigrationSecurity | undefined,
|
|
): TMigrationSecurity {
|
|
if (!base && !override) return {};
|
|
if (!base) return structuredClone(override || {});
|
|
if (!override) return structuredClone(base || {});
|
|
|
|
const merged: TMigrationSecurity = structuredClone(base);
|
|
|
|
if (override.ipAllowList || base.ipAllowList) {
|
|
merged.ipAllowList = [
|
|
...new Set([
|
|
...(base.ipAllowList || []),
|
|
...(override.ipAllowList || []),
|
|
]),
|
|
];
|
|
}
|
|
|
|
if (override.ipBlockList || base.ipBlockList) {
|
|
merged.ipBlockList = [
|
|
...new Set([
|
|
...(base.ipBlockList || []),
|
|
...(override.ipBlockList || []),
|
|
]),
|
|
];
|
|
}
|
|
|
|
for (const key of ['maxConnections', 'rateLimit', 'authentication', 'basicAuth', 'jwtAuth', 'vpn']) {
|
|
if (override[key] !== undefined) {
|
|
merged[key] = structuredClone(override[key]);
|
|
}
|
|
}
|
|
|
|
return merged;
|
|
}
|
|
|
|
function resolveMigrationSourceProfileSecurity(
|
|
profileId: string,
|
|
profiles: Map<string, any>,
|
|
visited = new Set<string>(),
|
|
depth = 0,
|
|
): TMigrationSecurity | null {
|
|
if (depth > 5 || visited.has(profileId)) return null;
|
|
|
|
const profile = profiles.get(profileId);
|
|
if (!profile) return null;
|
|
|
|
visited.add(profileId);
|
|
let baseSecurity: TMigrationSecurity = {};
|
|
const extendsProfiles = Array.isArray(profile.extendsProfiles) ? profile.extendsProfiles : [];
|
|
for (const parentId of extendsProfiles) {
|
|
if (typeof parentId !== 'string') continue;
|
|
const parentSecurity = resolveMigrationSourceProfileSecurity(
|
|
parentId,
|
|
profiles,
|
|
new Set(visited),
|
|
depth + 1,
|
|
);
|
|
if (parentSecurity) {
|
|
baseSecurity = mergeMigrationSecurityFields(baseSecurity, parentSecurity);
|
|
}
|
|
}
|
|
|
|
return mergeMigrationSecurityFields(baseSecurity, profile.security || {});
|
|
}
|
|
|
|
async function rematerializeSourceProfileRouteSecurity(ctx: {
|
|
mongo?: { collection: (name: string) => any };
|
|
log: { log: (level: 'info', message: string) => void };
|
|
}): Promise<void> {
|
|
const profileCollection = ctx.mongo!.collection('SourceProfileDoc');
|
|
const routeCollection = ctx.mongo!.collection('RouteDoc');
|
|
const profiles = new Map<string, any>();
|
|
|
|
for await (const profile of profileCollection.find({})) {
|
|
if (typeof (profile as any).id === 'string') {
|
|
profiles.set((profile as any).id, profile);
|
|
}
|
|
}
|
|
|
|
let inspected = 0;
|
|
let migrated = 0;
|
|
let skippedMissingProfile = 0;
|
|
const now = Date.now();
|
|
|
|
for await (const routeDoc of routeCollection.find({})) {
|
|
const sourceProfileRef = (routeDoc as any).metadata?.sourceProfileRef;
|
|
if (typeof sourceProfileRef !== 'string' || sourceProfileRef.trim() === '') continue;
|
|
inspected++;
|
|
|
|
const resolvedSecurity = resolveMigrationSourceProfileSecurity(sourceProfileRef, profiles);
|
|
const profile = profiles.get(sourceProfileRef);
|
|
if (!resolvedSecurity || !profile) {
|
|
skippedMissingProfile++;
|
|
continue;
|
|
}
|
|
|
|
const currentSecurity = (routeDoc as any).route?.security || {};
|
|
const securityChanged = JSON.stringify(currentSecurity) !== JSON.stringify(resolvedSecurity);
|
|
const profileNameChanged = (routeDoc as any).metadata?.sourceProfileName !== profile.name;
|
|
if (!securityChanged && !profileNameChanged) continue;
|
|
|
|
const query = (routeDoc as any)._id
|
|
? { _id: (routeDoc as any)._id }
|
|
: { id: (routeDoc as any).id };
|
|
await routeCollection.updateOne(query, {
|
|
$set: {
|
|
'route.security': structuredClone(resolvedSecurity),
|
|
'metadata.sourceProfileName': profile.name,
|
|
'metadata.lastResolvedAt': now,
|
|
updatedAt: now,
|
|
},
|
|
});
|
|
migrated++;
|
|
}
|
|
|
|
ctx.log.log(
|
|
'info',
|
|
`rematerialize-source-profile-route-security: migrated ${migrated}/${inspected} route(s), skipped ${skippedMissingProfile} missing profile ref(s)`,
|
|
);
|
|
}
|
|
|
|
async function migrateTargetProfileTargetHosts(ctx: {
|
|
mongo?: { collection: (name: string) => any };
|
|
log: { log: (level: 'info', message: string) => void };
|
|
}): Promise<void> {
|
|
const collection = ctx.mongo!.collection('TargetProfileDoc');
|
|
const cursor = collection.find({ 'targets.host': { $exists: true } });
|
|
let migrated = 0;
|
|
|
|
for await (const doc of cursor) {
|
|
const targets = ((doc as any).targets || []).map((target: any) => {
|
|
if (target && typeof target === 'object' && 'host' in target && !('ip' in target)) {
|
|
const { host, ...rest } = target;
|
|
return { ...rest, ip: host };
|
|
}
|
|
return target;
|
|
});
|
|
|
|
await collection.updateOne({ _id: (doc as any)._id }, { $set: { targets } });
|
|
migrated++;
|
|
}
|
|
|
|
ctx.log.log('info', `rename-target-profile-host-to-ip: migrated ${migrated} profile(s)`);
|
|
}
|
|
|
|
async function backfillSystemRouteKeys(ctx: {
|
|
mongo?: { collection: (name: string) => any };
|
|
log: { log: (level: 'info', message: string) => void };
|
|
}): Promise<void> {
|
|
const collection = ctx.mongo!.collection('RouteDoc');
|
|
const cursor = collection.find({
|
|
origin: { $in: ['config', 'email', 'dns'] },
|
|
systemKey: { $exists: false },
|
|
'route.name': { $type: 'string' },
|
|
});
|
|
let migrated = 0;
|
|
|
|
for await (const doc of cursor) {
|
|
const origin = typeof (doc as any).origin === 'string' ? (doc as any).origin : undefined;
|
|
const routeName = typeof (doc as any).route?.name === 'string' ? (doc as any).route.name.trim() : '';
|
|
if (!origin || !routeName) continue;
|
|
|
|
await collection.updateOne(
|
|
{ _id: (doc as any)._id },
|
|
{ $set: { systemKey: `${origin}:${routeName}` } },
|
|
);
|
|
migrated++;
|
|
}
|
|
|
|
ctx.log.log('info', `backfill-system-route-keys: migrated ${migrated} route(s)`);
|
|
}
|
|
|
|
/**
|
|
* Create a configured SmartMigration runner with all dcrouter migration steps registered.
|
|
*
|
|
* Call `.run()` on the returned instance at startup (after DcRouterDb is ready,
|
|
* before any service that reads migrated collections).
|
|
*
|
|
* @param db - The initialized SmartdataDb instance from DcRouterDb.getDb()
|
|
* @param targetVersion - The current app version (from commitinfo.version)
|
|
*/
|
|
export async function createMigrationRunner(
|
|
db: unknown,
|
|
targetVersion: string,
|
|
): Promise<IMigrationRunner> {
|
|
const sm = await import('@push.rocks/smartmigration');
|
|
const migration = new sm.SmartMigration({
|
|
targetVersion,
|
|
db: db as any,
|
|
// Brand-new installs skip all migrations and stamp directly to the current version.
|
|
freshInstallVersion: targetVersion,
|
|
// dcrouter uses the package version as targetVersion; bridge releases without DB changes.
|
|
targetVersionStrategy: 'bridge',
|
|
});
|
|
|
|
// Register steps in execution order. Each step's .from() must match the
|
|
// previous step's .to() to form a contiguous chain.
|
|
migration
|
|
.step('rename-target-profile-host-to-ip')
|
|
.from('13.0.11').to('13.1.0')
|
|
.description('Rename ITargetProfileTarget.host → ip on all target profiles')
|
|
.up(async (ctx) => migrateTargetProfileTargetHosts(ctx))
|
|
.step('rename-domain-source-manual-to-dcrouter')
|
|
.from('13.1.0').to('13.8.1')
|
|
.description('Rename DomainDoc.source value from "manual" to "dcrouter"')
|
|
.up(async (ctx) => {
|
|
const collection = ctx.mongo!.collection('domaindoc');
|
|
const result = await collection.updateMany(
|
|
{ source: 'manual' },
|
|
{ $set: { source: 'dcrouter' } },
|
|
);
|
|
ctx.log.log(
|
|
'info',
|
|
`rename-domain-source-manual-to-dcrouter: migrated ${result.modifiedCount} domain(s)`,
|
|
);
|
|
})
|
|
.step('rename-record-source-manual-to-local')
|
|
.from('13.8.1').to('13.8.2')
|
|
.description('Rename DnsRecordDoc.source value from "manual" to "local"')
|
|
.up(async (ctx) => {
|
|
const collection = ctx.mongo!.collection('dnsrecorddoc');
|
|
const result = await collection.updateMany(
|
|
{ source: 'manual' },
|
|
{ $set: { source: 'local' } },
|
|
);
|
|
ctx.log.log(
|
|
'info',
|
|
`rename-record-source-manual-to-local: migrated ${result.modifiedCount} record(s)`,
|
|
);
|
|
})
|
|
.step('unify-routes-rename-collection')
|
|
.from('13.8.2').to('13.16.0')
|
|
.description('Rename StoredRouteDoc → RouteDoc, add origin field, drop RouteOverrideDoc')
|
|
.up(async (ctx) => {
|
|
const db = ctx.mongo!;
|
|
|
|
// 1. Rename StoredRouteDoc → RouteDoc (smartdata uses exact class names)
|
|
const collections = await db.listCollections({ name: 'StoredRouteDoc' }).toArray();
|
|
if (collections.length > 0) {
|
|
await db.renameCollection('StoredRouteDoc', 'RouteDoc');
|
|
ctx.log.log('info', 'Renamed StoredRouteDoc → RouteDoc');
|
|
}
|
|
|
|
// 2. Set origin='api' on all migrated docs (they were API-created)
|
|
const routeCol = db.collection('RouteDoc');
|
|
const result = await routeCol.updateMany(
|
|
{ origin: { $exists: false } },
|
|
{ $set: { origin: 'api' } },
|
|
);
|
|
ctx.log.log('info', `Set origin='api' on ${result.modifiedCount} migrated route(s)`);
|
|
|
|
// 3. Drop RouteOverrideDoc collection
|
|
const overrideCollections = await db.listCollections({ name: 'RouteOverrideDoc' }).toArray();
|
|
if (overrideCollections.length > 0) {
|
|
await db.collection('RouteOverrideDoc').drop();
|
|
ctx.log.log('info', 'Dropped RouteOverrideDoc collection');
|
|
}
|
|
})
|
|
.step('repair-target-profile-ip-migration')
|
|
.from('13.16.0').to('13.17.4')
|
|
.description('Repair TargetProfileDoc.targets host→ip migration for already-upgraded installs')
|
|
.up(async (ctx) => {
|
|
await migrateTargetProfileTargetHosts(ctx);
|
|
})
|
|
.step('backfill-system-route-keys')
|
|
.from('13.17.4').to('13.18.0')
|
|
.description('Backfill RouteDoc.systemKey for persisted config/email/dns routes')
|
|
.up(async (ctx) => {
|
|
await backfillSystemRouteKeys(ctx);
|
|
})
|
|
.step('rematerialize-source-profile-route-security')
|
|
.from('13.18.0').to('13.40.2')
|
|
.description('Replace stale route security with resolved source profile security')
|
|
.up(async (ctx) => {
|
|
await rematerializeSourceProfileRouteSecurity(ctx);
|
|
});
|
|
|
|
return migration;
|
|
}
|