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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +24 -0
- data/README.md +314 -0
- data/bin/nanokvm +9 -0
- data/lib/nanokvm/cli.rb +793 -0
- data/lib/nanokvm/client.rb +373 -0
- data/lib/nanokvm/errors.rb +8 -0
- data/lib/nanokvm/version.rb +5 -0
- data/lib/nanokvm.rb +36 -0
- metadata +154 -0
@@ -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
|
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: []
|