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,144 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "securerandom"
|
|
4
|
+
require "socket"
|
|
5
|
+
require "timeout"
|
|
6
|
+
|
|
7
|
+
require_relative "thread_pool"
|
|
8
|
+
require_relative "http_request"
|
|
9
|
+
require_relative "socket_helpers"
|
|
10
|
+
|
|
11
|
+
module TunnelRb
|
|
12
|
+
class Server
|
|
13
|
+
# Public-facing HTTP edge: accepts browser connections, parses headers,
|
|
14
|
+
# asks the tunneled client to open a fresh data connection via
|
|
15
|
+
# PendingConnections, and proxies bytes both ways.
|
|
16
|
+
class PublicServer
|
|
17
|
+
POOL_SIZE = 200
|
|
18
|
+
QUEUE_SIZE = 200
|
|
19
|
+
READ_TIMEOUT_SEC = 5
|
|
20
|
+
BIND_WAIT_SEC = 10
|
|
21
|
+
|
|
22
|
+
def initialize(port:, registry:, pending_connections:, logger:)
|
|
23
|
+
@port = port
|
|
24
|
+
@registry = registry
|
|
25
|
+
@pending_connections = pending_connections
|
|
26
|
+
@logger = logger
|
|
27
|
+
|
|
28
|
+
@pool = ThreadPool.new(POOL_SIZE, max_queue: QUEUE_SIZE)
|
|
29
|
+
@stopping = false
|
|
30
|
+
@server = nil
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def start
|
|
34
|
+
@server = TCPServer.new("0.0.0.0", @port)
|
|
35
|
+
@logger.info "🌍 Public server listening on #{@port}"
|
|
36
|
+
|
|
37
|
+
loop do
|
|
38
|
+
browser_socket = @server.accept
|
|
39
|
+
@pool.submit { handle_request(browser_socket) }
|
|
40
|
+
rescue IOError, Errno::EBADF
|
|
41
|
+
break if @stopping
|
|
42
|
+
|
|
43
|
+
raise
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def stop
|
|
48
|
+
@stopping = true
|
|
49
|
+
@server&.close rescue nil
|
|
50
|
+
@pool.shutdown
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
private
|
|
54
|
+
|
|
55
|
+
def handle_request(browser_socket)
|
|
56
|
+
conn_id = nil
|
|
57
|
+
data_socket = nil
|
|
58
|
+
client_ip = browser_socket.remote_address.ip_address rescue "127.0.0.1"
|
|
59
|
+
SocketHelpers.enable_tcp_nodelay(browser_socket)
|
|
60
|
+
|
|
61
|
+
host, header_buffer = read_headers(browser_socket, client_ip)
|
|
62
|
+
return unless header_buffer
|
|
63
|
+
|
|
64
|
+
subdomain = extract_subdomain(host)
|
|
65
|
+
client = @registry.lookup(subdomain)
|
|
66
|
+
unless client
|
|
67
|
+
send_error(browser_socket, 404, "Tunnel '#{subdomain}' not found or disconnected.")
|
|
68
|
+
return
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
conn_id = SecureRandom.uuid
|
|
72
|
+
queue = @pending_connections.register(conn_id)
|
|
73
|
+
|
|
74
|
+
begin
|
|
75
|
+
client.send_message(action: "new_connection", conn_id: conn_id)
|
|
76
|
+
rescue => _e
|
|
77
|
+
send_error(browser_socket, 502, "Failed to reach tunnel.")
|
|
78
|
+
return
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
begin
|
|
82
|
+
Timeout.timeout(BIND_WAIT_SEC) { data_socket = queue.pop }
|
|
83
|
+
rescue Timeout::Error
|
|
84
|
+
send_error(browser_socket, 504, "Timeout: local server did not respond.")
|
|
85
|
+
return
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
data_socket.write(header_buffer)
|
|
89
|
+
proxy_stream(browser_socket, data_socket)
|
|
90
|
+
rescue => e
|
|
91
|
+
@logger.warn "❌ HTTP routing error: #{e.message}"
|
|
92
|
+
ensure
|
|
93
|
+
@pending_connections.close(conn_id) if conn_id
|
|
94
|
+
data_socket.close rescue nil if data_socket
|
|
95
|
+
browser_socket.close rescue nil
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def read_headers(browser_socket, client_ip)
|
|
99
|
+
HttpRequest.read(browser_socket, client_ip: client_ip, timeout: READ_TIMEOUT_SEC)
|
|
100
|
+
rescue HttpRequest::Timeout
|
|
101
|
+
send_error(browser_socket, 408, "Request Timeout")
|
|
102
|
+
[nil, nil]
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def extract_subdomain(host_header)
|
|
106
|
+
return nil if host_header.nil? || host_header.empty?
|
|
107
|
+
|
|
108
|
+
host_header.split(":", 2).first.split(".").first
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def proxy_stream(client, backend)
|
|
112
|
+
t1 = Thread.new do
|
|
113
|
+
IO.copy_stream(client, backend) rescue nil
|
|
114
|
+
backend.close_write rescue nil
|
|
115
|
+
end
|
|
116
|
+
t2 = Thread.new do
|
|
117
|
+
IO.copy_stream(backend, client) rescue nil
|
|
118
|
+
client.close_write rescue nil
|
|
119
|
+
end
|
|
120
|
+
t1.join
|
|
121
|
+
t2.join
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
STATUS_TEXTS = {
|
|
125
|
+
404 => "Not Found",
|
|
126
|
+
408 => "Request Timeout",
|
|
127
|
+
502 => "Bad Gateway",
|
|
128
|
+
504 => "Gateway Timeout"
|
|
129
|
+
}.freeze
|
|
130
|
+
|
|
131
|
+
def send_error(socket, code, message)
|
|
132
|
+
status = STATUS_TEXTS.fetch(code, "Bad Gateway")
|
|
133
|
+
socket.print(
|
|
134
|
+
"HTTP/1.1 #{code} #{status}\r\n" \
|
|
135
|
+
"Connection: close\r\n" \
|
|
136
|
+
"Content-Length: #{message.bytesize}\r\n" \
|
|
137
|
+
"Content-Type: text/plain; charset=utf-8\r\n" \
|
|
138
|
+
"\r\n#{message}"
|
|
139
|
+
)
|
|
140
|
+
socket.close rescue nil
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
end
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "socket"
|
|
4
|
+
|
|
5
|
+
module TunnelRb
|
|
6
|
+
class Server
|
|
7
|
+
module SocketHelpers
|
|
8
|
+
TCP_KEEPALIVE_IDLE = 60
|
|
9
|
+
TCP_KEEPALIVE_INTERVAL = 30
|
|
10
|
+
TCP_KEEPALIVE_PROBES = 3
|
|
11
|
+
|
|
12
|
+
module_function
|
|
13
|
+
|
|
14
|
+
def enable_tcp_keepalive(socket)
|
|
15
|
+
# Operate on the raw fd so this works for both plain TCP and TLS
|
|
16
|
+
# sockets (SSLSocket does not define setsockopt itself).
|
|
17
|
+
io = socket.respond_to?(:to_io) ? socket.to_io : socket
|
|
18
|
+
io.setsockopt(:SOCKET, :SO_KEEPALIVE, true)
|
|
19
|
+
if defined?(Socket::TCP_KEEPIDLE)
|
|
20
|
+
io.setsockopt(:TCP, :TCP_KEEPIDLE, TCP_KEEPALIVE_IDLE)
|
|
21
|
+
io.setsockopt(:TCP, :TCP_KEEPINTVL, TCP_KEEPALIVE_INTERVAL)
|
|
22
|
+
io.setsockopt(:TCP, :TCP_KEEPCNT, TCP_KEEPALIVE_PROBES)
|
|
23
|
+
elsif defined?(Socket::TCP_KEEPALIVE)
|
|
24
|
+
io.setsockopt(:TCP, :TCP_KEEPALIVE, TCP_KEEPALIVE_IDLE)
|
|
25
|
+
end
|
|
26
|
+
rescue StandardError => e
|
|
27
|
+
warn "TCP keepalive not configured: #{e.message}"
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Disables Nagle's algorithm so small writes (TLS handshake records, short
|
|
31
|
+
# HTTP responses) go out immediately instead of waiting to coalesce. On the
|
|
32
|
+
# short-lived data sockets this avoids ~40ms Nagle/delayed-ACK stalls.
|
|
33
|
+
def enable_tcp_nodelay(socket)
|
|
34
|
+
io = socket.respond_to?(:to_io) ? socket.to_io : socket
|
|
35
|
+
io.setsockopt(:TCP, :TCP_NODELAY, true)
|
|
36
|
+
rescue StandardError => e
|
|
37
|
+
warn "TCP_NODELAY not configured: #{e.message}"
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "thread"
|
|
4
|
+
|
|
5
|
+
module TunnelRb
|
|
6
|
+
class Server
|
|
7
|
+
# Bounded worker pool. `submit` blocks the caller when the queue is full,
|
|
8
|
+
# which propagates backpressure all the way to TCP accept loops.
|
|
9
|
+
class ThreadPool
|
|
10
|
+
SHUTDOWN = :shutdown
|
|
11
|
+
|
|
12
|
+
def initialize(size, max_queue: size)
|
|
13
|
+
@size = size
|
|
14
|
+
@queue = SizedQueue.new(max_queue)
|
|
15
|
+
@workers = Array.new(size) do
|
|
16
|
+
Thread.new do
|
|
17
|
+
loop do
|
|
18
|
+
job = @queue.pop
|
|
19
|
+
break if job == SHUTDOWN
|
|
20
|
+
|
|
21
|
+
job.call
|
|
22
|
+
rescue => e
|
|
23
|
+
warn "ThreadPool job error: #{e.message}"
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def submit(&block)
|
|
30
|
+
@queue << block
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def shutdown
|
|
34
|
+
@size.times { @queue << SHUTDOWN }
|
|
35
|
+
@workers.each { |t| t.join(2) }
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "openssl"
|
|
4
|
+
|
|
5
|
+
module TunnelRb
|
|
6
|
+
class Server
|
|
7
|
+
# Builds TLS contexts and wraps the control listener. TLS is optional:
|
|
8
|
+
# callers pass nil cert/key to run the control plane in plaintext.
|
|
9
|
+
module TLS
|
|
10
|
+
module_function
|
|
11
|
+
|
|
12
|
+
# Returns an SSLContext configured to present the given cert/key, or
|
|
13
|
+
# nil when either path is missing (TLS disabled).
|
|
14
|
+
def server_context(cert_path:, key_path:)
|
|
15
|
+
return nil if cert_path.nil? || key_path.nil?
|
|
16
|
+
|
|
17
|
+
ctx = OpenSSL::SSL::SSLContext.new
|
|
18
|
+
certs = load_certificates(cert_path)
|
|
19
|
+
ctx.cert = certs.shift
|
|
20
|
+
ctx.extra_chain_cert = certs unless certs.empty?
|
|
21
|
+
ctx.key = OpenSSL::PKey.read(File.read(key_path))
|
|
22
|
+
ctx.min_version = OpenSSL::SSL::TLS1_3_VERSION
|
|
23
|
+
|
|
24
|
+
# Enable session resumption: the server issues TLS 1.3 session tickets so
|
|
25
|
+
# repeat data-socket handshakes skip the expensive signature/verify step.
|
|
26
|
+
ctx.session_id_context = "tunnel-rb-server"
|
|
27
|
+
ctx
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Wraps a TCPServer in an SSLServer. The handshake is deferred
|
|
31
|
+
# (start_immediately = false) so accept() does not block on a slow
|
|
32
|
+
# client; the worker thread performs the handshake instead.
|
|
33
|
+
def load_certificates(path)
|
|
34
|
+
File.read(path).scan(/-----BEGIN CERTIFICATE-----.*?-----END CERTIFICATE-----/m).map do |pem|
|
|
35
|
+
OpenSSL::X509::Certificate.new(pem)
|
|
36
|
+
end.tap do |certs|
|
|
37
|
+
raise "No certificates in #{path}" if certs.empty?
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def wrap_listener(tcp_server, ctx)
|
|
42
|
+
ssl_server = OpenSSL::SSL::SSLServer.new(tcp_server, ctx)
|
|
43
|
+
ssl_server.start_immediately = false
|
|
44
|
+
ssl_server
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "securerandom"
|
|
5
|
+
require "set"
|
|
6
|
+
require "thread"
|
|
7
|
+
|
|
8
|
+
module TunnelRb
|
|
9
|
+
class Server
|
|
10
|
+
# Persistent token <-> subdomain mapping with TTL. Tokens belonging to a
|
|
11
|
+
# currently-connected client never expire (they're refreshed implicitly).
|
|
12
|
+
# Tokens of disconnected clients age out after TOKEN_TTL.
|
|
13
|
+
class TokenStore
|
|
14
|
+
DEFAULT_PATH = "/tmp/tunnel-rb-server-tokens.json".freeze
|
|
15
|
+
TOKEN_TTL = 24 * 3600 # seconds
|
|
16
|
+
CLEANUP_INTERVAL = 600
|
|
17
|
+
|
|
18
|
+
# active_subdomains: callable returning a Set/Array of subdomains held
|
|
19
|
+
# by currently-connected clients. Used to keep their tokens alive and to
|
|
20
|
+
# avoid colliding when generating new subdomains.
|
|
21
|
+
def initialize(path: DEFAULT_PATH, active_subdomains:, logger: nil)
|
|
22
|
+
@path = path
|
|
23
|
+
@active_subdomains = active_subdomains
|
|
24
|
+
@logger = logger
|
|
25
|
+
@mutex = Mutex.new
|
|
26
|
+
@tokens = load_from_disk
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def issue(subdomain)
|
|
30
|
+
@mutex.synchronize do
|
|
31
|
+
loop do
|
|
32
|
+
token = SecureRandom.hex(16)
|
|
33
|
+
next if @tokens.key?(token)
|
|
34
|
+
|
|
35
|
+
@tokens[token] = entry(subdomain)
|
|
36
|
+
persist
|
|
37
|
+
return token
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def revoke(token)
|
|
43
|
+
return unless token
|
|
44
|
+
|
|
45
|
+
@mutex.synchronize do
|
|
46
|
+
next unless @tokens.delete(token)
|
|
47
|
+
|
|
48
|
+
persist
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Returns the subdomain bound to `token` if the token is valid, or nil.
|
|
53
|
+
# A token is valid if either (a) its subdomain currently has an active
|
|
54
|
+
# client, or (b) the token has not expired yet.
|
|
55
|
+
def resolve(token)
|
|
56
|
+
@mutex.synchronize do
|
|
57
|
+
record = @tokens[token]
|
|
58
|
+
return nil unless record
|
|
59
|
+
|
|
60
|
+
sub = subdomain_of(record)
|
|
61
|
+
return sub if active?(sub)
|
|
62
|
+
return nil if expired?(record)
|
|
63
|
+
|
|
64
|
+
sub
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def generate_subdomain
|
|
69
|
+
@mutex.synchronize do
|
|
70
|
+
loop do
|
|
71
|
+
candidate = SecureRandom.hex(4)
|
|
72
|
+
return candidate unless taken_unlocked?(candidate)
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# Drops tokens whose subdomain has no active client and whose TTL has
|
|
78
|
+
# elapsed. Called both periodically and on every registration.
|
|
79
|
+
def cleanup_expired
|
|
80
|
+
@mutex.synchronize { cleanup_unlocked }
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def cleanup_loop(interval: CLEANUP_INTERVAL, stop_flag: nil)
|
|
84
|
+
loop do
|
|
85
|
+
interval.times do
|
|
86
|
+
return if stop_flag && stop_flag.call
|
|
87
|
+
|
|
88
|
+
sleep 1
|
|
89
|
+
end
|
|
90
|
+
return if stop_flag && stop_flag.call
|
|
91
|
+
|
|
92
|
+
cleanup_expired
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def persist_now
|
|
97
|
+
@mutex.synchronize { persist }
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# Snapshot for tests/diagnostics.
|
|
101
|
+
def to_h
|
|
102
|
+
@mutex.synchronize { @tokens.dup }
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
private
|
|
106
|
+
|
|
107
|
+
def cleanup_unlocked
|
|
108
|
+
now = Time.now.to_i
|
|
109
|
+
expired_tokens = @tokens.reject do |_token, record|
|
|
110
|
+
active?(subdomain_of(record)) || !expired?(record, now)
|
|
111
|
+
end
|
|
112
|
+
return if expired_tokens.empty?
|
|
113
|
+
|
|
114
|
+
expired_tokens.each_key { |t| @tokens.delete(t) }
|
|
115
|
+
persist
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def taken_unlocked?(subdomain)
|
|
119
|
+
return true if active?(subdomain)
|
|
120
|
+
|
|
121
|
+
@tokens.any? { |_token, record| subdomain_of(record) == subdomain }
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def active?(subdomain)
|
|
125
|
+
active_set.include?(subdomain)
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def active_set
|
|
129
|
+
result = @active_subdomains.call
|
|
130
|
+
result.is_a?(Set) ? result : result.to_set
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def entry(subdomain, expires_at: Time.now.to_i + TOKEN_TTL)
|
|
134
|
+
{ "subdomain" => subdomain, "expires_at" => expires_at }
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def subdomain_of(record)
|
|
138
|
+
record.is_a?(Hash) ? record["subdomain"] : record
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def expired?(record, now = Time.now.to_i)
|
|
142
|
+
expires_at = record.is_a?(Hash) ? record["expires_at"] : nil
|
|
143
|
+
expires_at.nil? || expires_at <= now
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def normalize(record, now)
|
|
147
|
+
if record.is_a?(Hash)
|
|
148
|
+
entry(record["subdomain"], expires_at: record["expires_at"] || (now + TOKEN_TTL))
|
|
149
|
+
else
|
|
150
|
+
entry(record, expires_at: now + TOKEN_TTL)
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def load_from_disk
|
|
155
|
+
return {} unless File.exist?(@path)
|
|
156
|
+
|
|
157
|
+
now = Time.now.to_i
|
|
158
|
+
result = {}
|
|
159
|
+
JSON.parse(File.read(@path)).each do |token, record|
|
|
160
|
+
normalized = normalize(record, now)
|
|
161
|
+
next if expired?(normalized, now)
|
|
162
|
+
|
|
163
|
+
result[token] = normalized
|
|
164
|
+
end
|
|
165
|
+
result
|
|
166
|
+
rescue JSON::ParserError, SystemCallError => e
|
|
167
|
+
log_warn("Failed to load tokens: #{e.message}")
|
|
168
|
+
{}
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
def persist
|
|
172
|
+
File.write(@path, JSON.pretty_generate(@tokens))
|
|
173
|
+
rescue SystemCallError => e
|
|
174
|
+
log_warn("Failed to save tokens: #{e.message}")
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
def log_warn(msg)
|
|
178
|
+
@logger ? @logger.warn("⚠️ #{msg}") : warn(msg)
|
|
179
|
+
end
|
|
180
|
+
end
|
|
181
|
+
end
|
|
182
|
+
end
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "server/logger"
|
|
4
|
+
require_relative "server/client_registry"
|
|
5
|
+
require_relative "server/token_store"
|
|
6
|
+
require_relative "server/pending_connections"
|
|
7
|
+
require_relative "server/control_server"
|
|
8
|
+
require_relative "server/public_server"
|
|
9
|
+
require_relative "server/tls"
|
|
10
|
+
|
|
11
|
+
module TunnelRb
|
|
12
|
+
# Top-level coordinator. Wires the components together, owns the
|
|
13
|
+
# background threads, and handles graceful shutdown on SIGINT/SIGTERM.
|
|
14
|
+
class Server
|
|
15
|
+
def initialize(
|
|
16
|
+
control_port:,
|
|
17
|
+
public_port:,
|
|
18
|
+
domain:,
|
|
19
|
+
url_port: nil,
|
|
20
|
+
tokens_path: TokenStore::DEFAULT_PATH,
|
|
21
|
+
tls_cert: nil,
|
|
22
|
+
tls_key: nil,
|
|
23
|
+
logger: Logger.new
|
|
24
|
+
)
|
|
25
|
+
@logger = logger
|
|
26
|
+
@registry = ClientRegistry.new
|
|
27
|
+
@token_store = TokenStore.new(
|
|
28
|
+
path: tokens_path,
|
|
29
|
+
active_subdomains: -> { @registry.active_subdomains },
|
|
30
|
+
logger: @logger
|
|
31
|
+
)
|
|
32
|
+
@pending_connections = PendingConnections.new
|
|
33
|
+
|
|
34
|
+
tls_context = TLS.server_context(cert_path: tls_cert, key_path: tls_key)
|
|
35
|
+
|
|
36
|
+
@control = ControlServer.new(
|
|
37
|
+
port: control_port,
|
|
38
|
+
domain: domain,
|
|
39
|
+
url_port: url_port,
|
|
40
|
+
registry: @registry,
|
|
41
|
+
token_store: @token_store,
|
|
42
|
+
pending_connections: @pending_connections,
|
|
43
|
+
tls_context: tls_context,
|
|
44
|
+
logger: @logger
|
|
45
|
+
)
|
|
46
|
+
@public = PublicServer.new(
|
|
47
|
+
port: public_port,
|
|
48
|
+
registry: @registry,
|
|
49
|
+
pending_connections: @pending_connections,
|
|
50
|
+
logger: @logger
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
@stop_flag = false
|
|
54
|
+
@threads = []
|
|
55
|
+
@stop_mutex = Mutex.new
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def start(install_signals: true)
|
|
59
|
+
@logger.info "🚀 Starting tunnel server..."
|
|
60
|
+
@logger.info(@control.tls? ? "🔒 Control plane TLS: enabled" : "🔓 Control plane TLS: disabled")
|
|
61
|
+
install_signal_handlers if install_signals
|
|
62
|
+
|
|
63
|
+
@threads << Thread.new { @control.start }
|
|
64
|
+
@threads << Thread.new { @public.start }
|
|
65
|
+
@threads << Thread.new { @control.read_loop }
|
|
66
|
+
@threads << Thread.new { @control.ping_loop }
|
|
67
|
+
@threads << Thread.new {
|
|
68
|
+
@token_store.cleanup_loop(stop_flag: -> { @stop_flag })
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
@threads.each(&:join)
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def stop
|
|
75
|
+
@stop_mutex.synchronize do
|
|
76
|
+
return if @stop_flag
|
|
77
|
+
|
|
78
|
+
@stop_flag = true
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
@logger.info "👋 Shutting down..."
|
|
82
|
+
@control.stop
|
|
83
|
+
@public.stop
|
|
84
|
+
@token_store.persist_now
|
|
85
|
+
@threads.each { |t| t.join(2) }
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
private
|
|
89
|
+
|
|
90
|
+
def install_signal_handlers
|
|
91
|
+
[:INT, :TERM].each do |sig|
|
|
92
|
+
Signal.trap(sig) { Thread.new { stop } }
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
end
|
data/lib/tunnel_rb.rb
ADDED
data/tunnel-rb.gemspec
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "lib/tunnel_rb/version"
|
|
4
|
+
|
|
5
|
+
Gem::Specification.new do |spec|
|
|
6
|
+
spec.name = "tunnel-rb"
|
|
7
|
+
spec.version = TunnelRb::VERSION
|
|
8
|
+
spec.authors = ["headmandev"]
|
|
9
|
+
spec.email = ["headman.dev@gmail.com"]
|
|
10
|
+
|
|
11
|
+
spec.summary = "Expose a local HTTP server to the internet through a tunnel server"
|
|
12
|
+
spec.description = <<~DESC
|
|
13
|
+
tunnel-rb exposes a local HTTP server (e.g. a Rails app) to the internet through a tunnel server.
|
|
14
|
+
Includes the tunnel client CLI and tunnel server library — plain Ruby stdlib only, no runtime dependencies.
|
|
15
|
+
DESC
|
|
16
|
+
spec.homepage = "https://github.com/headmandev/tunnel-rb"
|
|
17
|
+
spec.license = "MIT"
|
|
18
|
+
spec.required_ruby_version = ">= 3.0"
|
|
19
|
+
|
|
20
|
+
spec.metadata = {
|
|
21
|
+
"source_code_uri" => spec.homepage
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
spec.files = Dir.chdir(__dir__) do
|
|
25
|
+
Dir.glob("{exe,lib}/**/*").select { |path| File.file?(path) } +
|
|
26
|
+
%w[tunnel-rb.gemspec README.md LICENSE]
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
spec.bindir = "exe"
|
|
30
|
+
spec.executables = %w[tunnel]
|
|
31
|
+
spec.require_paths = ["lib"]
|
|
32
|
+
|
|
33
|
+
spec.add_development_dependency "minitest", "~> 5.0"
|
|
34
|
+
spec.add_development_dependency "rake", "~> 13.0"
|
|
35
|
+
end
|
metadata
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: tunnel-rb
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- headmandev
|
|
8
|
+
bindir: exe
|
|
9
|
+
cert_chain: []
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
|
+
dependencies:
|
|
12
|
+
- !ruby/object:Gem::Dependency
|
|
13
|
+
name: minitest
|
|
14
|
+
requirement: !ruby/object:Gem::Requirement
|
|
15
|
+
requirements:
|
|
16
|
+
- - "~>"
|
|
17
|
+
- !ruby/object:Gem::Version
|
|
18
|
+
version: '5.0'
|
|
19
|
+
type: :development
|
|
20
|
+
prerelease: false
|
|
21
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
22
|
+
requirements:
|
|
23
|
+
- - "~>"
|
|
24
|
+
- !ruby/object:Gem::Version
|
|
25
|
+
version: '5.0'
|
|
26
|
+
- !ruby/object:Gem::Dependency
|
|
27
|
+
name: rake
|
|
28
|
+
requirement: !ruby/object:Gem::Requirement
|
|
29
|
+
requirements:
|
|
30
|
+
- - "~>"
|
|
31
|
+
- !ruby/object:Gem::Version
|
|
32
|
+
version: '13.0'
|
|
33
|
+
type: :development
|
|
34
|
+
prerelease: false
|
|
35
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
36
|
+
requirements:
|
|
37
|
+
- - "~>"
|
|
38
|
+
- !ruby/object:Gem::Version
|
|
39
|
+
version: '13.0'
|
|
40
|
+
description: |
|
|
41
|
+
tunnel-rb exposes a local HTTP server (e.g. a Rails app) to the internet through a tunnel server.
|
|
42
|
+
Includes the tunnel client CLI and tunnel server library — plain Ruby stdlib only, no runtime dependencies.
|
|
43
|
+
email:
|
|
44
|
+
- headman.dev@gmail.com
|
|
45
|
+
executables:
|
|
46
|
+
- tunnel
|
|
47
|
+
extensions: []
|
|
48
|
+
extra_rdoc_files: []
|
|
49
|
+
files:
|
|
50
|
+
- LICENSE
|
|
51
|
+
- README.md
|
|
52
|
+
- exe/tunnel
|
|
53
|
+
- lib/tunnel_rb.rb
|
|
54
|
+
- lib/tunnel_rb/cli.rb
|
|
55
|
+
- lib/tunnel_rb/client.rb
|
|
56
|
+
- lib/tunnel_rb/server.rb
|
|
57
|
+
- lib/tunnel_rb/server/client.rb
|
|
58
|
+
- lib/tunnel_rb/server/client_registry.rb
|
|
59
|
+
- lib/tunnel_rb/server/control_server.rb
|
|
60
|
+
- lib/tunnel_rb/server/http_request.rb
|
|
61
|
+
- lib/tunnel_rb/server/logger.rb
|
|
62
|
+
- lib/tunnel_rb/server/pending_connections.rb
|
|
63
|
+
- lib/tunnel_rb/server/public_server.rb
|
|
64
|
+
- lib/tunnel_rb/server/socket_helpers.rb
|
|
65
|
+
- lib/tunnel_rb/server/thread_pool.rb
|
|
66
|
+
- lib/tunnel_rb/server/tls.rb
|
|
67
|
+
- lib/tunnel_rb/server/token_store.rb
|
|
68
|
+
- lib/tunnel_rb/version.rb
|
|
69
|
+
- tunnel-rb.gemspec
|
|
70
|
+
homepage: https://github.com/headmandev/tunnel-rb
|
|
71
|
+
licenses:
|
|
72
|
+
- MIT
|
|
73
|
+
metadata:
|
|
74
|
+
source_code_uri: https://github.com/headmandev/tunnel-rb
|
|
75
|
+
rdoc_options: []
|
|
76
|
+
require_paths:
|
|
77
|
+
- lib
|
|
78
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
79
|
+
requirements:
|
|
80
|
+
- - ">="
|
|
81
|
+
- !ruby/object:Gem::Version
|
|
82
|
+
version: '3.0'
|
|
83
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
84
|
+
requirements:
|
|
85
|
+
- - ">="
|
|
86
|
+
- !ruby/object:Gem::Version
|
|
87
|
+
version: '0'
|
|
88
|
+
requirements: []
|
|
89
|
+
rubygems_version: 3.6.9
|
|
90
|
+
specification_version: 4
|
|
91
|
+
summary: Expose a local HTTP server to the internet through a tunnel server
|
|
92
|
+
test_files: []
|