├── src ├── static │ ├── logo.png │ └── favicon.png ├── bucketsrelay │ ├── dbschema.nim │ ├── common.nim │ ├── mailer.nim │ ├── licenses.nim │ ├── asyncstdin.nim │ ├── httpreq.nim │ ├── netstring.nim │ ├── stringproto.nim │ ├── client.nim │ ├── jwtrsaonly.nim │ ├── proto.nim │ └── server.nim ├── brelay.nim ├── bclient.nim └── partials │ └── index.mustache ├── config.nims ├── tests ├── config.nims ├── util.nim ├── func1.sh ├── tstringproto.nim ├── tbrelay.nim ├── tnetstring.nim ├── tlicenses.nim ├── tclient.nim ├── tserver.nim └── tproto.nim ├── changes ├── config.toml └── README.md ├── .gitignore ├── docker ├── config.nims ├── singleuser.Dockerfile └── multiuser.Dockerfile ├── LICENSE.md ├── CHANGELOG.md ├── .github └── workflows │ └── main.yml ├── bucketsrelay.nimble ├── nimble.lock └── README.md /src/static/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/buckets/relay/master/src/static/logo.png -------------------------------------------------------------------------------- /config.nims: -------------------------------------------------------------------------------- 1 | # See LICENSE.md for licensing 2 | switch("gc", "orc") 3 | switch("threads", "on") 4 | -------------------------------------------------------------------------------- /src/static/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/buckets/relay/master/src/static/favicon.png -------------------------------------------------------------------------------- /tests/config.nims: -------------------------------------------------------------------------------- 1 | # See LICENSE.md for licensing 2 | switch("path", "$projectDir/../src") 3 | switch("d", "testmode") 4 | -------------------------------------------------------------------------------- /changes/config.toml: -------------------------------------------------------------------------------- 1 | update_nimble = true 2 | 3 | [[replacement]] 4 | pattern = '#(\d+)' 5 | replace = "[#$1](https://github.com/buckets/relay/issues/$1)" -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # ignore extensionless files 2 | * 3 | !/**/ 4 | !*.* 5 | bucketsrelay.sqlite 6 | *.public 7 | *.secret 8 | _tests 9 | !TODO 10 | fly.toml 11 | -------------------------------------------------------------------------------- /docker/config.nims: -------------------------------------------------------------------------------- 1 | switch("gc", "orc") 2 | 3 | when defined(linux): 4 | import os 5 | switch("dynlibOverride", "libsodium") 6 | switch("cincludes", "/usr/include") 7 | switch("clibdir", "/usr/lib") 8 | switch("passL", "-lsodium") 9 | -------------------------------------------------------------------------------- /docker/singleuser.Dockerfile: -------------------------------------------------------------------------------- 1 | # -- Stage 1 -- # 2 | FROM nimlang/nim:1.6.18-alpine@sha256:e54f241d4cc4c7e677641a535df6f5cae2e6fa527cb36f53a4c7bd77214b1b80 as builder 3 | WORKDIR /app 4 | RUN apk update && apk add libsodium-static libsodium musl-dev 5 | RUN nimble refresh 6 | RUN nimble install -y nimble 7 | COPY bucketsrelay.nimble . 8 | RUN nimble install -y --depsOnly --verbose 9 | COPY . . 10 | COPY docker/config.nims . 11 | RUN nimble c -o:brelay -d:relaysingleusermode -d:release src/brelay 12 | 13 | # -- Stage 2 -- # 14 | FROM alpine:3.13.12@sha256:16fd981ddc557fd3b38209d15e7ee8e3e6d9d4d579655e8e47243e2c8525b503 15 | WORKDIR /root/ 16 | RUN apk update && apk add libressl3.1-libcrypto sqlite-dev 17 | COPY --from=builder /app/brelay /usr/local/bin/ 18 | CMD ["/usr/local/bin/brelay", "server", "--address", "0.0.0.0", "--port", "8080"] 19 | -------------------------------------------------------------------------------- /docker/multiuser.Dockerfile: -------------------------------------------------------------------------------- 1 | # -- Stage 1 -- # 2 | FROM nimlang/nim:1.6.18-alpine@sha256:e54f241d4cc4c7e677641a535df6f5cae2e6fa527cb36f53a4c7bd77214b1b80 as builder 3 | WORKDIR /app 4 | RUN apk update && apk add libsodium-static libsodium musl-dev 5 | RUN nimble refresh 6 | RUN nimble install -y nimble 7 | COPY bucketsrelay.nimble . 8 | RUN nimble install -y --depsOnly --verbose 9 | COPY . . 10 | COPY docker/config.nims . 11 | RUN nimble c -o:brelay -d:release src/brelay 12 | 13 | # -- Stage 2 -- # 14 | FROM alpine:3.13.12@sha256:16fd981ddc557fd3b38209d15e7ee8e3e6d9d4d579655e8e47243e2c8525b503 15 | WORKDIR /root/ 16 | RUN apk update && apk add libressl3.1-libcrypto sqlite-dev 17 | COPY --from=builder /app/brelay /usr/local/bin/ 18 | RUN mkdir -p /data 19 | CMD ["/usr/local/bin/brelay", "--database", "/data/bucketsrelay.sqlite", "server", "--address", "0.0.0.0", "--port", "8080"] 20 | -------------------------------------------------------------------------------- /changes/README.md: -------------------------------------------------------------------------------- 1 | `changer` makes it easy to manage a `CHANGELOG.md` file. It works in Nim projects and other languages, too. 2 | 3 | # Installation 4 | 5 | ``` 6 | nimble install https://github.com/iffy/changer 7 | ``` 8 | 9 | # Configuration 10 | 11 | You can configure how `changer` behaves by editing the `changes/config.toml` file. 12 | 13 | # Usage 14 | 15 | Start a changelog in a project by running: 16 | 17 | changer init 18 | 19 | Every time you want to add something to the changelog, make a new Markdown file in `./changes/` named like this: 20 | 21 | - `fix-NAME.md` 22 | - `new-NAME.md` 23 | - `break-NAME.md` 24 | - `other-NAME.md` 25 | 26 | Use the tool to add a changelog entry: 27 | 28 | changer add 29 | 30 | When you're ready to release a new version, preview the new changelog with: 31 | 32 | changer bump -n 33 | 34 | Then make the new changelog (and update the version of any `.nimble` file): 35 | 36 | changer bump 37 | -------------------------------------------------------------------------------- /tests/util.nim: -------------------------------------------------------------------------------- 1 | import std/logging 2 | import std/os; export os 3 | import std/random 4 | import std/strformat 5 | 6 | if os.getEnv("SHOW_LOGS") != "": 7 | var L = newConsoleLogger() 8 | addHandler(L) 9 | else: 10 | echo "set SHOW_LOGS=something to see logs" 11 | 12 | randomize() 13 | 14 | proc tmpDir*(): string = 15 | result = os.getTempDir() / &"test{random.rand(10000000)}" 16 | result.createDir() 17 | 18 | template withinTmpDir*(body:untyped):untyped = 19 | let 20 | tmp = tmpDir() 21 | olddir = getCurrentDir() 22 | setCurrentDir(tmp) 23 | body 24 | setCurrentDir(olddir) 25 | try: 26 | tmp.removeDir() 27 | except: 28 | echo "WARNING: failed to remove temporary test directory: ", getCurrentExceptionMsg() 29 | 30 | template cd*(dirname: string, body: untyped): untyped = 31 | let olddir = getCurrentDir() 32 | try: 33 | createDir(dirname) 34 | setCurrentDir(dirname) 35 | body 36 | finally: 37 | setCurrentDir(olddir) 38 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | Copyright 2022 One Part Rain, LLC 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 4 | 5 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 6 | 7 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 8 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # v0.3.1 - 2024-03-04 2 | 3 | - **FIX:** Internal type-checking fix 4 | 5 | # v0.3.0 - 2024-02-14 6 | 7 | - **NEW:** When installing the package, the binaries are no longer built automatically 8 | - **FIX:** Pin dependency versions 9 | 10 | # v0.2.0 - 2023-02-07 11 | 12 | - **NEW:** Add docker build testing to CI 13 | - **NEW:** Enable using TLS without needing another server in front. 14 | - **NEW:** Relay remembers `Connect` requests even after remote disconnects. 15 | - **FIX:** Fixed cleanup of connections so that nothing attempts to write to closedd streams 16 | - **FIX:** Make RelayClient.handler public 17 | - **FIX:** Prevent attackers from preemptively setting passwords on accounts that authenticate with licenses. 18 | - **FIX:** Update to version of websock that includes the memory fix 19 | - **FIX:** Only keep stats for 90 days ([#6](https://github.com/buckets/relay/issues/6)) 20 | - **FIX:** Fix nimble package so that library is importable as `bucketsrelay`. 21 | - **FIX:** Fix race condition in functional test ([#5](https://github.com/buckets/relay/issues/5)) 22 | - **FIX:** Fix excessive memory use per websocket connection. Now ~20k instead of 1MB 23 | 24 | # v0.1.0 - 2023-01-24 25 | 26 | - **NEW:** Initial release 27 | 28 | -------------------------------------------------------------------------------- /src/bucketsrelay/dbschema.nim: -------------------------------------------------------------------------------- 1 | import std/strformat 2 | import std/strutils 3 | import std/logging 4 | 5 | import ndb/sqlite 6 | 7 | type 8 | Patch* = tuple 9 | name: string 10 | sqls: seq[string] 11 | 12 | proc upgradeSchema*(db:DbConn, patches:openArray[Patch]) = 13 | ## Apply database patches to this file 14 | # See what patches have already been applied 15 | db.exec(sql""" 16 | CREATE TABLE IF NOT EXISTS _schema_version ( 17 | id INTEGER PRIMARY KEY, 18 | created TIMESTAMP DEFAULT CURRENT_TIMESTAMP, 19 | name TEXT UNIQUE 20 | )""") 21 | var applied:seq[string] 22 | for row in db.getAllRows(sql"SELECT name FROM _schema_version"): 23 | applied.add(row[0].s) 24 | if applied.len > 0: 25 | logging.debug &"(dbpatch) existing patches: {applied}" 26 | 27 | # Apply patches 28 | for patch in patches: 29 | if patch.name in applied: 30 | continue 31 | logging.info &"(dbpatch) applying patch: {patch.name}" 32 | db.exec(sql"BEGIN") 33 | try: 34 | for statement in patch.sqls: 35 | db.exec(sql(statement)) 36 | db.exec(sql"INSERT INTO _schema_version (name) VALUES (?)", patch.name) 37 | db.exec(sql"COMMIT") 38 | except: 39 | logging.error &"(dbpatch) error applying patch {patch.name}: {getCurrentExceptionMsg()}" 40 | db.exec(sql"ROLLBACK") 41 | raise 42 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: Tests 2 | on: 3 | pull_request: 4 | push: 5 | schedule: 6 | - cron: '0 0 15 * *' 7 | 8 | jobs: 9 | tests: 10 | runs-on: ${{ matrix.os }} 11 | strategy: 12 | matrix: 13 | version: 14 | - binary:1.6.18 15 | os: 16 | - ubuntu-latest 17 | # - macOS-latest 18 | steps: 19 | - uses: actions/checkout@v1 20 | - uses: iffy/install-nim@v5 21 | env: 22 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 23 | with: 24 | version: ${{ matrix.version }} 25 | - name: Update nimble 26 | run: nimble install -y nimble 27 | #---------------------------------------- 28 | # multi user mode (default) 29 | #---------------------------------------- 30 | - name: Install 31 | run: nimble install -y 32 | - name: Build bins 33 | run: nimble multiuserbins 34 | - name: Run tests 35 | run: | 36 | export PATH="${PATH}:$(pwd)/bin" 37 | nimble test 38 | - name: Command-line tests 39 | run: | 40 | export PATH="${PATH}:$(pwd)/bin" 41 | tests/func1.sh 42 | #---------------------------------------- 43 | # single user mode 44 | #---------------------------------------- 45 | - name: Install (single user mode) 46 | run: nimble singleuserbins 47 | - name: Run tests (single user mode) 48 | run: | 49 | export PATH="${PATH}:$(pwd)/bin" 50 | nimble -d:relaysingleusermode test 51 | 52 | docker: 53 | runs-on: ubuntu-latest 54 | steps: 55 | - uses: actions/checkout@v1 56 | - run: docker build --file docker/singleuser.Dockerfile . 57 | - run: docker build --file docker/multiuser.Dockerfile . 58 | 59 | -------------------------------------------------------------------------------- /bucketsrelay.nimble: -------------------------------------------------------------------------------- 1 | # Package 2 | 3 | version = "0.3.1" 4 | author = "Matt Haggard" 5 | description = "The relay service for the Buckets budgeting app" 6 | license = "MIT" 7 | srcDir = "src" 8 | installExt = @["nim", "mustache", "png"] 9 | 10 | 11 | # Dependencies 12 | requires "nim >= 1.6.0" 13 | requires "argparse == 4.0.1" 14 | requires "libsodium == 0.6.0" 15 | requires "mustache == 0.4.3" 16 | requires "ndb == 0.19.9" 17 | requires "https://github.com/status-im/nim-stew.git#d085e48e89062de307aab0d0629fba2f867cb49a" 18 | requires "https://github.com/status-im/nim-serialization.git#9f56a0738c616061382928b9f60e1c5721622f51" 19 | requires "https://github.com/status-im/nim-json-serialization.git#b068e1440d4cb2cf3ede6b3567eaaeecd6c8c96a" 20 | requires "https://github.com/status-im/nim-chronos.git#ba143e029f35fd9b4cd3d89d007cc834d0d5ba3c" 21 | requires "https://github.com/cheatfate/nimcrypto.git#a065c1741836462762d18d2fced1fedd46095b02" 22 | requires "https://github.com/status-im/nim-websock.git#fea05cde8b123b38d1a0a8524b77efbc84daa848" 23 | requires "https://github.com/yglukhov/bearssl_pkey_decoder.git#546f8d9bb887ae1d8a23f62155c583acb0358046" 24 | 25 | 26 | # dependency locks 27 | requires "https://github.com/status-im/nim-zlib.git#826e2fc013f55b4478802d4f2e39f187c50d520a" 28 | 29 | import std/os 30 | 31 | task singleuserbins, "Build single user brelay and bclient bins": 32 | exec("mkdir -p bin") 33 | exec("nimble c -d:relaysingleusermode -o:bin/brelay src/brelay.nim") 34 | exec("nimble c -d:relaysingleusermode -o:bin/bclient src/bclient.nim") 35 | 36 | task multiuserbins, "Build multi user brelay and bclient bins": 37 | exec("mkdir -p bin") 38 | exec("nimble c -o:bin/brelay src/brelay.nim") 39 | exec("nimble c -o:bin/bclient src/bclient.nim") 40 | -------------------------------------------------------------------------------- /tests/func1.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | waitForOpenPort() { 4 | 5 | PORT=$1 6 | HOSTTOCHECK="127.0.0.1" 7 | TIMEOUT=5 8 | echo "Waiting for $HOSTTOCHECK:$PORT to open" 9 | sleep "$TIMEOUT" & 10 | SLEEPPID=$! 11 | while ! nc -z "$HOSTTOCHECK" "$PORT"; do 12 | sleep 0.1 13 | if ! kill -0 "$SLEEPPID" 2>/dev/null; then 14 | echo "Timed out waiting for $HOSTTOCHECK:$PORT to open" 15 | return 1 16 | fi 17 | done 18 | kill "$SLEEPPID" 2>/dev/null 19 | echo "Port $HOSTTOCHECK:$PORT is open!" 20 | return 0 21 | } 22 | 23 | dotest() { 24 | echo "Adding a user ..." 25 | printf 'foobar' | brelay adduser me@me.com --password-stdin 26 | 27 | echo "Generating keys ..." 28 | (mkdir -p client1 && cd client1 && bclient genkeys) 29 | (mkdir -p client2 && cd client2 && bclient genkeys) 30 | echo "hello, world" > testfile 31 | 32 | echo "Starting the server ..." 33 | brelay server --port 8080 & 34 | CHILDPID=$! 35 | trap "kill $CHILDPID" exit 36 | 37 | waitForOpenPort 8080 38 | 39 | echo "Starting the clients ..." 40 | printf "hello, world" > client2/testfile 41 | (cd client1 && bclient receive -u me@me.com -p foobar http://127.0.0.1:8080/v1/relay "$(cat ../client2/relay.key.public)" > output) & 42 | CLIENT1PID=$! 43 | (cd client2 && cat testfile | bclient send -u me@me.com -p foobar http://127.0.0.1:8080/v1/relay "$(cat ../client1/relay.key.public)") 44 | wait $CLIENT1PID 45 | cat client1/output 46 | 47 | if [ "$(cat client2/testfile)" != "$(cat client1/output)" ]; then 48 | echo "input != output" 49 | exit 1 50 | fi 51 | 52 | echo "Showing some stats ..." 53 | echo '.timeout 1000' | sqlite3 buckets_relay.sqlite 54 | brelay stats 55 | } 56 | 57 | rm -r _tests 58 | set -xe 59 | mkdir -p _tests 60 | (cd _tests && dotest) 61 | -------------------------------------------------------------------------------- /src/bucketsrelay/common.nim: -------------------------------------------------------------------------------- 1 | import std/macros 2 | import std/strformat 3 | import std/random; export random 4 | 5 | import chronicles 6 | 7 | import libsodium/sodium 8 | import libsodium/sodium_sizes 9 | 10 | const 11 | singleusermode* = defined(relaysingleusermode) 12 | multiusermode* = not singleusermode 13 | relayverbose* = defined(relayverbose) 14 | 15 | template nextDebugName*(): untyped = 16 | $rand(100000..999999) 17 | 18 | template vlog*(x: varargs[string, `$`]): untyped = 19 | when relayverbose: 20 | debug x 21 | 22 | proc hash_password*(password: string): string = 23 | # We use a lower memlimit because a stolen password is 24 | # easy to mitigate and doesn't cause immediate harm to users. 25 | let memlimit = max(crypto_pwhash_memlimit_min(), 10_000_000) 26 | crypto_pwhash_str(password, memlimit = memlimit) 27 | 28 | proc verify_password*(pwhash: string, password: string): bool {.inline.} = 29 | crypto_pwhash_str_verify(pwhash, password) 30 | 31 | macro multiuseronly*(fn: untyped): untyped = 32 | ## Add as a pragma to procs that are only available in multiusermode 33 | when multiusermode: 34 | fn 35 | else: 36 | newStmtList() 37 | 38 | macro singleuseronly*(fn: untyped): untyped = 39 | ## Add as a pragma to procs that should only be available in singleusermode 40 | when singleusermode: 41 | fn 42 | else: 43 | newStmtList() 44 | 45 | #------------------------------------------------------------ 46 | # Memory-checking helpers 47 | #------------------------------------------------------------ 48 | var lastMem = getOccupiedMem() 49 | 50 | proc checkmem*(name: string) = 51 | let newMem = getOccupiedMem() 52 | let diffMem = newMem - lastMem 53 | debug "checkmem", res = &"{diffMem:>10} = {newMem:>10} <- {lastMem:>10} {name}" 54 | lastMem = newMem 55 | 56 | template checkMemGrowth(name: string, body: untyped): untyped = 57 | let occ {.genSym.} = getOccupiedMem() 58 | body 59 | echo "Mem growth during: " & name & " " & $(getOccupiedMem() - occ) 60 | -------------------------------------------------------------------------------- /tests/tstringproto.nim: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2022 One Part Rain, LLC. All rights reserved. 2 | # 3 | # This work is licensed under the terms of the MIT license. 4 | # For a copy, see LICENSE.md in this repository. 5 | 6 | import unittest 7 | import strformat 8 | 9 | import bucketsrelay/proto 10 | import bucketsrelay/stringproto 11 | 12 | proc cev(ev: RelayEvent) = 13 | doAssert ev == ev 14 | checkpoint "original: " & $ev 15 | let intermediate = ev.dumps() 16 | let actual = intermediate.loadsRelayEvent() 17 | checkpoint "actual: " & $actual 18 | checkpoint "intermed: " & intermediate 19 | doAssert ev == actual, &"Expected {ev} to equal {actual}" 20 | 21 | proc ccmd(cmd: RelayCommand) = 22 | doAssert cmd == cmd 23 | checkpoint "original: " & $cmd 24 | let intermediate = cmd.dumps() 25 | let actual = intermediate.loadsRelayCommand() 26 | checkpoint "actual: " & $actual 27 | checkpoint "intermed: " & intermediate 28 | doAssert cmd == actual, &"Expected {cmd} to equal {actual}" 29 | 30 | suite "RelayEvent": 31 | test "Who": 32 | cev RelayEvent(kind: Who, who_challenge: "something\x00!") 33 | test "Authenticated": 34 | cev RelayEvent(kind: Authenticated) 35 | test "Connected": 36 | cev RelayEvent(kind: Connected, conn_pubkey: "hi".PublicKey) 37 | test "Disconnected": 38 | cev RelayEvent(kind: Disconnected, dcon_pubkey: "hi".PublicKey) 39 | test "Data": 40 | cev RelayEvent(kind: Data, sender_pubkey: "hey".PublicKey, data: "bob") 41 | test "ErrorEvent": 42 | cev RelayEvent(kind: ErrorEvent, err_message: "foo") 43 | test "Entered": 44 | cev RelayEvent(kind: Entered, entered_pubkey: "alice".PublicKey) 45 | test "Exited": 46 | cev RelayEvent(kind: Exited, exited_pubkey: "bob".PublicKey) 47 | 48 | suite "RelayCommand": 49 | 50 | test "Iam": 51 | ccmd RelayCommand(kind: Iam) 52 | test "Connect": 53 | ccmd RelayCommand(kind: Connect) 54 | test "Disconnect": 55 | ccmd RelayCommand(kind: Disconnect) 56 | test "SendData": 57 | ccmd RelayCommand(kind: SendData) -------------------------------------------------------------------------------- /src/bucketsrelay/mailer.nim: -------------------------------------------------------------------------------- 1 | import std/logging 2 | import std/os 3 | import std/strformat 4 | import std/strutils 5 | 6 | import chronos 7 | 8 | import ./common 9 | 10 | const usepostmark = multiusermode and not defined(nopostmark) 11 | const fromEmail {.strdefine.} = "relay@budgetwithbuckets.com" 12 | when usepostmark: 13 | const POSTMARK_API_KEY {.strdefine.} = "env:POSTMARK_API_KEY" 14 | 15 | import std/json 16 | import ./httpreq 17 | 18 | 19 | proc valueRef(location: string): string = 20 | ## Get a value from the given location. `location` is a string 21 | ## prefixed with one of the following, which determines where 22 | ## the value comes from: 23 | runnableExamples: 24 | assert getValue("env:FOO") == getEnv("FOO") 25 | assert getValue("embed:someval") == "someval" 26 | if location.startsWith("env:"): 27 | getEnv(location.substr("env:".len)) 28 | elif location.startsWith("embed:"): 29 | location.substr("embed:".len) 30 | else: 31 | raise ValueError.newException("Unknown variable ref type") 32 | 33 | proc sendEmail*(toEmail, subject, text: string) {.async, raises: [CatchableError].} = 34 | when usepostmark: 35 | let data = $(%* { 36 | "From": fromEmail, 37 | "To": toEmail, 38 | "Subject": subject, 39 | "MessageStream": "outbound", 40 | "TextBody": text, 41 | }) 42 | var headers = HttpTable.init() 43 | headers.add("Accept", "application/json") 44 | headers.add("Content-Type", "application/json") 45 | headers.add("X-Postmark-Server-Token", POSTMARK_API_KEY.valueRef) 46 | let (code, res) = await request("https://api.postmarkapp.com/email", MethodPost, data, headers = headers) 47 | if code != 200: 48 | try: 49 | error "Error sending email: " & $res 50 | except: 51 | discard 52 | raise CatchableError.newException("Email sending failed") 53 | else: 54 | # logging only 55 | info "EMAIL FAKE SENDER:\nFrom: " & fromEmail & "\nTo: " & toEmail & "\nSubject: " & subject & "\n\n" & text & "\n------------------------------------" 56 | stderr.flushFile() 57 | -------------------------------------------------------------------------------- /tests/tbrelay.nim: -------------------------------------------------------------------------------- 1 | import std/unittest 2 | import std/logging 3 | import ./util 4 | 5 | import chronos 6 | 7 | import brelay 8 | import bclient 9 | 10 | import bucketsrelay/common 11 | import bucketsrelay/proto 12 | 13 | proc tlog(msg: string) = 14 | debug "TEST: " & msg 15 | 16 | when multiusermode: 17 | test "copy": 18 | withinTmpDir: 19 | tlog "Adding users ..." 20 | addverifieduser("data.sqlite", "alice", "alice") 21 | addverifieduser("data.sqlite", "bob", "bob") 22 | let relayurl = "http://127.0.0.1:9001/v1/relay" 23 | tlog "Starting relay ..." 24 | let server = startRelay("data.sqlite", 9001.Port, "127.0.0.1") 25 | tlog "Generating keys ..." 26 | let akeys = genkeys() 27 | let bkeys = genkeys() 28 | tlog "Sending from sender to receiver ..." 29 | let sendres = relaySend("hello", bkeys.pk, 30 | relayurl = relayurl, 31 | mykeys = akeys, 32 | username = "alice", 33 | password = "alice", 34 | ) 35 | tlog "Receiving from sender ..." 36 | let recvres = relayReceive(akeys.pk, 37 | relayurl = relayurl, 38 | mykeys = bkeys, 39 | username = "bob", 40 | password = "bob", 41 | ) 42 | tlog "Waiting for send to resolve" 43 | waitFor sendres 44 | tlog "Waiting for recv to resolve" 45 | let res = waitFor recvres 46 | check res == "hello" 47 | 48 | when singleusermode: 49 | test "copy": 50 | withinTmpDir: 51 | let relayurl = "http://127.0.0.1:9001/v1/relay" 52 | tlog "Starting relay ..." 53 | let server = startRelaySingleUser("alice", "password", 9001.Port, "127.0.0.1") 54 | tlog "Generating keys ..." 55 | let akeys = genkeys() 56 | let bkeys = genkeys() 57 | tlog "Sending from sender to receiver ..." 58 | let sendres = relaySend("hello", bkeys.pk, 59 | relayurl = relayurl, 60 | mykeys = akeys, 61 | username = "alice", 62 | password = "password", 63 | ) 64 | tlog "Receiving from sender ..." 65 | let recvres = relayReceive(akeys.pk, 66 | relayurl = relayurl, 67 | mykeys = bkeys, 68 | username = "alice", 69 | password = "password", 70 | ) 71 | tlog "Waiting for send to resolve" 72 | waitFor sendres 73 | tlog "Waiting for recv to resolve" 74 | let res = waitFor recvres 75 | check res == "hello" 76 | -------------------------------------------------------------------------------- /tests/tnetstring.nim: -------------------------------------------------------------------------------- 1 | import std/unittest 2 | 3 | import bucketsrelay/netstring 4 | 5 | test "nsencode": 6 | check nsencode("apple") == "5:apple," 7 | check nsencode("") == "0:," 8 | check nsencode("banana\x00,") == "8:banana\x00,," 9 | 10 | test "nsencode newline allowed instead of comma": 11 | check nsencode("apple", '\n') == "5:apple\n" 12 | check nsencode("", '\n') == "0:\n" 13 | check nsencode("banana\x00\n", '\n') == "8:banana\x00\n\n" 14 | 15 | suite "NetstringDecoder": 16 | 17 | test "netstring in, message out": 18 | var ns = newNetstringDecoder() 19 | ns.consume("5:apple,") 20 | check ns.len == 1 21 | ns.consume("7:bana") 22 | check ns.len == 1 23 | ns.consume("na\x00,3:foo,3:bar") 24 | check ns.len == 3 25 | ns.consume(",") 26 | check ns.len == 4 27 | check ns.nextMessage() == "apple" 28 | check ns.nextMessage() == "banana\x00" 29 | check ns.nextMessage() == "foo" 30 | check ns.nextMessage() == "bar" 31 | 32 | test "newline delimiter": 33 | var ns = newNetstringDecoder('\n') 34 | ns.consume("5:apple\n") 35 | check ns.len == 1 36 | ns.consume("7:bana") 37 | check ns.len == 1 38 | ns.consume("na\x00\n3:foo\n3:bar") 39 | check ns.len == 3 40 | ns.consume("\n") 41 | check ns.len == 4 42 | check ns.nextMessage() == "apple" 43 | check ns.nextMessage() == "banana\x00" 44 | check ns.nextMessage() == "foo" 45 | check ns.nextMessage() == "bar" 46 | 47 | test "empty string": 48 | var ns = newNetstringDecoder() 49 | ns.consume("0:,") 50 | check ns.nextMessage() == "" 51 | 52 | test "can't start with 0": 53 | var ns = newNetstringDecoder() 54 | expect(Exception): 55 | ns.consume("01:,") 56 | 57 | test "can't include non-numerics": 58 | var ns = newNetstringDecoder() 59 | expect(Exception): 60 | ns.consume("1a:,") 61 | 62 | test ": required": 63 | var ns = newNetstringDecoder() 64 | expect(Exception): 65 | ns.consume("1f,") 66 | 67 | test ", required": 68 | var ns = newNetstringDecoder() 69 | expect(Exception): 70 | ns.consume("1:a2:ab,") 71 | 72 | test "len required": 73 | var ns = newNetstringDecoder() 74 | expect(Exception): 75 | ns.consume(":s,") 76 | 77 | test "max message length": 78 | var ns = newNetstringDecoder() 79 | 80 | ns.maxlen = 4 81 | ns.consume("4:fooa,") 82 | expect(Exception): 83 | ns.consume("5:") 84 | ns.reset() 85 | 86 | ns.maxlen = 10000 87 | expect(Exception): 88 | ns.consume("100000") 89 | -------------------------------------------------------------------------------- /src/bucketsrelay/licenses.nim: -------------------------------------------------------------------------------- 1 | import std/json 2 | import std/strformat 3 | import std/strutils 4 | import std/times 5 | 6 | import ./jwtrsaonly 7 | 8 | proc formatForEmail*(x: string): string = 9 | ## Format a base64-encoded string nicely for email delivery 10 | for i,c in x: 11 | result.add(c) 12 | if (i+1) mod 40 == 0: 13 | result.add "\n" 14 | elif (i+1) mod 10 == 0: 15 | result.add " " 16 | if result[^1] != '\n': 17 | result.add "\n" 18 | 19 | #------------------------------------------------------ 20 | # V1 RSA License 21 | #------------------------------------------------------ 22 | const 23 | rsaPrefix = "-----BEGIN RSA PRIVATE KEY-----" 24 | rsaSuffix = "-----END RSA PRIVATE KEY-----" 25 | licensePrefix = "------------- START LICENSE ---------------" 26 | licenseSuffix = "------------- END LICENSE -----------------" 27 | 28 | type 29 | BucketsV1License* = distinct string 30 | 31 | proc unformatLicense*(x: string): BucketsV1License = 32 | var tmp = x.replace(licensePrefix, "").replace(licenseSuffix, "") 33 | var res: string 34 | for c in tmp: 35 | case c 36 | of 'a'..'z','A'..'Z','0'..'9','+','=','_','-','/','.': 37 | res.add c 38 | else: 39 | discard 40 | return res.BucketsV1License 41 | 42 | proc createV1License*(privateKey: string, email: string): BucketsV1License = 43 | ## Generate a new license 44 | var privateKey = privateKey.replace(rsaPrefix, "") 45 | privateKey = privateKey.replace(rsaSuffix, "") 46 | privateKey = privateKey.strip().replace(" ", "\n") 47 | privateKey = &"{rsaPrefix}\n{privateKey}\n{rsaSuffix}" 48 | var token = toJWT( %* { 49 | "header": { 50 | "alg": "RS256", 51 | "typ": "JWT" 52 | }, 53 | "claims": { 54 | "email": email, 55 | "iat": getTime().toUnix(), 56 | } 57 | }) 58 | token.sign(privateKey) 59 | return ($token).BucketsV1License 60 | 61 | proc `$`*(license: BucketsV1License): string = 62 | ## Format a license for delivery in email 63 | result.add licensePrefix & "\n" 64 | result.add license.string.formatForEmail() 65 | result.add licenseSuffix 66 | 67 | proc verify*(license: BucketsV1License, pubkey: string): bool = 68 | ## Return true if the license is valid, raise an exception if not 69 | result = false 70 | let jwtToken = license.string.toJWT() 71 | result = jwtToken.verify(pubkey) 72 | 73 | proc extractEmail*(license: BucketsV1License): string = 74 | ## Extract the email address this license was issued to 75 | try: 76 | let jwt = license.string.toJWT() 77 | return $jwt.claims["email"].getStr() 78 | except: 79 | discard 80 | 81 | -------------------------------------------------------------------------------- /src/bucketsrelay/asyncstdin.nim: -------------------------------------------------------------------------------- 1 | ## Asynchronous reading from stdin 2 | ## 3 | ## The implementation may change. The important thing is that this works: 4 | ## 5 | ## var reader = asyncStdinReader() 6 | ## let res = waitFor reader.read(10) 7 | import std/deques 8 | import chronos 9 | 10 | const BUFSIZE = 4096.uint 11 | 12 | type 13 | ReadResponse = uint 14 | 15 | AsyncStdinReader* = ref object 16 | outQ: AsyncQueue[string] 17 | inQ: AsyncQueue[uint] 18 | closed: bool 19 | thread: Thread[AsyncFD] 20 | 21 | var requestCh: Channel[uint] 22 | requestCh.open(0) 23 | 24 | proc workerReadLoop(wfd: AsyncFD) {.thread.} = 25 | ## Run this in a thread other than the main one 26 | ## to get somewhat asynchronous IO 27 | let transp = fromPipe(wfd) 28 | var buf: array[BUFSIZE, char] 29 | var closed = false 30 | while not closed: 31 | let req = requestCh.recv() 32 | var toRead = req 33 | var didRead: uint = 0 34 | while toRead > 0: 35 | let toReadThisTime = min(BUFSIZE, toRead) 36 | let n = stdin.readBuffer(addr buf, toReadThisTime) 37 | if n == 0: 38 | closed = true 39 | break 40 | didRead.inc(n) 41 | toRead.dec(n) 42 | discard waitFor transp.write(addr buf, n) 43 | waitFor transp.closeWait() 44 | 45 | proc mainReadLoop(reader: AsyncStdinReader, transp: StreamTransport) {.async.} = 46 | ## Run this companion loop of workerReadLoop in the main thread 47 | while true: 48 | let size = await reader.inQ.get() 49 | var ret = "" 50 | if not reader.closed: 51 | var toRead = size 52 | while toRead > 0 and not reader.closed: 53 | var data: seq[byte] 54 | try: 55 | data = await transp.read(toRead.int) 56 | except: 57 | discard 58 | if data.len == 0: 59 | reader.closed = true 60 | break 61 | for c in data: 62 | ret.add(chr(c)) 63 | toRead.dec(data.len) 64 | reader.outQ.putNoWait(ret) 65 | 66 | #--------------------------------------------------------------- 67 | # Public API 68 | #--------------------------------------------------------------- 69 | proc asyncStdinReader*(): AsyncStdinReader = 70 | new(result) 71 | result.inQ = newAsyncQueue[uint]() 72 | result.outQ = newAsyncQueue[string]() 73 | let (rfd, wfd) = createAsyncPipe() 74 | let readTransp = fromPipe(rfd) 75 | result.thread.createThread(workerReadLoop, wfd) 76 | asyncSpawn result.mainReadLoop(readTransp) 77 | 78 | proc read*(reader: AsyncStdinReader, size: uint): Future[string] {.async.} = 79 | requestCh.send(size) 80 | reader.inQ.putNoWait(size) 81 | return await reader.outQ.get() 82 | 83 | template read*(reader: AsyncStdinReader, size: int): untyped = 84 | reader.read(size.uint) 85 | -------------------------------------------------------------------------------- /src/bucketsrelay/httpreq.nim: -------------------------------------------------------------------------------- 1 | ## HTTP client that does SSL/TLS with BearSSL (so you don't need `-d:ssl`) 2 | ## 3 | import std/strutils 4 | import std/options 5 | import std/uri 6 | 7 | import chronos 8 | import chronos/apps/http/httpclient 9 | import chronos/apps/http/httpcommon; export httpcommon 10 | import chronos/apps/http/httptable; export httptable 11 | 12 | export waitFor 13 | 14 | type 15 | HttpResponse* = tuple 16 | code: int 17 | body: string 18 | 19 | 20 | proc fetch*(session: HttpSessionRef, req: HttpClientRequestRef): Future[HttpResponseTuple] {.async.} = 21 | ## Copied from nim-chronos 22 | var 23 | request = req 24 | response: HttpClientResponseRef = nil 25 | redirect: HttpClientRequestRef = nil 26 | 27 | while true: 28 | try: 29 | response = await request.send() 30 | if response.status >= 300 and response.status < 400: 31 | redirect = 32 | block: 33 | if "location" in response.headers: 34 | let location = response.headers.getString("location") 35 | if len(location) > 0: 36 | let res = request.redirect(parseUri(location)) 37 | if res.isErr(): 38 | raiseHttpRedirectError(res.error()) 39 | res.get() 40 | else: 41 | raiseHttpRedirectError("Location header with an empty value") 42 | else: 43 | raiseHttpRedirectError("Location header missing") 44 | discard await response.consumeBody() 45 | await response.closeWait() 46 | response = nil 47 | await request.closeWait() 48 | request = nil 49 | request = redirect 50 | request.headers.set(HostHeader, request.address.hostname) 51 | redirect = nil 52 | else: 53 | let data = await response.getBodyBytes() 54 | let code = response.status 55 | await response.closeWait() 56 | response = nil 57 | await request.closeWait() 58 | request = nil 59 | return (code, data) 60 | except CancelledError as exc: 61 | if not(isNil(response)): await closeWait(response) 62 | if not(isNil(request)): await closeWait(request) 63 | if not(isNil(redirect)): await closeWait(redirect) 64 | raise exc 65 | except HttpError as exc: 66 | if not(isNil(response)): await closeWait(response) 67 | if not(isNil(request)): await closeWait(request) 68 | if not(isNil(redirect)): await closeWait(redirect) 69 | raise exc 70 | 71 | proc request*(url: string, meth: HttpMethod, body = "", headers = HttpTable.init()): Future[HttpResponse] {.async.} = 72 | ## High level request 73 | var session = HttpSessionRef.new() 74 | let address = session.getAddress(url).tryGet() 75 | var req = HttpClientRequestRef.new(session, address, meth, 76 | body = body.toOpenArrayByte(0, body.len-1), 77 | headers = headers.toList()) 78 | let (code, bytes) = await session.fetch(req) 79 | return (code, bytes.bytesToString) 80 | 81 | when isMainModule: 82 | import std/os 83 | import std/strformat 84 | let url = paramStr(1) 85 | echo &"requesting {url}" 86 | let resp = waitFor request(url, MethodGet) 87 | echo "resp: ", resp[0] 88 | echo resp[1] 89 | -------------------------------------------------------------------------------- /src/bucketsrelay/netstring.nim: -------------------------------------------------------------------------------- 1 | import std/deques 2 | import std/strformat 3 | import std/strutils 4 | 5 | type 6 | NSDecoderState = enum 7 | LookingForNumber, 8 | ReadingData, 9 | LookingForComma, 10 | NetstringDecoder* = object 11 | buf: string 12 | expectedLen: int 13 | state: NSDecoderState 14 | maxlen: int 15 | output: Deque[string] 16 | terminalChar*: char 17 | 18 | const 19 | COLONCHAR = ':' 20 | TERMINALCHAR = ',' 21 | DEFAULTMAXLEN = 1_000_000 22 | 23 | proc nsencode*(msg:string, terminalChar = TERMINALCHAR):string {.inline.} = 24 | $msg.len & COLONCHAR & msg & terminalChar 25 | 26 | proc newNetstringDecoder*(terminalChar = TERMINALCHAR):NetstringDecoder = 27 | result.output = initDeque[string]() 28 | result.terminalChar = terminalChar 29 | result.maxlen = DEFAULTMAXLEN 30 | 31 | when defined(testmode): 32 | proc reset*(p: var NetstringDecoder) = 33 | ## Reset the parser. For testing only. 34 | p.buf = "" 35 | p.expectedLen = 0 36 | p.state = LookingForNumber 37 | 38 | proc `maxlen=`*(p: var NetstringDecoder, length:int) = 39 | ## Set the maximum message length 40 | p.maxlen = length 41 | 42 | proc `len`*(p: var NetstringDecoder):int = 43 | p.output.len 44 | 45 | proc consume*(p: var NetstringDecoder, data:string) = 46 | ## Send some netstring data (perhaps incomplete as yet) 47 | var cursor:int = 0 48 | while cursor < data.len: 49 | case p.state: 50 | of LookingForNumber: 51 | let ch = data[cursor] 52 | cursor.inc() 53 | case ch 54 | of '0'..'9': 55 | p.buf.add(ch) 56 | if p.buf.len == 2 and p.buf[0] == '0': 57 | raise newException(ValueError, &"Length may not start with 0") 58 | if p.maxlen != 0: 59 | if p.buf.parseInt() > p.maxlen: 60 | raise newException(ValueError, &"Message too long") 61 | of COLONCHAR: 62 | p.expectedLen = p.buf.parseInt() 63 | p.buf = "" 64 | if p.expectedLen == 0: 65 | p.state = LookingForComma 66 | else: 67 | p.state = ReadingData 68 | else: 69 | raise newException(ValueError, &"Invalid netstring length char: {ch.repr}") 70 | 71 | of ReadingData: 72 | let toread = p.expectedLen - int(p.buf.len) 73 | var sidx = cursor 74 | var eidx = sidx + toread - 1 75 | if eidx >= int(data.len): 76 | eidx = int(data.len-1) 77 | let snippet = data[sidx..eidx] 78 | 79 | p.buf.add(snippet) 80 | cursor += toread 81 | if int(p.buf.len) == p.expectedLen: 82 | # message possibly complete 83 | p.state = LookingForComma 84 | of LookingForComma: 85 | let ch = data[cursor] 86 | cursor.inc() 87 | if ch == p.terminalChar: 88 | # message complete! 89 | # Is this a copy? I'd rather it be a move 90 | let msg = p.buf 91 | p.buf = "" 92 | p.output.addLast(msg) 93 | p.state = LookingForNumber 94 | else: 95 | raise newException(ValueError, &"Missing terminal comma") 96 | 97 | proc bytesToRead*(p: var NetstringDecoder): int = 98 | ## Return how many bytes the decoder needs to read 99 | case p.state 100 | of LookingForNumber: 101 | return 1 102 | of LookingForComma: 103 | return 1 104 | of ReadingData: 105 | return p.expectedLen - int(p.buf.len) 106 | 107 | proc hasMessage*(p: var NetstringDecoder): bool = 108 | return p.output.len > 0 109 | 110 | proc nextMessage*(p: var NetstringDecoder): string = 111 | ## Get the next decoded message 112 | if p.output.len > 0: 113 | p.output.popFirst() 114 | else: 115 | raise newException(IndexError, &"No message available") 116 | 117 | -------------------------------------------------------------------------------- /src/bucketsrelay/stringproto.nim: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2022 One Part Rain, LLC. All rights reserved. 2 | # 3 | # This work is licensed under the terms of the MIT license. 4 | # For a copy, see LICENSE.md in this repository. 5 | 6 | import strutils 7 | import ./proto 8 | import ./netstring 9 | 10 | proc dumps*(ev: RelayEvent): string = 11 | ## Serialize a RelayEvent to a string. Opposite of loadsRelayEvent 12 | result = $ev.kind 13 | case ev.kind 14 | of Who: 15 | result.add nsencode(ev.who_challenge) 16 | of Authenticated: 17 | discard 18 | of Connected: 19 | result.add nsencode(ev.conn_pubkey.string) 20 | of Disconnected: 21 | result.add nsencode(ev.dcon_pubkey.string) 22 | of Data: 23 | result.add nsencode($ev.sender_pubkey.string) 24 | result.add nsencode(ev.data) 25 | of Entered: 26 | result.add nsencode(ev.entered_pubkey.string) 27 | of Exited: 28 | result.add nsencode(ev.exited_pubkey.string) 29 | of ErrorEvent: 30 | result.add nsencode($ev.err_code) 31 | result.add nsencode(ev.err_message) 32 | 33 | proc loadsRelayEvent*(msg: string): RelayEvent = 34 | ## Deserialize a RelayEvent from a string. Opposite of dumps 35 | let kind = case $msg[0] 36 | of $Who: Who 37 | of $Authenticated: Authenticated 38 | of $Connected: Connected 39 | of $Disconnected: Disconnected 40 | of $Data: Data 41 | of $Entered: Entered 42 | of $Exited: Exited 43 | of $ErrorEvent: ErrorEvent 44 | else: 45 | raise ValueError.newException("Unknown event type: " & msg[0]) 46 | let rest = msg[1..^1] 47 | result = RelayEvent(kind: kind) 48 | var decoder = newNetstringDecoder() 49 | decoder.consume(rest) 50 | case kind 51 | of Who: 52 | result.who_challenge = decoder.nextMessage() 53 | of Authenticated: 54 | discard 55 | of Connected: 56 | result.conn_pubkey = decoder.nextMessage().PublicKey 57 | of Disconnected: 58 | result.dcon_pubkey = decoder.nextMessage().PublicKey 59 | of Data: 60 | result.sender_pubkey = decoder.nextMessage().PublicKey 61 | result.data = decoder.nextMessage() 62 | of Entered: 63 | result.entered_pubkey = decoder.nextMessage().PublicKey 64 | of Exited: 65 | result.exited_pubkey = decoder.nextMessage().PublicKey 66 | of ErrorEvent: 67 | result.err_code = parseEnum[ErrorCode](decoder.nextMessage()) 68 | result.err_message = decoder.nextMessage() 69 | 70 | proc dumps*(cmd: RelayCommand): string = 71 | ## Serialize a RelayCommand to a string. Opposite of loadsRelayCommand. 72 | result = $cmd.kind 73 | case cmd.kind 74 | of Iam: 75 | result.add nsencode(cmd.iam_signature) 76 | result.add nsencode(cmd.iam_pubkey.string) 77 | of Connect: 78 | result.add nsencode(cmd.conn_pubkey.string) 79 | of Disconnect: 80 | result.add nsencode(cmd.dcon_pubkey.string) 81 | of SendData: 82 | result.add nsencode(cmd.dest_pubkey.string) 83 | result.add nsencode(cmd.send_data) 84 | 85 | proc loadsRelayCommand*(msg: string): RelayCommand = 86 | ## Deserialize a RelayCommand from a string. Opposite of dumps. 87 | let kind = case $msg[0] 88 | of $Iam: Iam 89 | of $Connect: Connect 90 | of $Disconnect: Disconnect 91 | of $SendData: SendData 92 | else: 93 | raise ValueError.newException("Unknown command type: " & msg[0]) 94 | let rest = msg[1..^1] 95 | result = RelayCommand(kind: kind) 96 | var decoder = newNetstringDecoder() 97 | decoder.consume(rest) 98 | case kind 99 | of Iam: 100 | result.iam_signature = decoder.nextMessage() 101 | result.iam_pubkey = decoder.nextMessage().PublicKey 102 | of Connect: 103 | result.conn_pubkey = decoder.nextMessage().PublicKey 104 | of Disconnect: 105 | result.dcon_pubkey = decoder.nextMessage().PublicKey 106 | of SendData: 107 | result.dest_pubkey = decoder.nextMessage().PublicKey 108 | result.send_data = decoder.nextMessage() 109 | -------------------------------------------------------------------------------- /tests/tlicenses.nim: -------------------------------------------------------------------------------- 1 | import std/strutils 2 | import std/unittest 3 | 4 | import bucketsrelay/licenses 5 | 6 | const PRIVATEKEY1 = """ 7 | -----BEGIN RSA PRIVATE KEY----- 8 | MIIEpAIBAAKCAQEAkFVXBWA85bBdFOpdwusXL5hELbGh9u7cg/ZeoV1ToDD02Tw2 9 | BEetGBUSzXsp3fKPbx89wigTjGAJNHAXVGcdbAbBCve+ARhTJTHIrXZ3lXNxvl0j 10 | KfXNa0VqV79WPeZaRtlvC0e8G9A8sL1wjvZn0nL2DG3gGBLeyAeSYiSCE8ROx5op 11 | oDylJRj5RVTWDtCsQFU4j5h7+Jk2nFCfIsaLyDDKquiycIRcXAt8f32RaMEZn0qh 12 | OXnqjHRAEF8V8hvDVfvVwx4iJXcAdnbDHKIl/aD7ssk2fYqeh0kFRH0zyLmPgFoJ 13 | UYOb+opA1NsWBcZCmaeyLC+RWjJDDXQW9H4NgwIDAQABAoIBACEBkwvkrShtg2vE 14 | CLsJXd0Beh3k8D/y8bSvw4YtPHF2oJeJAGVMKtZGA23AC5v42zozL8FVvtqsH47B 15 | T2R6zCynAsBKVUYU1Pa9gsHARKqFou5AiEkRL++nCSGV3Nf89IodMRqoRekqXqag 16 | O7xFtwpWRdQj0EpRDmc57AzLgn+YWrdhwy/2IklJpkmbXiE/lr2Hmgt1eLPb+F5Q 17 | zJ3JGpLKyFmgQZEuShhSVFJnqnFdJGhpK6DDI9XTEufxoBOhEbgJyJrc3FKqjQ4s 18 | Fro4GGNBOjFzOM8nAVWjAeMTMDh/6DSFDP0DDhbQlCHvKfv78UK7oIDEylGOwSha 19 | ODaTVWECgYEA+6gRIR5M9hy8E4/09ZwWjCgOqOSEEL4dluZWSS8a3nMLLTz/Q18u 20 | disfJVNP/rFPO40eRP5FdNHXtXDVbOFclCm0tERcWzQNFYNi54vT9yGQrMpdqJsd 21 | a5/vX3vztvr0Kw7O3jPwzCOkMne04BGZKW/TJelguEBN6d0wNNYh1dMCgYEAktMS 22 | CLM+tyf/tULVAkapYr6kr/fi5ZyNn7S6YkZQx8soH54JfhMqbBCzRwSeF2NKnj7D 23 | XBzjp4FFQoFCoqNwo0G9nTAoOoY6y3S9lLTZr4LjTthW4Zgo1JpgD6/jIEL/v2mC 24 | zpEpvfUWWKjVCn77QBj9Zxoda9v0DRa40DiAq5ECgYEAvXsJEree2Pw/vDb7COcy 25 | rusGRrJwoa6T1uetdkMKZw2WD8TKqi6DbCQBunflVm6oqr0RWn9dSp0pXosLl4SD 26 | 0WcpkUWbiGxDobwgfxjwSzYxmXhxVp8cYsm0UV+h3FdN+xGWPwY6u2nmmr05KjD1 27 | 8pYpFHWJBpIcWAbb4hyMs1MCgYA0tnTODNRiW4jxocnp5Eah/gIQbzXV68vo37De 28 | 4ZHU+Toxh8KuseDUJXbH839ytCIxCCWJZ5HQLJgaFWBAFd+1rT+PNJ/syw5Gx2Xd 29 | AsT4v0wunXsryT43fiko2KP5jDRXm2DsGq/a1CgusoayGv7Hd3Fa18RiWfiXzmWR 30 | 1AdWEQKBgQCGjvSH/6cVzf5L8qpdhGcZ1jIalc5K0eO5//qvCYv8HysdXB8mUfdh 31 | 63oK3QONPrYql3KKLgWjQxaffPEWshm4c02tuJQanSGa2yTQwhSJsk4hg1iY+7lX 32 | ihlOePC/fSmBJCmr9f0n0DNn1MxUL6GuIU7peFGm5Q1TJ0toimWG/w== 33 | -----END RSA PRIVATE KEY----- 34 | """.strip() 35 | 36 | const PUBLICKEY1 = """ 37 | -----BEGIN PUBLIC KEY----- 38 | MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAkFVXBWA85bBdFOpdwusX 39 | L5hELbGh9u7cg/ZeoV1ToDD02Tw2BEetGBUSzXsp3fKPbx89wigTjGAJNHAXVGcd 40 | bAbBCve+ARhTJTHIrXZ3lXNxvl0jKfXNa0VqV79WPeZaRtlvC0e8G9A8sL1wjvZn 41 | 0nL2DG3gGBLeyAeSYiSCE8ROx5opoDylJRj5RVTWDtCsQFU4j5h7+Jk2nFCfIsaL 42 | yDDKquiycIRcXAt8f32RaMEZn0qhOXnqjHRAEF8V8hvDVfvVwx4iJXcAdnbDHKIl 43 | /aD7ssk2fYqeh0kFRH0zyLmPgFoJUYOb+opA1NsWBcZCmaeyLC+RWjJDDXQW9H4N 44 | gwIDAQAB 45 | -----END PUBLIC KEY----- 46 | """.strip() 47 | 48 | const PRIVATEKEY2 = """ 49 | -----BEGIN RSA PRIVATE KEY----- 50 | MIICXAIBAAKBgQDCqQMftQvDX2B2oJl1t7eXRSMhviklJx00olcqI/4okB2WLX18 51 | 3wNUM+O+DZiMAkOlMk96Z6y1Rs03CmV4wJmu4fwrOGFrcS1nsOky8z9KLPENmzxp 52 | 0FAL2xwdG6TEhGOlHSRloDQQN58CEjegPYGcLwiysL30fmK69GbVE6f1ZwIDAQAB 53 | AoGBAJCtVzIIuH5z89kXUhdo/V3Dt/HLSP9hC9bj1Y7vg2YYfrTwiHT3t5ysmFbX 54 | +goNYMN2GhYq2fU9cya2ZmaSF2XR9fD5zGINSFltSSOQTUtokhUUx6pVDk06CmjJ 55 | vetu7//nhVp1xP4T2IHXIOuaOB1FxfMlUk8LV+TNsmhsXHgxAkEA/WbYpDp8ukLw 56 | ryhpOaqZiZW06aTe8seLNS2U7cGlTe+VsA9uGwS1HHIvAiOQ9/4f5rjM3XZtNwZD 57 | NrH+2BambwJBAMSn+bNuoFUtwVGzKSaAMGOg/IERQN8uH73iSCSaFnfM7Kwzl4o7 58 | u96nEYi0B2R7UMa/UwbgpBDplnvZ8QRXXIkCPw/WXbPl8+WwSVqpK+puvynaMXRo 59 | 2YZS8mBgeO5jK/GzB6f5TuhhYvBkMovvrR/SwiupYSR2Ql0uBwVkGolm4QJANFHs 60 | YQyho4fU0wOzgwa/2QHPrBcHB1miIEa/ot1L9PuUTAw92Q0jYo1YYOJkxRr51qa4 61 | VDAX9lfvLWxCb0E+4QJBAJlhxvujrPotY6/rXMVAY6Zt+MmQiUiYNDVm4eEaH6t5 62 | jMiVvR+d8aAzTRTV1U8jg+LwhM7t0lyN5gIC8NeuHuU= 63 | -----END RSA PRIVATE KEY----- 64 | """.strip() 65 | 66 | const PUBLICKEY2 {.used.} = """ 67 | -----BEGIN PUBLIC KEY----- 68 | MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQDCqQMftQvDX2B2oJl1t7eXRSMh 69 | viklJx00olcqI/4okB2WLX183wNUM+O+DZiMAkOlMk96Z6y1Rs03CmV4wJmu4fwr 70 | OGFrcS1nsOky8z9KLPENmzxp0FAL2xwdG6TEhGOlHSRloDQQN58CEjegPYGcLwiy 71 | sL30fmK69GbVE6f1ZwIDAQAB 72 | -----END PUBLIC KEY----- 73 | """.strip() 74 | 75 | suite "BucketsV1RSALicense": 76 | 77 | test "works": 78 | let license = createV1License(PRIVATEKEY1, "foo@foo.com") 79 | check $license is string 80 | checkpoint $license 81 | check verify(license, PUBLICKEY1) == true 82 | check extractEmail(license) == "foo@foo.com" 83 | 84 | test "wrong key": 85 | let license = createV1License(PRIVATEKEY2, "bad@foo.com") 86 | check $license is string 87 | checkpoint $license 88 | check verify(license, PUBLICKEY1) == false 89 | check extractEmail(license) == "bad@foo.com" 90 | -------------------------------------------------------------------------------- /tests/tclient.nim: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2022 One Part Rain, LLC. All rights reserved. 2 | # 3 | # This work is licensed under the terms of the MIT license. 4 | # For a copy, see LICENSE.md in this repository. 5 | 6 | import std/unittest 7 | import std/strutils 8 | 9 | import ./util 10 | 11 | import bucketsrelay/common 12 | import bucketsrelay/client 13 | import bucketsrelay/server 14 | 15 | type 16 | ClientHandler = ref object 17 | events: seq[RelayEvent] 18 | lifeEvents: seq[ClientLifeEvent] 19 | 20 | proc handleEvent(handler: ClientHandler, ev: RelayEvent, remote: RelayClient) {.async.} = 21 | handler.events.add(ev) 22 | 23 | proc handleLifeEvent(handler: ClientHandler, ev: ClientLifeEvent, remote: RelayClient) {.async.} = 24 | handler.lifeEvents.add(ev) 25 | 26 | proc newClientHandler(): ClientHandler = 27 | new(result) 28 | 29 | proc popEvent(client: ClientHandler, k: EventKind): Future[RelayEvent] {.async, gcsafe.} = 30 | ## Wait for and remove particular event type from the queue 31 | # Since this is just for tests, this does dumb polling 32 | var res: RelayEvent 33 | var delay = 10 34 | while true: 35 | var idx = -1 36 | for i,ev in client.events: 37 | if ev.kind == k: 38 | idx = i 39 | res = ev 40 | break 41 | if idx >= 0: 42 | client.events.del(idx) 43 | return res 44 | else: 45 | if delay > 1000: 46 | echo "Waiting for event: " & $k 47 | await sleepAsync(delay.milliseconds) 48 | delay += 100 49 | 50 | proc popEvent(client: ClientHandler, k: ClientLifeEventKind): Future[ClientLifeEvent] {.async.} = 51 | var delay = 10 52 | while true: 53 | var idx = -1 54 | for i,ev in client.lifeEvents: 55 | if ev.kind == k: 56 | idx = i 57 | result = ev 58 | break 59 | if idx >= 0: 60 | client.lifeEvents.del(idx) 61 | return result 62 | else: 63 | if delay > 1000: 64 | echo "Waiting for event: " & $k 65 | await sleepAsync(delay.milliseconds) 66 | delay += 100 67 | 68 | when multiusermode: 69 | 70 | proc verified_user(rs: RelayServer, email: string, password = ""): int64 = 71 | result = rs.register_user(email, password) 72 | let token = rs.generate_email_verification_token(result) 73 | assert rs.use_email_verification_token(result, token) == true 74 | 75 | test "basic": 76 | withinTmpDir: 77 | var server = newRelayServer(":memory:") 78 | server.start(initTAddress("127.0.0.1", 9001)) 79 | defer: 80 | waitFor server.finish() 81 | let user1 = server.verified_user("alice", "password") 82 | let user2 = server.verified_user("bob", "password") 83 | 84 | var c1h = newClientHandler() 85 | var keys1 = genkeys() 86 | var client1 = newRelayClient(keys1, c1h, "alice", "password") 87 | waitFor client1.connect("ws://127.0.0.1:9001/v1/relay") 88 | discard waitFor c1h.popEvent(ConnectedToServer) 89 | 90 | var c2h = newClientHandler() 91 | var keys2 = genkeys() 92 | var client2 = newRelayClient(keys2, c2h, "bob", "password") 93 | waitFor client2.connect("ws://127.0.0.1:9001/v1/relay") 94 | discard waitFor c2h.popEvent(ConnectedToServer) 95 | 96 | waitFor client1.connect(keys2.pk) 97 | waitFor client2.connect(keys1.pk) 98 | 99 | var atob = (waitFor c1h.popEvent(Connected)).conn_pubkey 100 | var btoa = (waitFor c2h.popEvent(Connected)).conn_pubkey 101 | check atob.string != "" 102 | check btoa.string != "" 103 | 104 | waitFor client1.sendData(atob, "hello") 105 | check (waitFor c2h.popEvent(Data)).data == "hello" 106 | waitFor client2.sendData(btoa, "a".repeat(4096)) 107 | check (waitFor c1h.popEvent(Data)).data == "a".repeat(4096) 108 | 109 | waitFor client1.disconnect(keys2.pk) 110 | waitFor client2.disconnect(keys2.pk) 111 | 112 | check (waitFor c1h.popEvent(Disconnected)).dcon_pubkey == keys2.pk 113 | check (waitFor c2h.popEvent(Disconnected)).dcon_pubkey == keys1.pk 114 | 115 | test "NotConnected": 116 | withinTmpDir: 117 | var server = newRelayServer(":memory:") 118 | server.start(initTAddress("127.0.0.1", 9002)) 119 | let user1 = server.verified_user("alice", "password") 120 | 121 | var ch = newClientHandler() 122 | var keys1 = genkeys() 123 | var client1 = newRelayClient(keys1, ch, "alice", "password") 124 | waitFor client1.connect("ws://127.0.0.1:9002/v1/relay") 125 | echo "Stopping relay server ..." 126 | waitFor server.finish() 127 | echo "Relay server stopped" 128 | for req in allHttpRequests: 129 | # req.stream.writer.tsource.close() 130 | req.stream.reader.tsource.close() 131 | echo "Closed stream" 132 | discard waitFor ch.popEvent(DisconnectedFromServer) 133 | expect RelayNotConnected: 134 | waitFor client1.connect("foobar".PublicKey) 135 | expect RelayNotConnected: 136 | waitFor client1.sendData("foobar".PublicKey, "some data") 137 | 138 | test "server goes down": 139 | withinTmpDir: 140 | var server = newRelayServer(":memory:") 141 | server.start(initTAddress("127.0.0.1", 9002)) 142 | let user1 = server.verified_user("alice", "password") 143 | 144 | var ch = newClientHandler() 145 | var keys1 = genkeys() 146 | var client1 = newRelayClient(keys1, ch, "alice", "password") 147 | waitFor client1.connect("ws://127.0.0.1:9002/v1/relay") 148 | discard waitFor ch.popEvent(ConnectedToServer) 149 | echo "Stopping relay server ..." 150 | waitFor server.finish() 151 | echo "Relay server stopped" 152 | for req in allHttpRequests: 153 | # req.stream.writer.tsource.close() 154 | req.stream.reader.tsource.close() 155 | echo "Closed stream" 156 | discard waitFor ch.popEvent(DisconnectedFromServer) 157 | expect RelayNotConnected: 158 | waitFor client1.connect("foobar".PublicKey) 159 | expect RelayNotConnected: 160 | waitFor client1.sendData("foobar".PublicKey, "some data") 161 | 162 | test "wrong credentials": 163 | var server = newRelayServer(":memory:") 164 | server.start(initTAddress("127.0.0.1", 9003)) 165 | defer: 166 | waitFor server.finish() 167 | let user1 = server.verified_user("alice", "password") 168 | 169 | var ch = newClientHandler() 170 | var keys1 = genkeys() 171 | var client1 = newRelayClient(keys1, ch, "alice", "wrongpassword") 172 | expect RelayErrLoginFailed: 173 | waitFor client1.connect("ws://127.0.0.1:9003/v1/relay") 174 | -------------------------------------------------------------------------------- /src/brelay.nim: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2022 One Part Rain, LLC. All rights reserved. 2 | # 3 | # This work is licensed under the terms of the MIT license. 4 | # For a copy, see LICENSE.md in this repository. 5 | 6 | import std/logging 7 | import std/strformat 8 | import std/json 9 | 10 | import chronos 11 | 12 | import bucketsrelay/common 13 | import bucketsrelay/server 14 | 15 | proc monitorMemory() {.async.} = 16 | var 17 | lastTotal = 0 18 | lastOccupied = 0 19 | lastFree = 0 20 | while true: 21 | let 22 | newTotal = getTotalMem() 23 | newOccupied = getOccupiedMem() 24 | newFree = getFreeMem() 25 | diffTotal = newTotal - lastTotal 26 | diffOccupied = newOccupied - lastOccupied 27 | diffFree = newFree - lastFree 28 | debug "--- Memory report ---" 29 | debug &"Total memory: {newTotal:>10} <- {lastTotal:>10} diff {diffTotal:>10}" 30 | debug &"Occupied memory: {newOccupied:>10} <- {lastOccupied:>10} diff {diffOccupied:>10}" 31 | debug &"Free memory: {newFree:>10} <- {lastFree:>10} diff {diffFree:>10}" 32 | lastTotal = newTotal 33 | lastOccupied = newOccupied 34 | lastFree = newFree 35 | await sleepAsync(10.seconds) 36 | 37 | proc startRelaySingleUser*(username, password: string, port = 9001.Port, address = "127.0.0.1"): RelayServer {.singleuseronly.} = 38 | ## Start the relay server on the given port. 39 | result = newRelayServer(username, password) 40 | let taddress = initTAddress(address, port.int) 41 | info &"Starting Single-User Buckets Relay on {taddress} ..." 42 | stderr.flushFile 43 | result.start(taddress) 44 | 45 | proc getRelayServer(dbfilename: string): RelayServer {.multiuseronly.} = 46 | newRelayServer(dbfilename, pubkey = AUTH_LICENSE_PUBKEY) 47 | 48 | proc startRelay*(dbfilename: string, port = 9001.Port, address = "127.0.0.1"): RelayServer {.multiuseronly.} = 49 | ## Start the relay server on the given port. 50 | result = getRelayServer(dbfilename) 51 | let taddress = initTAddress(address, port.int) 52 | info &"Starting Buckets Relay on {taddress} ..." 53 | if result.pubkey == "": 54 | info &"[config] License auth: DISABLED" 55 | else: 56 | info &"[config] License auth: on" 57 | stderr.flushFile 58 | result.start(taddress) 59 | result.periodically_delete_old_stats() 60 | 61 | proc addverifieduser*(dbfilename, username, password: string) {.multiuseronly.} = 62 | var rs = getRelayServer(dbfilename) 63 | let userid = rs.register_user(username, password) 64 | let token = rs.generate_email_verification_token(userid) 65 | doAssert rs.use_email_verification_token(userid, token) == true 66 | 67 | proc blockuser*(dbfilename, email: string) {.multiuseronly.} = 68 | var rs = getRelayServer(dbfilename) 69 | let uid = rs.get_user_id(email) 70 | rs.block_user(uid) 71 | 72 | proc unblockuser*(dbfilename, email: string) {.multiuseronly.} = 73 | var rs = getRelayServer(dbfilename) 74 | let uid = rs.get_user_id(email) 75 | rs.unblock_user(uid) 76 | 77 | proc blocklicense*(dbfilename, email: string) {.multiuseronly.} = 78 | var rs = getRelayServer(dbfilename) 79 | let uid = rs.get_user_id(email) 80 | rs.disable_most_recently_used_license(uid) 81 | 82 | proc stats(dbfilename: string, days = 30): JsonNode {.multiuseronly.} = 83 | result = %* { 84 | "days": days, 85 | "users": [], 86 | "ips": [], 87 | } 88 | var rs = newRelayServer(dbfilename, updateSchema = false, pubkey = AUTH_LICENSE_PUBKEY) 89 | for row in rs.top_data_users(20, days = days): 90 | result["users"].add(%* { 91 | "sent": row.data.sent, 92 | "recv": row.data.recv, 93 | "user": row.user, 94 | }) 95 | for row in rs.top_data_ips(20, days = days): 96 | result["ips"].add(%* { 97 | "sent": row.data.sent, 98 | "recv": row.data.recv, 99 | "ip": row.ip, 100 | }) 101 | 102 | proc showStats(dbfilename: string, days = 30): string {.multiuseronly.} = 103 | ## Show some usage stats 104 | return stats(dbfilename, days).pretty 105 | 106 | when defined(posix): 107 | proc getpass(prompt: cstring) : cstring {.header: "", importc: "getpass".} 108 | else: 109 | proc getpass(prompt: cstring): cstring = 110 | stdout.write(prompt) 111 | stdout.flushFile() 112 | stdin.readLine() 113 | 114 | when isMainModule: 115 | import argparse 116 | newConsoleLogger(lvlAll, useStderr = true).addHandler() 117 | when multiusermode: 118 | var p = newParser: 119 | option("-d", "--database", help="User/stats database filename", default=some("bucketsrelay.sqlite")) 120 | command("adduser"): 121 | help("Add a user") 122 | arg("email", help="Email address of user") 123 | flag("--password-stdin", help="If given, read the password from stdin rather than from the terminal") 124 | run: 125 | var password = if opts.password_stdin: 126 | stdout.write("Password? ") 127 | stdout.flushFile 128 | stdin.readLine() 129 | else: 130 | $getpass("Password? ".cstring) 131 | addverifieduser(opts.parentOpts.database, opts.email, password) 132 | echo "added user ", opts.email 133 | command("blockuser"): 134 | help("Block a user from using the relay") 135 | arg("email", help="Email address of user to block") 136 | run: 137 | blockuser(opts.parentOpts.database, opts.email) 138 | echo "User blocked" 139 | command("unblockuser"): 140 | help("Unblock a previously blocked user") 141 | arg("email", help="Email address of user to block") 142 | run: 143 | unblockuser(opts.parentOpts.database, opts.email) 144 | echo "User unblocked" 145 | command("disablelicense"): 146 | help("Disable a user's most recently-used license") 147 | arg("email", help="Email address of user") 148 | run: 149 | blocklicense(opts.parentOpts.database, opts.email) 150 | echo "License disabled" 151 | command("stats"): 152 | help("Show some statistics") 153 | option("--days", help = "Show data for this number of days", default=some("30")) 154 | run: 155 | echo showStats(opts.parentOpts.database, days=opts.days.parseInt) 156 | command("server"): 157 | help("Start the relay server") 158 | option("-p", "--port", help="Port to run server on", default=some("9001")) 159 | option("-a", "--address", help="Address to run on", default=some("127.0.0.1")) 160 | run: 161 | var server = startRelay(opts.parentOpts.database, opts.port.parseInt.Port, opts.address) 162 | runForever() 163 | elif singleusermode: 164 | var p = newParser: 165 | command("server"): 166 | help("Start a single user relay server. Set RELAY_USERNAME and RELAY_PASSWORD environment variables") 167 | option("-p", "--port", help="Port to run server on", default=some("9001")) 168 | option("-a", "--address", help="Address to run on", default=some("127.0.0.1")) 169 | option("-u", "--username", help="Username", env = "RELAY_USERNAME") 170 | option("-P", "--password", help="Password", env = "RELAY_PASSWORD") 171 | run: 172 | var server = startRelaySingleUser(opts.username, opts.password, opts.port.parseInt.Port, opts.address) 173 | runForever() 174 | try: 175 | p.run() 176 | except UsageError: 177 | stderr.writeLine getCurrentExceptionMsg() 178 | quit(1) 179 | -------------------------------------------------------------------------------- /nimble.lock: -------------------------------------------------------------------------------- 1 | { 2 | "version": 2, 3 | "packages": { 4 | "unittest2": { 5 | "version": "0.1.0", 6 | "vcsRevision": "2300fa9924a76e6c96bc4ea79d043e3a0f27120c", 7 | "url": "https://github.com/status-im/nim-unittest2", 8 | "downloadMethod": "git", 9 | "dependencies": [], 10 | "checksums": { 11 | "sha1": "914cf9a380c83b2ae40697981e5d94903505e87e" 12 | } 13 | }, 14 | "testutils": { 15 | "version": "0.5.0", 16 | "vcsRevision": "dfc4c1b39f9ded9baf6365014de2b4bfb4dafc34", 17 | "url": "https://github.com/status-im/nim-testutils", 18 | "downloadMethod": "git", 19 | "dependencies": [ 20 | "unittest2" 21 | ], 22 | "checksums": { 23 | "sha1": "756d0757c4dd06a068f9d38c7f238576ba5ee897" 24 | } 25 | }, 26 | "stew": { 27 | "version": "0.1.0", 28 | "vcsRevision": "d085e48e89062de307aab0d0629fba2f867cb49a", 29 | "url": "https://github.com/status-im/nim-stew.git", 30 | "downloadMethod": "git", 31 | "dependencies": [ 32 | "unittest2" 33 | ], 34 | "checksums": { 35 | "sha1": "2315e9345c058378545fddb48ada06351193db64" 36 | } 37 | }, 38 | "faststreams": { 39 | "version": "0.3.0", 40 | "vcsRevision": "720fc5e5c8e428d9d0af618e1e27c44b42350309", 41 | "url": "https://github.com/status-im/nim-faststreams", 42 | "downloadMethod": "git", 43 | "dependencies": [ 44 | "stew", 45 | "unittest2" 46 | ], 47 | "checksums": { 48 | "sha1": "ab178ba25970b95d953434b5d86b4d60396ccb64" 49 | } 50 | }, 51 | "serialization": { 52 | "version": "0.1.0", 53 | "vcsRevision": "9f56a0738c616061382928b9f60e1c5721622f51", 54 | "url": "https://github.com/status-im/nim-serialization.git", 55 | "downloadMethod": "git", 56 | "dependencies": [ 57 | "faststreams", 58 | "unittest2", 59 | "stew" 60 | ], 61 | "checksums": { 62 | "sha1": "f47556540e6663e9d867eea6ee61b25fdfc7a0d3" 63 | } 64 | }, 65 | "json_serialization": { 66 | "version": "0.1.5", 67 | "vcsRevision": "85b7ea093cb85ee4f433a617b97571bd709d30df", 68 | "url": "https://github.com/status-im/nim-json-serialization", 69 | "downloadMethod": "git", 70 | "dependencies": [ 71 | "serialization", 72 | "stew" 73 | ], 74 | "checksums": { 75 | "sha1": "c6b30565292acf199b8be1c62114726e354af59e" 76 | } 77 | }, 78 | "chronicles": { 79 | "version": "0.10.3", 80 | "vcsRevision": "32ac8679680ea699f7dbc046e8e0131cac97d41a", 81 | "url": "https://github.com/status-im/nim-chronicles", 82 | "downloadMethod": "git", 83 | "dependencies": [ 84 | "testutils", 85 | "json_serialization" 86 | ], 87 | "checksums": { 88 | "sha1": "79f09526d4d9b9196dd2f6a75310d71a890c4f88" 89 | } 90 | }, 91 | "libsodium": { 92 | "version": "0.6.0", 93 | "vcsRevision": "0258efe4e7f48e22daedf26f70e3efbe830abfb5", 94 | "url": "https://github.com/FedericoCeratto/nim-libsodium", 95 | "downloadMethod": "git", 96 | "dependencies": [], 97 | "checksums": { 98 | "sha1": "a2bcc3d783446e393eacf5759dda821f0f714796" 99 | } 100 | }, 101 | "ndb": { 102 | "version": "0.19.9", 103 | "vcsRevision": "f9c85711ffc2ba350fb3c64e5ce38ada47380742", 104 | "url": "https://github.com/xzfc/ndb.nim", 105 | "downloadMethod": "git", 106 | "dependencies": [], 107 | "checksums": { 108 | "sha1": "33c39596c3ece1128fa0c46ab8d4bed844b7032d" 109 | } 110 | }, 111 | "bearssl": { 112 | "version": "0.2.1", 113 | "vcsRevision": "e4157639db180e52727712a47deaefcbbac6ec86", 114 | "url": "https://github.com/status-im/nim-bearssl", 115 | "downloadMethod": "git", 116 | "dependencies": [ 117 | "unittest2" 118 | ], 119 | "checksums": { 120 | "sha1": "a5086fd5c0af2b852f34c0cc6e4cff93a98f97ec" 121 | } 122 | }, 123 | "httputils": { 124 | "version": "0.3.0", 125 | "vcsRevision": "87b7cbf032c90b9e6b446081f4a647e950362cec", 126 | "url": "https://github.com/status-im/nim-http-utils", 127 | "downloadMethod": "git", 128 | "dependencies": [ 129 | "stew", 130 | "unittest2" 131 | ], 132 | "checksums": { 133 | "sha1": "72a138157a9951f0986a9c4afc8c9a83ce3979a8" 134 | } 135 | }, 136 | "chronos": { 137 | "version": "3.2.0", 138 | "vcsRevision": "ba143e029f35fd9b4cd3d89d007cc834d0d5ba3c", 139 | "url": "https://github.com/status-im/nim-chronos", 140 | "downloadMethod": "git", 141 | "dependencies": [ 142 | "stew", 143 | "bearssl", 144 | "httputils", 145 | "unittest2" 146 | ], 147 | "checksums": { 148 | "sha1": "5783067584ac6812eb64b8454ea6f9c97ff1262a" 149 | } 150 | }, 151 | "bearssl_pkey_decoder": { 152 | "version": "0.1.0", 153 | "vcsRevision": "546f8d9bb887ae1d8a23f62155c583acb0358046", 154 | "url": "https://github.com/yglukhov/bearssl_pkey_decoder.git", 155 | "downloadMethod": "git", 156 | "dependencies": [ 157 | "bearssl" 158 | ], 159 | "checksums": { 160 | "sha1": "78460e0295dd7beff2f243cb68fd1646b4dc82ef" 161 | } 162 | }, 163 | "nimcrypto": { 164 | "version": "0.5.4", 165 | "vcsRevision": "a065c1741836462762d18d2fced1fedd46095b02", 166 | "url": "https://github.com/cheatfate/nimcrypto.git", 167 | "downloadMethod": "git", 168 | "dependencies": [], 169 | "checksums": { 170 | "sha1": "88d68a2df326df59f1390450ca6ddade15ba2d21" 171 | } 172 | }, 173 | "argparse": { 174 | "version": "4.0.1", 175 | "vcsRevision": "98c7c99bfbcaae750ac515a6fd603f85ed68668f", 176 | "url": "https://github.com/iffy/nim-argparse", 177 | "downloadMethod": "git", 178 | "dependencies": [], 179 | "checksums": { 180 | "sha1": "e9c2ebe3f74b1dfc4df773686ae6dab7638a8662" 181 | } 182 | }, 183 | "mustache": { 184 | "version": "0.4.3", 185 | "vcsRevision": "1677949a848fc126a0716a67bba2c6217205255c", 186 | "url": "https://github.com/soasme/nim-mustache", 187 | "downloadMethod": "git", 188 | "dependencies": [], 189 | "checksums": { 190 | "sha1": "9c7e49440ae9bb6494bd202eea6ef7405811c6bb" 191 | } 192 | }, 193 | "zlib": { 194 | "version": "0.1.0", 195 | "vcsRevision": "826e2fc013f55b4478802d4f2e39f187c50d520a", 196 | "url": "https://github.com/status-im/nim-zlib.git", 197 | "downloadMethod": "git", 198 | "dependencies": [ 199 | "stew" 200 | ], 201 | "checksums": { 202 | "sha1": "6148e06a83c01425af4b63050ee81bed1bae1491" 203 | } 204 | }, 205 | "websock": { 206 | "version": "0.1.0", 207 | "vcsRevision": "fea05cde8b123b38d1a0a8524b77efbc84daa848", 208 | "url": "https://github.com/status-im/nim-websock.git", 209 | "downloadMethod": "git", 210 | "dependencies": [ 211 | "chronos", 212 | "httputils", 213 | "chronicles", 214 | "stew", 215 | "nimcrypto", 216 | "bearssl", 217 | "zlib" 218 | ], 219 | "checksums": { 220 | "sha1": "fc7626b0858afccd7968301e904671c0694ac807" 221 | } 222 | } 223 | }, 224 | "tasks": {} 225 | } 226 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![.github/workflows/main.yml](https://github.com/buckets/relay/actions/workflows/main.yml/badge.svg)](https://github.com/buckets/relay/actions/workflows/main.yml) 2 | 3 | ![Buckets Relay Server Logo](./src/static/favicon.png) 4 | 5 | # Buckets Relay Server 6 | 7 | This repository contains the open source code for the [Buckets](https://www.budgetwithbuckets.com) relay server, which allows users to share budget data between their devices in an end-to-end encrypted way. 8 | 9 | ## Quickstart - single user mode 10 | 11 | If you want to run the relay on your own computer with only one user account, do the following: 12 | 13 | 1. Install [Nim](https://nim-lang.org/) 14 | 2. Build the relay: 15 | 16 | ``` 17 | git clone https://github.com/buckets/relay.git buckets-relay.git 18 | cd buckets-relay.git 19 | nimble singleuserbins 20 | ``` 21 | 22 | 3. Run the server: 23 | 24 | ```sh 25 | RELAY_USERNAME=someusername 26 | RELAY_PASSWORD=somepassword 27 | bin/brelay server 28 | ``` 29 | 30 | This will launch the relay on the default port. Run `brelay --help` for more options. 31 | 32 | ## Multi-user mode 33 | 34 | If instead of `nimble singleuserbins` you run `nimble multiuserbins` the server will be built in multi-user mode. 35 | 36 | Register users via `brelay adduser ...` or through the web interface. 37 | 38 | Registration-related emails are sent through [Postmark](https://postmarkapp.com/). Set `POSTMARK_API_KEY` to your Postmark key to use it. Otherwise, disable emails with `-d:nopostmark`. 39 | 40 | Users can authenticate with their Buckets license if you set an environment variable `AUTH_LICENSE_PUBKEY=` 41 | 42 | ## Security 43 | 44 | - You should ensure that connections to this relay server are made with TLS. 45 | - This relay server can see all traffic, so clients should encrypt data intended for other clients. 46 | - Clients should also authenticate each other through the relay and not trust the authentication done by this server. 47 | 48 | ## Development 49 | 50 | To run the server locally: 51 | 52 | ```sh 53 | nimble run brelay server 54 | ``` 55 | 56 | ## Deployment to fly.io 57 | 58 | If you'd like to run a relay server on [fly.io](https://fly.io/), sign up for the service then do one of the following. If you'd like to host somewhere else, you could use the Dockerfiles in [docker/](./docker/) as a starting point. 59 | 60 | ### Single-user mode 61 | 62 | ```sh 63 | fly launch --dockerfile docker/singleuser.Dockerfile 64 | fly secrets set RELAY_USERNAME='someusername' RELAY_PASSWORD='somepassword' 65 | ``` 66 | 67 | | Variable | Description | 68 | |---|---| 69 | | `RELAY_USERNAME` | Username or email you'll use to authenticate to the relay. | 70 | | `RELAY_PASSWORD` | Password you'll use to authenticate to the relay. | 71 | 72 | ### Multi-user mode 73 | 74 | ```sh 75 | fly launch --dockerfile docker/multiuser.Dockerfile 76 | fly secrets set POSTMARK_API_KEY='your key' AUTH_LICENSE_PUBKEY='the key' LICENSE_HASH_SALT='choose something here' 77 | ``` 78 | 79 | | Variable | Description | 80 | |---|---| 81 | | `POSTMARK_API_KEY` | API key from [Postmark](https://postmarkapp.com/) | 82 | | `AUTH_LICENSE_PUBKEY` | RSA public key of Buckets licenses. If empty, license authentication is disabled. | 83 | | `LICENSE_HASH_SALT` | A hashing salt for the case when a license needs to be disabled. Any random, but consistent value is fine. | 84 | 85 | ## Protocol 86 | 87 | Relay clients communicate with the relay server using the following protocol. See [./src/bucketsrelay/proto.nim](./src/bucketsrelay/proto.nim) for more information, and [./src/bucketsrelay/stringproto.nim](./src/bucketsrelay/stringproto.nim) for encoding details. 88 | 89 | In summary, devices connect with websockets and exchange messages. Messages sent from client to server are called commands. Messages sent from server to client are called events. 90 | 91 | ### Authentication 92 | 93 | Clients authenticate with the server in two ways: 94 | 95 | 1. With a relay account via HTTP Basic authentication. This is used to group together a user's various clients and prevent abuse. 96 | 2. With a public/private key. This is used to identify and connect individual clients. 97 | 98 | A single relay account can have multiple public/private keys; typically one for each device. 99 | 100 | ### Client Commands 101 | 102 | Clients send the following commands: 103 | 104 | | Command | Description | 105 | |--------------|-------------| 106 | | `Iam` | In response to a `Who` event, proves that this client has the private key for their public key. | 107 | | `Connect` | Asks the server for a connection to another client identified by the client's public key. | 108 | | `Disconnect` | Asks the server to disconnect a connection to another client. | 109 | | `SendData` | Sends bytes to another client. | 110 | 111 | ### Server Events 112 | 113 | The relay server sends the following events: 114 | 115 | | Event | Description | 116 | |-----------------|-------------| 117 | | `Who` | Challenge for authenticating a client's public/private keys | 118 | | `Authenticated` | Sent when a client successfully completes authentication | 119 | | `Connected` | Sent when a client has connected to another client | 120 | | `Disconnected` | Sent when a client has been disconnected from another client | 121 | | `Data` | Data payload from another, connected client | 122 | | `Entered` | Sent when a client within the same user account has authenticated to the relay | 123 | | `Exited` | Sent when a client within the same user account has disconnected from the relay | 124 | | `ErrorEvent` | Sent when errors happen with authentication, connection or message sending | 125 | 126 | ### Sequences and Usage 127 | 128 | #### Authentication 129 | 130 | Authentication happens like this: 131 | 132 | 1. On connection, server sends `Who(challenge=ABCD...)` 133 | 2. Client responds with `Iam(pubkey=MYPK..., signature=SIGN...)` 134 | 3. If the signature is correct, server sends `Authenticated` 135 | 136 | ``` 137 | Client Relay 138 | │ │ 139 | │ Who │ 140 | │◄────────────────┤ 141 | │ │ 142 | │ Iam │ 143 | ├────────────────►│ 144 | │ │ 145 | │ Authenticated │ 146 | │◄────────────────┤ 147 | │ │ 148 | ``` 149 | 150 | #### Client-to-client connection 151 | 152 | After authenticating, clients connect to each other and send data like this: 153 | 154 | 1. Alice sends `Connect(pubkey=BOBPK)` 155 | 2. Bob sends `Connect(pubkey=ALICEPK)` 156 | 3. Server sends Alice `Connected(pubkey=BOBPK)` 157 | 4. Server sends Bob `Connected(pubkey=ALICEPK)` 158 | 5. Alice sends data with `SendData(data=hello, pubkey=BOBPK)` 159 | 6. Server sends Bob data with `Data(data=hello, sender=ALICEPK)` 160 | 161 | ``` 162 | Alice Relay Bob 163 | │ │ │ 164 | ├───Authenticated─┼─Authenticated───┤ 165 | │ │ │ 166 | │Connect(Bob) │ │ 167 | ├────────────────►│ Connect(Alice) │ 168 | │ │◄────────────────┤ 169 | │ │ │ 170 | │ Connected(Bob) │ Connected(Alice)│ 171 | │◄────────────────┼────────────────►│ 172 | │ │ │ 173 | │SendData(Bob) │ │ 174 | ├────────────────►│ Data(Alice) │ 175 | │ ├────────────────►│ 176 | │ │ │ 177 | ``` 178 | 179 | #### Same-user presence notifications 180 | 181 | The relay server will announce client presence to all clients that use the same HTTP Auth credentials. For example, if both Alice and Bob signed in as `alicenbob@example.com` the following would happen: 182 | 183 | 1. Alice finishes authenticating 184 | 2. Bob finishes authenticating 185 | 3. Server sends Alice `Entered(pubkey=BOBPK)` 186 | 4. Server sends Bob `Entered(pubkey=ALICEPK)` 187 | 5. Alice disconnects 188 | 6. Server sends Bob `Exited(pubkey=ALICEPK)` 189 | 190 | 191 | -------------------------------------------------------------------------------- /src/bucketsrelay/client.nim: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2022 One Part Rain, LLC. All rights reserved. 2 | # 3 | # This work is licensed under the terms of the MIT license. 4 | # For a copy, see LICENSE.md in this repository. 5 | import std/base64 6 | import std/logging 7 | import std/options 8 | import std/random 9 | import std/strformat 10 | 11 | import chronos; export chronos 12 | import chronicles except debug, info, warn, error 13 | export activeChroniclesStream, Record, activeChroniclesScope 14 | import stew/byteutils 15 | import websock/websock 16 | 17 | import ./common 18 | import ./netstring 19 | import ./proto; export proto 20 | import ./stringproto 21 | 22 | const HEARTBEAT_INTERVAL = 50.seconds 23 | const HEARTBEAT_JITTER = 1000 24 | 25 | type 26 | ## RelayClient wraps a single websockets connection 27 | ## to a relay server. It will call things on 28 | ## `handler: T` to interact with your code. 29 | RelayClient*[T] = ref object 30 | keys: KeyPair 31 | wsopt: Option[WSSession] 32 | handler*: T 33 | username: string 34 | password: string 35 | verifyHostname: bool 36 | done*: Future[void] 37 | tasks*: seq[Future[void]] 38 | debugname*: string 39 | 40 | ClientLifeEventKind* = enum 41 | ConnectedToServer 42 | DisconnectedFromServer 43 | 44 | ClientLifeEvent* = ref object 45 | case kind*: ClientLifeEventKind 46 | of ConnectedToServer: 47 | discard 48 | of DisconnectedFromServer: 49 | discard 50 | 51 | RelayErrLoginFailed* = RelayErr 52 | RelayNotConnected* = RelayErr 53 | 54 | proc newRelayClient*[T](keys: KeyPair, handler: T, username, password: string, verifyHostname = true): RelayClient[T] = 55 | new(result) 56 | result.keys = keys 57 | result.handler = handler 58 | result.username = username 59 | result.password = password 60 | result.verifyHostname = verifyHostname 61 | # result.done = newFuture[void]("newRelayClient done") 62 | result.debugname = "RelayClient" & nextDebugName() 63 | 64 | proc logname*(client: RelayClient): string = 65 | "(" & client.debugname & ") " 66 | 67 | proc `$`*(client: RelayClient): string = client.debugname 68 | 69 | proc ws*(client: RelayClient): WSSession = 70 | if client.wsopt.isSome: 71 | client.wsopt.get() 72 | else: 73 | raise RelayNotConnected.newException("Not connected") 74 | 75 | proc send(ws: WSSession, cmd: RelayCommand) {.async.} = 76 | ## Send a RelayCommand to the server 77 | await ws.send(nsencode(dumps(cmd)).toBytes, Opcode.Binary) 78 | 79 | proc keepAliveLoop(client: RelayClient) {.async.} = 80 | ## Start a loop that periodically issues a ping to keep the 81 | ## connection alive 82 | try: 83 | while true: 84 | await sleepAsync(HEARTBEAT_INTERVAL + rand(HEARTBEAT_JITTER).milliseconds) 85 | if client.wsopt.isSome: 86 | let ws = client.wsopt.get() 87 | await ws.ping() 88 | else: 89 | break 90 | except CancelledError: 91 | discard 92 | except: 93 | error client.logname, "unexpected error in ws keepAliveLoop" 94 | 95 | proc loop(client: RelayClient, authenticated: Future[void]): Future[void] {.async.} = 96 | var decoder = newNetstringDecoder() 97 | if client.wsopt.isSome(): 98 | let ws = client.ws 99 | while ws.readyState != ReadyState.Closed: 100 | try: 101 | let buff = try: 102 | await ws.recvMsg() 103 | except Exception as exc: 104 | break 105 | if buff.len <= 0: 106 | break 107 | let data = string.fromBytes(buff) 108 | decoder.consume(data) 109 | while decoder.hasMessage(): 110 | let ev = loadsRelayEvent(decoder.nextMessage()) 111 | case ev.kind 112 | of Who: 113 | await ws.send(RelayCommand( 114 | kind: Iam, 115 | iam_signature: sign(client.keys.sk, ev.who_challenge), 116 | iam_pubkey: client.keys.pk, 117 | )) 118 | of Authenticated: 119 | authenticated.complete() 120 | else: 121 | discard 122 | try: 123 | await client.handler.handleEvent(ev, client) 124 | except: 125 | debug client.logname, "Error handling event ", $ev, " ", getCurrentExceptionMsg() 126 | raise 127 | except CancelledError: 128 | break 129 | debug client.logname, "closing..." 130 | client.wsopt = none[WSSession]() 131 | await ws.close() 132 | await client.handler.handleLifeEvent(ClientLifeEvent( 133 | kind: DisconnectedFromServer, 134 | ), client) 135 | 136 | proc authHeaderHook*(username, password: string): Hook = 137 | ## Create a websock connection hook that adds Basic HTTP authentication 138 | ## to the websocket. 139 | new(result) 140 | result.append = proc(ctx: Hook, headers: var HttpTable): Result[void, string] = 141 | headers.add("Authorization", "Basic " & base64.encode(username & ":" & password)) 142 | ok() 143 | 144 | proc addHeadersHook(addheaders: HttpTable): Hook = 145 | new(result) 146 | result.append = proc(ctx: Hook, headers: var HttpTable): Result[void, string] = 147 | for key, val in addheaders.stringItems: 148 | headers.add(key, val) 149 | ok() 150 | 151 | proc connect*(client: RelayClient, url: string) {.async.} = 152 | ## Connect and authenticate with a relay server. Returns 153 | ## after authentication succeeds. 154 | var uri = parseUri(url) 155 | if uri.scheme == "http": 156 | uri.scheme = "ws" 157 | elif uri.scheme == "https": 158 | uri.scheme = "wss" 159 | let 160 | hostname = uri.hostname 161 | port = if uri.port == "": "443" else: uri.port 162 | addresses = resolveTAddress(uri.hostname, port.parseInt.Port) 163 | hooks = @[ 164 | authHeaderHook(client.username, client.password), 165 | addHeadersHook(HttpTable.init({ 166 | "User-Agent": "buckets-relay client 1.0", 167 | })), 168 | ] 169 | tls = uri.scheme == "https" or uri.scheme == "wss" or port == "443" 170 | if addresses.len == 0: 171 | raise ValueError.newException(&"Unable to resolve {uri.hostname}") 172 | let address = addresses[0] 173 | try: 174 | let ws = if tls: 175 | var flags: set[TLSFlags] 176 | if not client.verifyHostname: 177 | flags.incl(TLSFlags.NoVerifyHost) 178 | flags.incl(TLSFlags.NoVerifyServerName) 179 | await WebSocket.connect( 180 | uri, 181 | protocols = @["proto"], 182 | flags = flags, 183 | hooks = hooks, 184 | version = WSDefaultVersion, 185 | frameSize = WSDefaultFrameSize, 186 | onPing = nil, 187 | onPong = nil, 188 | onClose = nil, 189 | rng = nil, 190 | ) 191 | else: 192 | await WebSocket.connect( 193 | address, 194 | path = uri.path, 195 | hooks = hooks, 196 | hostName = hostname, 197 | ) 198 | client.wsopt = some(ws) 199 | except WebSocketError as exc: 200 | let msg = getCurrentExceptionMsg() 201 | if "403" in msg and "Forbidden" in msg: 202 | raise RelayErrLoginFailed.newException("Failed initial authentication") 203 | else: 204 | raise exc 205 | await client.handler.handleLifeEvent(ClientLifeEvent( 206 | kind: ConnectedToServer, 207 | ), client) 208 | var authenticated = newFuture[void]("relay.client.dial.authenticated") 209 | client.done = client.loop(authenticated) 210 | let fut = client.keepAliveLoop() 211 | client.tasks.add(fut) 212 | await authenticated 213 | 214 | proc connect*(client: RelayClient, pubkey: PublicKey) {.async, raises: [RelayNotConnected].} = 215 | ## Initiate a connection through the relay to the given public key 216 | await client.ws.send(RelayCommand( 217 | kind: Connect, 218 | conn_pubkey: pubkey, 219 | )) 220 | 221 | proc sendData*(client: RelayClient, dest_pubkey: PublicKey, data: string) {.async, raises: [RelayNotConnected].} = 222 | ## Send data to a connection through the relay 223 | await client.ws.send(RelayCommand( 224 | kind: SendData, 225 | send_data: data, 226 | dest_pubkey: dest_pubkey, 227 | )) 228 | 229 | proc disconnect*(client: RelayClient) {.async.} = 230 | ## Disconnect this client from the network 231 | if not client.done.isNil: 232 | await client.done.cancelAndWait() 233 | if client.wsopt.isSome: 234 | await client.wsopt.get().close() 235 | client.wsopt = none[WSSession]() 236 | for task in client.tasks: 237 | await task.cancelAndWait() 238 | 239 | proc disconnect*(client: RelayClient, dest_pubkey: PublicKey) {.async.} = 240 | ## Disconnect this client from a remote client 241 | if client.wsopt.isSome: 242 | await client.wsopt.get().send(RelayCommand( 243 | kind: Disconnect, 244 | dcon_pubkey: dest_pubkey, 245 | )) 246 | -------------------------------------------------------------------------------- /src/bclient.nim: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2022 One Part Rain, LLC. All rights reserved. 2 | # 3 | # This work is licensed under the terms of the MIT license. 4 | # For a copy, see LICENSE.md in this repository. 5 | 6 | import std/logging 7 | import std/strformat 8 | import std/strutils 9 | import std/base64 10 | import std/os 11 | 12 | import chronos except debug, info, warn, error 13 | 14 | import bucketsrelay/client 15 | import bucketsrelay/proto 16 | import bucketsrelay/asyncstdin 17 | 18 | type 19 | SendHandler = ref object 20 | data: string 21 | sent: Future[void] 22 | 23 | proc newSendHandler(data: string): SendHandler = 24 | new(result) 25 | result.data = data 26 | result.sent = newFuture[void]("newSendHandler") 27 | 28 | proc handleEvent(handler: SendHandler, ev: RelayEvent, remote: RelayClient) {.async.} = 29 | try: 30 | case ev.kind 31 | of Connected: 32 | await remote.sendData(ev.conn_pubkey, handler.data) 33 | callSoon(proc(udata: pointer) = 34 | handler.sent.complete()) 35 | else: 36 | discard 37 | except CancelledError: 38 | warn "SendHandler cancelled during event handling" 39 | raise 40 | 41 | proc handleLifeEvent(handler: SendHandler, ev: ClientLifeEvent, remote: RelayClient) {.async.} = 42 | discard 43 | 44 | 45 | type 46 | RecvHandler = ref object 47 | buf: string 48 | data: Future[string] 49 | 50 | proc newRecvHandler(): RecvHandler = 51 | new(result) 52 | result.data = newFuture[string]("newRecvHandler") 53 | 54 | proc handleEvent(handler: RecvHandler, ev: RelayEvent, remote: RelayClient) {.async.} = 55 | try: 56 | case ev.kind 57 | of Data: 58 | handler.buf.add(ev.data) 59 | of Disconnected: 60 | handler.data.complete(handler.buf) 61 | else: 62 | discard 63 | except CancelledError: 64 | warn "RecvHandler cancelled during event handling" 65 | raise 66 | 67 | proc handleLifeEvent(handler: RecvHandler, ev: ClientLifeEvent, remote: RelayClient) {.async.} = 68 | discard 69 | 70 | proc relaySend*(data: string, topubkey: PublicKey, relayurl: string, mykeys: KeyPair, username: string, password: string, verify = true): Future[void] {.async.} = 71 | debug &"Sending {data.len} bytes to {topubkey} via {relayurl} ..." 72 | var sh = newSendHandler(data) 73 | var client = newRelayClient(mykeys, sh, username, password, verifyHostname = verify) 74 | await client.connect(relayurl) 75 | await client.connect(topubkey) 76 | await sh.sent 77 | await client.disconnect() 78 | await client.done 79 | 80 | proc relayReceive*(frompubkey: PublicKey, relayurl: string, mykeys: KeyPair, username: string, password: string, verify = true): Future[string] {.async.} = 81 | debug &"Receiving from {frompubkey} via {relayurl} ..." 82 | var rh = newRecvHandler() 83 | var client = newRelayClient(mykeys, rh, username, password, verifyHostname = verify) 84 | await client.connect(relayurl) 85 | await client.connect(frompubkey) 86 | result = await rh.data 87 | await client.disconnect() 88 | await client.done 89 | 90 | type 91 | ChatHandler = ref object 92 | done: Future[void] 93 | 94 | proc handleEvent(handler: ChatHandler, ev: RelayEvent, remote: RelayClient) {.async.} = 95 | case ev.kind 96 | of Data: 97 | stdout.write(ev.data) 98 | stdout.flushFile() 99 | of Disconnected: 100 | handler.done.complete() 101 | else: 102 | discard 103 | 104 | proc handleLifeEvent(handler: ChatHandler, ev: ClientLifeEvent, remote: RelayClient) {.async.} = 105 | discard 106 | 107 | proc chat(ch: ChatHandler, remote: RelayClient, remotePubkey: PublicKey) {.async.} = 108 | let reader = asyncStdinReader() 109 | while true: 110 | let inp = reader.read(1) 111 | await ch.done or inp 112 | if ch.done.completed: 113 | await inp.cancelAndWait() 114 | break 115 | else: 116 | let data = inp.read() 117 | await remote.sendData(remotePubkey, data) 118 | 119 | proc relayChat*(otherpubkey: PublicKey, relayurl: string, mykeys: KeyPair, username: string, password: string, verify = true): Future[string] {.async.} = 120 | debug &"Attempting to chat with {otherpubkey} via {relayurl} ..." 121 | var ch = ChatHandler() 122 | ch.done = newFuture[void]() 123 | var client = newRelayClient(mykeys, ch, username, password, verifyHostname = verify) 124 | await client.connect(relayurl) 125 | await client.connect(otherpubkey) 126 | await ch.chat(client, otherpubkey) 127 | 128 | when isMainModule: 129 | import argparse 130 | var p = newParser: 131 | command("genkeys"): 132 | help("Generate a keypair") 133 | option("-p", "--public", help="Filename to save public key to", default=some("relay.key.public")) 134 | option("-s", "--secret", help="Filename to save secret key to", default=some("relay.key.secret")) 135 | run: 136 | var keys = genkeys() 137 | writeFile(opts.public, keys.pk.string.encode & "\n") 138 | echo "Wrote ", opts.public 139 | writeFile(opts.secret, keys.sk.string.encode & "\n") 140 | echo "Wrote ", opts.secret 141 | echo "Public key:" 142 | echo keys.pk.string.encode() 143 | command("send"): 144 | help("Send stdin through the relay") 145 | option("-u", "--username", help="Relay username", env = "RELAY_USERNAME") 146 | option("-p", "--password", help="Relay password", env = "RELAY_PASSWORD") 147 | flag("-k", "--no-ssl-verify", help="Disable SSL verification") 148 | option("--local-secret", help="Path to local secret key", default=some("relay.key.secret")) 149 | option("--local-public", help="Path to local public key", default=some("relay.key.public")) 150 | arg("url", help="Relay URL to connect to. Should end in /relay") 151 | arg("public_key", help="Public key of remote client to connect to") 152 | run: 153 | newConsoleLogger(lvlAll, useStderr = true).addHandler() 154 | let keys = ( 155 | readFile(opts.local_public).decode().PublicKey, 156 | readFile(opts.local_secret).decode().SecretKey, 157 | ) 158 | let pubkey = opts.public_key.decode().PublicKey 159 | let data = stdin.readAll() 160 | waitFor relaySend(data, pubkey, relayurl = opts.url, mykeys = keys, username = opts.username, password = opts.password, verify = not opts.no_ssl_verify) 161 | command("receive"): 162 | help("Receive data through the relay to stdout") 163 | option("-u", "--username", help="Relay username", env = "RELAY_USERNAME") 164 | option("-p", "--password", help="Relay password", env = "RELAY_PASSWORD") 165 | flag("-k", "--no-ssl-verify", help="Disable SSL verification") 166 | option("--local-secret", help="Path to local secret key", default=some("relay.key.secret")) 167 | option("--local-public", help="Path to local public key", default=some("relay.key.public")) 168 | arg("url", help="Relay URL to connect to. Should end in /relay") 169 | arg("public_key", help="Public key of remote client to connect to") 170 | run: 171 | newConsoleLogger(lvlAll, useStderr = true).addHandler() 172 | let keys = ( 173 | readFile(opts.local_public).decode().PublicKey, 174 | readFile(opts.local_secret).decode().SecretKey, 175 | ) 176 | let pubkey = opts.public_key.decode().PublicKey 177 | echo waitFor relayReceive(pubkey, relayurl = opts.url, mykeys = keys, username = opts.username, password = opts.password, verify = not opts.no_ssl_verify) 178 | command("chat"): 179 | help("Open a symmetric chat stream with another client") 180 | option("-u", "--username", help="Relay username", env = "RELAY_USERNAME") 181 | option("-p", "--password", help="Relay password", env = "RELAY_PASSWORD") 182 | flag("-k", "--no-ssl-verify", help="Disable SSL verification") 183 | option("--local-secret", help="Path to local secret key", default=some("relay.key.secret")) 184 | option("--local-public", help="Path to local public key", default=some("relay.key.public")) 185 | flag("-v", "--verbose", help="Verbose logging") 186 | arg("url", help="Relay URL to connect to. Should end in /relay") 187 | arg("public_key", help="Public key of remote client to connect to") 188 | run: 189 | if opts.verbose: 190 | newConsoleLogger(lvlAll, useStderr = true).addHandler() 191 | let keys = ( 192 | readFile(opts.local_public).decode().PublicKey, 193 | readFile(opts.local_secret).decode().SecretKey, 194 | ) 195 | let pubkey = opts.public_key.decode().PublicKey 196 | echo waitFor relayChat(pubkey, relayurl = opts.url, mykeys = keys, username = opts.username, password = opts.password, verify = not opts.no_ssl_verify) 197 | try: 198 | p.run() 199 | except UsageError: 200 | stderr.writeLine getCurrentExceptionMsg() 201 | quit(1) 202 | -------------------------------------------------------------------------------- /tests/tserver.nim: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2022 One Part Rain, LLC. All rights reserved. 2 | # 3 | # This work is licensed under the terms of the MIT license. 4 | # For a copy, see LICENSE.md in this repository. 5 | 6 | import std/unittest 7 | import std/strutils 8 | import ./util 9 | 10 | import chronos 11 | 12 | import bucketsrelay/common 13 | import bucketsrelay/server 14 | import bucketsrelay/licenses 15 | 16 | when multiusermode: 17 | test "add user": 18 | withinTmpDir: 19 | var rs = newRelayServer("test.db") 20 | let uid = rs.register_user("foo", "password") 21 | check rs.is_email_verified(uid) == false 22 | check rs.can_use_relay(uid) == false 23 | check rs.password_auth("foo", "password") == uid 24 | expect WrongPassword: 25 | discard rs.password_auth("foo", "something else") 26 | 27 | let token = rs.generate_email_verification_token(uid) 28 | checkpoint "token: " & token 29 | check rs.use_email_verification_token(uid, token) == true 30 | check rs.is_email_verified(uid) == true 31 | check rs.can_use_relay(uid) == true 32 | 33 | test "duplicate users not allowed": 34 | withinTmpDir: 35 | var rs = newRelayServer("test.db") 36 | discard rs.register_user("foo", "password") 37 | expect DuplicateUser: 38 | discard rs.register_user("foo", "another") 39 | 40 | test "email verification only 5 latest codes work": 41 | withinTmpDir: 42 | var rs = newRelayServer("test.db") 43 | let uid = rs.register_user("foo", "password") 44 | let t1 = rs.generate_email_verification_token(uid) 45 | discard rs.generate_email_verification_token(uid) 46 | let t3 = rs.generate_email_verification_token(uid) 47 | discard rs.generate_email_verification_token(uid) 48 | check rs.use_email_verification_token(uid, "invalid token") == false 49 | check rs.is_email_verified(uid) == false 50 | 51 | check rs.use_email_verification_token(uid, t1) == false # failed because only 5 are valid 52 | check rs.is_email_verified(uid) == false 53 | 54 | check rs.use_email_verification_token(uid, t3) == true 55 | check rs.is_email_verified(uid) == true 56 | 57 | proc verified_user(rs: RelayServer, email: string, password = ""): int64 = 58 | result = rs.register_user(email, password) 59 | let token = rs.generate_email_verification_token(result) 60 | assert rs.use_email_verification_token(result, token) == true 61 | 62 | test "reset password": 63 | withinTmpDir: 64 | var rs = newRelayServer(":memory:") 65 | let uid = rs.register_user("foo", "password") 66 | let t1 = rs.generate_password_reset_token("foo") 67 | check rs.user_for_password_reset_token(t1).get() == uid 68 | rs.update_password_with_token(t1, "newpassword") 69 | check rs.password_auth("foo", "newpassword") == uid 70 | 71 | test "reset password once only": 72 | withinTmpDir: 73 | var rs = newRelayServer(":memory:") 74 | let uid = rs.register_user("foo", "password") 75 | let t1 = rs.generate_password_reset_token("foo") 76 | check rs.user_for_password_reset_token(t1).get() == uid 77 | rs.update_password_with_token(t1, "newpassword") 78 | expect NotFound: 79 | rs.update_password_with_token(t1, "another password") 80 | check rs.password_auth("foo", "newpassword") == uid 81 | 82 | test "block user": 83 | withinTmpDir: 84 | var rs = newRelayServer("test.db") 85 | var uid = rs.verified_user("foo") 86 | var other = rs.verified_user("bar") 87 | rs.block_user("foo") 88 | check rs.can_use_relay(uid) == false 89 | check rs.can_use_relay(other) == true 90 | rs.unblock_user("foo") 91 | check rs.can_use_relay(uid) == true 92 | check rs.can_use_relay(other) == true 93 | 94 | test "log user data": 95 | withinTmpDir: 96 | var rs = newRelayServer("test.db") 97 | var uid = rs.verified_user("foo") 98 | rs.log_user_data_sent(uid, 10) 99 | rs.log_user_data_recv(uid, 20) 100 | rs.log_user_data_sent(uid, 30) 101 | rs.log_user_data_recv(uid, 40) 102 | check rs.data_by_user(uid, days = 1) == (40, 60) 103 | rs.log_ip_data_sent("10.0.0.5", 10) 104 | rs.log_ip_data_recv("10.0.0.4", 20) 105 | check rs.data_by_ip("10.0.0.5", days = 1) == (10, 0) 106 | check rs.data_by_ip("10.0.0.4", days = 1) == (0, 20) 107 | 108 | check rs.top_data_users(10, days = 7) == @[ 109 | ("foo", (40, 60)), 110 | ] 111 | check rs.top_data_ips(10, days = 7) == @[ 112 | ("10.0.0.4", (0, 20)), 113 | ("10.0.0.5", (10, 0)), 114 | ] 115 | 116 | const SAMPLE_PRIVATE_KEY = """ 117 | -----BEGIN RSA PRIVATE KEY----- 118 | MIICWwIBAAKBgH29pIKU/P8ELlA4ofzSiq4InGzd45hxqE/vfqqUOP70Sa5R5s6W 119 | ntYVz6x5Btp8uc2vwWcDg4gFDkyBJ9xShROaCrtvncRIbJ5m3uka46yAObWYkKxP 120 | +e3AMud/8tu5DvnJRiPq9Nu0wbdWXePriajk/Nc4CQAl8tB1Ka1QWLhhAgMBAAEC 121 | gYBLP5aX3vmY07OzpnCqkIUVqWmTbSarMDl9vOGcy59gVGlTvQfXUiQ0ElF58eO8 122 | FTBMe4XOVDf+yqfH+PMV0vx348rxXG1z2SBAB9tDk4G54bldeoEOtU3kcZCQhstr 123 | A7i4bjFwEK73URRMFaa8/NFGAC9KQ+eD1o/kptB8GPFWEQJBAOuyvEl1mFZtCXHB 124 | THLL92isP/o/q6+dLpI6WlmakFawV1Aof5QPrGeYf50R+c9XebkAxKRtSfzMeQ5u 125 | wLUuPMsCQQCIkkpzrsMtvEPV8iuIphJHcQ7T4DP+Z0iRwN+AjWN9FL6tlTuuIdYW 126 | EsGX6SBeKLbgpsIPsXz+ipsISTnhO8YDAkAuOJrb/QemyzMy76lCSeV2zXCubpYI 127 | llZvrqnRMJJlracxvP9n1bsFhc5gywmmM41XTmNBq3z66k5DGk0IOs0JAkBcZsYy 128 | 0NpDdm5bMZdcxCf36DmFBtuG0/CYlOtjOcZHWaLNJPwVC9WiZ5xOILACpP9erdT8 129 | 8zRDsBnGmGytxFhrAkEAlrmBpqG8yQyLnyls/R6CPbewf4iqy9utub2ZeiqjYMO4 130 | aXJie+gnu0Oc8WMQF4BFshD6Lr76QgwUcvjIraNJZw== 131 | -----END RSA PRIVATE KEY----- 132 | """.dedent.strip 133 | 134 | const SAMPLE_PUBLIC_KEY = """ 135 | -----BEGIN PUBLIC KEY----- 136 | MIGeMA0GCSqGSIb3DQEBAQUAA4GMADCBiAKBgH29pIKU/P8ELlA4ofzSiq4InGzd 137 | 45hxqE/vfqqUOP70Sa5R5s6WntYVz6x5Btp8uc2vwWcDg4gFDkyBJ9xShROaCrtv 138 | ncRIbJ5m3uka46yAObWYkKxP+e3AMud/8tu5DvnJRiPq9Nu0wbdWXePriajk/Nc4 139 | CQAl8tB1Ka1QWLhhAgMBAAE= 140 | -----END PUBLIC KEY----- 141 | """.dedent.strip 142 | 143 | test "authenticate with Buckets license": 144 | withinTmpDir: 145 | var rs = newRelayServer("test.db", pubkey = SAMPLE_PUBLIC_KEY) 146 | let license = createV1License(SAMPLE_PRIVATE_KEY, "jim@jim.com") 147 | checkpoint "license:" 148 | checkpoint $license 149 | let uid = rs.license_auth($license) 150 | let uid2 = rs.get_user_id("jim@jim.com") 151 | check uid == uid2 152 | check rs.is_email_verified(uid) == true 153 | check rs.can_use_relay(uid) == true 154 | let uid3 = rs.license_auth($license) 155 | check uid3 == uid2 156 | 157 | test "disable Buckets license": 158 | withinTmpDir: 159 | var rs = newRelayServer("test.db", pubkey = SAMPLE_PUBLIC_KEY) 160 | let license = createV1License(SAMPLE_PRIVATE_KEY, "jim@jim.com") 161 | checkpoint "first license" 162 | checkpoint $license 163 | let uid = rs.license_auth($license) 164 | rs.disable_most_recently_used_license(uid) 165 | expect WrongPassword: 166 | discard rs.license_auth($license) 167 | sleep(1001) 168 | let license2 = createV1License(SAMPLE_PRIVATE_KEY, "jim@jim.com") 169 | checkpoint "second license" 170 | checkpoint $license2 171 | let uid2 = rs.license_auth($license2) 172 | check uid == uid2 173 | 174 | test "auth w/ password, then license, should disable password": 175 | withinTmpDir: 176 | var rs = newRelayServer("test.db", pubkey = SAMPLE_PUBLIC_KEY) 177 | let uid = rs.register_user("jim@jim.com", "password") 178 | check rs.is_email_verified(uid) == false 179 | check rs.can_use_relay(uid) == false 180 | check rs.password_auth("jim@jim.com", "password") == uid 181 | expect WrongPassword: 182 | discard rs.password_auth("jim@jim.com", "something else") 183 | 184 | let license = createV1License(SAMPLE_PRIVATE_KEY, "jim@jim.com") 185 | let uid2 = rs.license_auth($license) 186 | check uid == uid2 187 | check rs.is_email_verified(uid2) == true 188 | check rs.can_use_relay(uid2) == true 189 | 190 | # The password is invalidated by the license authentication 191 | # to prevent preemptive account takeover. Without this, 192 | # an attacker could register a user, setting a password, 193 | # but not verify the email address. Then, when the user 194 | # authenticates with a license it counts that as 195 | # email verification and the password chosen by the attacker 196 | # would work. 197 | expect WrongPassword: 198 | discard rs.password_auth("jim@jim.com", "password") 199 | let t1 = rs.generate_password_reset_token("jim@jim.com") 200 | check rs.user_for_password_reset_token(t1).get() == uid 201 | rs.update_password_with_token(t1, "newpassword") 202 | 203 | # Now, both password and license auth work 204 | check rs.password_auth("jim@jim.com", "newpassword") == uid 205 | check rs.license_auth($license) == uid 206 | check rs.password_auth("jim@jim.com", "newpassword") == uid 207 | check rs.license_auth($license) == uid 208 | -------------------------------------------------------------------------------- /src/bucketsrelay/jwtrsaonly.nim: -------------------------------------------------------------------------------- 1 | # This code comes from https://github.com/yglukhov/nim-jwt 2 | # but with modifications to work with the version of BearSSL 3 | # included with this project and only support RSA256 JWTs. 4 | 5 | # The MIT License (MIT) 6 | 7 | # Copyright (c) 2017 Yuriy Glukhov 8 | 9 | # Permission is hereby granted, free of charge, to any person obtaining a copy 10 | # of this software and associated documentation files (the "Software"), to deal 11 | # in the Software without restriction, including without limitation the rights 12 | # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 13 | # copies of the Software, and to permit persons to whom the Software is 14 | # furnished to do so, subject to the following conditions: 15 | 16 | # The above copyright notice and this permission notice shall be included in all 17 | # copies or substantial portions of the Software. 18 | 19 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 20 | # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 21 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 22 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 23 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 24 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 25 | # SOFTWARE. 26 | 27 | import std/base64 28 | import std/json 29 | import std/strutils 30 | 31 | import bearssl 32 | import bearssl_pkey_decoder 33 | 34 | #-------------------------------------- 35 | # jwt/private/utils 36 | #-------------------------------------- 37 | 38 | proc encodeUrlSafe(s: openarray[byte]): string = 39 | when NimMajor >= 1 and (NimMinor >= 1 or NimPatch >= 2): 40 | result = base64.encode(s) 41 | else: 42 | result = base64.encode(s, newLine="") 43 | while result.endsWith("="): 44 | result.setLen(result.len - 1) 45 | result = result.replace('+', '-').replace('/', '_') 46 | 47 | proc encodeUrlSafe(s: openarray[char]): string {.inline.} = 48 | encodeUrlSafe(s.toOpenArrayByte(s.low, s.high)) 49 | 50 | proc decodeUrlSafeAsString(s: string): string = 51 | var s = s.replace('-', '+').replace('_', '/') 52 | while s.len mod 4 > 0: 53 | s &= "=" 54 | base64.decode(s) 55 | 56 | proc decodeUrlSafe(s: string): seq[byte] = 57 | cast[seq[byte]](decodeUrlSafeAsString(s)) 58 | 59 | #-------------------------------------- 60 | # jwt/private/jose 61 | #-------------------------------------- 62 | 63 | proc toBase64(j: JsonNode): string = 64 | encodeUrlSafe($j) 65 | 66 | #-------------------------------------- 67 | # jwt/crypto 68 | #-------------------------------------- 69 | 70 | # This pragma should be the same as in nim-bearssl/decls.nim 71 | {.pragma: bearSslFunc, cdecl, gcsafe, noSideEffect, raises: [].} 72 | 73 | #-------------------------------------- 74 | # Custom PEM-decoding 75 | #-------------------------------------- 76 | 77 | proc invalidPemKey() = 78 | raise newException(ValueError, "Invalid PEM encoding") 79 | 80 | proc pemDecoderLoop(pem: string, prc: proc(ctx: pointer, pbytes: pointer, nbytes: uint) {.bearSslFunc.}, ctx: pointer) = 81 | var pemCtx: PemDecoderContext 82 | pemDecoderInit(pemCtx) 83 | var length = len(pem) 84 | var offset = 0 85 | var inobj = false 86 | while length > 0: 87 | var tlen = pemDecoderPush(pemCtx, 88 | unsafeAddr pem[offset], length.uint).int 89 | offset = offset + tlen 90 | length = length - tlen 91 | 92 | let event = pemDecoderEvent(pemCtx) 93 | if event == PEM_BEGIN_OBJ: 94 | inobj = true 95 | pemDecoderSetdest(pemCtx, prc, ctx) 96 | elif event == PEM_END_OBJ: 97 | if inobj: 98 | inobj = false 99 | else: 100 | break 101 | elif event == 0 and length == 0: 102 | break 103 | else: 104 | invalidPemKey() 105 | 106 | proc decodeFromPem(skCtx: var SkeyDecoderContext, pem: string) = 107 | skeyDecoderInit(skCtx) 108 | pemDecoderLoop(pem, cast[proc(ctx: pointer, pbytes: pointer, nbytes: uint) {.bearSslFunc.}](skeyDecoderPush), addr skCtx) 109 | if skeyDecoderLastError(skCtx) != 0: invalidPemKey() 110 | 111 | proc decodeFromPem(pkCtx: var PkeyDecoderContext, pem: string) = 112 | pkeyDecoderInit(addr pkCtx) 113 | pemDecoderLoop(pem, cast[proc(ctx: pointer, pbytes: pointer, nbytes: uint) {.bearSslFunc.}](pkeyDecoderPush), addr pkCtx) 114 | if pkeyDecoderLastError(addr pkCtx) != 0: invalidPemKey() 115 | 116 | proc calcHash(alg: ptr HashClass, data: string, output: var array[64, byte]) = 117 | var ctx: array[512, byte] 118 | let pCtx = cast[ptr ptr HashClass](addr ctx[0]) 119 | assert(alg.contextSize <= sizeof(ctx).uint) 120 | alg.init(pCtx) 121 | if data.len > 0: 122 | alg.update(pCtx, unsafeAddr data[0], data.len.uint) 123 | alg.`out`(pCtx, addr output[0]) 124 | 125 | proc bearSignRSPem(data, key: string, alg: ptr HashClass, hashOid: cstring, hashLen: int): seq[byte] = 126 | # Step 1. Extract RSA key from `key` in PEM format 127 | var skCtx: SkeyDecoderContext 128 | decodeFromPem(skCtx, key) 129 | if skeyDecoderKeyType(skCtx) != KEYTYPE_RSA: 130 | invalidPemKey() 131 | 132 | template privateKey(): RsaPrivateKey = skCtx.key.rsa 133 | 134 | # Step 2. Hash! 135 | var digest: array[64, byte] 136 | calcHash(alg, data, digest) 137 | 138 | let sigLen = (privateKey.nBitlen + 7) div 8 139 | result = newSeqUninitialized[byte](sigLen) 140 | let s = rsaPkcs1SignGetDefault() 141 | assert(not s.isNil) 142 | if s(cast[ptr byte](hashOid), addr digest[0], hashLen.uint, addr privateKey, addr result[0]) != 1: 143 | raise newException(ValueError, "Could not sign") 144 | 145 | proc bearVerifyRSPem(data, key: string, sig: openarray[byte], alg: ptr HashClass, hashOid: cstring, hashLen: int): bool = 146 | # Step 1. Extract RSA key from `key` in PEM format 147 | var pkCtx: PkeyDecoderContext 148 | decodeFromPem(pkCtx, key) 149 | if pkeyDecoderKeyType(addr pkCtx) != KEYTYPE_RSA: 150 | invalidPemKey() 151 | template publicKey(): RsaPublicKey = pkCtx.key.rsa 152 | 153 | var digest: array[64, byte] 154 | calcHash(alg, data, digest) 155 | 156 | let s = rsaPkcs1VrfyGetDefault() 157 | var digest2: array[64, byte] 158 | 159 | if s(unsafeAddr sig[0], sig.len.uint, cast[ptr byte](hashOid), hashLen.uint, addr publicKey, addr digest2[0]) != 1: 160 | return false 161 | 162 | digest == digest2 163 | 164 | 165 | #-------------------------------------- 166 | # jwt main 167 | #-------------------------------------- 168 | 169 | type 170 | InvalidToken* = object of ValueError 171 | 172 | JWT* = object 173 | headerB64: string 174 | claimsB64: string 175 | header*: JsonNode 176 | claims*: JsonNode 177 | signature*: seq[byte] 178 | 179 | 180 | proc splitToken(s: string): seq[string] = 181 | let parts = s.split(".") 182 | if parts.len != 3: 183 | raise newException(InvalidToken, "Invalid token") 184 | result = parts 185 | 186 | proc initJWT*(header: JsonNode, claims: JsonNode, signature: seq[byte] = @[]): JWT = 187 | JWT( 188 | headerB64: header.toBase64, 189 | claimsB64: claims.toBase64, 190 | header: header, 191 | claims: claims, 192 | signature: signature 193 | ) 194 | 195 | # Load up a b64url string to JWT 196 | proc toJWT*(s: string): JWT = 197 | var parts = splitToken(s) 198 | let 199 | headerB64 = parts[0] 200 | claimsB64 = parts[1] 201 | headerJson = parseJson(decodeUrlSafeAsString(headerB64)) 202 | claimsJson = parseJson(decodeUrlSafeAsString(claimsB64)) 203 | signature = decodeUrlSafe(parts[2]) 204 | 205 | JWT( 206 | headerB64: headerB64, 207 | claimsB64: claimsB64, 208 | header: headerJson, 209 | claims: claimsJson, 210 | signature: signature 211 | ) 212 | 213 | proc toJWT*(node: JsonNode): JWT = 214 | initJWT(node["header"], node["claims"]) 215 | 216 | # Encodes the raw signature to b64url 217 | proc signatureToB64(token: JWT): string = 218 | assert token.signature.len != 0 219 | result = encodeUrlSafe(token.signature) 220 | 221 | proc loaded(token: JWT): string = 222 | token.headerB64 & "." & token.claimsB64 223 | 224 | proc parsed(token: JWT): string = 225 | result = token.header.toBase64 & "." & token.claims.toBase64 226 | 227 | # Signs a string with a secret 228 | proc signString(toSign: string, secret: string): seq[byte] = 229 | template rsSign(hc, oid: typed, hashLen: int): seq[byte] = 230 | bearSignRSPem(toSign, secret, addr hc, oid, hashLen) 231 | return rsSign(sha256Vtable, HASH_OID_SHA256, sha256SIZE) 232 | 233 | # Verify that the token is not tampered with 234 | proc verifySignature(data: string, signature: seq[byte], secret: string): bool = 235 | result = bearVerifyRSPem(data, secret, signature, addr sha256Vtable, HASH_OID_SHA256, sha256SIZE) 236 | 237 | proc sign*(token: var JWT, secret: string) = 238 | assert token.signature.len == 0 239 | token.signature = signString(token.parsed, secret) 240 | 241 | # Verify a token typically an incoming request 242 | proc verify*(token: JWT, secret: string): bool = 243 | verifySignature(token.loaded, token.signature, secret) 244 | 245 | proc toString(token: JWT): string = 246 | token.header.toBase64 & "." & token.claims.toBase64 & "." & token.signatureToB64 247 | 248 | proc `$`*(token: JWT): string = 249 | token.toString 250 | 251 | proc `%`*(token: JWT): JsonNode = 252 | let s = $token 253 | %s 254 | -------------------------------------------------------------------------------- /tests/tproto.nim: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2022 One Part Rain, LLC. All rights reserved. 2 | # 3 | # This work is licensed under the terms of the MIT license. 4 | # For a copy, see LICENSE.md in this repository. 5 | 6 | import unittest 7 | import os 8 | import options 9 | import tables 10 | import sets 11 | import logging 12 | 13 | import bucketsrelay/proto 14 | import libsodium/sodium 15 | import ./util 16 | 17 | type 18 | KeyPair = tuple 19 | pk: PublicKey 20 | sk: SecretKey 21 | StringClient = ref object 22 | id: int 23 | received: seq[RelayEvent] 24 | pk: PublicKey 25 | sk: SecretKey 26 | 27 | proc newClient(): StringClient = 28 | new(result) 29 | result.received = newSeq[RelayEvent]() 30 | 31 | proc popEvent(client: StringClient): RelayEvent = 32 | doAssert client.received.len > 0, "Expected an event" 33 | result = client.received[0] 34 | client.received.del(0) 35 | 36 | proc popEvent(client: StringClient, kind: EventKind): RelayEvent = 37 | result = client.popEvent() 38 | doAssert result.kind == kind, "Expected " & $kind & " but found " & $result 39 | 40 | proc sendEvent(client: StringClient, ev: RelayEvent) = 41 | client.received.add(ev) 42 | 43 | proc popEvent(conn: RelayConnection[StringClient]): RelayEvent = 44 | conn.sender.popEvent() 45 | 46 | proc popEvent(conn: RelayConnection[StringClient], kind: EventKind): RelayEvent = 47 | conn.sender.popEvent(kind) 48 | 49 | proc mkConnection(relay: var Relay, keys = none[KeyPair](), channel = ""): RelayConnection[StringClient] = 50 | var keys = keys 51 | if keys.isNone: 52 | keys = some(genkeys()) 53 | var client = newClient() 54 | client.pk = keys.get().pk 55 | client.sk = keys.get().sk 56 | var conn = relay.initAuth(client, channel = channel) 57 | let who = client.popEvent() 58 | let signature = sign(client.sk, who.who_challenge) 59 | relay.handleCommand(conn, RelayCommand(kind: Iam, iam_signature: signature, iam_pubkey: client.pk)) 60 | let ok = client.popEvent() 61 | result = conn 62 | 63 | template sendData*(relay: var Relay, src: RelayConnection, dst: PublicKey, data: string) = 64 | relay.handleCommand(src, RelayCommand(kind: SendData, send_data: data, dest_pubkey: dst)) 65 | 66 | test "basic": 67 | var relay = newRelay[StringClient]() 68 | let (pk, sk) = genkeys() 69 | var aclient = newClient() 70 | aclient.pk = pk 71 | aclient.sk = sk 72 | 73 | checkpoint "who?" 74 | var alice = relay.initAuth(aclient) 75 | let who = alice.popEvent() 76 | check who.kind == Who 77 | check who.who_challenge != "" 78 | 79 | checkpoint "iam" 80 | let signature = sign(sk, who.who_challenge) 81 | relay.handleCommand(alice, RelayCommand(kind: Iam, iam_signature: signature, iam_pubkey: pk)) 82 | let ok = alice.popEvent() 83 | check ok.kind == Authenticated 84 | 85 | checkpoint "connect" 86 | let bob = relay.mkConnection() 87 | check bob.pubkey != alice.pubkey 88 | relay.handleCommand(alice, RelayCommand(kind: Connect, conn_pubkey: bob.pubkey)) 89 | relay.handleCommand(bob, RelayCommand(kind: Connect, conn_pubkey: alice.pubkey)) 90 | block: 91 | let ev = bob.popEvent() 92 | check ev.kind == Connected 93 | check ev.conn_pubkey == alice.pubkey 94 | 95 | block: 96 | let ev = alice.popEvent() 97 | check ev.kind == Connected 98 | check ev.conn_pubkey == bob.pubkey 99 | 100 | checkpoint "data" 101 | relay.handleCommand(bob, RelayCommand(kind: SendData, send_data: "hello, alice!", dest_pubkey: alice.pubkey)) 102 | let adata = alice.popEvent() 103 | check adata.kind == Data 104 | check adata.data == "hello, alice!" 105 | check adata.sender_pubkey == bob.pubkey 106 | 107 | relay.handleCommand(alice, RelayCommand(kind: SendData, send_data: "hello, bob!", dest_pubkey: bob.pubkey)) 108 | let bdata = bob.popEvent() 109 | check bdata.kind == Data 110 | check bdata.data == "hello, bob!" 111 | check bdata.sender_pubkey == alice.pubkey 112 | 113 | test "multiple conns to same pubkey": 114 | var relay = newRelay[StringClient]() 115 | var alice = relay.mkConnection() 116 | var bob = relay.mkConnection() 117 | relay.handleCommand(alice, RelayCommand(kind: Connect, conn_pubkey: bob.pubkey)) 118 | relay.handleCommand(bob, RelayCommand(kind: Connect, conn_pubkey: alice.pubkey)) 119 | discard alice.popEvent(Connected) 120 | discard bob.popEvent(Connected) 121 | relay.handleCommand(bob, RelayCommand(kind: Connect, conn_pubkey: alice.pubkey)) 122 | check bob.sender.received.len == 0 123 | check alice.sender.received.len == 0 124 | 125 | test "no crosstalk": 126 | var relay = newRelay[StringClient]() 127 | var alice = relay.mkConnection() 128 | var bob = relay.mkConnection() 129 | var cathy = relay.mkConnection() 130 | var dave = relay.mkConnection() 131 | relay.handleCommand(alice, RelayCommand(kind: Connect, conn_pubkey: bob.pubkey)) 132 | relay.handleCommand(bob, RelayCommand(kind: Connect, conn_pubkey: alice.pubkey)) 133 | discard alice.popEvent(Connected) 134 | discard bob.popEvent(Connected) 135 | check cathy.sender.received.len == 0 136 | check dave.sender.received.len == 0 137 | relay.handleCommand(alice, RelayCommand(kind: Connect, conn_pubkey: dave.pubkey)) 138 | relay.handleCommand(dave, RelayCommand(kind: Connect, conn_pubkey: alice.pubkey)) 139 | discard alice.popEvent(Connected) 140 | discard dave.popEvent(Connected) 141 | relay.sendData(alice, bob.pubkey, "hi, bob") 142 | check bob.popEvent(Data).data == "hi, bob" 143 | check cathy.sender.received.len == 0 144 | check dave.sender.received.len == 0 145 | 146 | test "disconnect multiple times": 147 | var relay = newRelay[StringClient]() 148 | var alice = relay.mkConnection() 149 | relay.removeConnection(alice) 150 | relay.removeConnection(alice) 151 | 152 | test "disconnect, remove from remote client.connections": 153 | var relay = newRelay[StringClient]() 154 | var alice = relay.mkConnection() 155 | var bob = relay.mkConnection() 156 | relay.handleCommand(alice, RelayCommand(kind: Connect, conn_pubkey: bob.pubkey)) 157 | relay.handleCommand(bob, RelayCommand(kind: Connect, conn_pubkey: alice.pubkey)) 158 | discard alice.popEvent(Connected) 159 | discard bob.popEvent(Connected) 160 | relay.removeConnection(alice) 161 | let edcon = bob.popEvent(Disconnected) 162 | check edcon.dcon_pubkey == alice.pubkey 163 | let bobclient = relay.testmode_conns()[bob.pubkey] 164 | check bobclient.testmode_conns.len == 0 165 | 166 | test "send data to invalid id": 167 | var relay = newRelay[StringClient]() 168 | var alice = relay.mkConnection() 169 | relay.sendData(alice, "goober".PublicKey, "testing?") 170 | discard alice.popEvent(ErrorEvent) 171 | relay.sendData(alice, alice.pubkey, "feedback") 172 | discard alice.popEvent(ErrorEvent) 173 | 174 | test "send data to unconnected id": 175 | var relay = newRelay[StringClient]() 176 | var alice = relay.mkConnection() 177 | var bob = relay.mkConnection() 178 | relay.sendData(alice, bob.pubkey, "hello") 179 | discard alice.popEvent(ErrorEvent) 180 | check bob.sender.received.len == 0 181 | 182 | test "connect to self": 183 | var relay = newRelay[StringClient]() 184 | var alice = relay.mkConnection() 185 | relay.handleCommand(alice, RelayCommand(kind: Connect, conn_pubkey: alice.pubkey)) 186 | discard alice.popEvent(ErrorEvent) 187 | 188 | test "not authenticated": 189 | var relay = newRelay[StringClient]() 190 | let (pk, sk) = genkeys() 191 | let aclient = newClient() 192 | 193 | checkpoint "who?" 194 | var alice = relay.initAuth(aclient) 195 | discard alice.popEvent(Who) 196 | 197 | let bob = relay.mkConnection() 198 | 199 | checkpoint "connect" 200 | relay.handleCommand(alice, RelayCommand(kind: Connect, conn_pubkey: bob.pubkey)) 201 | discard alice.popEvent(ErrorEvent) 202 | check bob.sender.received.len == 0 203 | 204 | checkpoint "send" 205 | relay.sendData(alice, bob.pubkey, "something") 206 | discard alice.popEvent(ErrorEvent) 207 | check bob.sender.received.len == 0 208 | 209 | test "disconnect command": 210 | var relay = newRelay[StringClient]() 211 | var alice = relay.mkConnection() 212 | var bob = relay.mkConnection() 213 | relay.handleCommand(alice, RelayCommand(kind: Connect, conn_pubkey: bob.pubkey)) 214 | relay.handleCommand(bob, RelayCommand(kind: Connect, conn_pubkey: alice.pubkey)) 215 | discard alice.popEvent(Connected) 216 | discard bob.popEvent(Connected) 217 | 218 | relay.handleCommand(alice, RelayCommand(kind: Disconnect, dcon_pubkey: bob.pubkey)) 219 | check bob.popEvent(Disconnected).dcon_pubkey == alice.pubkey 220 | check alice.popEvent(Disconnected).dcon_pubkey == bob.pubkey 221 | 222 | test "remember connection requests": 223 | var relay = newRelay[StringClient]() 224 | var alice = relay.mkConnection() 225 | var bob = relay.mkConnection() 226 | relay.handleCommand(alice, RelayCommand(kind: Connect, conn_pubkey: bob.pubkey)) 227 | relay.handleCommand(bob, RelayCommand(kind: Connect, conn_pubkey: alice.pubkey)) 228 | discard alice.popEvent(Connected) 229 | discard bob.popEvent(Connected) 230 | 231 | relay.handleCommand(alice, RelayCommand(kind: Disconnect, dcon_pubkey: bob.pubkey)) 232 | check bob.popEvent(Disconnected).dcon_pubkey == alice.pubkey 233 | check alice.popEvent(Disconnected).dcon_pubkey == bob.pubkey 234 | 235 | relay.handleCommand(alice, RelayCommand(kind: Connect, conn_pubkey: bob.pubkey)) 236 | discard alice.popEvent(Connected) 237 | discard bob.popEvent(Connected) 238 | 239 | test "forget connection requests on disconnect": 240 | var relay = newRelay[StringClient]() 241 | var alice = relay.mkConnection() 242 | var bob = relay.mkConnection() 243 | relay.handleCommand(alice, RelayCommand(kind: Connect, conn_pubkey: bob.pubkey)) 244 | relay.handleCommand(bob, RelayCommand(kind: Connect, conn_pubkey: alice.pubkey)) 245 | discard alice.popEvent(Connected) 246 | discard bob.popEvent(Connected) 247 | 248 | relay.handleCommand(alice, RelayCommand(kind: Disconnect, dcon_pubkey: bob.pubkey)) 249 | check bob.popEvent(Disconnected).dcon_pubkey == alice.pubkey 250 | check alice.popEvent(Disconnected).dcon_pubkey == bob.pubkey 251 | 252 | relay.handleCommand(bob, RelayCommand(kind: Disconnect, dcon_pubkey: alice.pubkey)) 253 | relay.handleCommand(alice, RelayCommand(kind: Connect, conn_pubkey: bob.pubkey)) 254 | relay.sendData(alice, bob.pubkey, "something") 255 | discard alice.popEvent(ErrorEvent) 256 | check bob.sender.received.len == 0 257 | 258 | test "pub/sub": 259 | var relay = newRelay[StringClient]() 260 | var alice = relay.mkConnection(channel = "alicenbob") 261 | var bob = relay.mkConnection(channel = "alicenbob") 262 | block: 263 | let ev = alice.popEvent(Entered) 264 | check ev.entered_pubkey == bob.pubkey 265 | block: 266 | let ev = bob.popEvent(Entered) 267 | check ev.entered_pubkey == alice.pubkey 268 | relay.removeConnection(alice) 269 | block: 270 | let ev = bob.popEvent(Exited) 271 | check ev.exited_pubkey == alice.pubkey 272 | -------------------------------------------------------------------------------- /src/partials/index.mustache: -------------------------------------------------------------------------------- 1 | 2 | 3 | Buckets Relay 4 | 5 | 98 | 99 | 100 |
101 |

