feat(federation): verify approval token before awaiting_approval → active
Receiver-side defense — closes the trust-bypass class the cheap fix
(4533e36) cannot cover. The /peer/accept handler's awaiting_approval
branch now requires an approvalToken matching the one stored on the
local peer row before promoting to active. Without a match:
- autoAccept=0 falls through to queueApprovalRequest (no bypass; new
approval-request queued, existing awaiting_approval row untouched).
- autoAccept=1 falls back to permissive promotion (no regression vs
prior behavior, since autoAccept=1 would accept any inbound regardless).
Successful match also deletes any stale approval-request row for the
origin to prevent debris accumulation from prior bypass attempts.
Refactor: queueing path extracted to a top-level queueApprovalRequest()
helper so both the no-existing-peer case and the awaiting_approval
mismatch fallback share one implementation. Helper generates a fresh
single-use token on every call.
Adds 'accept_awaiting_approval_fallback' variant to PeerActivationReason
to distinguish the autoAccept=1 fallback path from the verified path
in onPeerActivated audit logs.
Spec §3.5, §3.6.
Test counts: 289 → 301 (+12).
This commit is contained in:
@@ -0,0 +1,345 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import Fastify, { type FastifyInstance } from 'fastify';
|
||||
import Database from 'better-sqlite3';
|
||||
import { drizzle } from 'drizzle-orm/better-sqlite3';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import * as schema from '../db/schema.js';
|
||||
import { setWorkerId } from '../utils/snowflake.js';
|
||||
|
||||
setWorkerId(1);
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
type TestDb = ReturnType<typeof drizzle<typeof schema>>;
|
||||
let sqlite: Database.Database;
|
||||
let testDb: TestDb;
|
||||
|
||||
vi.mock('../db/index.js', () => ({
|
||||
getDb: () => testDb,
|
||||
getRawDb: () => sqlite,
|
||||
schema,
|
||||
}));
|
||||
|
||||
vi.mock('../utils/auth.js', () => ({
|
||||
authenticate: async (req: { userId?: string }) => {
|
||||
req.userId = 'admin-user';
|
||||
},
|
||||
requireAdmin: async () => {},
|
||||
}));
|
||||
|
||||
vi.mock('../ws/handler.js', () => ({
|
||||
connectionManager: {
|
||||
sendToAdmins: vi.fn(),
|
||||
getAllOnlineUserIds: () => [],
|
||||
sendToUser: vi.fn(),
|
||||
sendToDmMembers: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../utils/federationPeerActivation.js', () => ({
|
||||
onPeerActivated: vi.fn(async () => undefined),
|
||||
onPeerDeactivated: vi.fn(async () => undefined),
|
||||
}));
|
||||
|
||||
function applyMigrations(db: Database.Database): void {
|
||||
const migrationsDir = path.resolve(__dirname, '../../drizzle');
|
||||
const files = fs.readdirSync(migrationsDir).filter(f => f.endsWith('.sql')).sort();
|
||||
for (const f of files) {
|
||||
const sqlText = fs.readFileSync(path.join(migrationsDir, f), 'utf8');
|
||||
const statements = sqlText.split(/-->\s*statement-breakpoint/);
|
||||
for (const stmt of statements) {
|
||||
const clean = stmt.trim();
|
||||
if (clean) db.exec(clean);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function seedInstanceSettings(autoAccept: 0 | 1): void {
|
||||
testDb.insert(schema.instanceSettings).values({
|
||||
id: 1,
|
||||
instanceName: 'Local Backspace',
|
||||
autoAcceptPeering: autoAccept,
|
||||
registrationOpen: 1,
|
||||
updatedAt: Date.now(),
|
||||
}).run();
|
||||
}
|
||||
|
||||
async function buildApp(): Promise<FastifyInstance> {
|
||||
const app = Fastify({ logger: false });
|
||||
const { federationRoutes } = await import('./federation.js');
|
||||
await app.register(federationRoutes);
|
||||
await app.ready();
|
||||
return app;
|
||||
}
|
||||
|
||||
describe('POST /api/federation/peer/accept — approval token verification', () => {
|
||||
let app: FastifyInstance;
|
||||
|
||||
beforeEach(async () => {
|
||||
sqlite = new Database(':memory:');
|
||||
testDb = drizzle(sqlite, { schema });
|
||||
applyMigrations(sqlite);
|
||||
});
|
||||
|
||||
async function setupAutoAccept(value: 0 | 1): Promise<void> {
|
||||
seedInstanceSettings(value);
|
||||
app = await buildApp();
|
||||
}
|
||||
|
||||
it('queueing path stores token on peer_approval_requests and returns it in the 202 body (autoAccept=0, no existing peer)', async () => {
|
||||
await setupAutoAccept(0);
|
||||
|
||||
const response = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/federation/peer/accept',
|
||||
payload: {
|
||||
sourceOrigin: 'https://remote.example',
|
||||
hmacSecret: 'remote-secret',
|
||||
instanceName: 'Remote',
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(202);
|
||||
const body = response.json() as { queued: boolean; approvalToken?: string };
|
||||
expect(body.queued).toBe(true);
|
||||
expect(typeof body.approvalToken).toBe('string');
|
||||
expect(body.approvalToken).toMatch(/^[0-9a-f]{64}$/);
|
||||
|
||||
const row = testDb.select().from(schema.peerApprovalRequests)
|
||||
.where(eq(schema.peerApprovalRequests.origin, 'https://remote.example')).get();
|
||||
expect(row?.approvalToken).toBe(body.approvalToken);
|
||||
});
|
||||
|
||||
it('regenerates token on re-handshake (existing approval-request row gets new token)', async () => {
|
||||
await setupAutoAccept(0);
|
||||
|
||||
const now = Date.now();
|
||||
const oldToken = 'old-token-' + 'a'.repeat(53);
|
||||
testDb.insert(schema.peerApprovalRequests).values({
|
||||
id: 'approval-old',
|
||||
origin: 'https://remote.example',
|
||||
instanceName: 'Remote',
|
||||
hmacSecret: 'old-secret',
|
||||
requestedAt: now - 1000,
|
||||
expiresAt: now + 30 * 24 * 60 * 60 * 1000,
|
||||
approvalToken: oldToken,
|
||||
}).run();
|
||||
|
||||
const response = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/federation/peer/accept',
|
||||
payload: {
|
||||
sourceOrigin: 'https://remote.example',
|
||||
hmacSecret: 'fresh-secret',
|
||||
instanceName: 'Remote',
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(202);
|
||||
const body = response.json() as { queued: boolean; approvalToken?: string };
|
||||
expect(body.approvalToken).toMatch(/^[0-9a-f]{64}$/);
|
||||
expect(body.approvalToken).not.toBe(oldToken);
|
||||
|
||||
const row = testDb.select().from(schema.peerApprovalRequests)
|
||||
.where(eq(schema.peerApprovalRequests.origin, 'https://remote.example')).get();
|
||||
expect(row?.approvalToken).toBe(body.approvalToken);
|
||||
expect(row?.hmacSecret).toBe('fresh-secret');
|
||||
});
|
||||
|
||||
it('promotes awaiting_approval → active when token matches (autoAccept=0)', async () => {
|
||||
await setupAutoAccept(0);
|
||||
|
||||
const token = 'a'.repeat(64);
|
||||
testDb.insert(schema.federationPeers).values({
|
||||
id: 'peer-pending',
|
||||
origin: 'https://remote.example',
|
||||
hmacSecret: 'old-secret',
|
||||
status: 'awaiting_approval',
|
||||
approvalToken: token,
|
||||
createdAt: Date.now(),
|
||||
}).run();
|
||||
|
||||
const response = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/federation/peer/accept',
|
||||
payload: {
|
||||
sourceOrigin: 'https://remote.example',
|
||||
hmacSecret: 'fresh-secret',
|
||||
instanceName: 'Remote',
|
||||
approvalToken: token,
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
const peer = testDb.select().from(schema.federationPeers)
|
||||
.where(eq(schema.federationPeers.origin, 'https://remote.example')).get();
|
||||
expect(peer?.status).toBe('active');
|
||||
expect(peer?.hmacSecret).toBe('fresh-secret');
|
||||
expect(peer?.approvalToken).toBeNull();
|
||||
});
|
||||
|
||||
it('on successful match, deletes any stale approval-request row for the same origin', async () => {
|
||||
await setupAutoAccept(0);
|
||||
const token = 'b'.repeat(64);
|
||||
const now = Date.now();
|
||||
testDb.insert(schema.federationPeers).values({
|
||||
id: 'peer-pending',
|
||||
origin: 'https://remote.example',
|
||||
hmacSecret: 'old-secret',
|
||||
status: 'awaiting_approval',
|
||||
approvalToken: token,
|
||||
createdAt: now,
|
||||
}).run();
|
||||
// Stale approval-request from a prior bypass attempt (e.g., bug-prone code path).
|
||||
testDb.insert(schema.peerApprovalRequests).values({
|
||||
id: 'stale-request',
|
||||
origin: 'https://remote.example',
|
||||
instanceName: 'Remote',
|
||||
hmacSecret: 'bypass-secret',
|
||||
requestedAt: now,
|
||||
expiresAt: now + 30 * 24 * 60 * 60 * 1000,
|
||||
approvalToken: 'stale-token',
|
||||
}).run();
|
||||
|
||||
const response = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/federation/peer/accept',
|
||||
payload: {
|
||||
sourceOrigin: 'https://remote.example',
|
||||
hmacSecret: 'fresh-secret',
|
||||
approvalToken: token,
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
const stale = testDb.select().from(schema.peerApprovalRequests)
|
||||
.where(eq(schema.peerApprovalRequests.origin, 'https://remote.example')).get();
|
||||
expect(stale).toBeUndefined();
|
||||
});
|
||||
|
||||
it('autoAccept=0 + missing token → does NOT promote, queues new approval-request', async () => {
|
||||
await setupAutoAccept(0);
|
||||
const token = 'c'.repeat(64);
|
||||
testDb.insert(schema.federationPeers).values({
|
||||
id: 'peer-pending',
|
||||
origin: 'https://remote.example',
|
||||
hmacSecret: 'old-secret',
|
||||
status: 'awaiting_approval',
|
||||
approvalToken: token,
|
||||
createdAt: Date.now(),
|
||||
}).run();
|
||||
|
||||
const response = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/federation/peer/accept',
|
||||
payload: {
|
||||
sourceOrigin: 'https://remote.example',
|
||||
hmacSecret: 'bypass-secret',
|
||||
// no approvalToken
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(202);
|
||||
const body = response.json() as { queued: boolean; approvalToken?: string };
|
||||
expect(body.queued).toBe(true);
|
||||
expect(typeof body.approvalToken).toBe('string');
|
||||
|
||||
// Existing awaiting_approval peer row UNCHANGED.
|
||||
const peer = testDb.select().from(schema.federationPeers)
|
||||
.where(eq(schema.federationPeers.origin, 'https://remote.example')).get();
|
||||
expect(peer?.status).toBe('awaiting_approval');
|
||||
expect(peer?.hmacSecret).toBe('old-secret');
|
||||
expect(peer?.approvalToken).toBe(token);
|
||||
|
||||
// New approval-request row exists with a fresh token (different from the existing peer's).
|
||||
const req = testDb.select().from(schema.peerApprovalRequests)
|
||||
.where(eq(schema.peerApprovalRequests.origin, 'https://remote.example')).get();
|
||||
expect(req?.approvalToken).toBe(body.approvalToken);
|
||||
expect(req?.approvalToken).not.toBe(token);
|
||||
});
|
||||
|
||||
it('autoAccept=0 + mismatched token → behaves the same as missing token (queues)', async () => {
|
||||
await setupAutoAccept(0);
|
||||
testDb.insert(schema.federationPeers).values({
|
||||
id: 'peer-pending',
|
||||
origin: 'https://remote.example',
|
||||
hmacSecret: 'old-secret',
|
||||
status: 'awaiting_approval',
|
||||
approvalToken: 'd'.repeat(64),
|
||||
createdAt: Date.now(),
|
||||
}).run();
|
||||
|
||||
const response = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/federation/peer/accept',
|
||||
payload: {
|
||||
sourceOrigin: 'https://remote.example',
|
||||
hmacSecret: 'bypass-secret',
|
||||
approvalToken: 'e'.repeat(64),
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(202);
|
||||
const peer = testDb.select().from(schema.federationPeers)
|
||||
.where(eq(schema.federationPeers.origin, 'https://remote.example')).get();
|
||||
expect(peer?.status).toBe('awaiting_approval');
|
||||
});
|
||||
|
||||
it('autoAccept=0 + null stored token (legacy) + no inbound token → queues (does not promote)', async () => {
|
||||
await setupAutoAccept(0);
|
||||
testDb.insert(schema.federationPeers).values({
|
||||
id: 'peer-legacy',
|
||||
origin: 'https://remote.example',
|
||||
hmacSecret: 'old-secret',
|
||||
status: 'awaiting_approval',
|
||||
approvalToken: null,
|
||||
createdAt: Date.now(),
|
||||
}).run();
|
||||
|
||||
const response = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/federation/peer/accept',
|
||||
payload: {
|
||||
sourceOrigin: 'https://remote.example',
|
||||
hmacSecret: 'fresh-secret',
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(202);
|
||||
const peer = testDb.select().from(schema.federationPeers)
|
||||
.where(eq(schema.federationPeers.origin, 'https://remote.example')).get();
|
||||
expect(peer?.status).toBe('awaiting_approval');
|
||||
});
|
||||
|
||||
it('autoAccept=1 + missing/mismatched token → fallback promotes (no security regression)', async () => {
|
||||
await setupAutoAccept(1);
|
||||
testDb.insert(schema.federationPeers).values({
|
||||
id: 'peer-pending',
|
||||
origin: 'https://remote.example',
|
||||
hmacSecret: 'old-secret',
|
||||
status: 'awaiting_approval',
|
||||
approvalToken: 'f'.repeat(64),
|
||||
createdAt: Date.now(),
|
||||
}).run();
|
||||
|
||||
const response = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/federation/peer/accept',
|
||||
payload: {
|
||||
sourceOrigin: 'https://remote.example',
|
||||
hmacSecret: 'fresh-secret',
|
||||
// no approvalToken
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
const peer = testDb.select().from(schema.federationPeers)
|
||||
.where(eq(schema.federationPeers.origin, 'https://remote.example')).get();
|
||||
expect(peer?.status).toBe('active');
|
||||
expect(peer?.hmacSecret).toBe('fresh-secret');
|
||||
expect(peer?.approvalToken).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import type { FastifyInstance, FastifyReply } from 'fastify';
|
||||
import { randomBytes } from 'node:crypto';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
@@ -246,6 +246,77 @@ setInterval(() => {
|
||||
}
|
||||
}, ACCEPT_RATE_WINDOW_MS).unref();
|
||||
|
||||
/**
|
||||
* Queue an inbound peer/accept request for local-admin approval.
|
||||
*
|
||||
* Called from `/peer/accept` when:
|
||||
* (a) `autoAcceptPeering=0` and no `pending`/`awaiting_approval` peer row
|
||||
* exists for the source origin (first-contact request from remote), OR
|
||||
* (b) the receiver is in `awaiting_approval` for this origin but the
|
||||
* inbound `/peer/accept` cannot be cryptographically verified
|
||||
* (token absent or mismatched) — see spec §3.5.
|
||||
*
|
||||
* Generates a fresh single-use approval token, upserts the
|
||||
* `peer_approval_requests` row, notifies admins, and returns 202 with the
|
||||
* token in the body. The initiator stores the token alongside its
|
||||
* `awaiting_approval` row so a future `/peer/accept` from this side's
|
||||
* `/approve` endpoint can verify mutual admin approval.
|
||||
*/
|
||||
function queueApprovalRequest(
|
||||
db: ReturnType<typeof getDb>,
|
||||
reply: FastifyReply,
|
||||
sourceOrigin: string,
|
||||
hmacSecret: string,
|
||||
reqInstanceName: string | null,
|
||||
): FastifyReply {
|
||||
const now = Date.now();
|
||||
const THIRTY_DAYS_MS = 30 * 24 * 60 * 60 * 1000;
|
||||
const approvalToken = randomBytes(32).toString('hex');
|
||||
|
||||
const existingRequest = db
|
||||
.select({ id: schema.peerApprovalRequests.id })
|
||||
.from(schema.peerApprovalRequests)
|
||||
.where(eq(schema.peerApprovalRequests.origin, sourceOrigin))
|
||||
.get();
|
||||
|
||||
if (existingRequest) {
|
||||
db.update(schema.peerApprovalRequests)
|
||||
.set({
|
||||
instanceName: reqInstanceName,
|
||||
hmacSecret,
|
||||
requestedAt: now,
|
||||
expiresAt: now + THIRTY_DAYS_MS,
|
||||
approvalToken,
|
||||
})
|
||||
.where(eq(schema.peerApprovalRequests.id, existingRequest.id))
|
||||
.run();
|
||||
} else {
|
||||
db.insert(schema.peerApprovalRequests)
|
||||
.values({
|
||||
id: generateSnowflake(),
|
||||
origin: sourceOrigin,
|
||||
instanceName: reqInstanceName,
|
||||
hmacSecret,
|
||||
requestedAt: now,
|
||||
expiresAt: now + THIRTY_DAYS_MS,
|
||||
approvalToken,
|
||||
})
|
||||
.run();
|
||||
}
|
||||
|
||||
connectionManager.sendToAdmins({
|
||||
type: 'federation_approval_request_received' as const,
|
||||
origin: sourceOrigin,
|
||||
instanceName: reqInstanceName ?? undefined,
|
||||
});
|
||||
|
||||
return reply.code(202).send({
|
||||
queued: true,
|
||||
message: 'Request queued for admin approval',
|
||||
approvalToken,
|
||||
});
|
||||
}
|
||||
|
||||
export async function federationRoutes(app: FastifyInstance): Promise<void> {
|
||||
// ─── POST /api/federation/peer/initiate ────────────────────────────────────
|
||||
// Admin-only: start a peering handshake with a remote instance.
|
||||
@@ -432,7 +503,7 @@ export async function federationRoutes(app: FastifyInstance): Promise<void> {
|
||||
// ─── POST /api/federation/peer/accept ──────────────────────────────────────
|
||||
// Server-to-server: accept a peering request from a remote instance.
|
||||
// No JWT auth — this is first contact. Rate-limited by IP.
|
||||
app.post<{ Body: { sourceOrigin: string; challenge?: string; hmacSecret: string; instanceName?: string } }>(
|
||||
app.post<{ Body: { sourceOrigin: string; challenge?: string; hmacSecret: string; instanceName?: string; approvalToken?: string } }>(
|
||||
'/api/federation/peer/accept',
|
||||
async (request, reply) => {
|
||||
const clientIp = request.ip;
|
||||
@@ -443,7 +514,7 @@ export async function federationRoutes(app: FastifyInstance): Promise<void> {
|
||||
});
|
||||
}
|
||||
|
||||
const { sourceOrigin: rawOrigin, hmacSecret, instanceName: reqInstanceName } = request.body ?? {};
|
||||
const { sourceOrigin: rawOrigin, hmacSecret, instanceName: reqInstanceName, approvalToken: inboundToken } = request.body ?? {};
|
||||
|
||||
if (!rawOrigin || typeof rawOrigin !== 'string') {
|
||||
return reply.code(400).send({ error: 'sourceOrigin is required', statusCode: 400 });
|
||||
@@ -514,50 +585,7 @@ export async function federationRoutes(app: FastifyInstance): Promise<void> {
|
||||
});
|
||||
}
|
||||
|
||||
// Queue for admin approval — upsert into peer_approval_requests
|
||||
const now = Date.now();
|
||||
const THIRTY_DAYS_MS = 30 * 24 * 60 * 60 * 1000;
|
||||
|
||||
const existingRequest = db
|
||||
.select({ id: schema.peerApprovalRequests.id })
|
||||
.from(schema.peerApprovalRequests)
|
||||
.where(eq(schema.peerApprovalRequests.origin, sourceOrigin))
|
||||
.get();
|
||||
|
||||
if (existingRequest) {
|
||||
db.update(schema.peerApprovalRequests)
|
||||
.set({
|
||||
instanceName: reqInstanceName ?? null,
|
||||
hmacSecret,
|
||||
requestedAt: now,
|
||||
expiresAt: now + THIRTY_DAYS_MS,
|
||||
})
|
||||
.where(eq(schema.peerApprovalRequests.id, existingRequest.id))
|
||||
.run();
|
||||
} else {
|
||||
db.insert(schema.peerApprovalRequests)
|
||||
.values({
|
||||
id: generateSnowflake(),
|
||||
origin: sourceOrigin,
|
||||
instanceName: reqInstanceName ?? null,
|
||||
hmacSecret,
|
||||
requestedAt: now,
|
||||
expiresAt: now + THIRTY_DAYS_MS,
|
||||
})
|
||||
.run();
|
||||
}
|
||||
|
||||
// Notify admin users that a new approval request arrived
|
||||
connectionManager.sendToAdmins({
|
||||
type: 'federation_approval_request_received' as const,
|
||||
origin: sourceOrigin,
|
||||
instanceName: reqInstanceName ?? undefined,
|
||||
});
|
||||
|
||||
return reply.code(202).send({
|
||||
queued: true,
|
||||
message: 'Request queued for admin approval',
|
||||
});
|
||||
return queueApprovalRequest(db, reply, sourceOrigin, hmacSecret, reqInstanceName ?? null);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -617,31 +645,82 @@ export async function federationRoutes(app: FastifyInstance): Promise<void> {
|
||||
return reply.code(200).send({ accepted: true, instanceName: ourInstanceName });
|
||||
}
|
||||
if (existing.status === 'awaiting_approval') {
|
||||
// Remote admin approved — this is a fresh handshake from them.
|
||||
db.update(schema.federationPeers)
|
||||
.set({
|
||||
hmacSecret,
|
||||
instanceName: reqInstanceName ?? null,
|
||||
status: 'active',
|
||||
lastSeenAt: Date.now(),
|
||||
})
|
||||
.where(eq(schema.federationPeers.id, existing.id))
|
||||
.run();
|
||||
// Spec §3.5: token verification gates the awaiting_approval → active
|
||||
// promotion. Without proof the inbound came from the remote's
|
||||
// /approve endpoint, an adversarial timing-knowledge attack or a
|
||||
// bug-prone background code path could falsely flip this row to
|
||||
// active. The token is single-use entropy issued in the 202 we
|
||||
// returned when the remote's outbound /peer/accept first hit our
|
||||
// queue — only their /approve endpoint forwards it.
|
||||
const tokenValid =
|
||||
typeof existing.approvalToken === 'string' &&
|
||||
existing.approvalToken.length > 0 &&
|
||||
existing.approvalToken === inboundToken;
|
||||
|
||||
// Broadcast activation
|
||||
for (const uid of connectionManager.getAllOnlineUserIds()) {
|
||||
connectionManager.sendToUser(uid, {
|
||||
type: 'federation_peer_active' as const,
|
||||
peerOrigin: sourceOrigin,
|
||||
});
|
||||
if (tokenValid) {
|
||||
db.update(schema.federationPeers)
|
||||
.set({
|
||||
hmacSecret,
|
||||
instanceName: reqInstanceName ?? null,
|
||||
status: 'active',
|
||||
lastSeenAt: Date.now(),
|
||||
approvalToken: null,
|
||||
})
|
||||
.where(eq(schema.federationPeers.id, existing.id))
|
||||
.run();
|
||||
|
||||
// Clean up any stale approval-request row for this origin (e.g.,
|
||||
// queued debris from a prior bypass attempt that did not promote).
|
||||
db.delete(schema.peerApprovalRequests)
|
||||
.where(eq(schema.peerApprovalRequests.origin, sourceOrigin))
|
||||
.run();
|
||||
|
||||
for (const uid of connectionManager.getAllOnlineUserIds()) {
|
||||
connectionManager.sendToUser(uid, {
|
||||
type: 'federation_peer_active' as const,
|
||||
peerOrigin: sourceOrigin,
|
||||
});
|
||||
}
|
||||
connectionManager.sendToAdmins({ type: 'federation_peers_changed' as const });
|
||||
onPeerActivated(existing.id, 'accept_awaiting_approval').catch(err =>
|
||||
console.error('[federation] onPeerActivated from /peer/accept (awaiting_approval) failed:', err)
|
||||
);
|
||||
return reply.code(200).send({ accepted: true, instanceName: ourInstanceName });
|
||||
}
|
||||
|
||||
connectionManager.sendToAdmins({ type: 'federation_peers_changed' as const });
|
||||
onPeerActivated(existing.id, 'accept_awaiting_approval').catch(err =>
|
||||
console.error('[federation] onPeerActivated from /peer/accept (awaiting_approval) failed:', err)
|
||||
);
|
||||
// Token absent or mismatched. Cannot prove mutual approval.
|
||||
if (autoAccept === 1) {
|
||||
// We accept any inbound anyway — promoting here is no weaker than
|
||||
// accepting a fresh handshake from a new peer. Clear the stored
|
||||
// token (moot now) and proceed.
|
||||
db.update(schema.federationPeers)
|
||||
.set({
|
||||
hmacSecret,
|
||||
instanceName: reqInstanceName ?? null,
|
||||
status: 'active',
|
||||
lastSeenAt: Date.now(),
|
||||
approvalToken: null,
|
||||
})
|
||||
.where(eq(schema.federationPeers.id, existing.id))
|
||||
.run();
|
||||
|
||||
return reply.code(200).send({ accepted: true, instanceName: ourInstanceName });
|
||||
for (const uid of connectionManager.getAllOnlineUserIds()) {
|
||||
connectionManager.sendToUser(uid, {
|
||||
type: 'federation_peer_active' as const,
|
||||
peerOrigin: sourceOrigin,
|
||||
});
|
||||
}
|
||||
connectionManager.sendToAdmins({ type: 'federation_peers_changed' as const });
|
||||
onPeerActivated(existing.id, 'accept_awaiting_approval_fallback').catch(err =>
|
||||
console.error('[federation] onPeerActivated from /peer/accept (awaiting_approval fallback) failed:', err)
|
||||
);
|
||||
return reply.code(200).send({ accepted: true, instanceName: ourInstanceName });
|
||||
}
|
||||
|
||||
// autoAccept=0 + unverifiable inbound → queue as new approval-request.
|
||||
// Existing awaiting_approval row stays untouched; the new approval-
|
||||
// request lets the local admin decide whether to honor this inbound.
|
||||
return queueApprovalRequest(db, reply, sourceOrigin, hmacSecret, reqInstanceName ?? null);
|
||||
}
|
||||
// Pending — update with new secret and activate
|
||||
db.update(schema.federationPeers)
|
||||
|
||||
@@ -9,6 +9,7 @@ export type PeerActivationReason =
|
||||
| 'initiate_accepted'
|
||||
| 'accept_rejected_override'
|
||||
| 'accept_awaiting_approval'
|
||||
| 'accept_awaiting_approval_fallback'
|
||||
| 'accept_pending'
|
||||
| 'accept_new'
|
||||
| 'approval_handshake'
|
||||
|
||||
Reference in New Issue
Block a user