Skip to content

Commit

Permalink
feat: Add ability for nat mapping through function
Browse files Browse the repository at this point in the history
Signed-off-by: maxbronnikov10 <[email protected]>
  • Loading branch information
maxbronnikov10 committed Jan 23, 2025
1 parent 0ef0632 commit bc651d8
Show file tree
Hide file tree
Showing 6 changed files with 150 additions and 15 deletions.
37 changes: 36 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -1079,7 +1079,42 @@ const cluster = new Valkey.Cluster(
);
```

This option is also useful when the cluster is running inside a Docker container.
Or you can specify this parameter through function:
```javascript
const cluster = new Redis.Cluster(
[
{
host: "203.0.113.73",
port: 30001,
},
],
{
natMap: (key) => {
if(key.indexOf('30001')) {
return { host: "203.0.113.73", port: 30001 };
}

return null;
},
}
);
```

When is a dynamic natMap especially needed?
- Dockerized Redis clusters where IPs change frequently.
- Kubernetes-hosted Redis clusters with ephemeral Pods.
- Cloud deployments where private subnets or NAT gateways are used for Redis communication where NAT mappings frequently change.
- Scenarios with Redis node failover where failing nodes get replaced by new replicas and need rebalancing.

Example of problem in a distributed Redis cluster with NAT in a Kubernetes environment:

Your Redis client is configured to connect to 10.0.1.101:6379, but this is only accessible internally.
The client uses static natMap to remap 10.0.1.101:6379 to 203.0.113.10:6379.
A failure occurs, and the cluster rebalances, replacing 10.0.1.101:6379 with 10.0.1.105:6379.
Without a function-based natMap, the static mapping is stale, and your client can no longer connect.
With a function-based natMap, you dynamically fetch the new mapping for 10.0.1.105, ensuring continued access

Specifying through may be useful if you don't know concrete internal host and know only node port.

### Transaction and Pipeline in Cluster Mode

Expand Down
6 changes: 4 additions & 2 deletions lib/cluster/ClusterOptions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,9 +19,11 @@ export type DNSLookupFunction = (
family?: number
) => void
) => void;
export interface NatMap {

export type NatMapFunction = (key: string) => { host: string; port: number } | null;
export type NatMap = {
[key: string]: { host: string; port: number };
}
} | NatMapFunction

/**
* Options for Cluster constructor
Expand Down
26 changes: 16 additions & 10 deletions lib/cluster/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -787,17 +787,23 @@ class Cluster extends Commander {
}

private natMapper(nodeKey: NodeKey | RedisOptions): RedisOptions {
if (this.options.natMap && typeof this.options.natMap === "object") {
const key =
typeof nodeKey === "string"
? nodeKey
: `${nodeKey.host}:${nodeKey.port}`;
const mapped = this.options.natMap[key];
if (mapped) {
debug("NAT mapping %s -> %O", key, mapped);
return Object.assign({}, mapped);
}
const key =
typeof nodeKey === "string"
? nodeKey
: `${nodeKey.host}:${nodeKey.port}`;

let mapped = null;
if (this.options.natMap && typeof this.options.natMap === "function") {
mapped = this.options.natMap(key);
} else if (this.options.natMap && typeof this.options.natMap === "object") {
mapped = this.options.natMap[key];
}

if (mapped) {
debug("NAT mapping %s -> %O", key, mapped);
return Object.assign({}, mapped);
}

return typeof nodeKey === "string"
? nodeKeyToRedisOptions(nodeKey)
: nodeKey;
Expand Down
11 changes: 10 additions & 1 deletion lib/connectors/SentinelConnector/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -282,7 +282,16 @@ export default class SentinelConnector extends AbstractConnector {
private sentinelNatResolve(item: SentinelAddress | null) {
if (!item || !this.options.natMap) return item;

return this.options.natMap[`${item.host}:${item.port}`] || item;
const key = `${item.host}:${item.port}`;

let result = item;
if(typeof this.options.natMap === "function") {
result = this.options.natMap(key) || item;
} else if (typeof this.options.natMap === "object") {
result = this.options.natMap[key] || item;
}

return result;
}

private connectToSentinel(
Expand Down
44 changes: 43 additions & 1 deletion test/functional/cluster/nat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { Cluster } from "../../../lib";
import * as sinon from "sinon";

describe("NAT", () => {
it("works for normal case", (done) => {
it("works for normal case with object", (done) => {
const slotTable = [
[0, 1, ["192.168.1.1", 30001]],
[2, 16383, ["192.168.1.2", 30001]],
Expand Down Expand Up @@ -42,6 +42,48 @@ describe("NAT", () => {
cluster.get("foo");
});

it("works for normal case with function", (done) => {
const slotTable = [
[0, 1, ["192.168.1.1", 30001]],
[2, 16383, ["192.168.1.2", 30001]],
];

let cluster;
new MockServer(30001, null, slotTable);
new MockServer(
30002,
([command, arg]) => {
if (command === "get" && arg === "foo") {
cluster.disconnect();
done();
}
},
slotTable
);

cluster = new Cluster(
[
{
host: "127.0.0.1",
port: 30001,
},
],
{
natMap: (key) => {
if(key === "192.168.1.1:30001") {
return { host: "127.0.0.1", port: 30001 };
}
if(key === "192.168.1.2:30001") {
return { host: "127.0.0.1", port: 30002 };
}
return null;
}
}
);

cluster.get("foo");
});

it("works if natMap does not match all the cases", (done) => {
const slotTable = [
[0, 1, ["192.168.1.1", 30001]],
Expand Down
41 changes: 41 additions & 0 deletions test/unit/clusters/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,47 @@ describe("cluster", () => {
}).to.throw(/Invalid role/);
});
});


describe("natMapper", () => {
it("returns the original nodeKey if no NAT mapping is provided", () => {
const cluster = new Cluster([]);
const nodeKey = { host: "127.0.0.1", port: 6379 };
const result = cluster["natMapper"](nodeKey);

expect(result).to.eql(nodeKey);
});

it("maps external IP to internal IP using NAT mapping object", () => {
const natMap = { "203.0.113.1:6379": { host: "127.0.0.1", port: 30000 } };
const cluster = new Cluster([], { natMap });
const nodeKey = "203.0.113.1:6379";
const result = cluster["natMapper"](nodeKey);
expect(result).to.eql({ host: "127.0.0.1", port: 30000 });
});

it("maps external IP to internal IP using NAT mapping function", () => {
const natMap = (key) => {
if (key === "203.0.113.1:6379") {
return { host: "127.0.0.1", port: 30000 };
}
return null;
};
const cluster = new Cluster([], { natMap });
const nodeKey = "203.0.113.1:6379";
const result = cluster["natMapper"](nodeKey);
expect(result).to.eql({ host: "127.0.0.1", port: 30000 });
});

it("returns the original nodeKey if NAT mapping is invalid", () => {
const natMap = { "invalid:key": { host: "127.0.0.1", port: 30000 } };
const cluster = new Cluster([], { natMap });
const nodeKey = "203.0.113.1:6379";
const result = cluster["natMapper"](nodeKey);
expect(result).to.eql({ host: "203.0.113.1", port: 6379 });
});
});

});

describe("nodeKeyToRedisOptions()", () => {
Expand Down

0 comments on commit bc651d8

Please sign in to comment.