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.
Files changed (139) hide show
  1. checksums.yaml +4 -4
  2. checksums.yaml.gz.sig +0 -0
  3. data/CHANGELOG.md +160 -4
  4. data/CONTRIBUTING.md +4 -2
  5. data/README.md +1 -1
  6. data/bin/asession +1 -1
  7. data/docs/README.md +1086 -331
  8. data/docs/test-mcp-with-ai.md +5 -3
  9. data/lib/aspera/agent/direct.rb +15 -4
  10. data/lib/aspera/agent/factory.rb +2 -1
  11. data/lib/aspera/agent/node.rb +1 -1
  12. data/lib/aspera/api/cos_node.rb +1 -1
  13. data/lib/aspera/api/faspex.rb +1 -1
  14. data/lib/aspera/api/node.rb +6 -2
  15. data/lib/aspera/ascp/installation.rb +47 -11
  16. data/lib/aspera/ascp/management.rb +89 -43
  17. data/lib/aspera/cli/ascp_actions.rb +5 -19
  18. data/lib/aspera/cli/bootstrapper.rb +1 -2
  19. data/lib/aspera/cli/command_registry.rb +43 -3
  20. data/lib/aspera/cli/command_spec.rb +3 -0
  21. data/lib/aspera/cli/completion/ascli.bash +39 -5
  22. data/lib/aspera/cli/completion/ascli.fish +16 -8
  23. data/lib/aspera/cli/completion/ascli.ps1 +41 -0
  24. data/lib/aspera/cli/completion/ascli.zsh +20 -4
  25. data/lib/aspera/cli/completion_actions.rb +360 -0
  26. data/lib/aspera/cli/error.rb +42 -0
  27. data/lib/aspera/cli/extended_value.rb +30 -5
  28. data/lib/aspera/cli/formatter.rb +118 -56
  29. data/lib/aspera/cli/hints.rb +0 -1
  30. data/lib/aspera/cli/http.rb +6 -5
  31. data/lib/aspera/cli/info.rb +10 -4
  32. data/lib/aspera/cli/mailer.rb +1 -1
  33. data/lib/aspera/cli/mcp/http_servlet.rb +156 -0
  34. data/lib/aspera/cli/mcp/secrets.rb +143 -0
  35. data/lib/aspera/cli/mcp/tool.rb +231 -0
  36. data/lib/aspera/cli/option_value.rb +9 -1
  37. data/lib/aspera/cli/options.schema.yaml +390 -39
  38. data/lib/aspera/cli/parser.rb +14 -5
  39. data/lib/aspera/cli/plugins/aoc.rb +8 -11
  40. data/lib/aspera/cli/plugins/base.rb +36 -15
  41. data/lib/aspera/cli/plugins/config.rb +15 -44
  42. data/lib/aspera/cli/plugins/console.rb +3 -1
  43. data/lib/aspera/cli/plugins/factory.rb +1 -1
  44. data/lib/aspera/cli/plugins/faspex5.rb +9 -19
  45. data/lib/aspera/cli/plugins/faspio.rb +36 -5
  46. data/lib/aspera/cli/plugins/httpgw.rb +1 -0
  47. data/lib/aspera/cli/plugins/mcp.rb +177 -118
  48. data/lib/aspera/cli/plugins/node.rb +14 -8
  49. data/lib/aspera/cli/plugins/orchestrator.rb +351 -38
  50. data/lib/aspera/cli/plugins/server.rb +3 -1
  51. data/lib/aspera/cli/plugins/shares.rb +205 -35
  52. data/lib/aspera/cli/preset_actions.rb +15 -7
  53. data/lib/aspera/cli/preset_manager.rb +24 -2
  54. data/lib/aspera/cli/prompt.rb +3 -1
  55. data/lib/aspera/cli/result.rb +17 -4
  56. data/lib/aspera/cli/runner.rb +133 -43
  57. data/lib/aspera/cli/security.rb +134 -0
  58. data/lib/aspera/cli/table_fitter.rb +116 -0
  59. data/lib/aspera/cli/terminal_formatter.rb +23 -34
  60. data/lib/aspera/cli/transfer_agent.rb +4 -1
  61. data/lib/aspera/cli/vault_manager.rb +6 -5
  62. data/lib/aspera/cli/version.rb +1 -1
  63. data/lib/aspera/cli/web_secret.html.erb +61 -0
  64. data/lib/aspera/cli/web_secret.rb +220 -0
  65. data/lib/aspera/cli/wizard.rb +19 -9
  66. data/lib/aspera/command_line_builder.rb +26 -0
  67. data/lib/aspera/data_repository.rb +38 -24
  68. data/lib/aspera/duration.rb +42 -0
  69. data/lib/aspera/environment.rb +88 -24
  70. data/lib/aspera/exec_spec.rb +6 -5
  71. data/lib/aspera/faspex_gw.rb +98 -32
  72. data/lib/aspera/faspex_postproc.rb +187 -44
  73. data/lib/aspera/formatter_interface.rb +0 -1
  74. data/lib/aspera/id_generator.rb +2 -1
  75. data/lib/aspera/json_rpc/client.rb +51 -32
  76. data/lib/aspera/json_rpc/version.rb +1 -0
  77. data/lib/aspera/keychain/base.rb +5 -0
  78. data/lib/aspera/keychain/encrypted_hash.rb +6 -2
  79. data/lib/aspera/keychain/factory.rb +2 -3
  80. data/lib/aspera/keychain/hashicorp_vault.rb +7 -4
  81. data/lib/aspera/keychain/macos_security.rb +119 -13
  82. data/lib/aspera/keychain/one_password_api.rb +8 -3
  83. data/lib/aspera/keychain/one_password_base.rb +14 -9
  84. data/lib/aspera/keychain/one_password_cli.rb +4 -2
  85. data/lib/aspera/log.rb +3 -6
  86. data/lib/aspera/markdown.rb +152 -115
  87. data/lib/aspera/mime.rb +6 -0
  88. data/lib/aspera/nagios.rb +3 -1
  89. data/lib/aspera/node_emulator.rb +98 -101
  90. data/lib/aspera/oauth/{base.rb → client/base.rb} +5 -4
  91. data/lib/aspera/oauth/{boot.rb → client/boot.rb} +3 -2
  92. data/lib/aspera/oauth/{factory.rb → client/factory.rb} +7 -0
  93. data/lib/aspera/oauth/{generic.rb → client/generic.rb} +1 -1
  94. data/lib/aspera/oauth/{json_credentials.rb → client/json_credentials.rb} +1 -1
  95. data/lib/aspera/oauth/{jwt.rb → client/jwt.rb} +1 -1
  96. data/lib/aspera/oauth/{url_json.rb → client/url_json.rb} +1 -1
  97. data/lib/aspera/oauth/{web.rb → client/web.rb} +2 -2
  98. data/lib/aspera/oauth/server/authorize.html.erb +34 -0
  99. data/lib/aspera/oauth/server/pkce.rb +426 -0
  100. data/lib/aspera/orchestrator_workflow_checker.rb +561 -0
  101. data/lib/aspera/preview/terminal.rb +3 -2
  102. data/lib/aspera/rainbow.rb +27 -1
  103. data/lib/aspera/rest/aspera_errors.rb +51 -59
  104. data/lib/aspera/rest/client.rb +21 -14
  105. data/lib/aspera/rest/error_analyzer.rb +9 -0
  106. data/lib/aspera/rest/list.rb +6 -6
  107. data/lib/aspera/rest/util.rb +7 -1
  108. data/lib/aspera/rest.rb +0 -1
  109. data/lib/aspera/schema/IBM Aspera Faspex API-5.0-enhanced.yaml +0 -2
  110. data/lib/aspera/schema/IBM Aspera Orchestrator API-v1-enhanced.yaml +4001 -0
  111. data/lib/aspera/schema/IBM Aspera faspio Gateway API-1.0.0-enhanced.yaml +1097 -0
  112. data/lib/aspera/schema/IBM Aspera on Cloud Automation API-1.0.5-enhanced.yaml +35 -35
  113. data/lib/aspera/schema/IBM_Aspera_Shares.yaml +819 -7
  114. data/lib/aspera/schema/documentation.rb +50 -78
  115. data/lib/aspera/schema/reader.rb +39 -11
  116. data/lib/aspera/schema/registry.rb +8 -2
  117. data/lib/aspera/secret_hider.rb +94 -15
  118. data/lib/aspera/secret_strength.rb +114 -0
  119. data/lib/aspera/servlet_helpers.rb +111 -0
  120. data/lib/aspera/sync/args.schema.yaml +3 -1
  121. data/lib/aspera/sync/conf.schema.yaml +16 -6
  122. data/lib/aspera/sync/operations.rb +21 -1
  123. data/lib/aspera/temp_file_manager.rb +79 -20
  124. data/lib/aspera/transfer/parameters.rb +5 -0
  125. data/lib/aspera/transfer/spec.rb +52 -12
  126. data/lib/aspera/transfer/spec.schema.yaml +8 -5
  127. data/lib/aspera/web_auth/index.html.erb +16 -0
  128. data/lib/aspera/web_auth/server.rb +18 -10
  129. data/lib/aspera/web_page.html.erb +262 -0
  130. data/lib/aspera/web_page.rb +61 -0
  131. data/lib/aspera/web_server_simple.rb +76 -10
  132. data.tar.gz.sig +0 -0
  133. metadata +47 -32
  134. metadata.gz.sig +0 -0
  135. data/lib/aspera/cli/mcp_tool.rb +0 -189
  136. data/lib/aspera/oauth.rb +0 -9
  137. data/lib/aspera/schema/IBM Aspera Orchestrator API-v1.yaml +0 -1784
  138. data/lib/aspera/schema/IBM Aspera faspio Gateway API-1.0.0.yaml +0 -249
  139. data/lib/aspera/web_auth/index.html +0 -147
