├── .gitignore ├── app ├── .dockerignore ├── .gitignore ├── Dockerfile.dev ├── src │ ├── config.ts │ ├── types.ts │ ├── docker.ts │ ├── log.ts │ ├── utils.ts │ ├── networking.ts │ └── app.ts ├── Dockerfile ├── build.js ├── tsconfig.json ├── package.json ├── develop_in_container.sh └── pnpm-lock.yaml ├── config └── wireguard-test │ ├── client.conf │ └── server.conf ├── LICENSE ├── .github └── workflows │ └── docker-image.yml ├── docker-compose.yml └── README.md /.gitignore: -------------------------------------------------------------------------------- 1 | data/ 2 | .vscode -------------------------------------------------------------------------------- /app/.dockerignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .pnpm-store 3 | dist -------------------------------------------------------------------------------- /app/.gitignore: -------------------------------------------------------------------------------- 1 | .pnpm-store 2 | node_modules 3 | dist 4 | !.gitkeep -------------------------------------------------------------------------------- /app/Dockerfile.dev: -------------------------------------------------------------------------------- 1 | FROM node:18-alpine 2 | 3 | RUN apk add git bash iproute2 curl iptables 4 | RUN npm install -g pnpm 5 | RUN mkdir -p /app 6 | WORKDIR /app -------------------------------------------------------------------------------- /app/src/config.ts: -------------------------------------------------------------------------------- 1 | import process from "process" 2 | 3 | export default { 4 | LOG_LEVEL: { 5 | debug: "debug", 6 | info: "info" 7 | }[(process.env.LOG_LEVEL || "info").toLowerCase()] || "info", 8 | DEV: Boolean(process.env.DEV || false), 9 | } -------------------------------------------------------------------------------- /app/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:18-alpine as build-stage 2 | 3 | RUN npm install -g pnpm 4 | RUN mkdir -p /app 5 | WORKDIR /app 6 | COPY . /app/. 7 | RUN pnpm i && pnpm run build 8 | 9 | FROM node:18-alpine 10 | RUN apk add iptables iproute2 11 | COPY --from=build-stage /app/dist/ /app/ 12 | CMD node /app/app.js start 13 | -------------------------------------------------------------------------------- /config/wireguard-test/client.conf: -------------------------------------------------------------------------------- 1 | [Interface] 2 | Address = 10.250.0.2 3 | PrivateKey = CBnSY2uWaLJgVT5bSbFw7TLhYcwHRRAypqfjbJgNR34= 4 | 5 | [Peer] 6 | PublicKey = y16OSc81zztqYGzRgk6Y9XhEoZl5aPVnMqgXCF1cdkA= 7 | PresharedKey = OVQaO5cFBoWQXDAawoL6b1Mgdt3aq+CH4sAlAV5RcBo= 8 | Endpoint = wireguard-server:51820 9 | AllowedIPs = 0.0.0.0/1, 128.0.0.0/1 10 | -------------------------------------------------------------------------------- /app/build.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | let esbuild = require("esbuild") 3 | 4 | module.exports.build = async()=>{ 5 | await esbuild.build({ 6 | entryPoints: ["./src/app.ts"], 7 | bundle: true, 8 | outfile: "./dist/app.js", 9 | platform: "node", 10 | format: "cjs", 11 | target: ["node14"], 12 | sourcemap: "inline", 13 | minify: false, 14 | plugins: [ 15 | require("esbuild-plugin-alias-path").aliasPath({}), 16 | ] 17 | }) 18 | } 19 | 20 | if (require.main === module){ 21 | module.exports.build() 22 | } -------------------------------------------------------------------------------- /config/wireguard-test/server.conf: -------------------------------------------------------------------------------- 1 | [Interface] 2 | Address = 10.250.0.1 3 | ListenPort = 51820 4 | PrivateKey = 2A4m96He4jv6WH2eXnaEyDJORh6AodG854y4gyaUTmE= 5 | # PostUp = iptables -A FORWARD -i %i -j ACCEPT; iptables -A FORWARD -o %i -j ACCEPT; iptables -t nat -A POSTROUTING -o eth+ -j MASQUERADE 6 | # PostDown = iptables -D FORWARD -i %i -j ACCEPT; iptables -D FORWARD -o %i -j ACCEPT; iptables -t nat -D POSTROUTING -o eth+ -j MASQUERADE 7 | 8 | [Peer] 9 | # peer1 10 | PublicKey = QInH6aE45ZLWA0Zu/p7vrAvlvWnp1IcXZ8vVWHmKdRw= 11 | PresharedKey = OVQaO5cFBoWQXDAawoL6b1Mgdt3aq+CH4sAlAV5RcBo= 12 | AllowedIPs = 10.250.0.2/32 13 | 14 | -------------------------------------------------------------------------------- /app/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "baseUrl": ".", 4 | "paths": { 5 | "@/*": ["src/*"] 6 | }, 7 | "allowJs": false, 8 | "target": "esnext", 9 | "module": "commonjs", 10 | "moduleResolution": "node", 11 | "resolveJsonModule": true, 12 | "esModuleInterop": true, 13 | "importHelpers": true, 14 | "allowSyntheticDefaultImports": true, 15 | "emitDecoratorMetadata": true, 16 | "experimentalDecorators": true, 17 | "strict": false, 18 | "outDir": "./dist/", 19 | "skipLibCheck": true, 20 | }, 21 | "ts-node": { 22 | "files": true 23 | }, 24 | "include": [ 25 | "src/**/*.ts" 26 | ], 27 | "exclude": ["node_modules"] 28 | } -------------------------------------------------------------------------------- /app/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "twine", 3 | "version": "0.0.1", 4 | "description": "", 5 | "main": "./dist/app.js", 6 | "scripts": { 7 | "start": "node --enable-source-maps ./dist/app.js start", 8 | "build": "node ./build.js", 9 | "dev": "pnpx nodemon -L -w ./src -e ts --exec \"pnpm build && pnpm start\"" 10 | }, 11 | "private": true, 12 | "license": "UNLICENSE", 13 | "dependencies": { 14 | "netmask": "^2.0.2", 15 | "node-docker-api": "^1.1.22", 16 | "neodoc": "^2.0.2", 17 | "tmp": "^0.2.3", 18 | "tar-stream": "^3.1.7" 19 | }, 20 | "devDependencies": { 21 | "esbuild": "^0.25.0", 22 | "esbuild-plugin-alias-path": "^2.0.2", 23 | "tslib": "^2.8.1", 24 | "typescript": "^5.7.3", 25 | "@types/node": "^18.0.0", 26 | "@types/netmask": "^2.0.5", 27 | "nodemon": "^3.1.9" 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /app/src/types.ts: -------------------------------------------------------------------------------- 1 | export type IPRoute = { 2 | destination: string, 3 | gateway: string, 4 | mask: string, 5 | metric: number, 6 | } 7 | 8 | export type Interface = { 9 | name: string, 10 | network: string, 11 | address: boolean, 12 | } 13 | 14 | export type ContainerRoute = { 15 | network: string, 16 | destination: string, 17 | } 18 | 19 | export type ContainerPortForwardRule = { 20 | interface: string, 21 | protocol: string, 22 | sourcePort: string, 23 | destination: string, 24 | destinationPort: string, 25 | } 26 | 27 | export type Container = { 28 | id: string, 29 | pid: string, 30 | name: string, 31 | iptables: { 32 | customRules: {[name: string]: string} 33 | }, 34 | nat: { 35 | interfaces: Array, 36 | portForwarding: Array, 37 | } 38 | host: { 39 | routes: Array, 40 | } 41 | routes: Array, 42 | } 43 | 44 | -------------------------------------------------------------------------------- /app/develop_in_container.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash -x 2 | 3 | # This script provides a way to develop within docker container, 4 | # elimnating the need to setup project-specific development environment 5 | 6 | # This script assumes that source code was mounted as docker volume, 7 | # to do that you need to specify: 8 | # volumes: 9 | # - ./backend:/app/ 10 | # in the docker-compose.yml file 11 | 12 | echo "Synchronizing node_modules with docker host..." 13 | 14 | # Sync node_modules container>host to provide type checking and hints in editor 15 | rsync -av --info=progress2 --info=name0 /app_dependencies/node_modules/ /app_live/node_modules & 16 | 17 | # Sync volume /app_live host>container to container's path /app 18 | # This is done to avoid slow reads on MacOS/Windows docker volumes 19 | npx nodemon -L -w /app_live -e ts --exec rsync -av --exclude node_modules /app_live/ /app & 20 | 21 | echo "Launching development environment..." 22 | 23 | npm run develop -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | This is free and unencumbered software released into the public domain. 2 | 3 | Anyone is free to copy, modify, publish, use, compile, sell, or 4 | distribute this software, either in source code form or as a compiled 5 | binary, for any purpose, commercial or non-commercial, and by any 6 | means. 7 | 8 | In jurisdictions that recognize copyright laws, the author or authors 9 | of this software dedicate any and all copyright interest in the 10 | software to the public domain. We make this dedication for the benefit 11 | of the public at large and to the detriment of our heirs and 12 | successors. We intend this dedication to be an overt act of 13 | relinquishment in perpetuity of all present and future rights to this 14 | software under copyright law. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 17 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 18 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. 19 | IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR 20 | OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, 21 | ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR 22 | OTHER DEALINGS IN THE SOFTWARE. 23 | 24 | For more information, please refer to -------------------------------------------------------------------------------- /app/src/docker.ts: -------------------------------------------------------------------------------- 1 | import nodeDockerApi from "node-docker-api" 2 | import tarStream from "tar-stream" 3 | 4 | import * as utils from "@/utils" 5 | 6 | export let docker 7 | 8 | export let init = async()=>{ 9 | docker = new nodeDockerApi.Docker({socketPath: "/var/run/docker.sock"}) 10 | } 11 | 12 | export let list = async(options={}): Promise>=>{ 13 | return await docker.container.list(options) 14 | } 15 | 16 | export let exec = async(containerId, command)=>{ 17 | command = command.replace(/\n/g, " && ").replace(/&&\s*$/m, "") 18 | let process = await docker.container.get(containerId).exec.create({ 19 | AttachStdout: true, 20 | AttachStderr: true, 21 | Cmd: command.split(" ") 22 | }) 23 | let stream = await process.start({ Detach: false }) 24 | let output = "" 25 | await new Promise((resolve, reject) => { 26 | stream.on("data", (data) =>{ 27 | output += data.toString("utf8") 28 | }) 29 | stream.on("error", reject) 30 | stream.on("end", resolve); 31 | }) 32 | return output 33 | } 34 | 35 | export let execNS = async(pid, command, options?): Promise=>{ 36 | return await utils.exec(`nsenter -n/proc/${pid}/ns/net ${command}`, options) as string 37 | } 38 | 39 | export let upload = async(containerId, files)=>{ 40 | let tarArchive = tarStream.pack() 41 | for(let [name, data] of Object.entries(files)){ 42 | tarArchive.entry({name, mode: 0o777}, data) 43 | } 44 | tarArchive.finalize() 45 | await docker.container.get(containerId).fs.put(tarArchive, { 46 | path: "/" 47 | }) 48 | } 49 | 50 | export default exports -------------------------------------------------------------------------------- /.github/workflows/docker-image.yml: -------------------------------------------------------------------------------- 1 | name: Docker Image CI 2 | 3 | on: 4 | push: 5 | tags: 6 | - "*" 7 | env: 8 | IMAGE_NAME: twine 9 | 10 | jobs: 11 | 12 | build: 13 | runs-on: ubuntu-latest 14 | permissions: 15 | packages: write 16 | contents: read 17 | 18 | steps: 19 | - uses: actions/checkout@v3 20 | 21 | - name: Get TAG 22 | id: get_tag 23 | run: echo TAG=${GITHUB_REF#refs/tags/} >> $GITHUB_ENV 24 | 25 | - name: Get Repo Owner 26 | id: get_repo_owner 27 | run: echo "REPO_OWNER=$(echo ${{ github.repository_owner }} | tr '[:upper:]' '[:lower:]')" > $GITHUB_ENV 28 | 29 | - name: Set up QEMU 30 | uses: docker/setup-qemu-action@v3 31 | 32 | - name: Set up Docker Buildx 33 | uses: docker/setup-buildx-action@v3 34 | 35 | - name: Login to container Registry 36 | uses: docker/login-action@v2 37 | with: 38 | username: ${{ github.repository_owner }} 39 | password: ${{ secrets.GITHUB_TOKEN }} 40 | registry: ghcr.io 41 | 42 | - name: Release build 43 | id: release_build 44 | uses: docker/build-push-action@v5 45 | with: 46 | outputs: "type=registry,push=true" 47 | provenance: false 48 | platforms: linux/amd64,linux/arm64,linux/arm/v7 49 | context: "./app" 50 | tags: | 51 | ghcr.io/${{ env.REPO_OWNER }}/${{ env.IMAGE_NAME }}:${{ github.sha }} 52 | ghcr.io/${{ env.REPO_OWNER }}/${{ env.IMAGE_NAME }}:${{ env.TAG }} 53 | ghcr.io/${{ env.REPO_OWNER }}/${{ env.IMAGE_NAME }}:latest -------------------------------------------------------------------------------- /app/src/log.ts: -------------------------------------------------------------------------------- 1 | import config from "@/config" 2 | 3 | export let started = Number(new Date()) 4 | 5 | export class FakeError extends Error { 6 | stackRaw?: any; 7 | constructor(message: string) { 8 | super(message) 9 | let _prepareStackTrace = Error.prepareStackTrace 10 | Error.prepareStackTrace = (error, stack) => { 11 | return _prepareStackTrace(error, stack).split("\n").slice(1) 12 | } 13 | this.stackRaw = this.stack 14 | Error.prepareStackTrace = _prepareStackTrace 15 | } 16 | } 17 | 18 | export let formattedLogger = (type, loggingFunction)=>{ 19 | return async(message: string, data:Object|Array=null)=>{ 20 | if(config.LOG_LEVEL != "debug" && type == "DEBUG"){ 21 | return 22 | } 23 | 24 | let stackTraceLimit = Number(Error.stackTraceLimit) 25 | Error.stackTraceLimit = 1000 26 | let stack: Array = (new Error()).stack as any 27 | Error.stackTraceLimit = stackTraceLimit 28 | 29 | if(typeof stack === "string"){ 30 | stack = (stack as string).split("\n") 31 | } 32 | 33 | let origin = "unknown" 34 | try{ 35 | // Travel through the callstack until we are in the log function callsite 36 | for(let callsite of stack){ 37 | let _origin = String(callsite).replace(/\\/g, "/").match(/src[\/\\](.*?)\)?$/m) 38 | if(_origin && !_origin[1].match(/log\.ts(:\d+)?(:\d+)?$/m)){ 39 | origin = _origin[1] 40 | break 41 | } 42 | } 43 | }catch(error){ 44 | console.log("erro caught", error) 45 | } 46 | 47 | let date = new Date().toISOString() 48 | let runningTime = ((Number(new Date()) - started)/1000).toFixed(3) 49 | let dataEncoded = "" 50 | if(data instanceof Error){ 51 | dataEncoded = String((data).stack) 52 | }else if(data){ 53 | dataEncoded = JSON.stringify(data, null, 4) 54 | } 55 | let text = `[${type} ${date} ${runningTime} ${origin}] ${message} ${dataEncoded}` 56 | loggingFunction(text) 57 | } 58 | } 59 | 60 | 61 | export let info = formattedLogger("INFO", console.log) 62 | export let error = formattedLogger("ERROR", console.error) 63 | export let debug = formattedLogger("DEBUG", console.log) 64 | 65 | export default exports -------------------------------------------------------------------------------- /app/src/utils.ts: -------------------------------------------------------------------------------- 1 | import fs from "fs" 2 | import process from "process" 3 | import childProcess from "child_process" 4 | import tmp from "tmp" 5 | 6 | import * as log from "@/log" 7 | 8 | export let sleep = (ms)=>{ 9 | return new Promise(resolve => setTimeout(resolve, ms)) 10 | } 11 | 12 | export let copy = (value)=>{ 13 | return JSON.parse(JSON.stringify(value)) 14 | } 15 | 16 | export let schedule = (handler, { 17 | every=1000*60, 18 | immediate=true 19 | })=>{ 20 | let canceled 21 | let worker = (async()=>{ 22 | try{ 23 | await handler() 24 | }catch(error){ 25 | log.error(`Error occurred while running a scheduled task`, error) 26 | } 27 | if(!canceled) setTimeout(worker, every) 28 | }) 29 | if(immediate){ 30 | worker() 31 | }else{ 32 | setTimeout(worker, every) 33 | } 34 | return ()=>{ 35 | canceled = true 36 | } 37 | } 38 | 39 | export let wait = async(condition, {timeout=240000, interval=1000}={})=>{ 40 | for(let i=0; i<=Math.round(timeout/interval); i++){ 41 | if( await condition() ) return 42 | await sleep(interval) 43 | } 44 | } 45 | 46 | export let exec = async(commands, options: { 47 | wait?: Boolean, 48 | log?: Boolean, 49 | ignore?: Boolean, 50 | cwd?: any, 51 | env?: any, 52 | }={})=>{ 53 | if(options.wait == undefined) options.wait = true; 54 | if(options.log == undefined) options.log = false; 55 | if(options.ignore == undefined) options.ignore = false; 56 | if(commands.length > 1024*10){ 57 | // Optimization for bulk execution 58 | let tempFile = tmp.tmpNameSync() 59 | fs.writeFileSync(tempFile, `${commands}`) 60 | commands = `/bin/sh ${tempFile}` 61 | } 62 | let output = "" 63 | commands = commands.replace(/\r/g, "").split("\n").map(command=>command.trim()).filter(command=>command) 64 | for(let command of commands){ 65 | let subprocess = childProcess.spawn(command, [], { 66 | shell: true, 67 | cwd: options.cwd, 68 | env: options.env 69 | }) 70 | let callback = (data)=>{ 71 | try{ 72 | if(options.log){ 73 | process.stdout.write(data.toString("utf8")) 74 | } 75 | if(options.wait && commands.length == 1 && output.length < 1024*1024*10){ 76 | output += data.toString("utf8") 77 | } 78 | }catch(error){ 79 | log.debug(`Failed decoding exec output`, error) 80 | } 81 | } 82 | subprocess.stdout.on("data", callback) 83 | subprocess.stderr.on("data", callback) 84 | 85 | if(options.wait){ 86 | await new Promise((resolve, reject)=>{ 87 | subprocess.on("error", reject) 88 | subprocess.on("close", (code)=>{ 89 | if(code !== 0 && !options.ignore){ 90 | reject(new Error(`${command}: exec returned code:${code}`)) 91 | }else{ 92 | resolve(null) 93 | } 94 | }) 95 | }) 96 | if(commands.length == 1){ 97 | return output 98 | } 99 | }else{ 100 | if(commands.length == 1){ 101 | return subprocess 102 | } 103 | } 104 | } 105 | } 106 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | # This docker-compose file is used for local development 2 | services: 3 | 4 | # Twine 5 | twine: 6 | build: 7 | context: ./app 8 | dockerfile: Dockerfile.dev 9 | restart: unless-stopped 10 | pid: host 11 | privileged: true 12 | volumes: 13 | - /var/run/docker.sock:/var/run/docker.sock 14 | - ./app:/app/ 15 | environment: 16 | - DEV=true 17 | command: sh -c "pnpm install && pnpm run dev" 18 | 19 | # Client side 20 | wireguard-client: 21 | image: lscr.io/linuxserver/wireguard:latest 22 | restart: unless-stopped 23 | cap_add: [NET_ADMIN, SYS_MODULE] 24 | sysctls: [net.ipv4.conf.all.src_valid_mark=1] 25 | depends_on: 26 | - wireguard-server 27 | networks: 28 | client: 29 | server: 30 | environment: 31 | - PUID=1000 32 | - PGID=1000 33 | - TZ=Etc/UTC 34 | volumes: 35 | - ./config/wireguard-test/client.conf:/config/wg_confs/wg0.conf:ro 36 | labels: 37 | # Interface for forwarding traffic (Required for "twine.route.*=wireguard-client" to work) 38 | - twine.nat.interfaces=+ 39 | # Expose qbittorrent on WireGuard Client ip address 40 | - twine.nat.forward.wg+.80=qbittorrent:80 41 | # - twine.host.routes=10.250.0.0/24 42 | # - twine.whitelist.eth+.to=qbittorrent 43 | 44 | qbittorrent: 45 | image: lscr.io/linuxserver/qbittorrent:latest 46 | restart: unless-stopped 47 | networks: 48 | - client 49 | environment: 50 | - PUID=1000 51 | - PGID=1000 52 | - TZ=Etc/UTC 53 | - WEBUI_PORT=80 54 | ports: 55 | - 127.0.0.1:80:80 56 | labels: 57 | # Route all outgoing traffic via WireGuard Client 58 | - twine.route.0.0.0.0/1=wireguard-client 59 | - twine.route.128.0.0.0/1=wireguard-client 60 | # Route only internal subnet via WireGuard Client 61 | # - twine.route.10.250.0.0/24=wireguard-client 62 | 63 | # Server side 64 | wireguard-server: 65 | image: lscr.io/linuxserver/wireguard:latest 66 | restart: unless-stopped 67 | cap_add: [NET_ADMIN, SYS_MODULE] 68 | networks: 69 | - server 70 | environment: 71 | - PUID=1000 72 | - PGID=1000 73 | - TZ=Etc/UTC 74 | - SERVERURL=wireguard-server 75 | - ALLOWEDIPS=0.0.0.0/0 76 | - PEERS=1 77 | - INTERNAL_SUBNET=10.250.0.0/24 78 | volumes: 79 | - ./config/wireguard-test/server.conf:/config/wg_confs/wg0.conf:ro 80 | - /lib/modules:/lib/modules 81 | labels: 82 | - twine.nat.interfaces=+ 83 | # Expose qbittorrent on WireGuard Server ip address 84 | - twine.nat.forward.80=helloworld 85 | # - twine.whitelist.wg+.to=helloworld 86 | - twine.iptables.rule.blockAll=TWINE_INPUT -s 10.250.0.1/24 -d 0.0.0.0 -j DROP 87 | - twine.iptables.rule.allowVPN=TWINE_INPUT -s 10.250.0.1/24 -d 10.250.0.1/24 -j ACCEPT 88 | 89 | 90 | helloworld: 91 | image: nginxdemos/hello 92 | restart: unless-stopped 93 | networks: 94 | - server 95 | # labels: 96 | # Make this container only accessible with CloudFlare IP address 97 | # - twine.whitelist.eth+.from=https://www.cloudflare.com/ips-v4/ 98 | 99 | networks: 100 | client: 101 | driver: bridge 102 | server: 103 | driver: bridge -------------------------------------------------------------------------------- /app/src/networking.ts: -------------------------------------------------------------------------------- 1 | import os from "os" 2 | import fs from "fs" 3 | 4 | import * as utils from "@/utils" 5 | import * as types from "@/types" 6 | 7 | 8 | // TODO: Implement ip route with named tables 9 | export class Router { 10 | exec: (command: string) => Promise 11 | constructor(exec?: (command: string) => Promise){ 12 | this.exec = exec || ((command)=>utils.exec(command) as any) 13 | } 14 | async fetch(): Promise>{ 15 | let routes: Array = [] 16 | let output = await this.exec(`route -ne`) 17 | for(let [_, destination, gateway, mask, metric] of output.matchAll(/^([\d\.]+)\s+([\d\.]+)\s+([\d\.]+)\s+\w+\s+(\d+)\s+/gm)){ 18 | routes.push({ 19 | destination, 20 | gateway, 21 | mask, 22 | metric: Number(metric) 23 | }) 24 | } 25 | return routes 26 | } 27 | async add(route: types.IPRoute){ 28 | await this.exec(`route add -net ${route.destination} netmask ${route.mask} gw ${route.gateway} metric ${route.metric}`) 29 | } 30 | async del(route: types.IPRoute){ 31 | await this.exec(`route del -net ${route.destination} netmask ${route.mask} gw ${route.gateway} metric ${route.metric}`) 32 | } 33 | async addHost(hostRoute: { 34 | destination: string, 35 | interface: string 36 | }){ 37 | await this.exec(`route add -host ${hostRoute.destination} dev ${hostRoute.interface}`) 38 | } 39 | } 40 | 41 | export class IPTables { 42 | exec: (command: string) => Promise 43 | constructor(exec?: (command: string) => Promise){ 44 | this.exec = exec || ((command)=>utils.exec(command) as any) 45 | } 46 | async check(rule){ 47 | try{ 48 | return !Boolean((await this.exec(`iptables ${rule.replace(/(?:^|\s)-A\s/m, " -C ")}`)).trim()) 49 | }catch(error){ 50 | return false 51 | } 52 | } 53 | async insert(rules: Array, {force=false}={}): Promise{ 54 | let updated = false 55 | for(let rule of rules){ 56 | if(!force&&rule.match(/(?:^|\s)-A\s/m) && await this.check(rule)) continue; 57 | try{ 58 | await this.exec(`iptables ${rule}`) 59 | updated = true 60 | }catch(error){ 61 | if(!force){ 62 | throw error 63 | } 64 | } 65 | } 66 | return updated 67 | } 68 | async remove(rules){ 69 | for(let rule of rules){ 70 | if(!rule.match(/(?:^|\s)-A\s/m)) continue; 71 | await this.exec(`iptables ${rule.replace(/(?:^|\s)-A\s/m, " -D ")}`) 72 | } 73 | } 74 | } 75 | 76 | export class NetworkInterfaces { 77 | exec: (command: string) => Promise 78 | constructor(exec?: (command: string) => Promise){ 79 | this.exec = exec || ((command)=>utils.exec(command) as any) 80 | } 81 | async fetch(): Promise{ 82 | let interfaces = {} 83 | let output = await this.exec(`ip addr`) 84 | for(let [_, _interface, data] of (output+"\n\n").matchAll(/^\d+\:\s(.*?)\:(.*?)(?=^\d|\n\n)/gms)){ 85 | let address = data.match(/inet\s(.*?)\//) 86 | if(address){ 87 | interfaces[_interface] = address[1] 88 | } 89 | } 90 | return interfaces 91 | } 92 | } 93 | 94 | export let matchNetworkInterface = (interfacePattern: string, interfaceName: string): Boolean=>{ 95 | return Boolean(interfaceName.match(RegExp(`${interfacePattern.replace("+", ".+")}`))) 96 | } 97 | 98 | export let iptables = new IPTables() 99 | export let router = new Router() 100 | export let networkInterfaces = new NetworkInterfaces() 101 | 102 | export default exports -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Twine 2 | ### Docker Container Network Management with Labels 3 | 4 | Current routing/forwarding implementations in Docker rely on `network_mode: service:vpn` method, which merges existing networks into one, creating some limitations, breaking network isolation and making it harder to write/read complex deployment configurations. 5 | 6 | Twine is created to improve this process with a simple label-based (similar to Traefik) approach that makes it easy to configure persitent networking rules and preserves containers full network isolation. 7 | 8 | Twine works by modifying container networking like iptables, routes, sysctl via permisson `pid: host` with `nsenter` in network layer of the docker containers. This preserves container network isolation, while keeping the changes in the temporary layer that gets automatically cleaned up in the container lifecycle. Twine automatically resolves the container hostnames and keeps configrations up to date, as the environment changes. 9 | 10 | 11 | ## API Referrence 12 | 13 | **WARNING: Twine undergone multiple heavy changes already, and the final syntax of the labels is not solidfied yet. Expect features to change and/or break until a stable release.** 14 | 15 | ### Container labels 16 | 17 | These labels, simillarly to the Traefik, apply networking configuration to the container, if specified in the `labels:` section of docker-compose. 18 | 19 | - #### `twine.nat.interfaces=[,...]` 20 | > `twine.nat.interfaces=wg+,eth+` 21 | 22 | > `twine.nat.interfaces=+` 23 | 24 | Enable NAT (forwarding) for the specified interfaces ([iptables interface pattern](https://linux.die.net/man/8/iptables)). Multiple interfaces can be specified, wildcards are supported. 25 | 26 | - #### `twine.nat.forward.[].[/tcp\|udp]=:` 27 | > `twine.nat.forward.eth0.25565/tcp=minecraft:25565` 28 | 29 | > `twine.nat.forward.wg+.80=nginx:80` 30 | 31 | > `twine.nat.forward.80=nginx:80` 32 | 33 | Forward incoming connections on `` to the specified `` 34 | 35 | - #### `twine.route.=` 36 | > `twine.route.192.168.0.1/24=wireguard` 37 | 38 | > `twine.route.0.0.0.0/0=wireguard` 39 | 40 | Create a route to the specified `` that can be reached via `` (gateway) 41 | 42 | - #### `twine.iptables.rule.=` 43 | > `twine.iptables.rule.blockAll=TWINE_INPUT -s 10.250.0.1/24 -d 0.0.0.0 -j DROP` 44 | 45 | > `twine.iptables.rule.allowVPN=TWINE_INPUT -s 10.250.0.1/24 -d 10.250.0.1/24 -j ACCEPT` 46 | 47 | Create a custom iptables rule with a ``. 48 | 49 | Note that `` must start with one of the avaialble chains: 50 | - `TWINE_INPUT` 51 | - `TWINE_OUTPUT` 52 | - `TWINE_FORWARD` 53 | - `TWINE_NAT_POSTROUTING` 54 | - `TWINE_NAT_PREROUTING` 55 | - `TWINE_NAT_OUTPUT` 56 | 57 | 58 | - #### `twine.host.routes=[,...]` 59 | > `twine.host.routes=192.168.100.1/24,10.20.0.0/24` 60 | 61 | > `twine.host.routes=192.168.0.1/24` 62 | 63 | Create a route from a Docker host machine to the container 64 | 65 | TODO: Implemented, but disabled. It is only possible to do on linux. Docker Desktop runtime (Windows/MacOS) is not supported... 66 | 67 | 73 | 74 | 75 | ## Usecase Examples 76 | 77 | Every Docker host machine must have at-least one instance of the twine container running. 78 | Only one instance can run at the time. 79 | 80 | ```yml 81 | services: 82 | twine: 83 | image: ghcr.io/bitwister/twine:latest 84 | restart: unless-stopped 85 | # Required access: 86 | volumes: 87 | - /var/run/docker.sock:/var/run/docker.sock 88 | privileged: true 89 | pid: host 90 | ``` 91 | 92 | ### VPN Stacking 93 | In this example traffic is "stacked" by routing `wireguard-client` VPN traffic via `xraytun` VPN tunnel to bypass local country restrictions on Wireguard. 94 | 95 | Using [goxray/tun](https://github.com/goxray/tun) in this example, but this can also work on any container which provides tunnelling interface. 96 | 97 | ```yml 98 | services: 99 | 100 | xraytun: 101 | image: ghcr.io/goxray/tun 102 | cap_add: [NET_ADMIN] 103 | networks: 104 | - main 105 | environment: 106 | # - CONFIG=vless://... 107 | labels: 108 | - twine.nat.interfaces=tun+ 109 | 110 | wireguard-client: 111 | image: lscr.io/linuxserver/wireguard:latest 112 | cap_add: [NET_ADMIN, SYS_MODULE] 113 | sysctls: [net.ipv4.conf.all.src_valid_mark=1] 114 | networks: 115 | - main 116 | labels: 117 | - twine.nat.interfaces=wg+ 118 | # Route all outgoing traffic via xraytun client 119 | - twine.route.0.0.0.0/1=xraytun 120 | - twine.route.128.0.0.0/1=xraytun 121 | 122 | networks: 123 | main: 124 | ``` 125 | 126 | ### VPN Infrastracture 127 | In this example whole internet traffic of `qbittorrent` container is routed via `wireguard-client` container. 128 | 129 | Additionally port `9080` is forwarded from `wireguard-client`'s ip address to the `qbittorent` container. 130 | ```yml 131 | services: 132 | 133 | wireguard-client: 134 | image: lscr.io/linuxserver/wireguard:latest 135 | networks: 136 | - main 137 | # ... 138 | labels: 139 | # Interface for forwarding traffic (Required for "twine.route.*=wireguard-client" to work) 140 | - twine.nat.interfaces=wg+ 141 | # Expose qbittorrent on WireGuard Client ip address 142 | - twine.nat.forward.9080=qbittorrent:9080 143 | 144 | qbittorrent: 145 | image: lscr.io/linuxserver/qbittorrent:latest 146 | networks: 147 | - main 148 | ports: 149 | - 127.0.0.1:9080:9080 150 | # ... 151 | labels: 152 | # Route all outgoing traffic via WireGuard Client 153 | - twine.route.0.0.0.0/1=wireguard-client 154 | - twine.route.128.0.0.0/1=wireguard-client 155 | # Route only internal subnet via WireGuard Client 156 | # - twine.route.10.250.0.0/24=wireguard-client 157 | 158 | ``` 159 | 160 | ### LetsEncrypt Traefik DNS Challenge with locally hosted DNS server dnsmasq 161 | 162 | In this example localhost connections on port `5353` from `dnsmasq` container are forwarded to `traefik`'s container. 163 | 164 | In a normal scenario you cannot provide a hostname to the dnsmasq's `address=//` configuration parameter, but using this approach we can dynamically address remote container `traefik` by sending our requests to the `localhost` in the loopback `lo` interface, providing a way for `traefik` to verify dns challenges from the local `dnsmasq` instance. 165 | 166 | ```yml 167 | services: 168 | 169 | dnsmasq: 170 | image: jpillora/dnsmasq:latest 171 | networks: 172 | - main 173 | volumes: 174 | - ./config/dnsmasq/dnsmasq.conf:/etc/dnsmasq.conf 175 | # In the dnsmasq.conf set: 176 | # server=/_acme-challenge.example.com/127.0.0.1#5353 177 | # This will allow forwarding of the acme dns challenges to the localhost 178 | # port 5353, which will be forwarded by twine to traefik container 179 | ports: 180 | - 53:53/tcp 181 | - 53:53/udp 182 | labels: 183 | - twine.nat.interfaces=lo 184 | # Forward localhost 185 | - twine.nat.forward.lo.5353/udp=traefik:5353 186 | 187 | helloworld: 188 | networks: 189 | - main 190 | labels: 191 | - traefik.http.services.helloworld.loadbalancer.server.port=8080 192 | - traefik.http.routers.helloworld.tls.domains[0].main=example.com 193 | - traefik.http.routers.helloworld.tls.domains[0].sans=*.example.com 194 | 195 | traefik: 196 | image: traefik:latest 197 | networks: 198 | - main 199 | ports: 200 | - 80:80 201 | - 443:443 202 | environment: 203 | - EXEC_PATH=/config/dns-challenge.sh 204 | # Example dns-challenge.sh, which will spin up dnsmasq instance with the challenge token, The requests comming on the external dnsmasq container will be forwarded here by twine. 205 | #!/bin/sh 206 | # CONFIG_FILE=$(mktemp /tmp/dnsmasq.conf.XXXXXX) 207 | 208 | # cat < "$CONFIG_FILE" 209 | # no-resolv 210 | # log-queries 211 | # port=5353 212 | # txt-record=${2%?},"$3" 213 | # EOF 214 | 215 | # killall dnsmasq || true 216 | 217 | # dnsmasq --conf-file="$CONFIG_FILE" & 218 | 219 | command: 220 | # ... 221 | - --certificatesresolvers.letsencrypt.acme.dnschallenge=true 222 | - --certificatesresolvers.letsencrypt.acme.dnschallenge.provider=exec 223 | labels: 224 | - twine.nat.interfaces=eth+ 225 | 226 | ``` 227 | 228 | 229 | ## Contribute 230 | 231 | ### Develop 232 | Docker is required. 233 | 234 | - To start development run: 235 | ```bash 236 | docker-compose up --build 237 | ``` 238 | 239 | - To install packages while the project is running you can place the dependencies in the `package.json` and run 240 | ```bash 241 | docker-compose exec app pnpm i 242 | ``` 243 | 244 | ### Windows 245 | 246 | - WSL2>Windows filesystem bridge is extremely slow. It is recommended to place the project files in the WSL2 filesystem. 247 | 248 | ```bash 249 | wsl 250 | git clone https://github.com/git-invoice.git 251 | code git-invoice 252 | ``` 253 | -------------------------------------------------------------------------------- /app/src/app.ts: -------------------------------------------------------------------------------- 1 | import fs from "fs" 2 | import process from "process" 3 | import neodoc from "neodoc" 4 | import * as netmask from "netmask" 5 | 6 | import * as types from "@/types" 7 | import * as utils from "@/utils" 8 | import * as log from "@/log" 9 | 10 | import * as networking from "@/networking" 11 | import * as docker from "@/docker" 12 | 13 | import config from "@/config" 14 | 15 | let doc = ` 16 | Usage: 17 | twine start 18 | twine cleanup 19 | 20 | Options: 21 | -h, --help 22 | `.replace(/\t/g, " ".repeat(4)) 23 | 24 | process.on("unhandledRejection", (reason, promise) => { 25 | if(config.LOG_LEVEL == "debug"){ 26 | console.error(reason) 27 | } 28 | log.debug("UnhandledRejection:", reason) 29 | }) 30 | 31 | export let cleanup = async()=>{ 32 | log.info("Cleaning up...") 33 | // TODO 34 | } 35 | 36 | export let scanContainers = async(containerId?: string): Promise>=>{ 37 | let containers: Array = [] 38 | for(let container of await docker.docker.container.list()){ 39 | if(containerId && container.id != container.data["Id"]) continue; 40 | 41 | let containerInfo: types.Container = { 42 | id: container.data["Id"], 43 | pid: (await container.status()).data["State"]["Pid"], 44 | name: container.data["Names"][0].replace(/^\//m, ""), 45 | iptables: { 46 | customRules: {} 47 | }, 48 | nat: { 49 | interfaces: [], 50 | portForwarding: [], 51 | }, 52 | host: { 53 | routes: [] 54 | }, 55 | routes: [], 56 | } 57 | for(let [label, value] of Object.entries(container.data["Labels"]) as Array<[string, string]>){ 58 | if(!label.startsWith("twine\.")) continue; 59 | 60 | // TODO: DEPRICATED 61 | // twine.forward.80=helloworld 62 | // twine.forward.80/tcp=helloworld 63 | // twine.forward.80/tcp=helloworld:74/tcp 64 | let labelForwardDepricated = /^twine\.forward\.(?\d+)(?:\/(?tcp|udp))?$/m.exec(label) 65 | if(labelForwardDepricated){ 66 | let valueForward = /^(?.*?)(?:\:(?\d+))?$/m.exec(value) 67 | containerInfo.nat.portForwarding.push({ 68 | interface: "+", 69 | protocol: labelForwardDepricated.groups.protocol || "tcp", sourcePort: labelForwardDepricated.groups.sourcePort, 70 | destination: valueForward.groups.destination, 71 | destinationPort: valueForward.groups.destinationPort || labelForwardDepricated.groups.sourcePort, 72 | }) 73 | log.error(`Container "${container.name}" using depricated twine label "${labelForwardDepricated[0]}", update to "twine.nat.+.forward.[/tcp\|udp]=:" as soon as possible`) 74 | } 75 | 76 | // twine.nat.forward.+.80=helloworld 77 | // twine.nat.forward.+.80/tcp=helloworld 78 | // twine.nat.forward.+.80/tcp=helloworld:74/tcp 79 | let labelForward = /^twine\.nat\.forward\.(?:(?.*?)\.)?(?\d+)(?:\/(?tcp|udp))?$/m.exec(label) 80 | if(labelForward){ 81 | let valueForward = /^(?.*?)(?:\:(?\d+))?$/m.exec(value) 82 | containerInfo.nat.portForwarding.push({ 83 | interface: labelForward.groups.interface || "+", 84 | protocol: labelForward.groups.protocol || "tcp", 85 | sourcePort: labelForward.groups.sourcePort, 86 | destination: valueForward.groups.destination, 87 | destinationPort: valueForward.groups.destinationPort || labelForward.groups.sourcePort, 88 | }) 89 | } 90 | 91 | // twine.host.routes=10.250.0.0/24,127.0.0.1 92 | let labelHostRoutes = label.match(/^twine\.host\.routes$/m) 93 | if(labelHostRoutes){ 94 | containerInfo.host.routes = value.trim().split(",") 95 | } 96 | 97 | // twine.route.128.0.0.0/1=wireguard-client 98 | let labelRoute = label.match(/^twine\.route\.(?.*?)$/m) 99 | if(labelRoute){ 100 | containerInfo.routes.push({ 101 | network: labelRoute.groups.network, 102 | destination: value.trim(), 103 | }) 104 | } 105 | 106 | // TODO: DEPRICATED 107 | // twine.gateway.interface=wg+ 108 | let labelGatewayInterfaceDepricated = label.match(/^twine\.gateway\.interface$/m) 109 | if(labelGatewayInterfaceDepricated){ 110 | containerInfo.nat.interfaces = value.trim().split(",") 111 | log.error(`Container "${container.name}" using depricated twine label "${labelForwardDepricated[0]}", update to "twine.nat.interfaces=[,...]" as soon as possible`) 112 | } 113 | 114 | // twine.nat.interfaces=wg+ 115 | let labelGatewayInterface = label.match(/^twine\.nat\.interfaces$/m) 116 | if(labelGatewayInterface){ 117 | containerInfo.nat.interfaces = value.trim().split(",") 118 | } 119 | 120 | // twine.iptables.rule.= 121 | let labelIptablesCustomRule = /^twine\.iptables\.rule\.(?\w+)$/m.exec(label) 122 | if(labelIptablesCustomRule){ 123 | let rule = value.trim().replace(/\s+/g, " ") 124 | if(labelIptablesCustomRule.groups.name.match(/^TWINE_NAT/m)){ 125 | rule = `-t nat -A ${rule}` 126 | }else{ 127 | rule = `-A ${rule}` 128 | } 129 | containerInfo.iptables.customRules[labelIptablesCustomRule.groups.name] = rule 130 | // TODO: Validation and normalization 131 | } 132 | 133 | // twine.forwarding.interface.wg+.whitelist= 134 | // twine.forwarding.interface.wg+.whitelist.in= 135 | // twine.forwarding.interface.wg+.whitelist.out= 136 | 137 | } 138 | containers.push(containerInfo) 139 | } 140 | return containers 141 | } 142 | 143 | export let update = async(containerId?: string)=>{ 144 | let containers = await scanContainers(containerId) 145 | 146 | // let hostRouter: networking.Router 147 | // if(fs.existsSync("/host/proc/1")){ 148 | // // I cant for the fuck of me figure out how to pull proper network namespace for the host 149 | // // on WSL2 via the new `pid: host` method 150 | // // For now this is the only dependency holding back from removing /proc:/host/proc mount 151 | // hostRouter = new networking.Router(command=>utils.exec(`nsenter -n/host/proc/1/ns/net ${command}`) as any) 152 | // }else{ 153 | // hostRouter = new networking.Router(command=>docker.execNS(1, command) as any) 154 | // } 155 | // let hostRoutes = await hostRouter.fetch() 156 | 157 | // TODO: Optimize 158 | for(let container of containers){ 159 | 160 | try{ 161 | let router = new networking.Router((command)=>docker.execNS(container.pid, command)) 162 | let iptables = new networking.IPTables((command)=>docker.execNS(container.pid, command, {log: true})) 163 | let networkInterfaces = new networking.NetworkInterfaces((command)=>docker.execNS(container.pid, command)) 164 | // let containerInterfaces = await networkInterfaces.fetch() 165 | 166 | // twine.route.128.0.0.0/1=wireguard-client 167 | // Create a route on the container to the destination `128.0.0.0/1` with gateway `wireguard-client` 168 | if(container.routes.length){ 169 | // Change default route metric to 1 170 | let routesCurrent = await router.fetch() 171 | let routeDefault = routesCurrent.find(route=> 172 | route.destination == "0.0.0.0" 173 | && 174 | route.mask == "0.0.0.0" 175 | && 176 | route.metric == 0 177 | ) 178 | if(routeDefault){ 179 | await router.del(routeDefault) 180 | await router.add({ 181 | ...routeDefault, 182 | metric: 1 183 | }) 184 | } 185 | 186 | // Update container routes if required 187 | for(let containerRoute of container.routes){ 188 | let network = new netmask.Netmask(containerRoute.network) 189 | let destinationIp = containerRoute.destination 190 | if(!destinationIp.match(/^([\d\.]+)\.([\d\.]+)\.([\d\.]+)$/m)){ 191 | try{ 192 | // Destination ip can change during container lifecycle 193 | destinationIp = (await docker.execNS(container.pid, `nslookup ${containerRoute.destination} 127.0.0.11`)).match(/answer\:.*?Address\:\s+([\d\.]+)/sm)[1] 194 | }catch(error){ 195 | log.error(`Failed to resolve destination "${destinationIp}" for route ${containerRoute.network}>${containerRoute.destination} on ${container.name}`) 196 | continue 197 | } 198 | } 199 | let route: types.IPRoute = { 200 | gateway: destinationIp, 201 | destination: network.base, 202 | mask: network.mask, 203 | metric: 0 204 | } 205 | // Only create the route if its not created yet 206 | if(!routesCurrent.find(_route=> 207 | _route.destination == route.destination 208 | && 209 | _route.gateway == route.gateway 210 | && 211 | _route.mask == route.mask 212 | )){ 213 | await router.add(route) 214 | log.info(`Created route ${containerRoute.network}>${containerRoute.destination} on ${container.name}`) 215 | } 216 | } 217 | } 218 | 219 | // Reset iptables rules with TWINE_* namespace 220 | await iptables.insert([ 221 | `-D INPUT -j TWINE_INPUT`, 222 | `-D OUTPUT -j TWINE_OUTPUT`, 223 | `-D FORWARD -j TWINE_FORWARD`, 224 | `-t nat -D POSTROUTING -j TWINE_NAT_POSTROUTING`, 225 | `-t nat -D PREROUTING -j TWINE_NAT_PREROUTING`, 226 | `-t nat -D OUTPUT -j TWINE_NAT_OUTPUT`, 227 | `-F TWINE_FORWARD`, 228 | `-t nat -F TWINE_NAT_POSTROUTING`, 229 | `-t nat -F TWINE_NAT_PREROUTING`, 230 | `-t nat -F TWINE_NAT_OUTPUT`, 231 | ], {force: true}) 232 | 233 | // Create TWINE_* namespace 234 | await iptables.insert([ 235 | `-P INPUT ACCEPT`, 236 | `-P OUTPUT ACCEPT`, 237 | `-P FORWARD ACCEPT`, 238 | `-t nat -P PREROUTING ACCEPT`, 239 | `-t nat -P POSTROUTING ACCEPT`, 240 | `-N TWINE_INPUT`, 241 | `-N TWINE_OUTPUT`, 242 | `-N TWINE_FORWARD`, 243 | `-t nat -N TWINE_NAT_POSTROUTING`, 244 | `-t nat -N TWINE_NAT_PREROUTING`, 245 | `-t nat -N TWINE_NAT_OUTPUT`, 246 | `-A FORWARD -j TWINE_INPUT`, 247 | `-A FORWARD -j TWINE_OUTPUT`, 248 | `-A FORWARD -j TWINE_FORWARD`, 249 | `-t nat -A POSTROUTING -j TWINE_NAT_POSTROUTING`, 250 | `-t nat -A PREROUTING -j TWINE_NAT_PREROUTING`, 251 | `-t nat -A OUTPUT -j TWINE_NAT_OUTPUT`, 252 | `-t nat -A TWINE_NAT_POSTROUTING -o eth+ -j MASQUERADE`, 253 | ], {force: true}) 254 | 255 | // Add forwarding rules between container interface and `twine.gateway.interface` 256 | if(container.nat.interfaces.length){ 257 | // TODO: is this needed? (this can also be done via container's sysctls) 258 | await docker.execNS(container.pid, `echo 1 > /proc/sys/net/ipv4/ip_forward`) 259 | 260 | for(let natInterface of container.nat.interfaces){ 261 | // TODO: More specific forwarding rules? or separte whitelisting function 262 | if(await iptables.insert([ 263 | `-A TWINE_FORWARD -d ${container.name} -o ${natInterface} -m conntrack --ctstate NEW,ESTABLISHED,RELATED -j ACCEPT`, 264 | `-A TWINE_FORWARD -i ${natInterface} -s ${container.name} -m conntrack --ctstate NEW,ESTABLISHED,RELATED -j ACCEPT`, 265 | `-t nat -A TWINE_NAT_POSTROUTING -o ${natInterface} -j MASQUERADE`, 266 | ])){ 267 | log.info(`Enabled NAT for "${natInterface}" interface on "${container.name}"`) 268 | } 269 | } 270 | } 271 | 272 | // twine.nat.+.forward.80/tcp=helloworld:74/tcp 273 | // Forwarding of `80/tcp` traffic comming on `twine.gateway.interface` to `helloworld:74/tcp` 274 | // The destination can be any resolavable by dns name (default:container's dns) 275 | if(container.nat.portForwarding.length){ 276 | let containerInterfaces = await networkInterfaces.fetch() 277 | await docker.execNS(container.pid, `sysctl -w net.ipv4.conf.all.route_localnet=1`) 278 | for(let rule of container.nat.portForwarding){ 279 | let destinationIp = rule.destination 280 | if(!destinationIp.match(/^([\d\.]+)\.([\d\.]+)\.([\d\.]+)$/m)){ 281 | try{ 282 | destinationIp = (await docker.execNS(container.pid, `nslookup ${rule.destination} 127.0.0.11`)).match(/answer\:.*?Address\:\s+([\d\.]+)/sm)[1] 283 | }catch(error){ 284 | log.error(`Failed to resolve destination "${destinationIp}" for forwarding ${rule.sourcePort}>${rule.destination}:${rule.destinationPort}/${rule.protocol}`) 285 | continue 286 | } 287 | } 288 | 289 | let rules = [] 290 | for(let [interfaceName, address] of Object.entries(containerInterfaces)){ 291 | if(!networking.matchNetworkInterface(rule.interface, interfaceName)) continue; 292 | if(!container.nat.interfaces.find(natInterface=>networking.matchNetworkInterface(natInterface, interfaceName))){ 293 | // log.error(`Failed applying NAT port forwarding rule for container "${container.name}": NAT is not enabled for the container, try enabling it by adding "twine.nat.interfaces=${interfaceName}"`) 294 | continue; 295 | } 296 | rules.push(`-t nat -A TWINE_NAT_PREROUTING -d ${address} -p ${rule.protocol} -m multiport --dports ${rule.sourcePort} -j DNAT --to ${destinationIp}:${rule.destinationPort}`) 297 | rules.push(`-t nat -A TWINE_NAT_OUTPUT -d ${address} -p ${rule.protocol} -m multiport --dports ${rule.sourcePort} -j DNAT --to ${destinationIp}:${rule.destinationPort}`) 298 | 299 | } 300 | 301 | if(await iptables.insert(rules)){ 302 | log.info(`Created NAT forwarding rule ${container.name}:(${rule.interface}):${rule.sourcePort} > ${rule.destination}:${rule.destinationPort}/${rule.protocol}`) 303 | } 304 | } 305 | } 306 | 307 | // twine.host.routes=10.250.0.0/24,10.20.0.0/26 308 | // Create routes to `10.250.0.0/24`, `10.20.0.0/26` on the host's operating system that 309 | // dynamically resolve current ip address of the container and forward traffic from host 310 | // on those networks to the containers docker network interface 311 | // if(container.host.routes.length){ 312 | 313 | // // TODO: there are multiple interfaces on the docker container for each network defined in the `networks:` section 314 | // // theoretically host can access the container with any of them, but maybe specific network is more preffered in some situations (? new label) 315 | // let containerHostIp = containerInterfaces[Object.keys(containerInterfaces).find(name=>name.match(/^eth\d/m)) as any] 316 | // for(let containerHostRoute of container.host.routes){ 317 | // let network = new netmask.Netmask(containerHostRoute) 318 | // let hostRoute = hostRoutes.find(route=>route.destination==network.base && route.mask==network.mask) 319 | // // TODO: WSL2: route add -host 172.22.0.3 dev eth0 320 | // if(hostRoute && hostRoute.gateway != containerHostIp){ 321 | // await hostRouter.del(hostRoute) 322 | // } 323 | // await hostRouter.add({ 324 | // destination: network.base, 325 | // mask: network.mask, 326 | // gateway: containerHostIp, 327 | // metric: 0, 328 | // }) 329 | // log.info(`Updated host route "${containerHostRoute}" to container "${container.name}" at "${containerHostIp}"`) 330 | // } 331 | // } 332 | 333 | // twine.iptables.rule. 334 | if(Object.keys(container.iptables.customRules).length){ 335 | let rules = [] 336 | for(let [name, rule] of Object.entries(container.iptables.customRules)){ 337 | rules.push(rule) 338 | } 339 | await iptables.insert(rules) 340 | } 341 | 342 | 343 | }catch(error){ 344 | log.error(`Error during processing of container "${container.name}"`, error) 345 | } 346 | } 347 | } 348 | 349 | async function init(){ 350 | let options = neodoc.run(doc) 351 | await docker.init() 352 | 353 | 354 | if(options["start"]){ 355 | process.once("SIGTERM", cleanup) 356 | process.once("SIGINT", cleanup) 357 | 358 | docker.docker.events({}) 359 | .then(stream=>{ 360 | stream.on("data", (data)=>{ 361 | try{ 362 | let event = JSON.parse(data.toString()) 363 | if(event.Type == "container" && ["restart", "start"].indexOf(event.Action) != -1){ 364 | update(event.id) 365 | } 366 | }catch(error){ 367 | log.error("Failed parsing Docker event", error) 368 | } 369 | }) 370 | }) 371 | 372 | log.info("Ready") 373 | 374 | await update() 375 | 376 | if(config.DEV){ 377 | log.info("Running tests") 378 | // return 379 | 380 | let containers = {} 381 | for(let container of await scanContainers()){ 382 | containers[container.name] = { 383 | ...container, 384 | test: async(command)=>{ 385 | log.info(`${container.name}: Running "${command}"`) 386 | console.log((await docker.execNS(container.pid, command, {ignore: true})).slice(0, 250)) 387 | } 388 | } 389 | } 390 | 391 | await containers["twine-wireguard-client-1"].test(`curl -sS localhost:80`) 392 | await containers["twine-wireguard-client-1"].test(`curl -sS 10.250.0.2:80`) 393 | await containers["twine-wireguard-client-1"].test(`curl -sS wireguard-client:80`) 394 | // await containers["twine-wireguard-client-1"].test(`curl -sS ipinfo.io`) 395 | await containers["twine-wireguard-client-1"].test(`curl -sS 10.250.0.1:80`) 396 | 397 | await containers["twine-qbittorrent-1"].test(`ping -c 1 1.1.1`) 398 | await containers["twine-qbittorrent-1"].test(`traceroute -m 2 10.250.0.2`) 399 | // await containers["twine-qbittorrent-1"].test(`curl -sS ipinfo.io`) 400 | await containers["twine-qbittorrent-1"].test(`curl -sS 10.250.0.1:80`) 401 | await containers["twine-qbittorrent-1"].test(`curl -sS 10.250.0.2:80`) 402 | await containers["twine-qbittorrent-1"].test(`curl -sS wireguard-client:80`) 403 | 404 | await containers["twine-wireguard-server-1"].test(`curl -sS localhost:80`) 405 | await containers["twine-wireguard-server-1"].test(`curl -sS 10.250.0.1:80`) 406 | await containers["twine-wireguard-server-1"].test(`curl -sS wireguard-server:80`) 407 | // await containers["twine-wireguard-server-1"].test(`curl -sS ipinfo.io`) 408 | await containers["twine-wireguard-server-1"].test(`curl -sS 10.250.0.2:80`) 409 | 410 | // await containers["twine-helloworld-1"].test(`curl -sS ipinfo.io`) 411 | await containers["twine-helloworld-1"].test(`curl -sS wireguard-server:80`) 412 | 413 | } 414 | 415 | while(true){ 416 | await utils.sleep(1000) 417 | } 418 | } 419 | 420 | await cleanup() 421 | process.exit(0) 422 | } 423 | 424 | init().catch((console.error)) 425 | 426 | -------------------------------------------------------------------------------- /app/pnpm-lock.yaml: -------------------------------------------------------------------------------- 1 | lockfileVersion: '9.0' 2 | 3 | settings: 4 | autoInstallPeers: true 5 | excludeLinksFromLockfile: false 6 | 7 | importers: 8 | 9 | .: 10 | dependencies: 11 | neodoc: 12 | specifier: ^2.0.2 13 | version: 2.0.2 14 | netmask: 15 | specifier: ^2.0.2 16 | version: 2.0.2 17 | node-docker-api: 18 | specifier: ^1.1.22 19 | version: 1.1.22 20 | tar-stream: 21 | specifier: ^3.1.7 22 | version: 3.1.7 23 | tmp: 24 | specifier: ^0.2.3 25 | version: 0.2.3 26 | devDependencies: 27 | '@types/netmask': 28 | specifier: ^2.0.5 29 | version: 2.0.5 30 | '@types/node': 31 | specifier: ^18.0.0 32 | version: 18.19.76 33 | esbuild: 34 | specifier: ^0.25.0 35 | version: 0.25.0 36 | esbuild-plugin-alias-path: 37 | specifier: ^2.0.2 38 | version: 2.0.2(esbuild@0.25.0) 39 | nodemon: 40 | specifier: ^3.1.9 41 | version: 3.1.9 42 | tslib: 43 | specifier: ^2.8.1 44 | version: 2.8.1 45 | typescript: 46 | specifier: ^5.7.3 47 | version: 5.7.3 48 | 49 | packages: 50 | 51 | '@esbuild/aix-ppc64@0.25.0': 52 | resolution: {integrity: sha512-O7vun9Sf8DFjH2UtqK8Ku3LkquL9SZL8OLY1T5NZkA34+wG3OQF7cl4Ql8vdNzM6fzBbYfLaiRLIOZ+2FOCgBQ==} 53 | engines: {node: '>=18'} 54 | cpu: [ppc64] 55 | os: [aix] 56 | 57 | '@esbuild/android-arm64@0.25.0': 58 | resolution: {integrity: sha512-grvv8WncGjDSyUBjN9yHXNt+cq0snxXbDxy5pJtzMKGmmpPxeAmAhWxXI+01lU5rwZomDgD3kJwulEnhTRUd6g==} 59 | engines: {node: '>=18'} 60 | cpu: [arm64] 61 | os: [android] 62 | 63 | '@esbuild/android-arm@0.25.0': 64 | resolution: {integrity: sha512-PTyWCYYiU0+1eJKmw21lWtC+d08JDZPQ5g+kFyxP0V+es6VPPSUhM6zk8iImp2jbV6GwjX4pap0JFbUQN65X1g==} 65 | engines: {node: '>=18'} 66 | cpu: [arm] 67 | os: [android] 68 | 69 | '@esbuild/android-x64@0.25.0': 70 | resolution: {integrity: sha512-m/ix7SfKG5buCnxasr52+LI78SQ+wgdENi9CqyCXwjVR2X4Jkz+BpC3le3AoBPYTC9NHklwngVXvbJ9/Akhrfg==} 71 | engines: {node: '>=18'} 72 | cpu: [x64] 73 | os: [android] 74 | 75 | '@esbuild/darwin-arm64@0.25.0': 76 | resolution: {integrity: sha512-mVwdUb5SRkPayVadIOI78K7aAnPamoeFR2bT5nszFUZ9P8UpK4ratOdYbZZXYSqPKMHfS1wdHCJk1P1EZpRdvw==} 77 | engines: {node: '>=18'} 78 | cpu: [arm64] 79 | os: [darwin] 80 | 81 | '@esbuild/darwin-x64@0.25.0': 82 | resolution: {integrity: sha512-DgDaYsPWFTS4S3nWpFcMn/33ZZwAAeAFKNHNa1QN0rI4pUjgqf0f7ONmXf6d22tqTY+H9FNdgeaAa+YIFUn2Rg==} 83 | engines: {node: '>=18'} 84 | cpu: [x64] 85 | os: [darwin] 86 | 87 | '@esbuild/freebsd-arm64@0.25.0': 88 | resolution: {integrity: sha512-VN4ocxy6dxefN1MepBx/iD1dH5K8qNtNe227I0mnTRjry8tj5MRk4zprLEdG8WPyAPb93/e4pSgi1SoHdgOa4w==} 89 | engines: {node: '>=18'} 90 | cpu: [arm64] 91 | os: [freebsd] 92 | 93 | '@esbuild/freebsd-x64@0.25.0': 94 | resolution: {integrity: sha512-mrSgt7lCh07FY+hDD1TxiTyIHyttn6vnjesnPoVDNmDfOmggTLXRv8Id5fNZey1gl/V2dyVK1VXXqVsQIiAk+A==} 95 | engines: {node: '>=18'} 96 | cpu: [x64] 97 | os: [freebsd] 98 | 99 | '@esbuild/linux-arm64@0.25.0': 100 | resolution: {integrity: sha512-9QAQjTWNDM/Vk2bgBl17yWuZxZNQIF0OUUuPZRKoDtqF2k4EtYbpyiG5/Dk7nqeK6kIJWPYldkOcBqjXjrUlmg==} 101 | engines: {node: '>=18'} 102 | cpu: [arm64] 103 | os: [linux] 104 | 105 | '@esbuild/linux-arm@0.25.0': 106 | resolution: {integrity: sha512-vkB3IYj2IDo3g9xX7HqhPYxVkNQe8qTK55fraQyTzTX/fxaDtXiEnavv9geOsonh2Fd2RMB+i5cbhu2zMNWJwg==} 107 | engines: {node: '>=18'} 108 | cpu: [arm] 109 | os: [linux] 110 | 111 | '@esbuild/linux-ia32@0.25.0': 112 | resolution: {integrity: sha512-43ET5bHbphBegyeqLb7I1eYn2P/JYGNmzzdidq/w0T8E2SsYL1U6un2NFROFRg1JZLTzdCoRomg8Rvf9M6W6Gg==} 113 | engines: {node: '>=18'} 114 | cpu: [ia32] 115 | os: [linux] 116 | 117 | '@esbuild/linux-loong64@0.25.0': 118 | resolution: {integrity: sha512-fC95c/xyNFueMhClxJmeRIj2yrSMdDfmqJnyOY4ZqsALkDrrKJfIg5NTMSzVBr5YW1jf+l7/cndBfP3MSDpoHw==} 119 | engines: {node: '>=18'} 120 | cpu: [loong64] 121 | os: [linux] 122 | 123 | '@esbuild/linux-mips64el@0.25.0': 124 | resolution: {integrity: sha512-nkAMFju7KDW73T1DdH7glcyIptm95a7Le8irTQNO/qtkoyypZAnjchQgooFUDQhNAy4iu08N79W4T4pMBwhPwQ==} 125 | engines: {node: '>=18'} 126 | cpu: [mips64el] 127 | os: [linux] 128 | 129 | '@esbuild/linux-ppc64@0.25.0': 130 | resolution: {integrity: sha512-NhyOejdhRGS8Iwv+KKR2zTq2PpysF9XqY+Zk77vQHqNbo/PwZCzB5/h7VGuREZm1fixhs4Q/qWRSi5zmAiO4Fw==} 131 | engines: {node: '>=18'} 132 | cpu: [ppc64] 133 | os: [linux] 134 | 135 | '@esbuild/linux-riscv64@0.25.0': 136 | resolution: {integrity: sha512-5S/rbP5OY+GHLC5qXp1y/Mx//e92L1YDqkiBbO9TQOvuFXM+iDqUNG5XopAnXoRH3FjIUDkeGcY1cgNvnXp/kA==} 137 | engines: {node: '>=18'} 138 | cpu: [riscv64] 139 | os: [linux] 140 | 141 | '@esbuild/linux-s390x@0.25.0': 142 | resolution: {integrity: sha512-XM2BFsEBz0Fw37V0zU4CXfcfuACMrppsMFKdYY2WuTS3yi8O1nFOhil/xhKTmE1nPmVyvQJjJivgDT+xh8pXJA==} 143 | engines: {node: '>=18'} 144 | cpu: [s390x] 145 | os: [linux] 146 | 147 | '@esbuild/linux-x64@0.25.0': 148 | resolution: {integrity: sha512-9yl91rHw/cpwMCNytUDxwj2XjFpxML0y9HAOH9pNVQDpQrBxHy01Dx+vaMu0N1CKa/RzBD2hB4u//nfc+Sd3Cw==} 149 | engines: {node: '>=18'} 150 | cpu: [x64] 151 | os: [linux] 152 | 153 | '@esbuild/netbsd-arm64@0.25.0': 154 | resolution: {integrity: sha512-RuG4PSMPFfrkH6UwCAqBzauBWTygTvb1nxWasEJooGSJ/NwRw7b2HOwyRTQIU97Hq37l3npXoZGYMy3b3xYvPw==} 155 | engines: {node: '>=18'} 156 | cpu: [arm64] 157 | os: [netbsd] 158 | 159 | '@esbuild/netbsd-x64@0.25.0': 160 | resolution: {integrity: sha512-jl+qisSB5jk01N5f7sPCsBENCOlPiS/xptD5yxOx2oqQfyourJwIKLRA2yqWdifj3owQZCL2sn6o08dBzZGQzA==} 161 | engines: {node: '>=18'} 162 | cpu: [x64] 163 | os: [netbsd] 164 | 165 | '@esbuild/openbsd-arm64@0.25.0': 166 | resolution: {integrity: sha512-21sUNbq2r84YE+SJDfaQRvdgznTD8Xc0oc3p3iW/a1EVWeNj/SdUCbm5U0itZPQYRuRTW20fPMWMpcrciH2EJw==} 167 | engines: {node: '>=18'} 168 | cpu: [arm64] 169 | os: [openbsd] 170 | 171 | '@esbuild/openbsd-x64@0.25.0': 172 | resolution: {integrity: sha512-2gwwriSMPcCFRlPlKx3zLQhfN/2WjJ2NSlg5TKLQOJdV0mSxIcYNTMhk3H3ulL/cak+Xj0lY1Ym9ysDV1igceg==} 173 | engines: {node: '>=18'} 174 | cpu: [x64] 175 | os: [openbsd] 176 | 177 | '@esbuild/sunos-x64@0.25.0': 178 | resolution: {integrity: sha512-bxI7ThgLzPrPz484/S9jLlvUAHYMzy6I0XiU1ZMeAEOBcS0VePBFxh1JjTQt3Xiat5b6Oh4x7UC7IwKQKIJRIg==} 179 | engines: {node: '>=18'} 180 | cpu: [x64] 181 | os: [sunos] 182 | 183 | '@esbuild/win32-arm64@0.25.0': 184 | resolution: {integrity: sha512-ZUAc2YK6JW89xTbXvftxdnYy3m4iHIkDtK3CLce8wg8M2L+YZhIvO1DKpxrd0Yr59AeNNkTiic9YLf6FTtXWMw==} 185 | engines: {node: '>=18'} 186 | cpu: [arm64] 187 | os: [win32] 188 | 189 | '@esbuild/win32-ia32@0.25.0': 190 | resolution: {integrity: sha512-eSNxISBu8XweVEWG31/JzjkIGbGIJN/TrRoiSVZwZ6pkC6VX4Im/WV2cz559/TXLcYbcrDN8JtKgd9DJVIo8GA==} 191 | engines: {node: '>=18'} 192 | cpu: [ia32] 193 | os: [win32] 194 | 195 | '@esbuild/win32-x64@0.25.0': 196 | resolution: {integrity: sha512-ZENoHJBxA20C2zFzh6AI4fT6RraMzjYw4xKWemRTRmRVtN9c5DcH9r/f2ihEkMjOW5eGgrwCslG/+Y/3bL+DHQ==} 197 | engines: {node: '>=18'} 198 | cpu: [x64] 199 | os: [win32] 200 | 201 | '@types/netmask@2.0.5': 202 | resolution: {integrity: sha512-9Q5iw9+pHZBVLDG700dlQSWWHTYvOb8KfPjfQTNckCYky4IyjV2xh81+RgC1CCwqv92bYLpz1cVKyJav0B88uQ==} 203 | 204 | '@types/node@18.19.76': 205 | resolution: {integrity: sha512-yvR7Q9LdPz2vGpmpJX5LolrgRdWvB67MJKDPSgIIzpFbaf9a1j/f5DnLp5VDyHGMR0QZHlTr1afsD87QCXFHKw==} 206 | 207 | JSONStream@0.10.0: 208 | resolution: {integrity: sha512-8XbSFFd43EG+1thjLNFIzCBlwXti0yKa7L+ak/f0T/pkC+31b7G41DXL/JzYpAoYWZ2eCPiu4IIqzijM8N0a/w==} 209 | hasBin: true 210 | 211 | ansi-regex@2.1.1: 212 | resolution: {integrity: sha512-TIGnTpdo+E3+pCyAluZvtED5p5wCqLdezCyhPZzKPcxvFplEt4i+W7OONCKgeZFT3+y5NZZfOOS/Bdcanm1MYA==} 213 | engines: {node: '>=0.10.0'} 214 | 215 | anymatch@3.1.3: 216 | resolution: {integrity: sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==} 217 | engines: {node: '>= 8'} 218 | 219 | b4a@1.6.7: 220 | resolution: {integrity: sha512-OnAYlL5b7LEkALw87fUVafQw5rVR9RjwGd4KUwNQ6DrrNmaVaUCgLipfVlzrPQ4tWOR9P0IXGNOx50jYCCdSJg==} 221 | 222 | balanced-match@1.0.2: 223 | resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} 224 | 225 | bare-events@2.5.4: 226 | resolution: {integrity: sha512-+gFfDkR8pj4/TrWCGUGWmJIkBwuxPS5F+a5yWjOHQt2hHvNZd5YLzadjmDUtFmMM4y429bnKLa8bYBMHcYdnQA==} 227 | 228 | binary-extensions@2.3.0: 229 | resolution: {integrity: sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==} 230 | engines: {node: '>=8'} 231 | 232 | brace-expansion@1.1.11: 233 | resolution: {integrity: sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==} 234 | 235 | braces@3.0.3: 236 | resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} 237 | engines: {node: '>=8'} 238 | 239 | chokidar@3.6.0: 240 | resolution: {integrity: sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==} 241 | engines: {node: '>= 8.10.0'} 242 | 243 | concat-map@0.0.1: 244 | resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} 245 | 246 | core-util-is@1.0.3: 247 | resolution: {integrity: sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==} 248 | 249 | debug@2.6.9: 250 | resolution: {integrity: sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==} 251 | peerDependencies: 252 | supports-color: '*' 253 | peerDependenciesMeta: 254 | supports-color: 255 | optional: true 256 | 257 | debug@4.4.0: 258 | resolution: {integrity: sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA==} 259 | engines: {node: '>=6.0'} 260 | peerDependencies: 261 | supports-color: '*' 262 | peerDependenciesMeta: 263 | supports-color: 264 | optional: true 265 | 266 | docker-modem@0.3.7: 267 | resolution: {integrity: sha512-4Xn4ZVtc/2DEFtxY04lOVeF7yvxwXGVo0sN8FKRBnLhBcwQ78Hb56j+Z5yAXXUhoweVhzGeBeGWahS+af0/mcg==} 268 | engines: {node: '>= 0.8'} 269 | 270 | esbuild-plugin-alias-path@2.0.2: 271 | resolution: {integrity: sha512-YK8H9bzx6/CG6YBV11XjoNLjRhNZP0Ta4xZ3ATHhPn7pN8ljQGg+zne4d47DpIzF8/sX2qM+xQWev0CvaD2rSQ==} 272 | peerDependencies: 273 | esbuild: '>= 0.14.0' 274 | 275 | esbuild@0.25.0: 276 | resolution: {integrity: sha512-BXq5mqc8ltbaN34cDqWuYKyNhX8D/Z0J1xdtdQ8UcIIIyJyz+ZMKUt58tF3SrZ85jcfN/PZYhjR5uDQAYNVbuw==} 277 | engines: {node: '>=18'} 278 | hasBin: true 279 | 280 | fast-fifo@1.3.2: 281 | resolution: {integrity: sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ==} 282 | 283 | fill-range@7.1.1: 284 | resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} 285 | engines: {node: '>=8'} 286 | 287 | find-up@5.0.0: 288 | resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==} 289 | engines: {node: '>=10'} 290 | 291 | fs-extra@10.1.0: 292 | resolution: {integrity: sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==} 293 | engines: {node: '>=12'} 294 | 295 | fsevents@2.3.3: 296 | resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} 297 | engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} 298 | os: [darwin] 299 | 300 | glob-parent@5.1.2: 301 | resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==} 302 | engines: {node: '>= 6'} 303 | 304 | graceful-fs@4.2.11: 305 | resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} 306 | 307 | has-flag@3.0.0: 308 | resolution: {integrity: sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==} 309 | engines: {node: '>=4'} 310 | 311 | ignore-by-default@1.0.1: 312 | resolution: {integrity: sha512-Ius2VYcGNk7T90CppJqcIkS5ooHUZyIQK+ClZfMfMNFEF9VSE73Fq+906u/CWu92x4gzZMWOwfFYckPObzdEbA==} 313 | 314 | inherits@2.0.4: 315 | resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} 316 | 317 | is-binary-path@2.1.0: 318 | resolution: {integrity: sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==} 319 | engines: {node: '>=8'} 320 | 321 | is-extglob@2.1.1: 322 | resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} 323 | engines: {node: '>=0.10.0'} 324 | 325 | is-glob@4.0.3: 326 | resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} 327 | engines: {node: '>=0.10.0'} 328 | 329 | is-number@7.0.0: 330 | resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} 331 | engines: {node: '>=0.12.0'} 332 | 333 | isarray@0.0.1: 334 | resolution: {integrity: sha512-D2S+3GLxWH+uhrNEcoh/fnmYeP8E8/zHl644d/jdA0g2uyXvy3sb0qxotE+ne0LtccHknQzWwZEzhak7oJ0COQ==} 335 | 336 | jsonfile@6.1.0: 337 | resolution: {integrity: sha512-5dgndWOriYSm5cnYaJNhalLNDKOqFwyDB/rr1E9ZsGciGvKPs8R2xYGCacuf3z6K1YKDz182fd+fY3cn3pMqXQ==} 338 | 339 | jsonparse@0.0.5: 340 | resolution: {integrity: sha512-fw7Q/8gFR8iSekUi9I+HqWIap6mywuoe7hQIg3buTVjuZgALKj4HAmm0X6f+TaL4c9NJbvyFQdaI2ppr5p6dnQ==} 341 | engines: {'0': node >= 0.2.0} 342 | 343 | locate-path@6.0.0: 344 | resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} 345 | engines: {node: '>=10'} 346 | 347 | memorystream@0.3.1: 348 | resolution: {integrity: sha512-S3UwM3yj5mtUSEfP41UZmt/0SCoVYUcU1rkXv+BQ5Ig8ndL4sPoJNBUJERafdPb5jjHJGuMgytgKvKIf58XNBw==} 349 | engines: {node: '>= 0.10.0'} 350 | 351 | minimatch@3.1.2: 352 | resolution: {integrity: sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==} 353 | 354 | ms@2.0.0: 355 | resolution: {integrity: sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==} 356 | 357 | ms@2.1.3: 358 | resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} 359 | 360 | neodoc@2.0.2: 361 | resolution: {integrity: sha512-NAppJ0YecKWdhSXFYCHbo6RutiX8vOt/Jo3l46mUg6pQlpJNaqc5cGxdrW2jITQm5JIYySbFVPDl3RrREXNyPw==} 362 | 363 | netmask@2.0.2: 364 | resolution: {integrity: sha512-dBpDMdxv9Irdq66304OLfEmQ9tbNRFnFTuZiLo+bD+r332bBmMJ8GBLXklIXXgxd3+v9+KUnZaUR5PJMa75Gsg==} 365 | engines: {node: '>= 0.4.0'} 366 | 367 | node-docker-api@1.1.22: 368 | resolution: {integrity: sha512-8xfOiuLDJQw+l58i66lUNQhRhS5fAExqQbLolmyqMucrsDON7k7eLMIHphcBwwB7utwCHCQkcp73gSAmzSiAiw==} 369 | 370 | nodemon@3.1.9: 371 | resolution: {integrity: sha512-hdr1oIb2p6ZSxu3PB2JWWYS7ZQ0qvaZsc3hK8DR8f02kRzc8rjYmxAIvdz+aYC+8F2IjNaB7HMcSDg8nQpJxyg==} 372 | engines: {node: '>=10'} 373 | hasBin: true 374 | 375 | normalize-path@3.0.0: 376 | resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==} 377 | engines: {node: '>=0.10.0'} 378 | 379 | p-limit@3.1.0: 380 | resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==} 381 | engines: {node: '>=10'} 382 | 383 | p-locate@5.0.0: 384 | resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==} 385 | engines: {node: '>=10'} 386 | 387 | path-exists@4.0.0: 388 | resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} 389 | engines: {node: '>=8'} 390 | 391 | picomatch@2.3.1: 392 | resolution: {integrity: sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==} 393 | engines: {node: '>=8.6'} 394 | 395 | pstree.remy@1.1.8: 396 | resolution: {integrity: sha512-77DZwxQmxKnu3aR542U+X8FypNzbfJ+C5XQDk3uWjWxn6151aIMGthWYRXTqT1E5oJvg+ljaa2OJi+VfvCOQ8w==} 397 | 398 | readable-stream@1.0.34: 399 | resolution: {integrity: sha512-ok1qVCJuRkNmvebYikljxJA/UEsKwLl2nI1OmaqAu4/UE+h0wKCHok4XkL/gvi39OacXvw59RJUOFUkDib2rHg==} 400 | 401 | readdirp@3.6.0: 402 | resolution: {integrity: sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==} 403 | engines: {node: '>=8.10.0'} 404 | 405 | semver@7.7.1: 406 | resolution: {integrity: sha512-hlq8tAfn0m/61p4BVRcPzIGr6LKiMwo4VM6dGi6pt4qcRkmNzTcWq6eCEjEh+qXjkMDvPlOFFSGwQjoEa6gyMA==} 407 | engines: {node: '>=10'} 408 | hasBin: true 409 | 410 | simple-update-notifier@2.0.0: 411 | resolution: {integrity: sha512-a2B9Y0KlNXl9u/vsW6sTIu9vGEpfKu2wRV6l1H3XEas/0gUIzGzBoP/IouTcUQbm9JWZLH3COxyn03TYlFax6w==} 412 | engines: {node: '>=10'} 413 | 414 | split-ca@1.0.1: 415 | resolution: {integrity: sha512-Q5thBSxp5t8WPTTJQS59LrGqOZqOsrhDGDVm8azCqIBjSBd7nd9o2PM+mDulQQkh8h//4U6hFZnc/mul8t5pWQ==} 416 | 417 | streamx@2.22.0: 418 | resolution: {integrity: sha512-sLh1evHOzBy/iWRiR6d1zRcLao4gGZr3C1kzNz4fopCOKJb6xD9ub8Mpi9Mr1R6id5o43S+d93fI48UC5uM9aw==} 419 | 420 | string_decoder@0.10.31: 421 | resolution: {integrity: sha512-ev2QzSzWPYmy9GuqfIVildA4OdcGLeFZQrq5ys6RtiuF+RQQiZWr8TZNyAcuVXyQRYfEO+MsoB/1BuQVhOJuoQ==} 422 | 423 | supports-color@5.5.0: 424 | resolution: {integrity: sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==} 425 | engines: {node: '>=4'} 426 | 427 | tar-stream@3.1.7: 428 | resolution: {integrity: sha512-qJj60CXt7IU1Ffyc3NJMjh6EkuCFej46zUqJ4J7pqYlThyd9bO0XBTmcOIhSzZJVWfsLks0+nle/j538YAW9RQ==} 429 | 430 | text-decoder@1.2.3: 431 | resolution: {integrity: sha512-3/o9z3X0X0fTupwsYvR03pJ/DjWuqqrfwBgTQzdWDiQSm9KitAyz/9WqsT2JQW7KV2m+bC2ol/zqpW37NHxLaA==} 432 | 433 | through@2.3.8: 434 | resolution: {integrity: sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg==} 435 | 436 | tmp@0.2.3: 437 | resolution: {integrity: sha512-nZD7m9iCPC5g0pYmcaxogYKggSfLsdxl8of3Q/oIbqCqLLIO9IAF0GWjX1z9NZRHPiXv8Wex4yDCaZsgEw0Y8w==} 438 | engines: {node: '>=14.14'} 439 | 440 | to-regex-range@5.0.1: 441 | resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} 442 | engines: {node: '>=8.0'} 443 | 444 | touch@3.1.1: 445 | resolution: {integrity: sha512-r0eojU4bI8MnHr8c5bNo7lJDdI2qXlWWJk6a9EAFG7vbhTjElYhBVS3/miuE0uOuoLdb8Mc/rVfsmm6eo5o9GA==} 446 | hasBin: true 447 | 448 | tslib@2.8.1: 449 | resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} 450 | 451 | typescript@5.7.3: 452 | resolution: {integrity: sha512-84MVSjMEHP+FQRPy3pX9sTVV/INIex71s9TL2Gm5FG/WG1SqXeKyZ0k7/blY/4FdOzI12CBy1vGc4og/eus0fw==} 453 | engines: {node: '>=14.17'} 454 | hasBin: true 455 | 456 | undefsafe@2.0.5: 457 | resolution: {integrity: sha512-WxONCrssBM8TSPRqN5EmsjVrsv4A8X12J4ArBiiayv3DyyG3ZlIg6yysuuSYdZsVz3TKcTg2fd//Ujd4CHV1iA==} 458 | 459 | undici-types@5.26.5: 460 | resolution: {integrity: sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==} 461 | 462 | universalify@2.0.1: 463 | resolution: {integrity: sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==} 464 | engines: {node: '>= 10.0.0'} 465 | 466 | yocto-queue@0.1.0: 467 | resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} 468 | engines: {node: '>=10'} 469 | 470 | snapshots: 471 | 472 | '@esbuild/aix-ppc64@0.25.0': 473 | optional: true 474 | 475 | '@esbuild/android-arm64@0.25.0': 476 | optional: true 477 | 478 | '@esbuild/android-arm@0.25.0': 479 | optional: true 480 | 481 | '@esbuild/android-x64@0.25.0': 482 | optional: true 483 | 484 | '@esbuild/darwin-arm64@0.25.0': 485 | optional: true 486 | 487 | '@esbuild/darwin-x64@0.25.0': 488 | optional: true 489 | 490 | '@esbuild/freebsd-arm64@0.25.0': 491 | optional: true 492 | 493 | '@esbuild/freebsd-x64@0.25.0': 494 | optional: true 495 | 496 | '@esbuild/linux-arm64@0.25.0': 497 | optional: true 498 | 499 | '@esbuild/linux-arm@0.25.0': 500 | optional: true 501 | 502 | '@esbuild/linux-ia32@0.25.0': 503 | optional: true 504 | 505 | '@esbuild/linux-loong64@0.25.0': 506 | optional: true 507 | 508 | '@esbuild/linux-mips64el@0.25.0': 509 | optional: true 510 | 511 | '@esbuild/linux-ppc64@0.25.0': 512 | optional: true 513 | 514 | '@esbuild/linux-riscv64@0.25.0': 515 | optional: true 516 | 517 | '@esbuild/linux-s390x@0.25.0': 518 | optional: true 519 | 520 | '@esbuild/linux-x64@0.25.0': 521 | optional: true 522 | 523 | '@esbuild/netbsd-arm64@0.25.0': 524 | optional: true 525 | 526 | '@esbuild/netbsd-x64@0.25.0': 527 | optional: true 528 | 529 | '@esbuild/openbsd-arm64@0.25.0': 530 | optional: true 531 | 532 | '@esbuild/openbsd-x64@0.25.0': 533 | optional: true 534 | 535 | '@esbuild/sunos-x64@0.25.0': 536 | optional: true 537 | 538 | '@esbuild/win32-arm64@0.25.0': 539 | optional: true 540 | 541 | '@esbuild/win32-ia32@0.25.0': 542 | optional: true 543 | 544 | '@esbuild/win32-x64@0.25.0': 545 | optional: true 546 | 547 | '@types/netmask@2.0.5': {} 548 | 549 | '@types/node@18.19.76': 550 | dependencies: 551 | undici-types: 5.26.5 552 | 553 | JSONStream@0.10.0: 554 | dependencies: 555 | jsonparse: 0.0.5 556 | through: 2.3.8 557 | 558 | ansi-regex@2.1.1: {} 559 | 560 | anymatch@3.1.3: 561 | dependencies: 562 | normalize-path: 3.0.0 563 | picomatch: 2.3.1 564 | 565 | b4a@1.6.7: {} 566 | 567 | balanced-match@1.0.2: {} 568 | 569 | bare-events@2.5.4: 570 | optional: true 571 | 572 | binary-extensions@2.3.0: {} 573 | 574 | brace-expansion@1.1.11: 575 | dependencies: 576 | balanced-match: 1.0.2 577 | concat-map: 0.0.1 578 | 579 | braces@3.0.3: 580 | dependencies: 581 | fill-range: 7.1.1 582 | 583 | chokidar@3.6.0: 584 | dependencies: 585 | anymatch: 3.1.3 586 | braces: 3.0.3 587 | glob-parent: 5.1.2 588 | is-binary-path: 2.1.0 589 | is-glob: 4.0.3 590 | normalize-path: 3.0.0 591 | readdirp: 3.6.0 592 | optionalDependencies: 593 | fsevents: 2.3.3 594 | 595 | concat-map@0.0.1: {} 596 | 597 | core-util-is@1.0.3: {} 598 | 599 | debug@2.6.9: 600 | dependencies: 601 | ms: 2.0.0 602 | 603 | debug@4.4.0(supports-color@5.5.0): 604 | dependencies: 605 | ms: 2.1.3 606 | optionalDependencies: 607 | supports-color: 5.5.0 608 | 609 | docker-modem@0.3.7: 610 | dependencies: 611 | JSONStream: 0.10.0 612 | debug: 2.6.9 613 | readable-stream: 1.0.34 614 | split-ca: 1.0.1 615 | transitivePeerDependencies: 616 | - supports-color 617 | 618 | esbuild-plugin-alias-path@2.0.2(esbuild@0.25.0): 619 | dependencies: 620 | esbuild: 0.25.0 621 | find-up: 5.0.0 622 | fs-extra: 10.1.0 623 | jsonfile: 6.1.0 624 | 625 | esbuild@0.25.0: 626 | optionalDependencies: 627 | '@esbuild/aix-ppc64': 0.25.0 628 | '@esbuild/android-arm': 0.25.0 629 | '@esbuild/android-arm64': 0.25.0 630 | '@esbuild/android-x64': 0.25.0 631 | '@esbuild/darwin-arm64': 0.25.0 632 | '@esbuild/darwin-x64': 0.25.0 633 | '@esbuild/freebsd-arm64': 0.25.0 634 | '@esbuild/freebsd-x64': 0.25.0 635 | '@esbuild/linux-arm': 0.25.0 636 | '@esbuild/linux-arm64': 0.25.0 637 | '@esbuild/linux-ia32': 0.25.0 638 | '@esbuild/linux-loong64': 0.25.0 639 | '@esbuild/linux-mips64el': 0.25.0 640 | '@esbuild/linux-ppc64': 0.25.0 641 | '@esbuild/linux-riscv64': 0.25.0 642 | '@esbuild/linux-s390x': 0.25.0 643 | '@esbuild/linux-x64': 0.25.0 644 | '@esbuild/netbsd-arm64': 0.25.0 645 | '@esbuild/netbsd-x64': 0.25.0 646 | '@esbuild/openbsd-arm64': 0.25.0 647 | '@esbuild/openbsd-x64': 0.25.0 648 | '@esbuild/sunos-x64': 0.25.0 649 | '@esbuild/win32-arm64': 0.25.0 650 | '@esbuild/win32-ia32': 0.25.0 651 | '@esbuild/win32-x64': 0.25.0 652 | 653 | fast-fifo@1.3.2: {} 654 | 655 | fill-range@7.1.1: 656 | dependencies: 657 | to-regex-range: 5.0.1 658 | 659 | find-up@5.0.0: 660 | dependencies: 661 | locate-path: 6.0.0 662 | path-exists: 4.0.0 663 | 664 | fs-extra@10.1.0: 665 | dependencies: 666 | graceful-fs: 4.2.11 667 | jsonfile: 6.1.0 668 | universalify: 2.0.1 669 | 670 | fsevents@2.3.3: 671 | optional: true 672 | 673 | glob-parent@5.1.2: 674 | dependencies: 675 | is-glob: 4.0.3 676 | 677 | graceful-fs@4.2.11: {} 678 | 679 | has-flag@3.0.0: {} 680 | 681 | ignore-by-default@1.0.1: {} 682 | 683 | inherits@2.0.4: {} 684 | 685 | is-binary-path@2.1.0: 686 | dependencies: 687 | binary-extensions: 2.3.0 688 | 689 | is-extglob@2.1.1: {} 690 | 691 | is-glob@4.0.3: 692 | dependencies: 693 | is-extglob: 2.1.1 694 | 695 | is-number@7.0.0: {} 696 | 697 | isarray@0.0.1: {} 698 | 699 | jsonfile@6.1.0: 700 | dependencies: 701 | universalify: 2.0.1 702 | optionalDependencies: 703 | graceful-fs: 4.2.11 704 | 705 | jsonparse@0.0.5: {} 706 | 707 | locate-path@6.0.0: 708 | dependencies: 709 | p-locate: 5.0.0 710 | 711 | memorystream@0.3.1: {} 712 | 713 | minimatch@3.1.2: 714 | dependencies: 715 | brace-expansion: 1.1.11 716 | 717 | ms@2.0.0: {} 718 | 719 | ms@2.1.3: {} 720 | 721 | neodoc@2.0.2: 722 | dependencies: 723 | ansi-regex: 2.1.1 724 | 725 | netmask@2.0.2: {} 726 | 727 | node-docker-api@1.1.22: 728 | dependencies: 729 | docker-modem: 0.3.7 730 | memorystream: 0.3.1 731 | transitivePeerDependencies: 732 | - supports-color 733 | 734 | nodemon@3.1.9: 735 | dependencies: 736 | chokidar: 3.6.0 737 | debug: 4.4.0(supports-color@5.5.0) 738 | ignore-by-default: 1.0.1 739 | minimatch: 3.1.2 740 | pstree.remy: 1.1.8 741 | semver: 7.7.1 742 | simple-update-notifier: 2.0.0 743 | supports-color: 5.5.0 744 | touch: 3.1.1 745 | undefsafe: 2.0.5 746 | 747 | normalize-path@3.0.0: {} 748 | 749 | p-limit@3.1.0: 750 | dependencies: 751 | yocto-queue: 0.1.0 752 | 753 | p-locate@5.0.0: 754 | dependencies: 755 | p-limit: 3.1.0 756 | 757 | path-exists@4.0.0: {} 758 | 759 | picomatch@2.3.1: {} 760 | 761 | pstree.remy@1.1.8: {} 762 | 763 | readable-stream@1.0.34: 764 | dependencies: 765 | core-util-is: 1.0.3 766 | inherits: 2.0.4 767 | isarray: 0.0.1 768 | string_decoder: 0.10.31 769 | 770 | readdirp@3.6.0: 771 | dependencies: 772 | picomatch: 2.3.1 773 | 774 | semver@7.7.1: {} 775 | 776 | simple-update-notifier@2.0.0: 777 | dependencies: 778 | semver: 7.7.1 779 | 780 | split-ca@1.0.1: {} 781 | 782 | streamx@2.22.0: 783 | dependencies: 784 | fast-fifo: 1.3.2 785 | text-decoder: 1.2.3 786 | optionalDependencies: 787 | bare-events: 2.5.4 788 | 789 | string_decoder@0.10.31: {} 790 | 791 | supports-color@5.5.0: 792 | dependencies: 793 | has-flag: 3.0.0 794 | 795 | tar-stream@3.1.7: 796 | dependencies: 797 | b4a: 1.6.7 798 | fast-fifo: 1.3.2 799 | streamx: 2.22.0 800 | 801 | text-decoder@1.2.3: 802 | dependencies: 803 | b4a: 1.6.7 804 | 805 | through@2.3.8: {} 806 | 807 | tmp@0.2.3: {} 808 | 809 | to-regex-range@5.0.1: 810 | dependencies: 811 | is-number: 7.0.0 812 | 813 | touch@3.1.1: {} 814 | 815 | tslib@2.8.1: {} 816 | 817 | typescript@5.7.3: {} 818 | 819 | undefsafe@2.0.5: {} 820 | 821 | undici-types@5.26.5: {} 822 | 823 | universalify@2.0.1: {} 824 | 825 | yocto-queue@0.1.0: {} 826 | --------------------------------------------------------------------------------