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 +4 -4
- data/README.md +95 -6
- data/lib/hookd/client.rb +150 -14
- data/lib/hookd/cursor_read.rb +24 -0
- data/lib/hookd/error.rb +3 -0
- data/lib/hookd/hook.rb +7 -5
- data/lib/hookd/hook_activity.rb +6 -3
- data/lib/hookd/interaction.rb +16 -5
- data/lib/hookd/version.rb +1 -1
- data/lib/hookd.rb +1 -0
- metadata +4 -3
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 85c9d3bb1f54b986026f6815e255ecdb515ad9e9bae6a4c78708fcf0f5d133b2
|
|
4
|
+
data.tar.gz: a8a93a75b73de9eba86444c0104643388f64fb71969c19588465071a6aa2272a
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
|
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
|
-
##### `#
|
|
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;
|
|
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
|
|
362
|
+
Represents a captured DNS, HTTP or SMTP interaction.
|
|
278
363
|
|
|
279
364
|
Attributes:
|
|
280
|
-
- `
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
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
|
-
|
|
99
|
-
|
|
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
|
-
|
|
178
|
-
|
|
179
|
-
JSON.parse(body)
|
|
286
|
+
parse_body(response)
|
|
180
287
|
when 401
|
|
181
|
-
raise AuthenticationError, "Authentication failed: #{
|
|
288
|
+
raise AuthenticationError, "Authentication failed: #{error_excerpt(response)}"
|
|
182
289
|
when 404
|
|
183
|
-
raise NotFoundError, "Resource not found: #{
|
|
290
|
+
raise NotFoundError, "Resource not found: #{error_excerpt(response)}"
|
|
184
291
|
when 500..599
|
|
185
|
-
raise ServerError, "Server error (#{response.status}): #{
|
|
292
|
+
raise ServerError, "Server error (#{response.status}): #{error_excerpt(response)}"
|
|
186
293
|
else
|
|
187
|
-
raise Error, "Unexpected response (#{response.status}): #{
|
|
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
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
|
|
5
|
-
#
|
|
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']
|
data/lib/hookd/hook_activity.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
|
data/lib/hookd/interaction.rb
CHANGED
|
@@ -1,11 +1,14 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module Hookd
|
|
4
|
-
# Represents a captured DNS or
|
|
4
|
+
# Represents a captured DNS, HTTP or SMTP interaction
|
|
5
5
|
class Interaction
|
|
6
|
-
|
|
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)} @
|
|
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
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.
|
|
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
|
|
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
|