1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
|
// Loads the same config.toml + .env the Flask dashboard uses (see config.py).
import { existsSync, readFileSync } from "node:fs";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { parse as parseToml } from "smol-toml";
export const ROOT_DIR = resolve(dirname(fileURLToPath(import.meta.url)), "..", "..");
// [section, key, ENV_VAR, type]. Keep in sync with ENV_OVERRIDES in config.py.
const ENV_OVERRIDES = [
["web", "host", "WEB_HOST", "string"],
["web", "port", "WEB_PORT", "int"],
["web", "dashboard_port", "DASHBOARD_PORT", "int"],
["server", "host", "TS_HOST", "string"],
["server", "webquery_port", "TS_WEBQUERY_PORT", "int"],
["server", "server_id", "TS_SERVER_ID", "string"],
["server", "voice_port", "TS_VOICE_PORT", "int"],
["server", "voice_port", "TS_PORT", "int"], // legacy name from the old .env.template
["server", "ts_domain", "TS_DOMAIN", "string"],
["voice", "enabled", "VOICE_ENABLED", "bool"],
["voice", "trust_proxy", "VOICE_TRUST_PROXY", "bool"],
];
function envValue(raw, kind) {
const value = raw.trim();
if (kind === "bool") return ["1", "true", "yes", "on"].includes(value.toLowerCase());
if (kind === "int") {
if (!/^\d+$/.test(value)) throw new Error("not an integer");
return Number(value);
}
return value;
}
function toInt(value, fallback, min = 0, max = Number.MAX_SAFE_INTEGER) {
const n = Number(value);
return Number.isInteger(n) && n >= min && n <= max ? n : fallback;
}
export function loadConfig(env = process.env) {
const envFile = resolve(ROOT_DIR, ".env");
// Like python-dotenv's load_dotenv(): existing variables are not overridden.
if (env === process.env && existsSync(envFile)) process.loadEnvFile(envFile);
const configFile = env.TS6_CONFIG_FILE || resolve(ROOT_DIR, "config.toml");
let raw = {};
if (existsSync(configFile)) raw = parseToml(readFileSync(configFile, "utf8"));
else console.warn(`[config] ${configFile} not found, using defaults`);
const section = (name) => ({ ...(raw[name] ?? {}) });
const web = section("web");
const server = section("server");
const voice = section("voice");
const sections = { web, server, voice };
const applied = new Set();
for (const [name, key, envName, kind] of ENV_OVERRIDES) {
const value = env[envName];
const id = `${name}.${key}`;
if (value === undefined || value.trim() === "" || applied.has(id)) continue;
try {
sections[name][key] = envValue(value, kind);
applied.add(id);
} catch {
console.warn(`[config] ignoring invalid ${envName}=${JSON.stringify(value)}`);
}
}
const allowedOrigins = Array.isArray(voice.allowed_origins)
? voice.allowed_origins.filter((o) => typeof o === "string" && o).map((o) => o.replace(/\/+$/, "").toLowerCase())
: [];
return {
web: {
host: String(web.host ?? "0.0.0.0"),
port: toInt(web.port, 5000, 1, 65535),
dashboardPort: toInt(web.dashboard_port, 5001, 1, 65535),
},
server: {
host: String(server.host ?? "").trim(),
voicePort: toInt(server.voice_port, 9987, 1, 65535),
// Secret: only ever read from the environment / .env.
password: env.TS_SERVER_PASSWORD ?? "",
},
voice: {
enabled: voice.enabled !== false,
maxSessions: toInt(voice.max_sessions, 8, 1, 1000),
maxSessionsPerIp: toInt(voice.max_sessions_per_ip, 2, 1, 1000),
maxFailedLogins: toInt(voice.max_failed_logins, 5, 1, 1000),
lockoutMs: toInt(voice.lockout_minutes, 10, 1, 24 * 60) * 60_000,
nicknamePrefix: String(voice.nickname_prefix ?? ""),
defaultChannel: String(voice.default_channel ?? ""),
identitySecurityLevel: toInt(voice.identity_security_level, 8, 0, 40),
trustProxy: voice.trust_proxy === true,
allowedOrigins,
},
};
}
|