keenetic 0.1.0 → 0.2.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: 150b29909b27e3c0c7704dead364af88b95152e02c48ad237dd2bbdaf5d6471e
4
- data.tar.gz: dde2d8a08098c812502fb07cb3cad0262d004ff6c431be3b8d11b21f2216491a
3
+ metadata.gz: 3fa7552177bec3d537db352ed748bcd24e07bee6156272569a121b7cb18c6a2f
4
+ data.tar.gz: e48b1c10d4cc62466ebd5e34b200f399e28e8261e0e1a83a1a4b1ee86efc4ee3
5
5
  SHA512:
6
- metadata.gz: 12e93dc8b6b71b665fd88994236ef984c042dc2cbf16b3108456f9615e55c5751a76b657adaf7ff970c4a83ffd3d4aa49ade8800b772666abc27c23e082bc630
7
- data.tar.gz: 7921327abfc55e5f1d2c4846602ecad522fe69478b8d78ddf2a3519ebe9b15aff87950618eeef2a8fe4a6e889c08e8b350931bfe2bc430549bb12a36668e8d95
6
+ metadata.gz: f801f8df36437d0f2ea76f6765beaeee3a68750b2974b8b3fc4eed9145be5a5c123940245b56566c690ef906d791eaf8223ae3f016b4bb0472c1a2a3476f77df
7
+ data.tar.gz: 984b84f49fb83117ab0c9c89b60a0b88718ccbbd92258c69987be8103bec4d2fd66554c4413c001e45167396735c634a578e839c35cce74b0124d2d3c829f930
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2026 Anton
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
data/README.md CHANGED
@@ -48,6 +48,34 @@ client.internet.speed
48
48
 
49
49
  # Ports
50
50
  client.ports.all
