monkrb 0.18.0 → 0.20.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 (55) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +166 -0
  3. data/README.md +2 -2
  4. data/lib/monk/base.rb +20 -0
  5. data/lib/monk/jobs/adapters/pg.rb +3 -1
  6. data/lib/monk/live/client/monk_live.js +2 -2
  7. data/lib/monk/live/client/protocol.js +5 -0
  8. data/lib/monk/live/policy.rb +39 -4
  9. data/lib/monk/live/session.rb +7 -0
  10. data/lib/monk/live.rb +15 -3
  11. data/lib/monk/persistence/errors.rb +32 -0
  12. data/lib/monk/persistence/pg.rb +92 -1
  13. data/lib/monk/persistence/pool.rb +513 -0
  14. data/lib/monk/persistence.rb +196 -7
  15. data/lib/monk/scaffold.rb +155 -78
  16. data/lib/monk/templates/auth/app/mailers/app_mailer.rb +22 -0
  17. data/lib/monk/templates/auth/config/auth.rb +13 -20
  18. data/lib/monk/templates/base/app/app.rb +9 -0
  19. data/lib/monk/templates/base/app/broadcasts/.keep +0 -0
  20. data/lib/monk/templates/base/app/helpers/.keep +0 -0
  21. data/lib/monk/templates/base/app/jobs/.keep +0 -0
  22. data/lib/monk/templates/base/app/mailers/.keep +0 -0
  23. data/lib/monk/templates/base/app/models/.keep +0 -0
  24. data/lib/monk/templates/base/app/presenters/.keep +0 -0
  25. data/lib/monk/templates/base/bin/websocket_server +17 -2
  26. data/lib/monk/templates/base/config/load.rb +18 -0
  27. data/lib/monk/templates/base/config.ru +3 -12
  28. data/lib/monk/templates/base/public/js/app.js +1 -1
  29. data/lib/monk/templates/jobs/{jobs → app/jobs}/send_login_link.rb +2 -1
  30. data/lib/monk/templates/jobs/bin/jobs +5 -13
  31. data/lib/monk/templates/jobs/config/jobs.rb +2 -4
  32. data/lib/monk/templates/live/{config.ru → app/app.rb} +1 -6
  33. data/lib/monk/templates/live/{views → app/views}/index.erb +2 -2
  34. data/lib/monk/templates/live/bin/websocket_server +23 -3
  35. data/lib/monk/templates/live/config/live.rb +1 -1
  36. data/lib/monk/templates/live/config/live_pg.rb +1 -1
  37. data/lib/monk/templates/mail/config/mail.rb +2 -2
  38. data/lib/monk/templates/postgres/bin/console +4 -2
  39. data/lib/monk/templates/postgres/db/migrate/.keep +0 -0
  40. data/lib/monk/version.rb +1 -1
  41. data/lib/monk/websocket/connection.rb +25 -6
  42. data/lib/monk/websocket/errors.rb +11 -0
  43. data/lib/monk/websocket/listeners.rb +47 -0
  44. data/lib/monk/websocket/pg_fanout.rb +189 -31
  45. data/lib/monk/websocket/redis_fanout.rb +125 -33
  46. data/lib/monk/websocket/registry.rb +30 -0
  47. data/lib/monk/websocket/server.rb +128 -29
  48. data/lib/monk/websocket.rb +2 -0
  49. data/lib/monk.rb +10 -0
  50. metadata +21 -9
  51. /data/lib/monk/templates/auth/{views → app/views}/mail/magic_link.erb +0 -0
  52. /data/lib/monk/templates/base/{views → app/views}/index.erb +0 -0
  53. /data/lib/monk/templates/base/{views → app/views}/layouts/app.erb +0 -0
  54. /data/lib/monk/templates/jobs/{jobs → app/jobs}/hello_job.rb +0 -0
  55. /data/lib/monk/templates/live/{views → app/views}/live/_hits.erb +0 -0
@@ -1,5 +1,7 @@
1
1
  require "redis"
2
2
  require "securerandom"
3
+ require_relative "errors"
4
+ require_relative "listeners"
3
5
 
4
6
  module Monk
5
7
  module WebSocket
@@ -9,7 +11,7 @@ module Monk
9
11
  # LISTEN/NOTIFY per docs/design/websocket.md Open Question 3).
10
12
  #
11
13
  # Wraps a Registry with the same public interface (#register,
12
- # #unregister, #count, #broadcast), so an app swaps which object it
14
+ # #unregister, #count, #broadcast, #listen!), so an app swaps which object it
13
15
  # holds and nothing else changes. #broadcast still delivers to this
14
16
  # process's local Registry directly -- same latency and reliability as
15
17
  # a plain Registry if Redis is briefly unavailable -- and additionally
