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/environment.rb
CHANGED
|
@@ -8,11 +8,12 @@ require 'singleton'
|
|
|
8
8
|
require 'openssl'
|
|
9
9
|
require 'open3'
|
|
10
10
|
require 'English'
|
|
11
|
+
require 'io/console'
|
|
11
12
|
require 'shellwords'
|
|
12
13
|
require 'aspera/rainbow'
|
|
13
14
|
using Rainbow
|
|
14
15
|
|
|
15
|
-
# cspell:words mswin bccwin
|
|
16
|
+
# cspell:words mswin bccwin conhost mintty Cmder
|
|
16
17
|
|
|
17
18
|
module Aspera
|
|
18
19
|
# detect OS, architecture, and specific stuff
|
|
@@ -40,6 +41,19 @@ module Aspera
|
|
|
40
41
|
|
|
41
42
|
I18N_VARS = %w(LC_ALL LC_CTYPE LANG).freeze
|
|
42
43
|
|
|
44
|
+
# Unicode characters used in messages, and their ASCII equivalent when Unicode is not supported (see `Environment.char`)
|
|
45
|
+
# Key: hidden secret, hieroglyph: app switcher menu of web apps, bust: user menu of web apps
|
|
46
|
+
UNICODE_TO_ASCII = {
|
|
47
|
+
'🔑' => '***',
|
|
48
|
+
'✓' => 'Y',
|
|
49
|
+
'✗' => ' ',
|
|
50
|
+
'→' => '->',
|
|
51
|
+
'…' => '...',
|
|
52
|
+
'—' => '-',
|
|
53
|
+
'𓃑' => '(App switcher)',
|
|
54
|
+
'👤' => '(User)'
|
|
55
|
+
}.freeze
|
|
56
|
+
|
|
43
57
|
# "/" is invalid on both Unix and Windows, other are Windows special characters
|
|
44
58
|
# See: https://learn.microsoft.com/en-us/windows/win32/fileio/naming-a-file
|
|
45
59
|
WINDOWS_FILENAME_INVALID_CHARACTERS = '<>:"/\\|?*'
|
|
@@ -205,6 +219,15 @@ module Aspera
|
|
|
205
219
|
$stdout.tty?
|
|
206
220
|
end
|
|
207
221
|
|
|
222
|
+
# @return [Integer, nil] Number of columns of the terminal, or `nil` if not a terminal or unknown
|
|
223
|
+
def terminal_columns
|
|
224
|
+
return unless terminal?
|
|
225
|
+
columns = $stdout.winsize[1]
|
|
226
|
+
columns.positive? ? columns : nil
|
|
227
|
+
rescue SystemCallError
|
|
228
|
+
nil
|
|
229
|
+
end
|
|
230
|
+
|
|
208
231
|
# force locale to C so that unicode characters are not used
|
|
209
232
|
def force_terminal_c
|
|
210
233
|
I18N_VARS.each { |var| ENV[var] = 'C' }
|
|
@@ -213,29 +236,59 @@ module Aspera
|
|
|
213
236
|
# Override detection of Unicode support: `true` or `false`, or `nil` for auto-detection
|
|
214
237
|
attr_writer :unicode
|
|
215
238
|
|
|
216
|
-
# @return [Boolean] true if we can display Unicode characters
|
|
239
|
+
# @return [Boolean] true if we can display Unicode characters on stdout
|
|
240
|
+
def terminal_supports_unicode?
|
|
241
|
+
return @unicode unless @unicode.nil?
|
|
242
|
+
terminal? && unicode_environment?
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
# @return [Boolean] true if the locale, or the terminal host on Windows, supports Unicode, whether stdout is a terminal or not
|
|
217
246
|
# Uses Encoding.locale_charmap for OS-independent detection.
|
|
218
|
-
#
|
|
247
|
+
# On Windows, also detects terminal hosts that render Unicode whatever the console code page.
|
|
248
|
+
# Falls back to the locale env var with highest precedence, for locales not installed.
|
|
219
249
|
# https://www.gnu.org/software/libc/manual/html_node/Locale-Categories.html
|
|
220
250
|
# https://pubs.opengroup.org/onlinepubs/7908799/xbd/envvar.html
|
|
221
|
-
def
|
|
222
|
-
return
|
|
223
|
-
return
|
|
224
|
-
|
|
251
|
+
def unicode_environment?
|
|
252
|
+
return true if locale_charmap_utf8?
|
|
253
|
+
return true if instance.os.eql?(OS_WINDOWS) && windows_unicode_terminal?
|
|
254
|
+
locale = ENV.values_at(*I18N_VARS).find { |value| !value.to_s.empty? }
|
|
255
|
+
locale&.match?(/UTF-?8/i) || false
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
# @param char [String] Unicode character, key of UNICODE_TO_ASCII
|
|
259
|
+
# @param unicode [Boolean] `true` if Unicode is supported
|
|
260
|
+
# @return [String] The character, or its ASCII equivalent if Unicode is not supported
|
|
261
|
+
def char(char, unicode: terminal_supports_unicode?)
|
|
262
|
+
Aspera.assert(UNICODE_TO_ASCII.key?(char), "No ASCII equivalent for: #{char}", type: Aspera::InternalError)
|
|
263
|
+
unicode ? char : UNICODE_TO_ASCII[char]
|
|
225
264
|
end
|
|
226
265
|
|
|
227
266
|
private
|
|
228
267
|
|
|
268
|
+
# @return [Boolean] true if the terminal host renders Unicode
|
|
269
|
+
# Ruby writes to the Windows console with WriteConsoleW (UTF-16): the code page does not matter, the font does.
|
|
270
|
+
# Legacy conhost may lack glyphs (e.g. check marks), while these hosts render them.
|
|
271
|
+
def windows_unicode_terminal?
|
|
272
|
+
# Windows Terminal, VS Code, Git Bash, ConEmu or Cmder, MSYS2 or Cygwin or SSH
|
|
273
|
+
ENV.key?('WT_SESSION') ||
|
|
274
|
+
%w[vscode mintty].include?(ENV.fetch('TERM_PROGRAM', nil)) ||
|
|
275
|
+
ENV.fetch('ConEmuANSI', nil).eql?('ON') ||
|
|
276
|
+
ENV.fetch('TERM', '').match?(/xterm|256color/)
|
|
277
|
+
end
|
|
278
|
+
|
|
229
279
|
# @return [Boolean] true if the locale charmap resolves to UTF-8
|
|
230
280
|
# Unix: nl_langinfo(CODESET) returns "UTF-8"
|
|
231
|
-
# Windows: GetACP
|
|
281
|
+
# Windows: console code page (GetConsoleCP), else ANSI code page (GetACP): "CP65001" resolves as UTF-8
|
|
232
282
|
def locale_charmap_utf8?
|
|
233
283
|
Encoding.find(Encoding.locale_charmap) == Encoding::UTF_8
|
|
234
284
|
rescue ArgumentError
|
|
235
285
|
false
|
|
236
286
|
end
|
|
237
287
|
end
|
|
238
|
-
|
|
288
|
+
# url_method: :text or :graphical, how URLs, secrets and files are presented to the user
|
|
289
|
+
# browser_command: [Array<String>, nil] command (and arguments) that opens a URL (URL is appended), instead of
|
|
290
|
+
# the system default
|
|
291
|
+
attr_accessor :url_method, :browser_command, :file_illegal_characters
|
|
239
292
|
attr_reader :os, :cpu, :default_gui_mode
|
|
240
293
|
|
|
241
294
|
def initialize
|
|
@@ -279,6 +332,7 @@ module Aspera
|
|
|
279
332
|
:text
|
|
280
333
|
end
|
|
281
334
|
@url_method = @default_gui_mode
|
|
335
|
+
@browser_command = nil
|
|
282
336
|
@file_illegal_characters = REPLACE_CHARACTER + WINDOWS_FILENAME_INVALID_CHARACTERS
|
|
283
337
|
nil
|
|
284
338
|
end
|
|
@@ -327,15 +381,20 @@ module Aspera
|
|
|
327
381
|
@default_gui_mode == :graphical
|
|
328
382
|
end
|
|
329
383
|
|
|
330
|
-
# Open a URI in the
|
|
384
|
+
# Open a URI in the browser (non-blocking).
|
|
385
|
+
# The browser is, by priority: `browser_command`, the system default.
|
|
331
386
|
# @param uri [String, URI] the URI to open
|
|
332
387
|
# @return [nil]
|
|
333
388
|
def open_uri_graphical(uri)
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
389
|
+
if @browser_command
|
|
390
|
+
self.class.secure_execute(*@browser_command, uri.to_s)
|
|
391
|
+
else
|
|
392
|
+
case @os
|
|
393
|
+
when Environment::OS_MACOS then self.class.secure_execute('open', uri.to_s)
|
|
394
|
+
when Environment::OS_WINDOWS then self.class.secure_execute('start', 'explorer', %Q{"#{uri}"})
|
|
395
|
+
when Environment::OS_LINUX then self.class.secure_execute('xdg-open', uri.to_s)
|
|
396
|
+
else Aspera.error_unexpected_value(os) { 'no graphical open method' }
|
|
397
|
+
end
|
|
339
398
|
end
|
|
340
399
|
nil
|
|
341
400
|
end
|
|
@@ -352,21 +411,26 @@ module Aspera
|
|
|
352
411
|
end
|
|
353
412
|
|
|
354
413
|
# Allows a user to open a URL
|
|
414
|
+
# if a browser command is defined (`browser_command`), then it is used, whatever the method
|
|
355
415
|
# if method is :text, then URL is displayed on terminal
|
|
356
416
|
# if method is :graphical, then the URL will be opened with the default browser.
|
|
357
417
|
# this is non blocking
|
|
358
418
|
def open_uri(the_url)
|
|
359
|
-
|
|
360
|
-
when :graphical
|
|
419
|
+
if @browser_command
|
|
361
420
|
open_uri_graphical(the_url)
|
|
362
|
-
|
|
363
|
-
case
|
|
364
|
-
when
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
421
|
+
else
|
|
422
|
+
case @url_method
|
|
423
|
+
when :graphical
|
|
424
|
+
open_uri_graphical(the_url)
|
|
425
|
+
when :text
|
|
426
|
+
case the_url.to_s
|
|
427
|
+
when /^http/
|
|
428
|
+
puts "USER ACTION: please enter this URL in a browser:\n#{the_url.to_s.red}\n"
|
|
429
|
+
else
|
|
430
|
+
puts "USER ACTION: open this:\n#{the_url.to_s.red}\n"
|
|
431
|
+
end
|
|
432
|
+
else Aspera.error_unexpected_value(@url_method) { 'URL open method' }
|
|
368
433
|
end
|
|
369
|
-
else Aspera.error_unexpected_value(@url_method) { 'URL open method' }
|
|
370
434
|
end
|
|
371
435
|
end
|
|
372
436
|
|
data/lib/aspera/exec_spec.rb
CHANGED
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module Aspera
|
|
4
|
-
# Holds the executable name, environment variables and command line arguments for a spawned process
|
|
5
|
-
|
|
6
|
-
|
|
4
|
+
# Holds the executable name, environment variables and command line arguments for a spawned process,
|
|
5
|
+
# and the temporary files it uses (for example a file list), which must exist until it ends.
|
|
6
|
+
ExecSpec = Struct.new(:exec, :env, :args, :temp_files, keyword_init: true) do
|
|
7
|
+
def initialize(exec: nil, env: {}, args: [], temp_files: [], **) = super
|
|
7
8
|
|
|
8
|
-
# @return [ExecSpec] deep copy with independent :args and :
|
|
9
|
+
# @return [ExecSpec] deep copy with independent :args, :env and :temp_files
|
|
9
10
|
def deep_clone
|
|
10
|
-
self.class.new(exec: exec, env: env.dup, args: args.dup)
|
|
11
|
+
self.class.new(exec: exec, env: env.dup, args: args.dup, temp_files: temp_files.dup)
|
|
11
12
|
end
|
|
12
13
|
end
|
|
13
14
|
end
|
data/lib/aspera/faspex_gw.rb
CHANGED
|
@@ -2,20 +2,66 @@
|
|
|
2
2
|
|
|
3
3
|
require 'aspera/log'
|
|
4
4
|
require 'aspera/assert'
|
|
5
|
+
require 'aspera/hash_ext'
|
|
5
6
|
require 'aspera/mime'
|
|
7
|
+
require 'aspera/secret_strength'
|
|
8
|
+
require 'aspera/servlet_helpers'
|
|
9
|
+
require 'aspera/web_server_simple'
|
|
6
10
|
require 'webrick'
|
|
7
11
|
require 'json'
|
|
8
12
|
|
|
9
13
|
module Aspera
|
|
10
14
|
# Simulate the Faspex 4 /send API and creates a package on Aspera on Cloud or Faspex 5
|
|
15
|
+
#
|
|
16
|
+
# Security notes:
|
|
17
|
+
# - Packages are created as the user of the application, to any recipient, and the caller receives an upload transfer spec.
|
|
18
|
+
# - Clients authenticate with HTTP Basic, as on Faspex 4, when `username` and `password` are set:
|
|
19
|
+
# they are required when the server listens on an address that is not loopback (checked by `serve`).
|
|
20
|
+
# - Requests that a web page could send are refused (see ServletHelpers#check_request): the body shall be `application/json`.
|
|
11
21
|
class Faspex4GWServlet < WEBrick::HTTPServlet::AbstractServlet
|
|
22
|
+
include ServletHelpers
|
|
23
|
+
|
|
12
24
|
AOC_API = 'Aspera::Api::AoC'
|
|
13
25
|
FX_API = 'Aspera::Api::Faspex'
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
26
|
+
ALLOWED_PARAMETERS = %i[username password password_min_bits].freeze
|
|
27
|
+
REALM = 'Faspex 4 Gateway'
|
|
28
|
+
private_constant :REALM
|
|
29
|
+
|
|
30
|
+
class << self
|
|
31
|
+
# Check parameters and set defaults
|
|
32
|
+
# Called at startup, so that errors are reported before the first request
|
|
33
|
+
# @param parameters [Hash] Parameters of the servlet
|
|
34
|
+
# @return [Hash] Parameters with defaults
|
|
35
|
+
def normalize_parameters(parameters)
|
|
36
|
+
Aspera.assert_type(parameters, Hash)
|
|
37
|
+
parameters = parameters.symbolize_keys
|
|
38
|
+
not_allowed = parameters.keys - ALLOWED_PARAMETERS
|
|
39
|
+
Aspera.assert(not_allowed.empty?, type: ParameterError) { "unsupported parameters: #{not_allowed.join(', ')}" }
|
|
40
|
+
Aspera.assert(parameters[:username].nil? == parameters[:password].nil?, type: ParameterError) { 'Parameters username and password must be set together' }
|
|
41
|
+
parameters[:password_min_bits] ||= SecretStrength::DEFAULT_MIN_BITS
|
|
42
|
+
SecretStrength.assert_strong(parameters[:password], name: 'password', min_bits: parameters[:password_min_bits]) unless parameters[:password].nil?
|
|
43
|
+
parameters
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Start the gateway, until signal INT or TERM
|
|
47
|
+
# @param parameters [Hash] Parameters of the server (see `WebServerSimple.url`) and of the servlet (see `normalize_parameters`)
|
|
48
|
+
# @param type [Exception] Exception raised when the server is not protected
|
|
49
|
+
# @yieldreturn [Array] API of the application, and workspace id (AoC only), see `initialize`: called once parameters are checked
|
|
50
|
+
def serve(parameters, type: ParameterError)
|
|
51
|
+
servlet_parameters = normalize_parameters(WebServerSimple.servlet_parameters(parameters))
|
|
52
|
+
# Packages can be sent to anyone by anyone who can reach the server
|
|
53
|
+
WebServerSimple.serve(parameters, self, protected: !servlet_parameters[:username].nil?, what: 'username and password', type: type) { [servlet_parameters, *yield] }
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# @param server [WEBrick::HTTPServer] Server
|
|
58
|
+
# @param parameters [Hash] Parameters from `normalize_parameters`
|
|
59
|
+
# @param app_api [Rest::Client] API object
|
|
60
|
+
# @param app_context [String] workspace id (aoc only)
|
|
61
|
+
def initialize(server, parameters, app_api, app_context)
|
|
17
62
|
Aspera.assert_values(app_api.class.name, [AOC_API, FX_API])
|
|
18
63
|
super(server)
|
|
64
|
+
@expected_auth = "#{parameters[:username]}:#{parameters[:password]}" unless parameters[:username].nil?
|
|
19
65
|
@app_api = app_api
|
|
20
66
|
@app_context = app_context
|
|
21
67
|
end
|
|
@@ -66,37 +112,57 @@ module Aspera
|
|
|
66
112
|
end
|
|
67
113
|
|
|
68
114
|
def do_POST(request, response)
|
|
69
|
-
|
|
70
|
-
|
|
115
|
+
# Refuse requests of web pages
|
|
116
|
+
check_request(request, json: true)
|
|
117
|
+
return json_error(response, 401, 'Invalid or missing credentials', headers: {'WWW-Authenticate' => %Q(Basic realm="#{REALM}")}) unless authorized?(request)
|
|
118
|
+
return json_error(response, 400, 'Unsupported endpoint') unless request.path.eql?('/aspera/faspex/send')
|
|
119
|
+
faspex_pkg_parameters = package_parameters(request.body)
|
|
120
|
+
Log.dump(:faspex_pkg_parameters, faspex_pkg_parameters)
|
|
121
|
+
# compare string, as class is not yet known here
|
|
122
|
+
faspex_package_create_result =
|
|
123
|
+
case @app_api.class.name
|
|
124
|
+
when AOC_API
|
|
125
|
+
faspex4_send_to_aoc(faspex_pkg_parameters)
|
|
126
|
+
when FX_API
|
|
127
|
+
faspex4_send_to_faspex5(faspex_pkg_parameters)
|
|
128
|
+
else Aspera.error_unexpected_value(@app_api.class.name)
|
|
129
|
+
end
|
|
130
|
+
Log.dump(:faspex_package_create_result, faspex_package_create_result)
|
|
131
|
+
json_response(response, 200, faspex_package_create_result)
|
|
132
|
+
rescue WEBrick::HTTPStatus::Error => e
|
|
133
|
+
Log.log.warn { "Rejected request: #{e.message}" }
|
|
134
|
+
json_error(response, e.code, e.message)
|
|
135
|
+
rescue StandardError => e
|
|
136
|
+
# The stack trace is logged only: it is not for the caller
|
|
137
|
+
Log.log.error(e.message)
|
|
138
|
+
Log.log.debug { e.backtrace.join("\n") }
|
|
139
|
+
json_error(response, 500, e.message)
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
private
|
|
143
|
+
|
|
144
|
+
# @param request [WEBrick::HTTPRequest]
|
|
145
|
+
# @return [Boolean] `true` if the request has the expected Basic credentials, or if none are expected
|
|
146
|
+
def authorized?(request)
|
|
147
|
+
@expected_auth.nil? || basic_authorized?(request, @expected_auth)
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
# @param body [String, nil] Body of the request
|
|
151
|
+
# @return [Hash] Parameters of Faspex 4 package creation, with `delivery` and its `recipients`
|
|
152
|
+
# @raise [WEBrick::HTTPStatus::BadRequest] if the body is not a valid package creation request
|
|
153
|
+
def package_parameters(body)
|
|
154
|
+
raise WEBrick::HTTPStatus::BadRequest, 'Empty request' if body.nil?
|
|
155
|
+
parameters =
|
|
71
156
|
begin
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
# compare string, as class is not yet known here
|
|
76
|
-
faspex_package_create_result =
|
|
77
|
-
case @app_api.class.name
|
|
78
|
-
when AOC_API
|
|
79
|
-
faspex4_send_to_aoc(faspex_pkg_parameters)
|
|
80
|
-
when FX_API
|
|
81
|
-
faspex4_send_to_faspex5(faspex_pkg_parameters)
|
|
82
|
-
else Aspera.error_unexpected_value(@app_api.class.name)
|
|
83
|
-
end
|
|
84
|
-
Log.dump(:faspex_package_create_result, faspex_package_create_result)
|
|
85
|
-
response.status = 200
|
|
86
|
-
response.content_type = Mime::JSON
|
|
87
|
-
response.body = JSON.generate(faspex_package_create_result)
|
|
88
|
-
rescue StandardError => e
|
|
89
|
-
response.status = 500
|
|
90
|
-
response['Content-Type'] = Mime::JSON
|
|
91
|
-
response.body = {error: e.message, stacktrace: e.backtrace}.to_json
|
|
92
|
-
Log.log.error(e.message)
|
|
93
|
-
Log.log.debug { e.backtrace.join("\n") }
|
|
157
|
+
JSON.parse(body)
|
|
158
|
+
rescue JSON::ParserError
|
|
159
|
+
nil
|
|
94
160
|
end
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
161
|
+
raise WEBrick::HTTPStatus::BadRequest, 'Request body shall be a JSON object' unless parameters.is_a?(Hash)
|
|
162
|
+
raise WEBrick::HTTPStatus::BadRequest, 'Missing object: delivery' unless parameters['delivery'].is_a?(Hash)
|
|
163
|
+
recipients = parameters['delivery']['recipients']
|
|
164
|
+
raise WEBrick::HTTPStatus::BadRequest, 'delivery.recipients shall be a non-empty list of names' unless recipients.is_a?(Array) && !recipients.empty? && recipients.all?(String)
|
|
165
|
+
parameters
|
|
100
166
|
end
|
|
101
167
|
end
|
|
102
168
|
end
|
|
@@ -3,24 +3,93 @@
|
|
|
3
3
|
require 'json'
|
|
4
4
|
require 'timeout'
|
|
5
5
|
require 'English'
|
|
6
|
+
require 'ipaddr'
|
|
7
|
+
require 'openssl'
|
|
6
8
|
require 'webrick'
|
|
7
9
|
require 'aspera/log'
|
|
8
10
|
require 'aspera/assert'
|
|
9
|
-
require 'aspera/
|
|
11
|
+
require 'aspera/environment'
|
|
12
|
+
require 'aspera/hash_ext'
|
|
13
|
+
require 'aspera/secret_strength'
|
|
14
|
+
require 'aspera/servlet_helpers'
|
|
15
|
+
require 'aspera/web_server_simple'
|
|
10
16
|
|
|
11
17
|
module Aspera
|
|
12
18
|
# Start a Faspex-4 style post-processing script using Faspex-5 webhook
|
|
19
|
+
#
|
|
20
|
+
# Security notes:
|
|
21
|
+
# - By default the listener binds to loopback only (url defaults to http://localhost:...).
|
|
22
|
+
# Use a routable address only when the Faspex containers must reach this host:
|
|
23
|
+
# then `secret_token` or `allowed_ips` is required (checked by `serve`).
|
|
24
|
+
# - Use `secret_token` so the token must be present as a `token` query parameter on every
|
|
25
|
+
# incoming request (configure the webhook URL in Faspex as http://host:port/path?token=VALUE).
|
|
26
|
+
# - Use `allowed_ips` to restrict which source IPs or networks may call the listener.
|
|
27
|
+
# - Requests that a web page could send are refused (see ServletHelpers#check_request): the body shall be `application/json`.
|
|
28
|
+
# - Only webhook values with a key starting with `faspex_` are passed to the script.
|
|
29
|
+
# - With `lambda` in the URL query, a Ruby script (`.rb`) is evaluated in this process:
|
|
30
|
+
# it shall return a lambda, called with the webhook values.
|
|
13
31
|
class Faspex4PostProcServlet < WEBrick::HTTPServlet::AbstractServlet
|
|
14
|
-
|
|
32
|
+
include ServletHelpers
|
|
33
|
+
|
|
34
|
+
ALLOWED_PARAMETERS = %i[root script_folder fail_on_error timeout_seconds secret_token secret_token_min_bits allowed_ips].freeze
|
|
35
|
+
# Prefix of keys of webhook values passed as environment variables: Faspex 4 post-processing variables
|
|
36
|
+
ENV_KEY_PREFIX = 'faspex_'
|
|
37
|
+
private_constant :ENV_KEY_PREFIX
|
|
38
|
+
|
|
39
|
+
class << self
|
|
40
|
+
# Check parameters and set defaults
|
|
41
|
+
# Called at startup, so that errors are reported before the first request
|
|
42
|
+
# @param parameters [Hash] Parameters of the servlet
|
|
43
|
+
# @return [Hash] Parameters with defaults, and `allowed_ips` as `IPAddr`
|
|
44
|
+
def normalize_parameters(parameters)
|
|
45
|
+
Aspera.assert_type(parameters, Hash)
|
|
46
|
+
parameters = parameters.symbolize_keys
|
|
47
|
+
not_allowed = parameters.keys - ALLOWED_PARAMETERS
|
|
48
|
+
Aspera.assert(not_allowed.empty?) { "unsupported parameters: #{not_allowed.join(', ')}" }
|
|
49
|
+
parameters[:script_folder] ||= '.'
|
|
50
|
+
parameters[:fail_on_error] ||= false
|
|
51
|
+
parameters[:timeout_seconds] ||= 60
|
|
52
|
+
parameters[:secret_token_min_bits] ||= SecretStrength::DEFAULT_MIN_BITS
|
|
53
|
+
Aspera.assert(File.directory?(parameters[:script_folder])) { "script_folder not found: #{parameters[:script_folder]}" }
|
|
54
|
+
SecretStrength.assert_strong(parameters[:secret_token], name: 'secret_token', min_bits: parameters[:secret_token_min_bits]) if parameters.key?(:secret_token)
|
|
55
|
+
# allowed_ips: optional IP addresses or networks (CIDR); nil means no restriction
|
|
56
|
+
parameters[:allowed_ips] = Array(parameters[:allowed_ips]).map { |ip| ip_address(ip) } if parameters.key?(:allowed_ips)
|
|
57
|
+
parameters
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Start the server, until signal INT or TERM
|
|
61
|
+
# Parameters are checked before: the servlet is created on each request
|
|
62
|
+
# @param parameters [Hash] Parameters of the server (see `WebServerSimple.url`) and of the servlet (see `normalize_parameters`)
|
|
63
|
+
# @param type [Exception] Exception raised when the server is not protected
|
|
64
|
+
def serve(parameters, type: ParameterError)
|
|
65
|
+
servlet_parameters = normalize_parameters(WebServerSimple.servlet_parameters(parameters).merge(root: WebServerSimple.url(parameters).path))
|
|
66
|
+
# Scripts can be started by anyone who can reach the server
|
|
67
|
+
WebServerSimple.serve(
|
|
68
|
+
parameters,
|
|
69
|
+
self,
|
|
70
|
+
protected: servlet_parameters.key?(:secret_token) || servlet_parameters.key?(:allowed_ips),
|
|
71
|
+
what: 'secret_token or allowed_ips',
|
|
72
|
+
type: type
|
|
73
|
+
) { [servlet_parameters] }
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
private
|
|
77
|
+
|
|
78
|
+
# @param ip [String, IPAddr] IP address or network (CIDR)
|
|
79
|
+
# @return [IPAddr] Parsed address
|
|
80
|
+
def ip_address(ip)
|
|
81
|
+
return ip if ip.is_a?(IPAddr)
|
|
82
|
+
IPAddr.new(ip)
|
|
83
|
+
rescue IPAddr::Error
|
|
84
|
+
raise ArgumentError, "allowed_ips: invalid IP address or network: #{ip}"
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# @param server [WEBrick::HTTPServer] Server
|
|
89
|
+
# @param parameters [Hash] Parameters from `normalize_parameters`: the servlet is created on each request
|
|
15
90
|
def initialize(server, parameters)
|
|
16
|
-
|
|
17
|
-
@parameters = parameters.symbolize_keys
|
|
91
|
+
@parameters = parameters
|
|
18
92
|
Log.dump(:post_proc_parameters, @parameters)
|
|
19
|
-
not_allowed = @parameters.keys - ALLOWED_PARAMETERS
|
|
20
|
-
Aspera.assert(not_allowed.empty?) { "unsupported parameters: #{not_allowed.join(', ')}" }
|
|
21
|
-
@parameters[:script_folder] ||= '.'
|
|
22
|
-
@parameters[:fail_on_error] ||= false
|
|
23
|
-
@parameters[:timeout_seconds] ||= 60
|
|
24
93
|
super(server)
|
|
25
94
|
Log.log.debug { 'Faspex4PostProcServlet initialized' }
|
|
26
95
|
end
|
|
@@ -28,41 +97,60 @@ module Aspera
|
|
|
28
97
|
# :reek:UncommunicativeMethodName
|
|
29
98
|
def do_POST(request, response)
|
|
30
99
|
Log.log.debug { "request=#{request.path}" }
|
|
31
|
-
|
|
100
|
+
# `request.query` only parses a form body for POST: parameters in the URL come from the query string
|
|
101
|
+
url_query = WEBrick::HTTPUtils.parse_query(request.query_string)
|
|
102
|
+
Log.dump(:query, url_query)
|
|
103
|
+
post_proc_pid = nil
|
|
104
|
+
script_path = nil
|
|
32
105
|
begin
|
|
33
|
-
#
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
106
|
+
# Refuse requests of web pages (also on localhost without protection)
|
|
107
|
+
check_request(request, json: true)
|
|
108
|
+
# Check source IP against allowlist when configured
|
|
109
|
+
if @parameters[:allowed_ips]
|
|
110
|
+
client_ip = request.peeraddr[3]
|
|
111
|
+
unless allowed_ip?(client_ip)
|
|
112
|
+
Log.log.warn { "Rejected request from disallowed IP: #{client_ip}" }
|
|
113
|
+
return error_response(response, 403, 'Forbidden')
|
|
114
|
+
end
|
|
39
115
|
end
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
response
|
|
44
|
-
return
|
|
116
|
+
# Verify shared secret when configured (expected as ?token=VALUE in the URL)
|
|
117
|
+
if @parameters[:secret_token] && !OpenSSL.secure_compare(url_query['token'].to_s, @parameters[:secret_token].to_s)
|
|
118
|
+
Log.log.warn { 'Rejected request with invalid or missing token' }
|
|
119
|
+
return error_response(response, 401, 'Unauthorized')
|
|
45
120
|
end
|
|
121
|
+
# Only accept requests on the root
|
|
122
|
+
return error_response(response, 400, 'Request outside domain') unless request.path.start_with?(@parameters[:root])
|
|
123
|
+
return error_response(response, 400, 'Empty request') if request.body.nil?
|
|
124
|
+
webhook_parameters =
|
|
125
|
+
begin
|
|
126
|
+
JSON.parse(request.body)
|
|
127
|
+
rescue JSON::ParserError
|
|
128
|
+
nil
|
|
129
|
+
end
|
|
130
|
+
return error_response(response, 400, 'Request body shall be a JSON object') unless webhook_parameters.is_a?(Hash)
|
|
131
|
+
Log.dump(:webhook_parameters, webhook_parameters)
|
|
46
132
|
# build script path by removing domain and adding script folder
|
|
47
133
|
script_file = request.path[@parameters[:root].size..]
|
|
48
134
|
Log.log.debug { "script file=#{script_file}" }
|
|
49
|
-
script_path = File.join(@parameters[:script_folder], script_file)
|
|
50
135
|
# Resolve the real path and ensure it stays within script_folder (prevents path traversal)
|
|
51
136
|
resolved_script_folder = File.realpath(@parameters[:script_folder])
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
137
|
+
begin
|
|
138
|
+
script_path = File.realpath(File.join(@parameters[:script_folder], script_file))
|
|
139
|
+
rescue Errno::ENOENT, Errno::ENOTDIR
|
|
140
|
+
return error_response(response, 404, 'Script not found')
|
|
141
|
+
end
|
|
142
|
+
unless script_path.start_with?("#{resolved_script_folder}/")
|
|
143
|
+
Log.log.warn { "Rejected script outside script_folder: #{script_path}" }
|
|
144
|
+
return error_response(response, 403, 'Script path traversal attempt detected')
|
|
145
|
+
end
|
|
146
|
+
return error_response(response, 404, 'Script not found') unless File.file?(script_path)
|
|
55
147
|
Log.log.debug { "script=#{script_path}" }
|
|
56
|
-
webhook_parameters = JSON.parse(request.body)
|
|
57
|
-
Log.dump(:webhook_parameters, webhook_parameters)
|
|
58
148
|
process_status = nil
|
|
59
|
-
if
|
|
60
|
-
|
|
61
|
-
|
|
149
|
+
if url_query.key?('lambda')
|
|
150
|
+
return error_response(response, 400, 'lambda requires a Ruby script (.rb)') unless File.extname(script_path).eql?('.rb')
|
|
151
|
+
run_lambda(script_path, webhook_parameters)
|
|
62
152
|
else
|
|
63
|
-
|
|
64
|
-
environment = webhook_parameters.each_with_object({}) { |(k, v), h| h[k] = v.to_s }
|
|
65
|
-
post_proc_pid = Environment.secure_execute(script_path, mode: :background, env: environment)
|
|
153
|
+
post_proc_pid = Environment.secure_execute(script_path, mode: :background, env: script_environment(webhook_parameters))
|
|
66
154
|
Timeout.timeout(@parameters[:timeout_seconds]) do
|
|
67
155
|
# "wait" for process to avoid zombie
|
|
68
156
|
Process.wait(post_proc_pid)
|
|
@@ -71,21 +159,76 @@ module Aspera
|
|
|
71
159
|
process_status = $CHILD_STATUS
|
|
72
160
|
Aspera.assert(process_status.success? || !@parameters[:fail_on_error]) { "script #{script_path} failed with code #{process_status.exitstatus}" }
|
|
73
161
|
end
|
|
74
|
-
response
|
|
75
|
-
response.content_type = Mime::JSON
|
|
76
|
-
response.body = JSON.generate({status: 'success', script: script_path, exit_code: process_status&.exitstatus})
|
|
162
|
+
json_response(response, 200, {status: 'success', script: script_path, exit_code: process_status&.exitstatus})
|
|
77
163
|
Log.log.debug { 'Script executed successfully' }
|
|
78
|
-
rescue => e
|
|
164
|
+
rescue WEBrick::HTTPStatus::Error => e
|
|
165
|
+
Log.log.warn { "Rejected request: #{e.message}" }
|
|
166
|
+
error_response(response, e.code, e.message)
|
|
167
|
+
rescue StandardError, ScriptError => e
|
|
168
|
+
# ScriptError: syntax error in lambda script
|
|
79
169
|
Log.log.error("Script failed: #{e.class}:#{e.message}")
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
170
|
+
kill_script(post_proc_pid) unless post_proc_pid.nil?
|
|
171
|
+
error_response(response, 500, e.message, script: script_path)
|
|
172
|
+
end
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
private
|
|
176
|
+
|
|
177
|
+
# Set an error in the response
|
|
178
|
+
# @param response [WEBrick::HTTPResponse] Response to set
|
|
179
|
+
# @param status [Integer] HTTP status code
|
|
180
|
+
# @param message [String] Error message
|
|
181
|
+
# @param info [Hash] Additional fields in body
|
|
182
|
+
# @return nil
|
|
183
|
+
def error_response(response, status, message, **info)
|
|
184
|
+
json_response(response, status, {status: 'error', **info, message: message})
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
# @param client_ip [String] Source IP address of the request
|
|
188
|
+
# @return [Boolean] `true` if the address is in one of `allowed_ips`
|
|
189
|
+
def allowed_ip?(client_ip)
|
|
190
|
+
# IPv4 clients of a dual-stack listener appear as IPv4-mapped IPv6 addresses
|
|
191
|
+
address = IPAddr.new(client_ip).native
|
|
192
|
+
@parameters[:allowed_ips].any? { |allowed| allowed.include?(address) }
|
|
193
|
+
rescue IPAddr::Error
|
|
194
|
+
false
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
# Environment variables of the script: same as Faspex 4 post-processing
|
|
198
|
+
# Other keys are ignored: they could change the behavior of the script (e.g. `PATH`, `LD_PRELOAD`)
|
|
199
|
+
# @param webhook_parameters [Hash] Values received in webhook
|
|
200
|
+
# @return [Hash] Environment variables, values are `String`
|
|
201
|
+
def script_environment(webhook_parameters)
|
|
202
|
+
webhook_parameters.each_with_object({}) do |(key, value), env|
|
|
203
|
+
unless key.start_with?(ENV_KEY_PREFIX)
|
|
204
|
+
Log.log.warn { "Ignoring webhook value with key not starting with #{ENV_KEY_PREFIX}: #{key.inspect}" }
|
|
205
|
+
next
|
|
84
206
|
end
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
response.body = {status: 'error', script: script_path, message: e.message}.to_json
|
|
207
|
+
# Structured values are given as JSON
|
|
208
|
+
env[key] = value.is_a?(Hash) || value.is_a?(Array) ? JSON.generate(value) : value.to_s
|
|
88
209
|
end
|
|
89
210
|
end
|
|
211
|
+
|
|
212
|
+
# Evaluate a Ruby script in this process, and call the lambda it returns
|
|
213
|
+
# @param script_path [String] Path of Ruby script
|
|
214
|
+
# @param webhook_parameters [Hash] Values received in webhook
|
|
215
|
+
def run_lambda(script_path, webhook_parameters)
|
|
216
|
+
Timeout.timeout(@parameters[:timeout_seconds]) do
|
|
217
|
+
code = Environment.secure_eval(File.read(script_path), script_path, 1)
|
|
218
|
+
Aspera.assert(code.respond_to?(:call)) { "script #{script_path} shall return a lambda" }
|
|
219
|
+
code.call(webhook_parameters)
|
|
220
|
+
end
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
# Kill the script after a timeout or an error
|
|
224
|
+
# @param pid [Integer] Process id of the script
|
|
225
|
+
def kill_script(pid)
|
|
226
|
+
Process.kill('SIGKILL', pid)
|
|
227
|
+
Process.wait(pid)
|
|
228
|
+
Log.log.error("Killed process: #{pid}")
|
|
229
|
+
rescue SystemCallError
|
|
230
|
+
# already terminated
|
|
231
|
+
nil
|
|
232
|
+
end
|
|
90
233
|
end
|
|
91
234
|
end
|