node-postgresで、PostgreSQL 15にSSL接続する（SSL証明書の検証は無視）

# What's？

node-postgresを使った時に、SSL接続の構成はどうしたらよいのか調べておきたいなと。

最終的に、ドキュメントをよくよく見るとわかったのだろうという気はするのですが…。  
最初はよくわからなかったので、メモとして。

https://github.com/brianc/node-postgres

# 関連するnode-postgresのドキュメント

node-postgresの接続まわりのドキュメントはこちら。

https://node-postgres.com/features/connecting

環境変数、API、Connection URIで指定する方法があります。

Connection URIはどのような項目が設定できるのかあまり書かれていないのですが、GitHubの`README.md`を見るしかなさそうですね。

https://github.com/brianc/node-postgres/tree/master/packages/pg-connection-string

node-postgresのSSLまわりのドキュメントはこちら。

https://node-postgres.com/features/ssl

APIで指定する場合は、`ssl`プロパティに`true`または`false`か、`CommonConnectionOptions`を渡します。

`CommonConnectionOptions`（要はオブジェクト）を指定した場合は、Node.jsの`tls.TLSSocket`のコンストラクタに渡されるようです。

[new tls.TLSSocket(socket[, options])](https://nodejs.org/dist/latest-v18.x/docs/api/tls.html#new-tlstlssocketsocket-options)

Connection URIの場合は、`ssl`または`sslmode`で指定します。

この説明は、こちらに書かれています。

[pg-connection-string / Connection Strings / TCP Connections](https://github.com/brianc/node-postgres/tree/master/packages/pg-connection-string#tcp-connections)

`sslmode`の説明は、こちらも参考にするとよいでしょう。

https://www.postgresql.org/docs/15/libpq-ssl.html

両者の定義は完全には一致しておらず、node-postgresの方は`no-verify`というものが増えていますが、これはpg-connection-stringの方に補足が書かれています。

今回は、PostgreSQLをSSL／TLSを有効にした状態で構築し、証明書の検証はパスする（通信の暗号化のみ行う）状態でnode-postgresから接続してみたいと思います。

# 環境

Node.jsの環境は、こちら。

```shell
$ node --version
v18.16.0


$ npm --version
9.5.1
```

PostgreSQLは、前に書いた以下の記事に沿ってSSL／TLS化含めて構築。

https://qiita.com/charon/items/28ae957d16eb6a12a42c

SSL証明書は、この記事で書いた時と同じように自己署名証明書とします。

Node.jsを動かす環境とは別サーバーで動かすものとして、IPアドレスは192.168.30.10とします。

設定は最終的にこうなりました。

```shell
$ sudo grep -vE '^\s*#.*$|^$' /var/lib/pgsql/15/data/postgresql.conf
listen_addresses = '*'          # what IP address(es) to listen on;
max_connections = 100                   # (change requires restart)
ssl = on
ssl_cert_file = '/var/lib/pgsql/15/data/server.crt'
ssl_key_file = '/var/lib/pgsql/15/data/server.key'
shared_buffers = 128MB                  # min 128kB
dynamic_shared_memory_type = posix      # the default is usually the first option
max_wal_size = 1GB
min_wal_size = 80MB
log_destination = 'stderr'              # Valid values are combinations of
logging_collector = on                  # Enable capturing of stderr, jsonlog,
log_directory = 'log'                   # directory where log files are written,
log_filename = 'postgresql-%a.log'      # log file name pattern,
log_rotation_age = 1d                   # Automatic rotation of logfiles will
log_rotation_size = 0                   # Automatic rotation of logfiles will
log_truncate_on_rotation = on           # If on, an existing log file with the
log_line_prefix = '%m [%p] '            # special values:
log_timezone = 'Asia/Tokyo'
datestyle = 'iso, mdy'
timezone = 'Asia/Tokyo'
lc_messages = 'C'                       # locale for system error message
lc_monetary = 'C'                       # locale for monetary formatting
lc_numeric = 'C'                        # locale for number formatting
lc_time = 'C'                           # locale for time formatting
default_text_search_config = 'pg_catalog.english'
```

firewalldは止めておきます。

```shell
$ sudo systemctl stop firewalld
```

PostgreSQLのバージョン。

```sql
$ psql
psql (15.2)
"help"でヘルプを表示します。

postgres=# select version();
                                                 version
----------------------------------------------------------------------------------------------------------
 PostgreSQL 15.2 on x86_64-pc-linux-gnu, compiled by gcc (GCC) 11.3.1 20220421 (Red Hat 11.3.1-2), 64-bit
(1 行)

```

利用するアカウントとデータベースの作成。

```sql
postgres=# create user myuser password 'password';
CREATE ROLE
postgres=# create database example owner myuser;
CREATE DATABASE
```

このアカウントおよびデータベースへのTCP接続の場合は、SSL／TLSの利用を必須とします（`hostssl`）。

```shell
$ sudo grep -vE '^\s*#.*$|^$' /var/lib/pgsql/15/data/pg_hba.conf
local   all             all                                     peer
hostssl example         myuser          192.168.0.0/16          scram-sha-256
host    all             all             127.0.0.1/32            scram-sha-256
host    all             all             ::1/128                 scram-sha-256
local   replication     all                                     peer
host    replication     all             127.0.0.1/32            scram-sha-256
host    replication     all             ::1/128                 scram-sha-256
```

`psql`でSSL／TLS接続できていることの確認。

```shell
$ psql -U myuser -h localhost -p 5432 example
ユーザー myuser のパスワード:
psql (15.2)
SSL接続(プロトコル: TLSv1.3、暗号化方式: TLS_AES_256_GCM_SHA384、圧縮: オフ)
"help"でヘルプを表示します。

example=>
```

# node-postgresを使ってPostgreSQLにSSL／TLS接続する

動作確認するための準備をしていきます。

Node.jsプロジェクトを作成。言語はTypeScriptにして、Jestで動作確認することにします。

```shell
$ npm init -y
$ npm i -D typescript
$ npm i -D @types/node@v18
$ npm i -D prettier
$ npm i -D jest @types/jest
$ npm i -D esbuild esbuild-jest
$ mkdir test
```

node-postgresと型宣言のインストール。

```shell
$ npm i pg
$ npm i -D @types/pg
```

依存関係。

```json
  "devDependencies": {
    "@types/jest": "^29.5.1",
    "@types/node": "^18.16.4",
    "@types/pg": "^8.6.6",
    "esbuild": "^0.17.18",
    "esbuild-jest": "^0.5.0",
    "jest": "^29.5.0",
    "prettier": "^2.8.8",
    "typescript": "^5.0.4"
  },
  "dependencies": {
    "pg": "^8.10.0"
  }
```

`scripts`は、こんな感じにしておきました。

```json
  "scripts": {
    "build": "tsc --project .",
    "build:watch": "tsc --project . --watch",
    "typecheck": "tsc --project ./tsconfig.typecheck.json",
    "typecheck:watch": "tsc --project ./tsconfig.typecheck.json --watch",
    "test": "jest",
    "format": "prettier --write test"
  },
```

各種設定ファイル。

```json:tsconfig.json
{
  "compilerOptions": {
    "target": "esnext",
    "module": "commonjs",
    "moduleResolution": "node",
    "lib": ["esnext"],
    "baseUrl": "./",
    "outDir": "dist",
    "strict": true,
    "forceConsistentCasingInFileNames": true,
    "noFallthroughCasesInSwitch": true,
    "noImplicitOverride": true,
    "noImplicitReturns": true,
    "noPropertyAccessFromIndexSignature": true,
    "esModuleInterop": true
  },
  "include": [
    "test"
  ]
}
```

```json:tsconfig.typecheck.json
{
  "extends": "./tsconfig",
  "compilerOptions": {
    "noEmit": true
  }
}
```

```javascript:jest.config.js
module.exports = {
  testEnvironment: 'node',
  transform: {
    "^.+\\.tsx?$": "esbuild-jest"
  }
};
```

```json:.prettierrc.json
{
  "singleQuote": true,
  "printWidth": 120
}
```

それでは、進めていきます。

## Connection URIを使う

最初はConnection URIから試してみたいと思います。

以下の3パターンを試しました。

- SSLを有効にしないで接続 → 失敗
- 証明書の検証を行わないモード（`no-verify`）でSSL接続 → 成功
- 証明書の確認を行うモード（`require`）でSSL接続 → 失敗
  - これは、Node.js側にPostgreSQLにインストールした自己署名証明書を入れていないから

作成したテストコード。

```typescript:test/pg-connect-ssl-using-connection-string.test.ts
import { Client } from 'pg';

test('connect no ssl', async () => {
  const connectionString = 'postgresql://myuser:password@192.168.33.10:5432/example?application_name=app';

  const client = new Client({
    connectionString,
  });

  try {
    await client.connect();
  } catch (e) {
    const error = e as Error;
    expect(error.message).toBe(
      'no pg_hba.conf entry for host "192.168.33.1", user "myuser", database "example", no encryption'
    );
  }
});

test('connect no-verify ssl', async () => {
  const connectionString =
    'postgresql://myuser:password@192.168.33.10:5432/example?application_name=app&sslmode=no-verify';

  const client = new Client({
    connectionString,
  });

  await client.connect();

  const res = await client.query(
    `
  select
      psa.datname, psa.usename, psa.application_name, query, pss.ssl
  from
      pg_stat_activity psa
  inner join
      pg_stat_ssl pss
  on
      psa.pid = pss.pid
  where
      psa.usename = $1
      and psa.application_name = $2
    `,
    ['myuser', 'app']
  );

  expect(res.rowCount).toBe(1);

  const row = res.rows[0];
  expect(row.datname).toBe('example');
  expect(row.usename).toBe('myuser');
  expect(row.ssl).toBeTruthy();

  await client.end();
});

test('connect require ssl', async () => {
  const connectionString =
    'postgresql://myuser:password@192.168.33.10:5432/example?application_name=app&sslmode=require';

  const client = new Client({
    connectionString,
  });

  try {
    await client.connect();
  } catch (e) {
    const error = e as Error;
    expect(error.message).toBe('self-signed certificate');
  }
});
```

Connection URIに指定している、`sslmode`がポイントですね。

2つ目のパターンでは接続に成功するので`pg_stat_activity`と`pg_stat_ssl`から、SSLでの接続状態を確認しています。

3つ目は、PostgreSQLサーバー側が自己署名証明書なのでエラーになっています…。  
これをちゃんとするのは、今回はやりません。

## APIで指定する

同じことをAPI指定でやってみます。

```typescript:test/pg-connect-ssl-using-programmatiic.test.ts
import { Client } from 'pg';

test('connect no ssl', async () => {
  const client = new Client({
    user: 'myuser',
    password: 'password',
    database: 'example',
    host: '192.168.33.10',
    port: 5432,
    application_name: 'app',
  });

  try {
    await client.connect();
  } catch (e) {
    const error = e as Error;
    expect(error.message).toBe(
      'no pg_hba.conf entry for host "192.168.33.1", user "myuser", database "example", no encryption'
    );
  }
});

test('connect no-verify ssl', async () => {
  const client = new Client({
    user: 'myuser',
    password: 'password',
    database: 'example',
    host: '192.168.33.10',
    port: 5432,
    application_name: 'app',
    ssl: {
      rejectUnauthorized: false
    },
  });

  await client.connect();

  const res = await client.query(
    `
  select
      psa.datname, psa.usename, psa.application_name, query, pss.ssl
  from
      pg_stat_activity psa
  inner join
      pg_stat_ssl pss
  on
      psa.pid = pss.pid
  where
      psa.usename = $1
      and psa.application_name = $2
    `,
    ['myuser', 'app']
  );

  expect(res.rowCount).toBe(1);

  const row = res.rows[0];
  expect(row.datname).toBe('example');
  expect(row.usename).toBe('myuser');
  expect(row.ssl).toBeTruthy();

  await client.end();
});

test('connect require ssl', async () => {
  const client = new Client({
    user: 'myuser',
    password: 'password',
    database: 'example',
    host: '192.168.33.10',
    port: 5432,
    application_name: 'app',
    ssl: {
      rejectUnauthorized: true,
    },
  });

  try {
    await client.connect();
  } catch (e) {
    const error = e as Error;
    expect(error.message).toBe('self-signed certificate');
  }
});
```

`sslmode=no-verify`相当のものはどう指定したらいいんだろう？というのにちょっと迷いましたが、これは`ssl`で`rejectUnauthorized`で調整すると良さそうです。

よくよく見ると、こちらにも同じことが書かれています。

[pg-connection-string / Connection Strings / TCP Connections](https://github.com/brianc/node-postgres/tree/master/packages/pg-connection-string#tcp-connections)

`rejectUnauthorized`の意味は、SSL接続に使用するCAリストで許可されていない接続を拒否する、です。

[tls.createServer([options][, secureConnectionListener])](https://nodejs.org/dist/latest-v18.x/docs/api/tls.html#tlscreateserveroptions-secureconnectionlistener)