hmt 4 месяцев назад
Родитель
Сommit
550e1ca234
9 измененных файлов с 75 добавлено и 271 удалено
  1. 32 39
      README.md
  2. 0 87
      app.ts
  3. 0 53
      bbb.ts
  4. 7 0
      deno.json
  5. 29 0
      deno.lock
  6. 0 10
      deps.ts
  7. 7 10
      main.ts
  8. 0 19
      mod.ts
  9. 0 53
      servers.ts

+ 32 - 39
README.md

@@ -1,5 +1,8 @@
 # Tinyscale
 
+TLDR: tinyscale is a single file load balancing solution for BigBlueButton that
+runs on deno.
+
 Depending on your requirements for BigBlueButton and your server capabilities
 you may need to host more than one instance of BigBlueButton to host meetings
 for your users. But as soon as you run more than one server you either need to
@@ -7,17 +10,12 @@ split up servers for different endpoints, let's say one BBB server for
 Greenlight and another one for moodle, or you install a load balancer that
 decides for you where new meetings are set up and where users are routed to. If
 you only use one endpoint for all your meetings you will have to use a load
-balancer such as scalelite to run more than one BBB server.
+balancer such as scalelite to run more than one BBB instance.
 
 While scalelite is a very good load balancer it also uses a lot of resources and
-is somewhat complicated to set up (ymmv). Since my resources were low and I
-could not affort to rent yet another server to run scalelite, I created
-tinyscale to solve the issue of load balancing multiple BBB instances.
-
-tinyscale is called tinyscale because, well, it is tiny. It runs on low end
-hardware and some spare CPU cycles on on of your existing servers is enough to
-deploy it. It is a simple TypeScript application that runs on deno, the next
-generation of NodeJS.
+is somewhat complicated to set up (ymmv). tinyscale aims to fill the niche for a
+lightweight load balancing option that uses very little resources and keeps
+things as simple as possible.
 
 ## So, how does it work?
 
@@ -25,25 +23,25 @@ tinyscale offers a unified gateway to your set of BBB servers. Just like BBB you
 have to give your endpoint your tinyscale address and a secret and on the server
 side you have to give tinyscale a JSON file with your servers in it. tinyscale
 then checks to see if it can connect to your BBB servers and wait for incoming
-calls from your endpoint(s). If you send it a `create` request, i.e. you want to
-open a new meeting room, tinyscale checks if that room exists on any of the BBB
-servers. If it does it will return that server's reply to your endpoint. If a
-user wants to `join` a meeting tinyscale does the same again and checks if there
-is on any of the BBB servers a meeting with the incoming ID. If there is it will
-redirect the client to that server. If the meeting does not exist tinyscale will
-reply with the original reply of one of the connected servers.
-
-The loadbalancing part is where a user wants to `create` a new room on a server
-which does not yet exist. When that happens tinyscale will send the request to
-the next server. So each time a new server is created the next server in the
-list of available servers is called to create the room and will accept all
-incoming users. There is no advanced number checking of rooms or participants or
-if your server is actually capable of serving more people. It just tries to
-create new rooms evenly on all servers. tinyscale does not know if your server
-is capable of serving that room or if there are too many people on the server.
-Just like BBB it will accept every request and forward it to the servers. As
-administrator you are still responsible to monitor the BBB servers and make sure
-they are capable of serving enough meetings for your users.
+calls from your endpoint(s).
+
+If you send a request to tinyscale it checks basically for two things: is there
+a BBB instance running that has the requested `meetingID` or `recordingID` and
+then redirects your request to that instance. You will from then on communicate
+directly with that instance.
+
+In case the request is a `create` call and the `meetingID` doesn't yet exist
+tinyscale will pick the next instance from the list of your BBB instances and
+will create a new room on that instance. The response is then returned to your
+endpoint.
+
+Apart from that all incoming requests are checked for a valid checksum according
+to BBB's own API. If an invalid checksum is found you will get an error
+response.
+
+The load balancing feature is basically a cycle through the available servers
+with every room creation request where no room could be found on any of the
+available instances.
 
 ## Getting started
 
