diff --git a/config/upstreams.php b/config/upstreams.php index 2cc1ab94..81fe5250 100644 --- a/config/upstreams.php +++ b/config/upstreams.php @@ -63,6 +63,9 @@ return [ 'operations_replica' => [ 'label' => '经营分析副本库', 'config' => 'operations_replica', + // Linux 生产镜像中该来源经私有隧道使用 FreeTDS 的 PDO_DBLIB。 + // Windows 和其他来源继续保持现有 PDO_ODBC 行为。 + 'use_pdo_dblib' => true, ], ], ]; diff --git a/deploy/Dockerfile b/deploy/Dockerfile index a85bdc1f..4dccd336 100644 --- a/deploy/Dockerfile +++ b/deploy/Dockerfile @@ -14,6 +14,7 @@ RUN apt-get update \ libxml2-dev \ libzip-dev \ freetds-bin \ + freetds-dev \ tdsodbc \ unixodbc-dev \ unzip \ @@ -27,6 +28,7 @@ RUN apt-get update \ gd \ intl \ mbstring \ + pdo_dblib \ pdo_odbc \ pdo_mysql \ simplexml \ @@ -68,6 +70,7 @@ COPY --from=frontend /js /var/www/html/public/js COPY --from=frontend /css/app.css /var/www/html/public/css/app.css COPY --from=frontend /mix-manifest.json /var/www/html/public/mix-manifest.json COPY deploy/apache-vhost.conf /etc/apache2/sites-available/000-default.conf +COPY deploy/freetds.conf /etc/freetds/freetds.conf COPY deploy/php.ini /usr/local/etc/php/conf.d/caiwu.ini COPY deploy/entrypoint.sh /usr/local/bin/caiwu-entrypoint COPY . /var/www/html diff --git a/deploy/freetds.conf b/deploy/freetds.conf new file mode 100644 index 00000000..aa06044a --- /dev/null +++ b/deploy/freetds.conf @@ -0,0 +1,7 @@ +[global] + # operations_replica 在生产中经受限私有隧道访问。PDO_DBLIB 由 + # FreeTDS 读取此配置,以请求 TLS;主机、端口和认证资料仍由应用 + # 从运行时私有配置和共享系统数据库解析,不能写入镜像。 + tds version = 7.4 + client charset = UTF-8 + encryption = request diff --git a/modules/EcommerceFinance/Services/UpstreamOdbcConnection.php b/modules/EcommerceFinance/Services/UpstreamOdbcConnection.php index fcd9183d..923f1ecd 100644 --- a/modules/EcommerceFinance/Services/UpstreamOdbcConnection.php +++ b/modules/EcommerceFinance/Services/UpstreamOdbcConnection.php @@ -10,7 +10,10 @@ use Symfony\Component\Process\PhpExecutableFinder; use Symfony\Component\Process\Process; /** - * 按来源隔离的 SQL Server PDO_ODBC 连接管理。 + * 按来源隔离的 SQL Server PDO 连接管理。 + * + * 默认使用 PDO_ODBC;仅生产的 operations_replica 通过已验证的 + * FreeTDS PDO_DBLIB 传输,以避免 unixODBC 在该私有隧道上的原生崩溃。 */ class UpstreamOdbcConnection { @@ -167,43 +170,34 @@ PHP; $applicationIntent = $applicationIntent === '' ? '' : 'ReadOnly'; - if (strcasecmp($driver, 'FreeTDS') === 0) { - $dsn = sprintf( - 'odbc:Driver={%s};Server=%s;Port=%d;Database=%s;UID=%s;PWD=%s;TDS_Version=%s;ClientCharset=UTF-8;Connect Timeout=%d', - str_replace('}', '}}', $driver), - self::quoteOdbcValue($host), - $port, - self::quoteOdbcValue($db), - self::quoteOdbcValue($user), - self::quoteOdbcValue($pass), - $tdsVersion, - $connectTimeout - ); - } else { - $dsn = sprintf( - 'odbc:Driver={%s};Server=%s;Database=%s;UID=%s;PWD=%s;Connection Timeout=%d', - str_replace('}', '}}', $driver), - self::quoteOdbcValue($host . ',' . $port), - self::quoteOdbcValue($db), - self::quoteOdbcValue($user), - self::quoteOdbcValue($pass), - $connectTimeout - ); - } - - $dsn .= self::encryptionAttributes($driver, $encryption); - - if ($applicationIntent !== '') { - $dsn .= ';ApplicationIntent=' . self::quoteOdbcValue($applicationIntent); - } - try { - return new PDO($dsn, null, null, [ - PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, - PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC, - PDO::ATTR_TIMEOUT => $queryTimeout, - PDO::ATTR_PERSISTENT => false, - ]); + if (self::shouldUsePdoDblib($source, $driver, $encryption)) { + if (preg_match('/[;\x00-\x1F]/', $host) === 1 || preg_match('/[;\x00-\x1F]/', $db) === 1) { + throw new ServiceUnavailableHttpException(60, '上游数据库配置不完整,请联系管理员。'); + } + + return new PDO( + self::pdoDblibDsn($host, $port, $db, $tdsVersion), + $user, + $pass, + self::pdoOptions($queryTimeout), + ); + } + + $dsn = self::odbcDsn( + $host, + $port, + $db, + $user, + $pass, + $driver, + $tdsVersion, + $encryption, + $connectTimeout, + $applicationIntent, + ); + + return new PDO($dsn, null, null, self::pdoOptions($queryTimeout)); } catch (PDOException $exception) { Log::warning('上游数据库连接失败。', [ 'source' => $source, @@ -234,6 +228,81 @@ PHP; return '{' . str_replace('}', '}}', $value) . '}'; } + private static function shouldUsePdoDblib(string $source, string $driver, string $encryption): bool + { + return PHP_OS_FAMILY !== 'Windows' + && extension_loaded('pdo_dblib') + && (bool) config('upstreams.sources.' . $source . '.use_pdo_dblib', false) + && strcasecmp($driver, 'FreeTDS') === 0 + // pdo_dblib 通过镜像内 FreeTDS 配置请求 TLS;其他安全模式 + // 保持在 ODBC 路径,避免静默降低连接安全要求。 + && $encryption === 'request'; + } + + private static function pdoDblibDsn(string $host, int $port, string $database, string $tdsVersion): string + { + return sprintf( + 'dblib:host=%s:%d;dbname=%s;version=%s;charset=UTF-8', + $host, + $port, + $database, + $tdsVersion, + ); + } + + private static function odbcDsn( + string $host, + int $port, + string $database, + string $username, + string $password, + string $driver, + string $tdsVersion, + string $encryption, + int $connectTimeout, + string $applicationIntent, + ): string { + if (strcasecmp($driver, 'FreeTDS') === 0) { + $dsn = sprintf( + 'odbc:Driver={%s};Server=%s;Port=%d;Database=%s;UID=%s;PWD=%s;TDS_Version=%s;ClientCharset=UTF-8;Connect Timeout=%d', + str_replace('}', '}}', $driver), + self::quoteOdbcValue($host), + $port, + self::quoteOdbcValue($database), + self::quoteOdbcValue($username), + self::quoteOdbcValue($password), + $tdsVersion, + $connectTimeout, + ); + } else { + $dsn = sprintf( + 'odbc:Driver={%s};Server=%s;Database=%s;UID=%s;PWD=%s;Connection Timeout=%d', + str_replace('}', '}}', $driver), + self::quoteOdbcValue($host . ',' . $port), + self::quoteOdbcValue($database), + self::quoteOdbcValue($username), + self::quoteOdbcValue($password), + $connectTimeout, + ); + } + + $dsn .= self::encryptionAttributes($driver, $encryption); + + return $applicationIntent === '' + ? $dsn + : $dsn . ';ApplicationIntent=' . self::quoteOdbcValue($applicationIntent); + } + + private static function pdoOptions(int $queryTimeout): array + { + return [ + PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, + PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC, + PDO::ATTR_TIMEOUT => $queryTimeout, + PDO::ATTR_PERSISTENT => false, + ]; + } + private static function encryptionAttributes(string $driver, string $mode): string { if (strcasecmp($driver, 'FreeTDS') === 0) { diff --git a/tests/Unit/EcommerceFinance/UpstreamOdbcConnectionTest.php b/tests/Unit/EcommerceFinance/UpstreamOdbcConnectionTest.php new file mode 100644 index 00000000..f5ede1b4 --- /dev/null +++ b/tests/Unit/EcommerceFinance/UpstreamOdbcConnectionTest.php @@ -0,0 +1,44 @@ +invoke(null, '127.0.0.1', 15433, 'reporting', '7.4'); + + $this->assertSame( + 'dblib:host=127.0.0.1:15433;dbname=reporting;version=7.4;charset=UTF-8', + $dsn, + ); + $this->assertStringNotContainsString('PWD=', $dsn); + $this->assertStringNotContainsString('UID=', $dsn); + } + + public function test_production_dblib_transport_is_only_opted_in_for_the_marked_source(): void + { + $method = new ReflectionMethod(UpstreamOdbcConnection::class, 'shouldUsePdoDblib'); + + config()->set('upstreams.sources.operations_replica.use_pdo_dblib', true); + config()->set('upstreams.sources.jiansun.use_pdo_dblib', false); + + $operations = $method->invoke(null, 'operations_replica', 'FreeTDS', 'request'); + $jiansun = $method->invoke(null, 'jiansun', 'FreeTDS', 'request'); + + if (PHP_OS_FAMILY !== 'Windows' && extension_loaded('pdo_dblib')) { + $this->assertTrue($operations); + } else { + $this->assertFalse($operations); + } + + $this->assertFalse($jiansun); + $this->assertFalse($method->invoke(null, 'operations_replica', 'FreeTDS', 'strict')); + } +}