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 +7 -0
- data/README.md +83 -0
- data/lib/keenetic/client.rb +361 -0
- data/lib/keenetic/configuration.rb +27 -0
- data/lib/keenetic/errors.rb +19 -0
- data/lib/keenetic/resources/base.rb +76 -0
- data/lib/keenetic/resources/devices.rb +309 -0
- data/lib/keenetic/resources/dhcp.rb +195 -0
- data/lib/keenetic/resources/internet.rb +169 -0
- data/lib/keenetic/resources/logs.rb +330 -0
- data/lib/keenetic/resources/network.rb +251 -0
- data/lib/keenetic/resources/policies.rb +171 -0
- data/lib/keenetic/resources/ports.rb +115 -0
- data/lib/keenetic/resources/routing.rb +200 -0
- data/lib/keenetic/resources/system.rb +267 -0
- data/lib/keenetic/resources/wifi.rb +376 -0
- data/lib/keenetic/version.rb +4 -0
- data/lib/keenetic.rb +99 -0
- metadata +89 -0
|
@@ -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
|
+
|