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,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
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TunnelRb
4
+ VERSION = "0.1.0"
5
+ end
data/lib/tunnel_rb.rb ADDED
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "tunnel_rb/version"
4
+ require_relative "tunnel_rb/client"
5
+ require_relative "tunnel_rb/server"
6
+
7
+ # Backward-compatible alias for scripts that reference the top-level Tunnel class.
8
+ Tunnel = TunnelRb::Client
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: []