synthient 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/LICENSE +202 -0
- data/README.md +420 -0
- data/lib/synthient/client.rb +188 -0
- data/lib/synthient/errors.rb +60 -0
- data/lib/synthient/feed.rb +135 -0
- data/lib/synthient/grpcschema.rb +231 -0
- data/lib/synthient/model.rb +93 -0
- data/lib/synthient/models/account.rb +67 -0
- data/lib/synthient/models/domain.rb +100 -0
- data/lib/synthient/models/events.rb +202 -0
- data/lib/synthient/models/export.rb +65 -0
- data/lib/synthient/models/lookup.rb +86 -0
- data/lib/synthient/response_metadata.rb +16 -0
- data/lib/synthient/transport.rb +340 -0
- data/lib/synthient/version.rb +5 -0
- data/lib/synthient.rb +30 -0
- data/sig/manifest.yaml +4 -0
- data/sig/synthient/models.rbs +555 -0
- data/sig/synthient.rbs +115 -0
- metadata +62 -0
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Synthient
|
|
4
|
+
# A client for the Synthient API.
|
|
5
|
+
#
|
|
6
|
+
# A client holds a pool of keep-alive connections and is safe to share
|
|
7
|
+
# between threads, so create one and reuse it. Call #close on shutdown. In a
|
|
8
|
+
# forking server, connections inherited from the parent are never reused.
|
|
9
|
+
#
|
|
10
|
+
# Lookups are billed and are never resent after a network failure, because
|
|
11
|
+
# the API may already have done the work. They are retried only when the API
|
|
12
|
+
# answers 429 with a Retry-After header, which it does before any work.
|
|
13
|
+
# Account and feed requests are also retried on 500, 502, 503, and 504
|
|
14
|
+
# responses and on network failures other than TLS errors before a response
|
|
15
|
+
# arrives. Retries wait with jittered exponential backoff, or as long as
|
|
16
|
+
# Retry-After asks, and stop when Retry-After exceeds 60 seconds.
|
|
17
|
+
#
|
|
18
|
+
# @example
|
|
19
|
+
# client = Synthient::Client.new # reads SYNTHIENT_API_KEY
|
|
20
|
+
# info = client.lookup_ip("8.8.8.8")
|
|
21
|
+
# puts info.intelligence&.risk_score
|
|
22
|
+
class Client
|
|
23
|
+
MAX_BATCH_SIZE = 1000
|
|
24
|
+
|
|
25
|
+
# @return [Feed<ProxyEvent>] proxy sightings
|
|
26
|
+
attr_reader :proxies
|
|
27
|
+
# @return [Feed<AnonymizerEvent>] VPN and anonymizer address ranges
|
|
28
|
+
attr_reader :anonymizers
|
|
29
|
+
# @return [Feed<TorrentEvent>] torrents seen on the BitTorrent DHT and trackers
|
|
30
|
+
attr_reader :torrents
|
|
31
|
+
# @return [Feed<JA4TEvent>] JA4T TCP fingerprint sightings
|
|
32
|
+
attr_reader :ja4t
|
|
33
|
+
# @return [Feed<HeliosHTTPEvent>] HTTP requests captured by Helios honeypots
|
|
34
|
+
attr_reader :helios_http
|
|
35
|
+
# @return [Feed<HeliosHTTPSEvent>] TLS ClientHellos captured by Helios honeypots
|
|
36
|
+
attr_reader :helios_https
|
|
37
|
+
# @return [Feed<HeliosDNSEvent>] destinations Helios honeypot tunnels were asked to reach
|
|
38
|
+
attr_reader :helios_dns
|
|
39
|
+
# @return [Feed<HeliosADBEvent>] ADB shell commands captured by Helios honeypots
|
|
40
|
+
attr_reader :helios_adb
|
|
41
|
+
|
|
42
|
+
# Makes no network requests.
|
|
43
|
+
#
|
|
44
|
+
# @param max_retries [Integer] retries after the first attempt; 0 disables them
|
|
45
|
+
# @param timeout [Numeric] seconds to wait for each read or write
|
|
46
|
+
# @param open_timeout [Numeric] seconds to wait for a connection
|
|
47
|
+
# @param user_agent [String, nil] appended to the SDK's User-Agent
|
|
48
|
+
# @param response_hook [#call, nil] called with a ResponseMetadata for every
|
|
49
|
+
# API response, before its body is read. Exceptions it raises propagate.
|
|
50
|
+
# @raise [ArgumentError] if the API key is missing or an option is invalid
|
|
51
|
+
def initialize(api_key: ENV.fetch("SYNTHIENT_API_KEY", nil), base_url: DEFAULT_BASE_URL, max_retries: 2,
|
|
52
|
+
timeout: 60, open_timeout: 10, user_agent: nil, response_hook: nil)
|
|
53
|
+
raise TypeError, "api_key must be a String" unless api_key.nil? || api_key.is_a?(String)
|
|
54
|
+
|
|
55
|
+
api_key = api_key.to_s.strip
|
|
56
|
+
raise ArgumentError, "missing API key: pass api_key: or set SYNTHIENT_API_KEY" if api_key.empty?
|
|
57
|
+
raise ArgumentError, "API key contains a line break" if api_key.match?(/[\r\n]/)
|
|
58
|
+
|
|
59
|
+
base_url = base_url.to_s.delete_suffix("/")
|
|
60
|
+
uri = begin
|
|
61
|
+
URI.parse(base_url)
|
|
62
|
+
rescue URI::InvalidURIError
|
|
63
|
+
nil
|
|
64
|
+
end
|
|
65
|
+
unless uri.is_a?(URI::HTTP) && !uri.host.to_s.empty? && uri.query.nil? && uri.fragment.nil? && uri.userinfo.nil?
|
|
66
|
+
raise ArgumentError, "invalid base URL #{base_url.inspect}"
|
|
67
|
+
end
|
|
68
|
+
raise ArgumentError, "max_retries must be a non-negative Integer" unless max_retries.is_a?(Integer) && max_retries >= 0
|
|
69
|
+
|
|
70
|
+
{ timeout:, open_timeout: }.each do |name, value|
|
|
71
|
+
unless value.is_a?(Numeric) && value.positive? && value.finite?
|
|
72
|
+
raise ArgumentError, "#{name} must be a positive finite number of seconds"
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
raise TypeError, "response_hook must respond to call" unless response_hook.nil? || response_hook.respond_to?(:call)
|
|
76
|
+
raise ArgumentError, "user_agent contains a line break" if user_agent.to_s.match?(/[\r\n]/)
|
|
77
|
+
|
|
78
|
+
agent = ["synthient-ruby/#{VERSION}", user_agent.to_s.strip].reject(&:empty?).join(" ")
|
|
79
|
+
@base_url = base_url
|
|
80
|
+
@transport = Transport.new(base_url:, api_key:, user_agent: agent, max_retries:, timeout:, open_timeout:,
|
|
81
|
+
response_hook:)
|
|
82
|
+
@proxies = Feed.new(@transport, "/feeds/proxies", ProxyEvent)
|
|
83
|
+
@anonymizers = Feed.new(@transport, "/feeds/anonymizers", AnonymizerEvent)
|
|
84
|
+
@torrents = Feed.new(@transport, "/feeds/torrents", TorrentEvent)
|
|
85
|
+
@ja4t = Feed.new(@transport, "/feeds/ja4t", JA4TEvent)
|
|
86
|
+
@helios_http = Feed.new(@transport, "/feeds/helio/http", HeliosHTTPEvent)
|
|
87
|
+
@helios_https = Feed.new(@transport, "/feeds/helio/https", HeliosHTTPSEvent)
|
|
88
|
+
@helios_dns = Feed.new(@transport, "/feeds/helio/dns", HeliosDNSEvent)
|
|
89
|
+
@helios_adb = Feed.new(@transport, "/feeds/helio/adb", HeliosADBEvent)
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# Costs one lookup credit; failed lookups are free.
|
|
93
|
+
#
|
|
94
|
+
# @param ip [String, IPAddr] an IPv4 or IPv6 address, without a prefix or zone
|
|
95
|
+
# @return [IPInfo]
|
|
96
|
+
# @raise [APIError] for example 402 when credits run out
|
|
97
|
+
def lookup_ip(ip)
|
|
98
|
+
json = @transport.json("GET", "/lookup/ip/#{escape_path(canonical_ip(ip))}", policy: :rate_limit)
|
|
99
|
+
Model.decode(IPInfo, json)
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# Looks up to 1000 addresses in one request. The API drops duplicate,
|
|
103
|
+
# private, and reserved addresses, so match results by +ip+, not position.
|
|
104
|
+
# An invalid address raises ArgumentError before the request. Costs
|
|
105
|
+
# ceil(n * 0.9) credits for n distinct usable addresses, and nothing if the
|
|
106
|
+
# request fails. An empty list returns [] without a request.
|
|
107
|
+
#
|
|
108
|
+
# @param ips [Enumerable<String, IPAddr>]
|
|
109
|
+
# @return [Array<IPInfo>]
|
|
110
|
+
def lookup_ips(ips)
|
|
111
|
+
raise TypeError, "ips must be Enumerable" unless ips.is_a?(Enumerable)
|
|
112
|
+
|
|
113
|
+
ips = ips.to_a
|
|
114
|
+
raise ArgumentError, "#{ips.size} addresses exceed the batch limit of #{MAX_BATCH_SIZE}" if ips.size > MAX_BATCH_SIZE
|
|
115
|
+
return [] if ips.empty?
|
|
116
|
+
|
|
117
|
+
addresses = ips.each_with_index.map do |ip, index|
|
|
118
|
+
canonical_ip(ip)
|
|
119
|
+
rescue ArgumentError, TypeError => e
|
|
120
|
+
raise e.class, "#{e.message} at index #{index}"
|
|
121
|
+
end
|
|
122
|
+
json = @transport.json("POST", "/lookup/ips", policy: :rate_limit, body: JSON.generate({ ips: addresses }))
|
|
123
|
+
Model.decode_value([IPInfo], json["results"] || raise(DecodeError, "IPBatch.results is required"), "IPBatch.results")
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
# Looks up the honeypot traffic Synthient has observed for a domain. Costs
|
|
127
|
+
# one lookup credit.
|
|
128
|
+
#
|
|
129
|
+
# @param limit [Integer, nil] how many recent events to return; the API defaults to 50 and clamps values above 1000
|
|
130
|
+
# @return [DomainInfo]
|
|
131
|
+
def lookup_domain(domain, limit: nil)
|
|
132
|
+
raise TypeError, "domain must be a String" unless domain.is_a?(String)
|
|
133
|
+
raise ArgumentError, "invalid domain" if ["", ".", ".."].include?(domain)
|
|
134
|
+
raise ArgumentError, "limit must be a positive Integer" unless limit.nil? || (limit.is_a?(Integer) && limit.positive?)
|
|
135
|
+
|
|
136
|
+
json = @transport.json("GET", "/lookup/domain/#{escape_path(domain)}", policy: :rate_limit, query: { limit: }.compact)
|
|
137
|
+
Model.decode(DomainInfo, json)
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
# Returns the account that owns the API key, including its remaining
|
|
141
|
+
# lookup credits. Free.
|
|
142
|
+
#
|
|
143
|
+
# @return [Account]
|
|
144
|
+
def account
|
|
145
|
+
Model.decode(Account, @transport.json("GET", "/account/me", policy: :transient))
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
# Raises APIError when the API is unhealthy. A 503 is not retried, since it
|
|
149
|
+
# is the answer.
|
|
150
|
+
#
|
|
151
|
+
# @return [nil]
|
|
152
|
+
def health
|
|
153
|
+
@transport.json("GET", "/health", policy: :rate_limit)
|
|
154
|
+
nil
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
# Closes idle connections. Idempotent. Requests after close raise IOError.
|
|
158
|
+
#
|
|
159
|
+
# @return [nil]
|
|
160
|
+
def close
|
|
161
|
+
@transport.close
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
def inspect
|
|
165
|
+
"#<#{self.class.name} base_url=#{@base_url.inspect}>"
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
private
|
|
169
|
+
|
|
170
|
+
def canonical_ip(ip)
|
|
171
|
+
if ip.is_a?(IPAddr)
|
|
172
|
+
raise ArgumentError, "IP address must not have a prefix" unless ip.prefix == (ip.ipv4? ? 32 : 128)
|
|
173
|
+
|
|
174
|
+
ip = ip.to_s
|
|
175
|
+
end
|
|
176
|
+
raise TypeError, "IP address must be a String or IPAddr" unless ip.is_a?(String)
|
|
177
|
+
raise ArgumentError, "invalid IP address" if ip.include?("/") || ip.include?("%")
|
|
178
|
+
|
|
179
|
+
IPAddr.new(ip).to_s
|
|
180
|
+
rescue IPAddr::InvalidAddressError
|
|
181
|
+
raise ArgumentError, "invalid IP address"
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
def escape_path(value)
|
|
185
|
+
value.b.gsub(/[^A-Za-z0-9\-._~$&+:=@]/n) { |char| format("%%%02X", char.ord) }
|
|
186
|
+
end
|
|
187
|
+
end
|
|
188
|
+
end
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Synthient
|
|
4
|
+
# The base class of every error the SDK raises for a failed request.
|
|
5
|
+
# Invalid arguments raise ArgumentError or TypeError instead.
|
|
6
|
+
class Error < StandardError; end
|
|
7
|
+
|
|
8
|
+
# The request could not be sent, or the response could not be read. The
|
|
9
|
+
# underlying exception is available as +cause+.
|
|
10
|
+
class ConnectionError < Error; end
|
|
11
|
+
|
|
12
|
+
# Connecting, sending, or waiting for response data took longer than the
|
|
13
|
+
# client's timeout.
|
|
14
|
+
class TimeoutError < ConnectionError; end
|
|
15
|
+
|
|
16
|
+
# A successful response did not match the API schema.
|
|
17
|
+
class DecodeError < Error; end
|
|
18
|
+
|
|
19
|
+
# The API, or export storage, answered with an error status.
|
|
20
|
+
#
|
|
21
|
+
# The message holds only the status, such as "429 Too Many Requests", so it
|
|
22
|
+
# is safe to log. The problem details the API sent are available as
|
|
23
|
+
# attributes.
|
|
24
|
+
class APIError < Error
|
|
25
|
+
STATUS_TEXT = {
|
|
26
|
+
300 => "Multiple Choices", 301 => "Moved Permanently", 302 => "Found", 303 => "See Other",
|
|
27
|
+
304 => "Not Modified", 307 => "Temporary Redirect", 308 => "Permanent Redirect",
|
|
28
|
+
400 => "Bad Request", 401 => "Unauthorized", 402 => "Payment Required", 403 => "Forbidden",
|
|
29
|
+
404 => "Not Found", 405 => "Method Not Allowed", 406 => "Not Acceptable", 408 => "Request Timeout",
|
|
30
|
+
409 => "Conflict", 410 => "Gone", 413 => "Request Entity Too Large", 415 => "Unsupported Media Type",
|
|
31
|
+
422 => "Unprocessable Entity", 429 => "Too Many Requests", 500 => "Internal Server Error",
|
|
32
|
+
501 => "Not Implemented", 502 => "Bad Gateway", 503 => "Service Unavailable", 504 => "Gateway Timeout"
|
|
33
|
+
}.freeze
|
|
34
|
+
|
|
35
|
+
attr_reader :status
|
|
36
|
+
# @return [String, nil] the problem type URI
|
|
37
|
+
attr_reader :type
|
|
38
|
+
# @return [String, nil] a short summary of the problem
|
|
39
|
+
attr_reader :title
|
|
40
|
+
# @return [String, nil] an explanation of this occurrence of the problem
|
|
41
|
+
attr_reader :detail
|
|
42
|
+
# @return [Hash{String => Array<String>}] validation errors by field, from a 422
|
|
43
|
+
attr_reader :errors
|
|
44
|
+
# @return [String, nil] the X-Request-Id of the response, for support requests
|
|
45
|
+
attr_reader :request_id
|
|
46
|
+
# @return [Numeric, nil] seconds the server asked to wait before retrying
|
|
47
|
+
attr_reader :retry_after
|
|
48
|
+
|
|
49
|
+
def initialize(status:, type: nil, title: nil, detail: nil, errors: {}, request_id: nil, retry_after: nil)
|
|
50
|
+
@status = status
|
|
51
|
+
@type = type
|
|
52
|
+
@title = title
|
|
53
|
+
@detail = detail
|
|
54
|
+
@errors = errors
|
|
55
|
+
@request_id = request_id
|
|
56
|
+
@retry_after = retry_after
|
|
57
|
+
super([status, STATUS_TEXT[status]].compact.join(" "))
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Synthient
|
|
4
|
+
# A Synthient data feed: a real-time stream of events and hourly parquet
|
|
5
|
+
# exports that roll up into daily exports. Access feeds through the client,
|
|
6
|
+
# such as Client#proxies.
|
|
7
|
+
#
|
|
8
|
+
# An export is identified by "latest", a UTC day as a Date or
|
|
9
|
+
# "YYYY-MM-DD", or a UTC hour as a Time or "YYYY-MM-DD/H", where a
|
|
10
|
+
# zero-padded hour is also accepted. Export#id values can be passed as they
|
|
11
|
+
# are. "latest" is the newest hourly export during the day, or the daily
|
|
12
|
+
# export once that day's hours are rolled up.
|
|
13
|
+
#
|
|
14
|
+
# An hourly export is available until its day's daily export is rolled up,
|
|
15
|
+
# shortly after 00:30 UTC the next day. After that, requests for the hour
|
|
16
|
+
# raise a 400 APIError. While a daily export is being rolled up, requests
|
|
17
|
+
# for it raise a 404 APIError with +retry_after+ set.
|
|
18
|
+
class Feed
|
|
19
|
+
# @api private
|
|
20
|
+
def initialize(transport, path, event_type)
|
|
21
|
+
@transport = transport
|
|
22
|
+
@path = path
|
|
23
|
+
@event_type = event_type
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Needs the feed's stream scope, such as PROXY_FIREHOSE.
|
|
27
|
+
#
|
|
28
|
+
# The server ends each stream after up to 30 minutes, and the call then
|
|
29
|
+
# returns normally. Events sent while disconnected are missed, so
|
|
30
|
+
# reconnect in a loop. Breaking out of the block closes the connection.
|
|
31
|
+
# Without a block, returns an Enumerator that opens the connection when
|
|
32
|
+
# iterated.
|
|
33
|
+
#
|
|
34
|
+
# @yieldparam event [Object] an event of this feed's type
|
|
35
|
+
# @return [nil]
|
|
36
|
+
def stream
|
|
37
|
+
return enum_for(:stream) unless block_given?
|
|
38
|
+
|
|
39
|
+
@transport.stream("#{@path}/stream") { |object| yield Model.decode(@event_type, object) }
|
|
40
|
+
nil
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Returns one page of the feed's exports, newest first.
|
|
44
|
+
#
|
|
45
|
+
# @param limit [Integer, nil] exports per page; the API defaults to 100 and clamps values above 500
|
|
46
|
+
# @param cursor [String, nil] the +next_cursor+ of the previous page
|
|
47
|
+
# @return [ExportPage]
|
|
48
|
+
def list_exports(limit: nil, cursor: nil)
|
|
49
|
+
raise ArgumentError, "limit must be a positive Integer" unless limit.nil? || (limit.is_a?(Integer) && limit.positive?)
|
|
50
|
+
raise TypeError, "cursor must be a String" unless cursor.nil? || cursor.is_a?(String)
|
|
51
|
+
|
|
52
|
+
query = { limit:, cursor: }.reject { |_, value| value.nil? || value == "" }
|
|
53
|
+
Model.decode(ExportPage, @transport.json("GET", "#{@path}/export", policy: :transient, query:))
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Yields every export, newest first, fetching pages as they are needed.
|
|
57
|
+
# Breaking out of the block stops fetching. Without a block, returns an
|
|
58
|
+
# Enumerator.
|
|
59
|
+
#
|
|
60
|
+
# @param limit [Integer, nil] exports per page
|
|
61
|
+
# @param cursor [String, nil] a +next_cursor+ to resume from
|
|
62
|
+
# @yieldparam export [Export]
|
|
63
|
+
# @return [nil]
|
|
64
|
+
# @raise [Error] if the API returns a cursor it already returned
|
|
65
|
+
def each_export(limit: nil, cursor: nil, &block)
|
|
66
|
+
return enum_for(:each_export, limit:, cursor:) unless block
|
|
67
|
+
|
|
68
|
+
seen = Set.new
|
|
69
|
+
while seen.add?(cursor)
|
|
70
|
+
page = list_exports(limit:, cursor:)
|
|
71
|
+
page.exports.each(&block)
|
|
72
|
+
cursor = page.next_cursor
|
|
73
|
+
return if cursor.nil? || cursor.empty?
|
|
74
|
+
end
|
|
75
|
+
raise Error, "export listing returned a repeated cursor"
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# Returns an export's metadata, including its parquet schema.
|
|
79
|
+
#
|
|
80
|
+
# @param export [String, Date, Time]
|
|
81
|
+
# @return [ExportMeta]
|
|
82
|
+
def export_meta(export)
|
|
83
|
+
Model.decode(ExportMeta, @transport.json("GET", "#{@path}/export/#{export_path(export)}/meta", policy: :transient))
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# Returns a presigned URL for downloading an export. The URL is valid for
|
|
87
|
+
# 24 hours and grants access without an API key, so treat it as a secret.
|
|
88
|
+
#
|
|
89
|
+
# @param export [String, Date, Time]
|
|
90
|
+
# @return [URI::HTTP]
|
|
91
|
+
def download_url(export)
|
|
92
|
+
@transport.redirect_location("#{@path}/export/#{export_path(export)}")
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# Downloads an export, yielding the parquet file's bytes in chunks as they
|
|
96
|
+
# arrive. The SDK does not verify them: compare a SHA-256 of the bytes
|
|
97
|
+
# with Export#checksum or ExportMeta#checksum. Breaking out of the block
|
|
98
|
+
# closes the connection. Without a block, returns an Enumerator.
|
|
99
|
+
#
|
|
100
|
+
# @param export [String, Date, Time]
|
|
101
|
+
# @yieldparam chunk [String] binary bytes
|
|
102
|
+
# @return [nil]
|
|
103
|
+
def download(export, &)
|
|
104
|
+
return enum_for(:download, export) unless block_given?
|
|
105
|
+
|
|
106
|
+
@transport.download(download_url(export), &)
|
|
107
|
+
nil
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
private
|
|
111
|
+
|
|
112
|
+
def export_path(export)
|
|
113
|
+
case export
|
|
114
|
+
when Time
|
|
115
|
+
time = export.getutc
|
|
116
|
+
"#{time.strftime("%Y-%m-%d")}/#{time.hour}"
|
|
117
|
+
when DateTime
|
|
118
|
+
raise TypeError, "export must be a Time or Date, not a DateTime"
|
|
119
|
+
when Date
|
|
120
|
+
export.strftime("%Y-%m-%d")
|
|
121
|
+
when String
|
|
122
|
+
return export if export == "latest"
|
|
123
|
+
|
|
124
|
+
match = %r{\A(\d{4})-(\d{2})-(\d{2})(?:/(\d{1,2}))?\z}.match(export)
|
|
125
|
+
unless match && Date.valid_date?(match[1].to_i, match[2].to_i, match[3].to_i) && match[4].to_i <= 23
|
|
126
|
+
raise ArgumentError, "invalid export ID #{export.inspect}"
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
match[4] ? "#{match[1]}-#{match[2]}-#{match[3]}/#{match[4].to_i}" : export
|
|
130
|
+
else
|
|
131
|
+
raise TypeError, "export must be a String, Date, or Time"
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
end
|
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "synthient"
|
|
4
|
+
|
|
5
|
+
begin
|
|
6
|
+
require "grpc"
|
|
7
|
+
require "google/protobuf"
|
|
8
|
+
require "google/protobuf/descriptor_pb"
|
|
9
|
+
rescue LoadError => e
|
|
10
|
+
raise LoadError, "synthient/grpcschema needs the grpc and google-protobuf gems: " \
|
|
11
|
+
"add them to your Gemfile or run `gem install grpc google-protobuf` (#{e.message})"
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
module Synthient
|
|
15
|
+
# Fetches the protobuf schema of the Synthient gRPC API using gRPC server
|
|
16
|
+
# reflection. Load it with <tt>require "synthient/grpcschema"</tt>; it needs
|
|
17
|
+
# the grpc and google-protobuf gems, which the synthient gem does not
|
|
18
|
+
# depend on.
|
|
19
|
+
#
|
|
20
|
+
# @example
|
|
21
|
+
# schema = Synthient::GRPCSchema.fetch
|
|
22
|
+
# schema.descriptor_set.file.each { |file| puts file.name }
|
|
23
|
+
module GRPCSchema
|
|
24
|
+
DEFAULT_ENDPOINT = "grpc.synthient.com:443"
|
|
25
|
+
MAX_MESSAGE_SIZE = 32 << 20
|
|
26
|
+
|
|
27
|
+
EXPLANATIONS = {
|
|
28
|
+
GRPC::Core::StatusCodes::UNAUTHENTICATED => "Missing or invalid API key.",
|
|
29
|
+
GRPC::Core::StatusCodes::PERMISSION_DENIED => "This API key does not have permission to inspect gRPC schemas.",
|
|
30
|
+
GRPC::Core::StatusCodes::NOT_FOUND =>
|
|
31
|
+
"No protobuf schema found for the requested symbol. Fetch without symbols to list the available services.",
|
|
32
|
+
GRPC::Core::StatusCodes::UNIMPLEMENTED => "The gRPC endpoint does not expose server reflection.",
|
|
33
|
+
GRPC::Core::StatusCodes::UNAVAILABLE =>
|
|
34
|
+
"Could not reach the gRPC endpoint. Check the address, network access, and TLS settings.",
|
|
35
|
+
GRPC::Core::StatusCodes::DEADLINE_EXCEEDED =>
|
|
36
|
+
"Timed out reading protobuf schemas from gRPC reflection. Use a longer timeout."
|
|
37
|
+
}.freeze
|
|
38
|
+
|
|
39
|
+
# +endpoint+ is the normalized address that was queried, +symbols+ the
|
|
40
|
+
# names that were resolved, and +descriptor_set+ a
|
|
41
|
+
# Google::Protobuf::FileDescriptorSet holding the resolved files in
|
|
42
|
+
# dependency order, each after the files it imports.
|
|
43
|
+
Schema = Data.define(:endpoint, :symbols, :descriptor_set)
|
|
44
|
+
|
|
45
|
+
# +code+ is the gRPC status code, or nil when the server's reflection
|
|
46
|
+
# responses were unusable.
|
|
47
|
+
class SchemaError < Synthient::Error
|
|
48
|
+
attr_reader :code
|
|
49
|
+
|
|
50
|
+
def initialize(message = nil, code: nil)
|
|
51
|
+
super(message)
|
|
52
|
+
@code = code
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# The reflection v1 messages, built in a private descriptor pool so they
|
|
57
|
+
# never clash with an application's own generated reflection classes.
|
|
58
|
+
# Only the fields the SDK uses are declared.
|
|
59
|
+
# @api private
|
|
60
|
+
MESSAGES = begin
|
|
61
|
+
proto = Google::Protobuf
|
|
62
|
+
field = lambda do |name, number, type, label: :LABEL_OPTIONAL, type_name: nil, oneof: nil|
|
|
63
|
+
proto::FieldDescriptorProto.new(name:, number:, type:, label:, oneof_index: oneof,
|
|
64
|
+
type_name: type_name && ".grpc.reflection.v1.#{type_name}")
|
|
65
|
+
end
|
|
66
|
+
file = proto::FileDescriptorProto.new(
|
|
67
|
+
name: "synthient/grpc_reflection_v1.proto", package: "grpc.reflection.v1", syntax: "proto3",
|
|
68
|
+
message_type: [
|
|
69
|
+
proto::DescriptorProto.new(
|
|
70
|
+
name: "ServerReflectionRequest", oneof_decl: [proto::OneofDescriptorProto.new(name: "message_request")],
|
|
71
|
+
field: [
|
|
72
|
+
field.call("host", 1, :TYPE_STRING),
|
|
73
|
+
field.call("file_by_filename", 3, :TYPE_STRING, oneof: 0),
|
|
74
|
+
field.call("file_containing_symbol", 4, :TYPE_STRING, oneof: 0),
|
|
75
|
+
field.call("list_services", 7, :TYPE_STRING, oneof: 0)
|
|
76
|
+
]
|
|
77
|
+
),
|
|
78
|
+
proto::DescriptorProto.new(
|
|
79
|
+
name: "ServerReflectionResponse", oneof_decl: [proto::OneofDescriptorProto.new(name: "message_response")],
|
|
80
|
+
field: [
|
|
81
|
+
field.call("file_descriptor_response", 4, :TYPE_MESSAGE, type_name: "FileDescriptorResponse", oneof: 0),
|
|
82
|
+
field.call("list_services_response", 6, :TYPE_MESSAGE, type_name: "ListServiceResponse", oneof: 0),
|
|
83
|
+
field.call("error_response", 7, :TYPE_MESSAGE, type_name: "ErrorResponse", oneof: 0)
|
|
84
|
+
]
|
|
85
|
+
),
|
|
86
|
+
proto::DescriptorProto.new(
|
|
87
|
+
name: "FileDescriptorResponse",
|
|
88
|
+
field: [field.call("file_descriptor_proto", 1, :TYPE_BYTES, label: :LABEL_REPEATED)]
|
|
89
|
+
),
|
|
90
|
+
proto::DescriptorProto.new(
|
|
91
|
+
name: "ListServiceResponse",
|
|
92
|
+
field: [field.call("service", 1, :TYPE_MESSAGE, label: :LABEL_REPEATED, type_name: "ServiceResponse")]
|
|
93
|
+
),
|
|
94
|
+
proto::DescriptorProto.new(name: "ServiceResponse", field: [field.call("name", 1, :TYPE_STRING)]),
|
|
95
|
+
proto::DescriptorProto.new(
|
|
96
|
+
name: "ErrorResponse",
|
|
97
|
+
field: [field.call("error_code", 1, :TYPE_INT32), field.call("error_message", 2, :TYPE_STRING)]
|
|
98
|
+
)
|
|
99
|
+
]
|
|
100
|
+
)
|
|
101
|
+
pool = proto::DescriptorPool.new
|
|
102
|
+
pool.add_serialized_file(proto::FileDescriptorProto.encode(file))
|
|
103
|
+
file.message_type.to_h { |message| [message.name, pool.lookup("grpc.reflection.v1.#{message.name}").msgclass] }.freeze
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# Fetches the protobuf files that define +symbols+, or every service the
|
|
107
|
+
# server lists when +symbols+ is empty, along with all of their imports.
|
|
108
|
+
# Listing skips the grpc.reflection services; name one in +symbols+ to
|
|
109
|
+
# fetch it.
|
|
110
|
+
#
|
|
111
|
+
# @param api_key [String, nil] sent as x-api-key metadata unless blank;
|
|
112
|
+
# by default from SYNTHIENT_API_KEY
|
|
113
|
+
# @param endpoint [String] host:port, a bare host (port 443 is assumed), or
|
|
114
|
+
# a grpc:// or https:// URL
|
|
115
|
+
# @param plaintext [Boolean] disables TLS, for local development servers
|
|
116
|
+
# @param symbols [Array<String>] fully qualified service or message names
|
|
117
|
+
# @param timeout [Numeric] seconds the whole fetch may take
|
|
118
|
+
# @return [Schema]
|
|
119
|
+
# @raise [SchemaError] with the gRPC status code when the server rejects a request
|
|
120
|
+
def self.fetch(api_key: ENV.fetch("SYNTHIENT_API_KEY", nil), endpoint: DEFAULT_ENDPOINT, plaintext: false,
|
|
121
|
+
symbols: [], timeout: 30)
|
|
122
|
+
raise ArgumentError, "timeout must be a positive number of seconds" unless timeout.is_a?(Numeric) && timeout.positive?
|
|
123
|
+
|
|
124
|
+
endpoint, host = normalize_endpoint(endpoint)
|
|
125
|
+
credentials = plaintext ? :this_channel_is_insecure : GRPC::Core::ChannelCredentials.new
|
|
126
|
+
channel = GRPC::Core::Channel.new(endpoint, { "grpc.max_receive_message_length" => MAX_MESSAGE_SIZE }, credentials)
|
|
127
|
+
stub = GRPC::ClientStub.new(endpoint, credentials, channel_override: channel)
|
|
128
|
+
deadline = Time.now + timeout
|
|
129
|
+
metadata = api_key.to_s.strip.empty? ? {} : { "x-api-key" => api_key.strip }
|
|
130
|
+
# Each request gets its own stream, because a shared open request stream
|
|
131
|
+
# hangs grpc's client if the server ends the call early.
|
|
132
|
+
exchange = lambda do |request|
|
|
133
|
+
response, = stub.bidi_streamer(
|
|
134
|
+
"/grpc.reflection.v1.ServerReflection/ServerReflectionInfo",
|
|
135
|
+
[MESSAGES["ServerReflectionRequest"].new(host:, **request)],
|
|
136
|
+
MESSAGES["ServerReflectionRequest"].method(:encode), MESSAGES["ServerReflectionResponse"].method(:decode),
|
|
137
|
+
deadline:, metadata:
|
|
138
|
+
).to_a
|
|
139
|
+
raise SchemaError, "reflection stream ended early" unless response
|
|
140
|
+
|
|
141
|
+
error = response.error_response
|
|
142
|
+
raise SchemaError.new(error.error_message, code: error.error_code) if error
|
|
143
|
+
|
|
144
|
+
response
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
symbols = symbols.map { |symbol| symbol.to_s.strip }.reject(&:empty?).uniq
|
|
148
|
+
if symbols.empty?
|
|
149
|
+
list = exchange.call(list_services: "").list_services_response
|
|
150
|
+
raise SchemaError, "reflection response has no service list" unless list
|
|
151
|
+
|
|
152
|
+
symbols = list.service.map { |service| service.name.strip }
|
|
153
|
+
symbols = symbols.reject { |name| name.empty? || name.start_with?("grpc.reflection.") }.sort
|
|
154
|
+
end
|
|
155
|
+
raise SchemaError, "server lists no services" if symbols.empty?
|
|
156
|
+
|
|
157
|
+
files = {}
|
|
158
|
+
add_files = lambda do |response|
|
|
159
|
+
list = response.file_descriptor_response
|
|
160
|
+
raise SchemaError, "reflection response has no file descriptors" unless list
|
|
161
|
+
|
|
162
|
+
list.file_descriptor_proto.each do |data|
|
|
163
|
+
file = Google::Protobuf::FileDescriptorProto.decode(data)
|
|
164
|
+
raise SchemaError, "reflection returned a file descriptor without a name" if file.name.empty?
|
|
165
|
+
|
|
166
|
+
files[file.name] ||= file
|
|
167
|
+
end
|
|
168
|
+
end
|
|
169
|
+
symbols.each { |symbol| add_files.call(exchange.call(file_containing_symbol: symbol)) }
|
|
170
|
+
until (missing = files.each_value.flat_map { |file| file.dependency.to_a }.uniq - files.keys).empty?
|
|
171
|
+
missing.sort.each do |name|
|
|
172
|
+
add_files.call(exchange.call(file_by_filename: name))
|
|
173
|
+
raise SchemaError, "reflection did not return dependency #{name}" unless files.key?(name)
|
|
174
|
+
end
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
ordered = []
|
|
178
|
+
visited = Set.new
|
|
179
|
+
visit = lambda do |name|
|
|
180
|
+
next unless files.key?(name) && visited.add?(name)
|
|
181
|
+
|
|
182
|
+
files[name].dependency.to_a.sort.each { |dependency| visit.call(dependency) }
|
|
183
|
+
ordered << files[name]
|
|
184
|
+
end
|
|
185
|
+
files.keys.sort.each { |name| visit.call(name) }
|
|
186
|
+
Schema.new(endpoint:, symbols: symbols.freeze,
|
|
187
|
+
descriptor_set: Google::Protobuf::FileDescriptorSet.new(file: ordered))
|
|
188
|
+
rescue GRPC::BadStatus => e
|
|
189
|
+
raise SchemaError.new(e.details, code: e.code)
|
|
190
|
+
rescue Google::Protobuf::ParseError
|
|
191
|
+
raise SchemaError, "reflection returned an invalid file descriptor"
|
|
192
|
+
ensure
|
|
193
|
+
channel&.close
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
# Converts an endpoint to host:port form, and returns it with the bare
|
|
197
|
+
# host that reflection requests carry in their host field.
|
|
198
|
+
#
|
|
199
|
+
# @return [Array(String, String)] the endpoint and its host
|
|
200
|
+
# @raise [ArgumentError] if the endpoint is empty or not a valid URL
|
|
201
|
+
def self.normalize_endpoint(raw)
|
|
202
|
+
raw = raw.to_s.strip
|
|
203
|
+
raw = DEFAULT_ENDPOINT if raw.empty?
|
|
204
|
+
if raw.include?("://")
|
|
205
|
+
uri = URI.parse(raw)
|
|
206
|
+
raw = uri.port ? "#{uri.host}:#{uri.port}" : uri.host.to_s
|
|
207
|
+
end
|
|
208
|
+
raw = raw.delete_suffix("/")
|
|
209
|
+
raise ArgumentError, "empty gRPC endpoint" if raw.empty?
|
|
210
|
+
|
|
211
|
+
if raw.start_with?("[")
|
|
212
|
+
host, port = raw.delete_prefix("[").split("]", 2)
|
|
213
|
+
return port.to_s.start_with?(":") ? [raw, host] : ["[#{host}]:443", host]
|
|
214
|
+
end
|
|
215
|
+
return [raw, raw.split(":").first] if raw.count(":") == 1
|
|
216
|
+
|
|
217
|
+
raw.include?(":") ? ["[#{raw}]:443", raw] : ["#{raw}:443", raw]
|
|
218
|
+
rescue URI::InvalidURIError
|
|
219
|
+
raise ArgumentError, "invalid gRPC endpoint #{raw.inspect}"
|
|
220
|
+
end
|
|
221
|
+
private_class_method :normalize_endpoint
|
|
222
|
+
|
|
223
|
+
# Returns a human-readable explanation of a common fetch failure, or nil.
|
|
224
|
+
#
|
|
225
|
+
# @param error [SchemaError, GRPC::BadStatus]
|
|
226
|
+
# @return [String, nil]
|
|
227
|
+
def self.explain(error)
|
|
228
|
+
EXPLANATIONS[error.code] if error.respond_to?(:code)
|
|
229
|
+
end
|
|
230
|
+
end
|
|
231
|
+
end
|