@@ -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
- # Falls back to locale env vars for systems where charmap is not available.
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 terminal_supports_unicode?
222
- return @unicode unless @unicode.nil?
223
- return false unless terminal?
224
- locale_charmap_utf8? || I18N_VARS.any? { |var| ENV[var]&.include?('UTF-8') }
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 returns "CP65001" which Encoding.find resolves as UTF-8
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
- attr_accessor :url_method, :file_illegal_characters
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 system's default graphical browser (non-blocking)
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
- case @os
335
- when Environment::OS_MACOS then self.class.secure_execute('open', uri.to_s)
336
- when Environment::OS_WINDOWS then self.class.secure_execute('start', 'explorer', %Q{"#{uri}"})
337
- when Environment::OS_LINUX then self.class.secure_execute('xdg-open', uri.to_s)
338
- else Aspera.error_unexpected_value(os) { 'no graphical open method' }
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
- case @url_method
360
- when :graphical
419
+ if @browser_command
361
420
  open_uri_graphical(the_url)
362
- when :text
363
- case the_url.to_s
364
- when /^http/
365
- puts "USER ACTION: please enter this URL in a browser:\n#{the_url.to_s.red}\n"
366
- else
367
- puts "USER ACTION: open this:\n#{the_url.to_s.red}\n"
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
 
