mirror of
https://github.com/lobehub/lobehub.git
synced 2026-09-20 04:56:13 +08:00
c5ec20abab
* ✨ feat(docker): run self-hosted Compose chats in Gateway Mode by default - Add the lobehub-gateway service to docker-compose and wire the lobe service to it; AGENT_GATEWAY_INTERNAL_URL keeps server pushes on the Compose network while browsers use AGENT_GATEWAY_URL. - S3_INTERNAL_ENDPOINT routes the server's own S3 requests to rustfs:9000 while presigned URLs keep the browser-facing S3_ENDPOINT, which local mode sets to localhost and the container cannot reach. - setup.sh generates a per-deployment JWKS_KEY and gateway token and writes the gateway URL for each deploy mode; the .env template drops the public JWKS key. - Warn loudly at container startup when an upgraded stack lacks the gateway settings or still uses the public example JWKS_KEY, and document the upgrade. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * 🔒 fix(docker): pass only the public JWK to the gateway container The gateway only needs the public half of JWKS_KEY to verify browser sessions, but it received the full private key set, so anyone able to read the gateway environment could sign tokens LobeHub trusts. setup.sh now also writes JWKS_PUBLIC_KEY (the same key without d/p/q/dp/dq/qi), Compose passes only that to the gateway, and the startup check warns when it is missing, still contains private fields, or does not match JWKS_KEY. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
297 lines
9.8 KiB
JavaScript
297 lines
9.8 KiB
JavaScript
const dns = require('node:dns').promises;
|
|
const fs = require('node:fs').promises;
|
|
const path = require('node:path');
|
|
const { spawn } = require('node:child_process');
|
|
const { existsSync } = require('node:fs');
|
|
|
|
// Resolve shared module path for both local dev and Docker environments
|
|
// Local: scripts/serverLauncher/startServer.js -> scripts/_shared/...
|
|
// Docker: /app/startServer.js -> /app/scripts/_shared/...
|
|
const localPath = path.join(__dirname, '..', '_shared', 'checkDeprecatedAuth.js');
|
|
const dockerPath = '/app/scripts/_shared/checkDeprecatedAuth.js';
|
|
const sharedModulePath = existsSync(localPath) ? localPath : dockerPath;
|
|
|
|
const { checkDeprecatedAuth } = require(sharedModulePath);
|
|
const gatewayCheckLocalPath = path.join(__dirname, '..', '_shared', 'checkGatewayConfig.js');
|
|
const gatewayCheckDockerPath = '/app/scripts/_shared/checkGatewayConfig.js';
|
|
const { checkGatewayConfig } = require(
|
|
existsSync(gatewayCheckLocalPath) ? gatewayCheckLocalPath : gatewayCheckDockerPath,
|
|
);
|
|
|
|
// Set file paths
|
|
const DB_MIGRATION_SCRIPT_PATH = '/app/docker.cjs';
|
|
const ES_MIGRATION_SCRIPT_PATH = '/app/fts-search-elasticsearch-reindex.cjs';
|
|
const SERVER_SCRIPT_PATH = '/app/server.js';
|
|
const PROXYCHAINS_CONF_PATH = '/etc/proxychains4.conf';
|
|
|
|
// Function to check if a string is a valid IP address
|
|
const isValidIP = (ip, version = 4) => {
|
|
const ipv4Regex = /^(25[0-5]|2[0-4]\d|[01]?\d{1,2})(\.(25[0-5]|2[0-4]\d|[01]?\d{1,2})){3}$/;
|
|
const ipv6Regex =
|
|
/^(([\da-f]{1,4}:){7}[\da-f]{1,4}|([\da-f]{1,4}:){1,7}:|([\da-f]{1,4}:){1,6}:[\da-f]{1,4}|([\da-f]{1,4}:){1,5}(:[\da-f]{1,4}){1,2}|([\da-f]{1,4}:){1,4}(:[\da-f]{1,4}){1,3}|([\da-f]{1,4}:){1,3}(:[\da-f]{1,4}){1,4}|([\da-f]{1,4}:){1,2}(:[\da-f]{1,4}){1,5}|[\da-f]{1,4}:((:[\da-f]{1,4}){1,6})|:((:[\da-f]{1,4}){1,7}|:)|fe80:(:[\da-f]{0,4}){0,4}%[\da-z]+|::(ffff(:0{1,4})?:)?((25[0-5]|(2[0-4]|1?\d)?\d)\.){3}(25[0-5]|(2[0-4]|1?\d)?\d)|([\da-f]{1,4}:){1,4}:((25[0-5]|(2[0-4]|1?\d)?\d)\.){3}(25[0-5]|(2[0-4]|1?\d)?\d))$/;
|
|
|
|
switch (version) {
|
|
case 4: {
|
|
return ipv4Regex.test(ip);
|
|
}
|
|
case 6: {
|
|
return ipv6Regex.test(ip);
|
|
}
|
|
default: {
|
|
return ipv4Regex.test(ip) || ipv6Regex.test(ip);
|
|
}
|
|
}
|
|
};
|
|
|
|
// Function to parse protocol, host and port from a URL
|
|
const parseUrl = (url) => {
|
|
const { protocol, hostname: host, port, username: user, password: pass } = new URL(url);
|
|
return { host, pass, port: port || 443, protocol: protocol.replace(':', ''), user };
|
|
};
|
|
|
|
// Function to resolve host IP via DNS
|
|
const resolveHostIP = async (host, version = 4) => {
|
|
try {
|
|
const { address } = await dns.lookup(host, { family: version });
|
|
|
|
if (!isValidIP(address, version)) {
|
|
console.error(
|
|
`❌ DNS Error: Invalid resolved IP: ${address}. IP address must be IPv${version}.`,
|
|
);
|
|
process.exit(1);
|
|
}
|
|
|
|
return address;
|
|
} catch (err) {
|
|
console.error(`❌ DNS Error: Could not resolve ${host}. Check DNS server:`);
|
|
console.error(err);
|
|
process.exit(1);
|
|
}
|
|
};
|
|
|
|
// Function to generate proxychains configuration
|
|
const runProxyChainsConfGenerator = async (url) => {
|
|
const { protocol, host, port, user, pass } = parseUrl(url);
|
|
|
|
if (!['http', 'socks4', 'socks5'].includes(protocol)) {
|
|
console.error(
|
|
`❌ ProxyChains: Invalid protocol (${protocol}). Protocol must be 'http', 'socks4' and 'socks5'.`,
|
|
);
|
|
process.exit(1);
|
|
}
|
|
|
|
const validPort = parseInt(port, 10);
|
|
if (isNaN(validPort) || validPort <= 0 || validPort > 65_535) {
|
|
console.error(
|
|
`❌ ProxyChains: Invalid port (${port}). Port must be a number between 1 and 65535.`,
|
|
);
|
|
process.exit(1);
|
|
}
|
|
|
|
let ip = isValidIP(host, 4) ? host : await resolveHostIP(host, 4);
|
|
|
|
const proxyDNSConfig =
|
|
process.env.ENABLE_PROXY_DNS === '1'
|
|
? `
|
|
proxy_dns
|
|
remote_dns_subnet 224
|
|
`.trim()
|
|
: '';
|
|
|
|
const configContent = `
|
|
localnet 127.0.0.0/8
|
|
localnet 10.0.0.0/8
|
|
localnet 172.16.0.0/12
|
|
localnet 192.168.0.0/16
|
|
localnet ::/127
|
|
${proxyDNSConfig}
|
|
strict_chain
|
|
tcp_connect_time_out 8000
|
|
tcp_read_time_out 15000
|
|
[ProxyList]
|
|
${protocol} ${ip} ${port} ${user} ${pass}
|
|
`
|
|
.replaceAll(/\n{2,}/g, '\n')
|
|
.trim();
|
|
|
|
await fs.writeFile(PROXYCHAINS_CONF_PATH, configContent);
|
|
console.log(`✅ ProxyChains: All outgoing traffic routed via ${url}.`);
|
|
console.log('-------------------------------------');
|
|
};
|
|
|
|
// Function to execute a script with child process spawn
|
|
const runScript = (scriptPath, useProxy = false, args = []) => {
|
|
const command = useProxy
|
|
? ['/bin/proxychains', '-q', '/bin/node', scriptPath, ...args]
|
|
: ['/bin/node', scriptPath, ...args];
|
|
return new Promise((resolve, reject) => {
|
|
const process = spawn(command.shift(), command, { stdio: 'inherit' });
|
|
process.on('error', reject);
|
|
process.on('close', (code) =>
|
|
code === 0 ? resolve() : reject(new Error(`🔴 Process exited with code ${code}`)),
|
|
);
|
|
});
|
|
};
|
|
|
|
// Function to start the bot gateway by calling the local API endpoint
|
|
const startGateway = async () => {
|
|
const KEY_VAULTS_SECRET = process.env.KEY_VAULTS_SECRET;
|
|
if (!KEY_VAULTS_SECRET) return;
|
|
|
|
const port = process.env.PORT || 3210;
|
|
const url = `http://localhost:${port}/api/agent/gateway/start`;
|
|
const maxRetries = 10;
|
|
const retryDelay = 3000;
|
|
|
|
for (let i = 0; i < maxRetries; i++) {
|
|
try {
|
|
const res = await fetch(url, {
|
|
method: 'POST',
|
|
headers: {
|
|
'Authorization': `Bearer ${KEY_VAULTS_SECRET}`,
|
|
'Content-Type': 'application/json',
|
|
},
|
|
body: JSON.stringify({}),
|
|
});
|
|
|
|
if (res.ok) {
|
|
console.log('✅ Gateway: Started successfully.');
|
|
return;
|
|
}
|
|
|
|
console.warn(`⚠️ Gateway: Received status ${res.status}, retrying...`);
|
|
} catch {
|
|
if (i < maxRetries - 1) {
|
|
await new Promise((r) => setTimeout(r, retryDelay));
|
|
}
|
|
}
|
|
}
|
|
|
|
console.error('❌ Gateway: Failed to start after retries.');
|
|
};
|
|
|
|
// Recurring QStash schedules this deployment needs.
|
|
//
|
|
// The goal sweep is not an optimization: a Goal Graph advances on events, so a
|
|
// dropped delivery or a process that dies after dispatch would strand the goal
|
|
// forever with nothing to notice. The sweep is what makes that recoverable.
|
|
const QSTASH_SCHEDULES = [
|
|
{
|
|
cron: '*/10 * * * *',
|
|
id: 'lobe-task-schedule-dispatch',
|
|
path: '/api/workflows/task/schedule-dispatch',
|
|
},
|
|
{
|
|
cron: '*/5 * * * *',
|
|
id: 'lobe-goal-sweep',
|
|
path: '/api/workflows/goal/sweep',
|
|
},
|
|
];
|
|
|
|
// Function to create the recurring QStash schedules the server relies on
|
|
const createQstashSchedule = async () => {
|
|
const QSTASH_URL = process.env.QSTASH_URL || 'https://qstash-eu-central-1.upstash.io';
|
|
|
|
const QSTASH_TOKEN = process.env.QSTASH_TOKEN;
|
|
if (!QSTASH_TOKEN) {
|
|
console.warn('⚠️ QStash: QSTASH_TOKEN not set. Skipping schedule creation.');
|
|
return;
|
|
}
|
|
|
|
const APP_URL = process.env.APP_URL;
|
|
if (!APP_URL) {
|
|
console.warn('⚠️ QStash: APP_URL not set. Skipping schedule creation.');
|
|
return;
|
|
}
|
|
|
|
for (const schedule of QSTASH_SCHEDULES) {
|
|
const url = `${QSTASH_URL}/v2/schedules/${APP_URL}${schedule.path}`;
|
|
|
|
try {
|
|
const res = await fetch(url, {
|
|
method: 'POST',
|
|
headers: {
|
|
'Authorization': `Bearer ${QSTASH_TOKEN}`,
|
|
'Content-Type': 'application/json',
|
|
'Upstash-Method': 'POST',
|
|
'Upstash-Cron': schedule.cron,
|
|
'Upstash-Schedule-Id': schedule.id,
|
|
},
|
|
body: JSON.stringify({}),
|
|
});
|
|
|
|
if (res.ok) {
|
|
console.log(`✅ QStash: Schedule ${schedule.id} created successfully.`);
|
|
} else {
|
|
console.error(`❌ QStash: Failed to create schedule ${schedule.id}. Status ${res.status}`);
|
|
}
|
|
} catch (err) {
|
|
console.error(`❌ QStash: Error creating schedule ${schedule.id}:`, err);
|
|
}
|
|
}
|
|
};
|
|
|
|
// Main function to run the server with optional proxy
|
|
const runServer = async () => {
|
|
const PROXY_URL = process.env.PROXY_URL || ''; // Default empty string to avoid undefined errors
|
|
|
|
if (PROXY_URL) {
|
|
await runProxyChainsConfGenerator(PROXY_URL);
|
|
return runScript(SERVER_SCRIPT_PATH, true);
|
|
}
|
|
return runScript(SERVER_SCRIPT_PATH);
|
|
};
|
|
|
|
// Main execution block
|
|
(async () => {
|
|
// Check for deprecated auth env vars first - fail fast if found
|
|
checkDeprecatedAuth({ action: 'restart' });
|
|
// Warn loudly when an upgraded Compose stack enables Gateway Mode without its .env settings
|
|
checkGatewayConfig();
|
|
|
|
console.log('🌐 DNS Server:', dns.getServers());
|
|
console.log('-------------------------------------');
|
|
|
|
const migrateElasticsearch = process.env.FTS_SEARCH_PROVIDER === 'elasticsearch';
|
|
if (process.env.DATABASE_DRIVER || migrateElasticsearch) {
|
|
try {
|
|
await fs.access(DB_MIGRATION_SCRIPT_PATH);
|
|
|
|
await runScript(DB_MIGRATION_SCRIPT_PATH);
|
|
} catch (err) {
|
|
if (err.code === 'ENOENT' && !migrateElasticsearch) {
|
|
console.log(
|
|
`⚠️ DB Migration: Not found ${DB_MIGRATION_SCRIPT_PATH}. Skipping DB migration. Ensure to migrate database manually.`,
|
|
);
|
|
console.log('-------------------------------------');
|
|
} else {
|
|
console.error('❌ Error during DB migration:');
|
|
console.error(err);
|
|
process.exit(1);
|
|
}
|
|
}
|
|
}
|
|
|
|
/** Search indexes must be ready before the application can accept requests. */
|
|
if (migrateElasticsearch) {
|
|
try {
|
|
const args = ['--startup', '--yes'];
|
|
if (process.env.ENABLE_TELEMETRY) args.push('--telemetry-environment=production');
|
|
await runScript(ES_MIGRATION_SCRIPT_PATH, false, args);
|
|
} catch (err) {
|
|
console.error('❌ Elasticsearch migration failed; application startup stopped.');
|
|
console.error(err);
|
|
process.exit(1);
|
|
}
|
|
}
|
|
|
|
// Start gateway in background after server is ready
|
|
startGateway();
|
|
|
|
// Create QStash schedule for workflow task dispatching
|
|
createQstashSchedule();
|
|
|
|
// Run the server in either database or non-database mode
|
|
await runServer();
|
|
})();
|