hookd-client 1.3.0 → 1.5.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: eeded85d2f08978163e752d64342ca5e52fd1cf1fcaade7da0305caeb53e03b5
4
- data.tar.gz: 966b9eaf4f9d25549f0473471e446628af82c8ea160e5ebbefef518aa1a6fa08
3
+ metadata.gz: 85c9d3bb1f54b986026f6815e255ecdb515ad9e9bae6a4c78708fcf0f5d133b2
4
+ data.tar.gz: a8a93a75b73de9eba86444c0104643388f64fb71969c19588465071a6aa2272a
5
5
  SHA512:
6
- metadata.gz: 0224226f06a4996e6034caabb6182f31d9a7848262a44bd8ed0571fe898a7706f4b95c46bdb0e5617c0bc04f948fc5dc8e5bffbb46cd20de04d9bdcacbe0b2fe
7
- data.tar.gz: 88e85eab06e2d351b6231f2769104b6d6a3320ae1cb2ccf68f432dad732a689ee5f50386ca69199534a76fb09425ddfd70f7d711b03b8e4d836e2b25415784ad
6
+ metadata.gz: fd9081da69578bdea39b76d8bedc0c6d71b1b2a82927b3fc07abadf345d20d10ad58797ea8689d0b84b0ea69d9609d23cb7bdcbd07b5f823fab0afb87eb2f8c7
7
+ data.tar.gz: caf8b630d270276df0d4a7b05b0bb30c5a96e446ff6b87f3eb9b2b5166789960a768c5545f733e91b58d6404ba5dcff1cf5b044b33798655df9dc5f186a0d1be
data/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # Hookd Ruby Client
2
2
 
