├── .dockerignore ├── .github ├── FUNDING.yml └── workflows │ └── main.yml ├── .gitignore ├── .prettierrc ├── .vscode └── settings.json ├── AppImageBuilder.yml ├── LICENSE ├── README.md ├── build.sh ├── docker └── Dockerfile ├── eslint.config.mjs ├── package-lock.json ├── package.json ├── services ├── http.json └── minecraft.json ├── setversion.sh ├── src ├── DockerManager.ts ├── Gateway.ts ├── Message.ts ├── Peer.ts ├── Router.ts ├── ServiceProvider.ts ├── TCPNet.ts ├── UDPNet.ts ├── Utils.ts └── cli.ts ├── static ├── Hypergate.drawio ├── gateway-provider.jpg ├── multi-gateway-provider.jpg └── virtual-network.jpg └── tsconfig.json /.dockerignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | build 3 | deploy -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: ["riccardobl"] 2 | custom: ["https://getalby.com/p/rblb"] 3 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | 2 | name: Build 3 | 4 | on: 5 | release: 6 | types: [published] 7 | push: 8 | 9 | jobs: 10 | BuildBinary: 11 | runs-on: ubuntu-latest 12 | permissions: 13 | contents: write 14 | packages: write 15 | steps: 16 | - uses: actions/checkout@v2 17 | 18 | - name: Setup Node.js environment 19 | uses: actions/setup-node@v3.6.0 20 | with: 21 | node-version: 22.x 22 | 23 | - name: Install dependencies 24 | run: | 25 | sudo apt update 26 | sudo apt install -y jq 27 | 28 | - name: Build 29 | run: | 30 | export VERSION="`if [[ $GITHUB_REF == refs\/tags* ]]; then echo ${GITHUB_REF//refs\/tags\//}; fi`" 31 | if [ "$VERSION" = "" ]; 32 | then 33 | export VERSION="SNAPSHOT" 34 | fi 35 | bash build.sh 36 | 37 | - name: Upload artifacts 38 | uses: actions/upload-artifact@v4 39 | with: 40 | name: hypergate 41 | path: deploy 42 | 43 | - name: Deploy to GitHub Releases 44 | if: github.event_name == 'release' 45 | run: | 46 | echo "${GITHUB_EVENT_PATH}" 47 | cat ${GITHUB_EVENT_PATH} 48 | releaseId=$(jq --raw-output '.release.id' ${GITHUB_EVENT_PATH}) 49 | 50 | echo "Upload binary to $releaseId" 51 | filename=deploy/hypergate 52 | url="https://uploads.github.com/repos/${GITHUB_REPOSITORY}/releases/$releaseId/assets?name=$(basename $filename)" 53 | echo "Upload to $url" 54 | curl -L \ 55 | -H "Authorization: token ${{ secrets.GITHUB_TOKEN }}" \ 56 | -H "Content-Type: application/octet-stream" \ 57 | --data-binary @"$filename" \ 58 | "$url" 59 | 60 | echo "Upload hash to $releaseId" 61 | filename=deploy/hypergate.sha256 62 | url="https://uploads.github.com/repos/${GITHUB_REPOSITORY}/releases/$releaseId/assets?name=$(basename $filename)" 63 | echo "Upload to $url" 64 | curl -L \ 65 | -H "Authorization: token ${{ secrets.GITHUB_TOKEN }}" \ 66 | -H "Content-Type: text/plain" \ 67 | --data-binary @"$filename" \ 68 | "$url" 69 | 70 | 71 | 72 | BuildContainer: 73 | runs-on: ubuntu-latest 74 | permissions: 75 | contents: read 76 | packages: write 77 | steps: 78 | - uses: actions/checkout@v2 79 | - name: Install dependencies 80 | run: | 81 | sudo apt update 82 | sudo apt install -y jq 83 | - name: Build and push to registry 84 | run: | 85 | VERSION="snapshot" 86 | if [[ $GITHUB_REF == refs/tags/* ]]; then 87 | VERSION=${GITHUB_REF#refs/tags/} 88 | fi 89 | bash setversion.sh 90 | echo ${{ secrets.GITHUB_TOKEN }} | docker login docker.pkg.github.com -u ${{ github.actor }} --password-stdin 91 | docker build -t hypergate:$VERSION . -f docker/Dockerfile 92 | docker tag hypergate:$VERSION docker.pkg.github.com/${{ github.repository }}/hypergate:${VERSION} 93 | docker push docker.pkg.github.com/${{ github.repository }}/hypergate:${VERSION} 94 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | build 3 | deploy -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "semi": true, 3 | "singleQuote": false, 4 | "trailingComma": "all", 5 | "printWidth": 200, 6 | "tabWidth": 4 7 | 8 | } -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "files.exclude": { 3 | "build": true 4 | }, 5 | "editor.formatOnSave": true 6 | } -------------------------------------------------------------------------------- /AppImageBuilder.yml: -------------------------------------------------------------------------------- 1 | version: 1 2 | 3 | AppDir: 4 | path: ./AppDir 5 | 6 | app_info: 7 | id: hypergate 8 | name: HyperGate 9 | icon: application-vnd.appimage 10 | version: "$VERSION" 11 | exec: node/bin/node 12 | exec_args: $APPDIR/cli.js $@ 13 | 14 | 15 | AppImage: 16 | update-information: None 17 | sign-key: None 18 | arch: "x86_64" -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | BSD 3-Clause License 2 | 3 | Copyright (c) 2024, Riccardo Balbo 4 | 5 | Redistribution and use in source and binary forms, with or without 6 | modification, are permitted provided that the following conditions are met: 7 | 8 | 1. Redistributions of source code must retain the above copyright notice, this 9 | list of conditions and the following disclaimer. 10 | 11 | 2. Redistributions in binary form must reproduce the above copyright notice, 12 | this list of conditions and the following disclaimer in the documentation 13 | and/or other materials provided with the distribution. 14 | 15 | 3. Neither the name of the copyright holder nor the names of its 16 | contributors may be used to endorse or promote products derived from 17 | this software without specific prior written permission. 18 | 19 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 20 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 21 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 22 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 23 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 24 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 25 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 26 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 27 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 28 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 29 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Hypergate 2 | 3 | **Hypergate** is a zero-configuration, peer-to-peer encrypted tunnel that enables seamless communication between Docker containers and physical machines across any location, even those behind NAT. 4 | 5 | ## Components 6 | Hypergate comprises three primary components: 7 | - **Service Providers**: Make local services accessible to the network. 8 | - **Gateways**: Act as entry points, connecting to the appropriate Service Provider. 9 | - **Hypergate Router**: A virtual router connecting Service Providers and Gateways via a secret key that establishes a Hyperswarm connection. 10 | 11 | Multiple Gateways and Service Providers can coexist on the same or different machines. 12 | 13 | ➡️ **Quick Start**: Jump to [Example: Providers-Gateways](#example-1-one-provider-one-gateway-with-docker) or [Example: Docker Network](#example-2-docker-virtual-network) for quick examples. 14 | 15 | --- 16 | 17 | # Usage Overview 18 | Hypergate supports several usage modes: 19 | 20 | - **Docker Bridge Network**: Bridges Docker networks across hosts, enabling container communication as if on the same machine. 21 | - **Docker Gateway**: Exposes Docker containers to the internet, bypassing NAT or firewall limitations. 22 | - **Generic Gateway or Reverse Proxy**: Exposes machine services behind NAT/firewall to the internet. 23 | - **P2P VPN**: Connects machines behind NAT/firewalls for direct interaction, such as gaming. 24 | 25 | Combine these modes for complex configurations tailored to specific use cases. 26 | 27 | ## Getting Started 28 | - To use **Hypergate** with Docker: 29 | - [Docker](https://www.docker.com/) must be installed. 30 | 31 | - To use **Hypergate** without Docker: 32 | - Download the "hypergate" executable from the [release page](https://github.com/riccardobl/hypergate/releases) *(for x86_64 Linux)*. 33 | - Or, clone the repository and run: 34 | ```bash 35 | npm i 36 | npm run build 37 | npm run start 38 | ``` 39 | 40 | --- 41 | 42 | # Network Configuration 43 | ### One Provider, One Gateway vs. Multiple Providers, Multiple Gateways 44 | 45 | | Single Provider & Gateway | Multiple Providers & Gateways | 46 | | -------------------------- | ----------------------------- | 47 | | ![Single Provider and Gateway](static/gateway-provider.jpg) | ![Multiple Providers and Gateways](static/multi-gateway-provider.jpg) | 48 | 49 | In Hypergate, Service Providers are authoritative, updating Gateway routing tables to expose services. If multiple Providers compete for the same port, a round-robin method selects the next available Provider. 50 | 51 | ⚠️ **Security Tip**: Protect the Hypergate Router secret carefully to avoid unauthorized network reconfigurations. Use multiple routers to isolate services if different trust levels are needed. 52 | 53 | --- 54 | 55 | # Docker Virtual Network 56 | 57 | | Docker Virtual Network | 58 | | ---------------------- | 59 | | ![Docker Virtual Network](static/virtual-network.jpg) | 60 | 61 | **Hypergate** simplifies container networking, allowing containers in the same Hypergate network to communicate without complex configurations or port mappings. It leverages Hyperswarm for P2P connections, enabling NAT traversal through hole-punching techniques, with all connections automatically encrypted. 62 | 63 | Containers using the `EXPOSE` directive in Dockerfiles are automatically configured. Alternatively, specify ports with the `hypergate.EXPOSE` label in the `docker run` command. 64 | 65 | ### Custom Docker Labels 66 | - `hypergate.EXCLUDE="true|false"`: Exclude a container from announcements by the Service Provider. 67 | - `hypergate.EXPOSE="port[:public port][/protocol]"`: CSV list of ports to expose. Protocol defaults to TCP if omitted. 68 | - `hypergate.UNEXPOSE="port[/protocol]"`: CSV list of exposed port to ignore. Only ports exposed by the Dockerfile EXPOSE directive are affected by this label. If `*` is used, all ports exposed by the Dockerfile are ignored. 69 | 70 | --- 71 | 72 | # Examples 73 | 74 | ## Example 1: Exposing an HTTP Server Behind NAT 75 | This example exposes an HTTP server on MACHINE1 using a Service Provider and a Gateway on MACHINE2. 76 | 77 | ### Steps: 78 | 1. **Start HTTP Service on MACHINE1** 79 | ```bash 80 | mkdir -p /tmp/www-test 81 | cd /tmp/www-test 82 | echo "Hello World" > index.html 83 | busybox httpd -p 8080 -f . 84 | ``` 85 | 86 | 2. **Create the Router** 87 | ```bash 88 | $ hypergate --new 89 | ``` 90 | 91 | 3. **Start Service Provider on MACHINE1** 92 | ```bash 93 | $ hypergate --router --provider services/http.json 94 | ``` 95 | 96 | 4. **Start Gateway on MACHINE2** 97 | ```bash 98 | $ hypergate --router --listen 0.0.0.0 --gateway 99 | ``` 100 | 101 | **Note**: By default, the gateway will expose all services announced to the router. This behavior is generally desirable, but in some cases, you may want to limit exposure to specific services—such as if you have competing services on the same port or have concerns about provider trust. To achieve this, pass the same service definition used by the provider to the gateway, ensuring only that service is exposed: 102 | ```bash 103 | $ hypergate --router --listen 0.0.0.0 --gateway services/http.json 104 | ``` 105 | 106 | **Test**: Connect to MACHINE2:8080 to view the "Hello World" page from MACHINE1. 107 | 108 | --- 109 | 110 | ## Example 2: Bridging a Docker Network with Gateway Creation 111 | This example bridges networks across MACHINE1, MACHINE2, and MACHINE3, where: 112 | - MACHINE1 hosts a MariaDB instance. 113 | - MACHINE2 hosts phpMyAdmin connecting to MariaDB. 114 | - MACHINE3 exposes phpMyAdmin publicly. 115 | 116 | ### Steps: 117 | 1. **Create Router Key** 118 | ```bash 119 | $ docker run -it --rm hypergate --new 120 | ``` 121 | 122 | 2. **Start Service Provider on MACHINE1** 123 | ```bash 124 | $ docker run -it --rm -u root --name="hypergate-sp-machine1" -v /var/run/docker.sock:/var/run/docker.sock hypergate --router --docker --provider --network hypergatenet 125 | ``` 126 | 127 | 3. **Start MariaDB on MACHINE1 and connect to the network** 128 | ```bash 129 | docker run -d --rm --name test-mysql -eMYSQL_ROOT_HOST=% -eMYSQL_DATABASE=wp -e MYSQL_ROOT_PASSWORD=secretpassword --label hypergate.EXPOSE=3306 mysql 130 | 131 | docker network connect hypergatenet test-mysql --alias mysql.hyper 132 | ``` 133 | 134 | 4. **Start Gateway on MACHINE2** 135 | ```bash 136 | docker run -it --rm -u root --name="hypergate-gw-machine2" -v /var/run/docker.sock:/var/run/docker.sock hypergate --router --docker --gateway --listen 0.0.0.0 --network hypergatenet 137 | ``` 138 | 139 | 5. **Start Service Provider on MACHINE2** 140 | ```bash 141 | docker run -it --rm -u root --name="hypergate-sp-machine2" -v /var/run/docker.sock:/var/run/docker.sock hypergate --router --docker --provider --network hypergatenet 142 | ``` 143 | 144 | 6. **Start phpMyAdmin on MACHINE2 and connect to the network** 145 | ```bash 146 | docker run --rm --name test-phpmyadmin -d -e PMA_HOST=mysql.hyper --label hypergate.EXPOSE=80 phpmyadmin 147 | docker network connect hypergatenet test-phpmyadmin --alias phpmyadmin.hyper 148 | ``` 149 | 150 | 7. **Start Gateway on MACHINE3** 151 | ```bash 152 | docker run -it --rm -u root --name="hypergate-gw-machine3" -v /var/run/docker.sock:/var/run/docker.sock -p 8080:80 hypergate --router --docker --gateway --listen 0.0.0.0 --network hypergatenet --exposeOnlyServices phpmyadmin.hyper 153 | ``` 154 | 155 | **Test**: Access phpMyAdmin by connecting to MACHINE3:8080. 156 | 157 | 158 | 159 | 160 | # License and Warranty 161 | 162 | This is an experimental free software, there is no warranty. You are free to use, modify and redistribute it under certain conditions. 163 | 164 | See the [LICENSE](LICENSE) file for details. 165 | 166 | This is an experimental software, it might or might not be production ready or even work as expected. 167 | 168 | Use it at your own discretion. 169 | 170 | # Similar projects 171 | Other projects related to sharing services with hyperswarm 172 | 173 | - [hypertele](https://github.com/bitfinexcom/hypertele) : A swiss-knife proxy powered by Hyperswarm DHT 174 | - [hyperseaport](https://github.com/ryanramage/hyperseaport) : A p2p service registry 175 | - [hyperssh](https://github.com/mafintosh/hyperssh) : Run SSH over hyperswarm! 176 | - [hyperbeam](https://github.com/mafintosh/hyperbeam) : A 1-1 end-to-end encrypted internet pipe powered by Hyperswarm -------------------------------------------------------------------------------- /build.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -e 3 | if [ "$VERSION" = "" ]; 4 | then 5 | VERSION=2.0 6 | fi 7 | 8 | bash setversion.sh 9 | 10 | rm -Rf build || true 11 | mkdir -p deploy 12 | mkdir -p build/AppDir 13 | 14 | npm i 15 | npm run build 16 | 17 | 18 | wget https://nodejs.org/dist/v22.11.0/node-v22.11.0-linux-x64.tar.xz -O build/AppDir/node.tar.xz 19 | tar -xf build/AppDir/node.tar.xz -C build/AppDir 20 | rm build/AppDir/node.tar.xz 21 | mv build/AppDir/node-*-linux-x64 build/AppDir/node 22 | 23 | 24 | cp build/dist/*.js build/AppDir 25 | cp *.json build/AppDir 26 | cd build/AppDir 27 | npm i --prefix=. --production 28 | 29 | cd .. 30 | 31 | wget https://github.com/AppImageCrafters/appimage-builder/releases/download/v1.1.0/appimage-builder-1.1.0-x86_64.AppImage -O ./appimage-builder 32 | chmod +x ./appimage-builder 33 | ./appimage-builder --appimage-extract 34 | 35 | 36 | cp ../AppImageBuilder.yml . 37 | sed -i "s/\$VERSION/$VERSION/g" AppImageBuilder.yml 38 | 39 | squashfs-root/AppRun --recipe AppImageBuilder.yml 40 | rm -Rf squashfs-root 41 | 42 | cp -f *.AppImage ../deploy/hypergate 43 | sha256sum ../deploy/hypergate | cut -d' ' -f1 > ../deploy/hypergate.sha256 44 | -------------------------------------------------------------------------------- /docker/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:22 2 | 3 | RUN mkdir -p /app 4 | WORKDIR /app 5 | 6 | COPY src ./src 7 | COPY package*.json ./ 8 | COPY tsconfig.json ./ 9 | 10 | RUN chown -R node:node /app 11 | 12 | USER node 13 | RUN ls /app 14 | RUN npm i 15 | RUN npm run build 16 | 17 | LABEL hypergate.EXCLUDE="true" 18 | 19 | ENTRYPOINT [ "npm", "run", "start" , "--" ] -------------------------------------------------------------------------------- /eslint.config.mjs: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | 3 | import eslint from '@eslint/js'; 4 | import tseslint from 'typescript-eslint'; 5 | 6 | export default tseslint.config( 7 | eslint.configs.recommended, 8 | tseslint.configs.recommended, 9 | { 10 | languageOptions: { 11 | parserOptions: { 12 | project: true, 13 | }, 14 | }, 15 | rules: { 16 | 'prefer-const': 'off', 17 | '@typescript-eslint/no-explicit-any': 'off', 18 | '@typescript-eslint/ban-ts-comment': 'off', 19 | "@typescript-eslint/no-floating-promises": "error" 20 | }, 21 | } 22 | ); -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "hypergate", 3 | "version": "0.0.0-snapshot", 4 | "description": "TCP and UDP gateway and reverse proxy built on top of hyperswarm", 5 | "main": "src/cli.ts", 6 | "dependencies": { 7 | "@hyperswarm/dht": "^6.4.0", 8 | "@types/minimist": "^1.2.5", 9 | "express": "^4.21.2", 10 | "hyperswarm": "^4.3.6", 11 | "minimist": "^1.2.5", 12 | "node-docker-api": "^1.1.22" 13 | }, 14 | "bin": { 15 | "hypergate": "./build/dist/cli.js" 16 | }, 17 | "repository": { 18 | "type": "git", 19 | "url": "https://github.com/riccardobl/hypergate.git" 20 | }, 21 | "author": "Riccardo Balbo", 22 | "license": "MIT", 23 | "bugs": { 24 | "url": "https://github.com/riccardobl/hypergate/issues" 25 | }, 26 | "homepage": "https://github.com/riccardobl/hypergate", 27 | "type": "module", 28 | "publishConfig": { 29 | "registry": "https://npm.pkg.github.com" 30 | }, 31 | "devDependencies": { 32 | "@eslint/js": "^9.17.0", 33 | "@types/node": "^22.8.6", 34 | "@typescript-eslint/eslint-plugin": "^8.19.0", 35 | "@typescript-eslint/parser": "^8.19.0", 36 | "eslint": "^9.17.0", 37 | "eslint-config-prettier": "^9.1.0", 38 | "eslint-plugin-prettier": "^5.2.1", 39 | "prettier": "^3.4.2", 40 | "ts-node": "^10.9.2", 41 | "tsx": "^4.19.2", 42 | "typescript": "^5.7.2", 43 | "typescript-eslint": "^8.19.0" 44 | }, 45 | "scripts": { 46 | "build": "tsc", 47 | "start": "node ./build/dist/cli.js", 48 | "debug": "HYPERGATE_VERBOSE=true tsx src/cli", 49 | "lint": "eslint 'src/**/*.{js,ts}'", 50 | "format": "prettier --write 'src/**/*.{js,ts,tsx,jsx,json,css,scss,md}'" 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /services/http.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "HTTP Server", 3 | "icon": "", 4 | "services": [ 5 | { 6 | "gatePort": 8080, 7 | "serviceHost": "localhost", 8 | "servicePort": 8080, 9 | "protocol": "tcp" 10 | } 11 | ] 12 | } -------------------------------------------------------------------------------- /services/minecraft.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Minecraft Server", 3 | "icon": "", 4 | "services": [ 5 | { 6 | "gatePort": 25565, 7 | "serviceHost": "localhost", 8 | "servicePort": 25565, 9 | "protocol": "tcp" 10 | }, 11 | { 12 | "gatePort": 19132, 13 | "serviceHost": "localhost", 14 | "servicePort": 19132, 15 | "protocol": "udp" 16 | }, 17 | { 18 | "gatePort": 8123, 19 | "serviceHost": "localhost", 20 | "servicePort": 8123, 21 | "protocol": "tcp" 22 | } 23 | ] 24 | } -------------------------------------------------------------------------------- /setversion.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -e 4 | if [ "$VERSION" = "" ] || [ "$VERSION" = "SNAPSHOT" ] || [ "$VERSION" = "snapshot" ]; 5 | then 6 | VERSION=2.0-snapshot 7 | fi 8 | jq ".version=\"$VERSION\"" package.json > tmp_package.json 9 | mv -f tmp_package.json package.json -------------------------------------------------------------------------------- /src/DockerManager.ts: -------------------------------------------------------------------------------- 1 | import { Docker } from "node-docker-api"; 2 | import { Service } from "./Router.js"; 3 | import ServiceProvider from "./ServiceProvider.js"; 4 | import Gateway from "./Gateway.js"; 5 | 6 | export default class DockerManager { 7 | private docker: Docker; 8 | private router: string; 9 | private image: string; 10 | private networkName: string; 11 | private serviceProvider?: ServiceProvider; 12 | private gateway?: Gateway; 13 | private refreshTime: number; 14 | private refreshTimer: any = null; 15 | private stopped: boolean = false; 16 | 17 | constructor(serviceProvider: ServiceProvider | Gateway, networkName: string, router: string, socketPath?: string, image?: string, refreshTime?: number) { 18 | this.docker = new Docker({ 19 | socketPath: socketPath ?? "/var/run/docker.sock", 20 | }); 21 | this.router = router; 22 | this.image = image ?? "hypergate"; 23 | this.networkName = networkName ?? `hypergate-${router}`; 24 | this.refreshTime = refreshTime ?? 20 * 1000; 25 | if (serviceProvider instanceof ServiceProvider) { 26 | this.serviceProvider = serviceProvider; 27 | } else if (serviceProvider instanceof Gateway) { 28 | this.gateway = serviceProvider; 29 | } 30 | this.loop().catch(console.error); 31 | } 32 | 33 | private async getNetwork() { 34 | let network = (await this.docker.network.list()).find((n: any) => n.data.Name == this.networkName); 35 | if (!network) { 36 | network = await this.docker.network.create({ 37 | Name: this.networkName, 38 | Driver: "bridge", 39 | EnableIPv6: false, 40 | }); 41 | } 42 | return network; 43 | } 44 | 45 | private async loop() { 46 | try { 47 | const services: Array = await this.getConnectedServices(); 48 | if (this.serviceProvider) { 49 | this.serviceProvider.setServices(services); 50 | } 51 | if (this.gateway) { 52 | await this.updateDockerGates(services); 53 | } 54 | if (this.stopped) return; 55 | } catch (e) { 56 | console.error(e); 57 | } 58 | this.refreshTimer = setTimeout(() => this.loop(), this.refreshTime); 59 | } 60 | 61 | private async updateDockerGates(services: Array) { 62 | const router = this.router; 63 | const network = await this.getNetwork(); 64 | 65 | const containers = await this.docker.container.list({ all: true }); 66 | const servicesXhost: { [host: string]: Array } = {}; 67 | for (const service of services) { 68 | if (!servicesXhost[service.serviceHost]) servicesXhost[service.serviceHost] = []; 69 | servicesXhost[service.serviceHost].push(service); 70 | } 71 | 72 | for (const [host, services] of Object.entries(servicesXhost)) { 73 | const gateContainerName = `${host}-hypergate-gateway-${router}`; 74 | let container = containers.find((c: any) => { 75 | return c.data.Names[0].substring(1) == gateContainerName; 76 | }); 77 | if (!container) { 78 | const filters = []; 79 | const ExposedPorts: any = {}; 80 | for (const service of services) { 81 | ExposedPorts[`${service.servicePort}/${service.protocol}`] = {}; 82 | filters.push({ 83 | serviceHost: service.serviceHost, 84 | }); 85 | } 86 | console.info("Creating gateway container", gateContainerName, filters); 87 | container = await this.docker.container.create({ 88 | Image: this.image, 89 | name: gateContainerName, 90 | Env: [`HYPERGATE_ROUTER=${router}`, `HYPERGATE_GATEWAY=${JSON.stringify(filters)}`, "HYPERGATE_LISTEN=0.0.0.0"], 91 | ExposedPorts, 92 | Hostname: host, 93 | Labels: { 94 | "hypergate.EXCLUDE": "true", 95 | }, 96 | NetworkingConfig: { 97 | EndpointsConfig: { 98 | [this.networkName]: { 99 | Aliases: [host], 100 | // @ts-ignore 101 | NetworkID: network.data.Id, 102 | }, 103 | }, 104 | }, 105 | }); 106 | } 107 | // @ts-ignore 108 | if (container.data.State != "running") { 109 | await container.start(); 110 | } 111 | } 112 | } 113 | 114 | public async stop() { 115 | this.stopped = true; 116 | if (this.refreshTimer) clearTimeout(this.refreshTimer); 117 | const router = this.router; 118 | const containers = await this.docker.container.list(); 119 | for (const container of containers) { 120 | // @ts-ignore 121 | if (container.data.Names[0].substring(1).includes(router)) { 122 | await container.stop(); 123 | await container.delete({ force: true }); 124 | } 125 | } 126 | } 127 | 128 | private async getConnectedServices(): Promise> { 129 | await this.getNetwork(); 130 | const networkName = this.networkName; 131 | const services: Array = []; 132 | const containers = await this.docker.container.list(); 133 | for (let container of containers) { 134 | // @ts-ignore 135 | const name = container.data.Names[0].substring(1); 136 | if (name.includes(this.router)) continue; 137 | 138 | // @ts-ignore 139 | const labels = container.data.Labels; 140 | 141 | if ((labels["hypergate.EXCLUDE"] || "false").toString().toLowerCase() == "true") continue; 142 | 143 | const containerStatus = await container.status(); 144 | // @ts-ignore 145 | const network = containerStatus.data?.NetworkSettings?.Networks[networkName]; 146 | if (!network) continue; 147 | 148 | const customUnExposedPorts = (labels["hypergate.UNEXPOSE"] ?? "").split(","); 149 | // @ts-ignore 150 | const ports = [ 151 | // @ts-ignore 152 | ...container.data.Ports.filter((p: any) => { 153 | if (p.PublicPort) return true; 154 | if (customUnExposedPorts.includes("*") || customUnExposedPorts.includes(`${p.PrivatePort}/${p.Type}`) || customUnExposedPorts.includes(`${p.PrivatePort}`)) { 155 | console.log("Unexposing", p.PrivatePort, p.Type); 156 | return false; 157 | } 158 | return true; 159 | }), 160 | ]; 161 | 162 | const customExposedPorts = (labels["hypergate.EXPOSE"] ?? "").split(","); 163 | for (const customPort of customExposedPorts) { 164 | let [port, proto] = customPort.split("/"); 165 | if (!port) continue; 166 | let privatePort; 167 | let publicPort; 168 | if (port.includes(":")) { 169 | [privatePort, publicPort] = port.split(":"); 170 | } else { 171 | privatePort = port; 172 | } 173 | privatePort = parseInt(privatePort); 174 | publicPort = publicPort ? parseInt(publicPort) : undefined; 175 | if (!privatePort || isNaN(privatePort)) continue; 176 | if (publicPort !== undefined && isNaN(publicPort)) continue; 177 | if (!proto) proto = "tcp"; 178 | const existing = ports.find((p: any) => p.PrivatePort == privatePort && p.Type == proto); 179 | if (existing) { 180 | if (!existing.PublicPort) existing.PublicPort = publicPort; 181 | } else { 182 | ports.push({ 183 | PrivatePort: privatePort, 184 | Type: proto, 185 | PublicPort: publicPort, 186 | }); 187 | } 188 | } 189 | // @ts-ignore 190 | const service = ports.map((ps: any) => { 191 | let servicePort = ps.PrivatePort; 192 | let serviceProto = ps.Type; 193 | let serviceHost = network?.Aliases?.[0] ?? name; 194 | let gatePort = ps.PublicPort; 195 | let published = !!gatePort; 196 | if (!gatePort) gatePort = servicePort; 197 | return { 198 | servicePort, 199 | protocol: serviceProto, 200 | serviceHost, 201 | gatePort, 202 | tags: "docker " + (published ? "published" : ""), 203 | } as Service; 204 | }); 205 | services.push(...service); 206 | } 207 | return services; 208 | } 209 | } 210 | -------------------------------------------------------------------------------- /src/Gateway.ts: -------------------------------------------------------------------------------- 1 | import Peer from "./Peer.js"; 2 | import Message, { MessageActions } from "./Message.js"; 3 | import Net from "net"; 4 | // @ts-ignore 5 | import b4a from "b4a"; 6 | import UDPNet from "./UDPNet.js"; 7 | import TCPNet from "./TCPNet.js"; 8 | import { RoutingEntry, RoutingTable } from "./Router.js"; 9 | import { Socket as NetSocket } from "net"; 10 | import Utils from "./Utils.js"; 11 | 12 | type Socket = UDPNet | NetSocket; 13 | 14 | type Channel = { 15 | socket: any; 16 | route?: Buffer; 17 | buffer: Array; 18 | duration: number; 19 | expire: number; 20 | gate: any; 21 | alive: boolean; 22 | pipeData?: (data?: Buffer) => void; 23 | close?: () => void; 24 | channelPort: number; 25 | accepted?: boolean; 26 | }; 27 | 28 | type Gate = { 29 | protocol: string; 30 | port: number; 31 | conn?: UDPNet | Net.Server; 32 | gateway: Gateway; 33 | refreshId: number; 34 | channels: Array; 35 | }; 36 | 37 | export default class Gateway extends Peer { 38 | private readonly routingTable: RoutingTable = []; 39 | private readonly usedChannels: Set = new Set(); 40 | private readonly listenOnAddr: string; 41 | private readonly gates: Array = []; 42 | private readonly routeFilter?: (routingEntry: RoutingEntry) => Promise; 43 | private readonly routeFindingTimeout: number = 5 * 60 * 1000; // 5 minutes 44 | 45 | private nextChannelId: number = 0; 46 | private refreshId: number = 0; 47 | 48 | constructor(secret: string, listenOnAddr: string, routeFilter?: (routingEntry: RoutingEntry) => Promise, opts?: object) { 49 | super(secret, true, opts); 50 | this.listenOnAddr = listenOnAddr; 51 | this.routeFilter = routeFilter; 52 | 53 | // listen for new routes 54 | this.addMessageHandler((peer, msg) => { 55 | if (msg?.actionId == MessageActions.advRoutes) { 56 | const routes = msg?.routes; 57 | if (!routes) return false; 58 | console.log("Receiving routes from " + peer.info.publicKey.toString("hex"), routes); 59 | this.mergeRoutingTableFragment(routes, peer.info.publicKey); 60 | } 61 | return false; 62 | }); 63 | this.stats(); 64 | this.start().catch(console.error); 65 | } 66 | 67 | private stats() { 68 | const activeGates = this.gates.length; 69 | let activeChannels = 0; 70 | let closingChannels = 0; 71 | let pendingChannels = 0; 72 | 73 | for (const gate of this.gates) { 74 | activeChannels += gate.channels.filter((c) => c.alive && c.accepted).length; 75 | closingChannels += gate.channels.filter((c) => !c.alive && c.accepted).length; 76 | pendingChannels += gate.channels.filter((c) => !c.accepted).length; 77 | } 78 | 79 | console.info(` 80 | Gates 81 | - active: ${activeGates} 82 | Channels 83 | - active: ${activeChannels} 84 | - closing: ${closingChannels} 85 | - pending: ${pendingChannels} 86 | `); 87 | 88 | setTimeout(() => { 89 | this.stats(); 90 | }, 10 * 60_000); 91 | } 92 | 93 | // merge routes 94 | private mergeRoutingTableFragment(routingTableFragment: RoutingTable, peerKey: Buffer) { 95 | const routeExpiration = Date.now() + 1000 * 60; // 1 minute 96 | for (const routingEntry of routingTableFragment) { 97 | const gatePort = routingEntry.gatePort; 98 | const alias = routingEntry.serviceHost; 99 | const protocol = routingEntry.protocol; 100 | const tags = routingEntry.tags; 101 | let storedRoutingEntry = this.routingTable.find((r) => r.gatePort == gatePort && r.serviceHost == alias && r.protocol == protocol && r.tags == tags); 102 | if (!storedRoutingEntry) { 103 | storedRoutingEntry = { 104 | gatePort: gatePort, 105 | serviceHost: alias, 106 | servicePort: routingEntry.servicePort, 107 | protocol: protocol, 108 | routes: [], 109 | i: 0, 110 | tags, 111 | }; 112 | this.routingTable.push(storedRoutingEntry); 113 | } 114 | let route = storedRoutingEntry.routes.find((r) => r.key.equals(peerKey)); 115 | if (!route) { 116 | route = { 117 | key: peerKey, 118 | routeExpiration: routeExpiration, 119 | }; 120 | storedRoutingEntry.routes.push(route); 121 | } else { 122 | route.routeExpiration = routeExpiration; 123 | } 124 | } 125 | console.log("Update routing table", JSON.stringify(this.routingTable)); 126 | } 127 | 128 | // find a route 129 | public getRoute(gatePort: number, serviceHost?: string, protocol?: string, tags?: string): Buffer { 130 | const ts: RoutingEntry[] = this.routingTable.filter( 131 | (r) => r.gatePort == gatePort && (!serviceHost || r.serviceHost == serviceHost) && (!protocol || r.protocol == protocol) && (!tags || r.tags == tags), 132 | ); 133 | for (const t of ts) { 134 | if (!t.routes.length) continue; 135 | while (true) { 136 | if (!t.i || t.i >= t.routes.length) t.i = 0; 137 | const route = t.routes[t.i++]; 138 | if (!route) throw "Undefined route??"; 139 | if (route.routeExpiration < Date.now()) { 140 | t.routes.splice(t.i - 1, 1); 141 | continue; 142 | } 143 | return route.key; 144 | } 145 | } 146 | throw "No route found"; 147 | } 148 | 149 | private getNextChannel(): number { 150 | const increment = () => { 151 | this.nextChannelId++; 152 | if (this.nextChannelId > 4294967295) this.nextChannelId = 1; 153 | }; 154 | 155 | if (!this.nextChannelId) this.nextChannelId = 1; 156 | else increment(); 157 | 158 | while (this.usedChannels.has(this.nextChannelId)) increment(); 159 | 160 | this.usedChannels.add(this.nextChannelId); 161 | return this.nextChannelId; 162 | } 163 | 164 | private releaseChannel(channelId: number) { 165 | this.usedChannels.delete(channelId); 166 | } 167 | 168 | // open a new gate 169 | public openGate(port: number, protocol: string) { 170 | const onConnection = (gate: Gate, socket: Socket) => { 171 | const gatePort = gate.port; 172 | // on incoming connection, create a channel 173 | const channelPort = this.getNextChannel(); 174 | console.log("Create channel", channelPort, "on gate", gatePort); 175 | 176 | const duration = Utils.getConnDuration(protocol == "udp"); 177 | const channel: Channel = { 178 | // protocol:protocol, 179 | socket: socket, 180 | buffer: [], 181 | duration, 182 | expire: Date.now() + duration, 183 | // gatePort:gatePort, 184 | gate: gate, 185 | alive: true, 186 | channelPort, 187 | }; 188 | 189 | // store channels in gateway object 190 | gate.channels.push(channel); 191 | 192 | // pipe data to route 193 | channel.pipeData = (data?: Buffer) => { 194 | // reset expiration everytime data is piped 195 | channel.expire = Date.now() + channel.duration; 196 | 197 | // pipe 198 | if (channel.route) { 199 | // if route established 200 | if (channel.buffer.length > 0) { 201 | const merged = Buffer.concat(channel.buffer); 202 | channel.buffer = []; 203 | this.send( 204 | channel.route, 205 | Message.create(MessageActions.stream, { 206 | channelPort: channelPort, 207 | data: merged, 208 | }), 209 | ); 210 | } 211 | if (data) { 212 | this.send( 213 | channel.route, 214 | Message.create(MessageActions.stream, { 215 | channelPort: channelPort, 216 | data: data, 217 | }), 218 | ); 219 | } 220 | } else { 221 | // if still waiting for a route, buffer 222 | if (data) channel.buffer.push(data); 223 | } 224 | }; 225 | 226 | // close route (bidirectional) 227 | channel.close = () => { 228 | try { 229 | if (channel.route) { 230 | this.send( 231 | channel.route, 232 | Message.create(MessageActions.close, { 233 | channelPort: channelPort, 234 | }), 235 | ); 236 | } 237 | } catch (e) { 238 | console.error(e); 239 | } 240 | try { 241 | socket.end(); 242 | } catch (e) { 243 | console.error(e); 244 | } 245 | channel.alive = false; 246 | this.releaseChannel(channelPort); 247 | }; 248 | 249 | // timeout channel 250 | const timeout = () => { 251 | if (!channel.alive) return; 252 | try { 253 | // if the socket is destroyed or the channel has expired 254 | const isExpired = socket.destroyed || channel.expire < Date.now(); 255 | if (isExpired) { 256 | console.log("Channel expired!"); 257 | channel.close?.(); 258 | } 259 | } catch (e) { 260 | console.error(e); 261 | } 262 | setTimeout(timeout, 1000 * 60); 263 | }; 264 | timeout(); 265 | 266 | // pipe gate actions to route 267 | socket.on("data", channel.pipeData); 268 | socket.on("close", channel.close); 269 | socket.on("end", channel.close); 270 | socket.on("error", channel.close); 271 | 272 | // look for a route 273 | const findRoute = async () => { 274 | try { 275 | console.log("Looking for route"); 276 | const routeFindingStartedAt = Date.now(); 277 | 278 | while (true) { 279 | const route: Buffer = this.getRoute(gatePort); 280 | 281 | // send open request and wait for response 282 | try { 283 | await new Promise((res, rej) => { 284 | console.log("Test route", b4a.toString(route, "hex")); 285 | this.send( 286 | route, 287 | Message.create(MessageActions.open, { 288 | channelPort: channelPort, 289 | gatePort: gatePort, 290 | }), 291 | ); 292 | const timeout = setTimeout(() => rej("route timeout"), 5000); // timeout open request 293 | this.addMessageHandler((peer, msg) => { 294 | if (msg.actionId == MessageActions.open && msg.channelPort == channelPort) { 295 | if (msg.error) { 296 | console.log("Received error", msg.error); 297 | rej(msg.error); 298 | return true; // error, detach 299 | } 300 | console.log("Received confirmation"); 301 | channel.route = route; 302 | channel.accepted = true; 303 | clearTimeout(timeout); 304 | res(channel); 305 | return true; // detach listener 306 | } 307 | return !channel.alive; // detach when channel dies 308 | }); 309 | }); 310 | 311 | // found a route 312 | console.log( 313 | "New gate channel opened:", 314 | channelPort, 315 | " tot: ", 316 | gate.channels.length, 317 | gate.protocol, 318 | "\nInitiated from ", 319 | socket.remoteAddress, 320 | ":", 321 | socket.remotePort, 322 | "\n To", 323 | socket.localAddress, 324 | ":", 325 | socket.localPort, 326 | ); 327 | 328 | // pipe route data to gate 329 | this.addMessageHandler((peer, msg) => { 330 | // everytime data is piped, reset expiration 331 | channel.expire = Date.now() + channel.duration; 332 | 333 | // pipe 334 | if (msg.actionId == MessageActions.stream && msg.channelPort == channelPort && msg.data) { 335 | socket.write(msg.data); 336 | return false; 337 | } else if (msg.actionId == MessageActions.close && (!msg.channelPort || msg.channelPort == channelPort || msg.channelPort <= 0) /* close all */) { 338 | channel.close?.(); 339 | return true; // detach listener 340 | } 341 | return !channel.alive; // detach when channel dies 342 | }); 343 | 344 | // pipe pending buffered data 345 | channel.pipeData?.(); 346 | 347 | // exit route finding mode, now everything is ready 348 | break; 349 | } catch (e) { 350 | console.error(e); 351 | if (Date.now() - routeFindingStartedAt > this.routeFindingTimeout) { 352 | throw new Error("Route finding timeout"); 353 | } 354 | await new Promise((res) => setTimeout(res, 100)); // wait 100 ms 355 | } 356 | } 357 | } catch (e) { 358 | channel.close?.(); 359 | throw e; 360 | } 361 | }; 362 | findRoute().catch(console.error); 363 | }; 364 | 365 | const gate: Gate = { 366 | protocol: protocol, 367 | port: port, 368 | gateway: this, 369 | refreshId: this.refreshId, 370 | channels: [], 371 | }; 372 | const conn = (protocol == "udp" ? UDPNet : TCPNet).createServer((socket) => { 373 | onConnection(gate, socket); 374 | }); 375 | 376 | conn.listen(gate.port, this.listenOnAddr, () => { 377 | if (gate.port == 0) { 378 | const addr = conn.address(); 379 | if (!addr || typeof addr == "string") return; 380 | else { 381 | gate.port = addr.port ?? 0; 382 | } 383 | } 384 | }); 385 | 386 | gate.conn = conn; 387 | console.info("Opened new gate on", this.listenOnAddr + ":" + gate.port, "with protocol", gate.protocol); 388 | return gate; 389 | } 390 | 391 | private getGate(port: number, protocol: string) { 392 | return this.gates.find((g) => g.port == port && g.protocol == protocol); 393 | } 394 | 395 | protected override async onRefresh() { 396 | try { 397 | this.refreshId++; 398 | 399 | for (const routingEntry of this.routingTable) { 400 | try { 401 | const gatePort = routingEntry.gatePort; 402 | const gateProtocol = routingEntry.protocol; 403 | if (this.routeFilter) { 404 | if (!(await this.routeFilter(routingEntry))) { 405 | // console.log("Route filtered", routingEntry); 406 | continue; 407 | } 408 | } 409 | let gate = this.getGate(gatePort, gateProtocol); 410 | if (gate) { 411 | gate.refreshId = this.refreshId; 412 | } else { 413 | gate = this.openGate(gatePort, gateProtocol); 414 | if (gate) { 415 | this.gates.push(gate); 416 | } 417 | } 418 | } catch (e) { 419 | console.error(e); 420 | } 421 | } 422 | 423 | for (let i = 0; i < this.gates.length; ) { 424 | const gate = this.gates[i]; 425 | if (gate.refreshId != this.refreshId) { 426 | this.gates.splice(i, 1); 427 | for (const channel of gate.channels) { 428 | await channel.close?.(); 429 | } 430 | await gate.conn?.close(); 431 | } else { 432 | i++; 433 | } 434 | } 435 | 436 | for (const gate of this.gates) { 437 | for (let j = 0; j < gate.channels.length; ) { 438 | if (!gate.channels[j].alive) { 439 | gate.channels.splice(j, 1); 440 | } else { 441 | j++; 442 | } 443 | } 444 | } 445 | } catch (e) { 446 | console.error(e); 447 | } 448 | } 449 | } 450 | -------------------------------------------------------------------------------- /src/Message.ts: -------------------------------------------------------------------------------- 1 | import { RoutingTable } from "./Router.js"; 2 | export type MessageContent = { 3 | error?: any; 4 | channelPort?: number; 5 | gatePort?: number; 6 | data?: Buffer; 7 | auth?: Buffer; 8 | isGate?: boolean; 9 | routes?: RoutingTable; 10 | actionId?: number; 11 | }; 12 | 13 | export enum MessageActions { 14 | hello = 0, // handshake 15 | open = 1, // open a channel 16 | stream = 2, // stream some data from/to a channel 17 | close = 3, // close a channel 18 | advRoutes = 4, // advertise peer routes 19 | } 20 | 21 | export default class Message { 22 | public static create(actionId: number, msg: MessageContent): Buffer { 23 | if (msg.error) { 24 | const msgErrorBuffer = Buffer.from(JSON.stringify(msg.error), "utf8"); 25 | if (msgErrorBuffer.length > 255) { 26 | throw new Error("Error message too long"); 27 | } else if (msgErrorBuffer.length == 0) { 28 | throw new Error("Error alias too short"); 29 | } 30 | const buffer = Buffer.alloc(1 + 1 + 4 + msgErrorBuffer.length); 31 | buffer.writeUInt8(actionId, 0); 32 | buffer.writeUInt8(msgErrorBuffer.length, 1); 33 | buffer.writeUInt32BE(msg.channelPort || 0, 2); 34 | buffer.set(msgErrorBuffer, 1 + 1 + 4); 35 | return buffer; 36 | } 37 | 38 | if (actionId == MessageActions.hello) { 39 | if (!msg.auth) throw new Error("Auth is required for hello message"); 40 | const buffer = Buffer.alloc(msg.auth.length + 1 + 1 + 4); 41 | buffer.writeUInt8(actionId, 0); 42 | buffer.writeUInt8(0, 1); 43 | buffer.writeUInt32BE(msg.isGate ? 1 : 0, 2); 44 | buffer.set(msg.auth, 1 + 1 + 4); 45 | return buffer; 46 | } else if (actionId == MessageActions.close) { 47 | if (msg.channelPort == null) throw new Error("Channel port is required for close message"); 48 | const buffer = Buffer.alloc(4 + 1 + 1); 49 | buffer.writeUInt8(actionId, 0); 50 | buffer.writeUInt8(0, 1); 51 | buffer.writeUInt32BE(msg.channelPort, 2); 52 | return buffer; 53 | } else if (actionId == MessageActions.stream) { 54 | if (msg.data == null) throw new Error("Data is required for stream message"); 55 | if (msg.channelPort == null) throw new Error("Channel port is required for stream message"); 56 | const buffer = Buffer.alloc(msg.data.length + 4 + 1 + 1); 57 | buffer.writeUInt8(actionId, 0); 58 | buffer.writeUInt8(0, 1); 59 | buffer.writeUInt32BE(msg.channelPort, 2); 60 | buffer.set(msg.data, 1 + 1 + 4); 61 | return buffer; 62 | } else if (actionId == MessageActions.advRoutes) { 63 | const routes = Buffer.from(JSON.stringify({ routes: msg.routes })); 64 | const buffer = Buffer.alloc(routes.length + 4 + 1 + 1); 65 | buffer.writeUInt8(actionId, 0); 66 | buffer.writeUInt8(0, 1); 67 | buffer.writeUInt32BE(0, 2); 68 | buffer.set(routes, 1 + 1 + 4); 69 | return buffer; 70 | } else if (actionId == MessageActions.open) { 71 | const buffer = Buffer.alloc(1 + 1 + 4 + 4); 72 | buffer.writeUInt8(actionId, 0); 73 | buffer.writeUInt8(0, 1); 74 | buffer.writeUInt32BE(msg.channelPort || 0, 2); 75 | buffer.writeUInt32BE(msg.gatePort || 0, 2 + 4); 76 | return buffer; 77 | } else { 78 | throw new Error("Unknown actionId " + actionId); 79 | } 80 | } 81 | 82 | public static parse(data: Buffer): MessageContent { 83 | const actionId = data.readUInt8(0); 84 | const error = data.readUInt8(1); 85 | const channelPort = data.readUInt32BE(2); 86 | data = data.slice(2 + 4); 87 | 88 | if (error) { 89 | return { 90 | actionId: actionId, 91 | error: JSON.parse(data.toString("utf8", 0, error)), 92 | channelPort: channelPort, 93 | }; 94 | } 95 | 96 | if (actionId == MessageActions.open) { 97 | // open 98 | const gatePort = data.readUInt32BE(0); 99 | return { 100 | actionId: actionId, 101 | gatePort: gatePort, 102 | channelPort: channelPort, 103 | }; 104 | } else if (actionId == MessageActions.stream) { 105 | // stream 106 | return { 107 | actionId: actionId, 108 | channelPort: channelPort, 109 | data: data, 110 | }; 111 | } else if (actionId == MessageActions.close) { 112 | // close 113 | return { 114 | actionId: actionId, 115 | channelPort: channelPort, 116 | }; 117 | } else if (actionId == MessageActions.advRoutes) { 118 | // get routing table 119 | return { 120 | actionId: actionId, 121 | routes: JSON.parse(data.toString("utf8")).routes, 122 | channelPort: channelPort, 123 | }; 124 | } else if (actionId == MessageActions.hello) { 125 | // hello 126 | const isGate = channelPort; 127 | const auth = data; 128 | return { 129 | actionId: actionId, 130 | auth: auth, 131 | isGate: isGate == 1, 132 | channelPort: channelPort, 133 | }; 134 | } 135 | throw new Error("Unknown actionId " + actionId); 136 | } 137 | } 138 | -------------------------------------------------------------------------------- /src/Peer.ts: -------------------------------------------------------------------------------- 1 | import Message from "./Message.js"; 2 | // @ts-ignore 3 | import HyperDHT from "@hyperswarm/dht"; 4 | // @ts-ignore 5 | import Hyperswarm from "hyperswarm"; 6 | // @ts-ignore 7 | import Sodium from "sodium-universal"; 8 | // @ts-ignore 9 | import b4a from "b4a"; 10 | import { MessageActions, MessageContent } from "./Message.js"; 11 | import UDPNet from "./UDPNet.js"; 12 | import Net from "net"; 13 | 14 | export type PeerChannel = { 15 | socket: UDPNet | Net.Socket; 16 | duration: number; 17 | expire: number; 18 | gatePort: number; 19 | alive: boolean; 20 | route: Buffer; 21 | channelPort: number; 22 | service: any; 23 | }; 24 | 25 | export type AuthorizedPeer = { 26 | c: any; 27 | info: any; 28 | channels: { [channelPort: number]: PeerChannel }; 29 | }; 30 | 31 | export default abstract class Peer { 32 | private readonly isGate: boolean; 33 | private readonly routerKeys: any; 34 | private readonly dht: any; 35 | private readonly swarm: any; 36 | private readonly discovery: any; 37 | private readonly messageHandlers: ((peer: AuthorizedPeer, msg: MessageContent) => boolean)[] = []; 38 | private readonly _authorizedPeers: AuthorizedPeer[] = []; 39 | private refreshing: boolean = false; 40 | private stopped: boolean = false; 41 | 42 | constructor(secret: string, isGate: boolean, opts?: object) { 43 | this.isGate = isGate; 44 | 45 | this.routerKeys = HyperDHT.keyPair(Buffer.from(secret, "hex")); 46 | 47 | this.dht = new HyperDHT(opts); 48 | this.dht.on("error", (err: any) => console.log("DHT error", err)); 49 | this.swarm = new Hyperswarm(this.dht); 50 | this.swarm.on("error", (err: any) => console.log("Swarm error", err)); 51 | this.swarm.on("connection", (c: any, peer: any) => { 52 | console.log("Swarm connection", b4a.toString(peer.publicKey, "hex")); 53 | this.onConnection(c, peer).catch(console.error); 54 | }); 55 | 56 | this.discovery = this.swarm.join(this.routerKeys.publicKey, { 57 | client: true, 58 | server: true, 59 | }); 60 | console.info("Joined router:", b4a.toString(this.routerKeys.publicKey, "hex")); 61 | } 62 | 63 | private addAuthorizedPeer(connection: any, peerInfo: any): AuthorizedPeer { 64 | const newPeer = { 65 | c: connection, 66 | info: peerInfo, 67 | channels: {}, 68 | }; 69 | this._authorizedPeers.push(newPeer); 70 | return newPeer; 71 | } 72 | 73 | private removeAuthorizedPeerByKey(peerKey: string) { 74 | for (let i = 0; i < this._authorizedPeers.length; i++) { 75 | const peer = this._authorizedPeers[i]; 76 | if (peer.info.publicKey.equals(peerKey)) { 77 | this._authorizedPeers.splice(i, 1); 78 | return; 79 | } 80 | } 81 | } 82 | 83 | private getAuthorizedPeerByKey(peerKey: Buffer): AuthorizedPeer | undefined { 84 | for (const peer of this._authorizedPeers) { 85 | if (peer.info.publicKey.equals(peerKey)) return peer; 86 | } 87 | return undefined; 88 | } 89 | 90 | private createAuthBlob(routerSecret: Buffer, sourcePublic: Buffer, targetPublic: Buffer, routerPublic: Buffer, timestamp: number): Buffer { 91 | if (!routerSecret || !sourcePublic || !targetPublic || !routerPublic || !timestamp) throw new Error("Invalid authkey"); 92 | const timestampBuffer = Buffer.alloc(1 + 8); 93 | timestampBuffer.writeUint8(21, 0); 94 | timestampBuffer.writeBigInt64BE(BigInt(timestamp), 1); 95 | 96 | const createKey = (source: Buffer): Buffer => { 97 | const keyLength = Sodium.crypto_pwhash_BYTES_MAX < Sodium.crypto_generichash_KEYBYTES_MAX ? Sodium.crypto_pwhash_BYTES_MAX : Sodium.crypto_generichash_KEYBYTES_MAX; 98 | if (keyLength < Sodium.crypto_pwhash_BYTES_MIN) throw new Error("Error. Key too short"); 99 | 100 | const salt = b4a.alloc(Sodium.crypto_pwhash_SALTBYTES); 101 | Sodium.crypto_generichash(salt, source); 102 | // console.log("Create salt",b4a.toString(salt,"hex")); 103 | 104 | const secretKey = b4a.alloc(keyLength); 105 | Sodium.crypto_pwhash(secretKey, source, salt, Sodium.crypto_pwhash_OPSLIMIT_INTERACTIVE, Sodium.crypto_pwhash_MEMLIMIT_INTERACTIVE, Sodium.crypto_pwhash_ALG_DEFAULT); 106 | // console.log("Create key",b4a.toString(secretKey,"hex")); 107 | 108 | return secretKey; 109 | }; 110 | 111 | const hash = (msg: Buffer, key: Buffer) => { 112 | const enc = b4a.alloc(Sodium.crypto_generichash_BYTES_MAX); 113 | Sodium.crypto_generichash(enc, msg, key); 114 | // console.log("Create hash",b4a.toString(enc,"hex"),"Using key",b4a.toString(key,"hex")); 115 | return enc; 116 | }; 117 | 118 | const authMsg = Buffer.concat([sourcePublic, targetPublic, routerPublic, timestampBuffer]); 119 | 120 | const key = createKey(routerSecret); 121 | if (!key) throw new Error("Error"); 122 | 123 | const encAuthMsg = hash(authMsg, key); 124 | if (!encAuthMsg) throw new Error("Error"); 125 | 126 | const out = Buffer.concat([timestampBuffer, encAuthMsg]); 127 | if (out.length < 32) throw new Error("Invalid authkey"); 128 | 129 | return out; 130 | } 131 | 132 | private getAuthKey(targetPublicKey: Buffer): Buffer { 133 | const sourcePublicKey = this.swarm.keyPair.publicKey; 134 | return this.createAuthBlob(this.routerKeys.secretKey, sourcePublicKey, targetPublicKey, this.routerKeys.publicKey, Date.now()); 135 | } 136 | 137 | private verifyAuthKey(sourcePublicKey: Buffer, authKey: Buffer) { 138 | const timestampBuffer = authKey.slice(0, 8 + 1); 139 | const timestamp = timestampBuffer.readBigInt64BE(1); 140 | const now = BigInt(Date.now()); 141 | if (now - timestamp > 1000 * 60 * 15) { 142 | console.error("AuthKey expired. Replay attack or clock mismatch?"); 143 | return false; 144 | } 145 | const targetPublicKey = this.swarm.keyPair.publicKey; 146 | const validAuthMessage = this.createAuthBlob(this.routerKeys.secretKey, sourcePublicKey, targetPublicKey, this.routerKeys.publicKey, Number(timestamp)); 147 | return validAuthMessage.equals(authKey); 148 | } 149 | 150 | private async onConnection(c: any, peer: any) { 151 | const closeConn = () => { 152 | const aPeer = this.getAuthorizedPeerByKey(peer.publicKey); 153 | try { 154 | if (aPeer) { 155 | const msg = Message.create(MessageActions.close, { channelPort: 0 }); 156 | this.onAuthorizedMessage(aPeer, Message.parse(msg)).catch(console.error); 157 | } 158 | } catch (err) { 159 | console.error("Error on close", err); 160 | } 161 | this.removeAuthorizedPeerByKey(peer.publicKey); 162 | }; 163 | 164 | c.on("error", (err: any) => { 165 | console.log("Connection error", err); 166 | closeConn(); 167 | }); 168 | 169 | c.on("close", () => { 170 | closeConn(); 171 | }); 172 | 173 | c.on("data", (data: Buffer) => { 174 | try { 175 | const msg = Message.parse(data); 176 | if (msg.actionId == MessageActions.hello) { 177 | console.log("Receiving handshake"); 178 | // Only gate->peer or peer->gate connections are allowed 179 | if (!this.isGate && !msg.isGate) { 180 | peer.ban(true); 181 | c.destroy(); 182 | console.log("Ban because", b4a.toString(peer.publicKey, "hex"), "is not a gate and tried to connect to a peer", this.isGate, msg.isGate); 183 | return; 184 | } 185 | 186 | if (!msg.auth || !this.verifyAuthKey(peer.publicKey, msg.auth)) { 187 | console.error("Authorization failed for peer", b4a.toString(peer.publicKey, "hex"), "Ban!"); 188 | console.log("Authorization failed using authkey ", b4a.toString(msg.auth, "hex")); 189 | peer.ban(true); 190 | c.destroy(); 191 | return; 192 | } 193 | 194 | if (this.getAuthorizedPeerByKey(peer.publicKey)) { 195 | console.error("Already connected??", peer.publicKey); 196 | return; 197 | } 198 | 199 | this.addAuthorizedPeer(c, peer); 200 | console.info("Authorized", b4a.toString(peer.publicKey, "hex")); 201 | } else { 202 | const aPeer = this.getAuthorizedPeerByKey(peer.publicKey); 203 | if (!aPeer) { 204 | console.error("Unauthorized message from", b4a.toString(peer.publicKey, "hex")); 205 | return; 206 | } else { 207 | this.onAuthorizedMessage(aPeer, msg).catch(console.error); 208 | } 209 | } 210 | } catch (err) { 211 | console.error("Error on message", err); 212 | } 213 | }); 214 | 215 | const authKey = this.getAuthKey(peer.publicKey); 216 | // console.log("Attempt authorization with authKey",b4a.toString(authKey,"hex")); 217 | c.write( 218 | Message.create(MessageActions.hello, { 219 | auth: authKey, 220 | isGate: this.isGate, 221 | }), 222 | ); 223 | } 224 | 225 | public broadcast(msg: Buffer) { 226 | if (!this._authorizedPeers) return; 227 | for (const p of this._authorizedPeers) { 228 | this.send(p.info.publicKey, msg); 229 | } 230 | } 231 | 232 | public send(peerKey: Buffer, msg: Buffer) { 233 | console.log("Sending message to", b4a.toString(peerKey, "hex")); 234 | const peer = this.getAuthorizedPeerByKey(peerKey); 235 | 236 | if (peer) peer.c.write(msg); 237 | else console.error("Peer not found"); 238 | } 239 | 240 | public addMessageHandler(handler: (peer: AuthorizedPeer, msg: MessageContent) => boolean) { 241 | this.messageHandlers.push(handler); 242 | } 243 | 244 | protected async onAuthorizedMessage(peer: AuthorizedPeer, msg: MessageContent) { 245 | console.log("Receiving message", msg); 246 | for (let i = 0; i < this.messageHandlers.length; i++) { 247 | const handler = this.messageHandlers[i]; 248 | try { 249 | if (handler(peer, msg)) { 250 | // remove 251 | this.messageHandlers.splice(i, 1); 252 | } 253 | } catch (err) { 254 | console.error("Error on message handler", err); 255 | } 256 | } 257 | } 258 | 259 | protected abstract onRefresh(): Promise; 260 | 261 | private async refresh() { 262 | try { 263 | if (this.stopped) return; 264 | this.refreshing = true; 265 | console.log("Refreshing peers"); 266 | await this.discovery.refresh({ 267 | server: true, 268 | client: true, 269 | }); 270 | await this.onRefresh(); 271 | } catch (err) { 272 | console.error("Error on refresh", err); 273 | } finally { 274 | this.refreshing = false; 275 | } 276 | setTimeout(() => this.refresh(), 5000); 277 | } 278 | 279 | public async stop() { 280 | this.stopped = true; 281 | while (this.refreshing) { 282 | await new Promise((resolve) => setTimeout(resolve, 100)); 283 | } 284 | try { 285 | this.swarm.destroy(); 286 | } catch (err) { 287 | console.error("Error on stop", err); 288 | } 289 | 290 | try { 291 | this.dht.destroy(); 292 | } catch (err) { 293 | console.error("Error on stop", err); 294 | } 295 | } 296 | 297 | protected async start() { 298 | this.refresh().catch(console.error); 299 | } 300 | } 301 | -------------------------------------------------------------------------------- /src/Router.ts: -------------------------------------------------------------------------------- 1 | export type Route = { 2 | key: Buffer; 3 | routeExpiration: number; 4 | }; 5 | 6 | export type Service = { 7 | gatePort: number; 8 | serviceHost: string; 9 | servicePort: number; 10 | protocol: string; 11 | tags?: string; 12 | }; 13 | 14 | export type RoutingEntry = Service & { 15 | routes: Route[]; 16 | i?: number; 17 | }; 18 | 19 | export type RoutingTable = RoutingEntry[]; 20 | -------------------------------------------------------------------------------- /src/ServiceProvider.ts: -------------------------------------------------------------------------------- 1 | import Peer from "./Peer.js"; 2 | import Message, { MessageContent } from "./Message.js"; 3 | import UDPNet from "./UDPNet.js"; 4 | import { MessageActions } from "./Message.js"; 5 | import { AuthorizedPeer } from "./Peer.js"; 6 | import { RoutingTable, Service } from "./Router.js"; 7 | import Utils from "./Utils.js"; 8 | import TCPNet from "./TCPNet.js"; 9 | 10 | export default class ServiceProvider extends Peer { 11 | private services: Array = []; 12 | 13 | constructor(secret: string, opts?: object) { 14 | super(secret, false, opts); 15 | this.start().catch(console.error); 16 | } 17 | 18 | public setServices(services: Service[]): Array { 19 | this.services = []; 20 | for (const service of services) { 21 | this.addService(service.gatePort, service.serviceHost, service.servicePort, service.protocol); 22 | } 23 | return this.services; 24 | } 25 | 26 | public addService(gatePort: number, serviceHost: string, servicePort: number, serviceProto: string, tags?: string): Service { 27 | let service = this.services.find((s) => s.gatePort == gatePort && s.serviceHost == serviceHost && s.servicePort == servicePort && s.protocol == serviceProto && s.tags == tags); 28 | if (service) { 29 | console.log("Service already exists"); 30 | return service; 31 | } 32 | console.info("Register service " + gatePort + " " + serviceHost + " " + servicePort + " " + serviceProto); 33 | service = { 34 | serviceHost, 35 | servicePort, 36 | protocol: serviceProto || "tcp", 37 | gatePort: gatePort, 38 | tags: tags, 39 | }; 40 | this.services.push(service); 41 | return service; 42 | } 43 | 44 | public getServices(gatePort: number) { 45 | return this.services.filter((s) => s.gatePort == gatePort); 46 | } 47 | 48 | private createRoutingTableFragment(): RoutingTable { 49 | const routingTable: RoutingTable = []; 50 | if (Object.keys(this.services).length == 0) return routingTable; 51 | for (const service of this.services) { 52 | const routingEntry = { 53 | ...service, 54 | routes: [], 55 | }; 56 | routingTable.push(routingEntry); 57 | } 58 | return routingTable; 59 | } 60 | 61 | protected override async onRefresh() { 62 | try { 63 | // Advertise local routes to newly connected peer 64 | const rfr = this.createRoutingTableFragment(); 65 | if (rfr) { 66 | await this.broadcast(Message.create(MessageActions.advRoutes, { routes: rfr })); 67 | console.log("Broadcast routing fragment", rfr); 68 | } 69 | } catch (e) { 70 | console.error(e); 71 | } 72 | } 73 | 74 | protected override async onAuthorizedMessage(peer: AuthorizedPeer, msg: MessageContent) { 75 | await super.onAuthorizedMessage(peer, msg); 76 | try { 77 | // close channel bidirectional 78 | const closeChannel = (channelPort: number) => { 79 | const channel = peer.channels[channelPort]; 80 | if (!channel) return; 81 | try { 82 | if (channel.route) { 83 | this.send( 84 | channel.route, 85 | Message.create(MessageActions.close, { 86 | channelPort: channelPort, 87 | }), 88 | ); 89 | } 90 | } catch (e) { 91 | console.error(e); 92 | } 93 | if (channel.socket) { 94 | channel.socket.end(); 95 | } 96 | channel.alive = false; 97 | delete peer.channels[channelPort]; 98 | }; 99 | 100 | // open new channel 101 | if (msg.actionId == MessageActions.open) { 102 | // open connection to service 103 | const gatePort = msg.gatePort; 104 | if (!gatePort) throw "Gate port is required"; 105 | const service = this.getServices(gatePort)[0]; 106 | if (!service) throw "Service not found " + gatePort; 107 | const isUDP = service.protocol == "udp"; 108 | const channelPort = msg.channelPort; 109 | if (channelPort == null) throw "Channel port is required"; 110 | 111 | // Service not found, tell peer there was an error 112 | if (!service) { 113 | console.error("service not found"); 114 | this.send( 115 | peer.info.publicKey, 116 | Message.create(MessageActions.open, { 117 | channelPort: msg.channelPort, 118 | error: "Service " + gatePort + " not found", 119 | }), 120 | ); 121 | // closeChannel(msg.channelPort); 122 | return; 123 | } 124 | 125 | // connect to service 126 | console.log("Connect to", service.serviceHost, service.servicePort, isUDP ? "UDP" : "TCP", "on channel", msg.channelPort); 127 | 128 | const serviceConn = (isUDP ? UDPNet : TCPNet).connect({ 129 | host: service.serviceHost, 130 | port: service.servicePort, 131 | allowHalfOpen: true, 132 | }); 133 | 134 | const duration = Utils.getConnDuration(isUDP); 135 | // create channel 136 | const channel = { 137 | socket: serviceConn, 138 | duration, 139 | expire: Date.now() + duration, 140 | gatePort: gatePort, 141 | alive: true, 142 | route: peer.info.publicKey, 143 | channelPort: channelPort, 144 | service: service, 145 | }; 146 | peer.channels[channel.channelPort] = channel; 147 | 148 | // pipe from service to route 149 | serviceConn.on("data", (data) => { 150 | // every time data is piped, reset channel expire time 151 | channel.expire = Date.now() + channel.duration; 152 | 153 | this.send( 154 | channel.route, 155 | Message.create(MessageActions.stream, { 156 | channelPort: msg.channelPort, 157 | data: data, 158 | }), 159 | ); 160 | }); 161 | 162 | const timeout = () => { 163 | if (!channel.alive) return; 164 | try { 165 | if (serviceConn.destroyed || channel.expire < Date.now()) { 166 | console.log("Channel expired!"); 167 | closeChannel(channel.channelPort); 168 | } 169 | } catch (e) { 170 | console.error(e); 171 | } 172 | setTimeout(timeout, 1000 * 60); 173 | }; 174 | timeout(); 175 | 176 | serviceConn.on("error", (err) => { 177 | console.error(err); 178 | closeChannel(channel.channelPort); 179 | }); 180 | 181 | serviceConn.on("close", () => { 182 | closeChannel(channel.channelPort); 183 | }); 184 | 185 | serviceConn.on("end", () => { 186 | closeChannel(channel.channelPort); 187 | }); 188 | 189 | // confirm open channel 190 | this.send( 191 | peer.info.publicKey, 192 | Message.create(MessageActions.open, { 193 | channelPort: msg.channelPort, 194 | gatePort: msg.gatePort, 195 | }), 196 | ); 197 | } else { 198 | const channelPort = msg.channelPort; 199 | if (channelPort == null) throw "Channel port is required"; 200 | 201 | // pipe from route to service 202 | if (msg.actionId == MessageActions.stream) { 203 | const data = msg.data; 204 | if (!data) throw "Data is required"; 205 | const channel = peer.channels[channelPort]; 206 | if (channel) { 207 | // console.log("Pipe to route"); 208 | // every time data is piped, reset channel expire time 209 | channel.expire = Date.now() + channel.duration; 210 | channel.socket.write(data); 211 | } 212 | } else if (msg.actionId == MessageActions.close) { 213 | // close channel 214 | if (channelPort <= 0) { 215 | for (const channel of Object.values(peer.channels)) { 216 | closeChannel(channel.channelPort); 217 | } 218 | } else { 219 | closeChannel(channelPort); 220 | } 221 | } 222 | } 223 | } catch (e) { 224 | console.error(e); 225 | if (msg.actionId) { 226 | this.send( 227 | peer.info.publicKey, 228 | Message.create(msg.actionId, { 229 | error: e?.toString(), 230 | }), 231 | ); 232 | } 233 | } 234 | } 235 | } 236 | -------------------------------------------------------------------------------- /src/TCPNet.ts: -------------------------------------------------------------------------------- 1 | import Net from 'net'; 2 | 3 | const timeout = 60 * 60 * 1000; 4 | 5 | export default class TCPNet { 6 | public static connect(options: Net.NetConnectOpts, connectionListener?: () => void): Net.Socket{ 7 | options.timeout = options.timeout ?? timeout; 8 | const socket:Net.Socket = Net.connect(options, connectionListener); 9 | socket.setKeepAlive(true); 10 | return socket; 11 | } 12 | 13 | public static createServer(connectionListener?: (socket: Net.Socket) => void): Net.Server{ 14 | const server:Net.Server = Net.createServer(connectionListener); 15 | server.on('connection', (socket) => { 16 | socket.setKeepAlive(true); 17 | socket.setTimeout(timeout); 18 | }); 19 | return server; 20 | } 21 | } -------------------------------------------------------------------------------- /src/UDPNet.ts: -------------------------------------------------------------------------------- 1 | import Dgram from "dgram"; 2 | 3 | /** 4 | * Simple and incomplete wrapper around dgram to make it look like Net 5 | */ 6 | export default class UDPNet { 7 | private server: Dgram.Socket; 8 | private isServer: boolean; 9 | private isCloseable: boolean; 10 | private events: { [key: string]: Array<(...args: any) => void | Promise> } = {}; 11 | private connections: { [key: string]: UDPNet } = {}; 12 | // private channelId: number; 13 | public remotePort: number = 0; 14 | public remoteAddress: string = ""; 15 | public localPort: number = 0; 16 | public localAddress: string = ""; 17 | public isClosed: boolean = false; 18 | private onConnection?: (conn: UDPNet) => void; 19 | 20 | // udp socket is never destroyed 21 | public readonly destroyed: boolean = false; 22 | 23 | static createServer(onConnection: (conn: UDPNet) => void): UDPNet { 24 | const server = new UDPNet(Dgram.createSocket("udp4")); 25 | server.onConnection = onConnection; 26 | server.isServer = true; 27 | server.isCloseable = true; 28 | return server; 29 | } 30 | 31 | static connect(options: { host: string; port: number }): UDPNet { 32 | const { host, port } = options; 33 | const s = new UDPNet(Dgram.createSocket("udp4"), false, true); 34 | s.connect(port, host); 35 | return s; 36 | } 37 | 38 | constructor(server: Dgram.Socket, isServer = true, isCloseable = true) { 39 | this.server = server; 40 | this.isServer = isServer; 41 | this.isCloseable = isCloseable; 42 | 43 | this.server.on("message", (data, info) => { 44 | const key = info.address + ":" + info.port; 45 | if (this.isServer) { 46 | let conn = this.connections[key]; 47 | if (!conn) { 48 | console.log("Connection not found for", key, "creating new connection"); 49 | conn = new UDPNet(this.server); 50 | conn.remotePort = info.port; 51 | conn.remoteAddress = info.address; 52 | conn.localPort = this.localPort; 53 | conn.localAddress = this.localAddress; 54 | conn.isCloseable = false; 55 | this.connections[key] = conn; 56 | if (this.onConnection) this.onConnection(conn); 57 | } 58 | conn.emitEvent("data", [data]); 59 | } else { 60 | this.emitEvent("data", [data]); 61 | } 62 | }); 63 | 64 | this.server.on("close", async () => { 65 | for (const c of Object.values(this.connections)) { 66 | c.close(); 67 | } 68 | this.emitEvent("close", []); 69 | this.isClosed = true; 70 | }); 71 | 72 | this.server.on("error", async (err) => { 73 | for (const c of Object.values(this.connections)) { 74 | c.emitEvent("error", [err]); 75 | } 76 | this.emitEvent("error", [err]); 77 | }); 78 | } 79 | 80 | public connect(port: number, host: string): void { 81 | if (this.isServer) throw new Error("This socket is not connectable"); 82 | this.remotePort = port; 83 | this.remoteAddress = host; 84 | this.server.connect(port, host); 85 | } 86 | 87 | public close(): void { 88 | this.isClosed = true; 89 | if (this.isCloseable) { 90 | if (!this.isClosed) { 91 | this.server.close(); 92 | this.emitEvent("close", []); 93 | this.close(); 94 | } 95 | } else { 96 | const key = this.remoteAddress + ":" + this.remotePort; 97 | const conn = this.connections[key]; 98 | if (conn) { 99 | conn.emitEvent("close", []); 100 | delete this.connections[key]; 101 | } 102 | } 103 | } 104 | 105 | public end(): void { 106 | this.close(); 107 | } 108 | 109 | public write(data: Buffer): void { 110 | if (this.isServer) throw new Error("This socket is not writable"); 111 | this.server.send(data, this.remotePort, this.remoteAddress); 112 | } 113 | 114 | public on(event: string, cb: (...args: any) => void | Promise): void { 115 | if (!this.events[event]) this.events[event] = []; 116 | this.events[event].push(cb); 117 | } 118 | 119 | private emitEvent(event: string, payload: Array): void { 120 | const listeners = this.events[event]; 121 | if (!listeners) return; 122 | for (const l of listeners) { 123 | const r: any = l(...payload); 124 | if (r instanceof Promise) r.catch(console.error); 125 | } 126 | } 127 | 128 | public listen(port: number, addr: string, dataListener?: (data: Buffer) => void): void { 129 | if (!this.isServer) throw new Error("Can't listen on this socket!"); 130 | this.server.bind(port, addr); 131 | this.localPort = port; 132 | this.localAddress = addr; 133 | this.server.on("listening", () => { 134 | //const address = socket.address(); 135 | //this.localPort = address.port; 136 | this.emitEvent("listening", []); 137 | }); 138 | if (dataListener) this.on("data", dataListener); 139 | } 140 | 141 | public address(): { port: number; address: string } { 142 | return { 143 | port: this.localPort, 144 | address: this.localAddress, 145 | }; 146 | } 147 | } 148 | -------------------------------------------------------------------------------- /src/Utils.ts: -------------------------------------------------------------------------------- 1 | // @ts-ignore 2 | import Sodium from "sodium-universal"; 3 | // @ts-ignore 4 | import HyperDHT from "@hyperswarm/dht"; 5 | // @ts-ignore 6 | import b4a from "b4a"; 7 | 8 | export default class Utils { 9 | static getConnDuration(isUDP: boolean): number { 10 | // How long a connection will stay alive without data exchange 11 | let duration = 1000 * 60; 12 | if (!isUDP) { 13 | // 21 years for tcp (it's a long time...) 14 | // actually we want this closed by the underlying tcp stack, that's why we defacto never expire 15 | duration = 1000 * 60 * 60 * 24 * 360 * 21; 16 | } else { 17 | // 1 hour for udp 18 | duration = 1000 * 60 * 60; 19 | } 20 | return duration; 21 | } 22 | 23 | static newSecret() { 24 | const b = Buffer.alloc(Sodium.randombytes_SEEDBYTES); 25 | Sodium.randombytes_buf(b); 26 | return b.toString("hex"); 27 | } 28 | 29 | static getRouterKeys(secret: string) { 30 | return HyperDHT.keyPair(Buffer.from(secret, "hex")); 31 | } 32 | 33 | static getRouterName(secret: string) { 34 | const keys = Utils.getRouterKeys(secret); 35 | return b4a.toString(keys.publicKey, "hex"); 36 | } 37 | 38 | static async scanRouter(routerName: string) { 39 | console.info("Scanning", routerName); 40 | const node = new HyperDHT(); 41 | const topic = b4a.from(routerName, "hex"); 42 | await node.announce(topic); 43 | 44 | for await (const e of node.lookup(topic)) { 45 | for (const p of e.peers) { 46 | const publicKey = p.publicKey; 47 | const socket = node.connect(publicKey); 48 | socket.on("open", function () { 49 | if (socket.rawStream) { 50 | console.info(socket.rawStream.remoteHost); 51 | } 52 | }); 53 | socket.on("connect", function () { 54 | if (socket.rawStream) { 55 | console.info(socket.rawStream.remoteHost); 56 | } 57 | }); 58 | socket.on("error", () => { 59 | if (socket.rawStream) { 60 | console.info(socket.rawStream.remoteHost); 61 | } 62 | }); 63 | } 64 | } 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /src/cli.ts: -------------------------------------------------------------------------------- 1 | import Minimist from "minimist"; 2 | import Utils from "./Utils.js"; 3 | import ServiceProvider from "./ServiceProvider.js"; 4 | import Gateway from "./Gateway.js"; 5 | import Fs from "fs"; 6 | import DockerManager from "./DockerManager.js"; 7 | 8 | function help(argv: string[] = []) { 9 | const launchCmd = argv[0] + " " + argv[1]; 10 | console.info(` 11 | Usage: ${launchCmd} [options] [router] 12 | 13 | Generate a new router: 14 | ${launchCmd} --new 15 | 16 | Scan a router: 17 | ${launchCmd} --scan 18 | 19 | Start a service provider: 20 | ${launchCmd} --provider [--provider ] --router 21 | 22 | Start a service gateway: 23 | ${launchCmd} --gateway --router 24 | 25 | Gateway options: 26 | --listen : Listen on ip (default 127.0.0.1) 27 | 28 | Options: 29 | --help : Show this help 30 | --docker []: Run in docker mode 31 | --exposeOnlyPublished: Expose only services that are marked as published (eg. they have a public port in docker) 32 | --exposeOnlyDocker: Expose only services that are docker containers (default: true if --docker is set) 33 | --exposeOnlyServices : csv list of services to expose 34 | --network : Docker network name 35 | --image : Docker image name 36 | --refreshTime