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.
@@ -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