@@ -36,49 +38,139 @@ module Monk
36
38
  # already shareable.
37
39
  CHANNEL_PREFIX = "monk:ws:".freeze
38
40
 
41
+ # The body of #listen!'s subscriber Ractor. A class method because
42
+ # the Ractor has no RedisFanout instance to call into. Reports :ok,
43
+ # or why it couldn't subscribe, on `ready`, then relays messages for
44
+ # the rest of the process's life.
45
+ #
46
+ # A dropped connection (a Redis restart, a failover) is resubscribed
47
+ # with backoff. Messages published while it was down are lost, so
48
+ # once subscribed again every open socket is closed
49
+ # (Listeners::MISSED_CODE), and each client reconnects and resyncs.
50
+ def self.subscribe(registry, url, own_origin, ready)
51
+ listening = false
52
+ delay = Listeners::RETRY_INITIAL
53
+ loop do
54
+ Redis.new(url: url).psubscribe("#{CHANNEL_PREFIX}*") do |on|
55
+ on.psubscribe do
56
+ delay = Listeners::RETRY_INITIAL
57
+ if listening
58
+ closed = registry.close_all(Listeners::MISSED_CODE, Listeners::MISSED_REASON)
59
+ log(:info, "subscribed again; closed #{closed} socket(s) so their pages resync")
60
+ else
61
+ listening = true
62
+ ready.send(:ok)
63
+ end
64
+ end
65
+ on.pmessage { |_pattern, channel, envelope| relay(registry, own_origin, channel, envelope) }
66
+ end
67
+ rescue StandardError => e
68
+ # Not narrower: redis-rb lets redis-client's own
69
+ # RedisClient::ConnectionError out of psubscribe, not a
70
+ # Redis::BaseConnectionError. #relay rescues its own errors, so
71
+ # what gets here is the connection.
72
+ return ready.send("#{e.class}: #{e.message}") unless listening
73
+
74
+ log(:error, "subscriber connection failed (#{e.class}: #{e.message}); retrying in #{delay}s")
75
+ sleep delay
76
+ delay = Listeners.next_delay(delay)
77
+ end
78
+ end
79
+
80
+ # One message into this process's registry. Anything that goes wrong
81
+ # here drops this message only: escaping, it would end the
82
+ # subscriber Ractor, and with it every later broadcast, silently.
83
+ #
84
+ # Redis channel names are always Strings, but Registry's own keys
85
+ # are ordinary Hash keys the app chooses -- every existing caller
86
+ # (Registry's own tests, the websocket_server scaffold's :chat
87
+ # channel) uses a Symbol. #to_sym here is what makes
88
+ # registry.broadcast(:chat, ...) land on the same Hash key
89
+ # #register(:chat, port) used -- without it this silently
90
+ # broadcasts to an unrelated "chat" String key that nothing is ever
91
+ # registered under, and #broadcast still returns true either way
92
+ # (Registry#broadcast succeeds whether or not any port is registered
93
+ # for a key).
94
+ def self.relay(registry, own_origin, channel, envelope)
95
+ sender, payload = envelope.split("\0", 2)
96
+ raise ArgumentError, "no origin separator in the message" unless payload
97
+ return if sender == own_origin
98
+
99
+ registry.broadcast(channel.delete_prefix(CHANNEL_PREFIX).to_sym, payload)
100
+ rescue StandardError => e
101
+ log(:error, "dropped a message on #{channel}: #{e.class}: #{e.message}")
102
+ end
103
+
104
+ def self.log(level, message) = Listeners.log(name, level, message)
105
+ private_class_method :relay, :log
106
+
107
+ # Closes the calling Ractor's publisher client (#publisher), if it
108
+ # opened one; the next broadcast in this Ractor reconnects.
109
+ # Server.serve runs it as each socket ends, as PgFanout's.
110
+ def self.disconnect_publisher
111
+ client = Ractor.current[:monk_redis_fanout_publisher]
112
+ return unless client
113
+
114
+ Ractor.current[:monk_redis_fanout_publisher] = nil
115
+ client.close
116
+ end
117
+
39
118
  def initialize(registry, redis_url:)
40
119
  @registry = registry
41
120
  @redis_url = redis_url.dup.freeze
42
121
  @origin = SecureRandom.uuid.freeze
43
122
 
