nanokvm 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,373 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "net/http"
4
+ require "uri"
5
+ require "json"
6
+ require "openssl"
7
+ require "base64"
8
+ require "digest/md5"
9
+ require "open3"
10
+ require "cgi"
11
+
12
+ module NanoKVM
13
+ class Client
14
+ attr_reader :host, :username
15
+ attr_accessor :token, :debug
16
+
17
+ def initialize(options = {})
18
+ @host = options[:host] || NanoKVM.configuration.default_host
19
+ @username = options[:username] || NanoKVM.configuration.default_username
20
+ @password = options[:password] || NanoKVM.configuration.default_password
21
+ @token = options[:token]
22
+ @debug = options[:debug]
23
+ @secret_key = options[:secret_key] || NanoKVM::DEFAULT_SECRET_KEY
24
+
25
+ raise ArgumentError, "Host is required" unless @host
26
+ end
27
+
28
+ def login
29
+ puts "Attempting to authenticate with NanoKVM..." if @debug
30
+
31
+ # Encrypt and encode password
32
+ puts "Encrypting password..." if @debug
33
+ obfuscated_password = obfuscate_password(@password)
34
+ puts "Obfuscated password: #{obfuscated_password}" if @debug
35
+
36
+ # Create the JSON payload - the password is already URI encoded
37
+ payload = { username: @username, password: obfuscated_password }.to_json
38
+ puts "Login payload: #{payload}" if @debug
39
+
40
+ # Send the request
41
+ response = post("/api/auth/login", payload)
42
+ @token = response.dig("data", "token")
43
+
44
+ raise AuthenticationError, "Failed to authenticate" unless @token
45
+
46
+ puts "Authentication successful, token: #{@token}" if @debug
47
+ @token
48
+ end
49
+
50
+ def get_device_info
51
+ get("/api/vm/info")
52
+ end
53
+
54
+ def get_gpio_state
55
+ get("/api/vm/gpio")
56
+ end
57
+
58
+ def power_button(duration = 800)
59
+ payload = {
60
+ type: "power",
61
+ duration: duration
62
+ }.to_json
63
+
64
+ post("/api/vm/gpio", payload)
65
+ end
66
+
67
+ def reset_button(duration = 200)
68
+ payload = {
69
+ type: "reset",
70
+ duration: duration
71
+ }.to_json
72
+
73
+ post("/api/vm/gpio", payload)
74
+ end
75
+
76
+ def get_available_images
77
+ get("/api/storage/image")
78
+ end
79
+
80
+ def get_mounted_image
81
+ get("/api/storage/image/mounted")
82
+ end
83
+
84
+ def mount_image(file_path)
85
+ payload = {
86
+ file: file_path
87
+ }.to_json
88
+
89
+ post("/api/storage/image/mount", payload)
90
+ end
91
+
92
+ def get_virtual_device_status
93
+ get("/api/vm/device/virtual")
94
+ end
95
+
96
+ def toggle_virtual_device(device)
97
+ payload = {
98
+ device: device
99
+ }.to_json
100
+
101
+ post("/api/vm/device/virtual", payload)
102
+ end
103
+
104
+ def send_text(content)
105
+ payload = {
106
+ content: content
107
+ }.to_json
108
+
109
+ post("/api/hid/paste", payload)
110
+ end
111
+
112
+ def reset_hid
113
+ post("/api/hid/reset", "{}")
114
+ end
115
+
116
+ def get_account_info
117
+ get("/api/auth/account")
118
+ end
119
+
120
+ def get_network_info
121
+ get("/api/network/wifi")
122
+ end
123
+
124
+ def get_app_version
125
+ get("/api/application/version")
126
+ end
127
+
128
+ def system_reboot
129
+ post("/api/vm/system/reboot", "{}")
130
+ end
131
+
132
+ def reset_hdmi
133
+ post("/api/vm/hdmi/reset", "{}")
134
+ end
135
+
136
+ def is_password_updated
137
+ get("/api/auth/password")
138
+ end
139
+
140
+ def get_tailscale_status
141
+ get("/api/extensions/tailscale/status")
142
+ end
143
+
144
+ def is_download_image_enabled
145
+ get("/api/download/image/enabled")
146
+ end
147
+
148
+ def get_download_image_status
149
+ get("/api/download/image/status")
150
+ end
151
+
152
+ def download_image(url)
153
+ payload = {
154
+ file: url
155
+ }.to_json
156
+
157
+ post("/api/download/image", payload)
158
+ end
159
+
160
+ def get_hardware_info
161
+ get("/api/vm/hardware")
162
+ end
163
+
164
+ def get_script_info
165
+ get("/api/vm/script")
166
+ end
167
+
168
+ def get_memory_limit
169
+ get("/api/vm/memory/limit")
170
+ end
171
+
172
+ def set_memory_limit(enabled, limit_mb)
173
+ payload = {
174
+ enabled: enabled,
175
+ limit: limit_mb
176
+ }.to_json
177
+
178
+ post("/api/vm/memory/limit", payload)
179
+ end
180
+
181
+ def get_oled_info
182
+ get("/api/vm/oled")
183
+ end
184
+
185
+ def set_oled_sleep(sleep_seconds)
186
+ payload = {
187
+ sleep: sleep_seconds
188
+ }.to_json
189
+
190
+ post("/api/vm/oled", payload)
191
+ end
192
+
193
+ def get_ssh_status
194
+ get("/api/vm/ssh")
195
+ end
196
+
197
+ def get_mdns_status
198
+ get("/api/vm/mdns")
199
+ end
200
+
201
+ def get_cdrom_info
202
+ get("/api/storage/cdrom")
203
+ end
204
+
205
+ def get_wol_mac
206
+ get("/api/network/wol/mac")
207
+ end
208
+
209
+ def get_hid_mode
210
+ get("/api/hid/mode")
211
+ end
212
+
213
+ def set_hid_mode(mode)
214
+ payload = {
215
+ mode: mode
216
+ }.to_json
217
+
218
+ post("/api/hid/mode", payload)
219
+ end
220
+
221
+ def get_preview_state
222
+ get("/api/application/preview")
223
+ end
224
+
225
+ def set_preview_state(enable)
226
+ payload = {
227
+ enable: enable
228
+ }.to_json
229
+
230
+ post("/api/application/preview", payload)
231
+ end
232
+
233
+ def get_interface_status
234
+ # Collect information from multiple endpoints
235
+ data = {}
236
+
237
+ # Get virtual device status for ethernet
238
+ begin
239
+ virtual_devices = get_virtual_device_status["data"]
240
+ data["eth_status"] = virtual_devices["network"] ? "CONNECTED" : "DISCONNECTED" if virtual_devices.key?("network")
241
+ rescue => e
242
+ # Ignore errors
243
+ end
244
+
245
+ # Get HID mode
246
+ begin
247
+ hid_info = get_hid_mode["data"]
248
+ data["hid_mode"] = hid_info["mode"] if hid_info.key?("mode")
249
+ data["hid_status"] = "CONNECTED" # Assume connected if we can get mode
250
+ rescue => e
251
+ # Ignore errors
252
+ end
253
+
254
+ # Get GPIO status for LEDs
255
+ begin
256
+ gpio = get_gpio_state["data"]
257
+ # Map LED states to status
258
+ data["pwr_led"] = gpio["pwr"] ? "ON" : "OFF" if gpio.key?("pwr")
259
+ data["hdd_led"] = gpio["hdd"] ? "ON" : "OFF" if gpio.key?("hdd")
260
+ rescue => e
261
+ # Ignore errors
262
+ end
263
+
264
+ # Get device info for any additional information
265
+ begin
266
+ info = get_device_info["data"]
267
+ data["device_ip"] = info["ip"] if info.key?("ip")
268
+ data["application_version"] = info["application"] if info.key?("application")
269
+ rescue => e
270
+ # Ignore errors
271
+ end
272
+
273
+ # Get hardware info
274
+ begin
275
+ hardware = get_hardware_info["data"]
276
+ # Add hardware info
277
+ hardware.each do |key, value|
278
+ data[key] = value
279
+ end
280
+ rescue => e
281
+ # Ignore errors
282
+ end
283
+
284
+ # Note about HDMI status
285
+ data["hdmi_status"] = "NOT AVAILABLE VIA API" # The API doesn't provide HDMI status
286
+ data["hdmi_note"] = "HDMI status only visible on device screen"
287
+
288
+ # Return all collected data
289
+ { "code" => 0, "msg" => "success", "data" => data }
290
+ end
291
+
292
+ private
293
+
294
+ # Key derivation
295
+ def derive_encryption_keys(password, salt, key_len: 32, iv_len: 16)
296
+ derived = ""
297
+ block = ""
298
+
299
+ while derived.length < (key_len + iv_len)
300
+ block = OpenSSL::Digest::MD5.digest(block + password + salt)
301
+ derived += block
302
+ end
303
+
304
+ [derived[0, key_len], derived[key_len, iv_len]]
305
+ end
306
+
307
+ # Encryption
308
+ def encrypt_with_aes(plaintext, password)
309
+ salt = OpenSSL::Random.random_bytes(8)
310
+ key, iv = derive_encryption_keys(password, salt)
311
+
312
+ cipher = OpenSSL::Cipher.new('AES-256-CBC')
313
+ cipher.encrypt
314
+ cipher.key = key
315
+ cipher.iv = iv
316
+
317
+ encrypted = cipher.update(plaintext) + cipher.final
318
+
319
+ "Salted__" + salt + encrypted
320
+ end
321
+
322
+ # Password obfuscation
323
+ def obfuscate_password(password)
324
+ encrypted = encrypt_with_aes(password, @secret_key)
325
+ encoded = Base64.strict_encode64(encrypted)
326
+ URI.encode_www_form_component(encoded)
327
+ end
328
+
329
+ def get(path)
330
+ uri = URI.parse("http://#{@host}#{path}")
331
+ request = Net::HTTP::Get.new(uri)
332
+ request["Cookie"] = "nano-kvm-token=#{@token}" if @token
333
+
334
+ handle_response(uri, request)
335
+ end
336
+
337
+ def post(path, payload)
338
+ uri = URI.parse("http://#{@host}#{path}")
339
+ request = Net::HTTP::Post.new(uri)
340
+ request.content_type = "application/json"
341
+ request["Cookie"] = "nano-kvm-token=#{@token}" if @token
342
+ request.body = payload
343
+
344
+ puts "POST to #{uri} with body: #{payload}" if @debug
345
+
346
+ handle_response(uri, request)
347
+ end
348
+
349
+ def handle_response(uri, request)
350
+ response = Net::HTTP.start(uri.hostname, uri.port) do |http|
351
+ http.request(request)
352
+ end
353
+
354
+ puts "Response body: #{response.body}" if @debug
355
+
356
+ if response.body.to_s.strip.empty? || response.body.include?("page not found")
357
+ raise ApiError, "Invalid endpoint or empty response: #{uri.path}"
358
+ end
359
+
360
+ body = JSON.parse(response.body)
361
+
362
+ if body["code"] != 0
363
+ raise ApiError, "API Error: #{body['msg']}"
364
+ end
365
+
366
+ body
367
+ rescue JSON::ParserError => e
368
+ raise ApiError, "Invalid JSON response: #{e.message}. Response: #{response.body}"
369
+ rescue Errno::ECONNREFUSED, Errno::EHOSTUNREACH, SocketError => e
370
+ raise ConnectionError, "Could not connect to NanoKVM: #{e.message}"
371
+ end
372
+ end
373
+ end
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ module NanoKVM
4
+ class Error < StandardError; end
5
+ class AuthenticationError < Error; end
6
+ class ConnectionError < Error; end
7
+ class ApiError < Error; end
8
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module NanoKVM
4
+ VERSION = "0.1.0"
5
+ end
data/lib/nanokvm.rb ADDED
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "nanokvm/version"
4
+ require_relative "nanokvm/client"
5
+ require_relative "nanokvm/errors"
6
+ require_relative "nanokvm/cli"
7
+
8
+ module NanoKVM
9
+ # Module constants
10
+ DEFAULT_SECRET_KEY = "nanokvm-sipeed-2024"
11
+ DEFAULT_USERNAME = "admin"
12
+ DEFAULT_PASSWORD = "admin"
13
+
14
+ class << self
15
+ attr_accessor :configuration
16
+
17
+ def configure
18
+ self.configuration ||= Configuration.new
19
+ yield(configuration) if block_given?
20
+ end
21
+ end
22
+
23
+ class Configuration
24
+ attr_accessor :default_host, :default_username, :default_password, :default_secret_key
25
+
26
+ def initialize
27
+ @default_host = nil
28
+ @default_username = DEFAULT_USERNAME
29
+ @default_password = DEFAULT_PASSWORD
30
+ @default_secret_key = DEFAULT_SECRET_KEY
31
+ end
32
+ end
33
+
34
+ # Initialize with defaults
35
+ configure
36
+ end
metadata ADDED
@@ -0,0 +1,154 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: nanokvm
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Jonathan Siegel
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2025-04-21 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: thor
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - ">="
18
+ - !ruby/object:Gem::Version
19
+ version: '0'
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - ">="
25
+ - !ruby/object:Gem::Version
26
+ version: '0'
27
+ - !ruby/object:Gem::Dependency
28
+ name: json
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - "~>"
32
+ - !ruby/object:Gem::Version
33
+ version: '2.6'
34
+ type: :runtime
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - "~>"
39
+ - !ruby/object:Gem::Version
40
+ version: '2.6'
41
+ - !ruby/object:Gem::Dependency
42
+ name: terminal-table
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - ">="
46
+ - !ruby/object:Gem::Version
47
+ version: '0'
48
+ type: :runtime
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - ">="
53
+ - !ruby/object:Gem::Version
54
+ version: '0'
55
+ - !ruby/object:Gem::Dependency
56
+ name: cgi
57
+ requirement: !ruby/object:Gem::Requirement
58
+ requirements:
59
+ - - "~>"
60
+ - !ruby/object:Gem::Version
61
+ version: '0.3'
62
+ type: :runtime
63
+ prerelease: false
64
+ version_requirements: !ruby/object:Gem::Requirement
65
+ requirements:
66
+ - - "~>"
67
+ - !ruby/object:Gem::Version
68
+ version: '0.3'
69
+ - !ruby/object:Gem::Dependency
70
+ name: bundler
71
+ requirement: !ruby/object:Gem::Requirement
72
+ requirements:
73
+ - - "~>"
74
+ - !ruby/object:Gem::Version
75
+ version: '2.0'
76
+ type: :development
77
+ prerelease: false
78
+ version_requirements: !ruby/object:Gem::Requirement
79
+ requirements:
80
+ - - "~>"
81
+ - !ruby/object:Gem::Version
82
+ version: '2.0'
83
+ - !ruby/object:Gem::Dependency
84
+ name: rake
85
+ requirement: !ruby/object:Gem::Requirement
86
+ requirements:
87
+ - - "~>"
88
+ - !ruby/object:Gem::Version
89
+ version: '13.0'
90
+ type: :development
91
+ prerelease: false
92
+ version_requirements: !ruby/object:Gem::Requirement
93
+ requirements:
94
+ - - "~>"
95
+ - !ruby/object:Gem::Version
96
+ version: '13.0'
97
+ - !ruby/object:Gem::Dependency
98
+ name: rspec
99
+ requirement: !ruby/object:Gem::Requirement
100
+ requirements:
101
+ - - "~>"
102
+ - !ruby/object:Gem::Version
103
+ version: '3.0'
104
+ type: :development
105
+ prerelease: false
106
+ version_requirements: !ruby/object:Gem::Requirement
107
+ requirements:
108
+ - - "~>"
109
+ - !ruby/object:Gem::Version
110
+ version: '3.0'
111
+ description: Ruby client library and CLI for controlling NanoKVM devices via their
112
+ REST API
113
+ email:
114
+ - "<248302+usiegj00@users.noreply.github.com>"
115
+ executables:
116
+ - nanokvm
117
+ extensions: []
118
+ extra_rdoc_files: []
119
+ files:
120
+ - CHANGELOG.md
121
+ - README.md
122
+ - bin/nanokvm
123
+ - lib/nanokvm.rb
124
+ - lib/nanokvm/cli.rb
125
+ - lib/nanokvm/client.rb
126
+ - lib/nanokvm/errors.rb
127
+ - lib/nanokvm/version.rb
128
+ homepage: https://github.com/usiegj00/nanokvm
129
+ licenses:
130
+ - MIT
131
+ metadata:
132
+ homepage_uri: https://github.com/usiegj00/nanokvm
133
+ source_code_uri: https://github.com/usiegj00/nanokvm
134
+ changelog_uri: https://github.com/usiegj00/nanokvm/blob/main/CHANGELOG.md
135
+ post_install_message:
136
+ rdoc_options: []
137
+ require_paths:
138
+ - lib
139
+ required_ruby_version: !ruby/object:Gem::Requirement
140
+ requirements:
141
+ - - ">="
142
+ - !ruby/object:Gem::Version
143
+ version: 2.6.0
144
+ required_rubygems_version: !ruby/object:Gem::Requirement
145
+ requirements:
146
+ - - ">="
147
+ - !ruby/object:Gem::Version
148
+ version: '0'
149
+ requirements: []
150
+ rubygems_version: 3.5.16
151
+ signing_key:
152
+ specification_version: 4
153
+ summary: Ruby client for controlling NanoKVM devices
154
+ test_files: []