monkrb 0.15.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/CHANGELOG.md +581 -0
- data/LICENSE.txt +21 -0
- data/README.md +88 -0
- data/exe/monk +116 -0
- data/lib/monk/assets.rb +197 -0
- data/lib/monk/auth/errors.rb +13 -0
- data/lib/monk/auth/helpers.rb +76 -0
- data/lib/monk/auth/login_token.rb +11 -0
- data/lib/monk/auth/rate_limiter.rb +46 -0
- data/lib/monk/auth/session.rb +11 -0
- data/lib/monk/auth.rb +301 -0
- data/lib/monk/base.rb +323 -0
- data/lib/monk/context.rb +78 -0
- data/lib/monk/environment.rb +50 -0
- data/lib/monk/errors.rb +37 -0
- data/lib/monk/freeze_hooks.rb +23 -0
- data/lib/monk/live/client/idiomorph.LICENSE +13 -0
- data/lib/monk/live/client/idiomorph.js +4 -0
- data/lib/monk/live/client/monk_live.js +204 -0
- data/lib/monk/live/client/protocol.js +87 -0
- data/lib/monk/live/envelope.rb +51 -0
- data/lib/monk/live/errors.rb +9 -0
- data/lib/monk/live/helpers.rb +22 -0
- data/lib/monk/live/policy.rb +58 -0
- data/lib/monk/live/publisher.rb +91 -0
- data/lib/monk/live/renderer.rb +47 -0
- data/lib/monk/live/session.rb +121 -0
- data/lib/monk/live.rb +96 -0
- data/lib/monk/log.rb +130 -0
- data/lib/monk/persistence/errors.rb +7 -0
- data/lib/monk/persistence/model.rb +41 -0
- data/lib/monk/persistence/pg/errors.rb +4 -0
- data/lib/monk/persistence/pg/migrator.rb +165 -0
- data/lib/monk/persistence/pg/model.rb +233 -0
- data/lib/monk/persistence/pg.rb +34 -0
- data/lib/monk/persistence.rb +113 -0
- data/lib/monk/scaffold.rb +606 -0
- data/lib/monk/settings.rb +151 -0
- data/lib/monk/state_ractor.rb +45 -0
- data/lib/monk/templates/auth/config/auth.rb +28 -0
- data/lib/monk/templates/auth/db/migrate/00000000000001_create_auth_tables.down.sql +2 -0
- data/lib/monk/templates/auth/db/migrate/00000000000001_create_auth_tables.up.sql +18 -0
- data/lib/monk/templates/base/.dockerignore +5 -0
- data/lib/monk/templates/base/.gitignore +4 -0
- data/lib/monk/templates/base/.ruby-version +1 -0
- data/lib/monk/templates/base/Dockerfile +28 -0
- data/lib/monk/templates/base/Gemfile +7 -0
- data/lib/monk/templates/base/bin/server +5 -0
- data/lib/monk/templates/base/bin/websocket_server +62 -0
- data/lib/monk/templates/base/config/settings.rb +30 -0
- data/lib/monk/templates/base/config.ru +13 -0
- data/lib/monk/templates/base/public/css/app.css +17 -0
- data/lib/monk/templates/base/public/js/app.js +5 -0
- data/lib/monk/templates/base/views/index.erb +6 -0
- data/lib/monk/templates/base/views/layouts/app.erb +18 -0
- data/lib/monk/templates/live/bin/websocket_server +30 -0
- data/lib/monk/templates/live/config/live.rb +47 -0
- data/lib/monk/templates/live/config.ru +27 -0
- data/lib/monk/templates/live/views/index.erb +18 -0
- data/lib/monk/templates/live/views/live/_hits.erb +1 -0
- data/lib/monk/templates/postgres/Dockerfile +30 -0
- data/lib/monk/templates/postgres/Gemfile.extra +2 -0
- data/lib/monk/templates/postgres/bin/console +7 -0
- data/lib/monk/templates/postgres/bin/migrate +22 -0
- data/lib/monk/templates/postgres/bin/setup_db +9 -0
- data/lib/monk/templates/postgres/config/persistence.rb +10 -0
- data/lib/monk/templates/redis/Gemfile.extra +1 -0
- data/lib/monk/version.rb +9 -0
- data/lib/monk/views.rb +175 -0
- data/lib/monk/websocket/connection.rb +226 -0
- data/lib/monk/websocket/errors.rb +9 -0
- data/lib/monk/websocket/frame.rb +71 -0
- data/lib/monk/websocket/handshake.rb +77 -0
- data/lib/monk/websocket/redis_fanout.rb +103 -0
- data/lib/monk/websocket/registry.rb +92 -0
- data/lib/monk/websocket/server.rb +234 -0
- data/lib/monk/websocket.rb +19 -0
- data/lib/monk.rb +45 -0
- metadata +252 -0
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
require "redis"
|
|
2
|
+
require "securerandom"
|
|
3
|
+
|
|
4
|
+
module Monk
|
|
5
|
+
module WebSocket
|
|
6
|
+
# Cross-process fan-out for Registry, opt-in: require
|
|
7
|
+
# "monk/websocket/redis_fanout" explicitly -- "monk/websocket" alone
|
|
8
|
+
# does not load this (docs/history/plan-websocket.md Phase 6, chosen over Postgres
|
|
9
|
+
# LISTEN/NOTIFY per docs/design/websocket.md Open Question 3).
|
|
10
|
+
#
|
|
11
|
+
# Wraps a Registry with the same public interface (#register,
|
|
12
|
+
# #unregister, #count, #broadcast), so an app swaps which object it
|
|
13
|
+
# holds and nothing else changes. #broadcast still delivers to this
|
|
14
|
+
# process's local Registry directly -- same latency and reliability as
|
|
15
|
+
# a plain Registry if Redis is briefly unavailable -- and additionally
|
|
16
|
+
# publishes to Redis so sibling WS processes' subscriber Ractors
|
|
17
|
+
# deliver it to their own local connections.
|
|
18
|
+
#
|
|
19
|
+
# Every published payload is tagged with this instance's origin id so
|
|
20
|
+
# the subscriber ignores messages that are this process's own
|
|
21
|
+
# broadcast echoed back over Redis -- without the tag, a broadcast
|
|
22
|
+
# would reach local connections twice (once directly, once via the
|
|
23
|
+
# round trip through Redis).
|
|
24
|
+
#
|
|
25
|
+
# #broadcast/#register keys must be Symbols. A Redis channel name is
|
|
26
|
+
# always a String, so the subscriber has to pick one Ruby type to
|
|
27
|
+
# convert the recovered channel back into -- Symbol, matching every
|
|
28
|
+
# existing caller (Registry's own tests, the websocket_server
|
|
29
|
+
# scaffold's :chat channel). A String-keyed #register(...) would still
|
|
30
|
+
# get this process's own direct/local deliveries but never a relayed
|
|
31
|
+
# one, since the relayed call always uses a Symbol.
|
|
32
|
+
class RedisFanout
|
|
33
|
+
# Frozen explicitly, not just left as a String literal -- the
|
|
34
|
+
# subscriber Ractor below reads this constant from a non-main
|
|
35
|
+
# Ractor, which raises Ractor::IsolationError unless its value is
|
|
36
|
+
# already shareable.
|
|
37
|
+
CHANNEL_PREFIX = "monk:ws:".freeze
|
|
38
|
+
|
|
39
|
+
def initialize(registry, redis_url:)
|
|
40
|
+
@registry = registry
|
|
41
|
+
@redis_url = redis_url.dup.freeze
|
|
42
|
+
@origin = SecureRandom.uuid.freeze
|
|
43
|
+
|
|
44
|
+
# Ractor.new's block args must be shareable: registry is (Registry
|
|
45
|
+
# 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
|
|
69
|
+
end
|
|
70
|
+
|
|
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
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def register(key, port) = @registry.register(key, port)
|
|
82
|
+
def unregister(key, port) = @registry.unregister(key, port)
|
|
83
|
+
def count(key) = @registry.count(key)
|
|
84
|
+
|
|
85
|
+
def broadcast(key, payload)
|
|
86
|
+
@registry.broadcast(key, payload)
|
|
87
|
+
publisher.publish("#{CHANNEL_PREFIX}#{key}", "#{@origin}\0#{payload}")
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
private
|
|
91
|
+
|
|
92
|
+
# A single shared Redis client called concurrently from every
|
|
93
|
+
# connection Ractor's #broadcast wouldn't be safe -- redis-rb clients
|
|
94
|
+
# aren't documented safe for concurrent commands from multiple
|
|
95
|
+
# threads, let alone Ractors. One client per calling Ractor, opened
|
|
96
|
+
# on first use and cached there, mirrors
|
|
97
|
+
# Monk::Persistence::Registry's #ractor_local/#entry exactly.
|
|
98
|
+
def publisher
|
|
99
|
+
Ractor.current[:monk_redis_fanout_publisher] ||= Redis.new(url: @redis_url)
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
end
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
module Monk
|
|
2
|
+
module WebSocket
|
|
3
|
+
# A dedicated Ractor holding the live set of connection handles keyed by
|
|
4
|
+
# an app-assigned channel/subject -- mirrors StateRactor's shape
|
|
5
|
+
# (docs/design/ractor.md): the mutable Hash stays hidden inside this one
|
|
6
|
+
# Ractor, and every call is a synchronous "ask" via a fresh
|
|
7
|
+
# Ractor::Port, so registration/broadcast can never race each other.
|
|
8
|
+
class Registry
|
|
9
|
+
def initialize
|
|
10
|
+
@ractor = Ractor.new do
|
|
11
|
+
keys = Hash.new { |h, k| h[k] = [] }
|
|
12
|
+
|
|
13
|
+
loop do
|
|
14
|
+
op, key, arg, reply_port = Ractor.receive
|
|
15
|
+
result =
|
|
16
|
+
case op
|
|
17
|
+
when :register
|
|
18
|
+
keys[key] << arg unless keys[key].include?(arg)
|
|
19
|
+
true
|
|
20
|
+
when :unregister
|
|
21
|
+
keys[key].delete(arg)
|
|
22
|
+
true
|
|
23
|
+
when :broadcast
|
|
24
|
+
# A port can be closed out from under this loop without
|
|
25
|
+
# ever going through #unregister -- e.g. the owning
|
|
26
|
+
# connection Ractor dies before its own cleanup runs.
|
|
27
|
+
# port.send on a closed port raises Ractor::ClosedError;
|
|
28
|
+
# left unguarded, that exception would propagate out of
|
|
29
|
+
# this single-threaded loop and kill the *registry*
|
|
30
|
+
# Ractor, taking down delivery for every key, not just
|
|
31
|
+
# this one. Rescuing it here, per port, keeps the
|
|
32
|
+
# broadcast going for every other subscriber and also
|
|
33
|
+
# drops the dead port from keys[key] -- self-healing the
|
|
34
|
+
# stale entry #unregister was supposed to have caught.
|
|
35
|
+
keys[key].reject! do |port|
|
|
36
|
+
port.send(arg)
|
|
37
|
+
false
|
|
38
|
+
rescue Ractor::ClosedError
|
|
39
|
+
true
|
|
40
|
+
end
|
|
41
|
+
true
|
|
42
|
+
when :count
|
|
43
|
+
keys[key].size
|
|
44
|
+
end
|
|
45
|
+
reply_port.send(result)
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
freeze
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def register(key, port)
|
|
52
|
+
ask(:register, key, port)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def unregister(key, port)
|
|
56
|
+
ask(:unregister, key, port)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def broadcast(key, payload)
|
|
60
|
+
ask(:broadcast, key, payload)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# Not part of Phase 4's own plan bullets, but needed to actually
|
|
64
|
+
# observe step 17's "a crashed handler never leaks a stale registry
|
|
65
|
+
# entry" claim from outside the registry, rather than trusting it.
|
|
66
|
+
def count(key)
|
|
67
|
+
ask(:count, key, nil)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
private
|
|
71
|
+
|
|
72
|
+
def ask(op, key, arg)
|
|
73
|
+
reply_port = Ractor::Port.new
|
|
74
|
+
@ractor.send([op, key, arg, reply_port])
|
|
75
|
+
reply_port.receive
|
|
76
|
+
rescue Ractor::ClosedError
|
|
77
|
+
# The registry Ractor itself can be gone by the time this runs --
|
|
78
|
+
# e.g. process-wide shutdown (Ctrl+C) tearing down Ractors in
|
|
79
|
+
# whatever order, with a connection's own cleanup (Connection#
|
|
80
|
+
# unsubscribe!, called from Server.serve's ensure) losing the race
|
|
81
|
+
# against the registry's. Nothing on the other end to reply to a
|
|
82
|
+
# dead call anyway; nil is indistinguishable from "the process is
|
|
83
|
+
# already exiting" to every caller here (register/unregister/
|
|
84
|
+
# broadcast ignore their return value, and #count is never read
|
|
85
|
+
# this late).
|
|
86
|
+
nil
|
|
87
|
+
ensure
|
|
88
|
+
reply_port&.close
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
end
|
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
require "socket"
|
|
2
|
+
|
|
3
|
+
module Monk
|
|
4
|
+
module WebSocket
|
|
5
|
+
# Intended to be the entire body of a small standalone script (e.g.
|
|
6
|
+
# bin/websocket_server) -- never embedded in the same process as Kino
|
|
7
|
+
# (docs/history/plan-websocket.md Decision 1).
|
|
8
|
+
class Server
|
|
9
|
+
# allowed_origins: and authenticate: implement docs/history/plan-websocket.md
|
|
10
|
+
# Phase 5 (identity via Monk::Auth, reused unmodified -- Decision
|
|
11
|
+
# 5). authenticate: defaults false so a plain server (Phases 1-4)
|
|
12
|
+
# behaves exactly as before; turning it on requires Monk::Auth to
|
|
13
|
+
# already be configured, fail-fast the same way Monk::Auth's own
|
|
14
|
+
# methods do (ADR 0003) rather than failing obscurely on the first
|
|
15
|
+
# connection.
|
|
16
|
+
#
|
|
17
|
+
# ping_interval: (seconds) opts a connection into a server-sent
|
|
18
|
+
# heartbeat ping -- see Connection#start_heartbeat. Defaults to nil
|
|
19
|
+
# (disabled) for the same reason authenticate: defaults false: a
|
|
20
|
+
# server that doesn't ask for it behaves exactly as before.
|
|
21
|
+
#
|
|
22
|
+
# reverify_interval: (seconds) re-runs Monk::Auth.verify against the
|
|
23
|
+
# same credential on that cadence for as long as the connection
|
|
24
|
+
# stays open, closing it the moment verify comes back nil (gap 4,
|
|
25
|
+
# docs/history/chat-gap-analysis.md) -- authenticate: true only checks the
|
|
26
|
+
# credential once, at the handshake, so a session revoked or
|
|
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:.
|
|
31
|
+
#
|
|
32
|
+
# max_payload_size: (bytes) -- see Connection::DEFAULT_MAX_PAYLOAD_SIZE
|
|
33
|
+
# for what it guards against (gap 5). Unlike ping_interval:/
|
|
34
|
+
# reverify_interval:, always on: this is insurance against a
|
|
35
|
+
# hostile client on a public endpoint, not an opt-in behavior
|
|
36
|
+
# change, so a server that doesn't mention it still gets the
|
|
37
|
+
# default cap rather than no cap at all.
|
|
38
|
+
def initialize(
|
|
39
|
+
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
|
|
41
|
+
)
|
|
42
|
+
if ping_interval && !ping_interval.positive?
|
|
43
|
+
raise ArgumentError, "ping_interval must be positive, got #{ping_interval.inspect}"
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
if reverify_interval
|
|
47
|
+
unless reverify_interval.positive?
|
|
48
|
+
raise ArgumentError, "reverify_interval must be positive, got #{reverify_interval.inspect}"
|
|
49
|
+
end
|
|
50
|
+
raise ArgumentError, "reverify_interval requires authenticate: true" unless authenticate
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
unless max_payload_size.positive?
|
|
54
|
+
raise ArgumentError, "max_payload_size must be positive, got #{max_payload_size.inspect}"
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
if authenticate
|
|
58
|
+
unless defined?(Monk::Auth) && Monk::Auth.config
|
|
59
|
+
raise Monk::AuthNotConfiguredError,
|
|
60
|
+
"Monk::WebSocket::Server.new(authenticate: true) requires Monk::Auth to already be " \
|
|
61
|
+
"configured -- call Monk::Auth.configure first"
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Monk::Auth's config is a plain, unfrozen Hash until this runs
|
|
65
|
+
# (lib/monk/freeze_hooks.rb) -- without it, the first
|
|
66
|
+
# Monk::Auth.verify call from inside a connection Ractor raises
|
|
67
|
+
# Ractor::IsolationError (docs/history/plan-websocket.md Phase 5 step 20).
|
|
68
|
+
# Called here, not left to the boot script to remember, the same
|
|
69
|
+
# way Base.call already auto-freezes on first use rather than
|
|
70
|
+
# trusting every app to call Base.freeze! itself.
|
|
71
|
+
Monk.freeze!
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
@tcp_server = TCPServer.new(bind, port)
|
|
75
|
+
@allowed_origins = allowed_origins ? Ractor.make_shareable(allowed_origins.dup) : nil
|
|
76
|
+
@authenticate = authenticate
|
|
77
|
+
@ping_interval = ping_interval
|
|
78
|
+
@reverify_interval = reverify_interval
|
|
79
|
+
@max_payload_size = max_payload_size
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def port
|
|
83
|
+
@tcp_server.addr[1]
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def run(&block)
|
|
87
|
+
shareable_block =
|
|
88
|
+
begin
|
|
89
|
+
Ractor.make_shareable(block)
|
|
90
|
+
rescue ArgumentError, Ractor::IsolationError => e
|
|
91
|
+
raise Monk::UnshareableBlockError,
|
|
92
|
+
"Monk::WebSocket::Server#run block is not Ractor-shareable: #{e.message} " \
|
|
93
|
+
"(build it where self is shareable, e.g. at class-body scope, not inline in a script's " \
|
|
94
|
+
"top-level block -- mirrors StateRactor#update's own constraint)"
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
loop do
|
|
98
|
+
socket = @tcp_server.accept
|
|
99
|
+
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|
|
|
102
|
+
Monk::WebSocket::Server.serve(
|
|
103
|
+
Ractor.receive, blk,
|
|
104
|
+
authenticate: authenticate, allowed_origins: origins,
|
|
105
|
+
ping_interval: ping_interval, reverify_interval: reverify_interval, max_payload_size: max_payload_size
|
|
106
|
+
)
|
|
107
|
+
end
|
|
108
|
+
ractor.send(socket, move: true)
|
|
109
|
+
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.
|
|
119
|
+
@tcp_server.close
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# Runs entirely inside the connection's own dedicated Ractor
|
|
123
|
+
# (docs/history/plan-websocket.md Decision 3) -- a class method, not an instance
|
|
124
|
+
# method, since the Server instance itself (holding a live
|
|
125
|
+
# TCPServer) is never Ractor-shareable and can't cross into here.
|
|
126
|
+
def self.serve(
|
|
127
|
+
socket, block, authenticate: false, allowed_origins: nil, ping_interval: nil, reverify_interval: nil,
|
|
128
|
+
max_payload_size: Connection::DEFAULT_MAX_PAYLOAD_SIZE
|
|
129
|
+
)
|
|
130
|
+
request = read_handshake_request(socket)
|
|
131
|
+
return socket.close unless request
|
|
132
|
+
|
|
133
|
+
headers =
|
|
134
|
+
begin
|
|
135
|
+
Handshake.parse_request(request)
|
|
136
|
+
rescue Monk::WebSocket::HandshakeError
|
|
137
|
+
return socket.write("HTTP/1.1 400 Bad Request\r\n\r\n")
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
subject = nil
|
|
141
|
+
if authenticate
|
|
142
|
+
subject = authenticate!(socket, headers, allowed_origins)
|
|
143
|
+
return unless subject
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
socket.write(Handshake.response_for(request))
|
|
147
|
+
|
|
148
|
+
connection = Connection.new(socket, subject: subject, max_payload_size: max_payload_size)
|
|
149
|
+
connection.start_heartbeat(ping_interval) if ping_interval
|
|
150
|
+
reverify_thread =
|
|
151
|
+
if reverify_interval
|
|
152
|
+
start_reverify(connection, Handshake.credential_from(headers)[:token], reverify_interval)
|
|
153
|
+
end
|
|
154
|
+
begin
|
|
155
|
+
block.call(connection)
|
|
156
|
+
rescue StandardError
|
|
157
|
+
# Isolates this connection's failure to its own Ractor
|
|
158
|
+
# (docs/history/plan-websocket.md step 12): neither the accept loop nor any
|
|
159
|
+
# other connection's Ractor is affected. Deliberately swallowed,
|
|
160
|
+
# not re-raised -- there's no caller left to hand it to once
|
|
161
|
+
# we're inside this connection's own dedicated Ractor.
|
|
162
|
+
end
|
|
163
|
+
ensure
|
|
164
|
+
# Runs on every exit path -- normal completion, the close
|
|
165
|
+
# handshake, and a crashed handler alike -- so a subscribed
|
|
166
|
+
# connection never leaks a stale registry entry (step 17), a
|
|
167
|
+
# heartbeat thread, or a reverify thread outliving its socket.
|
|
168
|
+
reverify_thread&.kill
|
|
169
|
+
connection&.unsubscribe!
|
|
170
|
+
connection&.stop_heartbeat!
|
|
171
|
+
socket.close
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
# docs/history/plan-websocket.md steps 22-23: the Origin allowlist check runs
|
|
175
|
+
# before Monk::Auth.verify, and only for a cookie-derived
|
|
176
|
+
# credential -- a Bearer connection has no Origin header to check
|
|
177
|
+
# by construction (mirrors require_csrf!'s own Bearer exemption in
|
|
178
|
+
# docs/history/plan-auth.md). Returns the verified subject, or nil after writing
|
|
179
|
+
# the appropriate 403/401 response itself.
|
|
180
|
+
def self.authenticate!(socket, headers, allowed_origins)
|
|
181
|
+
credential = Handshake.credential_from(headers)
|
|
182
|
+
|
|
183
|
+
if credential&.fetch(:via) == :cookie && allowed_origins
|
|
184
|
+
origin = headers["origin"]
|
|
185
|
+
unless origin && allowed_origins.include?(origin)
|
|
186
|
+
socket.write("HTTP/1.1 403 Forbidden\r\n\r\n")
|
|
187
|
+
return nil
|
|
188
|
+
end
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
subject = credential && Monk::Auth.verify(credential[:token])
|
|
192
|
+
socket.write("HTTP/1.1 401 Unauthorized\r\n\r\n") unless subject
|
|
193
|
+
subject
|
|
194
|
+
end
|
|
195
|
+
private_class_method :authenticate!
|
|
196
|
+
|
|
197
|
+
# Sleeps in `interval`-sized steps, re-running Monk::Auth.verify
|
|
198
|
+
# against the same raw credential each time, until it comes back
|
|
199
|
+
# nil (revoked or expired) -- then closes the connection with the
|
|
200
|
+
# standard "policy violation" close code, the one bit of RFC 6455's
|
|
201
|
+
# registered code space that fits "this session is no longer
|
|
202
|
+
# authorized" without inventing an app-specific one. Runs in its
|
|
203
|
+
# own Thread rather than blocking the handler's own read loop,
|
|
204
|
+
# mirroring Connection#start_heartbeat's shape; killed from #serve's
|
|
205
|
+
# ensure the same way.
|
|
206
|
+
def self.start_reverify(connection, raw_token, interval)
|
|
207
|
+
Thread.new do
|
|
208
|
+
loop do
|
|
209
|
+
sleep interval
|
|
210
|
+
break unless Monk::Auth.verify(raw_token)
|
|
211
|
+
end
|
|
212
|
+
connection.close(code: 1008, reason: "session no longer valid")
|
|
213
|
+
rescue IOError, Errno::EPIPE, Errno::ECONNRESET
|
|
214
|
+
# The connection already closed via some other path (normal
|
|
215
|
+
# disconnect, the close handshake, a dead ping) -- nothing left
|
|
216
|
+
# to close.
|
|
217
|
+
end
|
|
218
|
+
end
|
|
219
|
+
private_class_method :start_reverify
|
|
220
|
+
|
|
221
|
+
def self.read_handshake_request(socket)
|
|
222
|
+
request = +""
|
|
223
|
+
until request.end_with?("\r\n\r\n")
|
|
224
|
+
chunk = socket.read(1)
|
|
225
|
+
return nil if chunk.nil?
|
|
226
|
+
|
|
227
|
+
request << chunk
|
|
228
|
+
end
|
|
229
|
+
request
|
|
230
|
+
end
|
|
231
|
+
private_class_method :read_handshake_request
|
|
232
|
+
end
|
|
233
|
+
end
|
|
234
|
+
end
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
require_relative "errors"
|
|
2
|
+
# Standalone, dependency-free -- required here so Server.new(authenticate:
|
|
3
|
+
# true) always raises this precise error, per ADR 0003, rather than a bare
|
|
4
|
+
# NameError when an app calls it without ever requiring "monk/auth" at all.
|
|
5
|
+
require_relative "auth/errors"
|
|
6
|
+
require_relative "websocket/errors"
|
|
7
|
+
require_relative "websocket/handshake"
|
|
8
|
+
require_relative "websocket/frame"
|
|
9
|
+
require_relative "websocket/connection"
|
|
10
|
+
require_relative "websocket/server"
|
|
11
|
+
require_relative "websocket/registry"
|
|
12
|
+
|
|
13
|
+
# Opt-in, like Monk::Persistence backends and Monk::Auth: require
|
|
14
|
+
# "monk/websocket" explicitly. `require "monk"` alone must not load this
|
|
15
|
+
# (docs/history/plan-websocket.md Decision 7).
|
|
16
|
+
module Monk
|
|
17
|
+
module WebSocket
|
|
18
|
+
end
|
|
19
|
+
end
|
data/lib/monk.rb
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
require_relative "monk/version"
|
|
2
|
+
require_relative "monk/settings"
|
|
3
|
+
require_relative "monk/environment"
|
|
4
|
+
require_relative "monk/errors"
|
|
5
|
+
require_relative "monk/views"
|
|
6
|
+
require_relative "monk/assets"
|
|
7
|
+
require_relative "monk/log"
|
|
8
|
+
require_relative "monk/context"
|
|
9
|
+
require_relative "monk/persistence/errors"
|
|
10
|
+
require_relative "monk/state_ractor"
|
|
11
|
+
require_relative "monk/persistence"
|
|
12
|
+
require_relative "monk/persistence/model"
|
|
13
|
+
require_relative "monk/base"
|
|
14
|
+
|
|
15
|
+
# Persistence backends (Monk::Persistence::Pg, and any future adapter) are
|
|
16
|
+
# opt-in -- require them explicitly, e.g. `require "monk/persistence/pg"`.
|
|
17
|
+
|
|
18
|
+
module Monk
|
|
19
|
+
def self.boot(app)
|
|
20
|
+
app.freeze!
|
|
21
|
+
|
|
22
|
+
app_class = app.is_a?(Class) ? app : app.class
|
|
23
|
+
parts = ['', "Monk #{VERSION} plays!", " - env=#{env}", " - routes=#{app_class.routes.size}"]
|
|
24
|
+
parts << " - auth=on" if defined?(Auth) && Auth.config
|
|
25
|
+
backends = persistence_backends
|
|
26
|
+
parts << " - persistence=#{backends.join(",")}" unless backends.empty?
|
|
27
|
+
parts << "\n"
|
|
28
|
+
|
|
29
|
+
$stdout.puts parts.join("\n")
|
|
30
|
+
app
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Backend module name (e.g. "pg") + its registered connection names
|
|
34
|
+
# (e.g. "primary"), for every Persistence::Registry backend actually in
|
|
35
|
+
# use -- Monk.freeze_hooks already lists every such module (each one
|
|
36
|
+
# extends Registry and adds itself there), so this just filters that
|
|
37
|
+
# list down to the ones with something registered, rather than keeping
|
|
38
|
+
# a second registry of backends.
|
|
39
|
+
def self.persistence_backends
|
|
40
|
+
freeze_hooks
|
|
41
|
+
.select { |hook| hook.is_a?(Module) && hook.singleton_class.include?(Persistence::Registry) }
|
|
42
|
+
.filter_map { |hook| "#{hook.name.split("::").last.downcase}:#{hook.names.join(",")}" if hook.names.any? }
|
|
43
|
+
end
|
|
44
|
+
private_class_method :persistence_backends
|
|
45
|
+
end
|