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
@@ -1,13 +1,20 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'aspera/web_server_simple'
4
+ require 'aspera/servlet_helpers'
5
+ require 'aspera/web_page'
4
6
  require 'aspera/assert'
5
- require 'erb'
6
7
 
7
8
  module Aspera
8
9
  module WebAuth
9
10
  # servlet called on callback: it records the callback request
10
11
  class Servlet < WEBrick::HTTPServlet::AbstractServlet
12
+ include ServletHelpers
13
+
14
+ # ERB template of page displayed in browser after authentication
15
+ HTML_TEMPLATE_PATH = File.join(__dir__, 'index.html.erb')
16
+ private_constant :HTML_TEMPLATE_PATH
17
+
11
18
  # @param server [WEBrick::HTTPServer] the HTTP server instance
12
19
  # @param web_auth [Server] the Server instance to record the callback
13
20
  def initialize(server, web_auth)
@@ -20,12 +27,14 @@ module Aspera
20
27
 
21
28
  def service(request, response)
22
29
  Log.log.debug { "received request from browser #{request.request_method} #{request.path}" }
23
- Aspera.assert_values(request.request_method, ['GET'], type: WEBrick::HTTPStatus::MethodNotAllowed) { "Unsupported method: #{req.request_method}" }
24
- Aspera.assert_values(request.path, ['/'], type: WEBrick::HTTPStatus::NotFound) { "Unsupported path: #{req.request_method}" }
30
+ # Refuse requests of other web pages
31
+ check_request(request)
32
+ Aspera.assert_values(request.request_method, ['GET'], type: WEBrick::HTTPStatus::MethodNotAllowed) { "Unsupported method: #{request.request_method}" }
33
+ Aspera.assert_values(request.path, [@web_auth.expected_path], type: WEBrick::HTTPStatus::NotFound) { "Unsupported path: #{request.path}" }
25
34
  # response to browser
26
35
  response.status = 200
27
- response.content_type = 'text/html'
28
- response.body = File.read("#{__dir__}/index.html").gsub('__ADD_INFO__', @web_auth.info_html)
36
+ WebPage.secure_headers(response)
37
+ response.body = WebPage.render(HTML_TEMPLATE_PATH, title: 'Authentication Complete', additional_info: @web_auth.additional_info)
29
38
  # continue internal processing
30
39
  @web_auth.signal_request(request)
31
40
  nil
@@ -36,7 +45,7 @@ module Aspera
36
45
  # then start a browser that will callback the local server upon authentication
37
46
  # store the final query
38
47
  class Server < WebServerSimple
39
- attr_reader :info_html
48
+ attr_reader :additional_info, :expected_path
40
49
 
41
50
  # @param endpoint_url [String] e.g. 'https://127.0.0.1:12345'
42
51
  # @param additional_info [String] Information in web page
@@ -47,8 +56,7 @@ module Aspera
47
56
  @cond = ConditionVariable.new
48
57
  @expected_path = uri.path.empty? ? '/' : uri.path
49
58
  @query = nil
50
- @info_html = additional_info.to_s
51
- @info_html = "<p>#{ERB::Util.html_escape(@info_html)}</p>" unless @info_html.empty?
59
+ @additional_info = additional_info
52
60
  # last argument (self) is provided to constructor of servlet
53
61
  mount(@expected_path, Servlet, self)
54
62
  # server runs in thread
@@ -70,8 +78,8 @@ module Aspera
70
78
  # wait for request on web server (main thread)
71
79
  # @return [Hash] the query
72
80
  def received_request
73
- # wait for signal from thread
74
- @mutex.synchronize { @cond.wait(@mutex) }
81
+ # wait for signal from thread, unless the request was already received (signal is not memorized)
82
+ @mutex.synchronize { @cond.wait(@mutex) while @query.nil? }
75
83
  # tell server thread to stop
76
84
  shutdown
77
85
  return @query