@@ -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
- ExecSpec = Struct.new(:exec, :env, :args, keyword_init: true) do
6
- def initialize(exec: nil, env: {}, args: [], **) = super
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 :env
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
@@ -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
- # @param app_api [Rest::Client] API object
15
- # @param app_context [String] workspace id (aoc only)
16
- def initialize(server, app_api, app_context)
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
- case request.path
70
- when '/aspera/faspex/send'
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
- Aspera.assert(!request.body.nil?, 'payload missing')
73
- faspex_pkg_parameters = JSON.parse(request.body)
74
- Log.dump(:faspex_pkg_parameters, faspex_pkg_parameters)
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
- else
96
- response.status = 400
97
- response['Content-Type'] = Mime::JSON
98
- response.body = {error: 'Unsupported endpoint'}.to_json
99
- end
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/mime'
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
- ALLOWED_PARAMETERS = %i[root script_folder fail_on_error timeout_seconds].freeze
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
- Aspera.assert_type(parameters, Hash)
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
- Log.dump(:query, request.query)
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
- # Only accept requests on the root
34
- if !request.path.start_with?(@parameters[:root])
35
- response.status = 400
36
- response['Content-Type'] = Mime::JSON
37
- response.body = {status: 'error', message: 'Request outside domain'}.to_json
38
- return
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
- if request.body.nil?
41
- response.status = 400
42
- response['Content-Type'] = Mime::JSON
43
- response.body = {status: 'error', message: 'Empty request'}.to_json
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
- resolved_script_path = File.realpath(script_path)
53
- Aspera.assert(resolved_script_path.start_with?("#{resolved_script_folder}/")) { 'Script path traversal attempt detected' }
54
- script_path = resolved_script_path
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 request.query.key?('lambda')
60
- # Code can throw exception, source code must return a lambda
61
- Environment.secure_eval(File.read(script_path), __FILE__, __LINE__).call(webhook_parameters)
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
- # env expects only strings
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.status = 200
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
- if !post_proc_pid.nil?
81
- Process.kill('SIGKILL', post_proc_pid)
82
- Process.wait(post_proc_pid)
83
- Log.log.error("Killed process: #{post_proc_pid}")
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
- response.status = 500
86
- response['Content-Type'] = Mime::JSON
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
@@ -8,7 +8,6 @@ module Aspera
8
8
  module FormatterInterface
9
9
  Aspera.require_method!(:tick)
10
10
  Aspera.require_method!(:special_format)
11
- Aspera.require_method!(:check_row)
12
11
  Aspera.require_method!(:markdown_text)
13
12
  end
14
13
  end