123
+ # No live connection lives on this object -- #listen! runs its
124
+ # subscriber in a Ractor of its own, and #publisher below opens one
125
+ # client per Ractor, lazily -- so there's nothing left unshareable
126
+ # and this instance can freeze itself the same way Registry does.
127
+ # That matters for real use: an app holds this behind a module
128
+ # constant (e.g. ChatServer::REGISTRY, mirroring Registry's own
129
+ # existing usage) read from every connection's own Ractor, which
130
+ # raises Ractor::IsolationError unless the constant's value is
131
+ # shareable.
132
+ freeze
133
+ end
134
+
135
+ # Starts relaying other processes' broadcasts into this process's
136
+ # registry: a subscriber Ractor with its own Redis connection. Only
137
+ # the process that holds sockets calls this (bin/websocket_server, at
138
+ # boot, from the main Ractor) -- a publish-only process (bin/server,
139
+ # bin/jobs) never does, so it holds no subscriber connection at all.
140
+ # Returns once the psubscribe is in effect, so nothing broadcast
141
+ # after it can be missed; raises ListenError if Redis can't be
142
+ # reached. Calling it again is a no-op.
143
+ def listen!
144
+ return self if Listeners.listening?(@origin)
145
+
146
+ ready = Ractor::Port.new
44
147
  # Ractor.new's block args must be shareable: registry is (Registry
45
148
  # freezes itself around a Ractor, which is inherently shareable),
46
- # redis_url and @origin are plain frozen Strings. The Redis client
47
- # itself is built inside the block, never passed in -- mirrors
48
- # PG::Connection's per-Ractor pattern (persistence/pg.rb).
49
- @subscriber = Ractor.new(registry, @redis_url, @origin) do |registry, url, origin|
50
- Redis.new(url: url).psubscribe("#{Monk::WebSocket::RedisFanout::CHANNEL_PREFIX}*") do |on|
51
- on.pmessage do |_pattern, channel, envelope|
52
- sender, payload = envelope.split("\0", 2)
53
- next if sender == origin
54
-
55
- # Redis channel names are always Strings, but Registry's own
56
- # keys are ordinary Hash keys the app chooses -- every
57
- # existing caller (Registry's own tests, the websocket_server
58
- # scaffold's :chat channel) uses a Symbol. #to_sym here is
59
- # what makes registry.broadcast(:chat, ...) land on the same
60
- # Hash key #register(:chat, port) used -- without it this
61
- # silently broadcasts to an unrelated "chat" String key that
62
- # nothing is ever registered under, and #broadcast still
63
- # returns true either way (Registry#broadcast succeeds
64
- # whether or not any port is registered for a key).
65
- key = channel.delete_prefix(Monk::WebSocket::RedisFanout::CHANNEL_PREFIX).to_sym
66
- registry.broadcast(key, payload)
67
- end
68
- end
149
+ # the URL and origin are frozen Strings, a Port always is. The
150
+ # Redis client itself is built inside the block, never passed in --
151
+ # mirrors PG::Connection's per-Ractor pattern (persistence/pg.rb).
152
+ Ractor.new(@registry, @redis_url, @origin, ready) do |registry, url, origin, ready|
153
+ Monk::WebSocket::RedisFanout.subscribe(registry, url, origin, ready)
69
154
  end
70
155
 
71
- # No live connection lives on this object -- #publisher below opens
72
- # one per Ractor, lazily -- so there's nothing left unshareable and
73
- # this instance can freeze itself the same way Registry does. That
74
- # matters for real use: an app holds this behind a module constant
75
- # (e.g. ChatServer::REGISTRY, mirroring Registry's own existing
76
- # usage) read from every connection's own Ractor, which raises
77
- # Ractor::IsolationError unless the constant's value is shareable.
78
- freeze
156
+ result = ready.receive
157
+ raise ListenError, "Monk::WebSocket::RedisFanout#listen! couldn't subscribe: #{result}" unless result == :ok
158
+
159
+ Listeners.add(@origin)
160
+ self
161
+ end
162
+
163
+ def register(key, port)
164
+ unless Listeners.listening?(@origin)
165
+ raise NotListeningError,
166
+ "Monk::WebSocket::RedisFanout#register before #listen!: this socket would never get " \
167
+ "other processes' broadcasts -- call listen! once at boot in the process that holds " \
168
+ "sockets (bin/websocket_server: Monk::Live.listen!)"
169
+ end
170
+
171
+ @registry.register(key, port)
79
172
  end
80
173
 
81
- def register(key, port) = @registry.register(key, port)
82
174
  def unregister(key, port) = @registry.unregister(key, port)
83
175
  def count(key) = @registry.count(key)
84
176
 
@@ -1,5 +1,10 @@
1
1
  module Monk
2
2
  module WebSocket
3
+ # Sent through a registered port instead of a payload: the socket
4
+ # behind it closes with this code and reason. Registry#close_all sends
5
+ # it; Connection's and Live::Session's relays act on it.
6
+ CloseRequest = Data.define(:code, :reason)
7
+
3
8
  # A dedicated Ractor holding the live set of connection handles keyed by
