feat: implement federation for inter-instance meeting invitations with dedicated API, UI, and configuration.

This commit is contained in:
2026-02-27 12:53:20 +01:00
parent a35b708cbf
commit c5a6a15731
11 changed files with 812 additions and 18 deletions

View File

@@ -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
View 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),
};
}

View File

@@ -10,6 +10,7 @@ import roomRoutes from './routes/rooms.js';
import recordingRoutes from './routes/recordings.js';
import adminRoutes from './routes/admin.js';
import brandingRoutes from './routes/branding.js';
import federationRoutes, { wellKnownHandler } from './routes/federation.js';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
@@ -35,6 +36,8 @@ async function start() {
app.use('/api/recordings', recordingRoutes);
app.use('/api/admin', adminRoutes);
app.use('/api/branding', brandingRoutes);
app.use('/api/federation', federationRoutes);
app.get('/.well-known/redlight', wellKnownHandler);
// Serve static files in production
if (process.env.NODE_ENV === 'production') {

262
server/routes/federation.js Normal file
View File

@@ -0,0 +1,262 @@
import { Router } from 'express';
import { v4 as uuidv4 } from 'uuid';
import { getDb } from '../config/database.js';
import { authenticateToken } from '../middleware/auth.js';
import {
getFederationDomain,
isFederationEnabled,
getPublicKey,
signPayload,
verifyPayload,
discoverInstance,
parseAddress,
} from '../config/federation.js';
const router = Router();
// ── Well-known discovery endpoint ───────────────────────────────────────────
// Mounted at /.well-known/redlight in index.js
export function wellKnownHandler(req, res) {
const domain = getFederationDomain();
if (!domain) {
return res.status(404).json({ error: 'Federation not configured' });
}
res.json({
domain,
federation_api: '/api/federation',
public_key: getPublicKey(),
software: 'Redlight',
version: '1.1.0',
});
}
// ── POST /api/federation/invite — Send invitation to remote user ────────────
router.post('/invite', authenticateToken, async (req, res) => {
try {
if (!isFederationEnabled()) {
return res.status(400).json({ error: 'Federation is not configured on this instance' });
}
const { room_uid, to, message } = req.body;
if (!room_uid || !to) {
return res.status(400).json({ error: 'room_uid and to are required' });
}
const { username, domain } = parseAddress(to);
if (!domain) {
return res.status(400).json({ error: 'Remote address must be in format username@domain' });
}
// Don't allow inviting to own instance
if (domain === getFederationDomain()) {
return res.status(400).json({ error: 'Cannot send federation invite to your own instance. Use local sharing instead.' });
}
const db = getDb();
// Verify room exists and user has access
const room = await db.get('SELECT * FROM rooms WHERE uid = ?', [room_uid]);
if (!room) {
return res.status(404).json({ error: 'Room not found' });
}
const isOwner = room.user_id === req.user.id;
if (!isOwner) {
const share = await db.get('SELECT id FROM room_shares WHERE room_id = ? AND user_id = ?', [room.id, req.user.id]);
if (!share) {
return res.status(403).json({ error: 'No permission to invite from this room' });
}
}
// Build guest join URL for the remote user
const baseUrl = process.env.APP_URL || `${req.protocol}://${req.get('host')}`;
const joinUrl = `${baseUrl}/join/${room.uid}`;
// Build invitation payload
const inviteId = uuidv4();
const payload = {
invite_id: inviteId,
from_user: `${req.user.name}@${getFederationDomain()}`,
to_user: to,
room_name: room.name,
message: message || null,
join_url: joinUrl,
timestamp: new Date().toISOString(),
};
// Sign and send to remote instance
const signature = signPayload(payload);
const { baseUrl: remoteApi } = await discoverInstance(domain);
const response = await fetch(`${remoteApi}/receive`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Federation-Signature': signature,
'X-Federation-Origin': getFederationDomain(),
},
body: JSON.stringify(payload),
});
if (!response.ok) {
const data = await response.json().catch(() => ({}));
throw new Error(data.error || `Remote server responded with ${response.status}`);
}
res.json({ success: true, invite_id: inviteId });
} catch (err) {
console.error('Federation invite error:', err);
res.status(500).json({ error: err.message || 'Failed to send federation invite' });
}
});
// ── POST /api/federation/receive — Accept incoming invitation from remote ───
router.post('/receive', async (req, res) => {
try {
if (!isFederationEnabled()) {
return res.status(400).json({ error: 'Federation is not configured on this instance' });
}
const signature = req.headers['x-federation-signature'];
const payload = req.body;
if (!signature) {
return res.status(401).json({ error: 'Missing federation signature' });
}
if (!invite_id || !from_user || !to_user || !room_name || !join_url) {
return res.status(400).json({ error: 'Incomplete invitation payload' });
}
// Fetch the sender's public key dynamically
const { domain: senderDomain } = parseAddress(from_user);
if (!senderDomain) {
return res.status(400).json({ error: 'Sender address must include a domain' });
}
const { publicKey } = await discoverInstance(senderDomain);
if (!publicKey) {
return res.status(400).json({ error: 'Sender instance did not provide a public key' });
}
if (!verifyPayload(payload, signature, publicKey)) {
return res.status(403).json({ error: 'Invalid federation RSA signature' });
}
// Parse the target address and find local user
const { username } = parseAddress(to_user);
const db = getDb();
// Look up user by name (case-insensitive)
const targetUser = await db.get(
'SELECT id FROM users WHERE LOWER(name) = LOWER(?)',
[username]
);
if (!targetUser) {
return res.status(404).json({ error: 'User not found on this instance' });
}
// Check for duplicate
const existing = await db.get(
'SELECT id FROM federation_invitations WHERE invite_id = ?',
[invite_id]
);
if (existing) {
return res.json({ success: true, message: 'Invitation already received' });
}
// Store the invitation
await db.run(
`INSERT INTO federation_invitations (invite_id, from_user, to_user_id, room_name, message, join_url)
VALUES (?, ?, ?, ?, ?, ?)`,
[invite_id, from_user, targetUser.id, room_name, message || null, join_url]
);
res.json({ success: true });
} catch (err) {
console.error('Federation receive error:', err);
res.status(500).json({ error: 'Failed to process federation invitation' });
}
});
// ── GET /api/federation/invitations — List invitations for current user ─────
router.get('/invitations', authenticateToken, async (req, res) => {
try {
const db = getDb();
const invitations = await db.all(
`SELECT * FROM federation_invitations
WHERE to_user_id = ?
ORDER BY created_at DESC`,
[req.user.id]
);
res.json({ invitations });
} catch (err) {
console.error('List federation invitations error:', err);
res.status(500).json({ error: 'Failed to load invitations' });
}
});
// ── GET /api/federation/invitations/pending-count — Badge count ─────────────
router.get('/invitations/pending-count', authenticateToken, async (req, res) => {
try {
const db = getDb();
const result = await db.get(
`SELECT COUNT(*) as count FROM federation_invitations
WHERE to_user_id = ? AND status = 'pending'`,
[req.user.id]
);
res.json({ count: result?.count || 0 });
} catch (err) {
res.json({ count: 0 });
}
});
// ── POST /api/federation/invitations/:id/accept — Accept an invitation ──────
router.post('/invitations/:id/accept', authenticateToken, async (req, res) => {
try {
const db = getDb();
const invitation = await db.get(
'SELECT * FROM federation_invitations WHERE id = ? AND to_user_id = ?',
[req.params.id, req.user.id]
);
if (!invitation) {
return res.status(404).json({ error: 'Invitation not found' });
}
await db.run(
"UPDATE federation_invitations SET status = 'accepted' WHERE id = ?",
[invitation.id]
);
res.json({ success: true, join_url: invitation.join_url });
} catch (err) {
console.error('Accept invitation error:', err);
res.status(500).json({ error: 'Failed to accept invitation' });
}
});
// ── DELETE /api/federation/invitations/:id — Decline/dismiss invitation ─────
router.delete('/invitations/:id', authenticateToken, async (req, res) => {
try {
const db = getDb();
const invitation = await db.get(
'SELECT * FROM federation_invitations WHERE id = ? AND to_user_id = ?',
[req.params.id, req.user.id]
);
if (!invitation) {
return res.status(404).json({ error: 'Invitation not found' });
}
await db.run('DELETE FROM federation_invitations WHERE id = ?', [invitation.id]);
res.json({ success: true });
} catch (err) {
console.error('Decline invitation error:', err);
res.status(500).json({ error: 'Failed to decline invitation' });
}
});
export default router;