runlight 0.1.0 → 0.1.1
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 +3 -3
- data/assets/dashboard.js +17 -9
- data/assets/locales.json +5 -5
- data/lib/runlight/accounts/accounts.rb +82 -13
- data/lib/runlight/accounts/throttle.rb +123 -38
- data/lib/runlight/accounts/web.rb +50 -14
- data/lib/runlight/assistant.rb +11 -0
- data/lib/runlight/connect.rb +2 -3
- data/lib/runlight/core.rb +23 -0
- data/lib/runlight/db/connect.rb +5 -2
- data/lib/runlight/db/db.rb +79 -7
- data/lib/runlight/http/fetcher.rb +4 -1
- data/lib/runlight/http/request.rb +14 -3
- data/lib/runlight/importers/csv_visits.rb +1 -1
- data/lib/runlight/importers/index.rb +10 -2
- data/lib/runlight/importers/visits.rb +17 -6
- data/lib/runlight/importers/write.rb +21 -6
- data/lib/runlight/links.rb +14 -9
- data/lib/runlight/mail/smtp.rb +2 -0
- data/lib/runlight/mail/smtp_session.rb +22 -1
- data/lib/runlight/mcp.rb +8 -1
- data/lib/runlight/oauth.rb +2 -3
- data/lib/runlight/rack_app.rb +54 -7
- data/lib/runlight/routes.rb +101 -55
- data/lib/runlight/safefetch.rb +35 -0
- data/lib/runlight/version.rb +1 -1
- metadata +2 -2
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "monitor"
|
|
4
|
+
|
|
3
5
|
module Runlight
|
|
4
6
|
module Accounts
|
|
5
7
|
# Accounts: who may sign in, their password hashes, two-factor, invites, and the signed cookie that keeps them
|
|
@@ -32,14 +34,50 @@ module Runlight
|
|
|
32
34
|
# A password checked against nothing, so a wrong email takes as long as a wrong password. Made when first needed.
|
|
33
35
|
@decoy = nil
|
|
34
36
|
|
|
37
|
+
# Password checks run at once, in this process, and the most that may wait for one.
|
|
38
|
+
CHECKING = 4
|
|
39
|
+
WAITING = 64
|
|
40
|
+
SLOTS = Mutex.new
|
|
41
|
+
FREED = ConditionVariable.new
|
|
42
|
+
private_constant :SLOTS, :FREED
|
|
43
|
+
@running = 0
|
|
44
|
+
@waiting = 0
|
|
45
|
+
|
|
35
46
|
class << self
|
|
36
47
|
attr_accessor :decoy
|
|
48
|
+
|
|
49
|
+
# Runs a password check when one of the few slots is free, so a flood of sign-ins cannot take every core.
|
|
50
|
+
# Past the queue's length it refuses at once, with an AccountError coded "busy".
|
|
51
|
+
def slot
|
|
52
|
+
SLOTS.synchronize do
|
|
53
|
+
if @running >= CHECKING
|
|
54
|
+
raise AccountError.new("Runlight is busy checking other sign-ins. Try again in a moment.", "busy") if @waiting >= WAITING
|
|
55
|
+
|
|
56
|
+
@waiting += 1
|
|
57
|
+
begin
|
|
58
|
+
FREED.wait(SLOTS) while @running >= CHECKING
|
|
59
|
+
ensure
|
|
60
|
+
@waiting -= 1
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
@running += 1
|
|
64
|
+
end
|
|
65
|
+
begin
|
|
66
|
+
yield
|
|
67
|
+
ensure
|
|
68
|
+
SLOTS.synchronize do
|
|
69
|
+
@running -= 1
|
|
70
|
+
FREED.signal
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
37
74
|
end
|
|
38
75
|
|
|
39
76
|
def initialize(store, secret)
|
|
40
77
|
@store = store
|
|
41
78
|
@secret = secret
|
|
42
79
|
@ready = false
|
|
80
|
+
@init_lock = Monitor.new
|
|
43
81
|
end
|
|
44
82
|
|
|
45
83
|
# A stored role read back; anything unknown reads as a viewer, the least it could be.
|
|
@@ -47,6 +85,11 @@ module Runlight
|
|
|
47
85
|
ROLES.include?(value) ? value : "viewer"
|
|
48
86
|
end
|
|
49
87
|
|
|
88
|
+
# Whether a role may invite people. The owner and admins invite as admin, member, or viewer.
|
|
89
|
+
def self.may_invite?(role)
|
|
90
|
+
role == "owner" || role == "admin"
|
|
91
|
+
end
|
|
92
|
+
|
|
50
93
|
# Starts turning on two-factor: a new secret, kept aside until a code from it is confirmed.
|
|
51
94
|
def start_two_factor(id)
|
|
52
95
|
init
|
|
@@ -181,6 +224,8 @@ module Runlight
|
|
|
181
224
|
raise AccountError.new("Ownership is handed over by the owner", "owner_hand_over") if role == "owner"
|
|
182
225
|
|
|
183
226
|
@store.db.run("UPDATE rl_users SET role = ? WHERE id = ?", [role, id])
|
|
227
|
+
# Someone who may no longer invite takes back the invites they sent, so nobody joins on their word.
|
|
228
|
+
@store.db.run("DELETE FROM rl_invites WHERE invited_by = ?", [user["email"]]) unless self.class.may_invite?(role)
|
|
184
229
|
user["role"] = role
|
|
185
230
|
user
|
|
186
231
|
end
|
|
@@ -212,6 +257,7 @@ module Runlight
|
|
|
212
257
|
raise AccountError.new("The owner cannot be removed", "owner_protected") if user["role"] == "owner"
|
|
213
258
|
|
|
214
259
|
@store.db.run("DELETE FROM rl_users WHERE id = ?", [id])
|
|
260
|
+
@store.db.run("DELETE FROM rl_invites WHERE invited_by = ?", [user["email"]])
|
|
215
261
|
@store.set_setting("login-link-used:#{id}", nil)
|
|
216
262
|
end
|
|
217
263
|
nil
|
|
@@ -280,27 +326,44 @@ module Runlight
|
|
|
280
326
|
before.positive?
|
|
281
327
|
end
|
|
282
328
|
|
|
283
|
-
# Turns an invite into an account with the password its person chose. The link then stops working.
|
|
329
|
+
# Turns an invite into an account with the password its person chose. The link then stops working. It takes a
|
|
330
|
+
# turn, so it cannot cross a removal of whoever sent it, and the sender must still be someone who may invite.
|
|
284
331
|
def accept_invite(code, password, now)
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
332
|
+
# The tables first: making them takes the same lock as a turn.
|
|
333
|
+
init
|
|
334
|
+
user = turn do
|
|
335
|
+
invite = invite_by_code(code, now)
|
|
336
|
+
next nil if invite.nil?
|
|
337
|
+
|
|
338
|
+
sender = by_email(invite["invitedBy"])
|
|
339
|
+
if sender.nil? || !self.class.may_invite?(sender["role"])
|
|
340
|
+
# Left without raising, since an error inside a turn on SQLite would roll this back.
|
|
341
|
+
@store.db.run("DELETE FROM rl_invites WHERE id = ?", [invite["id"]])
|
|
342
|
+
next nil
|
|
343
|
+
end
|
|
344
|
+
unless by_email(invite["email"]).nil?
|
|
345
|
+
raise AccountError.new("#{invite["email"]} already has an account", "account_exists", { "email" => invite["email"] })
|
|
346
|
+
end
|
|
347
|
+
|
|
348
|
+
made = set_password(invite["email"], password, now, invite["role"])
|
|
349
|
+
@store.db.run("DELETE FROM rl_invites WHERE id = ?", [invite["id"]])
|
|
350
|
+
made
|
|
289
351
|
end
|
|
352
|
+
raise AccountError.new("This invite has expired or was already used. Ask for a new one.", "invite_gone") if user.nil?
|
|
290
353
|
|
|
291
|
-
user = set_password(invite["email"], password, now, invite["role"])
|
|
292
|
-
@store.db.run("DELETE FROM rl_invites WHERE id = ?", [invite["id"]])
|
|
293
354
|
user
|
|
294
355
|
end
|
|
295
356
|
|
|
296
|
-
# The account for an email and password, or nil. Takes the same time either way.
|
|
357
|
+
# The account for an email and password, or nil. Takes the same time either way. Only a few checks run at
|
|
358
|
+
# once; with too many waiting it raises an AccountError coded "busy".
|
|
297
359
|
def sign_in(email, password)
|
|
298
360
|
user = by_email(email)
|
|
299
361
|
if user.nil?
|
|
300
|
-
|
|
362
|
+
hash = self.class.decoy ||= Crypto.hash_password(Crypto.hex(Crypto.random_bytes(16)))
|
|
363
|
+
self.class.slot { Crypto.check_password(password, hash) }
|
|
301
364
|
return nil
|
|
302
365
|
end
|
|
303
|
-
Crypto.check_password(password, user["hash"]) ? user : nil
|
|
366
|
+
self.class.slot { Crypto.check_password(password, user["hash"]) } ? user : nil
|
|
304
367
|
end
|
|
305
368
|
|
|
306
369
|
# A cookie value naming the user and when it expires, signed with the
|
|
@@ -348,15 +411,21 @@ module Runlight
|
|
|
348
411
|
|
|
349
412
|
private
|
|
350
413
|
|
|
351
|
-
# Changes to who has an account take turns
|
|
352
|
-
# lock, so two owners demoting each other at once cannot leave none,
|
|
414
|
+
# Changes to who has an account take turns across threads and processes (the database's lock, or on SQLite a
|
|
415
|
+
# transaction that takes the write lock first), so two owners demoting each other at once cannot leave none,
|
|
416
|
+
# a double-clicked invite makes one, and a code or a sign-in link works once.
|
|
353
417
|
def turn(&fn)
|
|
354
|
-
@store.db
|
|
418
|
+
db = @store.db
|
|
419
|
+
db.respond_to?(:turn) ? db.turn { fn.call } : db.exclusive { fn.call }
|
|
355
420
|
end
|
|
356
421
|
|
|
357
422
|
def init
|
|
358
423
|
return if @ready
|
|
359
424
|
|
|
425
|
+
@init_lock.synchronize { init_now unless @ready }
|
|
426
|
+
end
|
|
427
|
+
|
|
428
|
+
def init_now
|
|
360
429
|
# Several processes starting at once create the tables one at a time.
|
|
361
430
|
@store.db.exclusive do
|
|
362
431
|
db = @store.db
|
|
@@ -3,15 +3,24 @@
|
|
|
3
3
|
module Runlight
|
|
4
4
|
module Accounts
|
|
5
5
|
# Counts failed sign-ins under a key and refuses more than a few in a while.
|
|
6
|
-
# Keys are hashed with a key made
|
|
7
|
-
# address or an email as it was given.
|
|
6
|
+
# Keys are hashed with a key made for each UTC day, so the counts never hold
|
|
7
|
+
# an address or an email as it was given.
|
|
8
8
|
#
|
|
9
9
|
# TypeScript keeps the counts in its process. Here they live in the database's
|
|
10
10
|
# settings, as "throttle:<name>:<id>" holding {count, until}, and the hashing
|
|
11
|
-
# key as "throttle-key", so every process of an install shares the
|
|
11
|
+
# key as "throttle-key:<UTC day>", so every process of an install shares the
|
|
12
|
+
# same counts. Some keys are addresses, and nothing derived from one lasts
|
|
13
|
+
# longer than a day: an expired count is deleted when it is read and by the
|
|
14
|
+
# scheduled check (sweep), and a day's hashing key goes once the day is over
|
|
15
|
+
# (yesterday's is kept only long enough to carry a count across midnight).
|
|
12
16
|
class Throttle
|
|
13
|
-
KEY = "throttle-key"
|
|
14
|
-
|
|
17
|
+
KEY = "throttle-key:"
|
|
18
|
+
# The one hashing key from before keys were made per day.
|
|
19
|
+
LEGACY_KEY = "throttle-key"
|
|
20
|
+
DAY_MS = 86_400_000
|
|
21
|
+
# How long after midnight yesterday's key carries a count over: the longest window a throttle counts in.
|
|
22
|
+
CARRY_MS = 15 * 60_000
|
|
23
|
+
private_constant :KEY, :LEGACY_KEY, :DAY_MS, :CARRY_MS
|
|
15
24
|
|
|
16
25
|
def initialize(store, name, limit = 10, window_ms = 15 * 60_000)
|
|
17
26
|
@store = store
|
|
@@ -20,8 +29,35 @@ module Runlight
|
|
|
20
29
|
@window_ms = window_ms
|
|
21
30
|
end
|
|
22
31
|
|
|
32
|
+
# Deletes every count that has run out and the hashing keys of days gone, for the scheduled check.
|
|
33
|
+
def self.sweep(store, now)
|
|
34
|
+
store.settings_starting_with("throttle:").each do |row|
|
|
35
|
+
entry = Json.try_decode(row["value"])
|
|
36
|
+
until_ms = entry.is_a?(Hash) ? Js.number(entry["until"].nil? ? 0 : entry["until"]) : 0
|
|
37
|
+
store.set_setting(row["key"], nil) unless until_ms.is_a?(Numeric) && until_ms.finite? && until_ms > now
|
|
38
|
+
end
|
|
39
|
+
drop_old_keys(store, now)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# The UTC day a time falls on, as YYYY-MM-DD.
|
|
43
|
+
def self.day(now)
|
|
44
|
+
Time.at(now.to_i / 1000).utc.strftime("%Y-%m-%d")
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Keeps today's hashing key, and yesterday's only just after midnight; the one from before days goes too.
|
|
48
|
+
def self.drop_old_keys(store, now)
|
|
49
|
+
keep = [day(now)]
|
|
50
|
+
keep << day(now - DAY_MS) if now.to_i % DAY_MS < CARRY_MS
|
|
51
|
+
store.set_setting(LEGACY_KEY, nil) unless store.setting(LEGACY_KEY).nil?
|
|
52
|
+
store.settings_starting_with(KEY).each do |row|
|
|
53
|
+
store.set_setting(row["key"], nil) unless keep.include?(row["key"][KEY.length..])
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
23
57
|
def blocked(key, now)
|
|
24
|
-
|
|
58
|
+
today, yesterday = ids(key, now)
|
|
59
|
+
entry = entry(today, now) || (yesterday && entry(yesterday, now))
|
|
60
|
+
blocked_entry?(entry)
|
|
25
61
|
end
|
|
26
62
|
|
|
27
63
|
# Counts a try before the slow check it guards, so a burst that arrives
|
|
@@ -29,43 +65,75 @@ module Runlight
|
|
|
29
65
|
# False, counting nothing, when the key is already at its limit. A try
|
|
30
66
|
# that turns out right is taken back with forgive().
|
|
31
67
|
def take(key, now)
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
68
|
+
ids = ids(key, now)
|
|
69
|
+
# Checked and counted in one turn, so tries at once from other threads and processes wait for it.
|
|
70
|
+
turn do
|
|
71
|
+
entry = carry(ids, now)
|
|
72
|
+
next false if blocked_entry?(entry)
|
|
73
|
+
|
|
74
|
+
count(ids[0], entry, now)
|
|
75
|
+
true
|
|
76
|
+
end
|
|
37
77
|
end
|
|
38
78
|
|
|
39
79
|
# Takes back one counted try, for one that turned out right.
|
|
40
|
-
def forgive(key)
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
80
|
+
def forgive(key, now)
|
|
81
|
+
ids = ids(key, now)
|
|
82
|
+
turn do
|
|
83
|
+
entry = carry(ids, now)
|
|
84
|
+
next unless entry && entry["count"].positive?
|
|
85
|
+
|
|
86
|
+
entry["count"] -= 1
|
|
87
|
+
save(ids[0], entry)
|
|
88
|
+
end
|
|
89
|
+
nil
|
|
47
90
|
end
|
|
48
91
|
|
|
49
92
|
def record_failure(key, now)
|
|
50
|
-
|
|
93
|
+
ids = ids(key, now)
|
|
94
|
+
turn { count(ids[0], carry(ids, now), now) }
|
|
95
|
+
nil
|
|
51
96
|
end
|
|
52
97
|
|
|
53
|
-
def clear(key)
|
|
54
|
-
@store.set_setting("#{prefix}#{id
|
|
98
|
+
def clear(key, now)
|
|
99
|
+
ids(key, now).compact.each { |id| @store.set_setting("#{prefix}#{id}", nil) }
|
|
100
|
+
nil
|
|
55
101
|
end
|
|
56
102
|
|
|
57
103
|
private
|
|
58
104
|
|
|
59
|
-
|
|
60
|
-
|
|
105
|
+
# The day's hashing key, made the first time it is needed (unless make is false, for yesterday's).
|
|
106
|
+
def salt(now, make = true)
|
|
107
|
+
name = "#{KEY}#{self.class.day(now)}"
|
|
108
|
+
saved = @store.setting(name)
|
|
61
109
|
return saved if saved && saved != ""
|
|
110
|
+
return nil unless make
|
|
62
111
|
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
112
|
+
# Made in a turn, so two processes starting at once do not each keep a different one.
|
|
113
|
+
turn do
|
|
114
|
+
saved = @store.setting(name)
|
|
115
|
+
next saved if saved && saved != ""
|
|
116
|
+
|
|
117
|
+
made = Hashing.random_id(16)
|
|
118
|
+
@store.set_setting(name, made)
|
|
119
|
+
self.class.drop_old_keys(@store, now)
|
|
120
|
+
made
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
# One change to the counts at a time, across threads and processes, as the accounts take turns.
|
|
125
|
+
def turn(&block)
|
|
126
|
+
db = @store.db
|
|
127
|
+
db.respond_to?(:turn) ? db.turn { block.call } : db.exclusive { block.call }
|
|
66
128
|
end
|
|
67
129
|
|
|
68
|
-
|
|
130
|
+
# The key's id under today's hashing key, and under yesterday's while that is still kept (else nil).
|
|
131
|
+
def ids(key, now)
|
|
132
|
+
yesterday = salt(now - DAY_MS, false)
|
|
133
|
+
[keyed(salt(now), key), yesterday.nil? ? nil : keyed(yesterday, key)]
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def keyed(salt, key)
|
|
69
137
|
Crypto.base64url(Crypto.hmac("SHA-256", salt, key))[0, 22]
|
|
70
138
|
end
|
|
71
139
|
|
|
@@ -73,10 +141,31 @@ module Runlight
|
|
|
73
141
|
"throttle:#{@name}:"
|
|
74
142
|
end
|
|
75
143
|
|
|
76
|
-
|
|
144
|
+
# A count that has not run out, or nil. One that has is deleted as it is read.
|
|
145
|
+
def entry(id, now)
|
|
77
146
|
saved = @store.setting("#{prefix}#{id}")
|
|
78
|
-
|
|
79
|
-
|
|
147
|
+
return nil if saved.nil?
|
|
148
|
+
|
|
149
|
+
entry = Json.try_decode(saved)
|
|
150
|
+
entry = entry.is_a?(Hash) ? { "count" => int(entry["count"]), "until" => int(entry["until"]) } : nil
|
|
151
|
+
return entry if entry && entry["until"] > now
|
|
152
|
+
|
|
153
|
+
@store.set_setting("#{prefix}#{id}", nil)
|
|
154
|
+
nil
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
# Today's count, or yesterday's moved under today's id, so a window that crosses midnight goes on.
|
|
158
|
+
def carry(ids, now)
|
|
159
|
+
today, yesterday = ids
|
|
160
|
+
found = entry(today, now)
|
|
161
|
+
return found if found || yesterday.nil?
|
|
162
|
+
|
|
163
|
+
found = entry(yesterday, now)
|
|
164
|
+
return nil if found.nil?
|
|
165
|
+
|
|
166
|
+
@store.set_setting("#{prefix}#{yesterday}", nil)
|
|
167
|
+
save(today, found)
|
|
168
|
+
found
|
|
80
169
|
end
|
|
81
170
|
|
|
82
171
|
# A saved number, whole, and 0 for anything that is not a number.
|
|
@@ -89,16 +178,12 @@ module Runlight
|
|
|
89
178
|
@store.set_setting("#{prefix}#{id}", Json.encode(entry))
|
|
90
179
|
end
|
|
91
180
|
|
|
92
|
-
def
|
|
93
|
-
entry
|
|
94
|
-
return false if entry.nil? || entry["until"] <= now
|
|
95
|
-
|
|
96
|
-
entry["count"] >= @limit
|
|
181
|
+
def blocked_entry?(entry)
|
|
182
|
+
!entry.nil? && entry["count"] >= @limit
|
|
97
183
|
end
|
|
98
184
|
|
|
99
|
-
def count(id, now)
|
|
100
|
-
|
|
101
|
-
if entry.nil? || entry["until"] <= now
|
|
185
|
+
def count(id, entry, now)
|
|
186
|
+
if entry.nil?
|
|
102
187
|
save(id, { "count" => 1, "until" => now + @window_ms })
|
|
103
188
|
prune(now)
|
|
104
189
|
return
|
|
@@ -44,16 +44,22 @@ module Runlight
|
|
|
44
44
|
@home_path = "#{@base}/"
|
|
45
45
|
@asks_for_token = @first.is_a?(Hash) && @first.key?("token")
|
|
46
46
|
@existing = false
|
|
47
|
-
# Wrong passwords are counted
|
|
47
|
+
# Wrong passwords are counted three times. Per account and address, ten tries;
|
|
48
48
|
# per account from anywhere, fifty, so a caller who invents a new address
|
|
49
49
|
# for every try still cannot guess on and on. Addresses come from
|
|
50
|
-
# forwarding headers a client can write, so they never stand alone.
|
|
50
|
+
# forwarding headers a client can write, so they never stand alone. And per
|
|
51
|
+
# address across every account, thirty, so one caller trying made-up emails
|
|
52
|
+
# cannot keep the server hashing; a request with no known address skips that one,
|
|
53
|
+
# since it would hold up everyone at once.
|
|
51
54
|
# Each try counts before the password is checked, and a right one is taken back.
|
|
52
55
|
@per_address = Throttle.new(@store, "address", 10)
|
|
53
56
|
@per_account = Throttle.new(@store, "account", 50)
|
|
57
|
+
@per_ip = Throttle.new(@store, "ip", 30)
|
|
54
58
|
# Six-digit codes: five wrong tries an account every fifteen minutes, and five to confirm the first one.
|
|
55
59
|
# Password re-checks in Account: ten.
|
|
56
60
|
@code_tries = Throttle.new(@store, "code", 5)
|
|
61
|
+
# A decoy ticket's tries count apart, the same way, so someone guessing passwords cannot use up the owner's five.
|
|
62
|
+
@decoy_tries = Throttle.new(@store, "decoy", 5)
|
|
57
63
|
@confirm_tries = Throttle.new(@store, "confirm", 5)
|
|
58
64
|
@rechecks = Throttle.new(@store, "recheck", 10)
|
|
59
65
|
end
|
|
@@ -70,21 +76,26 @@ module Runlight
|
|
|
70
76
|
# Only a path on this install, so a sign-in can never send someone elsewhere.
|
|
71
77
|
# Browsers drop tabs and newlines from a URL and read a backslash as a slash,
|
|
72
78
|
# so "/\t/evil.example" would leave; anything with those is refused outright,
|
|
73
|
-
# and what is left must resolve to this origin.
|
|
79
|
+
# and what is left must resolve to this origin. A path that resolves to one
|
|
80
|
+
# starting "//", as "/..//evil.example" does, would leave from the Location
|
|
81
|
+
# header, so that goes home too.
|
|
74
82
|
def safe_next(value)
|
|
75
83
|
return @home_path if value.nil? || value == "" || !value.start_with?("/") || value.match?(/[\x00-\x1f\x7f\\]/)
|
|
76
84
|
|
|
77
85
|
url = Http::Url.parse(value, "http://runlight.invalid")
|
|
78
|
-
|
|
86
|
+
return @home_path if url.nil? || url.origin != "http://runlight.invalid"
|
|
87
|
+
|
|
88
|
+
path = "#{url.pathname}#{url.search}#{url.hash}"
|
|
89
|
+
path.start_with?("//") || path.start_with?("/\\") ? @home_path : path
|
|
79
90
|
end
|
|
80
91
|
|
|
81
92
|
def signed_in(request)
|
|
82
93
|
value = read_cookie(request, Accounts::SESSION_COOKIE)
|
|
83
94
|
return nil if value == ""
|
|
84
95
|
|
|
96
|
+
# A cookie that is not valid percent-encoding is nobody's session, never an error.
|
|
85
97
|
decoded = Js.decode_uri_component(value)
|
|
86
|
-
|
|
87
|
-
raise ArgumentError, "URI malformed" if decoded.nil?
|
|
98
|
+
return nil if decoded.nil?
|
|
88
99
|
|
|
89
100
|
@accounts.from_session(decoded, now)
|
|
90
101
|
end
|
|
@@ -143,6 +154,12 @@ module Runlight
|
|
|
143
154
|
@now.call
|
|
144
155
|
end
|
|
145
156
|
|
|
157
|
+
# The store's tables, settings among them, which the throttles keep their counts in (TypeScript keeps them
|
|
158
|
+
# in memory), so a fresh install's first sign-in or two-factor change finds them there.
|
|
159
|
+
def ready
|
|
160
|
+
@rl.init if @rl.respond_to?(:init)
|
|
161
|
+
end
|
|
162
|
+
|
|
146
163
|
def home_origin
|
|
147
164
|
@home&.call
|
|
148
165
|
end
|
|
@@ -378,6 +395,7 @@ module Runlight
|
|
|
378
395
|
end
|
|
379
396
|
|
|
380
397
|
if path == "/login/code" && method == "POST"
|
|
398
|
+
ready
|
|
381
399
|
form = Http::SearchParams.new(request.text)
|
|
382
400
|
next_path = safe_next(form.get("next"))
|
|
383
401
|
pending = @accounts.from_pending(form.get("pending") || "", now)
|
|
@@ -386,7 +404,7 @@ module Runlight
|
|
|
386
404
|
user = pending["user"]
|
|
387
405
|
real = pending["real"]
|
|
388
406
|
# Counted before the check, so a burst cannot get past five.
|
|
389
|
-
unless @code_tries.take(user["id"], now)
|
|
407
|
+
unless (real ? @code_tries : @decoy_tries).take(user["id"], now)
|
|
390
408
|
return html(Pages.code_page(base, { "pending" => form.get("pending") || "", "next" => next_path, "error" => "Too many tries. Wait fifteen minutes and try again." }), 429)
|
|
391
409
|
end
|
|
392
410
|
if !real || !@accounts.check_second_factor(user["id"], form.get("code") || "", now)
|
|
@@ -394,7 +412,7 @@ module Runlight
|
|
|
394
412
|
"error" => "That code is not right. Check the time on your phone, or use a recovery code." }), 401)
|
|
395
413
|
end
|
|
396
414
|
|
|
397
|
-
@code_tries.clear(user["id"])
|
|
415
|
+
@code_tries.clear(user["id"], now)
|
|
398
416
|
return signed_in_to(request, user, next_path)
|
|
399
417
|
end
|
|
400
418
|
|
|
@@ -424,6 +442,8 @@ module Runlight
|
|
|
424
442
|
@existing = true
|
|
425
443
|
return redirect(@home_path, { "set-cookie" => session_cookie(request, @accounts.session_for(user, now), Accounts::SESSION_MS / 1000) })
|
|
426
444
|
rescue RangeError => e
|
|
445
|
+
return html(Pages.invite_gone_page(base), 410) if e.is_a?(AccountError) && e.code == "invite_gone"
|
|
446
|
+
|
|
427
447
|
return again.call(e.message)
|
|
428
448
|
end
|
|
429
449
|
end
|
|
@@ -432,6 +452,7 @@ module Runlight
|
|
|
432
452
|
end
|
|
433
453
|
|
|
434
454
|
def login(request, context)
|
|
455
|
+
ready
|
|
435
456
|
base = @base
|
|
436
457
|
form = Http::SearchParams.new(request.text)
|
|
437
458
|
email = form.get("email") || ""
|
|
@@ -442,6 +463,7 @@ module Runlight
|
|
|
442
463
|
pair = "#{account}\n#{ip == "" ? "unknown" : ip}"
|
|
443
464
|
login_page = ->(opts) { Pages.login_page(base, opts.merge("next" => next_path, "forgot" => @forgot) { |_k, mine, _theirs| mine }) }
|
|
444
465
|
too_many = -> { html(login_page.call({ "error" => "Too many tries. Wait fifteen minutes and try again.", "email" => email }), 429) }
|
|
466
|
+
return too_many.call if ip != "" && !@per_ip.take(ip, now)
|
|
445
467
|
return too_many.call unless @per_address.take(pair, now)
|
|
446
468
|
|
|
447
469
|
# A browser that signed in to the account before is never held up by others' failures.
|
|
@@ -474,12 +496,18 @@ module Runlight
|
|
|
474
496
|
|
|
475
497
|
return html(login_page.call({ "error" => "That email and password do not match an account.", "email" => email }), 401)
|
|
476
498
|
end
|
|
477
|
-
@per_address.clear(pair)
|
|
478
|
-
@
|
|
499
|
+
@per_address.clear(pair, now)
|
|
500
|
+
@per_ip.forgive(ip, now) if ip != ""
|
|
501
|
+
@per_account.forgive(account, now) if !over && !trusted
|
|
479
502
|
# With two-factor on, the password only earns the second step.
|
|
480
503
|
return html(Pages.code_page(base, { "pending" => @accounts.pending_for(user, now), "next" => next_path })) if user["twoFactor"]
|
|
481
504
|
|
|
482
505
|
signed_in_to(request, user, next_path)
|
|
506
|
+
rescue AccountError => e
|
|
507
|
+
# Too many password checks already waiting, so this one was never run: try again in a moment.
|
|
508
|
+
raise unless e.code == "busy"
|
|
509
|
+
|
|
510
|
+
html(login_page.call({ "error" => e.message, "email" => email }), 503, { "retry-after" => "5" })
|
|
483
511
|
end
|
|
484
512
|
|
|
485
513
|
# Your own account, and for the owner and admins, everyone else's.
|
|
@@ -487,6 +515,7 @@ module Runlight
|
|
|
487
515
|
user = signed_in(request)
|
|
488
516
|
return coded("Sign in first", "sign_in", 401) if user.nil?
|
|
489
517
|
|
|
518
|
+
ready
|
|
490
519
|
now = self.now
|
|
491
520
|
method = request.method
|
|
492
521
|
# Writes must be JSON, which a form on another page cannot send, even those with no body.
|
|
@@ -495,9 +524,16 @@ module Runlight
|
|
|
495
524
|
|
|
496
525
|
recheck = lambda do |input, name, wrong|
|
|
497
526
|
return coded("Too many tries. Wait fifteen minutes and try again.", "too_many_tries", 429) unless @rechecks.take(user["id"], now)
|
|
498
|
-
|
|
527
|
+
begin
|
|
528
|
+
right = @accounts.sign_in(user["email"], field(input, name))
|
|
529
|
+
rescue AccountError => e
|
|
530
|
+
raise unless e.code == "busy"
|
|
531
|
+
|
|
532
|
+
return coded("Runlight is busy checking other sign-ins. Try again in a moment.", "busy", 503)
|
|
533
|
+
end
|
|
534
|
+
return coded(wrong[0], wrong[1], 400) if right.nil?
|
|
499
535
|
|
|
500
|
-
@rechecks.forgive(user["id"])
|
|
536
|
+
@rechecks.forgive(user["id"], now)
|
|
501
537
|
nil
|
|
502
538
|
end
|
|
503
539
|
fresh = ->(updated) { { "set-cookie" => session_cookie(request, @accounts.session_for(updated, now), Accounts::SESSION_MS / 1000) } }
|
|
@@ -532,7 +568,7 @@ module Runlight
|
|
|
532
568
|
codes = @accounts.confirm_two_factor(user["id"], field(input, "code").gsub(/[#{Js::SPACE}]/o, ""), now)
|
|
533
569
|
return coded("That code is not right. Check the time on your phone and try the next one.", "code_wrong", 400) if codes.nil?
|
|
534
570
|
|
|
535
|
-
@confirm_tries.clear(user["id"])
|
|
571
|
+
@confirm_tries.clear(user["id"], now)
|
|
536
572
|
# Turning it on signs out every other browser; this one gets a new session.
|
|
537
573
|
updated = @accounts.by_id(user["id"])
|
|
538
574
|
return reply({ "recovery" => codes }, 200, fresh.call(updated))
|
|
@@ -541,7 +577,7 @@ module Runlight
|
|
|
541
577
|
return refused unless refused.nil?
|
|
542
578
|
|
|
543
579
|
if action == "/start"
|
|
544
|
-
@confirm_tries.clear(user["id"])
|
|
580
|
+
@confirm_tries.clear(user["id"], now)
|
|
545
581
|
secret = @accounts.start_two_factor(user["id"])
|
|
546
582
|
return reply({ "secret" => secret, "uri" => Crypto.otpauth_uri(secret, user["email"], Http::Url.new(request.url).host) })
|
|
547
583
|
end
|
data/lib/runlight/assistant.rb
CHANGED
|
@@ -85,8 +85,18 @@ module Runlight
|
|
|
85
85
|
message.is_a?(String) ? message : ""
|
|
86
86
|
end
|
|
87
87
|
|
|
88
|
+
# Refuses a service at a link-local or cloud metadata address. One on this computer (Ollama, LM Studio) or a
|
|
89
|
+
# private network is allowed, as the person setting it up means it to be.
|
|
90
|
+
def refuse_metadata(url)
|
|
91
|
+
host = Http::Url.new(url).hostname
|
|
92
|
+
return unless Safefetch.metadata_host?(host)
|
|
93
|
+
|
|
94
|
+
raise AssistantError.new("#{host} is a link-local or cloud metadata address, which Runlight never connects to", "address_refused", { "host" => host })
|
|
95
|
+
end
|
|
96
|
+
|
|
88
97
|
def post(fetcher, url, headers, body, deadline, now, cancelled)
|
|
89
98
|
in_time(deadline, now, cancelled)
|
|
99
|
+
refuse_metadata(url)
|
|
90
100
|
left = deadline - now.call
|
|
91
101
|
begin
|
|
92
102
|
answer = fetcher.fetch(url, {
|
|
@@ -293,6 +303,7 @@ module Runlight
|
|
|
293
303
|
elsif key != "" then { "authorization" => "Bearer #{key}" }
|
|
294
304
|
else {}
|
|
295
305
|
end
|
|
306
|
+
refuse_metadata(base)
|
|
296
307
|
begin
|
|
297
308
|
answer = fetcher.fetch("#{base}/models#{provider["protocol"] == "anthropic" ? "?limit=100" : ""}", { "headers" => headers, "timeoutMs" => 20_000 })
|
|
298
309
|
rescue StandardError
|
data/lib/runlight/connect.rb
CHANGED
|
@@ -136,9 +136,8 @@ module Runlight
|
|
|
136
136
|
def finish_connect(runlight, params)
|
|
137
137
|
state = params.get("state") || ""
|
|
138
138
|
key = "connect:#{state}"
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
runlight.store.set_setting(key, nil) if !stored.nil? && stored != ""
|
|
139
|
+
# Each attempt works once, even when it comes back twice at once.
|
|
140
|
+
stored = state.match?(/\A[a-f0-9]{32}\z/) ? runlight.store.take_setting(key) : nil
|
|
142
141
|
pending = !stored.nil? && stored != "" ? pending_from(stored, runlight.now) : nil
|
|
143
142
|
if pending.nil?
|
|
144
143
|
raise ConnectError.new("That connection took too long or was already used. Start again.", "expired")
|
data/lib/runlight/core.rb
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "monitor"
|
|
4
|
+
|
|
3
5
|
module Runlight
|
|
4
6
|
# Runlight in an app: the sites it counts, the tracker endpoint's work, short links, email reports, and the
|
|
5
7
|
# scheduled upkeep. A port of the TypeScript SDK's runlight.ts; `Runlight.new(options)` makes one.
|
|
@@ -132,6 +134,8 @@ module Runlight
|
|
|
132
134
|
@remote_seen = {}
|
|
133
135
|
@overrides = {}
|
|
134
136
|
@ready = false
|
|
137
|
+
# init runs once however many threads ask at once; reentrant, as init may ask for itself.
|
|
138
|
+
@init_lock = Monitor.new
|
|
135
139
|
@checking = false
|
|
136
140
|
# When planner statistics were last gathered.
|
|
137
141
|
@optimized_at = 0
|
|
@@ -211,6 +215,10 @@ module Runlight
|
|
|
211
215
|
config = settings.merge("from" => from)
|
|
212
216
|
config["fromName"] = from_name unless from_name == ""
|
|
213
217
|
Mail::Transports.check_config(config)
|
|
218
|
+
if config["service"] == "smtp" && Js.truthy?(config["host"]) && Safefetch.metadata_host?(config["host"].to_s)
|
|
219
|
+
raise Mail::MailError.new("#{config["host"]} is a link-local or cloud metadata address, which Runlight never connects to", "address_refused",
|
|
220
|
+
{ "host" => config["host"] })
|
|
221
|
+
end
|
|
214
222
|
@store.set_setting("mail", Mail::Secret.seal(Json.encode(config), @secret))
|
|
215
223
|
end
|
|
216
224
|
|
|
@@ -275,6 +283,15 @@ module Runlight
|
|
|
275
283
|
def init
|
|
276
284
|
return if @ready
|
|
277
285
|
|
|
286
|
+
@init_lock.synchronize { init_now unless @ready }
|
|
287
|
+
end
|
|
288
|
+
|
|
289
|
+
# Whether init has run, so middleware can leave the app's own requests alone until it has.
|
|
290
|
+
def ready?
|
|
291
|
+
@ready
|
|
292
|
+
end
|
|
293
|
+
|
|
294
|
+
private def init_now
|
|
278
295
|
@store.migrate
|
|
279
296
|
# A database that never had its statistics gathered gets them now, before any report is read, rather
|
|
280
297
|
# than at the first scheduled check, which an app may never run.
|
|
@@ -601,6 +618,10 @@ module Runlight
|
|
|
601
618
|
if parsed.nil? || (parsed.protocol != "https:" && parsed.protocol != "http:")
|
|
602
619
|
raise SettingsError.new("Enter the service's address, starting with https://", "assistant_address_bad")
|
|
603
620
|
end
|
|
621
|
+
if Safefetch.metadata_host?(parsed.hostname)
|
|
622
|
+
raise SettingsError.new("#{parsed.hostname} is a link-local or cloud metadata address, which Runlight never connects to", "address_refused",
|
|
623
|
+
{ "host" => parsed.hostname })
|
|
624
|
+
end
|
|
604
625
|
end
|
|
605
626
|
raise SettingsError.new("Enter the service's address", "assistant_address") if base_url == "" && provider["baseUrl"] == ""
|
|
606
627
|
|
|
@@ -1335,6 +1356,8 @@ module Runlight
|
|
|
1335
1356
|
@salts = {}
|
|
1336
1357
|
sites.map { |site| site["timezone"] }.uniq.each { |timezone| current_salts(now, timezone) }
|
|
1337
1358
|
drop_old_salts(now)
|
|
1359
|
+
# Sign-in counts that have run out, some keyed by an address, and the hashing keys of days gone.
|
|
1360
|
+
Accounts::Throttle.sweep(@store, now)
|
|
1338
1361
|
# Every site's retention covers any one site's that is still waiting.
|
|
1339
1362
|
@pruning = []
|
|
1340
1363
|
begin
|