├── .gitignore ├── lib ├── GlobalSubscriptionPool.js ├── packageInfo.js ├── stripV6Brackets.js ├── CustomEvent.js ├── Server.js ├── WsClient.js ├── HttpServerPool.js ├── UdpClient.js ├── WwwServer.js ├── WsServer.js ├── SubscriptionPool.js ├── UdpServer.js ├── argv.js └── Client.js ├── Dockerfile ├── package.json ├── LICENSE ├── index.js ├── .github └── workflows │ └── deno-build-release.yml ├── CONTRIBUTING.md └── README.md /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | -------------------------------------------------------------------------------- /lib/GlobalSubscriptionPool.js: -------------------------------------------------------------------------------- 1 | import SubscriptionPool from './SubscriptionPool.js'; 2 | 3 | export default new SubscriptionPool(); -------------------------------------------------------------------------------- /lib/packageInfo.js: -------------------------------------------------------------------------------- 1 | import pkg from '../package.json' with { type: 'json' }; 2 | 3 | const { name, version, copyright } = pkg; 4 | export default { name, version, copyright }; 5 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:22-alpine 2 | 3 | WORKDIR /usr/src/app 4 | 5 | COPY package*.json ./ 6 | RUN npm install --production 7 | 8 | COPY . . 9 | 10 | ENTRYPOINT ["node", "index.js"] 11 | CMD [] 12 | -------------------------------------------------------------------------------- /lib/stripV6Brackets.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Strip brackets from formatted IPv6 address, if applicable. 3 | * @param {string} s 4 | */ 5 | export default function stripV6Brackets(s) { 6 | return s.replace(/^\[([^\]]+)\]$/, '$1'); 7 | } -------------------------------------------------------------------------------- /lib/CustomEvent.js: -------------------------------------------------------------------------------- 1 | export default class CustomEvent extends Event { 2 | #detail; 3 | 4 | constructor(type, options) { 5 | super(type, options); 6 | this.#detail = options?.detail ?? null; 7 | } 8 | 9 | get detail() { 10 | return this.#detail; 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /lib/Server.js: -------------------------------------------------------------------------------- 1 | export default class Server extends EventTarget { 2 | /** 3 | * Underlying Node.js socket sever 4 | * @type { NodeJS.net.Server } 5 | */ 6 | server; 7 | 8 | get address() { 9 | return this.server.address().address; 10 | } 11 | 12 | get port() { 13 | return this.server.address().port; 14 | } 15 | 16 | get listening() { 17 | return this.server.listening; 18 | } 19 | } -------------------------------------------------------------------------------- /lib/WsClient.js: -------------------------------------------------------------------------------- 1 | import Client from './Client.js'; 2 | 3 | class WsClient extends Client { 4 | constructor(...args) { 5 | super(...args); 6 | this.downstreamSocket.on('message', (data) => { 7 | this.sendUpstream(data); 8 | }); 9 | this.downstreamSocket.on('close', this.destroy.bind(this)); 10 | } 11 | 12 | get type() { 13 | return 'WS'; 14 | } 15 | 16 | sendDownstream(data) { 17 | super.sendDownstream(data); 18 | this.downstreamSocket.send(data); 19 | } 20 | } 21 | 22 | export default WsClient; -------------------------------------------------------------------------------- /lib/HttpServerPool.js: -------------------------------------------------------------------------------- 1 | import http from 'node:http'; 2 | 3 | export default class HttpServerPool { 4 | static #pool = new Map(); 5 | 6 | static getServer(port, address) { 7 | const key = `${port}:${address}`; 8 | 9 | return ( 10 | // Use existing HTTP server instance 11 | this.#pool.get(key) || 12 | 13 | // Create and cache a new HTTP server instance 14 | this.#pool.set(key, (() => { 15 | const server = http.createServer(); 16 | 17 | server.on('clientError', (e, socket) => { 18 | console.error(`HTTP client error:`, e); 19 | socket.destroy(); 20 | }); 21 | 22 | setTimeout(() => server.listen(port, address)); 23 | 24 | return server; 25 | 26 | })()).get(key) 27 | ); 28 | } 29 | } -------------------------------------------------------------------------------- /lib/UdpClient.js: -------------------------------------------------------------------------------- 1 | import argv from './argv.js'; 2 | import Client from './Client.js'; 3 | 4 | class UdpClient extends Client { 5 | constructor(...args) { 6 | super(...args); 7 | this.resetTimeout(); 8 | } 9 | 10 | resetTimeout() { 11 | clearTimeout(this.timeout); 12 | this.timeout = setTimeout(this.destroy.bind(this), argv.udpClientTimeout); 13 | } 14 | 15 | get type() { 16 | return 'UDP'; 17 | } 18 | 19 | sendUpstream(...args) { 20 | this.resetTimeout(); 21 | return super.sendUpstream(...args); 22 | } 23 | 24 | sendDownstream(data) { 25 | this.resetTimeout(); 26 | data = super.sendDownstream(data); 27 | this.downstreamSocket.send(data, this.downstreamPort, this.downstreamAddress); 28 | } 29 | } 30 | 31 | export default UdpClient; -------------------------------------------------------------------------------- /lib/WwwServer.js: -------------------------------------------------------------------------------- 1 | import argv from './argv.js'; 2 | import HttpServerPool from './HttpServerPool.js'; 3 | import serveStatic from 'serve-static'; 4 | import finalhandler from 'finalhandler'; 5 | import Server from './Server.js'; 6 | 7 | export default class WwwServer extends Server { 8 | constructor(opts, ...args) { 9 | super(opts, ...args); 10 | 11 | this.server = HttpServerPool.getServer(opts.port, opts.address); 12 | 13 | this.server.on('request', (req, res) => { 14 | const serve = serveStatic( 15 | argv.wwwRoot, 16 | { 17 | index: ['index.html', 'index.htm'] 18 | } 19 | ); 20 | serve(req, res, finalhandler(req, res)); 21 | }); 22 | 23 | this.server.on('listening', () => this.dispatchEvent(new Event('listening'))); 24 | if (this.listening) { 25 | this.dispatchEvent(new Event('listening')); 26 | } 27 | } 28 | 29 | get type() { 30 | return 'WWW'; 31 | } 32 | } -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "x32-proxy", 3 | "version": "2.5.8", 4 | "description": "Proxy server for Behringer X32/Midas M32-series consoles' OSC commands, for VPNs and IPv6 networks", 5 | "homepage": "https://github.com/audiopump/x32-proxy", 6 | "repository": { 7 | "type": "git", 8 | "url": "git+https://github.com/audiopump/x32-proxy.git" 9 | }, 10 | "type": "module", 11 | "main": "index.js", 12 | "bin": { 13 | "x32-proxy": "index.js" 14 | }, 15 | "author": "Brad Isbell ", 16 | "license": "BSD-3-Clause", 17 | "copyright": "Copyright © 2025 AudioPump, Inc.", 18 | "private": false, 19 | "dependencies": { 20 | "console.table": "^0.10.0", 21 | "finalhandler": "^2.1.0", 22 | "serve-static": "^2.2.0", 23 | "ws": "^8.18.0", 24 | "yargs": "^18.0.0" 25 | }, 26 | "files": [ 27 | "/index.js", 28 | "/lib" 29 | ], 30 | "devDependencies": { 31 | "@types/node": "^24.7.1" 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /lib/WsServer.js: -------------------------------------------------------------------------------- 1 | import CustomEvent from './CustomEvent.js'; 2 | import HttpServerPool from './HttpServerPool.js'; 3 | import Server from './Server.js'; 4 | import { WebSocketServer } from 'ws'; 5 | import WsClient from './WsClient.js'; 6 | 7 | 8 | export default class WsServer extends Server { 9 | constructor(opts, ...args) { 10 | super(opts, ...args); 11 | 12 | this.server = HttpServerPool.getServer(opts.port, opts.address); 13 | 14 | this.wss = new WebSocketServer({ 15 | server: this.server 16 | }); 17 | 18 | this.wss.on('connection', (stream, req) => { 19 | const client = new WsClient({ 20 | downstreamAddress: req.socket.remoteAddress, 21 | downstreamPort: req.socket.remotePort, 22 | downstreamSocket: stream, 23 | server: this, 24 | upstreamAddress: opts.target, 25 | upstreamPort: opts.targetPort 26 | }); 27 | 28 | this.dispatchEvent(new CustomEvent('connection', { 29 | detail: client 30 | })); 31 | 32 | // TODO: Track client? 33 | }); 34 | 35 | this.server.on('listening', () => this.dispatchEvent(new Event('listening'))); 36 | if (this.listening) { 37 | this.dispatchEvent(new Event('listening')); 38 | } 39 | } 40 | 41 | get type() { 42 | return 'WS'; 43 | } 44 | } -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright © 2022 AudioPump, Inc. 2 | 3 | Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: 4 | 5 | 1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. 6 | 7 | 2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. 8 | 9 | 3. Neither the name of the copyright holder nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission. 10 | 11 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 12 | -------------------------------------------------------------------------------- /lib/SubscriptionPool.js: -------------------------------------------------------------------------------- 1 | import argv from './argv.js'; 2 | import dgram from 'node:dgram'; 3 | import net from 'node:net'; 4 | 5 | import stripV6Brackets from './stripV6Brackets.js'; 6 | 7 | const whitelist = new Set([ 8 | '/xremote~~~~,~~~', 9 | '/meters~,s~~/meters/1~~~', 10 | '/meters~,s~~/meters/2~~~', 11 | '/meters~,s~~/meters/3~~~', 12 | '/meters~,s~~/meters/4~~~', 13 | '/meters~,s~~/meters/5~~~', 14 | '/meters~,s~~/meters/6~~~', 15 | '/meters~,s~~/meters/7~~~', 16 | '/meters~,s~~/meters/8~~~', 17 | '/meters~,s~~/meters/9~~~', 18 | '/meters~,s~~/meters/10~~', 19 | '/meters~,s~~/meters/11~~', 20 | '/meters~,s~~/meters/12~~', 21 | '/meters~,s~~/meters/13~~', 22 | '/meters~,s~~/meters/14~~', 23 | '/meters~,s~~/meters/15~~', 24 | '/meters~,s~~/meters/16~~', 25 | ].map(s => s.replace(/~/g, '\0'))); 26 | 27 | export default class SubscriptionPool { 28 | constructor() { 29 | this.pool = new Map(); 30 | } 31 | 32 | async sendUpstream(data, client) { 33 | const key = data.toString(); 34 | if (!whitelist.has(key)) { 35 | return false; 36 | } 37 | 38 | if (!this.pool.has(key)) { 39 | const clients = new Set(); 40 | this.pool.set(key, { 41 | clients, 42 | socketPromise: new Promise((resolve, reject) => { 43 | const socket = dgram.createSocket( 44 | net.isIPv6(stripV6Brackets(argv.target)) ? 'udp6' : 'udp4' 45 | ); 46 | socket.once('error', reject); 47 | socket.once('listening', resolve.bind(this, socket)); 48 | socket.on('message', (data) => { 49 | for (const client of clients) { 50 | client.sendDownstream(data); 51 | } 52 | }); 53 | socket.bind(); 54 | }) 55 | }); 56 | } 57 | 58 | const {socketPromise, clients} = this.pool.get(key); 59 | const socket = await socketPromise; 60 | 61 | if (!clients.has(client)) { 62 | clients.add(client); 63 | client.addEventListener('destroy', () => { 64 | clients.delete(client); 65 | if (!clients.size) { // No more clients subscribed? Clean up... 66 | socket.close(); 67 | this.pool.delete(key); 68 | } 69 | }, {once: true}); 70 | } 71 | 72 | socket.send(data, argv.targetPort, stripV6Brackets(argv.target)); 73 | return true; 74 | } 75 | } -------------------------------------------------------------------------------- /lib/UdpServer.js: -------------------------------------------------------------------------------- 1 | import CustomEvent from './CustomEvent.js'; 2 | import dgram from 'node:dgram'; 3 | import net from 'node:net'; 4 | import Server from './Server.js'; 5 | import UdpClient from './UdpClient.js'; 6 | 7 | export default class UdpServer extends Server { 8 | constructor(opts, ...args) { 9 | super(opts, ...args); 10 | 11 | this.clients = new Map(); 12 | 13 | this.server = dgram.createSocket( 14 | net.isIPv6(opts.address) ? 'udp6' : 'udp4' 15 | ); 16 | 17 | this.server.on('error', (err) => { 18 | console.error('Error with upstream socket.', err); 19 | this.server.close(); 20 | process.exit(1); // TODO: Move to outer scripts 21 | }); 22 | 23 | // When receiving a message from a client... 24 | this.server.on('message', (msg, clientRemoteInfo) => { 25 | const key = `${clientRemoteInfo.address}~${clientRemoteInfo.port}`; 26 | 27 | let client; 28 | client = this.clients.get(key); // Check for existing client 29 | 30 | if (!client) { 31 | // Instantiate a new Client 32 | client = new UdpClient({ 33 | downstreamAddress: clientRemoteInfo.address, 34 | downstreamPort: clientRemoteInfo.port, 35 | downstreamSocket: this.server, 36 | server: this, 37 | upstreamAddress: opts.target, 38 | upstreamPort: opts.targetPort 39 | }); 40 | 41 | // Cache the new Client for future use 42 | this.clients.set(key, client); 43 | 44 | client.addEventListener('destroy', () => { 45 | this.clients.delete(key); 46 | }, {once: true}); 47 | 48 | this.dispatchEvent(new CustomEvent('connection', { 49 | detail: client 50 | })); 51 | } 52 | 53 | client.sendUpstream(msg); 54 | }); 55 | 56 | // Start listening for packets. When ready, start the output loop. 57 | this.server.bind(opts.port, opts.address, () => { 58 | this.dispatchEvent(new Event('listening')); 59 | }); 60 | } 61 | 62 | // For UDP servers, the only way to determine if they're currently bound is 63 | // to check to see if there is a bound address. The `.address()` function 64 | // throws an error if it isn't bound yet. 65 | get listening() { 66 | try { 67 | this.server.address(); 68 | return true; 69 | } catch(e) { 70 | // Do nothing 71 | } 72 | 73 | return false; 74 | } 75 | 76 | get type() { 77 | return 'UDP'; 78 | } 79 | } -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | import 'console.table'; 4 | import argv from './lib/argv.js'; 5 | import net from 'node:net'; 6 | import packageInfo from './lib/packageInfo.js'; 7 | import stripV6Brackets from './lib/stripV6Brackets.js'; 8 | import UdpServer from './lib/UdpServer.js'; 9 | import WsServer from './lib/WsServer.js'; 10 | import WwwServer from './lib/WwwServer.js'; 11 | 12 | function formatAddressPort(address, port) { 13 | if (net.isIPv6(address)) { 14 | address = `[${address}]`; 15 | } 16 | return `${address}:${port}`; 17 | } 18 | 19 | // Console output loop 20 | let outputTimeout; 21 | function output() { 22 | clearTimeout(outputTimeout); 23 | console.clear(); 24 | 25 | // Header 26 | console.log(`${packageInfo.name} v${packageInfo.version} -- ${packageInfo.copyright}`); 27 | console.log(`Target X32: ${formatAddressPort(argv.target, argv.targetPort)}`); 28 | 29 | for (const server of servers) { 30 | if (server.listening) { 31 | console.log(`${server.type} Listening: ${formatAddressPort(server.address, server.port)}`); 32 | } 33 | } 34 | 35 | console.log(); // Blank line 36 | 37 | // Array of Objects containing string-based client data, for display 38 | const clientTableData = [...clients.values()].map((client) => { 39 | return { 40 | type: client.type, 41 | address: client.downstreamAddress, 42 | port: client.downstreamPort, 43 | 'packets-tx': client.upstreamPacketCount.toLocaleString(), 44 | 'packets-rx': client.downstreamPacketCount.toLocaleString() 45 | } 46 | }); 47 | 48 | // If no clients, say so 49 | if (!clientTableData.length) { 50 | console.log('No connected clients'); 51 | } 52 | 53 | // Output table of clients 54 | console.table('Clients', clientTableData); 55 | 56 | outputTimeout = setTimeout(output, 1000); 57 | } 58 | 59 | // List of active clients, WS or UDP 60 | const clients = new Set(); 61 | 62 | // List of servers 63 | const servers = new Set(); 64 | 65 | for (const [type, constructor, defaultPort] of [ 66 | ['udp', UdpServer, argv.targetPort], 67 | ['ws', WsServer, 8080], 68 | ['www', WwwServer, 80] 69 | ]) { 70 | for (const host of argv[type] || []) { 71 | const url = new URL(`fake-protocol://${host || '127.0.0.1'}`); 72 | servers.add( 73 | new constructor({ 74 | target: stripV6Brackets(argv.target), 75 | targetPort: argv.targetPort, 76 | port: Number.parseInt(url.port) || defaultPort, 77 | address: stripV6Brackets(url.hostname) 78 | }) 79 | ); 80 | } 81 | } 82 | 83 | for (const server of servers) { 84 | server.addEventListener('connection', (e) => { 85 | clients.add(e.detail); 86 | 87 | e.detail.addEventListener('destroy', () => { 88 | clients.delete(e.detail); 89 | output(); 90 | }, {once: true}); 91 | 92 | output(); 93 | }); 94 | 95 | server.addEventListener('listening', output); 96 | } 97 | 98 | if (!servers.size) { 99 | throw new Error('No servers configured. You must set at least one of --udp, --ws, or --www. See --help for details.'); 100 | } 101 | 102 | output(); -------------------------------------------------------------------------------- /lib/argv.js: -------------------------------------------------------------------------------- 1 | import process from 'node:process'; 2 | import yargs from 'yargs'; 3 | 4 | const argv = yargs(process.argv.slice(2)) 5 | .usage('$0 [options]') 6 | .scriptName('x32-proxy') 7 | .options({ 8 | // UDP OSC server config 9 | udp: { 10 | array: true, 11 | type: 'string', 12 | description: 'Enables the UDP OSC server, and optionally specify a bind address and/or port' 13 | }, 14 | 'udp-client-timeout': { 15 | default: 5000, 16 | type: 'number', 17 | description: 'Number of milliseconds of inactivity before assuming a client is no longer connected' 18 | }, 19 | 20 | // WebSocket server config 21 | ws: { 22 | array: true, 23 | type: 'string', 24 | description: 'Enable the Web Socket server, and optionally specify a bind address and/or port' 25 | }, 26 | 27 | // Static web server (www) config 28 | www: { 29 | array: true, 30 | type: 'string', 31 | description: 'Enable the static web server, and optionally specify a bind address and/or port' 32 | }, 33 | 'www-root': { 34 | type: 'string', 35 | description: 'Document root directory for the static web server', 36 | default: '.' 37 | }, 38 | 39 | // Target config 40 | target: { 41 | type: 'string', 42 | required: true, 43 | description: 'Network address and (optional) port of the upstream X32 mixer to proxy to' 44 | }, 45 | 46 | // Status message rewrite config 47 | name: { 48 | default: null, 49 | description: 'Name of the X32 to send to clients. If left unset, the target mixer\'s name will be used. Status rewrite must be enabled for this option to work.' 50 | }, 51 | model: { 52 | default: null, 53 | description: 'Make the mixer appear as if it is a different model than what it is. Status rewrite must be enabled for this option to work.' 54 | }, 55 | 'disable-status-rewrite': { 56 | default: false, 57 | type: 'boolean', 58 | description: 'By default, the proxy will rewrite the upstream console address in \`/status\` and \`/xinfo\` packets. This flag disables that functionality.' 59 | }, 60 | 61 | // Subscription Pool (to reduce traffic to the mixer when many clients are in use) 62 | 'disable-subscription-pool': { 63 | default: false, 64 | type: 'boolean', 65 | description: 'By default, meter and remote updates will be pooled into a single connection, to reduce load on the X32. You can disable it with this flag.' 66 | } 67 | }) 68 | .help() 69 | .argv; 70 | 71 | // Add missing `null` values for array params --ws, --udp, and --www. 72 | // (Yargs normally filters these out from the array. However, we actually 73 | // *want* these parameters, because no value for --ws, --udp, or --www signifies 74 | // that the proxy server should use the default address and port.) 75 | for (const [index, arg] of process.argv.entries()) { 76 | switch (arg) { 77 | case '--ws': 78 | case '--udp': 79 | case '--www': { 80 | if (!process.argv[index + 1] || process.argv[index + 1].startsWith('-')) { 81 | argv[arg.replace(/^--/, '')].push(null); 82 | } 83 | break; 84 | } 85 | } 86 | } 87 | 88 | // Split the target host:port into a separate hostname and port 89 | [argv.target, argv.targetPort] = (() => { 90 | const url = new URL(`fake-protocol://${argv.target}`); 91 | return [ 92 | url.hostname, 93 | Number.parseInt(url.port) || 10023 94 | ] 95 | })(); 96 | 97 | export default argv; -------------------------------------------------------------------------------- /lib/Client.js: -------------------------------------------------------------------------------- 1 | import argv from './argv.js'; 2 | import { Buffer } from 'node:buffer'; 3 | import dgram from 'node:dgram'; 4 | import globalSubscriptionPool from './GlobalSubscriptionPool.js'; 5 | import net from 'node:net'; 6 | 7 | /** 8 | * Client Class 9 | * Collection of data and objects tracked for each "connected" client. 10 | */ 11 | class Client extends EventTarget { 12 | constructor(opts) { 13 | super(); 14 | 15 | Object.assign(this, { 16 | // Timestamp for last time this client had activity 17 | lastActive: new Date(), 18 | 19 | // Client address/port (to be set in `opts`) 20 | downstreamAddress: null, 21 | downstreamPort: null, 22 | 23 | // Socket to send/receive from X32 24 | upstreamSocket: dgram.createSocket( 25 | net.isIPv6(opts.upstreamAddress) ? 'udp6' : 'udp4' 26 | ), 27 | 28 | // Packet Counts 29 | upstreamPacketCount: 0, 30 | downstreamPacketCount: 0 31 | }, opts); 32 | 33 | // Send messages from the X32 back to the client 34 | this.upstreamSocket.on('message', (data) => { 35 | this.sendDownstream(data); 36 | }); 37 | } 38 | 39 | /** 40 | * bindUpstream 41 | * Create socket with which to communicate with the X32. Each client needs 42 | * its own socket on a different port to disambiguate which client the reply 43 | * packets are being sent to. (The X32 replies back to the same port from 44 | * which data was sent.) Once this socket is listening, we're ready for 45 | * replies and can proceed with forwarding the original request message 46 | * upstream. 47 | */ 48 | bindUpstream() { 49 | this.bindPromise = this.bindPromise || new Promise((resolve, reject) => { 50 | this.upstreamSocket.once('error', reject); 51 | this.upstreamSocket.once('listening', resolve); 52 | this.upstreamSocket.bind(); 53 | }); 54 | return this.bindPromise; 55 | } 56 | 57 | /** 58 | * destroy 59 | * Once we're done with a client (such as when they timeout), let's clean up 60 | * our upstream socket to the X32. 61 | */ 62 | destroy() { 63 | this.dispatchEvent(new Event('destroy')); 64 | this.upstreamSocket?.close(); 65 | this.upstreamSocket = null; 66 | } 67 | 68 | // Send data back to client 69 | // NOTE: This method **must** be implemented by subclasses. 70 | // This parent method just processes the message. That's it. 71 | sendDownstream(data) { 72 | this.lastActive = new Date(); 73 | this.downstreamPacketCount++; 74 | 75 | // Rewrite `/status` packet, as it contains the IP of the X32. 76 | // Older versions of Behringer's X32-Edit use the IP address in this 77 | // packet, rather than the IP that the user said they wanted. 78 | const statusPacketPrefix = new Buffer.from('/status'); 79 | const xinfoPacketPrefix = new Buffer.from('/xinfo'); 80 | if ( 81 | !argv.disableStatusRewrite && ( 82 | // If the packet starts with `/status` or `/xinfo`... 83 | !statusPacketPrefix.compare( 84 | data.slice(0, statusPacketPrefix.length) 85 | ) || 86 | !xinfoPacketPrefix.compare( 87 | data.slice(0, xinfoPacketPrefix.length) 88 | ) 89 | ) 90 | ) { 91 | // Parse the `/status` OSC packet into its individual fields by splitting on NULL (0x00) 92 | const statusPacketFields = data.toString().split('\0').filter(field => field); 93 | 94 | switch (statusPacketFields[0]) { 95 | // /status ,sss active 192.0.2.1 X32C-FF-EE-DD 96 | case '/status': 97 | statusPacketFields[3] = this.server.address; 98 | if (argv.name) { 99 | statusPacketFields[4] = argv.name; 100 | } 101 | break; 102 | // /xinfo ,ssss 192.0.2.1 X32C-FF-EE-DD X32C 3.09 103 | case '/xinfo': 104 | statusPacketFields[2] = this.server.address; 105 | if (argv.name) { 106 | statusPacketFields[3] = argv.name; 107 | } 108 | if (argv.model) { 109 | statusPacketFields[4] = argv.model; 110 | } 111 | break; 112 | } 113 | 114 | // Rebuild OSC message 115 | data = Buffer.from(statusPacketFields.reduce((data, field) => { 116 | data += field; 117 | data += '\0'.repeat(4 - (data.length % 4)); 118 | return data; 119 | }, '')); 120 | } 121 | 122 | return data; 123 | } 124 | 125 | async sendUpstream(data) { 126 | this.lastActive = new Date(); 127 | this.upstreamPacketCount++; 128 | await 129 | ( 130 | !argv.disableSubscriptionPool && 131 | globalSubscriptionPool.sendUpstream(data, this) 132 | ) 133 | || 134 | (async () => { 135 | await this.bindUpstream(); 136 | this.upstreamSocket.send(data, this.upstreamPort, this.upstreamAddress); 137 | })(); 138 | } 139 | } 140 | 141 | export default Client; -------------------------------------------------------------------------------- /.github/workflows/deno-build-release.yml: -------------------------------------------------------------------------------- 1 | name: Build & Release (Deno) 2 | 3 | on: 4 | push: 5 | tags: 6 | - "v*" 7 | 8 | permissions: 9 | contents: write # create releases & upload assets 10 | 11 | concurrency: 12 | group: release-${{ github.ref }} 13 | cancel-in-progress: false 14 | 15 | env: 16 | BIN_NAME: x32-proxy 17 | 18 | jobs: 19 | build: 20 | name: Build (${{ matrix.label }}) 21 | runs-on: ${{ matrix.os }} 22 | strategy: 23 | fail-fast: false 24 | matrix: 25 | include: 26 | - { os: ubuntu-latest, platform: x86_64-unknown-linux-gnu, ext: "", packext: "tar.gz", label: "Linux x64" } 27 | - { os: ubuntu-24.04-arm, platform: aarch64-unknown-linux-gnu, ext: "", packext: "tar.gz", label: "Linux ARM64" } 28 | - { os: macos-latest, platform: aarch64-apple-darwin, ext: "", packext: "tar.gz", label: "macOS (Apple Silicon)" } 29 | - { os: windows-latest, platform: x86_64-pc-windows-msvc, ext: ".exe", packext: "zip", label: "Windows x64" } 30 | 31 | steps: 32 | - name: Checkout 33 | uses: actions/checkout@v4 34 | 35 | - name: Setup Deno 36 | uses: denoland/setup-deno@v1 37 | with: 38 | deno-version: v2.x 39 | 40 | - name: Install dependencies (deno) 41 | shell: bash 42 | run: | 43 | deno install 44 | 45 | - name: Prepare names 46 | id: names 47 | shell: bash 48 | run: | 49 | set -euo pipefail 50 | VERSION="${GITHUB_REF_NAME}" 51 | OUTDIR="dist" 52 | BIN="${{ env.BIN_NAME }}${{ matrix.ext }}" 53 | ARCHIVE="${{ env.BIN_NAME }}-${VERSION}-${{ matrix.platform }}.${{ matrix.packext }}" 54 | mkdir -p "$OUTDIR" 55 | { 56 | echo "version=$VERSION" 57 | echo "outdir=$OUTDIR" 58 | echo "bin=$BIN" 59 | echo "archive=$ARCHIVE" 60 | } >> "$GITHUB_OUTPUT" 61 | 62 | - name: Compile (non-Windows) 63 | if: runner.os != 'Windows' 64 | run: | 65 | deno compile \ 66 | --allow-env \ 67 | --allow-net \ 68 | --allow-read \ 69 | --target ${{ matrix.platform }} \ 70 | --output "${{ steps.names.outputs.outdir }}/${{ steps.names.outputs.bin }}" \ 71 | index.js 72 | chmod +x "${{ steps.names.outputs.outdir }}/${{ steps.names.outputs.bin }}" 73 | 74 | - name: Compile (Windows) 75 | if: runner.os == 'Windows' 76 | shell: pwsh 77 | run: | 78 | deno compile ` 79 | --allow-env ` 80 | --allow-net ` 81 | --allow-read ` 82 | --target ${{ matrix.platform }} ` 83 | --output "${{ steps.names.outputs.outdir }}\${{ steps.names.outputs.bin }}" ` 84 | index.js 85 | 86 | - name: Package (tar.gz) 87 | if: matrix.packext == 'tar.gz' 88 | shell: bash 89 | run: | 90 | set -euo pipefail 91 | cd "${{ steps.names.outputs.outdir }}" 92 | tar -czf "${{ steps.names.outputs.archive }}" "${{ steps.names.outputs.bin }}" 93 | 94 | - name: Package (zip) 95 | if: matrix.packext == 'zip' 96 | shell: pwsh 97 | run: | 98 | Set-StrictMode -Version Latest 99 | $outdir = "${{ steps.names.outputs.outdir }}" 100 | $bin = "${{ steps.names.outputs.bin }}" 101 | $archive= "${{ steps.names.outputs.archive }}" 102 | Compress-Archive -Path (Join-Path $outdir $bin) -DestinationPath (Join-Path $outdir $archive) -Force 103 | 104 | - name: Upload artifact 105 | uses: actions/upload-artifact@v4 106 | with: 107 | name: ${{ matrix.label }} 108 | path: | 109 | ${{ steps.names.outputs.outdir }}/${{ steps.names.outputs.bin }} 110 | ${{ steps.names.outputs.outdir }}/${{ steps.names.outputs.archive }} 111 | 112 | docker: 113 | name: Docker image 114 | runs-on: ubuntu-latest 115 | steps: 116 | - name: Checkout 117 | uses: actions/checkout@v4 118 | 119 | - name: Compute version & outdir 120 | id: meta 121 | shell: bash 122 | run: | 123 | set -euo pipefail 124 | VERSION="${GITHUB_REF_NAME}" 125 | OUTDIR="dist" 126 | mkdir -p "$OUTDIR" 127 | echo "version=$VERSION" >> "$GITHUB_OUTPUT" 128 | echo "outdir=$OUTDIR" >> "$GITHUB_OUTPUT" 129 | 130 | - name: Build Docker image 131 | run: | 132 | docker build --pull -t x32-proxy:${{ steps.meta.outputs.version }} . 133 | 134 | - name: Save versioned tarball (gzip) 135 | shell: bash 136 | run: | 137 | set -euo pipefail 138 | docker save x32-proxy:${{ steps.meta.outputs.version }} \ 139 | | gzip -9n > "${{ steps.meta.outputs.outdir }}/x32-proxy-${{ steps.meta.outputs.version }}-docker.tar.gz" 140 | ls -lh "${{ steps.meta.outputs.outdir }}"/*.tar.gz 141 | 142 | - name: Upload Docker artifact 143 | uses: actions/upload-artifact@v4 144 | with: 145 | name: docker-image 146 | path: ${{ steps.meta.outputs.outdir }}/*.tar.gz 147 | 148 | release: 149 | name: Create GitHub Release 150 | needs: [build, docker] 151 | runs-on: ubuntu-latest 152 | steps: 153 | - name: Download all artifacts 154 | uses: actions/download-artifact@v4 155 | with: 156 | path: dist 157 | merge-multiple: true 158 | 159 | - name: Publish release 160 | uses: softprops/action-gh-release@v2 161 | with: 162 | tag_name: ${{ github.ref_name }} 163 | name: ${{ github.ref_name }} 164 | files: | 165 | dist/*.tar.gz 166 | dist/*.zip 167 | draft: false 168 | prerelease: false 169 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | Contributing 2 | ============ 3 | 4 | Thank you for your interest in contributing to the project! 5 | 6 | Please submit your changes via a GitHub Pull Request for review. For large changes, or changes that break backwards compatibility, please consider adding an issue to the tracker first for discussion. 7 | 8 | All contributors must agree to the Contributor License Agreement below before contributions will be accepted. Please affirm in your pull request message that you agree to the CLA. Alternatively, you can e-mail opensource@audiopump.co. Your affirmation serves as your digital signature to the agreement. 9 | 10 | # Contributor License Agreement 11 | 12 | In order to clarify the intellectual property license granted with Contributions from any person or entity, AudioPump, Inc. ("AudioPump") must have a Contributor License Agreement ("CLA") on file that has been signed by each Contributor, indicating agreement to the license terms below. This license is for your protection as a Contributor as well as the protection of AudioPump; it does not change your rights to use your own Contributions for any other purpose. 13 | 14 | You accept and agree to the following terms and conditions for Your present and future Contributions submitted to AudioPump. Except for the license granted herein to AudioPump and recipients of software distributed by AudioPump, You reserve all right, title, and interest in and to Your Contributions. 15 | 16 | ## Definitions 17 | 18 | "You" (or "Your") shall mean the copyright owner or legal entity authorized by the copyright owner that is making this Agreement with AudioPump. For legal entities, the entity making a Contribution and all other entities that control, are controlled by, or are under common control with that entity are considered to be a single Contributor. For the purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity. 19 | 20 | "Contribution" shall mean any original work of authorship, including any modifications or additions to an existing work, that is intentionally submitted by You to AudioPump for inclusion in, or documentation of, any of the products owned or managed by AudioPump (the "Work"). For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent to AudioPump or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, AudioPump for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by You as "Not a Contribution." 21 | 22 | ## Grant of Copyright License 23 | 24 | Subject to the terms and conditions of this Agreement, You hereby grant to AudioPump and to recipients of software distributed by AudioPump a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare derivative works of, publicly display, publicly perform, sublicense, and distribute Your Contributions and such derivative works. 25 | 26 | ## Grant of Patent License 27 | 28 | Subject to the terms and conditions of this Agreement, You hereby grant to AudioPump and to recipients of software distributed by AudioPump a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by You that are necessarily infringed by Your Contribution(s) alone or by combination of Your Contribution(s) with the Work to which such Contribution(s) was submitted. If any entity institutes patent litigation against You or any other entity (including a cross-claim or counterclaim in a lawsuit) alleging that your Contribution, or the Work to which you have contributed, constitutes direct or contributory patent infringement, then any patent licenses granted to that entity under this Agreement for that Contribution or Work shall terminate as of the date such litigation is filed. 29 | 30 | ## Legally Entitled 31 | 32 | You represent that you are legally entitled to grant the above license. If your employer(s) has rights to intellectual property that you create that includes your Contributions, you represent that you have received permission to make Contributions on behalf of that employer, that your employer has waived such rights for your Contributions to AudioPump, or that your employer has executed a separate Corporate CLA with AudioPump. 33 | 34 | ## Original Creation 35 | 36 | You represent that each of Your Contributions is Your original creation (see section 'upstream works' for submissions on behalf of others). You represent that Your Contribution submissions include complete details of any third-party license or other restriction (including, but not limited to, related patents and trademarks) of which you are personally aware and which are associated with any part of Your Contributions. 37 | 38 | ## As-Is 39 | 40 | You are not expected to provide support for Your Contributions, except to the extent You desire to provide support. You may provide support for free, for a fee, or not at all. Unless required by applicable law or agreed to in writing, You provide Your Contributions on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. 41 | 42 | ## Upstream Works 43 | 44 | Should You wish to submit work that is not Your original creation, You may submit it to AudioPump separately from any Contribution, identifying the complete details of its source and of any license or other restriction (including, but not limited to, related patents, trademarks, and license agreements) of which you are personally aware, and conspicuously marking the work as "Submitted on behalf of a third-party: [named here]". 45 | 46 | ## Corrections 47 | 48 | You agree to notify AudioPump of any facts or circumstances of which you become aware that would make these representations inaccurate in any respect. 49 | 50 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | x32-proxy 2 | -------- 3 | 4 | *UDP and Web Socket proxy server for Behringer and Midas digital mixer control.* 5 | 6 | Use cases: 7 | 8 | - Use your mixers over a VPN. 9 | - Connect over IPv6. 10 | - Use Web Sockets to control mixers from a web application. 11 | 12 | Compatible mixers include: 13 | 14 | - **X32/M32 Series**
X32 Core, X32 Rack, X32 Producer, X32 Compact, X32, M32C, M32R, M32R Live, M32 Live 15 | - **X-Air/MR Series**
XR12, XR16, XR18, X18, MR18, MR12 16 | 17 | # Installation 18 | 19 | 1. Install [Node.js for your platform](https://nodejs.org/en/download/). 20 | 21 | 2. Install x32-proxy via NPM. (It is recommended you install x32-proxy globally, as this is a CLI.) 22 | 23 | ``` 24 | npm install -g x32-proxy 25 | ``` 26 | 27 | # Usage 28 | 29 | To start a simple proxy server: 30 | 31 | ``` 32 | x32-proxy --udp --target 33 | ``` 34 | 35 | For example, suppose your X32/M32 mixer has an IP address of `192.0.2.20`. Also suppose your PC is on the `192.0.2.0/24` LAN with the mixer, as well as on a VPN with the address of `203.0.113.10`. You would like to share access to the mixer with everyone on the VPN. To do that, you bind the proxy to the VPN, and specify the mixer's address on your LAN: 36 | 37 | ``` 38 | x32-proxy --udp 203.0.113.10 --target 192.0.2.20 39 | ``` 40 | 41 | Once running, VPN users should be able to access the mixer using their normal X32/M32 OSC clients (such as X32 Edit) on `203.0.113.10`. 42 | 43 | 44 | ## Options 45 | 46 | ### `--target ` (required) 47 | 48 | Specifies the address of the mixer to connect to. Port defaults to `10023`, which is used for the X32/M32 series mixers. If you are using the XR/MR series mixers, you should specify port `10024`. 49 | 50 | ### `--udp [bind address[:port]]` 51 | 52 | Start a UDP server, compatible with standard tools such as X32 Edit and X-Air Edit. 53 | 54 | It is recommended that you specify an IP address to bind to. Otherwise `127.0.0.1` will be used. You should **not** use `0.0.0.0` to listen on all interfaces, as this setting would be incompatible with Behringer/Midas software. (The bind address is used in status messages when initially connecting, and therefore has to be an address reachable by X32 Edit or X-Air Edit.) 55 | 56 | The port will default to match the target port (`10023` for X32 series, or `10024` for X-Air series), but you may want to change it for special use cases. 57 | 58 | You may specify `--udp` multiple times to listen on multiple interfaces and/or ports. 59 | 60 | ### `--ws [bind address[:port]]` 61 | 62 | Start a Web Socket server, for use from web applications. 63 | 64 | As with `--udp`, the default bind address is `127.0.0.1`. Unlike `--udp`, you **may** use `0.0.0.0` or `[::]` to listen on all IPv4 or IPv4/IPv6 interfaces, respectively. 65 | 66 | The default port is `8080`. 67 | 68 | Web clients can connect as follows: 69 | 70 | ```javascript 71 | const ws = new WebSocket('http://127.0.0.1:8080/'); 72 | ws.binaryType = 'arraybuffer'; 73 | 74 | ws.addEventListener('open', (() => { 75 | // Send /xinfo~~,~~~ 76 | ws.send(new Uint8Array([47, 120, 105, 110, 102, 111, 0, 0, 44, 0, 0, 0]).buffer); 77 | }), {once: true}); 78 | 79 | ws.addEventListener('message', (e) => { 80 | console.log(e.data); 81 | }); 82 | ``` 83 | 84 | The bind address and port may be shared with the built-in static web server. You may specify `--ws` multiple times to listen on multiple interfaces and/or ports. 85 | 86 | ### `--www [bind address[:port]]` 87 | 88 | Start a minimal static web server, for convenience in hosting simple client-side web applications. Will serve files from the current working directory by default. See [serve-static](https://www.npmjs.com/package/serve-static) for details. 89 | 90 | As with `--udp`, the default bind address is `127.0.0.1`. Unlike `--udp`, you **may** use `0.0.0.0` or `[::]` to listen on all IPv4 or IPv4/IPv6 interfaces, respectively. 91 | 92 | The default port is `80`. 93 | 94 | The bind address and port may be shared with the Web Socket server. You may specify `--www` multiple times to listen on multiple interfaces and/or ports. 95 | 96 | ### `--www-root ` 97 | 98 | Set the document root directory for the static web server. Defaults to the current working directory. 99 | 100 | ### `--help` 101 | Shows all the options and usage. 102 | 103 | ### `--name ` 104 | You may want to rewrite the display name of the upstream mixer for clients to display in their setup dialog. This option allows specifying the name. (Requires status rewrite to be left enabled.) 105 | 106 | ## Advanced Options 107 | 108 | ### `--disable-status-rewrite` 109 | By default, the proxy will rewrite the upstream console address in `/status` and `/xinfo` packets. This flag disables that functionality. (Conflicts with `--name` and `--model` options.) 110 | 111 | ### `--disable-subscription-pool` 112 | The proxy will combine requests for `/xremote` and `/meter` subscriptions, when possible. This will reduce load on the mixer, enabling more simultaneous clients. You can disable this feature with this flag. 113 | 114 | ### `--model` 115 | You can rewrite the model name of your mixer with this option. It might be useful to make newer mixer variants work with legacy software. For example, you may have a newer model `XR18V2` mixer that you need to appear as a regular `XR18` for compatibility. 116 | 117 | ### `--udp-client-timeout ` 118 | If the proxy hasn't heard from the client in awhile (5 seconds by default), it will remove them from the list of active clients and will close the socket that the X32 would normally respond on. You can adjust this timeout if you wish. There should be no practical reason to change this. If a client is assumed inactive, its upstream socket is closed. A new one will be reopened immediately if a new packet is received from the client. 119 | 120 | ## Example: IPv6 VPN with stock X32 Edit 121 | Unfortunately, the mixers don't support IPv6, and [probably never will](https://community.musictribe.com/discussions/89151/166716/ipv6-support-for-osc-control). Even more unfortunate is that the standard X32 Edit/X-Air Edit applications are hardcoded to only use IPv4 addresses. They don't even support hostnames. What are you to do if you must access your mixer over an IPv6-only VPN? You can run this proxy utility on two sides of it. 122 | 123 | Example Address: 124 | 125 | - X32 LAN: `192.0.2.1` 126 | - PC1 LAN: `192.0.2.5` 127 | - PC1 VPN: `2001:0db8::19aa:5035` 128 | - PC2 VPN: `2001:0db8::19ac:75fc` 129 | 130 | Suppose you want to access the X32 from PC2. On PC1, run the following: 131 | 132 | ``` 133 | x32-proxy --udp [2001:0db8::19aa]:5035 --target 192.0.2.1 134 | ``` 135 | 136 | On PC2, run the following: 137 | 138 | ``` 139 | x32-proxy --udp --target [2001:0db8::19aa]:5035 140 | ``` 141 | 142 | Now, on PC2, you can open your software and connect to `127.0.0.1` with X32 Edit as if you were directly connected to the console. 143 | 144 | # Docker 145 | 146 | This application also works well under Docker, which can be useful for running on appliances like a Synology NAS. 147 | 148 | To build the image: 149 | 150 | ```bash 151 | docker build -t x32-proxy:latest . 152 | ``` 153 | 154 | To save the image as a tarball, for sending elsewhere: 155 | 156 | ```bash 157 | docker save x32-proxy:latest > x32-proxy-latest.tar 158 | ``` 159 | 160 | To run it: 161 | 162 | ```bash 163 | docker run --network=host -p 10023:10023/udp x32-proxy:latest --udp 203.0.113.10 --target 192.0.2.20 164 | ``` 165 | 166 | Note that we've specified `host` networking here. If you plan to use X32 Edit over UDP, you'll need this as X32 Edit (and likely others) depends on the IP address to be set in the reply packets sent from the proxy. Therefore, the proxy cannot be on some other network... it needs to be connected to the host's network to be accessible. If you're only using web socket clients, you don't need to worry about this if you forward the right ports. 167 | 168 | 169 | # Security 170 | It is important to note that the X32/M32 OSC implementation has no security at all. You urged to only connect trustworthy devices to its network which require full access to the console. When proxying data from other networks, you must be careful and be absolutely sure you trust every device on that network. If you enable access to the mixer from the internet, you're probably going to have a bad day. 171 | 172 | # License 173 | See license in LICENSE file. 174 | 175 | This project is not associated with Behringer, Midas, MUSIC Group, or any of those folks. Please do not contact them for support for this tool. 176 | 177 | Copyright © 2025 AudioPump, Inc. --------------------------------------------------------------------------------