Compare commits

2 Commits
Author SHA1 Message Date
Ben fb367ac5f0 sv1-sv2 routing 2026-06-05 23:44:00 -04:00
Ben 3d6c8b3134 sv2 debug 2026-06-05 23:41:22 -04:00
3 changed files with 88 additions and 1 deletions
+43 -1
View File
@@ -61,6 +61,7 @@ const DEFAULT_TARGET_SHARES_PER_MINUTE = 2;
const DEFAULT_DIFFICULTY_CHECK_INTERVAL_MS = 60 * 1000; const DEFAULT_DIFFICULTY_CHECK_INTERVAL_MS = 60 * 1000;
const FIXED_STANDARD_EXTRANONCE2 = '0000000000000000'; const FIXED_STANDARD_EXTRANONCE2 = '0000000000000000';
const RETIRED_EXTENDED_JOB_RETENTION_MS = 5 * 60 * 1000; const RETIRED_EXTENDED_JOB_RETENTION_MS = 5 * 60 * 1000;
const SV2_AUTH_FAILURE_LOG_INTERVAL_MS = 60 * 1000;
interface ExtendedJobData { interface ExtendedJobData {
coinbasePrefix: Buffer; coinbasePrefix: Buffer;
@@ -93,6 +94,8 @@ interface ChannelState {
} }
export class StratumV2Client { export class StratumV2Client {
private static authFailureLogState = new Map<string, { nextLogAt: number; suppressed: number }>();
private readonly sessionId = crypto.randomBytes(4).toString('hex'); private readonly sessionId = crypto.randomBytes(4).toString('hex');
private readonly noiseSession: Sv2NoiseSession; private readonly noiseSession: Sv2NoiseSession;
private readonly frameReader = new Sv2FrameReader(null); private readonly frameReader = new Sv2FrameReader(null);
@@ -116,6 +119,7 @@ export class StratumV2Client {
private sessionDifficulty: number; private sessionDifficulty: number;
private clientEntity: ClientEntity = null; private clientEntity: ClientEntity = null;
private creatingEntity: Promise<void> = null; private creatingEntity: Promise<void> = null;
private readonly firstChunkSummary: string;
constructor( constructor(
private readonly socket: Socket, private readonly socket: Socket,
@@ -130,6 +134,7 @@ export class StratumV2Client {
private readonly configService: ConfigService, private readonly configService: ConfigService,
private readonly addressSettingsService: AddressSettingsService, private readonly addressSettingsService: AddressSettingsService,
) { ) {
this.firstChunkSummary = this.describeChunk(firstChunk);
this.noiseSession = new Sv2NoiseSession(this.stratumV2Service.getNoiseConfig()); this.noiseSession = new Sv2NoiseSession(this.stratumV2Service.getNoiseConfig());
this.sessionDifficulty = this.getInitialDifficulty(); this.sessionDifficulty = this.getInitialDifficulty();
this.targetSharesPerMinute = this.getTargetSharesPerMinute(); this.targetSharesPerMinute = this.getTargetSharesPerMinute();
@@ -182,7 +187,7 @@ export class StratumV2Client {
await this.handleEncryptedData(data); await this.handleEncryptedData(data);
} }
} catch (error) { } catch (error) {
console.error(`[SV2 ${this.sessionId}] ${error.message}`); this.logProtocolError(error);
this.closeSocket(); this.closeSocket();
} }
} }
@@ -1152,6 +1157,43 @@ export class StratumV2Client {
throw new Error('Invalid network configuration'); throw new Error('Invalid network configuration');
} }
private logProtocolError(error: Error): void {
if (!this.isNoisyAuthFailure(error)) {
console.error(`[SV2 ${this.sessionId}] ${error.message}`);
return;
}
const remote = this.socket.remoteAddress ?? 'unknown';
const key = `${remote}:${error.message}`;
const now = Date.now();
const logState = StratumV2Client.authFailureLogState.get(key);
if (logState != null && now < logState.nextLogAt) {
logState.suppressed += 1;
return;
}
const suppressed = logState?.suppressed ?? 0;
const suffix = suppressed > 0 ? ` (${suppressed} similar auth failures suppressed)` : '';
console.warn(`[SV2 ${this.sessionId}] Authentication failed from ${remote}: ${error.message}; firstChunk=${this.firstChunkSummary}${suffix}`);
StratumV2Client.authFailureLogState.set(key, {
nextLogAt: now + SV2_AUTH_FAILURE_LOG_INTERVAL_MS,
suppressed: 0,
});
}
private isNoisyAuthFailure(error: Error): boolean {
return error.message.includes('Unsupported state or unable to authenticate data');
}
private describeChunk(chunk: Buffer): string {
const preview = chunk.subarray(0, 16);
const printable = Array.from(preview).every(byte => byte >= 0x20 && byte <= 0x7e);
const prefix = printable
? preview.toString('ascii').replace(/\\/g, '\\\\').replace(/"/g, '\\"')
: preview.toString('hex');
return `len=${chunk.length},${printable ? 'ascii' : 'hex'}=${prefix}`;
}
private closeSocket(): void { private closeSocket(): void {
if (!this.socket.destroyed) { if (!this.socket.destroyed) {
this.socket.destroy(); this.socket.destroy();
+19
View File
@@ -157,6 +157,15 @@ describe('StratumV1Service', () => {
expect((service as any).detectProtocol(firstChunk)).toBe('v1'); expect((service as any).detectProtocol(firstChunk)).toBe('v1');
}); });
it('should detect JSON-RPC as Stratum V1 even with non-printable trailing bytes', () => {
const firstChunk = Buffer.concat([
Buffer.from('{"id": 1, "method": "mining.subscribe", "params": []}\n'),
Buffer.from([0x00, 0xff])
]);
expect((service as any).detectProtocol(firstChunk)).toBe('v1');
});
it('should detect binary Noise traffic as Stratum V2', () => { it('should detect binary Noise traffic as Stratum V2', () => {
const firstChunk = Buffer.concat([ const firstChunk = Buffer.concat([
Buffer.from([0x01, 0x02, 0x03, 0x04]), Buffer.from([0x01, 0x02, 0x03, 0x04]),
@@ -174,6 +183,16 @@ describe('StratumV1Service', () => {
expect((service as any).detectProtocol(Buffer.from([0x16, 0xaa, 0xbb]))).toBe('v2'); expect((service as any).detectProtocol(Buffer.from([0x16, 0xaa, 0xbb]))).toBe('v2');
}); });
it('should not route plaintext PROXY protocol headers to Stratum V2', () => {
const firstChunk = Buffer.from('PROXY TCP4 203.0.113.10 192.0.2.10 54321 3333\\r\\n');
expect((service as any).detectProtocol(firstChunk)).toBeNull();
});
it('should not route malformed plaintext to Stratum V2', () => {
expect((service as any).detectProtocol(Buffer.from('mining.subscribe\\n'))).toBeNull();
});
function restoreEnv(key: string, value: string | undefined) { function restoreEnv(key: string, value: string | undefined) {
if (value == null) { if (value == null) {
delete process.env[key]; delete process.env[key];
+26
View File
@@ -428,6 +428,13 @@ export class StratumV1Service implements OnModuleInit {
return null; return null;
} }
// Plaintext that is not JSON-RPC is not a valid SV2 Noise Act 1. This
// catches PROXY-protocol lines, SSH banners, and malformed SV1 clients
// before they get misrouted into the SV2 decrypt path.
if (this.looksLikePlaintext(firstChunk)) {
return null;
}
return 'v2'; return 'v2';
} }
@@ -446,6 +453,13 @@ export class StratumV1Service implements OnModuleInit {
return false; return false;
} }
const jsonPrefix = firstChunk
.subarray(firstNonWhitespace, Math.min(firstChunk.length, firstNonWhitespace + 256))
.toString('utf8');
if (jsonPrefix.includes('"method"') || jsonPrefix.includes('"id"')) {
return true;
}
for (const byte of firstChunk) { for (const byte of firstChunk) {
const isWhitespace = byte === 0x09 || byte === 0x0a || byte === 0x0d; const isWhitespace = byte === 0x09 || byte === 0x0a || byte === 0x0d;
const isPrintableAscii = byte >= 0x20 && byte <= 0x7e; const isPrintableAscii = byte >= 0x20 && byte <= 0x7e;
@@ -473,6 +487,18 @@ export class StratumV1Service implements OnModuleInit {
|| prefix.startsWith('OPTIONS '); || prefix.startsWith('OPTIONS ');
} }
private looksLikePlaintext(firstChunk: Buffer): boolean {
for (const byte of firstChunk) {
const isWhitespace = byte === 0x09 || byte === 0x0a || byte === 0x0d;
const isPrintableAscii = byte >= 0x20 && byte <= 0x7e;
if (!isWhitespace && !isPrintableAscii) {
return false;
}
}
return true;
}
private getPositiveIntegerEnv(key: string, fallback: number) { private getPositiveIntegerEnv(key: string, fallback: number) {
const configured = parseInt(process.env[key], 10); const configured = parseInt(process.env[key], 10);
if (Number.isFinite(configured) && configured > 0) { if (Number.isFinite(configured) && configured > 0) {