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.
@@ -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