From 4e6f6bf466eebb3c0c0a99a87894618e2cc40860 Mon Sep 17 00:00:00 2001 From: Mads Jon Nielsen Date: Sat, 21 Feb 2026 22:22:44 +0100 Subject: [PATCH 1/2] Add Noise protocol encryption for rathole tunnels Kings can now be started with --noise-private-key and --noise-public-key to enable Noise protocol (NK pattern) encryption on rathole tunnels. The public key is distributed to lings via council state, and lings automatically enable encryption when connecting to keys that have one. --- examples/docker-swarm/stack.yml | 2 ++ readme.md | 10 +++++- src/cmds/king-cmd.ts | 10 ++++++ src/configs/king-config.ts | 8 +++++ src/managers/king-rathole-manager.ts | 14 +++++++- src/managers/ling-rathole-manager.ts | 33 ++++++++++++++++++- src/routes/council-route-put-king.ts | 11 +++++++ src/state-handler.ts | 1 + src/tickers/king-syncer.ts | 1 + tests/council-provisioner.test.ts | 3 +- tests/council-server.test.ts | 48 ++++++++++++++++++++++++++++ 11 files changed, 137 insertions(+), 4 deletions(-) diff --git a/examples/docker-swarm/stack.yml b/examples/docker-swarm/stack.yml index 2643c6c..2975135 100644 --- a/examples/docker-swarm/stack.yml +++ b/examples/docker-swarm/stack.yml @@ -27,6 +27,8 @@ services: --host="172.17.0.1" --rathole="bind_port=2333 ports=5000-5001" --location=CPH + # --noise-private-key="" + # --noise-public-key="" ports: - { target: 2333, published: 2333, protocol: tcp, mode: host } - { target: 5000, published: 5000, protocol: tcp, mode: host } diff --git a/readme.md b/readme.md index 22cf78d..9cfa653 100644 --- a/readme.md +++ b/readme.md @@ -16,7 +16,15 @@ Controlplane application starting rathole servers, must be reachable for all rat Dataplane application managing rathole clients and traefik proxies, can be completely isolated ### encryption -Since reverse tunnel and proxy encryption isn't implemented yet, it's highly recommended that network traffic encryption is handled via other mechanisms (e.g. [Nebula](https://github.com/slackhq/nebula) or VPN), unless you are absolutely sure your traffic will stay in-house +Rathole tunnel traffic between kings and lings can be encrypted using the [Noise protocol](https://noiseprotocol.org/) (NK pattern). Generate a keypair with `rathole --genkey` and pass the keys to the king: + +```bash +king --noise-private-key="" --noise-public-key="" ... +``` + +The king sends its public key to the council, which distributes it to lings via state. Lings automatically enable encryption when connecting to kings that have a noise public key. Kings without noise keys continue to work unencrypted. + +For proxy traffic or additional defense-in-depth, network-level encryption via [Nebula](https://github.com/slackhq/nebula) or VPN is still recommended. ## Quickstart diff --git a/src/cmds/king-cmd.ts b/src/cmds/king-cmd.ts index 0edc3ef..6716f89 100644 --- a/src/cmds/king-cmd.ts +++ b/src/cmds/king-cmd.ts @@ -14,6 +14,8 @@ export interface KingArguments { rathole: string[]; host: string; location: string; + noisePrivateKey?: string; + noisePublicKey?: string; } export const command = "king"; @@ -61,5 +63,13 @@ export function builder (yargs: Argv) { description: "Location identifier", demandOption: true, }); + yargs.options("noise-private-key", { + type: "string", + description: "Base64 encoded Noise protocol private key (from rathole --genkey)", + }); + yargs.options("noise-public-key", { + type: "string", + description: "Base64 encoded Noise protocol public key (from rathole --genkey)", + }); return yargs; } diff --git a/src/configs/king-config.ts b/src/configs/king-config.ts index 2f8b0a1..2ead257 100644 --- a/src/configs/king-config.ts +++ b/src/configs/king-config.ts @@ -12,12 +12,20 @@ export class KingConfig { location: string; host: string; councilHost: string; + noisePrivateKey: string | null; + noisePublicKey: string | null; constructor (args: KingArguments) { this.location = args.location; this.host = args.host; this.councilHost = args.councilHost; + const hasPrivate = args.noisePrivateKey != null; + const hasPublic = args.noisePublicKey != null; + assert(hasPrivate === hasPublic, "--noise-private-key and --noise-public-key must both be set or both be omitted"); + this.noisePrivateKey = args.noisePrivateKey ?? null; + this.noisePublicKey = args.noisePublicKey ?? null; + for (const ratholeArg of args.rathole ?? []) { const pairs: {[key: string]: string} = {}; for (const pair of ratholeArg.split(" ")) { diff --git a/src/managers/king-rathole-manager.ts b/src/managers/king-rathole-manager.ts index 152da17..8bc897f 100644 --- a/src/managers/king-rathole-manager.ts +++ b/src/managers/king-rathole-manager.ts @@ -39,9 +39,21 @@ export class KingRatholeManager extends ProcessManager { "[server]", `bind_addr = "0.0.0.0:${bindPort}"`, "", - "[server.services]", ); + if (this.context.config.noisePrivateKey) { + lines.push( + "[server.transport]", + `type = "noise"`, + "", + "[server.transport.noise]", + `local_private_key = "${this.context.config.noisePrivateKey}"`, + "", + ); + } + + lines.push("[server.services]"); + for (const service of services) { lines.push( `[server.services.${service.service_id.replace(/:/g, "-")}]`, diff --git a/src/managers/ling-rathole-manager.ts b/src/managers/ling-rathole-manager.ts index 2b97534..aa1557c 100644 --- a/src/managers/ling-rathole-manager.ts +++ b/src/managers/ling-rathole-manager.ts @@ -9,6 +9,7 @@ import {RatholeTransform} from "../stream/rathole-transform.js"; export class LingRatholeManager extends ProcessManager { private readonly context; + private readonly noiseKeys = new Map(); constructor (context: LingContext) { super({...context, serviceType: "ratling"}); @@ -26,15 +27,35 @@ export class LingRatholeManager extends ProcessManager { }); } + private getNoisePublicKey (kingBindAddr: string): string | null { + const [host, portStr] = kingBindAddr.split(":"); + const bindPort = Number(portStr); + const king = this.context.state.kings.find(k => k.host === host && k.bind_port === bindPort); + return king?.noise_public_key ?? null; + } + private writeRatholeFile (kingBindAddr: string, services: StateService[], config: LingConfig, lingId: string): string { + const noisePublicKey = this.getNoisePublicKey(kingBindAddr); const lines = []; lines.push( "[client]", `remote_addr = "${kingBindAddr}"`, "", - "[client.services]", ); + if (noisePublicKey) { + lines.push( + "[client.transport]", + `type = "noise"`, + "", + "[client.transport.noise]", + `remote_public_key = "${noisePublicKey}"`, + "", + ); + } + + lines.push("[client.services]"); + for (const service of services.filter(s => `${s.host}:${s.bind_port}` === kingBindAddr)) { const ratholeCnf = config.ratholeMap.get(service.name); assert(ratholeCnf != null, "ratholeCnf is undefined or null"); @@ -57,6 +78,16 @@ export class LingRatholeManager extends ProcessManager { const kingBindAddrs = services.map(s => `${s.host}:${s.bind_port}`); + // Kill processes where the noise key has changed so they restart with updated config + for (const kingBindAddr of kingBindAddrs) { + const noisePublicKey = this.getNoisePublicKey(kingBindAddr); + const previousKey = this.noiseKeys.get(kingBindAddr); + if (previousKey !== undefined && previousKey !== noisePublicKey) { + await this.killProcess(kingBindAddr, "SIGTERM"); + } + this.noiseKeys.set(kingBindAddr, noisePublicKey); + } + // Ensure rathole process is running and maintain rathole client configuration file for (const kingBindAddr of kingBindAddrs) { const ratholeFile = this.writeRatholeFile(kingBindAddr, services, config, lingId); diff --git a/src/routes/council-route-put-king.ts b/src/routes/council-route-put-king.ts index 643cc54..a03e0e5 100644 --- a/src/routes/council-route-put-king.ts +++ b/src/routes/council-route-put-king.ts @@ -22,11 +22,21 @@ export default async function ({req, res, state, provisioner, socketIo}: RouteCt } } + const noisePublicKey: string | null = data["noise_public_key"] ?? null; + for (const rathole of data["ratholes"]) { const king = state.kings.find(k => k.ports === rathole.ports && k.host === data.host); if (king) { + let changed = false; if (king.shutting_down !== data["shutting_down"]) { king.shutting_down = data["shutting_down"]; + changed = true; + } + if (king.noise_public_key !== noisePublicKey) { + king.noise_public_key = noisePublicKey; + changed = true; + } + if (changed) { state.revision++; provisioner.provision(state); socketIo.sockets.emit("state-changed"); @@ -41,6 +51,7 @@ export default async function ({req, res, state, provisioner, socketIo}: RouteCt location: data["location"], beat: Date.now(), shutting_down: false, + noise_public_key: noisePublicKey, }); state.revision++; provisioner.provision(state); diff --git a/src/state-handler.ts b/src/state-handler.ts index 4cdb065..e2388fe 100644 --- a/src/state-handler.ts +++ b/src/state-handler.ts @@ -11,6 +11,7 @@ export interface StateKing { shutting_down: boolean; beat: number; location: string; + noise_public_key: string | null; } export interface StateLing { diff --git a/src/tickers/king-syncer.ts b/src/tickers/king-syncer.ts index 8c943df..a94a89a 100644 --- a/src/tickers/king-syncer.ts +++ b/src/tickers/king-syncer.ts @@ -24,6 +24,7 @@ export class KingSyncer extends Ticker { ratholes: this.context.config.ratholes, ready_service_ids: this.context.readyServiceIds, location: this.context.config.location, + noise_public_key: this.context.config.noisePublicKey, }, })); if (err || response.statusCode !== 200) { diff --git a/tests/council-provisioner.test.ts b/tests/council-provisioner.test.ts index 4717969..3e51577 100644 --- a/tests/council-provisioner.test.ts +++ b/tests/council-provisioner.test.ts @@ -27,7 +27,7 @@ test("Find available port on king", () => { remote_port: null, }, ], - kings: [{host: "kinghost.com", ports: "5000-5000", location: "myhouse", bind_port: 2343, beat: 0, shutting_down: false}], + kings: [{host: "kinghost.com", ports: "5000-5000", location: "myhouse", bind_port: 2343, beat: 0, shutting_down: false, noise_public_key: null}], lings: [{ling_id: "some_ling_id", beat: 0, shutting_down: false}], revision: 0, }; @@ -43,6 +43,7 @@ test("Find available port on king", () => { "location": "myhouse", "ports": "5000-5000", "shutting_down": false, + "noise_public_key": null, }, "ports": [5000], }, diff --git a/tests/council-server.test.ts b/tests/council-server.test.ts index 83af82a..3dcfe34 100644 --- a/tests/council-server.test.ts +++ b/tests/council-server.test.ts @@ -45,5 +45,53 @@ describe("PUT /king", () => { expect(res.text).toEqual("ok"); expect(res.statusCode).toEqual(200); }); + + test("stores noise_public_key on king creation", async () => { + const server = createServer({provisioner, state}).httpServer; + await request(server).put("/king").send({ + ratholes: [{bind_port: 2333, ports: "5000-5001"}], + ready_service_ids: [], + location: "mylocation", + host: "example.com", + noise_public_key: "abc123pubkey", + }); + expect(state.kings).toHaveLength(1); + expect(state.kings[0].noise_public_key).toEqual("abc123pubkey"); + }); + + test("stores null noise_public_key when not provided", async () => { + const server = createServer({provisioner, state}).httpServer; + await request(server).put("/king").send({ + ratholes: [{bind_port: 2333, ports: "5000-5001"}], + ready_service_ids: [], + location: "mylocation", + host: "example.com", + }); + expect(state.kings).toHaveLength(1); + expect(state.kings[0].noise_public_key).toBeNull(); + }); + + test("updates noise_public_key on existing king", async () => { + state.kings.push({ + bind_port: 2333, + ports: "5000-5001", + host: "example.com", + location: "mylocation", + beat: 0, + shutting_down: false, + noise_public_key: null, + }); + const initialRevision = state.revision; + const server = createServer({provisioner, state}).httpServer; + await request(server).put("/king").send({ + ratholes: [{bind_port: 2333, ports: "5000-5001"}], + ready_service_ids: [], + location: "mylocation", + host: "example.com", + noise_public_key: "newkey123", + }); + expect(state.kings[0].noise_public_key).toEqual("newkey123"); + expect(state.revision).toBeGreaterThan(initialRevision); + }); }); From 236af4cb0af16ff62ff5e318bd00072db7d1ed62 Mon Sep 17 00:00:00 2001 From: Mads Jon Nielsen Date: Sat, 28 Feb 2026 17:14:48 +0100 Subject: [PATCH 2/2] Fix eslint quotes errors in rathole managers Use escaped double quotes instead of template literals for strings without interpolation. --- src/managers/king-rathole-manager.ts | 2 +- src/managers/ling-rathole-manager.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/managers/king-rathole-manager.ts b/src/managers/king-rathole-manager.ts index 8bc897f..e56da07 100644 --- a/src/managers/king-rathole-manager.ts +++ b/src/managers/king-rathole-manager.ts @@ -44,7 +44,7 @@ export class KingRatholeManager extends ProcessManager { if (this.context.config.noisePrivateKey) { lines.push( "[server.transport]", - `type = "noise"`, + "type = \"noise\"", "", "[server.transport.noise]", `local_private_key = "${this.context.config.noisePrivateKey}"`, diff --git a/src/managers/ling-rathole-manager.ts b/src/managers/ling-rathole-manager.ts index aa1557c..44877ef 100644 --- a/src/managers/ling-rathole-manager.ts +++ b/src/managers/ling-rathole-manager.ts @@ -46,7 +46,7 @@ export class LingRatholeManager extends ProcessManager { if (noisePublicKey) { lines.push( "[client.transport]", - `type = "noise"`, + "type = \"noise\"", "", "[client.transport.noise]", `remote_public_key = "${noisePublicKey}"`,