feat: implement federation for inter-instance meeting invitations with dedicated API, UI, and configuration.
This commit is contained in:
@@ -179,6 +179,20 @@ export async function initDatabase() {
|
||||
value TEXT,
|
||||
updated_at TIMESTAMP DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS federation_invitations (
|
||||
id SERIAL PRIMARY KEY,
|
||||
invite_id TEXT UNIQUE NOT NULL,
|
||||
from_user TEXT NOT NULL,
|
||||
to_user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
room_name TEXT NOT NULL,
|
||||
message TEXT,
|
||||
join_url TEXT NOT NULL,
|
||||
status TEXT DEFAULT 'pending' CHECK(status IN ('pending','accepted','declined')),
|
||||
created_at TIMESTAMP DEFAULT NOW()
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_fed_inv_to_user ON federation_invitations(to_user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_fed_inv_invite_id ON federation_invitations(invite_id);
|
||||
`);
|
||||
} else {
|
||||
await db.exec(`
|
||||
@@ -240,6 +254,22 @@ export async function initDatabase() {
|
||||
value TEXT,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS federation_invitations (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
invite_id TEXT UNIQUE NOT NULL,
|
||||
from_user TEXT NOT NULL,
|
||||
to_user_id INTEGER NOT NULL,
|
||||
room_name TEXT NOT NULL,
|
||||
message TEXT,
|
||||
join_url TEXT NOT NULL,
|
||||
status TEXT DEFAULT 'pending' CHECK(status IN ('pending','accepted','declined')),
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (to_user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_fed_inv_to_user ON federation_invitations(to_user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_fed_inv_invite_id ON federation_invitations(invite_id);
|
||||
`);
|
||||
}
|
||||
|
||||
|
||||
158
server/config/federation.js
Normal file
158
server/config/federation.js
Normal file
@@ -0,0 +1,158 @@
|
||||
import crypto from 'crypto';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
|
||||
const FEDERATION_DOMAIN = process.env.FEDERATION_DOMAIN || '';
|
||||
let privateKeyPem = process.env.FEDERATION_PRIVATE_KEY || '';
|
||||
let publicKeyPem = '';
|
||||
|
||||
// Load or generate RSA keys
|
||||
if (FEDERATION_DOMAIN) {
|
||||
const keyPath = path.join(__dirname, 'federation_key.pem');
|
||||
|
||||
if (!privateKeyPem && fs.existsSync(keyPath)) {
|
||||
privateKeyPem = fs.readFileSync(keyPath, 'utf8');
|
||||
}
|
||||
|
||||
if (!privateKeyPem) {
|
||||
console.log('Generating new RSA federation key pair...');
|
||||
const { privateKey, publicKey } = crypto.generateKeyPairSync('rsa', {
|
||||
modulusLength: 2048,
|
||||
publicKeyEncoding: { type: 'spki', format: 'pem' },
|
||||
privateKeyEncoding: { type: 'pkcs8', format: 'pem' },
|
||||
});
|
||||
privateKeyPem = privateKey;
|
||||
fs.writeFileSync(keyPath, privateKeyPem, 'utf8');
|
||||
console.log(`Saved new federation private key to ${keyPath}`);
|
||||
}
|
||||
|
||||
// Derive public key from the loaded private key
|
||||
const currentPrivateKey = crypto.createPrivateKey(privateKeyPem);
|
||||
publicKeyPem = crypto.createPublicKey(currentPrivateKey).export({ type: 'spki', format: 'pem' });
|
||||
}
|
||||
|
||||
// Instance discovery cache (domain → { baseUrl, publicKey })
|
||||
const discoveryCache = new Map();
|
||||
|
||||
/**
|
||||
* Get this instance's federation domain.
|
||||
*/
|
||||
export function getFederationDomain() {
|
||||
return FEDERATION_DOMAIN;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get this instance's RSA public key (PEM format).
|
||||
*/
|
||||
export function getPublicKey() {
|
||||
return publicKeyPem;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if federation is configured on this instance.
|
||||
*/
|
||||
export function isFederationEnabled() {
|
||||
return !!(FEDERATION_DOMAIN && privateKeyPem);
|
||||
}
|
||||
|
||||
/**
|
||||
* RSA sign a JSON payload.
|
||||
* @param {object} payload
|
||||
* @returns {string} base64 signature
|
||||
*/
|
||||
export function signPayload(payload) {
|
||||
if (!privateKeyPem) throw new Error("Federation private key not available");
|
||||
const data = Buffer.from(JSON.stringify(payload));
|
||||
const sign = crypto.createSign('SHA256');
|
||||
sign.update(data);
|
||||
sign.end();
|
||||
return sign.sign(privateKeyPem, 'base64');
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify an RSA signature against a JSON payload using a remote public key.
|
||||
* @param {object} payload
|
||||
* @param {string} signature base64 signature
|
||||
* @param {string} remotePublicKeyPem
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function verifyPayload(payload, signature, remotePublicKeyPem) {
|
||||
if (!remotePublicKeyPem || !signature) return false;
|
||||
try {
|
||||
const data = Buffer.from(JSON.stringify(payload));
|
||||
const verify = crypto.createVerify('SHA256');
|
||||
verify.update(data);
|
||||
verify.end();
|
||||
return verify.verify(remotePublicKeyPem, signature, 'base64');
|
||||
} catch (e) {
|
||||
console.error('Signature verification error:', e.message);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Discover a remote Redlight instance's federation API base URL.
|
||||
* Fetches https://{domain}/.well-known/redlight and caches the result.
|
||||
* @param {string} domain
|
||||
* @returns {Promise<{ baseUrl: string, publicKey: string }>}
|
||||
*/
|
||||
export async function discoverInstance(domain) {
|
||||
if (discoveryCache.has(domain)) {
|
||||
return discoveryCache.get(domain);
|
||||
}
|
||||
|
||||
const wellKnownUrl = `https://${domain}/.well-known/redlight`;
|
||||
try {
|
||||
// Since we test locally, allow http fallback if the request fails (optional but good for testing)
|
||||
let response;
|
||||
try {
|
||||
response = await fetch(wellKnownUrl);
|
||||
} catch (e) {
|
||||
if (e.message.includes('fetch') && wellKnownUrl.startsWith('https://localhost')) {
|
||||
response = await fetch(`http://${domain}/.well-known/redlight`);
|
||||
} else throw e;
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Discovery failed: HTTP ${response.status}`);
|
||||
}
|
||||
const data = await response.json();
|
||||
|
||||
if (!data.public_key) {
|
||||
throw new Error(`Remote instance at ${domain} did not provide a public key`);
|
||||
}
|
||||
|
||||
const baseUrl = `https://${domain}${data.federation_api || '/api/federation'}`;
|
||||
// Optionally handle local testing gracefully for baseUrl
|
||||
const result = {
|
||||
baseUrl: baseUrl.replace('https://localhost', 'http://localhost'),
|
||||
publicKey: data.public_key
|
||||
};
|
||||
|
||||
discoveryCache.set(domain, result);
|
||||
return result;
|
||||
} catch (error) {
|
||||
console.error(`Federation discovery failed for ${domain}:`, error.message);
|
||||
throw new Error(`Could not discover Redlight instance at ${domain}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a federated address like "username@domain.com".
|
||||
* @param {string} address
|
||||
* @returns {{ username: string, domain: string | null }}
|
||||
*/
|
||||
export function parseAddress(address) {
|
||||
if (!address || !address.includes('@')) {
|
||||
return { username: address, domain: null };
|
||||
}
|
||||
const atIndex = address.lastIndexOf('@');
|
||||
return {
|
||||
username: address.substring(0, atIndex),
|
||||
domain: address.substring(atIndex + 1),
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user