runlight 0.3.3 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/assets/agent-ranges.json +849 -0
- data/assets/build.json +3 -3
- data/assets/dashboard.css +1 -1
- data/assets/dashboard.js +10 -10
- data/assets/locales.json +5 -5
- data/lib/runlight/agent_check.rb +197 -0
- data/lib/runlight/core.rb +75 -3
- data/lib/runlight/data/agents.rb +22 -15
- data/lib/runlight/routes.rb +8 -2
- data/lib/runlight/server/agents.rb +16 -6
- data/lib/runlight/server/cli.rb +6 -2
- data/lib/runlight/server/config.rb +13 -0
- data/lib/runlight/server/standalone.rb +3 -0
- data/lib/runlight/store/sql_store.rb +16 -4
- data/lib/runlight/version.rb +1 -1
- data/lib/runlight.rb +1 -0
- metadata +4 -2
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Runlight
|
|
4
|
+
# Checks a request that names an AI agent against the addresses that agent's company publishes. Every
|
|
5
|
+
# list is in the same shape, {"prefixes": [{"ipv4Prefix": "a.b.c.d/n"} or {"ipv6Prefix": "x::/n"}]}.
|
|
6
|
+
#
|
|
7
|
+
# The answer is "verified" when the address is in the list, "mismatch" when the company publishes a list
|
|
8
|
+
# and the address is not in it, and "" when there is nothing to check against: no list for that agent,
|
|
9
|
+
# or no usable address. The address is only compared, never kept. The port of the SDK's agentcheck.ts.
|
|
10
|
+
module AgentCheck
|
|
11
|
+
# A list of more prefixes than this, or a reply bigger than MAX_LIST_BYTES, is not a list we read.
|
|
12
|
+
MAX_PREFIXES = 20_000
|
|
13
|
+
MAX_LIST_BYTES = 4_000_000
|
|
14
|
+
USER_AGENT = "Runlight (+https://runlight.sh)"
|
|
15
|
+
|
|
16
|
+
# Lists compiled to prefixes, by the list itself, as a WeakMap holds them in TypeScript. A list is
|
|
17
|
+
# compiled once while it is in use; the cache is emptied when it grows past what any install holds.
|
|
18
|
+
@compiled = {}.compare_by_identity
|
|
19
|
+
@compiled_lock = Mutex.new
|
|
20
|
+
|
|
21
|
+
module_function
|
|
22
|
+
|
|
23
|
+
# The address lists that came with this version: { "fetchedAt" => "YYYY-MM-DD", "lists" => { url => [prefix] } },
|
|
24
|
+
# read once from assets/agent-ranges.json.
|
|
25
|
+
def bundled
|
|
26
|
+
@bundled ||= begin
|
|
27
|
+
data = Json.decode(File.read(File.join(Version::ASSETS, "agent-ranges.json")))
|
|
28
|
+
lists = data["lists"].to_h { |url, list| [url.freeze, list.map { |p| p.dup.freeze }.freeze] }.freeze
|
|
29
|
+
{ "fetchedAt" => data["fetchedAt"], "lists" => lists }.freeze
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def ipv4(text)
|
|
34
|
+
parts = text.split(".", -1)
|
|
35
|
+
return nil unless parts.length == 4
|
|
36
|
+
|
|
37
|
+
n = 0
|
|
38
|
+
parts.each do |part|
|
|
39
|
+
return nil unless part.match?(/\A[0-9]{1,3}\z/)
|
|
40
|
+
|
|
41
|
+
byte = part.to_i
|
|
42
|
+
return nil if byte > 255
|
|
43
|
+
|
|
44
|
+
n = (n * 256) + byte
|
|
45
|
+
end
|
|
46
|
+
n
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def ipv6(text)
|
|
50
|
+
return nil if !text.match?(/\A[0-9a-f:.]+\z/i) || text.split("::", -1).length > 2
|
|
51
|
+
|
|
52
|
+
# A dotted IPv4 tail ("::ffff:1.2.3.4") stands for the last two groups.
|
|
53
|
+
groups = text
|
|
54
|
+
if text.include?(".")
|
|
55
|
+
dot = text.rindex(":")
|
|
56
|
+
tail = ipv4(text[(dot + 1)..])
|
|
57
|
+
return nil if tail.nil?
|
|
58
|
+
|
|
59
|
+
groups = "#{text[0, dot + 1]}#{(tail >> 16).to_s(16)}:#{(tail & 0xffff).to_s(16)}"
|
|
60
|
+
end
|
|
61
|
+
halves = groups.split("::", -1)
|
|
62
|
+
head = halves[0]
|
|
63
|
+
rest = halves[1]
|
|
64
|
+
left = head.empty? ? [] : head.split(":", -1)
|
|
65
|
+
right = rest.nil? || rest.empty? ? [] : rest.split(":", -1)
|
|
66
|
+
missing = 8 - left.length - right.length
|
|
67
|
+
return nil if rest.nil? ? missing != 0 : missing < 1
|
|
68
|
+
|
|
69
|
+
n = 0
|
|
70
|
+
[*left, *Array.new(rest.nil? ? 0 : missing, "0"), *right].each do |group|
|
|
71
|
+
return nil unless group.match?(/\A[0-9a-f]{1,4}\z/i)
|
|
72
|
+
|
|
73
|
+
n = (n << 16) | group.to_i(16)
|
|
74
|
+
end
|
|
75
|
+
n
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# An address as the check reads it, as [4 or 6, number]: surrounding space, [brackets], and an IPv6 zone
|
|
79
|
+
# ("%eth0") are ignored, and an IPv4 address written as IPv6 (::ffff:1.2.3.4) is read as IPv4. Nil when
|
|
80
|
+
# it is not one.
|
|
81
|
+
def parse_address(text)
|
|
82
|
+
s = Js.trim(text.to_s)
|
|
83
|
+
s = s[1...-1] if s.start_with?("[") && s.end_with?("]") && s.length >= 2
|
|
84
|
+
zone = s.index("%")
|
|
85
|
+
s = s[0, zone] unless zone.nil?
|
|
86
|
+
return nil if s.empty?
|
|
87
|
+
|
|
88
|
+
unless s.include?(":")
|
|
89
|
+
n = ipv4(s)
|
|
90
|
+
return n.nil? ? nil : [4, n]
|
|
91
|
+
end
|
|
92
|
+
n = ipv6(s)
|
|
93
|
+
return nil if n.nil?
|
|
94
|
+
return [4, n & 0xffffffff] if n >> 32 == 0xffff
|
|
95
|
+
|
|
96
|
+
[6, n]
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# A prefix such as 20.171.206.0/24 or 2600:1f28::/56, as [4 or 6, base, bits], or nil. Bits past the length
|
|
100
|
+
# are ignored.
|
|
101
|
+
def parse_prefix(text)
|
|
102
|
+
slash = text.index("/")
|
|
103
|
+
return nil if slash.nil?
|
|
104
|
+
|
|
105
|
+
bits_text = text[(slash + 1)..]
|
|
106
|
+
return nil unless bits_text.match?(/\A[0-9]{1,3}\z/)
|
|
107
|
+
|
|
108
|
+
bits = bits_text.to_i
|
|
109
|
+
head = text[0, slash]
|
|
110
|
+
if head.include?(":")
|
|
111
|
+
n = ipv6(head)
|
|
112
|
+
return nil if n.nil? || bits > 128
|
|
113
|
+
|
|
114
|
+
return [6, n, bits]
|
|
115
|
+
end
|
|
116
|
+
n = ipv4(head)
|
|
117
|
+
return nil if n.nil? || bits > 32
|
|
118
|
+
|
|
119
|
+
[4, n, bits]
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def covers?(prefix, address)
|
|
123
|
+
return false if prefix[0] != address[0]
|
|
124
|
+
|
|
125
|
+
shift = (prefix[0] == 4 ? 32 : 128) - prefix[2]
|
|
126
|
+
address[1] >> shift == prefix[1] >> shift
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
# The prefixes in a published list (decoded JSON), or nil when it is not a list in the expected shape (not
|
|
130
|
+
# an object, no prefixes, an entry that is not a prefix, or too many). One bad entry rejects the whole
|
|
131
|
+
# list, so a half broken download never replaces a good copy.
|
|
132
|
+
def read_range_list(body)
|
|
133
|
+
return nil unless body.is_a?(Hash)
|
|
134
|
+
|
|
135
|
+
prefixes = body["prefixes"]
|
|
136
|
+
return nil if !prefixes.is_a?(Array) || prefixes.empty? || prefixes.length > MAX_PREFIXES
|
|
137
|
+
|
|
138
|
+
prefixes.map do |entry|
|
|
139
|
+
return nil unless entry.is_a?(Hash)
|
|
140
|
+
|
|
141
|
+
v4 = entry["ipv4Prefix"]
|
|
142
|
+
v6 = entry["ipv6Prefix"]
|
|
143
|
+
text = if v4.is_a?(String) then v4
|
|
144
|
+
elsif v6.is_a?(String) then v6
|
|
145
|
+
end
|
|
146
|
+
return nil if text.nil?
|
|
147
|
+
|
|
148
|
+
text = Js.trim(text)
|
|
149
|
+
prefix = parse_prefix(text)
|
|
150
|
+
return nil if prefix.nil? || v4.is_a?(String) != (prefix[0] == 4)
|
|
151
|
+
|
|
152
|
+
text
|
|
153
|
+
end
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
# Downloads one published list through fetcher (anything with fetch(url, init)). Nil when it cannot be
|
|
157
|
+
# fetched or read; the caller keeps what it had.
|
|
158
|
+
def download_range_list(url, fetcher, timeout_ms = 15_000)
|
|
159
|
+
response = fetcher.fetch(url, {
|
|
160
|
+
"headers" => { "accept" => "application/json", "user-agent" => USER_AGENT },
|
|
161
|
+
"redirect" => "follow", "timeoutMs" => timeout_ms, "maxBytes" => MAX_LIST_BYTES,
|
|
162
|
+
})
|
|
163
|
+
return nil unless response.ok?
|
|
164
|
+
|
|
165
|
+
read_range_list(Body.read_json_capped(response, MAX_LIST_BYTES))
|
|
166
|
+
rescue StandardError
|
|
167
|
+
nil
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
def prefixes_of(list)
|
|
171
|
+
@compiled_lock.synchronize do
|
|
172
|
+
found = @compiled[list]
|
|
173
|
+
return found unless found.nil?
|
|
174
|
+
|
|
175
|
+
@compiled.clear if @compiled.length >= 256
|
|
176
|
+
@compiled[list] = list.filter_map { |p| p.is_a?(String) ? parse_prefix(p) : nil }.freeze
|
|
177
|
+
end
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
# Whether ip is inside any of the prefixes. False for an address that cannot be read.
|
|
181
|
+
def in_ranges?(ip, list)
|
|
182
|
+
address = parse_address(ip)
|
|
183
|
+
!address.nil? && prefixes_of(list).any? { |p| covers?(p, address) }
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
# The check for one request that names agent (a Hash from Data::Agents), made from ip (see the top of
|
|
187
|
+
# this file): "verified", "mismatch", or "".
|
|
188
|
+
def check_agent(agent, ip, lists)
|
|
189
|
+
list = agent["ranges"].nil? ? nil : lists[agent["ranges"]]
|
|
190
|
+
return "" if list.nil? || parse_address(ip).nil?
|
|
191
|
+
|
|
192
|
+
in_ranges?(ip, list) ? "verified" : "mismatch"
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
private_class_method :ipv4, :ipv6, :parse_prefix, :covers?, :prefixes_of
|
|
196
|
+
end
|
|
197
|
+
end
|
data/lib/runlight/core.rb
CHANGED
|
@@ -42,6 +42,9 @@ module Runlight
|
|
|
42
42
|
# - localInstalls: lets a connected install be at http://localhost or http://127.0.0.1, for trying a hub and
|
|
43
43
|
# an app on one machine. Default false: otherwise anyone who can add a site could have this server ask
|
|
44
44
|
# services on its own machine, so other installs must be public https addresses.
|
|
45
|
+
# - updateAgentRanges: downloads the address lists AI companies publish, at most once a day during check(), so
|
|
46
|
+
# requests that name an agent are checked against current addresses. Default false: the lists that came
|
|
47
|
+
# with this version are used, and Runlight makes no requests of its own for them.
|
|
45
48
|
# - now: a callable giving the clock in milliseconds. For tests.
|
|
46
49
|
# - fetcher: what every outgoing request goes through (anything with fetch(url, init)). Default
|
|
47
50
|
# Http::NetFetcher.
|
|
@@ -72,7 +75,9 @@ module Runlight
|
|
|
72
75
|
ROLLUP_DELAY_MS = 2 * 3_600_000
|
|
73
76
|
# The longest a tracker hit waits for the tables while another process creates or upgrades them.
|
|
74
77
|
TRACKER_WAIT_MS = 10_000
|
|
75
|
-
|
|
78
|
+
# How often the scheduled check downloads the agents' address lists, when updateAgentRanges is on.
|
|
79
|
+
AGENT_RANGES_EVERY_MS = 86_400_000
|
|
80
|
+
private_constant :AGENT_RANGES_EVERY_MS, :ROLLUP_VERSION, :ROLLUP_BATCH, :REMOTE_MAX_BYTES, :METERED_ROLLUP_BATCH, :ROLLUP_DELAY_MS, :TRACKER_WAIT_MS
|
|
76
81
|
|
|
77
82
|
BUSY = /timeout exceeded when trying to connect|connection timeout|no MySQL connection was free|SQLITE_BUSY|database is locked/i
|
|
78
83
|
private_constant :BUSY
|
|
@@ -164,6 +169,10 @@ module Runlight
|
|
|
164
169
|
else Mail::Transports::SERVICES.select { |s| offered.include?(s["id"]) }.freeze
|
|
165
170
|
end
|
|
166
171
|
@local_installs = options["localInstalls"] ? true : false
|
|
172
|
+
@update_agent_ranges = Js.truthy?(options["updateAgentRanges"])
|
|
173
|
+
# The agents' address lists in use: downloaded ones from the database when there are any, else those that
|
|
174
|
+
# came with this version.
|
|
175
|
+
@agent_ranges = AgentCheck.bundled["lists"]
|
|
167
176
|
@secret = if options.key?("secret") && !options["secret"].nil?
|
|
168
177
|
options["secret"].to_s
|
|
169
178
|
else
|
|
@@ -961,7 +970,12 @@ module Runlight
|
|
|
961
970
|
# Records a request from a known AI agent. Call it from middleware for
|
|
962
971
|
# every page request; it ignores everything else and never raises.
|
|
963
972
|
# Agents do not run JavaScript, so the tracker cannot see them.
|
|
964
|
-
|
|
973
|
+
#
|
|
974
|
+
# The request's address (from context "ip" or a trusted proxy header, as
|
|
975
|
+
# for tracker hits, or context "agentIp" when a log reader or plugin
|
|
976
|
+
# reports it) is checked against the list the agent's company publishes
|
|
977
|
+
# and then dropped: only "verified", "mismatch", or "" is kept with the fetch.
|
|
978
|
+
def observe(request, at = nil, context = {})
|
|
965
979
|
return false if request.method != "GET"
|
|
966
980
|
|
|
967
981
|
agent = Ua.ai_agent(request.headers.get("user-agent") || "")
|
|
@@ -993,9 +1007,11 @@ module Runlight
|
|
|
993
1007
|
"pageview" => "",
|
|
994
1008
|
"path" => Js.slice(url.pathname, 0, 1000),
|
|
995
1009
|
"hostname" => Sources.strip_www(url.hostname),
|
|
996
|
-
"title" => "",
|
|
997
1010
|
"name" => agent["name"],
|
|
998
1011
|
"props" => { "company" => agent["company"], "kind" => agent["kind"] },
|
|
1012
|
+
# A fetch keeps the result of the address check in title: "verified", "mismatch", or "" when there was
|
|
1013
|
+
# nothing to check against. The address itself is not kept.
|
|
1014
|
+
"title" => AgentCheck.check_agent(agent, agent_ip(request, context), @agent_ranges),
|
|
999
1015
|
"engagedMs" => 0,
|
|
1000
1016
|
"scroll" => nil,
|
|
1001
1017
|
"link" => "",
|
|
@@ -1502,6 +1518,61 @@ module Runlight
|
|
|
1502
1518
|
})
|
|
1503
1519
|
end
|
|
1504
1520
|
|
|
1521
|
+
# The address an AI agent's request is checked from: the one a report names, else the client's.
|
|
1522
|
+
def agent_ip(request, context)
|
|
1523
|
+
given = context.key?("agentIp") ? context["agentIp"] : context[:agent_ip]
|
|
1524
|
+
given.nil? ? client_ip(request, context) : given.to_s
|
|
1525
|
+
end
|
|
1526
|
+
private :agent_ip
|
|
1527
|
+
|
|
1528
|
+
# Uses the agents' address lists downloaded by any process sharing this database, and downloads them again
|
|
1529
|
+
# when updateAgentRanges is on and the last try was a day ago or more. A list that cannot be fetched or read
|
|
1530
|
+
# keeps the copy it replaces; a failed day is not retried until the next day.
|
|
1531
|
+
def refresh_agent_ranges
|
|
1532
|
+
saved = saved_agent_ranges(@store.setting("agent-ranges"))
|
|
1533
|
+
if @update_agent_ranges && (saved.nil? || now - saved["at"] >= AGENT_RANGES_EVERY_MS)
|
|
1534
|
+
lists = saved.nil? ? {} : saved["lists"].dup
|
|
1535
|
+
AgentCheck.bundled["lists"].each_key do |url|
|
|
1536
|
+
fresh = AgentCheck.download_range_list(url, @fetcher)
|
|
1537
|
+
lists[url] = fresh unless fresh.nil?
|
|
1538
|
+
end
|
|
1539
|
+
saved = { "at" => now, "lists" => lists }
|
|
1540
|
+
@store.set_setting("agent-ranges", Json.encode(saved))
|
|
1541
|
+
end
|
|
1542
|
+
@agent_ranges = saved.nil? ? AgentCheck.bundled["lists"] : AgentCheck.bundled["lists"].merge(saved["lists"])
|
|
1543
|
+
rescue StandardError => e
|
|
1544
|
+
warn("Runlight: could not update the AI agents' address lists #{e.message}")
|
|
1545
|
+
end
|
|
1546
|
+
private :refresh_agent_ranges
|
|
1547
|
+
|
|
1548
|
+
# The "agent-ranges" setting, { "at" => ms, "lists" => { url => [prefix] } } with each list read as a
|
|
1549
|
+
# download is, or nil when there is none or it is not JSON. A list that does not read is left out.
|
|
1550
|
+
def saved_agent_ranges(stored)
|
|
1551
|
+
return nil if stored.nil? || stored == ""
|
|
1552
|
+
|
|
1553
|
+
parsed = begin
|
|
1554
|
+
Json.decode(stored)
|
|
1555
|
+
rescue StandardError
|
|
1556
|
+
return nil
|
|
1557
|
+
end
|
|
1558
|
+
return nil if parsed.nil?
|
|
1559
|
+
|
|
1560
|
+
given = parsed.is_a?(Hash) ? parsed["lists"] : nil
|
|
1561
|
+
entries = if given.is_a?(Hash) then given.to_a
|
|
1562
|
+
elsif given.is_a?(Array) then given.each_with_index.map { |list, i| [i.to_s, list] }
|
|
1563
|
+
else []
|
|
1564
|
+
end
|
|
1565
|
+
lists = {}
|
|
1566
|
+
entries.each do |url, list|
|
|
1567
|
+
wrapped = list.is_a?(Array) ? list.map { |p| { (Js.string(p).include?(":") ? "ipv6Prefix" : "ipv4Prefix") => p } } : nil
|
|
1568
|
+
prefixes = AgentCheck.read_range_list({ "prefixes" => wrapped })
|
|
1569
|
+
lists[url] = prefixes unless prefixes.nil?
|
|
1570
|
+
end
|
|
1571
|
+
at = parsed.is_a?(Hash) ? Js.number(parsed["at"]) : 0
|
|
1572
|
+
{ "at" => at.is_a?(Float) && (at.nan? || at.zero?) ? 0 : at, "lists" => lists }
|
|
1573
|
+
end
|
|
1574
|
+
private :saved_agent_ranges
|
|
1575
|
+
|
|
1505
1576
|
def run_check
|
|
1506
1577
|
init
|
|
1507
1578
|
# Requests take a current schema version on trust; the scheduled check goes over every table and index.
|
|
@@ -1512,6 +1583,7 @@ module Runlight
|
|
|
1512
1583
|
end
|
|
1513
1584
|
# A name or timezone changed in the dashboard by another process reaches this one too.
|
|
1514
1585
|
@overrides = @store.site_overrides
|
|
1586
|
+
refresh_agent_ranges
|
|
1515
1587
|
@salts = {}
|
|
1516
1588
|
sites.map { |site| site["timezone"] }.uniq.each { |timezone| current_salts(now, timezone) }
|
|
1517
1589
|
drop_old_salts(now)
|
data/lib/runlight/data/agents.rb
CHANGED
|
@@ -8,27 +8,34 @@ module Runlight
|
|
|
8
8
|
# "live" means an assistant fetched the page because a person asked about it
|
|
9
9
|
# just now. "crawl" is training or search indexing.
|
|
10
10
|
#
|
|
11
|
-
# Each agent is a Hash {"name", "company", "kind" ("live" or "crawl"), "token"}; the token is matched
|
|
12
|
-
# case-insensitively as a substring of the user agent.
|
|
11
|
+
# Each agent is a Hash {"name", "company", "kind" ("live" or "crawl"), "token", "ranges"}; the token is matched
|
|
12
|
+
# case-insensitively as a substring of the user agent. "ranges" is the list of addresses its company
|
|
13
|
+
# publishes for it, which a request naming it is checked against (see AgentCheck); it is absent when the
|
|
14
|
+
# company publishes none, so its requests stay claimed.
|
|
13
15
|
module Agents
|
|
16
|
+
OPENAI = "https://openai.com/"
|
|
17
|
+
ANTHROPIC = "https://claude.com/crawling/bots.json"
|
|
18
|
+
PERPLEXITY = "https://www.perplexity.ai/"
|
|
19
|
+
private_constant :OPENAI, :ANTHROPIC, :PERPLEXITY
|
|
20
|
+
|
|
14
21
|
AI_AGENTS = [
|
|
15
|
-
{ "name" => "ChatGPT-User", "company" => "OpenAI", "kind" => "live", "token" => "chatgpt-user" },
|
|
16
|
-
{ "name" => "OAI-SearchBot", "company" => "OpenAI", "kind" => "crawl", "token" => "oai-searchbot" },
|
|
17
|
-
{ "name" => "GPTBot", "company" => "OpenAI", "kind" => "crawl", "token" => "gptbot" },
|
|
18
|
-
{ "name" => "Claude-User", "company" => "Anthropic", "kind" => "live", "token" => "claude-user" },
|
|
19
|
-
{ "name" => "Claude-SearchBot", "company" => "Anthropic", "kind" => "crawl", "token" => "claude-searchbot" },
|
|
20
|
-
{ "name" => "ClaudeBot", "company" => "Anthropic", "kind" => "crawl", "token" => "claudebot" },
|
|
21
|
-
{ "name" => "Claude-Web", "company" => "Anthropic", "kind" => "crawl", "token" => "claude-web" },
|
|
22
|
-
{ "name" => "anthropic-ai", "company" => "Anthropic", "kind" => "crawl", "token" => "anthropic-ai" },
|
|
23
|
-
{ "name" => "Perplexity-User", "company" => "Perplexity", "kind" => "live", "token" => "perplexity-user" },
|
|
24
|
-
{ "name" => "PerplexityBot", "company" => "Perplexity", "kind" => "crawl", "token" => "perplexitybot" },
|
|
25
|
-
{ "name" => "MistralAI-User", "company" => "Mistral", "kind" => "live", "token" => "mistralai-user" },
|
|
22
|
+
{ "name" => "ChatGPT-User", "company" => "OpenAI", "kind" => "live", "token" => "chatgpt-user", "ranges" => "#{OPENAI}chatgpt-user.json" },
|
|
23
|
+
{ "name" => "OAI-SearchBot", "company" => "OpenAI", "kind" => "crawl", "token" => "oai-searchbot", "ranges" => "#{OPENAI}searchbot.json" },
|
|
24
|
+
{ "name" => "GPTBot", "company" => "OpenAI", "kind" => "crawl", "token" => "gptbot", "ranges" => "#{OPENAI}gptbot.json" },
|
|
25
|
+
{ "name" => "Claude-User", "company" => "Anthropic", "kind" => "live", "token" => "claude-user", "ranges" => ANTHROPIC },
|
|
26
|
+
{ "name" => "Claude-SearchBot", "company" => "Anthropic", "kind" => "crawl", "token" => "claude-searchbot", "ranges" => ANTHROPIC },
|
|
27
|
+
{ "name" => "ClaudeBot", "company" => "Anthropic", "kind" => "crawl", "token" => "claudebot", "ranges" => ANTHROPIC },
|
|
28
|
+
{ "name" => "Claude-Web", "company" => "Anthropic", "kind" => "crawl", "token" => "claude-web", "ranges" => ANTHROPIC },
|
|
29
|
+
{ "name" => "anthropic-ai", "company" => "Anthropic", "kind" => "crawl", "token" => "anthropic-ai", "ranges" => ANTHROPIC },
|
|
30
|
+
{ "name" => "Perplexity-User", "company" => "Perplexity", "kind" => "live", "token" => "perplexity-user", "ranges" => "#{PERPLEXITY}perplexity-user.json" },
|
|
31
|
+
{ "name" => "PerplexityBot", "company" => "Perplexity", "kind" => "crawl", "token" => "perplexitybot", "ranges" => "#{PERPLEXITY}perplexitybot.json" },
|
|
32
|
+
{ "name" => "MistralAI-User", "company" => "Mistral", "kind" => "live", "token" => "mistralai-user", "ranges" => "https://mistral.ai/mistralai-user-ips.json" },
|
|
26
33
|
{ "name" => "meta-externalfetcher", "company" => "Meta", "kind" => "live", "token" => "meta-externalfetcher" },
|
|
27
34
|
{ "name" => "meta-externalagent", "company" => "Meta", "kind" => "crawl", "token" => "meta-externalagent" },
|
|
28
|
-
{ "name" => "DuckAssistBot", "company" => "DuckDuckGo", "kind" => "crawl", "token" => "duckassistbot" },
|
|
35
|
+
{ "name" => "DuckAssistBot", "company" => "DuckDuckGo", "kind" => "crawl", "token" => "duckassistbot", "ranges" => "https://duckduckgo.com/duckassistbot.json" },
|
|
29
36
|
{ "name" => "Amazonbot", "company" => "Amazon", "kind" => "crawl", "token" => "amazonbot" },
|
|
30
37
|
{ "name" => "Bytespider", "company" => "ByteDance", "kind" => "crawl", "token" => "bytespider" },
|
|
31
|
-
{ "name" => "CCBot", "company" => "Common Crawl", "kind" => "crawl", "token" => "ccbot" },
|
|
38
|
+
{ "name" => "CCBot", "company" => "Common Crawl", "kind" => "crawl", "token" => "ccbot", "ranges" => "https://index.commoncrawl.org/ccbot.json" },
|
|
32
39
|
{ "name" => "cohere-ai", "company" => "Cohere", "kind" => "crawl", "token" => "cohere-ai" },
|
|
33
40
|
{ "name" => "YouBot", "company" => "You.com", "kind" => "crawl", "token" => "youbot" },
|
|
34
41
|
{ "name" => "Diffbot", "company" => "Diffbot", "kind" => "crawl", "token" => "diffbot" },
|
data/lib/runlight/routes.rb
CHANGED
|
@@ -2090,7 +2090,7 @@ module Runlight
|
|
|
2090
2090
|
body = read_json(request)
|
|
2091
2091
|
return body if body.is_a?(Http::Response)
|
|
2092
2092
|
|
|
2093
|
-
# One fetch as { url, userAgent, at? }, or up to 500 as { fetches: [...] } from a log reader.
|
|
2093
|
+
# One fetch as { url, userAgent, at?, ip? }, or up to 500 as { fetches: [...] } from a log reader.
|
|
2094
2094
|
fetches = Js.get(body, "fetches")
|
|
2095
2095
|
batch = fetches.is_a?(Array)
|
|
2096
2096
|
list = batch ? fetches : [body]
|
|
@@ -2110,10 +2110,14 @@ module Runlight
|
|
|
2110
2110
|
Importers::Client.parse_date(at)
|
|
2111
2111
|
end
|
|
2112
2112
|
agent = read.call("userAgent")
|
|
2113
|
+
# ip is the address the agent's request came from, checked against its company's list and then dropped.
|
|
2114
|
+
ip = read.call("ip")
|
|
2115
|
+
ip = ip.is_a?(String) ? Js.slice(ip, 0, 100) : ""
|
|
2113
2116
|
pages << {
|
|
2114
2117
|
"page" => page,
|
|
2115
2118
|
"userAgent" => Js.slice(agent.nil? || agent.equal?(UNDEFINED) ? "" : Js.string(agent), 0, 500),
|
|
2116
2119
|
"at" => !whenever.nil? && whenever.to_f.finite? ? whenever : nil,
|
|
2120
|
+
"ip" => ip,
|
|
2117
2121
|
}
|
|
2118
2122
|
end
|
|
2119
2123
|
keep = pages
|
|
@@ -2125,8 +2129,10 @@ module Runlight
|
|
|
2125
2129
|
return coded("Unauthorized", "unauthorized", 401) if !batch && keep.empty?
|
|
2126
2130
|
end
|
|
2127
2131
|
recorded = 0
|
|
2132
|
+
# A fetch reported without its address cannot be checked; it is never checked against the reporter's own.
|
|
2128
2133
|
keep.each do |p|
|
|
2129
|
-
|
|
2134
|
+
request = Http::Request.new(p["page"].href, method: "GET", headers: { "user-agent" => p["userAgent"] })
|
|
2135
|
+
recorded += 1 if rl.observe(request, p["at"], { "agentIp" => p["ip"] })
|
|
2130
2136
|
end
|
|
2131
2137
|
# A single report, as the CMS plugins send, needs no answer; a batch learns what was kept.
|
|
2132
2138
|
return Http::Response.new("", status: 204) unless batch
|
|
@@ -25,7 +25,7 @@ module Runlight
|
|
|
25
25
|
S = "[^#{Js::SPACE}]"
|
|
26
26
|
|
|
27
27
|
# host? ip - user [time] "METHOD /path HTTP/x" status bytes "referrer" "user agent"
|
|
28
|
-
COMBINED = /\A(?:(#{S}+) )
|
|
28
|
+
COMBINED = /\A(?:(#{S}+) )?(#{S}+) #{S}+ #{S}+ \[([^\]]+)\] "(#{S}+) (#{S}+)[^"]*" ([0-9]{3}) #{S}+ "(?:[^"\\]|\\[^\n\r
])*" "((?:[^"\\]|\\[^\n\r
])*)"/
|
|
29
29
|
|
|
30
30
|
# A request line and status, in a line that might be a combined log line without its host.
|
|
31
31
|
REQUEST = %r{"#{S}+ /#{S}* [^"]*" [0-9]{3}}
|
|
@@ -137,8 +137,15 @@ module Runlight
|
|
|
137
137
|
url = uri.is_a?(String) ? page_url(uri, host) : nil
|
|
138
138
|
return nil if url.nil?
|
|
139
139
|
|
|
140
|
+
# client_ip is the visitor's address after Caddy's trusted proxies; remote_ip is the connection's.
|
|
141
|
+
client = at(request, "client_ip")
|
|
142
|
+
remote = at(request, "remote_ip")
|
|
143
|
+
ip = if client.is_a?(String) then client
|
|
144
|
+
elsif remote.is_a?(String) then remote
|
|
145
|
+
else ""
|
|
146
|
+
end
|
|
140
147
|
return { "method" => method, "url" => url, "status" => Js.number(either(at(entry, "status"), 0)),
|
|
141
|
-
"userAgent" => Js.string(ua), "at" => at_ms }
|
|
148
|
+
"userAgent" => Js.string(ua), "at" => at_ms, "ip" => ip }
|
|
142
149
|
rescue Json::ParseError, TypeError
|
|
143
150
|
return nil
|
|
144
151
|
end
|
|
@@ -154,11 +161,11 @@ module Runlight
|
|
|
154
161
|
base = vhost.nil? ? site : "https://#{vhost}"
|
|
155
162
|
return nil if base.nil? || base == ""
|
|
156
163
|
|
|
157
|
-
url = page_url(m[
|
|
164
|
+
url = page_url(m[5], base)
|
|
158
165
|
return nil if url.nil?
|
|
159
166
|
|
|
160
|
-
{ "method" => m[
|
|
161
|
-
"at" => log_time(m[
|
|
167
|
+
{ "method" => m[4], "url" => url, "status" => m[6].to_i, "userAgent" => m[7].gsub('\\"', '"'),
|
|
168
|
+
"at" => log_time(m[3]), "ip" => m[2] }
|
|
162
169
|
end
|
|
163
170
|
|
|
164
171
|
# The lines worth sending: GETs that succeeded, from known AI agents. now gives epoch milliseconds, for a
|
|
@@ -170,7 +177,10 @@ module Runlight
|
|
|
170
177
|
|
|
171
178
|
at = hit["at"]
|
|
172
179
|
finite = !at.is_a?(Float) || at.finite?
|
|
173
|
-
{ "url" => hit["url"], "userAgent" => hit["userAgent"], "at" => finite ? at : (now || method(:clock)).call }
|
|
180
|
+
fetch = { "url" => hit["url"], "userAgent" => hit["userAgent"], "at" => finite ? at : (now || method(:clock)).call }
|
|
181
|
+
# The address goes along so the server can check the agent's published list.
|
|
182
|
+
fetch["ip"] = hit["ip"] unless hit["ip"].nil? || hit["ip"] == ""
|
|
183
|
+
fetch
|
|
174
184
|
end
|
|
175
185
|
|
|
176
186
|
def clock
|
data/lib/runlight/server/cli.rb
CHANGED
|
@@ -24,8 +24,12 @@ module Runlight
|
|
|
24
24
|
Add --config <file> to read settings from a config.rb other than the
|
|
25
25
|
project folder's. Settings are read from the environment first and then
|
|
26
26
|
config.rb: PORT, HOST, DATA_DIR, DATABASE_URL, RUNLIGHT_SECRET,
|
|
27
|
-
RUNLIGHT_TOKEN, RUNLIGHT_URL, TRUST_PROXY, RUNLIGHT_GEO, CRON_SECRET,
|
|
28
|
-
RUNLIGHT_OBSERVE_KEY.
|
|
27
|
+
RUNLIGHT_TOKEN, RUNLIGHT_URL, TRUST_PROXY, RUNLIGHT_GEO, CRON_SECRET,
|
|
28
|
+
RUNLIGHT_OBSERVE_KEY, and RUNLIGHT_AGENT_RANGES.
|
|
29
|
+
|
|
30
|
+
RUNLIGHT_AGENT_RANGES=off stops the daily download of the address lists AI
|
|
31
|
+
companies publish, which AI agent fetches are checked against; the lists that
|
|
32
|
+
came with this version are used instead.
|
|
29
33
|
|
|
30
34
|
The server runs the scheduled check every five minutes. To run it from
|
|
31
35
|
cron as well, or for a server that is not always running:
|
|
@@ -21,6 +21,8 @@ module Runlight
|
|
|
21
21
|
# RUNLIGHT_GEO city (the default), country, off, or the path to an MMDB file
|
|
22
22
|
# CRON_SECRET lets a scheduler run the check over HTTP, at POST /api/check
|
|
23
23
|
# RUNLIGHT_OBSERVE_KEY one key for every site's AI agent reports
|
|
24
|
+
# RUNLIGHT_AGENT_RANGES on (the default) downloads the AI agents' published address lists daily; off
|
|
25
|
+
# keeps the lists that came with this version
|
|
24
26
|
#
|
|
25
27
|
# Relative paths are read from the project folder.
|
|
26
28
|
class Config
|
|
@@ -117,6 +119,16 @@ module Runlight
|
|
|
117
119
|
|
|
118
120
|
# false with nothing in front, or the one header your proxy sets, such as cf-connecting-ip behind Cloudflare.
|
|
119
121
|
# Unset stays nil, so the library's default applies: forwarding headers count only from a private address.
|
|
122
|
+
# Whether the scheduled check downloads the AI agents' address lists: RUNLIGHT_AGENT_RANGES on (the
|
|
123
|
+
# default) or off.
|
|
124
|
+
def agent_ranges
|
|
125
|
+
value = get("RUNLIGHT_AGENT_RANGES")
|
|
126
|
+
return true if value.nil? || value == "on"
|
|
127
|
+
return false if value == "off"
|
|
128
|
+
|
|
129
|
+
raise RuntimeError, "Runlight: set RUNLIGHT_AGENT_RANGES to on or off"
|
|
130
|
+
end
|
|
131
|
+
|
|
120
132
|
def trust_proxy
|
|
121
133
|
value = (get("TRUST_PROXY") || "").downcase
|
|
122
134
|
return nil if value == ""
|
|
@@ -196,6 +208,7 @@ module Runlight
|
|
|
196
208
|
"geoCredit" => !db_ip.nil?,
|
|
197
209
|
"cronSecret" => get("CRON_SECRET"),
|
|
198
210
|
"observeKey" => get("RUNLIGHT_OBSERVE_KEY"),
|
|
211
|
+
"updateAgentRanges" => agent_ranges,
|
|
199
212
|
}
|
|
200
213
|
settings["geo"] = geo unless geo.nil?
|
|
201
214
|
unless code.nil?
|
|
@@ -58,6 +58,8 @@ module Runlight
|
|
|
58
58
|
# written in setup.txt, for the page that asks for it.
|
|
59
59
|
# - cronSecret: a bearer secret for POST /api/check, for a scheduler that calls it over HTTP.
|
|
60
60
|
# - observeKey: one key for every site's AI agent reports.
|
|
61
|
+
# - updateAgentRanges: download the address lists AI companies publish once a day, to check AI agent fetches
|
|
62
|
+
# against. Default false; Config turns it on unless RUNLIGHT_AGENT_RANGES is off.
|
|
61
63
|
def initialize(options)
|
|
62
64
|
options = Options.normalize(options)
|
|
63
65
|
@store = options["store"]
|
|
@@ -80,6 +82,7 @@ module Runlight
|
|
|
80
82
|
core["trustProxy"] = @trust_proxy unless @trust_proxy.nil?
|
|
81
83
|
core["geo"] = options["geo"] unless options["geo"].nil?
|
|
82
84
|
core["fetcher"] = options["fetcher"] unless options["fetcher"].nil?
|
|
85
|
+
core["updateAgentRanges"] = options["updateAgentRanges"] ? true : false
|
|
83
86
|
@runlight = rl = Core.new(core)
|
|
84
87
|
|
|
85
88
|
# Accounts, shared with apps that turn them on. The first one is made with the one-time code, or with the
|
|
@@ -1284,12 +1284,24 @@ module Runlight
|
|
|
1284
1284
|
def breakdown(query, dimension, limit, offset)
|
|
1285
1285
|
page = [limit, offset]
|
|
1286
1286
|
dialect = @db.dialect
|
|
1287
|
-
if dimension == "ai_agent"
|
|
1288
|
-
|
|
1287
|
+
if dimension == "ai_agent"
|
|
1288
|
+
# A fetch's title holds its address check (see Core#observe); only "verified" is not claimed.
|
|
1289
|
+
claimed = "CASE WHEN e.title = 'verified' THEN 0 ELSE 1 END"
|
|
1289
1290
|
rows = @db.all(
|
|
1290
|
-
"SELECT #{
|
|
1291
|
+
"SELECT e.name AS value, #{claimed} AS claimed, COUNT(*) AS fetches FROM rl_events e
|
|
1291
1292
|
WHERE e.site = ? AND e.ts >= ? AND e.ts < ? AND e.kind = 'fetch'
|
|
1292
|
-
GROUP BY #{
|
|
1293
|
+
GROUP BY e.name, #{claimed} ORDER BY fetches DESC, e.name#{text_order}, claimed LIMIT ? OFFSET ?",
|
|
1294
|
+
[query["site"], query["from"], query["to"], *page],
|
|
1295
|
+
)
|
|
1296
|
+
return rows.map do |row|
|
|
1297
|
+
{ "value" => Sql.string(row["value"]), "visitors" => 0, "fetches" => Sql.num(row["fetches"]), "claimed" => Sql.num(row["claimed"]) == 1 }
|
|
1298
|
+
end
|
|
1299
|
+
end
|
|
1300
|
+
if dimension == "ai_page"
|
|
1301
|
+
rows = @db.all(
|
|
1302
|
+
"SELECT e.path AS value, COUNT(*) AS fetches FROM rl_events e
|
|
1303
|
+
WHERE e.site = ? AND e.ts >= ? AND e.ts < ? AND e.kind = 'fetch'
|
|
1304
|
+
GROUP BY e.path ORDER BY fetches DESC, e.path#{text_order} LIMIT ? OFFSET ?",
|
|
1293
1305
|
[query["site"], query["from"], query["to"], *page],
|
|
1294
1306
|
)
|
|
1295
1307
|
return rows.map { |row| { "value" => Sql.string(row["value"]), "visitors" => 0, "fetches" => Sql.num(row["fetches"]) } }
|
data/lib/runlight/version.rb
CHANGED
|
@@ -4,7 +4,7 @@ require "json"
|
|
|
4
4
|
|
|
5
5
|
module Runlight
|
|
6
6
|
# The gem's own version, which the release keeps equal to the npm package's.
|
|
7
|
-
GEM_VERSION = "0.
|
|
7
|
+
GEM_VERSION = "0.4.0"
|
|
8
8
|
|
|
9
9
|
# The SDK's version and the HTTP API's, read from assets/build.json, which
|
|
10
10
|
# scripts/ruby-assets.mts writes from the TypeScript SDK, so the two always
|
data/lib/runlight.rb
CHANGED
|
@@ -13,6 +13,7 @@ require_relative "runlight/undefined"
|
|
|
13
13
|
#
|
|
14
14
|
# Every class loads on first use.
|
|
15
15
|
module Runlight
|
|
16
|
+
autoload :AgentCheck, "runlight/agent_check"
|
|
16
17
|
autoload :Assistant, "runlight/assistant"
|
|
17
18
|
autoload :AssistantError, "runlight/assistant_error"
|
|
18
19
|
autoload :Body, "runlight/body"
|
metadata
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: runlight
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.4.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Jon C. Phillips
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: exe
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date: 2026-11-
|
|
11
|
+
date: 2026-11-09 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: activerecord
|
|
@@ -41,6 +41,7 @@ extra_rdoc_files: []
|
|
|
41
41
|
files:
|
|
42
42
|
- LICENSE
|
|
43
43
|
- README.md
|
|
44
|
+
- assets/agent-ranges.json
|
|
44
45
|
- assets/assistant-action.json
|
|
45
46
|
- assets/build.json
|
|
46
47
|
- assets/dashboard.css
|
|
@@ -61,6 +62,7 @@ files:
|
|
|
61
62
|
- lib/runlight/accounts/scrypt.rb
|
|
62
63
|
- lib/runlight/accounts/throttle.rb
|
|
63
64
|
- lib/runlight/accounts/web.rb
|
|
65
|
+
- lib/runlight/agent_check.rb
|
|
64
66
|
- lib/runlight/assistant.rb
|
|
65
67
|
- lib/runlight/assistant_error.rb
|
|
66
68
|
- lib/runlight/body.rb
|