keenetic 0.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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 150b29909b27e3c0c7704dead364af88b95152e02c48ad237dd2bbdaf5d6471e
4
+ data.tar.gz: dde2d8a08098c812502fb07cb3cad0262d004ff6c431be3b8d11b21f2216491a
5
+ SHA512:
6
+ metadata.gz: 12e93dc8b6b71b665fd88994236ef984c042dc2cbf16b3108456f9615e55c5751a76b657adaf7ff970c4a83ffd3d4aa49ade8800b772666abc27c23e082bc630
7
+ data.tar.gz: 7921327abfc55e5f1d2c4846602ecad522fe69478b8d78ddf2a3519ebe9b15aff87950618eeef2a8fe4a6e889c08e8b350931bfe2bc430549bb12a36668e8d95
data/README.md ADDED
@@ -0,0 +1,83 @@
1
+ # Keenetic
2
+
3
+ Ruby wrapper for the Keenetic router RCI (Remote Command Interface) — the same API that powers the Keenetic web UI.
4
+
5
+ ## Installation
6
+
7
+ ```ruby
8
+ gem 'keenetic'
9
+ ```
10
+
11
+ ## Configuration
12
+
13
+ ```ruby
14
+ Keenetic.configure do |config|
15
+ config.host = '192.168.1.1'
16
+ config.login = 'admin'
17
+ config.password = 'your_password'
18
+ config.timeout = 30 # optional
19
+ config.logger = Logger.new($stdout) # optional
20
+ end
21
+ ```
22
+
23
+ ## Usage
24
+
25
+ ```ruby
26
+ client = Keenetic.client
27
+
28
+ # Connected devices
29
+ client.devices.all
30
+ client.devices.active
31
+ client.devices.find(mac: 'AA:BB:CC:DD:EE:FF')
32
+ client.devices.update(mac: 'AA:BB:CC:DD:EE:FF', name: 'My Phone')
33
+
34
+ # System info
35
+ client.system.info # model, firmware
36
+ client.system.resources # CPU, memory, uptime
37
+
38
+ # Network
39
+ client.network.interfaces
40
+
41
+ # WiFi
42
+ client.wifi.access_points
43
+ client.wifi.clients
44
+
45
+ # Internet
46
+ client.internet.status
47
+ client.internet.speed
48
+
49
+ # Ports
50
+ client.ports.all
51
+ ```
52
+
53
+ ## Error Handling
54
+
55
+ ```ruby
56
+ begin
57
+ client.devices.all
58
+ rescue Keenetic::AuthenticationError
59
+ # invalid credentials
60
+ rescue Keenetic::ConnectionError
61
+ # router unreachable
62
+ rescue Keenetic::TimeoutError
63
+ # request timed out
64
+ rescue Keenetic::NotFoundError
65
+ # resource not found
66
+ rescue Keenetic::ApiError => e
67
+ # Other API errors
68
+ e.status_code
69
+ e.response_body
70
+ end
71
+ ```
72
+
73
+ ## API Reference
74
+
75
+ See [KEENETIC_API.md](KEENETIC_API.md) for complete API documentation.
76
+
77
+ ## Requirements
78
+
79
+ - Ruby >= 3.0
80
+
81
+ ## License
82
+
83
+ MIT
@@ -0,0 +1,361 @@
1
+ require 'typhoeus'
2
+ require 'json'
3
+ require 'digest'
4
+
5
+ module Keenetic
6
+ # HTTP client for Keenetic router API.
7
+ #
8
+ # == Authentication Flow
9
+ # Keenetic uses a challenge-response authentication mechanism:
10
+ #
11
+ # Step 1: GET /auth
12
+ # Response: HTTP 401 with headers X-NDM-Challenge and X-NDM-Realm
13
+ # Also sets session cookie
14
+ #
15
+ # Step 2: Calculate authentication hash
16
+ # md5_hash = MD5(login + ":" + realm + ":" + password)
17
+ # auth_hash = SHA256(challenge + md5_hash)
18
+ #
19
+ # Step 3: POST /auth
20
+ # Body: {"login": "admin", "password": "<auth_hash>"}
21
+ # Response: HTTP 200 on success
22
+ # Session maintained via cookies
23
+ #
24
+ # == Request Types
25
+ #
26
+ # === Reading Data (GET)
27
+ # GET /rci/show/<path>
28
+ # Example: GET /rci/show/system, GET /rci/show/ip/hotspot
29
+ #
30
+ # === Writing Data (POST - Batch Format)
31
+ # POST /rci/
32
+ # Body: Array of commands (MUST be array, even for single command)
33
+ # Example: [{"ip":{"hotspot":{"host":{"mac":"aa:bb:cc:dd:ee:ff","permit":true}}}}]
34
+ #
35
+ # == Thread Safety
36
+ # The client uses a mutex to prevent concurrent authentication attempts.
37
+ # Resource instances are memoized and thread-safe for reading.
38
+ #
39
+ class Client
40
+ attr_reader :config
41
+
42
+ # Create a new client instance.
43
+ #
44
+ # @param config [Configuration, nil] Optional configuration (uses global if nil)
45
+ # @raise [ConfigurationError] if configuration is invalid
46
+ #
47
+ def initialize(config = nil)
48
+ @config = config || Keenetic.configuration
49
+ @config.validate!
50
+ @cookies = {}
51
+ @authenticated = false
52
+ @mutex = Mutex.new
53
+ end
54
+
55
+ # @return [Resources::Devices] Device management resource
56
+ def devices
57
+ @devices ||= Resources::Devices.new(self)
58
+ end
59
+
60
+ # @return [Resources::System] System information resource
61
+ def system
62
+ @system ||= Resources::System.new(self)
63
+ end
64
+
65
+ # @return [Resources::Network] Network interfaces resource
66
+ def network
67
+ @network ||= Resources::Network.new(self)
68
+ end
69
+
70
+ # @return [Resources::WiFi] Wi-Fi resource
71
+ def wifi
72
+ @wifi ||= Resources::WiFi.new(self)
73
+ end
74
+
75
+ # @return [Resources::Internet] Internet status resource
76
+ def internet
77
+ @internet ||= Resources::Internet.new(self)
78
+ end
79
+
80
+ # @return [Resources::Ports] Physical ports resource
81
+ def ports
82
+ @ports ||= Resources::Ports.new(self)
83
+ end
84
+
85
+ # @return [Resources::Policies] Routing policies resource
86
+ def policies
87
+ @policies ||= Resources::Policies.new(self)
88
+ end
89
+
90
+ # @return [Resources::DHCP] DHCP resource
91
+ def dhcp
92
+ @dhcp ||= Resources::DHCP.new(self)
93
+ end
94
+
95
+ # @return [Resources::Routing] Routing resource
96
+ def routing
97
+ @routing ||= Resources::Routing.new(self)
98
+ end
99
+
100
+ # @return [Resources::Logs] System logs resource
101
+ def logs
102
+ @logs ||= Resources::Logs.new(self)
103
+ end
104
+
105
+ # Make a GET request to the router API.
106
+ #
107
+ # == Keenetic API
108
+ # GET http://<host>/rci/show/<path>
109
+ #
110
+ # @param path [String] API path (e.g., '/rci/show/system')
111
+ # @param params [Hash] Optional query parameters
112
+ # @return [Hash, Array, nil] Parsed JSON response
113
+ #
114
+ def get(path, params = {})
115
+ request(:get, path, params: params)
116
+ end
117
+
118
+ # Make a POST request to the router API.
119
+ #
120
+ # == Keenetic API
121
+ # POST http://<host>/rci/<path>
122
+ # Content-Type: application/json
123
+ #
124
+ # @param path [String] API path
125
+ # @param body [Hash] Request body (will be JSON encoded)
126
+ # @return [Hash, Array, nil] Parsed JSON response
127
+ #
128
+ def post(path, body = {})
129
+ request(:post, path, body: body)
130
+ end
131
+
132
+ # Execute multiple commands in a single batch request.
133
+ #
134
+ # == Keenetic API
135
+ # POST http://<host>/rci/
136
+ # Content-Type: application/json
137
+ # Body: Array of command objects
138
+ #
139
+ # == Important
140
+ # All write operations to Keenetic MUST use batch format (array).
141
+ # Even single commands must be wrapped in an array.
142
+ #
143
+ # @param commands [Array<Hash>] Array of command hashes
144
+ # @return [Array] Array of responses in the same order as commands
145
+ # @raise [ArgumentError] if commands is not a non-empty array
146
+ #
147
+ # @example Read multiple values
148
+ # client.batch([
149
+ # { 'show' => { 'system' => {} } },
150
+ # { 'show' => { 'version' => {} } }
151
+ # ])
152
+ #
153
+ # @example Write command (update device)
154
+ # client.batch([
155
+ # { 'known' => { 'host' => { 'mac' => 'aa:bb:cc:dd:ee:ff', 'name' => 'My Device' } } }
156
+ # ])
157
+ #
158
+ def batch(commands)
159
+ raise ArgumentError, 'Commands must be an array' unless commands.is_a?(Array)
160
+ raise ArgumentError, 'Commands array cannot be empty' if commands.empty?
161
+
162
+ request(:post, '/rci/', body: commands)
163
+ end
164
+
165
+ # Check if client is authenticated.
166
+ # @return [Boolean]
167
+ def authenticated?
168
+ @authenticated
169
+ end
170
+
171
+ # Perform authentication (thread-safe).
172
+ #
173
+ # Called automatically before first request.
174
+ # Uses mutex to prevent concurrent authentication attempts.
175
+ #
176
+ # @return [Boolean] true on success
177
+ # @raise [AuthenticationError] on failure
178
+ # @raise [TimeoutError] if connection times out
179
+ # @raise [ConnectionError] if router is unreachable
180
+ #
181
+ def authenticate!
182
+ @mutex.synchronize do
183
+ return true if @authenticated
184
+
185
+ perform_authentication
186
+ end
187
+ end
188
+
189
+ private
190
+
191
+ def request(method, path, options = {})
192
+ authenticate! unless @authenticated || path == '/auth'
193
+
194
+ url = "#{config.base_url}#{path}"
195
+
196
+ request_options = {
197
+ method: method,
198
+ timeout: config.timeout,
199
+ connecttimeout: config.open_timeout,
200
+ headers: build_headers,
201
+ accept_encoding: 'gzip'
202
+ }
203
+
204
+ if options[:params] && !options[:params].empty?
205
+ url += "?#{URI.encode_www_form(options[:params])}"
206
+ end
207
+
208
+ if options[:body]
209
+ request_options[:body] = options[:body].to_json
210
+ request_options[:headers]['Content-Type'] = 'application/json'
211
+ end
212
+
213
+ config.logger.debug { "Keenetic: #{method.upcase} #{url}" }
214
+
215
+ response = Typhoeus::Request.new(url, request_options).run
216
+
217
+ handle_response(response)
218
+ end
219
+
220
+ def build_headers
221
+ headers = {
222
+ 'Accept' => 'application/json',
223
+ 'User-Agent' => "Keenetic Ruby Client/#{VERSION}"
224
+ }
225
+
226
+ headers['Cookie'] = format_cookies unless @cookies.empty?
227
+ headers
228
+ end
229
+
230
+ def format_cookies
231
+ @cookies.map { |k, v| "#{k}=#{v}" }.join('; ')
232
+ end
233
+
234
+ def parse_cookies(response)
235
+ return unless response.headers
236
+
237
+ set_cookie_headers = response.headers['Set-Cookie']
238
+ return unless set_cookie_headers
239
+
240
+ cookies = set_cookie_headers.is_a?(Array) ? set_cookie_headers : [set_cookie_headers]
241
+
242
+ cookies.each do |cookie|
243
+ parts = cookie.split(';').first
244
+ next unless parts
245
+
246
+ name, value = parts.split('=', 2)
247
+ @cookies[name.strip] = value&.strip if name
248
+ end
249
+ end
250
+
251
+ def handle_response(response)
252
+ parse_cookies(response)
253
+
254
+ if response.timed_out?
255
+ raise TimeoutError, "Request timed out after #{config.timeout}s"
256
+ end
257
+
258
+ if response.code == 0
259
+ raise ConnectionError, "Connection failed: #{response.return_message}"
260
+ end
261
+
262
+ unless response.success? || response.code == 401
263
+ if response.code == 404
264
+ raise NotFoundError, "Resource not found"
265
+ end
266
+ raise ApiError.new(
267
+ "API request failed with status #{response.code}",
268
+ status_code: response.code,
269
+ response_body: response.body
270
+ )
271
+ end
272
+
273
+ return nil if response.body.nil? || response.body.empty?
274
+
275
+ begin
276
+ JSON.parse(response.body)
277
+ rescue JSON::ParserError
278
+ response.body
279
+ end
280
+ end
281
+
282
+ def perform_authentication
283
+ # Step 1: Get challenge from router
284
+ url = "#{config.base_url}/auth"
285
+
286
+ challenge_response = Typhoeus::Request.new(url, {
287
+ method: :get,
288
+ timeout: config.timeout,
289
+ connecttimeout: config.open_timeout,
290
+ headers: { 'Accept' => 'application/json' }
291
+ }).run
292
+
293
+ if challenge_response.timed_out?
294
+ raise TimeoutError, auth_error_context("Authentication timed out after #{config.timeout}s")
295
+ end
296
+
297
+ if challenge_response.code == 0
298
+ raise ConnectionError, auth_error_context("Connection failed: #{challenge_response.return_message}")
299
+ end
300
+
301
+ parse_cookies(challenge_response)
302
+
303
+ # If already authenticated (returns 200), we're done
304
+ if challenge_response.code == 200
305
+ @authenticated = true
306
+ config.logger.info { "Keenetic: Already authenticated" }
307
+ return true
308
+ end
309
+
310
+ unless challenge_response.code == 401
311
+ raise AuthenticationError, auth_error_context("Unexpected response: HTTP #{challenge_response.code}")
312
+ end
313
+
314
+ headers = challenge_response.headers || {}
315
+ challenge = headers['X-NDM-Challenge']
316
+ realm = headers['X-NDM-Realm']
317
+
318
+ unless challenge && realm
319
+ raise AuthenticationError, auth_error_context("Missing challenge headers from router")
320
+ end
321
+
322
+ config.logger.debug { "Keenetic: Got challenge, realm=#{realm}" }
323
+
324
+ # Step 2: Calculate authentication hash
325
+ # MD5(login:realm:password) -> then SHA256(challenge + md5_hash)
326
+ md5_hash = Digest::MD5.hexdigest("#{config.login}:#{realm}:#{config.password}")
327
+ auth_hash = Digest::SHA256.hexdigest("#{challenge}#{md5_hash}")
328
+
329
+ # Step 3: Send authentication request
330
+ auth_response = Typhoeus::Request.new(url, {
331
+ method: :post,
332
+ timeout: config.timeout,
333
+ connecttimeout: config.open_timeout,
334
+ headers: build_headers.merge('Content-Type' => 'application/json'),
335
+ body: { login: config.login, password: auth_hash }.to_json
336
+ }).run
337
+
338
+ parse_cookies(auth_response)
339
+
340
+ if auth_response.code == 200
341
+ @authenticated = true
342
+ config.logger.info { "Keenetic: Authentication successful" }
343
+ true
344
+ else
345
+ raise AuthenticationError, auth_error_context("Authentication failed: HTTP #{auth_response.code}")
346
+ end
347
+ end
348
+
349
+ def auth_error_context(message)
350
+ details = [
351
+ message,
352
+ "host=#{config.host}",
353
+ "login=#{config.login}",
354
+ "timeout=#{config.timeout}s",
355
+ "connect_timeout=#{config.open_timeout}s"
356
+ ]
357
+ details.join(' | ')
358
+ end
359
+ end
360
+ end
361
+
@@ -0,0 +1,27 @@
1
+ require 'logger'
2
+
3
+ module Keenetic
4
+ class Configuration
5
+ attr_accessor :host, :login, :password, :timeout, :open_timeout, :logger
6
+
7
+ def initialize
8
+ @host = nil
9
+ @login = nil
10
+ @password = nil
11
+ @timeout = 30
12
+ @open_timeout = 10
13
+ @logger = Logger.new(nil)
14
+ end
15
+
16
+ def base_url
17
+ "http://#{host}"
18
+ end
19
+
20
+ def validate!
21
+ raise ConfigurationError, 'Host is required' if host.nil? || host.empty?
22
+ raise ConfigurationError, 'Login is required' if login.nil? || login.empty?
23
+ raise ConfigurationError, 'Password is required' if password.nil? || password.empty?
24
+ end
25
+ end
26
+ end
27
+
@@ -0,0 +1,19 @@
1
+ module Keenetic
2
+ class Error < StandardError; end
3
+
4
+ class ConfigurationError < Error; end
5
+ class AuthenticationError < Error; end
6
+ class ConnectionError < Error; end
7
+ class TimeoutError < Error; end
8
+ class NotFoundError < Error; end
9
+ class ApiError < Error
10
+ attr_reader :status_code, :response_body
11
+
12
+ def initialize(message, status_code: nil, response_body: nil)
13
+ super(message)
14
+ @status_code = status_code
15
+ @response_body = response_body
16
+ end
17
+ end
18
+ end
19
+
@@ -0,0 +1,76 @@
1
+ module Keenetic
2
+ module Resources
3
+ class Base
4
+ attr_reader :client
5
+
6
+ def initialize(client)
7
+ @client = client
8
+ end
9
+
10
+ protected
11
+
12
+ def get(path, params = {})
13
+ client.get(path, params)
14
+ end
15
+
16
+ def post(path, body = {})
17
+ client.post(path, body)
18
+ end
19
+
20
+ # Convert kebab-case keys to snake_case symbols
21
+ # @param hash [Hash] Hash with string keys
22
+ # @return [Hash] Hash with symbolized snake_case keys
23
+ def normalize_keys(hash)
24
+ return {} unless hash.is_a?(Hash)
25
+
26
+ hash.transform_keys { |key| key.to_s.tr('-', '_').to_sym }
27
+ end
28
+
29
+ # Deep normalize keys in a hash (recursive)
30
+ # @param obj [Hash, Array, Object] Object to normalize
31
+ # @return [Hash, Array, Object] Normalized object
32
+ def deep_normalize_keys(obj)
33
+ case obj
34
+ when Hash
35
+ obj.each_with_object({}) do |(key, value), result|
36
+ new_key = key.to_s.tr('-', '_').to_sym
37
+ result[new_key] = deep_normalize_keys(value)
38
+ end
39
+ when Array
40
+ obj.map { |item| deep_normalize_keys(item) }
41
+ else
42
+ obj
43
+ end
44
+ end
45
+
46
+ # Normalize boolean values from various formats
47
+ # API may return true/false or "true"/"false" strings
48
+ # @param value [Object] Value to normalize
49
+ # @return [Boolean, Object] Normalized boolean or original value
50
+ def normalize_boolean(value)
51
+ case value
52
+ when true, 'true', 'yes', '1', 1
53
+ true
54
+ when false, 'false', 'no', '0', 0
55
+ false
56
+ else
57
+ value
58
+ end
59
+ end
60
+
61
+ # Normalize all boolean values in a hash
62
+ # @param hash [Hash] Hash with potential boolean values
63
+ # @param keys [Array<Symbol>] Keys to normalize as booleans
64
+ # @return [Hash] Hash with normalized boolean values
65
+ def normalize_booleans(hash, keys)
66
+ return hash unless hash.is_a?(Hash)
67
+
68
+ keys.each do |key|
69
+ hash[key] = normalize_boolean(hash[key]) if hash.key?(key)
70
+ end
71
+ hash
72
+ end
73
+ end
74
+ end
75
+ end
76
+