durable_streams-rails 0.5.2 → 0.6.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 79f28df832bb82db8fe2c8bf0204cf5c6ffededd6755c153d2a00aba4ec7f611
4
- data.tar.gz: 698ea2440a5422124be37cb82ee2f988ad2e34dd008c702943f30d60797b0510
3
+ metadata.gz: 7536abdf74ca0e1333cc080be34b9007965a698c79c8f6275d5062a20e1cba9a
4
+ data.tar.gz: 4b6a41ec2819f411f2f8b4fe7da3682fa93aaf31553d056d688c8456ce7005ed
5
5
  SHA512:
6
- metadata.gz: 407c2bcb96115a2b19207a3a10d99992b09daa273a39c935ce87adde911df3bc38c7be8b5e5ad409d6313c366ad74607ad9abe3a32eee62a6acfb4b95ce5afd3
7
- data.tar.gz: 4c91ddfb552668d981f16c355876daa08c616ed42988f4eea157f1848af5b386eb9a93ace96def84a25c7abc9582e3e7d04b24e053b5146e2d15498ec5b95d5a
6
+ metadata.gz: 5f63473d9d5239b786233367f1d47e404646f57c89baf78854ae1bf84cafef0ea7e0e678e7bfa423b1259d8642dd7523096eee893245c2a286489a9484cffed9
7
+ data.tar.gz: 5740d145eba781c339e451fa6d15767064d5140e852ee61be435d618c9f6ff5dc71dbc6876f3f15ee44288243962893158659627baacbbf3f1173491fe40748a
data/README.md CHANGED
@@ -83,6 +83,88 @@ production:
83
83
  data_dir: /var/data/durable-streams
84
84
  ```
85
85
 
86
+ ### Multi-server with internal listener
87
+
88
+ In production, you typically want browsers to reach the stream server through a CDN
89
+ (`client_url`), but Rails broadcasts should go directly over the private network
90
+ (`server_url`). The `internal` option adds a plain HTTP listener for server-to-server
91
+ traffic:
92
+
93
+ ```yaml
94
+ production:
95
+ domain: streams.example.com
96
+ tls:
97
+ cert: /etc/caddy/certs/cert.pem
98
+ key: /etc/caddy/certs/key.pem
99
+ internal:
100
+ port: 4437
101
+ bind: 10.0.0.5 # Only listen on the private interface.
102
+ allowed_ips: # Caddy remote_ip matcher — 403 for everyone else.
103
+ - 10.0.0.4
104
+ auth:
105
+ url: https://10.0.0.4 # Connect to Rails over private VNET.
106
+ host: app.example.com # Host header + TLS SNI for the Kamal proxy.
107
+ path: /durable_streams/auth/verify
108
+ copy_headers:
109
+ - Cookie
110
+ - Authorization
111
+ storage:
112
+ data_dir: /data/streams
113
+ ```
114
+
115
+ When `auth.host` is set and the URL is HTTPS, the generated Caddyfile adds
116
+ `header_up Host`, `tls_server_name`, and `tls_insecure_skip_verify` to the auth
117
+ reverse proxy — same pattern as the internal listener's loopback connection. Use this
118
+ when the auth URL is an internal IP and the Kamal proxy routes by Host header.
119
+
120
+ #### Request flow
121
+
122
+ A server-to-server write (e.g., Rails broadcasting a message) follows this path:
123
+
124
+ ```
125
+ Rails (10.0.0.4)
126
+
127
+ │ PUT http://10.0.0.5:4437/v1/streams/{name}
128
+ │ (plain HTTP over private VNET — no TLS, no CDN)
129
+
130
+
131
+ :4437 listener (Caddy on 10.0.0.5)
132
+
133
+ │ 1. remote_ip check: is it 10.0.0.4? → proceed (otherwise 403)
134
+
135
+ │ 2. reverse_proxy to https://127.0.0.1:443
136
+ │ Caddy connects to itself over loopback TLS:
137
+
138
+ │ header_up Host streams.example.com
139
+ │ → Host header so :443 matches the right server block
140
+
141
+ │ tls_server_name streams.example.com
142
+ │ → SNI in the TLS ClientHello so Caddy selects the right cert
143
+
144
+ │ tls_insecure_skip_verify
145
+ │ → Skip cert verification (origin certs aren't in the system
146
+ │ trust store; safe on loopback — nothing to MITM)
147
+
148
+
149
+ streams.example.com :443 listener (same Caddy process)
150
+
151
+ │ reverse_proxy → app.example.com (validates Bearer token) - this is "forward_auth" expanded
152
+ │ durable_streams → writes to bbolt store
153
+
154
+
155
+ Response flows back up the chain to Rails
156
+ ```
157
+
158
+ Client reads (browsers) go directly to `streams.example.com:443` through the CDN,
159
+ bypassing the internal listener entirely.
160
+
161
+ #### Why reverse_proxy instead of a second `durable_streams`?
162
+
163
+ The stream storage engine (bbolt) is single-writer — two handler instances can't open
164
+ the same database file. The second one deadlocks waiting for the write lock. The
165
+ internal listener avoids this by proxying to the main listener over loopback, so only
166
+ one `durable_streams` handler exists process-wide.
167
+
86
168
  Power users can bypass the YAML entirely:
87
169
 
88
170
  ```bash