102 | 103 | Buckets Relay 104 |

105 | 106 |

107 | If you use Buckets, this relay lets you securely share your budget among your devices. This relay doesn't store any budget info. Instead, think of it like a satellite in the sky that can bounce your data from your computer to your phone. 108 |

109 | 110 |

111 | Use of this service may be revoked at any time for any reason. 112 |

113 | 114 |

115 | The code for this is Open Source if you'd like to run your own instance. 116 |

117 | 118 | {{#openregistration}} 119 |
120 |

Register

121 |
122 | 123 | 124 |
125 | 126 |
127 | 128 | 129 |
130 | 131 | 132 |
133 |
134 | 135 |
136 |

Verify email address

137 |
138 | 139 | 140 |
141 | 142 |
143 | 144 | 145 |
146 | 147 | 148 |
149 |
150 | If you didn't receive a verification code, you can 151 | 152 |
153 | 154 |
155 |

Forgot password?

156 |
157 | 158 | 159 |
160 | 161 | 162 |
163 |
164 | 165 |
166 |

Change password

167 |
168 | 169 | 170 |
171 |
172 | 173 | 174 |
175 | 176 | 177 |
178 |
179 | {{/openregistration}} 180 | 181 |
182 | {{#openregistration}} 183 | 382 | {{/openregistration}} 383 | 384 | -------------------------------------------------------------------------------- /src/bucketsrelay/proto.nim: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2022 One Part Rain, LLC. All rights reserved. 2 | # 3 | # This work is licensed under the terms of the MIT license. 4 | # For a copy, see LICENSE.md in this repository. 5 | 6 | import std/base64 7 | import std/hashes 8 | import std/logging 9 | import std/options 10 | import std/sets; export sets 11 | import std/strformat 12 | import std/strutils 13 | import std/tables 14 | 15 | import ./common 16 | 17 | import libsodium/sodium 18 | import ndb/sqlite 19 | 20 | template TODO*(msg: string) = 21 | when defined(release): 22 | {.error: msg .} 23 | 24 | type 25 | PublicKey* = distinct string 26 | SecretKey* = distinct string 27 | 28 | KeyPair* = tuple 29 | pk: PublicKey 30 | sk: SecretKey 31 | 32 | ## Relay event types 33 | EventKind* = enum 34 | Who = "?" 35 | Authenticated = "+" 36 | Connected = "c" 37 | Disconnected = "x" 38 | Data = "d" 39 | Entered = ">" 40 | Exited = "^" 41 | ErrorEvent = "E" 42 | 43 | ## RelayEvent error types 44 | ErrorCode* = enum 45 | Generic = 0 46 | DestNotPresent 47 | 48 | ## Relay events -- server to client message 49 | RelayEvent* = object 50 | case kind*: EventKind 51 | of Who: 52 | who_challenge*: string 53 | of Authenticated: 54 | discard 55 | of Connected: 56 | conn_pubkey*: PublicKey 57 | of Disconnected: 58 | dcon_pubkey*: PublicKey 59 | of Data: 60 | data*: string 61 | sender_pubkey*: PublicKey 62 | of Entered: 63 | entered_pubkey*: PublicKey 64 | of Exited: 65 | exited_pubkey*: PublicKey 66 | of ErrorEvent: 67 | err_code*: ErrorCode 68 | err_message*: string 69 | 70 | ## Relay command types 71 | CommandKind* = enum 72 | Iam = "i" 73 | Connect = "c" 74 | Disconnect = "x" 75 | SendData = "d" 76 | 77 | ## Relay command - client to server message 78 | RelayCommand* = object 79 | case kind*: CommandKind 80 | of Iam: 81 | iam_signature*: string 82 | iam_pubkey*: PublicKey 83 | of Connect: 84 | conn_pubkey*: PublicKey 85 | of Disconnect: 86 | dcon_pubkey*: PublicKey 87 | of SendData: 88 | send_data*: string 89 | dest_pubkey*: PublicKey 90 | 91 | RelayConnection*[T] = ref object 92 | challenge: string 93 | pubkey*: PublicKey 94 | channel*: string 95 | peer_connections: HashSet[PublicKey] 96 | sender*: T 97 | 98 | Relay*[T] = ref object 99 | conns: TableRef[PublicKey, RelayConnection[T]] 100 | channels: TableRef[string, HashSet[PublicKey]] 101 | conn_requests: TableRef[PublicKey, HashSet[PublicKey]] 102 | db: DbConn 103 | 104 | RelayErr* = object of CatchableError 105 | 106 | proc newRelay*[T](): Relay[T] = 107 | new(result) 108 | result.conns = newTable[PublicKey, RelayConnection[T]]() 109 | result.channels = newTable[string, HashSet[PublicKey]]() 110 | result.conn_requests = newTable[PublicKey, HashSet[PublicKey]]() 111 | 112 | proc `$`*(a: PublicKey): string = 113 | a.string.encode() 114 | 115 | proc abbr*(s: string, size = 6): string = 116 | if s.len > size: 117 | result.add s.substr(0, size) & "..." 118 | else: 119 | result.add(s) 120 | 121 | proc abbr*(a: PublicKey): string = 122 | a.string.encode().abbr 123 | 124 | proc `$`*(conn: RelayConnection): string = 125 | result.add "[RConn " 126 | if conn.pubkey.string == "": 127 | result.add "----------" 128 | else: 129 | result.add conn.pubkey.abbr 130 | result.add "]" 131 | 132 | proc `==`*(a, b: PublicKey): bool {.borrow.} 133 | 134 | proc hash*(p: PublicKey): Hash {.borrow.} 135 | 136 | proc `==`*(a, b: RelayEvent): bool = 137 | if a.kind != b.kind: 138 | return false 139 | else: 140 | case a.kind 141 | of Who: 142 | return a.who_challenge == b.who_challenge 143 | of Authenticated: 144 | return true 145 | of Connected: 146 | return a.conn_pubkey == b.conn_pubkey 147 | of Disconnected: 148 | return a.dcon_pubkey == b.dcon_pubkey 149 | of Data: 150 | return a.sender_pubkey == b.sender_pubkey and a.data == b.data 151 | of Entered: 152 | return a.entered_pubkey == b.entered_pubkey 153 | of Exited: 154 | return a.exited_pubkey == b.exited_pubkey 155 | of ErrorEvent: 156 | return a.err_message == b.err_message 157 | 158 | proc `==`*(a, b: RelayCommand): bool = 159 | if a.kind != b.kind: 160 | return false 161 | else: 162 | case a.kind: 163 | of Iam: 164 | return a.iam_signature == b.iam_signature and a.iam_pubkey == b.iam_pubkey 165 | of Connect: 166 | return a.conn_pubkey == b.conn_pubkey 167 | of Disconnect: 168 | return a.dcon_pubkey == b.dcon_pubkey 169 | of SendData: 170 | return a.send_data == b.send_data and a.dest_pubkey == b.dest_pubkey 171 | 172 | proc `$`*(ev: RelayEvent): string = 173 | result.add "(" 174 | case ev.kind 175 | of Who: 176 | result.add "Who challenge=" & ev.who_challenge.encode().abbr 177 | of Authenticated: 178 | result.add "Authenticated" 179 | of Connected: 180 | result.add "Connected " & ev.conn_pubkey.abbr 181 | of Disconnected: 182 | result.add "Disconnected " & ev.dcon_pubkey.abbr 183 | of Data: 184 | result.add "Data " & ev.sender_pubkey.abbr & " data=" & $ev.data.len 185 | of Entered: 186 | result.add "Entered " & ev.entered_pubkey.abbr 187 | of Exited: 188 | result.add "Exited " & ev.exited_pubkey.abbr 189 | of ErrorEvent: 190 | result.add "Error " & ev.err_message 191 | result.add ")" 192 | 193 | template dbg*(ev: RelayEvent): string = $ev 194 | 195 | proc `$`*(cmd: RelayCommand): string = 196 | result.add "(" 197 | case cmd.kind 198 | of Iam: 199 | result.add &"Iam {cmd.iam_pubkey.abbr} sig={cmd.iam_signature.encode.abbr}" 200 | of Connect: 201 | result.add &"Connect {cmd.conn_pubkey.abbr}" 202 | of Disconnect: 203 | result.add &"Disconnect {cmd.dcon_pubkey.abbr}" 204 | of SendData: 205 | result.add &"SendData {cmd.dest_pubkey.abbr} data={cmd.send_data.len}" 206 | result.add ")" 207 | 208 | template dbg*(cmd: RelayCommand): string = $cmd 209 | 210 | when defined(testmode): 211 | # proc dump*(relay: Relay): string = 212 | # for row in relay.db.getAllRows(sql"SELECT * FROM clients"): 213 | # result.add $row & "\l" 214 | # for row in relay.db.getAllRows(sql"SELECT * FROM pending_conns"): 215 | # result.add $row & "\l" 216 | 217 | proc testmode_conns*[T](relay: Relay[T]): TableRef[PublicKey, RelayConnection[T]] = 218 | relay.conns 219 | 220 | proc testmode_conns*(conn: RelayConnection): HashSet[PublicKey] = 221 | conn.peer_connections 222 | 223 | proc newRelayConnection*[T](sender: T): RelayConnection[T] = 224 | new(result) 225 | result.sender = sender 226 | result.peer_connections = initHashSet[PublicKey]() 227 | 228 | template sendEvent(conn: RelayConnection, ev: RelayEvent) = 229 | case ev.kind 230 | of Data: 231 | when relayverbose: 232 | debug $conn & "< " & ev.dbg 233 | else: 234 | discard 235 | else: 236 | debug $conn & "< " & ev.dbg 237 | conn.sender.sendEvent(ev) 238 | 239 | template sendError(conn: RelayConnection, message: string) = 240 | debug $conn & "< error: " & message 241 | conn.sender.sendEvent(RelayEvent( 242 | kind: ErrorEvent, 243 | err_message: message, 244 | )) 245 | 246 | proc initAuth*[T](relay: var Relay[T], client: T, channel = ""): RelayConnection[T] = 247 | ## Ask the client to authenticate itself. After it succeeds, it will 248 | ## be added as a connected client. 249 | ## If channel is provided, this is the channel to which this client 250 | ## will be subscribed for Entered/Exited events. 251 | var conn = newRelayConnection[T](client) 252 | conn.challenge = randombytes(32) 253 | conn.channel = channel 254 | conn.sendEvent(RelayEvent( 255 | kind: Who, 256 | who_challenge: conn.challenge, 257 | )) 258 | return conn 259 | 260 | proc connectPair[T](a, b: var RelayConnection[T]) = 261 | ## Connect two clients together 262 | a.peer_connections.incl(b.pubkey) 263 | b.peer_connections.incl(a.pubkey) 264 | a.sendEvent(RelayEvent(kind: Connected, conn_pubkey: b.pubkey)) 265 | b.sendEvent(RelayEvent(kind: Connected, conn_pubkey: a.pubkey)) 266 | 267 | proc addConnRequest(relay: var Relay, alice_pubkey: PublicKey, bob_pubkey: PublicKey) = 268 | ## Add or fulfil a connection request from alice to bob 269 | var alice = relay.conns[alice_pubkey] 270 | relay.conn_requests.mgetOrPut(alice_pubkey, initHashSet[PublicKey]()).incl(bob_pubkey) 271 | if bob_pubkey in alice.peer_connections: 272 | # They're already connected 273 | return 274 | var bob_requests = relay.conn_requests.getOrDefault(bob_pubkey, initHashSet[PublicKey]()) 275 | if alice_pubkey in bob_requests: 276 | # They both want to connect! 277 | var bob = relay.conns[bob_pubkey] 278 | connectPair(alice, bob) 279 | 280 | proc removeConnRequest(relay: var Relay, alice_pubkey: PublicKey, bob_pubkey: PublicKey) = 281 | ## Remove a connection request from alice to bob 282 | relay.conn_requests.mgetOrPut(alice_pubkey, initHashSet[PublicKey]()).excl(bob_pubkey) 283 | 284 | proc removeConnection*[T](relay: var Relay[T], conn: RelayConnection[T]) = 285 | ## Remove a conn from the relay if it exists. 286 | if conn.pubkey in relay.conn_requests: 287 | relay.conn_requests.del(conn.pubkey) 288 | # disconnect all peer connections 289 | var commands: seq[RelayCommand] 290 | for other_pubkey in conn.peer_connections: 291 | commands.add(RelayCommand( 292 | kind: Disconnect, 293 | dcon_pubkey: other_pubkey, 294 | )) 295 | for command in commands: 296 | relay.handleCommand(conn, command) 297 | # notify the channel (if any) 298 | if conn.channel != "": 299 | relay.channels.mgetOrPut(conn.channel, initHashSet[PublicKey]()).excl(conn.pubkey) 300 | for other in relay.channels[conn.channel].items: 301 | if other in relay.conns: 302 | relay.conns[other].sendEvent(RelayEvent( 303 | kind: Exited, 304 | exited_pubkey: conn.pubkey, 305 | )) 306 | # remove it from the registry 307 | if conn.pubkey in relay.conns: 308 | relay.conns.del(conn.pubkey) 309 | debug &"{conn} gone" 310 | 311 | proc handleCommand*[T](relay: var Relay[T], conn: RelayConnection[T], command: RelayCommand) = 312 | case command.kind 313 | of SendData: 314 | when defined(verbose): 315 | debug &"{conn} > {command.dbg}" 316 | else: 317 | discard 318 | else: 319 | debug &"{conn} > {command.dbg}" 320 | case command.kind 321 | of Iam: 322 | if conn.challenge == "": 323 | conn.sendError "Authentication cannot proceed. Reconnect and try again." 324 | try: 325 | crypto_sign_verify_detached(command.iam_pubkey.string, conn.challenge, command.iam_signature) 326 | except: 327 | conn.challenge = "" # disable authentication 328 | conn.sendError "Invalid signature" 329 | return 330 | conn.pubkey = command.iam_pubkey 331 | if conn.pubkey in relay.conns: 332 | # this pubkey is already connected; boot the old conn 333 | relay.removeConnection(relay.conns[conn.pubkey]) 334 | relay.conns[conn.pubkey] = conn 335 | conn.sendEvent(RelayEvent( 336 | kind: Authenticated, 337 | )) 338 | if conn.channel != "": 339 | relay.channels.mgetOrPut(conn.channel, initHashSet[PublicKey]()).incl(conn.pubkey) 340 | for other in relay.channels[conn.channel].items: 341 | if other != conn.pubkey: 342 | conn.sendEvent(RelayEvent( 343 | kind: Entered, 344 | entered_pubkey: other, 345 | )) 346 | if other in relay.conns: 347 | relay.conns[other].sendEvent(RelayEvent( 348 | kind: Entered, 349 | entered_pubkey: conn.pubkey, 350 | )) 351 | of Connect: 352 | if conn.pubkey.string == "": 353 | conn.sendError "Connection forbidden" 354 | elif command.conn_pubkey.string == conn.pubkey.string: 355 | conn.sendError "Can't connect to self" 356 | else: 357 | relay.addConnRequest(conn.pubkey, command.conn_pubkey) 358 | of Disconnect: 359 | relay.removeConnRequest(conn.pubkey, command.dcon_pubkey) 360 | if command.dcon_pubkey in conn.peer_connections: 361 | if command.dcon_pubkey in relay.conns: 362 | var other = relay.conns[command.dcon_pubkey] 363 | # disassociate 364 | other.peer_connections.excl(conn.pubkey) 365 | conn.peer_connections.excl(other.pubkey) 366 | # notify 367 | conn.sendEvent(RelayEvent( 368 | kind: Disconnected, 369 | dcon_pubkey: other.pubkey, 370 | )) 371 | other.sendEvent(RelayEvent( 372 | kind: Disconnected, 373 | dcon_pubkey: conn.pubkey, 374 | )) 375 | of SendData: 376 | if conn.pubkey.string == "": 377 | conn.sendError "Sending forbidden" 378 | elif command.dest_pubkey notin conn.peer_connections: 379 | conn.sendError "No such connection" 380 | else: 381 | if command.dest_pubkey notin relay.conns: 382 | conn.sendEvent(RelayEvent( 383 | kind: ErrorEvent, 384 | err_message: "Other side disconnected", 385 | )) 386 | else: 387 | let remote = relay.conns[command.dest_pubkey] 388 | remote.sendEvent(RelayEvent( 389 | kind: Data, 390 | sender_pubkey: conn.pubkey, 391 | data: command.send_data, 392 | )) 393 | 394 | #------------------------------------------------------------ 395 | # utilities 396 | #------------------------------------------------------------ 397 | proc genkeys*(): KeyPair = 398 | let (pk, sk) = crypto_sign_keypair() 399 | result = (pk.PublicKey, sk.SecretKey) 400 | 401 | proc sign*(key: SecretKey, message: string): string = 402 | ## Sign a message with the given secret key 403 | result = crypto_sign_detached(key.string, message) 404 | -------------------------------------------------------------------------------- /src/bucketsrelay/server.nim: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2022 One Part Rain, LLC. All rights reserved. 2 | # 3 | # This work is licensed under the terms of the MIT license. 4 | # For a copy, see LICENSE.md in this repository. 5 | 6 | import std/base64 7 | import std/json 8 | import std/logging 9 | import std/mimetypes 10 | import std/options; export options 11 | import std/os 12 | import std/sha1 13 | import std/sqlite3 14 | import std/strformat 15 | import std/strutils 16 | import std/tables 17 | 18 | import chronicles except debug, info, warn, error 19 | import chronos 20 | import httputils 21 | import libsodium/sodium 22 | import mustache 23 | import ndb/sqlite 24 | import stew/byteutils 25 | import websock/extensions/compression/deflate 26 | import websock/websock 27 | 28 | import ./common 29 | import ./dbschema 30 | import ./netstring 31 | import ./proto 32 | import ./stringproto 33 | import ./mailer 34 | import ./licenses 35 | 36 | type 37 | WSClient = ref object 38 | debugname*: string 39 | ws: WSSession 40 | user_id: int64 41 | ip: string 42 | relayserver: RelayServer 43 | eventQueue: AsyncQueue[RelayEvent] 44 | 45 | RelayHttpServer = ref object 46 | case tls: bool 47 | of true: 48 | httpsServer: TlsHttpServer 49 | of false: 50 | httpServer: HttpServer 51 | 52 | RelayServer* = ref object 53 | debugname*: string 54 | nextid: int 55 | relay: Relay[WSClient] 56 | http: RelayHttpServer 57 | mcontext*: proc(): mustache.Context 58 | longrunservices: seq[Future[void]] 59 | runningRequests: TableRef[int, HttpRequest] 60 | when multiusermode: 61 | pubkey*: string 62 | dbfilename: string 63 | updateSchema: bool 64 | userdb: Option[DbConn] 65 | elif singleusermode: 66 | usernameHash: string 67 | passwordHash: string 68 | 69 | NotFound* = object of CatchableError 70 | WrongPassword* = object of CatchableError 71 | DuplicateUser* = object of CatchableError 72 | 73 | const 74 | partialsDir = currentSourcePath.parentDir.parentDir / "partials" 75 | staticDir = currentSourcePath.parentDir.parentDir / "static" 76 | 77 | when multiusermode: 78 | let 79 | AUTH_LICENSE_PUBKEY* = getEnv("AUTH_LICENSE_PUBKEY", "") 80 | LICENSE_HASH_SALT = getEnv("LICENSE_HASH_SALT", "yououghttochangethis") 81 | 82 | const versionSupport = static: 83 | var jnode = %* { 84 | "versions": [], 85 | } 86 | var authMethods = %* ["usernamepassword"] 87 | when multiusermode: 88 | authMethods.add newJString("v1license") 89 | jnode["versions"].add(%* { 90 | "version": "1", 91 | "authMethods": authMethods, 92 | }) 93 | $jnode 94 | 95 | var mimedb = newMimetypes() 96 | 97 | when defined(release) or defined(embedassets): 98 | # embed templates and static data 99 | const partialsData = static: 100 | var tab = initTable[string, string]() 101 | echo "Embedding templates from ", partialsDir 102 | for item in walkDir(partialsDir): 103 | if item.kind == pcFile: 104 | let 105 | parts = item.path.splitFile 106 | name = parts.name 107 | echo " + ", name, ": ", item.path 108 | tab[name] = slurp(item.path) 109 | tab 110 | proc addDefaultContext*(c: var Context) = 111 | c.searchTable(partialsData) 112 | 113 | const staticData = static: 114 | var tab = initTable[string, string]() 115 | echo "Embedding static data from ", staticDir 116 | for item in walkDir(staticDir): 117 | if item.kind == pcFile: 118 | let name = "/" & item.path.extractFilename 119 | echo " + ", name, ": ", item.path 120 | tab[name] = slurp(item.path) 121 | tab 122 | 123 | template readStaticFile(path: string): string = 124 | staticData[path] 125 | else: 126 | # read templates and static data from disk 127 | proc addDefaultContext*(c: var Context) = 128 | c.searchDirs(@[partialsDir]) 129 | 130 | proc readStaticFile(path: string): string = 131 | let fullpath = normalizedPath(staticDir / path) 132 | if fullpath.isRelativeTo(staticDir) and fullpath.fileExists(): 133 | readFile(fullpath) 134 | else: 135 | raise NotFound.newException("No such file: " & path) 136 | 137 | template logname*(rs: RelayServer): string = 138 | "(" & rs.debugname & ") " 139 | 140 | proc start*(rhs: RelayHttpServer) = 141 | case rhs.tls 142 | of true: 143 | rhs.httpsServer.start() 144 | of false: 145 | rhs.httpServer.start() 146 | 147 | proc stop*(rhs: RelayHttpServer) = 148 | case rhs.tls 149 | of true: 150 | rhs.httpsServer.stop() 151 | of false: 152 | rhs.httpServer.stop() 153 | 154 | proc close*(rhs: RelayHttpServer) = 155 | case rhs.tls 156 | of true: 157 | rhs.httpsServer.close() 158 | of false: 159 | rhs.httpServer.close() 160 | 161 | proc join*(rhs: RelayHttpServer): Future[void] = 162 | case rhs.tls 163 | of true: 164 | rhs.httpsServer.join() 165 | of false: 166 | rhs.httpServer.join() 167 | 168 | proc `handler=`*(rhs: RelayHttpServer, handler: HttpAsyncCallback) = 169 | case rhs.tls 170 | of true: 171 | rhs.httpsServer.handler = handler 172 | of false: 173 | rhs.httpServer.handler = handler 174 | 175 | #------------------------------------------------------------- 176 | # netstrings 177 | #------------------------------------------------------------- 178 | const 179 | COLONCHAR = ':' 180 | TERMINALCHAR = ',' 181 | DEFAULTMAXLEN = 1_000_000 182 | 183 | proc nsencode*(msg:string, terminalChar = TERMINALCHAR):string {.inline.} = 184 | $msg.len & COLONCHAR & msg & terminalChar 185 | 186 | #------------------------------------------------------------- 187 | # User management 188 | #------------------------------------------------------------- 189 | type 190 | LowerString* = distinct string 191 | 192 | converter toLowercase*(s: string): LowerString = s.toLower().LowerString 193 | converter toString*(s: LowerString): string = s.string 194 | 195 | const userdbSchema = [ 196 | ("initial", @[ 197 | """CREATE TABLE IF NOT EXISTS iplog ( 198 | day TEXT NOT NULL, 199 | ip TEXT NOT NULL, 200 | bytes_sent INT DEFAULT 0, 201 | bytes_recv INT DEFAULT 0, 202 | PRIMARY KEY (day, ip) 203 | )""", 204 | """CREATE TABLE IF NOT EXISTS user ( 205 | id INTEGER PRIMARY KEY, 206 | created TIMESTAMP DEFAULT CURRENT_TIMESTAMP, 207 | email TEXT NOT NULL, 208 | pwhash TEXT NOT NULL, 209 | emailverified TINYINT DEFAULT 0, 210 | blocked TINYINT DEFAULT 0, 211 | recentlicensehash TEXT DEFAULT '', 212 | UNIQUE(email) 213 | )""", 214 | """CREATE TABLE IF NOT EXISTS userlog ( 215 | day TEXT NOT NULL, 216 | user_id INTEGER, 217 | bytes_sent INT DEFAULT 0, 218 | bytes_recv INT DEFAULT 0, 219 | PRIMARY KEY (day, user_id), 220 | FOREIGN KEY (user_id) REFERENCES user(id) 221 | )""", 222 | """CREATE TABLE IF NOT EXISTS emailtoken ( 223 | id INTEGER PRIMARY KEY, 224 | expires TIMESTAMP DEFAULT (datetime('now', '+1 hour')), 225 | user_id INTEGER NOT NULL, 226 | token TEXT, 227 | FOREIGN KEY (user_id) REFERENCES user(id) 228 | )""", 229 | """CREATE TABLE IF NOT EXISTS pwreset ( 230 | id INTEGER PRIMARY KEY, 231 | expires TIMESTAMP DEFAULT (datetime('now', '+1 hour')), 232 | user_id INTEGER NOT NULL, 233 | token TEXT, 234 | FOREIGN KEY (user_id) REFERENCES user(id) 235 | )""", 236 | """CREATE TABLE IF NOT EXISTS disabledlicense ( 237 | licensehash TEXT PRIMARY KEY 238 | )""", 239 | ]) 240 | ] 241 | 242 | template boolVal*(d: DbValue): bool = 243 | d.i == 1 244 | 245 | proc db*(rs: RelayServer): DbConn {.multiuseronly.} = 246 | ## Get the user-data database for this server 247 | if not rs.userdb.isSome: 248 | var db = open(rs.dbfilename, "", "", "") 249 | discard db.busy_timeout(1000) 250 | db.exec(sql"PRAGMA foreign_keys = ON") 251 | if rs.updateSchema: 252 | db.upgradeSchema(userdbSchema) 253 | rs.userdb = some(db) 254 | rs.userdb.get() 255 | 256 | proc newRelayServer*(dbfilename: string, updateSchema = true, pubkey = ""): RelayServer {.multiuseronly.} = 257 | ## Make a new multi-user relay server 258 | new(result) 259 | result.relay = newRelay[WSClient]() 260 | result.mcontext = proc(): Context = 261 | result = newContext() 262 | result.addDefaultContext() 263 | result.dbfilename = dbfilename 264 | result.updateSchema = updateSchema 265 | result.pubkey = pubkey 266 | result.runningRequests = newTable[int, HttpRequest]() 267 | discard result.db() 268 | result.debugname = "RelayServer" & nextDebugName() 269 | 270 | proc stop*(rs: RelayServer) {.async.} = 271 | for fut in rs.longrunservices: 272 | await fut.cancelAndWait() 273 | for req in rs.runningRequests.values(): 274 | try: 275 | await req.sendError(Http503) 276 | except: 277 | discard 278 | 279 | proc newRelayServer*(username, password: string): RelayServer {.singleuseronly.} = 280 | ## Make a new single-user relay server 281 | new(result) 282 | result.relay = newRelay[WSClient]() 283 | result.mcontext = proc(): Context = 284 | result = newContext() 285 | result.addDefaultContext() 286 | result.usernameHash = hash_password(username) 287 | result.passwordHash = hash_password(password) 288 | result.runningRequests = newTable[int, HttpRequest]() 289 | 290 | proc get_user_id*(rs: RelayServer, email: LowerString): int64 {.multiuseronly.} = 291 | ## Get a user's id from their email 292 | try: 293 | rs.db.getRow(sql"SELECT id FROM user WHERE email=?", email).get()[0].i 294 | except: 295 | raise NotFound.newException("No such user") 296 | 297 | proc register_user*(rs: RelayServer, email: LowerString, password: string): int64 {.multiuseronly.} = 298 | ## Register a user with a password 299 | let pwhash = try: 300 | hash_password(password) 301 | except: 302 | logging.error "Error hashing password", getCurrentExceptionMsg() 303 | raise CatchableError.newException("Crypto error") 304 | try: 305 | result = rs.db.insertID(sql"INSERT INTO user (email, pwhash) VALUES (?,?)", 306 | email, pwhash) 307 | except: 308 | logging.error rs.logname, "failed registering", getCurrentExceptionMsg() 309 | raise DuplicateUser.newException("Account already exists") 310 | 311 | proc password_auth*(rs: RelayServer, email: LowerString, password: string): int64 {.multiuseronly.} = 312 | ## Return the userid if the password is correct, else raise an exception 313 | let orow = rs.db.getRow(sql"SELECT id, pwhash FROM user WHERE email = ?", email) 314 | if orow.isNone: 315 | raise NotFound.newException("No such user") 316 | else: 317 | let row = orow.get() 318 | let user_id = row[0].i 319 | let pwhash = row[1].s 320 | if verify_password(pwhash, password): 321 | return user_id 322 | raise WrongPassword.newException("Wrong password") 323 | 324 | proc password_auth*(rs: RelayServer, email: LowerString, password: string): int64 {.singleuseronly.} = 325 | ## Return 1 if the password is correct, or else raise an exception 326 | if not verify_password(rs.usernameHash, email): 327 | raise NotFound.newException("No such user") 328 | if verify_password(rs.passwordHash, password): 329 | return 1 330 | raise WrongPassword.newException("Wrong password") 331 | 332 | proc strHash(lic: BucketsV1License, email: LowerString): string {.multiuseronly.} = 333 | $secureHash( 334 | $secureHash(LICENSE_HASH_SALT) & $secureHash(email.string) & $secureHash($lic) 335 | ) 336 | 337 | proc license_auth*(rs: RelayServer, license: string): int64 {.multiuseronly.} = 338 | ## Return the userid if the license is valid, else raise an error 339 | if rs.pubkey == "": 340 | raise WrongPassword.newException("License auth not supported") 341 | let lic = license.unformatLicense() 342 | if lic.verify(rs.pubkey) == false: 343 | raise WrongPassword.newException("Invalid license") 344 | let email = lic.extractEmail().toLowercase() 345 | let lichash = strHash(lic, email) 346 | let disabled = rs.db.getRow(sql"SELECT count(*) FROM disabledlicense WHERE licensehash=?", lichash).get()[0].i 347 | if disabled != 0: 348 | raise WrongPassword.newException("License disabled") 349 | try: 350 | result = rs.get_user_id(email) 351 | except NotFound: 352 | # create the user 353 | result = rs.db.insertID(sql"INSERT INTO user (email, pwhash, emailverified) VALUES (?, '', 1)", email) 354 | # disable former passwords 355 | rs.db.exec(sql"UPDATE user SET pwhash='' WHERE recentlicensehash='' AND id=?", result) 356 | # add license 357 | rs.db.exec(sql"UPDATE user SET recentlicensehash=?, emailverified=1 WHERE id=?", lichash, result) 358 | 359 | proc is_email_verified*(rs: RelayServer, user_id: int64): bool {.multiuseronly.} = 360 | ## Return true if the user has verified their email address 361 | let row = rs.db.getRow(sql"SELECT emailverified FROM user WHERE id=?", user_id) 362 | if row.isSome: 363 | return row.get()[0].boolVal 364 | 365 | proc generate_email_verification_token*(rs: RelayServer, user_id: int64): string {.multiuseronly.} = 366 | ## Generate a string to be emailed to a user that when returned 367 | ## to `use_email_verification_token` will mark that user's email 368 | ## as verified. 369 | result = randombytes(16).toHex() 370 | rs.db.exec(sql"INSERT INTO emailtoken (user_id, token) VALUES (?, ?)", 371 | user_id, result) 372 | rs.db.exec(sql"""DELETE FROM emailtoken WHERE id NOT IN 373 | (SELECT id FROM emailtoken WHERE user_id=? ORDER BY id DESC LIMIT 3)""", 374 | user_id) 375 | 376 | proc use_email_verification_token*(rs: RelayServer, user_id: int64, token: string): bool {.multiuseronly.} = 377 | ## Verify a user's email address via token. Return `true` if they are now 378 | ## verified and `false` if they are not. 379 | try: 380 | let row = rs.db.getRow(sql"SELECT count(*) FROM emailtoken WHERE user_id=? AND token=?", 381 | user_id, token).get() 382 | if row[0].i == 1: 383 | rs.db.exec(sql"DELETE FROM emailtoken WHERE user_id = ?", user_id) 384 | rs.db.exec(sql"UPDATE user SET emailverified=1 WHERE id=?", user_id) 385 | except: 386 | discard 387 | return rs.is_email_verified(user_id) 388 | 389 | proc generate_password_reset_token*(rs: RelayServer, email: LowerString): string {.multiuseronly.} = 390 | ## Generate a string token to be emailed to a user that can be used 391 | ## to set their password. 392 | result = randombytes(16).toHex() 393 | let user_id = rs.get_user_id(email) 394 | rs.db.exec(sql"INSERT INTO pwreset (user_id, token) VALUES (?, ?)", 395 | user_id, result) 396 | rs.db.exec(sql"""DELETE FROM pwreset WHERE id NOT IN 397 | (SELECT id FROM pwreset WHERE user_id=? ORDER BY id DESC LIMIT 3)""", 398 | user_id) 399 | 400 | proc delete_old_pwreset_tokens(rs: RelayServer) {.multiuseronly.} = 401 | rs.db.exec(sql"DELETE FROM pwreset WHERE expires < datetime('now')") 402 | 403 | proc user_for_password_reset_token*(rs: RelayServer, token: string): Option[int64] {.multiuseronly.} = 404 | ## Get the user associated with a password reset token, if one exists. 405 | rs.delete_old_pwreset_tokens() 406 | try: 407 | let row = rs.db.getRow(sql"SELECT user_id FROM pwreset WHERE token = ?", token).get() 408 | return some(row[0].i) 409 | except: 410 | discard 411 | 412 | proc update_password_with_token*(rs: RelayServer, token: string, newpassword: string) {.multiuseronly.} = 413 | ## Update a user's password using a password-reset token 414 | let o_user_id = rs.user_for_password_reset_token(token) 415 | if o_user_id.isNone: 416 | raise NotFound.newException("Invalid token") 417 | let user_id = o_user_id.get() 418 | let pwhash = hash_password(newpassword) 419 | rs.db.exec(sql"DELETE FROM pwreset WHERE user_id = ?", user_id) 420 | rs.db.exec(sql"UPDATE user SET pwhash=? WHERE id=?", pwhash, user_id) 421 | 422 | proc block_user*(rs: RelayServer, user_id: int64) {.multiuseronly.} = 423 | ## Block a user's access to the relay 424 | rs.db.exec(sql"UPDATE user SET blocked=1 WHERE id=?", user_id) 425 | 426 | proc block_user*(rs: RelayServer, email: LowerString) {.multiuseronly.} = 427 | ## Block a user's access to the relay 428 | rs.block_user(rs.get_user_id(email)) 429 | 430 | proc unblock_user*(rs: RelayServer, user_id: int64) {.multiuseronly.} = 431 | ## Unblock a user's access to the relay 432 | rs.db.exec(sql"UPDATE user SET blocked=0 WHERE id=?", user_id) 433 | 434 | proc unblock_user*(rs: RelayServer, email: LowerString) {.multiuseronly.} = 435 | ## Unblock a user's access to the relay 436 | rs.unblock_user(rs.get_user_id(email)) 437 | 438 | proc disable_most_recently_used_license*(rs: RelayServer, uid: int64) {.multiuseronly.} = 439 | ## Block the most recently-used license for a user 440 | let lichash = try: 441 | rs.db.getRow(sql"SELECT recentlicensehash FROM user WHERE id=?", uid).get()[0].s 442 | except: 443 | raise NotFound.newException("No such user") 444 | if lichash == "": 445 | raise NotFound.newException("User has not authenticated via license") 446 | rs.db.exec(sql"INSERT INTO disabledlicense (licensehash) VALUES (?) ON CONFLICT DO NOTHING", lichash) 447 | 448 | proc can_use_relay*(rs: RelayServer, user_id: int64): bool {.multiuseronly.} = 449 | ## Return true if the user is allowed to use the relay 450 | ## because their email is verified and they are not blocked 451 | try: 452 | return rs.db.getRow(sql"SELECT emailverified AND not(blocked) FROM user WHERE id=?", user_id).get()[0].boolVal 453 | except: 454 | discard 455 | 456 | type 457 | DataSentRecv* = tuple 458 | sent: int 459 | recv: int 460 | 461 | proc log_user_data*(rs: RelayServer, user_id: int64, dlen: DataSentRecv) {.multiuseronly.} = 462 | rs.db.exec(sql"""INSERT INTO userlog (day, user_id, bytes_sent, bytes_recv) 463 | VALUES (date(), ?, ?, ?) 464 | ON CONFLICT (day, user_id) DO 465 | UPDATE SET 466 | bytes_sent = bytes_sent + excluded.bytes_sent, 467 | bytes_recv = bytes_recv + excluded.bytes_recv 468 | """, user_id, dlen.sent, dlen.recv) 469 | 470 | when multiusermode: 471 | template log_user_data_sent*(rs: RelayServer, user_id: int64, dlen: int) = 472 | rs.log_user_data(user_id, (dlen, 0)) 473 | 474 | template log_user_data_recv*(rs: RelayServer, user_id: int64, dlen: int) = 475 | rs.log_user_data(user_id, (0, dlen)) 476 | 477 | proc data_by_user*(rs: RelayServer, user_id: int64, days = 1): DataSentRecv {.multiuseronly.} = 478 | let orow = rs.db.getRow(sql""" 479 | SELECT 480 | sum(bytes_sent), 481 | sum(bytes_recv) 482 | FROM 483 | userlog 484 | WHERE 485 | user_id = ? 486 | AND day >= date('now', '-' || ? || ' day') 487 | """, user_id, days) 488 | if orow.isSome: 489 | let row = orow.get() 490 | return (row[0].i.int, row[1].i.int) 491 | 492 | proc top_data_users*(rs: RelayServer, limit = 20, days = 7): seq[tuple[user: string, data: DataSentRecv]] {.multiuseronly.} = 493 | let rows = rs.db.getAllRows(sql""" 494 | SELECT 495 | u.email, 496 | sum(ll.bytes_sent), 497 | sum(ll.bytes_recv), 498 | sum(ll.bytes_sent + ll.bytes_recv) as total 499 | FROM 500 | userlog as ll 501 | LEFT JOIN user AS u 502 | ON ll.user_id = u.id 503 | WHERE 504 | ll.day >= date('now', '-' || ? || ' day') 505 | GROUP BY 1 506 | ORDER BY total DESC 507 | LIMIT ? 508 | """, days, limit) 509 | for row in rows: 510 | result.add((row[0].s, (row[1].i.int, row[2].i.int))) 511 | 512 | proc log_ip_data*(rs: RelayServer, ip: string, dlen: DataSentRecv) {.multiuseronly.} = 513 | rs.db.exec(sql"""INSERT INTO iplog (day, ip, bytes_sent, bytes_recv) 514 | VALUES (date(), ?, ?, ?) 515 | ON CONFLICT (day, ip) DO 516 | UPDATE SET 517 | bytes_sent = bytes_sent + excluded.bytes_sent, 518 | bytes_recv = bytes_recv + excluded.bytes_recv 519 | """, ip, dlen.sent, dlen.recv) 520 | 521 | when multiusermode: 522 | template log_ip_data_sent*(rs: RelayServer, ip: string, dlen: int) = 523 | rs.log_ip_data(ip, (dlen, 0)) 524 | 525 | template log_ip_data_recv*(rs: RelayServer, ip: string, dlen: int) = 526 | rs.log_ip_data(ip, (0, dlen)) 527 | 528 | proc data_by_ip*(rs: RelayServer, ip: string, days = 1): DataSentRecv {.multiuseronly.} = 529 | let orow = rs.db.getRow(sql""" 530 | SELECT 531 | sum(bytes_sent), 532 | sum(bytes_recv) 533 | FROM 534 | iplog 535 | WHERE 536 | ip = ? 537 | AND day >= date('now', '-' || ? || ' day') 538 | """, ip, days) 539 | if orow.isSome: 540 | let row = orow.get() 541 | return (row[0].i.int, row[1].i.int) 542 | 543 | proc top_data_ips*(rs: RelayServer, limit = 20, days = 7): seq[tuple[ip: string, data: DataSentRecv]] {.multiuseronly.} = 544 | let rows = rs.db.getAllRows(sql""" 545 | SELECT 546 | ip, 547 | sum(bytes_sent), 548 | sum(bytes_recv), 549 | sum(bytes_sent + bytes_recv) as total 550 | FROM 551 | iplog 552 | WHERE 553 | day >= date('now', '-' || ? || ' day') 554 | GROUP BY 1 555 | ORDER BY total DESC 556 | LIMIT ? 557 | """, days, limit) 558 | for row in rows: 559 | result.add((row[0].s, (row[1].i.int, row[2].i.int))) 560 | 561 | proc delete_old_stats*(rs: RelayServer, keep_days = 90) {.gcsafe, multiuseronly.} = 562 | ## Remote stats older than `keep_days` days 563 | try: 564 | info "Deleting stats older than " & $keep_days & "days" 565 | except: 566 | discard 567 | {.gcsafe.}: 568 | rs.db.exec(sql"DELETE FROM iplog WHERE day < date('now', '-' || ? || ' day')", keep_days) 569 | rs.db.exec(sql"DELETE FROM userlog WHERE day < date('now', '-' || ? || ' day')", keep_days) 570 | 571 | proc clear_stat_loop(rs: RelayServer) {.async, multiuseronly.} = 572 | while true: 573 | await sleepAsync(24.hours) 574 | rs.delete_old_stats() 575 | 576 | proc periodically_delete_old_stats*(rs: RelayServer) {.multiuseronly.} = 577 | ## Delete old stats at a regular interval 578 | rs.longrunservices.add(rs.clear_stat_loop()) 579 | 580 | #------------------------------------------------------------- 581 | # Common HTTP helpers 582 | #------------------------------------------------------------- 583 | 584 | proc ipAddress(request: HttpRequest): string = 585 | ## Return the IP Address associated with this request 586 | # # Forwarded (TODO) 587 | # let forwarded = request.headers.getOrDefault("forwarded") 588 | # if forwarded != "": 589 | # return result 590 | # True-Client-IP (cloudflare) 591 | result = request.headers.getString("true-client-ip") 592 | if result != "": 593 | return result 594 | # X-Real-IP (nginx) 595 | result = request.headers.getString("x-real-ip") 596 | if result != "": 597 | return result 598 | result = request.stream.writer.tsource.remoteAddress().host() 599 | 600 | proc sendHTML(req: HttpRequest, data: string) {.async.} = 601 | var headers = HttpTable.init() 602 | headers.add("Content-Type", "text/html") 603 | await req.sendResponse(Http200, headers, data = data) 604 | 605 | #------------------------------------------------------------- 606 | # Version 1 607 | #------------------------------------------------------------- 608 | 609 | template logname*(c: WSClient): string = 610 | "(" & c.debugname & ") " 611 | 612 | proc sendEvent*(c: WSClient, ev: RelayEvent) = 613 | ## Queue an event to a single ws client 614 | c.eventQueue.addLastNoWait(ev) 615 | 616 | proc newWSClient(rs: RelayServer, ws: WSSession, user_id: int64, ip: string): WSClient = 617 | new(result) 618 | result.ws = ws 619 | result.relayserver = rs 620 | result.user_id = user_id 621 | result.ip = ip 622 | result.eventQueue = newAsyncQueue[RelayEvent]() 623 | result.debugname = "WSClient" & nextDebugName() 624 | 625 | proc closeWait(c: WSClient) {.async.} = 626 | c.ws = nil 627 | 628 | proc authenticate(rs: RelayServer, req: HttpRequest): int64 = 629 | ## Perform HTTP basic authentication and return the 630 | ## user id if correct. 631 | let authorization = req.headers.getString("authorization") 632 | let parts = authorization.strip().split(" ") 633 | doAssert parts.len == 2, "Authorization header should have 2 items" 634 | doAssert parts[0] == "Basic", "Only basic HTTP auth is supported" 635 | let credentials = base64.decode(parts[1]).split(":", maxsplit = 1) 636 | doAssert credentials.len == 2, "Must supply username and password" 637 | let 638 | username = credentials[0] 639 | password = credentials[1] 640 | try: 641 | when multiusermode: 642 | if rs.pubkey != "" and username == "_license": 643 | return rs.license_auth(password) 644 | else: 645 | return rs.password_auth(username, password) 646 | else: 647 | return rs.password_auth(username, password) 648 | except WrongPassword: 649 | info rs.logname, "WrongPassword: " & getCurrentExceptionMsg() 650 | raise 651 | except: 652 | logging.error rs.logname, "Error during authentication: " & getCurrentExceptionMsg() 653 | raise 654 | 655 | when defined(testmode): 656 | var allHttpRequests*: seq[HttpRequest] 657 | 658 | proc handleRequestRelayV1(rs: RelayServer, req: HttpRequest) {.async, gcsafe.} = 659 | # Perform HTTP basic authenciation 660 | {.gcsafe.}: 661 | vlog rs.logname, "starting..." 662 | let user_id = block: 663 | try: 664 | vlog "authenticating..." 665 | rs.authenticate(req) 666 | except: 667 | logging.error "Error authenticating: " & getCurrentExceptionMsg() 668 | await req.sendError(Http403) 669 | return 670 | vlog rs.logname, "auth ok" 671 | when multiusermode: 672 | if not rs.can_use_relay(user_id): 673 | info rs.logname, "Blocked from relay: " & $user_id 674 | await req.sendError(Http403) 675 | return 676 | let ip = req.ipAddress() 677 | 678 | # Upgrade protocol to websockets 679 | var relayconn: RelayConnection[WSClient] 680 | try: 681 | let deflateFactory = deflateFactory() 682 | let server = WSServer.new(factories = [deflateFactory]) 683 | vlog rs.logname, "opening WS..." 684 | var ws = await server.handleRequest(req) 685 | if ws.readyState != Open: 686 | raise ValueError.newException("Failed to open websocket connection") 687 | 688 | var wsclient = newWSClient(rs, ws, user_id, ip) 689 | wsclient.debugname = rs.debugname & "." & wsclient.debugname 690 | vlog wsclient.logname, "starting..." 691 | try: 692 | relayconn = rs.relay.initAuth(wsclient, channel = $user_id) 693 | var decoder = newNetstringDecoder() 694 | var msgfut: Future[seq[byte]] 695 | var evfut: Future[RelayEvent] 696 | while ws.readyState != ReadyState.Closed: 697 | if msgfut.isNil: 698 | msgfut = ws.recvMsg() 699 | if evfut.isNil: 700 | evfut = wsclient.eventQueue.get() 701 | await (msgfut or evfut) 702 | if evfut.finished: 703 | let ev = await evfut 704 | evfut = nil 705 | let msg = nsencode(dumps(ev)) 706 | when multiusermode: 707 | rs.log_user_data_recv(wsclient.user_id, msg.len) 708 | rs.log_ip_data_recv(wsclient.ip, msg.len) 709 | await ws.send(msg.toBytes, Opcode.Binary) 710 | if msgfut.finished: 711 | let buff = try: 712 | await msgfut 713 | except: 714 | vlog wsclient.logname, "error getting msg: ", getCurrentExceptionMsg() 715 | break 716 | msgfut = nil 717 | when multiusermode: 718 | rs.log_user_data_sent(user_id, buff.len) 719 | rs.log_ip_data_sent(ip, buff.len) 720 | decoder.consume(string.fromBytes(buff)) 721 | while decoder.hasMessage(): 722 | let cmd = loadsRelayCommand(decoder.nextMessage()) 723 | rs.relay.handleCommand(relayconn, cmd) 724 | finally: 725 | await wsclient.closeWait() 726 | except WSClosedError: 727 | discard 728 | except WebSocketError as exc: 729 | error rs.logname, "WebSocketError: ", exc.msg 730 | await req.sendError(Http400) 731 | except Exception as exc: 732 | error rs.logname, "connection failed: ", exc.msg 733 | await req.sendError(Http400) 734 | finally: 735 | if not relayconn.isNil: 736 | rs.relay.removeConnection(relayconn) 737 | 738 | proc handleRequestAuthV1(rs: RelayServer, req: HttpRequest) {.async, multiuseronly.} = 739 | ## Handle user registration activities 740 | # Upgrade protocol to websockets 741 | {.gcsafe.}: 742 | try: 743 | vlog "[ws.auth] starting..." 744 | let deflateFactory = deflateFactory() 745 | let server = WSServer.new(factories = [deflateFactory]) 746 | var ws = await server.handleRequest(req) 747 | if ws.readyState != Open: 748 | raise ValueError.newException("Failed to open websocket connection") 749 | 750 | while ws.readyState != ReadyState.Closed: 751 | let buff = try: 752 | await ws.recvMsg() 753 | except: 754 | break 755 | let msg = string.fromBytes(buff) 756 | let data = parseJson(msg) 757 | var resp = newJObject() 758 | resp["id"] = data["id"] 759 | try: 760 | let command = data["command"].getStr() 761 | vlog "[ws.auth] command: " & command 762 | let args = data["args"] 763 | case command 764 | of "register": 765 | let email = args["email"].getStr() 766 | let password = args["password"].getStr() 767 | vlog "[ws.auth] register_user" 768 | let user_id = rs.register_user(email, password) 769 | vlog "[ws.auth] generate_email_verification_token" 770 | let email_token = rs.generate_email_verification_token(user_id) 771 | try: 772 | vlog "[ws.auth] sendEmail" 773 | await sendEmail(email, "Buckets Relay - Email Verification", 774 | &"Use this code to verify your email address:\n\n{email_token}") 775 | resp["response"] = newJBool(true) 776 | except: 777 | resp["error"] = newJString("Failed to send email") 778 | of "sendVerify": 779 | let email = args["email"].getStr() 780 | let user_id = rs.get_user_id(email) 781 | let email_token = rs.generate_email_verification_token(user_id) 782 | try: 783 | await sendEmail(email, "Buckets Relay - Email Verification", 784 | &"Use this code to verify your email address:\n\n{email_token}") 785 | resp["response"] = newJBool(true) 786 | except: 787 | resp["error"] = newJString("Failed to send email") 788 | of "verify": 789 | let email = args["email"].getStr() 790 | let code = args["code"].getStr() 791 | let user_id = rs.get_user_id(email) 792 | resp["response"] = newJBool(rs.use_email_verification_token(user_id, code)) 793 | of "resetPassword": 794 | let email = args["email"].getStr() 795 | let pw_token = rs.generate_password_reset_token(email) 796 | try: 797 | await sendEmail(email, "Buckets Relay - Password Reset", 798 | &"Use this code to change your password:\n\n{pw_token}") 799 | except: 800 | resp["error"] = newJString("Failed to send email") 801 | of "updatePassword": 802 | let pw_token = args["token"].getStr() 803 | let new_password = args["new_password"].getStr() 804 | rs.update_password_with_token(pw_token, new_password) 805 | else: 806 | resp["error"] = newJString("Unknown command"); 807 | except NotFound: 808 | vlog "[ws.auth] not found" 809 | resp["error"] = newJString("Not found") 810 | except DuplicateUser: 811 | vlog "[ws.auth] duplicate user" 812 | resp["error"] = newJString("Account already exists") 813 | except WrongPassword: 814 | vlog "[ws.auth] wrong password" 815 | resp["error"] = newJString("Wrong password") 816 | except Exception as exc: 817 | vlog "[ws.auth] unexpected error" 818 | error exc.msg 819 | resp["error"] = newJString("Unexpected error") 820 | finally: 821 | vlog "[ws.auth] sending response" 822 | await ws.send($resp) 823 | except WSClosedError: 824 | vlog "[ws.auth] WSClosedError" 825 | except WebSocketError as exc: 826 | logging.error "relay/server: WebSocketError: " & exc.msg 827 | await req.sendError(Http400) 828 | except Exception as exc: 829 | logging.error "relay/server: connection failed: " & exc.msg 830 | await req.sendError(Http400) 831 | 832 | proc handleRequestV1(rs: RelayServer, req: HttpRequest, subpath: string) {.async, gcsafe.} = 833 | ## Version 1 request handling 834 | {.gcsafe.}: 835 | var path = req.uri.path.substr(subpath.len) 836 | if path == "": path = "/" 837 | if path == "/relay": 838 | await rs.handleRequestRelayV1(req) 839 | elif path == "/auth": 840 | when multiusermode: 841 | await rs.handleRequestAuthV1(req) 842 | else: 843 | await req.sendError(Http404) 844 | elif path == "/": 845 | let ctx = rs.mcontext() 846 | ctx["openregistration"] = multiusermode 847 | let rendered = render("{{>index}}", ctx) 848 | await req.sendHTML(rendered) 849 | else: 850 | await req.sendError(Http404) 851 | 852 | #------------------------------------------------------------- 853 | # HTTP routing common to all versions 854 | #------------------------------------------------------------- 855 | 856 | proc handleRequest*(rs: RelayServer, req: HttpRequest): Future[void] {.async, gcsafe.} = 857 | ## Handle a relay server websocket request. 858 | {.gcsafe.}: 859 | let reqid = rs.nextid 860 | rs.nextid.inc() 861 | rs.runningRequests[reqid] = req 862 | defer: rs.runningRequests.del(reqid) 863 | when defined(testmode): 864 | allHttpRequests.add(req) 865 | defer: 866 | allHttpRequests.delete(allHttpRequests.find(req)) 867 | let path = req.uri.path 868 | if path.startsWith("/v1/"): 869 | await rs.handleRequestV1(req, "/v1") 870 | elif path == "/versions": 871 | await req.sendResponse(Http200, data = versionSupport) 872 | elif path == "/": 873 | var headers = HttpTable.init() 874 | headers.add("Location", "/v1/") 875 | await req.sendResponse(Http307, headers, "") 876 | elif path.startsWith("/static"): 877 | let subpath = path.substr("/static".len) 878 | try: 879 | var headers = HttpTable.init() 880 | headers.add("Content-Type", mimedb.getMimetype(path.splitFile.ext)) 881 | await req.sendResponse(Http200, headers, data = readStaticFile(subpath)) 882 | except: 883 | await req.sendError(Http404) 884 | else: 885 | await req.sendError(Http404) 886 | 887 | proc start*(rs: RelayServer, address: TransportAddress, tlsPrivateKey = "", tlsCertificate = "") = 888 | ## Start the relay server at the given address. 889 | let 890 | socketFlags = {ServerFlags.TcpNoDelay, ServerFlags.ReuseAddr} 891 | if tlsPrivateKey != "" and tlsCertificate != "": 892 | rs.http = RelayHttpServer( 893 | tls: true, 894 | httpsServer: TlsHttpServer.create( 895 | address = address, 896 | tlsPrivateKey = TLSPrivateKey.init(tlsPrivateKey), 897 | tlsCertificate = TLSCertificate.init(tlsCertificate), 898 | flags = socketFlags) 899 | ) 900 | else: 901 | rs.http = RelayHttpServer( 902 | tls: false, 903 | httpServer: HttpServer.create(address, flags = socketFlags), 904 | ) 905 | 906 | rs.http.handler = proc(request: HttpRequest) {.async.} = 907 | try: 908 | await rs.handleRequest(request) 909 | except: 910 | let msg = getCurrentExceptionMsg() 911 | if "Stream is already closed" in msg: 912 | discard 913 | else: 914 | logging.error rs.logname, "Error handling HTTP request: " & getCurrentExceptionMsg() 915 | rs.http.start() 916 | 917 | proc finish*(rs: RelayServer) {.async.} = 918 | ## Completely stop the running server 919 | rs.http.stop() 920 | rs.http.close() 921 | await rs.http.join() 922 | vlog rs.logname, "finished" 923 | --------------------------------------------------------------------------------