@@ -0,0 +1,262 @@
1
+ <%# Layout of HTML pages: style and branding, see Aspera::WebPage. Variables: title, content (HTML of the card) %>
2
+ <!DOCTYPE html>
3
+ <html lang="en">
4
+
5
+ <head>
6
+ <meta charset="utf-8">
7
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
8
+ <%# Light and dark themes: the browser adapts form controls and scrollbars too %>
9
+ <meta name="color-scheme" content="light dark">
10
+ <link rel="icon" type="image/svg+xml"
11
+ href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Crect width='32' height='32' rx='6' fill='%230f62fe'/%3E%3Ctext x='16' y='22' font-family='monospace' font-size='18' font-weight='bold' fill='white' text-anchor='middle'%3E%3E_%3C/text%3E%3C/svg%3E">
12
+ <title><%= h title %></title>
13
+ <style>
14
+ /* Colors: light theme, overridden by the dark theme of the system or browser */
15
+ :root {
16
+ --page-bg-start: #f0f4f8;
17
+ --page-bg-end: #d9e2ec;
18
+ --card-bg: #ffffff;
19
+ --surface: #f4f4f4;
20
+ --border: #e0e0e0;
21
+ --shadow: 0 4px 24px rgba(0, 0, 0, 0.08), 0 1px 3px rgba(0, 0, 0, 0.04);
22
+ --text: #161616;
23
+ --text-secondary: #525252;
24
+ --text-muted: #8d8d8d;
25
+ --accent: #0f62fe;
26
+ --button-bg: #0f62fe;
27
+ --button-bg-hover: #0043ce;
28
+ --on-button: #ffffff;
29
+ --danger: #da1e28;
30
+ --danger-bg: #da1e28;
31
+ --success: #24a148;
32
+ --icon-bg: #edf5ff;
33
+ --icon-bg-ok: #defbe6;
34
+ --icon-bg-ko: #fff1f1;
35
+ --input-bg: #ffffff;
36
+ --input-border: #8d8d8d;
37
+ }
38
+
39
+ @media (prefers-color-scheme: dark) {
40
+ :root {
41
+ --page-bg-start: #121619;
42
+ --page-bg-end: #1d2226;
43
+ --card-bg: #21272a;
44
+ --surface: #2f353a;
45
+ --border: #42484e;
46
+ --shadow: 0 4px 24px rgba(0, 0, 0, 0.45), 0 1px 3px rgba(0, 0, 0, 0.3);
47
+ --text: #f2f4f8;
48
+ --text-secondary: #c1c7cd;
49
+ --text-muted: #878d96;
50
+ --accent: #78a9ff;
51
+ --button-bg: #0f62fe;
52
+ --button-bg-hover: #0050e6;
53
+ --on-button: #ffffff;
54
+ --danger: #ff8389;
55
+ --danger-bg: #da1e28;
56
+ --success: #42be65;
57
+ --icon-bg: rgba(69, 137, 255, 0.18);
58
+ --icon-bg-ok: rgba(66, 190, 101, 0.18);
59
+ --icon-bg-ko: rgba(250, 77, 86, 0.18);
60
+ --input-bg: #121619;
61
+ --input-border: #697077;
62
+ }
63
+ }
64
+
65
+ * {
66
+ box-sizing: border-box;
67
+ margin: 0;
68
+ padding: 0;
69
+ }
70
+
71
+ body {
72
+ font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
73
+ background: linear-gradient(135deg, var(--page-bg-start) 0%, var(--page-bg-end) 100%);
74
+ min-height: 100vh;
75
+ display: flex;
76
+ align-items: center;
77
+ justify-content: center;
78
+ padding: 1.5rem;
79
+ color: var(--text);
80
+ }
81
+
82
+ .card {
83
+ background: var(--card-bg);
84
+ border-radius: 12px;
85
+ box-shadow: var(--shadow);
86
+ border: 1px solid var(--border);
87
+ max-width: 460px;
88
+ width: 100%;
89
+ padding: 2.25rem 2rem;
90
+ text-align: center;
91
+ }
92
+
93
+ .brand {
94
+ display: inline-flex;
95
+ align-items: center;
96
+ gap: 0.5rem;
97
+ margin-bottom: 1.5rem;
98
+ padding: 0.25rem 0.75rem;
99
+ background: var(--surface);
100
+ border-radius: 16px;
101
+ font-size: 0.8rem;
102
+ color: var(--text-secondary);
103
+ border: 1px solid var(--border);
104
+ }
105
+
106
+ .brand .badge {
107
+ font-weight: 700;
108
+ color: var(--accent);
109
+ font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
110
+ }
111
+
112
+ /* Icons are drawn with currentColor */
113
+ .icon-circle {
114
+ width: 56px;
115
+ height: 56px;
116
+ border-radius: 50%;
117
+ margin: 0 auto 1.25rem;
118
+ display: flex;
119
+ align-items: center;
120
+ justify-content: center;
121
+ background: var(--icon-bg);
122
+ color: var(--accent);
123
+ }
124
+
125
+ .icon-circle.ok {
126
+ background: var(--icon-bg-ok);
127
+ color: var(--success);
128
+ }
129
+
130
+ .icon-circle.ko {
131
+ background: var(--icon-bg-ko);
132
+ color: var(--danger);
133
+ }
134
+
135
+ h1 {
136
+ font-size: 1.5rem;
137
+ font-weight: 600;
138
+ color: var(--text);
139
+ margin-bottom: 0.5rem;
140
+ }
141
+
142
+ p {
143
+ font-size: 0.95rem;
144
+ line-height: 1.5;
145
+ color: var(--text-secondary);
146
+ margin-top: 0.5rem;
147
+ }
148
+
149
+ .client {
150
+ font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
151
+ font-weight: 600;
152
+ background: var(--surface);
153
+ padding: 0.15rem 0.4rem;
154
+ border-radius: 4px;
155
+ color: var(--text);
156
+ border: 1px solid var(--border);
157
+ word-break: break-all;
158
+ }
159
+
160
+ .actions {
161
+ display: flex;
162
+ gap: 0.75rem;
163
+ margin-top: 1.5rem;
164
+ justify-content: center;
165
+ }
166
+
167
+ button {
168
+ flex: 1;
169
+ padding: 0.75rem 1.25rem;
170
+ font-size: 0.95rem;
171
+ font-weight: 500;
172
+ border-radius: 6px;
173
+ cursor: pointer;
174
+ transition: background-color 0.15s ease, border-color 0.15s ease, color 0.15s ease;
175
+ border: 1px solid transparent;
176
+ }
177
+
178
+ .allow {
179
+ background: var(--button-bg);
180
+ color: var(--on-button);
181
+ border-color: var(--button-bg);
182
+ }
183
+
184
+ .allow:hover {
185
+ background: var(--button-bg-hover);
186
+ border-color: var(--button-bg-hover);
187
+ }
188
+
189
+ .deny {
190
+ background: var(--card-bg);
191
+ color: var(--danger);
192
+ border: 1px solid var(--danger);
193
+ }
194
+
195
+ .deny:hover {
196
+ background: var(--danger-bg);
197
+ border-color: var(--danger-bg);
198
+ color: var(--on-button);
199
+ }
200
+
201
+ .secret {
202
+ display: block;
203
+ margin-top: 1.25rem;
204
+ text-align: left;
205
+ font-size: 0.875rem;
206
+ color: var(--text-secondary);
207
+ }
208
+
209
+ .secret input {
210
+ display: block;
211
+ width: 100%;
212
+ margin-top: 0.35rem;
213
+ padding: 0.6rem 0.75rem;
214
+ font-size: 0.95rem;
215
+ background: var(--input-bg);
216
+ color: var(--text);
217
+ border: 1px solid var(--input-border);
218
+ border-radius: 6px;
219
+ }
220
+
221
+ .error {
222
+ color: var(--danger);
223
+ font-weight: 600;
224
+ }
225
+
226
+ .info-box {
227
+ background: var(--surface);
228
+ border-radius: 6px;
229
+ padding: 0.75rem 1rem;
230
+ margin-top: 1.25rem;
231
+ font-size: 0.875rem;
232
+ color: var(--text);
233
+ word-break: break-word;
234
+ border-left: 3px solid var(--accent);
235
+ text-align: left;
236
+ }
237
+
238
+ .footer {
239
+ margin-top: 1.5rem;
240
+ padding-top: 1rem;
241
+ border-top: 1px solid var(--surface);
242
+ font-size: 0.75rem;
243
+ color: var(--text-muted);
244
+ }
245
+ </style>
246
+ </head>
247
+
248
+ <body>
249
+ <div class="card">
250
+ <div class="brand">
251
+ <span class="badge">ascli</span>
252
+ <span>IBM Aspera CLI</span>
253
+ </div>
254
+
255
+ <%= content %>
256
+ <div class="footer">
257
+ IBM Aspera Command-Line Interface
258
+ </div>
259
+ </div>
260
+ </body>
261
+
262
+ </html>
@@ -0,0 +1,61 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'erb'
4
+
5
+ module Aspera
6
+ # HTML pages of embedded web servers, sharing a layout: style and branding.
7
+ # The ERB template of each page is the content of the card of the layout, with its own variables.
8
+ module WebPage
9
+ # ERB template of the layout, with variables `title` and `content`
10
+ LAYOUT_PATH = File.join(__dir__, 'web_page.html.erb')
11
+ # Context of templates: variables as methods, and `h` to escape HTML
12
+ class Context
13
+ include ERB::Util
14
+
15
+ # @param variables [Hash{Symbol => Object}] Variables of the template
16
+ def initialize(variables)
17
+ variables.each { |name, value| define_singleton_method(name) { value } }
18
+ end
19
+
20
+ # @param template_path [String] Path of the ERB template
21
+ # @return [String] Result of the template
22
+ def render(template_path)
23
+ ERB.new(File.read(template_path)).result(template_binding)
24
+ end
25
+
26
+ private
27
+
28
+ # @return [Binding] Binding without local variables, so that they do not hide template variables
29
+ def template_binding = binding
30
+ end
31
+ # Headers of HTML pages: type, and protection against clickjacking, caching, and leak of the URL to other sites.
32
+ # Not `Referrer-Policy: no-referrer`: the browser would send `Origin: null` with a form, refused by ServletHelpers#check_request
33
+ HEADERS = {
34
+ 'Content-Type' => 'text/html; charset=utf-8',
35
+ 'X-Frame-Options' => 'DENY',
36
+ 'Content-Security-Policy' => "frame-ancestors 'none'",
37
+ 'Cache-Control' => 'no-store',
38
+ 'Referrer-Policy' => 'same-origin'
39
+ }.freeze
40
+ private_constant :LAYOUT_PATH, :Context, :HEADERS
41
+
42
+ class << self
43
+ # Set the headers of an HTML page: type and security headers
44
+ # @param response [WEBrick::HTTPResponse]
45
+ # @return [nil]
46
+ def secure_headers(response)
47
+ HEADERS.each { |name, value| response[name] = value }
48
+ nil
49
+ end
50
+
51
+ # @param template_path [String] Path of the ERB template of the content of the page
52
+ # @param title [String] Title of the page
53
+ # @param variables [Hash] Variables of the template of the content
54
+ # @return [String] HTML page
55
+ def render(template_path, title:, **variables)
56
+ content = Context.new(variables).render(template_path)
57
+ Context.new(title: title, content: content).render(LAYOUT_PATH)
58
+ end
59
+ end
60
+ end
61
+ end
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'delegate'
4
+ require 'ipaddr'
3
5
  require 'webrick'
