tunnel-rb 0.1.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/LICENSE +21 -0
- data/README.md +506 -0
- data/exe/tunnel +6 -0
- data/lib/tunnel_rb/cli.rb +59 -0
- data/lib/tunnel_rb/client.rb +195 -0
- data/lib/tunnel_rb/server/client.rb +29 -0
- data/lib/tunnel_rb/server/client_registry.rb +96 -0
- data/lib/tunnel_rb/server/control_server.rb +244 -0
- data/lib/tunnel_rb/server/http_request.rb +45 -0
- data/lib/tunnel_rb/server/logger.rb +23 -0
- data/lib/tunnel_rb/server/pending_connections.rb +43 -0
- data/lib/tunnel_rb/server/public_server.rb +144 -0
- data/lib/tunnel_rb/server/socket_helpers.rb +41 -0
- data/lib/tunnel_rb/server/thread_pool.rb +39 -0
- data/lib/tunnel_rb/server/tls.rb +48 -0
- data/lib/tunnel_rb/server/token_store.rb +182 -0
- data/lib/tunnel_rb/server.rb +96 -0
- data/lib/tunnel_rb/version.rb +5 -0
- data/lib/tunnel_rb.rb +8 -0
- data/tunnel-rb.gemspec +35 -0
- metadata +92 -0
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "socket"
|
|
4
|
+
require "json"
|
|
5
|
+
require "openssl"
|
|
6
|
+
|
|
7
|
+
module TunnelRb
|
|
8
|
+
class Client
|
|
9
|
+
TCP_KEEPALIVE_IDLE = 60
|
|
10
|
+
TCP_KEEPALIVE_INTERVAL = 30
|
|
11
|
+
TCP_KEEPALIVE_PROBES = 3
|
|
12
|
+
|
|
13
|
+
CONNECT_TIMEOUT = 5
|
|
14
|
+
RECONNECT_INITIAL_DELAY = 1
|
|
15
|
+
RECONNECT_MAX_DELAY = 30
|
|
16
|
+
|
|
17
|
+
LOCAL_UNREACHABLE_ERRORS = [
|
|
18
|
+
Errno::ECONNREFUSED,
|
|
19
|
+
Errno::EHOSTUNREACH,
|
|
20
|
+
Errno::ENETUNREACH,
|
|
21
|
+
Errno::ETIMEDOUT,
|
|
22
|
+
SocketError
|
|
23
|
+
].freeze
|
|
24
|
+
|
|
25
|
+
def initialize(server_host:, server_port:, local_host: "localhost", local_port:, tls: false, tls_ca: nil, tls_verify: false)
|
|
26
|
+
@server_host = server_host
|
|
27
|
+
@server_port = server_port
|
|
28
|
+
@local_host = local_host
|
|
29
|
+
@local_port = local_port
|
|
30
|
+
@tls = tls
|
|
31
|
+
@tls_ca = tls_ca
|
|
32
|
+
@tls_verify = tls_verify
|
|
33
|
+
@token = nil
|
|
34
|
+
@session_mutex = Mutex.new
|
|
35
|
+
@tls_session = nil
|
|
36
|
+
@ssl_context = build_ssl_context if @tls
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def start
|
|
40
|
+
attempt = 0
|
|
41
|
+
loop do
|
|
42
|
+
connect_and_run { attempt = 0 }
|
|
43
|
+
rescue Interrupt
|
|
44
|
+
puts "\n[Tunnel] Shutting down..."
|
|
45
|
+
break
|
|
46
|
+
rescue => e
|
|
47
|
+
delay = [RECONNECT_INITIAL_DELAY * (2**attempt), RECONNECT_MAX_DELAY].min
|
|
48
|
+
attempt += 1
|
|
49
|
+
puts "[Tunnel] Disconnected: #{e.message}. Reconnecting in #{delay}s..."
|
|
50
|
+
sleep delay
|
|
51
|
+
end
|
|
52
|
+
ensure
|
|
53
|
+
@control_socket&.close
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
private
|
|
57
|
+
|
|
58
|
+
def connect_and_run
|
|
59
|
+
@control_socket = open_tcp(@server_host, @server_port)
|
|
60
|
+
enable_tcp_keepalive(@control_socket)
|
|
61
|
+
|
|
62
|
+
payload = { action: "register" }
|
|
63
|
+
payload[:token] = @token if @token
|
|
64
|
+
@control_socket.puts(payload.to_json)
|
|
65
|
+
|
|
66
|
+
response = read_json(@control_socket, "registration response")
|
|
67
|
+
raise "Registration failed: #{response['error']}" if response["status"] != "ok"
|
|
68
|
+
|
|
69
|
+
@token = response["token"]
|
|
70
|
+
transport = @tls ? "TLS" : "TCP"
|
|
71
|
+
puts "\n🚀 [Tunnel] Ready! #{response['url']} -> #{@local_host}:#{@local_port} (server: #{transport})\n\n"
|
|
72
|
+
|
|
73
|
+
yield if block_given?
|
|
74
|
+
|
|
75
|
+
loop do
|
|
76
|
+
command = read_json(@control_socket, "command")
|
|
77
|
+
|
|
78
|
+
case command["action"]
|
|
79
|
+
when "ping"
|
|
80
|
+
@control_socket.puts({ action: "pong" }.to_json)
|
|
81
|
+
when "new_connection"
|
|
82
|
+
Thread.new { handle_data_connection(command["conn_id"]) }
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def handle_data_connection(conn_id)
|
|
88
|
+
server_sock =
|
|
89
|
+
begin
|
|
90
|
+
open_tcp(@server_host, @server_port)
|
|
91
|
+
rescue => e
|
|
92
|
+
warn "[Tunnel] Failed to open data connection to server: #{e.message}"
|
|
93
|
+
return
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
server_sock.puts({ action: "bind", conn_id: conn_id }.to_json)
|
|
97
|
+
|
|
98
|
+
begin
|
|
99
|
+
local_sock = open_tcp(@local_host, @local_port, tls: false)
|
|
100
|
+
rescue *LOCAL_UNREACHABLE_ERRORS => e
|
|
101
|
+
respond_bad_gateway(server_sock, e)
|
|
102
|
+
return
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
t1 = Thread.new { proxy_stream(server_sock, local_sock) }
|
|
106
|
+
t2 = Thread.new { proxy_stream(local_sock, server_sock) }
|
|
107
|
+
t1.join
|
|
108
|
+
t2.join
|
|
109
|
+
ensure
|
|
110
|
+
server_sock&.close
|
|
111
|
+
local_sock&.close
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def open_tcp(host, port, tls: @tls)
|
|
115
|
+
tcp = Socket.tcp(host, port, connect_timeout: CONNECT_TIMEOUT)
|
|
116
|
+
tcp.setsockopt(:TCP, :TCP_NODELAY, true) rescue nil
|
|
117
|
+
return tcp unless tls
|
|
118
|
+
|
|
119
|
+
ssl = OpenSSL::SSL::SSLSocket.new(tcp, @ssl_context)
|
|
120
|
+
ssl.hostname = host
|
|
121
|
+
cached_session = @session_mutex.synchronize { @tls_session }
|
|
122
|
+
ssl.session = cached_session if cached_session
|
|
123
|
+
ssl.sync_close = true
|
|
124
|
+
ssl.connect
|
|
125
|
+
ssl.post_connection_check(host) if @ssl_context.verify_mode == OpenSSL::SSL::VERIFY_PEER
|
|
126
|
+
ssl
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def build_ssl_context
|
|
130
|
+
ctx = OpenSSL::SSL::SSLContext.new
|
|
131
|
+
ctx.min_version = OpenSSL::SSL::TLS1_3_VERSION
|
|
132
|
+
|
|
133
|
+
ctx.session_cache_mode = OpenSSL::SSL::SSLContext::SESSION_CACHE_CLIENT
|
|
134
|
+
ctx.session_new_cb = proc do |_ssl, session|
|
|
135
|
+
@session_mutex.synchronize { @tls_session = session }
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
if @tls_ca
|
|
139
|
+
ctx.verify_mode = OpenSSL::SSL::VERIFY_PEER
|
|
140
|
+
ctx.ca_file = @tls_ca
|
|
141
|
+
elsif @tls_verify
|
|
142
|
+
ctx.verify_mode = OpenSSL::SSL::VERIFY_PEER
|
|
143
|
+
store = OpenSSL::X509::Store.new
|
|
144
|
+
store.set_default_paths
|
|
145
|
+
ctx.cert_store = store
|
|
146
|
+
else
|
|
147
|
+
ctx.verify_mode = OpenSSL::SSL::VERIFY_NONE
|
|
148
|
+
end
|
|
149
|
+
ctx
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
def read_json(socket, what)
|
|
153
|
+
line = socket.gets
|
|
154
|
+
raise "Server closed connection while waiting for #{what}" if line.nil?
|
|
155
|
+
|
|
156
|
+
JSON.parse(line)
|
|
157
|
+
rescue JSON::ParserError => e
|
|
158
|
+
raise "Invalid #{what} from server: #{e.message}"
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
def respond_bad_gateway(server_sock, error)
|
|
162
|
+
body = "Local service #{@local_host}:#{@local_port} is not reachable: #{error.message}\n"
|
|
163
|
+
server_sock.write(
|
|
164
|
+
"HTTP/1.1 502 Bad Gateway\r\n" \
|
|
165
|
+
"Connection: close\r\n" \
|
|
166
|
+
"Content-Type: text/plain; charset=utf-8\r\n" \
|
|
167
|
+
"Content-Length: #{body.bytesize}\r\n" \
|
|
168
|
+
"\r\n" \
|
|
169
|
+
"#{body}"
|
|
170
|
+
)
|
|
171
|
+
rescue IOError, SystemCallError
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
def enable_tcp_keepalive(socket)
|
|
175
|
+
io = socket.respond_to?(:to_io) ? socket.to_io : socket
|
|
176
|
+
io.setsockopt(:SOCKET, :SO_KEEPALIVE, true)
|
|
177
|
+
if defined?(Socket::TCP_KEEPIDLE)
|
|
178
|
+
io.setsockopt(:TCP, :TCP_KEEPIDLE, TCP_KEEPALIVE_IDLE)
|
|
179
|
+
io.setsockopt(:TCP, :TCP_KEEPINTVL, TCP_KEEPALIVE_INTERVAL)
|
|
180
|
+
io.setsockopt(:TCP, :TCP_KEEPCNT, TCP_KEEPALIVE_PROBES)
|
|
181
|
+
elsif defined?(Socket::TCP_KEEPALIVE)
|
|
182
|
+
io.setsockopt(:TCP, :TCP_KEEPALIVE, TCP_KEEPALIVE_IDLE)
|
|
183
|
+
end
|
|
184
|
+
rescue StandardError => e
|
|
185
|
+
warn "TCP keepalive not configured: #{e.message}"
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
def proxy_stream(source, destination)
|
|
189
|
+
IO.copy_stream(source, destination)
|
|
190
|
+
rescue IOError, SystemCallError
|
|
191
|
+
ensure
|
|
192
|
+
destination.close_write rescue nil
|
|
193
|
+
end
|
|
194
|
+
end
|
|
195
|
+
end
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "thread"
|
|
5
|
+
|
|
6
|
+
module TunnelRb
|
|
7
|
+
class Server
|
|
8
|
+
# Server-side view of a connected tunnel client. Owns the control socket
|
|
9
|
+
# and per-connection state (read buffer, ping bookkeeping, write mutex).
|
|
10
|
+
class Client
|
|
11
|
+
attr_reader :subdomain, :socket, :token, :read_buffer
|
|
12
|
+
attr_accessor :missed_pongs, :pong_received
|
|
13
|
+
|
|
14
|
+
def initialize(subdomain:, socket:, token:)
|
|
15
|
+
@subdomain = subdomain
|
|
16
|
+
@socket = socket
|
|
17
|
+
@token = token
|
|
18
|
+
@write_mutex = Mutex.new
|
|
19
|
+
@read_buffer = String.new(encoding: Encoding::ASCII_8BIT)
|
|
20
|
+
@missed_pongs = 0
|
|
21
|
+
@pong_received = true
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def send_message(payload)
|
|
25
|
+
@write_mutex.synchronize { @socket.puts(payload.to_json) }
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "set"
|
|
4
|
+
require "thread"
|
|
5
|
+
|
|
6
|
+
require_relative "client"
|
|
7
|
+
|
|
8
|
+
module TunnelRb
|
|
9
|
+
class Server
|
|
10
|
+
# Thread-safe in-memory registry of connected clients keyed by subdomain,
|
|
11
|
+
# with a reverse index for O(1) lookup by socket.
|
|
12
|
+
class ClientRegistry
|
|
13
|
+
def initialize
|
|
14
|
+
@clients = {} # subdomain -> Client
|
|
15
|
+
@socket_to_subdomain = {} # control_socket -> subdomain
|
|
16
|
+
@mutex = Mutex.new
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# Registers a new client. If a client with the same subdomain is already
|
|
20
|
+
# connected, returns the old socket so the caller can close it outside
|
|
21
|
+
# the registry's mutex.
|
|
22
|
+
def register(subdomain, socket, token)
|
|
23
|
+
old_socket = nil
|
|
24
|
+
client = nil
|
|
25
|
+
@mutex.synchronize do
|
|
26
|
+
old = @clients[subdomain]
|
|
27
|
+
if old && old.socket != socket
|
|
28
|
+
@socket_to_subdomain.delete(old.socket)
|
|
29
|
+
old_socket = old.socket
|
|
30
|
+
end
|
|
31
|
+
client = Client.new(subdomain: subdomain, socket: socket, token: token)
|
|
32
|
+
@clients[subdomain] = client
|
|
33
|
+
@socket_to_subdomain[socket] = subdomain
|
|
34
|
+
end
|
|
35
|
+
[client, old_socket]
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Removes a client identified by socket. Returns the subdomain it was
|
|
39
|
+
# registered under, or nil if the socket was already gone (e.g. replaced
|
|
40
|
+
# by a reconnect).
|
|
41
|
+
def forget(socket)
|
|
42
|
+
@mutex.synchronize { forget_unlocked(socket) }
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def lookup(subdomain)
|
|
46
|
+
@mutex.synchronize { @clients[subdomain] }
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def lookup_by_socket(socket)
|
|
50
|
+
@mutex.synchronize do
|
|
51
|
+
sub = @socket_to_subdomain[socket]
|
|
52
|
+
sub && @clients[sub]
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Snapshot of currently registered control sockets, for IO.select.
|
|
57
|
+
def sockets
|
|
58
|
+
@mutex.synchronize { @socket_to_subdomain.keys }
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Yields each Client under the registry mutex. Block must not block on
|
|
62
|
+
# I/O; collect work and execute it after the iteration.
|
|
63
|
+
def each_client(&block)
|
|
64
|
+
@mutex.synchronize { @clients.each_value(&block) }
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def active_subdomains
|
|
68
|
+
@mutex.synchronize { @clients.keys.to_set }
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def size
|
|
72
|
+
@mutex.synchronize { @clients.size }
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# Atomically tags many clients for removal and returns their sockets.
|
|
76
|
+
# Used by the ping loop to evict unresponsive peers without holding the
|
|
77
|
+
# mutex while closing sockets.
|
|
78
|
+
def forget_many(sockets)
|
|
79
|
+
@mutex.synchronize do
|
|
80
|
+
sockets.each { |s| forget_unlocked(s) }
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
private
|
|
85
|
+
|
|
86
|
+
def forget_unlocked(socket)
|
|
87
|
+
sub = @socket_to_subdomain.delete(socket)
|
|
88
|
+
return nil unless sub
|
|
89
|
+
|
|
90
|
+
client = @clients[sub]
|
|
91
|
+
@clients.delete(sub) if client && client.socket == socket
|
|
92
|
+
sub
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
end
|
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "socket"
|
|
5
|
+
|
|
6
|
+
require_relative "thread_pool"
|
|
7
|
+
require_relative "socket_helpers"
|
|
8
|
+
require_relative "tls"
|
|
9
|
+
|
|
10
|
+
module TunnelRb
|
|
11
|
+
class Server
|
|
12
|
+
# Owns the control plane: port 7777 listener, handshake handler, the
|
|
13
|
+
# IO.select-based read loop that consumes pong/disconnect events, and
|
|
14
|
+
# the ping loop that evicts unresponsive clients.
|
|
15
|
+
class ControlServer
|
|
16
|
+
PING_INTERVAL = 30 # seconds; keeps NAT mappings alive
|
|
17
|
+
PONG_MISSES = 3
|
|
18
|
+
HANDSHAKE_POOL_SIZE = 64
|
|
19
|
+
HANDSHAKE_QUEUE_SIZE = 64
|
|
20
|
+
READ_CHUNK = 4096
|
|
21
|
+
LINE_MAX = 16 * 1024 # drop clients that send oversized lines without \n
|
|
22
|
+
SELECT_TIMEOUT = 1.0
|
|
23
|
+
IDLE_SLEEP = 0.5 # when no clients are connected
|
|
24
|
+
|
|
25
|
+
def initialize(port:, domain:, registry:, token_store:, pending_connections:, logger:, url_port: nil, tls_context: nil)
|
|
26
|
+
@port = port
|
|
27
|
+
@domain = domain
|
|
28
|
+
@url_port = url_port
|
|
29
|
+
@registry = registry
|
|
30
|
+
@token_store = token_store
|
|
31
|
+
@pending_connections = pending_connections
|
|
32
|
+
@tls_context = tls_context
|
|
33
|
+
@logger = logger
|
|
34
|
+
|
|
35
|
+
@pool = ThreadPool.new(HANDSHAKE_POOL_SIZE, max_queue: HANDSHAKE_QUEUE_SIZE)
|
|
36
|
+
@stopping = false
|
|
37
|
+
@server = nil
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def tls?
|
|
41
|
+
!@tls_context.nil?
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Blocking accept loop. Returns when stop is called.
|
|
45
|
+
def start
|
|
46
|
+
tcp_server = TCPServer.new("0.0.0.0", @port)
|
|
47
|
+
@server = tls? ? TLS.wrap_listener(tcp_server, @tls_context) : tcp_server
|
|
48
|
+
@logger.info "🎧 Control server listening for clients on #{@port}"
|
|
49
|
+
|
|
50
|
+
loop do
|
|
51
|
+
socket = @server.accept
|
|
52
|
+
@pool.submit { handle_connection(socket) }
|
|
53
|
+
rescue IOError, Errno::EBADF
|
|
54
|
+
break if @stopping
|
|
55
|
+
|
|
56
|
+
raise
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# IO.select over all registered control sockets. One thread serves all
|
|
61
|
+
# clients, so the count of long-lived ping/pong loops doesn't grow with
|
|
62
|
+
# the number of registered tunnels.
|
|
63
|
+
def read_loop
|
|
64
|
+
until @stopping
|
|
65
|
+
sockets = @registry.sockets
|
|
66
|
+
if sockets.empty?
|
|
67
|
+
sleep IDLE_SLEEP
|
|
68
|
+
next
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
begin
|
|
72
|
+
readable, = IO.select(sockets, nil, nil, SELECT_TIMEOUT)
|
|
73
|
+
rescue IOError, Errno::EBADF
|
|
74
|
+
# A socket was closed (e.g. shutdown) while we were waiting on it.
|
|
75
|
+
# Drop it from our snapshot and re-loop to rebuild the set.
|
|
76
|
+
next
|
|
77
|
+
end
|
|
78
|
+
next unless readable
|
|
79
|
+
|
|
80
|
+
readable.each { |socket| handle_readable(socket) }
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def ping_loop
|
|
85
|
+
while interruptible_sleep(PING_INTERVAL)
|
|
86
|
+
tick_pings
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def stop
|
|
91
|
+
@stopping = true
|
|
92
|
+
@server&.close rescue nil
|
|
93
|
+
@pool.shutdown
|
|
94
|
+
@registry.sockets.each { |s| s.close rescue nil }
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
private
|
|
98
|
+
|
|
99
|
+
# Sleeps in 1-second slices so stop() is observed quickly. Returns
|
|
100
|
+
# false when @stopping flips during the sleep.
|
|
101
|
+
def interruptible_sleep(total)
|
|
102
|
+
total.times do
|
|
103
|
+
return false if @stopping
|
|
104
|
+
|
|
105
|
+
sleep 1
|
|
106
|
+
end
|
|
107
|
+
!@stopping
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def handle_connection(socket)
|
|
111
|
+
# SSLServer defers the handshake (start_immediately = false), so we run
|
|
112
|
+
# it here in the worker thread rather than blocking the accept loop.
|
|
113
|
+
socket.accept if tls?
|
|
114
|
+
|
|
115
|
+
SocketHelpers.enable_tcp_nodelay(socket)
|
|
116
|
+
|
|
117
|
+
line = socket.gets
|
|
118
|
+
return socket.close rescue nil unless line
|
|
119
|
+
|
|
120
|
+
request = JSON.parse(line)
|
|
121
|
+
|
|
122
|
+
case request["action"]
|
|
123
|
+
when "register"
|
|
124
|
+
handle_registration(socket, request)
|
|
125
|
+
when "bind"
|
|
126
|
+
@pending_connections.deliver(request["conn_id"], socket)
|
|
127
|
+
else
|
|
128
|
+
socket.close rescue nil
|
|
129
|
+
end
|
|
130
|
+
rescue => e
|
|
131
|
+
@logger.warn "❌ Tunnel connection error: #{e.message}"
|
|
132
|
+
socket.close rescue nil
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def handle_registration(control_socket, request)
|
|
136
|
+
@token_store.cleanup_expired
|
|
137
|
+
|
|
138
|
+
reused = false
|
|
139
|
+
subdomain =
|
|
140
|
+
if request["token"] && (existing = @token_store.resolve(request["token"]))
|
|
141
|
+
@token_store.revoke(request["token"])
|
|
142
|
+
reused = true
|
|
143
|
+
existing
|
|
144
|
+
else
|
|
145
|
+
@token_store.generate_subdomain
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
token = @token_store.issue(subdomain)
|
|
149
|
+
client, old_socket = @registry.register(subdomain, control_socket, token)
|
|
150
|
+
old_socket.close rescue nil if old_socket
|
|
151
|
+
|
|
152
|
+
authority = @url_port ? "#{subdomain}.#{@domain}:#{@url_port}" : "#{subdomain}.#{@domain}"
|
|
153
|
+
url = "https://#{authority}"
|
|
154
|
+
SocketHelpers.enable_tcp_keepalive(control_socket)
|
|
155
|
+
client.send_message(status: "ok", url: url, token: token)
|
|
156
|
+
|
|
157
|
+
@logger.info(reused ? "✅ Tunnel reconnected: #{url}" : "✅ Tunnel registered: #{url}")
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
def handle_readable(socket)
|
|
161
|
+
client = @registry.lookup_by_socket(socket)
|
|
162
|
+
# Socket already removed (e.g. ping_loop closed it); drop it.
|
|
163
|
+
return disconnect(socket, log: false) unless client
|
|
164
|
+
|
|
165
|
+
buffer = client.read_buffer
|
|
166
|
+
|
|
167
|
+
# Drain in a loop: a TLS socket can hold decrypted bytes that IO.select
|
|
168
|
+
# never surfaces (readiness is at the TCP layer, but a full TLS record
|
|
169
|
+
# may already be buffered). read_nonblock works for plain and TLS
|
|
170
|
+
# sockets alike, unlike recv_nonblock which SSLSocket does not support.
|
|
171
|
+
loop do
|
|
172
|
+
chunk = socket.read_nonblock(READ_CHUNK, exception: false)
|
|
173
|
+
case chunk
|
|
174
|
+
when :wait_readable, :wait_writable
|
|
175
|
+
break # nothing more to read right now
|
|
176
|
+
when nil
|
|
177
|
+
return disconnect(socket)
|
|
178
|
+
when ""
|
|
179
|
+
break
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
buffer << chunk
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
while (idx = buffer.index("\n"))
|
|
186
|
+
line = buffer.slice!(0, idx + 1)
|
|
187
|
+
process_line(client, line)
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
# No newline yet but buffer keeps growing => slowloris. Drop the client.
|
|
191
|
+
if buffer.bytesize > LINE_MAX
|
|
192
|
+
@logger.warn "control buffer overflow on #{client.subdomain}, disconnecting"
|
|
193
|
+
disconnect(socket)
|
|
194
|
+
end
|
|
195
|
+
rescue IOError, SystemCallError, OpenSSL::SSL::SSLError
|
|
196
|
+
disconnect(socket)
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
def process_line(client, line)
|
|
200
|
+
message = JSON.parse(line) rescue return
|
|
201
|
+
return unless message["action"] == "pong"
|
|
202
|
+
|
|
203
|
+
client.pong_received = true
|
|
204
|
+
client.missed_pongs = 0
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
def disconnect(socket, log: true)
|
|
208
|
+
subdomain = @registry.forget(socket)
|
|
209
|
+
socket.close rescue nil
|
|
210
|
+
@logger.info "🔴 Tunnel #{subdomain} disconnected" if log && subdomain
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
def tick_pings
|
|
214
|
+
to_close = []
|
|
215
|
+
to_ping = []
|
|
216
|
+
|
|
217
|
+
@registry.each_client do |client|
|
|
218
|
+
unless client.pong_received
|
|
219
|
+
client.missed_pongs += 1
|
|
220
|
+
if client.missed_pongs >= PONG_MISSES
|
|
221
|
+
to_close << client.socket
|
|
222
|
+
next
|
|
223
|
+
end
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
client.pong_received = false
|
|
227
|
+
to_ping << client
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
to_close.each do |socket|
|
|
231
|
+
sub = @registry.forget(socket)
|
|
232
|
+
@logger.warn "⚠️ Tunnel #{sub} unresponsive (#{PONG_MISSES} missed pongs), closing control channel" if sub
|
|
233
|
+
socket.close rescue nil
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
to_ping.each do |client|
|
|
237
|
+
client.send_message(action: "ping")
|
|
238
|
+
rescue IOError, SystemCallError
|
|
239
|
+
nil
|
|
240
|
+
end
|
|
241
|
+
end
|
|
242
|
+
end
|
|
243
|
+
end
|
|
244
|
+
end
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "timeout"
|
|
4
|
+
|
|
5
|
+
module TunnelRb
|
|
6
|
+
class Server
|
|
7
|
+
# Reads the request line and headers from a browser socket, injects
|
|
8
|
+
# X-Forwarded-* headers, and returns the parsed Host plus the raw bytes
|
|
9
|
+
# ready to forward to the tunneled backend.
|
|
10
|
+
module HttpRequest
|
|
11
|
+
Timeout = Class.new(StandardError)
|
|
12
|
+
|
|
13
|
+
DEFAULT_TIMEOUT = 5
|
|
14
|
+
|
|
15
|
+
module_function
|
|
16
|
+
|
|
17
|
+
def read(socket, client_ip:, timeout: DEFAULT_TIMEOUT)
|
|
18
|
+
buffer = +""
|
|
19
|
+
host = nil
|
|
20
|
+
|
|
21
|
+
::Timeout.timeout(timeout) do
|
|
22
|
+
while (line = socket.gets)
|
|
23
|
+
if line.strip.empty?
|
|
24
|
+
buffer << "X-Forwarded-For: #{client_ip}\r\n"
|
|
25
|
+
buffer << "X-Forwarded-Proto: https\r\n"
|
|
26
|
+
buffer << "\r\n"
|
|
27
|
+
break
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
next if line.match?(/^X-Forwarded-/i)
|
|
31
|
+
|
|
32
|
+
buffer << line
|
|
33
|
+
if (match = line.match(/^Host:\s+([^\r\n]+)/i))
|
|
34
|
+
host = match[1]
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
[host, buffer]
|
|
40
|
+
rescue ::Timeout::Error
|
|
41
|
+
raise Timeout
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module TunnelRb
|
|
4
|
+
class Server
|
|
5
|
+
# Small wrapper around stdout/stderr so call sites stay short and tests
|
|
6
|
+
# can swap in a StringIO. Existing emoji-decorated strings pass through
|
|
7
|
+
# unchanged.
|
|
8
|
+
class Logger
|
|
9
|
+
def initialize(out: $stdout, err: $stderr)
|
|
10
|
+
@out = out
|
|
11
|
+
@err = err
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def info(message)
|
|
15
|
+
@out.puts(message)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def warn(message)
|
|
19
|
+
@err.puts(message)
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "thread"
|
|
4
|
+
|
|
5
|
+
module TunnelRb
|
|
6
|
+
class Server
|
|
7
|
+
# Coordination between the public-request thread (which generates a
|
|
8
|
+
# conn_id and asks the tunneled client for a fresh data socket) and the
|
|
9
|
+
# control connection where the tunneled client eventually 'bind's that
|
|
10
|
+
# socket. The public side registers a Queue, the control side delivers into
|
|
11
|
+
# it.
|
|
12
|
+
class PendingConnections
|
|
13
|
+
def initialize
|
|
14
|
+
@pending = {}
|
|
15
|
+
@mutex = Mutex.new
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def register(conn_id)
|
|
19
|
+
queue = Queue.new
|
|
20
|
+
@mutex.synchronize { @pending[conn_id] = queue }
|
|
21
|
+
queue
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# Public side cleans up its slot whether it consumed the data socket
|
|
25
|
+
# or timed out.
|
|
26
|
+
def close(conn_id)
|
|
27
|
+
@mutex.synchronize { @pending.delete(conn_id) }
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Control side hands a freshly-bound data socket to whichever public
|
|
31
|
+
# request is waiting. If nobody is waiting (e.g. browser closed first),
|
|
32
|
+
# the socket is closed.
|
|
33
|
+
def deliver(conn_id, socket)
|
|
34
|
+
queue = @mutex.synchronize { @pending.delete(conn_id) }
|
|
35
|
+
if queue
|
|
36
|
+
queue.push(socket)
|
|
37
|
+
else
|
|
38
|
+
socket.close rescue nil
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|