@@ -36,7 +36,15 @@ module DurableStreams
36
36
  <%- end -%>
37
37
  @allowed remote_ip <%= internal_allowed_ips.join(" ") %>
38
38
  handle @allowed {
39
- <%= route_block(indent: 2) %>
39
+ reverse_proxy <%= upstream_address %> {
40
+ <%- if upstream_tls? -%>
41
+ header_up Host <%= domain %>
42
+ transport http {
43
+ tls_server_name <%= domain %>
44
+ tls_insecure_skip_verify
45
+ }
46
+ <%- end -%>
47
+ }
40
48
  }
41
49
  respond 403
42
50
  }
@@ -110,9 +118,27 @@ module DurableStreams
110
118
  t = "\t" * indent
111
119
  lines = []
112
120
  lines << "#{t}route #{route_path} {"
113
- lines << "#{t}\tforward_auth #{auth["url"]} {"
114
- lines << "#{t}\t\turi #{auth["path"]}"
115
- copy_headers.each { |h| lines << "#{t}\t\tcopy_headers #{h}" }
121
+ lines << "#{t}\t# Forward auth — verify request with Rails before reaching durable_streams"
122
+ lines << "#{t}\treverse_proxy #{auth["url"]} {"
123
+ lines << "#{t}\t\tmethod GET"
124
+ lines << "#{t}\t\trewrite #{auth["path"]}"
125
+ lines << "#{t}\t\theader_up Host #{auth_host}" if auth_host
126
+ lines << "#{t}\t\theader_up X-Forwarded-Method {method}"
127
+ lines << "#{t}\t\theader_up X-Forwarded-Uri {uri}"
128
+ if auth_tls_transport?
129
+ lines << "#{t}\t\ttransport http {"
130
+ lines << "#{t}\t\t\ttls_server_name #{auth_host}"
131
+ lines << "#{t}\t\t\ttls_insecure_skip_verify"
132
+ lines << "#{t}\t\t}"
133
+ end
134
+ lines << ""
135
+ lines << "#{t}\t\t@ok status 200"
136
+ lines << "#{t}\t\thandle_response @ok {"
137
+ copy_headers.each { |h| lines << "#{t}\t\t\trequest_header #{h} {rp.header.#{h}}" }
138
+ lines << "#{t}\t\t}"
139
+ lines << "#{t}\t\thandle_response {"
140
+ lines << "#{t}\t\t\trespond 401"
141
+ lines << "#{t}\t\t}"
116
142
  lines << "#{t}\t}"
117
143
 
118
144
  directives = storage_directives
@@ -140,6 +166,14 @@ module DurableStreams
140
166
  auth.fetch("copy_headers", [ "Cookie" ])
141
167
  end
142
168
 
169
+ def auth_host
170
+ auth["host"]
171
+ end
172
+
173
+ def auth_tls_transport?
174
+ auth_host && auth["url"].start_with?("https://")
175
+ end
176
+
143
177
  def storage_directives
144
178
  storage = @config.fetch("storage", {})
145
179
  STORAGE_DIRECTIVES.filter_map { |key| "#{key} #{storage[key]}" if storage[key] }
@@ -152,6 +186,22 @@ module DurableStreams
152
186
  def internal_allowed_ips
153
187
  internal&.dig("allowed_ips")
154
188
  end
189
+
190
+ def upstream_address
191
+ if upstream_tls?
192
+ "https://127.0.0.1:443"
193
+ else
194
+ "http://127.0.0.1:#{@config.fetch("port", 4437)}"
195
+ end
196
+ end
197
+
198
+ def upstream_tls?
199
+ tls_config || auto_https?
200
+ end
201
+
202
+ def domain
203
+ @config["domain"]
204
+ end
155
205
  end
156
206
  end
157
207
  end
@@ -1,5 +1,5 @@
1
1
  module DurableStreams
2
2
  module Rails
3
- VERSION = "0.5.2"
3
+ VERSION = "0.6.0"
4
4
  end
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: durable_streams-rails
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.5.2
4
+ version: 0.6.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - tokimonki