4
6
  require 'aspera/log'
5
7
  require 'aspera/assert'
@@ -15,9 +17,67 @@ module Aspera
15
17
  PKCS12_EXT = %w[p12 pfx].map { |i| ".#{i}" }.freeze
16
18
  CLOCK_SKEW_OFFSET_SEC = 5
17
19
 
18
- private_constant :GENERIC_ISSUER, :ONE_YEAR_SECONDS, :PKCS12_EXT, :CLOCK_SKEW_OFFSET_SEC
20
+ private_constant :PARAMS, :DEFAULT_URL, :GENERIC_ISSUER, :ONE_YEAR_SECONDS, :PKCS12_EXT, :CLOCK_SKEW_OFFSET_SEC
21
+
22
+ # Logger of WEBrick: its information messages (version, start, shutdown) at debug level, the server logs its URL
23
+ class WebrickLogger < SimpleDelegator
24
+ def info(message = nil, &block) = debug(message, &block)
25
+ def info? = debug?
26
+ end
27
+ private_constant :WebrickLogger
28
+
29
+ # @return [URI] URL of the server, with the port it listens on: any free port if `0` was given
30
+ attr_reader :uri
19
31
 
20
32
  class << self
33
+ # @param host [String] Host name or IP address the server listens on
34
+ # @return [Boolean] `true` if the server is reachable from the local host only
35
+ def loopback?(host)
36
+ return true if host.eql?('localhost')
37
+ address = IPAddr.new(host)
38
+ # Older versions of ipaddr do not see IPv4-mapped IPv6 addresses (::ffff:127.0.0.1) as loopback
39
+ address = address.native if address.ipv4_mapped?
40
+ address.loopback?
41
+ rescue IPAddr::Error
42
+ false
43
+ end
44
+
45
+ # @param parameters [Hash] Parameters of a command that starts a server (symbol keys): `url`, `cert`, `key`, `chain`, others are for the servlet
46
+ # @return [URI] URL of the server: `url`, or `DEFAULT_URL`
47
+ def url(parameters) = URI.parse(parameters.fetch(:url, DEFAULT_URL))
48
+
49
+ # @param parameters [Hash] Parameters of a command that starts a server, see `url`
50
+ # @return [Hash] Parameters of the servlet: other than `url`, `cert`, `key`, `chain`
51
+ def servlet_parameters(parameters) = parameters.except(:url, *PARAMS)
52
+
53
+ # Start a server with one servlet on the path of `url`, until signal INT or TERM.
54
+ # A server without protection reachable from the network is refused, see `assert_protected`.
55
+ # @param parameters [Hash] Parameters of the command, see `url`
56
+ # @param servlet [Class] Servlet
57
+ # @param protected [Boolean] `true` if requests are authenticated or restricted
58
+ # @param what [String] Parameters that protect the server, for the error message
59
+ # @param type [Exception] Exception to raise
60
+ # @yieldreturn [Array] Arguments of the servlet, evaluated once parameters are checked
61
+ def serve(parameters, servlet, protected:, what:, type: ParameterError)
62
+ uri = url(parameters)
63
+ assert_protected(uri.hostname, protected: protected, what: what, type: type)
64
+ server = new(uri, **parameters.slice(*PARAMS))
65
+ server.mount(uri.path, servlet, *yield)
66
+ server.start
67
+ end
68
+
69
+ # Refuse a server without protection reachable from the network: anyone could call it
70
+ # @param host [String] Host name or IP address the server listens on
71
+ # @param protected [Boolean] `true` if requests are authenticated or restricted
72
+ # @param what [String] Parameters that protect the server, for the error message
73
+ # @param type [Exception] Exception to raise
74
+ # @raise [Exception] if not protected and `host` is not a loopback address
75
+ def assert_protected(host, protected:, what:, type: ParameterError)
76
+ Aspera.assert(protected || loopback?(host), type: type) do
77
+ "#{what} is required when the server listens on an address that is not loopback (localhost, 127.0.0.0/8, ::1): #{host}"
78
+ end
79
+ end
80
+
21
81
  # Generate or fill and self sign certificate
