feat(core): support env-var overrides for webui port and bootstrap password

Two additions for trusted local launchers (notably SnowLumaDesktop) to
configure core without writing to disk-shared config:

- SNOWLUMA_WEBUI_PORT overrides runtime.json's webuiPort at startup
  without persisting; CLI/Docker users continue to drive it from the
  file. Mirrors the existing SNOWLUMA_HOOK_RUNTIME_DIR / *_TRUST_PROXY
  conventions.
- SNOWLUMA_WEBUI_BOOTSTRAP_PASSWORD seeds the initial WebUI password on
  first launch (when webui.json doesn't yet exist), with
  mustChangePassword=false. After the file is persisted the env var is
  ignored — password rotation continues through the UI.
This commit is contained in:
DrSmoothl
2026-05-17 00:23:55 +08:00
parent a8661ea216
commit bc9f7b49be
2 changed files with 61 additions and 11 deletions
+30 -11
View File
@@ -21,26 +21,45 @@ export function loadRuntimeConfig(): RuntimeConfig {
fs.mkdirSync(CONFIG_DIR, { recursive: true });
const loaded = tryLoadRuntimeConfig();
let normalized: RuntimeConfig;
if (!loaded) {
saveRuntimeConfig(DEFAULT_RUNTIME_CONFIG);
return { ...DEFAULT_RUNTIME_CONFIG };
normalized = { ...DEFAULT_RUNTIME_CONFIG };
} else {
normalized = {
webuiPort: normalizePort(loaded.webuiPort ?? 5099, 5099),
hookAutoLoad: normalizeBool(loaded.hookAutoLoad, false),
};
if (
normalized.webuiPort !== loaded.webuiPort
|| normalized.hookAutoLoad !== loaded.hookAutoLoad
) {
saveRuntimeConfig(normalized);
}
}
const normalized: RuntimeConfig = {
webuiPort: normalizePort(loaded.webuiPort ?? 5099, 5099),
hookAutoLoad: normalizeBool(loaded.hookAutoLoad, false),
};
if (
normalized.webuiPort !== loaded.webuiPort
|| normalized.hookAutoLoad !== loaded.hookAutoLoad
) {
saveRuntimeConfig(normalized);
// SNOWLUMA_WEBUI_PORT overrides the on-disk value at runtime without
// rewriting runtime.json. Lets a trusted launcher (e.g. SnowLumaDesktop)
// pick a free port per-launch while CLI/Docker users continue to drive
// it from runtime.json.
const envPort = envPortOverride();
if (envPort !== undefined) {
normalized = { ...normalized, webuiPort: envPort };
}
return normalized;
}
function envPortOverride(): number | undefined {
const raw = process.env.SNOWLUMA_WEBUI_PORT;
if (!raw || typeof raw !== 'string') return undefined;
const n = Number(raw.trim());
if (!Number.isFinite(n)) return undefined;
const port = Math.trunc(n);
if (port <= 0 || port > 65535) return undefined;
return port;
}
function tryLoadRuntimeConfig(): RuntimeConfig | null {
if (!fs.existsSync(RUNTIME_CONFIG_PATH)) return null;
+31
View File
@@ -24,6 +24,16 @@ export function isDevAuthMode(): boolean {
return process.env.SNOWLUMA_DEV_MODE === '1';
}
function envBootstrapPassword(): string | null {
const raw = process.env.SNOWLUMA_WEBUI_BOOTSTRAP_PASSWORD;
if (!raw || typeof raw !== 'string') return null;
// Minimum sanity: avoid accidental empty / 1-char seeds. Strength
// rules still don't apply because we want trusted launchers to be
// able to pick a high-entropy random secret (e.g. base64(16)).
if (raw.length < 8) return null;
return raw;
}
export interface WebuiAuthState {
passwordHash: string; // hex
passwordSalt: string; // hex
@@ -153,6 +163,27 @@ export class WebuiAuth {
backupCorruptConfig();
}
}
// SNOWLUMA_WEBUI_BOOTSTRAP_PASSWORD lets a trusted launcher seed the
// password on first run instead of having to scrape it from logs.
// Effect: write webui.json with the env-provided password, hashed,
// and mustChangePassword=false. Only honoured when there is NO existing
// webui.json — once persisted, password changes happen via the UI.
const envPassword = envBootstrapPassword();
if (envPassword !== null) {
const salt = randomBytes(16);
const hash = hashPassword(envPassword, salt);
const now = new Date().toISOString();
const state: WebuiAuthState = {
passwordHash: hash.toString('hex'),
passwordSalt: salt.toString('hex'),
mustChangePassword: false,
generatedAt: now,
updatedAt: now,
};
atomicWrite(state);
log.info('webui credentials seeded from SNOWLUMA_WEBUI_BOOTSTRAP_PASSWORD');
return new WebuiAuth(state, null, false);
}
const initialPassword = randomBytes(8).toString('hex');
const state = generateInitialState(initialPassword);
atomicWrite(state);