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.
@@ -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
- private_constant :ROLLUP_VERSION, :ROLLUP_BATCH, :REMOTE_MAX_BYTES, :METERED_ROLLUP_BATCH, :ROLLUP_DELAY_MS, :TRACKER_WAIT_MS
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
- def observe(request, at = nil)
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)
@@ -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" },
@@ -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
- recorded += 1 if rl.observe(Http::Request.new(p["page"].href, method: "GET", headers: { "user-agent" => p["userAgent"] }), p["at"])
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}+) )?#{S}+ #{S}+ #{S}+ \[([^\]]+)\] "(#{S}+) (#{S}+)[^"]*" ([0-9]{3}) #{S}+ "(?:[^"\\]|\\[^\n\r

])*" "((?:[^"\\]|\\[^\n\r

])*)"/
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[4], base)
164
+ url = page_url(m[5], base)
158
165
  return nil if url.nil?
159
166
 
160
- { "method" => m[3], "url" => url, "status" => m[5].to_i, "userAgent" => m[6].gsub('\\"', '"'),
161
- "at" => log_time(m[2]) }
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
@@ -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, and
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" || dimension == "ai_page"
1288
- col = dimension == "ai_agent" ? "e.name" : "e.path"
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 #{col} AS value, COUNT(*) AS fetches FROM rl_events e
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 #{col} ORDER BY fetches DESC, #{col}#{text_order} LIMIT ? OFFSET ?",
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"]) } }
@@ -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.3.3"
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.3.3
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-06 00:00:00.000000000 Z
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