51
+
52
+ # Static Routes
53
+ client.routes.all
54
+ client.routes.add(host: '1.2.3.4', interface: 'Wireguard0', comment: 'VPN host')
55
+ client.routes.add(network: '10.0.0.0/24', interface: 'Wireguard0', comment: 'VPN network')
56
+ client.routes.add_batch([
57
+ { host: '1.2.3.4', interface: 'Wireguard0', comment: 'Host 1' },
58
+ { network: '10.0.0.0/24', interface: 'Wireguard0', comment: 'Network 1' }
59
+ ])
60
+ client.routes.delete(host: '1.2.3.4')
61
+ client.routes.delete(network: '10.0.0.0/24')
62
+
63
+ # Hotspot / Policies
64
+ client.hotspot.policies # all IP policies
65
+ client.hotspot.hosts # all registered hosts
66
+ client.hotspot.set_host_policy(mac: 'AA:BB:CC:DD:EE:FF', policy: 'Policy0')
67
+ client.hotspot.set_host_policy(mac: 'AA:BB:CC:DD:EE:FF', policy: nil) # remove policy
68
+
69
+ # Configuration
70
+ client.system_config.save # save to flash
71
+ client.system_config.download # download startup-config.txt
72
+
73
+ # Raw RCI Access (for custom commands)
74
+ client.rci({ 'show' => { 'system' => {} } })
75
+ client.rci([
76
+ { 'show' => { 'system' => {} } },
77
+ { 'show' => { 'version' => {} } }
78
+ ])
51
79
  ```
52
80
 
53
81
  ## Error Handling
@@ -102,6 +102,55 @@ module Keenetic
102
102
  @logs ||= Resources::Logs.new(self)
103
103
  end
104
104
 
105
+ # @return [Resources::Routes] Static routes resource
106
+ def routes
107
+ @routes ||= Resources::Routes.new(self)
108
+ end
109
+
110
+ # @return [Resources::Hotspot] Hotspot hosts and policies resource
111
+ def hotspot
112
+ @hotspot ||= Resources::Hotspot.new(self)
113
+ end
114
+
115
+ # @return [Resources::Config] Configuration management resource
116
+ def system_config
117
+ @system_config ||= Resources::Config.new(self)
118
+ end
119
+
120
+ # Execute arbitrary RCI command(s).
121
+ #
122
+ # Provides raw access to the Keenetic RCI (Remote Command Interface).
123
+ # Use this for custom commands not covered by the gem's resources.
124
+ #
125
+ # == Keenetic API
126
+ # POST http://<host>/rci/
127
+ # Content-Type: application/json
128
+ # Body: Array or Hash of RCI commands
129
+ #
130
+ # @param body [Hash, Array<Hash>] RCI command(s) to execute
131
+ # @return [Hash, Array, nil] Parsed JSON response
132
+ #
133
+ # @example Execute single command
134
+ # client.rci({ 'show' => { 'system' => {} } })
135
+ # # => { "show" => { "system" => { ... } } }
136
+ #
137
+ # @example Execute batch commands
138
+ # client.rci([
139
+ # { 'show' => { 'system' => {} } },
140
+ # { 'show' => { 'version' => {} } }
141
+ # ])
142
+ # # => [{ "show" => { "system" => { ... } } }, { "show" => { "version" => { ... } } }]
143
+ #
144
+ # @example Execute write command
145
+ # client.rci([
146
+ # { 'ip' => { 'hotspot' => { 'host' => { 'mac' => 'aa:bb:cc:dd:ee:ff', 'permit' => true } } } }
147
+ # ])
148
+ #
149
+ def rci(body)
150
+ commands = body.is_a?(Array) ? body : [body]
151
+ batch(commands)
152
+ end
153
+
105
154
  # Make a GET request to the router API.
106
155
  #
107
156
  # == Keenetic API
@@ -0,0 +1,53 @@
1
+ module Keenetic
2
+ module Resources
3
+ # Manages router configuration operations.
4
+ #
5
+ # == API Endpoints Used
6
+ #
7
+ # === Save Configuration
8
+ # POST /rci/ (batch format)
9
+ # Body: [{"system": {"configuration": {"save": {}}}}]
10
+ # Saves current configuration to persistent storage
11
+ #
12
+ # === Download Configuration
13
+ # GET /ci/startup-config.txt
14
+ # Returns: Plain text configuration file
15
+ #
16
+ class Config < Base
17
+ # Save current configuration to persistent storage.
18
+ #
19
+ # == Keenetic API Request
20
+ # POST /rci/ (batch format)
21
+ # Body: [{"system": {"configuration": {"save": {}}}}]
22
+ #
23
+ # Configuration changes are typically auto-saved, but this method
24
+ # forces an immediate save to flash storage.
25
+ #
26
+ # @return [Array<Hash>] API response
27
+ # @example
28
+ # client.config.save
29
+ # # => [{ "system" => { "configuration" => { "save" => {} } } }]
30
+ #
31
+ def save
32
+ client.batch([{ 'system' => { 'configuration' => { 'save' => {} } } }])
33
+ end
34
+
35
+ # Download the startup configuration file.
36
+ #
37
+ # == Keenetic API Request
38
+ # GET /ci/startup-config.txt
39
+ #
40
+ # Returns the full router configuration as a text file.
41
+ # This is the same format used for backup/restore operations.
42
+ #
43
+ # @return [String] Configuration file content
44
+ # @example
45
+ # config_text = client.config.download
46
+ # File.write('router-backup.txt', config_text)
47
+ #
48
+ def download
49
+ get('/ci/startup-config.txt')
50
+ end
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,282 @@
1
+ module Keenetic
2
+ module Resources
3
+ # Manages hotspot hosts and IP policies.
4
+ #
5
+ # == API Endpoints Used
6
+ #
7
+ # === Reading Policies
8
+ # POST /rci/ (batch format)
9
+ # Body: [{"show": {"sc": {"ip": {"policy": {}}}}}]
10
+ # Returns: All IP policies from configuration
11
+ #
12
+ # === Reading Hosts
13
+ # POST /rci/ (batch format)
14
+ # Body: [
15
+ # {"show": {"sc": {"ip": {"hotspot": {"host": {}}}}}},
16
+ # {"show": {"ip": {"hotspot": {}}}}
17
+ # ]
18
+ # Returns: Configuration hosts and runtime hosts
19
+ #
20
+ # === Setting Host Policy
21
+ # POST /rci/ (batch format)
22
+ # Body: [
23
+ # {"webhelp": {"event": {"push": {"data": "..."}}}},
24
+ # {"ip": {"hotspot": {"host": {"mac": "...", "permit": true, "policy": "..."}}}},
25
+ # {"system": {"configuration": {"save": {}}}}
26
+ # ]
27
+ #
28
+ class Hotspot < Base
29
+ # Get all IP policies.
30
+ #
31
+ # == Keenetic API Request
32
+ # POST /rci/ (batch format)
33
+ # Body: [{"show": {"sc": {"ip": {"policy": {}}}}}]
34
+ #
35
+ # == Response Structure from API
36
+ # [
37
+ # {
38
+ # "id": "Policy0",
39
+ # "description": "VPN Policy",
40
+ # "global": false,
41
+ # "interface": [
42
+ # { "name": "Wireguard0", "priority": 100 }
43
+ # ]
44
+ # }
45
+ # ]
46
+ #
47
+ # @return [Array<Hash>] List of normalized policy hashes
48
+ # @example
49
+ # policies = client.hotspot.policies
50
+ # # => [{ id: "Policy0", description: "VPN Policy", global: false, interfaces: [...] }]
51
+ #
52
+ def policies
53
+ response = client.batch([{ 'show' => { 'sc' => { 'ip' => { 'policy' => {} } } } }])
54
+ policies_data = extract_policies_from_response(response)
55
+ normalize_policies(policies_data)
56
+ end
57
+
58
+ # Get all registered hosts with their policies.
59
+ #
60
+ # == Keenetic API Request
61
+ # POST /rci/ (batch format)
62
+ # Body: [
63
+ # {"show": {"sc": {"ip": {"hotspot": {"host": {}}}}}},
64
+ # {"show": {"ip": {"hotspot": {}}}}
65
+ # ]
66
+ #
67
+ # Returns merged data from configuration (static settings) and runtime (current status).
68
+ #
69
+ # @return [Array<Hash>] List of normalized host hashes
70
+ # @example
71
+ # hosts = client.hotspot.hosts
72
+ # # => [{ mac: "AA:BB:CC:DD:EE:FF", name: "My Device", policy: "Policy0", permit: true, ... }]
73
+ #
74
+ def hosts
75
+ response = client.batch([
76
+ { 'show' => { 'sc' => { 'ip' => { 'hotspot' => { 'host' => {} } } } } },
77
+ { 'show' => { 'ip' => { 'hotspot' => {} } } }
78
+ ])
79
+
80
+ config_hosts = extract_config_hosts(response)
81
+ runtime_hosts = extract_runtime_hosts(response)
82
+
83
+ merge_hosts(config_hosts, runtime_hosts)
84
+ end
85
+
86
+ # Set or remove policy for a host.
87
+ #
88
+ # == Keenetic API Request
89
+ # POST /rci/ (batch format)
90
+ # Body: [
91
+ # {"webhelp": {"event": {"push": {"data": "{\"type\":\"configuration_change\",\"value\":{\"url\":\"/policies/policy-consumers\"}}"}}}},
92
+ # {"ip": {"hotspot": {"host": {"mac": "...", "permit": true, "policy": "..."}}}},
93
+ # {"system": {"configuration": {"save": {}}}}
94
+ # ]
95
+ #
96
+ # @param mac [String] Client MAC address (required)
97
+ # @param policy [String, nil] Policy name (e.g., "Policy0"), or nil to remove policy
98
+ # @param permit [Boolean] Whether to permit the host (default: true)
99
+ # @return [Array<Hash>] API response
100
+ #
101
+ # @example Assign policy to host
102
+ # client.hotspot.set_host_policy(mac: "AA:BB:CC:DD:EE:FF", policy: "Policy0")
103
+ #
104
+ # @example Remove policy from host
105
+ # client.hotspot.set_host_policy(mac: "AA:BB:CC:DD:EE:FF", policy: nil)
106
+ #
107
+ # @example Assign policy with deny access
108
+ # client.hotspot.set_host_policy(mac: "AA:BB:CC:DD:EE:FF", policy: "Policy0", permit: false)
109
+ #
110
+ def set_host_policy(mac:, policy:, permit: true)
111
+ raise ArgumentError, 'MAC address is required' if mac.nil? || mac.to_s.strip.empty?
112
+
113
+ normalized_mac = mac.downcase
114
+
115
+ host_params = {
116
+ 'mac' => normalized_mac,
117
+ 'permit' => permit
118
+ }
119
+
120
+ if policy.nil? || policy.to_s.strip.empty?
121
+ # Remove policy assignment
122
+ host_params['policy'] = { 'no' => true }
123
+ else
124
+ host_params['policy'] = policy
125
+ end
126
+
127
+ commands = [
128
+ webhelp_event,
129
+ { 'ip' => { 'hotspot' => { 'host' => host_params } } },
130
+ save_config_command
131
+ ]
132
+
133
+ client.batch(commands)
134
+ end
135
+
136
+ # Find a specific policy by ID.
137
+ #
138
+ # @param id [String] Policy ID (e.g., "Policy0")
139
+ # @return [Hash, nil] Policy data or nil if not found
140
+ # @example
141
+ # policy = client.hotspot.find_policy(id: "Policy0")
142
+ # # => { id: "Policy0", description: "VPN Policy", ... }
143
+ #
144
+ def find_policy(id:)
145
+ policies.find { |p| p[:id] == id }
146
+ end
147
+
148
+ # Find a specific host by MAC address.
149
+ #
150
+ # @param mac [String] MAC address (case-insensitive)
151
+ # @return [Hash, nil] Host data or nil if not found
152
+ # @example
153
+ # host = client.hotspot.find_host(mac: "AA:BB:CC:DD:EE:FF")
154
+ # # => { mac: "AA:BB:CC:DD:EE:FF", name: "My Device", policy: "Policy0", ... }
155
+ #
156
+ def find_host(mac:)
157
+ hosts.find { |h| h[:mac]&.downcase == mac.downcase }
158
+ end
159
+
160
+ private
161
+
162
+ def extract_policies_from_response(response)
163
+ return [] unless response.is_a?(Array) && response.first.is_a?(Hash)
164
+
165
+ response.dig(0, 'show', 'sc', 'ip', 'policy') || []
166
+ end
167
+
168
+ def extract_config_hosts(response)
169
+ return [] unless response.is_a?(Array) && response[0].is_a?(Hash)
170
+
171
+ hosts = response.dig(0, 'show', 'sc', 'ip', 'hotspot', 'host') || []
172
+ hosts.is_a?(Array) ? hosts : [hosts]
173
+ end
174
+
175
+ def extract_runtime_hosts(response)
176
+ return [] unless response.is_a?(Array) && response[1].is_a?(Hash)
177
+
178
+ hosts = response.dig(1, 'show', 'ip', 'hotspot', 'host') || []
179
+ hosts.is_a?(Array) ? hosts : [hosts]
180
+ end
181
+
182
+ def normalize_policies(policies_data)
183
+ return [] unless policies_data.is_a?(Array)
184
+
185
+ policies_data.map { |policy| normalize_policy(policy) }.compact
186
+ end
187
+
188
+ def normalize_policy(data)
189
+ return nil unless data.is_a?(Hash)
190
+
191
+ interfaces = data['interface'] || []
192
+ interfaces = [interfaces] unless interfaces.is_a?(Array)
193
+
194
+ {
195
+ id: data['id'],
196
+ description: data['description'],
197
+ global: normalize_boolean(data['global']),
198
+ interfaces: interfaces.map { |iface| normalize_policy_interface(iface) }.compact
199
+ }
200
+ end
201
+
202
+ def normalize_policy_interface(data)
203
+ return nil unless data.is_a?(Hash)
204
+
205
+ {
206
+ name: data['name'],
207
+ priority: data['priority']
208
+ }
209
+ end
210
+
211
+ def merge_hosts(config_hosts, runtime_hosts)
212
+ # Create lookup from runtime hosts by MAC
213
+ runtime_by_mac = runtime_hosts.each_with_object({}) do |host, lookup|
214
+ next unless host.is_a?(Hash) && host['mac']
215
+
216
+ lookup[host['mac'].upcase] = host
217
+ end
218
+
219
+ # Process config hosts and merge with runtime data
220
+ all_hosts = config_hosts.map do |config_host|
221
+ next unless config_host.is_a?(Hash) && config_host['mac']
222
+
223
+ mac = config_host['mac'].upcase
224
+ runtime_host = runtime_by_mac.delete(mac) || {}
225
+ normalize_host(config_host, runtime_host)
226
+ end.compact
227
+
228
+ # Add any remaining runtime-only hosts
229
+ runtime_by_mac.values.each do |runtime_host|
230
+ all_hosts << normalize_host({}, runtime_host)
231
+ end
232
+
233
+ all_hosts
234
+ end
235
+
236
+ def normalize_host(config_data, runtime_data)
237
+ config_data ||= {}
238
+ runtime_data ||= {}
239
+
240
+ mac = config_data['mac'] || runtime_data['mac']
241
+ return nil unless mac
242
+
243
+ {
244
+ mac: mac.upcase,
245
+ name: config_data['name'] || runtime_data['name'] || runtime_data['hostname'],
246
+ hostname: runtime_data['hostname'],
247
+ ip: runtime_data['ip'],
248
+ interface: runtime_data['interface'],
249
+ via: runtime_data['via'],
250
+ policy: config_data['policy'],
251
+ permit: normalize_boolean(config_data['permit']),
252
+ deny: normalize_boolean(config_data['deny']),
253
+ schedule: config_data['schedule'],
254
+ active: normalize_boolean(runtime_data['active']),
255
+ registered: normalize_boolean(runtime_data['registered']),
256
+ access: runtime_data['access'],
257
+ rxbytes: runtime_data['rxbytes'],
258
+ txbytes: runtime_data['txbytes'],
259
+ uptime: runtime_data['uptime'],
260
+ first_seen: runtime_data['first-seen'],
261
+ last_seen: runtime_data['last-seen']
262
+ }
263
+ end
264
+
265
+ def webhelp_event
266
+ {
267
+ 'webhelp' => {
268
+ 'event' => {
269
+ 'push' => {
270
+ 'data' => '{"type":"configuration_change","value":{"url":"/policies/policy-consumers"}}'
271
+ }
272
+ }
273
+ }
274
+ }
275
+ end
276
+
277
+ def save_config_command
278
+ { 'system' => { 'configuration' => { 'save' => {} } } }
279
+ end
280
+ end
281
+ end
282
+ end
@@ -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
@@ -1,4 +1,3 @@
1
1
  module Keenetic
2
- VERSION = '0.1.0'.freeze
2
+ VERSION = '0.2.0'.freeze
3
3
  end
4
-
data/lib/keenetic.rb CHANGED
@@ -13,6 +13,9 @@ require_relative 'keenetic/resources/policies'
13
13
  require_relative 'keenetic/resources/dhcp'
14
14
  require_relative 'keenetic/resources/routing'
15
15
  require_relative 'keenetic/resources/logs'
16
+ require_relative 'keenetic/resources/routes'
17
+ require_relative 'keenetic/resources/hotspot'
18
+ require_relative 'keenetic/resources/config'
16
19
 
17
20
  # Keenetic Router API Client
18
21
  #
@@ -57,6 +60,23 @@ require_relative 'keenetic/resources/logs'
57
60
  # # Ports
58
61
  # client.ports.all # Physical port statuses
59
62
  #
63
+ # # Static Routes
64
+ # client.routes.all # All static routes
65
+ # client.routes.add(...) # Add static route
66
+ # client.routes.delete(...) # Delete static route
67
+ #
68
+ # # Hotspot / Policies
69
+ # client.hotspot.policies # All IP policies
70
+ # client.hotspot.hosts # All hosts with policies
71
+ # client.hotspot.set_host_policy(mac: '...', policy: '...')
72
+ #
73
+ # # Configuration
74
+ # client.system_config.save # Save configuration
75
+ # client.system_config.download # Download startup config
76
+ #
77
+ # # Raw RCI Access
78
+ # client.rci({ ... }) # Execute arbitrary RCI commands
79
+ #
60
80
  # == Error Handling
61
81
  #
62
82
  # begin
metadata CHANGED
@@ -1,10 +1,10 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: keenetic
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.2.0
5
5
  platform: ruby
6
6
  authors:
7
- - Anton
7
+ - Anton Zaytsev
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
@@ -45,24 +45,28 @@ executables: []
45
45
  extensions: []
46
46
  extra_rdoc_files: []
47
47
  files:
48
+ - LICENSE.txt
48
49
  - README.md
49
50
  - lib/keenetic.rb
50
51
  - lib/keenetic/client.rb
51
52
  - lib/keenetic/configuration.rb
52
53
  - lib/keenetic/errors.rb
53
54
  - lib/keenetic/resources/base.rb
55
+ - lib/keenetic/resources/config.rb
54
56
  - lib/keenetic/resources/devices.rb
55
57
  - lib/keenetic/resources/dhcp.rb
58
+ - lib/keenetic/resources/hotspot.rb
56
59
  - lib/keenetic/resources/internet.rb
57
60
  - lib/keenetic/resources/logs.rb
58
61
  - lib/keenetic/resources/network.rb
59
62
  - lib/keenetic/resources/policies.rb
60
63
  - lib/keenetic/resources/ports.rb
64
+ - lib/keenetic/resources/routes.rb
61
65
  - lib/keenetic/resources/routing.rb
62
66
  - lib/keenetic/resources/system.rb
63
67
  - lib/keenetic/resources/wifi.rb
64
68
  - lib/keenetic/version.rb
65
- homepage: https://github.com/example/keenetic
69
+ homepage: https://github.com/antonzaytsev/keenetic-ruby
66
70
  licenses:
67
71
  - MIT
68
72
  metadata: