mirror of
https://github.com/UNITRONIX/BetterDesk.git
synced 2026-09-10 09:35:39 +00:00
0b82160592
Store PUBLIC_* on the console-data volume so panel settings survive compose recreate; keep Compose env override and .env mirror for IaC and bare-metal.
305 lines
9.2 KiB
JavaScript
305 lines
9.2 KiB
JavaScript
'use strict';
|
|
|
|
const fs = require('fs');
|
|
const path = require('path');
|
|
const { upsertEnvKey } = require('../lib/envMerge');
|
|
const conn = require('./agentBundleConnection');
|
|
|
|
const CONSOLE_ROOT = path.join(__dirname, '..');
|
|
const ENV_PATH = path.join(CONSOLE_ROOT, '.env');
|
|
const DURABLE_BASENAME = 'public-endpoints.env';
|
|
const PANEL_PUBLIC_HOST_KEY = 'PANEL_PUBLIC_HOST';
|
|
|
|
const ENV_KEYS = {
|
|
public_server_id: 'PUBLIC_SERVER_ID',
|
|
public_relay_server: 'PUBLIC_RELAY_SERVER',
|
|
public_api_url: 'PUBLIC_API_URL',
|
|
};
|
|
|
|
/** Keys persisted in the durable volume-backed file (non-secrets only). */
|
|
const DURABLE_KEYS = [
|
|
ENV_KEYS.public_server_id,
|
|
ENV_KEYS.public_relay_server,
|
|
ENV_KEYS.public_api_url,
|
|
PANEL_PUBLIC_HOST_KEY,
|
|
];
|
|
|
|
let _migrated = false;
|
|
/** @type {{ envPath?: string, durablePath?: string, dataDir?: string } | null} */
|
|
let _testPaths = null;
|
|
|
|
function getEnvPath() {
|
|
if (_testPaths && _testPaths.envPath) return _testPaths.envPath;
|
|
return ENV_PATH;
|
|
}
|
|
|
|
function getDataDir() {
|
|
if (_testPaths && _testPaths.dataDir) return _testPaths.dataDir;
|
|
// Lazy require avoids load-order issues with config.js
|
|
const config = require('../config/config');
|
|
return config.dataDir;
|
|
}
|
|
|
|
function getDurableEnvPath() {
|
|
if (_testPaths && _testPaths.durablePath) return _testPaths.durablePath;
|
|
return path.join(getDataDir(), DURABLE_BASENAME);
|
|
}
|
|
|
|
function parseEnvFile(content) {
|
|
const out = {};
|
|
if (!content) return out;
|
|
for (const line of String(content).split(/\r?\n/)) {
|
|
const trimmed = line.trim();
|
|
if (!trimmed || trimmed.startsWith('#')) continue;
|
|
const eq = trimmed.indexOf('=');
|
|
if (eq <= 0) continue;
|
|
out[trimmed.slice(0, eq).trim()] = trimmed.slice(eq + 1).trim();
|
|
}
|
|
return out;
|
|
}
|
|
|
|
function readEnvFileMap(filePath) {
|
|
if (!fs.existsSync(filePath)) return {};
|
|
try {
|
|
return parseEnvFile(fs.readFileSync(filePath, 'utf8'));
|
|
} catch (_) {
|
|
return {};
|
|
}
|
|
}
|
|
|
|
function nonEmpty(value) {
|
|
if (value === undefined || value === null) return '';
|
|
const trimmed = String(value).trim();
|
|
return trimmed === '' ? '' : trimmed;
|
|
}
|
|
|
|
/**
|
|
* Read precedence: non-empty process.env → durable file → console .env
|
|
* Empty process.env must NOT mask durable/.env values (Docker Compose empty keys).
|
|
*/
|
|
function resolveEnvKey(key, durableMap, legacyMap) {
|
|
const fromProcess = nonEmpty(process.env[key]);
|
|
if (fromProcess) return fromProcess;
|
|
const fromDurable = nonEmpty(durableMap[key]);
|
|
if (fromDurable) return fromDurable;
|
|
return nonEmpty(legacyMap[key]);
|
|
}
|
|
|
|
function assertNoEnvInjection(value) {
|
|
if (value === undefined || value === null || value === '') return;
|
|
if (/[\r\n\0]/.test(String(value))) {
|
|
throw new Error('invalid_env_value');
|
|
}
|
|
}
|
|
|
|
function normalizeHostField(value) {
|
|
const raw = String(value || '').trim();
|
|
if (!raw) return '';
|
|
assertNoEnvInjection(raw);
|
|
const normalized = conn.normalizeServerHost(raw);
|
|
if (!normalized.valid) {
|
|
throw new Error('invalid_host');
|
|
}
|
|
assertNoEnvInjection(normalized.host);
|
|
return normalized.host;
|
|
}
|
|
|
|
function normalizeApiUrl(value) {
|
|
const raw = String(value || '').trim();
|
|
if (!raw) return '';
|
|
assertNoEnvInjection(raw);
|
|
let urlStr = raw;
|
|
if (!/^https?:\/\//i.test(urlStr)) {
|
|
throw new Error('invalid_api_url');
|
|
}
|
|
try {
|
|
const u = new URL(urlStr);
|
|
if (u.protocol !== 'http:' && u.protocol !== 'https:') {
|
|
throw new Error('invalid_api_url');
|
|
}
|
|
const pathPart = u.pathname === '/' ? '' : u.pathname.replace(/\/+$/, '');
|
|
const portPart = u.port ? `:${u.port}` : '';
|
|
const out = `${u.protocol}//${u.hostname}${portPart}${pathPart}${u.search || ''}`;
|
|
assertNoEnvInjection(out);
|
|
return out;
|
|
} catch (err) {
|
|
if (err.message === 'invalid_api_url' || err.message === 'invalid_env_value') throw err;
|
|
throw new Error('invalid_api_url');
|
|
}
|
|
}
|
|
|
|
function normalizeSettings(raw = {}) {
|
|
return {
|
|
public_server_id: normalizeHostField(raw.public_server_id),
|
|
public_relay_server: normalizeHostField(raw.public_relay_server),
|
|
public_api_url: normalizeApiUrl(raw.public_api_url),
|
|
};
|
|
}
|
|
|
|
function validateSettings(settings) {
|
|
try {
|
|
return normalizeSettings(settings);
|
|
} catch (err) {
|
|
if (err.message === 'invalid_host' || err.message === 'invalid_env_value') {
|
|
throw new Error('invalid_public_host');
|
|
}
|
|
if (err.message === 'invalid_api_url') throw new Error('invalid_public_api_url');
|
|
throw err;
|
|
}
|
|
}
|
|
|
|
function durableHasAnyPublicKeys(durableMap) {
|
|
return DURABLE_KEYS.some((key) => nonEmpty(durableMap[key]));
|
|
}
|
|
|
|
function writeEnvFileAtomic(filePath, content) {
|
|
const dir = path.dirname(filePath);
|
|
fs.mkdirSync(dir, { recursive: true });
|
|
const tmpPath = path.join(dir, `.${path.basename(filePath)}.${process.pid}.tmp`);
|
|
fs.writeFileSync(tmpPath, content, { encoding: 'utf8', mode: 0o600 });
|
|
fs.renameSync(tmpPath, filePath);
|
|
try {
|
|
fs.chmodSync(filePath, 0o600);
|
|
} catch (_) { /* ignore on platforms that cannot chmod */ }
|
|
}
|
|
|
|
function upsertKeysToFile(filePath, keyValues) {
|
|
let content = fs.existsSync(filePath) ? fs.readFileSync(filePath, 'utf8') : '';
|
|
for (const [key, value] of Object.entries(keyValues)) {
|
|
assertNoEnvInjection(value);
|
|
content = upsertEnvKey(content, key, value || '');
|
|
}
|
|
writeEnvFileAtomic(filePath, content);
|
|
}
|
|
|
|
/**
|
|
* One-shot migration: if durable file has no PUBLIC_* or PANEL_PUBLIC_HOST,
|
|
* copy from console .env and/or non-empty process.env. Never overwrites
|
|
* an already-populated durable file.
|
|
*/
|
|
function ensureMigratedPublicEndpoints() {
|
|
if (_migrated) return;
|
|
_migrated = true;
|
|
|
|
const durablePath = getDurableEnvPath();
|
|
const durableMap = readEnvFileMap(durablePath);
|
|
if (durableHasAnyPublicKeys(durableMap)) return;
|
|
|
|
const legacyMap = readEnvFileMap(getEnvPath());
|
|
const toWrite = {};
|
|
let any = false;
|
|
for (const key of DURABLE_KEYS) {
|
|
const value = nonEmpty(process.env[key]) || nonEmpty(legacyMap[key]);
|
|
if (value) {
|
|
try {
|
|
assertNoEnvInjection(value);
|
|
toWrite[key] = value;
|
|
any = true;
|
|
} catch (_) {
|
|
// skip unsafe values during migration
|
|
}
|
|
}
|
|
}
|
|
if (!any) return;
|
|
|
|
try {
|
|
upsertKeysToFile(durablePath, toWrite);
|
|
} catch (err) {
|
|
console.warn('[public-endpoints] durable migration failed:', err.message);
|
|
}
|
|
}
|
|
|
|
function readPublicEndpointEnv() {
|
|
ensureMigratedPublicEndpoints();
|
|
const durableMap = readEnvFileMap(getDurableEnvPath());
|
|
const legacyMap = readEnvFileMap(getEnvPath());
|
|
return {
|
|
public_server_id: resolveEnvKey(ENV_KEYS.public_server_id, durableMap, legacyMap),
|
|
public_relay_server: resolveEnvKey(ENV_KEYS.public_relay_server, durableMap, legacyMap),
|
|
public_api_url: resolveEnvKey(ENV_KEYS.public_api_url, durableMap, legacyMap),
|
|
};
|
|
}
|
|
|
|
/**
|
|
* PANEL_PUBLIC_HOST with same precedence as PUBLIC_*.
|
|
*/
|
|
function readPanelPublicHostValue() {
|
|
ensureMigratedPublicEndpoints();
|
|
const durableMap = readEnvFileMap(getDurableEnvPath());
|
|
const legacyMap = readEnvFileMap(getEnvPath());
|
|
return resolveEnvKey(PANEL_PUBLIC_HOST_KEY, durableMap, legacyMap);
|
|
}
|
|
|
|
function syncProcessEnv(settings) {
|
|
for (const [field, envKey] of Object.entries(ENV_KEYS)) {
|
|
const value = settings[field] || '';
|
|
if (value) {
|
|
process.env[envKey] = value;
|
|
} else {
|
|
delete process.env[envKey];
|
|
}
|
|
}
|
|
}
|
|
|
|
function getPublicEndpointSettings() {
|
|
return readPublicEndpointEnv();
|
|
}
|
|
|
|
function isEnvOverrideActive(env = readPublicEndpointEnv()) {
|
|
return Boolean(env.public_server_id || env.public_relay_server || env.public_api_url);
|
|
}
|
|
|
|
function writePublicEndpointSettingsToEnv(settings) {
|
|
const normalized = validateSettings(settings);
|
|
const keyValues = {};
|
|
for (const [field, envKey] of Object.entries(ENV_KEYS)) {
|
|
keyValues[envKey] = normalized[field] || '';
|
|
}
|
|
|
|
// Primary: volume-backed durable file (survives Docker recreate)
|
|
upsertKeysToFile(getDurableEnvPath(), keyValues);
|
|
|
|
// Mirror: console .env for bare-metal / Advanced editor / install scripts
|
|
upsertKeysToFile(getEnvPath(), keyValues);
|
|
|
|
syncProcessEnv(normalized);
|
|
return normalized;
|
|
}
|
|
|
|
async function savePublicEndpointSettings(settings) {
|
|
const normalized = writePublicEndpointSettingsToEnv(settings);
|
|
return { settings: normalized };
|
|
}
|
|
|
|
/** Test helpers */
|
|
function _resetMigrationForTests() {
|
|
_migrated = false;
|
|
}
|
|
|
|
function _setPathsForTests(paths) {
|
|
_testPaths = paths || null;
|
|
_migrated = false;
|
|
}
|
|
|
|
module.exports = {
|
|
ENV_PATH,
|
|
ENV_KEYS,
|
|
PANEL_PUBLIC_HOST_KEY,
|
|
DURABLE_BASENAME,
|
|
getDurableEnvPath,
|
|
getEnvPath,
|
|
parseEnvFile,
|
|
readPublicEndpointEnv,
|
|
readPanelPublicHostValue,
|
|
normalizeSettings,
|
|
validateSettings,
|
|
syncProcessEnv,
|
|
getPublicEndpointSettings,
|
|
isEnvOverrideActive,
|
|
writePublicEndpointSettingsToEnv,
|
|
savePublicEndpointSettings,
|
|
ensureMigratedPublicEndpoints,
|
|
_resetMigrationForTests,
|
|
_setPathsForTests,
|
|
};
|