4
9
  # an app-assigned channel/subject -- mirrors StateRactor's shape
5
10
  # (docs/design/ractor.md): the mutable Hash stays hidden inside this one
@@ -41,6 +46,17 @@ module Monk
41
46
  true
42
47
  when :count
43
48
  keys[key].size
49
+ when :close_all
50
+ # Once per port: a Live page holds one port for all its
51
+ # topics. Entries stay until each connection's own cleanup
52
+ # unregisters them, as on any other close.
53
+ ports = keys.values.flatten.uniq
54
+ ports.each do |port|
55
+ port.send(arg)
56
+ rescue Ractor::ClosedError
57
+ nil
58
+ end
59
+ ports.size
44
60
  end
45
61
  reply_port.send(result)
46
62
  end
@@ -67,6 +83,20 @@ module Monk
67
83
  ask(:count, key, nil)
68
84
  end
69
85
 
86
+ # Closes every registered connection's socket with code and reason,
87
+ # so each client reconnects -- a fanout's subscriber does this after
88
+ # missing broadcasts, to make every page resync. Returns how many
89
+ # connections were asked to close.
90
+ def close_all(code, reason)
91
+ ask(:close_all, nil, Ractor.make_shareable(CloseRequest.new(code: code, reason: reason.to_s)))
92
+ end
93
+
94
+ # Nothing to listen to: every broadcast reaching this registry comes
95
+ # from this process. Here so a WS process calls #listen! the same
96
+ # way whether it holds a Registry or a RedisFanout/PgFanout around
97
+ # one.
98
+ def listen! = self
99
+
70
100
  private
71
101
 
72
102
  def ask(op, key, arg)
@@ -25,9 +25,29 @@ module Monk
25
25
  # docs/history/chat-gap-analysis.md) -- authenticate: true only checks the
26
26
  # credential once, at the handshake, so a session revoked or
27
27
  # expired afterward would otherwise leave the socket live
28
- # indefinitely. Requires authenticate: true (there's no credential
29
- # to reverify without it) and defaults to nil (disabled), same as
30
- # ping_interval:.
28
+ # indefinitely. Requires authenticate: true or :optional (there's no
29
+ # credential to reverify without it) and defaults to nil (disabled),
30
+ # same as ping_interval:. Under :optional it watches only the
31
+ # connections that have a session.
32
+ #
33
+ # authenticate: :optional is the middle mode (docs/design/websocket.md,
34
+ # "Anonymous connections"): a connection with a valid session gets its
35
+ # subject, and one without -- no credential, or a revoked or expired
36
+ # one -- comes in anonymous (subject nil) instead of being refused.
37
+ # What an anonymous connection may then do is the handler's call:
38
+ # Monk::Live's rules deny it everything a rule doesn't open with
39
+ # `anonymous: true`. The Origin check still applies: to a cookie, as
40
+ # under true, and to an anonymous connection that sends an Origin,
41
+ # since only a browser does.
42
+ #
43
+ # db_pool: (a pool name, docs/history/plan-pg-pool.md) runs those
44
+ # Monk::Auth.verify calls -- the handshake's and reverify_interval's
45
+ # -- in that Monk::Persistence::Pg pool instead of in the socket's
46
+ # own Ractor. Without it, each socket's Ractor opens a connection of
47
+ # its own on the first verify and holds it while the socket is open:
48
+ # one Postgres connection per open page. With it, the server holds
49
+ # the pool's. Requires authenticate:, and the pool started
50
+ # (start_pools!) before the server.
31
51
  #
32
52
  # max_payload_size: (bytes) -- see Connection::DEFAULT_MAX_PAYLOAD_SIZE
33
53
  # for what it guards against (gap 5). Unlike ping_interval:/
@@ -35,21 +55,32 @@ module Monk
35
55
  # hostile client on a public endpoint, not an opt-in behavior
36
56
  # change, so a server that doesn't mention it still gets the
37
57
  # default cap rather than no cap at all.
58
+ AUTHENTICATE_MODES = [false, true, :optional].freeze
59
+
60
+ # The signals #run treats as "stop": Ctrl+C and TERM.
61
+ STOP_SIGNALS = [Signal.list.fetch("INT"), Signal.list.fetch("TERM")].freeze
62
+
38
63
  def initialize(
39
64
  port:, bind: "0.0.0.0", allowed_origins: nil, authenticate: false, ping_interval: nil,
40
- reverify_interval: nil, max_payload_size: Connection::DEFAULT_MAX_PAYLOAD_SIZE
65
+ reverify_interval: nil, max_payload_size: Connection::DEFAULT_MAX_PAYLOAD_SIZE, db_pool: nil
41
66
  )
42
67
  if ping_interval && !ping_interval.positive?