22
82
  def self_signed_cert(private_key, digest: 'SHA256')
23
83
  cert = OpenSSL::X509::Certificate.new
@@ -46,18 +106,19 @@ module Aspera
46
106
  end
47
107
  end
48
108
 
49
- # @param url [URI] Local address where server will listen (use scheme, host and port only)
109
+ # @param uri [URI] Local address where server will listen (use scheme, host and port only), port `0` for any free port
50
110
  # @param cert [String] Path to certificate file, either with extension .p12 or .pfx, else assumed PEM
51
111
  # @param key [String] Path to key file (PEM) or passphrase (pkcs12)
52
112
  # @param chain [String] Path to certificate chain file (PEM only)
53
113
  def initialize(uri, cert: nil, key: nil, chain: nil)
54
114
  Aspera.assert_type(uri, URI)
55
- @uri = uri
115
+ @uri = uri.dup
56
116
  # see https://www.rubydoc.info/stdlib/webrick/WEBrick/Config
57
117
  webrick_options = {
58
- BindAddress: @uri.host,
118
+ # without brackets of IPv6 address
119
+ BindAddress: @uri.hostname,
59
120
  Port: @uri.port,
60
- Logger: Log.log,
121
+ Logger: WebrickLogger.new(Log.log),
61
122
  AccessLog: [[self, WEBrick::AccessLog::COMMON_LOG_FORMAT]] # replace default access log to call local method "<<" below
62
123
  }
