diff --git a/.config/docker_example.yml b/.config/docker_example.yml index c80cb3851c9..cdd6346969d 100644 --- a/.config/docker_example.yml +++ b/.config/docker_example.yml @@ -2,6 +2,10 @@ # Misskey configuration #━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +# Validate this configuration before Misskey starts. +# Use "legacy" only temporarily to retain the unvalidated behavior of older versions. +# configValidation: strict + # ┌─────┐ #───┘ URL └───────────────────────────────────────────────────── diff --git a/.config/example.yml b/.config/example.yml index 9657d4f4f2f..38546a677e1 100644 --- a/.config/example.yml +++ b/.config/example.yml @@ -73,6 +73,10 @@ # # setupPassword: example_password_please_change_this_or_you_will_get_hacked +# Validate this configuration before Misskey starts. +# Use "legacy" only temporarily to retain the unvalidated behavior of older versions. +# configValidation: strict + # ┌─────┐ #───┘ URL └───────────────────────────────────────────────────── diff --git a/.github/misskey/test.yml b/.github/misskey/test.yml index 513bfb1ac0c..3c807e8b9ea 100644 --- a/.github/misskey/test.yml +++ b/.github/misskey/test.yml @@ -15,5 +15,3 @@ redis: host: 127.0.0.1 port: 56312 id: aidx - -proxyRemoteFiles: true diff --git a/CHANGELOG.md b/CHANGELOG.md index 7051a35b78b..b91b14014f3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ ## Unreleased +### Note + +- configファイルはデフォルトで設定値の検証が行われるようになりました。ソフトウェア更新に際して `pnpm --filter backend validate-config` を実行し、報告された問題を修正してください。 + - `signToActivityPubGet`、`proxyRemoteFiles`、`disallowExternalApRedirect` がconfigファイルに残っている場合は削除してください。これらの設定はコントロールパネルへ移動済みです。 + - 問題をすぐに修正できない場合は、configファイルに `configValidation: legacy` を指定すると、警告を出しつつ従来どおり検証せずに起動できます。 + ### General - @@ -7,8 +13,7 @@ - ### Server -- - +- Enhance: configファイルの読み込み時の設定値検証と検証コマンドを追加 ## 2026.7.0 diff --git a/package.json b/package.json index b70f20b34d5..c9f925c56e1 100644 --- a/package.json +++ b/package.json @@ -25,6 +25,7 @@ "private": true, "scripts": { "compile-config": "cd packages/backend && pnpm compile-config", + "validate-config": "cd packages/backend && pnpm validate-config", "build-pre": "node scripts/build-pre.mjs", "build-assets": "node ./scripts/build-assets.mjs", "build": "pnpm build-pre && pnpm -r build && pnpm build-assets", diff --git a/packages/backend/eslint.config.js b/packages/backend/eslint.config.js index d15a703ba2b..ceff428c2f5 100644 --- a/packages/backend/eslint.config.js +++ b/packages/backend/eslint.config.js @@ -19,7 +19,7 @@ export default [ languageOptions: { parserOptions: { parser: tsParser, - project: ['./tsconfig.json', './test/tsconfig.json', './test-federation/tsconfig.json'], + project: ['./tsconfig.json', './scripts/tsconfig.json', './test/tsconfig.json', './test-federation/tsconfig.json'], sourceType: 'module', tsconfigRootDir: import.meta.dirname, }, diff --git a/packages/backend/package.json b/packages/backend/package.json index 1fb8e50fc80..1cfd025abf9 100644 --- a/packages/backend/package.json +++ b/packages/backend/package.json @@ -15,6 +15,7 @@ "cli": "pnpm compile-config && node ./built/cli.js", "check:connect": "pnpm compile-config && node ./scripts/check_connect.js", "compile-config": "node ./scripts/compile_config.js", + "validate-config": "pnpm compile-config && tsx ./scripts/validate_config.ts", "build": "rolldown -c", "build:unit": "rolldown -c --sourcemap", "build:e2e": "rolldown -c --e2e", @@ -22,8 +23,8 @@ "watch": "pnpm compile-config && node ./scripts/watch.mjs", "restart": "pnpm build && pnpm start", "dev": "pnpm compile-config && rolldown -c --watch", - "typecheck": "tsc --noEmit && tsc -p test --noEmit && tsc -p test-federation --noEmit", - "eslint": "eslint --quiet \"{src,test-federation}/**/*.ts\"", + "typecheck": "tsc --noEmit && tsc -p scripts --noEmit && tsc -p test --noEmit && tsc -p test-federation --noEmit", + "eslint": "eslint --quiet \"{src,scripts,test-federation}/**/*.ts\"", "lint": "pnpm typecheck && pnpm eslint", "test": "pnpm build:unit && cross-env NODE_ENV=test pnpm compile-config && vitest --config vitest.config.unit.ts", "test:e2e": "pnpm build:e2e && cross-env NODE_ENV=test pnpm compile-config && vitest --config vitest.config.e2e.ts", @@ -138,8 +139,10 @@ "tinycolor2": "1.6.0", "tmp": "0.2.7", "tsc-alias": "1.9.0", + "tsx": "4.23.1", "typeorm": "1.1.0", "ulid": "3.0.2", + "valibot": "1.4.2", "vary": "1.1.2", "web-push": "3.6.7", "ws": "8.21.0", diff --git a/packages/backend/scripts/tsconfig.json b/packages/backend/scripts/tsconfig.json new file mode 100644 index 00000000000..eee74b2de9d --- /dev/null +++ b/packages/backend/scripts/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../tsconfig.json", + "compilerOptions": { + "noEmit": true, + "rootDir": ".." + }, + "include": [ + "./validate_config.ts" + ] +} diff --git a/packages/backend/scripts/validate_config.ts b/packages/backend/scripts/validate_config.ts new file mode 100644 index 00000000000..99ca7f16659 --- /dev/null +++ b/packages/backend/scripts/validate_config.ts @@ -0,0 +1,14 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { loadConfig } from '../src/config.js'; + +try { + loadConfig({ forceConfigValidation: true }); + console.log('Configuration is valid ✓'); +} catch (error) { + console.error(error instanceof Error ? error.message : 'Configuration validation failed'); + process.exitCode = 1; +} diff --git a/packages/backend/src/boot/master.ts b/packages/backend/src/boot/master.ts index 3cb340e9637..5800f29cdcf 100644 --- a/packages/backend/src/boot/master.ts +++ b/packages/backend/src/boot/master.ts @@ -152,11 +152,20 @@ function showNodejsVersion(): void { /** 設定を読み込み、成功時に後続のログ出力形式を適用します。 */ function loadConfigBoot(): Config { const configLogger = bootLogger.createSubLogger('config'); + const configWarnings: string[] = []; let config; try { - config = loadConfig(); + config = loadConfig({ + onWarning: warning => configWarnings.push(warning), + }); configureLogging(config.logging); + for (const warning of configWarnings) { + configLogger.warn({ + message: warning, + eventName: 'config.validation.disabled', + }); + } } catch (exception) { if (typeof exception === 'string') { configLogger.error(exception); diff --git a/packages/backend/src/config-schema.ts b/packages/backend/src/config-schema.ts new file mode 100644 index 00000000000..6498465f5e7 --- /dev/null +++ b/packages/backend/src/config-schema.ts @@ -0,0 +1,390 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import * as v from 'valibot'; +import ipaddr from 'ipaddr.js'; +import type * as Sentry from '@sentry/node'; +import type * as SentryVue from '@sentry/vue'; +import type { RedisOptions } from 'ioredis'; + +const unknownPropertyMessage = 'Unknown configuration property'; +const invalidUrlMessage = 'Must be a valid URL'; + +export type ConfigValidationMode = 'strict' | 'legacy'; + +const legacyConfigWarning = + 'Configuration validation is disabled because configValidation is set to legacy. ' + + 'Run "pnpm --filter backend validate-config" to validate this configuration.'; + +const PortSchema = v.pipe( + v.number(), + v.safeInteger('Must be an integer'), + v.minValue(0, 'Must be between 0 and 65535'), + v.maxValue(65535, 'Must be between 0 and 65535'), +); + +const PositiveIntegerSchema = v.pipe( + v.number(), + v.safeInteger('Must be an integer'), + v.minValue(1, 'Must be a positive integer'), +); + +const NonNegativeIntegerSchema = v.pipe( + v.number(), + v.safeInteger('Must be an integer'), + v.minValue(0, 'Must be a non-negative integer'), +); + +const PlainObjectSchema = v.pipe( + v.unknown(), + v.check( + input => { + if (typeof input !== 'object' || input === null || Array.isArray(input)) return false; + const prototype = Object.getPrototypeOf(input); + return prototype === Object.prototype || prototype === null; + }, + 'Must be an object', + ), +); + +function strictConfigObject(entries: TEntries) { + return v.pipe(PlainObjectSchema, v.strictObject(entries)); +} + +function looseConfigObject(entries: TEntries) { + return v.pipe(PlainObjectSchema, v.looseObject(entries)); +} + +const UrlSchema = v.pipe(v.string(), v.url(invalidUrlMessage)); + +const CidrSchema = v.pipe( + v.string(), + v.check(value => { + try { + ipaddr.parseCIDR(value); + return true; + } catch { + return false; + } + }, 'Must be a valid CIDR'), +); + +const LogLevelSettingSchema = v.picklist( + ['debug', 'info', 'warn', 'error', 'fatal', 'off'], + 'Must be one of debug, info, warn, error, fatal, or off', +); + +const AccessLogConfigurationSchema = strictConfigObject({ + statusClasses: v.optional(v.array(v.picklist( + ['2xx', '3xx', '4xx', '5xx'], + 'Must be one of 2xx, 3xx, 4xx, or 5xx', + ))), + bodies: v.optional(strictConfigObject({ + request: v.optional(v.boolean()), + response: v.optional(v.boolean()), + maxBytes: v.optional(v.pipe( + PositiveIntegerSchema, + v.maxValue(128 * 1024, 'Must be no greater than 131072'), + )), + })), +}); + +const LoggingConfigSchema = strictConfigObject({ + format: v.optional(v.picklist( + ['pretty', 'json'], + 'Must be either pretty or json', + )), + level: v.optional(LogLevelSettingSchema), + domains: v.optional(v.nullable(v.pipe( + v.record(v.string(), LogLevelSettingSchema), + v.check( + domains => Object.keys(domains).every(domain => + domain.length > 0 && + domain.trim() === domain && + domain.split('.').every(segment => segment.length > 0)), + 'Domain names must not be empty, padded, or contain empty segments', + ), + ))), + access: v.optional(AccessLogConfigurationSchema), + sql: v.optional(strictConfigObject({ + disableQueryTruncation: v.optional(v.boolean()), + enableQueryParamLogging: v.optional(v.boolean()), + })), +}); + +const DatabaseSchema = strictConfigObject({ + host: v.string(), + port: PortSchema, + db: v.optional(v.string()), + user: v.optional(v.string()), + pass: v.optional(v.string()), + disableCache: v.optional(v.boolean()), + extra: v.optional(v.record(v.string(), v.unknown())), +}); + +const DatabaseSlaveSchema = strictConfigObject({ + host: v.string(), + port: PortSchema, + db: v.string(), + user: v.string(), + pass: v.string(), +}); + +// Redisが管理するオプションは更新で増える可能性があるため、Misskey固有の項目だけ検証して未知キーを保持する。 +export type RedisOptionsSource = Partial & { + host: string; + port: number; + family?: 0 | 4 | 6; + pass?: string; + db?: number; + prefix?: string; +}; + +const RedisOptionsSourceSchema = looseConfigObject({ + host: v.string(), + port: PortSchema, + family: v.optional(v.picklist([0, 4, 6], 'Must be one of 0, 4, or 6')), + pass: v.optional(v.string()), + db: v.optional(NonNegativeIntegerSchema), + prefix: v.optional(v.string()), +}) as v.GenericSchema; + +const FulltextSearchSchema = strictConfigObject({ + provider: v.optional(v.picklist( + ['sqlLike', 'sqlPgroonga', 'meilisearch'], + 'Must be one of sqlLike, sqlPgroonga, or meilisearch', + )), +}); + +// Meilisearchクライアントへは文字列で渡すため、YAMLで数値として記述されたポートもここで正規化する。 +const MeilisearchPortSchema = v.pipe( + v.union([ + PortSchema, + v.pipe( + v.string(), + v.check(value => { + const number = Number(value); + return Number.isSafeInteger(number) && number >= 0 && number <= 65535; + }, 'Must be an integer between 0 and 65535'), + ), + ], 'Must be an integer between 0 and 65535'), + v.transform(String), +); + +const MeilisearchSchema = strictConfigObject({ + host: v.string(), + port: MeilisearchPortSchema, + apiKey: v.string(), + ssl: v.optional(v.boolean()), + index: v.string(), + scope: v.optional(v.union([ + v.picklist(['local', 'global'], 'Must be either local or global'), + v.array(v.string()), + ])), +}); + +export type SentryBackendConfig = { + options: Partial; + enableNodeProfiling: boolean; + disabledIntegrations?: string[]; +}; + +// Sentryが管理するoptionsとintegration設定は、SDK更新との互換性を保つため未知キーを許可する。 +const SentryNodeOptionsSchema = + looseConfigObject({}) as v.GenericSchema>; + +const SentryBackendConfigSchema = strictConfigObject({ + options: SentryNodeOptionsSchema, + enableNodeProfiling: v.boolean(), + disabledIntegrations: v.optional(v.array(v.string())), +}); + +export type SentryFrontendConfig = { + options: Partial & { dsn: string }; + vueIntegration?: SentryVue.VueIntegrationOptions | null; + browserTracingIntegration?: Parameters[0] | null; + replayIntegration?: Parameters[0] | null; +}; + +const SentryBrowserOptionsSchema = looseConfigObject({ + dsn: v.string(), +}) as v.GenericSchema; + +const SentryVueIntegrationOptionsSchema = + looseConfigObject({}) as v.GenericSchema; + +const SentryBrowserTracingOptionsSchema = + looseConfigObject({}) as v.GenericSchema>; + +const SentryReplayOptionsSchema = + looseConfigObject({}) as v.GenericSchema>; + +const SentryFrontendConfigSchema = strictConfigObject({ + options: SentryBrowserOptionsSchema, + vueIntegration: v.optional(v.nullable(SentryVueIntegrationOptionsSchema)), + browserTracingIntegration: v.optional(v.nullable(SentryBrowserTracingOptionsSchema)), + replayIntegration: v.optional(v.nullable(SentryReplayOptionsSchema)), +}); + +const OtelBackendConfigSchema = strictConfigObject({ + endpoint: v.optional(UrlSchema), + headers: v.optional(v.record(v.string(), v.string())), + sampleRate: v.optional(v.pipe( + v.number(), + v.minValue(0, 'Must be between 0 and 1'), + v.maxValue(1, 'Must be between 0 and 1'), + )), + capturePgSpans: v.optional(v.boolean()), + capturePgStatement: v.optional(v.boolean()), + capturePgConnectionSpans: v.optional(v.boolean()), + captureRedisCommandSpans: v.optional(v.boolean()), + captureRedisConnectionSpans: v.optional(v.boolean()), + captureRedisRootSpans: v.optional(v.boolean()), + resourceAttributes: v.optional(v.record(v.string(), v.string())), + propagateTraceToRemote: v.optional(v.boolean()), + jobTraceContextMode: v.optional(v.picklist( + ['link', 'parent'], + 'Must be either link or parent', + )), +}); + +const TrustProxySchema = v.union([ + v.boolean(), + v.string(), + v.array(v.string()), + NonNegativeIntegerSchema, +], 'Must be a boolean, string, string array, or non-negative integer'); + +const ConfigSourceWithMetadataSchema = strictConfigObject({ + _NOTE_: v.optional(v.string()), + configValidation: v.optional(v.picklist( + ['strict', 'legacy'], + 'Must be either strict or legacy', + )), + url: v.optional(UrlSchema), + port: v.optional(PortSchema), + socket: v.optional(v.string()), + trustProxy: v.optional(TrustProxySchema), + chmodSocket: v.optional(v.pipe( + v.string(), + v.regex(/^[0-7]{3,4}$/, 'Must be a three or four digit octal mode'), + )), + enableIpRateLimit: v.optional(v.boolean()), + disableHsts: v.optional(v.boolean()), + db: DatabaseSchema, + dbReplications: v.optional(v.boolean()), + dbSlaves: v.optional(v.array(DatabaseSlaveSchema)), + redis: RedisOptionsSourceSchema, + redisForPubsub: v.optional(RedisOptionsSourceSchema), + redisForJobQueue: v.optional(RedisOptionsSourceSchema), + redisForTimelines: v.optional(RedisOptionsSourceSchema), + redisForReactions: v.optional(RedisOptionsSourceSchema), + fulltextSearch: v.optional(FulltextSearchSchema), + meilisearch: v.optional(MeilisearchSchema), + sentryForBackend: v.optional(SentryBackendConfigSchema), + otelForBackend: v.optional(OtelBackendConfigSchema), + sentryForFrontend: v.optional(SentryFrontendConfigSchema), + publishTarballInsteadOfProvideRepositoryUrl: v.optional(v.boolean()), + setupPassword: v.optional(v.string()), + proxy: v.optional(UrlSchema), + proxySmtp: v.optional(UrlSchema), + proxyBypassHosts: v.optional(v.array(v.string())), + allowedPrivateNetworks: v.optional(v.array(CidrSchema)), + maxFileSize: v.optional(PositiveIntegerSchema), + clusterLimit: v.optional(PositiveIntegerSchema), + threadPoolSize: v.optional(PositiveIntegerSchema), + id: v.pipe( + v.string(), + v.transform(value => value.toLowerCase()), + v.picklist( + ['aid', 'aidx', 'meid', 'meidg', 'ulid', 'objectid'], + 'Must be one of aid, aidx, meid, meidg, ulid, or objectid', + ), + ), + outgoingAddress: v.optional(v.string()), + outgoingAddressFamily: v.optional(v.picklist( + ['ipv4', 'ipv6', 'dual'], + 'Must be one of ipv4, ipv6, or dual', + )), + deliverJobConcurrency: v.optional(PositiveIntegerSchema), + inboxJobConcurrency: v.optional(PositiveIntegerSchema), + relationshipJobConcurrency: v.optional(PositiveIntegerSchema), + deliverJobPerSec: v.optional(PositiveIntegerSchema), + inboxJobPerSec: v.optional(PositiveIntegerSchema), + relationshipJobPerSec: v.optional(PositiveIntegerSchema), + deliverJobMaxAttempts: v.optional(PositiveIntegerSchema), + inboxJobMaxAttempts: v.optional(PositiveIntegerSchema), + mediaProxy: v.optional(UrlSchema), + videoThumbnailGenerator: v.optional(UrlSchema), + perChannelMaxNoteCacheCount: v.optional(NonNegativeIntegerSchema), + perUserNotificationsMaxCount: v.optional(NonNegativeIntegerSchema), + deactivateAntennaThreshold: v.optional(NonNegativeIntegerSchema), + pidFile: v.optional(v.string()), + logging: v.optional(LoggingConfigSchema), +}); + +export const ConfigSourceSchema = v.pipe( + ConfigSourceWithMetadataSchema, + // 有効化された機能が参照する関連設定も、起動前に揃っていることを保証する。 + v.forward( + v.check( + config => config.dbReplications !== true || (config.dbSlaves?.length ?? 0) > 0, + 'dbSlaves must contain at least one replica when dbReplications is enabled', + ), + ['dbSlaves'], + ), + v.forward( + v.check( + config => config.fulltextSearch?.provider !== 'meilisearch' || config.meilisearch != null, + 'meilisearch must be configured when fulltextSearch.provider is meilisearch', + ), + ['meilisearch'], + ), + v.transform(({ _NOTE_: _, configValidation: __, ...source }) => source), +); + +export type ConfigSource = v.InferOutput; +export type FulltextSearchProvider = NonNullable['provider']>; +export type OtelBackendConfig = NonNullable; + +export type ParseConfigSourceOptions = { + forceValidation?: boolean; + onWarning?: (message: string) => void; +}; + +export function getConfigValidationMode(input: unknown): ConfigValidationMode { + if ( + typeof input === 'object' && + input !== null && + 'configValidation' in input && + input.configValidation === 'legacy' + ) { + return 'legacy'; + } + + return 'strict'; +} + +export function parseConfigSource(input: unknown, options: ParseConfigSourceOptions = {}): ConfigSource { + if (!options.forceValidation && getConfigValidationMode(input) === 'legacy') { + // legacyは旧バージョンと同じ無検証動作を維持し、呼び出し側へ警告だけを通知する。 + options.onWarning?.(legacyConfigWarning); + + const { _NOTE_: _, configValidation: __, ...source } = input as Record; + return source as ConfigSource; + } + + const result = v.safeParse(ConfigSourceSchema, input); + if (result.success) return result.output; + + const issues = result.issues.map(issue => { + const path = v.getDotPath(issue) ?? ''; + const message = issue.type === 'strict_object' && issue.expected === 'never' + ? unknownPropertyMessage + : issue.message; + return `- ${path}: ${message}`; + }); + throw new Error(`Invalid configuration:\n${issues.join('\n')}`); +} diff --git a/packages/backend/src/config.ts b/packages/backend/src/config.ts index e42fbc279ac..0a8aa8cce9a 100644 --- a/packages/backend/src/config.ts +++ b/packages/backend/src/config.ts @@ -7,126 +7,16 @@ import * as fs from 'node:fs'; import { fileURLToPath } from 'node:url'; import { dirname, resolve } from 'node:path'; import { type FastifyServerOptions } from 'fastify'; -import type * as Sentry from '@sentry/node'; -import type * as SentryVue from '@sentry/vue'; +import { parseConfigSource } from './config-schema.js'; import type { RedisOptions } from 'ioredis'; -import type { AccessLogConfiguration, LogFormat, LogLevelSetting } from './logging/types.js'; - -type RedisOptionsSource = Partial & { - host: string; - port: number; - family?: number; - pass: string; - db?: number; - prefix?: string; -}; - -type SentryBackendConfig = { - options: Partial; - enableNodeProfiling: boolean; - disabledIntegrations?: string[]; -}; - -/** - * 設定ファイルの型 - */ -type Source = { - url?: string; - port?: number; - socket?: string; - trustProxy?: FastifyServerOptions['trustProxy']; - chmodSocket?: string; - enableIpRateLimit?: boolean; - disableHsts?: boolean; - db: { - host: string; - port: number; - db?: string; - user?: string; - pass?: string; - disableCache?: boolean; - extra?: { [x: string]: string }; - }; - dbReplications?: boolean; - dbSlaves?: { - host: string; - port: number; - db: string; - user: string; - pass: string; - }[]; - redis: RedisOptionsSource; - redisForPubsub?: RedisOptionsSource; - redisForJobQueue?: RedisOptionsSource; - redisForTimelines?: RedisOptionsSource; - redisForReactions?: RedisOptionsSource; - fulltextSearch?: { - provider?: FulltextSearchProvider; - }; - meilisearch?: { - host: string; - port: string; - apiKey: string; - ssl?: boolean; - index: string; - scope?: 'local' | 'global' | string[]; - }; - sentryForBackend?: SentryBackendConfig; - sentryForFrontend?: { - options: Partial & { dsn: string }; - vueIntegration?: SentryVue.VueIntegrationOptions | null; - browserTracingIntegration?: Parameters[0] | null; - replayIntegration?: Parameters[0] | null; - }; - - publishTarballInsteadOfProvideRepositoryUrl?: boolean; - - setupPassword?: string; - - proxy?: string; - proxySmtp?: string; - proxyBypassHosts?: string[]; - - allowedPrivateNetworks?: string[]; - - maxFileSize?: number; - - clusterLimit?: number; - threadPoolSize?: number; - - id: string; - - outgoingAddress?: string; - outgoingAddressFamily?: 'ipv4' | 'ipv6' | 'dual'; - - deliverJobConcurrency?: number; - inboxJobConcurrency?: number; - relationshipJobConcurrency?: number; - deliverJobPerSec?: number; - inboxJobPerSec?: number; - relationshipJobPerSec?: number; - deliverJobMaxAttempts?: number; - inboxJobMaxAttempts?: number; - - mediaProxy?: string; - videoThumbnailGenerator?: string; - - perChannelMaxNoteCacheCount?: number; - perUserNotificationsMaxCount?: number; - deactivateAntennaThreshold?: number; - pidFile: string; - - logging?: { - format?: LogFormat; - level?: LogLevelSetting; - domains?: Record | null; - access?: AccessLogConfiguration; - sql?: { - disableQueryTruncation?: boolean, - enableQueryParamLogging?: boolean, - } - } -}; +import type { + ConfigSource, + FulltextSearchProvider, + OtelBackendConfig, + RedisOptionsSource, + SentryBackendConfig, + SentryFrontendConfig, +} from './config-schema.js'; export type Config = { url: string; @@ -143,7 +33,7 @@ export type Config = { user: string; pass: string; disableCache?: boolean; - extra?: { [x: string]: string }; + extra?: Record; }; dbReplications: boolean | undefined; dbSlaves: { @@ -153,17 +43,8 @@ export type Config = { user: string; pass: string; }[] | undefined; - fulltextSearch?: { - provider?: FulltextSearchProvider; - }; - meilisearch: { - host: string; - port: string; - apiKey: string; - ssl?: boolean; - index: string; - scope?: 'local' | 'global' | string[]; - } | undefined; + fulltextSearch: ConfigSource['fulltextSearch']; + meilisearch: ConfigSource['meilisearch']; proxy: string | undefined; proxySmtp: string | undefined; proxyBypassHosts: string[] | undefined; @@ -171,7 +52,7 @@ export type Config = { maxFileSize: number; clusterLimit: number | undefined; threadPoolSize: number; - id: string; + id: ConfigSource['id']; outgoingAddress: string | undefined; outgoingAddressFamily: 'ipv4' | 'ipv6' | 'dual' | undefined; deliverJobConcurrency: number | undefined; @@ -182,16 +63,7 @@ export type Config = { relationshipJobPerSec: number | undefined; deliverJobMaxAttempts: number | undefined; inboxJobMaxAttempts: number | undefined; - logging?: { - format?: LogFormat; - level?: LogLevelSetting; - domains?: Record | null; - access?: AccessLogConfiguration; - sql?: { - disableQueryTruncation?: boolean, - enableQueryParamLogging?: boolean, - } - } + logging: ConfigSource['logging']; version: string; publishTarballInsteadOfProvideRepositoryUrl: boolean; @@ -217,19 +89,20 @@ export type Config = { redisForTimelines: RedisOptions & RedisOptionsSource; redisForReactions: RedisOptions & RedisOptionsSource; sentryForBackend: SentryBackendConfig | undefined; - sentryForFrontend: { - options: Partial & { dsn: string }; - vueIntegration?: SentryVue.VueIntegrationOptions | null; - browserTracingIntegration?: Parameters[0] | null; - replayIntegration?: Parameters[0] | null; - } | undefined; + otelForBackend: OtelBackendConfig | undefined; + sentryForFrontend: SentryFrontendConfig | undefined; perChannelMaxNoteCacheCount: number; perUserNotificationsMaxCount: number; deactivateAntennaThreshold: number; - pidFile: string; + pidFile: string | undefined; }; -export type FulltextSearchProvider = 'sqlLike' | 'sqlPgroonga' | 'meilisearch'; +export type { FulltextSearchProvider }; + +export type LoadConfigOptions = { + forceConfigValidation?: boolean; + onWarning?: (message: string) => void; +}; const _filename = fileURLToPath(import.meta.url); const _dirname = dirname(_filename); @@ -256,7 +129,7 @@ export const compiledConfigFilePath = fs.existsSync(compiledConfigFilePathForTes ? compiledConfigFilePathForTest : resolve(projectBuiltDir, '.config.json'); -export function loadConfig(): Config { +export function loadConfig(options: LoadConfigOptions = {}): Config { if (!fs.existsSync(compiledConfigFilePath)) { throw new Error('Compiled configuration file not found. Try running \'pnpm compile-config\'.'); } @@ -266,7 +139,13 @@ export function loadConfig(): Config { const frontendManifestExists = fs.existsSync(resolve(projectBuiltDir, '_frontend_vite_/manifest.json')); const frontendEmbedManifestExists = fs.existsSync(resolve(projectBuiltDir, '_frontend_embed_vite_/manifest.json')); - const config = JSON.parse(fs.readFileSync(compiledConfigFilePath, 'utf-8')) as Source; + const config = parseConfigSource( + JSON.parse(fs.readFileSync(compiledConfigFilePath, 'utf-8')), + { + forceValidation: options.forceConfigValidation, + onWarning: options.onWarning, + }, + ); const url = tryCreateUrl(config.url ?? process.env.MISSKEY_URL ?? ''); const version = meta.version; @@ -278,6 +157,7 @@ export function loadConfig(): Config { const dbDb = config.db.db ?? process.env.DATABASE_DB ?? ''; const dbUser = config.db.user ?? process.env.DATABASE_USER ?? ''; const dbPass = config.db.pass ?? process.env.DATABASE_PASSWORD ?? ''; + const port = resolvePort(config.port, process.env.PORT); const externalMediaProxy = config.mediaProxy ? config.mediaProxy.endsWith('/') ? config.mediaProxy.substring(0, config.mediaProxy.length - 1) : config.mediaProxy @@ -290,7 +170,7 @@ export function loadConfig(): Config { publishTarballInsteadOfProvideRepositoryUrl: !!config.publishTarballInsteadOfProvideRepositoryUrl, setupPassword: config.setupPassword, url: url.origin, - port: config.port ?? parseInt(process.env.PORT ?? '', 10), + port, socket: config.socket, trustProxy: config.trustProxy ?? [ '10.0.0.0/8', @@ -322,6 +202,7 @@ export function loadConfig(): Config { redisForTimelines: config.redisForTimelines ? convertRedisOptions(config.redisForTimelines, host) : redis, redisForReactions: config.redisForReactions ? convertRedisOptions(config.redisForReactions, host) : redis, sentryForBackend: config.sentryForBackend, + otelForBackend: config.otelForBackend, sentryForFrontend: config.sentryForFrontend, id: config.id, proxy: config.proxy, @@ -362,8 +243,18 @@ function tryCreateUrl(url: string) { try { return new URL(url); } catch (_) { - throw new Error(`url="${url}" is not a valid URL.`); + throw new Error('Invalid configuration:\n- url: Must be a valid URL'); + } +} + +function resolvePort(configPort: number | undefined, environmentPort: string | undefined): number { + if (configPort != null) return configPort; + + const port = Number(environmentPort); + if (environmentPort == null || environmentPort.length === 0 || !Number.isSafeInteger(port) || port < 0 || port > 65535) { + throw new Error('Invalid configuration:\n- port: Must be an integer between 0 and 65535'); } + return port; } function convertRedisOptions(options: RedisOptionsSource, host: string): RedisOptions & RedisOptionsSource { diff --git a/packages/backend/test/unit/config-schema.ts b/packages/backend/test/unit/config-schema.ts new file mode 100644 index 00000000000..11c1f45cc94 --- /dev/null +++ b/packages/backend/test/unit/config-schema.ts @@ -0,0 +1,273 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import * as fs from 'node:fs'; +import { resolve } from 'node:path'; +import { describe, expect, test, vi } from 'vitest'; +import { load as loadYaml } from 'js-yaml'; +import { parseConfigSource } from '@/config-schema.js'; + +function createValidSource() { + return { + _NOTE_: 'generated metadata', + url: 'https://example.com/', + port: 3000, + db: { + host: 'localhost', + port: 5432, + db: 'misskey', + user: 'misskey', + pass: 'password', + }, + redis: { + host: 'localhost', + port: 6379, + }, + id: 'aidx', + } as const; +} + +describe('config schema', () => { + test.each([ + '.config/example.yml', + '.config/docker_example.yml', + '.config/playwright-devcontainer.yml', + '.github/misskey/test.yml', + ])('parses the repository configuration example %s', relativePath => { + const repositoryRoot = resolve(import.meta.dirname, '../../../..'); + const yaml = fs.readFileSync(resolve(repositoryRoot, relativePath), 'utf8'); + + expect(() => parseConfigSource(loadYaml(yaml))).not.toThrow(); + }); + + test('parses a valid source and removes compiler metadata', () => { + const source = parseConfigSource({ + ...createValidSource(), + configValidation: 'strict', + id: 'AIDX', + }); + + expect(source).not.toHaveProperty('_NOTE_'); + expect(source).not.toHaveProperty('configValidation'); + expect(source.db.port).toBe(5432); + expect(source.redis.port).toBe(6379); + expect(source.id).toBe('aidx'); + }); + + test('preserves the legacy unvalidated behavior and reports a warning to the caller', () => { + const onWarning = vi.fn(); + const source = parseConfigSource({ + ...createValidSource(), + configValidation: 'legacy', + db: { + host: 'localhost', + port: 'not-a-port', + }, + unknownOption: true, + }, { onWarning }); + + expect(onWarning).toHaveBeenCalledWith(expect.stringContaining('validate-config')); + expect(source).not.toHaveProperty('configValidation'); + expect(source).not.toHaveProperty('_NOTE_'); + expect(source.db.port).toBe('not-a-port'); + expect(source).toHaveProperty('unknownOption', true); + }); + + test('does not write legacy warnings directly to the console', () => { + const consoleWarn = vi.spyOn(console, 'warn').mockImplementation(() => undefined); + + parseConfigSource({ + ...createValidSource(), + configValidation: 'legacy', + }); + + expect(consoleWarn).not.toHaveBeenCalled(); + }); + + test('forces strict validation regardless of the configured mode', () => { + const onWarning = vi.fn(); + + expect(() => parseConfigSource({ + ...createValidSource(), + configValidation: 'legacy', + db: { + host: 'localhost', + port: 'not-a-port', + }, + }, { + forceValidation: true, + onWarning, + })).toThrowError(/db\.port/); + expect(onWarning).not.toHaveBeenCalled(); + }); + + test('rejects an invalid validation mode', () => { + expect(() => parseConfigSource({ + ...createValidSource(), + configValidation: 'disabled', + })).toThrowError(/configValidation: Must be either strict or legacy/); + }); + + test('retains options owned by Redis and Sentry', () => { + const source = parseConfigSource({ + ...createValidSource(), + redis: { + host: 'localhost', + port: 6379, + username: 'redis-user', + connectTimeout: 5000, + }, + sentryForBackend: { + enableNodeProfiling: false, + options: { + dsn: 'https://public@example.com/1', + tracesSampleRate: 0.5, + }, + }, + }); + + expect(source.redis.username).toBe('redis-user'); + expect(source.redis.connectTimeout).toBe(5000); + expect(source.sentryForBackend?.options.tracesSampleRate).toBe(0.5); + }); + + test('normalizes a numeric Meilisearch port', () => { + const source = parseConfigSource({ + ...createValidSource(), + fulltextSearch: { + provider: 'meilisearch', + }, + meilisearch: { + host: 'localhost', + port: 7700, + apiKey: '', + index: 'misskey', + }, + }); + + expect(source.meilisearch?.port).toBe('7700'); + }); + + test('reports all invalid nested values with their paths', () => { + expect(() => parseConfigSource({ + ...createValidSource(), + db: { + host: 'localhost', + port: '5432', + unknownOption: true, + }, + redis: { + host: 'localhost', + port: 70000, + family: 5, + }, + id: 'snowflake', + })).toThrowError(expect.objectContaining({ + message: expect.stringMatching( + /db\.port[\s\S]*db\.unknownOption[\s\S]*redis\.port[\s\S]*redis\.family[\s\S]*id/, + ), + })); + }); + + test('rejects unknown Misskey configuration properties', () => { + expect(() => parseConfigSource({ + ...createValidSource(), + proxyRemoteFiles: true, + })).toThrowError(/proxyRemoteFiles: Unknown configuration property/); + }); + + test('distinguishes missing and invalid values from unknown properties', () => { + expect(() => parseConfigSource({ + ...createValidSource(), + db: 'postgresql', + })).toThrowError(expect.objectContaining({ + message: expect.stringMatching(/db: Must be an object/), + })); + + expect(() => parseConfigSource({ + ...createValidSource(), + db: { + host: 'localhost', + }, + })).toThrowError(expect.objectContaining({ + message: expect.not.stringContaining('Unknown configuration property'), + })); + }); + + test('rejects arrays used in place of configuration objects', () => { + expect(() => parseConfigSource({ + ...createValidSource(), + logging: [], + sentryForBackend: { + options: [], + enableNodeProfiling: false, + }, + })).toThrowError(expect.objectContaining({ + message: expect.stringMatching(/sentryForBackend\.options: Must be an object[\s\S]*logging: Must be an object/), + })); + }); + + test('validates job rate limits and allowed private network CIDRs', () => { + expect(() => parseConfigSource({ + ...createValidSource(), + deliverJobPerSec: 0, + inboxJobPerSec: -1, + allowedPrivateNetworks: ['127.0.0.1', 'not-a-network'], + })).toThrowError(expect.objectContaining({ + message: expect.stringMatching( + /allowedPrivateNetworks\.0: Must be a valid CIDR[\s\S]*allowedPrivateNetworks\.1: Must be a valid CIDR[\s\S]*deliverJobPerSec: Must be a positive integer[\s\S]*inboxJobPerSec: Must be a positive integer/, + ), + })); + }); + + test('requires Meilisearch options when it is the selected provider', () => { + expect(() => parseConfigSource({ + ...createValidSource(), + fulltextSearch: { + provider: 'meilisearch', + }, + })).toThrowError(/meilisearch must be configured/); + }); + + test('requires a database slave when replication is enabled', () => { + expect(() => parseConfigSource({ + ...createValidSource(), + dbReplications: true, + dbSlaves: [], + })).toThrowError(/dbSlaves must contain at least one replica/); + }); + + test('validates logging and telemetry boundaries', () => { + expect(() => parseConfigSource({ + ...createValidSource(), + logging: { + format: 'ndjson', + access: { + bodies: { + maxBytes: 128 * 1024 + 1, + }, + }, + }, + otelForBackend: { + sampleRate: 1.1, + jobTraceContextMode: 'child', + }, + })).toThrowError(expect.objectContaining({ + message: expect.stringMatching( + /otelForBackend\.sampleRate[\s\S]*otelForBackend\.jobTraceContextMode[\s\S]*logging\.format[\s\S]*logging\.access\.bodies\.maxBytes/, + ), + })); + }); + + test('does not expose secret values in validation errors', () => { + const secret = 'secret-token-that-must-not-be-logged'; + + expect(() => parseConfigSource({ + ...createValidSource(), + proxy: secret, + setupPassword: secret, + })).toThrowError(expect.not.stringContaining(secret)); + }); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 60976eb41c3..a21d7960ccc 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -431,12 +431,18 @@ importers: tsc-alias: specifier: 1.9.0 version: 1.9.0 + tsx: + specifier: 4.23.1 + version: 4.23.1 typeorm: specifier: 1.1.0 version: 1.1.0(ioredis@5.11.1)(pg@8.22.0) ulid: specifier: 3.0.2 version: 3.0.2 + valibot: + specifier: 1.4.2 + version: 1.4.2(@typescript/typescript6@6.0.2) vary: specifier: 1.1.2 version: 1.1.2 @@ -8948,6 +8954,14 @@ packages: '@vue/composition-api': optional: true + valibot@1.4.2: + resolution: {integrity: sha512-gjdCvJ6d3RyHAneqxMYMW9QMCwYMb3jpOO0IyHZV1bnRHFBHrX3VkIILt5XYR0WhwHiH7Mty8ovuPZ/O3gamrg==} + peerDependencies: + typescript: '>=5' + peerDependenciesMeta: + typescript: + optional: true + valid-data-url@3.0.1: resolution: {integrity: sha512-jOWVmzVceKlVVdwjNSenT4PbGghU0SBIizAev8ofZVgivk/TVHXSbNL8LP6M3spZvkR9/QolkyJavGSX5Cs0UA==} engines: {node: '>=10'} @@ -17787,6 +17801,10 @@ snapshots: vue: 3.5.39(typescript@6.0.2) vue-demi: 0.14.10(vue@3.5.39(typescript@6.0.2)) + valibot@1.4.2(@typescript/typescript6@6.0.2): + optionalDependencies: + typescript: '@typescript/typescript6@6.0.2' + valid-data-url@3.0.1: {} validate-npm-package-license@3.0.4: