diff --git a/config.yaml.example b/config.yaml.example index 8d4127a..27b17f5 100644 --- a/config.yaml.example +++ b/config.yaml.example @@ -319,6 +319,7 @@ tools: # プロキシが TLS を終端しているので、ここで有効にすると二重終端になり接続が壊れる。 # # server: +# port: 9876 # HTTP(S) 待ち受けポート (default 9876)。環境変数 PORT (.env 含む) が優先。反映には再起動が必要 # tls: # enabled: true # フレッシュインストールのデフォルト; ブロック未記載=アップグレード時 false # cert_file: null # PEM 証明書パス (任意); cert_file と key_file は両方設定するか両方省略 diff --git a/src/bridge/server.ts b/src/bridge/server.ts index d6c2689..42884be 100644 --- a/src/bridge/server.ts +++ b/src/bridge/server.ts @@ -107,7 +107,7 @@ import { readGatewayConfig } from '../gateway/config.js'; import { createAdminGatewayStatusRouter } from './admin-gateway-status-api.js'; import { createServer as createHttpsServer } from 'https'; import { X509Certificate } from 'crypto'; -import { mergeServerConfig } from '../server/config.js'; +import { mergeServerConfig, resolveListenPort } from '../server/config.js'; import { resolveTlsOptions } from '../net/tls-options.js'; import { createHttpRedirectServer } from '../net/http-redirect.js'; @@ -297,7 +297,9 @@ export function createCoreServer(opts: CoreServerOptions): { // tls.enabled and the cookie secure flag. const serverCfg = mergeServerConfig(loadConfig().server, { freshInstall: false, - httpsPort: opts.listenPort ?? Number(process.env['PORT'] ?? 9876), + // startCoreServer threads the resolved port in via listenPort; the fallback + // (for callers that bypass it) applies the same PORT-env > config precedence. + httpsPort: opts.listenPort ?? resolveListenPort(process.env['PORT'], loadConfig().server?.port), }); if (authActive) { @@ -1152,9 +1154,7 @@ export function createCoreServer(opts: CoreServerOptions): { // (startCoreServer always sets it) over the PORT env-var guess so // the status endpoint matches what the bridge actually bound. // env-var fallback is kept for callers that bypass startCoreServer. - const envPortRaw = Number(process.env['PORT']); - const envPort = Number.isFinite(envPortRaw) && envPortRaw > 0 ? envPortRaw : 9876; - const actualPort = opts.listenPort ?? envPort; + const actualPort = opts.listenPort ?? resolveListenPort(process.env['PORT'], loadConfig().server?.port); const statusRouter = createAdminGatewayStatusRouter({ mount: gatewayMount, configManager: opts.configManager ?? null, @@ -1267,7 +1267,9 @@ function deriveCallbackBaseUrl(authConfig: AuthConfig | undefined): string { } } } - const port = Number(process.env.PORT ?? 9876); + // Same precedence as the listener (PORT env > server.port > default) so the + // derived OAuth callback origin matches the port the bridge actually binds. + const port = resolveListenPort(process.env.PORT, loadConfig().server?.port); return `http://localhost:${port}`; } diff --git a/src/server/config.test.ts b/src/server/config.test.ts index 0a81950..27eec0b 100644 --- a/src/server/config.test.ts +++ b/src/server/config.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest'; -import { mergeServerConfig, SERVER_TLS_DEFAULTS } from './config.js'; +import { mergeServerConfig, SERVER_TLS_DEFAULTS, resolveListenPort, DEFAULT_SERVER_PORT } from './config.js'; describe('mergeServerConfig', () => { it('upgrade-safe: an absent server block disables TLS', () => { @@ -58,4 +58,56 @@ describe('mergeServerConfig', () => { mergeServerConfig({ tls: { enabled: false, certFile: '/x/c.pem' } }, { freshInstall: false }), ).not.toThrow(); }); + + it('resolves port: httpsPort wins, else config port, else default', () => { + expect(mergeServerConfig({ port: 3000 }, { freshInstall: false }).port).toBe(3000); + expect(mergeServerConfig({ port: 3000 }, { freshInstall: false, httpsPort: 8443 }).port).toBe(8443); + expect(mergeServerConfig(undefined, { freshInstall: false }).port).toBe(DEFAULT_SERVER_PORT); + }); +}); + +describe('resolveListenPort', () => { + it('PORT env wins over config port and default', () => { + expect(resolveListenPort('4000', 5000)).toBe(4000); + }); + + it('falls back to config port when env is unset/empty', () => { + expect(resolveListenPort(undefined, 5000)).toBe(5000); + expect(resolveListenPort('', 5000)).toBe(5000); + }); + + it('falls back to the default when neither is set', () => { + expect(resolveListenPort(undefined, undefined)).toBe(DEFAULT_SERVER_PORT); + }); + + it('ignores an invalid env PORT and warns, then uses config', () => { + const warns: string[] = []; + expect(resolveListenPort('not-a-port', 5000, (m) => warns.push(m))).toBe(5000); + expect(resolveListenPort('70000', 5000, (m) => warns.push(m))).toBe(5000); // out of range + expect(warns.length).toBe(2); + }); + + it('ignores an invalid config port and warns, then uses the default', () => { + const warns: string[] = []; + expect(resolveListenPort(undefined, 0, (m) => warns.push(m))).toBe(DEFAULT_SERVER_PORT); + expect(resolveListenPort(undefined, 99999, (m) => warns.push(m))).toBe(DEFAULT_SERVER_PORT); + expect(resolveListenPort(undefined, 1.5, (m) => warns.push(m))).toBe(DEFAULT_SERVER_PORT); // non-integer + expect(warns.length).toBe(3); + }); + + it('accepts the boundary ports 1 and 65535', () => { + expect(resolveListenPort('1', undefined)).toBe(1); + expect(resolveListenPort('65535', undefined)).toBe(65535); + }); + + it('rejects env "0" and falls through', () => { + expect(resolveListenPort('0', 5000)).toBe(5000); + expect(resolveListenPort('0', undefined)).toBe(DEFAULT_SERVER_PORT); + }); + + it('accepts a quoted-string config port symmetrically with env', () => { + expect(resolveListenPort(undefined, '5000' as unknown as number)).toBe(5000); + expect(resolveListenPort(undefined, '' as unknown as number)).toBe(DEFAULT_SERVER_PORT); + expect(resolveListenPort(undefined, 'abc' as unknown as number)).toBe(DEFAULT_SERVER_PORT); + }); }); diff --git a/src/server/config.ts b/src/server/config.ts index 1eb6f7c..d6fd2a5 100644 --- a/src/server/config.ts +++ b/src/server/config.ts @@ -16,9 +16,42 @@ export interface ServerTlsConfig { } export interface ServerConfig { + /** HTTP(S) listen port. Resolved by mergeServerConfig (default 9876). */ + port: number; tls: ServerTlsConfig; } +/** Default HTTP(S) listen port when neither PORT nor server.port is set. */ +export const DEFAULT_SERVER_PORT = 9876; + +/** + * Resolve the effective listen port from the precedence + * `PORT env > server.port (config) > default`. Invalid values (non-integer or + * out of the 1–65535 range) are ignored with a caller-supplied warn hook so a + * typo can't bind port 0 / NaN. Pure: the env string is passed in. + */ +export function resolveListenPort( + envPort: string | undefined, + configPort: number | string | undefined, + warn?: (msg: string) => void, +): number { + const isValid = (n: unknown): n is number => + typeof n === 'number' && Number.isInteger(n) && n >= 1 && n <= 65535; + + if (envPort !== undefined && envPort !== '') { + const n = Number(envPort); + if (isValid(n)) return n; + warn?.(`[server] ignoring invalid PORT env "${envPort}" (want an integer 1–65535)`); + } + if (configPort !== undefined && configPort !== '') { + // Accept a numeric YAML value or a quoted "9876" string symmetrically with env. + const n = typeof configPort === 'string' ? Number(configPort) : configPort; + if (isValid(n)) return n; + warn?.(`[server] ignoring invalid server.port "${configPort}" (want an integer 1–65535)`); + } + return DEFAULT_SERVER_PORT; +} + export const SERVER_TLS_DEFAULTS: ServerTlsConfig = { enabled: false, certFile: null, @@ -33,6 +66,11 @@ export const SERVER_TLS_DEFAULTS: ServerTlsConfig = { export interface MergeServerOpts { freshInstall: boolean; + /** + * The already-resolved effective listen port (PORT env > server.port > + * default), threaded in by the caller. Becomes the merged `port` and is the + * value the http_redirect_port collision check compares against. + */ httpsPort?: number; } @@ -59,5 +97,8 @@ export function mergeServerConfig( throw new Error(`server.tls: http_redirect_port (${tls.httpRedirectPort}) must differ from the HTTPS port`); } } - return { tls }; + // opts.httpsPort is the already-resolved listen port in production; the + // fallback validates/coerces a raw config value for callers that don't pass it. + const port = opts.httpsPort ?? resolveListenPort(undefined, partial?.port); + return { port, tls }; } diff --git a/src/worker-bootstrap.ts b/src/worker-bootstrap.ts index fb06a07..923fc3c 100644 --- a/src/worker-bootstrap.ts +++ b/src/worker-bootstrap.ts @@ -14,6 +14,7 @@ */ import { Repository, BrowserSessionRepo } from './db/repository.js'; import { startCoreServer } from './bridge/server.js'; +import { resolveListenPort } from './server/config.js'; import { runMigrations } from './db/migrate.js'; import { logger } from './logger.js'; import { accessSync, existsSync, mkdirSync, constants } from 'fs'; @@ -147,7 +148,10 @@ export async function start(opts: StartWorkerOptions = {}): Promise { const workerManager = new WorkerManager(repo, configManager); workerManager.start(); - const port = parseInt(process.env['PORT'] ?? '9876', 10); + // Listen port precedence: PORT env (incl. .env loaded by scripts/server.sh) + // > config.yaml server.port > default 9876. UI/config changes apply on the + // next restart (no live re-bind), same as the TLS settings. + const port = resolveListenPort(process.env['PORT'], config.server?.port, (m) => logger.warn(m)); // タイトル自動生成関数を作成(roles に 'title' を持つ worker を優先、なければ最初の worker) const titleWorker = diff --git a/ui/src/components/settings/ServerTlsForm.tsx b/ui/src/components/settings/ServerTlsForm.tsx index 1eb465b..8eda96f 100644 --- a/ui/src/components/settings/ServerTlsForm.tsx +++ b/ui/src/components/settings/ServerTlsForm.tsx @@ -23,6 +23,7 @@ export function ServerTlsForm({ config, onChange }: SectionFormProps) { // Navigate to the server.tls sub-object; fall back to empty object if absent. const tls = (config?.server?.tls) ?? {}; + const server = (config?.server) ?? {}; return (
@@ -35,6 +36,21 @@ export function ServerTlsForm({ config, onChange }: SectionFormProps) { {t('serverTls.restartBanner')}
+ {/* HTTP(S) listen port */} +
+ {t('serverTls.port')} + { + const n = parseInt(v, 10); + onChange('server.port', isNaN(n) ? undefined : n); + }} + placeholder="9876" + /> + {t('serverTls.portHelp')} +
+ {/* Enable HTTPS */}