3
- Ruby client library for [Hookd](https://github.com/JoshuaMart/hookd/server), a DNS/HTTP interaction server for security testing and debugging.
3
+ Ruby client library for [Hookd](https://github.com/JoshuaMart/hookd/server), a DNS/HTTP/SMTP interaction server for security testing and debugging.
4
4
 
5
5
  ## Installation
6
6
 
@@ -35,6 +35,7 @@ hook = client.register
35
35
  puts "DNS endpoint: #{hook.dns}"
36
36
  puts "HTTP endpoint: #{hook.http}"
37
37
  puts "HTTPS endpoint: #{hook.https}"
38
+ puts "Mail endpoint: #{hook.smtp}" if hook.smtp
38
39
 
39
40
  # Make a request to the HTTP endpoint to simulate an interaction
40
41
  Typhoeus.get(hook.http)
@@ -46,6 +47,8 @@ interactions.each do |interaction|
46
47
  puts "DNS query: #{interaction.data}"
47
48
  elsif interaction.http?
48
49
  puts "HTTP request: #{interaction.data}"
50
+ elsif interaction.smtp?
51
+ puts "Mail from #{interaction.data['mail_from']}: #{interaction.data['subject']}"
49
52
  end
50
53
  end
51
54
  ```
@@ -95,6 +98,8 @@ results.each do |hook_id, result|
95
98
  puts " - DNS: #{interaction.data['qname']} (#{interaction.data['qtype']})"
96
99
  elsif interaction.http?
97
100
  puts " - HTTP: #{interaction.data['method']} #{interaction.data['path']}"
101
+ elsif interaction.smtp?
102
+ puts " - SMTP: #{interaction.data['mail_from']} (#{interaction.data['subject']})"
98
103
  end
99
104
  end
100
105
  end
@@ -108,6 +113,10 @@ The client requires two configuration parameters:
108
113
  - `server`: The Hookd server URL (e.g., `https://hookd.example.com`)
109
114
  - `token`: Authentication token for API access
110
115
 
116
+ An optional `max_response_bytes:` caps the response payload the client will read
117
+ (64 MiB by default; zero or less disables it). Exceeding it raises
118
+ `Hookd::ResponseTooLargeError`.
119
+
111
120
  ### API Reference
112
121
 
113
122
  #### `Hookd::Client`
@@ -116,7 +125,7 @@ Main client class for interacting with the Hookd server.
116
125
 
117
126
  ##### `#register(count: nil)`
118
127
 
119
- Register one or more hooks and get DNS/HTTP endpoints.
128
+ Register one or more hooks and get their DNS, HTTP and (when the server runs a mail listener) SMTP endpoints.
120
129
 
121
130
  **Single hook (default):**
122
131
  ```ruby
@@ -155,10 +164,39 @@ Raises:
155
164
  - `Hookd::ServerError` - Server error (5xx)
156
165
  - `Hookd::ConnectionError` - Connection failed
157
166
 
158
- ##### `#activity`
167
+ ##### `#register_batch(specs)`
168
+
169
+ Register one hook per spec in a single request — e.g. one per injectable field,
170
+ each carrying its own context. Hooks come back in spec order; the server
171
+ creates all of them or none (up to 500 per call).
172
+
173
+ ```ruby
174
+ hooks = client.register_batch([
175
+ { ttl: "7d", metadata: { endpoint_id: "e_412", param: "bio" } },
176
+ { ttl: "7d", metadata: { endpoint_id: "e_413", param: "name" } }
177
+ ])
178
+ # => [#<Hookd::Hook ...>, #<Hookd::Hook ...>]
179
+ ```
180
+
181
+ ##### `#hooks(metadata: nil)`
182
+
183
+ List the long-lived hooks (without interactions) whose top-level metadata
184
+ matches every key/value given; omit `metadata` to list all. Useful to rebuild
185
+ your hook list after losing local state.
186
+
187
+ ```ruby
188
+ client.hooks(metadata: { run_id: "0f3a" })
189
+ ```
190
+
191
+ ##### `#activity(metadata: nil)`
192
+
193
+ Pass `metadata:` to keep only matching hooks, so workers sharing a server each
194
+ see only their own.
195
+
159
196
 
160
197
  List the long-lived hooks that currently have pending interactions, so you can
161
- discover which fired without polling each one; drain the details with `#poll`.
198
+ discover which fired without polling each one; fetch the details with `#read`
199
+ (or `#poll` to drain). Skip hooks whose `last_seq` is not above your cursor.
162
200
 
163
201
  ```ruby
164
202
  client.activity.each do |a|
@@ -239,6 +277,39 @@ Raises:
239
277
  - **Efficiency**: Automatic connection reuse with HTTPX
240
278
  - **Atomic**: Consistent snapshot of all hooks
241
279
 
280
+ ##### `#read(hook_id, after:)` and `#ack(hook_id, through:)`
281
+
282
+ `#poll` deletes what it returns, so a crash before you store the result loses
283
+ it. `#read` leaves the interactions on the server; `#ack` them once your own
284
+ write has committed. Keep the cursor (the last `seq` stored) on your side.
285
+
286
+ ```ruby
287
+ read = client.read(hook_id, after: cursor)
288
+ warn "interactions up to seq #{read.dropped_through} were evicted unread" if read.lost?(cursor)
289
+
290
+ unless read.interactions.empty?
291
+ last = read.interactions.last.seq
292
+ store(read.interactions) # raises: nothing acknowledged, read again later
293
+ client.ack(hook_id, through: last) # => number of interactions removed
294
+ cursor = last
295
+ end
296
+ ```
297
+
298
+ `#read` returns a `Hookd::CursorRead`; `#ack` returns the number removed. Both
299
+ raise `Hookd::NotFoundError` for an unknown hook and `Hookd::ServerError` when
300
+ the server fails, so a failed ack is never mistaken for success.
301
+
302
+ ##### `#read_batch(cursors)` and `#ack_batch(cursors)`
303
+
304
+ The same for many hooks in one request, keyed by hook ID:
305
+
306
+ ```ruby
307
+ client.read_batch("abc123" => 4, "def456" => 0)
308
+ # => { "abc123" => { interactions: [...], dropped_through: 0, error: nil }, ... }
309
+ client.ack_batch("abc123" => 6)
310
+ # => { "abc123" => { acknowledged: 2, error: nil } }
311
+ ```
312
+
242
313
  ##### `#metrics`
243
314
 
244
315
  Get server metrics (requires authentication).
@@ -259,6 +330,7 @@ Attributes:
259
330
  - `dns` (String) - DNS endpoint
260
331
  - `http` (String) - HTTP endpoint
261
332
  - `https` (String) - HTTPS endpoint
333
+ - `smtp` (String, nil) - Mail address (nil unless the server runs a mail listener)
262
334
  - `created_at` (String) - Creation timestamp
263
335
  - `expires_at` (String, nil) - Expiry timestamp (long-lived hooks)
264
336
  - `metadata` (Hash, nil) - Metadata attached at registration
@@ -271,19 +343,35 @@ Attributes:
271
343
  - `hook` (`Hookd::Hook`) - The long-lived hook that fired
272
344
  - `pending_count` (Integer) - Number of interactions awaiting poll
273
345
  - `last_interaction_at` (String) - Timestamp of the most recent interaction
346
+ - `last_seq` (Integer) - Seq of the most recent interaction
347
+
348
+ #### `Hookd::CursorRead`
349
+
350
+ Returned by `#read`.
351
+
352
+ Attributes:
353
+ - `interactions` (Array<`Hookd::Interaction`>) - Interactions past the cursor
354
+ - `dropped_through` (Integer) - Highest seq evicted before acknowledgement
355
+ - `metadata` (Hash, nil) - Metadata attached at registration
356
+
357
+ Methods:
358
+ - `#lost?(after)` - True if interactions past `after` were evicted unread
274
359
 
275
360
  #### `Hookd::Interaction`
276
361
 
277
- Represents a captured DNS or HTTP interaction.
362
+ Represents a captured DNS, HTTP or SMTP interaction.
278
363
 
279
364
  Attributes:
280
- - `type` (String) - Interaction type ("dns" or "http")
365
+ - `id` (String) - Interaction identifier
366
+ - `seq` (Integer) - Per-hook sequence number, the cursor for `#read` / `#ack`
367
+ - `type` (String) - Interaction type ("dns", "http" or "smtp")
281
368
  - `timestamp` (String) - When the interaction was captured
282
369
  - `data` (Hash) - Interaction details
283
370
 
284
371
  Methods:
285
372
  - `#dns?` - Returns true if this is a DNS interaction
286
373
  - `#http?` - Returns true if this is an HTTP interaction
374
+ - `#smtp?` - Returns true if this is an SMTP interaction
287
375
 
288
376
  ### Error Handling
289
377
 
@@ -307,3 +395,4 @@ Exception hierarchy:
307
395
  - `Hookd::NotFoundError` - 404 Not Found
308
396
  - `Hookd::ServerError` - 5xx Server Error
309
397
  - `Hookd::ConnectionError` - Network/connection errors
398
+ - `Hookd::ResponseTooLargeError` - Response above `max_response_bytes`
data/lib/hookd/client.rb CHANGED
@@ -2,15 +2,24 @@
2
2
 
3
3
  require 'httpx'
4
4
  require 'json'
5
+ require 'uri'
5
6
 
6
7
  module Hookd
7
8
  # HTTP client for interacting with Hookd server
8
9
  class Client
9
- attr_reader :server, :token
10
+ # Caps the payload materialised as a String. Generous, since a poll can
11
+ # return many interactions with full bodies. Zero or less disables it.
12
+ DEFAULT_MAX_RESPONSE_BYTES = 64 * 1024 * 1024
10
13
 
11
- def initialize(server:, token:)
14
+ # How much of an error response is quoted back in the raised message.
15
+ ERROR_BODY_EXCERPT_BYTES = 1024
16
+
17
+ attr_reader :server, :token, :max_response_bytes
18
+
19
+ def initialize(server:, token:, max_response_bytes: DEFAULT_MAX_RESPONSE_BYTES)
12
20
  @server = server
13
21
  @token = token
22
+ @max_response_bytes = max_response_bytes
14
23
  @http = HTTPX.with(
15
24
  headers: { 'X-API-Key' => token },
16
25
  timeout: {
@@ -38,6 +47,26 @@ module Hookd
38
47
  parse_register_response(post('/register', register_body(count, ttl, metadata)))
39
48
  end
40
49
 
50
+ # Register one hook per spec in a single request, each with its own ttl and
51
+ # metadata. The server creates all of them or none.
52
+ # @param specs [Array<Hash>] entries of { ttl: String or nil, metadata: Hash or nil }
53
+ # @return [Array<Hookd::Hook>] hooks in spec order
54
+ # @raise [ArgumentError] if specs is empty or not an array
55
+ def register_batch(specs)
56
+ raise ArgumentError, 'specs must be a non-empty array' unless specs.is_a?(Array) && !specs.empty?
57
+
58
+ body = specs.map { |spec| register_body(nil, spec[:ttl], spec[:metadata]) || {} }
59
+ hook_list(post('/register', { hooks: body }))
60
+ end
61
+
62
+ # List long-lived hooks, without interactions, whose metadata matches every
63
+ # key/value of the filter
64
+ # @param metadata [Hash, nil] filter on top-level metadata keys
65
+ # @return [Array<Hookd::Hook>]
66
+ def hooks(metadata: nil)
67
+ hook_list(get("/hooks#{metadata_query(metadata)}"))
68
+ end
69
+
41
70
  # Poll for interactions on a hook
42
71
  # @param hook_id [String] the hook ID to poll
43
72
  # @return [Array<Hookd::Interaction>] array of interactions (may be empty)
@@ -78,6 +107,60 @@ module Hookd
78
107
  raise Error, "Invalid response format: #{e.message}"
79
108
  end
80
109
 
110
+ # Read interactions past a cursor without deleting them; acknowledge them
111
+ # with #ack once stored.
112
+ # @param hook_id [String] the hook ID to read
113
+ # @param after [Integer] return interactions with a seq above this
114
+ # @return [Hookd::CursorRead]
115
+ # @raise [Hookd::NotFoundError] if hook not found
116
+ # @raise [Hookd::ServerError] if server returns 5xx
117
+ # @raise [ArgumentError] if after is invalid
118
+ def read(hook_id, after:)
119
+ validate_cursor(after, 'after')
120
+ response = get("/poll/#{hook_id}?after=#{after}")
121
+
122
+ CursorRead.new(
123
+ interactions: map_interactions(response['interactions']),
124
+ dropped_through: response['dropped_through'] || 0,
125
+ metadata: response['metadata']
126
+ )
127
+ end
128
+
129
+ # Delete interactions up to and including a seq
130
+ # @param hook_id [String] the hook ID
131
+ # @param through [Integer] the last seq stored by the caller
132
+ # @return [Integer] number of interactions removed
133
+ # @raise [Hookd::NotFoundError] if hook not found
134
+ # @raise [Hookd::ServerError] if server returns 5xx
135
+ # @raise [ArgumentError] if through is invalid
136
+ def ack(hook_id, through:)
137
+ validate_cursor(through, 'through')
138
+ handle_response(@http.delete("#{@server}/poll/#{hook_id}?through=#{through}"))['acknowledged']
139
+ end
140
+
141
+ # Read several hooks past their cursors in one request
142
+ # @param cursors [Hash<String, Integer>] hook ID to seq
143
+ # @return [Hash<String, Hash>] hook ID to
144
+ # { interactions: [...], dropped_through: Integer, error: String or nil }
145
+ def read_batch(cursors)
146
+ cursor_batch('/read', 'after', cursors).transform_values do |result|
147
+ {
148
+ interactions: map_interactions(result['interactions']),
149
+ dropped_through: result['dropped_through'] || 0,
150
+ error: result['error']
151
+ }
152
+ end
153
+ end
154
+
155
+ # Acknowledge several hooks in one request
156
+ # @param cursors [Hash<String, Integer>] hook ID to seq
157
+ # @return [Hash<String, Hash>] hook ID to { acknowledged: Integer, error: String or nil }
158
+ def ack_batch(cursors)
159
+ cursor_batch('/ack', 'through', cursors).transform_values do |result|
160
+ { acknowledged: result['acknowledged'] || 0, error: result['error'] }
161
+ end
162
+ end
163
+
81
164
  # Get server metrics (requires authentication)
82
165
  # @return [Hash] metrics data
83
166
  # @raise [Hookd::AuthenticationError] if authentication fails
@@ -89,14 +172,15 @@ module Hookd
89
172
 
90
173
  # List long-lived hooks that currently have pending interactions, so you can
91
174
  # discover which of your long-lived hooks fired without polling each one.
92
- # Drain the details with #poll. Returns an empty array when none have fired
175
+ # Fetch the details with #read (or #poll to drain). Returns an empty array when none have fired
93
176
  # (or the server has long-lived hooks disabled).
94
177
  # @return [Array<Hookd::HookActivity>]
95
178
  # @raise [Hookd::AuthenticationError] if authentication fails
96
179
  # @raise [Hookd::ServerError] if server returns 5xx
97
180
  # @raise [Hookd::ConnectionError] if connection fails
98
- def activity
99
- response = get('/activity')
181
+ # @param metadata [Hash, nil] keep only hooks whose metadata matches
182
+ def activity(metadata: nil)
183
+ response = get("/activity#{metadata_query(metadata)}")
100
184
 
101
185
  hooks = response['hooks']
102
186
  return [] if hooks.nil? || hooks.empty? || !hooks.is_a?(Array)
@@ -141,6 +225,33 @@ module Hookd
141
225
  handle_response(response)
142
226
  end
143
227
 
228
+ def metadata_query(metadata)
229
+ return '' if metadata.nil? || metadata.empty?
230
+
231
+ "?#{URI.encode_www_form(metadata.to_h { |k, v| ["metadata.#{k}", v.to_s] })}"
232
+ end
233
+
234
+ def hook_list(response)
235
+ hooks = response['hooks']
236
+ raise Error, 'Invalid response format: missing hooks' unless hooks.is_a?(Array)
237
+
238
+ hooks.map { |h| Hook.from_hash(h) }
239
+ end
240
+
241
+ def validate_cursor(seq, name)
242
+ raise ArgumentError, "#{name} must be a non-negative integer" unless seq.is_a?(Integer) && seq >= 0
243
+ end
244
+
245
+ def cursor_batch(path, field, cursors)
246
+ raise ArgumentError, "#{field} must be a non-empty hash" unless cursors.is_a?(Hash) && !cursors.empty?
247
+
248
+ cursors.each_value { |seq| validate_cursor(seq, field) }
249
+ results = post(path, { field => cursors })['results']
250
+ raise Error, 'Invalid response format: missing results' unless results.is_a?(Hash)
251
+
252
+ results
253
+ end
254
+
144
255
  def validate_hook_ids(hook_ids)
145
256
  raise ArgumentError, 'hook_ids must be an array' unless hook_ids.is_a?(Array)
146
257
  raise ArgumentError, 'hook_ids cannot be empty' if hook_ids.empty?
@@ -170,24 +281,49 @@ module Hookd
170
281
  raise ConnectionError, "Connection failed: #{error.message}"
171
282
  end
172
283
 
173
- body = response.body.to_s
174
-
175
284
  case response.status
176
285
  when 200, 201
177
- raise Error, 'Empty response body from server' if body.nil? || body.empty?
178
-
179
- JSON.parse(body)
286
+ parse_body(response)
180
287
  when 401
181
- raise AuthenticationError, "Authentication failed: #{body}"
288
+ raise AuthenticationError, "Authentication failed: #{error_excerpt(response)}"
182
289
  when 404
183
- raise NotFoundError, "Resource not found: #{body}"
290
+ raise NotFoundError, "Resource not found: #{error_excerpt(response)}"
184
291
  when 500..599
185
- raise ServerError, "Server error (#{response.status}): #{body}"
292
+ raise ServerError, "Server error (#{response.status}): #{error_excerpt(response)}"
186
293
  else
187
- raise Error, "Unexpected response (#{response.status}): #{body}"
294
+ raise Error, "Unexpected response (#{response.status}): #{error_excerpt(response)}"
188
295
  end
296
+ end
297
+
298
+ def parse_body(response)
299
+ body = read_body(response, @max_response_bytes)
300
+ raise Error, 'Empty response body from server' if body.empty?
301
+
302
+ JSON.parse(body)
189
303
  rescue JSON::ParserError => e
190
304
  raise Error, "Invalid JSON response: #{e.message}"
191
305
  end
306
+
307
+ # Accumulates chunk by chunk and stops at the ceiling, raising unless
308
+ # truncate is set, in which case it returns the bounded slice.
309
+ def read_body(response, limit, truncate: false)
310
+ return response.body.to_s if limit <= 0
311
+
312
+ body = nil
313
+ response.body.each do |chunk|
314
+ # Seed from the first chunk to keep the payload's own encoding.
315
+ body = body ? body << chunk : chunk.dup
316
+ next if body.bytesize <= limit
317
+ return body.byteslice(0, limit).scrub if truncate
318
+
319
+ raise ResponseTooLargeError, "Response exceeds the #{limit} byte limit"
320
+ end
321
+ body || ''
322
+ end
323
+
324
+ # A large error page must not become a large exception message.
325
+ def error_excerpt(response)
326
+ read_body(response, ERROR_BODY_EXCERPT_BYTES, truncate: true)
327
+ end
192
328
  end
193
329
  end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Hookd
4
+ # Result of a non-destructive Client#read
5
+ class CursorRead
6
+ attr_reader :interactions, :dropped_through, :metadata
7
+
8
+ def initialize(interactions:, dropped_through:, metadata: nil)
9
+ @interactions = interactions
10
+ @dropped_through = dropped_through
11
+ @metadata = metadata
12
+ end
13
+
14
+ # True when the server evicted interactions past the cursor before they
15
+ # were acknowledged.
16
+ def lost?(after)
17
+ dropped_through > after
18
+ end
19
+
20
+ def to_s
21
+ "#<Hookd::CursorRead interactions=#{interactions.size} dropped_through=#{dropped_through}>"
22
+ end
23
+ end
24
+ end
data/lib/hookd/error.rb CHANGED
@@ -15,4 +15,7 @@ module Hookd
15
15
 
16
16
  # Raised when the server returns a 5xx error
17
17
  class ServerError < Error; end
18
+
19
+ # Raised when a response exceeds the client's size limit
20
+ class ResponseTooLargeError < Error; end
18
21
  end
data/lib/hookd/hook.rb CHANGED
@@ -1,17 +1,18 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Hookd
4
- # Represents a registered hook with DNS and HTTP endpoints. expires_at and
5
- # metadata are populated for long-lived hooks (registered with a ttl) and nil
6
- # otherwise.
4
+ # Represents a registered hook with its endpoints. smtp is set only when the
5
+ # server runs a mail listener. expires_at and metadata are populated for
6
+ # long-lived hooks (registered with a ttl) and nil otherwise.
7
7
  class Hook
8
- attr_reader :id, :dns, :http, :https, :created_at, :expires_at, :metadata
8
+ attr_reader :id, :dns, :http, :https, :smtp, :created_at, :expires_at, :metadata
9
9
 
10
- def initialize(id:, dns:, http:, https:, created_at:, expires_at: nil, metadata: nil)
10
+ def initialize(id:, dns:, http:, https:, created_at:, smtp: nil, expires_at: nil, metadata: nil)
11
11
  @id = id
12
12
  @dns = dns
13
13
  @http = http
14
14
  @https = https
15
+ @smtp = smtp
15
16
  @created_at = created_at
16
17
  @expires_at = expires_at
17
18
  @metadata = metadata
@@ -26,6 +27,7 @@ module Hookd
26
27
  dns: hash['dns'],
27
28
  http: hash['http'],
28
29
  https: hash['https'],
30
+ smtp: hash['smtp'],
29
31
  created_at: hash['created_at'],
30
32
  expires_at: hash['expires_at'],
31
33
  metadata: hash['metadata']
@@ -4,12 +4,14 @@ module Hookd
4
4
  # Summarises a long-lived hook that currently has pending interactions,
5
5
  # returned by Client#activity.
6
6
  class HookActivity
7
- attr_reader :hook, :pending_count, :last_interaction_at
7
+ # last_seq is the newest seq held: at or below a cursor, nothing is new.
8
+ attr_reader :hook, :pending_count, :last_interaction_at, :last_seq
8
9
 
9
- def initialize(hook:, pending_count:, last_interaction_at:)
10
+ def initialize(hook:, pending_count:, last_interaction_at:, last_seq: nil)
10
11
  @hook = hook
11
12
  @pending_count = pending_count
12
13
  @last_interaction_at = last_interaction_at
14
+ @last_seq = last_seq
13
15
  end
14
16
 
15
17
  # Create a HookActivity from an API response hash
@@ -19,7 +21,8 @@ module Hookd
19
21
  new(
20
22
  hook: Hook.from_hash(hash['hook']),
21
23
  pending_count: hash['pending_count'],
22
- last_interaction_at: hash['last_interaction_at']
24
+ last_interaction_at: hash['last_interaction_at'],
25
+ last_seq: hash['last_seq']
23
26
  )
24
27
  end
25
28
 
@@ -1,11 +1,14 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Hookd
4
- # Represents a captured DNS or HTTP interaction
4
+ # Represents a captured DNS, HTTP or SMTP interaction
5
5
  class Interaction
6
- attr_reader :type, :timestamp, :source_ip, :data
6
+ # seq increases per hook and is the cursor for Client#read and #ack.
7
+ attr_reader :id, :seq, :type, :timestamp, :source_ip, :data
7
8
 
8
- def initialize(type:, timestamp:, source_ip:, data:)
9
+ def initialize(type:, timestamp:, source_ip:, data:, id: nil, seq: nil)
10
+ @id = id
11
+ @seq = seq
9
12
  @type = type
10
13
  @timestamp = timestamp
11
14
  @source_ip = source_ip
@@ -15,6 +18,8 @@ module Hookd
15
18
  # Create an Interaction from API response hash
16
19
  def self.from_hash(hash)
17
20
  new(
21
+ id: hash['id'],
22
+ seq: hash['seq'],
18
23
  type: hash['type'],
19
24
  timestamp: hash['timestamp'],
20
25
  source_ip: hash['source_ip'],
@@ -32,12 +37,18 @@ module Hookd
32
37
  type == 'http'
33
38
  end
34
39
 
40
+ # Check if this is an SMTP interaction
41
+ def smtp?
42
+ type == 'smtp'
43
+ end
44
+
35
45
  def to_s
36
- "#<Hookd::Interaction type=#{type} timestamp=#{timestamp} source_ip=#{source_ip}>"
46
+ "#<Hookd::Interaction seq=#{seq} type=#{type} timestamp=#{timestamp} source_ip=#{source_ip}>"
37
47
  end
38
48
 
39
49
  def inspect
40
- "#<Hookd::Interaction:#{object_id.to_s(16)} @type=#{type.inspect}, @timestamp=#{timestamp.inspect}, " \
50
+ "#<Hookd::Interaction:#{object_id.to_s(16)} @id=#{id.inspect}, @seq=#{seq.inspect}, " \
51
+ "@type=#{type.inspect}, @timestamp=#{timestamp.inspect}, " \
41
52
  "@source_ip=#{source_ip.inspect}, @data=#{data.inspect}>"
42
53
  end
43
54
  end
data/lib/hookd/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Hookd
4
- VERSION = '1.3.0'
4
+ VERSION = '1.5.0'
5
5
  end
data/lib/hookd.rb CHANGED
@@ -5,6 +5,7 @@ require_relative 'hookd/error'
5
5
  require_relative 'hookd/hook'
6
6
  require_relative 'hookd/hook_activity'
7
7
  require_relative 'hookd/interaction'
8
+ require_relative 'hookd/cursor_read'
8
9
  require_relative 'hookd/client'
9
10
 
10
11
  # Hookd client library for interacting with Hookd DNS/HTTP interaction server
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: hookd-client
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.3.0
4
+ version: 1.5.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Joshua MARTINELLE
@@ -23,8 +23,8 @@ dependencies:
23
23
  - - "~>"
24
24
  - !ruby/object:Gem::Version
25
25
  version: '1.0'
26
- description: Ruby client library for Hookd, a DNS/HTTP interaction server for security
27
- testing and debugging
26
+ description: Ruby client library for Hookd, a DNS/SMTP/HTTP interaction server for
27
+ security testing and debugging
28
28
  email:
29
29
  - contact@jomar.fr
30
30
  executables: []
@@ -34,6 +34,7 @@ files:
34
34
  - README.md
35
35
  - lib/hookd.rb
36
36
  - lib/hookd/client.rb
37
+ - lib/hookd/cursor_read.rb
37
38
  - lib/hookd/error.rb
38
39
  - lib/hookd/hook.rb
39
40
  - lib/hookd/hook_activity.rb