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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +166 -0
- data/README.md +2 -2
- data/lib/monk/base.rb +20 -0
- data/lib/monk/jobs/adapters/pg.rb +3 -1
- data/lib/monk/live/client/monk_live.js +2 -2
- data/lib/monk/live/client/protocol.js +5 -0
- data/lib/monk/live/policy.rb +39 -4
- data/lib/monk/live/session.rb +7 -0
- data/lib/monk/live.rb +15 -3
- data/lib/monk/persistence/errors.rb +32 -0
- data/lib/monk/persistence/pg.rb +92 -1
- data/lib/monk/persistence/pool.rb +513 -0
- data/lib/monk/persistence.rb +196 -7
- data/lib/monk/scaffold.rb +155 -78
- data/lib/monk/templates/auth/app/mailers/app_mailer.rb +22 -0
- data/lib/monk/templates/auth/config/auth.rb +13 -20
- data/lib/monk/templates/base/app/app.rb +9 -0
- data/lib/monk/templates/base/app/broadcasts/.keep +0 -0
- data/lib/monk/templates/base/app/helpers/.keep +0 -0
- data/lib/monk/templates/base/app/jobs/.keep +0 -0
- data/lib/monk/templates/base/app/mailers/.keep +0 -0
- data/lib/monk/templates/base/app/models/.keep +0 -0
- data/lib/monk/templates/base/app/presenters/.keep +0 -0
- data/lib/monk/templates/base/bin/websocket_server +17 -2
- data/lib/monk/templates/base/config/load.rb +18 -0
- data/lib/monk/templates/base/config.ru +3 -12
- data/lib/monk/templates/base/public/js/app.js +1 -1
- data/lib/monk/templates/jobs/{jobs → app/jobs}/send_login_link.rb +2 -1
- data/lib/monk/templates/jobs/bin/jobs +5 -13
- data/lib/monk/templates/jobs/config/jobs.rb +2 -4
- data/lib/monk/templates/live/{config.ru → app/app.rb} +1 -6
- data/lib/monk/templates/live/{views → app/views}/index.erb +2 -2
- data/lib/monk/templates/live/bin/websocket_server +23 -3
- data/lib/monk/templates/live/config/live.rb +1 -1
- data/lib/monk/templates/live/config/live_pg.rb +1 -1
- data/lib/monk/templates/mail/config/mail.rb +2 -2
- data/lib/monk/templates/postgres/bin/console +4 -2
- data/lib/monk/templates/postgres/db/migrate/.keep +0 -0
- data/lib/monk/version.rb +1 -1
- data/lib/monk/websocket/connection.rb +25 -6
- data/lib/monk/websocket/errors.rb +11 -0
- data/lib/monk/websocket/listeners.rb +47 -0
- data/lib/monk/websocket/pg_fanout.rb +189 -31
- data/lib/monk/websocket/redis_fanout.rb +125 -33
- data/lib/monk/websocket/registry.rb +30 -0
- data/lib/monk/websocket/server.rb +128 -29
- data/lib/monk/websocket.rb +2 -0
- data/lib/monk.rb +10 -0
- metadata +21 -9
- /data/lib/monk/templates/auth/{views → app/views}/mail/magic_link.erb +0 -0
- /data/lib/monk/templates/base/{views → app/views}/index.erb +0 -0
- /data/lib/monk/templates/base/{views → app/views}/layouts/app.erb +0 -0
- /data/lib/monk/templates/jobs/{jobs → app/jobs}/hello_job.rb +0 -0
- /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
|
-
#
|
|
47
|
-
# itself is built inside the block, never passed in --
|
|
48
|
-
# PG::Connection's per-Ractor pattern (persistence/pg.rb).
|
|
49
|
-
|
|
50
|
-
|
|
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
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
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
|
|
29
|
-
# to reverify without it) and defaults to nil (disabled),
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
#
|
|
114
|
-
#
|
|
115
|
-
#
|
|
116
|
-
#
|
|
117
|
-
#
|
|
118
|
-
#
|
|
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
|
-
|
|
142
|
-
|
|
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 &&
|
|
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
|
|
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")
|
data/lib/monk/websocket.rb
CHANGED
|
@@ -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.
|
|
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/
|
|
234
|
-
- lib/monk/templates/
|
|
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/
|
|
240
|
-
- lib/monk/templates/
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|