etunnel 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.
Files changed (6) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +21 -0
  3. data/README.md +47 -0
  4. data/bin/etunnel +6 -0
  5. data/lib/etunnel.rb +174 -0
  6. metadata +60 -0
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 2fef3d0b4489dc6b413507a3df7b135535d6dcb108435a4549375fbb0a0519bf
4
+ data.tar.gz: d3bdb54234f9caefe126784dced3c7f2cd252b7a2f1526448bdeaf7e8d41f738
5
+ SHA512:
6
+ metadata.gz: fa6516ba349f1e9562195489a218062ca6dc6f86cd5f89ba224f2677eff8d00d6ada789919b2118577f08546a6f828322e74e4c163569fa065a933c09ec5ad83
7
+ data.tar.gz: a8960d0a92e76922465837ea21ca05fa6d7faeecb0b1d191e98cde8376a47a18aed6075739cceab923b2aae74b7b14d149dfcc3229506cdc53c9dd9da947833b
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 etunnel authors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,47 @@
1
+ # etunnel for Ruby
2
+
3
+ EventMachine/faye-websocket based.
4
+
5
+ Zero registration: every SDK client speaks the same tiny wire protocol
6
+ ([PROTOCOL.md](../../PROTOCOL.md)) against any etunnel server
7
+ (default `wss://t.ggcode.uk/tunnel`).
8
+
9
+ ## Install
10
+
11
+ ```
12
+ gem install etunnel
13
+ ```
14
+
15
+ Requires Ruby >= 2.7 (pulls in faye-websocket).
16
+
17
+ ## CLI
18
+
19
+ ```
20
+ etunnel 3000
21
+ ```
22
+
23
+ Prints `TUNNEL_URL: https://<subdomain>.t.ggcode.uk` — that URL now routes to
24
+ your local service. Ctrl-C closes the tunnel.
25
+
26
+ ## Embed
27
+
28
+ ```ruby
29
+ require 'etunnel'
30
+ t = Etunnel.connect('wss://t.ggcode.uk/tunnel', 3000)
31
+ puts t.url
32
+ ```
33
+
34
+ ## Environment variables
35
+
36
+ | Variable | Meaning | Default |
37
+ |---|---|---|
38
+ | `ETUNNEL_SERVER` | tunnel server WebSocket URL | `wss://t.ggcode.uk/tunnel` |
39
+ | `ETUNNEL_LOCAL` | local port or `host:port` (or 1st CLI arg) | `3000` |
40
+ | `ETUNNEL_TOKEN` | paid-plan API key (optional) | - |
41
+ | `ETUNNEL_SUBDOMAIN` | custom subdomain (paid plans) | random |
42
+
43
+ ## License
44
+
45
+ MIT — see [LICENSE](../../LICENSE). Buffer-based protocol client: request and
46
+ response bodies are buffered per stream (fine for webhooks/dashboards/demos;
47
+ use the [Go SDK](../../sdk) for streaming and WebSocket pass-through).
data/bin/etunnel ADDED
@@ -0,0 +1,6 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require "etunnel"
5
+
6
+ Etunnel.main
data/lib/etunnel.rb ADDED
@@ -0,0 +1,174 @@
1
+ # frozen_string_literal: true
2
+
3
+ # etunnel SDK for Ruby (requires gem install faye-websocket).
4
+ # Buffer-based protocol client (bodies buffered per stream; no WS upgrades).
5
+ #
6
+ # gem install faye-websocket
7
+ # ruby etunnel.rb 3000
8
+ #
9
+ # Embed:
10
+ # require_relative 'etunnel'
11
+ # t = Etunnel.connect('wss://t.ggcode.uk/tunnel', 3000)
12
+ # puts t.url
13
+ require 'json'
14
+ require 'socket'
15
+ require 'net/http'
16
+ begin
17
+ require 'eventmachine'
18
+ require 'faye/websocket'
19
+ rescue LoadError
20
+ warn 'etunnel: gem install faye-websocket'
21
+ exit 1
22
+ end
23
+
24
+ # faye-websocket needs a running EventMachine reactor.
25
+ unless EventMachine.reactor_running?
26
+ Thread.new { EventMachine.run }
27
+ sleep 0.05 until EventMachine.reactor_running?
28
+ end
29
+
30
+ MAXP = 32_768
31
+ HOP = %w[connection transfer-encoding keep-alive upgrade proxy-connection te trailer content-length host].freeze
32
+
33
+ module Etunnel
34
+ def self.frame(kind, sid, payload)
35
+ [kind].pack('C') + [sid].pack('N') + [payload.bytesize].pack('N') + payload
36
+ end
37
+
38
+ def self.connect(server, local, token: nil, subdomain: nil)
39
+ local = "127.0.0.1:#{local}" unless local.to_s.include?(':')
40
+ ws = Faye::WebSocket::Client.new(server, ['etunnel-v1'])
41
+
42
+ queue = Queue.new
43
+ ws.on :message do |event|
44
+ data = event.data
45
+ if data.is_a?(String) && data.encoding == Encoding::UTF_8 && data.start_with?('{')
46
+ queue << [:text, data]
47
+ else
48
+ # binary frames arrive as ASCII-8BIT Strings (or Arrays)
49
+ bin = data.is_a?(Array) ? data.pack('C*') : data
50
+ queue << [:bin, bin]
51
+ end
52
+ end
53
+ ws.on :close do
54
+ queue << [:close, nil]
55
+ end
56
+
57
+ hello = { type: 'hello', proto: 1, client: 'etunnel-ruby/1.0' }
58
+ hello[:token] = token if token
59
+ hello[:subdomain] = subdomain if subdomain
60
+ ws.send(JSON.generate(hello))
61
+
62
+ url = nil
63
+ loop do
64
+ kind, payload = queue.pop
65
+ raise 'etunnel: connection closed' if kind == :close
66
+
67
+ next unless kind == :text
68
+
69
+ m = JSON.parse(payload)
70
+ if m['type'] == 'welcome'
71
+ url = m['url']
72
+ break
73
+ elsif m['type'] == 'error'
74
+ raise "etunnel: #{m['code']}: #{m['message']}"
75
+ end
76
+ end
77
+
78
+ streams = {}
79
+ closed = false
80
+
81
+ Thread.new do
82
+ loop do
83
+ kind, payload = queue.pop
84
+ break if kind == :close
85
+
86
+ if kind == :text
87
+ m = JSON.parse(payload)
88
+ case m['type']
89
+ when 'req'
90
+ streams[m['streamId']] = { req: m, body: +''.b }
91
+ when 'ping'
92
+ ws.send(JSON.generate({ type: 'pong', ts: m['ts'] || 0 }))
93
+ when 'bye'
94
+ closed = true
95
+ ws.close
96
+ break
97
+ end
98
+ else
99
+ next if payload.bytesize < 9
100
+
101
+ kind_b, sid, n = payload.unpack('C N N')
102
+ data = payload.byteslice(9, n)
103
+ st = streams[sid]
104
+ next unless st
105
+
106
+ case kind_b
107
+ when 0x01 then st[:body] << data
108
+ when 0x02
109
+ streams.delete(sid)
110
+ serve(ws, st[:req], st[:body], sid, local)
111
+ when 0x03 then streams.delete(sid)
112
+ end
113
+ end
114
+ end
115
+ end
116
+
117
+ tunnel = Object.new
118
+ tunnel.define_singleton_method(:url) { url }
119
+ tunnel.define_singleton_method(:close) do
120
+ ws.send(JSON.generate({ type: 'bye', reason: 'client closed' }))
121
+ ws.close
122
+ end
123
+ tunnel
124
+ end
125
+
126
+ def self.serve(ws, req, body, sid, local)
127
+ host, port = local.split(':')
128
+ method = req['method'] || 'GET'
129
+ path = req['path'] || '/'
130
+ headers = (req['headers'] || {}).reject { |k, _| HOP.include?(k.downcase) }
131
+
132
+ out = Thread.new do
133
+ begin
134
+ http = Net::HTTP.new(host, port.to_i)
135
+ http.open_timeout = 10
136
+ klass = Net::HTTP.const_get(method.capitalize)
137
+ r = klass.new(path, headers.to_h { |k, v| [k, Array(v).first] })
138
+ r.body = body unless method == 'GET' || method == 'HEAD'
139
+ resp = http.request(r)
140
+ [resp.code.to_i, resp.each_header.reject { |k, _| HOP.include?(k) }.to_h, resp.body || '']
141
+ rescue StandardError => e
142
+ [502, { 'Content-Type' => 'text/plain' }, "etunnel: local service error: #{e}\n"]
143
+ end
144
+ end.value
145
+
146
+ status, headers, payload = out
147
+ ws.send(JSON.generate({ type: 'res', streamId: sid, status: status,
148
+ headers: headers.to_h { |k, v| [k, [v]] } }))
149
+ payload = payload.b
150
+ i = 0
151
+ while i < payload.bytesize
152
+ ws.send(frame(0x01, sid, payload.byteslice(i, MAXP)))
153
+ i += MAXP
154
+ end
155
+ ws.send(frame(0x02, sid, ''))
156
+ end
157
+ end
158
+
159
+ def Etunnel.main(argv = ARGV)
160
+ server = ENV['ETUNNEL_SERVER'] || 'wss://t.ggcode.uk/tunnel'
161
+ local = argv[0] || ENV['ETUNNEL_LOCAL'] || '3000'
162
+ begin
163
+ t = Etunnel.connect(server, local,
164
+ token: ENV['ETUNNEL_TOKEN'], subdomain: ENV['ETUNNEL_SUBDOMAIN'])
165
+ rescue StandardError => e
166
+ warn "etunnel: #{e}"
167
+ exit 1
168
+ end
169
+ $stdout.sync = true
170
+ puts "TUNNEL_URL: #{t.url}"
171
+ trap(:INT) { t.close; exit 0 }
172
+ sleep 1 while true
173
+ end
174
+ Etunnel.main if $PROGRAM_NAME == __FILE__
metadata ADDED
@@ -0,0 +1,60 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: etunnel
3
+ version: !ruby/object:Gem::Version
4
+ version: 1.0.0
5
+ platform: ruby
6
+ authors:
7
+ - etunnel authors
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: faye-websocket
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - "~>"
17
+ - !ruby/object:Gem::Version
18
+ version: '0.11'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - "~>"
24
+ - !ruby/object:Gem::Version
25
+ version: '0.11'
26
+ description: 'Expose a local service through an etunnel reverse-tunnel server with
27
+ one line of code. '
28
+ executables:
29
+ - etunnel
30
+ extensions: []
31
+ extra_rdoc_files: []
32
+ files:
33
+ - LICENSE
34
+ - README.md
35
+ - bin/etunnel
36
+ - lib/etunnel.rb
37
+ homepage: https://github.com/ggcode/etunnel
38
+ licenses:
39
+ - MIT
40
+ metadata:
41
+ source_code_uri: https://github.com/ggcode/etunnel
42
+ rdoc_options: []
43
+ require_paths:
44
+ - lib
45
+ required_ruby_version: !ruby/object:Gem::Requirement
46
+ requirements:
47
+ - - ">="
48
+ - !ruby/object:Gem::Version
49
+ version: '2.7'
50
+ required_rubygems_version: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - ">="
53
+ - !ruby/object:Gem::Version
54
+ version: '0'
55
+ requirements: []
56
+ rubygems_version: 4.0.23
57
+ specification_version: 4
58
+ summary: Expose a local service through an etunnel reverse-tunnel server with one
59
+ line of code
60
+ test_files: []