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.
@@ -0,0 +1,330 @@
1
+ require 'time'
2
+
3
+ module Keenetic
4
+ module Resources
5
+ # Retrieves and filters system logs from the router.
6
+ #
7
+ # == API Endpoints Used
8
+ #
9
+ # === Reading Logs
10
+ # GET /rci/show/log
11
+ # Returns: Array of log entry objects
12
+ #
13
+ # === Filtered Logs (POST)
14
+ # POST /rci/show/log
15
+ # Body: { "level": "error", "limit": 100 }
16
+ # Returns: Filtered log entries
17
+ #
18
+ # == Log Entry Fields from API
19
+ # - time: Timestamp of the event (Unix epoch or ISO string)
20
+ # - level: Log level (debug, info, warning, error)
21
+ # - message: Human-readable log message
22
+ # - facility: Log facility/subsystem
23
+ #
24
+ # == Device Events
25
+ # Device connection/disconnection events can be identified by:
26
+ # - Facility: "Core::Hotspot" or "Hotspot"
27
+ # - Keywords in message: "connected", "disconnected", "link up", "link down"
28
+ #
29
+ class Logs < Base
30
+ # Device-related facilities in Keenetic logs
31
+ DEVICE_FACILITIES = %w[
32
+ Core::Hotspot
33
+ Hotspot
34
+ Core::KnownHosts
35
+ Ndm::Hotspot
36
+ ip::hotspot
37
+ WifiMonitor
38
+ Network::Interface
39
+ ].freeze
40
+
41
+ # Keywords indicating device connection events
42
+ CONNECTION_KEYWORDS = [
43
+ 'connected',
44
+ 'disconnected',
45
+ 'link up',
46
+ 'link down',
47
+ 'has connected',
48
+ 'has disconnected',
49
+ 'registered',
50
+ 'unregistered',
51
+ 'appeared',
52
+ 'disappeared',
53
+ 'joined',
54
+ 'left',
55
+ 'associated',
56
+ 'disassociated',
57
+ 'deauthenticated',
58
+ 'STA('
59
+ ].freeze
60
+
61
+ # Fetch all system logs.
62
+ #
63
+ # @param limit [Integer, nil] Maximum number of entries to return
64
+ # @return [Array<Hash>] List of normalized log entries
65
+ # @example
66
+ # logs = client.logs.all
67
+ # logs = client.logs.all(limit: 100)
68
+ #
69
+ def all(limit: nil)
70
+ # Try batch format first (more compatible), then fall back to direct GET
71
+ begin
72
+ result = client.batch([{ 'show' => { 'log' => limit ? { 'limit' => limit } : {} } }])
73
+ # Response structure: [{"show":{"log":{"log":{"123":{...},...},"continued":true}}}]
74
+ log_data = result&.first&.dig('show', 'log')
75
+ # The actual log entries are in the nested 'log' key
76
+ response = log_data.is_a?(Hash) ? (log_data['log'] || log_data) : log_data
77
+ normalize_logs(response)
78
+ rescue StandardError
79
+ # Fallback to direct GET
80
+ response = get('/rci/show/log')
81
+ normalize_logs(response)
82
+ end
83
+ end
84
+
85
+ # Fetch only device connection/disconnection events.
86
+ #
87
+ # Filters logs to show only entries related to devices connecting
88
+ # or disconnecting from the network.
89
+ #
90
+ # @param limit [Integer, nil] Maximum number of entries to fetch before filtering
91
+ # @param mac [String, nil] Filter events for specific MAC address (optional)
92
+ # @param since [Integer, nil] Only return events from the last N seconds (default: 3600 = 1 hour)
93
+ # @return [Array<Hash>] List of device event log entries
94
+ # @example
95
+ # events = client.logs.device_events
96
+ # events = client.logs.device_events(mac: 'AA:BB:CC:DD:EE:FF')
97
+ # events = client.logs.device_events(since: 7200) # last 2 hours
98
+ #
99
+ def device_events(limit: nil, mac: nil, since: 3600)
100
+ logs = all(limit: limit)
101
+
102
+ # Filter by time if since is specified
103
+ # The logs are in router local time, so we filter based on the difference
104
+ # between the newest log and other logs (relative filtering)
105
+ if since && !logs.empty?
106
+ # Find the newest log timestamp to use as reference
107
+ newest_time = logs.map { |log|
108
+ next nil unless log[:time]
109
+ begin
110
+ Time.parse(log[:time])
111
+ rescue ArgumentError
112
+ nil
113
+ end
114
+ }.compact.max
115
+
116
+ if newest_time
117
+ cutoff_time = newest_time - since
118
+ logs = logs.select do |log|
119
+ next false unless log[:time]
120
+ begin
121
+ Time.parse(log[:time]) >= cutoff_time
122
+ rescue ArgumentError
123
+ true # Keep entries we can't parse
124
+ end
125
+ end
126
+ end
127
+ end
128
+
129
+ events = logs.select { |log| device_connection_event?(log) }
130
+
131
+ if mac
132
+ mac_pattern = mac.upcase.gsub(':', '[:-]?')
133
+ events = events.select { |log| log[:message]&.upcase&.match?(mac_pattern) }
134
+ end
135
+
136
+ # Parse device info from log messages
137
+ events.map { |log| enrich_device_event(log) }
138
+ end
139
+
140
+ # Fetch logs filtered by level.
141
+ #
142
+ # @param level [String] Log level to filter: "debug", "info", "warning", "error"
143
+ # @param limit [Integer, nil] Maximum entries
144
+ # @return [Array<Hash>] Filtered log entries
145
+ #
146
+ def by_level(level:, limit: nil)
147
+ params = { 'level' => level }
148
+ params['limit'] = limit if limit
149
+
150
+ result = client.batch([{ 'show' => { 'log' => params } }])
151
+ log_data = result&.first&.dig('show', 'log')
152
+ response = log_data.is_a?(Hash) ? (log_data['log'] || log_data) : log_data
153
+ normalize_logs(response)
154
+ end
155
+
156
+ private
157
+
158
+ def normalize_logs(response)
159
+ return [] if response.nil?
160
+
161
+ # Keenetic returns logs as a hash with numeric string keys: {"114154": {...}, "114155": {...}}
162
+ # Each entry has: timestamp, ident, id, message: {level, label, message}
163
+ logs = case response
164
+ when Hash
165
+ # Sort by key (log ID) descending to get newest first
166
+ response.keys.sort_by { |k| k.to_i }.reverse.map { |k| response[k] }
167
+ when Array
168
+ response
169
+ else
170
+ []
171
+ end
172
+
173
+ logs.map { |log| normalize_log_entry(log) }.compact
174
+ end
175
+
176
+ def normalize_log_entry(entry)
177
+ return nil unless entry.is_a?(Hash)
178
+
179
+ # Keenetic format: { "timestamp": "Jan 7 02:20:42", "ident": "ndm", "id": 114154,
180
+ # "message": { "level": "Info", "label": "I", "message": "..." } }
181
+ msg_obj = entry['message']
182
+
183
+ if msg_obj.is_a?(Hash)
184
+ # New Keenetic format with nested message object
185
+ time = parse_keenetic_timestamp(entry['timestamp'])
186
+ {
187
+ time: time,
188
+ level: msg_obj['level']&.downcase,
189
+ message: msg_obj['message'],
190
+ facility: entry['ident']
191
+ }
192
+ else
193
+ # Fallback for simpler format
194
+ time = entry['time'] || entry['timestamp']
195
+ normalized_time = case time
196
+ when Integer then Time.at(time).iso8601
197
+ when String then time
198
+ else nil
199
+ end
200
+ {
201
+ time: normalized_time,
202
+ level: (entry['level'] || entry['priority'])&.to_s&.downcase,
203
+ message: msg_obj || entry['msg'],
204
+ facility: entry['facility'] || entry['ident']
205
+ }
206
+ end
207
+ end
208
+
209
+ def parse_keenetic_timestamp(timestamp)
210
+ return nil unless timestamp.is_a?(String)
211
+
212
+ # Format: "Jan 7 02:20:42" - parse and add current year
213
+ # The router may be in a different timezone, so we preserve its local time
214
+ begin
215
+ current_year = Time.now.year
216
+ # Parse as UTC to avoid local timezone interference
217
+ parsed = Time.parse("#{timestamp} #{current_year} UTC")
218
+ # If the parsed time is more than 1 day in the future, it's probably from last year
219
+ if parsed > Time.now.utc + 86400
220
+ parsed = Time.parse("#{timestamp} #{current_year - 1} UTC")
221
+ end
222
+ # Return in ISO8601 format with the router's implied timezone
223
+ # We mark it as UTC but it's actually the router's local time
224
+ parsed.strftime('%Y-%m-%dT%H:%M:%S+00:00')
225
+ rescue ArgumentError
226
+ timestamp
227
+ end
228
+ end
229
+
230
+ def device_connection_event?(log)
231
+ return false unless log[:message]
232
+
233
+ message = log[:message].downcase
234
+ facility = log[:facility]&.to_s || ''
235
+
236
+ # Check if facility indicates device/hotspot related
237
+ is_device_facility = DEVICE_FACILITIES.any? { |f| facility.downcase.include?(f.downcase) }
238
+
239
+ # Check if message contains connection-related keywords
240
+ has_connection_keyword = CONNECTION_KEYWORDS.any? { |kw| message.include?(kw.downcase) }
241
+
242
+ # Either from a device facility OR has connection keywords with MAC/IP patterns
243
+ (is_device_facility && has_connection_keyword) ||
244
+ (has_connection_keyword && message.match?(/([0-9a-f]{2}[:-]){5}[0-9a-f]{2}/i))
245
+ end
246
+
247
+ def enrich_device_event(log)
248
+ message = log[:message] || ''
249
+
250
+ # Try to extract MAC address from message
251
+ # Keenetic uses format like STA(9c:9c:1f:44:40:a9)
252
+ mac_match = message.match(/([0-9a-f]{2}[:-]){5}[0-9a-f]{2}/i)
253
+ mac = mac_match ? mac_match[0].upcase.tr('-', ':') : nil
254
+
255
+ # Try to extract IP address from message
256
+ ip_match = message.match(/(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})/)
257
+ ip = ip_match ? ip_match[1] : nil
258
+
259
+ # Extract WiFi interface (2.4G or 5G band)
260
+ interface_match = message.match(/"(WifiMaster\d+\/\w+)"/)
261
+ interface = interface_match ? interface_match[1] : nil
262
+ band = if interface&.include?('WifiMaster0')
263
+ '2.4GHz'
264
+ elsif interface&.include?('WifiMaster1')
265
+ '5GHz'
266
+ else
267
+ nil
268
+ end
269
+
270
+ # Extract reason for disconnect (if present)
271
+ reason_match = message.match(/\(reason:\s*([^)]+)\)/i)
272
+ reason = reason_match ? reason_match[1].strip : nil
273
+
274
+ # Extract connection details
275
+ details = extract_connection_details(message)
276
+
277
+ # Determine event type
278
+ msg_lower = message.downcase
279
+ event_type = if msg_lower.include?('disconnected') || msg_lower.include?('left') ||
280
+ msg_lower.include?('disappeared') || msg_lower.include?('link down') ||
281
+ msg_lower.include?('disassociated') || msg_lower.include?('deauthenticated')
282
+ 'disconnected'
283
+ elsif msg_lower.include?('connected') || msg_lower.include?('joined') ||
284
+ msg_lower.include?('appeared') || msg_lower.include?('link up') ||
285
+ msg_lower.include?('associated') || msg_lower.include?('set key done')
286
+ 'connected'
287
+ else
288
+ 'unknown'
289
+ end
290
+
291
+ log.merge(
292
+ mac: mac,
293
+ ip: ip,
294
+ event_type: event_type,
295
+ interface: interface,
296
+ band: band,
297
+ reason: reason,
298
+ details: details
299
+ )
300
+ end
301
+
302
+ def extract_connection_details(message)
303
+ details = []
304
+
305
+ # WiFi security type
306
+ if message.include?('WPA2/WPA2PSK')
307
+ details << 'WPA2'
308
+ elsif message.include?('WPA3')
309
+ details << 'WPA3'
310
+ end
311
+
312
+ # Connection event specifics
313
+ if message.include?('had associated')
314
+ details << 'WiFi Associated'
315
+ elsif message.include?('set key done')
316
+ details << 'Authenticated'
317
+ elsif message.include?('had deauthenticated')
318
+ details << 'Deauthenticated'
319
+ elsif message.include?('disassociated')
320
+ details << 'Disassociated'
321
+ elsif message.include?('handshake timeout')
322
+ details << 'Handshake Timeout'
323
+ end
324
+
325
+ details.empty? ? nil : details.join(', ')
326
+ end
327
+ end
328
+ end
329
+ end
330
+
@@ -0,0 +1,251 @@
1
+ module Keenetic
2
+ module Resources
3
+ # Network resource for managing network interfaces.
4
+ #
5
+ # == API Endpoints Used
6
+ #
7
+ # === Reading Interfaces
8
+ # GET /rci/show/interface
9
+ # Returns: Object with interface IDs as keys, each containing interface details
10
+ # Example: { "GigabitEthernet0": { description, type, mac, state, ... }, "Bridge0": {...} }
11
+ #
12
+ # === Reading Interface Statistics
13
+ # GET /rci/show/interface/stat
14
+ # Returns: Same structure as /interface but with additional error statistics
15
+ # Additional fields: rxerrors, txerrors, rxdrops, txdrops, collisions, media
16
+ #
17
+ # === Configuring Interface
18
+ # POST /rci/ (batch request)
19
+ # Body: [{"interface": {"<interface_id>": {"up": true}}}]
20
+ #
21
+ # == Interface Types
22
+ # - GigabitEthernet: Physical Gigabit port
23
+ # - Bridge: Network bridge (LAN segments)
24
+ # - WifiMaster/AccessPoint: Wi-Fi interfaces
25
+ # - PPPoE, PPTP, L2TP: WAN connection types
26
+ # - OpenVPN, WireGuard, IPsec: VPN tunnels
27
+ #
28
+ class Network < Base
29
+ # Get all network interfaces.
30
+ #
31
+ # == Keenetic API Request
32
+ # GET /rci/show/interface
33
+ #
34
+ # == Response Structure from API
35
+ # {
36
+ # "GigabitEthernet0": {
37
+ # "description": "WAN",
38
+ # "type": "GigabitEthernet",
39
+ # "mac": "AA:BB:CC:DD:EE:FF",
40
+ # "mtu": 1500,
41
+ # "state": "up",
42
+ # "link": "up",
43
+ # "connected": true,
44
+ # "address": "192.168.1.1",
45
+ # "mask": "255.255.255.0",
46
+ # "defaultgw": true,
47
+ # "rxbytes": 1000000,
48
+ # "txbytes": 500000,
49
+ # ...
50
+ # },
51
+ # "Bridge0": { ... }
52
+ # }
53
+ #
54
+ # @return [Array<Hash>] List of normalized interface hashes
55
+ # @example
56
+ # interfaces = client.network.interfaces
57
+ # # => [{ id: "GigabitEthernet0", description: "WAN", type: "GigabitEthernet", ... }]
58
+ #
59
+ def interfaces
60
+ response = get('/rci/show/interface')
61
+ normalize_interfaces(response)
62
+ end
63
+
64
+ # Get specific interface by ID.
65
+ #
66
+ # Uses #interfaces internally to fetch all interfaces, then filters.
67
+ #
68
+ # @param id [String] Interface ID (e.g., "GigabitEthernet0", "Bridge0")
69
+ # @return [Hash, nil] Interface data or nil if not found
70
+ # @example
71
+ # iface = client.network.interface('Bridge0')
72
+ # # => { id: "Bridge0", description: "Home", type: "bridge", ... }
73
+ #
74
+ def interface(id)
75
+ interfaces.find { |i| i[:id] == id }
76
+ end
77
+
78
+ # Get WAN interfaces (those marked as default gateway).
79
+ #
80
+ # @return [Array<Hash>] WAN interface(s) with defaultgw flag
81
+ # @example
82
+ # wan = client.network.wan_status
83
+ # # => [{ id: "ISP", defaultgw: true, address: "203.0.113.50", ... }]
84
+ #
85
+ def wan_status
86
+ interfaces.select { |i| i[:type] == 'wan' || i[:defaultgw] }
87
+ end
88
+
89
+ # Get LAN interfaces (bridges).
90
+ #
91
+ # @return [Array<Hash>] Bridge interfaces
92
+ # @example
93
+ # lan = client.network.lan_interfaces
94
+ # # => [{ id: "Bridge0", description: "Home", type: "bridge", ... }]
95
+ #
96
+ def lan_interfaces
97
+ interfaces.select { |i| i[:type] == 'bridge' || i[:id]&.start_with?('Bridge') }
98
+ end
99
+
100
+ # Get detailed interface statistics including error counts.
101
+ #
102
+ # == Keenetic API Request
103
+ # GET /rci/show/interface/stat
104
+ #
105
+ # == Additional Response Fields (beyond standard interface fields)
106
+ # - rxerrors: Receive errors count
107
+ # - txerrors: Transmit errors count
108
+ # - rxdrops: Dropped received packets
109
+ # - txdrops: Dropped transmitted packets
110
+ # - collisions: Collision count
111
+ # - media: Media type (e.g., "1000baseT")
112
+ #
113
+ # @return [Array<Hash>] List of interfaces with statistics
114
+ # @example
115
+ # stats = client.network.statistics
116
+ # # => [{ id: "GigabitEthernet0", rxerrors: 0, txerrors: 0, rxdrops: 5, ... }]
117
+ #
118
+ def statistics
119
+ response = get('/rci/show/interface/stat')
120
+ normalize_statistics(response)
121
+ end
122
+
123
+ # Get statistics for a specific interface.
124
+ #
125
+ # Uses #statistics internally to fetch all, then filters by ID.
126
+ #
127
+ # @param id [String] Interface ID
128
+ # @return [Hash, nil] Interface statistics or nil if not found
129
+ # @example
130
+ # stats = client.network.interface_statistics('GigabitEthernet0')
131
+ # # => { id: "GigabitEthernet0", rxerrors: 0, txerrors: 0, collisions: 0, ... }
132
+ #
133
+ def interface_statistics(id)
134
+ statistics.find { |i| i[:id] == id }
135
+ end
136
+
137
+ # Configure interface settings (enable/disable, MTU, etc.).
138
+ #
139
+ # == Keenetic API Request
140
+ # POST /rci/ (batch format required for write operations)
141
+ # Body: [{"interface": {"<id>": {"up": true|false, ...}}}]
142
+ #
143
+ # == Common Options
144
+ # - up: true/false - Enable or disable the interface
145
+ # - mtu: Integer - Set MTU value
146
+ #
147
+ # @param id [String] Interface ID (e.g., "GigabitEthernet0", "WifiMaster0/AccessPoint1")
148
+ # @param up [Boolean, nil] Enable (true) or disable (false) interface
149
+ # @param options [Hash] Additional interface configuration options
150
+ # @return [Array<Hash>] API response, or {} if no parameters provided
151
+ #
152
+ # @example Enable interface
153
+ # client.network.configure('GigabitEthernet0', up: true)
154
+ # # Sends: POST /rci/ [{"interface":{"GigabitEthernet0":{"up":true}}}]
155
+ #
156
+ # @example Disable Wi-Fi access point
157
+ # client.network.configure('WifiMaster0/AccessPoint1', up: false)
158
+ # # Sends: POST /rci/ [{"interface":{"WifiMaster0/AccessPoint1":{"up":false}}}]
159
+ #
160
+ # @example Configure with additional options
161
+ # client.network.configure('Bridge0', up: true, mtu: 1400)
162
+ #
163
+ def configure(id, up: nil, **options)
164
+ params = options.dup
165
+ params['up'] = up unless up.nil?
166
+
167
+ return {} if params.empty?
168
+
169
+ client.batch([{ 'interface' => { id => params } }])
170
+ end
171
+
172
+ private
173
+
174
+ def normalize_interfaces(response)
175
+ return [] unless response.is_a?(Hash)
176
+
177
+ response.map { |id, data| normalize_interface(id, data) }.compact
178
+ end
179
+
180
+ def normalize_interface(id, data)
181
+ return nil unless data.is_a?(Hash)
182
+
183
+ {
184
+ id: id,
185
+ description: data['description'],
186
+ type: data['type'],
187
+ mac: data['mac'],
188
+ mtu: data['mtu'],
189
+ state: data['state'],
190
+ link: data['link'],
191
+ connected: data['connected'],
192
+ address: data['address'],
193
+ mask: data['mask'],
194
+ gateway: data['gateway'],
195
+ defaultgw: data['defaultgw'],
196
+ uptime: data['uptime'],
197
+ rxbytes: data['rxbytes'],
198
+ txbytes: data['txbytes'],
199
+ rxpackets: data['rxpackets'],
200
+ txpackets: data['txpackets'],
201
+ last_change: data['last-change'],
202
+ speed: data['speed'],
203
+ duplex: data['duplex'],
204
+ security: data['security-level'],
205
+ global: data['global']
206
+ }
207
+ end
208
+
209
+ def normalize_statistics(response)
210
+ return [] unless response.is_a?(Hash)
211
+
212
+ response.map { |id, data| normalize_interface_stat(id, data) }.compact
213
+ end
214
+
215
+ def normalize_interface_stat(id, data)
216
+ return nil unless data.is_a?(Hash)
217
+
218
+ # Include all base interface fields plus statistics-specific fields
219
+ {
220
+ id: id,
221
+ description: data['description'],
222
+ type: data['type'],
223
+ mac: data['mac'],
224
+ mtu: data['mtu'],
225
+ state: data['state'],
226
+ link: data['link'],
227
+ connected: data['connected'],
228
+ address: data['address'],
229
+ mask: data['mask'],
230
+ uptime: data['uptime'],
231
+ # Traffic counters
232
+ rxbytes: data['rxbytes'],
233
+ txbytes: data['txbytes'],
234
+ rxpackets: data['rxpackets'],
235
+ txpackets: data['txpackets'],
236
+ # Error statistics (specific to /interface/stat)
237
+ rxerrors: data['rxerrors'],
238
+ txerrors: data['txerrors'],
239
+ rxdrops: data['rxdrops'],
240
+ txdrops: data['txdrops'],
241
+ collisions: data['collisions'],
242
+ media: data['media'],
243
+ # Additional fields
244
+ speed: data['speed'],
245
+ duplex: data['duplex']
246
+ }
247
+ end
248
+ end
249
+ end
250
+ end
251
+