63
124
  case @uri.scheme
@@ -92,7 +153,7 @@ module Aspera
92
153
  else
93
154
  OpenSSL::X509::Certificate.new(File.read(cert))
94
155
  end
95
- webrick_options[:SSLExtraChainCert] = read_chain_file(chain) unless chain.nil?
156
+ webrick_options[:SSLExtraChainCert] = self.class.read_chain_file(chain) unless chain.nil?
96
157
  Aspera.assert(webrick_options[:SSLCertificate].public_key.to_der == webrick_options[:SSLPrivateKey].public_key.to_der, type: Error) { 'key and cert do not match' }
97
158
  end
98
159
  end
@@ -100,14 +161,19 @@ module Aspera
100
161
  # self signed certificate generates characters on STDERR
101
162
  # see create_self_signed_cert in webrick/ssl.rb
102
163
  Log.capture_stderr { super(webrick_options) }
164
+ @uri.port = config[:Port]
103
165
  end
104
166
 
105
- # blocking
167
+ # Serve requests until #shutdown.
168
+ # In the main thread, signals INT and TERM call #shutdown.
169
+ # In another thread, signals are left to the application, which calls #shutdown, e.g. INT interrupts the main thread.
106
170
  def start
107
171
  Log.log.info { "Listening on #{@uri}" }
108
- # kill (-TERM) for graceful shutdown
109
- handler = proc { shutdown }
110
- %i{INT TERM}.each { |sig| trap(sig, &handler) }
172
+ if Thread.current.equal?(Thread.main)
173
+ # kill (-TERM) for graceful shutdown
174
+ handler = proc { shutdown }
175
+ %i{INT TERM}.each { |sig| trap(sig, &handler) }
176
+ end
111
177
  super
112
178
  end
113
179
 
data.tar.gz.sig CHANGED
Binary file