feat(runtime): override deploy settings from the environment

SNOWLUMA_ variables can set the factory OneBot listen ports, WebUI TLS,
packet and hook timeouts, the stream directory, file download timeout,
and notification HTTP timeout. The values apply in memory and are not
written back to saved config.
This commit is contained in:
DrSmoothl
2026-10-06 00:48:34 +08:00
parent c989c764e3
commit f95d247e73
19 changed files with 342 additions and 14 deletions
+4
View File
@@ -24,6 +24,10 @@
"types": "./src/hook-packet-client.ts",
"default": "./src/hook-packet-client.ts"
},
"./packet-timeout": {
"types": "./src/packet-timeout.ts",
"default": "./src/packet-timeout.ts"
},
"./injector": {
"types": "./src/injector.ts",
"default": "./src/injector.ts"
+2 -1
View File
@@ -1,4 +1,5 @@
import type { PacketSender, SendPacketResult } from '@snowluma/common/packet-sender';
import { packetTimeoutMs } from './packet-timeout';
import {
HookPipeRequestError,
PIPE_STATUS_CONNECTION_UNAVAILABLE,
@@ -16,7 +17,7 @@ export class HookPacketClient implements PacketSender {
private readonly onOutboundHealthChanged?: OutboundHealthListener,
) { }
async sendPacket(serviceCmd: string, body: Buffer, timeoutMs = 15000): Promise<SendPacketResult> {
async sendPacket(serviceCmd: string, body: Buffer, timeoutMs = packetTimeoutMs()): Promise<SendPacketResult> {
if (!this.client.isLoggedIn) {
return { success: false, gotResponse: false, errorCode: -1, errorMessage: 'qq_hook client is not logged in', responseData: null };
}
+8
View File
@@ -0,0 +1,8 @@
import { positiveIntEnv } from '@snowluma/common/env';
/** Default wait for a QQ reply when the caller does not pass one. */
export const DEFAULT_PACKET_TIMEOUT_MS = 15_000;
export function packetTimeoutMs(env?: NodeJS.ProcessEnv): number {
return positiveIntEnv('SNOWLUMA_PACKET_TIMEOUT_MS', DEFAULT_PACKET_TIMEOUT_MS, { env });
}
+2 -1
View File
@@ -10,6 +10,7 @@ import { isRealUin } from '@snowluma/common/uin';
import { readdirSync, promises as fs } from 'fs';
import net from 'net';
import path from 'path';
import { positiveIntEnv } from '@snowluma/common/env';
import { resolveHookRuntimeDir } from './hook-runtime-dir';
export const PIPE_MAGIC = 0x31504851;
@@ -471,7 +472,7 @@ export class QqHookClient extends EventEmitter {
private loginWaiters: Deferred<QqHookLoginState>[] = [];
constructor(pid: number, {
ackTimeoutMs = DEFAULT_ACK_TIMEOUT_MS,
ackTimeoutMs = positiveIntEnv('SNOWLUMA_HOOK_ACK_TIMEOUT_MS', DEFAULT_ACK_TIMEOUT_MS),
replyTimeoutMs = DEFAULT_REPLY_TIMEOUT_MS,
runtimeDir = resolveHookRuntimeDir(pid),
}: QqHookClientOptions = {}) {
@@ -0,0 +1,30 @@
import { afterEach, describe, expect, it } from 'vitest';
import { QqHookClient } from '../src/qq-hook-client';
import { packetTimeoutMs } from '../src/packet-timeout';
const PACKET = 'SNOWLUMA_PACKET_TIMEOUT_MS';
const ACK = 'SNOWLUMA_HOOK_ACK_TIMEOUT_MS';
afterEach(() => {
delete process.env[PACKET];
delete process.env[ACK];
});
describe('packet and hook ack timeouts', () => {
it('keeps the 15 second packet default', () => {
expect(packetTimeoutMs({})).toBe(15_000);
});
it('reads SNOWLUMA_PACKET_TIMEOUT_MS', () => {
expect(packetTimeoutMs({ [PACKET]: '8000' })).toBe(8000);
expect(packetTimeoutMs({ [PACKET]: '0' })).toBe(15_000);
});
it('uses SNOWLUMA_HOOK_ACK_TIMEOUT_MS when the client is not given one', () => {
process.env[ACK] = '1200';
const client = new QqHookClient(1, { runtimeDir: '/tmp' });
expect(client.defaultAckTimeoutMs).toBe(1200);
const explicit = new QqHookClient(1, { runtimeDir: '/tmp', ackTimeoutMs: 50 });
expect(explicit.defaultAckTimeoutMs).toBe(50);
});
});
+4
View File
@@ -20,6 +20,10 @@
"types": "./src/coerce.ts",
"default": "./src/coerce.ts"
},
"./env": {
"types": "./src/env.ts",
"default": "./src/env.ts"
},
"./single-flight-cache": {
"types": "./src/single-flight-cache.ts",
"default": "./src/single-flight-cache.ts"
+28
View File
@@ -0,0 +1,28 @@
import { createLogger } from './logger';
const log = createLogger('Env');
/** Node's setTimeout delay is a signed 32-bit integer. */
const MAX_POSITIVE_INT = 2_147_483_647;
/** Read a positive integer environment variable.
*
* A blank value is absent and returns `fallback`. A present but unusable
* value is ignored, with a warning, and also returns `fallback`.
*/
export function positiveIntEnv(
name: string,
fallback: number,
options?: { max?: number; env?: NodeJS.ProcessEnv },
): number {
const max = options?.max ?? MAX_POSITIVE_INT;
const raw = (options?.env ?? process.env)[name];
if (typeof raw !== 'string' || !raw.trim()) return fallback;
const value = raw.trim();
const parsed = /^[1-9]\d*$/.test(value) ? Number(value) : Number.NaN;
if (!Number.isSafeInteger(parsed) || parsed > max) {
log.warn('%s=%j is ignored; expected a positive integer up to %d', name, raw, max);
return fallback;
}
return parsed;
}
+3
View File
@@ -108,6 +108,9 @@ export function resolveRuntimeEnvOverrides(env: NodeJS.ProcessEnv): Partial<Runt
);
if (logPerUin !== undefined) out.logPerUin = logPerUin;
const webuiTls = parseRequiredBoolEnv(env.SNOWLUMA_WEBUI_TLS, 'SNOWLUMA_WEBUI_TLS');
if (webuiTls !== undefined) out.webuiTls = { enabled: webuiTls };
return out;
}
+26
View File
@@ -0,0 +1,26 @@
import { describe, expect, it } from 'vitest';
import { positiveIntEnv } from '../src/env';
describe('positiveIntEnv', () => {
it('returns the fallback when the variable is absent or blank', () => {
expect(positiveIntEnv('SNOWLUMA_TEST_INT', 15, { env: {} })).toBe(15);
expect(positiveIntEnv('SNOWLUMA_TEST_INT', 15, { env: { SNOWLUMA_TEST_INT: ' ' } })).toBe(15);
});
it('reads a positive integer and honors the max', () => {
expect(positiveIntEnv('SNOWLUMA_TEST_INT', 15, { env: { SNOWLUMA_TEST_INT: '2500' } })).toBe(2500);
expect(positiveIntEnv('SNOWLUMA_TEST_INT', 15, {
max: 65535,
env: { SNOWLUMA_TEST_INT: '80' },
})).toBe(80);
});
it('ignores zero, negatives, fractions, and values above the max', () => {
for (const raw of ['0', '-5', '1.5', 'nope', '70000']) {
expect(positiveIntEnv('SNOWLUMA_TEST_INT', 15, {
max: 65535,
env: { SNOWLUMA_TEST_INT: raw },
})).toBe(15);
}
});
});
+11
View File
@@ -124,6 +124,17 @@ describe('resolveRuntimeEnvOverrides', () => {
})).toThrow(/SNOWLUMA_LOG_RETAIN_DAYS/);
});
it('parses SNOWLUMA_WEBUI_TLS over the saved switch', () => {
expect(resolveRuntimeEnvOverrides({ SNOWLUMA_WEBUI_TLS: '1' })).toEqual({
webuiTls: { enabled: true },
});
expect(resolveRuntimeEnvOverrides({ SNOWLUMA_WEBUI_TLS: '0' })).toEqual({
webuiTls: { enabled: false },
});
expect(() => resolveRuntimeEnvOverrides({ SNOWLUMA_WEBUI_TLS: 'maybe' }))
.toThrow(/SNOWLUMA_WEBUI_TLS/);
});
it('ignores an out-of-range / non-numeric port env', () => {
expect(resolveRuntimeEnvOverrides({ SNOWLUMA_WEBUI_PORT: '0' })).toEqual({});
expect(resolveRuntimeEnvOverrides({ SNOWLUMA_WEBUI_PORT: 'abc' })).toEqual({});
+7 -6
View File
@@ -1,4 +1,5 @@
import type { JsonObject, JsonValue } from '@snowluma/common/json';
import { packetTimeoutMs } from '@snowluma/bridge/packet-timeout';
import { createLogger } from '@snowluma/common/logger';
import type {
AlbumCreator,
@@ -179,7 +180,7 @@ export class GroupAlbumApi {
body.length,
);
const result = await this.ctx.sendRawPacket(GET_ALBUM_LIST_CMD, body, 15000);
const result = await this.ctx.sendRawPacket(GET_ALBUM_LIST_CMD, body, packetTimeoutMs());
if (!result.success || !result.gotResponse || !result.responseData) {
throw new Error(
`get group album list transport failed: ${result.errorMessage || `code ${result.errorCode}`}`,
@@ -297,7 +298,7 @@ export class GroupAlbumApi {
const result = await this.ctx.sendRawPacket(
'QunAlbum.trpc.qzone.webapp_qun_media.QunMedia.GetMediaList',
body,
15000,
packetTimeoutMs(),
);
if (!result.success || !result.gotResponse || !result.responseData) {
@@ -359,7 +360,7 @@ export class GroupAlbumApi {
extMap: [{ key: 'fc-appid', value: '100' }],
});
const result = await this.ctx.sendRawPacket(DO_QUN_COMMENT_CMD, body, 15000);
const result = await this.ctx.sendRawPacket(DO_QUN_COMMENT_CMD, body, packetTimeoutMs());
if (!result.success || !result.gotResponse || !result.responseData) {
throw new Error(result.errorMessage || 'failed to comment on album media');
@@ -431,7 +432,7 @@ export class GroupAlbumApi {
const result = await this.ctx.sendRawPacket(
'QunAlbum.trpc.qzone.webapp_qun_operation.FeedsWriter.DoQunLike',
body,
15000,
packetTimeoutMs(),
);
if (!result.success || !result.gotResponse || !result.responseData) {
@@ -470,7 +471,7 @@ export class GroupAlbumApi {
const result = await this.ctx.sendRawPacket(
'QunAlbum.trpc.qzone.webapp_qun_media.QunMedia.DeleteMedias',
body,
15000,
packetTimeoutMs(),
);
if (!result.success || !result.gotResponse || !result.responseData) {
@@ -571,7 +572,7 @@ export class GroupAlbumApi {
extMap: [{ key: 'fc-appid', value: '100' }],
});
const result = await this.ctx.sendRawPacket(GET_QUN_FEED_DETAIL_CMD, body, 15000);
const result = await this.ctx.sendRawPacket(GET_QUN_FEED_DETAIL_CMD, body, packetTimeoutMs());
if (!result.success || !result.gotResponse || !result.responseData) {
throw new Error(result.errorMessage || 'failed to fetch album feed');
}
+2 -1
View File
@@ -4,6 +4,7 @@ import {
runWithTraceRequest,
} from '@snowluma/common/logger';
import type { PacketSender, SendPacketResult } from '@snowluma/common/packet-sender';
import { packetTimeoutMs } from '@snowluma/bridge/packet-timeout';
import type { PacketInfo } from '@snowluma/common/protocol-types';
import { BridgeEventBus } from '@snowluma/protocol/event-bus';
import { IdentityService } from '@snowluma/protocol/identity-service';
@@ -296,7 +297,7 @@ export class Bridge implements BridgeInterface {
this.msgRandom_ = (this.msgRandom_ + 0x9E3779B9) >>> 0;
return this.msgRandom_ & 0x7FFFFFFF;
}
async sendRawPacket(serviceCmd: string, body: Uint8Array, timeoutMs = 15000): Promise<SendPacketResult> {
async sendRawPacket(serviceCmd: string, body: Uint8Array, timeoutMs = packetTimeoutMs()): Promise<SendPacketResult> {
if (!this.packetClient_) {
return runWithTraceRequest(() => {
const startedAt = Date.now();
+4 -1
View File
@@ -8,6 +8,7 @@
// Side-effecting collaborators (config load, per-UIN channel ids, webhook POST,
// SMTP send, the clock) are injected so the class is fully unit-testable; the
// real wiring lives in `createNotificationManager()` at the bottom.
import { positiveIntEnv } from '@snowluma/common/env';
import { createLogger } from '@snowluma/common/logger';
import { loadOneBotConfig } from '@snowluma/onebot/config';
import type { BridgeManager } from '../bridge/manager';
@@ -262,7 +263,9 @@ export class NotificationManager {
/** Default outbound POST: a single attempt with a timeout, JSON content-type
* (the common case for 钉钉/Discord/飞书). Never throws. */
export function createDefaultPost(timeoutMs = DEFAULT_POST_TIMEOUT_MS): NotificationManagerDeps['post'] {
export function createDefaultPost(
timeoutMs = positiveIntEnv('SNOWLUMA_NOTIFICATION_HTTP_TIMEOUT_MS', DEFAULT_POST_TIMEOUT_MS),
): NotificationManagerDeps['post'] {
return async (url, body, headers) => {
try {
const res = await fetch(url, {
@@ -1,6 +1,7 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import {
NotificationManager,
createDefaultPost,
selectChannels,
type MailPayload,
type NotificationManagerDeps,
@@ -321,3 +322,28 @@ describe('NotificationManager — debounce integration (fake timers)', () => {
expect(events).toEqual([]);
});
});
describe('createDefaultPost timeout', () => {
const name = 'SNOWLUMA_NOTIFICATION_HTTP_TIMEOUT_MS';
const saved = process.env[name];
afterEach(() => {
if (saved === undefined) delete process.env[name];
else process.env[name] = saved;
vi.unstubAllGlobals();
});
it('uses SNOWLUMA_NOTIFICATION_HTTP_TIMEOUT_MS', async () => {
process.env[name] = '1234';
const seen: number[] = [];
const timeout = AbortSignal.timeout.bind(AbortSignal);
vi.spyOn(AbortSignal, 'timeout').mockImplementation((ms: number) => {
seen.push(ms);
return timeout(50);
});
vi.stubGlobal('fetch', vi.fn(async () => ({ ok: true, status: 200 })));
const post = createDefaultPost();
await post('http://127.0.0.1/hook', '{}');
expect(seen).toEqual([1234]);
});
});
+7 -2
View File
@@ -1,3 +1,4 @@
import { positiveIntEnv } from '@snowluma/common/env';
import { readFile } from 'node:fs/promises';
import { FriendDressError } from '@snowluma/protocol/web/friend-dress';
import { formatGroupRequestFlag } from '@snowluma/protocol/qq-info';
@@ -21,7 +22,11 @@ import { groupInfoReturnsSchema } from './group-info';
import { GROUP_MESSAGE_EVENT, hashMessageIdInt32 } from '../message-id';
const DOWNLOAD_FILE_MAX_BYTES = 1024 * 1024 * 1024; // 1 GiB
const DOWNLOAD_FILE_TIMEOUT_MS = 60_000;
const DEFAULT_DOWNLOAD_FILE_TIMEOUT_MS = 60_000;
function downloadFileTimeoutMs(): number {
return positiveIntEnv('SNOWLUMA_DOWNLOAD_FILE_TIMEOUT_MS', DEFAULT_DOWNLOAD_FILE_TIMEOUT_MS);
}
const profileLikeUserSchema = {
type: 'object',
@@ -2592,7 +2597,7 @@ export const actions = [
buf = Buffer.from(base64, 'base64');
if (buf.length > DOWNLOAD_FILE_MAX_BYTES) return failedResponse(RETCODE.BAD_REQUEST, `base64 payload too large: ${buf.length} > ${DOWNLOAD_FILE_MAX_BYTES} bytes`);
} else {
buf = await fetchDownloadFile(url, parseDownloadHeaders(p.headers), DOWNLOAD_FILE_MAX_BYTES, DOWNLOAD_FILE_TIMEOUT_MS);
buf = await fetchDownloadFile(url, parseDownloadHeaders(p.headers), DOWNLOAD_FILE_MAX_BYTES, downloadFileTimeoutMs());
}
try {
const safe = await saveDownloadBuffer(buf, name);
+64 -1
View File
@@ -1,3 +1,4 @@
import { positiveIntEnv } from '@snowluma/common/env';
import { createLogger } from '@snowluma/common/logger';
import { isRealUin } from '@snowluma/common/uin';
import { randomBytes } from 'crypto';
@@ -110,7 +111,69 @@ export function loadOneBotConfig(uin: string, options: LoadOneBotConfigOptions =
saveOneBotConfig(uin, config, { mode: globalRaw ? 'overlay' : 'snapshot' });
}
return config;
// Listen-port overrides stay in memory. Persisting them would bake a
// machine-specific port into onebot_<uin>.json.
return applyOneBotListenPortOverrides(config);
}
const HTTP_DEFAULT_SERVER = 'http-default';
const WS_DEFAULT_SERVER = 'ws-default';
/** Override only the factory listeners. Other adapters keep their saved ports. */
export function applyOneBotListenPortOverrides(
config: OneBotConfig,
env: NodeJS.ProcessEnv = process.env,
): OneBotConfig {
const httpPort = readListenPort('SNOWLUMA_ONEBOT_HTTP_PORT', env);
const wsPort = readListenPort('SNOWLUMA_ONEBOT_WS_PORT', env);
if (httpPort === undefined && wsPort === undefined) return config;
const next = structuredClone(config);
const httpApplied = httpPort !== undefined && assignFactoryPort(
next.networks.httpServers,
HTTP_DEFAULT_SERVER,
httpPort,
'SNOWLUMA_ONEBOT_HTTP_PORT',
);
const wsApplied = wsPort !== undefined && assignFactoryPort(
next.networks.wsServers,
WS_DEFAULT_SERVER,
wsPort,
'SNOWLUMA_ONEBOT_WS_PORT',
);
if (!httpApplied && !wsApplied) return config;
try {
assertValidOneBotConfig(next);
} catch (error) {
log.warn(
'OneBot listen port override ignored: %s',
error instanceof Error ? error.message : String(error),
);
return config;
}
return next;
}
function readListenPort(name: string, env: NodeJS.ProcessEnv): number | undefined {
const raw = env[name];
if (typeof raw !== 'string' || !raw.trim()) return undefined;
const port = positiveIntEnv(name, 0, { max: 65535, env });
return port > 0 ? port : undefined;
}
function assignFactoryPort(
servers: Array<{ name: string; port: number }>,
name: string,
port: number,
envName: string,
): boolean {
const server = servers.find((entry) => entry.name === name);
if (!server) {
log.warn('%s is ignored; no %s listener', envName, name);
return false;
}
server.port = port;
return true;
}
export interface SaveOneBotConfigOptions {
+20 -1
View File
@@ -1,7 +1,10 @@
import { createLogger } from '@snowluma/common/logger';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
const log = createLogger('OneBot.Stream');
export interface StreamStorageSnapshot {
totalBytes: number;
fileCount: number;
@@ -273,7 +276,23 @@ function resolveManagedRoot(input: string): string {
return path.join(fs.realpathSync(parent), path.basename(absolute));
}
export const streamStorage = new StreamStorage(path.join(os.tmpdir(), 'onebot-stream'));
export function createStreamStorage(env: NodeJS.ProcessEnv = process.env): StreamStorage {
const fallback = path.join(os.tmpdir(), 'onebot-stream');
const raw = env.SNOWLUMA_STREAM_DIR?.trim();
if (!raw) return new StreamStorage(fallback);
try {
return new StreamStorage(raw);
} catch (error) {
log.warn(
'SNOWLUMA_STREAM_DIR=%j is ignored: %s',
raw,
error instanceof Error ? error.message : String(error),
);
return new StreamStorage(fallback);
}
}
export const streamStorage = createStreamStorage();
export const STREAM_ROOT = streamStorage.root;
export const STREAM_UPLOAD_DIR = streamStorage.uploadDir;
export const STREAM_DOWNLOAD_DIR = streamStorage.downloadDir;
+58
View File
@@ -640,3 +640,61 @@ describe('OneBotConfig login history sync', () => {
.toThrow(/historySync\.enabled must be a boolean/);
});
});
describe('OneBot listen port environment overrides', () => {
let tempDir = '';
let previous = '';
const saved: Record<string, string | undefined> = {};
beforeEach(() => {
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'snowluma-onebot-port-env-'));
previous = process.cwd();
process.chdir(tempDir);
for (const name of ['SNOWLUMA_ONEBOT_HTTP_PORT', 'SNOWLUMA_ONEBOT_WS_PORT']) {
saved[name] = process.env[name];
delete process.env[name];
}
});
afterEach(() => {
for (const name of ['SNOWLUMA_ONEBOT_HTTP_PORT', 'SNOWLUMA_ONEBOT_WS_PORT']) {
if (saved[name] === undefined) delete process.env[name];
else process.env[name] = saved[name];
}
process.chdir(previous);
fs.rmSync(tempDir, { recursive: true, force: true });
});
it('overrides only the factory listeners and does not write the port back', () => {
process.env.SNOWLUMA_ONEBOT_HTTP_PORT = '3800';
process.env.SNOWLUMA_ONEBOT_WS_PORT = '3801';
const loaded = loadOneBotConfig('10001', { persistDefaults: true });
expect(loaded.networks.httpServers[0]?.port).toBe(3800);
expect(loaded.networks.wsServers[0]?.port).toBe(3801);
const onDisk = JSON.parse(fs.readFileSync(path.join(tempDir, 'config', 'onebot_10001.json'), 'utf8')) as {
networks: { httpServers: Array<{ port: number }>; wsServers: Array<{ port: number }> };
};
expect(onDisk.networks.httpServers[0]?.port).toBe(3000);
expect(onDisk.networks.wsServers[0]?.port).toBe(3001);
});
it('leaves a custom listener alone', () => {
const config = makeDefaultOneBotConfig();
config.networks.httpServers = [{
...config.networks.httpServers[0]!,
name: 'public-http',
port: 4000,
}];
saveOneBotConfig('10001', config);
process.env.SNOWLUMA_ONEBOT_HTTP_PORT = '3800';
const loaded = loadOneBotConfig('10001');
expect(loaded.networks.httpServers.map((server) => server.port)).toEqual([4000]);
});
it('keeps the saved port when the override is not a port', () => {
process.env.SNOWLUMA_ONEBOT_HTTP_PORT = 'nope';
const loaded = loadOneBotConfig('10001', { persistDefaults: true });
expect(loaded.networks.httpServers[0]?.port).toBe(3000);
});
});
@@ -0,0 +1,36 @@
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { afterEach, describe, expect, it } from 'vitest';
import { createStreamStorage } from '../src/stream-storage';
describe('SNOWLUMA_STREAM_DIR', () => {
const created: string[] = [];
afterEach(() => {
for (const directory of created.splice(0)) {
fs.rmSync(directory, { recursive: true, force: true });
}
});
it('uses the system temp directory by default', () => {
const storage = createStreamStorage({});
expect(storage.root.endsWith(`${path.sep}onebot-stream`)).toBe(true);
created.push(storage.root);
});
it('uses a caller directory when the variable is set', () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'snowluma-stream-env-'));
created.push(root);
const storage = createStreamStorage({ SNOWLUMA_STREAM_DIR: root });
expect(storage.root).toBe(fs.realpathSync(root));
});
it('falls back when the variable does not name a usable directory', () => {
const file = path.join(os.tmpdir(), `snowluma-stream-not-dir-${process.pid}`);
fs.writeFileSync(file, 'x');
created.push(file);
const storage = createStreamStorage({ SNOWLUMA_STREAM_DIR: file });
expect(storage.root.endsWith(`${path.sep}onebot-stream`)).toBe(true);
});
});