43
68
  raise ArgumentError, "ping_interval must be positive, got #{ping_interval.inspect}"
44
69
  end
45
70
 
71
+ unless AUTHENTICATE_MODES.include?(authenticate)
72
+ raise ArgumentError, "authenticate must be false, true or :optional, got #{authenticate.inspect}"
73
+ end
74
+
46
75
  if reverify_interval
47
76
  unless reverify_interval.positive?
48
77
  raise ArgumentError, "reverify_interval must be positive, got #{reverify_interval.inspect}"
49
78
  end
50
- raise ArgumentError, "reverify_interval requires authenticate: true" unless authenticate
79
+ raise ArgumentError, "reverify_interval requires authenticate: true or :optional" unless authenticate
51
80
  end
52
81
 
82
+ raise ArgumentError, "db_pool requires authenticate: true or :optional" if db_pool && !authenticate
83
+
53
84
  unless max_payload_size.positive?
54
85
  raise ArgumentError, "max_payload_size must be positive, got #{max_payload_size.inspect}"
55
86
  end
@@ -57,8 +88,8 @@ module Monk
57
88
  if authenticate
58
89
  unless defined?(Monk::Auth) && Monk::Auth.config
59
90
  raise Monk::AuthNotConfiguredError,
60
- "Monk::WebSocket::Server.new(authenticate: true) requires Monk::Auth to already be " \
61
- "configured -- call Monk::Auth.configure first"
91
+ "Monk::WebSocket::Server.new(authenticate: #{authenticate.inspect}) requires Monk::Auth to " \
92
+ "already be configured -- call Monk::Auth.configure first"
62
93
  end
63
94
 
64
95
  # Monk::Auth's config is a plain, unfrozen Hash until this runs
@@ -71,12 +102,17 @@ module Monk
71
102
  Monk.freeze!
72
103
  end
73
104
 
105
+ # Raises now, not on the first connection, if the pool was never
106
+ # declared or isn't started in this process.
107
+ Monk::Persistence::Pg.pool(db_pool) if db_pool
108
+
74
109
  @tcp_server = TCPServer.new(bind, port)
75
110
  @allowed_origins = allowed_origins ? Ractor.make_shareable(allowed_origins.dup) : nil
76
111
  @authenticate = authenticate
77
112
  @ping_interval = ping_interval
78
113
  @reverify_interval = reverify_interval
79
114
  @max_payload_size = max_payload_size
115
+ @db_pool = db_pool
80
116
  end
81
117
 
82
118
  def port
@@ -97,25 +133,33 @@ module Monk
97
133
  loop do
98
134
  socket = @tcp_server.accept
99
135
  ractor = Ractor.new(
100
- shareable_block, @authenticate, @allowed_origins, @ping_interval, @reverify_interval, @max_payload_size
101
- ) do |blk, authenticate, origins, ping_interval, reverify_interval, max_payload_size|
136
+ shareable_block, @authenticate, @allowed_origins, @ping_interval, @reverify_interval, @max_payload_size,
137
+ @db_pool
138
+ ) do |blk, authenticate, origins, ping_interval, reverify_interval, max_payload_size, db_pool|
102
139
  Monk::WebSocket::Server.serve(
103
140
  Ractor.receive, blk,
104
- authenticate: authenticate, allowed_origins: origins,
105
- ping_interval: ping_interval, reverify_interval: reverify_interval, max_payload_size: max_payload_size
141
+ authenticate: authenticate, allowed_origins: origins, ping_interval: ping_interval,
142
+ reverify_interval: reverify_interval, max_payload_size: max_payload_size, db_pool: db_pool
106
143
  )
107
144
  end
108
145
  ractor.send(socket, move: true)
109
146
  end
110
- rescue Interrupt
111
- # Ctrl+C's default Ruby behavior: raise Interrupt in the thread
112
- # blocked in the syscall, here TCPServer#accept. Unrescued, that's
113
- # an unhandled exception -- Ruby prints its backtrace and exits
114
- # non-zero, which reads as a crash even though this is the normal,
115
- # intended way to stop a long-running server. Registry#ask's
116
- # Ractor::ClosedError rescue (registry.rb) guards a different
117
- # failure point in this same shutdown -- a live connection's
118
- # cleanup racing the registry Ractor's teardown -- not this one.
147
+ rescue SignalException => e
148
+ raise unless STOP_SIGNALS.include?(e.signo)
149
+
150
+ # Ctrl+C (INT, raised as Interrupt) and TERM (what docker stop,
151
+ # Kubernetes, Fly, Render and systemd send on every deploy) both
152
+ # land here: Ruby raises them in the thread blocked in
153
+ # TCPServer#accept. Unrescued, INT prints a backtrace and TERM ends
154
+ # the process by the signal (exit 143) -- either reads as a crash
155
+ # though it's the normal way to stop a long-running server. Here
156
+ # the server stops accepting and #run returns, so the script ends
157
+ # with exit 0, as bin/server and bin/jobs do. Open connections are
158
+ # dropped when the process exits, with no close frame
159
+ # (docs/design/websocket.md, "Stopping the server").
160
+ # Registry#ask's Ractor::ClosedError rescue (registry.rb) guards a
161
+ # different failure point in this same shutdown -- a live
162
+ # connection's cleanup racing the registry Ractor's teardown.
119
163
  @tcp_server.close
