Skip to content

Commit

Permalink
Multitenancy (#18)
Browse files Browse the repository at this point in the history
* refactor server

Signed-off-by: Jari Kolehmainen <[email protected]>

* add multitenancy

Signed-off-by: Jari Kolehmainen <[email protected]>

* cleanup

Signed-off-by: Jari Kolehmainen <[email protected]>

* clarify agents key

Signed-off-by: Jari Kolehmainen <[email protected]>
  • Loading branch information
jakolehm authored Apr 15, 2021
1 parent 9b0ea11 commit ddb5f78
Show file tree
Hide file tree
Showing 9 changed files with 306 additions and 114 deletions.
24 changes: 23 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
7 changes: 1 addition & 6 deletions index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
2 changes: 0 additions & 2 deletions kubernetes/deployment.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,6 @@ spec:
ports:
- containerPort: 8080
env:
- name: AGENT_TOKEN
value: double0seven
- name: IDP_PUBLIC_KEY
value: |
-----BEGIN PUBLIC KEY-----
Expand Down
81 changes: 65 additions & 16 deletions src/__tests__/server.test.ts
Original file line number Diff line number Diff line change
@@ -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";

Expand All @@ -19,29 +19,40 @@ describe("TunnelServer", () => {
const port = 51515;
const secret = "doubleouseven";
const clusterAddress = "http://localhost/bored/a026e50d-f9b4-4aa8-ba02-c9722f7f0663";

/**
* {
* "sub": "lens-user",
* "groups": [
* "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(() => {
server?.stop();
});

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<string> => {
return new Promise((resolve, reject) => {
Expand Down Expand Up @@ -89,37 +100,65 @@ 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");

expect(res.statusCode).toBe(200);
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}`
Expand All @@ -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", {
Expand All @@ -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}`
});
};

Expand All @@ -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);
Expand All @@ -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);
Expand All @@ -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);
};

Expand All @@ -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}`
});
};

Expand Down
65 changes: 65 additions & 0 deletions src/request-handlers/agent-socket.ts
Original file line number Diff line number Diff line change
@@ -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);
}
});
}
53 changes: 53 additions & 0 deletions src/request-handlers/client-public-key.ts
Original file line number Diff line number Diff line change
@@ -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();
}
Loading

0 comments on commit ddb5f78

Please sign in to comment.