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,71 @@
1
+ module Keenetic
2
+ module Resources
3
+ # Dynamic DNS resource for managing KeenDNS and third-party DDNS.
4
+ #
5
+ # == API Endpoints Used
6
+ #
7
+ # === KeenDNS Status
8
+ # GET /rci/show/rc/ip/http/dyndns
9
+ #
10
+ # === Configure KeenDNS
11
+ # POST /rci/ip/http/dyndns
12
+ #
13
+ # === Third-Party DDNS
14
+ # GET /rci/show/dyndns
15
+ #
16
+ class Dyndns < Base
17
+ # Get KeenDNS status.
18
+ #
19
+ # @return [Hash] KeenDNS configuration and status
20
+ # @example
21
+ # status = client.dyndns.keendns_status
22
+ #
23
+ def keendns_status
24
+ response = get('/rci/show/rc/ip/http/dyndns')
25
+ normalize_response(response)
26
+ end
27
+
28
+ # Configure KeenDNS.
29
+ #
30
+ # @param params [Hash] KeenDNS configuration parameters
31
+ # @return [Hash, nil] API response
32
+ # @example
33
+ # client.dyndns.configure_keendns(enabled: true, domain: 'myrouter')
34
+ #
35
+ def configure_keendns(**params)
36
+ post('/rci/ip/http/dyndns', normalize_params(params))
37
+ end
38
+
39
+ # Get third-party DDNS providers status.
40
+ #
41
+ # @return [Hash] Third-party DDNS configuration
42
+ # @example
43
+ # ddns = client.dyndns.third_party
44
+ #
45
+ def third_party
46
+ response = get('/rci/show/dyndns')
47
+ normalize_response(response)
48
+ end
49
+
50
+ # Alias for third_party
51
+ alias providers third_party
52
+
53
+ private
54
+
55
+ def normalize_response(response)
56
+ return {} unless response.is_a?(Hash)
57
+
58
+ deep_normalize_keys(response)
59
+ end
60
+
61
+ def normalize_params(params)
62
+ result = {}
63
+ params.each do |key, value|
64
+ api_key = key.to_s.tr('_', '-')
65
+ result[api_key] = value
66
+ end
67
+ result
68
+ end
69
+ end
70
+ end
71
+ end
@@ -0,0 +1,103 @@
1
+ module Keenetic
2
+ module Resources
3
+ # Firewall resource for managing firewall policies and access lists.
4
+ #
5
+ # == API Endpoints Used
6
+ #
7
+ # === Reading Firewall Policies
8
+ # GET /rci/show/ip/policy
9
+ # Returns: Firewall policy configuration
10
+ #
11
+ # === Reading Access Lists
12
+ # GET /rci/show/access-list
13
+ # Returns: Access control lists
14
+ #
15
+ # === Adding Firewall Rule
16
+ # POST /rci/ip/policy
17
+ # Body: Rule configuration
18
+ #
19
+ class Firewall < Base
20
+ # Get firewall policies.
21
+ #
22
+ # == Keenetic API Request
23
+ # GET /rci/show/ip/policy
24
+ #
25
+ # @return [Hash] Firewall policy configuration
26
+ # @example
27
+ # policies = client.firewall.policies
28
+ #
29
+ def policies
30
+ response = get('/rci/show/ip/policy')
31
+ normalize_response(response)
32
+ end
33
+
34
+ # Get access control lists.
35
+ #
36
+ # == Keenetic API Request
37
+ # GET /rci/show/access-list
38
+ #
39
+ # @return [Hash] Access lists configuration
40
+ # @example
41
+ # lists = client.firewall.access_lists
42
+ #
43
+ def access_lists
44
+ response = get('/rci/show/access-list')
45
+ normalize_response(response)
46
+ end
47
+
48
+ # Add a firewall rule.
49
+ #
50
+ # == Keenetic API Request
51
+ # POST /rci/ip/policy
52
+ #
53
+ # @param params [Hash] Rule configuration
54
+ # @return [Hash, nil] API response
55
+ #
56
+ # @example Add a basic rule
57
+ # client.firewall.add_rule(
58
+ # action: 'permit',
59
+ # protocol: 'tcp',
60
+ # src: '192.168.1.0/24',
61
+ # dst_port: 80
62
+ # )
63
+ #
64
+ def add_rule(**params)
65
+ post('/rci/ip/policy', normalize_params(params))
66
+ end
67
+
68
+ # Delete a firewall rule.
69
+ #
70
+ # == Keenetic API Request
71
+ # POST /rci/ip/policy
72
+ # Body: { index, no: true }
73
+ #
74
+ # @param index [Integer] Rule index to delete
75
+ # @return [Hash, nil] API response
76
+ #
77
+ # @example
78
+ # client.firewall.delete_rule(index: 1)
79
+ #
80
+ def delete_rule(index:)
81
+ post('/rci/ip/policy', { 'index' => index, 'no' => true })
82
+ end
83
+
84
+ private
85
+
86
+ def normalize_response(response)
87
+ return {} unless response.is_a?(Hash)
88
+
89
+ deep_normalize_keys(response)
90
+ end
91
+
92
+ def normalize_params(params)
93
+ result = {}
94
+ params.each do |key, value|
95
+ # Convert snake_case to kebab-case for API
96
+ api_key = key.to_s.tr('_', '-')
97
+ result[api_key] = value
98
+ end
99
+ result
100
+ end
101
+ end
102
+ end
103
+ 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,74 @@
1
+ module Keenetic
2
+ module Resources
3
+ # IPv6 resource for IPv6 network information.
4
+ #
5
+ # == API Endpoints Used
6
+ #
7
+ # === IPv6 Interfaces
8
+ # GET /rci/show/ipv6/interface
9
+ #
10
+ # === IPv6 Routes
11
+ # GET /rci/show/ipv6/route
12
+ #
13
+ # === IPv6 Neighbors
14
+ # GET /rci/show/ipv6/neighbor
15
+ #
16
+ class Ipv6 < Base
17
+ # Get IPv6 interfaces.
18
+ #
19
+ # @return [Array<Hash>] List of IPv6 interfaces
20
+ # @example
21
+ # interfaces = client.ipv6.interfaces
22
+ #
23
+ def interfaces
24
+ response = get('/rci/show/ipv6/interface')
25
+ normalize_list(response, 'interface')
26
+ end
27
+
28
+ # Get IPv6 routing table.
29
+ #
30
+ # @return [Array<Hash>] List of IPv6 routes
31
+ # @example
32
+ # routes = client.ipv6.routes
33
+ #
34
+ def routes
35
+ response = get('/rci/show/ipv6/route')
36
+ normalize_list(response, 'route')
37
+ end
38
+
39
+ # Get IPv6 neighbor table (NDP cache).
40
+ #
41
+ # @return [Array<Hash>] List of IPv6 neighbors
42
+ # @example
43
+ # neighbors = client.ipv6.neighbors
44
+ #
45
+ def neighbors
46
+ response = get('/rci/show/ipv6/neighbor')
47
+ normalize_list(response, 'neighbor')
48
+ end
49
+
50
+ private
51
+
52
+ def normalize_list(response, key)
53
+ data = case response
54
+ when Array
55
+ response
56
+ when Hash
57
+ response[key] || response["#{key}s"] || []
58
+ else
59
+ []
60
+ end
61
+
62
+ return [] unless data.is_a?(Array)
63
+
64
+ data.map { |item| normalize_item(item) }.compact
65
+ end
66
+
67
+ def normalize_item(data)
68
+ return nil unless data.is_a?(Hash)
69
+
70
+ deep_normalize_keys(data)
71
+ end
72
+ end
73
+ end
74
+ end
@@ -0,0 +1,84 @@
1
+ module Keenetic
2
+ module Resources
3
+ # Mesh resource for monitoring mesh Wi-Fi system status.
4
+ #
5
+ # == API Endpoints Used
6
+ #
7
+ # === Reading Mesh Status
8
+ # GET /rci/show/mws
9
+ # Returns: Mesh Wi-Fi system status and configuration
10
+ #
11
+ # === Reading Mesh Members
12
+ # GET /rci/show/mws/member
13
+ # Returns: Connected mesh nodes/extenders
14
+ #
15
+ class Mesh < Base
16
+ # Get mesh Wi-Fi system status.
17
+ #
18
+ # == Keenetic API Request
19
+ # GET /rci/show/mws
20
+ #
21
+ # @return [Hash] Mesh system status and configuration
22
+ # @example
23
+ # status = client.mesh.status
24
+ # # => { enabled: true, role: "controller", members_count: 2, ... }
25
+ #
26
+ def status
27
+ response = get('/rci/show/mws')
28
+ normalize_status(response)
29
+ end
30
+
31
+ # Get connected mesh members (nodes/extenders).
32
+ #
33
+ # == Keenetic API Request
34
+ # GET /rci/show/mws/member
35
+ #
36
+ # @return [Array<Hash>] List of mesh members
37
+ # @example
38
+ # members = client.mesh.members
39
+ # # => [
40
+ # # { mac: "AA:BB:CC:DD:EE:FF", name: "Living Room", mode: "extender", ... },
41
+ # # ...
42
+ # # ]
43
+ #
44
+ def members
45
+ response = get('/rci/show/mws/member')
46
+ normalize_members(response)
47
+ end
48
+
49
+ private
50
+
51
+ def normalize_status(response)
52
+ return {} unless response.is_a?(Hash)
53
+
54
+ result = deep_normalize_keys(response)
55
+ normalize_booleans(result, %i[enabled active])
56
+ result
57
+ end
58
+
59
+ def normalize_members(response)
60
+ # Response might be array directly or wrapped in an object
61
+ members_data = case response
62
+ when Array
63
+ response
64
+ when Hash
65
+ response['member'] || response['members'] || []
66
+ else
67
+ []
68
+ end
69
+
70
+ return [] unless members_data.is_a?(Array)
71
+
72
+ members_data.map { |member| normalize_member(member) }.compact
73
+ end
74
+
75
+ def normalize_member(data)
76
+ return nil unless data.is_a?(Hash)
77
+
78
+ result = deep_normalize_keys(data)
79
+ normalize_booleans(result, %i[online active connected])
80
+ result
81
+ end
82
+ end
83
+ end
84
+ end