runlight 0.1.1 → 0.1.3
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/assets/build.json +4 -4
- data/assets/dashboard.css +1 -1
- data/assets/dashboard.js +64 -23
- data/assets/locales.json +5 -5
- data/assets/pick-return.js +1 -0
- data/assets/picker.js +1 -1
- data/assets/tracker.js +1 -1
- data/lib/runlight/accounts/accounts.rb +8 -6
- data/lib/runlight/assistant.rb +37 -16
- data/lib/runlight/core.rb +125 -34
- data/lib/runlight/dates.rb +31 -8
- data/lib/runlight/db/connect.rb +27 -4
- data/lib/runlight/db/db.rb +52 -4
- data/lib/runlight/db/prefixed.rb +92 -0
- data/lib/runlight/geo.rb +4 -3
- data/lib/runlight/http/fetcher.rb +27 -5
- data/lib/runlight/mail/ses.rb +3 -2
- data/lib/runlight/mail/transports.rb +19 -2
- data/lib/runlight/metadata_address_error.rb +13 -0
- data/lib/runlight/oauth.rb +83 -15
- data/lib/runlight/rack_app.rb +11 -4
- data/lib/runlight/redirect_refused_error.rb +14 -0
- data/lib/runlight/routes.rb +208 -92
- data/lib/runlight/safefetch.rb +98 -1
- data/lib/runlight/server/agents.rb +1 -1
- data/lib/runlight/server/cli.rb +1 -1
- data/lib/runlight/server/config.rb +64 -19
- data/lib/runlight/server/standalone.rb +7 -6
- data/lib/runlight/store/sql.rb +10 -0
- data/lib/runlight/store/sql_store.rb +75 -13
- data/lib/runlight/stores.rb +19 -15
- data/lib/runlight/tables_busy_error.rb +11 -0
- data/lib/runlight/version.rb +1 -1
- data/lib/runlight.rb +4 -0
- metadata +7 -2
data/lib/runlight/db/db.rb
CHANGED
|
@@ -22,6 +22,23 @@ module Runlight
|
|
|
22
22
|
# Arbitrary but fixed, so every Runlight process takes the same lock to create tables.
|
|
23
23
|
MIGRATION_LOCK = 7_331_906
|
|
24
24
|
|
|
25
|
+
# The longest a process waits for another one creating or upgrading the tables before it gives up.
|
|
26
|
+
LOCK_WAIT_MS = 30_000
|
|
27
|
+
|
|
28
|
+
@lock_wait = LOCK_WAIT_MS
|
|
29
|
+
|
|
30
|
+
class << self
|
|
31
|
+
# How long the lock for creating tables is waited for: LOCK_WAIT_MS, unless a test has set another.
|
|
32
|
+
def lock_wait_ms
|
|
33
|
+
@lock_wait
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# For tests: the lock is waited for this long instead, or LOCK_WAIT_MS again with nil.
|
|
37
|
+
def lock_wait_in_tests(ms)
|
|
38
|
+
@lock_wait = ms || LOCK_WAIT_MS
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
25
42
|
# One lock per MySQL database, so installs sharing a server do not wait on each other. Lock names are 64
|
|
26
43
|
# characters at most.
|
|
27
44
|
MYSQL_LOCK = "CONCAT('runlight:', LEFT(SHA2(COALESCE(DATABASE(), ''), 256), 48))"
|
|
@@ -34,6 +51,9 @@ module Runlight
|
|
|
34
51
|
# they do not linger in the file's free space. Rails' pragmas setting takes this one only as on or off.
|
|
35
52
|
SECURE_DELETE = "PRAGMA secure_delete = FAST"
|
|
36
53
|
|
|
54
|
+
# How long a SQLite connection waits for a lock another one holds, as Connect.sqlite sets it.
|
|
55
|
+
BUSY_TIMEOUT_MS = 5000
|
|
56
|
+
|
|
37
57
|
attr_reader :connection_class
|
|
38
58
|
|
|
39
59
|
# connection_class: an ActiveRecord::Base subclass whose pool to use (ActiveRecord::Base for the app's
|
|
@@ -106,7 +126,8 @@ module Runlight
|
|
|
106
126
|
end
|
|
107
127
|
|
|
108
128
|
# Runs the block while holding a database-wide lock, so two processes starting at once do not race to
|
|
109
|
-
# create the same tables.
|
|
129
|
+
# create the same tables. Waits for the lock at most LOCK_WAIT_MS, then raises a TablesBusyError. Yields
|
|
130
|
+
# this database.
|
|
110
131
|
def exclusive(&block)
|
|
111
132
|
connection do |c|
|
|
112
133
|
@held += 1
|
|
@@ -249,9 +270,20 @@ module Runlight
|
|
|
249
270
|
|
|
250
271
|
c.execute(dialect == "sqlite" ? SECURE_DELETE : NO_BACKSLASH_ESCAPES_OFF, "Runlight")
|
|
251
272
|
raw = c.instance_variable_get(:@raw_connection)
|
|
273
|
+
wait_without_the_lock(raw) if dialect == "sqlite"
|
|
252
274
|
@set_up[raw] = true if raw
|
|
253
275
|
end
|
|
254
276
|
|
|
277
|
+
# Rails 7.2 sets SQLite's busy timeout with busy_timeout, which sleeps in C holding Ruby's global lock. A
|
|
278
|
+
# thread waiting for the file's write lock then stops the thread of the same process that holds it from
|
|
279
|
+
# ever finishing, so the wait runs out and raises "database is locked". busy_handler_timeout= (sqlite3
|
|
280
|
+
# 2.x, and what Rails 8 uses) waits the same time but lets other threads run.
|
|
281
|
+
def wait_without_the_lock(raw)
|
|
282
|
+
return unless raw.respond_to?(:busy_handler_timeout=)
|
|
283
|
+
|
|
284
|
+
raw.busy_handler_timeout = BUSY_TIMEOUT_MS
|
|
285
|
+
end
|
|
286
|
+
|
|
255
287
|
# A statement on a connection. A connection the server dropped (a restart, a failover, an idle timeout)
|
|
256
288
|
# is replaced, as a pool replaces it, and the statement sent again: it never reached the server. Not
|
|
257
289
|
# inside a transaction or a lock, whose work went with the connection.
|
|
@@ -298,7 +330,14 @@ module Runlight
|
|
|
298
330
|
if dialect == "postgres"
|
|
299
331
|
# Asked for again and again rather than waited on: a waiting statement would hold up an index being
|
|
300
332
|
# built CONCURRENTLY by whoever has the lock, and the two would wait on each other for good.
|
|
301
|
-
|
|
333
|
+
# Up to LOCK_WAIT_MS, so a request does not wait on a long index build for good.
|
|
334
|
+
wait = self.class.lock_wait_ms
|
|
335
|
+
until_at = monotonic_ms + wait
|
|
336
|
+
until truthy(value(c, "SELECT pg_try_advisory_lock(#{MIGRATION_LOCK}) AS ok"))
|
|
337
|
+
raise TablesBusyError, wait if monotonic_ms >= until_at
|
|
338
|
+
|
|
339
|
+
sleep 0.1
|
|
340
|
+
end
|
|
302
341
|
begin
|
|
303
342
|
return yield(self)
|
|
304
343
|
ensure
|
|
@@ -309,11 +348,16 @@ module Runlight
|
|
|
309
348
|
end
|
|
310
349
|
end
|
|
311
350
|
end
|
|
351
|
+
# Up to LOCK_WAIT_MS, so a request does not wait on a long index build for good.
|
|
352
|
+
wait = self.class.lock_wait_ms
|
|
353
|
+
until_at = monotonic_ms + wait
|
|
312
354
|
loop do
|
|
313
|
-
|
|
355
|
+
left = ((until_at - monotonic_ms) / 1000.0).ceil.clamp(1, 5)
|
|
356
|
+
ok = value(c, "SELECT GET_LOCK(#{MYSQL_LOCK}, #{left}) AS ok")
|
|
314
357
|
raise "Runlight: MySQL refused the lock for creating tables" if ok.nil?
|
|
315
358
|
break if ok.to_i == 1
|
|
316
|
-
# Not got
|
|
359
|
+
# Not got in time: another process is creating the tables. Ask again, until the wait is up.
|
|
360
|
+
raise TablesBusyError, wait if monotonic_ms >= until_at
|
|
317
361
|
end
|
|
318
362
|
begin
|
|
319
363
|
# An index on a big table takes a while to build, so the build may run past the statement timeout.
|
|
@@ -330,6 +374,10 @@ module Runlight
|
|
|
330
374
|
end
|
|
331
375
|
end
|
|
332
376
|
|
|
377
|
+
def monotonic_ms
|
|
378
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC, :millisecond)
|
|
379
|
+
end
|
|
380
|
+
|
|
333
381
|
# The first column of a statement's first row, always asked of the database. select_value goes through
|
|
334
382
|
# Rails' query cache, which a Rails request turns on for every pool, so a poll for a lock would see its
|
|
335
383
|
# first answer again and again; exec_query is never cached.
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Runlight
|
|
4
|
+
module Db
|
|
5
|
+
# A database whose tables and indexes start with a prefix of the app's choosing in place of `rl_`, such as
|
|
6
|
+
# `wp_rl_` beside a WordPress database's tables, so two apps sharing one database each keep a set of their
|
|
7
|
+
# own. Runlight's SQL names every table and index `rl_` and something, and those names alone are rewritten
|
|
8
|
+
# on the way through, in each statement and in the names a schema check compares as text. A store given a
|
|
9
|
+
# prefix puts one in front of its database, so the accounts and importers that reach the database through
|
|
10
|
+
# the store follow it too. Values bound to `?` are never rewritten.
|
|
11
|
+
class Prefixed
|
|
12
|
+
# Whatever else the database underneath has (close, limit_statements, connection_class) is passed on.
|
|
13
|
+
OPTIONAL = %i[affected turn metered?].freeze
|
|
14
|
+
private_constant :OPTIONAL
|
|
15
|
+
|
|
16
|
+
attr_reader :inner, :prefix
|
|
17
|
+
|
|
18
|
+
def initialize(inner, prefix)
|
|
19
|
+
# A name in a statement, and short enough that the longest index name stays inside MySQL's 64.
|
|
20
|
+
unless prefix.is_a?(String) && prefix.match?(/\A[A-Za-z_][A-Za-z0-9_]{0,31}\z/)
|
|
21
|
+
raise ArgumentError, "Runlight: a table prefix is up to 32 letters, digits, and underscores, and does not start with a digit"
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
@inner = inner
|
|
25
|
+
@prefix = prefix
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# A statement with each of Runlight's names under the prefix.
|
|
29
|
+
def sql(text)
|
|
30
|
+
text.gsub(/\brl_(?=[a-z])/, @prefix)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def dialect
|
|
34
|
+
@inner.dialect
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def all(text, params = [])
|
|
38
|
+
@inner.all(sql(text), params)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def run(text, params = [])
|
|
42
|
+
@inner.run(sql(text), params)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def affected(text, params = [])
|
|
46
|
+
@inner.affected(sql(text), params)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def metered?
|
|
50
|
+
@inner.metered?
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def transaction
|
|
54
|
+
@inner.transaction { |db| yield wrap(db) }
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def exclusive
|
|
58
|
+
@inner.exclusive { |db| yield wrap(db) }
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def turn
|
|
62
|
+
@inner.turn { |db| yield wrap(db) }
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def close
|
|
66
|
+
@inner.close
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def respond_to?(name, include_all = false)
|
|
70
|
+
return @inner.respond_to?(name, include_all) if OPTIONAL.include?(name.to_sym)
|
|
71
|
+
|
|
72
|
+
super
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
private
|
|
76
|
+
|
|
77
|
+
def wrap(db)
|
|
78
|
+
db.equal?(@inner) ? self : Prefixed.new(db, @prefix)
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def method_missing(name, ...)
|
|
82
|
+
return super unless @inner.respond_to?(name)
|
|
83
|
+
|
|
84
|
+
@inner.public_send(name, ...)
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def respond_to_missing?(name, include_all = false)
|
|
88
|
+
@inner.respond_to?(name, include_all) || super
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
end
|
data/lib/runlight/geo.rb
CHANGED
|
@@ -102,9 +102,10 @@ module Runlight
|
|
|
102
102
|
end
|
|
103
103
|
|
|
104
104
|
# The location of a request: the platform's headers when they name a country, else the lookup's answer
|
|
105
|
-
# for the address, else nowhere.
|
|
106
|
-
|
|
107
|
-
|
|
105
|
+
# for the address, else nowhere. The headers are believed only with trust_headers, as the address is: without
|
|
106
|
+
# a proxy in front, a client writes them itself.
|
|
107
|
+
def locate(headers, ip, lookup = nil, trust_headers = true)
|
|
108
|
+
from_headers = trust_headers ? location_from_headers(headers) : nil
|
|
108
109
|
return from_headers if !from_headers.nil? && from_headers["country"] != ""
|
|
109
110
|
|
|
110
111
|
if !lookup.nil? && ip != ""
|
|
@@ -58,6 +58,20 @@ module Runlight
|
|
|
58
58
|
|
|
59
59
|
private
|
|
60
60
|
|
|
61
|
+
# Opens the connection. A name pinned to several addresses is tried address by address, as a connection
|
|
62
|
+
# by name is, so a host that listens on one of them is reached; only the failure of the last is raised.
|
|
63
|
+
def connect(http, addresses)
|
|
64
|
+
tries = addresses.empty? ? [nil] : addresses
|
|
65
|
+
tries.each_with_index do |address, at|
|
|
66
|
+
http.ipaddr = address unless address.nil?
|
|
67
|
+
begin
|
|
68
|
+
return http.start
|
|
69
|
+
rescue SystemCallError, SocketError, Net::OpenTimeout
|
|
70
|
+
raise if at == tries.length - 1
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
|
|
61
75
|
def monotonic
|
|
62
76
|
Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
63
77
|
end
|
|
@@ -78,13 +92,18 @@ module Runlight
|
|
|
78
92
|
http = pinned ? Net::HTTP.new(host, port, nil) : Net::HTTP.new(host, port)
|
|
79
93
|
http.use_ssl = https
|
|
80
94
|
# A pin connects to the checked address, with the name kept for the Host header and the certificate.
|
|
95
|
+
addresses = []
|
|
81
96
|
Array(init["resolve"]).each do |pin|
|
|
82
|
-
|
|
83
|
-
next unless
|
|
97
|
+
lead = "#{host}:#{port}:"
|
|
98
|
+
next unless pin.to_s.length > lead.length && pin.to_s[0, lead.length].casecmp?(lead)
|
|
84
99
|
|
|
85
|
-
|
|
100
|
+
addresses = pin.to_s[lead.length..].split(",").map { |address| address.delete_prefix("[").delete_suffix("]") }.reject(&:empty?)
|
|
101
|
+
http.ipaddr = addresses.first
|
|
86
102
|
break
|
|
87
103
|
end
|
|
104
|
+
# A pin that names another host or port would leave the name to be looked up again: refused, never
|
|
105
|
+
# fetched unpinned.
|
|
106
|
+
raise FetchError, "fetch failed" if pinned && http.ipaddr.nil?
|
|
88
107
|
http.open_timeout = [left, 15].min
|
|
89
108
|
http.read_timeout = left
|
|
90
109
|
http.write_timeout = left
|
|
@@ -108,8 +127,9 @@ module Runlight
|
|
|
108
127
|
# Past maxBytes the whole request is left by a throw, which closes the connection: a break would leave
|
|
109
128
|
# only read_body, and Net::HTTP would then read the rest of the body after all.
|
|
110
129
|
catch(:too_long) do
|
|
111
|
-
http
|
|
112
|
-
|
|
130
|
+
connect(http, addresses)
|
|
131
|
+
begin
|
|
132
|
+
http.request(request) do |answer|
|
|
113
133
|
status = answer.code.to_i
|
|
114
134
|
answer.each_capitalized_name { |name| answer_headers[name.downcase] = answer.get_fields(name) }
|
|
115
135
|
answer.read_body do |chunk|
|
|
@@ -123,6 +143,8 @@ module Runlight
|
|
|
123
143
|
received << chunk
|
|
124
144
|
end
|
|
125
145
|
end
|
|
146
|
+
ensure
|
|
147
|
+
http.finish if http.started?
|
|
126
148
|
end
|
|
127
149
|
end
|
|
128
150
|
rescue Net::OpenTimeout, Net::ReadTimeout, Net::WriteTimeout, Timeout::Error
|
data/lib/runlight/mail/ses.rb
CHANGED
|
@@ -79,13 +79,14 @@ module Runlight
|
|
|
79
79
|
})
|
|
80
80
|
headers.delete("host")
|
|
81
81
|
begin
|
|
82
|
-
response = (fetcher || Http::NetFetcher.new).fetch(url.href, { "method" => "POST", "headers" => headers, "body" => body, "timeoutMs" => 20_000
|
|
82
|
+
response = (fetcher || Http::NetFetcher.new).fetch(url.href, { "method" => "POST", "headers" => headers, "body" => body, "timeoutMs" => 20_000,
|
|
83
|
+
"maxBytes" => Transports::MAX_ERROR_BYTES + 1, "truncate" => true })
|
|
83
84
|
rescue Http::FetchError => e
|
|
84
85
|
raise MailError.new("Could not reach Amazon SES: #{e.message}", "mail_unreachable", { "host" => "Amazon SES", "detail" => e.message })
|
|
85
86
|
end
|
|
86
87
|
return if response.ok?
|
|
87
88
|
|
|
88
|
-
message = Transports.service_message(response
|
|
89
|
+
message = Transports.service_message(Transports.error_text(response))
|
|
89
90
|
raise MailError.new("Amazon SES answered #{response.status}#{message.empty? ? "" : ": #{message}"}", "mail_refused",
|
|
90
91
|
{ "host" => "Amazon SES", "detail" => "#{response.status}#{message.empty? ? "" : " #{message}"}" })
|
|
91
92
|
end
|
|
@@ -81,17 +81,34 @@ module Runlight
|
|
|
81
81
|
""
|
|
82
82
|
end
|
|
83
83
|
|
|
84
|
+
# The most of a service's error read, so an answer without end never fills memory. One byte more is asked of
|
|
85
|
+
# the fetcher, which hands back what it read, so that an answer past the limit is seen as such and read as empty.
|
|
86
|
+
MAX_ERROR_BYTES = 65_536
|
|
87
|
+
|
|
88
|
+
# A service's error text, or none when it runs past MAX_ERROR_BYTES.
|
|
89
|
+
def error_text(response)
|
|
90
|
+
Body.read_text_capped(response, MAX_ERROR_BYTES)
|
|
91
|
+
rescue Http::BodyTooLong
|
|
92
|
+
""
|
|
93
|
+
end
|
|
94
|
+
|
|
84
95
|
# POSTs to a service, with the errors the dashboard shows. init: "headers" and "body".
|
|
85
96
|
def post(fetcher, url, init, explains = true)
|
|
86
97
|
begin
|
|
87
|
-
|
|
98
|
+
# A webhook can be at any address, so it and each redirect are checked, as for every service.
|
|
99
|
+
response = Safefetch.service_fetch(url, { "method" => "POST", "headers" => init["headers"], "body" => init["body"], "timeoutMs" => 20_000,
|
|
100
|
+
"maxBytes" => MAX_ERROR_BYTES + 1, "truncate" => true }, fetcher)
|
|
101
|
+
rescue MetadataAddressError => e
|
|
102
|
+
raise MailError.new(e.message, "address_refused", { "host" => e.host })
|
|
103
|
+
rescue RedirectRefusedError => e
|
|
104
|
+
raise MailError.new(e.message, "redirect_refused", { "host" => e.host, "to" => e.to })
|
|
88
105
|
rescue Http::FetchError => e
|
|
89
106
|
host = Http::Url.new(url).host
|
|
90
107
|
raise MailError.new("Could not reach #{host}: #{e.message}", "mail_unreachable", { "host" => host, "detail" => e.message })
|
|
91
108
|
end
|
|
92
109
|
return if response.ok?
|
|
93
110
|
|
|
94
|
-
message = explains ? service_message(response
|
|
111
|
+
message = explains ? service_message(error_text(response)) : ""
|
|
95
112
|
host = Http::Url.new(url).host
|
|
96
113
|
raise MailError.new("#{host} answered #{response.status}#{message.empty? ? "" : ": #{message}"}", "mail_refused",
|
|
97
114
|
{ "host" => host, "detail" => "#{response.status}#{message.empty? ? "" : " #{message}"}" })
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Runlight
|
|
4
|
+
# Refused before anything was fetched, because the address is link-local or a cloud's metadata service.
|
|
5
|
+
class MetadataAddressError < RuntimeError
|
|
6
|
+
attr_reader :host
|
|
7
|
+
|
|
8
|
+
def initialize(host)
|
|
9
|
+
@host = host
|
|
10
|
+
super("#{host} is a link-local or cloud metadata address, which Runlight never connects to")
|
|
11
|
+
end
|
|
12
|
+
end
|
|
13
|
+
end
|
data/lib/runlight/oauth.rb
CHANGED
|
@@ -16,7 +16,10 @@ module Runlight
|
|
|
16
16
|
# The token is an ordinary API token, so it appears in Settings, API and AI,
|
|
17
17
|
# beside the others, and deleting it there disconnects the app. It reads
|
|
18
18
|
# stats, or with the "manage" scope (asked for by a Runlight hub) it also
|
|
19
|
-
# changes one site's settings.
|
|
19
|
+
# changes one site's settings. With the "cms" scope (asked for by the
|
|
20
|
+
# WordPress, Drupal, and Craft plugins) it is the site's dashboard key, which
|
|
21
|
+
# only opens its read-only dashboard in the CMS, and the answer also carries
|
|
22
|
+
# the site's agents key, so connecting fills in the plugin's settings.
|
|
20
23
|
#
|
|
21
24
|
# The context the routes pass is a Hash: "runlight", "base", "isOwner" (callable(request) giving a bool), and
|
|
22
25
|
# optionally "signIn" (String), "isReader" (callable(request) giving a bool), "accountOf" (callable(request)
|
|
@@ -29,6 +32,7 @@ module Runlight
|
|
|
29
32
|
REGISTRATIONS_PER_MINUTE = 10
|
|
30
33
|
# The longest client id, which carries the app's name and redirect addresses.
|
|
31
34
|
MAX_CLIENT_ID = 2048
|
|
35
|
+
SCOPES = %w[read manage cms].freeze
|
|
32
36
|
|
|
33
37
|
# Where the per-address count of registrations is kept. TypeScript keeps it in memory per install; here it
|
|
34
38
|
# lives in the install's settings, keyed by a hash of the address, so every process shares it.
|
|
@@ -41,7 +45,7 @@ module Runlight
|
|
|
41
45
|
}.freeze
|
|
42
46
|
|
|
43
47
|
ESCAPES = { "&" => "&", "<" => "<", ">" => ">", '"' => """, "'" => "'" }.freeze
|
|
44
|
-
private_constant :REGISTRATIONS, :CORS, :ESCAPES
|
|
48
|
+
private_constant :REGISTRATIONS, :CORS, :ESCAPES, :SCOPES
|
|
45
49
|
|
|
46
50
|
module_function
|
|
47
51
|
|
|
@@ -50,6 +54,16 @@ module Runlight
|
|
|
50
54
|
base64url(OpenSSL::Digest::SHA256.digest(verifier))
|
|
51
55
|
end
|
|
52
56
|
|
|
57
|
+
# The key a CMS plugin reports AI agents reading a site's pages with: made on first ask, or a new one that replaces it.
|
|
58
|
+
def agents_key(runlight, site, replace = false)
|
|
59
|
+
name = "observe-key:#{site}"
|
|
60
|
+
return runlight.store.setting_or_add(name) { "rlo_#{Hashing.random_id(20)}" } unless replace
|
|
61
|
+
|
|
62
|
+
key = "rlo_#{Hashing.random_id(20)}"
|
|
63
|
+
runlight.store.set_setting(name, key)
|
|
64
|
+
key
|
|
65
|
+
end
|
|
66
|
+
|
|
53
67
|
# The URL that a 401 from the MCP endpoint points clients at, to start OAuth.
|
|
54
68
|
def resource_metadata_url(origin, base)
|
|
55
69
|
"#{origin}#{base}/.well-known/oauth-protected-resource"
|
|
@@ -71,7 +85,7 @@ module Runlight
|
|
|
71
85
|
end
|
|
72
86
|
|
|
73
87
|
if known.start_with?("/.well-known/oauth-protected-resource")
|
|
74
|
-
return json({ "resource" => "#{issuer}/mcp", "authorization_servers" => [issuer], "scopes_supported" =>
|
|
88
|
+
return json({ "resource" => "#{issuer}/mcp", "authorization_servers" => [issuer], "scopes_supported" => SCOPES, "bearer_methods_supported" => ["header"] })
|
|
75
89
|
end
|
|
76
90
|
if known.start_with?("/.well-known/oauth-authorization-server") || known.start_with?("/.well-known/openid-configuration")
|
|
77
91
|
return json({
|
|
@@ -83,7 +97,7 @@ module Runlight
|
|
|
83
97
|
"grant_types_supported" => ["authorization_code"],
|
|
84
98
|
"code_challenge_methods_supported" => ["S256"],
|
|
85
99
|
"token_endpoint_auth_methods_supported" => ["none"],
|
|
86
|
-
"scopes_supported" =>
|
|
100
|
+
"scopes_supported" => SCOPES,
|
|
87
101
|
})
|
|
88
102
|
end
|
|
89
103
|
|
|
@@ -141,8 +155,15 @@ module Runlight
|
|
|
141
155
|
return refuse.call({ "error" => "invalid_request", "error_description" => "PKCE with S256 is required" })
|
|
142
156
|
end
|
|
143
157
|
|
|
144
|
-
|
|
158
|
+
scopes = (form.get("scope") || "").split(/[#{Js::SPACE}]+/o)
|
|
159
|
+
cms = scopes.include?("cms")
|
|
160
|
+
manage = !cms && scopes.include?("manage")
|
|
145
161
|
name = client["name"].to_s
|
|
162
|
+
# A CMS plugin is for the site it runs on: one of the sites counting its admin's domain, or a new one for it.
|
|
163
|
+
host = site_host(redirect)
|
|
164
|
+
# As the tracker counts: a site that lists the domain, or the only site here when it lists none, which counts any.
|
|
165
|
+
local = runlight.sites.select { |s| runlight.remote(s["id"]).nil? }
|
|
166
|
+
counting = local.length == 1 && local[0]["hostnames"].empty? ? local : local.select { |s| s["hostnames"].include?(host) }
|
|
146
167
|
|
|
147
168
|
unless ctx["isOwner"].call(request)
|
|
148
169
|
# Someone signed in who may only read would be sent to sign in again and again.
|
|
@@ -169,6 +190,27 @@ module Runlight
|
|
|
169
190
|
end
|
|
170
191
|
# The app names itself, so the page also shows where the answer goes, which it cannot fake.
|
|
171
192
|
sends_to = "<p class=\"note\">Allowing sends you back to <strong>#{esc(Http::Url.new(redirect).host)}</strong>. Only allow it if you started connecting there.</p>"
|
|
193
|
+
if cms
|
|
194
|
+
if counting.empty? && !runlight.managed_sites
|
|
195
|
+
return page("Add this site first",
|
|
196
|
+
"<p>No site in this Runlight counts <strong>#{esc(host)}</strong>. Add it to a site's domains in your code, then connect again from #{esc(name)}.</p>", 400)
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
choices = +""
|
|
200
|
+
counting.each { |s| choices << "<option value=\"#{esc(s["id"])}\">#{esc(s["name"])}</option>" }
|
|
201
|
+
choices << "<option value=\"new\">A new site for #{esc(host)}</option>" if runlight.managed_sites && counting.empty?
|
|
202
|
+
return page(
|
|
203
|
+
"Connect #{esc(name)}",
|
|
204
|
+
"<p><strong>#{esc(name)}</strong> wants to count <strong>#{esc(host)}</strong> in this Runlight.</p>\n" \
|
|
205
|
+
"<p>It will get the site's agents key, to report AI agents such as ChatGPT and Claude reading its pages, and a dashboard key, to show the site's " \
|
|
206
|
+
"read-only dashboard in its admin pages. It cannot change anything here or read other sites.</p>\n" \
|
|
207
|
+
"#{sends_to}\n" \
|
|
208
|
+
"<form method=\"post\" action=\"#{esc(base)}/oauth/authorize\">#{hidden}\n" \
|
|
209
|
+
"<label>Site<select name=\"site\">#{choices}</select></label>\n" \
|
|
210
|
+
"<p class=\"note\">Its dashboard key appears in Settings, API and AI, where deleting it stops the dashboard in #{esc(name)}.</p>\n" \
|
|
211
|
+
'<div class="buttons"><button type="submit" name="decision" value="deny" class="ghost">Deny</button><button type="submit" name="decision" value="allow">Allow</button></div></form>',
|
|
212
|
+
)
|
|
213
|
+
end
|
|
172
214
|
sites = runlight.sites
|
|
173
215
|
if manage
|
|
174
216
|
# Changing settings is for one site at a time, so there is no "every site" here.
|
|
@@ -209,6 +251,22 @@ module Runlight
|
|
|
209
251
|
return back.call({ "error" => "access_denied" }) if form.get("decision") != "allow"
|
|
210
252
|
|
|
211
253
|
site = form.get("site") || ""
|
|
254
|
+
if cms
|
|
255
|
+
if site == "new"
|
|
256
|
+
begin
|
|
257
|
+
site = if !counting.empty? then counting[0]["id"]
|
|
258
|
+
elsif runlight.managed_sites then runlight.add_site({ "hostnames" => [host] })["id"]
|
|
259
|
+
else ""
|
|
260
|
+
end
|
|
261
|
+
rescue RangeError => e
|
|
262
|
+
# Such as an address, which a site cannot count by: there is no new site to make.
|
|
263
|
+
return back.call({ "error" => "invalid_request", "error_description" => e.message })
|
|
264
|
+
end
|
|
265
|
+
elsif counting.none? { |s| s["id"] == site }
|
|
266
|
+
site = ""
|
|
267
|
+
end
|
|
268
|
+
return back.call({ "error" => "invalid_request", "error_description" => "Pick a site that counts #{host}" }) if site == ""
|
|
269
|
+
end
|
|
212
270
|
return back.call({ "error" => "invalid_request", "error_description" => "Unknown site" }) if site != "" && runlight.site(site).nil?
|
|
213
271
|
if manage && (site == "" || !runlight.remote(site).nil?)
|
|
214
272
|
return back.call({ "error" => "invalid_request", "error_description" => "Pick the site to manage" })
|
|
@@ -216,7 +274,7 @@ module Runlight
|
|
|
216
274
|
|
|
217
275
|
code = Hashing.random_id(32)
|
|
218
276
|
by = ctx["accountOf"]&.call(request)
|
|
219
|
-
grant = { "client" => client_id, "redirect" => redirect, "challenge" => challenge, "site" => site, "scope" => manage ? "manage" : "read",
|
|
277
|
+
grant = { "client" => client_id, "redirect" => redirect, "challenge" => challenge, "site" => site, "scope" => cms ? "cms" : manage ? "manage" : "read",
|
|
220
278
|
"expires" => runlight.now + CODE_MS }
|
|
221
279
|
grant["by"] = by if !by.nil? && by != ""
|
|
222
280
|
runlight.store.set_setting("oauth-code:#{Hashing.sha256(code)}", Json.encode(grant))
|
|
@@ -248,12 +306,16 @@ module Runlight
|
|
|
248
306
|
runlight.store.set_setting(found["usedKey"], stored_client ? Json.encode(used) : runlight.now.to_s)
|
|
249
307
|
end
|
|
250
308
|
secret = "rl_#{Hashing.random_id(20)}"
|
|
251
|
-
scope = grant["scope"] == "
|
|
309
|
+
scope = if grant["scope"] == "cms" then "cms"
|
|
310
|
+
elsif grant["scope"] == "manage" then "manage"
|
|
311
|
+
else "read"
|
|
312
|
+
end
|
|
313
|
+
# A CMS plugin's token is the site's dashboard key, which only opens its read-only dashboard there.
|
|
252
314
|
row = {
|
|
253
315
|
"id" => Hashing.random_id,
|
|
254
316
|
"name" => Js.slice("#{Js.string(client["name"].nil? ? "An app" : client["name"])} (OAuth)", 0, 100),
|
|
255
317
|
"site" => grant["site"].to_s,
|
|
256
|
-
"scope" => scope,
|
|
318
|
+
"scope" => scope == "cms" ? "embed" : scope,
|
|
257
319
|
"hash" => Hashing.sha256(secret),
|
|
258
320
|
"hint" => secret[-4..],
|
|
259
321
|
"createdAt" => runlight.now,
|
|
@@ -267,6 +329,11 @@ module Runlight
|
|
|
267
329
|
end
|
|
268
330
|
# A hub's own address, from where it asked to be sent back, so the picker only ever sends choices there.
|
|
269
331
|
runlight.store.set_setting("token-origin:#{row["id"]}", Http::Url.new(grant["redirect"].to_s).origin) if scope == "manage"
|
|
332
|
+
# A CMS plugin gets the site's agents key too, so connecting fills in every key its settings ask for.
|
|
333
|
+
if scope == "cms"
|
|
334
|
+
return json({ "access_token" => secret, "token_type" => "Bearer", "scope" => scope, "site" => grant["site"],
|
|
335
|
+
"agents_key" => agents_key(runlight, grant["site"].to_s) })
|
|
336
|
+
end
|
|
270
337
|
# site is not part of OAuth, but a hub needs to know which site it was given.
|
|
271
338
|
answer = { "access_token" => secret, "token_type" => "Bearer", "scope" => scope }
|
|
272
339
|
answer["site"] = grant["site"] if grant["site"] != ""
|
|
@@ -286,12 +353,8 @@ module Runlight
|
|
|
286
353
|
|
|
287
354
|
# The key client ids are signed with, made on first use and kept in the database for every process.
|
|
288
355
|
def client_key(runlight)
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
made = Hashing.random_id(32)
|
|
293
|
-
runlight.store.set_setting("oauth-key", made)
|
|
294
|
-
made
|
|
356
|
+
# Written only if no other request wrote one first, so every process signs with the same key.
|
|
357
|
+
runlight.store.setting_or_add("oauth-key") { Hashing.random_id(32) }
|
|
295
358
|
end
|
|
296
359
|
|
|
297
360
|
# The app a client id names, and where to note that it connected: {"client" => ..., "usedKey" => ...}, or nil.
|
|
@@ -314,6 +377,11 @@ module Runlight
|
|
|
314
377
|
{ "client" => client, "usedKey" => used_key }
|
|
315
378
|
end
|
|
316
379
|
|
|
380
|
+
# A redirect address's host as a site's domains are kept: lowercase, with no port and no www.
|
|
381
|
+
def site_host(redirect)
|
|
382
|
+
Http::Url.new(redirect).hostname.downcase.sub(/\.+\z/, "").sub(/\Awww\./, "")
|
|
383
|
+
end
|
|
384
|
+
|
|
317
385
|
def constant_time_equal(a, b)
|
|
318
386
|
a.bytesize == b.bytesize && OpenSSL.fixed_length_secure_compare(a, b)
|
|
319
387
|
end
|
|
@@ -405,7 +473,7 @@ module Runlight
|
|
|
405
473
|
)
|
|
406
474
|
end
|
|
407
475
|
|
|
408
|
-
private_class_method :base64url, :from_base64url, :client_key, :client_for, :constant_time_equal, :esc, :json, :oauth_error,
|
|
476
|
+
private_class_method :base64url, :from_base64url, :client_key, :client_for, :site_host, :constant_time_equal, :esc, :json, :oauth_error,
|
|
409
477
|
:allowed_redirect, :json_form, :allow_registration, :register, :page
|
|
410
478
|
end
|
|
411
479
|
end
|
data/lib/runlight/rack_app.rb
CHANGED
|
@@ -5,9 +5,10 @@ module Runlight
|
|
|
5
5
|
# in the order the standalone server answers: a link domain added in Settings
|
|
6
6
|
# first (it leaves the dashboard's own paths alone), then `{linkPath}/{slug}`
|
|
7
7
|
# on the app's own domain, then the dashboard and API under the routes' base
|
|
8
|
-
# path. As middleware, every other request goes on to the app,
|
|
9
|
-
#
|
|
10
|
-
#
|
|
8
|
+
# path. As middleware, every other request goes on to the app, a short link
|
|
9
|
+
# with no link by that name included, so the app's own 404 page answers it,
|
|
10
|
+
# after AI agent fetches among them are recorded (observe), which returns at
|
|
11
|
+
# once for everyone else. Alone, Runlight answers those with its own 404.
|
|
11
12
|
#
|
|
12
13
|
# # config.ru, Runlight alone
|
|
13
14
|
# run Runlight::RackApp.new(runlight: RL, base_path: "/runlight")
|
|
@@ -86,7 +87,13 @@ module Runlight
|
|
|
86
87
|
linked = core.link_domain_response(request, context)
|
|
87
88
|
return linked unless linked.nil?
|
|
88
89
|
end
|
|
89
|
-
|
|
90
|
+
if link
|
|
91
|
+
linked = core.link_handler.call(request, context)
|
|
92
|
+
# A slug with no link is the app's to answer, so its own 404 page shows.
|
|
93
|
+
return linked unless @app && linked.status == 404
|
|
94
|
+
|
|
95
|
+
own = mine?(path) || Routes.oauth_document?(path)
|
|
96
|
+
end
|
|
90
97
|
return routes.handle(request) if own
|
|
91
98
|
|
|
92
99
|
nil
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Runlight
|
|
4
|
+
# Refused before anything was sent there, because a service's redirect led to another origin.
|
|
5
|
+
class RedirectRefusedError < RuntimeError
|
|
6
|
+
attr_reader :host, :to
|
|
7
|
+
|
|
8
|
+
def initialize(host, to)
|
|
9
|
+
@host = host
|
|
10
|
+
@to = to
|
|
11
|
+
super("#{host} redirected to #{to}. Runlight sends a service's key and request only to the address it was set up with, so set it up with that address instead")
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
end
|