120
164
  end
121
165
 
@@ -125,7 +169,7 @@ module Monk
125
169
  # TCPServer) is never Ractor-shareable and can't cross into here.
126
170
  def self.serve(
127
171
  socket, block, authenticate: false, allowed_origins: nil, ping_interval: nil, reverify_interval: nil,
128
- max_payload_size: Connection::DEFAULT_MAX_PAYLOAD_SIZE
172
+ max_payload_size: Connection::DEFAULT_MAX_PAYLOAD_SIZE, db_pool: nil
129
173
  )
130
174
  request = read_handshake_request(socket)
131
175
  return socket.close unless request
@@ -138,9 +182,14 @@ module Monk
138
182
  end
139
183
 
140
184
  subject = nil
141
- if authenticate
142
- subject = authenticate!(socket, headers, allowed_origins)
185
+ case authenticate
186
+ when true
187
+ subject = authenticate!(socket, headers, allowed_origins, db_pool)
143
188
  return unless subject
189
+ when :optional
190
+ return unless origin_allowed_for_optional?(socket, headers, allowed_origins)
191
+
192
+ subject = identify(headers, db_pool)
144
193
  end
145
194
 
146
195
  socket.write(Handshake.response_for(request))
@@ -148,8 +197,8 @@ module Monk
148
197
  connection = Connection.new(socket, subject: subject, max_payload_size: max_payload_size)
149
198
  connection.start_heartbeat(ping_interval) if ping_interval
150
199
  reverify_thread =
151
- if reverify_interval
152
- start_reverify(connection, Handshake.credential_from(headers)[:token], reverify_interval)
200
+ if reverify_interval && subject
201
+ start_reverify(connection, Handshake.credential_from(headers)[:token], reverify_interval, db_pool)
153
202
  end
154
203
  begin
155
204
  block.call(connection)
@@ -169,7 +218,21 @@ module Monk
169
218
  connection&.unsubscribe!
170
219
  connection&.stop_heartbeat!
171
220
  socket.close
221
+ disconnect_all
222
+ end
223
+
224
+ # Closes the connections this socket's Ractor opened: its database
225
+ # connection (verifying the session without db_pool:, or a block
226
+ # that queries) and a fan-out's publisher (a block that broadcasts).
227
+ # Left alone, each would stay open until the garbage collector
228
+ # happened to run, which a mostly idle server may not do for a long
229
+ # time. The fan-outs are opt-in, so each is closed only if loaded.
230
+ def self.disconnect_all
231
+ Monk::Persistence.disconnect_all
232
+ PgFanout.disconnect_publisher if defined?(PgFanout)
233
+ RedisFanout.disconnect_publisher if defined?(RedisFanout)
172
234
  end
235
+ private_class_method :disconnect_all
173
236
 
174
237
  # docs/history/plan-websocket.md steps 22-23: the Origin allowlist check runs
175
238
  # before Monk::Auth.verify, and only for a cookie-derived
@@ -177,7 +240,7 @@ module Monk
177
240
  # by construction (mirrors require_csrf!'s own Bearer exemption in
178
241
  # docs/history/plan-auth.md). Returns the verified subject, or nil after writing
179
242
  # the appropriate 403/401 response itself.
180
- def self.authenticate!(socket, headers, allowed_origins)
243
+ def self.authenticate!(socket, headers, allowed_origins, db_pool)
181
244
  credential = Handshake.credential_from(headers)
182
245
 
183
246
  if credential&.fetch(:via) == :cookie && allowed_origins
@@ -188,12 +251,40 @@ module Monk
188
251
  end
189
252
  end
190
253
 
191
- subject = credential && Monk::Auth.verify(credential[:token])
254
+ subject = credential && verify(credential[:token], db_pool)
192
255
  socket.write("HTTP/1.1 401 Unauthorized\r\n\r\n") unless subject
193
256
  subject
194
257
  end
195
258
  private_class_method :authenticate!
196
259
 
