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,171 @@
1
+ module Keenetic
2
+ module Resources
3
+ # Manages routing policies (VPN policies, traffic routing rules).
4
+ #
5
+ # Policies allow routing specific devices through VPN tunnels or other interfaces.
6
+ #
7
+ # == API Endpoints Used
8
+ #
9
+ # === Reading Policies
10
+ # POST /rci/ (batch request)
11
+ # Body: [{"show":{"sc":{"ip":{"policy":{}}}}}]
12
+ #
13
+ # === Reading Device-Policy Assignments
14
+ # POST /rci/ (batch request)
15
+ # Body: [{"show":{"sc":{"ip":{"hotspot":{"host":{}}}}}}]
16
+ #
17
+ # == Policy Structure
18
+ # Policies are identified by IDs like "Policy0", "Policy1", etc.
19
+ # Each policy has:
20
+ # - description: Name prefixed with "!" (e.g., "!Latvia VPN")
21
+ # - permit: Array of interfaces this policy routes through
22
+ #
23
+ # == Example Response from API
24
+ # {
25
+ # "show": {
26
+ # "sc": {
27
+ # "ip": {
28
+ # "policy": {
29
+ # "Policy0": {
30
+ # "description": "!Latvia",
31
+ # "permit": [
32
+ # { "interface": "Wireguard0", "enabled": true },
33
+ # { "interface": "ISP", "enabled": false }
34
+ # ]
35
+ # }
36
+ # }
37
+ # }
38
+ # }
39
+ # }
40
+ # }
41
+ #
42
+ class Policies < Base
43
+ # Fetch all routing policies.
44
+ #
45
+ # == Keenetic API Request
46
+ # POST /rci/
47
+ # Body: [{"show":{"sc":{"ip":{"policy":{}}}}}]
48
+ #
49
+ # == Response Processing
50
+ # - Extracts policy data from nested response structure
51
+ # - Filters permit list to only enabled interfaces (enabled: true, no: not true)
52
+ # - Removes "!" prefix from description for clean policy name
53
+ #
54
+ # @return [Array<Hash>] List of policies with :id, :description, :name, :interfaces
55
+ # @example
56
+ # policies = client.policies.all
57
+ # # => [{ id: "Policy0", description: "!Latvia", name: "Latvia",
58
+ # # interfaces: ["Wireguard0"], interface_count: 1 }]
59
+ #
60
+ def all
61
+ response = client.batch([
62
+ { 'show' => { 'sc' => { 'ip' => { 'policy' => {} } } } }
63
+ ])
64
+
65
+ normalize_policies(response&.first)
66
+ end
67
+
68
+ # Get policy assignments for all devices.
69
+ #
70
+ # == Keenetic API Request
71
+ # POST /rci/
72
+ # Body: [{"show":{"sc":{"ip":{"hotspot":{"host":{}}}}}}]
73
+ #
74
+ # == Response Structure
75
+ # {
76
+ # "show": {
77
+ # "sc": {
78
+ # "ip": {
79
+ # "hotspot": {
80
+ # "host": [
81
+ # { "mac": "00:11:22:33:44:55", "policy": "Policy0" },
82
+ # { "mac": "AA:BB:CC:DD:EE:FF", "policy": "Policy1" }
83
+ # ]
84
+ # }
85
+ # }
86
+ # }
87
+ # }
88
+ # }
89
+ #
90
+ # @return [Hash] MAC address (lowercase) => policy ID mapping
91
+ # @example
92
+ # assignments = client.policies.device_assignments
93
+ # # => { "00:11:22:33:44:55" => "Policy0", "aa:bb:cc:dd:ee:ff" => "Policy1" }
94
+ #
95
+ def device_assignments
96
+ response = client.batch([
97
+ { 'show' => { 'sc' => { 'ip' => { 'hotspot' => { 'host' => {} } } } } }
98
+ ])
99
+
100
+ extract_device_policies(response&.first)
101
+ end
102
+
103
+ # Find a specific policy by ID.
104
+ #
105
+ # @param id [String] Policy ID (e.g., "Policy0")
106
+ # @return [Hash] Policy data
107
+ # @raise [NotFoundError] if policy not found
108
+ # @example
109
+ # policy = client.policies.find(id: 'Policy0')
110
+ # # => { id: "Policy0", name: "Latvia", interfaces: ["Wireguard0"], ... }
111
+ #
112
+ def find(id:)
113
+ policies = all
114
+ policy = policies.find { |p| p[:id] == id }
115
+ raise NotFoundError, "Policy #{id} not found" unless policy
116
+ policy
117
+ end
118
+
119
+ private
120
+
121
+ def normalize_policies(response)
122
+ return [] unless response.is_a?(Hash)
123
+
124
+ policies_data = response.dig('show', 'sc', 'ip', 'policy')
125
+ return [] unless policies_data.is_a?(Hash)
126
+
127
+ policies_data.map do |id, data|
128
+ normalize_policy(id, data)
129
+ end
130
+ end
131
+
132
+ def normalize_policy(id, data)
133
+ return nil unless data.is_a?(Hash)
134
+
135
+ # Extract active interfaces from permit list
136
+ permits = data['permit'] || []
137
+ active_interfaces = permits
138
+ .select { |p| p.is_a?(Hash) && p['enabled'] == true && p['no'] != true }
139
+ .map { |p| p['interface'] }
140
+ .compact
141
+
142
+ {
143
+ id: id,
144
+ description: data['description'] || id,
145
+ name: extract_policy_name(data['description'] || id),
146
+ interfaces: active_interfaces,
147
+ interface_count: active_interfaces.size
148
+ }
149
+ end
150
+
151
+ def extract_policy_name(description)
152
+ # Remove leading "!" and clean up the name
153
+ name = description.to_s.sub(/^!/, '').strip
154
+ name.empty? ? 'Unnamed Policy' : name
155
+ end
156
+
157
+ def extract_device_policies(response)
158
+ return {} unless response.is_a?(Hash)
159
+
160
+ hosts = response.dig('show', 'sc', 'ip', 'hotspot', 'host')
161
+ return {} unless hosts.is_a?(Array)
162
+
163
+ hosts.each_with_object({}) do |host, mapping|
164
+ next unless host.is_a?(Hash) && host['policy']
165
+ mapping[host['mac']&.downcase] = host['policy']
166
+ end
167
+ end
168
+ end
169
+ end
170
+ end
171
+
@@ -0,0 +1,115 @@
1
+ module Keenetic
2
+ module Resources
3
+ # Ports resource for accessing physical Ethernet port status.
4
+ #
5
+ # == API Endpoints Used
6
+ #
7
+ # === Reading Port Statistics
8
+ # GET /rci/show/interface/stat
9
+ # Filters: Physical ports only (GigabitEthernet*, FastEthernet*, SFP*, USB*)
10
+ #
11
+ # == Port Naming Convention
12
+ # - GigabitEthernet0, GigabitEthernet1, etc.: Gigabit Ethernet ports
13
+ # - FastEthernet0, etc.: Fast Ethernet ports (100Mbps)
14
+ # - SFP0: SFP port (if available)
15
+ #
16
+ # == Link Status
17
+ # - link: true = cable connected, false = no cable
18
+ # - speed: Current negotiated speed (1000, 100, 10 Mbps)
19
+ # - duplex: "full" or "half"
20
+ #
21
+ class Ports < Base
22
+ # Get all physical port statuses.
23
+ #
24
+ # == Keenetic API Request
25
+ # GET /rci/show/interface/stat
26
+ # Internally filters for physical ports only
27
+ #
28
+ # == Response Fields
29
+ # - id: Interface ID (e.g., "GigabitEthernet0")
30
+ # - port: Port number extracted from ID
31
+ # - type: "gigabit", "fast", "sfp", or "usb"
32
+ # - link: true if cable connected
33
+ # - speed: Negotiated speed in Mbps
34
+ # - duplex: "full" or "half"
35
+ # - rxbytes/txbytes: Traffic counters
36
+ # - rxerrors/txerrors: Error counters
37
+ # - media: Media type string
38
+ #
39
+ # @return [Array<Hash>] List of physical ports with status
40
+ # @example
41
+ # ports = client.ports.all
42
+ # # => [{ id: "GigabitEthernet0", port: 0, type: "gigabit", link: true, speed: 1000, ... }]
43
+ #
44
+ def all
45
+ response = get('/rci/show/interface/stat')
46
+ normalize_ports(response)
47
+ end
48
+
49
+ # Get specific port by ID.
50
+ #
51
+ # @param id [String] Port interface ID (e.g., "GigabitEthernet0")
52
+ # @return [Hash, nil] Port data or nil if not found
53
+ # @example
54
+ # port = client.ports.find('GigabitEthernet0')
55
+ # # => { id: "GigabitEthernet0", port: 0, link: true, speed: 1000, ... }
56
+ #
57
+ def find(id)
58
+ all.find { |p| p[:id] == id }
59
+ end
60
+
61
+ private
62
+
63
+ def normalize_ports(response)
64
+ return [] unless response.is_a?(Hash)
65
+
66
+ response.filter_map do |id, data|
67
+ next unless physical_port?(id, data)
68
+ normalize_port(id, data)
69
+ end
70
+ end
71
+
72
+ def physical_port?(id, data)
73
+ return false unless data.is_a?(Hash)
74
+
75
+ # Physical ports are typically named GigabitEthernet0, GigabitEthernet1, etc.
76
+ # or SFP ports, or USB ports
77
+ id.match?(/^(Gigabit|Fast)?Ethernet\d+|SFP|USB/)
78
+ end
79
+
80
+ def normalize_port(id, data)
81
+ {
82
+ id: id,
83
+ port: extract_port_number(id),
84
+ type: extract_port_type(id),
85
+ link: data['link'] == true,
86
+ speed: data['speed'],
87
+ duplex: data['duplex'],
88
+ rxbytes: data['rxbytes'],
89
+ txbytes: data['txbytes'],
90
+ rxpackets: data['rxpackets'],
91
+ txpackets: data['txpackets'],
92
+ rxerrors: data['rxerrors'],
93
+ txerrors: data['txerrors'],
94
+ media: data['media']
95
+ }
96
+ end
97
+
98
+ def extract_port_number(id)
99
+ match = id.match(/(\d+)$/)
100
+ match ? match[1].to_i : nil
101
+ end
102
+
103
+ def extract_port_type(id)
104
+ case id
105
+ when /GigabitEthernet/ then 'gigabit'
106
+ when /FastEthernet/ then 'fast'
107
+ when /SFP/ then 'sfp'
108
+ when /USB/ then 'usb'
109
+ else 'unknown'
110
+ end
111
+ end
112
+ end
113
+ end
114
+ end
115
+
@@ -0,0 +1,200 @@
1
+ module Keenetic
2
+ module Resources
3
+ # Routing resource for managing IP routes and ARP table.
4
+ #
5
+ # == API Endpoints Used
6
+ #
7
+ # === Reading Routing Table
8
+ # GET /rci/show/ip/route
9
+ # Returns: Array of IP routes
10
+ #
11
+ # === Reading ARP Table
12
+ # GET /rci/show/ip/arp
13
+ # Returns: Array of ARP entries
14
+ #
15
+ # === Adding Static Route
16
+ # POST /rci/ip/route
17
+ # Body: { "destination": "10.0.0.0", "mask": "255.0.0.0", "gateway": "192.168.1.1", "interface": "ISP" }
18
+ #
19
+ # === Deleting Static Route
20
+ # POST /rci/ip/route
21
+ # Body: { "destination": "10.0.0.0", "mask": "255.0.0.0", "no": true }
22
+ #
23
+ class Routing < Base
24
+ # Get IP routing table.
25
+ #
26
+ # == Keenetic API Request
27
+ # GET /rci/show/ip/route
28
+ #
29
+ # == Response Structure from API
30
+ # [
31
+ # {
32
+ # "destination": "0.0.0.0",
33
+ # "mask": "0.0.0.0",
34
+ # "gateway": "192.168.1.1",
35
+ # "interface": "ISP",
36
+ # "metric": 0,
37
+ # "flags": "G",
38
+ # "auto": true
39
+ # }
40
+ # ]
41
+ #
42
+ # @return [Array<Hash>] List of normalized route hashes
43
+ # @example
44
+ # routes = client.routing.routes
45
+ # # => [{ destination: "0.0.0.0", mask: "0.0.0.0", gateway: "192.168.1.1", ... }]
46
+ #
47
+ def routes
48
+ response = get('/rci/show/ip/route')
49
+ normalize_routes(response)
50
+ end
51
+
52
+ # Get ARP table (IP to MAC address mappings).
53
+ #
54
+ # == Keenetic API Request
55
+ # GET /rci/show/ip/arp
56
+ #
57
+ # == Response Structure from API
58
+ # [
59
+ # {
60
+ # "ip": "192.168.1.100",
61
+ # "mac": "AA:BB:CC:DD:EE:FF",
62
+ # "interface": "Bridge0",
63
+ # "state": "reachable"
64
+ # }
65
+ # ]
66
+ #
67
+ # @return [Array<Hash>] List of normalized ARP entries
68
+ # @example
69
+ # arp_table = client.routing.arp_table
70
+ # # => [{ ip: "192.168.1.100", mac: "AA:BB:CC:DD:EE:FF", interface: "Bridge0", state: "reachable" }]
71
+ #
72
+ def arp_table
73
+ response = get('/rci/show/ip/arp')
74
+ normalize_arp_entries(response)
75
+ end
76
+
77
+ # Find a specific route by destination and mask.
78
+ #
79
+ # @param destination [String] Destination network (e.g., "10.0.0.0")
80
+ # @param mask [String] Network mask (e.g., "255.0.0.0")
81
+ # @return [Hash, nil] Route data or nil if not found
82
+ # @example
83
+ # route = client.routing.find_route(destination: "10.0.0.0", mask: "255.0.0.0")
84
+ # # => { destination: "10.0.0.0", mask: "255.0.0.0", gateway: "192.168.1.1", ... }
85
+ #
86
+ def find_route(destination:, mask:)
87
+ routes.find { |r| r[:destination] == destination && r[:mask] == mask }
88
+ end
89
+
90
+ # Find ARP entry by IP address.
91
+ #
92
+ # @param ip [String] IP address to look up
93
+ # @return [Hash, nil] ARP entry or nil if not found
94
+ # @example
95
+ # entry = client.routing.find_arp_entry(ip: "192.168.1.100")
96
+ # # => { ip: "192.168.1.100", mac: "AA:BB:CC:DD:EE:FF", ... }
97
+ #
98
+ def find_arp_entry(ip:)
99
+ arp_table.find { |e| e[:ip] == ip }
100
+ end
101
+
102
+ # Add a static route.
103
+ #
104
+ # == Keenetic API Request
105
+ # POST /rci/ (batch format)
106
+ # Body: [{"ip": {"route": {"destination": "...", "mask": "...", "gateway": "...", "interface": "..."}}}]
107
+ #
108
+ # @param destination [String] Destination network (e.g., "10.0.0.0")
109
+ # @param mask [String] Network mask (e.g., "255.0.0.0")
110
+ # @param gateway [String, nil] Next hop gateway IP address
111
+ # @param interface [String, nil] Output interface name
112
+ # @param metric [Integer, nil] Route metric (optional)
113
+ # @return [Array<Hash>] API response
114
+ #
115
+ # @example Add route via gateway
116
+ # client.routing.create_route(destination: "10.0.0.0", mask: "255.0.0.0", gateway: "192.168.1.1")
117
+ #
118
+ # @example Add route via interface
119
+ # client.routing.create_route(destination: "10.0.0.0", mask: "255.0.0.0", interface: "ISP")
120
+ #
121
+ def create_route(destination:, mask:, gateway: nil, interface: nil, metric: nil)
122
+ params = {
123
+ 'destination' => destination,
124
+ 'mask' => mask
125
+ }
126
+ params['gateway'] = gateway if gateway
127
+ params['interface'] = interface if interface
128
+ params['metric'] = metric if metric
129
+
130
+ client.batch([{ 'ip' => { 'route' => params } }])
131
+ end
132
+
133
+ # Delete a static route.
134
+ #
135
+ # == Keenetic API Request
136
+ # POST /rci/ (batch format)
137
+ # Body: [{"ip": {"route": {"destination": "...", "mask": "...", "no": true}}}]
138
+ #
139
+ # @param destination [String] Destination network
140
+ # @param mask [String] Network mask
141
+ # @return [Array<Hash>] API response
142
+ #
143
+ # @example
144
+ # client.routing.delete_route(destination: "10.0.0.0", mask: "255.0.0.0")
145
+ #
146
+ def delete_route(destination:, mask:)
147
+ client.batch([{
148
+ 'ip' => {
149
+ 'route' => {
150
+ 'destination' => destination,
151
+ 'mask' => mask,
152
+ 'no' => true
153
+ }
154
+ }
155
+ }])
156
+ end
157
+
158
+ private
159
+
160
+ def normalize_routes(response)
161
+ return [] unless response.is_a?(Array)
162
+
163
+ response.map { |route| normalize_route(route) }.compact
164
+ end
165
+
166
+ def normalize_route(data)
167
+ return nil unless data.is_a?(Hash)
168
+
169
+ {
170
+ destination: data['destination'],
171
+ mask: data['mask'],
172
+ gateway: data['gateway'],
173
+ interface: data['interface'],
174
+ metric: data['metric'],
175
+ flags: data['flags'],
176
+ auto: data['auto'],
177
+ comment: data['comment']
178
+ }
179
+ end
180
+
181
+ def normalize_arp_entries(response)
182
+ return [] unless response.is_a?(Array)
183
+
184
+ response.map { |entry| normalize_arp_entry(entry) }.compact
185
+ end
186
+
187
+ def normalize_arp_entry(data)
188
+ return nil unless data.is_a?(Hash)
189
+
190
+ {
191
+ ip: data['ip'],
192
+ mac: data['mac'],
193
+ interface: data['interface'],
194
+ state: data['state']
195
+ }
196
+ end
197
+ end
198
+ end
199
+ end
200
+