Skip to content

Commit bc651d8

Browse files
author
maxbronnikov10
committed
feat: Add ability for nat mapping through function
Signed-off-by: maxbronnikov10 <[email protected]>
1 parent 0ef0632 commit bc651d8

File tree

6 files changed

+150
-15
lines changed

6 files changed

+150
-15
lines changed

README.md

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1079,7 +1079,42 @@ const cluster = new Valkey.Cluster(
10791079
);
10801080
```
10811081

1082-
This option is also useful when the cluster is running inside a Docker container.
1082+
Or you can specify this parameter through function:
1083+
```javascript
1084+
const cluster = new Redis.Cluster(
1085+
[
1086+
{
1087+
host: "203.0.113.73",
1088+
port: 30001,
1089+
},
1090+
],
1091+
{
1092+
natMap: (key) => {
1093+
if(key.indexOf('30001')) {
1094+
return { host: "203.0.113.73", port: 30001 };
1095+
}
1096+
1097+
return null;
1098+
},
1099+
}
1100+
);
1101+
```
1102+
1103+
When is a dynamic natMap especially needed?
1104+
- Dockerized Redis clusters where IPs change frequently.
1105+
- Kubernetes-hosted Redis clusters with ephemeral Pods.
1106+
- Cloud deployments where private subnets or NAT gateways are used for Redis communication where NAT mappings frequently change.
1107+
- Scenarios with Redis node failover where failing nodes get replaced by new replicas and need rebalancing.
1108+
1109+
Example of problem in a distributed Redis cluster with NAT in a Kubernetes environment:
1110+
1111+
Your Redis client is configured to connect to 10.0.1.101:6379, but this is only accessible internally.
1112+
The client uses static natMap to remap 10.0.1.101:6379 to 203.0.113.10:6379.
1113+
A failure occurs, and the cluster rebalances, replacing 10.0.1.101:6379 with 10.0.1.105:6379.
1114+
Without a function-based natMap, the static mapping is stale, and your client can no longer connect.
1115+
With a function-based natMap, you dynamically fetch the new mapping for 10.0.1.105, ensuring continued access
1116+
1117+
Specifying through may be useful if you don't know concrete internal host and know only node port.
10831118

10841119
### Transaction and Pipeline in Cluster Mode
10851120

lib/cluster/ClusterOptions.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,9 +19,11 @@ export type DNSLookupFunction = (
1919
family?: number
2020
) => void
2121
) => void;
22-
export interface NatMap {
22+
23+
export type NatMapFunction = (key: string) => { host: string; port: number } | null;
24+
export type NatMap = {
2325
[key: string]: { host: string; port: number };
24-
}
26+
} | NatMapFunction
2527

2628
/**
2729
* Options for Cluster constructor

lib/cluster/index.ts

Lines changed: 16 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -787,17 +787,23 @@ class Cluster extends Commander {
787787
}
788788

789789
private natMapper(nodeKey: NodeKey | RedisOptions): RedisOptions {
790-
if (this.options.natMap && typeof this.options.natMap === "object") {
791-
const key =
792-
typeof nodeKey === "string"
793-
? nodeKey
794-
: `${nodeKey.host}:${nodeKey.port}`;
795-
const mapped = this.options.natMap[key];
796-
if (mapped) {
797-
debug("NAT mapping %s -> %O", key, mapped);
798-
return Object.assign({}, mapped);
799-
}
790+
const key =
791+
typeof nodeKey === "string"
792+
? nodeKey
793+
: `${nodeKey.host}:${nodeKey.port}`;
794+
795+
let mapped = null;
796+
if (this.options.natMap && typeof this.options.natMap === "function") {
797+
mapped = this.options.natMap(key);
798+
} else if (this.options.natMap && typeof this.options.natMap === "object") {
799+
mapped = this.options.natMap[key];
800+
}
801+
802+
if (mapped) {
803+
debug("NAT mapping %s -> %O", key, mapped);
804+
return Object.assign({}, mapped);
800805
}
806+
801807
return typeof nodeKey === "string"
802808
? nodeKeyToRedisOptions(nodeKey)
803809
: nodeKey;

lib/connectors/SentinelConnector/index.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -282,7 +282,16 @@ export default class SentinelConnector extends AbstractConnector {
282282
private sentinelNatResolve(item: SentinelAddress | null) {
283283
if (!item || !this.options.natMap) return item;
284284

285-
return this.options.natMap[`${item.host}:${item.port}`] || item;
285+
const key = `${item.host}:${item.port}`;
286+
287+
let result = item;
288+
if(typeof this.options.natMap === "function") {
289+
result = this.options.natMap(key) || item;
290+
} else if (typeof this.options.natMap === "object") {
291+
result = this.options.natMap[key] || item;
292+
}
293+
294+
return result;
286295
}
287296

288297
private connectToSentinel(

test/functional/cluster/nat.ts

Lines changed: 43 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import { Cluster } from "../../../lib";
55
import * as sinon from "sinon";
66

77
describe("NAT", () => {
8-
it("works for normal case", (done) => {
8+
it("works for normal case with object", (done) => {
99
const slotTable = [
1010
[0, 1, ["192.168.1.1", 30001]],
1111
[2, 16383, ["192.168.1.2", 30001]],
@@ -42,6 +42,48 @@ describe("NAT", () => {
4242
cluster.get("foo");
4343
});
4444

45+
it("works for normal case with function", (done) => {
46+
const slotTable = [
47+
[0, 1, ["192.168.1.1", 30001]],
48+
[2, 16383, ["192.168.1.2", 30001]],
49+
];
50+
51+
let cluster;
52+
new MockServer(30001, null, slotTable);
53+
new MockServer(
54+
30002,
55+
([command, arg]) => {
56+
if (command === "get" && arg === "foo") {
57+
cluster.disconnect();
58+
done();
59+
}
60+
},
61+
slotTable
62+
);
63+
64+
cluster = new Cluster(
65+
[
66+
{
67+
host: "127.0.0.1",
68+
port: 30001,
69+
},
70+
],
71+
{
72+
natMap: (key) => {
73+
if(key === "192.168.1.1:30001") {
74+
return { host: "127.0.0.1", port: 30001 };
75+
}
76+
if(key === "192.168.1.2:30001") {
77+
return { host: "127.0.0.1", port: 30002 };
78+
}
79+
return null;
80+
}
81+
}
82+
);
83+
84+
cluster.get("foo");
85+
});
86+
4587
it("works if natMap does not match all the cases", (done) => {
4688
const slotTable = [
4789
[0, 1, ["192.168.1.1", 30001]],

test/unit/clusters/index.ts

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,47 @@ describe("cluster", () => {
5656
}).to.throw(/Invalid role/);
5757
});
5858
});
59+
60+
61+
describe("natMapper", () => {
62+
it("returns the original nodeKey if no NAT mapping is provided", () => {
63+
const cluster = new Cluster([]);
64+
const nodeKey = { host: "127.0.0.1", port: 6379 };
65+
const result = cluster["natMapper"](nodeKey);
66+
67+
expect(result).to.eql(nodeKey);
68+
});
69+
70+
it("maps external IP to internal IP using NAT mapping object", () => {
71+
const natMap = { "203.0.113.1:6379": { host: "127.0.0.1", port: 30000 } };
72+
const cluster = new Cluster([], { natMap });
73+
const nodeKey = "203.0.113.1:6379";
74+
const result = cluster["natMapper"](nodeKey);
75+
expect(result).to.eql({ host: "127.0.0.1", port: 30000 });
76+
});
77+
78+
it("maps external IP to internal IP using NAT mapping function", () => {
79+
const natMap = (key) => {
80+
if (key === "203.0.113.1:6379") {
81+
return { host: "127.0.0.1", port: 30000 };
82+
}
83+
return null;
84+
};
85+
const cluster = new Cluster([], { natMap });
86+
const nodeKey = "203.0.113.1:6379";
87+
const result = cluster["natMapper"](nodeKey);
88+
expect(result).to.eql({ host: "127.0.0.1", port: 30000 });
89+
});
90+
91+
it("returns the original nodeKey if NAT mapping is invalid", () => {
92+
const natMap = { "invalid:key": { host: "127.0.0.1", port: 30000 } };
93+
const cluster = new Cluster([], { natMap });
94+
const nodeKey = "203.0.113.1:6379";
95+
const result = cluster["natMapper"](nodeKey);
96+
expect(result).to.eql({ host: "203.0.113.1", port: 6379 });
97+
});
98+
});
99+
59100
});
60101

61102
describe("nodeKeyToRedisOptions()", () => {

0 commit comments

Comments
 (0)