pixl-server-storage 4.2.1 → 4.2.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +256 -1
- package/docs/API.md +17 -0
- package/engines/Postgres.js +97 -3
- package/package.json +1 -1
- package/storage.js +29 -0
- package/test/test-main.js +32 -0
- package/test/test-postgres-password-plugin.js +243 -0
package/README.md
CHANGED
|
@@ -56,9 +56,14 @@ Here is the table of contents for this current document:
|
|
|
56
56
|
+ [S3 Cache](#s3-cache)
|
|
57
57
|
+ [S3 Compatible Services](#s3-compatible-services)
|
|
58
58
|
* [Redis](#redis)
|
|
59
|
+
+ [Redis Auth and TLS](#redis-auth-and-tls)
|
|
59
60
|
+ [RedisCluster](#rediscluster)
|
|
61
|
+
- [RedisCluster Auth and TLS](#rediscluster-auth-and-tls)
|
|
60
62
|
* [SQLite](#sqlite)
|
|
61
63
|
* [Postgres](#postgres)
|
|
64
|
+
+ [Postgres SSL](#postgres-ssl)
|
|
65
|
+
+ [AWS RDS](#aws-rds)
|
|
66
|
+
+ [Postgres Password Plugin](#postgres-password-plugin)
|
|
62
67
|
* [Hybrid](#hybrid)
|
|
63
68
|
- [Key Normalization](#key-normalization)
|
|
64
69
|
- [Basic Functions](#basic-functions)
|
|
@@ -590,6 +595,8 @@ Then configure your storage thusly:
|
|
|
590
595
|
"Redis": {
|
|
591
596
|
"host": "127.0.0.1",
|
|
592
597
|
"port": 6379,
|
|
598
|
+
"username": "",
|
|
599
|
+
"password": "",
|
|
593
600
|
"keyPrefix": "",
|
|
594
601
|
"keyTemplate": "",
|
|
595
602
|
"cache": {
|
|
@@ -603,6 +610,58 @@ Then configure your storage thusly:
|
|
|
603
610
|
|
|
604
611
|
Set the `host` and `port` for your own Redis server setup. Please see [Common Redis Options](https://redis.github.io/ioredis/interfaces/CommonRedisOptions.html) for other things you can include here, such as timeouts, authentication and database selection.
|
|
605
612
|
|
|
613
|
+
### Redis Auth and TLS
|
|
614
|
+
|
|
615
|
+
For production, Redis is usually deployed on a private network, firewalled to trusted clients, and protected with authentication as an extra layer of safety. Modern Redis ACL users use both `username` and `password`.
|
|
616
|
+
|
|
617
|
+
Configure `username` and `password` like this:
|
|
618
|
+
|
|
619
|
+
```json
|
|
620
|
+
{
|
|
621
|
+
"engine": "Redis",
|
|
622
|
+
"Redis": {
|
|
623
|
+
"host": "redis.example.com",
|
|
624
|
+
"port": 6379,
|
|
625
|
+
"username": "default",
|
|
626
|
+
"password": "your-redis-password"
|
|
627
|
+
}
|
|
628
|
+
}
|
|
629
|
+
```
|
|
630
|
+
|
|
631
|
+
Redis `AUTH` is sent over the Redis connection itself, so use TLS too when traffic can cross an untrusted network, a shared network, or a managed provider endpoint that requires encryption. The Redis engine passes extra config properties through to [ioredis](https://github.com/redis/ioredis), so you can enable TLS with the `tls` option:
|
|
632
|
+
|
|
633
|
+
```json
|
|
634
|
+
{
|
|
635
|
+
"engine": "Redis",
|
|
636
|
+
"Redis": {
|
|
637
|
+
"host": "redis.example.com",
|
|
638
|
+
"port": 6380,
|
|
639
|
+
"username": "default",
|
|
640
|
+
"password": "your-redis-password",
|
|
641
|
+
"tls": {}
|
|
642
|
+
}
|
|
643
|
+
}
|
|
644
|
+
```
|
|
645
|
+
|
|
646
|
+
For a private CA or self-signed certificate, pass TLS options such as `ca`, `cert`, `key` or `servername` inside the `tls` object. These are passed through to Node.js TLS. If you store PEM text directly in JSON, preserve line breaks as `\n`:
|
|
647
|
+
|
|
648
|
+
```json
|
|
649
|
+
{
|
|
650
|
+
"engine": "Redis",
|
|
651
|
+
"Redis": {
|
|
652
|
+
"host": "redis.example.com",
|
|
653
|
+
"port": 6380,
|
|
654
|
+
"username": "default",
|
|
655
|
+
"password": "your-redis-password",
|
|
656
|
+
"tls": {
|
|
657
|
+
"ca": "-----BEGIN CERTIFICATE-----\n...\n-----END CERTIFICATE-----\n"
|
|
658
|
+
}
|
|
659
|
+
}
|
|
660
|
+
}
|
|
661
|
+
```
|
|
662
|
+
|
|
663
|
+
Please avoid `tls: { "rejectUnauthorized": false }` for production use. It can be useful as a short diagnostic test, but it disables certificate verification.
|
|
664
|
+
|
|
606
665
|
The optional `keyPrefix` property works similarly to the [S3 Key Prefix](#s3-key-prefix) feature. It allows you to prefix all the Redis keys with a common string, to separate your application's data in a shared database situation.
|
|
607
666
|
|
|
608
667
|
The optional `keyTemplate` property works similarly to the [S3 Key Template](#s3-key-template) feature. It allows you to specify an exact layout of MD5 hash characters, which can be prefixed, mixed in with or postfixed after the key.
|
|
@@ -648,6 +707,53 @@ Set the `host` and `port` for your own Redis cluster setup. The `host` should p
|
|
|
648
707
|
|
|
649
708
|
The `clusterOpts` property can hold several different cluster configuration options. Please see [Cluster Options](https://redis.github.io/ioredis/interfaces/ClusterOptions.html) for other things you can include here, such as `scaleReads` and `redisOptions`. For the `redisOptions` object, please see [Common Redis Options](https://redis.github.io/ioredis/interfaces/CommonRedisOptions.html) for other things you can include, such as timeouts, authentication and database selection.
|
|
650
709
|
|
|
710
|
+
#### RedisCluster Auth and TLS
|
|
711
|
+
|
|
712
|
+
For RedisCluster, shared authentication and TLS options should go inside `clusterOpts.redisOptions`, because those options are passed to every Redis node connection created by ioredis:
|
|
713
|
+
|
|
714
|
+
```json
|
|
715
|
+
{
|
|
716
|
+
"engine": "RedisCluster",
|
|
717
|
+
"RedisCluster": {
|
|
718
|
+
"host": "clustercfg.redis.example.com",
|
|
719
|
+
"port": 6379,
|
|
720
|
+
"clusterOpts": {
|
|
721
|
+
"scaleReads": "master",
|
|
722
|
+
"redisOptions": {
|
|
723
|
+
"commandTimeout": 5000,
|
|
724
|
+
"connectTimeout": 5000,
|
|
725
|
+
"username": "default",
|
|
726
|
+
"password": "your-cluster-password"
|
|
727
|
+
}
|
|
728
|
+
}
|
|
729
|
+
}
|
|
730
|
+
}
|
|
731
|
+
```
|
|
732
|
+
|
|
733
|
+
To connect to a TLS-enabled Redis cluster, add `tls` under `redisOptions`:
|
|
734
|
+
|
|
735
|
+
```json
|
|
736
|
+
{
|
|
737
|
+
"engine": "RedisCluster",
|
|
738
|
+
"RedisCluster": {
|
|
739
|
+
"host": "clustercfg.redis.example.com",
|
|
740
|
+
"port": 6380,
|
|
741
|
+
"clusterOpts": {
|
|
742
|
+
"scaleReads": "master",
|
|
743
|
+
"redisOptions": {
|
|
744
|
+
"commandTimeout": 5000,
|
|
745
|
+
"connectTimeout": 5000,
|
|
746
|
+
"username": "default",
|
|
747
|
+
"password": "your-cluster-password",
|
|
748
|
+
"tls": {}
|
|
749
|
+
}
|
|
750
|
+
}
|
|
751
|
+
}
|
|
752
|
+
}
|
|
753
|
+
```
|
|
754
|
+
|
|
755
|
+
AWS ElastiCache for Redis commonly uses this pattern when authentication and in-transit encryption are enabled. Use `username` and `password` for ACL users.
|
|
756
|
+
|
|
651
757
|
It is **highly recommended** that you keep the `scaleReads` property set to `"master"`, for immediate consistency (required for [Lists](https://github.com/jhuckaby/pixl-server-storage/blob/master/docs/Lists.md), [Hashes](https://github.com/jhuckaby/pixl-server-storage/blob/master/docs/Hashes.md), [Transactions](https://github.com/jhuckaby/pixl-server-storage/blob/master/docs/Transactions.md) and the [Indexer](https://github.com/jhuckaby/pixl-server-storage/blob/master/docs/Indexer.md)).
|
|
652
758
|
|
|
653
759
|
The optional `keyPrefix` property works similarly to the [S3 Key Prefix](#s3-key-prefix) feature. It allows you to prefix all the Redis keys with a common string, to separate your application's data in a shared database situation.
|
|
@@ -752,7 +858,156 @@ You can customize the table name via the `table` configuration property. It def
|
|
|
752
858
|
|
|
753
859
|
[Connection Pooling](https://node-postgres.com/features/pooling) is utilized to open many simultaneous connections to greatly increase throughput. Set the `max` property to the maximum number of concurrent connections your database can handle. Ideally this should be set to the global [concurrency](#concurrency) setting.
|
|
754
860
|
|
|
755
|
-
|
|
861
|
+
### Postgres SSL
|
|
862
|
+
|
|
863
|
+
The Postgres engine passes all extra properties in the `Postgres` configuration block directly into the [pg.Pool constructor](https://node-postgres.com/apis/pool), except for `table`, `cache`, `passwordPlugin`, and `passwordPluginTimeout`, which are used by pixl-server-storage itself. This means you can use the standard [node-postgres SSL options](https://node-postgres.com/features/ssl) without any special pixl-server-storage code.
|
|
864
|
+
|
|
865
|
+
For a server with a certificate trusted by Node.js already, you can enable SSL by simply adding `"ssl":true` like this:
|
|
866
|
+
|
|
867
|
+
```json
|
|
868
|
+
{
|
|
869
|
+
"engine": "Postgres",
|
|
870
|
+
"Postgres": {
|
|
871
|
+
"host": "postgres.example.com",
|
|
872
|
+
"database": "postgres",
|
|
873
|
+
"user": "postgres",
|
|
874
|
+
"password": "postgres",
|
|
875
|
+
"port": 5432,
|
|
876
|
+
"ssl": true
|
|
877
|
+
}
|
|
878
|
+
}
|
|
879
|
+
```
|
|
880
|
+
|
|
881
|
+
For a private CA, self-signed server certificate, or hosted database that requires its own CA bundle, pass an `ssl` object. This object is passed through to Node.js TLS, so you can provide `ca`, `cert`, `key`, `servername`, and other supported TLS options. If you store PEM text directly in JSON, preserve line breaks as `\n` (standard JSON string encoding):
|
|
882
|
+
|
|
883
|
+
```json
|
|
884
|
+
{
|
|
885
|
+
"engine": "Postgres",
|
|
886
|
+
"Postgres": {
|
|
887
|
+
"host": "postgres.example.com",
|
|
888
|
+
"database": "postgres",
|
|
889
|
+
"user": "postgres",
|
|
890
|
+
"password": "postgres",
|
|
891
|
+
"port": 5432,
|
|
892
|
+
"ssl": {
|
|
893
|
+
"ca": "-----BEGIN CERTIFICATE-----\n...\n-----END CERTIFICATE-----\n"
|
|
894
|
+
}
|
|
895
|
+
}
|
|
896
|
+
}
|
|
897
|
+
```
|
|
898
|
+
|
|
899
|
+
If you need to load the CA certificate from disk, you can also use a `connectionString` with the standard Postgres SSL parameters. The `pg` module will read `sslrootcert` and use it as the TLS CA:
|
|
900
|
+
|
|
901
|
+
```json
|
|
902
|
+
{
|
|
903
|
+
"engine": "Postgres",
|
|
904
|
+
"Postgres": {
|
|
905
|
+
"connectionString": "postgres://postgres:postgres@postgres.example.com:5432/postgres?sslmode=verify-full&sslrootcert=/path/to/root-ca.pem",
|
|
906
|
+
"max": 32,
|
|
907
|
+
"table": "items"
|
|
908
|
+
}
|
|
909
|
+
}
|
|
910
|
+
```
|
|
911
|
+
|
|
912
|
+
If you put SSL parameters such as `sslmode`, `sslcert`, `sslkey`, or `sslrootcert` in the connection string, avoid also setting a separate `ssl` object. The `pg` connection string parser replaces the `ssl` object when those URL parameters are present.
|
|
913
|
+
|
|
914
|
+
Please avoid `ssl: { "rejectUnauthorized": false }` for production use. It can be useful as a short diagnostic test, but it disables certificate verification.
|
|
915
|
+
|
|
916
|
+
### AWS RDS
|
|
917
|
+
|
|
918
|
+
Amazon RDS for PostgreSQL works well with this setup. Download the appropriate RDS CA bundle from the [AWS RDS SSL/TLS documentation](https://docs.aws.amazon.com/AmazonRDS/latest/UserGuide/UsingWithRDS.SSL.html), place it somewhere readable by your app, and use `sslmode=verify-full` with `sslrootcert`:
|
|
919
|
+
|
|
920
|
+
```json
|
|
921
|
+
{
|
|
922
|
+
"engine": "Postgres",
|
|
923
|
+
"Postgres": {
|
|
924
|
+
"connectionString": "postgres://xyops:secret@mydb.abc123.us-east-1.rds.amazonaws.com:5432/xyops?sslmode=verify-full&sslrootcert=/opt/xyops/conf/rds-ca.pem",
|
|
925
|
+
"max": 32,
|
|
926
|
+
"table": "items"
|
|
927
|
+
}
|
|
928
|
+
}
|
|
929
|
+
```
|
|
930
|
+
|
|
931
|
+
AWS has a few quirks worth knowing:
|
|
932
|
+
|
|
933
|
+
* RDS for PostgreSQL 15 and newer enables `rds.force_ssl` by default, so non-SSL connections may be rejected with an error similar to `no pg_hba.conf entry ... SSL off`.
|
|
934
|
+
* `sslmode=verify-full` verifies both the certificate chain and the hostname. Use the actual RDS endpoint hostname in your connection string when possible.
|
|
935
|
+
* If you connect through a custom DNS name, SSH tunnel, or local proxy, hostname verification may need the original RDS endpoint as the TLS `servername` option. In that case, prefer explicit config properties and an `ssl` object instead of URL SSL parameters.
|
|
936
|
+
* AWS recommends trusting the root RDS CA certificate. Avoid pinning intermediate certificates, as that can cause trouble when RDS rotates server certificates.
|
|
937
|
+
|
|
938
|
+
### Postgres Password Plugin
|
|
939
|
+
|
|
940
|
+
If your Postgres password needs to be generated dynamically, such as with a cloud provider token service, you can configure a password plugin command. This uses the [node-postgres dynamic password callback](https://node-postgres.com/features/connecting), so the command is called whenever the pool needs a new physical database connection.
|
|
941
|
+
|
|
942
|
+
```json
|
|
943
|
+
{
|
|
944
|
+
"engine": "Postgres",
|
|
945
|
+
"Postgres": {
|
|
946
|
+
"host": "mypg.postgres.example.com",
|
|
947
|
+
"database": "mydb",
|
|
948
|
+
"user": "my-app-user",
|
|
949
|
+
"ssl": true,
|
|
950
|
+
"passwordPlugin": "npx -y pxpg-plug-example@1.0.0"
|
|
951
|
+
}
|
|
952
|
+
}
|
|
953
|
+
```
|
|
954
|
+
|
|
955
|
+
In this example the command uses the [npx](https://docs.npmjs.com/cli/commands/npx) CLI to automatically download and run a NPM package. The package is also cached on local disk (in `~/.npm`) for fast subsequent runs.
|
|
956
|
+
|
|
957
|
+
The command receives one compact JSON document on STDIN, delimited by an EOL (pretty-printed here for display purposes):
|
|
958
|
+
|
|
959
|
+
```json
|
|
960
|
+
{
|
|
961
|
+
"type": "postgres_password",
|
|
962
|
+
"config": {
|
|
963
|
+
"host": "mypg.postgres.example.com",
|
|
964
|
+
"database": "mydb",
|
|
965
|
+
"user": "my-app-user",
|
|
966
|
+
"ssl": true
|
|
967
|
+
}
|
|
968
|
+
}
|
|
969
|
+
```
|
|
970
|
+
|
|
971
|
+
The `config` object contains the Postgres configuration with `password`, `passwordPlugin`, and `passwordPluginTimeout` removed.
|
|
972
|
+
|
|
973
|
+
The command should print one compact JSON document to STDOUT. Extra logging is allowed, as pixl-server-storage only parses the last line of STDOUT:
|
|
974
|
+
|
|
975
|
+
```json
|
|
976
|
+
{
|
|
977
|
+
"password": "GENERATED_PASSWORD_OR_TOKEN",
|
|
978
|
+
"ttl": 3000
|
|
979
|
+
}
|
|
980
|
+
```
|
|
981
|
+
|
|
982
|
+
The `password` property is required, and must be a string. The optional `ttl` property is a cache duration in seconds. If `ttl` is greater than zero, pixl-server-storage caches the returned password in memory and reuses it for subsequent new database connections until the TTL expires. If `ttl` is omitted or zero, the command is invoked for every new database connection.
|
|
983
|
+
|
|
984
|
+
If the plugin cannot produce a password, it can return an error:
|
|
985
|
+
|
|
986
|
+
```json
|
|
987
|
+
{
|
|
988
|
+
"code": "auth",
|
|
989
|
+
"description": "Could not fetch provider token."
|
|
990
|
+
}
|
|
991
|
+
```
|
|
992
|
+
|
|
993
|
+
The default plugin timeout is 30 seconds. You can customize it with `passwordPluginTimeout`, in milliseconds:
|
|
994
|
+
|
|
995
|
+
```json
|
|
996
|
+
{
|
|
997
|
+
"engine": "Postgres",
|
|
998
|
+
"Postgres": {
|
|
999
|
+
"host": "mypg.postgres.example.com",
|
|
1000
|
+
"database": "mydb",
|
|
1001
|
+
"user": "my-app-user",
|
|
1002
|
+
"passwordPlugin": "/usr/local/bin/px-pg-plug-example",
|
|
1003
|
+
"passwordPluginTimeout": 10000
|
|
1004
|
+
}
|
|
1005
|
+
}
|
|
1006
|
+
```
|
|
1007
|
+
|
|
1008
|
+
For production, consider preinstalling the plugin and pointing `passwordPlugin` at the installed executable. This avoids starting `npx` and checking the NPM registry whenever the cached password expires.
|
|
1009
|
+
|
|
1010
|
+
See [S3 Cache](#s3-cache) for details on the `cache` section, as Postgres works in the same way.
|
|
756
1011
|
|
|
757
1012
|
Note that pooling and caching have little effect when the database is running locally (i.e. localhost). They offer much more benefit when the database is located remotely over some number of network hops.
|
|
758
1013
|
|
package/docs/API.md
CHANGED
|
@@ -24,6 +24,7 @@ let storage = server.Storage;
|
|
|
24
24
|
* [headMulti](#headmulti)
|
|
25
25
|
* [delete](#delete)
|
|
26
26
|
* [deleteMulti](#deletemulti)
|
|
27
|
+
* [deleteAll](#deleteall)
|
|
27
28
|
* [copy](#copy)
|
|
28
29
|
* [rename](#rename)
|
|
29
30
|
* [lock](#lock)
|
|
@@ -328,6 +329,22 @@ storage.deleteMulti( ['test1', 'test2', 'test3'], function(err) {
|
|
|
328
329
|
} );
|
|
329
330
|
```
|
|
330
331
|
|
|
332
|
+
Note that this function returns immediately if it fails to delete any key in the specified set.
|
|
333
|
+
|
|
334
|
+
## deleteAll
|
|
335
|
+
|
|
336
|
+
```js
|
|
337
|
+
storage.deleteAll( KEYS, CALLBACK );
|
|
338
|
+
```
|
|
339
|
+
|
|
340
|
+
The `deleteAll()` method deletes multiple records at once, from a specified array of keys. This is similar to [deleteMulti()](#deletemulti), except it does not stop on the first error. Each delete error is logged, and the remaining keys are still attempted. Example:
|
|
341
|
+
|
|
342
|
+
```js
|
|
343
|
+
storage.deleteAll( ['test1', 'test2', 'test3'], function(err) {
|
|
344
|
+
if (err) throw err;
|
|
345
|
+
} );
|
|
346
|
+
```
|
|
347
|
+
|
|
331
348
|
## copy
|
|
332
349
|
|
|
333
350
|
```js
|
package/engines/Postgres.js
CHANGED
|
@@ -7,6 +7,8 @@
|
|
|
7
7
|
const fs = require('fs');
|
|
8
8
|
const Path = require('path');
|
|
9
9
|
const zlib = require('zlib');
|
|
10
|
+
const os = require('os');
|
|
11
|
+
const cp = require('child_process');
|
|
10
12
|
const Class = require("pixl-class");
|
|
11
13
|
const Component = require("pixl-server/component");
|
|
12
14
|
const pg = require('pg');
|
|
@@ -33,6 +35,8 @@ module.exports = Class.create({
|
|
|
33
35
|
query_timeout: 6000,
|
|
34
36
|
connectionTimeoutMillis: 30000,
|
|
35
37
|
idleTimeoutMillis: 10000,
|
|
38
|
+
passwordPlugin: "",
|
|
39
|
+
passwordPluginTimeout: 30000,
|
|
36
40
|
table: "items"
|
|
37
41
|
},
|
|
38
42
|
|
|
@@ -75,9 +79,16 @@ module.exports = Class.create({
|
|
|
75
79
|
}
|
|
76
80
|
|
|
77
81
|
// pass entire config to pg.Pool, sans our custom keys
|
|
78
|
-
var
|
|
79
|
-
|
|
80
|
-
)
|
|
82
|
+
var pool_config = Tools.copyHashRemoveKeys( pg_config, { cache: 1, table: 1, passwordPlugin: 1, passwordPluginTimeout: 1 } );
|
|
83
|
+
|
|
84
|
+
if (pg_config.passwordPlugin) {
|
|
85
|
+
// node-postgres supports password as a sync/async callback, called for each
|
|
86
|
+
// new physical connection. We use that hook to ask an external command for
|
|
87
|
+
// short-lived provider tokens without baking provider-specific code in here.
|
|
88
|
+
pool_config.password = this.getPasswordFromPlugin.bind(this);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
var db = this.db = new pg.Pool( pool_config );
|
|
81
92
|
|
|
82
93
|
db.on('error', function(err) {
|
|
83
94
|
self.logError('db', "DB Error: " + err, err);
|
|
@@ -104,6 +115,89 @@ module.exports = Class.create({
|
|
|
104
115
|
this.queryWithClient( this.db, sql, args, callback );
|
|
105
116
|
},
|
|
106
117
|
|
|
118
|
+
getPasswordFromPlugin: function() {
|
|
119
|
+
// execute an external command to resolve the Postgres password
|
|
120
|
+
// the plugin may also return a TTL, allowing us to cache it in memory
|
|
121
|
+
var self = this;
|
|
122
|
+
var pg_config = this.config.get();
|
|
123
|
+
var now = Date.now();
|
|
124
|
+
|
|
125
|
+
if (this.passwordPluginCache && (now < this.passwordPluginCache.expires)) {
|
|
126
|
+
return Promise.resolve( this.passwordPluginCache.password );
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// If a plugin call is already in-flight, return the same Promise.
|
|
130
|
+
// This coalesces simultaneous pool reconnects into one plugin exec.
|
|
131
|
+
if (this.passwordPluginFetch) return this.passwordPluginFetch;
|
|
132
|
+
|
|
133
|
+
var hook_args = {
|
|
134
|
+
type: 'postgres_password',
|
|
135
|
+
config: Tools.copyHashRemoveKeys( pg_config, { password: 1, passwordPlugin: 1, passwordPluginTimeout: 1 } )
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
var child_cmd = pg_config.passwordPlugin;
|
|
139
|
+
var child_opts = {
|
|
140
|
+
cwd: os.tmpdir(),
|
|
141
|
+
timeout: pg_config.passwordPluginTimeout || 30000
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
this.logDebug(5, "Calling Postgres password plugin");
|
|
145
|
+
|
|
146
|
+
this.passwordPluginFetch = new Promise( function(resolve, reject) {
|
|
147
|
+
var child = cp.exec( child_cmd, child_opts, function(err, stdout, stderr) {
|
|
148
|
+
var json = null;
|
|
149
|
+
|
|
150
|
+
if (!err && stdout.match(/\S/)) {
|
|
151
|
+
// parse last line only, to omit any noise from plugin
|
|
152
|
+
try { json = JSON.parse( stdout.replace(/\r\n/g, "\n").trim().split(/\n/).pop() ); }
|
|
153
|
+
catch (e) {
|
|
154
|
+
err = new Error("Postgres password plugin JSON parse error: " + (e.message || e));
|
|
155
|
+
err.code = 'json';
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
if (stderr && stderr.match(/\S/)) {
|
|
160
|
+
self.logDebug(9, "Postgres password plugin emitted STDERR: " + Buffer.byteLength(stderr) + " bytes");
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
if (err) return reject( err );
|
|
164
|
+
if (!json) return reject( new Error("Postgres password plugin error: No JSON found in response STDOUT") );
|
|
165
|
+
if (json.code) return reject( new Error("Postgres password plugin error: " + (json.description || json.code)) );
|
|
166
|
+
if (typeof(json.password) != 'string') return reject( new Error("Postgres password plugin error: No password string found in response JSON") );
|
|
167
|
+
|
|
168
|
+
var ttl = parseInt( json.ttl || 0, 10 ) || 0;
|
|
169
|
+
|
|
170
|
+
if (ttl > 0) {
|
|
171
|
+
self.passwordPluginCache = {
|
|
172
|
+
password: json.password,
|
|
173
|
+
expires: Date.now() + (ttl * 1000)
|
|
174
|
+
};
|
|
175
|
+
|
|
176
|
+
self.logDebug(6, "Postgres password plugin returned password with TTL: " + ttl + " seconds");
|
|
177
|
+
}
|
|
178
|
+
else {
|
|
179
|
+
self.passwordPluginCache = null;
|
|
180
|
+
self.logDebug(6, "Postgres password plugin returned password without TTL");
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
resolve( json.password );
|
|
184
|
+
} ); // cp.exec
|
|
185
|
+
|
|
186
|
+
// Write hook data to child's stdin, using one compact JSON document.
|
|
187
|
+
child.stdin.on('error', noop);
|
|
188
|
+
child.stdin.write( JSON.stringify(hook_args) + "\n" );
|
|
189
|
+
child.stdin.end();
|
|
190
|
+
} ).then( function(password) {
|
|
191
|
+
self.passwordPluginFetch = null;
|
|
192
|
+
return password;
|
|
193
|
+
}, function(err) {
|
|
194
|
+
self.passwordPluginFetch = null;
|
|
195
|
+
throw err;
|
|
196
|
+
} );
|
|
197
|
+
|
|
198
|
+
return this.passwordPluginFetch;
|
|
199
|
+
},
|
|
200
|
+
|
|
107
201
|
queryWithClient: function(client, sql, args, callback) {
|
|
108
202
|
// run and log db query, and error if applicable
|
|
109
203
|
// allow for named variables in sql, expand to numbered $ variables for pg
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pixl-server-storage",
|
|
3
|
-
"version": "4.2.
|
|
3
|
+
"version": "4.2.2",
|
|
4
4
|
"description": "A key/value/list storage component for the pixl-server framework.",
|
|
5
5
|
"author": "Joseph Huckaby <jhuckaby@gmail.com>",
|
|
6
6
|
"homepage": "https://github.com/jhuckaby/pixl-server-storage",
|
package/storage.js
CHANGED
|
@@ -516,6 +516,35 @@ module.exports = Class.create({
|
|
|
516
516
|
);
|
|
517
517
|
},
|
|
518
518
|
|
|
519
|
+
deleteAll: function(keys, callback) {
|
|
520
|
+
// delete multiple records at once, given array of keys
|
|
521
|
+
// continue through all keys, even if some deletes fail
|
|
522
|
+
var self = this;
|
|
523
|
+
if (!this.started) return callback( new Error("Storage has not completed startup.") );
|
|
524
|
+
|
|
525
|
+
// if engine provides its own deleteAll, call that directly
|
|
526
|
+
if (("deleteAll" in this.engine) && !this.currentTransactionPath) {
|
|
527
|
+
return this.engine.deleteAll(keys, callback);
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
async.eachLimit(keys, this.concurrency,
|
|
531
|
+
function(key, callback) {
|
|
532
|
+
// iterator for each key
|
|
533
|
+
self.delete(key, function(err) {
|
|
534
|
+
if (err) {
|
|
535
|
+
// log and swallow the error so the remaining deletes can proceed
|
|
536
|
+
self.logError('storage', "Failed to delete key in deleteAll: " + key + ": " + err);
|
|
537
|
+
}
|
|
538
|
+
callback();
|
|
539
|
+
} );
|
|
540
|
+
},
|
|
541
|
+
function() {
|
|
542
|
+
// all keys attempted
|
|
543
|
+
callback();
|
|
544
|
+
}
|
|
545
|
+
);
|
|
546
|
+
},
|
|
547
|
+
|
|
519
548
|
copy: function(old_key, new_key, callback) {
|
|
520
549
|
// copy record to new key
|
|
521
550
|
var self = this;
|
package/test/test-main.js
CHANGED
|
@@ -656,6 +656,38 @@ module.exports = {
|
|
|
656
656
|
} );
|
|
657
657
|
},
|
|
658
658
|
|
|
659
|
+
function testDeleteAll(test) {
|
|
660
|
+
// delete multiple keys using deleteAll, continuing past errors
|
|
661
|
+
test.expect(5);
|
|
662
|
+
var self = this;
|
|
663
|
+
var keys = ['deleteall1', 'deleteall_missing', 'deleteall2'];
|
|
664
|
+
|
|
665
|
+
this.storage.putMulti( {
|
|
666
|
+
deleteall1: { fruit: 'apple' },
|
|
667
|
+
deleteall2: { fruit: 'banana' }
|
|
668
|
+
}, function(err) {
|
|
669
|
+
test.ok( !err, "No error creating deleteAll test records: " + err );
|
|
670
|
+
|
|
671
|
+
self.storage.deleteAll( keys, function(err) {
|
|
672
|
+
test.ok( !err, "No error calling deleteAll: " + err );
|
|
673
|
+
|
|
674
|
+
// the missing middle key should not prevent the final key from deleting
|
|
675
|
+
self.storage.get( 'deleteall1', function(err) {
|
|
676
|
+
test.ok( !!err, "Expected error fetching first deleteAll key after delete" );
|
|
677
|
+
|
|
678
|
+
self.storage.get( 'deleteall_missing', function(err) {
|
|
679
|
+
test.ok( !!err, "Expected error fetching missing deleteAll key after delete" );
|
|
680
|
+
|
|
681
|
+
self.storage.get( 'deleteall2', function(err) {
|
|
682
|
+
test.ok( !!err, "Expected error fetching final deleteAll key after delete" );
|
|
683
|
+
test.done();
|
|
684
|
+
} );
|
|
685
|
+
} );
|
|
686
|
+
} );
|
|
687
|
+
} );
|
|
688
|
+
} );
|
|
689
|
+
},
|
|
690
|
+
|
|
659
691
|
function testOptimize(test) {
|
|
660
692
|
// run engine-specific optimization, or a safe no-op if unsupported
|
|
661
693
|
test.expect(8);
|
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
// Unit tests for Postgres password plugin support
|
|
2
|
+
// Copyright (c) 2026 Joseph Huckaby
|
|
3
|
+
// Released under the MIT License
|
|
4
|
+
|
|
5
|
+
var cp = require('child_process');
|
|
6
|
+
var EventEmitter = require('events').EventEmitter;
|
|
7
|
+
var Module = require('module');
|
|
8
|
+
|
|
9
|
+
var origLoad = Module._load;
|
|
10
|
+
var origExec = cp.exec;
|
|
11
|
+
|
|
12
|
+
// The Postgres engine requires 'pg' at module load time. Stub it here so this
|
|
13
|
+
// focused test can run without needing a live Postgres module or database.
|
|
14
|
+
var fakePG = {
|
|
15
|
+
lastConfig: null,
|
|
16
|
+
Pool: function(config) {
|
|
17
|
+
fakePG.lastConfig = config;
|
|
18
|
+
this.on = function() {};
|
|
19
|
+
this.query = function(sql, args, callback) {
|
|
20
|
+
callback( null, { command: 'CREATE', rowCount: 0, rows: [] } );
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
Module._load = function(request, parent, isMain) {
|
|
26
|
+
if (request == 'pg') return fakePG;
|
|
27
|
+
return origLoad.apply( this, arguments );
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
var PostgresEngine = require('../engines/Postgres.js');
|
|
31
|
+
Module._load = origLoad;
|
|
32
|
+
|
|
33
|
+
function makeEngine(config) {
|
|
34
|
+
// Keep this intentionally tiny. The password plugin helper only needs the
|
|
35
|
+
// component config and log methods, and setup() only needs query logging.
|
|
36
|
+
var engine = Object.create( PostgresEngine.prototype );
|
|
37
|
+
engine.config = {
|
|
38
|
+
get: function() { return config; }
|
|
39
|
+
};
|
|
40
|
+
engine.logDebug = function() {};
|
|
41
|
+
engine.logError = function() {};
|
|
42
|
+
return engine;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function mockExec(responder) {
|
|
46
|
+
var calls = [];
|
|
47
|
+
|
|
48
|
+
cp.exec = function(cmd, opts, callback) {
|
|
49
|
+
var call = { cmd: cmd, opts: opts, stdin: '' };
|
|
50
|
+
var child = { stdin: new EventEmitter() };
|
|
51
|
+
calls.push( call );
|
|
52
|
+
|
|
53
|
+
child.stdin.write = function(data) {
|
|
54
|
+
call.stdin += data;
|
|
55
|
+
};
|
|
56
|
+
child.stdin.end = function() {
|
|
57
|
+
responder( call, callback );
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
return child;
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
return calls;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function restoreExec() {
|
|
67
|
+
cp.exec = origExec;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
module.exports = {
|
|
71
|
+
tests: [
|
|
72
|
+
|
|
73
|
+
function setup_installsPasswordFunctionAndStripsPluginKeys(test) {
|
|
74
|
+
test.expect(4);
|
|
75
|
+
|
|
76
|
+
var engine = makeEngine({
|
|
77
|
+
host: 'localhost',
|
|
78
|
+
database: 'test',
|
|
79
|
+
user: 'test',
|
|
80
|
+
password: 'static',
|
|
81
|
+
passwordPlugin: 'node ./plugin.js',
|
|
82
|
+
passwordPluginTimeout: 1234,
|
|
83
|
+
table: 'items'
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
fakePG.lastConfig = null;
|
|
87
|
+
engine.setup( function(err) {
|
|
88
|
+
test.ok( !err, "Setup completed without error" );
|
|
89
|
+
test.ok( typeof(fakePG.lastConfig.password) == 'function', "Password was converted to a callback function" );
|
|
90
|
+
test.ok( !('passwordPlugin' in fakePG.lastConfig), "passwordPlugin was stripped from pool config" );
|
|
91
|
+
test.ok( !('passwordPluginTimeout' in fakePG.lastConfig), "passwordPluginTimeout was stripped from pool config" );
|
|
92
|
+
test.done();
|
|
93
|
+
} );
|
|
94
|
+
},
|
|
95
|
+
|
|
96
|
+
function passwordPlugin_returnsPasswordAndSendsConfig(test) {
|
|
97
|
+
test.expect(5);
|
|
98
|
+
|
|
99
|
+
var engine = makeEngine({
|
|
100
|
+
host: 'localhost',
|
|
101
|
+
database: 'test',
|
|
102
|
+
user: 'tester',
|
|
103
|
+
password: 'static',
|
|
104
|
+
passwordPlugin: 'node ./plugin.js',
|
|
105
|
+
passwordPluginTimeout: 1234,
|
|
106
|
+
table: 'items'
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
var calls = mockExec( function(call, callback) {
|
|
110
|
+
callback( null, JSON.stringify({ xy: 1, password: 'dynamic-secret' }) + "\n", "" );
|
|
111
|
+
} );
|
|
112
|
+
|
|
113
|
+
engine.getPasswordFromPlugin().then( function(password) {
|
|
114
|
+
var input = JSON.parse( calls[0].stdin );
|
|
115
|
+
|
|
116
|
+
test.ok( password == 'dynamic-secret', "Returned dynamic password" );
|
|
117
|
+
test.ok( calls[0].cmd == 'node ./plugin.js', "Used configured plugin command" );
|
|
118
|
+
test.ok( calls[0].opts.timeout == 1234, "Used configured plugin timeout" );
|
|
119
|
+
test.ok( input.type == 'postgres_password', "Sent expected hook type" );
|
|
120
|
+
test.ok( !('password' in input.config), "Static password was omitted from plugin input" );
|
|
121
|
+
|
|
122
|
+
restoreExec();
|
|
123
|
+
test.done();
|
|
124
|
+
} ).catch( function(err) {
|
|
125
|
+
restoreExec();
|
|
126
|
+
test.ok( false, "Unexpected error: " + err );
|
|
127
|
+
test.done();
|
|
128
|
+
} );
|
|
129
|
+
},
|
|
130
|
+
|
|
131
|
+
function passwordPlugin_cachesPasswordForTTL(test) {
|
|
132
|
+
test.expect(3);
|
|
133
|
+
|
|
134
|
+
var engine = makeEngine({
|
|
135
|
+
passwordPlugin: 'node ./plugin.js',
|
|
136
|
+
table: 'items'
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
var calls = mockExec( function(call, callback) {
|
|
140
|
+
callback( null, JSON.stringify({ xy: 1, password: 'cached-secret', ttl: 60 }) + "\n", "" );
|
|
141
|
+
} );
|
|
142
|
+
|
|
143
|
+
engine.getPasswordFromPlugin().then( function(first) {
|
|
144
|
+
return engine.getPasswordFromPlugin().then( function(second) {
|
|
145
|
+
test.ok( first == 'cached-secret', "First password was returned" );
|
|
146
|
+
test.ok( second == 'cached-secret', "Second password was returned from cache" );
|
|
147
|
+
test.ok( calls.length == 1, "Plugin was only invoked once" );
|
|
148
|
+
|
|
149
|
+
restoreExec();
|
|
150
|
+
test.done();
|
|
151
|
+
} );
|
|
152
|
+
} ).catch( function(err) {
|
|
153
|
+
restoreExec();
|
|
154
|
+
test.ok( false, "Unexpected error: " + err );
|
|
155
|
+
test.done();
|
|
156
|
+
} );
|
|
157
|
+
},
|
|
158
|
+
|
|
159
|
+
function passwordPlugin_doesNotCacheWithoutTTL(test) {
|
|
160
|
+
test.expect(2);
|
|
161
|
+
|
|
162
|
+
var counter = 0;
|
|
163
|
+
var engine = makeEngine({
|
|
164
|
+
passwordPlugin: 'node ./plugin.js',
|
|
165
|
+
table: 'items'
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
var calls = mockExec( function(call, callback) {
|
|
169
|
+
counter++;
|
|
170
|
+
callback( null, JSON.stringify({ xy: 1, password: 'secret-' + counter }) + "\n", "" );
|
|
171
|
+
} );
|
|
172
|
+
|
|
173
|
+
engine.getPasswordFromPlugin().then( function(first) {
|
|
174
|
+
return engine.getPasswordFromPlugin().then( function(second) {
|
|
175
|
+
test.ok( first != second, "Passwords were fetched separately" );
|
|
176
|
+
test.ok( calls.length == 2, "Plugin was invoked twice" );
|
|
177
|
+
|
|
178
|
+
restoreExec();
|
|
179
|
+
test.done();
|
|
180
|
+
} );
|
|
181
|
+
} ).catch( function(err) {
|
|
182
|
+
restoreExec();
|
|
183
|
+
test.ok( false, "Unexpected error: " + err );
|
|
184
|
+
test.done();
|
|
185
|
+
} );
|
|
186
|
+
},
|
|
187
|
+
|
|
188
|
+
function passwordPlugin_coalescesConcurrentFetches(test) {
|
|
189
|
+
test.expect(2);
|
|
190
|
+
|
|
191
|
+
var engine = makeEngine({
|
|
192
|
+
passwordPlugin: 'node ./plugin.js',
|
|
193
|
+
table: 'items'
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
var calls = mockExec( function(call, callback) {
|
|
197
|
+
setTimeout( function() {
|
|
198
|
+
callback( null, JSON.stringify({ xy: 1, password: 'shared-secret', ttl: 60 }) + "\n", "" );
|
|
199
|
+
}, 10 );
|
|
200
|
+
} );
|
|
201
|
+
|
|
202
|
+
Promise.all([
|
|
203
|
+
engine.getPasswordFromPlugin(),
|
|
204
|
+
engine.getPasswordFromPlugin(),
|
|
205
|
+
engine.getPasswordFromPlugin()
|
|
206
|
+
]).then( function(passwords) {
|
|
207
|
+
test.ok( passwords.join(',') == 'shared-secret,shared-secret,shared-secret', "All callers received the same password" );
|
|
208
|
+
test.ok( calls.length == 1, "Only one plugin process was spawned" );
|
|
209
|
+
|
|
210
|
+
restoreExec();
|
|
211
|
+
test.done();
|
|
212
|
+
} ).catch( function(err) {
|
|
213
|
+
restoreExec();
|
|
214
|
+
test.ok( false, "Unexpected error: " + err );
|
|
215
|
+
test.done();
|
|
216
|
+
} );
|
|
217
|
+
},
|
|
218
|
+
|
|
219
|
+
function passwordPlugin_reportsPluginError(test) {
|
|
220
|
+
test.expect(1);
|
|
221
|
+
|
|
222
|
+
var engine = makeEngine({
|
|
223
|
+
passwordPlugin: 'node ./plugin.js',
|
|
224
|
+
table: 'items'
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
mockExec( function(call, callback) {
|
|
228
|
+
callback( null, JSON.stringify({ xy: 1, code: 'auth', description: 'Token rejected' }) + "\n", "" );
|
|
229
|
+
} );
|
|
230
|
+
|
|
231
|
+
engine.getPasswordFromPlugin().then( function() {
|
|
232
|
+
restoreExec();
|
|
233
|
+
test.ok( false, "Expected plugin error" );
|
|
234
|
+
test.done();
|
|
235
|
+
} ).catch( function(err) {
|
|
236
|
+
restoreExec();
|
|
237
|
+
test.ok( /Token rejected/.test(err.message), "Reported plugin error: " + err.message );
|
|
238
|
+
test.done();
|
|
239
|
+
} );
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
]
|
|
243
|
+
};
|