keenetic 1.0.0 → 1.1.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: cc71a113a4147e3ffd1035596420469ca8c7d9e9639623fd14172b2137d902f6
4
- data.tar.gz: 7fcb0cbd7b8a1219715288354b58bfa7a8f15ed7900334be7fd4014a1de9e788
3
+ metadata.gz: 55da30f5dcac15dad86d5d93cd6dd35cd8d9f63ee2dfb63f73a8e8b32d66a4bb
4
+ data.tar.gz: 7be5b4c51ca2b764c7bc2cc68e1bb69402377d3c19106f0e288a15103847bbcd
5
5
  SHA512:
6
- metadata.gz: e03cfc9b96ac18afa8ec3526d735970e5381983325920aaf0f771c1e31337f4c0800158a320081c696ad973b75eab29846b961ea5ea76d2c12b6209516a894e4
7
- data.tar.gz: fba387ac8b75676298f75715b22c1c348d3ca749fe44234b2c0dbd8d01ff6b9554f78ddf19fcea2b4dcfbee17ccadc253f91a5c777c7c91427d89140fab5335e
6
+ metadata.gz: 655fd847b64bec33b24534d23d8f7459cad8cede50af9e78b5289f615612e334b8bf53e74990569d52e3ee6276ac49e01e83894b35c4fd64c8a8418668fd17da
7
+ data.tar.gz: 14e3d21ee47872269d4a13bb0794e06d9926eb111e919788122cc76f6d2a6f7cb9af225c39cb9e397883c1194b3e32faa53e7fc58f8619f2bf2314c2052c0f13
data/README.md CHANGED
@@ -38,9 +38,21 @@ client.network.interfaces # all interfaces
38
38
  client.wifi.access_points # Wi-Fi networks
39
39
  client.internet.status # internet connectivity
40
40
 
41
+ # Static routes
42
+ client.routes.all # configured static routes
43
+ client.routes.add(network: '10.0.0.0/24', interface: 'Wireguard0', comment: 'VPN')
44
+
45
+ # DNS-based routes
46
+ client.dns_routes.domain_groups # FQDN domain groups
47
+ client.dns_routes.routes # DNS-based route mappings
48
+ client.dns_routes.create_domain_group(name: 'domain-list0', description: 'YouTube',
49
+ domains: ['youtube.com', 'googlevideo.com'])
50
+ client.dns_routes.add_route(group: 'domain-list0', interface: 'Wireguard0')
51
+ client.dns_routes.delete_route(index: 'abc123...')
52
+
41
53
  # Port forwarding
42
54
  client.nat.rules # NAT rules
