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 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,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Outpunch
4
+ module Rack
5
+ VERSION = "0.1.0"
6
+ end
7
+ 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: []