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
data/lib/aspera/id_generator.rb
CHANGED
|
@@ -9,13 +9,14 @@ module Aspera
|
|
|
9
9
|
class << self
|
|
10
10
|
# Generate an ID from a list of object IDs
|
|
11
11
|
# The generated ID is safe as file name
|
|
12
|
+
# In URLs keep only the host name, we assume only one instance on a given host.
|
|
12
13
|
# @param ids [Array] the object IDs (can be nested, will be flattened, and nils removed)
|
|
13
14
|
# @return [String] the generated ID
|
|
14
15
|
def from_list(*ids)
|
|
15
16
|
safe_char = Environment.instance.safe_filename_character
|
|
16
17
|
# compact: remove nils
|
|
17
18
|
id = ids.flatten.compact.map do |i|
|
|
18
|
-
i.is_a?(String) && i.start_with?('https://') ? URI.parse(i).host : i.to_s
|
|
19
|
+
i.is_a?(String) && i.start_with?('http://', 'https://') ? URI.parse(i).host : i.to_s
|
|
19
20
|
end.join(safe_char)
|
|
20
21
|
# keep dot for extension only (nicer)
|
|
21
22
|
return Environment.instance.sanitized_filename(id.gsub('.', safe_char)).downcase
|
|
@@ -1,27 +1,54 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
# cspell:ignore
|
|
3
|
+
# cspell:ignore jsonrpc
|
|
4
4
|
|
|
5
5
|
require 'aspera/rest/error_analyzer'
|
|
6
|
+
require 'aspera/rest/call_error'
|
|
6
7
|
require 'aspera/assert'
|
|
7
8
|
require 'aspera/json_rpc/version'
|
|
8
|
-
require 'blankslate'
|
|
9
9
|
|
|
10
|
-
|
|
10
|
+
# Error in non-2XX response. Error in 2XX response is raised by the client.
|
|
11
|
+
Aspera::Rest::ErrorAnalyzer.instance.add_simple_handler(name: 'JSON RPC', path: %w[error message])
|
|
11
12
|
|
|
12
13
|
module Aspera
|
|
14
|
+
# JSON-RPC 2.0 over HTTP
|
|
13
15
|
module JsonRpc
|
|
16
|
+
class << self
|
|
17
|
+
# Send a JSON-RPC request
|
|
18
|
+
# @param api [Rest::Client] Aspera REST object pointing at the JSON-RPC endpoint
|
|
19
|
+
# @param method [String] JSON-RPC method
|
|
20
|
+
# @param args [Array] Arguments of call: a single Hash is sent as named params
|
|
21
|
+
# @param id [Integer] Request id
|
|
22
|
+
# @return [Object] `result` of response
|
|
23
|
+
# @raise [Rest::CallError] on JSON-RPC error, or invalid response
|
|
24
|
+
def request(api, method, args, id)
|
|
25
|
+
args = args.first if args.size == 1 && args.first.is_a?(Hash)
|
|
26
|
+
data = api.create('', {
|
|
27
|
+
jsonrpc: VERSION,
|
|
28
|
+
method: method,
|
|
29
|
+
params: args,
|
|
30
|
+
id: id
|
|
31
|
+
})
|
|
32
|
+
Aspera.assert_type(data, Hash, type: Rest::CallError) { 'JSON-RPC response' }
|
|
33
|
+
Aspera.assert(data['jsonrpc'] == VERSION, type: Rest::CallError) { "JSON-RPC: bad version in response: #{data['jsonrpc']}" }
|
|
34
|
+
Aspera.assert(data.key?('result') ^ data.key?('error'), type: Rest::CallError) { 'JSON-RPC: response must have either result or error' }
|
|
35
|
+
# id is null if server could not read it from request
|
|
36
|
+
Aspera.assert(data['id'] == id || (data.key?('error') && data['id'].nil?), type: Rest::CallError) { "JSON-RPC: bad id in response: #{data['id']}, expected #{id}" }
|
|
37
|
+
return data['result'] if data.key?('result')
|
|
38
|
+
error = data['error']
|
|
39
|
+
Aspera.assert(error.is_a?(Hash) && error['code'].is_a?(Integer) && error['message'].is_a?(String), type: Rest::CallError) { "JSON-RPC: bad error in response: #{error}" }
|
|
40
|
+
raise Rest::CallError, "#{error['message']} (code: #{error['code']})"
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
14
44
|
# JSON-RPC 2.0 client over an Aspera::Rest::Client HTTP endpoint.
|
|
15
45
|
# Methods are dispatched dynamically via method_missing.
|
|
46
|
+
# Based on BasicObject, so that methods of Object are sent as JSON-RPC requests.
|
|
16
47
|
# Example:
|
|
17
48
|
# client = JsonRpc::Client.new(Rest::Client.new(base_url: 'http://127.0.0.1:33024'))
|
|
18
49
|
# client.get_info
|
|
19
50
|
# client.start_transfer(app_id: '...', transfer_spec: {...})
|
|
20
|
-
class Client <
|
|
21
|
-
reveal :instance_variable_get
|
|
22
|
-
reveal :inspect
|
|
23
|
-
reveal :to_s
|
|
24
|
-
|
|
51
|
+
class Client < BasicObject
|
|
25
52
|
# @param api [Rest::Client] Aspera REST object pointing at the JSON-RPC endpoint
|
|
26
53
|
# @param namespace [String, nil] optional method prefix, e.g. "myns."
|
|
27
54
|
def initialize(api, namespace = nil)
|
|
@@ -31,32 +58,24 @@ module Aspera
|
|
|
31
58
|
@request_id = 0
|
|
32
59
|
end
|
|
33
60
|
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
61
|
+
# @return [String] Class and URL of JSON-RPC endpoint
|
|
62
|
+
def inspect = "#<#{Client.name} #{@api.base_url}>"
|
|
63
|
+
|
|
64
|
+
# @return [String] Same as {#inspect}
|
|
65
|
+
def to_s = inspect
|
|
66
|
+
|
|
67
|
+
# Ruby checks it before implicit conversions (`to_ary`, `to_str`, ...): those are not JSON-RPC methods
|
|
68
|
+
# @param _sym [Symbol] Method name
|
|
69
|
+
# @param _include_private [Boolean] Also private methods
|
|
70
|
+
# @return [false]
|
|
71
|
+
def respond_to_missing?(_sym, _include_private = false) = false
|
|
37
72
|
|
|
38
73
|
# Dispatch any Ruby method call as a JSON-RPC request
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
params: args,
|
|
45
|
-
id: @request_id += 1
|
|
46
|
-
})
|
|
47
|
-
Aspera.assert_type(data, Hash) { 'response' }
|
|
48
|
-
Aspera.assert(data['jsonrpc'] == VERSION, 'bad version in response')
|
|
49
|
-
Aspera.assert(data.key?('id'), 'missing id in response')
|
|
50
|
-
Aspera.assert(!(data.key?('error') && data.key?('result')), 'both error and response')
|
|
51
|
-
Aspera.assert(
|
|
52
|
-
!data.key?('error') ||
|
|
53
|
-
data['error'].is_a?(Hash) &&
|
|
54
|
-
data['error']['code'].is_a?(Integer) &&
|
|
55
|
-
data['error']['message'].is_a?(String),
|
|
56
|
-
'bad error response'
|
|
57
|
-
)
|
|
58
|
-
return data['result']
|
|
59
|
-
end
|
|
74
|
+
# @param method [Symbol] JSON-RPC method, without namespace
|
|
75
|
+
# @param args [Array] Arguments of call: a single Hash is sent as named params, else positional params
|
|
76
|
+
# @return [Object] `result` of response
|
|
77
|
+
# @raise [Rest::CallError] on JSON-RPC error, or invalid response
|
|
78
|
+
def method_missing(method, *args) = JsonRpc.request(@api, "#{@namespace}#{method}", args, @request_id += 1)
|
|
60
79
|
end
|
|
61
80
|
end
|
|
62
81
|
end
|
data/lib/aspera/keychain/base.rb
CHANGED
|
@@ -20,6 +20,11 @@ module Aspera
|
|
|
20
20
|
end
|
|
21
21
|
Aspera.assert(options.key?(:label), 'label is required')
|
|
22
22
|
end
|
|
23
|
+
|
|
24
|
+
# Labels are unique: `set` does not overwrite an existing secret
|
|
25
|
+
def assert_new_label(label)
|
|
26
|
+
Aspera.assert(get(label: label, exception: false).nil?) { "secret #{label} already exist, delete first" }
|
|
27
|
+
end
|
|
23
28
|
end
|
|
24
29
|
end
|
|
25
30
|
end
|
|
@@ -40,7 +40,8 @@ module Aspera
|
|
|
40
40
|
if File.exist?(@path)
|
|
41
41
|
vault_file = File.read(@path)
|
|
42
42
|
if vault_file.start_with?('---')
|
|
43
|
-
vault_info =
|
|
43
|
+
vault_info = Yaml.safe_load(vault_file)
|
|
44
|
+
Aspera.assert_type(vault_info, Hash) { "Invalid vault file: #{@path}" }
|
|
44
45
|
sorted_keys = vault_info.keys.sort
|
|
45
46
|
Aspera.assert(sorted_keys == FILE_KEYS || sorted_keys == FILE_KEYS_KDF) { "Invalid vault file: #{@path}: #{sorted_keys}" }
|
|
46
47
|
@cipher_name = vault_info['cipher']
|
|
@@ -82,7 +83,7 @@ module Aspera
|
|
|
82
83
|
def set(options)
|
|
83
84
|
validate_set(options)
|
|
84
85
|
label = options.delete(:label)
|
|
85
|
-
|
|
86
|
+
assert_new_label(label)
|
|
86
87
|
@all_secrets[label] = options.symbolize_keys
|
|
87
88
|
save
|
|
88
89
|
end
|
|
@@ -95,12 +96,15 @@ module Aspera
|
|
|
95
96
|
end
|
|
96
97
|
|
|
97
98
|
def delete(label:)
|
|
99
|
+
Aspera.assert(@all_secrets.key?(label)) { "Label not found: #{label}" }
|
|
98
100
|
@all_secrets.delete(label)
|
|
99
101
|
save
|
|
100
102
|
end
|
|
101
103
|
|
|
102
104
|
def change_password(password)
|
|
103
105
|
# Generate new KDF params so a password change also re-salts the vault
|
|
106
|
+
# (and migrates a legacy vault to PBKDF2)
|
|
107
|
+
@legacy_key = false
|
|
104
108
|
@kdf_params = nil
|
|
105
109
|
@cipher = cipher(password)
|
|
106
110
|
save
|
|
@@ -15,7 +15,6 @@ module Aspera
|
|
|
15
15
|
Aspera.assert_hash_all(info, Symbol, String) { 'vault info shall have only string values' }
|
|
16
16
|
info = info.symbolize_keys
|
|
17
17
|
vault_type = info.delete(:type)
|
|
18
|
-
Aspera.assert_values(vault_type, LIST.map(&:to_s)) { 'vault.type' }
|
|
19
18
|
case vault_type
|
|
20
19
|
when 'file'
|
|
21
20
|
# `name` (or `file`), default: `vault.bin`
|
|
@@ -40,7 +39,6 @@ module Aspera
|
|
|
40
39
|
Keychain::HashicorpVault.new(**info)
|
|
41
40
|
when '1password'
|
|
42
41
|
source = info.delete(:source) || 'api'
|
|
43
|
-
Aspera.assert_values(source, %w[api cli]) { 'vault.source' }
|
|
44
42
|
case source
|
|
45
43
|
when 'api'
|
|
46
44
|
require 'aspera/keychain/one_password_api'
|
|
@@ -49,8 +47,9 @@ module Aspera
|
|
|
49
47
|
when 'cli'
|
|
50
48
|
require 'aspera/keychain/one_password_cli'
|
|
51
49
|
Keychain::OnePasswordCli.new(**info)
|
|
50
|
+
else Aspera.error_unexpected_value(source, type: ParameterError) { 'vault.source' }
|
|
52
51
|
end
|
|
53
|
-
else Aspera.error_unexpected_value(vault_type)
|
|
52
|
+
else Aspera.error_unexpected_value(vault_type, type: ParameterError) { 'vault.type' }
|
|
54
53
|
end
|
|
55
54
|
end
|
|
56
55
|
end
|
|
@@ -32,7 +32,8 @@ module Aspera
|
|
|
32
32
|
def all
|
|
33
33
|
metadata_path = STORE_PATH.sub('/data/', '/metadata/')
|
|
34
34
|
return Vault.logical.list(metadata_path).filter_map do |label|
|
|
35
|
-
|
|
35
|
+
# KV v2: deleted secrets are still listed in metadata
|
|
36
|
+
get(label: label, exception: false)&.merge(label: label)
|
|
36
37
|
end
|
|
37
38
|
end
|
|
38
39
|
|
|
@@ -41,6 +42,7 @@ module Aspera
|
|
|
41
42
|
def set(options)
|
|
42
43
|
validate_set(options)
|
|
43
44
|
label = options.fetch(:label)
|
|
45
|
+
assert_new_label(label)
|
|
44
46
|
data = {
|
|
45
47
|
username: options[:username],
|
|
46
48
|
password: options[:password],
|
|
@@ -51,12 +53,13 @@ module Aspera
|
|
|
51
53
|
end
|
|
52
54
|
|
|
53
55
|
def get(label:, exception: true)
|
|
54
|
-
secret
|
|
55
|
-
|
|
56
|
+
# KV v2: a deleted secret may have metadata but no data
|
|
57
|
+
data = Vault.logical.read(path(label))&.data&.[](:data)
|
|
58
|
+
if data.nil?
|
|
56
59
|
raise "Secret '#{label}' not found" if exception
|
|
57
60
|
return
|
|
58
61
|
end
|
|
59
|
-
return
|
|
62
|
+
return data
|
|
60
63
|
end
|
|
61
64
|
|
|
62
65
|
def delete(label:)
|
|
@@ -18,7 +18,10 @@ module Aspera
|
|
|
18
18
|
DOMAINS = %i[user system common dynamic].freeze
|
|
19
19
|
LIST_OPTIONS = {
|
|
20
20
|
domain: :c
|
|
21
|
-
}
|
|
21
|
+
}.freeze
|
|
22
|
+
KEYCHAIN_OPTIONS = {
|
|
23
|
+
password: :p
|
|
24
|
+
}.freeze
|
|
22
25
|
ADD_PASS_OPTIONS = {
|
|
23
26
|
account: :a,
|
|
24
27
|
creator: :c,
|
|
@@ -37,8 +40,17 @@ module Aspera
|
|
|
37
40
|
password: :w,
|
|
38
41
|
getpass: :g
|
|
39
42
|
}.freeze
|
|
43
|
+
# Exit code of `security` when the item is not found
|
|
44
|
+
EXIT_ITEM_NOT_FOUND = 44
|
|
40
45
|
class << self
|
|
41
|
-
|
|
46
|
+
# Execute a `security` CLI command with given options
|
|
47
|
+
# @param command [String] The security sub-command
|
|
48
|
+
# @param options [Hash, nil] Command options
|
|
49
|
+
# @param supported [Hash, nil] Mapping of option keys to CLI flags
|
|
50
|
+
# @param last_opt [String, nil] Trailing argument (e.g. keychain path)
|
|
51
|
+
# @param exception [Boolean] Whether to raise on non-zero exit
|
|
52
|
+
# @return [Array(String, String, Process::Status)] stdout, stderr, status
|
|
53
|
+
def execute(command, options = nil, supported = nil, last_opt = nil, exception: true)
|
|
42
54
|
url = options&.delete(:url)
|
|
43
55
|
if !url.nil?
|
|
44
56
|
uri = URI.parse(url)
|
|
@@ -54,43 +66,82 @@ module Aspera
|
|
|
54
66
|
Aspera.assert(supported.key?(k)) { "unknown option: #{k}" }
|
|
55
67
|
next if v.nil?
|
|
56
68
|
command_args.push("-#{supported[k]}")
|
|
57
|
-
|
|
69
|
+
# No shell is involved: value is passed as is
|
|
70
|
+
command_args.push(v.to_s) unless v.to_s.empty?
|
|
58
71
|
end
|
|
59
72
|
command_args.push(last_opt) unless last_opt.nil?
|
|
60
|
-
Environment.secure_execute(*command_args, mode: :capture)
|
|
73
|
+
Environment.secure_execute(*command_args, mode: :capture, exception: exception)
|
|
61
74
|
end
|
|
62
75
|
|
|
76
|
+
# Parse output of list command into Keychain objects
|
|
77
|
+
# @param output [String] Raw CLI output
|
|
78
|
+
# @return [Array<Keychain>] List of keychain instances
|
|
63
79
|
def key_chains(output)
|
|
64
80
|
output.split("\n").collect { |line| new(line.strip.gsub(/^"|"$/, '')) }
|
|
65
81
|
end
|
|
66
82
|
|
|
83
|
+
# Get the macOS default keychain
|
|
84
|
+
# @return [Keychain] Default keychain instance
|
|
67
85
|
def default
|
|
68
|
-
key_chains(execute('default-keychain')).first
|
|
86
|
+
key_chains(execute('default-keychain').first).first
|
|
69
87
|
end
|
|
70
88
|
|
|
89
|
+
# Get the macOS login keychain
|
|
90
|
+
# @return [Keychain] Login keychain instance
|
|
71
91
|
def login
|
|
72
|
-
key_chains(execute('login-keychain')).first
|
|
92
|
+
key_chains(execute('login-keychain').first).first
|
|
73
93
|
end
|
|
74
94
|
|
|
95
|
+
# List keychains in the search list or domain
|
|
96
|
+
# @param options [Hash] Domain options
|
|
97
|
+
# @return [Array<Keychain>] List of keychains
|
|
75
98
|
def list(options = {})
|
|
76
99
|
Aspera.assert_values(options[:domain], DOMAINS, type: ParameterError) { 'domain' } unless options[:domain].nil?
|
|
77
|
-
key_chains(execute('list-keychains', options, LIST_OPTIONS))
|
|
100
|
+
key_chains(execute('list-keychains', options, LIST_OPTIONS).first)
|
|
78
101
|
end
|
|
79
102
|
|
|
103
|
+
# Find a keychain by its short name (without `.keychain-db` extension)
|
|
104
|
+
# @param name [String] Keychain name
|
|
105
|
+
# @return [Keychain, nil] Matching keychain or nil
|
|
80
106
|
def by_name(name)
|
|
81
107
|
list.find { |kc| kc.path.end_with?("/#{name}.keychain-db") }
|
|
82
108
|
end
|
|
109
|
+
|
|
110
|
+
# Create a new macOS keychain file
|
|
111
|
+
# @param path [String] File path for the new keychain
|
|
112
|
+
# @param password [String] Password for the new keychain
|
|
113
|
+
# @return [Keychain] Created keychain instance
|
|
114
|
+
def create(path, password:)
|
|
115
|
+
execute('create-keychain', {password: password}, KEYCHAIN_OPTIONS, path)
|
|
116
|
+
new(path)
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# Set the keychain search list
|
|
120
|
+
# @param keychain_list [Array<Keychain>] Ordered list of keychains
|
|
121
|
+
# @return [Array(String, String, Process::Status)] Execution result
|
|
122
|
+
def search_list=(keychain_list)
|
|
123
|
+
Environment.secure_execute(SECURITY_UTILITY, 'list-keychains', '-s', *keychain_list.map(&:path), mode: :capture)
|
|
124
|
+
end
|
|
83
125
|
end
|
|
84
126
|
attr_reader :path
|
|
85
127
|
|
|
128
|
+
# @param path [String] File path of the keychain
|
|
86
129
|
def initialize(path)
|
|
87
130
|
@path = path
|
|
88
131
|
end
|
|
89
132
|
|
|
133
|
+
# Decode a hexadecimal encoded string into UTF-8
|
|
134
|
+
# @param string [String] Hex string
|
|
135
|
+
# @return [String] Decoded string
|
|
90
136
|
def decode_hex_blob(string)
|
|
91
137
|
[string].pack('H*').force_encoding('UTF-8')
|
|
92
138
|
end
|
|
93
139
|
|
|
140
|
+
# Perform generic or internet password operation (add, find, delete)
|
|
141
|
+
# @param operation [Symbol] :add, :find, or :delete
|
|
142
|
+
# @param pass_type [Symbol] :generic or :internet
|
|
143
|
+
# @param options [Hash] Operation options (account, service, password, label, etc.)
|
|
144
|
+
# @return [Hash, nil] Parsed attributes for :find, or nil
|
|
94
145
|
def password(operation, pass_type, options)
|
|
95
146
|
Aspera.assert_values(operation, %i[add find delete]) { 'operation' }
|
|
96
147
|
Aspera.assert_values(pass_type, %i[generic internet]) { 'pass_type' }
|
|
@@ -98,11 +149,15 @@ module Aspera
|
|
|
98
149
|
missing = (operation.eql?(:add) ? %i[account service password] : %i[label]) - options.keys
|
|
99
150
|
Aspera.assert(missing.empty?) { "missing options: #{missing}" }
|
|
100
151
|
options[:getpass] = '' if operation.eql?(:find)
|
|
101
|
-
|
|
102
|
-
|
|
152
|
+
stdout, stderr, status = self.class.execute("#{operation}-#{pass_type}-password", options, ADD_PASS_OPTIONS, @path, exception: false)
|
|
153
|
+
unless status.success?
|
|
154
|
+
return if operation.eql?(:find) && status.exitstatus.eql?(EXIT_ITEM_NOT_FOUND)
|
|
155
|
+
raise Error, stderr.strip.gsub(/^.*: /, '')
|
|
156
|
+
end
|
|
103
157
|
return unless operation.eql?(:find)
|
|
104
158
|
attributes = {}
|
|
105
|
-
|
|
159
|
+
# Attributes are on stdout, password (option -g) is on stderr
|
|
160
|
+
"#{stdout}\n#{stderr}".split("\n").each do |line|
|
|
106
161
|
case line
|
|
107
162
|
when /^keychain: "(.+)"/
|
|
108
163
|
# ignore
|
|
@@ -120,10 +175,47 @@ module Aspera
|
|
|
120
175
|
end
|
|
121
176
|
return attributes
|
|
122
177
|
end
|
|
178
|
+
|
|
179
|
+
# Unlock this keychain with its password
|
|
180
|
+
# @param password [String] Password of the keychain
|
|
181
|
+
# @return [Array(String, String, Process::Status)] Execution result
|
|
182
|
+
def unlock(password:)
|
|
183
|
+
self.class.execute('unlock-keychain', {password: password}, KEYCHAIN_OPTIONS, @path)
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
# Lock this keychain
|
|
187
|
+
# @return [Array(String, String, Process::Status)] Execution result
|
|
188
|
+
def lock
|
|
189
|
+
self.class.execute('lock-keychain', nil, nil, @path)
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
# Delete this keychain file from disk
|
|
193
|
+
# @return [Array(String, String, Process::Status)] Execution result
|
|
194
|
+
def delete
|
|
195
|
+
self.class.execute('delete-keychain', nil, nil, @path)
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
# Configure settings for this keychain (lock timeout, lock on sleep)
|
|
199
|
+
# @param timeout [Integer, nil] Inactivity timeout in seconds
|
|
200
|
+
# @param lock_on_sleep [Boolean] Whether to lock when system sleeps
|
|
201
|
+
# @return [Array(String, String, Process::Status)] Execution result
|
|
202
|
+
def set_settings(timeout: nil, lock_on_sleep: false)
|
|
203
|
+
args = [SECURITY_UTILITY, 'set-keychain-settings']
|
|
204
|
+
args.push('-l') if lock_on_sleep
|
|
205
|
+
args.push('-u', '-t', timeout.to_s) if timeout
|
|
206
|
+
args.push(@path)
|
|
207
|
+
Environment.secure_execute(*args, mode: :capture)
|
|
208
|
+
end
|
|
123
209
|
end
|
|
124
210
|
end
|
|
125
211
|
|
|
212
|
+
# Vault backend implementation wrapping macOS system keychain
|
|
126
213
|
class MacosSystem < Base
|
|
214
|
+
# Account is mandatory in keychain: used when no username is provided
|
|
215
|
+
NO_ACCOUNT = 'none'
|
|
216
|
+
private_constant :NO_ACCOUNT
|
|
217
|
+
|
|
218
|
+
# @param name [String, nil] Specific keychain name, or nil for default
|
|
127
219
|
def initialize(name: nil)
|
|
128
220
|
super()
|
|
129
221
|
@keychain_name = name.nil? ? 'default keychain' : name
|
|
@@ -131,25 +223,35 @@ module Aspera
|
|
|
131
223
|
Aspera.assert(!@keychain.nil?) { "no such keychain #{name}" }
|
|
132
224
|
end
|
|
133
225
|
|
|
226
|
+
# @return [Hash] Vault information metadata
|
|
134
227
|
def info
|
|
135
228
|
return {
|
|
136
229
|
keychain: @keychain_name
|
|
137
230
|
}
|
|
138
231
|
end
|
|
139
232
|
|
|
233
|
+
# Overview of all secrets (not supported on macOS due to OS restrictions)
|
|
234
|
+
# @raise [Error] Always raised
|
|
140
235
|
def all
|
|
141
236
|
# the only way to list is `dump-keychain` which triggers security alert
|
|
142
237
|
raise Error, 'overview not implemented, use macos keychain app'
|
|
143
238
|
end
|
|
144
239
|
|
|
240
|
+
# Store a secret in the keychain
|
|
241
|
+
# @param options [Hash] Secret attributes (label, password, username, description)
|
|
242
|
+
# @return [Hash, nil] Result
|
|
145
243
|
def set(options)
|
|
146
244
|
validate_set(options)
|
|
147
245
|
@keychain.password(
|
|
148
246
|
:add, :generic, service: options[:label],
|
|
149
|
-
account: options[:username] ||
|
|
247
|
+
account: options[:username] || NO_ACCOUNT, password: options[:password], comment: options[:description]
|
|
150
248
|
)
|
|
151
249
|
end
|
|
152
250
|
|
|
251
|
+
# Retrieve a secret by label
|
|
252
|
+
# @param label [String] Secret label identifier
|
|
253
|
+
# @param exception [Boolean] Whether to raise if not found
|
|
254
|
+
# @return [Hash, nil] Secret attributes or nil
|
|
153
255
|
def get(label:, exception: true)
|
|
154
256
|
info = @keychain.password(:find, :generic, label: label)
|
|
155
257
|
if info.nil?
|
|
@@ -158,13 +260,17 @@ module Aspera
|
|
|
158
260
|
end
|
|
159
261
|
return {
|
|
160
262
|
label: label,
|
|
263
|
+
username: (info['acct'] unless info['acct'].eql?(NO_ACCOUNT)),
|
|
161
264
|
password: info['password'],
|
|
162
265
|
description: info['icmt'] # cspell: disable-line
|
|
163
|
-
}
|
|
266
|
+
}.compact
|
|
164
267
|
end
|
|
165
268
|
|
|
269
|
+
# Delete a secret by label
|
|
270
|
+
# @param label [String] Secret label identifier
|
|
271
|
+
# @return [Hash, nil] Result
|
|
166
272
|
def delete(label:)
|
|
167
|
-
|
|
273
|
+
@keychain.password(:delete, :generic, label: label)
|
|
168
274
|
end
|
|
169
275
|
end
|
|
170
276
|
end
|
|
@@ -42,6 +42,7 @@ module Aspera
|
|
|
42
42
|
|
|
43
43
|
def set(options)
|
|
44
44
|
validate_set(options)
|
|
45
|
+
Aspera.assert(resolve_id(options[:label], exception: false).nil?) { "secret #{options[:label]} already exist, delete first" }
|
|
45
46
|
@api.create("v1/vaults/#{@vault_id}/items", build_item(options))
|
|
46
47
|
nil
|
|
47
48
|
end
|
|
@@ -60,10 +61,12 @@ module Aspera
|
|
|
60
61
|
|
|
61
62
|
private
|
|
62
63
|
|
|
63
|
-
# Resolve an item id from its title; raises if ambiguous or missing
|
|
64
|
+
# Resolve an item id from its title, or else from its id; raises if ambiguous or missing
|
|
64
65
|
def resolve_id(label, exception: true)
|
|
65
66
|
items = @api.read("v1/vaults/#{@vault_id}/items", {filter: "title eq \"#{label}\""})
|
|
66
67
|
raise "Multiple secrets found with label '#{label}': use id to disambiguate" if items.length > 1
|
|
68
|
+
# No title matches: the identifier may be an item id
|
|
69
|
+
items = @api.read("v1/vaults/#{@vault_id}/items").select { |item| item['id'] == label } if items.empty?
|
|
67
70
|
raise "Secret '#{label}' not found" if items.empty? && exception
|
|
68
71
|
items.first&.fetch('id')
|
|
69
72
|
end
|
|
@@ -72,14 +75,16 @@ module Aspera
|
|
|
72
75
|
fields = []
|
|
73
76
|
fields << {'id' => FIELD_USERNAME, 'type' => 'STRING', 'value' => options[:username]} if options[:username]
|
|
74
77
|
fields << {'id' => FIELD_PASSWORD, 'type' => 'CONCEALED', 'value' => options[:password]} if options[:password]
|
|
75
|
-
fields << {'id' => FIELD_URL, 'type' => 'URL', 'value' => options[:url]} if options[:url]
|
|
76
78
|
fields << {'id' => FIELD_NOTES_ID, 'type' => 'STRING', 'value' => options[:description]} if options[:description]
|
|
77
|
-
{
|
|
79
|
+
item = {
|
|
78
80
|
'title' => options[:label],
|
|
79
81
|
'category' => ITEM_CATEGORY.upcase,
|
|
80
82
|
'vault' => {'id' => @vault_id},
|
|
81
83
|
'fields' => fields
|
|
82
84
|
}
|
|
85
|
+
# Website of the item
|
|
86
|
+
item['urls'] = [{'href' => options[:url], 'primary' => true}] if options[:url]
|
|
87
|
+
item
|
|
83
88
|
end
|
|
84
89
|
end
|
|
85
90
|
end
|
|
@@ -18,16 +18,21 @@ module Aspera
|
|
|
18
18
|
private
|
|
19
19
|
|
|
20
20
|
# Convert a 1Password item JSON object to a keychain secret Hash.
|
|
21
|
-
#
|
|
22
|
-
#
|
|
21
|
+
# Same JSON schema for API Connect and CLI items.
|
|
22
|
+
# Built-in fields are found by `id`, custom fields (generated `id`) by `label`.
|
|
23
|
+
# URL is the website of the item, or a custom field `url` (created by previous versions).
|
|
23
24
|
def item_to_secret(item)
|
|
24
|
-
fields = Array(item['fields'])
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
25
|
+
fields = Array(item['fields'])
|
|
26
|
+
by_id = fields.to_h { |f| [f['id'], f['value']] }
|
|
27
|
+
by_label = fields.to_h { |f| [f['label'], f['value']] }
|
|
28
|
+
urls = Array(item['urls'])
|
|
29
|
+
values = {
|
|
30
|
+
username: by_id[FIELD_USERNAME] || by_label[FIELD_USERNAME],
|
|
31
|
+
password: by_id[FIELD_PASSWORD] || by_label[FIELD_PASSWORD],
|
|
32
|
+
url: (urls.find { |u| u['primary'] } || urls.first)&.[]('href') || by_id[FIELD_URL] || by_label[FIELD_URL],
|
|
33
|
+
description: by_id[FIELD_NOTES_ID] || by_label[FIELD_NOTES_ID]
|
|
34
|
+
}
|
|
35
|
+
return {label: item['title']}.merge(values.compact)
|
|
31
36
|
end
|
|
32
37
|
end
|
|
33
38
|
end
|
|
@@ -42,10 +42,11 @@ module Aspera
|
|
|
42
42
|
|
|
43
43
|
def set(options)
|
|
44
44
|
validate_set(options)
|
|
45
|
+
assert_new_label(options[:label])
|
|
45
46
|
args = ['item', 'create', '--category', ITEM_CATEGORY, "--title=#{options[:label]}"]
|
|
47
|
+
args << "--url=#{options[:url]}" if options[:url]
|
|
46
48
|
args << "#{FIELD_USERNAME}=#{options[:username]}" if options[:username]
|
|
47
49
|
args << "#{FIELD_PASSWORD}[password]=#{options[:password]}" if options[:password]
|
|
48
|
-
args << "#{FIELD_URL}[url]=#{options[:url]}" if options[:url]
|
|
49
50
|
args << "#{FIELD_NOTES_ID}=#{options[:description]}" if options[:description]
|
|
50
51
|
op_run(*args)
|
|
51
52
|
nil
|
|
@@ -53,8 +54,9 @@ module Aspera
|
|
|
53
54
|
|
|
54
55
|
def get(label:, id: nil, exception: true)
|
|
55
56
|
identifier = id || label
|
|
56
|
-
stdout,
|
|
57
|
+
stdout, stderr, status = op_capture('item', 'get', identifier, '--format=json', exception: false)
|
|
57
58
|
if !status.success?
|
|
59
|
+
raise "Multiple secrets found with label '#{label}': use id to disambiguate, or option vault to restrict" if stderr.to_s.match?(/more than one item/i)
|
|
58
60
|
raise "Secret '#{label}' not found" if exception
|
|
59
61
|
return
|
|
60
62
|
end
|
data/lib/aspera/log.rb
CHANGED
|
@@ -110,11 +110,8 @@ module Aspera
|
|
|
110
110
|
return unless instance.logger.send(:"#{level}?")
|
|
111
111
|
Aspera.assert(object.nil? || block.nil?, 'Use either object, or block, not both')
|
|
112
112
|
object = yield if block_given?
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
# @return [String] Dump of object
|
|
117
|
-
def obj_dump(name, object)
|
|
113
|
+
# Secrets hidden by key name in the structure, whatever the dump format (the log formatter then hides secrets in text)
|
|
114
|
+
object = SecretHider.instance.hidden_copy(object) unless SecretHider.instance.log_secrets
|
|
118
115
|
dump_text =
|
|
119
116
|
case instance.dump_format
|
|
120
117
|
when :json
|
|
@@ -123,7 +120,7 @@ module Aspera
|
|
|
123
120
|
PP.pp(object, +'')
|
|
124
121
|
else Aspera.error_unexpected_value(instance.dump_format) { 'dump format' }
|
|
125
122
|
end
|
|
126
|
-
"#{name.to_s.green}(#{instance.dump_format})#{object.class}=\n#{dump_text}"
|
|
123
|
+
instance.logger.send(level, "#{name.to_s.green}(#{instance.dump_format})#{object.class}=\n#{dump_text}")
|
|
127
124
|
end
|
|
128
125
|
|
|
129
126
|
# Capture the output of $stderr and log it at debug level
|