43
- client.nat.add_forward(index: 1, protocol: 'tcp', port: 8080,
55
+ client.nat.add_forward(index: 1, protocol: 'tcp', port: 8080,
44
56
  to_host: '192.168.1.100', to_port: 80)
45
57
 
46
58
  # VPN
@@ -182,6 +182,11 @@ module Keenetic
182
182
  @ipv6 ||= Resources::Ipv6.new(self)
183
183
  end
184
184
 
185
+ # @return [Resources::DnsRoutes] DNS-based routes and FQDN domain groups resource
186
+ def dns_routes
187
+ @dns_routes ||= Resources::DnsRoutes.new(self)
188
+ end
189
+
185
190
  # Execute arbitrary RCI command(s).
186
191
  #
187
192
  # Provides raw access to the Keenetic RCI (Remote Command Interface).
@@ -318,6 +323,25 @@ module Keenetic
318
323
  def request(method, path, options = {})
319
324
  authenticate! unless @authenticated || path == '/auth'
320
325
 
326
+ response = execute_request(method, path, options)
327
+
328
+ # Handle 401 by re-authenticating and retrying once
329
+ if response.code == 401 && path != '/auth'
330
+ config.logger.debug { "Keenetic: Got 401, re-authenticating..." }
331
+ @authenticated = false
332
+ @mutex.synchronize { perform_authentication }
333
+
334
+ response = execute_request(method, path, options)
335
+
336
+ if response.code == 401
337
+ raise AuthenticationError, "Request unauthorized after re-authentication (HTTP 401)"
338
+ end
339
+ end
340
+
341
+ handle_response(response)
342
+ end
343
+
344
+ def execute_request(method, path, options)
321
345
  url = "#{config.base_url}#{path}"
322
346
 
323
347
  request_options = {
@@ -342,9 +366,7 @@ module Keenetic
342
366
 
343
367
  config.logger.debug { "Keenetic: #{method.upcase} #{url}" }
344
368
 
345
- response = Typhoeus::Request.new(url, request_options).run
346
-
347
- handle_response(response)
369
+ Typhoeus::Request.new(url, request_options).run
348
370
  end
349
371
 
350
372
  def build_headers
@@ -378,7 +400,7 @@ module Keenetic
378
400
  end
379
401
  end
380
402
 
381
- def handle_response(response)
403
+ def handle_response(response, allow_401: false)
382
404
  parse_cookies(response)
383
405
 
384
406
  if response.timed_out?
@@ -389,7 +411,7 @@ module Keenetic
389
411
  raise ConnectionError, "Connection failed: #{response.return_message}"
390
412
  end
391
413
 
392
- unless response.success? || response.code == 401
414
+ unless response.success? || (allow_401 && response.code == 401)
393
415
  if response.code == 404
394
416
  raise NotFoundError, "Resource not found"
395
417
  end
@@ -0,0 +1,298 @@
1
+ module Keenetic
2
+ module Resources
3
+ # DNS-based routes resource for managing FQDN domain groups and their routing rules.
4
+ #
5
+ # DNS-based routing lets you route traffic for a set of domain names through a
6
+ # specific interface. The router resolves each domain and automatically installs
7
+ # floating static routes for the resolved IPs.
8
+ #
9
+ # Two related concepts are managed here:
10
+ #
11
+ # 1. **FQDN Domain Groups** (`object-group fqdn`) — named lists of domains.
12
+ # 2. **DNS-Proxy Routes** (`dns-proxy route`) — maps a domain group to an interface.
13
+ #
14
+ # == API Endpoints Used
15
+ #
16
+ # === Reading FQDN Domain Groups
17
+ # POST /rci/ (batch)
18
+ # Body: [{"show":{"sc":{"object-group":{"fqdn":{}}}}}]
19
+ # Returns: Hash keyed by group name
20
+ #
21
+ # === Creating FQDN Domain Group
22
+ # POST /rci/ (batch)
23
+ # Body: [
24
+ # webhelp_event,
25
+ # {"object-group":{"fqdn":{"<name>":{"description":"...","include":[{"address":"..."}]}}}},
26
+ # save_config
27
+ # ]
28
+ #
29
+ # === Deleting FQDN Domain Group
30
+ # POST /rci/ (batch)
31
+ # Body: [
32
+ # webhelp_event,
33
+ # {"object-group":{"fqdn":{"<name>":{"no":true}}}},
34
+ # save_config
35
+ # ]
36
+ #
37
+ # === Reading DNS-Based Routes
38
+ # POST /rci/ (batch)
39
+ # Body: [{"show":{"sc":{"dns-proxy":{"route":{}}}}}]
40
+ # Returns: Array of route entries
41
+ #
42
+ # === Creating DNS-Based Route
43
+ # POST /rci/ (batch)
44
+ # Body: [
45
+ # webhelp_event,
46
+ # {"dns-proxy":{"route":{"group":"...","interface":"...","comment":"..."}}},
47
+ # save_config
48
+ # ]
49
+ #
50
+ # === Deleting DNS-Based Route
51
+ # POST /rci/ (batch)
52
+ # Body: [
53
+ # webhelp_event,
54
+ # {"dns-proxy":{"route":{"no":true,"index":"..."}}},
55
+ # save_config
56
+ # ]
57
+ #
58
+ class DnsRoutes < Base
59
+ # Get all FQDN domain groups.
60
+ #
61
+ # == Keenetic API Request
62
+ # POST /rci/ (batch)
63
+ # Body: [{"show":{"sc":{"object-group":{"fqdn":{}}}}}]
64
+ #
65
+ # == Response Structure from API
66
+ # {
67
+ # "domain-list0": {
68
+ # "description": "youtube.com",
69
+ # "include": [{"address": "googlevideo.com"}, {"address": "youtube.com"}]
70
+ # }
71
+ # }
72
+ #
73
+ # @return [Array<Hash>] List of domain groups, each with :name, :description, :domains
74
+ # @example
75
+ # groups = client.dns_routes.domain_groups
76
+ # # => [{ name: "domain-list0", description: "youtube.com", domains: ["googlevideo.com", "youtube.com"] }]
77
+ #
78
+ def domain_groups
79
+ response = client.batch([{ 'show' => { 'sc' => { 'object-group' => { 'fqdn' => {} } } } }])
80
+ fqdn_data = response.dig(0, 'show', 'sc', 'object-group', 'fqdn') || {}
81
+ normalize_domain_groups(fqdn_data)
82
+ end
83
+
84
+ # Find a single FQDN domain group by name.
85
+ #
86
+ # @param name [String] Group name (e.g., "domain-list0")
87
+ # @return [Hash, nil] Domain group or nil if not found
88
+ # @example
89
+ # group = client.dns_routes.find_domain_group(name: "domain-list0")
90
+ # # => { name: "domain-list0", description: "youtube.com", domains: [...] }
91
+ #
92
+ def find_domain_group(name:)
93
+ domain_groups.find { |g| g[:name] == name }
94
+ end
95
+
96
+ # Create an FQDN domain group.
97
+ #
98
+ # == Keenetic API Request
99
+ # POST /rci/ (batch)
100
+ # Body: [webhelp_event, {"object-group":{"fqdn":{"<name>":{"description":"...","include":[...]}}}}, save]
101
+ #
102
+ # @param name [String] Group identifier (e.g., "domain-list0")
103
+ # @param description [String] Human-readable label
104
+ # @param domains [Array<String>] List of domain names to include
105
+ # @return [Array<Hash>] API response
106
+ # @raise [ArgumentError] if name, description, or domains are missing/empty
107
+ # @example
108
+ # client.dns_routes.create_domain_group(
109
+ # name: "domain-list0",
110
+ # description: "YouTube",
111
+ # domains: ["youtube.com", "googlevideo.com"]
112
+ # )
113
+ #
114
+ def create_domain_group(name:, description:, domains:)
115
+ raise ArgumentError, 'Name is required' if name.nil? || name.to_s.strip.empty?
116
+ raise ArgumentError, 'Description is required' if description.nil? || description.to_s.strip.empty?
117
+ raise ArgumentError, 'Domains cannot be empty' if domains.nil? || domains.empty?
118
+
119
+ include_list = domains.map { |d| { 'address' => d.to_s } }
120
+
121
+ commands = [
122
+ webhelp_event,
123
+ { 'object-group' => { 'fqdn' => { name.to_s => { 'description' => description.to_s, 'include' => include_list } } } },
124
+ save_config_command
125
+ ]
126
+
127
+ client.batch(commands)
128
+ end
129
+
130
+ # Delete an FQDN domain group.
131
+ #
132
+ # == Keenetic API Request
133
+ # POST /rci/ (batch)
134
+ # Body: [webhelp_event, {"object-group":{"fqdn":{"<name>":{"no":true}}}}, save]
135
+ #
136
+ # @param name [String] Group name to delete
137
+ # @return [Array<Hash>] API response
138
+ # @raise [ArgumentError] if name is missing
139
+ # @example
140
+ # client.dns_routes.delete_domain_group(name: "domain-list0")
141
+ #
142
+ def delete_domain_group(name:)
143
+ raise ArgumentError, 'Name is required' if name.nil? || name.to_s.strip.empty?
144
+
145
+ commands = [
146
+ webhelp_event,
147
+ { 'object-group' => { 'fqdn' => { name.to_s => { 'no' => true } } } },
148
+ save_config_command
149
+ ]
150
+
151
+ client.batch(commands)
152
+ end
153
+
154
+ # Get all DNS-based routes.
155
+ #
156
+ # == Keenetic API Request
157
+ # POST /rci/ (batch)
158
+ # Body: [{"show":{"sc":{"dns-proxy":{"route":{}}}}}]
159
+ #
160
+ # == Response Structure from API
161
+ # [
162
+ # {
163
+ # "group": "domain-list0",
164
+ # "interface": "Wireguard2",
165
+ # "auto": true,
166
+ # "index": "c52bba355a2830fdf55ccb3748a879df",
167
+ # "comment": ""
168
+ # }
169
+ # ]
170
+ #
171
+ # @return [Array<Hash>] List of DNS-based routes with :group, :interface, :auto, :index, :comment
172
+ # @example
173
+ # routes = client.dns_routes.routes
174
+ # # => [{ group: "domain-list0", interface: "Wireguard2", auto: true, index: "c52b...", comment: "" }]
175
+ #
176
+ def routes
177
+ response = client.batch([{ 'show' => { 'sc' => { 'dns-proxy' => { 'route' => {} } } } }])
178
+ routes_data = response.dig(0, 'show', 'sc', 'dns-proxy', 'route') || []
179
+ normalize_routes(routes_data)
180
+ end
181
+
182
+ # Find a DNS-based route by FQDN group name.
183
+ #
184
+ # @param group [String] FQDN group name
185
+ # @return [Hash, nil] Route or nil if not found
186
+ # @example
187
+ # route = client.dns_routes.find_route(group: "domain-list0")
188
+ # # => { group: "domain-list0", interface: "Wireguard2", index: "...", ... }
189
+ #
190
+ def find_route(group:)
191
+ routes.find { |r| r[:group] == group }
192
+ end
193
+
194
+ # Create a DNS-based route mapping a domain group to an interface.
195
+ #
196
+ # == Keenetic API Request
197
+ # POST /rci/ (batch)
198
+ # Body: [webhelp_event, {"dns-proxy":{"route":{"group":"...","interface":"...","comment":"..."}}}, save]
199
+ #
200
+ # @param group [String] FQDN group name (e.g., "domain-list0")
201
+ # @param interface [String] Target interface (e.g., "Wireguard0")
202
+ # @param comment [String] Optional description (default: "")
203
+ # @return [Array<Hash>] API response
204
+ # @raise [ArgumentError] if group or interface is missing
205
+ # @example
206
+ # client.dns_routes.add_route(group: "domain-list0", interface: "Wireguard0")
207
+ #
208
+ def add_route(group:, interface:, comment: '')
209
+ raise ArgumentError, 'Group is required' if group.nil? || group.to_s.strip.empty?
210
+ raise ArgumentError, 'Interface is required' if interface.nil? || interface.to_s.strip.empty?
211
+
212
+ commands = [
213
+ webhelp_event,
214
+ { 'dns-proxy' => { 'route' => { 'group' => group.to_s, 'interface' => interface.to_s, 'comment' => comment.to_s } } },
215
+ save_config_command
216
+ ]
217
+
218
+ client.batch(commands)
219
+ end
220
+
221
+ # Delete a DNS-based route by its index.
222
+ #
223
+ # == Keenetic API Request
224
+ # POST /rci/ (batch)
225
+ # Body: [webhelp_event, {"dns-proxy":{"route":{"no":true,"index":"..."}}}, save]
226
+ #
227
+ # @param index [String] Route index (MD5 hash from routes list)
228
+ # @return [Array<Hash>] API response
229
+ # @raise [ArgumentError] if index is missing
230
+ # @example
231
+ # client.dns_routes.delete_route(index: "c52bba355a2830fdf55ccb3748a879df")
232
+ #
233
+ def delete_route(index:)
234
+ raise ArgumentError, 'Index is required' if index.nil? || index.to_s.strip.empty?
235
+
236
+ commands = [
237
+ webhelp_event,
238
+ { 'dns-proxy' => { 'route' => { 'no' => true, 'index' => index.to_s } } },
239
+ save_config_command
240
+ ]
241
+
242
+ client.batch(commands)
243
+ end
244
+
245
+ private
246
+
247
+ def normalize_domain_groups(fqdn_data)
248
+ return [] unless fqdn_data.is_a?(Hash)
249
+
250
+ fqdn_data.map do |name, data|
251
+ next nil unless data.is_a?(Hash)
252
+
253
+ domains = Array(data['include']).map { |entry| entry['address'] }.compact
254
+
255
+ {
256
+ name: name,
257
+ description: data['description'],
258
+ domains: domains
259
+ }
260
+ end.compact
261
+ end
262
+
263
+ def normalize_routes(routes_data)
264
+ return [] unless routes_data.is_a?(Array)
265
+
266
+ routes_data.map { |r| normalize_route(r) }.compact
267
+ end
268
+
269
+ def normalize_route(data)
270
+ return nil unless data.is_a?(Hash)
271
+
272
+ {
273
+ group: data['group'],
274
+ interface: data['interface'],
275
+ auto: normalize_boolean(data['auto']),
276
+ index: data['index'],
277
+ comment: data['comment']
278
+ }
279
+ end
280
+
281
+ def webhelp_event
282
+ {
283
+ 'webhelp' => {
284
+ 'event' => {
285
+ 'push' => {
286
+ 'data' => '{"type":"configuration_change","value":{"url":"/staticRoutes/dns"}}'
287
+ }
288
+ }
289
+ }
290
+ }
291
+ end
292
+
293
+ def save_config_command
294
+ { 'system' => { 'configuration' => { 'save' => {} } } }
295
+ end
296
+ end
297
+ end
298
+ end
@@ -1,3 +1,3 @@
1
1
  module Keenetic
2
- VERSION = '1.0.0'.freeze
2
+ VERSION = '1.1.0'.freeze
3
3
  end
data/lib/keenetic.rb CHANGED
@@ -29,6 +29,7 @@ require_relative 'keenetic/resources/users'
29
29
  require_relative 'keenetic/resources/components'
30
30
  require_relative 'keenetic/resources/qos'
31
31
  require_relative 'keenetic/resources/ipv6'
32
+ require_relative 'keenetic/resources/dns_routes'
32
33
 
33
34
  # Keenetic Router API Client
34
35
  #
metadata CHANGED
@@ -1,14 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: keenetic
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.0.0
4
+ version: 1.1.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Anton Zaytsev
8
- autorequire:
9
8
  bindir: bin
10
9
  cert_chain: []
11
- date: 2026-02-01 00:00:00.000000000 Z
10
+ date: 2026-03-27 00:00:00.000000000 Z
12
11
  dependencies:
13
12
  - !ruby/object:Gem::Dependency
14
13
  name: logger
@@ -40,7 +39,6 @@ dependencies:
40
39
  version: '1.4'
41
40
  description: A Ruby client for interacting with Keenetic router REST API. Supports
42
41
  authentication, device management, system monitoring, and network interfaces.
43
- email:
44
42
  executables: []
45
43
  extensions: []
46
44
  extra_rdoc_files: []
@@ -58,6 +56,7 @@ files:
58
56
  - lib/keenetic/resources/dhcp.rb
59
57
  - lib/keenetic/resources/diagnostics.rb
60
58
  - lib/keenetic/resources/dns.rb
59
+ - lib/keenetic/resources/dns_routes.rb
61
60
  - lib/keenetic/resources/dyndns.rb
62
61
  - lib/keenetic/resources/firewall.rb
63
62
  - lib/keenetic/resources/hotspot.rb
@@ -84,7 +83,6 @@ licenses:
84
83
  - MIT
85
84
  metadata:
86
85
  rubygems_mfa_required: 'true'
87
- post_install_message:
88
86
  rdoc_options: []
89
87
  require_paths:
90
88
  - lib
@@ -99,8 +97,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
99
97
  - !ruby/object:Gem::Version
100
98
  version: '0'
101
99
  requirements: []
102
- rubygems_version: 3.5.16
103
- signing_key:
100
+ rubygems_version: 3.6.2
104
101
  specification_version: 4
105
102
  summary: Ruby client for Keenetic router API
106
103
  test_files: []