xshellz 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/LICENSE +21 -0
- data/README.md +166 -0
- data/lib/xshellz/client.rb +168 -0
- data/lib/xshellz/errors.rb +41 -0
- data/lib/xshellz/keys.rb +93 -0
- data/lib/xshellz/models.rb +56 -0
- data/lib/xshellz/sandbox.rb +270 -0
- data/lib/xshellz/ssh.rb +157 -0
- data/lib/xshellz/version.rb +5 -0
- data/lib/xshellz.rb +22 -0
- metadata +113 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: fae387e6b5028c2064007a18564008e3e9ee33ecf740f38d038d6e9b37c4b707
|
|
4
|
+
data.tar.gz: f26c57baadbe0d628c8e9cd24442b3b846303e9747e2f079995f43f7048eaada
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 1356a6f340d78bc8267dea29fc6712680da93660bc5dd9b42f9119593fcb1d39e9b0b48aaa27130cba6a909e96d9dfbc7fe51867c6f00ff8bce84316a398fb93
|
|
7
|
+
data.tar.gz: 6fd6696f8eb7d23fe0035ae7f0381b0702b6d0dd4d0ce1ce6a31c29871f174450e229ce7b1c37582fdf46f9ce9b2431680cf1782660c15364ce05291194e8f25
|
data/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 xShellz
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
# xshellz
|
|
2
|
+
|
|
3
|
+
Official Ruby SDK for [xShellz](https://xshellz.com) sandboxes: throwaway,
|
|
4
|
+
gVisor-isolated Linux boxes you can spawn and run commands in from your own
|
|
5
|
+
program - in three lines.
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
gem install xshellz
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
```ruby
|
|
12
|
+
require "xshellz"
|
|
13
|
+
|
|
14
|
+
Xshellz::Sandbox.create do |sbx|
|
|
15
|
+
result = sbx.run("ruby -e 'puts 6*7'")
|
|
16
|
+
puts result.stdout # 42
|
|
17
|
+
end
|
|
18
|
+
# the box is destroyed when the block exits
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
Each sandbox is a real Linux box (root shell, package manager, network) running
|
|
22
|
+
under [gVisor](https://gvisor.dev) kernel isolation. Spawning is synchronous -
|
|
23
|
+
`Sandbox.create` returns once the box is running, typically in a few seconds.
|
|
24
|
+
|
|
25
|
+
## Authentication
|
|
26
|
+
|
|
27
|
+
The SDK authenticates with an xShellz personal access token (PAT) carrying the
|
|
28
|
+
`read` and `write` scopes:
|
|
29
|
+
|
|
30
|
+
1. Create a token from your [xShellz dashboard](https://app.xshellz.com)
|
|
31
|
+
(Settings -> API tokens), or via the API: `POST /v1/auth/tokens`.
|
|
32
|
+
2. Export it:
|
|
33
|
+
|
|
34
|
+
```bash
|
|
35
|
+
export XSHELLZ_API_KEY="your-token"
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
or pass it explicitly: `Xshellz::Sandbox.create(api_key: "your-token")`.
|
|
39
|
+
|
|
40
|
+
Config precedence: explicit argument > `XSHELLZ_API_KEY` / `XSHELLZ_API_URL`
|
|
41
|
+
environment variables > default (`https://api.xshellz.com/v1`).
|
|
42
|
+
|
|
43
|
+
To target a staging or self-hosted control plane:
|
|
44
|
+
|
|
45
|
+
```bash
|
|
46
|
+
export XSHELLZ_API_URL="https://api.staging.example.com/v1"
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
## Usage
|
|
50
|
+
|
|
51
|
+
### Run commands
|
|
52
|
+
|
|
53
|
+
```ruby
|
|
54
|
+
Xshellz::Sandbox.create(name: "build-box") do |sbx|
|
|
55
|
+
r = sbx.run("apt-get update && apt-get install -y jq", timeout: 300)
|
|
56
|
+
puts r.exit_code, r.stdout, r.stderr
|
|
57
|
+
|
|
58
|
+
# A non-zero exit code does NOT raise - it's data:
|
|
59
|
+
r = sbx.run("false")
|
|
60
|
+
r.exit_code # => 1
|
|
61
|
+
r.ok? # => false
|
|
62
|
+
|
|
63
|
+
# cwd and env:
|
|
64
|
+
sbx.run("make test", cwd: "/srv/app", env: { "CI" => "1" })
|
|
65
|
+
|
|
66
|
+
# Stream long-running output as it arrives:
|
|
67
|
+
sbx.run("npm run build") do |stream, chunk|
|
|
68
|
+
warn(chunk) if stream == :stderr
|
|
69
|
+
print(chunk) if stream == :stdout
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
### Files (SFTP)
|
|
75
|
+
|
|
76
|
+
```ruby
|
|
77
|
+
sbx.write_file("/tmp/config.json", '{"debug": true}')
|
|
78
|
+
data = sbx.read_file("/tmp/config.json") # => String
|
|
79
|
+
|
|
80
|
+
sbx.upload("local.txt", "/tmp/remote.txt")
|
|
81
|
+
sbx.download("/tmp/remote.txt", "out.txt")
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
### Lifecycle
|
|
85
|
+
|
|
86
|
+
```ruby
|
|
87
|
+
sbx.uuid # sandbox id
|
|
88
|
+
sbx.ssh_host # e.g. "shellus1.xshellz.com"
|
|
89
|
+
sbx.ssh_port # e.g. 42001
|
|
90
|
+
sbx.ssh_command # ready-to-copy "ssh -p 42001 root@..."
|
|
91
|
+
sbx.status # "running", "stopped", ...
|
|
92
|
+
|
|
93
|
+
sbx.detach # keep the box alive after the create block
|
|
94
|
+
sbx.kill # destroy the box explicitly
|
|
95
|
+
sbx.start # resume an idle-stopped box
|
|
96
|
+
|
|
97
|
+
# Non-block form: you own the lifecycle.
|
|
98
|
+
sbx = Xshellz::Sandbox.create
|
|
99
|
+
sbx.run("uname -a")
|
|
100
|
+
sbx.kill
|
|
101
|
+
|
|
102
|
+
# Re-attach later (persist sbx.private_key_openssh + sbx.uuid for this):
|
|
103
|
+
sbx = Xshellz::Sandbox.connect(uuid, private_key: saved_private_key)
|
|
104
|
+
|
|
105
|
+
# Enumerate your sandboxes:
|
|
106
|
+
Xshellz::Sandbox.list.each do |info|
|
|
107
|
+
puts "#{info.uuid} #{info.status}"
|
|
108
|
+
end
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
### Typed errors
|
|
112
|
+
|
|
113
|
+
```ruby
|
|
114
|
+
begin
|
|
115
|
+
sbx = Xshellz::Sandbox.create
|
|
116
|
+
rescue Xshellz::QuotaError
|
|
117
|
+
# plan limit reached - attach to the existing box instead
|
|
118
|
+
existing = Xshellz::Sandbox.list.first
|
|
119
|
+
sbx = Xshellz::Sandbox.connect(existing.uuid, private_key: saved_key)
|
|
120
|
+
rescue Xshellz::AuthError => e
|
|
121
|
+
puts e.message # missing/invalid token, scope, or account verification
|
|
122
|
+
end
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
- `Xshellz::Error` - base class for everything the SDK raises
|
|
126
|
+
- `Xshellz::AuthError` - 401/403: bad or missing token, scopes, account gates
|
|
127
|
+
- `Xshellz::QuotaError` - plan sandbox limit reached / plan has no sandbox entitlement
|
|
128
|
+
- `Xshellz::SandboxNotRunningError` - operation needs a `running` box
|
|
129
|
+
- `Xshellz::CommandTimeoutError` - `run(timeout: ...)` exceeded
|
|
130
|
+
- `Xshellz::ApiError` - any other 4xx/5xx (carries `.status` and `.body`)
|
|
131
|
+
|
|
132
|
+
## How it works
|
|
133
|
+
|
|
134
|
+
- **Control plane**: HTTPS to `api.xshellz.com/v1` (create / list / start /
|
|
135
|
+
destroy), authenticated by your PAT.
|
|
136
|
+
- **Data plane**: SSH directly to the box as `root` (net-ssh), files over
|
|
137
|
+
SFTP (net-sftp). `Sandbox.create` generates an in-memory ed25519 keypair
|
|
138
|
+
per sandbox; the private key never leaves your process and the server never
|
|
139
|
+
sees it - only the public half is installed in the box's `authorized_keys`.
|
|
140
|
+
- **Host keys are auto-accepted** (`verify_host_key: :never`). Sandbox host
|
|
141
|
+
keys are generated at spawn time, so there is no out-of-band fingerprint to
|
|
142
|
+
pin. If your threat model requires host-key verification, connect manually
|
|
143
|
+
with your own SSH tooling using `sbx.ssh_command`.
|
|
144
|
+
|
|
145
|
+
## v0 limits
|
|
146
|
+
|
|
147
|
+
- **Free tier: 1 concurrent sandbox.** A second `Sandbox.create` raises
|
|
148
|
+
`Xshellz::QuotaError` while one exists - use `Sandbox.list` +
|
|
149
|
+
`Sandbox.connect` to attach to the existing box, or `kill` it first. Paid
|
|
150
|
+
plans raise the limit.
|
|
151
|
+
- **Free boxes idle-stop after ~30 minutes.** The box (its `/home` and your
|
|
152
|
+
key) is preserved; call `sbx.start` to resume it.
|
|
153
|
+
- Sandbox creation is throttled to 10 requests/minute per account.
|
|
154
|
+
|
|
155
|
+
## Local development (Docker)
|
|
156
|
+
|
|
157
|
+
No local Ruby toolchain is required - the whole test suite runs inside a
|
|
158
|
+
`ruby:3.3` container (gems cached in a named volume):
|
|
159
|
+
|
|
160
|
+
```bash
|
|
161
|
+
docker compose run --rm test
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
## License
|
|
165
|
+
|
|
166
|
+
MIT
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "net/http"
|
|
5
|
+
require "uri"
|
|
6
|
+
|
|
7
|
+
module Xshellz
|
|
8
|
+
# Default HTTP adapter: one Net::HTTP request per call.
|
|
9
|
+
#
|
|
10
|
+
# Any object responding to +call(method, url, headers, body)+ and returning
|
|
11
|
+
# something with +status+ (Integer) and +body+ (String) can replace it -
|
|
12
|
+
# specs inject a fake so no test ever touches the network.
|
|
13
|
+
class NetHttpAdapter
|
|
14
|
+
def initialize(open_timeout: 10, read_timeout: 120)
|
|
15
|
+
@open_timeout = open_timeout
|
|
16
|
+
@read_timeout = read_timeout
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def call(method, url, headers, body)
|
|
20
|
+
uri = URI.parse(url)
|
|
21
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
22
|
+
http.use_ssl = uri.scheme == "https"
|
|
23
|
+
http.open_timeout = @open_timeout
|
|
24
|
+
http.read_timeout = @read_timeout
|
|
25
|
+
|
|
26
|
+
request = Net::HTTP.const_get(method.capitalize).new(uri.request_uri, headers)
|
|
27
|
+
request.body = body unless body.nil?
|
|
28
|
+
|
|
29
|
+
response = http.request(request)
|
|
30
|
+
Client::Response.new(status: response.code.to_i, body: response.body.to_s)
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Bearer-authenticated JSON client for the xShellz control plane
|
|
35
|
+
# (+https://api.xshellz.com/v1+) with typed error mapping.
|
|
36
|
+
class Client
|
|
37
|
+
Response = Struct.new(:status, :body, keyword_init: true)
|
|
38
|
+
|
|
39
|
+
DEFAULT_API_URL = "https://api.xshellz.com/v1"
|
|
40
|
+
API_KEY_ENV = "XSHELLZ_API_KEY"
|
|
41
|
+
API_URL_ENV = "XSHELLZ_API_URL"
|
|
42
|
+
|
|
43
|
+
# 403 message fragments emitted by the control plane's guard chain. All
|
|
44
|
+
# guards abort(403, message); quota/entitlement are distinguished by
|
|
45
|
+
# message text.
|
|
46
|
+
QUOTA_FRAGMENTS = [
|
|
47
|
+
"agent shell limit", # "You've reached your plan's agent shell limit (N)."
|
|
48
|
+
"plan does not include agent shells" # entitlement gate
|
|
49
|
+
].freeze
|
|
50
|
+
|
|
51
|
+
attr_reader :api_url
|
|
52
|
+
|
|
53
|
+
def initialize(api_key: nil, api_url: nil, http: nil)
|
|
54
|
+
@api_key = self.class.resolve_api_key(api_key)
|
|
55
|
+
@api_url = self.class.resolve_api_url(api_url)
|
|
56
|
+
@http = http || NetHttpAdapter.new
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Resolve the API key (explicit argument > environment) or raise a
|
|
60
|
+
# helpful AuthError.
|
|
61
|
+
def self.resolve_api_key(api_key = nil)
|
|
62
|
+
key = (api_key || ENV[API_KEY_ENV] || "").strip
|
|
63
|
+
return key unless key.empty?
|
|
64
|
+
|
|
65
|
+
raise AuthError,
|
|
66
|
+
"No xShellz API key found. Pass api_key: ... or set the " \
|
|
67
|
+
"#{API_KEY_ENV} environment variable. Create a personal access " \
|
|
68
|
+
"token with `read` and `write` scopes from your xShellz dashboard " \
|
|
69
|
+
"(Settings -> API tokens) or via POST /v1/auth/tokens."
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# Resolve the API base URL (explicit argument > environment > default),
|
|
73
|
+
# with no trailing slash.
|
|
74
|
+
def self.resolve_api_url(api_url = nil)
|
|
75
|
+
url = api_url || ENV[API_URL_ENV] || DEFAULT_API_URL
|
|
76
|
+
url = url.strip
|
|
77
|
+
url = DEFAULT_API_URL if url.empty?
|
|
78
|
+
url.sub(%r{/+\z}, "")
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def get(path)
|
|
82
|
+
request("GET", path)
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def post(path, body = nil)
|
|
86
|
+
request("POST", path, body)
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def delete(path)
|
|
90
|
+
request("DELETE", path)
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# Issue a request; return the parsed JSON body or raise a typed error.
|
|
94
|
+
def request(method, path, body = nil)
|
|
95
|
+
headers = {
|
|
96
|
+
"Authorization" => "Bearer #{@api_key}",
|
|
97
|
+
"Accept" => "application/json",
|
|
98
|
+
"User-Agent" => "xshellz-ruby/#{VERSION}"
|
|
99
|
+
}
|
|
100
|
+
headers["Content-Type"] = "application/json" unless body.nil?
|
|
101
|
+
|
|
102
|
+
response = @http.call(method, "#{@api_url}#{path}", headers, body.nil? ? nil : JSON.generate(body))
|
|
103
|
+
raise map_error(response) if response.status >= 400
|
|
104
|
+
return nil if response.body.nil? || response.body.empty?
|
|
105
|
+
|
|
106
|
+
JSON.parse(response.body)
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
private
|
|
110
|
+
|
|
111
|
+
def map_error(response)
|
|
112
|
+
status = response.status
|
|
113
|
+
body = parse_body(response.body)
|
|
114
|
+
|
|
115
|
+
message = ""
|
|
116
|
+
error_code = ""
|
|
117
|
+
if body.is_a?(Hash)
|
|
118
|
+
message = (body["message"] || "").to_s
|
|
119
|
+
error_code = (body["error"] || "").to_s
|
|
120
|
+
end
|
|
121
|
+
message = response.body.to_s if message.empty?
|
|
122
|
+
message = "HTTP #{status}" if message.empty?
|
|
123
|
+
|
|
124
|
+
case status
|
|
125
|
+
when 401
|
|
126
|
+
AuthError.new(
|
|
127
|
+
"Authentication failed (401): the API key is missing, invalid, " \
|
|
128
|
+
"expired, or revoked. Create a personal access token with `read` " \
|
|
129
|
+
"and `write` scopes from your xShellz dashboard (Settings -> API " \
|
|
130
|
+
"tokens) or via POST /v1/auth/tokens. Server said: #{message}"
|
|
131
|
+
)
|
|
132
|
+
when 403
|
|
133
|
+
map_forbidden(message, error_code)
|
|
134
|
+
when 429
|
|
135
|
+
ApiError.new(
|
|
136
|
+
"Rate limited (429): #{message} - sandbox creation is throttled " \
|
|
137
|
+
"to 10 requests/minute.",
|
|
138
|
+
status: status,
|
|
139
|
+
body: body
|
|
140
|
+
)
|
|
141
|
+
else
|
|
142
|
+
ApiError.new("HTTP #{status}: #{message}", status: status, body: body)
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def map_forbidden(message, error_code)
|
|
147
|
+
lowered = message.downcase
|
|
148
|
+
if QUOTA_FRAGMENTS.any? { |fragment| lowered.include?(fragment) }
|
|
149
|
+
return QuotaError.new(
|
|
150
|
+
"#{message} Tip: on the free tier only one sandbox may exist at a " \
|
|
151
|
+
"time - use Sandbox.list and Sandbox.connect to attach to the " \
|
|
152
|
+
"existing box, or kill it first."
|
|
153
|
+
)
|
|
154
|
+
end
|
|
155
|
+
return AuthError.new("Account verification required (403): #{message}") if error_code == "verification_required"
|
|
156
|
+
|
|
157
|
+
AuthError.new("Forbidden (403): #{message}")
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
def parse_body(raw)
|
|
161
|
+
return nil if raw.nil? || raw.empty?
|
|
162
|
+
|
|
163
|
+
JSON.parse(raw)
|
|
164
|
+
rescue JSON::ParserError
|
|
165
|
+
raw
|
|
166
|
+
end
|
|
167
|
+
end
|
|
168
|
+
end
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Xshellz
|
|
4
|
+
# Base class for every error raised by the xShellz SDK.
|
|
5
|
+
class Error < StandardError; end
|
|
6
|
+
|
|
7
|
+
# Authentication or authorization failed (HTTP 401/403).
|
|
8
|
+
#
|
|
9
|
+
# Raised for a missing/invalid API key, insufficient token scopes, account
|
|
10
|
+
# verification requirements, and other access gates.
|
|
11
|
+
class AuthError < Error; end
|
|
12
|
+
|
|
13
|
+
# The account's sandbox quota or plan entitlement blocks the operation.
|
|
14
|
+
#
|
|
15
|
+
# The control plane returns HTTP 403 both when the plan's concurrent sandbox
|
|
16
|
+
# limit is reached ("agent shell limit") and when the plan does not include
|
|
17
|
+
# sandboxes at all. On the free tier the limit is 1 concurrent box - use
|
|
18
|
+
# +Sandbox.list+ + +Sandbox.connect+ to attach to the existing one instead
|
|
19
|
+
# of creating a new box.
|
|
20
|
+
class QuotaError < Error; end
|
|
21
|
+
|
|
22
|
+
# The sandbox is not in the +running+ state (or no longer exists).
|
|
23
|
+
class SandboxNotRunningError < Error; end
|
|
24
|
+
|
|
25
|
+
# A command executed with +run(timeout: ...)+ exceeded its deadline.
|
|
26
|
+
class CommandTimeoutError < Error; end
|
|
27
|
+
|
|
28
|
+
# Any other non-success API response (4xx/5xx).
|
|
29
|
+
#
|
|
30
|
+
# Carries the HTTP +status+ and the parsed JSON +body+ (or the raw text when
|
|
31
|
+
# the response was not JSON).
|
|
32
|
+
class ApiError < Error
|
|
33
|
+
attr_reader :status, :body
|
|
34
|
+
|
|
35
|
+
def initialize(message, status:, body: nil)
|
|
36
|
+
super(message)
|
|
37
|
+
@status = status
|
|
38
|
+
@body = body
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
data/lib/xshellz/keys.rb
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "ed25519"
|
|
4
|
+
require "securerandom"
|
|
5
|
+
|
|
6
|
+
module Xshellz
|
|
7
|
+
# In-memory ed25519 SSH keypair generation.
|
|
8
|
+
#
|
|
9
|
+
# The keypair is generated per +Sandbox.create+ and never touches disk; the
|
|
10
|
+
# control plane only ever sees the public half. Both halves are encoded by
|
|
11
|
+
# hand (RFC 4253 public line, openssh-key-v1 private container) so the
|
|
12
|
+
# private key can be handed to net-ssh via +key_data+ without any file IO.
|
|
13
|
+
module Keys
|
|
14
|
+
# An in-memory ed25519 keypair.
|
|
15
|
+
#
|
|
16
|
+
# +private_key_openssh+ is the private key in OpenSSH format
|
|
17
|
+
# (-----BEGIN OPENSSH PRIVATE KEY-----). Persist it if you plan to
|
|
18
|
+
# +detach+ and +Sandbox.connect+ again later from another process.
|
|
19
|
+
# +public_key_line+ is the single-line OpenSSH public key
|
|
20
|
+
# (+ssh-ed25519 <base64> <comment>+) sent to the control plane.
|
|
21
|
+
KeyPair = Struct.new(:private_key_openssh, :public_key_line, keyword_init: true)
|
|
22
|
+
|
|
23
|
+
DEFAULT_KEY_COMMENT = "xshellz-sdk"
|
|
24
|
+
AUTH_MAGIC = "openssh-key-v1\0"
|
|
25
|
+
KEY_TYPE = "ssh-ed25519"
|
|
26
|
+
BLOCK_SIZE = 8
|
|
27
|
+
|
|
28
|
+
module_function
|
|
29
|
+
|
|
30
|
+
# Generate a fresh in-memory ed25519 keypair.
|
|
31
|
+
#
|
|
32
|
+
# @return [KeyPair]
|
|
33
|
+
def generate(comment: DEFAULT_KEY_COMMENT)
|
|
34
|
+
signing_key = Ed25519::SigningKey.generate
|
|
35
|
+
seed = signing_key.to_bytes
|
|
36
|
+
public_bytes = signing_key.verify_key.to_bytes
|
|
37
|
+
|
|
38
|
+
line = "#{KEY_TYPE} #{base64(public_key_blob(public_bytes))}"
|
|
39
|
+
line = "#{line} #{comment}" unless comment.to_s.empty?
|
|
40
|
+
|
|
41
|
+
KeyPair.new(
|
|
42
|
+
private_key_openssh: encode_openssh_private_key(seed, public_bytes, comment.to_s),
|
|
43
|
+
public_key_line: line
|
|
44
|
+
)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# The RFC 4253 public key blob: string "ssh-ed25519" + string key-bytes.
|
|
48
|
+
def public_key_blob(public_bytes)
|
|
49
|
+
ssh_string(KEY_TYPE) + ssh_string(public_bytes)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# A length-prefixed SSH wire string.
|
|
53
|
+
def ssh_string(value)
|
|
54
|
+
[value.bytesize].pack("N") + value.dup.force_encoding(Encoding::BINARY)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Serialize the private key into the openssh-key-v1 container (unencrypted:
|
|
58
|
+
# cipher "none", kdf "none") - the format ssh-keygen writes and the one
|
|
59
|
+
# net-ssh's ED25519 support parses from +key_data+.
|
|
60
|
+
def encode_openssh_private_key(seed, public_bytes, comment)
|
|
61
|
+
check = SecureRandom.bytes(4)
|
|
62
|
+
|
|
63
|
+
section = "".b
|
|
64
|
+
section << check << check
|
|
65
|
+
section << ssh_string(KEY_TYPE)
|
|
66
|
+
section << ssh_string(public_bytes)
|
|
67
|
+
section << ssh_string(seed + public_bytes)
|
|
68
|
+
section << ssh_string(comment)
|
|
69
|
+
pad = 1
|
|
70
|
+
until (section.bytesize % BLOCK_SIZE).zero?
|
|
71
|
+
section << [pad].pack("C")
|
|
72
|
+
pad += 1
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
blob = "".b
|
|
76
|
+
blob << AUTH_MAGIC.b
|
|
77
|
+
blob << ssh_string("none") # ciphername
|
|
78
|
+
blob << ssh_string("none") # kdfname
|
|
79
|
+
blob << ssh_string("") # kdfoptions
|
|
80
|
+
blob << [1].pack("N") # number of keys
|
|
81
|
+
blob << ssh_string(public_key_blob(public_bytes))
|
|
82
|
+
blob << ssh_string(section)
|
|
83
|
+
|
|
84
|
+
body = base64(blob).scan(/.{1,70}/).join("\n")
|
|
85
|
+
"-----BEGIN OPENSSH PRIVATE KEY-----\n#{body}\n-----END OPENSSH PRIVATE KEY-----\n"
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# Strict (unwrapped) base64 without pulling in the base64 gem.
|
|
89
|
+
def base64(data)
|
|
90
|
+
[data].pack("m0")
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Xshellz
|
|
4
|
+
# The outcome of a single +Sandbox#run+ invocation.
|
|
5
|
+
#
|
|
6
|
+
# A non-zero +exit_code+ does NOT raise - it is data, exactly like a local
|
|
7
|
+
# subprocess call.
|
|
8
|
+
CommandResult = Struct.new(:stdout, :stderr, :exit_code, keyword_init: true) do
|
|
9
|
+
# True when the command exited with status 0.
|
|
10
|
+
def ok?
|
|
11
|
+
exit_code == 0
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
# A sandbox as reported by the control plane (snake_case wire shape).
|
|
16
|
+
SandboxInfo = Struct.new(
|
|
17
|
+
:uuid,
|
|
18
|
+
:name,
|
|
19
|
+
:status,
|
|
20
|
+
:ssh_command,
|
|
21
|
+
:ssh_host,
|
|
22
|
+
:ssh_port,
|
|
23
|
+
:web_terminal_ready,
|
|
24
|
+
:always_on,
|
|
25
|
+
:trial_hours_remaining,
|
|
26
|
+
:spawned_at,
|
|
27
|
+
:created_at,
|
|
28
|
+
:isolation,
|
|
29
|
+
:gvisor,
|
|
30
|
+
keyword_init: true
|
|
31
|
+
) do
|
|
32
|
+
# Build a +SandboxInfo+ from the API's JSON payload.
|
|
33
|
+
#
|
|
34
|
+
# Tolerant of missing keys so a newer/older API version never breaks
|
|
35
|
+
# deserialization.
|
|
36
|
+
def self.from_api(payload)
|
|
37
|
+
payload ||= {}
|
|
38
|
+
port = payload["ssh_port"]
|
|
39
|
+
new(
|
|
40
|
+
uuid: payload["uuid"].to_s,
|
|
41
|
+
name: payload["name"].to_s,
|
|
42
|
+
status: payload["status"].to_s,
|
|
43
|
+
ssh_command: payload["ssh_command"],
|
|
44
|
+
ssh_host: payload["ssh_host"],
|
|
45
|
+
ssh_port: port.nil? ? nil : Integer(port),
|
|
46
|
+
web_terminal_ready: !!payload["web_terminal_ready"],
|
|
47
|
+
always_on: !!payload["always_on"],
|
|
48
|
+
trial_hours_remaining: (payload["trial_hours_remaining"] || 0.0).to_f,
|
|
49
|
+
spawned_at: payload["spawned_at"],
|
|
50
|
+
created_at: payload["created_at"],
|
|
51
|
+
isolation: payload["isolation"],
|
|
52
|
+
gvisor: !!payload["gvisor"]
|
|
53
|
+
)
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Xshellz
|
|
4
|
+
# A remote sandbox: control plane over HTTPS, data plane over SSH.
|
|
5
|
+
#
|
|
6
|
+
# Create one with +Sandbox.create+, attach to an existing one with
|
|
7
|
+
# +Sandbox.connect+, or enumerate them with +Sandbox.list+.
|
|
8
|
+
#
|
|
9
|
+
# The block form destroys the sandbox when the block exits, unless
|
|
10
|
+
# +detach+ was called:
|
|
11
|
+
#
|
|
12
|
+
# Xshellz::Sandbox.create do |sbx|
|
|
13
|
+
# result = sbx.run("echo hello")
|
|
14
|
+
# end
|
|
15
|
+
class Sandbox
|
|
16
|
+
STATUS_RUNNING = "running"
|
|
17
|
+
|
|
18
|
+
# The last-known control-plane state (a SandboxInfo).
|
|
19
|
+
attr_reader :info
|
|
20
|
+
|
|
21
|
+
# OpenSSH serialization of the private key authenticating this sandbox's
|
|
22
|
+
# SSH (persist it to reconnect later via +Sandbox.connect+).
|
|
23
|
+
attr_reader :private_key_openssh
|
|
24
|
+
|
|
25
|
+
def initialize(info:, client:, private_key_openssh: nil, transport: nil)
|
|
26
|
+
@info = info
|
|
27
|
+
@client = client
|
|
28
|
+
@private_key_openssh = private_key_openssh
|
|
29
|
+
@transport = transport
|
|
30
|
+
@detached = false
|
|
31
|
+
@killed = false
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# ------------------------------------------------------------------ #
|
|
35
|
+
# Constructors (control plane)
|
|
36
|
+
# ------------------------------------------------------------------ #
|
|
37
|
+
|
|
38
|
+
# Spawn a new sandbox and return it once it is RUNNING.
|
|
39
|
+
#
|
|
40
|
+
# An in-memory ed25519 keypair is generated for the box; the private key
|
|
41
|
+
# never leaves this process and the server never sees it. Spawning is
|
|
42
|
+
# synchronous - the box is reachable when this returns.
|
|
43
|
+
#
|
|
44
|
+
# With a block, the sandbox is yielded and destroyed when the block
|
|
45
|
+
# exits (unless +detach+ was called); the block's value is returned.
|
|
46
|
+
#
|
|
47
|
+
# Raises AuthError (missing/invalid API key, insufficient scope, or an
|
|
48
|
+
# account gate), QuotaError (the plan's concurrent sandbox limit is
|
|
49
|
+
# reached or the plan has no sandbox entitlement), or ApiError (other
|
|
50
|
+
# API failures: throttle 429, host capacity 503, ...).
|
|
51
|
+
def self.create(name: nil, api_key: nil, api_url: nil, http: nil)
|
|
52
|
+
keypair = Keys.generate
|
|
53
|
+
client = Client.new(api_key: api_key, api_url: api_url, http: http)
|
|
54
|
+
|
|
55
|
+
body = { "ssh_public_key" => keypair.public_key_line }
|
|
56
|
+
body["name"] = name unless name.nil?
|
|
57
|
+
payload = client.post("/shells/agent", body)
|
|
58
|
+
|
|
59
|
+
sandbox = new(
|
|
60
|
+
info: SandboxInfo.from_api(payload),
|
|
61
|
+
client: client,
|
|
62
|
+
private_key_openssh: keypair.private_key_openssh
|
|
63
|
+
)
|
|
64
|
+
return sandbox unless block_given?
|
|
65
|
+
|
|
66
|
+
begin
|
|
67
|
+
yield sandbox
|
|
68
|
+
ensure
|
|
69
|
+
sandbox.kill unless sandbox.detached?
|
|
70
|
+
sandbox.close
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# Attach to an existing sandbox by UUID.
|
|
75
|
+
#
|
|
76
|
+
# +private_key+ is the OpenSSH-format private key whose public half the
|
|
77
|
+
# box was created with (the +private_key_openssh+ of the original
|
|
78
|
+
# sandbox).
|
|
79
|
+
def self.connect(uuid, private_key:, api_key: nil, api_url: nil, http: nil)
|
|
80
|
+
Ssh.load_private_key!(private_key)
|
|
81
|
+
client = Client.new(api_key: api_key, api_url: api_url, http: http)
|
|
82
|
+
info = find(client, uuid)
|
|
83
|
+
new(info: info, client: client, private_key_openssh: private_key)
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# List the account's active sandboxes (a bare array on the wire).
|
|
87
|
+
#
|
|
88
|
+
# @return [Array<SandboxInfo>]
|
|
89
|
+
def self.list(api_key: nil, api_url: nil, http: nil)
|
|
90
|
+
client = Client.new(api_key: api_key, api_url: api_url, http: http)
|
|
91
|
+
client.get("/shells/agent").map { |item| SandboxInfo.from_api(item) }
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# Resolve one sandbox via the list endpoint (there is no GET show route).
|
|
95
|
+
def self.find(client, uuid)
|
|
96
|
+
client.get("/shells/agent").each do |item|
|
|
97
|
+
info = SandboxInfo.from_api(item)
|
|
98
|
+
return info if info.uuid == uuid
|
|
99
|
+
end
|
|
100
|
+
raise SandboxNotRunningError,
|
|
101
|
+
"Sandbox #{uuid} was not found among this account's active sandboxes."
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# ------------------------------------------------------------------ #
|
|
105
|
+
# Properties
|
|
106
|
+
# ------------------------------------------------------------------ #
|
|
107
|
+
|
|
108
|
+
def uuid
|
|
109
|
+
@info.uuid
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def name
|
|
113
|
+
@info.name
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def status
|
|
117
|
+
@info.status
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def ssh_host
|
|
121
|
+
@info.ssh_host
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def ssh_port
|
|
125
|
+
@info.ssh_port
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def ssh_command
|
|
129
|
+
@info.ssh_command
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def detached?
|
|
133
|
+
@detached
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
# ------------------------------------------------------------------ #
|
|
137
|
+
# Data plane (SSH/SFTP)
|
|
138
|
+
# ------------------------------------------------------------------ #
|
|
139
|
+
|
|
140
|
+
# Run a shell command in the sandbox and wait for it to finish.
|
|
141
|
+
#
|
|
142
|
+
# A non-zero exit code does NOT raise - inspect +result.exit_code+. An
|
|
143
|
+
# optional block receives (:stdout | :stderr, chunk) as output arrives
|
|
144
|
+
# (the full output is still returned in the result).
|
|
145
|
+
#
|
|
146
|
+
# Raises SandboxNotRunningError when the box is not +running+, and
|
|
147
|
+
# CommandTimeoutError when +timeout+ seconds elapse before exit.
|
|
148
|
+
#
|
|
149
|
+
# @return [CommandResult]
|
|
150
|
+
def run(command, cwd: nil, env: nil, timeout: nil, &block)
|
|
151
|
+
transport.exec(Ssh.build_shell_command(command, cwd: cwd, env: env), timeout: timeout, &block)
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
# Write +data+ to +path+ inside the sandbox (SFTP).
|
|
155
|
+
def write_file(path, data)
|
|
156
|
+
transport.write_file(path, data)
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
# Read and return the contents of +path+ inside the sandbox (SFTP).
|
|
160
|
+
def read_file(path)
|
|
161
|
+
transport.read_file(path)
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
# Upload a local file into the sandbox (SFTP).
|
|
165
|
+
def upload(local_path, remote_path)
|
|
166
|
+
transport.upload(local_path, remote_path)
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
# Download a file from the sandbox to a local path (SFTP).
|
|
170
|
+
def download(remote_path, local_path)
|
|
171
|
+
transport.download(remote_path, local_path)
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
# ------------------------------------------------------------------ #
|
|
175
|
+
# Lifecycle (control plane)
|
|
176
|
+
# ------------------------------------------------------------------ #
|
|
177
|
+
|
|
178
|
+
# Resume an idle-stopped box (+POST /shells/agent/{uuid}/start+).
|
|
179
|
+
#
|
|
180
|
+
# Free-tier boxes idle-stop after ~30 minutes; this brings the same box
|
|
181
|
+
# (same /home, same authorized key) back to +running+.
|
|
182
|
+
def start
|
|
183
|
+
begin
|
|
184
|
+
payload = @client.post("/shells/agent/#{uuid}/start")
|
|
185
|
+
rescue ApiError => e
|
|
186
|
+
if e.status == 404
|
|
187
|
+
raise SandboxNotRunningError,
|
|
188
|
+
"Sandbox #{uuid} has no stopped box to start - it may " \
|
|
189
|
+
"already be running, suspended, or deleted."
|
|
190
|
+
end
|
|
191
|
+
raise
|
|
192
|
+
end
|
|
193
|
+
@info = SandboxInfo.from_api(payload)
|
|
194
|
+
close_transport
|
|
195
|
+
self
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
# Destroy the sandbox (+DELETE /shells/agent/{uuid}+). Idempotent: a 404
|
|
199
|
+
# (already gone) is swallowed.
|
|
200
|
+
def kill
|
|
201
|
+
close_transport
|
|
202
|
+
return if @killed
|
|
203
|
+
|
|
204
|
+
begin
|
|
205
|
+
@client.delete("/shells/agent/#{uuid}")
|
|
206
|
+
rescue ApiError => e
|
|
207
|
+
raise unless e.status == 404
|
|
208
|
+
end
|
|
209
|
+
@killed = true
|
|
210
|
+
nil
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
# Keep the sandbox alive when the +create+ block exits.
|
|
214
|
+
#
|
|
215
|
+
# Persist +private_key_openssh+ and +uuid+ to re-attach later with
|
|
216
|
+
# +Sandbox.connect+.
|
|
217
|
+
def detach
|
|
218
|
+
@detached = true
|
|
219
|
+
self
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
# Re-fetch this sandbox's state from the control plane.
|
|
223
|
+
def refresh
|
|
224
|
+
@info = self.class.find(@client, uuid)
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
# Close the SSH connection (keeps the box alive).
|
|
228
|
+
def close
|
|
229
|
+
close_transport
|
|
230
|
+
nil
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
def inspect
|
|
234
|
+
"#<Xshellz::Sandbox uuid=#{uuid.inspect} status=#{status.inspect} ssh=#{ssh_host}:#{ssh_port}>"
|
|
235
|
+
end
|
|
236
|
+
alias to_s inspect
|
|
237
|
+
|
|
238
|
+
private
|
|
239
|
+
|
|
240
|
+
def transport
|
|
241
|
+
return @transport unless @transport.nil?
|
|
242
|
+
|
|
243
|
+
unless status == STATUS_RUNNING
|
|
244
|
+
raise SandboxNotRunningError,
|
|
245
|
+
"Sandbox #{uuid} is #{status.inspect}, not 'running'. " \
|
|
246
|
+
"Call start to resume an idle-stopped box."
|
|
247
|
+
end
|
|
248
|
+
if ssh_host.nil? || ssh_port.nil?
|
|
249
|
+
raise SandboxNotRunningError,
|
|
250
|
+
"Sandbox #{uuid} has no SSH endpoint yet (host/port unknown)."
|
|
251
|
+
end
|
|
252
|
+
if @private_key_openssh.nil?
|
|
253
|
+
raise Error,
|
|
254
|
+
"No private key available for this sandbox - attach with " \
|
|
255
|
+
"Sandbox.connect(uuid, private_key: ...)."
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
@transport = Ssh::NetSshTransport.new(
|
|
259
|
+
host: ssh_host,
|
|
260
|
+
port: ssh_port,
|
|
261
|
+
private_key_openssh: @private_key_openssh
|
|
262
|
+
)
|
|
263
|
+
end
|
|
264
|
+
|
|
265
|
+
def close_transport
|
|
266
|
+
@transport&.close
|
|
267
|
+
@transport = nil
|
|
268
|
+
end
|
|
269
|
+
end
|
|
270
|
+
end
|
data/lib/xshellz/ssh.rb
ADDED
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "net/ssh"
|
|
4
|
+
require "net/sftp"
|
|
5
|
+
require "shellwords"
|
|
6
|
+
require "stringio"
|
|
7
|
+
|
|
8
|
+
module Xshellz
|
|
9
|
+
# SSH/SFTP data plane, behind a small transport interface for testability.
|
|
10
|
+
#
|
|
11
|
+
# The real implementation (NetSshTransport) speaks SSH as +root+ to the
|
|
12
|
+
# sandbox. Host keys are auto-accepted (+verify_host_key: :never+):
|
|
13
|
+
# sandboxes are throwaway boxes whose host keys are generated at spawn
|
|
14
|
+
# time, so there is no out-of-band fingerprint to pin against.
|
|
15
|
+
module Ssh
|
|
16
|
+
ENV_NAME = /\A[A-Za-z_][A-Za-z0-9_]*\z/
|
|
17
|
+
|
|
18
|
+
# Wrap a command with optional +cd+ and environment exports.
|
|
19
|
+
#
|
|
20
|
+
# Environment variable names are validated (sshd rarely honours
|
|
21
|
+
# +AcceptEnv+, so variables are exported in the remote shell instead).
|
|
22
|
+
def self.build_shell_command(command, cwd: nil, env: nil)
|
|
23
|
+
parts = []
|
|
24
|
+
if env && !env.empty?
|
|
25
|
+
exports = env.map do |key, value|
|
|
26
|
+
raise Error, "Invalid environment variable name: #{key.inspect}" unless key.to_s.match?(ENV_NAME)
|
|
27
|
+
|
|
28
|
+
"#{key}=#{Shellwords.escape(value.to_s)}"
|
|
29
|
+
end
|
|
30
|
+
parts << "export #{exports.join(" ")}"
|
|
31
|
+
end
|
|
32
|
+
parts << "cd #{Shellwords.escape(cwd)}" if cwd
|
|
33
|
+
parts << command
|
|
34
|
+
parts.join(" && ")
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Load/validate an OpenSSH-format private key string; raises a helpful
|
|
38
|
+
# Xshellz::Error when it cannot be parsed.
|
|
39
|
+
def self.load_private_key!(private_key)
|
|
40
|
+
Net::SSH::KeyFactory.load_data_private_key(private_key.to_s, nil, false)
|
|
41
|
+
rescue StandardError => e
|
|
42
|
+
raise Error,
|
|
43
|
+
"Could not load the private key. Expected an unencrypted " \
|
|
44
|
+
"OpenSSH-format private key (e.g. Sandbox#private_key_openssh). " \
|
|
45
|
+
"Details: #{e.class}: #{e.message}"
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Real SSH transport: exec over a session channel, files over SFTP.
|
|
49
|
+
#
|
|
50
|
+
# The private key is handed to net-ssh in memory via +key_data+ - it
|
|
51
|
+
# never touches disk. Requires the ed25519 + bcrypt_pbkdf gems (declared
|
|
52
|
+
# as runtime dependencies) for net-ssh to accept ed25519 keys.
|
|
53
|
+
class NetSshTransport
|
|
54
|
+
POLL_INTERVAL = 0.02
|
|
55
|
+
|
|
56
|
+
def initialize(host:, port:, private_key_openssh:, username: "root", connect_timeout: 30)
|
|
57
|
+
@ssh = Net::SSH.start(
|
|
58
|
+
host,
|
|
59
|
+
username,
|
|
60
|
+
port: port,
|
|
61
|
+
key_data: [private_key_openssh],
|
|
62
|
+
keys: [],
|
|
63
|
+
keys_only: true,
|
|
64
|
+
auth_methods: ["publickey"],
|
|
65
|
+
non_interactive: true,
|
|
66
|
+
# Sandbox host keys are minted at spawn time; nothing to pin against.
|
|
67
|
+
verify_host_key: :never,
|
|
68
|
+
timeout: connect_timeout
|
|
69
|
+
)
|
|
70
|
+
@sftp = nil
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# Execute a command; optional block receives (:stdout | :stderr, chunk)
|
|
74
|
+
# as output arrives. Returns a CommandResult; a non-zero exit code does
|
|
75
|
+
# NOT raise.
|
|
76
|
+
def exec(command, timeout: nil, &block)
|
|
77
|
+
stdout = +""
|
|
78
|
+
stderr = +""
|
|
79
|
+
exit_code = nil
|
|
80
|
+
|
|
81
|
+
channel = @ssh.open_channel do |ch|
|
|
82
|
+
ch.exec(command) do |_, success|
|
|
83
|
+
raise Error, "Failed to start command on the sandbox: #{command.inspect}" unless success
|
|
84
|
+
|
|
85
|
+
ch.on_data do |_, data|
|
|
86
|
+
stdout << data
|
|
87
|
+
block&.call(:stdout, data)
|
|
88
|
+
end
|
|
89
|
+
ch.on_extended_data do |_, _type, data|
|
|
90
|
+
stderr << data
|
|
91
|
+
block&.call(:stderr, data)
|
|
92
|
+
end
|
|
93
|
+
ch.on_request("exit-status") do |_, buffer|
|
|
94
|
+
exit_code = buffer.read_long
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
deadline = timeout && (monotonic + timeout)
|
|
100
|
+
timed_out = false
|
|
101
|
+
@ssh.loop(POLL_INTERVAL) do
|
|
102
|
+
timed_out = !deadline.nil? && monotonic > deadline
|
|
103
|
+
channel.active? && !timed_out
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
if timed_out && channel.active?
|
|
107
|
+
close_channel_quietly(channel)
|
|
108
|
+
raise CommandTimeoutError,
|
|
109
|
+
"Command did not finish within #{timeout} seconds: #{command.inspect}"
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
CommandResult.new(stdout: stdout, stderr: stderr, exit_code: exit_code || -1)
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def read_file(path)
|
|
116
|
+
sftp.download!(path)
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def write_file(path, data)
|
|
120
|
+
sftp.upload!(StringIO.new(data), path)
|
|
121
|
+
nil
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def upload(local_path, remote_path)
|
|
125
|
+
sftp.upload!(local_path, remote_path)
|
|
126
|
+
nil
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def download(remote_path, local_path)
|
|
130
|
+
sftp.download!(remote_path, local_path)
|
|
131
|
+
nil
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def close
|
|
135
|
+
@sftp = nil
|
|
136
|
+
@ssh.close unless @ssh.closed?
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
private
|
|
140
|
+
|
|
141
|
+
def sftp
|
|
142
|
+
@sftp ||= Net::SFTP::Session.new(@ssh).connect!
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def monotonic
|
|
146
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
def close_channel_quietly(channel)
|
|
150
|
+
channel.close
|
|
151
|
+
@ssh.loop(POLL_INTERVAL) { channel.active? }
|
|
152
|
+
rescue StandardError
|
|
153
|
+
nil
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
end
|
|
157
|
+
end
|
data/lib/xshellz.rb
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "xshellz/version"
|
|
4
|
+
require_relative "xshellz/errors"
|
|
5
|
+
require_relative "xshellz/models"
|
|
6
|
+
require_relative "xshellz/keys"
|
|
7
|
+
require_relative "xshellz/client"
|
|
8
|
+
require_relative "xshellz/ssh"
|
|
9
|
+
require_relative "xshellz/sandbox"
|
|
10
|
+
|
|
11
|
+
# xShellz Ruby SDK - throwaway, gVisor-isolated Linux sandboxes.
|
|
12
|
+
#
|
|
13
|
+
# Quickstart:
|
|
14
|
+
#
|
|
15
|
+
# require "xshellz"
|
|
16
|
+
#
|
|
17
|
+
# Xshellz::Sandbox.create do |sbx|
|
|
18
|
+
# result = sbx.run("ruby -e 'puts 6*7'")
|
|
19
|
+
# puts result.stdout # "42"
|
|
20
|
+
# end
|
|
21
|
+
module Xshellz
|
|
22
|
+
end
|
metadata
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: xshellz
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- xShellz
|
|
8
|
+
autorequire:
|
|
9
|
+
bindir: bin
|
|
10
|
+
cert_chain: []
|
|
11
|
+
date: 2026-07-17 00:00:00.000000000 Z
|
|
12
|
+
dependencies:
|
|
13
|
+
- !ruby/object:Gem::Dependency
|
|
14
|
+
name: bcrypt_pbkdf
|
|
15
|
+
requirement: !ruby/object:Gem::Requirement
|
|
16
|
+
requirements:
|
|
17
|
+
- - "~>"
|
|
18
|
+
- !ruby/object:Gem::Version
|
|
19
|
+
version: '1.1'
|
|
20
|
+
type: :runtime
|
|
21
|
+
prerelease: false
|
|
22
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
23
|
+
requirements:
|
|
24
|
+
- - "~>"
|
|
25
|
+
- !ruby/object:Gem::Version
|
|
26
|
+
version: '1.1'
|
|
27
|
+
- !ruby/object:Gem::Dependency
|
|
28
|
+
name: ed25519
|
|
29
|
+
requirement: !ruby/object:Gem::Requirement
|
|
30
|
+
requirements:
|
|
31
|
+
- - "~>"
|
|
32
|
+
- !ruby/object:Gem::Version
|
|
33
|
+
version: '1.3'
|
|
34
|
+
type: :runtime
|
|
35
|
+
prerelease: false
|
|
36
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
37
|
+
requirements:
|
|
38
|
+
- - "~>"
|
|
39
|
+
- !ruby/object:Gem::Version
|
|
40
|
+
version: '1.3'
|
|
41
|
+
- !ruby/object:Gem::Dependency
|
|
42
|
+
name: net-sftp
|
|
43
|
+
requirement: !ruby/object:Gem::Requirement
|
|
44
|
+
requirements:
|
|
45
|
+
- - "~>"
|
|
46
|
+
- !ruby/object:Gem::Version
|
|
47
|
+
version: '4.0'
|
|
48
|
+
type: :runtime
|
|
49
|
+
prerelease: false
|
|
50
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
51
|
+
requirements:
|
|
52
|
+
- - "~>"
|
|
53
|
+
- !ruby/object:Gem::Version
|
|
54
|
+
version: '4.0'
|
|
55
|
+
- !ruby/object:Gem::Dependency
|
|
56
|
+
name: net-ssh
|
|
57
|
+
requirement: !ruby/object:Gem::Requirement
|
|
58
|
+
requirements:
|
|
59
|
+
- - "~>"
|
|
60
|
+
- !ruby/object:Gem::Version
|
|
61
|
+
version: '7.0'
|
|
62
|
+
type: :runtime
|
|
63
|
+
prerelease: false
|
|
64
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
65
|
+
requirements:
|
|
66
|
+
- - "~>"
|
|
67
|
+
- !ruby/object:Gem::Version
|
|
68
|
+
version: '7.0'
|
|
69
|
+
description: Spawn throwaway, gVisor-isolated Linux sandboxes and run commands in
|
|
70
|
+
them from your own program - control plane over HTTPS, data plane over SSH/SFTP.
|
|
71
|
+
email:
|
|
72
|
+
executables: []
|
|
73
|
+
extensions: []
|
|
74
|
+
extra_rdoc_files: []
|
|
75
|
+
files:
|
|
76
|
+
- LICENSE
|
|
77
|
+
- README.md
|
|
78
|
+
- lib/xshellz.rb
|
|
79
|
+
- lib/xshellz/client.rb
|
|
80
|
+
- lib/xshellz/errors.rb
|
|
81
|
+
- lib/xshellz/keys.rb
|
|
82
|
+
- lib/xshellz/models.rb
|
|
83
|
+
- lib/xshellz/sandbox.rb
|
|
84
|
+
- lib/xshellz/ssh.rb
|
|
85
|
+
- lib/xshellz/version.rb
|
|
86
|
+
homepage: https://github.com/xshellz/xshellz-ruby
|
|
87
|
+
licenses:
|
|
88
|
+
- MIT
|
|
89
|
+
metadata:
|
|
90
|
+
homepage_uri: https://github.com/xshellz/xshellz-ruby
|
|
91
|
+
source_code_uri: https://github.com/xshellz/xshellz-ruby
|
|
92
|
+
changelog_uri: https://github.com/xshellz/xshellz-ruby/releases
|
|
93
|
+
rubygems_mfa_required: 'true'
|
|
94
|
+
post_install_message:
|
|
95
|
+
rdoc_options: []
|
|
96
|
+
require_paths:
|
|
97
|
+
- lib
|
|
98
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
99
|
+
requirements:
|
|
100
|
+
- - ">="
|
|
101
|
+
- !ruby/object:Gem::Version
|
|
102
|
+
version: '3.1'
|
|
103
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
104
|
+
requirements:
|
|
105
|
+
- - ">="
|
|
106
|
+
- !ruby/object:Gem::Version
|
|
107
|
+
version: '0'
|
|
108
|
+
requirements: []
|
|
109
|
+
rubygems_version: 3.5.22
|
|
110
|
+
signing_key:
|
|
111
|
+
specification_version: 4
|
|
112
|
+
summary: Official Ruby SDK for xShellz sandboxes.
|
|
113
|
+
test_files: []
|