Skip to content

Conversation

@DirtyHairy
Copy link
Contributor

This PR adds support for establishing TCP and TLS connections through a SOCKS proxy. Proxy support is enabled with a new socks configuration parameter that takes a curl-style SOCKS URL, or by setting an environment variable (MQTTJS_SOCKS_PROXY).

Supported schemes (and protocols) are socks5:// , socks5h:// , socks4:// and socks4a:// .

@DirtyHairy DirtyHairy changed the title Add intrinsic support for SOCKS proxies feat: add intrinsic support for SOCKS proxies Feb 5, 2025
Comment on lines 107 to 118
if (isBrowser || opts.unixSocket) {
opts.socks = undefined
}

if (
!isBrowser &&
!opts.unixSocket &&
opts.socks === undefined &&
typeof process !== 'undefined'
) {
opts.socks = process.env['MQTTJS_SOCKS_PROXY']
}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why? Could you remove the env var here?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I added the environment variable in order to provide an easy way to route unmodified applications that use mqtt.js through a proxy. In or case the culprit is Node-RED.

It would be definitely preferable if these had support for using a proxy themselves, but configuring a proxy is one of those networking options where it is not uncommon to offer configuration through the environment.

@robertsLando robertsLando requested a review from mcollina February 6, 2025 10:40
Co-authored-by: Daniel Lando <daniel.sorridi@gmail.com>
@robertsLando
Copy link
Member

@DirtyHairy Would it be possible to test part of this?

@DirtyHairy
Copy link
Contributor Author

Sure, done 😏

@DirtyHairy
Copy link
Contributor Author

Just checking in to see whether there is something still missing.

Copy link
Member

@robertsLando robertsLando left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Tests are failing

Comment on lines +104 to +111
function fatal<T>(e: T): T {
try {
if ((e as any).code === undefined) (e as any).code = 'SOCKS'
return e
} catch {
return e
}
}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

check ErrorWithReasonCode and do something like that instead:

export class SocksError extends Error {
	public code: string

