Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
72 changes: 72 additions & 0 deletions .github/workflows/integration-tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -98,3 +98,75 @@ jobs:
- name: Show database logs on failure
if: failure()
run: docker logs --tail 80 dmpython-ci-dm8 || true

real-dm-ssl-arm:
name: Real DM8 SSL / ARM Linux / Python 3.10
runs-on: ubuntu-24.04-arm
timeout-minutes: 35
steps:
- uses: actions/checkout@v4

- uses: actions/setup-go@v5
with:
go-version: "1.21"
cache-dependency-path: dpi_bridge/go.sum

- uses: actions/setup-python@v5
with:
python-version: "3.10"

- name: Start isolated DM8 and extract build headers
run: |
admin_password="DmPyA1$(openssl rand -hex 12)"
echo "::add-mask::$admin_password"
{
echo "DM_CI_ADMIN_PASSWORD=$admin_password"
echo 'DM_SSL_TEST_PASSWORD='"$admin_password"
echo 'DM_SSL_TEST_USER=SYSDBA'
echo 'DM_SSL_TEST_HOST=127.0.0.1'
echo 'DM_SSL_TEST_PORT=15239'
echo "DM_SSL_TEST_PATH=$PWD/ssl-certs"
} >> "$GITHUB_ENV"
docker pull --platform linux/arm64 \
yhl452493373/dm8@sha256:5b9d23c04b148d5765d6077d95b64032be64daedabeef9cddc7a4216b9ad0a1a
docker run -d --user root --workdir /opt/dmdbms/bin --name dmpython-ci-dm8-ssl \
-e SYSDBA_PWD="$admin_password" \
-e SYSAUDITOR_PWD="$admin_password" \
-e CHARSET=1 -e DB_NAME=DMPYSSL -e INSTANCE_NAME=DMPYSSL \
-p 127.0.0.1:15239:5236 \
yhl452493373/dm8@sha256:5b9d23c04b148d5765d6077d95b64032be64daedabeef9cddc7a4216b9ad0a1a
mkdir -p dpi_include ssl-certs
docker cp dmpython-ci-dm8-ssl:/opt/dmdbms/drivers/dpi/include/. dpi_include/
docker cp dmpython-ci-dm8-ssl:/opt/dmdbms/bin/client_ssl/SYSDBA/. ssl-certs/
docker cp dmpython-ci-dm8-ssl:/opt/dmdbms/bin/server_ssl/server-cert.pem ssl-certs/server-cert.pem
test -f dpi_include/DPI.h
chmod 700 ssl-certs
chmod 600 ssl-certs/*

- name: Build driver and enable mandatory SSL
run: |
python -m pip install setuptools wheel pytest pytest-timeout
if ! python setup.py build_ext --inplace > /tmp/dmpython-ssl-build.log 2>&1; then
tail -80 /tmp/dmpython-ssl-build.log
exit 1
fi
PYTHONPATH="$PWD" python scripts/enable_dm_ssl_ci.py
docker restart dmpython-ci-dm8-ssl

- name: Run encrypted-connection regression
timeout-minutes: 10
env:
TZ: Asia/Shanghai
run: python -m pytest -q tests/ssl --junitxml=pytest-dm-ssl.xml

- name: Upload SSL regression evidence
if: always()
uses: actions/upload-artifact@v4
with:
name: real-dm-ssl-arm-py3.10
path: pytest-dm-ssl.xml
if-no-files-found: ignore

- name: Show SSL database logs on failure
if: failure()
run: docker logs --tail 80 dmpython-ci-dm8-ssl || true
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ For production environments, prefer the official [DamengDB/dmPython](https://git
- **Supported build targets**: macOS 14+ ARM64 with CPython 3.9–3.13. Database behavior is supported only where integration tests have evidence.
- **Best-effort**: Extended scenarios not currently covered by CI.
- **Not guaranteed**: Production SLA commitments, vendor-certified compatibility guarantees, and closed-source component support contracts.
- **Connection security**: SSL certificate and UKey login options are not implemented by the Go bridge. Non-empty `ssl_path`, `ssl_pwd`, `ukey_name`, or `ukey_pin` now raise an error instead of silently using a regular connection. MPP and read/write separation settings are passed through, but cluster routing needs a cluster regression environment.
- **Connection security**: `ssl_path` supports encrypted DM8 connections with client certificate and key. The directory must contain `ca-cert.pem`, `client-cert.pem`, and `client-key.pem`; bundled legacy server certificates without a SAN also require an exact `server-cert.pem` pin. A plain server is rejected when `ssl_path` is set. Non-empty `ssl_pwd`, `ukey_name`, and `ukey_pin` remain unsupported. MPP and read/write separation settings are passed through, but cluster routing needs a cluster regression environment.

## Roadmap & Status

Expand Down
2 changes: 1 addition & 1 deletion docs/README_zh.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ dmPython 是达梦数据库(DM8)的原生 Python 驱动程序,遵循 [Pyth
- **构建支持范围**:macOS 14+ ARM64、CPython 3.9–3.13。数据库行为仅以已有集成测试证据为准。
- **Best-effort(尽力支持)**:尚未纳入 CI 覆盖的扩展使用场景。
- **Not guaranteed(不保证)**:生产 SLA 承诺、厂商认证兼容性与闭源组件支持协议。
- **连接安全**:Go 桥接层尚未实现 SSL 证书和 UKey 登录。非空的 `ssl_path`、`ssl_pwd`、`ukey_name`、`ukey_pin` 现在会报错;MPP 与读写分离参数已传递到底层驱动,集群路由效果仍需集群环境回归。
- **连接安全**:`ssl_path` 支持使用客户端证书与私钥连接启用加密的 DM8。目录需包含 `ca-cert.pem`、`client-cert.pem`、`client-key.pem`;服务端证书没有 SAN 的旧版本还需提供与服务端完全一致的 `server-cert.pem`。指定 `ssl_path` 时,未协商加密的连接会报错。非空的 `ssl_pwd`、`ukey_name`、`ukey_pin` 仍不支持;MPP 与读写分离参数已传递到底层驱动,集群路由效果仍需集群环境回归。

## 路线图与状态

Expand Down
2 changes: 1 addition & 1 deletion docs/api-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@ dmPython.connect(
- IPv6 地址使用方括号,例如 `server="[::1]"`;`dsn` 可写为 `"[::1]:5236"`。
- `dmsvc_path` 指向包含 `dm_svc.conf` 的目录;连接时可把 `server` 设为配置文件中的服务名。
- `mpp_login` 接受 `DSQL_MPP_LOGIN_GLOBAL` 或 `DSQL_MPP_LOGIN_LOCAL`;`rwseparate` 接受 `DSQL_RWSEPARATE_OFF`、`DSQL_RWSEPARATE_ON` 或 `DSQL_RWSEPARATE_ON2`,`rwseparate_percent` 范围为 0–100。这些选项在建连时传给底层驱动,当前仅验证了单机实例上的 MPP LOCAL 握手和参数传递,读写路由仍需主备环境验证。
- 当前桥接层尚未实现与原生 DPI 语义一致的 SSL 证书和 UKey 登录;传入非空的 `ssl_path`、`ssl_pwd`、`ukey_name` 或 `ukey_pin` 会报错,避免按未启用的安全设置建立连接。
- `ssl_path` 指向含 `ca-cert.pem`、`client-cert.pem`、`client-key.pem` 的目录。服务端证书没有 SAN 时还需提供准确的 `server-cert.pem`,用于证书固定校验;有 SAN 的证书按 CA 链和主机名校验。设置后若服务端未协商加密,连接失败。非空的 `ssl_pwd`、`ukey_name`、`ukey_pin` 暂不支持。
- `user` 支持 `user/password@server:port[/schema][?catalog=...]` 形式。
- `login_timeout` 以毫秒为单位,默认 5000,限制首次建连握手;设为 0 表示不限制。`connection_timeout` 以秒为单位,默认 0 不限制,限制 SQL 执行时间。
- 常量参数建议使用模块常量(如 `DSQL_AUTOCOMMIT_ON`、`ISO_LEVEL_READ_COMMITTED`)。
Expand Down
2 changes: 2 additions & 0 deletions docs/ci.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@

The [data-type and connection-option matrix](test-results/2026-09-26-type-connection-matrix.md) adds 38 P1 cases. CI sets `TZ=Asia/Shanghai` during the real-database run so the `TIME` binding regression remains observable on UTC-hosted runners. The matrix records currently unverified option effects and the high-precision `DECIMAL` write defect.

A separate ARM Linux job starts DM8 with mandatory SSL and tests a verified encrypted connection on Python 3.10. It also checks paths containing spaces and `&`, rejection of a wrong or missing server certificate pin, and rejection of a plain server when `ssl_path` is requested. The repository does not upload the CI client key as an artifact.

The real-database job also exposes its disposable DM8 container to the BFILE tests. Those tests create a binary file in the container and a database directory with the temporary CI administrator credential, grant the test user read access, then remove both resources. Local runs need `DM_BFILE_TEST_CONTAINER` and `DM_CI_ADMIN_PASSWORD` to run these cases; without them, the BFILE cases are skipped. CI supplies both and treats skips as a gate failure.

After all five real-database jobs pass, five macOS ARM jobs build and install wheels for CPython 3.9–3.13. They use the existing `DPI_HEADERS_TAR_B64` repository secret, so fork pull requests run the real-database matrix but skip macOS wheel builds. The required `CI gate` check accepts that documented fork exception; it requires both real-database and wheel jobs for trusted branches. Headers and image archives are not committed or uploaded as artifacts. The standalone `Integration Tests` workflow also runs the full five-version suite nightly and can be started manually. [Five-version results and release rehearsal](test-results/2026-09-25-five-python-release-rehearsal.md) record the exact scope.
Expand Down
22 changes: 22 additions & 0 deletions docs/test-results/2026-09-27-ssl-connection.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
# SSL connection regression (2026-09-27)

## Local ARM verification

- Official DM8 ARM container with `ENABLE_ENCRYPT=1`: four SSL tests passed.
- The exact ARM development image pinned by CI, started with working directory
`/opt/dmdbms/bin` and `ENABLE_ENCRYPT=1`: the same four tests passed.
- The image fails at startup with `SSL encrypt fail!` when left at its default
working directory, `/home/dmdba`; the SSL CI job sets the working directory.
- The four checks cover encrypted login and query, an SSL path containing spaces
and `&`, a wrong server-certificate pin, and a missing pin.
- Full real-database suite on the GB18030 instance: 188 passed, 6 deselected.
The UTF8 instance lacks the admin credential required by four container-based
cases; 184 passed, 4 skipped, 6 deselected there. GitHub CI supplies the
required credential for both database encodings.

## Remaining scope

The local test uses DM8's bundled legacy certificate without SAN, so it
exercises exact certificate pinning. CA and hostname verification for modern
SAN certificates is implemented but needs a server with a modern certificate
for an end-to-end regression. `ssl_pwd` and UKey login remain unsupported.
30 changes: 28 additions & 2 deletions dpi_bridge/dpi_conn.go
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ type connHandle struct {
connTimeout int
appName string
compressMsg int
sslPath string
svcPath string
mppLogin int
rwSeparate int
Expand Down Expand Up @@ -244,10 +245,22 @@ func dpi_set_con_attr(hcon C.dhcon, attrID C.sdint4, val C.dpointer, valLen C.sd
conn.lastErr = &diagInfo{errorCode: -1, message: "use_stmt_pool is not supported by this bridge"}
return DSQL_ERROR
}
case DSQL_ATTR_SSL_PATH, DSQL_ATTR_SSL_PWD, DSQL_ATTR_UKEY_NAME, DSQL_ATTR_UKEY_PIN:
case DSQL_ATTR_SSL_PATH:
if conn.conn != nil {
conn.lastErr = &diagInfo{errorCode: -1, message: "ssl_path can only be set before login"}
return DSQL_ERROR
}
if val == nil {
conn.sslPath = ""
} else if valLen > 0 {
conn.sslPath = C.GoStringN((*C.char)(val), C.int(valLen))
} else {
conn.sslPath = C.GoString((*C.char)(val))
}
case DSQL_ATTR_SSL_PWD, DSQL_ATTR_UKEY_NAME, DSQL_ATTR_UKEY_PIN:
if val != nil && C.GoString((*C.char)(val)) != "" {
name := map[int32]string{
DSQL_ATTR_SSL_PATH: "ssl_path", DSQL_ATTR_SSL_PWD: "ssl_pwd",
DSQL_ATTR_SSL_PWD: "ssl_pwd",
DSQL_ATTR_UKEY_NAME: "ukey_name", DSQL_ATTR_UKEY_PIN: "ukey_pin",
}[attr]
conn.lastErr = &diagInfo{errorCode: -1, message: name + " is not supported by this bridge"}
Expand Down Expand Up @@ -363,6 +376,11 @@ func dpi_get_con_attr(hcon C.dhcon, attrID C.sdint4, val C.dpointer, bufLen C.sd
if valLen != nil {
*valLen = C.sdint4(n)
}
case DSQL_ATTR_SSL_PATH:
n := cStringLen((*C.sdbyte)(val), int(bufLen), conn.sslPath)
if valLen != nil {
*valLen = C.sdint4(n)
}
case DSQL_ATTR_CONNECTION_DEAD:
dead := C.sdint4(0) // DSQL_CD_FALSE
if conn.conn == nil {
Expand Down Expand Up @@ -481,6 +499,9 @@ func dpi_login(hcon C.dhcon, svr *C.sdbyte, user *C.sdbyte, pwd *C.sdbyte) C.DPI
if conn.compressMsg >= 0 {
params = append(params, "compress="+strconv.Itoa(conn.compressMsg))
}
if conn.sslPath != "" {
params = append(params, "sslFilesPath="+url.QueryEscape(conn.sslPath))
}
if conn.svcPath != "" {
params = append(params, "svcConfPath="+url.QueryEscape(filepath.Join(conn.svcPath, "dm_svc.conf")))
}
Expand Down Expand Up @@ -542,6 +563,11 @@ func dpi_login(hcon C.dhcon, svr *C.sdbyte, user *C.sdbyte, pwd *C.sdbyte) C.DPI
}
return DSQL_ERROR
}
if conn.sslPath != "" && dmConn.SSLMode() != 1 {
db.Close()
conn.lastErr = &diagInfo{errorCode: -1, message: "ssl_path requested, but the server did not negotiate encrypted SSL"}
return DSQL_ERROR
}

conn.db = db
conn.conn = dmConn
Expand Down
12 changes: 12 additions & 0 deletions dpi_bridge/third_party/chunanyong_dm/PATCHES.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,18 @@
- Regression: `test_compress_msg_is_applied` connects with compression both
disabled and enabled against a real DM8 instance.

## Patch: verified SSL connections

- Files: `a.go`, `n.go`, `bridge_options.go`, `security/zzi.go`
- Resolve the client key from `sslFilesPath` and decode escaped SSL path values.
- Verify modern server certificates with the configured CA and hostname. For
DM8's bundled legacy certificate without SAN, require an exact server
certificate pin and reject expired pins.
- Expose the negotiated SSL mode so the DPI bridge rejects plain connections
when the caller requests `ssl_path`.
- Regression: `tests/ssl/test_ssl_connection.py` uses a real SSL-enabled DM8
instance, including wrong and missing pins.

## Patch: initial connection timeout through endpoint groups

- Files: `a.go`, `n.go`, `x.go`, `y.go`, `m.go`
Expand Down
2 changes: 1 addition & 1 deletion dpi_bridge/third_party/chunanyong_dm/a.go
Original file line number Diff line number Diff line change
Expand Up @@ -890,7 +890,7 @@ func (dm_build_680 *dm_build_414) dm_build_679(dm_build_681 int, dm_build_682 []
}

func (dm_build_687 *dm_build_414) dm_build_686(dm_build_688 bool) (dm_build_689 error) {
if dm_build_687.dm_build_416, dm_build_689 = security.NewTLSFromTCP(dm_build_687.dm_build_415, dm_build_687.dm_build_418.dmConnector.sslCertPath, dm_build_687.dm_build_418.dmConnector.sslKeyPath, dm_build_687.dm_build_418.dmConnector.user); dm_build_689 != nil {
if dm_build_687.dm_build_416, dm_build_689 = security.NewTLSFromTCP(dm_build_687.dm_build_415, dm_build_687.dm_build_418.dmConnector.sslCertPath, dm_build_687.dm_build_418.dmConnector.sslKeyPath, dm_build_687.dm_build_418.dmConnector.sslFilesPath, dm_build_687.dm_build_418.dmConnector.host); dm_build_689 != nil {
return
}
if !dm_build_688 {
Expand Down
5 changes: 5 additions & 0 deletions dpi_bridge/third_party/chunanyong_dm/bridge_options.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,3 +7,8 @@ func (dc *DmConnection) CompressionMode() int {
}
return dc.dmConnector.compress
}

// SSLMode reports the server-negotiated TLS mode (1 encrypts the session).
func (dc *DmConnection) SSLMode() int {
return dc.sslEncrypt
}
5 changes: 3 additions & 2 deletions dpi_bridge/third_party/chunanyong_dm/n.go
Original file line number Diff line number Diff line change
Expand Up @@ -602,7 +602,7 @@ func (c *DmConnector) setAttributes(props *Properties) error {
}
c.sslKeyPath = props.GetTrimString(SslKeyPathKey, c.sslKeyPath)
if c.sslKeyPath == "" && c.sslFilesPath != "" {
c.sslKeyPath = filepath.Join(c.sslKeyPath, "client-key.pem")
c.sslKeyPath = filepath.Join(c.sslFilesPath, "client-key.pem")
}

c.kerberosLoginConfPath = props.GetTrimString(KerberosLoginConfPathKey, c.kerberosLoginConfPath)
Expand Down Expand Up @@ -760,7 +760,8 @@ func (c *DmConnector) parseDSN(dsn string) (*Properties, string, string, error)
kv := strings.SplitN(kvString, "=", 2)
if kv != nil && len(kv) > 1 {
value := kv[1]
if kv[0] == AppNameKey || kv[0] == "svcConfPath" {
if kv[0] == AppNameKey || kv[0] == "svcConfPath" ||
kv[0] == SslFilesPathKey || kv[0] == SslCertPathKey || kv[0] == SslKeyPathKey {
decoded, err := url.QueryUnescape(value)
if err != nil {
return nil, "", "", err
Expand Down
68 changes: 60 additions & 8 deletions dpi_bridge/third_party/chunanyong_dm/security/zzi.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,28 +6,80 @@
package security

import (
"bytes"
"crypto/tls"
"crypto/x509"
"encoding/pem"
"errors"
"fmt"
"net"
"os"
"path/filepath"
"sync"
"time"
)

//var dmHome = flag.String("DM_HOME", "", "Where DMDB installed")
// var dmHome = flag.String("DM_HOME", "", "Where DMDB installed")
var flagLock = sync.Mutex{}

func NewTLSFromTCP(conn net.Conn, sslCertPath string, sslKeyPath string, user string) (*tls.Conn, error) {
if sslCertPath == "" && sslKeyPath == "" {
// 用户必须手动指定ssl文件和签名(.cert文件)
return nil, errors.New("sslCertPath and sslKeyPath can not be empty!")

func NewTLSFromTCP(conn net.Conn, sslCertPath, sslKeyPath, sslFilesPath, serverName string) (*tls.Conn, error) {
if sslCertPath == "" || sslKeyPath == "" || sslFilesPath == "" {
return nil, errors.New("SSL certificate, key, and CA directory are required")
}
cer, err := tls.LoadX509KeyPair(sslCertPath, sslKeyPath)
cert, err := tls.LoadX509KeyPair(sslCertPath, sslKeyPath)
if err != nil {
return nil, err
}
caPEM, err := os.ReadFile(filepath.Join(sslFilesPath, "ca-cert.pem"))
if err != nil {
return nil, err
}
roots := x509.NewCertPool()
if !roots.AppendCertsFromPEM(caPEM) {
return nil, errors.New("SSL CA file has no valid certificates")
}
conf := &tls.Config{
MinVersion: tls.VersionTLS12,
ServerName: serverName,
Certificates: []tls.Certificate{cert},
// DM's bundled server certificate has no SAN and uses SHA1. Require an
// exact certificate pin for that legacy case; modern certs use CA and SAN.
InsecureSkipVerify: true,
Certificates: []tls.Certificate{cer},
VerifyConnection: func(state tls.ConnectionState) error {
if len(state.PeerCertificates) == 0 {
return errors.New("SSL server did not provide a certificate")
}
leaf := state.PeerCertificates[0]
if len(leaf.DNSNames) == 0 && len(leaf.IPAddresses) == 0 {
pinnedPEM, err := os.ReadFile(filepath.Join(sslFilesPath, "server-cert.pem"))
if err != nil {
return fmt.Errorf("legacy SSL server certificate requires server-cert.pem pin: %w", err)
}
block, _ := pem.Decode(pinnedPEM)
if block == nil || !bytes.Equal(leaf.Raw, block.Bytes) {
return errors.New("SSL server certificate does not match server-cert.pem pin")
}
now := time.Now()
if now.Before(leaf.NotBefore) || now.After(leaf.NotAfter) {
return errors.New("SSL server certificate pin is expired or not yet valid")
}
if leaf.IsCA || (leaf.Subject.CommonName != "server" && leaf.Subject.CommonName != serverName) {
return fmt.Errorf("SSL server certificate has unexpected CN %q", leaf.Subject.CommonName)
}
return nil
}
intermediates := x509.NewCertPool()
for _, intermediate := range state.PeerCertificates[1:] {
intermediates.AddCert(intermediate)
}
if _, err := leaf.Verify(x509.VerifyOptions{
Roots: roots, Intermediates: intermediates,
KeyUsages: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
}); err != nil {
return err
}
return leaf.VerifyHostname(serverName)
},
}
tlsConn := tls.Client(conn, conf)
if err := tlsConn.Handshake(); err != nil {
Expand Down
29 changes: 29 additions & 0 deletions scripts/enable_dm_ssl_ci.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
"""Enable mandatory SSL on the isolated DM8 CI instance before its restart."""

import os
import time

import dmPython


deadline = time.monotonic() + 240
while True:
try:
conn = dmPython.connect(
user="SYSDBA",
password=os.environ["DM_CI_ADMIN_PASSWORD"],
server=os.environ["DM_SSL_TEST_HOST"],
port=int(os.environ["DM_SSL_TEST_PORT"]),
)
break
except dmPython.Error:
if time.monotonic() >= deadline:
raise RuntimeError("DM8 did not become ready within 240 seconds") from None
time.sleep(3)

try:
with conn.cursor() as cur:
cur.execute("SP_SET_PARA_VALUE(2, 'ENABLE_ENCRYPT', 1)")
conn.commit()
finally:
conn.close()
Loading
Loading