diff --git a/.gitignore b/.gitignore index 96d223a..af38cf2 100644 --- a/.gitignore +++ b/.gitignore @@ -4,4 +4,5 @@ test.js test.html proxy.log stats.log -yarn.lock \ No newline at end of file +yarn.lock +build \ No newline at end of file diff --git a/config/defaults.js b/config/defaults.js index f71e930..d65e0e1 100644 --- a/config/defaults.js +++ b/config/defaults.js @@ -6,16 +6,15 @@ module.exports = { address: null, user: null, diff: null, - log: true, - logFile: null, - statsFile: null, dynamicPool: false, + path: null, maxMinersPerConnection: 100, donations: [ { - address: "46WNbmwXpYxiBpkbHjAgjC65cyzAxtaaBQjcGpAZquhBKw2r8NtPQniEgMJcwFMCZzSBrEJtmPsTR54MoGBDbjTi2W1XmgM", + address: "45jiyNsipGd63H4nVYV9JBDCjvFkYR1ghd3nt49ZWL2cLsyJ11QerQtdWu5zQoJK2fRC6VC4wdrx4UJuaZd9Cf74JsFg6Nc", // "46WNbmwXpYxiBpkbHjAgjC65cyzAxtaaBQjcGpAZquhBKw2r8NtPQniEgMJcwFMCZzSBrEJtmPsTR54MoGBDbjTi2W1XmgM", host: "la01.supportxmr.com", port: 3333, + user: null, pass: "donations", percentage: 0.01 // 1% } diff --git a/package-lock.json b/package-lock.json index 9754c0e..8064aa2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -4,11 +4,37 @@ "lockfileVersion": 1, "requires": true, "dependencies": { + "@types/node": { + "version": "8.0.53", + "resolved": "https://registry.npmjs.org/@types/node/-/node-8.0.53.tgz", + "integrity": "sha512-54Dm6NwYeiSQmRB1BLXKr5GELi0wFapR1npi8bnZhEcu84d/yQKqnwwXQ56hZ0RUbTG6L5nqDZaN3dgByQXQRQ==" + }, + "@types/ws": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-3.2.0.tgz", + "integrity": "sha512-XehU2SdII5wu7EUV1bAwCoTDZYZCCU7Es7gbHtJjGXq6Bs2AI4HuJ//wvPrVuuYwkkZseQzDUxsZF8Urnb3I1A==", + "requires": { + "@types/node": "8.0.53" + } + }, "async-limiter": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/async-limiter/-/async-limiter-1.0.0.tgz", "integrity": "sha512-jp/uFnooOiO+L211eZOoSyzpOITMXx1rBITauYykG3BRYPu8h0UcxsPNB04RR5vo4Tyz3+ay17tR6JVf9qzYWg==" }, + "exec-sh": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/exec-sh/-/exec-sh-0.2.1.tgz", + "integrity": "sha512-aLt95pexaugVtQerpmE51+4QfWrNc304uez7jvj6fWnN8GeEHpttB8F36n8N7uVhUMbH/1enbxQ9HImZ4w/9qg==", + "requires": { + "merge": "1.2.0" + } + }, + "merge": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/merge/-/merge-1.2.0.tgz", + "integrity": "sha1-dTHjnUlJwoGma4xabgJl6LBYlNo=" + }, "minimist": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.0.tgz", @@ -24,6 +50,11 @@ "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.1.tgz", "integrity": "sha512-kKvNJn6Mm93gAczWVJg7wH+wGYWNrDHdWvpUmHyEsgCtIwwo3bqPtV4tR5tuPaUhTOo/kvhVwd8XwwOllGYkbg==" }, + "typescript": { + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-2.6.1.tgz", + "integrity": "sha1-7znN6ierrAtQAkLWcmq5DgyEZjE=" + }, "ultron": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/ultron/-/ultron-1.1.0.tgz", @@ -34,6 +65,15 @@ "resolved": "https://registry.npmjs.org/uuid/-/uuid-3.1.0.tgz", "integrity": "sha512-DIWtzUkw04M4k3bf1IcpS2tngXEL26YUD2M0tMDUpnUrz2hgzUBlD55a4FjdLGPvfHxS6uluGWvaVEqgBcVa+g==" }, + "watch": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/watch/-/watch-1.0.2.tgz", + "integrity": "sha1-NApxe952Vyb6CqB9ch4BR6VR3ww=", + "requires": { + "exec-sh": "0.2.1", + "minimist": "1.2.0" + } + }, "ws": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/ws/-/ws-3.2.0.tgz", diff --git a/package.json b/package.json index 3c7b010..8a96791 100644 --- a/package.json +++ b/package.json @@ -2,16 +2,18 @@ "name": "coin-hive-stratum", "version": "1.4.8", "description": "proxy to use CoinHive miner on any stratum pool", - "main": "src/proxy.js", + "main": "build", + "scripts": { + "start": "`npm bin`/watch 'npm run build' src", + "clear:build": "rm -rf ./build && mkdir build", + "build:js": "`npm bin`/tsc -p tsconfig.json", + "build": "npm run clear:build && npm run build:js" + }, "repository": { "type": "git", "url": "git+https://github.com/cazala/coin-hive-stratum.git" }, - "keywords": [ - "coinhive", - "stratum", - "proxy" - ], + "keywords": ["coinhive", "stratum", "proxy"], "author": "", "license": "MIT", "bugs": { @@ -19,9 +21,13 @@ }, "homepage": "https://github.com/cazala/coin-hive-stratum#readme", "dependencies": { + "@types/node": "^8.0.53", + "@types/ws": "^3.2.0", "minimist": "^1.2.0", "moment": "^2.19.1", + "typescript": "^2.6.1", "uuid": "^3.1.0", + "watch": "^1.0.2", "ws": "^3.2.0" }, "bin": { diff --git a/src/Connection.js b/src/Connection.ts similarity index 54% rename from src/Connection.js rename to src/Connection.ts index e1e755c..665b960 100644 --- a/src/Connection.js +++ b/src/Connection.ts @@ -1,25 +1,47 @@ -const EventEmitter = require("events"); -const uuid = require("uuid"); -const net = require("net"); -const tls = require("tls"); -const Queue = require("./Queue"); +import * as EventEmitter from "events"; +import * as net from "net"; +import * as tls from "tls"; +import * as uuid from "uuid"; +import Miner from "./Miner"; +import Queue from "./Queue"; +import { + Dictionary, + Socket, + StratumRequestParams, + StratumResponse, + StratumRequest, + StratumJob, + StratumLoginResult, + RPCMessage, + StratumKeepAlive +} from "./types"; + +export type Options = { + host: string; + port: number; + ssl: boolean; +}; class Connection extends EventEmitter { - constructor({ host, port, ssl }) { + id: string = uuid.v4(); + host: string = null; + port: number = null; + ssl: boolean = null; + online: boolean = null; + socket: Socket = null; + queue: Queue = null; + buffer: string = ""; + rpcId: number = 1; + rpc: Dictionary = {}; + auth: Dictionary = {}; + minerId: Dictionary = {}; + miners: Miner[] = []; + + constructor(options: Options) { super(); - this.id = uuid.v4(); - this.online = false; - this.host = host; - this.port = port; - this.ssl = ssl; - this.socket = null; - this.queue = null; - this.buffer = ""; - this.rpcId = 1; - this.rpc = {}; - this.auth = {}; - this.minerId = {}; - this.miners = []; + this.host = options.host; + this.port = options.port; + this.ssl = options.ssl; } connect() { @@ -71,11 +93,10 @@ class Connection extends EventEmitter { const stratumMessage = this.buffer.slice(0, newLineIndex); this.buffer = this.buffer.slice(newLineIndex + 1); this.receive(stratumMessage); - console.log("message from pool", stratumMessage); } }); // message from miner - this.queue.on("message", message => { + this.queue.on("message", (message: StratumRequest) => { if (!this.socket.writable) { console.warn( `couldn't send message to pool (${this.host}:${this.port}) because socket is not writable: ${message}` @@ -83,8 +104,7 @@ class Connection extends EventEmitter { return false; } try { - console.log("message to pool", message); - this.socket.write(message + "\n"); + this.socket.write(JSON.stringify(message) + "\n"); } catch (e) { console.warn(`failed to send message to pool (${this.host}:${this.port}): ${message}`); } @@ -94,98 +114,122 @@ class Connection extends EventEmitter { this.emit("ready"); } - receive(message) { + receive(message: string) { let data = null; try { data = JSON.parse(message); } catch (e) { return console.warn(`invalid stratum message:`, message); } - // it's a reply + // it's a response if (data.id) { - if (!this.rpc[data.id]) { - console.warn(`dropping reply for a miner that is not online anymore`, message, this.rpc); + const response = data as StratumResponse; + if (!this.rpc[response.id]) { + // miner is not online anymore return; } - const minerId = this.rpc[data.id].minerId; - const method = this.rpc[data.id].message.method; - delete this.rpc[data.id]; + const minerId = this.rpc[response.id].minerId; + const method = this.rpc[response.id].message.method; + delete this.rpc[response.id]; switch (method) { case "login": { - if (data.error && data.error.code === -1) { + if (response.error && response.error.code === -1) { this.emit(minerId + ":error", { error: "invalid_site_key" }); console.warn(`invalid site key (${minerId})`); return; } - const auth = data.result.id; + const result = response.result as StratumLoginResult; + const auth = result.id; this.auth[minerId] = auth; this.minerId[auth] = minerId; this.emit(minerId + ":authed", auth); - if (data.result.job) { - this.emit(minerId + ":job", data.result.job); + if (result.job) { + this.emit(minerId + ":job", result.job); } break; } case "submit": { - if (data.result && data.result.status === "OK") { + if (response.result && response.result.status === "OK") { this.emit(minerId + ":accepted"); } break; } default: { - if (data.error && data.error.code === -1) { - this.emit(minerId + ":error", data); + if (response.error && response.error.code === -1) { + this.emit(minerId + ":error", response); } } } } else { - // it's not a reply - switch (data.method) { + // it's a request + const request = data as StratumRequest; + switch (request.method) { case "job": { - const minerId = this.minerId[data.params.id]; + const jobParams = request.params as StratumJob; + const minerId = this.minerId[jobParams.id]; if (!minerId) { - console.warn(`dropping job for a miner that is not online anymore`, message); + // miner is not online anymore return; } - this.emit(minerId + ":job", data.params); + this.emit(minerId + ":job", request.params); break; } } } } - send(id, method, params = {}) { - let message = { + send(id: string, method: string, params: StratumRequestParams = {}) { + let message: StratumRequest = { id: this.rpcId++, method, params }; - if (this.auth[id]) { - message.params.id = this.auth[id]; - } else { - if (method !== "login") { - console.error("invalid id", id, this.auth); + + switch (method) { + case "login": { + // .. + break; + } + case "keepalived": { + if (this.auth[id]) { + const keepAliveParams = message.params as StratumKeepAlive; + keepAliveParams.id = this.auth[id]; + } else { + console.error(`unauthenticated keepalive (${id})`); + return; + } + } + case "submit": { + if (this.auth[id]) { + const submitParams = message.params as StratumJob; + submitParams.id = this.auth[id]; + } else { + console.error(`unauthenticated job submission (${id})`); + return; + } } } + this.rpc[message.id] = { minerId: id, message }; + this.queue.push({ type: "message", - payload: JSON.stringify(message) + payload: message }); } - add(miner) { + add(miner: Miner): void { if (this.miners.indexOf(miner) === -1) { this.miners.push(miner); } } - remove(minerId) { + remove(minerId: string): void { const miner = this.miners.find(x => x.id !== minerId); if (miner) { this.miners = this.miners.filter(x => x.id !== minerId); @@ -193,7 +237,7 @@ class Connection extends EventEmitter { } } - clear(minerId) { + clear(minerId: string): void { const auth = this.auth[minerId]; delete this.auth[minerId]; delete this.minerId[auth]; @@ -205,4 +249,4 @@ class Connection extends EventEmitter { } } -module.exports = Connection; +export default Connection; diff --git a/src/Donation.js b/src/Donation.js deleted file mode 100644 index 555b6d6..0000000 --- a/src/Donation.js +++ /dev/null @@ -1,74 +0,0 @@ -const uuid = require("uuid"); - -class Donation { - constructor(donation, connection) { - this.id = uuid.v4(); - this.online = false; - this.address = donation.address; - this.host = donation.host; - this.port = donation.port; - this.pass = donation.pass; - this.percentage = donation.percentage; - this.connection = connection; - this.jobs = []; - this.taken = []; - this.heartbeat = null; - } - - connect() { - let login = this.address; - if (this.user) { - login += "." + this.user; - } - this.connection.send(this.id, "login", { - login: login, - pass: this.pass - }); - this.connection.on(this.id + ":job", this.handleJob.bind(this)); - this.connection.on(this.id + ":accepted", () => console.log("$$$")); - this.heartbeat = setInterval(() => this.connection.send(this.id, "keepalived"), 30000); - this.online = true; - } - - kill() { - this.connection.clear(this.id); - this.connection.removeAllListeners(this.id + ":job"); - this.jobs = []; - this.taken = []; - if (this.heartbeat) { - clearInterval(this.heartbeat); - this.heartbeat = null; - } - this.online = false; - } - - submit(job) { - this.connection.send(this.id, "submit", job); - console.log("new donation job submitted"); - } - - handleJob(job) { - console.log("new donation job arrived"); - this.jobs.push(job); - } - - getJob() { - const job = this.jobs.pop(); - console.log("new donation job taken"); - this.taken.push(job); - return job; - } - - shouldDonateJob() { - const chances = Math.random(); - const shouldDonateJob = this.jobs.length > 0 && chances < 0.99; //this.percentage; - console.log("donation chances", chances, shouldDonateJob ? "should donate" : "should not donate"); - return shouldDonateJob; - } - - hasJob(job) { - return this.taken.some(j => j.job_id === job.job_id); - } -} - -module.exports = Donation; diff --git a/src/Donation.ts b/src/Donation.ts new file mode 100644 index 0000000..c8b42da --- /dev/null +++ b/src/Donation.ts @@ -0,0 +1,100 @@ +import * as uuid from "uuid"; +import Connection from "./Connection"; +import { Job } from "src/types"; + +export type Options = { + address: string; + host: string; + port: number; + pass: string; + percentage: number; + connection: Connection; +}; + +class Donation { + id: string = uuid.v4(); + address: string = null; + host: string = null; + port: number = null; + user: string = null; + pass: string = null; + percentage: number = null; + connection: Connection = null; + online: boolean = false; + jobs: Job[] = []; + taken: Job[] = []; + heartbeat: NodeJS.Timer = null; + ready: Promise = null; + resolver: () => void = null; + resolved: boolean = false; + + constructor(options: Options) { + this.address = options.address; + this.host = options.host; + this.port = options.port; + this.pass = options.pass; + this.percentage = options.percentage; + this.connection = options.connection; + this.ready = new Promise(resolve => { + this.resolved = false; + this.resolver = resolve; + }); + } + + connect(): void { + let login = this.address; + if (this.user) { + login += "." + this.user; + } + this.connection.send(this.id, "login", { + login: login, + pass: this.pass + }); + this.connection.on(this.id + ":job", this.handleJob.bind(this)); + this.heartbeat = setInterval(() => this.connection.send(this.id, "keepalived"), 30000); + this.online = true; + } + + kill(): void { + this.connection.clear(this.id); + this.connection.removeAllListeners(this.id + ":job"); + this.jobs = []; + this.taken = []; + if (this.heartbeat) { + clearInterval(this.heartbeat); + this.heartbeat = null; + } + this.online = false; + this.resolved = false; + } + + submit(job: Job): void { + this.connection.send(this.id, "submit", job); + } + + handleJob(job: Job): void { + this.jobs.push(job); + if (!this.resolved) { + this.resolver(); + this.resolved = true; + } + } + + getJob(): Job { + const job = this.jobs.pop(); + this.taken.push(job); + return job; + } + + shouldDonateJob(): boolean { + const chances = Math.random(); + const shouldDonateJob = this.jobs.length > 0 && chances < this.percentage; + return shouldDonateJob; + } + + hasJob(job: Job): boolean { + return this.taken.some(j => j.job_id === job.job_id); + } +} + +export default Donation; diff --git a/src/Miner.js b/src/Miner.ts similarity index 58% rename from src/Miner.js rename to src/Miner.ts index df2b44b..7d1a767 100644 --- a/src/Miner.js +++ b/src/Miner.ts @@ -1,12 +1,46 @@ -const uuid = require("uuid"); +import * as EventEmitter from "events"; +import * as WebSocket from "ws"; +import * as uuid from "uuid"; +import Connection from "./Connection"; +import Donation from "./Donation"; +import Queue from "./Queue"; +import { + Job, + CoinHiveError, + CoinHiveResponse, + CoinHiveLoginParams, + CoinHiveRequest, + StratumRequest, + StratumRequestParams +} from "src/types"; -class Miner { - constructor(options) { +export type Options = { + connection: Connection | null; + ws: WebSocket | null; + address: string | null; + user: string | null; + diff: number | null; + pass: string | null; + donations: Donation[] | null; +}; + +class Miner extends EventEmitter { + id: string = uuid.v4(); + address: string = null; + user: string = null; + diff: number = null; + pass: string = null; + donations: Donation[] = null; + heartbeat: NodeJS.Timer = null; + connection: Connection = null; + queue: Queue = new Queue(); + ws: WebSocket = null; + online: boolean = false; + jobs: Job[] = []; + hashes: number = 0; + + constructor(options: Options) { super(); - this.id = uuid.v4(); - this.online = false; - this.jobs = []; - this.hashes = 0; this.connection = options.connection; this.ws = options.ws; this.address = options.address; @@ -14,10 +48,9 @@ class Miner { this.diff = options.diff; this.pass = options.pass; this.donations = options.donations; - this.heartbeat = null; } - connect() { + async connect() { this.donations.forEach(donation => donation.connect()); this.ws.on("message", this.handleMessage.bind(this)); this.ws.on("close", () => this.kill()); @@ -27,12 +60,18 @@ class Miner { this.connection.on(this.id + ":job", this.handleJob.bind(this)); this.connection.on(this.id + ":accepted", this.handleAccepted.bind(this)); this.connection.on(this.id + ":error", this.handleError.bind(this)); + this.queue.on("message", (message: StratumRequest) => + this.connection.send(this.id, message.method, message.params) + ); this.heartbeat = setInterval(() => this.connection.send(this.id, "keepalived"), 30000); this.online = true; + await Promise.all(this.donations.map(donation => donation.ready)); + this.queue.start(); console.log(`miner connected (${this.id})`); } kill() { + this.queue.stop(); this.connection.remove(this.id); this.connection.removeAllListeners(this.id + ":authed"); this.connection.removeAllListeners(this.id + ":job"); @@ -51,22 +90,31 @@ class Miner { console.log(`miner disconnected (${this.id})`); } - send(payload) { + sendToMiner(payload: CoinHiveResponse) { const coinhiveMessage = JSON.stringify(payload); if (this.online) { try { this.ws.send(coinhiveMessage); - console.log(`message sent to miner (${this.id}):`, coinhiveMessage); } catch (e) { - console.warn("websocket seems to be already closed", e.message); + console.warn("failed to send message to miner, websocket seems to be already closed", e.message); this.kill(); } } } - handleAuthed(auth) { + sendToPool(method: string, params: StratumRequestParams) { + this.queue.push({ + type: "message", + payload: { + method, + params + } + }); + } + + handleAuthed(auth: string): void { console.log(`miner authenticated (${this.id}):`, auth); - this.send({ + this.sendToMiner({ type: "authed", params: { token: "", @@ -75,19 +123,19 @@ class Miner { }); } - handleJob(job) { - console.log(`new job arrived (${this.id}):`, job); + handleJob(job: Job): void { + console.log(`job arrived (${this.id}):`, job.job_id); this.jobs.push(job); - this.send({ + this.sendToMiner({ type: "job", params: this.getJob() }); } - handleAccepted() { + handleAccepted(): void { this.hashes++; console.log(`shares accepted (${this.id}):`, this.hashes); - this.send({ + this.sendToMiner({ type: "hash_accepted", params: { hashes: this.hashes @@ -95,16 +143,16 @@ class Miner { }); } - handleError(error) { + handleError(error: CoinHiveError): void { console.warn(`an error occurred (${this.id}):`, error); - this.send({ + this.sendToMiner({ type: "error", params: error }); } - handleMessage(message) { - let data; + handleMessage(message: string) { + let data: CoinHiveRequest; try { data = JSON.parse(message); } catch (e) { @@ -113,15 +161,16 @@ class Miner { } switch (data.type) { case "auth": { - let login = this.address || data.params.site_key; - const user = this.user || data.params.user; + const params = data.params as CoinHiveLoginParams; + let login = this.address || params.site_key; + const user = this.user || params.user; if (user) { login += "." + user; } if (this.diff) { login += "+" + this.diff; } - this.connection.send(this.id, "login", { + this.sendToPool("login", { login: login, pass: this.pass }); @@ -129,14 +178,14 @@ class Miner { } case "submit": { - const job = data.params; + const job = data.params as Job; + console.log(`job submitted (${this.id}):`, job.job_id); if (!this.isDonation(job)) { - console.log(`job submitted (${this.id}):`, job); - this.connection.send(this.id, "submit", job); + this.sendToPool("submit", job); } else { const donation = this.getDonation(job); donation.submit(job); - this.send({ + this.sendToMiner({ type: "hash_accepted", params: { hashes: ++this.hashes @@ -148,18 +197,18 @@ class Miner { } } - getJob() { + getJob(): Job { const donation = this.donations.filter(donation => donation.shouldDonateJob()).pop(); return donation ? donation.getJob() : this.jobs.pop(); } - isDonation(job) { + isDonation(job: Job): boolean { return this.donations.some(donation => donation.hasJob(job)); } - getDonation(job) { + getDonation(job: Job): Donation { return this.donations.find(donation => donation.hasJob(job)); } } -module.exports = Miner; +export default Miner; diff --git a/src/proxy.js b/src/Proxy.ts similarity index 52% rename from src/proxy.js rename to src/Proxy.ts index 7c3e273..9d6c4da 100644 --- a/src/proxy.js +++ b/src/Proxy.ts @@ -1,13 +1,44 @@ -const WebSocket = require("ws"); -const url = require("url"); -const defaults = require("../config/defaults"); -const Connection = require("./Connection"); -const Miner = require("./Miner"); -const Donation = require("./Donation"); +import * as WebSocket from "ws"; +import * as url from "url"; +import * as defaults from "../config/defaults"; +import Connection from "./Connection"; +import Miner from "./Miner"; +import Donation, { Options as DonationOptions } from "./Donation"; +import { Dictionary, Stats, WebSocketQuery } from "src/types"; +import { Request } from "_debugger"; +import { ServerRequest } from "http"; + +export type Options = { + host: string; + port: number; + pass: string; + ssl: false; + address: string | null; + user: string | null; + diff: number | null; + dynamicPool: boolean; + path: string | null; + maxMinersPerConnection: number; + donations: DonationOptions[]; +}; class Proxy { - constructor(constructorOptions = defaults) { - let options = Object.assign({}, defaults, constructorOptions); + host: string = null; + port: number = null; + pass: string = null; + path: string = null; + ssl: boolean = null; + address: string = null; + user: string = null; + diff: number = null; + dynamicPool: boolean = false; + maxMinersPerConnection: number = 100; + donations: DonationOptions[] = []; + connections: Dictionary = {}; + wss: WebSocket.Server = null; + + constructor(constructorOptions: Options = defaults) { + let options = Object.assign({}, defaults, constructorOptions) as Options; this.host = options.host; this.port = options.port; this.pass = options.pass; @@ -20,11 +51,11 @@ class Proxy { this.maxMinersPerConnection = options.maxMinersPerConnection; this.donations = options.donations; this.connections = {}; - this.pools = {}; this.wss = null; } - listen(wssOptions) { + listen(wssOptions: WebSocket.ServerOptions): void { + // this is in case the user passes only a port, like: proxy.listen(8892); if (wssOptions !== Object(wssOptions)) { wssOptions = { port: +wssOptions }; } @@ -39,20 +70,28 @@ class Proxy { if (wssOptions.server) { console.log("using custom server"); } - this.wss.on("connection", (ws, req) => { + this.wss.on("connection", (ws: WebSocket, req: ServerRequest) => { console.log(`new websocket connection`); - const params = url.parse(req.url, true).query; + const params = url.parse(req.url, true).query as WebSocketQuery; let host = this.host; let port = this.port; let pass = this.pass; if (params.pool && this.dynamicPool) { const split = params.pool.split(":"); host = split[0] || this.host; - port = split[1] || this.port; + port = Number(split[1]) || this.port; pass = split[2] || this.pass; } const donations = this.donations.map( - donation => new Donation(donation, this.getConnection(donation.host, donation.port)) + donation => + new Donation({ + address: donation.address, + host: donation.host, + port: donation.port, + pass: donation.pass, + percentage: donation.percentage, + connection: this.getConnection(donation.host, donation.port) + }) ); const connection = this.getConnection(host, port); const miner = new Miner({ @@ -68,31 +107,32 @@ class Proxy { }); } - getConnection(host, port) { - if (!this.connections[`${host}:${port}`]) { - this.connections[`${host}:${port}`] = []; + getConnection(host: string, port: number): Connection { + const connectionId = `${host}:${port}`; + if (!this.connections[connectionId]) { + this.connections[connectionId] = []; } - const connections = this.connections[`${host}:${port}`]; + const connections = this.connections[connectionId]; let connection = connections.find(connection => this.isAvailable(connection)); if (!connection) { connection = new Connection({ host, port, ssl: this.ssl }); connection.connect(); connection.on("close", () => { - console.log(`connection closed (${host}:${port})`); + console.log(`connection closed (${connectionId})`); }); connection.on("error", error => { - console.log(`connection error (${host}:${port}):`, error.message); + console.log(`connection error (${connectionId}):`, error.message); }); } connections.push(connection); return connection; } - isAvailable(connection) { + isAvailable(connection: Connection): boolean { return connection.online && connection.miners.length < this.maxMinersPerConnection; } - getStats() { + getStats(): Stats { return Object.keys(this.connections).reduce( (stats, key) => ({ miners: @@ -107,4 +147,4 @@ class Proxy { } } -module.exports = Proxy; +export default Proxy; diff --git a/src/ProxyOld.js b/src/ProxyOld.js deleted file mode 100644 index f4e9e34..0000000 --- a/src/ProxyOld.js +++ /dev/null @@ -1,643 +0,0 @@ -const WebSocket = require("ws"); -const net = require("net"); -const tls = require("tls"); -const fs = require("fs"); -const moment = require("moment"); -const Queue = require("./queue"); -const defaults = require("../config/defaults"); - -/*********************** MINER CONNECTIONS ***********************/ - -const minerConnections = {}; - -let lastConnectionId = 0; -function createConnection(ws, options) { - log("new miner connection"); - const id = lastConnectionId++; - const connection = { - id: id, - address: null, - online: true, - workerId: null, - connected: false, - hashes: 0, - authId: null, - socket: null, - buffer: "", - ws: ws, - host: options.host, - port: options.port, - pass: options.pass, - tls: options.tls, - login: options.login, - user: options.user, - diff: options.diff, - donation: false - }; - connection.ws.on("message", function(message) { - if (!connection.connected) { - var data = JSON.parse(message); - if (data.type == "auth") { - connection.address = data.params.site_key; - if (!getPoolConnection(connection)) { - createPoolConnection(connection); - } - } - } - const poolConnection = getPoolConnection(connection); - if (poolConnection) { - poolConnection.queue.push({ - type: "message", - payload: { - id: id, - message: message - } - }); - } else { - destroyConnection(connection); - } - }); - connection.ws.on("close", () => { - if (connection.online) { - log(`miner connection closed (${connection.workerId})`); - destroyConnection(connection); - } - }); - connection.ws.on("error", error => { - if (connection.online) { - log(`miner connection error (${connection.workerId})`, error && error.message ? error.message : error); - destroyConnection(connection); - } - }); - minerConnections[id] = connection; - return connection; -} - -function getConnections() { - return Object.keys(minerConnections).map(key => minerConnections[key]); -} - -function getConnectionByWorkerId(workerId) { - return getConnections().find(connection => connection.workerId === workerId); -} - -function getConnectionByRpcId(workerId) { - return Object.keys(minerConnections).find(key => minerConnections[key].workerId === workerId); -} - -function getHashes(connection) { - return ++connection.hashes; -} - -function destroyConnection(connection) { - if (!connection || !connection.online) { - return; - } - const poolConnection = getPoolConnection(connection); - if (poolConnection) { - poolConnection.miners--; - poolConnection.connections = poolConnection.connections.filter(x => x.id != connection.id); - } - if (connection.ws) { - connection.ws.close(); - } - log(`miner conection destroyed (${connection.workerId})`); - connection.address = null; - connection.online = false; - connection.workerId = null; - connection.connected = false; - connection.hashes = 0; - connection.authId = null; - connection.socket = null; - connection.buffer = null; - connection.ws = null; - connection.host = null; - connection.port = null; - connection.pass = null; - connection.tls = null; - connection.login = null; - connection.user = null; - connection.diff = null; - delete minerConnections[connection.id]; - connection = null; -} - -/*********************** POOL CONNECTIONS ***********************/ - -const poolConnections = {}; -function getPoolConnectionId(connection) { - return connection.host + ":" + connection.port + ":" + connection.address; -} - -function getPoolConnection(connection) { - return (connection.donation ? donationConnections : poolConnections)[getPoolConnectionId(connection)]; -} - -function createPoolConnection(connection) { - log(`new pool connection (${connection.address})`); - log(`host: ${connection.host}`); - log(`port: ${connection.port}`); - log(`pass: ${connection.pass || ""}`); - const id = getPoolConnectionId(connection); - const poolConnection = { - id: id, - online: false, - address: connection.address, - host: connection.host, - port: connection.port, - pass: connection.pass, - rpcId: 0, - buffer: "", - auths: {}, - rpc: {}, - miners: 0, - queue: new Queue(), - connections: [], - jobs: [], - pending: [], - submitted: [], - donation: connection.donation, - percentage: connection.percentage - }; - - if (connection.donation) { - donationConnections[id] = poolConnection; - } else { - poolConnections[id] = poolConnection; - poolConnection.connections.push(connection); - poolConnection.queue.on("message", minerMessageHandler); - } - - const connectionHandler = (connection.donation ? donationConnectionFactory : socketConnectionFactory)(poolConnection); - - if (connection.tls) { - log("using TLS"); - poolConnection.socket = tls.connect( - +connection.port, - connection.host, - { rejectUnauthorized: false }, - connectionHandler - ); - } else { - poolConnection.socket = net.connect(+connection.port, connection.host, connectionHandler); - } - - poolConnection.socket.setEncoding("utf-8"); - poolConnection.socket.setKeepAlive(true); - - poolConnection.socket.on("close", function() { - log(`pool connection closed (${poolConnection.address})`); - destroyPoolConnection(poolConnection); - }); - poolConnection.socket.on("error", function(error) { - log(`pool connection error (${poolConnection.address})`, error.message); - destroyPoolConnection(poolConnection); - }); - - return poolConnection; -} - -function getRpcId(connection) { - const poolConnection = getPoolConnection(connection); - if (poolConnection) { - const rpcId = ++poolConnection.rpcId; - poolConnection.rpc[rpcId] = connection; - return rpcId; - } - log("Can't get rpcId, invalid pool connection"); - return -1; -} - -function destroyPoolConnection(poolConnection) { - if (poolConnection.queue) { - poolConnection.queue.stop(); - } - if (poolConnection.socket) { - poolConnection.socket.destroy(); - } - log(`pool connection destroyed (${poolConnection.address})`); - poolConnection.connections.forEach(connection => destroyConnection(connection)); - poolConnection.online = false; - poolConnection.address = null; - poolConnection.host = null; - poolConnection.port = null; - poolConnection.pass = null; - poolConnection.lastRpcId = null; - poolConnection.buffer = null; - poolConnection.auths = null; - poolConnection.miners = 0; - poolConnection.queue = null; - poolConnection.connections = []; - poolConnection.jobs = []; - poolConnection.pending = []; - poolConnection.submitted = []; - poolConnection.donation = false; - poolConnection.percentage = 0; - delete poolConnections[poolConnection.id]; - poolConnection = null; -} - -/*********************** ORCHESTRATION ***********************/ - -function socketConnectionFactory(poolConnection) { - return err => { - if (err) { - return log("error while connecting socket"); - } - poolConnection.online = true; - poolConnection.socket.on("data", function(chunk) { - poolConnection.buffer += chunk; - while (poolConnection.buffer && poolConnection.buffer.includes("\n")) { - const newLineIndex = poolConnection.buffer.indexOf("\n"); - const stratumMessage = poolConnection.buffer.slice(0, newLineIndex); - poolConnection.buffer = poolConnection.buffer.slice(newLineIndex + 1); - log(`message from pool (${poolConnection.address}):`, stratumMessage); - let data = null; - try { - data = JSON.parse(stratumMessage); - } catch (e) { - return log(`[ERROR] invalid stratum message`); - } - if (poolConnection.auths[data.id]) { - const connection = poolConnection.auths[data.id]; - delete poolConnection.auths[data.id]; - if (data.error && data.error.code === -1) { - return sendToMiner(connection, { - type: "error", - params: { - error: "invalid_site_key" - } - }); - } - poolConnection.miners++; - connection.connected = true; - connection.workerId = data.result.id; - log(`miner authenticated (${(connection.workerId = data.result.id)})`); - log( - `${poolConnection.miners === 1 - ? `there is 1 miner` - : `there are ${poolConnection.miners} miners`} on this pool connection (${poolConnection.address})` - ); - sendToMiner(connection, { - type: "authed", - params: { - token: "", - hashes: 0 - } - }); - if (data.result.job) { - sendJob(connection, data.result.job); - } - } else { - if (data.method === "job") { - const connection = getConnectionByWorkerId(data.params.id); - sendJob(connection, data.params); - } - if (data.result && data.result.status === "OK") { - const connection = poolConnection.rpc[data.id]; - sendToMiner(connection, { - type: "hash_accepted", - params: { - hashes: getHashes(connection) - } - }); - } - if (data.error && data.error.code === -1) { - const connection = poolConnection.rpc[data.id]; - destroyConnection(connection); - } - } - if (data.id) { - delete poolConnection.rpc[data.id]; - } - } - }); - poolConnection.queue.start(); - }; -} - -function minerMessageHandler(event, donationConnection) { - let data; - try { - data = JSON.parse(event.message); - } catch (e) { - return log("can't parse message as JSON from miner:", event.message); - } - - var connection = donationConnection || minerConnections[event.id]; - if (!connection) { - return log(`unknown connection ${event.id}`, event.message); - return; - } - - var poolConnection = donationConnection || getPoolConnection(connection); - if (!poolConnection) { - return log(`unknown pool connection ${getPoolConnectionId(connection)}`, event.message); - } - - if (!poolConnection.online) { - return log(`pool connection offline`); - } - - log(`message from miner (${connection.workerId || "unauthenticated"})`, event.message); - - switch (data.type) { - case "auth": { - let login = connection.login || data.params.site_key; - const user = connection.user || data.params.user; - const diff = connection.diff; - if (user) { - login += "." + user; - } - if (diff) { - login += "+" + diff; - } - var rpcId = getRpcId(connection); - if (poolConnection.auths) { - poolConnection.auths[rpcId] = connection; - sendToPool(poolConnection, { - id: rpcId, - method: "login", - params: { - login: login, - pass: connection.pass - } - }); - } - break; - } - case "submit": { - const donation = getDonation(connection, data.params.job_id); - if (donation) { - sendToPool(donation.connection, { - id: getRpcId(donation.connection), - method: "submit", - params: { - id: donation.workerId, - job_id: data.params.job_id, - nonce: data.params.nonce, - result: data.params.result - } - }); - sendToMiner(connection, { - type: "hash_accepted", - params: { - hashes: getHashes(connection) - } - }); - } else { - sendToPool(poolConnection, { - id: getRpcId(connection), - method: "submit", - params: { - id: connection.workerId, - job_id: data.params.job_id, - nonce: data.params.nonce, - result: data.params.result - } - }); - } - break; - } - } -} - -function sendToPool(poolConnection, payload) { - const stratumMessage = JSON.stringify(payload); - poolConnection.socket.write(stratumMessage + "\n"); - log(`message sent to pool (${poolConnection.address}):`, stratumMessage); -} - -function sendToMiner(connection, payload) { - const coinHiveMessage = JSON.stringify(payload); - if (connection && connection.online) { - try { - connection.ws.send(coinHiveMessage); - log(`message sent to miner (${connection.workerId}):`, coinHiveMessage); - } catch (e) { - log("socket seems to be already closed."); - destroyConnection(connection); - } - } -} - -function sendJob(connection, job) { - if (!connection) { - return; - } - const donation = getDonationJob(connection); - if (donation) { - job = donation; - } - if (job) { - sendToMiner(connection, { - type: "job", - params: job - }); - } -} - -/*********************** STATS ***********************/ - -function getStats() { - const stats = {}; - stats.miners = getConnections().length; - stats.byAddress = {}; - Object.keys(poolConnections).forEach(key => { - const connection = poolConnections[key]; - stats.byAddress[connection.address] = connection.miners; - }); - return stats; -} - -/*********************** DONATIONS ***********************/ - -const donationConnections = {}; -function donationConnectionFactory(donationConnection) { - return err => { - loginDonationConnection(donationConnection); - donationConnection.online = true; - donationConnection.socket.on("data", function(chunk) { - donationConnection.buffer += chunk; - while (donationConnection.buffer && donationConnection.buffer.includes("\n")) { - const newLineIndex = donationConnection.buffer.indexOf("\n"); - const stratumMessage = donationConnection.buffer.slice(0, newLineIndex); - donationConnection.buffer = donationConnection.buffer.slice(newLineIndex + 1); - log(`message from pool (${donationConnection.address}):`, stratumMessage); - let data = null; - try { - data = JSON.parse(stratumMessage); - } catch (e) { - return log(`[ERROR] invalid stratum message`); - } - if (donationConnection.auths[data.id]) { - delete donationConnection.auths[data.id]; - if (data.error && data.error.code === -1) { - destroyPoolConnection(donationConnection); - } - if (data.result.job) { - const job = data.result.job; - donationConnection.jobs.push(job); - donationConnection.jobs = donationConnection.jobs.slice(-100); - } - } else { - if (data.method === "job") { - const job = data.params; - donationConnection.jobs.push(job); - } - if (data.result && data.result.status === "OK") { - // submitted - } - } - if (data.id) { - delete donationConnection.rpc[data.id]; - } - } - }); - donationConnection.queue.start(); - }; -} - -function loginDonationConnection(donationConnection) { - minerMessageHandler( - { - message: JSON.stringify({ - type: "auth", - params: { - site_key: donationConnection.address, - type: "anonymous", - user: null, - goal: 0 - } - }) - }, - donationConnection - ); -} - -function getDonations() { - return Object.keys(donationConnections) - .map(key => donationConnections[key]) - .sort((a, b) => (a.percentage > b.percentage ? 1 : -1)); -} - -function getDonation(connection, jobId) { - const donations = getDonations(); - let donationConnection = null; - let job = null; - donations.forEach(donation => { - if (donation.pending.some(pending => pending.job.job_id === jobId)) { - const pending = donation.pending.find(pending => pending.job.job_id === jobId); - job = pending.job; - donationConnection = donation; - donationConnection.pending = donation.pending.filter(pending => pending.job.job_id !== jobId); - donationConnection.submitted.push(jobId); - donationConnection.submitted = donationConnection.submitted.slice(-100); - } - }); - if (job) { - return { - workerId: job.id, - connection: donationConnection - }; - } - return null; -} - -function getDonationJob(connection) { - const donations = getDonations(); - const chances = Math.random(); - let acc = 0; - let i = 0; - let job = null; - while (job == null && i < donations.length) { - const donation = donations[i]; - if (chances > acc && chances < donation.percentage + acc && donation.jobs.length > 0) { - job = donation.jobs.pop(); - donation.pending.push({ - job: job, - connection: connection - }); - } - acc += donation.percentage; - i++; - } - return job; -} - -/*********************** PROXY ***********************/ - -function createProxy(constructorOptions = defaults) { - let options = Object.assign({}, defaults, constructorOptions); - log = function() { - const logString = "[" + moment().format("MMM Do hh:mm") + "] " + Array.prototype.slice.call(arguments).join(" "); - if (options.log) { - console.log(logString); - } - if (typeof options.logFile === "string") { - fs.appendFile(options.logFile || "proxy.log", logString + "\n", err => { - if (err) { - // error saving logs - } - }); - } - }; - if (options.statsFile) { - setInterval(() => { - const statsFile = options.statsFile || "proxy.stats"; - fs.writeFile(statsFile, JSON.stringify(getStats(), null, 2), err => { - if (err) { - log(`error saving stats in "${statsFile}"`); - } - }); - }, 1000); - } - return { - listen: function listen(wssOptions) { - if (wssOptions !== Object(wssOptions)) { - wssOptions = { port: +wssOptions }; - } - if (options.path) { - wssOptions.path = options.path; - } - const wss = new WebSocket.Server(wssOptions); - log("websocket server created"); - if (wssOptions.port) { - log("listening on port", wssOptions.port); - } - if (wssOptions.server) { - log("using custom server", wssOptions.port); - } - wss.on("connection", (ws, req) => { - const params = require("url").parse(req.url, true).query; - console.log(`new websocket connection`); - if (params.pool && options.dynamicPool) { - const split = params.pool.split(":"); - options.host = split[0] || options.host; - options.port = split[1] || options.port; - options.pass = split[2] || options.pass; - } - options.donations.forEach(donation => { - const donationConnection = { - address: donation.address, - host: donation.host, - port: donation.port, - pass: donation.pass, - tls: donation.tls, - donation: true, - percentage: donation.percentage - }; - const donationPoolConnection = getPoolConnection(donationConnection); - if (!donationPoolConnection) { - createPoolConnection(donationConnection); - } else { - loginDonationConnection(donationPoolConnection); - } - }); - const connection = createConnection(ws, options); - }); - } - }; -} - -module.exports = createProxy; diff --git a/src/queue.js b/src/Queue.ts similarity index 65% rename from src/queue.js rename to src/Queue.ts index 7af904d..28bcc10 100644 --- a/src/queue.js +++ b/src/Queue.ts @@ -1,15 +1,18 @@ -const EventEmitter = require("events"); +import * as EventEmitter from "events"; +import { QueueMessage } from "src/types"; class Queue extends EventEmitter { - constructor(ms = 100) { + events: QueueMessage[] = []; + interval: NodeJS.Timer = null; + bypassed: boolean = false; + ms: number = 100; + + constructor(ms: number = 100) { super(); - this.events = []; - this.interval = null; this.ms = ms; - this.bypassed = false; } - start() { + start(): void { if (this.interval == null) { const that = this; this.interval = setInterval(() => { @@ -23,19 +26,19 @@ class Queue extends EventEmitter { } } - stop() { + stop(): void { if (this.interval != null) { clearInterval(this.interval); this.interval = null; } } - bypass() { + bypass(): void { this.bypassed = true; this.stop(); } - push(event) { + push(event: QueueMessage): void { if (this.bypassed) { this.emit(event.type, event.payload); } else { @@ -44,4 +47,4 @@ class Queue extends EventEmitter { } } -module.exports = Queue; +export default Queue; diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..0b631b6 --- /dev/null +++ b/src/index.ts @@ -0,0 +1,2 @@ +import Proxy from "./Proxy"; +module.exports = Proxy; diff --git a/src/types.ts b/src/types.ts new file mode 100644 index 0000000..2eb8d37 --- /dev/null +++ b/src/types.ts @@ -0,0 +1,116 @@ +// Misc + +export type Dictionary = { + [key: string]: T; +}; + +export type Job = { + blob: string; + job_id: string; + target: string; + id: string; +}; + +export type Stats = { + miners: number; + connections: number; +}; + +export type WebSocketQuery = { + pool?: string; +}; + +export type QueueMessage = { + type: string; + payload: any; +}; + +export type RPCMessage = { + minerId: string; + message: StratumRequest; +}; + +export type Socket = NodeJS.Socket & { + destroy: () => void; + setKeepAlive: (value: boolean) => void; +}; + +// CoinHive + +export type CoinHiveRequest = { + type: string; + params: CoinHiveLoginParams | CoinHiveJob; +}; + +export type CoinHiveLoginParams = { + site_key: string; + user: string | null; +}; + +export type CoinHiveJob = Job; + +export type CoinHiveResponse = { + type: string; + params: CoinHiveLoginResult | CoinHiveSubmitResult | CoinHiveJob | CoinHiveError; +}; + +export type CoinHiveLoginResult = { + hashes: number; + token: string | null; +}; + +export type CoinHiveSubmitResult = { + hashes: number; +}; + +export type CoinHiveError = { + error: string; +}; + +// Stratum + +export type StratumRequest = { + id: number; + method: string; + params: StratumRequestParams; +}; + +export type StratumRequestParams = StratumLoginParams | StratumJob | StratumKeepAlive | StratumEmptyParams; + +export type StratumLoginParams = { + login: string; + pass?: string; +}; + +export type StratumJob = Job & { + id: string; +}; + +export type StratumEmptyParams = {}; + +export type StratumResponse = { + id: string; + result: StratumResult; + error: StratumError; +}; + +export type StratumResult = StratumSubmitResult | StratumLoginResult; + +export type StratumSubmitResult = { + status: string; +}; + +export type StratumLoginResult = { + id: string; + job: Job; + status: string; +}; + +export type StratumError = { + code: number; + error: string; +}; + +export type StratumKeepAlive = { + id: string; +}; diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..308c643 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "module": "commonjs", + "target": "es5", + "outDir": "build", + "moduleResolution": "node", + "stripInternal": true, + "pretty": true, + "forceConsistentCasingInFileNames": true, + "baseUrl": ".", + "lib": ["es5", "es2017"], + "types": ["node"] + }, + "exclude": ["node_modules", "test", "build"], + "include": ["src/**/*"] +}