diff --git a/README.md b/README.md index a5671a0..41b3e8e 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,7 @@ BoreD is a secure, end-to-end encrypted, reverse tunnel daemon for Kubernetes AP - End-to-end encryption, BoreD daemon cannot see the traffic it tunnels - Link encryption using TLS for websockets (`wss://`) - Automatic reconnects +- Handles multiple Kubernetes clusters ## Architecture @@ -25,9 +26,30 @@ BoreD is a secure, end-to-end encrypted, reverse tunnel daemon for Kubernetes AP - [BoreD](./README.md) - [BoreD Agent](https://github.com/lensapp/bored-agent) -- [Lens Platform Extension](https://github.com/lensapp/lensplatform-lens-extension) (BoreD Client) +## JWT Tokens + +### Client + +```json +{ + "sub": "username", + "groups": [], + "clusterId": "cluster-uuid", + "aud": "https://bored.domain.com/" +} +``` + +### Agent + +```json +{ + "sub": "cluster-uuid", + "aud": "https://bored.domain.com/" +} +``` + ## Encryption ### Transport Layer Encryption diff --git a/index.ts b/index.ts index b57a5c2..0f455d7 100644 --- a/index.ts +++ b/index.ts @@ -4,14 +4,9 @@ import { version } from "./package.json"; console.log(`~~ BoreD v${version} ~~`); const serverPort = parseInt(process.env.PORT || "8080"); -const agentToken = process.env.AGENT_TOKEN; +const agentToken = process.env.AGENT_TOKEN || ""; const idpPublicKey = process.env.IDP_PUBLIC_KEY; -if (!agentToken) { - console.error("missing AGENT_TOKEN env, cannot continue"); - process.exit(1); -} - if (!idpPublicKey) { console.error("missing IDP_PUBLIC_KEY env, cannot continue"); process.exit(1); diff --git a/kubernetes/deployment.yaml b/kubernetes/deployment.yaml index d5acae9..f163814 100644 --- a/kubernetes/deployment.yaml +++ b/kubernetes/deployment.yaml @@ -20,8 +20,6 @@ spec: ports: - containerPort: 8080 env: - - name: AGENT_TOKEN - value: double0seven - name: IDP_PUBLIC_KEY value: | -----BEGIN PUBLIC KEY----- diff --git a/src/__tests__/server.test.ts b/src/__tests__/server.test.ts index b50543d..e4cac16 100644 --- a/src/__tests__/server.test.ts +++ b/src/__tests__/server.test.ts @@ -1,5 +1,5 @@ import { TunnelServer } from "../server"; -import got from "got"; +import got, { Headers } from "got"; import { Agent } from "../agent"; import WebSocket from "ws"; @@ -19,6 +19,7 @@ describe("TunnelServer", () => { const port = 51515; const secret = "doubleouseven"; const clusterAddress = "http://localhost/bored/a026e50d-f9b4-4aa8-ba02-c9722f7f0663"; + /** * { * "sub": "lens-user", @@ -26,14 +27,24 @@ describe("TunnelServer", () => { * "dev" * ], * "iat": 1516239022, + * "clusterId": "a026e50d-f9b4-4aa8-ba02-c9722f7f0663", * "aud": "http://localhost/bored/a026e50d-f9b4-4aa8-ba02-c9722f7f0663" * } */ - const token = "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJsZW5zLXVzZXIiLCJncm91cHMiOlsiZGV2Il0sImlhdCI6MTUxNjIzOTAyMiwiYXVkIjoiaHR0cDovL2xvY2FsaG9zdC9ib3JlZC9hMDI2ZTUwZC1mOWI0LTRhYTgtYmEwMi1jOTcyMmY3ZjA2NjMifQ.UuBNbUAT6_xcFHarHCR6CSdT63Yuu5_AA9Y5igPHdU8AvawYiY68yAxnms_xIK5d9W3Bq_Sf520dLSyl-Q4se5-Y0uT7LaFCy4nf8nbpbMdZQ0Q7b6j-G-MrcgqdU-FQeBalcuA4YoLEiXDbHioq3LKOtP0AwYNDMSwSJcMuVS-JQOtEaqPDmk-L2Jn-oWw2pV48u82_xg-RMnoCmSm5MPQ_CHPETTH2yRrXD_279Pog47_yi8Qq8a_9_GxbaHTpzxZ3Zb2n1STfVu-hOvkeRTzoydfpJ5lUYroX-YPQ8ZWeCycVAamlvW2KulDdSuPE1R-vTSE9j-Ng9kcyl8rE_w"; + const jwtToken = "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJsZW5zLXVzZXIiLCJncm91cHMiOlsiZGV2Il0sImlhdCI6MTUxNjIzOTAyMiwiY2x1c3RlcklkIjoiYTAyNmU1MGQtZjliNC00YWE4LWJhMDItYzk3MjJmN2YwNjYzIiwiYXVkIjoiaHR0cDovL2xvY2FsaG9zdC9ib3JlZC9hMDI2ZTUwZC1mOWI0LTRhYTgtYmEwMi1jOTcyMmY3ZjA2NjMifQ.jkTbX_O8UWbYdCRiTv4NEgDkewEOB9QrLOHOm_Ox8BKt7DC4696bbdOwVn_VHist0g6889ms0m8Nr_RKW5BW90ItAsfDx_0cp34_WKPuMBeXYxkfAEabBbhjATfrW1IUTVtV9R_qQ71nbqlhY9UudByfETI8CanjbDP7QYZCxmVCf2HvRML3h6mS1tqHmqZvjRAHY-cFmO8qa6xLp2c1vFMxuCoSZGoGIqoNPaLKIVBbDdjxzOEjO__gQX6ksUZxsHOy13iBre8gbBVi85lhkSCZa9OtXDEAICqsrlpHZvxIYqYMgBNG0YY4sVvvDGJgDxxTyWn8lphKrZyWWtNvjw"; + + /** + * { + * "sub": "a026e50d-f9b4-4aa8-ba02-c9722f7f0663", + * "iat": 1516239022, + * "aud": "http://localhost/bored/a026e50d-f9b4-4aa8-ba02-c9722f7f0663" + * } + */ + const agentJwtToken = "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJhMDI2ZTUwZC1mOWI0LTRhYTgtYmEwMi1jOTcyMmY3ZjA2NjMiLCJpYXQiOjE1MTYyMzkwMjIsImF1ZCI6Imh0dHA6Ly9sb2NhbGhvc3QvYm9yZWQvYTAyNmU1MGQtZjliNC00YWE4LWJhMDItYzk3MjJmN2YwNjYzIn0.ih-cyGWn83lwyQRVF4ccZ2fDt8AL78jF533RmAACt-YMR0GtcVuobZZCEoe6pGKI4uujwD8SXMaxPlTA6-bcJcVdgvmo3w4E48Lbz_PLe8A9_o1Q3RP-ak_a7Pq9igB5Whu2pK8E5IeAqjYkE34Uv7HkT9f3UvJRL1ERSZVdVMciW_1BeUfm713gdbGY89leAnff19slPdDAmMghHO1ZoRGzsFM4MvxbxjgPZiZxsOeKqTv3jWLCZ2XEFT1-s7c1K5bS9T3mctd6lgN7tJPVz4ewCsdddSgB0SoYQSMPBrfzOcLgpXl8vvow1chOQb-W-ZuQ7AZeme8CFqdqCDMpAA"; beforeEach(async () => { server = new TunnelServer(); - await server.start(port, secret, idpPublicKey, clusterAddress); + await server.start(port, "", idpPublicKey, clusterAddress); }); afterEach(() => { @@ -41,7 +52,7 @@ describe("TunnelServer", () => { }); const sleep = (amount: number) => new Promise((resolve) => setTimeout(resolve, amount)); - const get = async (path: string) => got(`http://localhost:${port}${path}`, { throwHttpErrors: false }); + const get = async (path: string, headers?: Headers) => got(`http://localhost:${port}${path}`, { throwHttpErrors: false, headers }); const incomingSocket = (type = "agent", headers: { [key: string]: string } = {}, keepOpen = 10): Promise => { return new Promise((resolve, reject) => { @@ -89,20 +100,22 @@ describe("TunnelServer", () => { expect(res.statusCode).toBe(200); }); - it("responds 200 on /.well-known/public_key", async () => { + it("responds 200 on /.well-known/public_key without bearer token", async () => { const res = await get("/.well-known/public_key"); expect(res.statusCode).toBe(200); expect(res.body).toBe(idpPublicKey); }); - it("responds 200 on /client/public-key if agent is connected", async () => { + it("responds 200 on /client/public-key without token if agent is connected", async () => { const ws = { once: jest.fn(), on: jest.fn() }; - server.agents.push(new Agent(ws as any, "rsa-public-key")); + const agents = server.getAgentsForClusterId("default"); + + agents.push(new Agent(ws as any, "rsa-public-key")); const res = await get("/client/public-key"); @@ -110,16 +123,42 @@ describe("TunnelServer", () => { expect(res.body).toBe("rsa-public-key"); }); - it("responds 404 on /client/public-key if agent is not connected", async () => { + it("responds 200 on /client/public-key with token if agent is connected", async () => { + const ws = { + once: jest.fn(), + on: jest.fn() + }; + + const agents = server.getAgentsForClusterId("a026e50d-f9b4-4aa8-ba02-c9722f7f0663"); + + agents.push(new Agent(ws as any, "rsa-public-key")); + + const res = await get("/client/public-key", { "Authorization": `Bearer ${jwtToken}`}); + + expect(res.statusCode).toBe(200); + expect(res.body).toBe("rsa-public-key"); + }); + + it("responds 404 on /client/public-key without token if agent is not connected", async () => { const res = await get("/client/public-key"); expect(res.statusCode).toBe(404); }); + + it("responds 404 on /client/public-key without token if agent is not connected", async () => { + const res = await get("/client/public-key", { "Authorization": `Bearer ${jwtToken}`}); + + expect(res.statusCode).toBe(404); + }); }); describe("websockets", () => { describe("agent socket", () => { - it("accepts agent connection with correct authorization header", async () => { + it("accepts agent connection with shared-secret authorization header", async () => { + server.stop(); + server = new TunnelServer(); + await server.start(port, secret, idpPublicKey, clusterAddress); + const connect = () => { return incomingSocket("agent", { "Authorization": `Bearer ${secret}` @@ -129,6 +168,16 @@ describe("TunnelServer", () => { await expect(connect()).resolves.toBe("open"); }); + it("accepts agent connection with jwt authorization header", async () => { + const connect = () => { + return incomingSocket("agent", { + "Authorization": `Bearer ${agentJwtToken}` + }); + }; + + await expect(connect()).resolves.toBe("open"); + }); + it("rejects agent connection with invalid authorization header", async () => { const connect = () => { return incomingSocket("agent", { @@ -143,14 +192,14 @@ describe("TunnelServer", () => { describe("client socket", () => { it("accepts client connection if agent is connected", async () => { const agent = incomingSocket("agent", { - "Authorization": `Bearer ${secret}` + "Authorization": `Bearer ${agentJwtToken}` }, 50); await sleep(10); const connect = () => { return incomingSocket("client", { - "Authorization": `Bearer ${token}` + "Authorization": `Bearer ${jwtToken}` }); }; @@ -161,7 +210,7 @@ describe("TunnelServer", () => { it("disconnects client connection if token is not signed by IdP", async () => { const agent = incomingSocket("agent", { - "Authorization": `Bearer ${secret}` + "Authorization": `Bearer ${agentJwtToken}` }, 50); await sleep(10); @@ -180,7 +229,7 @@ describe("TunnelServer", () => { it("disconnects client connection if token audience doesn't match cluster address", async () => { const agent = incomingSocket("agent", { - "Authorization": `Bearer ${secret}` + "Authorization": `Bearer ${agentJwtToken}` }, 50); await sleep(10); @@ -200,14 +249,14 @@ describe("TunnelServer", () => { it("disconnects client if agent is disconnected", async () => { const agent = incomingSocket("agent", { - "Authorization": `Bearer ${secret}` + "Authorization": `Bearer ${agentJwtToken}` }, 50); await sleep(10); const connect = () => { return incomingSocket("client", { - "Authorization": `Bearer ${token}` + "Authorization": `Bearer ${jwtToken}` }, 200); }; @@ -219,7 +268,7 @@ describe("TunnelServer", () => { it("rejects client connection if agent is not connected", async () => { const connect = () => { return incomingSocket("client", { - "Authorization": `Bearer ${token}` + "Authorization": `Bearer ${jwtToken}` }); }; diff --git a/src/request-handlers/agent-socket.ts b/src/request-handlers/agent-socket.ts new file mode 100644 index 0000000..5fc6253 --- /dev/null +++ b/src/request-handlers/agent-socket.ts @@ -0,0 +1,65 @@ +import { IncomingMessage } from "http"; +import WebSocket from "ws"; +import { Agent } from "../agent"; +import { defaultClusterId, TunnelServer } from "../server"; +import { parseAuthorization, verifyAgentToken } from "../util"; + +export function handleAgentSocket(req: IncomingMessage, socket: WebSocket, server: TunnelServer) { + if (!req.headers.authorization) { + console.log("SERVER: agent did not specify authorization header, closing connection."); + socket.close(4401); + + return; + } + + const authorization = parseAuthorization(req.headers.authorization); + + if (authorization?.type !== "bearer") { + console.log("SERVER: invalid agent token, closing connection."); + socket.close(4403); + + return; + } + + let clusterId: string; + + if (server.agentToken) { + if (authorization.token !== server.agentToken) { + console.log("SERVER: invalid agent token, closing connection."); + socket.close(4403); + + return; + } + + clusterId = defaultClusterId; + } else { + try { + const tokenData = verifyAgentToken(authorization.token, server); + + clusterId = tokenData.sub; + } catch(error) { + console.error(error); + console.log("SERVER: invalid agent jwt token, closing connection."); + socket.close(4403); + + return; + } + } + + const agents = server.getAgentsForClusterId(clusterId); + + console.log("SERVER: agent connected"); + const publicKey = Buffer.from(req.headers["x-bored-publickey"]?.toString() || "", "base64").toString("utf-8"); + const agent = new Agent(socket, publicKey); + + agents.push(agent); + + socket.on("close", () => { + console.log("SERVER: agent disconnected"); + const index = agents.findIndex((agent) => agent.socket === socket); + + if (index !== -1) { + agents.splice(index, 1); + } + }); +} diff --git a/src/request-handlers/client-public-key.ts b/src/request-handlers/client-public-key.ts new file mode 100644 index 0000000..6c8fce5 --- /dev/null +++ b/src/request-handlers/client-public-key.ts @@ -0,0 +1,53 @@ +import { IncomingMessage, ServerResponse } from "http"; +import { Agent } from "../agent"; +import { defaultClusterId, TunnelServer } from "../server"; +import { parseAuthorization, verifyClientToken } from "../util"; + +export function handleClientPublicKey(req: IncomingMessage, res: ServerResponse, server: TunnelServer) { + if (!req.headers.authorization) { // old agent + handleClientDefaultPublicKey(res, server); + + return; + } + + const authorization = parseAuthorization(req.headers.authorization); + + if (!authorization || !authorization.token) { + res.writeHead(403); + res.end(); + + return; + } + + try { + const tokenData = verifyClientToken(authorization?.token, server); + const agents = server.getAgentsForClusterId(tokenData.clusterId); + + respondWithAgentPublicKey(res, agents); + } catch(error) { + res.writeHead(4403); + res.end(); + } + + + return; +} + +function handleClientDefaultPublicKey(res: ServerResponse, server: TunnelServer) { + const agents = server.getAgentsForClusterId(defaultClusterId); + + respondWithAgentPublicKey(res, agents); +} + +function respondWithAgentPublicKey(res: ServerResponse, agents: Agent[]) { + if (agents.length === 0) { + res.writeHead(404); + res.end(); + + return; + } + + res.writeHead(200); + res.write(agents[0].publicKey); + res.end(); +} diff --git a/src/request-handlers/client-socket.ts b/src/request-handlers/client-socket.ts new file mode 100644 index 0000000..673b986 --- /dev/null +++ b/src/request-handlers/client-socket.ts @@ -0,0 +1,48 @@ +import { IncomingMessage } from "http"; +import WebSocket from "ws"; +import { defaultClusterId, TunnelServer } from "../server"; +import { parseAuthorization, verifyClientToken } from "../util"; + +export function handleClientSocket(req: IncomingMessage, socket: WebSocket, server: TunnelServer) { + if (!req.headers.authorization) { + console.log("SERVER: client did not specify authorization header, closing connection."); + socket.close(4401); + + return; + } + + const authorization = parseAuthorization(req.headers.authorization); + + if (authorization?.type !== "bearer") { + console.log("SERVER: invalid client token, closing connection."); + + socket.close(4403); + + return; + } + + let clusterId: string; + + try { + const tokenData = verifyClientToken(authorization.token, server); + + clusterId = server.agentToken === "" ? tokenData.clusterId : defaultClusterId; + } catch (error) { + console.log("SERVER: client token is not signed by IdP, or token aud invalid, closing connection"); + socket.close(4403); + + return; + } + + console.log("SERVER: client connected"); + const agents = server.getAgentsForClusterId(clusterId); + const agent = agents[Math.floor(Math.random() * agents.length)]; + + if (!agent) { + console.log("SERVER: no agents online, closing client request"); + socket.close(4404); + + return; + } + agent.addClient(socket); +} diff --git a/src/server.ts b/src/server.ts index 15169c3..2096b8d 100644 --- a/src/server.ts +++ b/src/server.ts @@ -3,16 +3,21 @@ import { IncomingMessage, ServerResponse, createServer, Server as HttpServer }  import { Agent } from "./agent"; import { Socket } from "net"; import { URL } from "url"; -import * as jwt from "jsonwebtoken"; -import { parseAuthorization } from "./util"; +import { handleClientPublicKey } from "./request-handlers/client-public-key"; +import { handleAgentSocket } from "./request-handlers/agent-socket"; +import { handleClientSocket } from "./request-handlers/client-socket"; + +export type ClusterId = string; +export const defaultClusterId: ClusterId = "default"; export class TunnelServer { - private agentToken = ""; - private idpPublicKey = ""; - private clusterAddress?: string; private server?: HttpServer; private ws?: Server; - public agents: Agent[] = []; + + public agentToken = ""; + public idpPublicKey = ""; + public clusterAddress?: string; + public agents: Map = new Map(); start(port = 8080, agentToken: string, idpPublicKey: string, clusterAddress = process.env.CLUSTER_ADDRESS || ""): Promise { this.agentToken = agentToken; @@ -44,6 +49,14 @@ export class TunnelServer { this.server?.close(); } + getAgentsForClusterId(clusterId: string): Agent[] { + const agents = this.agents.get(clusterId) || []; + + if (!this.agents.has(clusterId)) this.agents.set(clusterId, agents); + + return agents; + } + handleRequest(req: IncomingMessage, res: ServerResponse) { if (!req.url) return; @@ -75,10 +88,8 @@ export class TunnelServer { return; } - if (url.pathname === "/client/public-key" && this.agents.length > 0) { - res.writeHead(200); - res.write(this.agents[0].publicKey); - res.end(); + if (url.pathname === "/client/public-key") { + handleClientPublicKey(req, res, this); return; } @@ -98,89 +109,12 @@ export class TunnelServer { if (url.pathname === "/agent/connect") { this.ws?.handleUpgrade(req, socket, head, (socket: WebSocket) => { - this.handleAgentSocket(req, socket); + handleAgentSocket(req, socket, this); }); } else if (url.pathname === "/client/connect") { this.ws?.handleUpgrade(req, socket, head, (socket: WebSocket) => { - this.handleClientSocket(req, socket); - }); - } - } - - handleAgentSocket(req: IncomingMessage, socket: WebSocket) { - if (!req.headers.authorization) { - console.log("SERVER: agent did not specify authorization header, closing connection."); - socket.close(4401); - - return; - } - - const authorization = parseAuthorization(req.headers.authorization); - - if (authorization?.type !== "bearer" || authorization.token !== this.agentToken) { - console.log("SERVER: invalid agent token, closing connection."); - - socket.close(4403); - - return; - } - - console.log("SERVER: agent connected"); - const publicKey = Buffer.from(req.headers["x-bored-publickey"]?.toString() || "", "base64").toString("utf-8"); - const agent = new Agent(socket, publicKey); - - this.agents.push(agent); - - socket.on("close", () => { - console.log("SERVER: agent disconnected"); - const index = this.agents.findIndex((agent) => agent.socket === socket); - - if (index !== -1) { - this.agents.splice(index, 1); - } - }); - } - - handleClientSocket(req: IncomingMessage, socket: WebSocket) { - if (!req.headers.authorization) { - console.log("SERVER: client did not specify authorization header, closing connection."); - socket.close(4401); - - return; - } - - const authorization = parseAuthorization(req.headers.authorization); - - if (authorization?.type !== "bearer") { - console.log("SERVER: invalid client token, closing connection."); - - socket.close(4403); - - return; - } - - try { - jwt.verify(authorization.token, this.idpPublicKey, { - algorithms: ["RS256", "RS384", "RS512"], - audience: this.clusterAddress + handleClientSocket(req, socket, this); }); - } catch (error) { - console.log("SERVER: client token is not signed by IdP, or token aud invalid, closing connection"); - socket.close(4403); - - return; - } - - - console.log("SERVER: client connected"); - const agent = this.agents[Math.floor(Math.random() * this.agents.length)]; - - if (!agent) { - console.log("SERVER: no agents online, closing client request"); - socket.close(4404); - - return; } - agent.addClient(socket); } } diff --git a/src/util.ts b/src/util.ts index 388fa7c..cab6ef5 100644 --- a/src/util.ts +++ b/src/util.ts @@ -1,3 +1,16 @@ +import * as jwt from "jsonwebtoken"; +import { TunnelServer } from "./server"; + +export type ClientTokenData = { + sub: string; + aud: string; + clusterId: string; +}; + +export type AgentTokenData = { + sub: string; + aud: string; +}; export function parseAuthorization(authHeader: string) { const authorization = authHeader.split(" "); @@ -11,3 +24,18 @@ export function parseAuthorization(authHeader: string) { token: authorization[1] }; } + +export function verifyClientToken(token: string, server: TunnelServer) { + return jwt.verify(token, server.idpPublicKey, { + algorithms: ["RS256", "RS384", "RS512"], + audience: server.clusterAddress + }) as ClientTokenData; +} + + +export function verifyAgentToken(token: string, server: TunnelServer) { + return jwt.verify(token, server.idpPublicKey, { + algorithms: ["RS256", "RS384", "RS512"], + audience: server.clusterAddress + }) as AgentTokenData; +}