@@ -56,11 +54,11 @@ Then create a `servers.json` file like this here:
 ```json
 [
   {
-    "host": "http://bbb1.schule.de",
+    "host": "http://bbb1.schule.de/bigbluebutton/api",
     "secret": "secret_string"
   },
   {
-    "host": "http://bbb2.schule.de",
+    "host": "http://bbb2.schule.de/bigbluebutton/api",
     "secret": "secret_string"
   }
 ]
@@ -69,11 +67,11 @@ Then create a `servers.json` file like this here:
 Now you are ready to start the script. Make sure to have an environment variable
 called `TINYSCALE_SECRET`:
 
-    TINYSCALE_SECRET=some_secret_string deno run --allow-net --allow-read --allow-env https://deno.land/x/tinyscale@v1.8.1/mod.ts
+    TINYSCALE_SECRET=some_secret_string deno run --allow-net --allow-env https://deno.land/x/tinyscale@v2.0.0/main.ts
 
-tinyscale will then run on port 3005 and you will have to set up your reverse
+tinyscale will then run on port 8000 and you will have to set up your reverse
 proxy so that it can pick up requests. If you prefer a different port you can
-set one with another env var: `PORT 3006`
+set one with another env var: `PORT 3005`
 
 When started, tinyscale will connect to each server and make a single call to
 check if your configuration is correct. If there is a problem tinyscale will
@@ -82,18 +80,13 @@ replacing your existing BBB settings with the new tinyscale url in your
 endpoints. Make sure to also replace the BBB secrets with your new
 `TINYSCALE_SECRET`.
 
-tinyscale has been tested to work with NextCloud, Moodle and Greenlight. Let me
-know if it works with other endpoints as well.
+tinyscale has been battle tested to work with NextCloud, Moodle and Mattermost.
 
 ## Caveats
 
 - tinyscale does not combine requests like `getMeetings`. It will return the
   list of meetings from the current server (i.e. the last one used for creating
   a room).
-- you cannot get recordings if the requests don't include the `meedingID` as
-  part of the request query. Also you can't get a list of recordings since
-  tinyscale only checks for `meetingID`s. So if you do request all recordings
-  you will receive the list of the current server.
 
 MIT Licensed
 

+ 0 - 87
app.ts

@@ -1,87 +0,0 @@
-import { opine, ErrorRequestHandler, Router, secret, HttpError, Color, deferred, Deferred } from "./deps.ts";
-import { BBB } from './bbb.ts';
-import { Servers } from './servers.ts'
-import type { server } from './deps.ts'
-
-const date = () => new Date().toLocaleTimeString('de')
-
-const S = new Servers()
-await S.init()
-let queue: Record<string, Deferred<string>> = {}
-
-const router = Router()
-router.use((req, res, next)=> {
-  res.set('Content-Type', 'text/xml')
-  next()
-});
-// check authentication via checksum
-router.use("/:call", (req, res, next) => {
-  const handler = new BBB(req)
-  const authenticated = handler.authenticated(secret)
-  res.locals.log = [`${date()} ${Color.green(handler.call)}${authenticated ? '':Color.red(' Rejected')}`]
-  if (authenticated) { 
-    res.locals.handler = handler
-    next()
-  } else {
-    next(new HttpError(401));
-  }
-})
-// if the param is call, check for races
-router.all('/create', async (req, res, next) => {
-  const meeting_id = req.query.meetingID
-  const existing_id = queue[meeting_id]
-  if (existing_id) {
-    console.log(`Race pending for meeting-ID: ${Color.red(meeting_id)}`)
-    await existing_id
-  }
-  queue[meeting_id] = deferred<string>();
-  next()
-})
-// the api itself answering to every call
-router.all("/:call", async (req, res, next) => {
-  const handler = res.locals.handler
-  let server: server
-  try {
-    server = await handler.find_meeting_id(S.servers)
-    res.locals.log.push(`found, ${handler.call==='join'?'redirect to':'reply with'} ${server.host}`)
-  } catch (e) {
-    res.locals.log.push(`${Color.yellow("not found")},`)
-    if (handler.call === 'create') {
-      S.get_available_server()
-      res.locals.log.push(`open new room on ${Color.green(S.current_server.host)}`);
-    } else res.locals.log.push(`reply with ${S.current_server.host}`)
-    server = S.current_server
-  }
-  const redirect = handler.rewritten_query(server)
-  if (handler.call === 'join') {
-    res.redirect(redirect)
-  } else {
-    try {
-      const data = await fetch(redirect)
-      const body = await data.text()
-      if (handler.call === 'create') { queue[handler.meeting_id]?.resolve(body); delete queue[handler.meeting_id] }
-      res.send(body)
-    } catch (e) {
-      if (handler.call === 'create') { queue[handler.meeting_id]?.resolve(e); delete queue[handler.meeting_id] }
-      next(new HttpError(500));
-    }
-  }
-  console.log(res.locals.log.join(' '));
-});
-// the fake answering machine to make sure we are recognized as a proper api
-router.get("/", (req, res, next) => {
-  res.send(`<response><returncode>SUCCESS</returncode><version>2.0</version></response>`);
-})
-
-const errorHandler: ErrorRequestHandler = (err, req, res, next) => {
-  res.setStatus(err.status ?? 500);
-  res.end();
-  console.log(`${Color.red(`${res.status}`)} ${req.originalUrl}`)
-};
-
-const app = opine()
-  .use("/bigbluebutton/api", router)
-  .use((req, res, next) => next(new HttpError(404)))
-  .use(errorHandler);
-
-export default app;

+ 0 - 53
bbb.ts

@@ -1,53 +0,0 @@
-import { Request, ParamsDictionary, createHash } from "./deps.ts";
-import type { server } from './deps.ts'
-
-export class BBB {
-  call: string
-  checksum_incoming: string
-  query: string
-  params: string
-  meeting_id: string
-  url: string
-
-  constructor(req: Request<ParamsDictionary, any, any>) {
-    this.call = req.params.call
-    this.checksum_incoming = req.query.checksum
-    this.query = req._parsedUrl?.query || ""
-    this.params = this.query.replace(/[?&]?checksum.*$/, '')
-    this.meeting_id = req.query.meetingID
-    this.url = req.originalUrl
-  }
-  // generate a checksum for various calls
-  generate_checksum = (secret: string, call: string = this.call, params: string = this.params) => {
-    const hash = createHash("sha1");
-    hash.update(`${call}${params}${secret}`)
-    return hash.toString()
-  }
-  // generate a url to check if meeting is available
-  check_for_meeting_query = (server: server) => {
-    const checksum = this.generate_checksum(server.secret, 'getMeetingInfo', `meetingID=${this.meeting_id}`)
-    return `${server.host}/bigbluebutton/api/getMeetingInfo?meetingID=${this.meeting_id}&checksum=${checksum}`
-  }
-  // write new query for target bbb server
-  rewritten_query = (server: server) => {
-    const checksum_outgoing = this.generate_checksum(server.secret)
-    return `${server.host}${this.url.replace(this.checksum_incoming, checksum_outgoing)}`
-  }
-  // check if request is autheticated with correct checksum
-  authenticated = (secret: string) => {
-    const checksum = this.generate_checksum(secret)
-    const ok = checksum === this.checksum_incoming
-    return ok
-  }
-  find_meeting_id = (servers: server[]): Promise<server> => {
-    if (!this.meeting_id) throw Error
-    const promises = servers.map(async s => {
-      const res = await fetch(this.check_for_meeting_query(s))
-      if (!res.ok) throw Error
-      const text = await res.text()
-      if (text.includes(this.meeting_id)) return s
-      else throw Error
-    })
-    return Promise.any(promises)
-  }
-}

+ 7 - 0
deno.json

@@ -0,0 +1,7 @@
+{
+  "imports": {
+    "@std/crypto": "jsr:@std/crypto@^1.0.1",
+    "@std/encoding": "jsr:@std/encoding@^1.0.1",
+    "@std/fmt": "jsr:@std/fmt@^0.225.6"
+  }
+}

+ 29 - 0
deno.lock

@@ -0,0 +1,29 @@
+{
+  "version": "3",
+  "packages": {
+    "specifiers": {
+      "jsr:@std/crypto@^1.0.1": "jsr:@std/crypto@1.0.1",
+      "jsr:@std/encoding@^1.0.1": "jsr:@std/encoding@1.0.1",
+      "jsr:@std/fmt@^0.225.6": "jsr:@std/fmt@0.225.6"
+    },
+    "jsr": {
+      "@std/crypto@1.0.1": {
+        "integrity": "5d60e6412b2ce61193e2bb622cba02d34890b3d8c4eef3312e499a77329a6f94"
+      },
+      "@std/encoding@1.0.1": {
+        "integrity": "5955c6c542ebb4ce6587c3b548dc71e07a6c27614f1976d1d3887b1196cf4e65"
+      },
+      "@std/fmt@0.225.6": {
+        "integrity": "aba6aea27f66813cecfd9484e074a9e9845782ab0685c030e453a8a70b37afc8"
+      }
+    }
+  },
+  "remote": {},
+  "workspace": {
+    "dependencies": [
+      "jsr:@std/crypto@^1.0.1",
+      "jsr:@std/encoding@^1.0.1",
+      "jsr:@std/fmt@^0.225.6"
+    ]
+  }
+}

+ 0 - 10
deps.ts

@@ -1,10 +0,0 @@
-export { deferred } from "https://deno.land/std/async/mod.ts";
-export type { Deferred } from "https://deno.land/std/async/mod.ts";
-export { join } from "https://deno.land/std/path/mod.ts";
-export { createHash } from "https://deno.land/std/hash/mod.ts";
-export * as Color from "https://deno.land/std/fmt/colors.ts";
-export { HttpError } from "https://deno.land/x/http_error@0.7.0/mod.ts";
-export { opine, Router } from "https://deno.land/x/opine@2.3.4/mod.ts";
-export type { ErrorRequestHandler, Request, ParamsDictionary } from "https://deno.land/x/opine@2.3.4/mod.ts";
-export const secret: string = Deno.env.get("TINYSCALE_SECRET") || ""
-export interface server { host: string; secret: string };

+ 7 - 10
main.ts

@@ -1,8 +1,8 @@
 import SERVERS from './servers.json' with { type: 'json' };
 
-import { crypto } from "https://deno.land/std@0.224.0/crypto/mod.ts"; 
-import { encodeHex } from "https://deno.land/std@0.224.0/encoding/hex.ts"
-import { red, green, yellow } from "https://deno.land/std@0.115.1/fmt/colors.ts";
+import { crypto } from "@std/crypto"; 
+import { encodeHex } from "@std/encoding"
+import { red, green, yellow } from "@std/fmt/colors";
 
 const SECRET = Deno.env.get("TINYSCALE_SECRET");
 const _port = Deno.env.get("PORT");
@@ -14,8 +14,10 @@ if (SECRET === undefined)
 
 if (SERVERS.length === 0)
 	throw "There are no servers listed in `servers.json`";
+SERVERS.forEach(server => new Server(server.host, server.secret));
 
 const date = () => new Date().toLocaleTimeString('de');
+const queue: Map<string, Promise<unknown>> = new Map();
 
 class Server {
 	host: string;
@@ -50,7 +52,7 @@ class Server {
 		// entferne `/bigbluebutton/api/`
 		const call = url.pathname.slice(19);
 		const params = url.search.replace('?', '').replace(/[?&]?checksum.*$/, '');
-		return { call, params }
+		return { call, params };
 	}
 
 	static async checkAuthenticated(url: URL) {
@@ -90,11 +92,6 @@ class Server {
 	}
 }
 
-const queue: Map<string, Promise<unknown>> = new Map();
-
-for (const server of SERVERS) {
-	new Server(server.host, server.secret);
-}
 
 Deno.serve({ port: PORT,
 	onListen({ port, hostname }) {
@@ -111,7 +108,7 @@ Deno.serve({ port: PORT,
 
 	// voicemail if just looking for a life sign from the server
 	if (url.pathname === '/bigbluebutton/api/' || url.pathname === '/bigbluebutton/')
-		return new Response("<response><returncode>SUCCESS</returncode><version>2.0</version></response>");
+		return new Response("<response><returncode>SUCCESS</returncode><version>2.0</version></response>", { headers: { "content-type": "text/xml" } });
 
 	// check the checksum and fail if not true
 	const authenticated = await Server.checkAuthenticated(url);

+ 0 - 19
mod.ts

@@ -1,19 +0,0 @@
-import { Color, secret } from './deps.ts'
-
-const VERSION = 'v1.8.1'
-// give your tinyscale server a secret so it looks like a BBB server
-if (!secret) throw "No secret set for tinyscale"
-console.log(Color.green(`Starting tinyscale ${VERSION} on Deno ${Deno.version.deno}`))
-console.log(`Your secret is set to ${Color.green(secret)}`)
-
-import app from "./app.ts";
-// Get the PORT from the environment variables and store in Opine.
-const port = parseInt(Deno.env.get("PORT") ?? "3005");
-app.set("port", port);
-
-// Get the DENO_ENV from the environment variables and store in Opine.
-const env = Deno.env.get("DENO_ENV") ?? "development";
-app.set("env", env);
-
-// Start our Opine server on the provided or default port.
-app.listen(port, () => console.log(`listening on port ${port}`));

+ 0 - 53
servers.ts

@@ -1,53 +0,0 @@
-import { createHash, Color } from "./deps.ts";
-import type { server } from './deps.ts'
-export class Servers {
-  servers: server[]
-  iterator!: IterableIterator<server>
-  current_server!: server
-
-  constructor() {
-    this.servers = []
-  }
-  async init(): Promise<server[]> {
-    // store your BBB servers in servers.json
-    const file: string = await Deno.readTextFile('servers.json')
-    this.servers = JSON.parse(file)
-    // create an iterator so that we can treat all servers equally
-    this.iterator = this.servers[Symbol.iterator]();
-    this.check()
-    this.get_available_server()
-    return this.servers
-  }
-  check(): void {
-    console.log('Checking servers first …')
-    console.log(this.servers)
-    // check servers for connectivity and if the secret is correct
-    this.servers.forEach(async s => {
-      const hash = createHash("sha1");
-      hash.update(`getMeetings${s.secret}`)
-      try {
-        // throw an error if cannot connect or if secret fails
-        const res = await fetch(`${s.host}/bigbluebutton/api/getMeetings?checksum=${hash.toString()}`)
-        if (!res.ok) throw "Connection error. Please check your host configuration"
-        const body = await res.text()
-        const ok = body.includes('SUCCESS')
-        console.log(`${s.host} is ${ok ? Color.green('ok') : Color.red('misconfigured. Please check your secret in servers.json')}`)
-        if (!ok) throw "Configuration error. Exiting …"
-      } catch (e) {
-        // exit tinyscale if an error is encountered in servers.json
-        console.log(Color.brightRed(JSON.stringify(e)))
-        Deno.exit(1);
-      }
-    })
-  }
-  // pick the next server, using an iterator to cycle through all servers available
-  get_available_server(): server {
-    let candidate = this.iterator.next()
-    if (candidate.done) {
-      this.iterator = this.servers[Symbol.iterator]()
-      candidate = this.iterator.next()
-    }
-    this.current_server = candidate.value;
-    return this.current_server
-  }
-}