keenetic 0.1.0 → 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,432 @@
1
+ module Keenetic
2
+ module Resources
3
+ # Manages static routes configuration on the router.
4
+ #
5
+ # == API Endpoints Used
6
+ #
7
+ # === Reading Static Routes
8
+ # POST /rci/ (batch format)
9
+ # Body: [{"show": {"sc": {"ip": {"route": {}}}}}]
10
+ # Returns: Static routes from router configuration
11
+ #
12
+ # === Adding Static Routes
13
+ # POST /rci/ (batch format)
14
+ # Body: [
15
+ # {"webhelp": {"event": {"push": {"data": "..."}}}},
16
+ # {"ip": {"route": {...}}},
17
+ # {"system": {"configuration": {"save": {}}}}
18
+ # ]
19
+ #
20
+ # === Deleting Static Routes
21
+ # POST /rci/ (batch format)
22
+ # Body: [
23
+ # {"webhelp": {"event": {"push": {"data": "..."}}}},
24
+ # {"ip": {"route": {"no": true, ...}}},
25
+ # {"system": {"configuration": {"save": {}}}}
26
+ # ]
27
+ #
28
+ # == CIDR Support
29
+ # The gem automatically converts CIDR notation to subnet masks:
30
+ # host: "10.0.0.0/24" -> network: "10.0.0.0", mask: "255.255.255.0"
31
+ # host: "1.2.3.4/32" -> host: "1.2.3.4"
32
+ #
33
+ class Routes < Base
34
+ # CIDR prefix to subnet mask mapping
35
+ CIDR_TO_MASK = {
36
+ 8 => '255.0.0.0',
37
+ 9 => '255.128.0.0',
38
+ 10 => '255.192.0.0',
39
+ 11 => '255.224.0.0',
40
+ 12 => '255.240.0.0',
41
+ 13 => '255.248.0.0',
42
+ 14 => '255.252.0.0',
43
+ 15 => '255.254.0.0',
44
+ 16 => '255.255.0.0',
45
+ 17 => '255.255.128.0',
46
+ 18 => '255.255.192.0',
47
+ 19 => '255.255.224.0',
48
+ 20 => '255.255.240.0',
49
+ 21 => '255.255.248.0',
50
+ 22 => '255.255.252.0',
51
+ 23 => '255.255.254.0',
52
+ 24 => '255.255.255.0',
53
+ 25 => '255.255.255.128',
54
+ 26 => '255.255.255.192',
55
+ 27 => '255.255.255.224',
56
+ 28 => '255.255.255.240',
57
+ 29 => '255.255.255.248',
58
+ 30 => '255.255.255.252',
59
+ 31 => '255.255.255.254',
60
+ 32 => '255.255.255.255'
61
+ }.freeze
62
+
63
+ # Get all static routes from router configuration.
64
+ #
65
+ # == Keenetic API Request
66
+ # POST /rci/ (batch format)
67
+ # Body: [{"show": {"sc": {"ip": {"route": {}}}}}]
68
+ #
69
+ # == Response Structure from API
70
+ # [
71
+ # {
72
+ # "network": "10.0.0.0",
73
+ # "mask": "255.255.255.0",
74
+ # "interface": "Wireguard0",
75
+ # "gateway": "",
76
+ # "auto": true,
77
+ # "reject": false,
78
+ # "comment": "VPN route"
79
+ # }
80
+ # ]
81
+ #
82
+ # @return [Array<Hash>] List of normalized static route hashes
83
+ # @example
84
+ # routes = client.routes.all
85
+ # # => [{ network: "10.0.0.0", mask: "255.255.255.0", interface: "Wireguard0", ... }]
86
+ #
87
+ def all
88
+ response = client.batch([{ 'show' => { 'sc' => { 'ip' => { 'route' => {} } } } }])
89
+ routes_data = extract_routes_from_response(response)
90
+ normalize_routes(routes_data)
91
+ end
92
+
93
+ # Add a single static route.
94
+ #
95
+ # == Keenetic API Request
96
+ # POST /rci/ (batch format)
97
+ # Body: [
98
+ # {"webhelp": {"event": {"push": {"data": "{\"type\":\"configuration_change\",\"value\":{\"url\":\"/staticRoutes\"}}"}}}},
99
+ # {"ip": {"route": {"host|network": "...", "mask": "...", "interface": "...", ...}}},
100
+ # {"system": {"configuration": {"save": {}}}}
101
+ # ]
102
+ #
103
+ # @param host [String, nil] Single host IP (e.g., "1.2.3.4" or "1.2.3.4/32"), mutually exclusive with network/mask
104
+ # @param network [String, nil] Network address (e.g., "10.0.0.0" or "10.0.0.0/24")
105
+ # @param mask [String, nil] Subnet mask (e.g., "255.255.255.0"), required with network unless CIDR notation used
106
+ # @param interface [String] Interface name (required, e.g., "Wireguard0")
107
+ # @param comment [String] Route description (required)
108
+ # @param gateway [String] Gateway address (optional, default "")
109
+ # @param auto [Boolean] Auto-enable route (optional, default true)
110
+ # @param reject [Boolean] Reject route (optional, default false)
111
+ # @return [Array<Hash>] API response
112
+ # @raise [ArgumentError] if required parameters are missing or invalid
113
+ #
114
+ # @example Add host route
115
+ # client.routes.add(host: "1.2.3.4", interface: "Wireguard0", comment: "VPN host")
116
+ #
117
+ # @example Add network route with CIDR
118
+ # client.routes.add(network: "10.0.0.0/24", interface: "Wireguard0", comment: "VPN network")
119
+ #
120
+ # @example Add network route with explicit mask
121
+ # client.routes.add(network: "10.0.0.0", mask: "255.255.255.0", interface: "Wireguard0", comment: "VPN network")
122
+ #
123
+ def add(host: nil, network: nil, mask: nil, interface:, comment:, gateway: '', auto: true, reject: false)
124
+ route_params = build_route_params(
125
+ host: host,
126
+ network: network,
127
+ mask: mask,
128
+ interface: interface,
129
+ comment: comment,
130
+ gateway: gateway,
131
+ auto: auto,
132
+ reject: reject
133
+ )
134
+
135
+ commands = [
136
+ webhelp_event,
137
+ { 'ip' => { 'route' => route_params } },
138
+ save_config_command
139
+ ]
140
+
141
+ response = client.batch(commands)
142
+ validate_route_response(response)
143
+ response
144
+ end
145
+
146
+ # Add multiple static routes in a single request.
147
+ #
148
+ # == Keenetic API Request
149
+ # POST /rci/ (batch format)
150
+ # Body: [
151
+ # {"webhelp": {"event": {"push": {"data": "..."}}}},
152
+ # {"ip": {"route": {...}}},
153
+ # {"ip": {"route": {...}}},
154
+ # ...
155
+ # {"system": {"configuration": {"save": {}}}}
156
+ # ]
157
+ #
158
+ # @param routes [Array<Hash>] Array of route parameter hashes (same format as #add)
159
+ # @return [Array<Hash>] API response
160
+ # @raise [ArgumentError] if routes is empty or any route has invalid parameters
161
+ #
162
+ # @example Add multiple routes
163
+ # client.routes.add_batch([
164
+ # { host: "1.2.3.4", interface: "Wireguard0", comment: "Host 1" },
165
+ # { network: "10.0.0.0/24", interface: "Wireguard0", comment: "Network 1" }
166
+ # ])
167
+ #
168
+ def add_batch(routes)
169
+ raise ArgumentError, 'Routes array cannot be empty' if routes.nil? || routes.empty?
170
+
171
+ commands = [webhelp_event]
172
+
173
+ routes.each do |route|
174
+ route_params = build_route_params(
175
+ host: route[:host],
176
+ network: route[:network],
177
+ mask: route[:mask],
178
+ interface: route[:interface],
179
+ comment: route[:comment],
180
+ gateway: route[:gateway] || '',
181
+ auto: route.fetch(:auto, true),
182
+ reject: route.fetch(:reject, false)
183
+ )
184
+ commands << { 'ip' => { 'route' => route_params } }
185
+ end
186
+
187
+ commands << save_config_command
188
+
189
+ response = client.batch(commands)
190
+ validate_batch_response(response, routes.size)
191
+ response
192
+ end
193
+
194
+ # Delete a single static route.
195
+ #
196
+ # == Keenetic API Request
197
+ # POST /rci/ (batch format)
198
+ # Body: [
199
+ # {"webhelp": {"event": {"push": {"data": "..."}}}},
200
+ # {"ip": {"route": {"no": true, "host|network": "...", "mask": "..."}}},
201
+ # {"system": {"configuration": {"save": {}}}}
202
+ # ]
203
+ #
204
+ # @param host [String, nil] Single host IP (e.g., "1.2.3.4")
205
+ # @param network [String, nil] Network address (e.g., "10.0.0.0")
206
+ # @param mask [String, nil] Subnet mask (e.g., "255.255.255.0")
207
+ # @return [Array<Hash>] API response
208
+ # @raise [ArgumentError] if neither host nor network is provided
209
+ #
210
+ # @example Delete host route
211
+ # client.routes.delete(host: "1.2.3.4")
212
+ #
213
+ # @example Delete network route with CIDR
214
+ # client.routes.delete(network: "10.0.0.0/24")
215
+ #
216
+ # @example Delete network route with explicit mask
217
+ # client.routes.delete(network: "10.0.0.0", mask: "255.255.255.0")
218
+ #
219
+ def delete(host: nil, network: nil, mask: nil)
220
+ route_params = build_delete_params(host: host, network: network, mask: mask)
221
+
222
+ commands = [
223
+ webhelp_event,
224
+ { 'ip' => { 'route' => route_params } },
225
+ save_config_command
226
+ ]
227
+
228
+ client.batch(commands)
229
+ end
230
+
231
+ # Delete multiple static routes in a single request.
232
+ #
233
+ # == Keenetic API Request
234
+ # POST /rci/ (batch format)
235
+ # Body: [
236
+ # {"webhelp": {"event": {"push": {"data": "..."}}}},
237
+ # {"ip": {"route": {"no": true, ...}}},
238
+ # {"ip": {"route": {"no": true, ...}}},
239
+ # ...
240
+ # {"system": {"configuration": {"save": {}}}}
241
+ # ]
242
+ #
243
+ # @param routes [Array<Hash>] Array of route identifiers (host or network/mask)
244
+ # @return [Array<Hash>] API response
245
+ # @raise [ArgumentError] if routes is empty
246
+ #
247
+ # @example Delete multiple routes
248
+ # client.routes.delete_batch([
249
+ # { host: "1.2.3.4" },
250
+ # { network: "10.0.0.0/24" }
251
+ # ])
252
+ #
253
+ def delete_batch(routes)
254
+ raise ArgumentError, 'Routes array cannot be empty' if routes.nil? || routes.empty?
255
+
256
+ commands = [webhelp_event]
257
+
258
+ routes.each do |route|
259
+ route_params = build_delete_params(
260
+ host: route[:host],
261
+ network: route[:network],
262
+ mask: route[:mask]
263
+ )
264
+ commands << { 'ip' => { 'route' => route_params } }
265
+ end
266
+
267
+ commands << save_config_command
268
+
269
+ client.batch(commands)
270
+ end
271
+
272
+ # Convert CIDR notation to subnet mask.
273
+ #
274
+ # @param cidr [Integer, String] CIDR prefix length (8-32)
275
+ # @return [String] Subnet mask in dotted notation
276
+ # @raise [ArgumentError] if CIDR is invalid
277
+ #
278
+ # @example
279
+ # Keenetic::Resources::Routes.cidr_to_mask(24)
280
+ # # => "255.255.255.0"
281
+ #
282
+ def self.cidr_to_mask(cidr)
283
+ prefix = cidr.to_i
284
+ raise ArgumentError, "Invalid CIDR prefix: #{cidr}" unless CIDR_TO_MASK.key?(prefix)
285
+
286
+ CIDR_TO_MASK[prefix]
287
+ end
288
+
289
+ private
290
+
291
+ def extract_routes_from_response(response)
292
+ return [] unless response.is_a?(Array) && response.first.is_a?(Hash)
293
+
294
+ response.dig(0, 'show', 'sc', 'ip', 'route') || []
295
+ end
296
+
297
+ def normalize_routes(routes_data)
298
+ return [] unless routes_data.is_a?(Array)
299
+
300
+ routes_data.map { |route| normalize_route(route) }.compact
301
+ end
302
+
303
+ def normalize_route(data)
304
+ return nil unless data.is_a?(Hash)
305
+
306
+ {
307
+ network: data['network'],
308
+ mask: data['mask'],
309
+ host: data['host'],
310
+ interface: data['interface'],
311
+ gateway: data['gateway'],
312
+ comment: data['comment'],
313
+ auto: normalize_boolean(data['auto']),
314
+ reject: normalize_boolean(data['reject'])
315
+ }
316
+ end
317
+
318
+ def build_route_params(host:, network:, mask:, interface:, comment:, gateway:, auto:, reject:)
319
+ raise ArgumentError, 'Interface is required' if interface.nil? || interface.to_s.strip.empty?
320
+ raise ArgumentError, 'Comment is required' if comment.nil? || comment.to_s.strip.empty?
321
+
322
+ params = {}
323
+
324
+ if host && !host.to_s.strip.empty?
325
+ parsed = parse_cidr(host)
326
+ if parsed[:cidr] == 32 || parsed[:cidr].nil?
327
+ params['host'] = parsed[:address]
328
+ else
329
+ # If CIDR is not /32, treat as network
330
+ params['network'] = parsed[:address]
331
+ params['mask'] = self.class.cidr_to_mask(parsed[:cidr])
332
+ end
333
+ elsif network && !network.to_s.strip.empty?
334
+ parsed = parse_cidr(network)
335
+ params['network'] = parsed[:address]
336
+ params['mask'] = parsed[:cidr] ? self.class.cidr_to_mask(parsed[:cidr]) : mask
337
+ raise ArgumentError, 'Mask is required for network routes without CIDR notation' if params['mask'].nil?
338
+ else
339
+ raise ArgumentError, 'Either host or network must be provided'
340
+ end
341
+
342
+ params['interface'] = interface
343
+ params['comment'] = comment
344
+ params['gateway'] = gateway.to_s
345
+ params['auto'] = auto
346
+ params['reject'] = reject
347
+
348
+ params
349
+ end
350
+
351
+ def build_delete_params(host:, network:, mask:)
352
+ params = { 'no' => true }
353
+
354
+ if host && !host.to_s.strip.empty?
355
+ parsed = parse_cidr(host)
356
+ if parsed[:cidr] == 32 || parsed[:cidr].nil?
357
+ params['host'] = parsed[:address]
358
+ else
359
+ params['network'] = parsed[:address]
360
+ params['mask'] = self.class.cidr_to_mask(parsed[:cidr])
361
+ end
362
+ elsif network && !network.to_s.strip.empty?
363
+ parsed = parse_cidr(network)
364
+ params['network'] = parsed[:address]
365
+ params['mask'] = parsed[:cidr] ? self.class.cidr_to_mask(parsed[:cidr]) : mask
366
+ raise ArgumentError, 'Mask is required for network routes without CIDR notation' if params['mask'].nil?
367
+ else
368
+ raise ArgumentError, 'Either host or network must be provided'
369
+ end
370
+
371
+ params
372
+ end
373
+
374
+ def parse_cidr(address)
375
+ return { address: address, cidr: nil } unless address.to_s.include?('/')
376
+
377
+ parts = address.to_s.split('/')
378
+ { address: parts[0], cidr: parts[1].to_i }
379
+ end
380
+
381
+ def webhelp_event
382
+ {
383
+ 'webhelp' => {
384
+ 'event' => {
385
+ 'push' => {
386
+ 'data' => '{"type":"configuration_change","value":{"url":"/staticRoutes"}}'
387
+ }
388
+ }
389
+ }
390
+ }
391
+ end
392
+
393
+ def save_config_command
394
+ { 'system' => { 'configuration' => { 'save' => {} } } }
395
+ end
396
+
397
+ def validate_route_response(response)
398
+ return unless response.is_a?(Array)
399
+
400
+ # Response index 1 is the route command response
401
+ route_response = response[1]
402
+ return unless route_response.is_a?(Hash)
403
+
404
+ status = route_response.dig('ip', 'route', 'status')
405
+ return unless status.is_a?(Array) && status.first.is_a?(Hash)
406
+
407
+ if status.first['status'] == 'error'
408
+ error_msg = status.first['message'] || 'Unknown error'
409
+ raise ApiError.new("Failed to add route: #{error_msg}")
410
+ end
411
+ end
412
+
413
+ def validate_batch_response(response, routes_count)
414
+ return unless response.is_a?(Array)
415
+
416
+ # Route responses start at index 1 (after webhelp event)
417
+ (1..routes_count).each do |i|
418
+ route_response = response[i]
419
+ next unless route_response.is_a?(Hash)
420
+
421
+ status = route_response.dig('ip', 'route', 'status')
422
+ next unless status.is_a?(Array) && status.first.is_a?(Hash)
423
+
424
+ if status.first['status'] == 'error'
425
+ error_msg = status.first['message'] || 'Unknown error'
426
+ raise ApiError.new("Failed to add route at index #{i - 1}: #{error_msg}")
427
+ end
428
+ end
429
+ end
430
+ end
431
+ end
432
+ end
@@ -0,0 +1,87 @@
1
+ module Keenetic
2
+ module Resources
3
+ # Schedule resource for managing access control schedules.
4
+ #
5
+ # == API Endpoints Used
6
+ #
7
+ # === List Schedules
8
+ # GET /rci/show/schedule
9
+ #
10
+ # === Create Schedule
11
+ # POST /rci/schedule
12
+ #
13
+ # === Delete Schedule
14
+ # POST /rci/schedule with { "name": "...", "no": true }
15
+ #
16
+ class Schedule < Base
17
+ # List all schedules.
18
+ #
19
+ # @return [Array<Hash>] List of schedules
20
+ # @example
21
+ # schedules = client.schedule.all
22
+ #
23
+ def all
24
+ response = get('/rci/show/schedule')
25
+ normalize_schedules(response)
26
+ end
27
+
28
+ # Find a schedule by name.
29
+ #
30
+ # @param name [String] Schedule name
31
+ # @return [Hash, nil] Schedule data or nil
32
+ #
33
+ def find(name)
34
+ all.find { |s| s[:name] == name }
35
+ end
36
+
37
+ # Create a new schedule.
38
+ #
39
+ # @param name [String] Schedule name
40
+ # @param entries [Array<Hash>] Schedule entries with days, start, end, action
41
+ # @return [Hash, nil] API response
42
+ # @example
43
+ # client.schedule.create(
44
+ # name: 'kids_bedtime',
45
+ # entries: [
46
+ # { days: 'mon,tue,wed,thu,fri', start: '22:00', end: '07:00', action: 'deny' }
47
+ # ]
48
+ # )
49
+ #
50
+ def create(name:, entries:)
51
+ post('/rci/schedule', { 'name' => name, 'entries' => entries })
52
+ end
53
+
54
+ # Delete a schedule.
55
+ #
56
+ # @param name [String] Schedule name
57
+ # @return [Hash, nil] API response
58
+ #
59
+ def delete(name:)
60
+ post('/rci/schedule', { 'name' => name, 'no' => true })
61
+ end
62
+
63
+ private
64
+
65
+ def normalize_schedules(response)
66
+ schedules_data = case response
67
+ when Array
68
+ response
69
+ when Hash
70
+ response['schedule'] || response['schedules'] || []
71
+ else
72
+ []
73
+ end
74
+
75
+ return [] unless schedules_data.is_a?(Array)
76
+
77
+ schedules_data.map { |schedule| normalize_schedule(schedule) }.compact
78
+ end
79
+
80
+ def normalize_schedule(data)
81
+ return nil unless data.is_a?(Hash)
82
+
83
+ deep_normalize_keys(data)
84
+ end
85
+ end
86
+ end
87
+ end
@@ -138,8 +138,142 @@ module Keenetic
138
138
  normalize_license(response)