	public constructor(message: string) {
		super(message)
		this.code = 'SOCKS'

		// We need to set the prototype explicitly
		Object.setPrototypeOf(this, SocksError.prototype)
		Object.getPrototypeOf(this).name = SocksError.name
	}
}

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I am fine with making that change. However, I still need to keep the function to "decorate" errors from the SOCKS client with .code so that MQTT.js will treat them as fatal (I'd rather not wrap them in another Error as that obscures the original reason).

Should I make the change anyway?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd rather not wrap them in another Error as that obscures the original reason

You are not wrapping them twice you just create a specific error instance for sock related errors and that is exactly what you are doing now but in a non-typed way

Copy link
Contributor Author

@DirtyHairy DirtyHairy Feb 28, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You are not wrapping them twice you just create a specific error instance for sock related errors and that is exactly what you are doing now but in a non-typed way

I have two code paths that call fatal: one decorates newly created errors, and the other decorates an error that I received and rethrow from socksClient (socks:205). In both cases I set .code in order to keep mqtt.js from ignoring the error ( client.ts:814). The first case can be replaced with a custom error class, the second case only if I wrap the error in my custom error, that's why I went for a decorator.

BTW: why are you manipulating the prototype directly? My understanding is that this used to be necessary for ES5 (as Error always returns a fresh object if called directly, independent of the scope), but for ES2015 the spec explicitly requires Error to be subclassable.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

About the first part cannot you do something like:

 constructor(messageOrError: string | Error) {
    if (messageOrError instanceof Error) {
      super(messageOrError.message);

      if (messageOrError.stack) {
        this.stack = messageOrError.stack;
      }
    } else {
      super(messageOrError);
    }
    
    Object.setPrototypeOf(this, SocksError.prototype);
    this.name = "SocksError";
  }

BTW: why are you manipulating the prototype directly? My understanding is that this used to be necessary for ES5 (as Error always returns a fresh object if called directly, independent of the scope), but for ES2015 the spec explicitly requires Error to be subclassable.

From what I know:

  • Object.setPrototypeOf(this, SocksError.prototype): This used to be necessary in order to have e instanceof Error returning true, without that it used to return false
  • Object.getPrototypeOf(this).name = SocksError.name without this the name property on the error thrown it's Error and not SocksError

Copy link
Contributor Author

@DirtyHairy DirtyHairy Mar 3, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

About the first part cannot you do something like:

Compared to that I personally prefer just setting code on the error. But, then again, this is not my project, so my personal preferences are not what counts here 😏 So, if you prefer that, I'll change the code.

From what I know:

I think you can do without that by now (in ES2015), unless you want to support compiling down to ES5:

Welcome to Node.js v23.7.0.
Type ".help" for more information.
> class FooError extends Error {name = 'FooError'}
undefined
> foo = new FooError()
FooError
    at REPL2:1:7
    at ContextifyScript.runInThisContext (node:vm:137:12)
    at REPLServer.defaultEval (node:repl:597:22)
    at bound (node:domain:433:15)
    at REPLServer.runBound [as eval] (node:domain:444:12)
    at REPLServer.onLine (node:repl:926:10)
    at REPLServer.emit (node:events:519:35)
    at REPLServer.emit (node:domain:489:12)
    at [_onLine] [as _onLine] (node:internal/readline/interface:416:12)
    at [_line] [as _line] (node:internal/readline/interface:887:18)
> foo.name
'FooError'
> foo instanceof FooError
true
> foo instanceof Error
true

But, same here, I can just go ahead and add the prototype mangling.

Copy link
Member

@robertsLando robertsLando Mar 4, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

problem is that foo instanceof Error is false without the second line? Anyway keep it like it is now no problem

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Anyway keep it like it is now no problem

Alright, I won't complain about that 😏

problem is that foo instanceof Error is false without the second line?

The issue is foo instanceof FooError which is false for ES5 / "old-school" class definitions (unless you set up the prototype correctly after calling Error). If you extend Error ES2015 style with class / extends the prototype chain is set up correctly. My understanding is that the spec requires Error to check new.target to see whether it is called in a ES2015 constructing context and to behave accordingly (modify the target instead of returning a new object) in this case.

If you run the code in my NodeJS session through TS in ES5 mode foo instanceof FooError is false, and foo instanceof Error is true.

@robertsLando
Copy link
Member

@DirtyHairy Sorry for late reply but I totally forgot about this

@robertsLando
Copy link
Member

@DirtyHairy That's not the right approach to fix tests, I suggest you to ensure there are no leaked handlers that prevent test to exit

@DirtyHairy
Copy link
Contributor Author

@robertsLando I don't think the reason are leaking handlers, I think this is a node.js issue similar to nodejs/node#54163 . I got errors because of remaining work on the loop before, and these look differently.

Furthermore I can't reproduce this issue locally with either v20.18.2 (which is what actions is running for 20) or v23.7.0, and for v18.20.7 (again what the CI claims to be running for 18) I do get this exact error for other, existing suites, even on main (without any changes from me, just doing npm install && npm run test:node:

 ▶ keepalive
    ✔ should send ping at keepalive interval (2.114583ms)
    ✔ should not shift ping on publish (1.18025ms)
    ✖ should reschedule pings if publishing at a higher rate than keepalive and reschedulePings===true (1.017125ms)
      'test did not finish before its parent and was cancelled'

    ✖ should not reschedule pings if publishing at a higher rate than keepalive and reschedulePings===false
      'test did not finish before its parent and was cancelled'

    ✖ should shift ping on pingresp when reschedulePings===true
      'test did not finish before its parent and was cancelled'

    ✖ should shift ping on pingresp when reschedulePings===false
      'test did not finish before its parent and was cancelled'

@DirtyHairy
Copy link
Contributor Author

But, anyway, my commit does not fix the tests either 🤷‍♂️ I must admit I am slightly clueless here as I can't reproduce the issue.

@DirtyHairy
Copy link
Contributor Author

DirtyHairy commented Feb 27, 2025

Also, not getting this error for any other run on the CI is a pretty strong argument for something being wrong with my tests 😏 I just have no clue what is going on.

@robertsLando
Copy link
Member

@DirtyHairy Seems you fixed it?

@DirtyHairy
Copy link
Contributor Author

Na, sorry, not yet. But I've got progress: I can reproduce the fail in a Linux VM (I am on a Mac) 😏 With a reproducer I'll get that fixed.

@DirtyHairy
Copy link
Contributor Author

Fixed. Unfortunately, be deleting a test: I had a test that tries to assert that errors on socket stream propagate to the stream presented to MQTT.js by closing the server with RST. Unfortunately, this does not translate into an error, but into an EOF in Linux, so the test waited indefinitely.

@codecov
Copy link

codecov bot commented Feb 28, 2025

Codecov Report

Attention: Patch coverage is 86.59794% with 13 lines in your changes missing coverage. Please review.

Project coverage is 81.46%. Comparing base (1c19ca0) to head (173b25b).
Report is 6 commits behind head on main.

Files with missing lines Patch % Lines
src/lib/connect/socks.ts 89.15% 5 Missing and 4 partials ⚠️
src/lib/connect/tcp.ts 33.33% 1 Missing and 1 partial ⚠️
src/lib/connect/index.ts 80.00% 0 Missing and 1 partial ⚠️
src/lib/connect/tls.ts 83.33% 0 Missing and 1 partial ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main    #1966      +/-   ##
==========================================
+ Coverage   81.15%   81.46%   +0.31%     
==========================================
  Files          24       25       +1     
  Lines        1470     1565      +95     
  Branches      349      366      +17     
==========================================
+ Hits         1193     1275      +82     
- Misses        193      199       +6     
- Partials       84       91       +7     
Flag Coverage Δ
unittests 81.46% <86.59%> (+0.31%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

@robertsLando
Copy link
Member

@mcollina could you kindly give a check to this please?

@robertsLando robertsLando merged commit ee0fcce into mqttjs:main Mar 4, 2025
7 checks passed
@wshshra
Copy link

wshshra commented Apr 24, 2025

@DirtyHairy
I can't use this
I tried:
$env:DEBUG="mqttjs:socks"; node .\index.js

but there is no debug output, and I can't find "mqttjs:socks" in dist

@DirtyHairy
Copy link
Contributor Author

@wshshra What are you trying to do, and what happens, and what do you expect to happen? Can you show some code?

@wshshra
Copy link

wshshra commented Apr 24, 2025

@wshshra What are you trying to do, and what happens, and what do you expect to happen? Can you show some code?

my code:

const options = {
      socksProxy: "socks5://example.com:port",
      ...otherOptions
    };
mqtt.connect(url, options)

the socks not work,then i want debug it, but there is not debug output

Finally I solved it like this

wsOptions: {
      agent,
 }

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants