outpunch-rack 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/lib/outpunch/rack/connection.rb +104 -0
- data/lib/outpunch/rack/hooks.rb +35 -0
- data/lib/outpunch/rack/middleware.rb +32 -0
- data/lib/outpunch/rack/server.rb +122 -0
- data/lib/outpunch/rack/version.rb +7 -0
- data/lib/outpunch/rack.rb +62 -0
- metadata +132 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 9617779c67e50ea7f710e3145466edecd3c3b704c2a55a4ea756bf17296de711
|
|
4
|
+
data.tar.gz: 6a061875774d092c30fa955141a8f86f2e8a47d1513424a543177176cd13dbd0
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 806fc1f33092a13861937c08727199e4373c6089ab8db587415667dcbdf6ede285129d1e6e2abddb6af83375358641a05a9380df284138eb6cabe891ab7db9f9
|
|
7
|
+
data.tar.gz: b20af1219aa6bb14c5fdf570741124b2cd4bccd93590caeefc4bd51cf1722e770bd323f0bd6c7ffc73ef53e8d66343575d4e0cff36cadf91c08320e76d02b799
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "websocket/driver"
|
|
4
|
+
require "json"
|
|
5
|
+
|
|
6
|
+
module Outpunch
|
|
7
|
+
module Rack
|
|
8
|
+
class Connection
|
|
9
|
+
def initialize(server)
|
|
10
|
+
@server = server
|
|
11
|
+
@service_name = nil
|
|
12
|
+
@write_mutex = Mutex.new
|
|
13
|
+
@io = nil
|
|
14
|
+
@driver = nil
|
|
15
|
+
@env = nil
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# Hijack the socket and run the WebSocket loop. Blocks until disconnected.
|
|
19
|
+
def run(env)
|
|
20
|
+
@env = env
|
|
21
|
+
@io = env.fetch("rack.hijack_io")
|
|
22
|
+
@driver = WebSocket::Driver.rack(self)
|
|
23
|
+
@driver.on(:message) { |event| on_message(event.data) }
|
|
24
|
+
@driver.on(:close) { on_close }
|
|
25
|
+
@driver.on(:error) { |e| log_error(e.message) }
|
|
26
|
+
@driver.start
|
|
27
|
+
|
|
28
|
+
while (chunk = @io.readpartial(4096))
|
|
29
|
+
@driver.parse(chunk)
|
|
30
|
+
end
|
|
31
|
+
rescue EOFError, IOError, Errno::ECONNRESET
|
|
32
|
+
# normal close
|
|
33
|
+
ensure
|
|
34
|
+
on_close
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Called by Server#handle_request to send a request over the wire.
|
|
38
|
+
def send_request(payload)
|
|
39
|
+
msg = payload
|
|
40
|
+
.merge(type: "request")
|
|
41
|
+
.transform_keys(&:to_s)
|
|
42
|
+
transmit(msg)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Called by websocket-driver to write raw bytes to the socket.
|
|
46
|
+
def write(data)
|
|
47
|
+
@io.write(data)
|
|
48
|
+
rescue IOError, Errno::EPIPE, Errno::ECONNRESET
|
|
49
|
+
nil
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# websocket-driver requires #env and #url to build the handshake.
|
|
53
|
+
def env
|
|
54
|
+
@env || {}
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def url
|
|
58
|
+
scheme = env["rack.url_scheme"] == "https" ? "wss" : "ws"
|
|
59
|
+
host = env["HTTP_HOST"] || env["SERVER_NAME"] || "localhost"
|
|
60
|
+
"#{scheme}://#{host}#{env["REQUEST_URI"]}"
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
private
|
|
64
|
+
|
|
65
|
+
def on_message(raw)
|
|
66
|
+
data = JSON.parse(raw)
|
|
67
|
+
case data["type"]
|
|
68
|
+
when "auth" then handle_auth(data)
|
|
69
|
+
when "response" then @server.complete_request(data["request_id"], data)
|
|
70
|
+
else
|
|
71
|
+
# unknown message type — ignore
|
|
72
|
+
end
|
|
73
|
+
rescue JSON::ParserError
|
|
74
|
+
nil
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def handle_auth(data)
|
|
78
|
+
if @server.valid_token?(data["token"]) && !data["service"].to_s.empty?
|
|
79
|
+
@service_name = data["service"]
|
|
80
|
+
@server.register_connection(@service_name, self)
|
|
81
|
+
transmit(type: "auth_ok")
|
|
82
|
+
else
|
|
83
|
+
transmit(type: "auth_error", message: "invalid token")
|
|
84
|
+
@driver.close
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def on_close
|
|
89
|
+
return unless @service_name
|
|
90
|
+
|
|
91
|
+
@server.unregister_connection(@service_name)
|
|
92
|
+
@service_name = nil
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def transmit(data)
|
|
96
|
+
@write_mutex.synchronize { @driver.text(JSON.generate(data)) }
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def log_error(message)
|
|
100
|
+
warn "[Outpunch::Rack] WebSocket error: #{message}"
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
end
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Outpunch
|
|
4
|
+
module Rack
|
|
5
|
+
module Hooks
|
|
6
|
+
HOOKS = {}
|
|
7
|
+
|
|
8
|
+
def self.register(service_name, path_pattern, handler_class)
|
|
9
|
+
HOOKS[service_name] ||= []
|
|
10
|
+
HOOKS[service_name] << { pattern: path_pattern, handler_class: handler_class }
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def self.before_proxy(service_name:, path:, payload:, request:)
|
|
14
|
+
find_handlers(service_name, path).each do |h|
|
|
15
|
+
h[:handler_class].new.before_proxy(path: path, payload: payload, request: request)
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def self.after_proxy(service_name:, path:, payload:, result:, request:)
|
|
20
|
+
find_handlers(service_name, path).each do |h|
|
|
21
|
+
h[:handler_class].new.after_proxy(path: path, payload: payload, result: result, request: request)
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def self.clear!
|
|
26
|
+
HOOKS.clear
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def self.find_handlers(service_name, path)
|
|
30
|
+
(HOOKS[service_name] || []).select { |h| path.match?(h[:pattern]) }
|
|
31
|
+
end
|
|
32
|
+
private_class_method :find_handlers
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Outpunch
|
|
4
|
+
module Rack
|
|
5
|
+
class Middleware
|
|
6
|
+
WS_PATH = "/ws"
|
|
7
|
+
|
|
8
|
+
def initialize(app, server:)
|
|
9
|
+
@app = app
|
|
10
|
+
@server = server
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def call(env)
|
|
14
|
+
if env["PATH_INFO"] == WS_PATH && websocket_upgrade?(env)
|
|
15
|
+
env["rack.hijack"].call
|
|
16
|
+
conn = @server.create_connection
|
|
17
|
+
Thread.new { conn.run(env) }
|
|
18
|
+
[-1, {}, []]
|
|
19
|
+
else
|
|
20
|
+
@app.call(env)
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
private
|
|
25
|
+
|
|
26
|
+
def websocket_upgrade?(env)
|
|
27
|
+
env["HTTP_UPGRADE"]&.downcase == "websocket" &&
|
|
28
|
+
env["HTTP_CONNECTION"]&.downcase&.include?("upgrade")
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "concurrent"
|
|
4
|
+
require "securerandom"
|
|
5
|
+
require "timeout"
|
|
6
|
+
require "openssl"
|
|
7
|
+
require "base64"
|
|
8
|
+
require "json"
|
|
9
|
+
|
|
10
|
+
module Outpunch
|
|
11
|
+
module Rack
|
|
12
|
+
class Server
|
|
13
|
+
HOP_BY_HOP = %w[HOST CONNECTION UPGRADE].freeze
|
|
14
|
+
|
|
15
|
+
def initialize(secret:, timeout: 25)
|
|
16
|
+
@secret = secret
|
|
17
|
+
@timeout = timeout
|
|
18
|
+
@connections = Concurrent::Map.new
|
|
19
|
+
@pending_requests = Concurrent::Map.new
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def create_connection
|
|
23
|
+
Connection.new(self)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Register a service connection. Called by Connection after successful auth.
|
|
27
|
+
def register_connection(service_name, conn)
|
|
28
|
+
@connections[service_name] = conn
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Remove a service connection. Called by Connection on close.
|
|
32
|
+
def unregister_connection(service_name)
|
|
33
|
+
@connections.delete(service_name)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def connected?(service_name)
|
|
37
|
+
@connections.key?(service_name)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Send a request through the tunnel and block until response or timeout.
|
|
41
|
+
def handle_request(service:, method:, path:, query:, headers:, body:)
|
|
42
|
+
conn = @connections[service]
|
|
43
|
+
raise "Service '#{service}' not connected" unless conn
|
|
44
|
+
|
|
45
|
+
request_id = SecureRandom.uuid
|
|
46
|
+
queue = Queue.new
|
|
47
|
+
@pending_requests[request_id] = queue
|
|
48
|
+
|
|
49
|
+
conn.send_request(
|
|
50
|
+
request_id: request_id,
|
|
51
|
+
service: service,
|
|
52
|
+
method: method,
|
|
53
|
+
path: path,
|
|
54
|
+
query: query,
|
|
55
|
+
headers: headers,
|
|
56
|
+
body: body
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
begin
|
|
60
|
+
Timeout.timeout(@timeout) { queue.pop }
|
|
61
|
+
ensure
|
|
62
|
+
@pending_requests.delete(request_id)
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Deliver a response to a waiting handle_request call.
|
|
67
|
+
def complete_request(request_id, response_data)
|
|
68
|
+
@pending_requests[request_id]&.push(response_data)
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# Constant-time token validation.
|
|
72
|
+
def valid_token?(token)
|
|
73
|
+
return false if @secret.nil? || @secret.empty? || token.nil? || token.empty?
|
|
74
|
+
return false if token.bytesize != @secret.bytesize
|
|
75
|
+
|
|
76
|
+
OpenSSL.fixed_length_secure_compare(token, @secret)
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# Build a success result hash from raw tunnel response data.
|
|
80
|
+
def success_response(data)
|
|
81
|
+
body = data["body"]
|
|
82
|
+
headers = (data["headers"] || {}).transform_keys(&:downcase)
|
|
83
|
+
|
|
84
|
+
if data["body_encoding"] == "base64" && !body.nil? && !body.empty?
|
|
85
|
+
body = Base64.decode64(body)
|
|
86
|
+
body.force_encoding("BINARY")
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
{
|
|
90
|
+
status: data["status"] || 200,
|
|
91
|
+
body: body,
|
|
92
|
+
headers: headers,
|
|
93
|
+
body_encoding: data["body_encoding"]
|
|
94
|
+
}
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# Build an error result hash.
|
|
98
|
+
def error_response(status, message)
|
|
99
|
+
{
|
|
100
|
+
status: status,
|
|
101
|
+
body: JSON.generate(error: message),
|
|
102
|
+
headers: { "Content-Type" => "application/json" }
|
|
103
|
+
}
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# Extract and normalize HTTP headers from a Rack env hash.
|
|
107
|
+
def extract_proxy_headers(headers)
|
|
108
|
+
headers
|
|
109
|
+
.to_h
|
|
110
|
+
.select { |k, _| k.to_s.start_with?("HTTP_") }
|
|
111
|
+
.transform_keys { |k| k.to_s.sub("HTTP_", "").tr("_", "-") }
|
|
112
|
+
.reject { |k, _| HOP_BY_HOP.include?(k) }
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# For test isolation — resets all state.
|
|
116
|
+
def reset!
|
|
117
|
+
@connections.clear
|
|
118
|
+
@pending_requests.clear
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
end
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "rack/version"
|
|
4
|
+
require_relative "rack/hooks"
|
|
5
|
+
require_relative "rack/server"
|
|
6
|
+
require_relative "rack/connection"
|
|
7
|
+
require_relative "rack/middleware"
|
|
8
|
+
|
|
9
|
+
module Outpunch
|
|
10
|
+
module Rack
|
|
11
|
+
class Configuration
|
|
12
|
+
attr_accessor :secret, :timeout, :base_controller, :authorize_service, :hooks, :route_prefix
|
|
13
|
+
|
|
14
|
+
def initialize
|
|
15
|
+
@timeout = 25
|
|
16
|
+
@base_controller = "ActionController::API"
|
|
17
|
+
@hooks = Outpunch::Rack::Hooks
|
|
18
|
+
@route_prefix = "/outpunch"
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
class << self
|
|
23
|
+
def configure
|
|
24
|
+
yield configuration
|
|
25
|
+
@server = nil # reset server so it picks up new config
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def configuration
|
|
29
|
+
@configuration ||= Configuration.new
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def server
|
|
33
|
+
@server ||= Server.new(
|
|
34
|
+
secret: configuration.secret,
|
|
35
|
+
timeout: configuration.timeout
|
|
36
|
+
)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Delegates — convenience access for application code.
|
|
40
|
+
|
|
41
|
+
def connected?(service_name)
|
|
42
|
+
server.connected?(service_name)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def handle_request(**kwargs)
|
|
46
|
+
server.handle_request(**kwargs)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def success_response(data)
|
|
50
|
+
server.success_response(data)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def error_response(status, message)
|
|
54
|
+
server.error_response(status, message)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def extract_proxy_headers(headers)
|
|
58
|
+
server.extract_proxy_headers(headers)
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
metadata
ADDED
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: outpunch-rack
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- TechyCorp
|
|
8
|
+
autorequire:
|
|
9
|
+
bindir: bin
|
|
10
|
+
cert_chain: []
|
|
11
|
+
date: 2026-04-04 00:00:00.000000000 Z
|
|
12
|
+
dependencies:
|
|
13
|
+
- !ruby/object:Gem::Dependency
|
|
14
|
+
name: websocket-driver
|
|
15
|
+
requirement: !ruby/object:Gem::Requirement
|
|
16
|
+
requirements:
|
|
17
|
+
- - ">="
|
|
18
|
+
- !ruby/object:Gem::Version
|
|
19
|
+
version: '0.7'
|
|
20
|
+
type: :runtime
|
|
21
|
+
prerelease: false
|
|
22
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
23
|
+
requirements:
|
|
24
|
+
- - ">="
|
|
25
|
+
- !ruby/object:Gem::Version
|
|
26
|
+
version: '0.7'
|
|
27
|
+
- !ruby/object:Gem::Dependency
|
|
28
|
+
name: concurrent-ruby
|
|
29
|
+
requirement: !ruby/object:Gem::Requirement
|
|
30
|
+
requirements:
|
|
31
|
+
- - ">="
|
|
32
|
+
- !ruby/object:Gem::Version
|
|
33
|
+
version: '1.0'
|
|
34
|
+
type: :runtime
|
|
35
|
+
prerelease: false
|
|
36
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
37
|
+
requirements:
|
|
38
|
+
- - ">="
|
|
39
|
+
- !ruby/object:Gem::Version
|
|
40
|
+
version: '1.0'
|
|
41
|
+
- !ruby/object:Gem::Dependency
|
|
42
|
+
name: rspec
|
|
43
|
+
requirement: !ruby/object:Gem::Requirement
|
|
44
|
+
requirements:
|
|
45
|
+
- - "~>"
|
|
46
|
+
- !ruby/object:Gem::Version
|
|
47
|
+
version: '3.12'
|
|
48
|
+
type: :development
|
|
49
|
+
prerelease: false
|
|
50
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
51
|
+
requirements:
|
|
52
|
+
- - "~>"
|
|
53
|
+
- !ruby/object:Gem::Version
|
|
54
|
+
version: '3.12'
|
|
55
|
+
- !ruby/object:Gem::Dependency
|
|
56
|
+
name: rack
|
|
57
|
+
requirement: !ruby/object:Gem::Requirement
|
|
58
|
+
requirements:
|
|
59
|
+
- - "~>"
|
|
60
|
+
- !ruby/object:Gem::Version
|
|
61
|
+
version: '3.0'
|
|
62
|
+
type: :development
|
|
63
|
+
prerelease: false
|
|
64
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
65
|
+
requirements:
|
|
66
|
+
- - "~>"
|
|
67
|
+
- !ruby/object:Gem::Version
|
|
68
|
+
version: '3.0'
|
|
69
|
+
- !ruby/object:Gem::Dependency
|
|
70
|
+
name: puma
|
|
71
|
+
requirement: !ruby/object:Gem::Requirement
|
|
72
|
+
requirements:
|
|
73
|
+
- - "~>"
|
|
74
|
+
- !ruby/object:Gem::Version
|
|
75
|
+
version: '6.0'
|
|
76
|
+
type: :development
|
|
77
|
+
prerelease: false
|
|
78
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
79
|
+
requirements:
|
|
80
|
+
- - "~>"
|
|
81
|
+
- !ruby/object:Gem::Version
|
|
82
|
+
version: '6.0'
|
|
83
|
+
- !ruby/object:Gem::Dependency
|
|
84
|
+
name: websocket-client-simple
|
|
85
|
+
requirement: !ruby/object:Gem::Requirement
|
|
86
|
+
requirements:
|
|
87
|
+
- - "~>"
|
|
88
|
+
- !ruby/object:Gem::Version
|
|
89
|
+
version: '0.6'
|
|
90
|
+
type: :development
|
|
91
|
+
prerelease: false
|
|
92
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
93
|
+
requirements:
|
|
94
|
+
- - "~>"
|
|
95
|
+
- !ruby/object:Gem::Version
|
|
96
|
+
version: '0.6'
|
|
97
|
+
description:
|
|
98
|
+
email:
|
|
99
|
+
executables: []
|
|
100
|
+
extensions: []
|
|
101
|
+
extra_rdoc_files: []
|
|
102
|
+
files:
|
|
103
|
+
- lib/outpunch/rack.rb
|
|
104
|
+
- lib/outpunch/rack/connection.rb
|
|
105
|
+
- lib/outpunch/rack/hooks.rb
|
|
106
|
+
- lib/outpunch/rack/middleware.rb
|
|
107
|
+
- lib/outpunch/rack/server.rb
|
|
108
|
+
- lib/outpunch/rack/version.rb
|
|
109
|
+
homepage:
|
|
110
|
+
licenses:
|
|
111
|
+
- MIT
|
|
112
|
+
metadata: {}
|
|
113
|
+
post_install_message:
|
|
114
|
+
rdoc_options: []
|
|
115
|
+
require_paths:
|
|
116
|
+
- lib
|
|
117
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
118
|
+
requirements:
|
|
119
|
+
- - ">="
|
|
120
|
+
- !ruby/object:Gem::Version
|
|
121
|
+
version: '3.1'
|
|
122
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
123
|
+
requirements:
|
|
124
|
+
- - ">="
|
|
125
|
+
- !ruby/object:Gem::Version
|
|
126
|
+
version: '0'
|
|
127
|
+
requirements: []
|
|
128
|
+
rubygems_version: 3.5.22
|
|
129
|
+
signing_key:
|
|
130
|
+
specification_version: 4
|
|
131
|
+
summary: Rack adapter for the outpunch reverse WebSocket tunnel
|
|
132
|
+
test_files: []
|