139
139
  end
140
140
 
141
+ # Reboot the router.
142
+ #
143
+ # == Keenetic API Request
144
+ # POST /rci/system/reboot
145
+ # Body: {}
146
+ #
147
+ # == Warning
148
+ # This will immediately restart the router. All active connections
149
+ # will be dropped and the router will be unavailable for 1-2 minutes.
150
+ #
151
+ # @return [Hash, nil] API response
152
+ # @example
153
+ # client.system.reboot
154
+ #
155
+ def reboot
156
+ post('/rci/system/reboot', {})
157
+ end
158
+
159
+ # Factory reset the router.
160
+ #
161
+ # == Keenetic API Request
162
+ # POST /rci/system/default
163
+ # Body: {}
164
+ #
165
+ # == Warning
166
+ # This will erase ALL configuration and restore factory defaults.
167
+ # The router will reboot and you will lose access until you
168
+ # reconfigure it. Use with extreme caution!
169
+ #
170
+ # @return [Hash, nil] API response
171
+ # @example
172
+ # client.system.factory_reset
173
+ #
174
+ def factory_reset
175
+ post('/rci/system/default', {})
176
+ end
177
+
178
+ # Check for available firmware updates.
179
+ #
180
+ # == Keenetic API Request
181
+ # GET /rci/show/system/update
182
+ #
183
+ # == Response Fields
184
+ # - available: Whether an update is available
185
+ # - version: Available firmware version
186
+ # - current: Current firmware version
187
+ # - channel: Update channel (stable, preview)
188
+ #
189
+ # @return [Hash] Update availability information
190
+ # @example
191
+ # update_info = client.system.check_updates
192
+ # # => { available: true, version: "4.2.0", current: "4.1.0", ... }
193
+ #
194
+ def check_updates
195
+ response = get('/rci/show/system/update')
196
+ normalize_update_info(response)
197
+ end
198
+
199
+ # Apply available firmware update.
200
+ #
201
+ # == Keenetic API Request
202
+ # POST /rci/system/update
203
+ # Body: {}
204
+ #
205
+ # == Warning
206
+ # This will download and install the latest firmware update.
207
+ # The router will reboot automatically after the update is applied.
208
+ # Do not power off the router during the update process!
209
+ #
210
+ # @return [Hash, nil] API response
211
+ # @example
212
+ # client.system.apply_update
213
+ #
214
+ def apply_update
215
+ post('/rci/system/update', {})
216
+ end
217
+
218
+ # Control LED mode on the router.
219
+ #
220
+ # == Keenetic API Request
221
+ # POST /rci/system/led
222
+ # Body: { "mode": "on" | "off" | "auto" }
223
+ #
224
+ # @param mode [String] LED mode: "on", "off", or "auto"
225
+ # @return [Hash, nil] API response
226
+ # @raise [ArgumentError] if mode is invalid
227
+ #
228
+ # @example Turn off LEDs
229
+ # client.system.set_led_mode('off')
230
+ #
231
+ # @example Set to automatic
232
+ # client.system.set_led_mode('auto')
233
+ #
234
+ def set_led_mode(mode)
235
+ valid_modes = %w[on off auto]
236
+ unless valid_modes.include?(mode)
237
+ raise ArgumentError, "Invalid LED mode: #{mode}. Valid modes: #{valid_modes.join(', ')}"
238
+ end
239
+
240
+ post('/rci/system/led', { 'mode' => mode })
241
+ end
242
+
243
+ # Get button configuration.
244
+ #
245
+ # == Keenetic API Request
246
+ # GET /rci/show/button
247
+ #
248
+ # Returns information about physical buttons on the router
249
+ # and their configured actions.
250
+ #
251
+ # @return [Hash] Button configuration
252
+ # @example
253
+ # buttons = client.system.button_config
254
+ # # => { wifi: { action: "toggle" }, fn: { action: "wps" } }
255
+ #
256
+ def button_config
257
+ response = get('/rci/show/button')
258
+ normalize_button_config(response)
259
+ end
260
+
141
261
  private
142
262
 
263
+ def normalize_update_info(response)
264
+ return {} unless response.is_a?(Hash)
265
+
266
+ result = deep_normalize_keys(response)
267
+ normalize_booleans(result, %i[available downloading])
268
+ result
269
+ end
270
+
271
+ def normalize_button_config(response)
272
+ return {} unless response.is_a?(Hash)
273
+
274
+ deep_normalize_keys(response)
275
+ end
276
+
143
277
  def normalize_resources(response)
144
278
  return {} unless response.is_a?(Hash)
145
279