260
+ # authenticate: :optional's Origin check, before anything is verified.
261
+ # A Bearer connection is exempt, as under true. A cookie needs an
262
+ # allowed Origin, as under true. An anonymous connection that sends an
263
+ # Origin needs an allowed one too: only a browser sends it, and this
264
+ # stops another site's page from opening sockets here on its
265
+ # visitors' behalf; one that sends none (not a browser) gets in.
266
+ # Writes the 403 itself and returns false when refused.
267
+ def self.origin_allowed_for_optional?(socket, headers, allowed_origins)
268
+ return true unless allowed_origins
269
+
270
+ credential = Handshake.credential_from(headers)
271
+ return true if credential&.fetch(:via) == :bearer
272
+
273
+ origin = headers["origin"]
274
+ allowed = credential ? origin && allowed_origins.include?(origin) : origin.nil? || allowed_origins.include?(origin)
275
+ socket.write("HTTP/1.1 403 Forbidden\r\n\r\n") unless allowed
276
+ allowed
277
+ end
278
+ private_class_method :origin_allowed_for_optional?
279
+
280
+ # The subject of a valid session, or nil -- no credential, or one
281
+ # Monk::Auth no longer accepts, both mean anonymous under :optional.
282
+ def self.identify(headers, db_pool)
283
+ credential = Handshake.credential_from(headers)
284
+ credential && verify(credential[:token], db_pool)
285
+ end
286
+ private_class_method :identify
287
+
197
288
  # Sleeps in `interval`-sized steps, re-running Monk::Auth.verify
198
289
  # against the same raw credential each time, until it comes back
199
290
  # nil (revoked or expired) -- then closes the connection with the
@@ -203,11 +294,11 @@ module Monk
203
294
  # own Thread rather than blocking the handler's own read loop,
204
295
  # mirroring Connection#start_heartbeat's shape; killed from #serve's
205
296
  # ensure the same way.
206
- def self.start_reverify(connection, raw_token, interval)
297
+ def self.start_reverify(connection, raw_token, interval, db_pool)
207
298
  Thread.new do
208
299
  loop do
209
300
  sleep interval
210
- break unless Monk::Auth.verify(raw_token)
301
+ break unless verify(raw_token, db_pool)
211
302
  end
212
303
  connection.close(code: 1008, reason: "session no longer valid")
213
304
  rescue IOError, Errno::EPIPE, Errno::ECONNRESET
@@ -218,6 +309,14 @@ module Monk
218
309
  end
219
310
  private_class_method :start_reverify
220
311
 
312
+ # Monk::Auth.verify, here or in the db_pool: pool (Server.new).
313
+ def self.verify(raw_token, db_pool)
314
+ return Monk::Auth.verify(raw_token) unless db_pool
315
+
316
+ Monk::Persistence::Pg.pool(db_pool).call(Monk::Auth, :verify, raw_token)
317
+ end
318
+ private_class_method :verify
319
+
221
320
  def self.read_handshake_request(socket)
222
321
  request = +""
223
322
  until request.end_with?("\r\n\r\n")
@@ -3,6 +3,8 @@ require_relative "errors"
3
3
  # true) always raises this precise error, per ADR 0003, rather than a bare
4
4
  # NameError when an app calls it without ever requiring "monk/auth" at all.
5
5
  require_relative "auth/errors"
6
+ # Dependency-free too: Server.serve closes a socket Ractor's connections.
7
+ require_relative "persistence"
6
8
  require_relative "websocket/errors"
7
9
  require_relative "websocket/handshake"
8
10
  require_relative "websocket/frame"
data/lib/monk.rb CHANGED
@@ -24,6 +24,8 @@ module Monk
24
24
  parts << " - auth=on" if defined?(Auth) && Auth.config
25
25
  backends = persistence_backends
26
26
  parts << " - persistence=#{backends.join(",")}" unless backends.empty?
27
+ pools = running_pools
28
+ parts << " - pools=#{pools.join(",")}" unless pools.empty?
27
29
  parts << "\n"
28
30
 
29
31
  $stdout.puts parts.join("\n")
@@ -42,4 +44,12 @@ module Monk
42
44
  .filter_map { |hook| "#{hook.name.split("::").last.downcase}:#{hook.names.join(",")}" if hook.names.any? }
43
45
  end
44
46
  private_class_method :persistence_backends
47
+
48
+ # "name(size)" for every pool started in this process, by any backend.
49
+ def self.running_pools
50
+ freeze_hooks
51
+ .select { |hook| hook.is_a?(Module) && hook.singleton_class.include?(Persistence::Registry) }
52
+ .flat_map { |hook| hook.running_pools.map { |name, size| "#{name}(#{size})" } }
53
+ end
54
+ private_class_method :running_pools
45
55
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: monkrb
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.18.0
4
+ version: 0.20.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Matteo Folin
@@ -212,38 +212,48 @@ files:
212
212
  - lib/monk/persistence/pg/errors.rb
