aspera-cli 4.27.5 → 4.28.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 +4 -4
- checksums.yaml.gz.sig +0 -0
- data/CHANGELOG.md +160 -4
- data/CONTRIBUTING.md +4 -2
- data/README.md +1 -1
- data/bin/asession +1 -1
- data/docs/README.md +1086 -331
- data/docs/test-mcp-with-ai.md +5 -3
- data/lib/aspera/agent/direct.rb +15 -4
- data/lib/aspera/agent/factory.rb +2 -1
- data/lib/aspera/agent/node.rb +1 -1
- data/lib/aspera/api/cos_node.rb +1 -1
- data/lib/aspera/api/faspex.rb +1 -1
- data/lib/aspera/api/node.rb +6 -2
- data/lib/aspera/ascp/installation.rb +47 -11
- data/lib/aspera/ascp/management.rb +89 -43
- data/lib/aspera/cli/ascp_actions.rb +5 -19
- data/lib/aspera/cli/bootstrapper.rb +1 -2
- data/lib/aspera/cli/command_registry.rb +43 -3
- data/lib/aspera/cli/command_spec.rb +3 -0
- data/lib/aspera/cli/completion/ascli.bash +39 -5
- data/lib/aspera/cli/completion/ascli.fish +16 -8
- data/lib/aspera/cli/completion/ascli.ps1 +41 -0
- data/lib/aspera/cli/completion/ascli.zsh +20 -4
- data/lib/aspera/cli/completion_actions.rb +360 -0
- data/lib/aspera/cli/error.rb +42 -0
- data/lib/aspera/cli/extended_value.rb +30 -5
- data/lib/aspera/cli/formatter.rb +118 -56
- data/lib/aspera/cli/hints.rb +0 -1
- data/lib/aspera/cli/http.rb +6 -5
- data/lib/aspera/cli/info.rb +10 -4
- data/lib/aspera/cli/mailer.rb +1 -1
- data/lib/aspera/cli/mcp/http_servlet.rb +156 -0
- data/lib/aspera/cli/mcp/secrets.rb +143 -0
- data/lib/aspera/cli/mcp/tool.rb +231 -0
- data/lib/aspera/cli/option_value.rb +9 -1
- data/lib/aspera/cli/options.schema.yaml +390 -39
- data/lib/aspera/cli/parser.rb +14 -5
- data/lib/aspera/cli/plugins/aoc.rb +8 -11
- data/lib/aspera/cli/plugins/base.rb +36 -15
- data/lib/aspera/cli/plugins/config.rb +15 -44
- data/lib/aspera/cli/plugins/console.rb +3 -1
- data/lib/aspera/cli/plugins/factory.rb +1 -1
- data/lib/aspera/cli/plugins/faspex5.rb +9 -19
- data/lib/aspera/cli/plugins/faspio.rb +36 -5
- data/lib/aspera/cli/plugins/httpgw.rb +1 -0
- data/lib/aspera/cli/plugins/mcp.rb +177 -118
- data/lib/aspera/cli/plugins/node.rb +14 -8
- data/lib/aspera/cli/plugins/orchestrator.rb +351 -38
- data/lib/aspera/cli/plugins/server.rb +3 -1
- data/lib/aspera/cli/plugins/shares.rb +205 -35
- data/lib/aspera/cli/preset_actions.rb +15 -7
- data/lib/aspera/cli/preset_manager.rb +24 -2
- data/lib/aspera/cli/prompt.rb +3 -1
- data/lib/aspera/cli/result.rb +17 -4
- data/lib/aspera/cli/runner.rb +133 -43
- data/lib/aspera/cli/security.rb +134 -0
- data/lib/aspera/cli/table_fitter.rb +116 -0
- data/lib/aspera/cli/terminal_formatter.rb +23 -34
- data/lib/aspera/cli/transfer_agent.rb +4 -1
- data/lib/aspera/cli/vault_manager.rb +6 -5
- data/lib/aspera/cli/version.rb +1 -1
- data/lib/aspera/cli/web_secret.html.erb +61 -0
- data/lib/aspera/cli/web_secret.rb +220 -0
- data/lib/aspera/cli/wizard.rb +19 -9
- data/lib/aspera/command_line_builder.rb +26 -0
- data/lib/aspera/data_repository.rb +38 -24
- data/lib/aspera/duration.rb +42 -0
- data/lib/aspera/environment.rb +88 -24
- data/lib/aspera/exec_spec.rb +6 -5
- data/lib/aspera/faspex_gw.rb +98 -32
- data/lib/aspera/faspex_postproc.rb +187 -44
- data/lib/aspera/formatter_interface.rb +0 -1
- data/lib/aspera/id_generator.rb +2 -1
- data/lib/aspera/json_rpc/client.rb +51 -32
- data/lib/aspera/json_rpc/version.rb +1 -0
- data/lib/aspera/keychain/base.rb +5 -0
- data/lib/aspera/keychain/encrypted_hash.rb +6 -2
- data/lib/aspera/keychain/factory.rb +2 -3
- data/lib/aspera/keychain/hashicorp_vault.rb +7 -4
- data/lib/aspera/keychain/macos_security.rb +119 -13
- data/lib/aspera/keychain/one_password_api.rb +8 -3
- data/lib/aspera/keychain/one_password_base.rb +14 -9
- data/lib/aspera/keychain/one_password_cli.rb +4 -2
- data/lib/aspera/log.rb +3 -6
- data/lib/aspera/markdown.rb +152 -115
- data/lib/aspera/mime.rb +6 -0
- data/lib/aspera/nagios.rb +3 -1
- data/lib/aspera/node_emulator.rb +98 -101
- data/lib/aspera/oauth/{base.rb → client/base.rb} +5 -4
- data/lib/aspera/oauth/{boot.rb → client/boot.rb} +3 -2
- data/lib/aspera/oauth/{factory.rb → client/factory.rb} +7 -0
- data/lib/aspera/oauth/{generic.rb → client/generic.rb} +1 -1
- data/lib/aspera/oauth/{json_credentials.rb → client/json_credentials.rb} +1 -1
- data/lib/aspera/oauth/{jwt.rb → client/jwt.rb} +1 -1
- data/lib/aspera/oauth/{url_json.rb → client/url_json.rb} +1 -1
- data/lib/aspera/oauth/{web.rb → client/web.rb} +2 -2
- data/lib/aspera/oauth/server/authorize.html.erb +34 -0
- data/lib/aspera/oauth/server/pkce.rb +426 -0
- data/lib/aspera/orchestrator_workflow_checker.rb +561 -0
- data/lib/aspera/preview/terminal.rb +3 -2
- data/lib/aspera/rainbow.rb +27 -1
- data/lib/aspera/rest/aspera_errors.rb +51 -59
- data/lib/aspera/rest/client.rb +21 -14
- data/lib/aspera/rest/error_analyzer.rb +9 -0
- data/lib/aspera/rest/list.rb +6 -6
- data/lib/aspera/rest/util.rb +7 -1
- data/lib/aspera/rest.rb +0 -1
- data/lib/aspera/schema/IBM Aspera Faspex API-5.0-enhanced.yaml +0 -2
- data/lib/aspera/schema/IBM Aspera Orchestrator API-v1-enhanced.yaml +4001 -0
- data/lib/aspera/schema/IBM Aspera faspio Gateway API-1.0.0-enhanced.yaml +1097 -0
- data/lib/aspera/schema/IBM Aspera on Cloud Automation API-1.0.5-enhanced.yaml +35 -35
- data/lib/aspera/schema/IBM_Aspera_Shares.yaml +819 -7
- data/lib/aspera/schema/documentation.rb +50 -78
- data/lib/aspera/schema/reader.rb +39 -11
- data/lib/aspera/schema/registry.rb +8 -2
- data/lib/aspera/secret_hider.rb +94 -15
- data/lib/aspera/secret_strength.rb +114 -0
- data/lib/aspera/servlet_helpers.rb +111 -0
- data/lib/aspera/sync/args.schema.yaml +3 -1
- data/lib/aspera/sync/conf.schema.yaml +16 -6
- data/lib/aspera/sync/operations.rb +21 -1
- data/lib/aspera/temp_file_manager.rb +79 -20
- data/lib/aspera/transfer/parameters.rb +5 -0
- data/lib/aspera/transfer/spec.rb +52 -12
- data/lib/aspera/transfer/spec.schema.yaml +8 -5
- data/lib/aspera/web_auth/index.html.erb +16 -0
- data/lib/aspera/web_auth/server.rb +18 -10
- data/lib/aspera/web_page.html.erb +262 -0
- data/lib/aspera/web_page.rb +61 -0
- data/lib/aspera/web_server_simple.rb +76 -10
- data.tar.gz.sig +0 -0
- metadata +47 -32
- metadata.gz.sig +0 -0
- data/lib/aspera/cli/mcp_tool.rb +0 -189
- data/lib/aspera/oauth.rb +0 -9
- data/lib/aspera/schema/IBM Aspera Orchestrator API-v1.yaml +0 -1784
- data/lib/aspera/schema/IBM Aspera faspio Gateway API-1.0.0.yaml +0 -249
- data/lib/aspera/web_auth/index.html +0 -147
|
@@ -33,11 +33,12 @@ module Aspera
|
|
|
33
33
|
|
|
34
34
|
# @return [String] value from vault matching <name>.<param>
|
|
35
35
|
def vault_value(name)
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
36
|
+
# Label may contain dots (e.g. preset name)
|
|
37
|
+
label, _, param = name.rpartition('.')
|
|
38
|
+
Aspera.assert(!label.empty? && !param.empty?, type: BadArgument) { 'vault name shall match <name>.<param>' }
|
|
39
|
+
info = vault_required.get(label: label)
|
|
40
|
+
value = info[param.to_sym]
|
|
41
|
+
raise "no such entry value: #{param}" if value.nil?
|
|
41
42
|
return value
|
|
42
43
|
end
|
|
43
44
|
|
data/lib/aspera/cli/version.rb
CHANGED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
<%# Content of the secret page, in layout of Aspera::WebPage %>
|
|
2
|
+
<% if state.eql?(:form) %>
|
|
3
|
+
<div class="icon-circle">
|
|
4
|
+
<svg xmlns="http://www.w3.org/2000/svg" width="28" height="28" viewBox="0 0 32 32" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
|
|
5
|
+
<rect x="6" y="13" width="20" height="15" rx="3"/>
|
|
6
|
+
<path d="M10 13V9a6 6 0 0 1 12 0v4"/>
|
|
7
|
+
<circle cx="16" cy="20.5" r="2" fill="currentColor"/>
|
|
8
|
+
</svg>
|
|
9
|
+
</div>
|
|
10
|
+
|
|
11
|
+
<h1>Secret request</h1>
|
|
12
|
+
<% if requester %>
|
|
13
|
+
<p><%= h name %> needs the secret <span class="client"><%= h prompt %></span> to execute a command requested by the <%= h requester %>.</p>
|
|
14
|
+
<p>The secret is sent to <%= h name %> only: the <%= h requester %> does not see it.</p>
|
|
15
|
+
<% else %>
|
|
16
|
+
<p><%= h name %> needs the secret <span class="client"><%= h prompt %></span> to execute your command.</p>
|
|
17
|
+
<% end %>
|
|
18
|
+
<% if error %>
|
|
19
|
+
<p class="error"><%= h error %></p>
|
|
20
|
+
<% end %>
|
|
21
|
+
|
|
22
|
+
<form method="POST" action="<%= h action_url %>">
|
|
23
|
+
<% if access_code_required %>
|
|
24
|
+
<label class="secret">Server access code
|
|
25
|
+
<input type="password" name="access_code" autocomplete="off" required autofocus>
|
|
26
|
+
</label>
|
|
27
|
+
<% end %>
|
|
28
|
+
<label class="secret">Secret: <%= h prompt %>
|
|
29
|
+
<input type="password" name="secret" autocomplete="off" required<%= ' autofocus' unless access_code_required %>>
|
|
30
|
+
</label>
|
|
31
|
+
<div class="actions">
|
|
32
|
+
<button class="allow" type="submit" name="decision" value="submit">Submit</button>
|
|
33
|
+
<button class="deny" type="submit" name="decision" value="cancel" formnovalidate>Cancel</button>
|
|
34
|
+
</div>
|
|
35
|
+
</form>
|
|
36
|
+
<% elsif state.eql?(:received) %>
|
|
37
|
+
<div class="icon-circle ok">
|
|
38
|
+
<svg xmlns="http://www.w3.org/2000/svg" width="30" height="30" viewBox="0 0 32 32" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round">
|
|
39
|
+
<polyline points="6 16 13 23 26 9"/>
|
|
40
|
+
</svg>
|
|
41
|
+
</div>
|
|
42
|
+
|
|
43
|
+
<h1>Secret received</h1>
|
|
44
|
+
<p>You can close this window and return to your <%= h(requester || 'terminal') %>.</p>
|
|
45
|
+
<% else %>
|
|
46
|
+
<div class="icon-circle ko">
|
|
47
|
+
<svg xmlns="http://www.w3.org/2000/svg" width="28" height="28" viewBox="0 0 32 32" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round">
|
|
48
|
+
<line x1="9" y1="9" x2="23" y2="23"/>
|
|
49
|
+
<line x1="23" y1="9" x2="9" y2="23"/>
|
|
50
|
+
</svg>
|
|
51
|
+
</div>
|
|
52
|
+
|
|
53
|
+
<% if state.eql?(:cancelled) %>
|
|
54
|
+
<h1>Secret request cancelled</h1>
|
|
55
|
+
<p>The command will fail. You can close this window and return to your <%= h(requester || 'terminal') %>.</p>
|
|
56
|
+
<% else %>
|
|
57
|
+
<h1>Unknown secret request</h1>
|
|
58
|
+
<p>This request is unknown, expired, or already answered.</p>
|
|
59
|
+
<% end %>
|
|
60
|
+
<% end %>
|
|
61
|
+
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# cspell:ignore urlsafe
|
|
4
|
+
|
|
5
|
+
require 'aspera/log'
|
|
6
|
+
require 'aspera/assert'
|
|
7
|
+
require 'aspera/environment'
|
|
8
|
+
require 'aspera/web_page'
|
|
9
|
+
require 'aspera/servlet_helpers'
|
|
10
|
+
require 'aspera/cli/error'
|
|
11
|
+
require 'aspera/cli/info'
|
|
12
|
+
require 'openssl'
|
|
13
|
+
require 'securerandom'
|
|
14
|
+
|
|
15
|
+
module Aspera
|
|
16
|
+
module Cli
|
|
17
|
+
# Secrets entered by the user on a web page, instead of the terminal.
|
|
18
|
+
# Used by extended value `@secret:` when option `ui` is `graphical`, and in MCP server mode (see Mcp::Secrets).
|
|
19
|
+
#
|
|
20
|
+
# Requests are kept in memory, identified by a random value in the URL of the page, and expire after TTL seconds.
|
|
21
|
+
# Pages are served by a local web server, started on first request, or by the web server of the caller (see #handle).
|
|
22
|
+
class WebSecret
|
|
23
|
+
# Lifetime of a request in seconds: the user shall enter the secret within this time
|
|
24
|
+
TTL = 600
|
|
25
|
+
# Path of the page, followed by the request identifier
|
|
26
|
+
PATH_PREFIX = '/secret/'
|
|
27
|
+
# Interval in seconds between calls to the block while waiting for the user
|
|
28
|
+
WAIT_SLICE = 10
|
|
29
|
+
# Address of the local web server
|
|
30
|
+
LOCAL_ADDRESS = '127.0.0.1'
|
|
31
|
+
# Maximum time in seconds to wait for the local web server to send the last page on shutdown
|
|
32
|
+
SHUTDOWN_TIMEOUT = 5
|
|
33
|
+
# ERB template of the page
|
|
34
|
+
HTML_TEMPLATE_PATH = File.join(__dir__, 'web_secret.html.erb')
|
|
35
|
+
# A request for a secret, status is one of: :pending, :received, :cancelled
|
|
36
|
+
Request = Struct.new(:prompt, :status, :value, :expires_at, keyword_init: true)
|
|
37
|
+
private_constant :WAIT_SLICE, :LOCAL_ADDRESS, :SHUTDOWN_TIMEOUT, :HTML_TEMPLATE_PATH, :Request
|
|
38
|
+
|
|
39
|
+
class << self
|
|
40
|
+
# Ask for a secret on a web page, opened in the browser
|
|
41
|
+
# @param prompt [String] Name of the secret
|
|
42
|
+
# @return [String] The secret
|
|
43
|
+
def ask(prompt)
|
|
44
|
+
web_secret = new
|
|
45
|
+
id = web_secret.create(prompt)
|
|
46
|
+
Environment.instance.open_uri(web_secret.url(id))
|
|
47
|
+
web_secret.wait_value(id)
|
|
48
|
+
ensure
|
|
49
|
+
web_secret&.shutdown
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# @param base_url [String, nil] URL of the web server serving the pages (see #handle), `nil` to start a local web server on first request
|
|
54
|
+
# @param access_code [String, nil] If set, the user must also enter it on the page
|
|
55
|
+
# @param name [String] Name of the application needing the secret, shown on the page
|
|
56
|
+
# @param requester [String, nil] Who requested the command, shown on the page, e.g. `AI assistant`, `nil` for the user on terminal
|
|
57
|
+
def initialize(base_url: nil, access_code: nil, name: Info::CMD_NAME, requester: nil)
|
|
58
|
+
Aspera.assert(access_code.nil? || !access_code.to_s.empty?) { 'access_code must not be empty' }
|
|
59
|
+
@base_url = base_url
|
|
60
|
+
@access_code = access_code&.to_s
|
|
61
|
+
@name = name
|
|
62
|
+
@requester = requester
|
|
63
|
+
@mutex = Mutex.new
|
|
64
|
+
@cond = ConditionVariable.new
|
|
65
|
+
# id => Request
|
|
66
|
+
@requests = {}
|
|
67
|
+
@local_server = nil
|
|
68
|
+
@local_thread = nil
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# @param prompt [String] Name of the secret, shown to the user
|
|
72
|
+
# @return [String] Identifier of the new request
|
|
73
|
+
def create(prompt)
|
|
74
|
+
id = SecureRandom.urlsafe_base64(24)
|
|
75
|
+
@mutex.synchronize do
|
|
76
|
+
purge_expired
|
|
77
|
+
@requests[id] = Request.new(prompt: prompt, status: :pending, expires_at: Time.now + TTL)
|
|
78
|
+
end
|
|
79
|
+
id
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# @param id [String] Identifier of the request
|
|
83
|
+
# @return [String] URL of the page where the user enters the secret
|
|
84
|
+
def url(id)
|
|
85
|
+
"#{base_url}#{PATH_PREFIX}#{id}"
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# @param id [String] Identifier of the request
|
|
89
|
+
# @return [String, nil] Prompt of the request, `nil` if unknown or expired
|
|
90
|
+
def prompt(id)
|
|
91
|
+
@mutex.synchronize { live_request(id)&.prompt }
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# Wait until the user enters the secret, or cancels, on the page
|
|
95
|
+
# @param id [String] Identifier of the request
|
|
96
|
+
# @yield [elapsed] Called periodically while waiting, with elapsed seconds
|
|
97
|
+
# @return [String] The secret
|
|
98
|
+
# @raise [Cli::Error] if the request is unknown, expired, or cancelled by the user
|
|
99
|
+
def wait_value(id)
|
|
100
|
+
start = Time.now
|
|
101
|
+
loop do
|
|
102
|
+
@mutex.synchronize do
|
|
103
|
+
request = live_request(id)
|
|
104
|
+
raise Error, 'Secret request expired: execute the command again' if request.nil?
|
|
105
|
+
raise Error, 'Secret entry cancelled by user' if request.status.eql?(:cancelled)
|
|
106
|
+
return request.value if request.status.eql?(:received)
|
|
107
|
+
@cond.wait(@mutex, (request.expires_at - Time.now).clamp(0, WAIT_SLICE))
|
|
108
|
+
end
|
|
109
|
+
yield(Time.now - start) if block_given?
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# Forget requests, and their secrets
|
|
114
|
+
# @param ids [Array<String>] Identifiers of requests
|
|
115
|
+
def delete(ids)
|
|
116
|
+
@mutex.synchronize { ids.each { |id| @requests.delete(id) } }
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# Stop the local web server, if started, after it sends the last page to the browser
|
|
120
|
+
def shutdown
|
|
121
|
+
return if @local_server.nil?
|
|
122
|
+
@local_server.shutdown
|
|
123
|
+
@local_thread.join(SHUTDOWN_TIMEOUT)
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
# Serve the page: GET shows the form, POST receives the secret or the cancellation
|
|
127
|
+
# @param req [WEBrick::HTTPRequest]
|
|
128
|
+
# @param res [WEBrick::HTTPResponse]
|
|
129
|
+
def handle(req, res)
|
|
130
|
+
require 'webrick'
|
|
131
|
+
id = req.path.delete_prefix(PATH_PREFIX)
|
|
132
|
+
status, page = @mutex.synchronize do
|
|
133
|
+
request = live_request(id)
|
|
134
|
+
if request.nil? || !request.status.eql?(:pending)
|
|
135
|
+
[404, {state: :unknown}]
|
|
136
|
+
elsif req.request_method.eql?('GET')
|
|
137
|
+
[200, {state: :form, prompt: request.prompt}]
|
|
138
|
+
elsif req.request_method.eql?('POST')
|
|
139
|
+
answer(request, WEBrick::HTTPUtils.parse_query(req.body.to_s))
|
|
140
|
+
else
|
|
141
|
+
[405, {state: :unknown}]
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
res.status = status
|
|
145
|
+
WebPage.secure_headers(res)
|
|
146
|
+
# Variables of the template, state is one of: :form, :received, :cancelled, :unknown
|
|
147
|
+
res.body = WebPage.render(
|
|
148
|
+
HTML_TEMPLATE_PATH,
|
|
149
|
+
title: 'Secret request',
|
|
150
|
+
name: @name,
|
|
151
|
+
requester: @requester,
|
|
152
|
+
action_url: "#{PATH_PREFIX}#{id}",
|
|
153
|
+
access_code_required: !@access_code.nil?,
|
|
154
|
+
prompt: nil,
|
|
155
|
+
error: nil,
|
|
156
|
+
**page
|
|
157
|
+
)
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
private
|
|
161
|
+
|
|
162
|
+
# Record the answer of the user, called with the mutex locked
|
|
163
|
+
# @param request [Request] The pending request
|
|
164
|
+
# @param params [Hash] Fields of the form
|
|
165
|
+
# @return [Array] HTTP status and attributes of the page
|
|
166
|
+
def answer(request, params)
|
|
167
|
+
if params['decision'].eql?('cancel')
|
|
168
|
+
request.status = :cancelled
|
|
169
|
+
elsif @access_code && !OpenSSL.secure_compare(params['access_code'].to_s, @access_code)
|
|
170
|
+
Log.log.warn('Secret page: wrong access code')
|
|
171
|
+
return [401, {state: :form, prompt: request.prompt, error: 'Invalid access code'}]
|
|
172
|
+
else
|
|
173
|
+
# Form data is binary, the page is UTF-8
|
|
174
|
+
request.value = params['secret'].to_s.dup.force_encoding(Encoding::UTF_8)
|
|
175
|
+
request.status = :received
|
|
176
|
+
end
|
|
177
|
+
@cond.broadcast
|
|
178
|
+
[200, {state: request.status}]
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
# Called with the mutex locked
|
|
182
|
+
# @return [Request, nil] The request, `nil` if unknown or expired
|
|
183
|
+
def live_request(id)
|
|
184
|
+
request = @requests[id]
|
|
185
|
+
request unless request.nil? || request.expires_at < Time.now
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
def purge_expired
|
|
189
|
+
now = Time.now
|
|
190
|
+
@requests.delete_if { |_, request| request.expires_at < now }
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
# @return [String] URL of the server, for the browser
|
|
194
|
+
def base_url
|
|
195
|
+
@mutex.synchronize { @base_url ||= start_local_server }
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
# Start a local web server for the pages
|
|
199
|
+
# @return [String] URL of the local server
|
|
200
|
+
def start_local_server
|
|
201
|
+
require 'aspera/web_server_simple'
|
|
202
|
+
servlet = Class.new(WEBrick::HTTPServlet::AbstractServlet) do
|
|
203
|
+
include ServletHelpers
|
|
204
|
+
|
|
205
|
+
# Refuse requests of other web pages, the WebSecret instance is the option of the servlet
|
|
206
|
+
def service(req, res)
|
|
207
|
+
check_request(req)
|
|
208
|
+
@options.first.handle(req, res)
|
|
209
|
+
end
|
|
210
|
+
end
|
|
211
|
+
# Port 0: any free port
|
|
212
|
+
@local_server = WebServerSimple.new(URI::HTTP.build(host: LOCAL_ADDRESS, port: 0))
|
|
213
|
+
@local_server.mount(PATH_PREFIX, servlet, self)
|
|
214
|
+
# In a thread: signals are not trapped by the server
|
|
215
|
+
@local_thread = Thread.new { @local_server.start }
|
|
216
|
+
@local_server.uri.to_s
|
|
217
|
+
end
|
|
218
|
+
end
|
|
219
|
+
end
|
|
220
|
+
end
|
data/lib/aspera/cli/wizard.rb
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require 'aspera/oauth/jwt'
|
|
3
|
+
require 'aspera/oauth/client/jwt'
|
|
4
4
|
require 'aspera/assert'
|
|
5
|
+
require 'aspera/environment'
|
|
6
|
+
require 'aspera/id_generator'
|
|
5
7
|
require 'aspera/cli/option_declarator'
|
|
6
8
|
require 'aspera/cli/plugins/factory'
|
|
7
9
|
require 'aspera/rainbow'
|
|
@@ -13,7 +15,7 @@ module Aspera
|
|
|
13
15
|
class Wizard
|
|
14
16
|
extend OptionDeclarator
|
|
15
17
|
|
|
16
|
-
WIZARD_RESULT_KEYS = %i[preset_value test_args].freeze
|
|
18
|
+
WIZARD_RESULT_KEYS = %i[preset_ids preset_value test_args].freeze
|
|
17
19
|
DEFAULT_PRIV_KEY_FILENAME = 'my_private_key.pem' # pragma: allowlist secret
|
|
18
20
|
private_constant :WIZARD_RESULT_KEYS, :DEFAULT_PRIV_KEY_FILENAME
|
|
19
21
|
|
|
@@ -84,10 +86,18 @@ module Aspera
|
|
|
84
86
|
return found_apps
|
|
85
87
|
end
|
|
86
88
|
|
|
89
|
+
# Display the path to a page of the web UI
|
|
90
|
+
# @param steps [Array<String>] Menus and pages, from the top (e.g. `Environment.char('👤')` for the user menu)
|
|
91
|
+
# @return [nil]
|
|
92
|
+
def display_navigation(*steps)
|
|
93
|
+
formatter.display_status("Navigate to: #{steps.join(" #{Environment.char('→')} ")}")
|
|
94
|
+
nil
|
|
95
|
+
end
|
|
96
|
+
|
|
87
97
|
# To be called in public wizard method to get private key
|
|
88
98
|
# @param user [String] User's email
|
|
89
99
|
# @param url [String] Instance URL
|
|
90
|
-
# @param page [String]
|
|
100
|
+
# @param page [Array<String>] Navigation steps to the page to enter the public key (see `display_navigation`)
|
|
91
101
|
# @return [String] Private key path (can contain ~ for home)
|
|
92
102
|
def ask_private_key(user:, url:, page:)
|
|
93
103
|
# Lets see if path to priv key is provided
|
|
@@ -111,7 +121,7 @@ module Aspera
|
|
|
111
121
|
pub_key_pem = OpenSSL::PKey::RSA.new(private_key_pem).public_key.to_s
|
|
112
122
|
options.set_option(:private_key, private_key_pem)
|
|
113
123
|
formatter.display_status("Please Log in as user #{user.red} at: #{url.red}")
|
|
114
|
-
|
|
124
|
+
display_navigation(*page)
|
|
115
125
|
formatter.display_status("Check or update the value to (#{'including BEGIN/END lines'.red}):".blink)
|
|
116
126
|
formatter.display_status(pub_key_pem, hide_secrets: false)
|
|
117
127
|
formatter.display_status('Once updated or validated, press [Enter].')
|
|
@@ -146,14 +156,14 @@ module Aspera
|
|
|
146
156
|
wizard_result = plugin_instance.wizard(self, wiz_url)
|
|
147
157
|
Log.log.debug { "wizard result: #{wizard_result}" }
|
|
148
158
|
Aspera.assert(WIZARD_RESULT_KEYS.eql?(wizard_result.keys.sort)) { "missing or extra keys in wizard result: #{wizard_result.keys}" }
|
|
159
|
+
Aspera.assert_array_all(wizard_result[:preset_ids], String) { 'preset_ids' }
|
|
149
160
|
# Get preset name from user or default
|
|
150
161
|
if wiz_preset_name.empty?
|
|
151
|
-
|
|
162
|
+
wiz_preset_name = IdGenerator.from_list(
|
|
152
163
|
identification[:product],
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
wiz_preset_name = elements.join('_').strip.downcase.gsub(/[^a-z0-9]/, '_').squeeze('_')
|
|
164
|
+
wiz_url,
|
|
165
|
+
*wizard_result[:preset_ids]
|
|
166
|
+
)
|
|
157
167
|
end
|
|
158
168
|
# Write configuration file
|
|
159
169
|
formatter.display_status("Preparing preset: #{wiz_preset_name}")
|
|
@@ -31,6 +31,7 @@ module Aspera
|
|
|
31
31
|
'x-agents', # [Array] Supported agents (for doc only), if not specified: all
|
|
32
32
|
'x-ts-name', # [Boolean,String] (async) true if same name in transfer spec, else real name in transfer spec, else ignored
|
|
33
33
|
'x-ts-convert', # [String] (async) Name of methods to convert value from transfer spec to `conf` API.
|
|
34
|
+
'x-unit', # [String] (async) Time unit of the integer value in `conf` (e.g. `ms`), command line option is a DURATION
|
|
34
35
|
'x-deprecation' # [String] Deprecation message for doc
|
|
35
36
|
].freeze
|
|
36
37
|
|
|
@@ -52,6 +53,30 @@ module Aspera
|
|
|
52
53
|
!properties.key?('x-agents') || properties['x-agents'].include?(agent.to_s)
|
|
53
54
|
end
|
|
54
55
|
|
|
56
|
+
# Documentation of the command line option of a property: `annotate` of `Schema::Documentation`
|
|
57
|
+
# @param node [Hash] Schema of the property
|
|
58
|
+
# @param formatter [FormatterInterface] Formatter of the documentation
|
|
59
|
+
# @return [Array<String>] Lines added to the description, empty if no option
|
|
60
|
+
def doc_lines(node, formatter)
|
|
61
|
+
envvar_prefix = ''
|
|
62
|
+
cli_option =
|
|
63
|
+
if node.key?('x-cli-envvar')
|
|
64
|
+
envvar_prefix = 'env:'
|
|
65
|
+
node['x-cli-envvar']
|
|
66
|
+
elsif node['x-cli-switch']
|
|
67
|
+
false_part = node.key?('x-cli-false') ? " / #{node['x-cli-false']}" : ''
|
|
68
|
+
"#{node['x-cli-option']}#{false_part}"
|
|
69
|
+
elsif node['x-cli-option']
|
|
70
|
+
arg_type = node.key?('enum') ? '{enum}' : "{#{[node['type']].flatten.join('|')}}"
|
|
71
|
+
conversion_tag = node.key?('x-cli-convert') ? 'conversion' : nil
|
|
72
|
+
sep = node['x-cli-option'].start_with?('--') ? '=' : ' '
|
|
73
|
+
"#{node['x-cli-option']}#{sep}#{"(#{conversion_tag})" if conversion_tag}#{arg_type}"
|
|
74
|
+
end
|
|
75
|
+
return [] unless cli_option
|
|
76
|
+
short = node.key?('x-cli-short') ? "(#{node['x-cli-short']})" : nil
|
|
77
|
+
["(#{'special:' if node['x-cli-special']}#{envvar_prefix}#{formatter.markdown_text("`#{cli_option}`")})#{short}"]
|
|
78
|
+
end
|
|
79
|
+
|
|
55
80
|
private
|
|
56
81
|
|
|
57
82
|
# `ascp`: mandatory type
|
|
@@ -72,6 +97,7 @@ module Aspera
|
|
|
72
97
|
Aspera.assert(node['type'].eql?('boolean')) { "switch must be bool: #{name}" } if node['x-cli-switch'] && !node['x-cli-special']
|
|
73
98
|
Aspera.assert(node['x-cli-switch']) { "boolean direct option must have x-cli-switch: #{name}" } if node['type'].eql?('boolean') && node.key?('x-cli-option') && supported_by_agent(:direct, node) && !node['x-cli-special']
|
|
74
99
|
node['x-cli-option'] = "--#{name.to_s.tr('_', '-')}" if node['x-cli-option'].eql?(true) || (node['x-cli-switch'].eql?(true) && !node.key?('x-cli-option'))
|
|
100
|
+
Aspera.assert(supported_by_agent(:direct, node)) { "#{name}: x-cli-option requires agent direct (or nil)" } if node['x-cli-option']
|
|
75
101
|
Aspera.assert(DIRECT_PROPERTIES.any? { |i| node.key?(i) }, name, type: :warn) if ascp && supported_by_agent(:direct, node)
|
|
76
102
|
node.freeze
|
|
77
103
|
end
|
|
@@ -4,6 +4,7 @@ require 'aspera/assert'
|
|
|
4
4
|
require 'singleton'
|
|
5
5
|
require 'openssl'
|
|
6
6
|
require 'zlib'
|
|
7
|
+
require 'base64'
|
|
7
8
|
|
|
8
9
|
module Aspera
|
|
9
10
|
# a simple binary data repository
|
|
@@ -14,39 +15,52 @@ module Aspera
|
|
|
14
15
|
class DataRepository
|
|
15
16
|
include Singleton
|
|
16
17
|
|
|
17
|
-
#
|
|
18
|
-
|
|
19
|
-
|
|
18
|
+
# name of data item => name of its file in data folder
|
|
19
|
+
FILES = {
|
|
20
|
+
dsa: '1',
|
|
21
|
+
rsa: '2',
|
|
22
|
+
uuid: '3',
|
|
23
|
+
'aspera.global-cli-client': '4',
|
|
24
|
+
'aspera.drive': '5',
|
|
25
|
+
license: '6'
|
|
26
|
+
}.freeze
|
|
27
|
+
# names of data items
|
|
28
|
+
ELEMENTS = FILES.keys.freeze
|
|
20
29
|
DATA_FOLDER_NAME = 'data'
|
|
21
30
|
|
|
22
|
-
#
|
|
23
|
-
# @param name [Symbol] name of the data item
|
|
24
|
-
# @return [String]
|
|
31
|
+
# Get a data item, decoded to its usable form
|
|
32
|
+
# @param name [Symbol] name of the data item, one of ELEMENTS
|
|
33
|
+
# @return [String] PEM private key (dsa, rsa), UUID (uuid), base64url secret (client apps), or text (license)
|
|
25
34
|
def item(name)
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
35
|
+
Aspera.assert_values(name, ELEMENTS, type: ParameterError) { 'data item' }
|
|
36
|
+
raw_data = data(name)
|
|
37
|
+
value =
|
|
38
|
+
case name
|
|
39
|
+
when :dsa, :rsa
|
|
40
|
+
# generate PEM from DER
|
|
41
|
+
# Class of key, not `OpenSSL::PKey.read`: on JRuby (jruby-openssl 0.16), it fails on these keys (DER, traditional format)
|
|
42
|
+
{dsa: OpenSSL::PKey::DSA, rsa: OpenSSL::PKey::RSA}[name].new(raw_data).to_pem
|
|
43
|
+
when :license
|
|
44
|
+
Zlib::Inflate.inflate(raw_data)
|
|
45
|
+
when :uuid
|
|
46
|
+
format('%08x-%04x-%04x-%04x-%04x%08x', *raw_data.unpack('NnnnnN'))
|
|
47
|
+
when :'aspera.global-cli-client', :'aspera.drive'
|
|
48
|
+
Base64.urlsafe_encode64(raw_data)
|
|
49
|
+
else Aspera.error_unexpected_value(name)
|
|
50
|
+
end
|
|
51
|
+
# all items are text
|
|
52
|
+
return value.force_encoding(Encoding::UTF_8)
|
|
41
53
|
end
|
|
42
54
|
|
|
43
55
|
private
|
|
44
56
|
|
|
45
|
-
private_constant :
|
|
57
|
+
private_constant :FILES, :DATA_FOLDER_NAME
|
|
46
58
|
|
|
47
59
|
# get binary value from data repository
|
|
48
|
-
|
|
49
|
-
|
|
60
|
+
# @param name [Symbol] name of the data item
|
|
61
|
+
# @return [String] raw binary data
|
|
62
|
+
def data(name)
|
|
63
|
+
File.binread(File.join(__dir__, DATA_FOLDER_NAME, FILES[name]))
|
|
50
64
|
end
|
|
51
65
|
end
|
|
52
66
|
end
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'aspera/assert'
|
|
4
|
+
require 'strscan'
|
|
5
|
+
|
|
6
|
+
module Aspera
|
|
7
|
+
# Duration in the format of Aspera Sync (`async` DURATION)
|
|
8
|
+
module Duration
|
|
9
|
+
# Microseconds per unit
|
|
10
|
+
UNIT_US = {'w' => 604_800_000_000, 'd' => 86_400_000_000, 'h' => 3_600_000_000, 'm' => 60_000_000, 's' => 1_000_000, 'ms' => 1_000, 'us' => 1}.freeze
|
|
11
|
+
private_constant :UNIT_US
|
|
12
|
+
|
|
13
|
+
class << self
|
|
14
|
+
# Convert a duration to an integer in the given time unit
|
|
15
|
+
# @param value [String] `DDd HH:MM:SS.mmm` or `NNw NNd NNh NNm NNs NNms NNus`, leading fields and spaces may be omitted, a plain number is seconds
|
|
16
|
+
# @param unit [String] Time unit of result, e.g. `ms`
|
|
17
|
+
# @return [Integer] Duration in `unit`, rounded down
|
|
18
|
+
def to_unit(value, unit)
|
|
19
|
+
Aspera.assert_values(unit, UNIT_US.keys) { 'duration unit' }
|
|
20
|
+
value = value.to_s.delete(' ')
|
|
21
|
+
if (m = value.match(/\A(?:(\d+)d)?(?:(?:(\d+):)?(\d+):)?(\d+)(?:\.(\d{1,3}))?\z/))
|
|
22
|
+
days, hours, minutes, seconds, millis = m.captures
|
|
23
|
+
hours = (days.to_i * 24) + hours.to_i
|
|
24
|
+
minutes = (hours * 60) + minutes.to_i
|
|
25
|
+
seconds = (minutes * 60) + seconds.to_i
|
|
26
|
+
microseconds = (seconds * UNIT_US['s']) + (millis.to_s.ljust(3, '0').to_i * UNIT_US['ms'])
|
|
27
|
+
else
|
|
28
|
+
# anchored sequential matching (linear time, no backtracking over start positions)
|
|
29
|
+
scanner = StringScanner.new(value)
|
|
30
|
+
units = []
|
|
31
|
+
until scanner.eos?
|
|
32
|
+
Aspera.assert(scanner.scan(/(\d+)(ms|us|[wdhms])/), type: ParameterError) { "Invalid duration: #{value}" }
|
|
33
|
+
units.push([scanner[1], scanner[2]])
|
|
34
|
+
end
|
|
35
|
+
Aspera.assert(!units.empty?, type: ParameterError) { "Invalid duration: #{value}" }
|
|
36
|
+
microseconds = units.sum { |number, number_unit| number.to_i * UNIT_US[number_unit] }
|
|
37
|
+
end
|
|
38
|
+
microseconds / UNIT_US[unit]
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|