feat(federation): add grace period finalization and auto-rotation to health worker (FED-011)
This commit is contained in:
@@ -1,10 +1,10 @@
|
|||||||
import { getDb } from '../db/index.js';
|
import { getDb } from '../db/index.js';
|
||||||
import * as schema from '../db/schema.js';
|
import * as schema from '../db/schema.js';
|
||||||
import { eq, and, lte, asc, inArray } from 'drizzle-orm';
|
import { eq, and, lte, asc, inArray, sql } from 'drizzle-orm';
|
||||||
import { config } from '../config.js';
|
import { config } from '../config.js';
|
||||||
import { isFederationRelayEnabled, queueOutboxEvent } from './federationOutbox.js';
|
import { isFederationRelayEnabled, queueOutboxEvent } from './federationOutbox.js';
|
||||||
import { runFederationJanitor } from './storageJanitor.js';
|
import { runFederationJanitor } from './storageJanitor.js';
|
||||||
import { buildFederationHeaders, getOurOrigin } from './federationAuth.js';
|
import { buildFederationHeaders, getOurOrigin, generateHmacSecret, ROTATION_GRACE_PERIOD_MS } from './federationAuth.js';
|
||||||
import { generateSnowflake } from './snowflake.js';
|
import { generateSnowflake } from './snowflake.js';
|
||||||
import { getDmMessageWithUser } from '../routes/dm.js';
|
import { getDmMessageWithUser } from '../routes/dm.js';
|
||||||
import { connectionManager } from '../ws/handler.js';
|
import { connectionManager } from '../ws/handler.js';
|
||||||
@@ -680,6 +680,100 @@ function scheduleHealthCheckTick(): void {
|
|||||||
async function processHealthCheckTick(): Promise<void> {
|
async function processHealthCheckTick(): Promise<void> {
|
||||||
const db = getDb();
|
const db = getDb();
|
||||||
|
|
||||||
|
// ── Grace period finalization ──────────────────────────────────────────────
|
||||||
|
// Promote pending secrets that have completed their grace period.
|
||||||
|
const rotatingPeers = db
|
||||||
|
.select()
|
||||||
|
.from(schema.federationPeers)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
sql`${schema.federationPeers.pendingHmacSecret} IS NOT NULL`,
|
||||||
|
sql`${schema.federationPeers.secretRotationAt} IS NOT NULL`,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.all();
|
||||||
|
|
||||||
|
for (const peer of rotatingPeers) {
|
||||||
|
const elapsed = Date.now() - (peer.secretRotationAt ?? 0);
|
||||||
|
if (elapsed > ROTATION_GRACE_PERIOD_MS) {
|
||||||
|
db.update(schema.federationPeers)
|
||||||
|
.set({
|
||||||
|
hmacSecret: peer.pendingHmacSecret!,
|
||||||
|
pendingHmacSecret: null,
|
||||||
|
secretRotationAt: null,
|
||||||
|
secretRotatedAt: Date.now(),
|
||||||
|
})
|
||||||
|
.where(eq(schema.federationPeers.id, peer.id))
|
||||||
|
.run();
|
||||||
|
console.log(`[federation-worker] Secret rotation finalized for peer ${peer.origin}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Auto-rotation ──────────────────────────────────────────────────────────
|
||||||
|
// Initiate rotation for active peers whose secret has aged past the threshold.
|
||||||
|
const autoRotateCandidates = db
|
||||||
|
.select()
|
||||||
|
.from(schema.federationPeers)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(schema.federationPeers.status, 'active'),
|
||||||
|
sql`${schema.federationPeers.pendingHmacSecret} IS NULL`,
|
||||||
|
sql`${schema.federationPeers.autoRotateIntervalDays} > 0`,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.all();
|
||||||
|
|
||||||
|
const ourOrigin = getOurOrigin();
|
||||||
|
|
||||||
|
for (const peer of autoRotateCandidates) {
|
||||||
|
const lastRotation = peer.secretRotatedAt ?? peer.createdAt;
|
||||||
|
const intervalMs = peer.autoRotateIntervalDays * 86_400_000;
|
||||||
|
if (Date.now() - lastRotation < intervalMs) continue;
|
||||||
|
|
||||||
|
// Time to rotate
|
||||||
|
const newSecret = generateHmacSecret();
|
||||||
|
|
||||||
|
// Store pending locally first
|
||||||
|
db.update(schema.federationPeers)
|
||||||
|
.set({
|
||||||
|
pendingHmacSecret: newSecret,
|
||||||
|
secretRotationAt: Date.now(),
|
||||||
|
})
|
||||||
|
.where(eq(schema.federationPeers.id, peer.id))
|
||||||
|
.run();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const rotateBody = JSON.stringify({ newSecret });
|
||||||
|
const headers = buildFederationHeaders(rotateBody, peer.hmacSecret, ourOrigin);
|
||||||
|
|
||||||
|
const response = await fetch(`${peer.origin}/api/federation/peer/rotate`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers,
|
||||||
|
body: rotateBody,
|
||||||
|
signal: AbortSignal.timeout(10_000),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
console.log(`[federation-worker] Auto-rotation initiated with peer ${peer.origin}`);
|
||||||
|
} else {
|
||||||
|
// Rollback
|
||||||
|
db.update(schema.federationPeers)
|
||||||
|
.set({ pendingHmacSecret: null, secretRotationAt: null })
|
||||||
|
.where(eq(schema.federationPeers.id, peer.id))
|
||||||
|
.run();
|
||||||
|
console.warn(`[federation-worker] Auto-rotation rejected by peer ${peer.origin} (HTTP ${response.status})`);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
// Rollback
|
||||||
|
db.update(schema.federationPeers)
|
||||||
|
.set({ pendingHmacSecret: null, secretRotationAt: null })
|
||||||
|
.where(eq(schema.federationPeers.id, peer.id))
|
||||||
|
.run();
|
||||||
|
const message = err instanceof Error ? err.message : 'Unknown error';
|
||||||
|
console.warn(`[federation-worker] Auto-rotation failed for peer ${peer.origin}: ${message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const unreachablePeers = db
|
const unreachablePeers = db
|
||||||
.select()
|
.select()
|
||||||
.from(schema.federationPeers)
|
.from(schema.federationPeers)
|
||||||
|
|||||||
Reference in New Issue
Block a user