netpump 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/CHANGELOG +41 -0
- data/VERSION +1 -0
- data/bin/netpump +114 -0
- data/lib/eventmachine/websocket/client.rb +100 -0
- data/lib/eventmachine/websocket/server.rb +43 -0
- data/lib/netpump/client.rb +158 -0
- data/lib/netpump/log.rb +22 -0
- data/lib/netpump/relay.rb +126 -0
- data/lib/netpump/server.rb +60 -0
- data/lib/netpump.rb +6 -0
- data/public/favicon.svg +1 -0
- data/public/netpump.html +191 -0
- metadata +78 -0
checksums.yaml
ADDED
@@ -0,0 +1,7 @@
|
|
1
|
+
---
|
2
|
+
SHA256:
|
3
|
+
metadata.gz: 298b321a2b63596d5247b8d8cd807722c5062249f8f2c9e1b44b30b030a528f3
|
4
|
+
data.tar.gz: c534a39e90383b0ac3896c8060d233dbd02c26c83563ed7d8c9d331b81c91370
|
5
|
+
SHA512:
|
6
|
+
metadata.gz: 95a11534001077454e72c94f94c81a2e816b613f9ab643ebaaeb06bbcf3e40f7210799130457d0af56677d2c0e5ac73ff8701426f778551c837f5b0ef9c3c711
|
7
|
+
data.tar.gz: c0a4abe22639b9667cdfdf879f63a2c05e96c8ed3eac0d3376506b3d2e094724d5c18933ca9464ad099a87560545dbac91a55324ee82707f337d3eb2cf3a567c
|
data/CHANGELOG
ADDED
@@ -0,0 +1,41 @@
|
|
1
|
+
1.0.0
|
2
|
+
|
3
|
+
* Add backpressure mechanism to keep memory usage bounded
|
4
|
+
* Add websocket pool timeout
|
5
|
+
* Add more unbind reasons
|
6
|
+
* Add option to set the server URL, remove the server query param
|
7
|
+
* Add direct client mode
|
8
|
+
|
9
|
+
1.3.0
|
10
|
+
|
11
|
+
* Run all servers in one event loop
|
12
|
+
* Replace webrick with a simple static file server
|
13
|
+
* Add websocket pool
|
14
|
+
* Add extensive logging
|
15
|
+
* Show connection count in browser
|
16
|
+
* Drop SOCKS5 support
|
17
|
+
* Drop web manifest
|
18
|
+
* Drop websocket pinging
|
19
|
+
|
20
|
+
1.2.0
|
21
|
+
|
22
|
+
* Prevent screen from sleeping (@zakir8)
|
23
|
+
* Send pings to keep websockets alive
|
24
|
+
|
25
|
+
1.1.2
|
26
|
+
|
27
|
+
* Add webrick as a runtime dependency
|
28
|
+
|
29
|
+
1.1.1
|
30
|
+
|
31
|
+
* Ignore interfaces with nil addresses
|
32
|
+
|
33
|
+
1.1.0
|
34
|
+
|
35
|
+
* Works on Windows
|
36
|
+
* HTTPS proxy support
|
37
|
+
|
38
|
+
1.0.0
|
39
|
+
|
40
|
+
* First public release
|
41
|
+
* SOCKS5 proxy support
|
data/VERSION
ADDED
@@ -0,0 +1 @@
|
|
1
|
+
1.0.0
|
data/bin/netpump
ADDED
@@ -0,0 +1,114 @@
|
|
1
|
+
#!/usr/bin/env -S ruby
|
2
|
+
|
3
|
+
Process.setproctitle "netpump"
|
4
|
+
$stdin.close
|
5
|
+
if $PROGRAM_NAME.include?("/")
|
6
|
+
$VERBOSE = true
|
7
|
+
$LOAD_PATH.unshift File.expand_path("../lib", __dir__)
|
8
|
+
else
|
9
|
+
$VERBOSE = nil
|
10
|
+
end
|
11
|
+
|
12
|
+
require "optparse"
|
13
|
+
require "uri"
|
14
|
+
require "netpump"
|
15
|
+
|
16
|
+
copts = {}
|
17
|
+
sopts = {}
|
18
|
+
|
19
|
+
op = OptionParser.new do |op|
|
20
|
+
op.summary_width = 24
|
21
|
+
|
22
|
+
op.banner = "Usage: netpump [options]"
|
23
|
+
op.separator ""
|
24
|
+
op.separator "Options:"
|
25
|
+
|
26
|
+
Port = %r{\A[1-9][0-9]*\z} # Port zero is not supported.
|
27
|
+
op.accept(Port, nil) do |val|
|
28
|
+
port = Integer(val)
|
29
|
+
raise ArgumentError if port > 65_535
|
30
|
+
port
|
31
|
+
rescue ArgumentError
|
32
|
+
raise OptionParser::InvalidArgument, val
|
33
|
+
end
|
34
|
+
|
35
|
+
WSS = %r{\Awss?://.*\z}i
|
36
|
+
op.accept(WSS, nil) do |val|
|
37
|
+
URI(val)
|
38
|
+
rescue URI::InvalidURIError
|
39
|
+
raise OptionParser::InvalidArgument, val
|
40
|
+
end
|
41
|
+
|
42
|
+
op.on("-c", "--client MODE", ["direct", "browser"], "start netpump client (mode: direct, browser)") do |mode|
|
43
|
+
copts[:mode] = mode
|
44
|
+
end
|
45
|
+
|
46
|
+
op.on("--client-host HOST", String, "client host (default: 0.0.0.0)") do |host|
|
47
|
+
copts[:host] = host
|
48
|
+
end
|
49
|
+
|
50
|
+
op.on("--client-port PORT", Port, "client port (default: 8080)") do |port|
|
51
|
+
copts[:port] = port
|
52
|
+
end
|
53
|
+
|
54
|
+
op.on("--proxy-host HOST", String, "local proxy server host (default: 127.0.0.1)") do |host|
|
55
|
+
copts[:proxy_host] = host
|
56
|
+
end
|
57
|
+
|
58
|
+
op.on("--proxy-port PORT", Port, "local proxy server port (default: 3128)") do |port|
|
59
|
+
copts[:proxy_port] = port
|
60
|
+
end
|
61
|
+
|
62
|
+
op.on("--server-url URL", WSS, "netpump server url (example: wss://example.org)") do |url|
|
63
|
+
copts[:server_url] = url
|
64
|
+
end
|
65
|
+
|
66
|
+
op.on("-s", "--server", "start netpump server") do
|
67
|
+
sopts[:server] = true
|
68
|
+
end
|
69
|
+
|
70
|
+
op.on("--server-host HOST", String, "server host (default: 0.0.0.0)") do |host|
|
71
|
+
sopts[:host] = host
|
72
|
+
end
|
73
|
+
|
74
|
+
op.on("--server-port PORT", Port, "server port (default: 10000)") do |port|
|
75
|
+
sopts[:port] = port
|
76
|
+
end
|
77
|
+
|
78
|
+
op.on_tail("--version", "print version") do
|
79
|
+
version = File.expand_path("../VERSION", __dir__)
|
80
|
+
puts(File.read(version))
|
81
|
+
exit
|
82
|
+
end
|
83
|
+
|
84
|
+
op.on_tail("--help", "print this help") do
|
85
|
+
puts(op)
|
86
|
+
exit
|
87
|
+
end
|
88
|
+
end
|
89
|
+
|
90
|
+
begin
|
91
|
+
op.parse!
|
92
|
+
rescue OptionParser::ParseError => e
|
93
|
+
op.abort(e)
|
94
|
+
else
|
95
|
+
unless copts.key?(:mode) || sopts.key?(:server)
|
96
|
+
copts[:mode] = "direct"
|
97
|
+
end
|
98
|
+
end
|
99
|
+
|
100
|
+
EventMachine.error_handler { |e| log "[!] unexpected errback.", error: e }
|
101
|
+
EventMachine.run do
|
102
|
+
trap(:INT) { puts; EventMachine.stop }
|
103
|
+
if sopts.delete(:server)
|
104
|
+
server = Netpump::Server.new(**sopts)
|
105
|
+
server.start
|
106
|
+
end
|
107
|
+
if copts[:mode]
|
108
|
+
client = Netpump::Client.new(**copts)
|
109
|
+
client.start
|
110
|
+
end
|
111
|
+
rescue => e
|
112
|
+
log "[!] #{e.message}."
|
113
|
+
EventMachine.stop
|
114
|
+
end
|
@@ -0,0 +1,100 @@
|
|
1
|
+
require "securerandom"
|
2
|
+
require "em-websocket"
|
3
|
+
require "http_parser"
|
4
|
+
|
5
|
+
module EventMachine::WebSocket
|
6
|
+
class Client < Connection
|
7
|
+
def post_init
|
8
|
+
url = @options.fetch(:url)
|
9
|
+
if url.scheme == "wss"
|
10
|
+
@secure = true
|
11
|
+
@tls_options[:sni_hostname] ||= url.host
|
12
|
+
end
|
13
|
+
super
|
14
|
+
@version = 13
|
15
|
+
@key = SecureRandom.base64(16)
|
16
|
+
@parser = Http::Parser.new
|
17
|
+
@parser.on_headers_complete = proc do
|
18
|
+
headers = @parser.headers
|
19
|
+
@parser = nil
|
20
|
+
accept = Digest::SHA1.base64digest(@key + "258EAFA5-E914-47DA-95CA-C5AB0DC85B11")
|
21
|
+
unless headers["Connection"]&.downcase == "upgrade" &&
|
22
|
+
headers["Upgrade"]&.downcase == "websocket" &&
|
23
|
+
headers["Sec-WebSocket-Accept"] == accept
|
24
|
+
raise HandshakeError, "Invalid server handshake"
|
25
|
+
end
|
26
|
+
debug [:handshake_completed]
|
27
|
+
@handler = Handler.klass_factory(@version).prepend(ClientFraming).new(self, @debug)
|
28
|
+
trigger_on_open(_handshake = nil)
|
29
|
+
:stop
|
30
|
+
end
|
31
|
+
send_handshake unless @secure
|
32
|
+
end
|
33
|
+
|
34
|
+
def ssl_handshake_completed
|
35
|
+
send_handshake
|
36
|
+
end
|
37
|
+
|
38
|
+
private
|
39
|
+
|
40
|
+
def dispatch(data)
|
41
|
+
@parser << data
|
42
|
+
rescue HTTP::Parser::Error => e
|
43
|
+
debug [:error, e]
|
44
|
+
trigger_on_error(e)
|
45
|
+
abort :handshake_error
|
46
|
+
end
|
47
|
+
|
48
|
+
def send_handshake
|
49
|
+
url = @options.fetch(:url)
|
50
|
+
send_data(
|
51
|
+
"GET /ws/rem/relay HTTP/1.1\r\n" \
|
52
|
+
"Host: #{url.host}\r\n" \
|
53
|
+
"Connection: Upgrade\r\n" \
|
54
|
+
"Upgrade: websocket\r\n" \
|
55
|
+
"Sec-WebSocket-Version: #{@version}\r\n" \
|
56
|
+
"Sec-WebSocket-Key: #{@key}\r\n\r\n"
|
57
|
+
)
|
58
|
+
end
|
59
|
+
end
|
60
|
+
|
61
|
+
module ClientFraming
|
62
|
+
module C
|
63
|
+
FIN = 0x80
|
64
|
+
MASKED = 0x80
|
65
|
+
FRAME_TYPES = Framing07::FRAME_TYPES
|
66
|
+
end
|
67
|
+
private_constant :C
|
68
|
+
|
69
|
+
def send_frame(frame_type, data)
|
70
|
+
head = String.new(capacity: 14)
|
71
|
+
head << (C::FIN | C::FRAME_TYPES[frame_type])
|
72
|
+
len = data.bytesize
|
73
|
+
case len
|
74
|
+
when 0..125
|
75
|
+
head << [len | C::MASKED].pack("C")
|
76
|
+
when 126..65535
|
77
|
+
head << [126 | C::MASKED, len].pack("Cn")
|
78
|
+
else
|
79
|
+
head << [127 | C::MASKED, len].pack("CQ>")
|
80
|
+
end
|
81
|
+
mask_size = 4
|
82
|
+
mask = SecureRandom.bytes(mask_size)
|
83
|
+
head << mask
|
84
|
+
dmask = mask * 2
|
85
|
+
dm = dmask.unpack1("Q")
|
86
|
+
dms = dmask.bytesize
|
87
|
+
q, r = len.divmod(dms)
|
88
|
+
q.times do |i|
|
89
|
+
b = i * dms
|
90
|
+
data[b, dms] = [data[b, dms].unpack1("Q") ^ dm].pack("Q")
|
91
|
+
end
|
92
|
+
r.times do |i|
|
93
|
+
b = q * dms + i
|
94
|
+
data.setbyte(b, data.getbyte(b) ^ dmask[i].ord)
|
95
|
+
end
|
96
|
+
@connection.send_data(head)
|
97
|
+
@connection.send_data(data)
|
98
|
+
end
|
99
|
+
end
|
100
|
+
end
|
@@ -0,0 +1,43 @@
|
|
1
|
+
module EventMachine::WebSocket::Server
|
2
|
+
def self.start(host, port, &block)
|
3
|
+
EventMachine::WebSocket.run(host: host, port: port, block: block) do |ws|
|
4
|
+
ws.onerror do |e|
|
5
|
+
if EventMachine::WebSocket::WebSocketError === e
|
6
|
+
log "[!] websocket error.", error: e
|
7
|
+
else
|
8
|
+
fail e
|
9
|
+
end
|
10
|
+
end
|
11
|
+
# WebSockets must not be closed because they are reused.
|
12
|
+
fail "inactivity timeout" unless ws.comm_inactivity_timeout.zero?
|
13
|
+
|
14
|
+
def ws.receive_data(data)
|
15
|
+
# The data can be nil on error.
|
16
|
+
return unless data
|
17
|
+
# Technically, we need to buffer the data until the first CRLF.
|
18
|
+
method, path, _httpv = data.split(" ", 3)
|
19
|
+
path, _qs = path.split("?", 2)
|
20
|
+
send_error = lambda do |status|
|
21
|
+
send_data("HTTP/1.1 #{status}\r\n\r\n#{status}")
|
22
|
+
close_connection_after_writing
|
23
|
+
log "[!] http client error.", method: method, path: path, status: status
|
24
|
+
end
|
25
|
+
if method != "GET"
|
26
|
+
send_error.call(405)
|
27
|
+
else
|
28
|
+
@options[:block].call(path, self) || send_error.call(404)
|
29
|
+
end
|
30
|
+
singleton_class.remove_method(__method__)
|
31
|
+
singleton_class.remove_method(:serve_file)
|
32
|
+
super if defined? @onopen
|
33
|
+
end
|
34
|
+
|
35
|
+
def ws.serve_file(path)
|
36
|
+
send_data("HTTP/1.1 200 OK\r\n\r\n")
|
37
|
+
# win: send/stream_file_data do not work on windows
|
38
|
+
send_data(path.read)
|
39
|
+
close_connection_after_writing
|
40
|
+
end
|
41
|
+
end
|
42
|
+
end
|
43
|
+
end
|
@@ -0,0 +1,158 @@
|
|
1
|
+
require "em-websocket"
|
2
|
+
require "eventmachine"
|
3
|
+
require "eventmachine/websocket/client"
|
4
|
+
require "eventmachine/websocket/server"
|
5
|
+
require "pathname"
|
6
|
+
require "socket"
|
7
|
+
require "uri"
|
8
|
+
require "netpump/relay"
|
9
|
+
|
10
|
+
module Netpump
|
11
|
+
class Client
|
12
|
+
def initialize(
|
13
|
+
mode: "direct",
|
14
|
+
host: "0.0.0.0",
|
15
|
+
port: "8080",
|
16
|
+
proxy_host: "127.0.0.1",
|
17
|
+
proxy_port: 3128,
|
18
|
+
server_url: "wss://weblink-ouux.onrender.com",
|
19
|
+
connection_inactivity_timeout: 30.0,
|
20
|
+
websocket_pool_size: 20,
|
21
|
+
websocket_pool_timeout: 60.0
|
22
|
+
)
|
23
|
+
@mode = mode
|
24
|
+
@host = host
|
25
|
+
@port = port
|
26
|
+
@proxy_host = proxy_host
|
27
|
+
@proxy_port = proxy_port
|
28
|
+
@server_url = URI(server_url)
|
29
|
+
@connection_inactivity_timeout = connection_inactivity_timeout
|
30
|
+
@websocket_pool = EventMachine::Queue.new
|
31
|
+
@websocket_pool_size = websocket_pool_size
|
32
|
+
@websocket_pool_timeout = websocket_pool_timeout
|
33
|
+
@control_ws = nil
|
34
|
+
@log = lambda { |msg, **ctx| log(msg, loc: true, **ctx) }
|
35
|
+
@ready = EventMachine::Completion.new
|
36
|
+
end
|
37
|
+
|
38
|
+
def start
|
39
|
+
@log.call "[+] netpump client is starting.", mode: @mode, host: @host, port: @port
|
40
|
+
case @mode
|
41
|
+
when "direct"
|
42
|
+
start_proxy_server
|
43
|
+
@ready.succeed
|
44
|
+
when "browser"
|
45
|
+
start_websocket_server
|
46
|
+
print_open_url
|
47
|
+
end
|
48
|
+
@ready
|
49
|
+
end
|
50
|
+
|
51
|
+
private
|
52
|
+
|
53
|
+
PUBLIC_DIR = Pathname(__dir__).parent.parent.join("public").freeze
|
54
|
+
private_constant :PUBLIC_DIR
|
55
|
+
|
56
|
+
def start_websocket_server
|
57
|
+
EventMachine::WebSocket::Server.start(@host, @port) do |path, ws|
|
58
|
+
case path
|
59
|
+
when "/"
|
60
|
+
ws.serve_file(PUBLIC_DIR.join("netpump.html"))
|
61
|
+
@log.call "[~] http request.", method: "GET", path: path, ip: ws.remote_ip
|
62
|
+
when "/favicon.svg"
|
63
|
+
ws.serve_file(PUBLIC_DIR.join("favicon.svg"))
|
64
|
+
@log.call "[~] http request.", method: "GET", path: path, ip: ws.remote_ip
|
65
|
+
when "/ws/loc/control"
|
66
|
+
next false if @control_ws
|
67
|
+
ws.onopen do |_handshake|
|
68
|
+
@log.call "[+] device is connected.", ip: ws.remote_ip
|
69
|
+
@control_ws = ws
|
70
|
+
@control_ws.send_text(@server_url.to_s)
|
71
|
+
proxy = start_proxy_server
|
72
|
+
@control_ws.onclose do
|
73
|
+
@control_ws = nil
|
74
|
+
@log.call "[-] device is disconnected.", sig: ws.signature
|
75
|
+
EventMachine.stop_server(proxy)
|
76
|
+
@log.call "[-] proxy server is stopped."
|
77
|
+
end
|
78
|
+
@ready.succeed
|
79
|
+
end
|
80
|
+
when "/ws/loc/relay"
|
81
|
+
next false unless @control_ws.remote_ip == ws.remote_ip
|
82
|
+
ws.onopen do |_handshake|
|
83
|
+
@log.call "[+] websocket is open.", ip: ws.remote_ip, sig: ws.signature
|
84
|
+
@websocket_pool << ws
|
85
|
+
end
|
86
|
+
else
|
87
|
+
next false
|
88
|
+
end
|
89
|
+
true
|
90
|
+
end
|
91
|
+
end
|
92
|
+
|
93
|
+
def print_open_url
|
94
|
+
ip = Socket.getifaddrs.find { |ifa| ifa.addr&.ipv4_private? } or raise(
|
95
|
+
"could not find an interface to listen on; " \
|
96
|
+
"make sure that you are connected to your device."
|
97
|
+
)
|
98
|
+
open_url = URI::HTTP.build(host: ip.addr.ip_address, port: @port)
|
99
|
+
@log.call "[~] waiting for device to connect.", url: open_url
|
100
|
+
end
|
101
|
+
|
102
|
+
def start_proxy_server
|
103
|
+
proxy = EventMachine.start_server(@proxy_host, @proxy_port, Relay, "loc") do |relay|
|
104
|
+
bind(relay)
|
105
|
+
end
|
106
|
+
@log.call "[+] proxy server is ready.", type: "https", host: @proxy_host, port: @proxy_port
|
107
|
+
proxy
|
108
|
+
end
|
109
|
+
|
110
|
+
def bind(relay)
|
111
|
+
if @websocket_pool.empty?
|
112
|
+
case @mode
|
113
|
+
when "direct"
|
114
|
+
host = @server_url.host
|
115
|
+
port = @server_url.port
|
116
|
+
begin
|
117
|
+
EventMachine.connect(host, port, EventMachine::WebSocket::Client, url: @server_url) do |ws|
|
118
|
+
ws.onopen do
|
119
|
+
@log.call "[+] websocket is open.", ip: ws.remote_ip, sig: ws.signature
|
120
|
+
@websocket_pool << ws
|
121
|
+
end
|
122
|
+
end
|
123
|
+
rescue EventMachine::ConnectionError => e
|
124
|
+
@log.call "[!] server connection error.", error: e
|
125
|
+
return relay.close("server connection error")
|
126
|
+
end
|
127
|
+
when "browser"
|
128
|
+
# Dogpile effect if batch size > 1.
|
129
|
+
@control_ws.send_text(_batch_size = "1")
|
130
|
+
end
|
131
|
+
@log.call "[~] websocket is requested.", waitcnt: @websocket_pool.num_waiting
|
132
|
+
end
|
133
|
+
timeout = EventMachine::Timer.new(@websocket_pool_timeout) do
|
134
|
+
relay.close("pool timeout")
|
135
|
+
raise "race condition" unless EventMachine.reactor_thread?
|
136
|
+
@websocket_pool.instance_variable_get(:@popq).shift || raise("popq")
|
137
|
+
end
|
138
|
+
@websocket_pool.pop do |ws|
|
139
|
+
timeout.cancel
|
140
|
+
if ws.state != :connected
|
141
|
+
# When the browser tab is closed or reloaded, all local websockets
|
142
|
+
# are closed, remaining dead in the pool until popped.
|
143
|
+
@log.call "[~] websocket is dead, retrying.", state: ws.state, sig: ws.signature
|
144
|
+
EventMachine.next_tick { bind(relay) }
|
145
|
+
else
|
146
|
+
relay.set_comm_inactivity_timeout(@connection_inactivity_timeout)
|
147
|
+
relay.bind(ws, ws.remote_ip).callback do
|
148
|
+
if @websocket_pool.size < @websocket_pool_size
|
149
|
+
@websocket_pool << ws
|
150
|
+
else
|
151
|
+
ws.close(1000, "purge")
|
152
|
+
end
|
153
|
+
end
|
154
|
+
end
|
155
|
+
end
|
156
|
+
end
|
157
|
+
end
|
158
|
+
end
|
data/lib/netpump/log.rb
ADDED
@@ -0,0 +1,22 @@
|
|
1
|
+
$log = $>
|
2
|
+
$log.sync = true
|
3
|
+
|
4
|
+
def log(msg, **context)
|
5
|
+
line = "%-35s" % msg
|
6
|
+
context.each do |k, v|
|
7
|
+
case v
|
8
|
+
when Exception
|
9
|
+
line << " #{k}=#{v.message.inspect}"
|
10
|
+
line << " backtrace=#{v.backtrace}" if !$VERBOSE.nil? && v.backtrace
|
11
|
+
when true
|
12
|
+
line << " #{k}"
|
13
|
+
when false, nil, ""
|
14
|
+
# skip
|
15
|
+
else
|
16
|
+
v = v.to_s
|
17
|
+
v = v.inspect if v.include?(" ")
|
18
|
+
line << " #{k}=#{v}"
|
19
|
+
end
|
20
|
+
end
|
21
|
+
$log.puts(line)
|
22
|
+
end
|
@@ -0,0 +1,126 @@
|
|
1
|
+
module Netpump
|
2
|
+
class Relay < EventMachine::Connection
|
3
|
+
def initialize(
|
4
|
+
side,
|
5
|
+
connection_inactivity_timeout: 0.0,
|
6
|
+
backpressure_min_bytes: 2**23,
|
7
|
+
backpressure_max_bytes: 2**24,
|
8
|
+
backpressure_drain_seconds: 1.0
|
9
|
+
)
|
10
|
+
pause
|
11
|
+
@ws = nil
|
12
|
+
@completion = nil
|
13
|
+
@closed = false
|
14
|
+
@close_reason = nil
|
15
|
+
@unbind_sent = false
|
16
|
+
@unbind_recv = false
|
17
|
+
@connection_inactivity_timeout = connection_inactivity_timeout
|
18
|
+
@backpressure_min_bytes = backpressure_min_bytes
|
19
|
+
@backpressure_max_bytes = backpressure_max_bytes
|
20
|
+
@backpressure_drain_seconds = backpressure_drain_seconds
|
21
|
+
@remote_ip = nil
|
22
|
+
@sig = "-.#{signature}"
|
23
|
+
@log = lambda do |msg, **ctx|
|
24
|
+
log(msg, "#{side}": true, ip: @remote_ip, sig: @sig, **ctx)
|
25
|
+
end
|
26
|
+
end
|
27
|
+
|
28
|
+
def bind(ws, remote_ip)
|
29
|
+
@ws = ws
|
30
|
+
@sig = "#{ws.signature}.#{signature}"
|
31
|
+
@completion = EventMachine::Completion.new
|
32
|
+
@unbind_sent = false
|
33
|
+
@unbind_recv = false
|
34
|
+
@remote_ip = remote_ip
|
35
|
+
@connection_inactivity_timeout = comm_inactivity_timeout
|
36
|
+
if @closed
|
37
|
+
# If a connection is closed due to the pool timeout before being bound to
|
38
|
+
# a websocket, it is not added to the wait queue in the websocket pool,
|
39
|
+
# so this case should not happen under normal operation. However, it is
|
40
|
+
# still possible is the connection is closed due to any other reason.
|
41
|
+
@log.call "[!] bound closed before bind."
|
42
|
+
@completion.succeed
|
43
|
+
return @completion
|
44
|
+
end
|
45
|
+
@ws.onbinary do |data|
|
46
|
+
if @closed
|
47
|
+
@log.call "[~] data discard, bound closed.", connerror: error?, bytes: data.bytesize
|
48
|
+
else
|
49
|
+
send_data(data)
|
50
|
+
end
|
51
|
+
end
|
52
|
+
@ws.onmessage do |msg|
|
53
|
+
if msg == "unbind"
|
54
|
+
@unbind_recv = true
|
55
|
+
if @unbind_sent
|
56
|
+
@completion.succeed
|
57
|
+
else
|
58
|
+
close("unbind")
|
59
|
+
end
|
60
|
+
else
|
61
|
+
@log.call "[!] unexpected text on websocket.", msg: msg[0, 16].inspect
|
62
|
+
end
|
63
|
+
end
|
64
|
+
@ws.onclose do |close_info|
|
65
|
+
close("websocket closed") unless @closed
|
66
|
+
@completion.fail
|
67
|
+
@log.call "[-] websocket is closed.", **close_info.slice(:code, :reason)
|
68
|
+
end
|
69
|
+
resume
|
70
|
+
@log.call "[+] bind."
|
71
|
+
@completion
|
72
|
+
end
|
73
|
+
|
74
|
+
def receive_data(data)
|
75
|
+
@ws.send_binary(data)
|
76
|
+
check_backpressure
|
77
|
+
end
|
78
|
+
|
79
|
+
def close(reason)
|
80
|
+
fail "double close" if @closed
|
81
|
+
@close_reason = reason
|
82
|
+
close_connection_after_writing
|
83
|
+
end
|
84
|
+
|
85
|
+
def unbind(errno)
|
86
|
+
@closed = true
|
87
|
+
if @ws&.state == :connected
|
88
|
+
@ws.send_text("unbind")
|
89
|
+
@unbind_sent = true
|
90
|
+
@completion&.succeed if @unbind_recv
|
91
|
+
end
|
92
|
+
if errno.nil?
|
93
|
+
reason = @close_reason
|
94
|
+
elsif errno == Errno::ETIMEDOUT && @connection_inactivity_timeout > 0
|
95
|
+
reason = "inactive"
|
96
|
+
elsif errno.respond_to?(:exception)
|
97
|
+
reason = errno.exception.message
|
98
|
+
reason[0] = reason[0].downcase
|
99
|
+
else
|
100
|
+
# win: errno can be set to :unknown on windows
|
101
|
+
reason = errno.to_s
|
102
|
+
end
|
103
|
+
@log.call "[-] unbind.", reason: reason
|
104
|
+
end
|
105
|
+
|
106
|
+
private
|
107
|
+
|
108
|
+
def check_backpressure
|
109
|
+
outbound_bytes = @ws.get_outbound_data_size
|
110
|
+
if outbound_bytes >= @backpressure_max_bytes && !paused?
|
111
|
+
pause
|
112
|
+
@log.call "[~] paused.", outbytes: outbound_bytes
|
113
|
+
end
|
114
|
+
if outbound_bytes <= @backpressure_min_bytes && paused?
|
115
|
+
resume
|
116
|
+
@log.call "[~] resumed.", outbytes: outbound_bytes
|
117
|
+
end
|
118
|
+
if paused?
|
119
|
+
EventMachine::Timer.new(@backpressure_drain_seconds) do
|
120
|
+
check_backpressure
|
121
|
+
end
|
122
|
+
end
|
123
|
+
end
|
124
|
+
end
|
125
|
+
private_constant :Relay
|
126
|
+
end
|
@@ -0,0 +1,60 @@
|
|
1
|
+
require "em-websocket"
|
2
|
+
require "eventmachine-proxy"
|
3
|
+
require "eventmachine/websocket/server"
|
4
|
+
require "socket"
|
5
|
+
require "netpump/relay"
|
6
|
+
|
7
|
+
module Netpump
|
8
|
+
class Server
|
9
|
+
def initialize(host: "0.0.0.0", port: 10000, proxy_host: "127.0.0.1", proxy_port: 0)
|
10
|
+
@host = host
|
11
|
+
@port = port
|
12
|
+
@proxy_host = proxy_host
|
13
|
+
@proxy_port = proxy_port
|
14
|
+
@log = lambda { |msg, **ctx| log(msg, rem: true, **ctx) }
|
15
|
+
end
|
16
|
+
|
17
|
+
def start
|
18
|
+
@log.call "[+] netpump server is starting.", host: @host, port: @port
|
19
|
+
start_websocket_server
|
20
|
+
proxy = EventMachine.start_server(
|
21
|
+
@proxy_host, @proxy_port, EventMachine::Protocols::CONNECT
|
22
|
+
) do |c|
|
23
|
+
fail "inactivity timeout" unless c.comm_inactivity_timeout.zero?
|
24
|
+
end
|
25
|
+
@proxy_port, @proxy_host = Socket.unpack_sockaddr_in(EventMachine.get_sockname(proxy))
|
26
|
+
end
|
27
|
+
|
28
|
+
private
|
29
|
+
|
30
|
+
def start_websocket_server
|
31
|
+
EventMachine::WebSocket::Server.start(@host, @port) do |path, ws|
|
32
|
+
case path
|
33
|
+
when "/", "/healthcheck"
|
34
|
+
@log.call "[~] http request.", method: "GET", path: path, ip: ws.remote_ip
|
35
|
+
ws.send_healthcheck_response
|
36
|
+
when "/ws/rem/relay"
|
37
|
+
ws.onopen do |handshake|
|
38
|
+
xff = handshake.headers_downcased["x-forwarded-for"]&.split(",", 2)&.first
|
39
|
+
ip = xff || ws.remote_ip
|
40
|
+
@log.call "[+] websocket is open.", ip: ip, sig: ws.signature
|
41
|
+
bind(ws, ip)
|
42
|
+
end
|
43
|
+
else
|
44
|
+
return false
|
45
|
+
end
|
46
|
+
true
|
47
|
+
end
|
48
|
+
end
|
49
|
+
|
50
|
+
def bind(ws, ip)
|
51
|
+
EventMachine.connect(@proxy_host, @proxy_port, Relay, "rem") do |relay|
|
52
|
+
fail "inactivity timeout" unless relay.comm_inactivity_timeout.zero?
|
53
|
+
relay.bind(ws, ip).callback { bind(ws, ip) }
|
54
|
+
end
|
55
|
+
rescue RuntimeError => e
|
56
|
+
@log.call "[!] proxy connection error.", error: e
|
57
|
+
ws.close(4000, "proxy connection error")
|
58
|
+
end
|
59
|
+
end
|
60
|
+
end
|
data/lib/netpump.rb
ADDED
data/public/favicon.svg
ADDED
@@ -0,0 +1 @@
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" xml:space="preserve" width="16" height="16"><style>@media (prefers-color-scheme:dark){path{stroke:#fff!important}}</style><path d="M5.072 5.73v6.054h5.856V5.73" style="stroke:#540b0b;stroke-width:2.91;fill:none"/></svg>
|
data/public/netpump.html
ADDED
@@ -0,0 +1,191 @@
|
|
1
|
+
<!doctype html>
|
2
|
+
<html>
|
3
|
+
<head>
|
4
|
+
<meta charset="utf-8" />
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
6
|
+
<title>netpump</title>
|
7
|
+
<link rel="icon" href="favicon.svg" type="image/svg+xml" />
|
8
|
+
<style>
|
9
|
+
html,
|
10
|
+
body {
|
11
|
+
height: 100%;
|
12
|
+
background-color: #540b0b;
|
13
|
+
font-family: arial, sans-serif;
|
14
|
+
}
|
15
|
+
body {
|
16
|
+
display: flex;
|
17
|
+
flex-direction: column;
|
18
|
+
align-items: center;
|
19
|
+
justify-content: center;
|
20
|
+
text-align: center;
|
21
|
+
color: white;
|
22
|
+
}
|
23
|
+
dd {
|
24
|
+
margin: 0;
|
25
|
+
}
|
26
|
+
input[type="checkbox"] {
|
27
|
+
margin-right: 0.5em;
|
28
|
+
}
|
29
|
+
header {
|
30
|
+
margin: 1em 0;
|
31
|
+
}
|
32
|
+
header h1 {
|
33
|
+
margin: 0;
|
34
|
+
font-size: 1.5em;
|
35
|
+
}
|
36
|
+
header svg {
|
37
|
+
width: 4em;
|
38
|
+
}
|
39
|
+
</style>
|
40
|
+
</head>
|
41
|
+
<body>
|
42
|
+
<header>
|
43
|
+
<svg role="img" viewBox="0 0 16 16">
|
44
|
+
<path
|
45
|
+
d="M5.072 5.73v6.054h5.856V5.73"
|
46
|
+
style="stroke: #fff; stroke-width: 2.91; fill: none"
|
47
|
+
/>
|
48
|
+
</svg>
|
49
|
+
<h1>netpump</h1>
|
50
|
+
</header>
|
51
|
+
<div class="settings">
|
52
|
+
<label
|
53
|
+
><input type="checkbox" id="nosleep" />Prevent screen from
|
54
|
+
sleeping</label
|
55
|
+
>
|
56
|
+
</div>
|
57
|
+
<dl class="stats">
|
58
|
+
<dt>Connections</dt>
|
59
|
+
<dd id="connectionCount">0</dd>
|
60
|
+
</dl>
|
61
|
+
<script>
|
62
|
+
function createRelayWebSocketPair(clientAddr, serverAddr) {
|
63
|
+
// client websocket.
|
64
|
+
var client;
|
65
|
+
// Connect to the server first, so that when we
|
66
|
+
// connect to the client the whole chain is complete.
|
67
|
+
var server = new WebSocket(serverAddr + "/ws/rem/relay");
|
68
|
+
// Log server errors.
|
69
|
+
server.onerror = function (event) {
|
70
|
+
console.error("[!] server/error", event);
|
71
|
+
};
|
72
|
+
// When the server closes its websocket, close the corresponding
|
73
|
+
// client websocket too.
|
74
|
+
server.onclose = function (event) {
|
75
|
+
console.log("[-] server/close", {
|
76
|
+
code: event.code,
|
77
|
+
reason: event.reason,
|
78
|
+
wasClean: event.wasClean,
|
79
|
+
});
|
80
|
+
if (client?.readyState === WebSocket.OPEN) {
|
81
|
+
client.close(
|
82
|
+
1000,
|
83
|
+
`bound close: ${event.code} ${event.reason}`.trimEnd(),
|
84
|
+
);
|
85
|
+
}
|
86
|
+
};
|
87
|
+
server.onopen = function () {
|
88
|
+
// Connect to the client to relay data.
|
89
|
+
client = new WebSocket(clientAddr + "/ws/loc/relay");
|
90
|
+
// Log relay errors.
|
91
|
+
client.onerror = function (event) {
|
92
|
+
console.error("[!] client/error", event);
|
93
|
+
};
|
94
|
+
// When the client websocket is closed, close the corresponding
|
95
|
+
// server websocket.
|
96
|
+
client.onclose = function (event) {
|
97
|
+
if (client.cleanOpen) {
|
98
|
+
connections--;
|
99
|
+
connectionCount.textContent = connections;
|
100
|
+
}
|
101
|
+
console.log("[-] client/close", {
|
102
|
+
code: event.code,
|
103
|
+
reason: event.reason,
|
104
|
+
wasClean: event.wasClean,
|
105
|
+
});
|
106
|
+
if (server.readyState === WebSocket.OPEN) {
|
107
|
+
server.close(
|
108
|
+
1000,
|
109
|
+
`bound close: ${event.code} ${event.reason}`.trimEnd(),
|
110
|
+
);
|
111
|
+
}
|
112
|
+
};
|
113
|
+
// Splice client and server websockets.
|
114
|
+
client.onopen = function () {
|
115
|
+
connections++;
|
116
|
+
connectionCount.textContent = connections;
|
117
|
+
client.cleanOpen = true;
|
118
|
+
client.onmessage = function (msg) {
|
119
|
+
server.send(msg.data);
|
120
|
+
};
|
121
|
+
server.onmessage = function (msg) {
|
122
|
+
client.send(msg.data);
|
123
|
+
};
|
124
|
+
};
|
125
|
+
};
|
126
|
+
}
|
127
|
+
// Netpump client.
|
128
|
+
var clientAddr = "ws://" + location.host;
|
129
|
+
// Netpump server.
|
130
|
+
var serverAddr;
|
131
|
+
// Establish a client control websocket to receive commands.
|
132
|
+
var control = new WebSocket(clientAddr + "/ws/loc/control");
|
133
|
+
// Total number of client connections.
|
134
|
+
var connections = 0;
|
135
|
+
// Log control websocket errors.
|
136
|
+
control.onerror = function (event) {
|
137
|
+
console.error("[!] control/error", event);
|
138
|
+
};
|
139
|
+
// Log control websocket closure.
|
140
|
+
control.onclose = function (event) {
|
141
|
+
console.log("[-] control/close", {
|
142
|
+
code: event.code,
|
143
|
+
reason: event.reason,
|
144
|
+
});
|
145
|
+
};
|
146
|
+
// Add more connections on demand.
|
147
|
+
control.onmessage = function (msg) {
|
148
|
+
serverAddr = msg.data;
|
149
|
+
control.onmessage = function (msg) {
|
150
|
+
var connectionBatchSize = Number.parseInt(msg.data);
|
151
|
+
console.log("[~] control/batch", { count: connectionBatchSize });
|
152
|
+
for (var i = 0; i < connectionBatchSize; i++) {
|
153
|
+
createRelayWebSocketPair(clientAddr, serverAddr);
|
154
|
+
}
|
155
|
+
};
|
156
|
+
};
|
157
|
+
</script>
|
158
|
+
<script
|
159
|
+
src="https://cdnjs.cloudflare.com/ajax/libs/nosleep/0.12.0/NoSleep.min.js"
|
160
|
+
type="text/javascript"
|
161
|
+
crossorigin="anonymous"
|
162
|
+
></script>
|
163
|
+
<script>
|
164
|
+
var noSleep = new NoSleep();
|
165
|
+
var noSleepCheckbox = document.getElementById("nosleep");
|
166
|
+
|
167
|
+
document.addEventListener(
|
168
|
+
"visibilitychange",
|
169
|
+
function resetNoSleep(event) {
|
170
|
+
if (document.visibilityState === "visible") {
|
171
|
+
noSleep.disable();
|
172
|
+
noSleep = new NoSleep();
|
173
|
+
noSleepCheckbox.checked = false;
|
174
|
+
}
|
175
|
+
},
|
176
|
+
);
|
177
|
+
|
178
|
+
noSleepCheckbox.addEventListener("click", function toggleNoSleep(event) {
|
179
|
+
if (noSleep.isEnabled) {
|
180
|
+
noSleep.disable();
|
181
|
+
} else {
|
182
|
+
try {
|
183
|
+
noSleep.enable();
|
184
|
+
} catch (error) {
|
185
|
+
event.preventDefault();
|
186
|
+
}
|
187
|
+
}
|
188
|
+
});
|
189
|
+
</script>
|
190
|
+
</body>
|
191
|
+
</html>
|
metadata
ADDED
@@ -0,0 +1,78 @@
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
2
|
+
name: netpump
|
3
|
+
version: !ruby/object:Gem::Version
|
4
|
+
version: 1.0.0
|
5
|
+
platform: ruby
|
6
|
+
authors:
|
7
|
+
- soylent
|
8
|
+
bindir: bin
|
9
|
+
cert_chain: []
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
11
|
+
dependencies:
|
12
|
+
- !ruby/object:Gem::Dependency
|
13
|
+
name: em-websocket
|
14
|
+
requirement: !ruby/object:Gem::Requirement
|
15
|
+
requirements:
|
16
|
+
- - "~>"
|
17
|
+
- !ruby/object:Gem::Version
|
18
|
+
version: '0.5'
|
19
|
+
type: :runtime
|
20
|
+
prerelease: false
|
21
|
+
version_requirements: !ruby/object:Gem::Requirement
|
22
|
+
requirements:
|
23
|
+
- - "~>"
|
24
|
+
- !ruby/object:Gem::Version
|
25
|
+
version: '0.5'
|
26
|
+
- !ruby/object:Gem::Dependency
|
27
|
+
name: eventmachine-proxy
|
28
|
+
requirement: !ruby/object:Gem::Requirement
|
29
|
+
requirements:
|
30
|
+
- - "~>"
|
31
|
+
- !ruby/object:Gem::Version
|
32
|
+
version: '1.0'
|
33
|
+
type: :runtime
|
34
|
+
prerelease: false
|
35
|
+
version_requirements: !ruby/object:Gem::Requirement
|
36
|
+
requirements:
|
37
|
+
- - "~>"
|
38
|
+
- !ruby/object:Gem::Version
|
39
|
+
version: '1.0'
|
40
|
+
executables:
|
41
|
+
- netpump
|
42
|
+
extensions: []
|
43
|
+
extra_rdoc_files: []
|
44
|
+
files:
|
45
|
+
- CHANGELOG
|
46
|
+
- VERSION
|
47
|
+
- bin/netpump
|
48
|
+
- lib/eventmachine/websocket/client.rb
|
49
|
+
- lib/eventmachine/websocket/server.rb
|
50
|
+
- lib/netpump.rb
|
51
|
+
- lib/netpump/client.rb
|
52
|
+
- lib/netpump/log.rb
|
53
|
+
- lib/netpump/relay.rb
|
54
|
+
- lib/netpump/server.rb
|
55
|
+
- public/favicon.svg
|
56
|
+
- public/netpump.html
|
57
|
+
homepage: https://netpump.org
|
58
|
+
licenses:
|
59
|
+
- MIT
|
60
|
+
metadata: {}
|
61
|
+
rdoc_options: []
|
62
|
+
require_paths:
|
63
|
+
- lib
|
64
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
65
|
+
requirements:
|
66
|
+
- - ">="
|
67
|
+
- !ruby/object:Gem::Version
|
68
|
+
version: 2.5.0
|
69
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
70
|
+
requirements:
|
71
|
+
- - ">="
|
72
|
+
- !ruby/object:Gem::Version
|
73
|
+
version: '0'
|
74
|
+
requirements: []
|
75
|
+
rubygems_version: 3.6.9
|
76
|
+
specification_version: 4
|
77
|
+
summary: Tiny websocket proxy tunnel
|
78
|
+
test_files: []
|