213
213
  - lib/monk/persistence/pg/migrator.rb
214
214
  - lib/monk/persistence/pg/model.rb
215
+ - lib/monk/persistence/pool.rb
215
216
  - lib/monk/scaffold.rb
216
217
  - lib/monk/settings.rb
217
218
  - lib/monk/state_ractor.rb
219
+ - lib/monk/templates/auth/app/mailers/app_mailer.rb
220
+ - lib/monk/templates/auth/app/views/mail/magic_link.erb
218
221
  - lib/monk/templates/auth/config/auth.rb
219
222
  - lib/monk/templates/auth/db/migrate/00000000000001_create_auth_tables.down.sql
220
223
  - lib/monk/templates/auth/db/migrate/00000000000001_create_auth_tables.up.sql
221
- - lib/monk/templates/auth/views/mail/magic_link.erb
222
224
  - lib/monk/templates/base/.dockerignore
223
225
  - lib/monk/templates/base/.gitignore
224
226
  - lib/monk/templates/base/.ruby-version
225
227
  - lib/monk/templates/base/Dockerfile
226
228
  - lib/monk/templates/base/Gemfile
229
+ - lib/monk/templates/base/app/app.rb
230
+ - lib/monk/templates/base/app/broadcasts/.keep
231
+ - lib/monk/templates/base/app/helpers/.keep
232
+ - lib/monk/templates/base/app/jobs/.keep
233
+ - lib/monk/templates/base/app/mailers/.keep
234
+ - lib/monk/templates/base/app/models/.keep
235
+ - lib/monk/templates/base/app/presenters/.keep
236
+ - lib/monk/templates/base/app/views/index.erb
237
+ - lib/monk/templates/base/app/views/layouts/app.erb
227
238
  - lib/monk/templates/base/bin/server
228
239
  - lib/monk/templates/base/bin/websocket_server
229
240
  - lib/monk/templates/base/config.ru
241
+ - lib/monk/templates/base/config/load.rb
230
242
  - lib/monk/templates/base/config/settings.rb
231
243
  - lib/monk/templates/base/public/css/app.css
232
244
  - lib/monk/templates/base/public/js/app.js
233
- - lib/monk/templates/base/views/index.erb
234
- - lib/monk/templates/base/views/layouts/app.erb
245
+ - lib/monk/templates/jobs/app/jobs/hello_job.rb
246
+ - lib/monk/templates/jobs/app/jobs/send_login_link.rb
235
247
  - lib/monk/templates/jobs/bin/jobs
236
248
  - lib/monk/templates/jobs/config/jobs.rb
237
249
  - lib/monk/templates/jobs/db/migrate/00000000000002_create_jobs_tables.down.sql
238
250
  - lib/monk/templates/jobs/db/migrate/00000000000002_create_jobs_tables.up.sql
239
- - lib/monk/templates/jobs/jobs/hello_job.rb
240
- - lib/monk/templates/jobs/jobs/send_login_link.rb
251
+ - lib/monk/templates/live/app/app.rb
252
+ - lib/monk/templates/live/app/views/index.erb
253
+ - lib/monk/templates/live/app/views/live/_hits.erb
241
254
  - lib/monk/templates/live/bin/websocket_server
242
- - lib/monk/templates/live/config.ru
243
255
  - lib/monk/templates/live/config/live.rb
244
256
  - lib/monk/templates/live/config/live_pg.rb
245
- - lib/monk/templates/live/views/index.erb
246
- - lib/monk/templates/live/views/live/_hits.erb
247
257
  - lib/monk/templates/mail/Gemfile.extra
248
258
  - lib/monk/templates/mail/config/mail.rb
249
259
  - lib/monk/templates/postgres/Dockerfile
@@ -252,6 +262,7 @@ files:
252
262
  - lib/monk/templates/postgres/bin/migrate
253
263
  - lib/monk/templates/postgres/bin/setup_db
254
264
  - lib/monk/templates/postgres/config/persistence.rb
265
+ - lib/monk/templates/postgres/db/migrate/.keep
255
266
  - lib/monk/templates/redis/Gemfile.extra
256
267
  - lib/monk/version.rb
257
268
  - lib/monk/views.rb
@@ -260,6 +271,7 @@ files:
260
271
  - lib/monk/websocket/errors.rb
261
272
  - lib/monk/websocket/frame.rb
262
273
  - lib/monk/websocket/handshake.rb
274
+ - lib/monk/websocket/listeners.rb
263
275
  - lib/monk/websocket/pg_fanout.rb
264
276
  - lib/monk/websocket/redis_fanout.rb
265
277
  - lib/monk/websocket/registry.rb