diff --git a/config.yaml.example b/config.yaml.example index 27b17f5..395e8c1 100644 --- a/config.yaml.example +++ b/config.yaml.example @@ -328,8 +328,9 @@ tools: # self_signed_dir: ./data/tls # self_signed_hosts: [] # localhost / 127.0.0.1 / ::1 / hostname は常に含まれる # http_redirect: true -# http_redirect_port: 9080 # HTTPS ポートと異なる値にすること -# redirect_host: null # リダイレクト先のホスト; null = バインドホストを使用 +# http_redirect_port: 9080 # HTTP→HTTPS リダイレクトの待ち受けポート。単一値か配列で複数指定可 (例: [80, 9876])。 +# # 各ポートは HTTPS ポートと異なる必要があり、1024 未満 (80 等) の bind には権限が必要 +# redirect_host: null # リダイレクト先のホスト; null かつワイルドカード bind の場合はリクエストの Host ヘッダから導出 # # リバースプロキシ構成: このブロックを省略するか enabled: false のままにすること。 # # プロキシが TLS を終端している場合に native TLS を有効にすると二重終端になり壊れる。 # # noVNC / SSH コンソールを wss で使う場合は「信頼済み証明書」が必要 diff --git a/src/bridge/server.ts b/src/bridge/server.ts index f1507c9..aff9fb5 100644 --- a/src/bridge/server.ts +++ b/src/bridge/server.ts @@ -1352,11 +1352,16 @@ export function startCoreServer(opts: CoreServerOptions, port: number = 9876): v ); } const pinnedHost = tls.redirectHost ?? (isWildcardBind || isLoopbackBind ? 'localhost' : host); - const redirector = createHttpRedirectServer({ httpsPort: port, pinnedHost, preferRequestHost }); - redirector.on('error', (e) => logger.warn(`[server] HTTP redirect listener error: ${(e as Error).message}`)); - redirector.listen(tls.httpRedirectPort, host, () => - logger.info(`HTTP->HTTPS redirect listening on http://${host}:${tls.httpRedirectPort}`), - ); + // One listener per configured redirect port (e.g. 80 plus a high port). + for (const rport of tls.httpRedirectPorts) { + const redirector = createHttpRedirectServer({ httpsPort: port, pinnedHost, preferRequestHost }); + redirector.on('error', (e) => + logger.warn(`[server] HTTP redirect listener error on :${rport}: ${(e as Error).message}`), + ); + redirector.listen(rport, host, () => + logger.info(`HTTP->HTTPS redirect listening on http://${host}:${rport}`), + ); + } } } else { server = finalApp.listen(port, host, () => { diff --git a/src/server/config.test.ts b/src/server/config.test.ts index 27eec0b..8a71943 100644 --- a/src/server/config.test.ts +++ b/src/server/config.test.ts @@ -22,13 +22,43 @@ describe('mergeServerConfig', () => { expect(cfg.tls.minVersion).toBe(SERVER_TLS_DEFAULTS.minVersion); expect(cfg.tls.selfSignedDir).toBe(SERVER_TLS_DEFAULTS.selfSignedDir); expect(cfg.tls.httpRedirect).toBe(true); - expect(cfg.tls.httpRedirectPort).toBe(9080); + expect(cfg.tls.httpRedirectPorts).toEqual([9080]); expect(cfg.tls.selfSignedHosts).toEqual([]); }); - it('throws when http_redirect_port equals the https port', () => { + it('normalizes a single http_redirect_port (legacy scalar) into an array', () => { + const cfg = mergeServerConfig( + { tls: { enabled: true, httpRedirectPort: 8080 } as never }, + { freshInstall: false }, + ); + expect(cfg.tls.httpRedirectPorts).toEqual([8080]); + // The legacy scalar key must not leak onto the resolved object. + expect((cfg.tls as Record).httpRedirectPort).toBeUndefined(); + }); + + it('accepts a list of redirect ports and de-duplicates / drops invalid', () => { + const cfg = mergeServerConfig( + { tls: { enabled: true, httpRedirectPort: [80, 9876, 80, 70000, 0] } as never }, + { freshInstall: false }, + ); + expect(cfg.tls.httpRedirectPorts).toEqual([80, 9876]); + }); + + it('throws when any redirect port equals the https port (scalar)', () => { expect(() => - mergeServerConfig({ tls: { enabled: true, httpRedirectPort: 9876 } }, { freshInstall: false, httpsPort: 9876 }), + mergeServerConfig( + { tls: { enabled: true, httpRedirectPort: 9876 } as never }, + { freshInstall: false, httpsPort: 9876 }, + ), + ).toThrow(/redirect.*port/i); + }); + + it('throws when one port in a redirect list collides with the https port', () => { + expect(() => + mergeServerConfig( + { tls: { enabled: true, httpRedirectPort: [80, 9876] } as never }, + { freshInstall: false, httpsPort: 9876 }, + ), ).toThrow(/redirect.*port/i); }); @@ -47,7 +77,7 @@ describe('mergeServerConfig', () => { it('does not throw on port collision when httpRedirect is false', () => { expect(() => mergeServerConfig( - { tls: { enabled: true, httpRedirect: false, httpRedirectPort: 9876 } }, + { tls: { enabled: true, httpRedirect: false, httpRedirectPort: 9876 } as never }, { freshInstall: false, httpsPort: 9876 }, ), ).not.toThrow(); diff --git a/src/server/config.ts b/src/server/config.ts index d6fd2a5..38e140e 100644 --- a/src/server/config.ts +++ b/src/server/config.ts @@ -6,7 +6,12 @@ export interface ServerTlsConfig { selfSignedDir: string; selfSignedHosts: string[]; httpRedirect: boolean; - httpRedirectPort: number; + /** + * Ports the HTTP→HTTPS redirector listens on, resolved to a non-empty, + * de-duplicated array. Config accepts `http_redirect_port` as either a single + * number (e.g. `9080`) or a list (e.g. `[80, 9876]`); both map here. + */ + httpRedirectPorts: number[]; /** * Host used to build the HTTP→HTTPS redirect `Location` header. * When null the bind host is used instead. @@ -52,6 +57,9 @@ export function resolveListenPort( return DEFAULT_SERVER_PORT; } +/** Default HTTP→HTTPS redirect port when none is configured. */ +export const DEFAULT_HTTP_REDIRECT_PORTS = [9080]; + export const SERVER_TLS_DEFAULTS: ServerTlsConfig = { enabled: false, certFile: null, @@ -60,10 +68,25 @@ export const SERVER_TLS_DEFAULTS: ServerTlsConfig = { selfSignedDir: './data/tls', selfSignedHosts: [], httpRedirect: true, - httpRedirectPort: 9080, + httpRedirectPorts: [...DEFAULT_HTTP_REDIRECT_PORTS], redirectHost: null, }; +/** + * Coerce a raw `http_redirect_port` / `http_redirect_ports` value into a + * non-empty, de-duplicated array of valid ports. Accepts a single number, an + * array, or numeric strings. Invalid/out-of-range entries are dropped; if + * nothing valid remains, falls back to the default. + */ +export function normalizeRedirectPorts(input: unknown): number[] { + const arr = Array.isArray(input) ? input : input == null ? [] : [input]; + const valid = arr + .map((v) => (typeof v === 'string' ? Number(v) : v)) + .filter((n): n is number => typeof n === 'number' && Number.isInteger(n) && n >= 1 && n <= 65535); + const unique = [...new Set(valid)]; + return unique.length > 0 ? unique : [...DEFAULT_HTTP_REDIRECT_PORTS]; +} + export interface MergeServerOpts { freshInstall: boolean; /** @@ -78,13 +101,20 @@ export function mergeServerConfig( partial: Partial | undefined, opts: MergeServerOpts, ): ServerConfig { - const tlsPartial = (partial?.tls ?? {}) as Partial; + // Accept the legacy scalar `http_redirect_port` alongside the resolved + // `http_redirect_ports` array — both arrive untyped from YAML/JSON. + const tlsPartial = (partial?.tls ?? {}) as Partial & { + httpRedirectPort?: number | number[]; + }; const enabledDefault = opts.freshInstall; const tls: ServerTlsConfig = { ...SERVER_TLS_DEFAULTS, ...tlsPartial, enabled: tlsPartial.enabled ?? enabledDefault, + httpRedirectPorts: normalizeRedirectPorts(tlsPartial.httpRedirectPorts ?? tlsPartial.httpRedirectPort), }; + // Drop the legacy scalar so the resolved object exposes only httpRedirectPorts. + delete (tls as unknown as Record)['httpRedirectPort']; tls.selfSignedHosts = [...(tlsPartial.selfSignedHosts ?? SERVER_TLS_DEFAULTS.selfSignedHosts)]; if (tls.enabled) { @@ -93,8 +123,11 @@ export function mergeServerConfig( if (hasCert !== hasKey) { throw new Error('server.tls: set both cert_file and key_file, or neither'); } - if (opts.httpsPort != null && tls.httpRedirect && tls.httpRedirectPort === opts.httpsPort) { - throw new Error(`server.tls: http_redirect_port (${tls.httpRedirectPort}) must differ from the HTTPS port`); + if (opts.httpsPort != null && tls.httpRedirect) { + const clash = tls.httpRedirectPorts.find((p) => p === opts.httpsPort); + if (clash != null) { + throw new Error(`server.tls: http_redirect_port (${clash}) must differ from the HTTPS port`); + } } } // opts.httpsPort is the already-resolved listen port in production; the diff --git a/ui/src/components/settings/ServerTlsForm.tsx b/ui/src/components/settings/ServerTlsForm.tsx index 8eda96f..1bceb4d 100644 --- a/ui/src/components/settings/ServerTlsForm.tsx +++ b/ui/src/components/settings/ServerTlsForm.tsx @@ -97,18 +97,30 @@ export function ServerTlsForm({ config, onChange }: SectionFormProps) { - {/* HTTP redirect port */} + {/* HTTP redirect port(s) — accepts a single port or a comma-separated list */}
{t('serverTls.httpRedirectPort')} { - const n = parseInt(v, 10); - onChange('server.tls.httpRedirectPort', isNaN(n) ? undefined : n); + const ports = v + .split(',') + .map(s => parseInt(s.trim(), 10)) + .filter(n => !isNaN(n)); + onChange( + 'server.tls.httpRedirectPort', + ports.length === 0 ? undefined : ports.length === 1 ? ports[0] : ports, + ); }} - placeholder="9080" + placeholder="9080 (or 80, 9876)" /> + {t('serverTls.httpRedirectPortHelp')}
{/* Redirect host (optional) */} diff --git a/ui/src/i18n/locales/en/settings.json b/ui/src/i18n/locales/en/settings.json index 599228e..743ab70 100644 --- a/ui/src/i18n/locales/en/settings.json +++ b/ui/src/i18n/locales/en/settings.json @@ -790,7 +790,8 @@ "keyFile": "Private key file (PEM)", "certHelp": "Leave both empty to use an auto-generated self-signed certificate.", "httpRedirect": "Redirect HTTP to HTTPS", - "httpRedirectPort": "HTTP redirect port", + "httpRedirectPort": "HTTP redirect port(s)", + "httpRedirectPortHelp": "Port(s) that redirect plain HTTP to HTTPS. One port, or a comma-separated list (e.g. 80, 9876). Each must differ from the HTTPS port; ports below 1024 (like 80) require privileges.", "redirectHost": "Redirect host (optional)", "selfSignedHosts": "Additional certificate hostnames", "restartBanner": "Changes to HTTPS settings require a server restart to take effect." diff --git a/ui/src/i18n/locales/ja/settings.json b/ui/src/i18n/locales/ja/settings.json index 8e54a72..deaf301 100644 --- a/ui/src/i18n/locales/ja/settings.json +++ b/ui/src/i18n/locales/ja/settings.json @@ -791,6 +791,7 @@ "certHelp": "両方を空欄にすると、自動生成の自己署名証明書を使用します。", "httpRedirect": "HTTP を HTTPS へリダイレクト", "httpRedirectPort": "HTTP リダイレクトポート", + "httpRedirectPortHelp": "平文 HTTP を HTTPS へリダイレクトするポート。単一ポートか、カンマ区切りの複数指定が可能(例: 80, 9876)。各ポートは HTTPS ポートと異なる必要があり、1024 未満(80 など)の bind には権限が必要。", "redirectHost": "リダイレクト先ホスト(省略可)", "selfSignedHosts": "証明書の追加ホスト名", "restartBanner": "HTTPS 設定の変更を反映するには、サーバーの再起動が必要です。"