railwatch 0.3.5 → 0.3.6

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 (134) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +9 -2
  3. data/app/channels/railwatch/environment_channel.rb +17 -4
  4. data/app/models/railwatch/ingest/batch.rb +1 -1
  5. data/app/models/railwatch/ingest/mapper.rb +1 -3
  6. data/docs/embedded.md +17 -6
  7. data/docs/getting-started.md +0 -2
  8. data/docs/source-maps.md +1 -3
  9. data/lib/generators/railwatch/install/install_generator.rb +0 -8
  10. data/lib/railwatch/backtrace.rb +12 -2
  11. data/lib/railwatch/configuration.rb +6 -5
  12. data/lib/railwatch/engine.rb +2 -2
  13. data/lib/railwatch/export/client.rb +10 -3
  14. data/lib/railwatch/reporter.rb +2 -12
  15. data/lib/railwatch/source_maps.rb +13 -1
  16. data/lib/railwatch/spec_helper.rb +1 -1
  17. data/lib/railwatch/transport/http.rb +14 -5
  18. data/lib/railwatch/version.rb +1 -1
  19. data/public/railwatch/assets/{app-layout-CdJ5Qjms.js → app-layout-B5Fcdwfz.js} +1 -1
  20. data/public/railwatch/assets/{app-wordmark-B-0yZiDb.js → app-wordmark-1SRllgHv.js} +1 -1
  21. data/public/railwatch/assets/{appearance-CU28GBf1.js → appearance-DbwahDrc.js} +1 -1
  22. data/public/railwatch/assets/{arrow-up-DY1fS0tD.js → arrow-up-DwKQcab4.js} +1 -1
  23. data/public/railwatch/assets/{auth-layout-C3FKuUJz.js → auth-layout-DXfxXAN_.js} +1 -1
  24. data/public/railwatch/assets/{badge-DKvItdlb.js → badge-CApjyWcg.js} +1 -1
  25. data/public/railwatch/assets/{braces-Bse5Gh3K.js → braces-kGSgxWnS.js} +1 -1
  26. data/public/railwatch/assets/{card-C9j_ffR9.js → card-BaJ8kNkp.js} +1 -1
  27. data/public/railwatch/assets/{chart-Cmh6JEww.js → chart-Xnrz1Nqw.js} +1 -1
  28. data/public/railwatch/assets/{chart-hover-B0SjyWFy.js → chart-hover-XYPNE2CX.js} +1 -1
  29. data/public/railwatch/assets/{chart-panel-DKNxoauI.js → chart-panel-BYk68mJJ.js} +1 -1
  30. data/public/railwatch/assets/{checkbox-BsBkRg5n.js → checkbox-Bf41R8W6.js} +1 -1
  31. data/public/railwatch/assets/{code-C879Qwgg.js → code-a-9sh36A.js} +1 -1
  32. data/public/railwatch/assets/{copy-block-DtO_3esq.js → copy-block-zykZWQym.js} +1 -1
  33. data/public/railwatch/assets/{copy-id-SeLCKsW2.js → copy-id-b6CW5ESL.js} +1 -1
  34. data/public/railwatch/assets/{cursor-load-more-OpHbe_kE.js → cursor-load-more-0cHzbTPT.js} +1 -1
  35. data/public/railwatch/assets/{data-table-Bc3ZRnpb.js → data-table-IvA7xvrK.js} +1 -1
  36. data/public/railwatch/assets/{edit-DDvf9mkq.js → edit-7lmf3k80.js} +1 -1
  37. data/public/railwatch/assets/{edit-BMpgcQhU.js → edit-DTtRV1fO.js} +1 -1
  38. data/public/railwatch/assets/{edit-CTD1jSWh.js → edit-ZKE15r-F.js} +1 -1
  39. data/public/railwatch/assets/{empty-state-CkPROZB0.js → empty-state-BFTnQ2oT.js} +1 -1
  40. data/public/railwatch/assets/{env-layout-aGQgTZRg.js → env-layout-erSDpIXg.js} +1 -1
  41. data/public/railwatch/assets/{execution-path-BX5NcXUZ.js → execution-path-Di5uUHi-.js} +1 -1
  42. data/public/railwatch/assets/{filter-bar-C0O2rJVr.js → filter-bar-Btm__gQi.js} +1 -1
  43. data/public/railwatch/assets/{flamegraph-Vj4QSAlT.js → flamegraph-C-mZdqa7.js} +1 -1
  44. data/public/railwatch/assets/{frames-CTiGwm4E.js → frames-KZBsjg6-.js} +1 -1
  45. data/public/railwatch/assets/{google-sign-in-button-BzogBGeF.js → google-sign-in-button-DJRoD72g.js} +1 -1
  46. data/public/railwatch/assets/{index-chS28dvS.js → index-9LL_C3eG.js} +1 -1
  47. data/public/railwatch/assets/{index-C__S10ze.js → index-B-bSm1jB.js} +1 -1
  48. data/public/railwatch/assets/{index-Dy7Wg880.js → index-B0VXzXYG.js} +1 -1
  49. data/public/railwatch/assets/{index-B-kuWp-m.js → index-BAkZNNEh.js} +1 -1
  50. data/public/railwatch/assets/{index-CPQKOQxV.js → index-BWSyQRZI.js} +1 -1
  51. data/public/railwatch/assets/{index-FgpFDA1Z.js → index-Bjw4sTXU.js} +1 -1
  52. data/public/railwatch/assets/{index-D9cV-IQr.js → index-Bo8hrjbs.js} +1 -1
  53. data/public/railwatch/assets/{index-BCLvKVhP.js → index-BoTOVM0v.js} +1 -1
  54. data/public/railwatch/assets/{index-y2c8xKzI.js → index-Bot7kHMA.js} +1 -1
  55. data/public/railwatch/assets/{index-DbyRgbzQ.js → index-BsrCnQqg.js} +1 -1
  56. data/public/railwatch/assets/{index-BKgAm-w2.js → index-BtSlLmkg.js} +1 -1
  57. data/public/railwatch/assets/{index-DigUOHQ8.js → index-CAzQkw-h.js} +1 -1
  58. data/public/railwatch/assets/{index-ChQ1_u8u.js → index-CBJZQgHA.js} +1 -1
  59. data/public/railwatch/assets/{index-uuKg6Zqn.js → index-CIUl9POV.js} +1 -1
  60. data/public/railwatch/assets/{index-DepurF6s.js → index-CP6jrrPT.js} +1 -1
  61. data/public/railwatch/assets/{index-Bvy021Vv.js → index-CU6yhEyt.js} +1 -1
  62. data/public/railwatch/assets/{index-B2_QluAw.js → index-CX8Br7m9.js} +1 -1
  63. data/public/railwatch/assets/{index-Dcxd_H-U.js → index-CYYmZpZu.js} +1 -1
  64. data/public/railwatch/assets/{index-DxuDLoTc.js → index-CeqMDByC.js} +1 -1
  65. data/public/railwatch/assets/{index-D3Axxkk2.js → index-Cj3_jD1E.js} +1 -1
  66. data/public/railwatch/assets/{index-D5YVcrqR.js → index-D4968-TC.js} +1 -1
  67. data/public/railwatch/assets/{index-DkgImCOg.js → index-DAEzO_vq.js} +1 -1
  68. data/public/railwatch/assets/{index-CtT4T6kp.js → index-DBTxQw77.js} +1 -1
  69. data/public/railwatch/assets/{index-CCLMaY9k.js → index-DCAho97K.js} +1 -1
  70. data/public/railwatch/assets/{index-jSEU_Yp7.js → index-DEfu_YvU.js} +1 -1
  71. data/public/railwatch/assets/{index-CY6lbwAC.js → index-DXUwhSCQ.js} +1 -1
  72. data/public/railwatch/assets/{index-Wb_U2_hz.js → index-DcItJxa5.js} +1 -1
  73. data/public/railwatch/assets/{index-CD4HIHq0.js → index-EGU__DOB.js} +1 -1
  74. data/public/railwatch/assets/{index-LaTKDe0-.js → index-HCJmFcZu.js} +1 -1
  75. data/public/railwatch/assets/{index-BRN_bXV_.js → index-P_e_cI0O.js} +1 -1
  76. data/public/railwatch/assets/{index-Ble4RoA3.js → index-QyHE9WsU.js} +1 -1
  77. data/public/railwatch/assets/{index-B69Iad2U.js → index-Z1c4nFL0.js} +1 -1
  78. data/public/railwatch/assets/{index-z2YfKBJl.js → index-kNhZPLVj.js} +1 -1
  79. data/public/railwatch/assets/{index-CxU1Jj35.js → index-uHBVO8Lh.js} +1 -1
  80. data/public/railwatch/assets/{index-Dep2XomH.js → index-wOj5j0KC.js} +1 -1
  81. data/public/railwatch/assets/{inertia-CgBdJDaE.js → inertia-Degv_G9N.js} +2 -2
  82. data/public/railwatch/assets/{input-error-DfpIawjl.js → input-error-C6SqO_iU.js} +1 -1
  83. data/public/railwatch/assets/{json-viewer-CH7nEIUs.js → json-viewer-DcQIxT86.js} +1 -1
  84. data/public/railwatch/assets/{klass-BTg1l2AO.js → klass-WgQEfWpo.js} +1 -1
  85. data/public/railwatch/assets/{label-BWgQzHCO.js → label-B_vK19tR.js} +1 -1
  86. data/public/railwatch/assets/{layout-neknbxn-.js → layout-C8q7GUlZ.js} +1 -1
  87. data/public/railwatch/assets/{live-dot-Bejk0a5o.js → live-dot-DaXEbkXo.js} +1 -1
  88. data/public/railwatch/assets/{nav-C1M8941-.js → nav-BnD5XArZ.js} +1 -1
  89. data/public/railwatch/assets/{new-BnGIYM37.js → new-BL9OqXHe.js} +1 -1
  90. data/public/railwatch/assets/{new-DGYPcz7w.js → new-BhRnQn4w.js} +1 -1
  91. data/public/railwatch/assets/{new-DElC-JCS.js → new-BqP0NhOF.js} +1 -1
  92. data/public/railwatch/assets/{new-bBwawsEg.js → new-CWP3wtwt.js} +1 -1
  93. data/public/railwatch/assets/{new-B8CzyoMu.js → new-DIjwu2jF.js} +1 -1
  94. data/public/railwatch/assets/{new-DLgCLBiW.js → new-uxMnBtoY.js} +1 -1
  95. data/public/railwatch/assets/{onboarding-CW0jUhHT.js → onboarding-C_Y3xTsH.js} +1 -1
  96. data/public/railwatch/assets/{origin-identity-bJMxeEHB.js → origin-identity-CNhOnfBa.js} +1 -1
  97. data/public/railwatch/assets/{percentile-picker-DLAnTKY3.js → percentile-picker-BujVEhP6.js} +1 -1
  98. data/public/railwatch/assets/{relative-time-BHbJh8HW.js → relative-time-6CoKr1lN.js} +1 -1
  99. data/public/railwatch/assets/{release-health-2O1GRclB.js → release-health-1Qzc_XhT.js} +1 -1
  100. data/public/railwatch/assets/{route-DSw8aIZa.js → route-xqgYjOwJ.js} +1 -1
  101. data/public/railwatch/assets/{segmented-Dg8CES94.js → segmented-CVKq2Xpa.js} +1 -1
  102. data/public/railwatch/assets/{select-CqKpvI98.js → select-CsmSXsJ4.js} +1 -1
  103. data/public/railwatch/assets/{separator-BOp2LHw3.js → separator-CrM10Oot.js} +1 -1
  104. data/public/railwatch/assets/{series-chart-COEtj3PY.js → series-chart-D33UhdG2.js} +1 -1
  105. data/public/railwatch/assets/{show-DrhEDGPn.js → show-8H9WAGav.js} +1 -1
  106. data/public/railwatch/assets/{show-dRDz8OVu.js → show-9jDeBNpc.js} +1 -1
  107. data/public/railwatch/assets/{show-C-xpDkoj.js → show-B0nHy31L.js} +1 -1
  108. data/public/railwatch/assets/{show-B7Zqjlqc.js → show-B3izvRdU.js} +1 -1
  109. data/public/railwatch/assets/{show-CGv85sRo.js → show-B7NhcWgp.js} +1 -1
  110. data/public/railwatch/assets/{show-Zr2xV4OD.js → show-BM-E2iYc.js} +1 -1
  111. data/public/railwatch/assets/{show-B40560ZT.js → show-BlNR3ZOZ.js} +1 -1
  112. data/public/railwatch/assets/{show-C9Y1To9_.js → show-CJMeX75E.js} +1 -1
  113. data/public/railwatch/assets/{show-BAxMoNvw.js → show-CMisHNAI.js} +1 -1
  114. data/public/railwatch/assets/{show-HjjzcKRq.js → show-CQ4Ze-Un.js} +1 -1
  115. data/public/railwatch/assets/{show-CPh-7VGL.js → show-CvNV23gH.js} +1 -1
  116. data/public/railwatch/assets/{show-DqtE0LI6.js → show-DMZlwzL_.js} +1 -1
  117. data/public/railwatch/assets/{show-C_9M2nLy.js → show-DUwQlohF.js} +1 -1
  118. data/public/railwatch/assets/{show-DFlP1ktM.js → show-DZwcZZrz.js} +1 -1
  119. data/public/railwatch/assets/{show-B_QVoqKE.js → show-ixK2r2f1.js} +1 -1
  120. data/public/railwatch/assets/{show-pbkvGRER.js → show-rysBbvt-.js} +1 -1
  121. data/public/railwatch/assets/{show-Bbs1i46N.js → show-zxVxhUel.js} +1 -1
  122. data/public/railwatch/assets/{sort-header-DBxQ_KFX.js → sort-header-CKj_7G3U.js} +1 -1
  123. data/public/railwatch/assets/{sparkline-cell-BbfXw3kA.js → sparkline-cell-Ckg29yVq.js} +1 -1
  124. data/public/railwatch/assets/{stat-Cd8QaTGw.js → stat-TxvoBiC8.js} +1 -1
  125. data/public/railwatch/assets/{status-badge-Ys4b0fcG.js → status-badge-5NCVJHjo.js} +1 -1
  126. data/public/railwatch/assets/{tenant-path-CQWzaNYR.js → tenant-path-B5ZRK4x3.js} +1 -1
  127. data/public/railwatch/assets/{text-link-BY8FY-CJ.js → text-link-BPllRj0F.js} +1 -1
  128. data/public/railwatch/assets/{textarea-Bq2jjOMC.js → textarea-DtD5yZp_.js} +1 -1
  129. data/public/railwatch/assets/{timeline-SxStCFO2.js → timeline-DZz5ZnHN.js} +1 -1
  130. data/public/railwatch/assets/{transition-CQ9yvfPa.js → transition-Ds6Cakuc.js} +1 -1
  131. data/public/railwatch/assets/{use-clipboard-3hTXU91g.js → use-clipboard-BLjjYH8V.js} +1 -1
  132. data/public/railwatch/assets/{use-live-LwJStJvD.js → use-live-GKze1mNC.js} +1 -1
  133. data/public/railwatch/manifest.json +1285 -1285
  134. metadata +115 -115
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 8904a8f775771bd894a52f80b7d5ecf4d8a40d0dee09aaf16697e68685c5e106
4
- data.tar.gz: de27dad2204510d9e8f3d1bb3ada89901ccd467486c94fde9f0b85ff83ab50ca
3
+ metadata.gz: 7670085bcbae37c58def37c8e23ffee964be9277a57462869346017016c33a3c
4
+ data.tar.gz: fb68d72c0863778eb607cfd2bded53714651caaecffefbff20994e25422a1411
5
5
  SHA512:
6
- metadata.gz: 5fe5b808c60a3aac92302f8669b82c539634cb655d7317e98279143c5e920f9178d15920a1e75145c9c153ad5d3bfc41ade5004c1df9f1e7c8de29ab2fc20b76
7
- data.tar.gz: 251237b9c8ee2a3d252c61eef492d89a532e3c7fef856bde91e19d4d3baa5c831ebc34a0bbbd24b2ced390f76f0ef8106c43cee2d68d6fbf25725f01b074298f
6
+ metadata.gz: 429bb5789a56059dce9848403622511df0f5094c2efe4a6ed8c51e5086fb11eaf56b8c762ee9ae68d1ae393a5fae7af65d628df019f01b00466644e207aa6b1d
7
+ data.tar.gz: 0c38280c362f3529ca2af1e93a6982d77ed15a872351072727dd1152396ea2c3f1a2e634f1132a6cac83bf0ee152f818fda15db76e2251a3f0f1a7a24db5bfb1
data/CHANGELOG.md CHANGED
@@ -1,5 +1,13 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.3.6 (2026-09-20)
4
+
5
+ - Harden embedded live-stream authorization and telemetry delivery. Bound
6
+ source-file access and HTTP response reads.
7
+ - Simplify prelaunch interfaces: use prompt, stdin, or environment token input;
8
+ pass the complete transport delivery metadata; use the current process-version
9
+ field. Update public install examples and remove obsolete upgrade wording.
10
+
3
11
  ## 0.3.5 (2026-09-20)
4
12
 
5
13
  - Make the embedded telemetry database give disk back. `PruneTelemetryJob`
@@ -676,8 +684,7 @@ First public release.
676
684
  - The installer now prefers a hidden prompt, stdin, or `RAILWATCH_TOKEN`, never
677
685
  prints token values, and refuses to put a token in a tracked or non-ignored
678
686
  `.env`. `railwatch:doctor` also fails when it finds a plaintext `lt_...` token
679
- in likely secret-bearing files tracked by Git. The legacy `--token=` option
680
- remains compatible but warns about shell-history and process-list exposure.
687
+ in likely secret-bearing files tracked by Git.
681
688
 
682
689
  - A forked worker profiles again. `Process._fork` now resets the profiler's
683
690
  process-global state in the child: it used to inherit `@running` holding
@@ -2,15 +2,15 @@
2
2
 
3
3
  module Railwatch
4
4
  # Live dashboard updates: Ingest::Batch broadcasts "ingested" on
5
- # environment_<id> after each write (throttled to one per 2 s), and the
5
+ # railwatch:environment:<id> after each write (throttled to one per 2 s), and the
6
6
  # page reloads its props. Under HTTP Basic the browser sends the same
7
7
  # Authorization header on the WebSocket handshake, so the channel checks
8
8
  # the same credentials as the pages.
9
9
  #
10
10
  # With Basic off, Configuration#dashboard_channel_allowed? asks whichever
11
11
  # gate the host declared: a dashboard_user resolver can refuse this
12
- # request, a named base controller or an explicit dashboard_open is taken
13
- # at its word, and an undeclared gate refuses. That last case matters
12
+ # request, or dashboard_open explicitly allows public live updates.
13
+ # A named base controller alone cannot authorize a channel. That last case matters
14
14
  # because Action Cable runs on the host's own /cable endpoint: a routes
15
15
  # constraint around the engine's mount does not cover it and a base
16
16
  # controller cannot reach it, so "something in front of /railwatch" is not
@@ -19,7 +19,9 @@ module Railwatch
19
19
  class EnvironmentChannel < ActionCable::Channel::Base
20
20
  def subscribed
21
21
  if params[:id].to_i == Environment::ID && Railwatch.config.dashboard_channel_allowed?(connection_request)
22
- stream_from "environment_#{Environment::ID}"
22
+ stream_from "railwatch:environment:#{Environment::ID}", coder: ActiveSupport::JSON do |message|
23
+ transmit_authorized(message)
24
+ end
23
25
  else
24
26
  reject
25
27
  end
@@ -27,6 +29,17 @@ module Railwatch
27
29
 
28
30
  private
29
31
 
32
+ # A host's resolver can revoke an operator while their socket is open.
33
+ def transmit_authorized(message)
34
+ if Railwatch.config.dashboard_channel_allowed?(connection_request)
35
+ transmit message
36
+ else
37
+ stop_all_streams
38
+ reject
39
+ reject_subscription
40
+ end
41
+ end
42
+
30
43
  # Built from the connection's env rather than asking it for its request:
31
44
  # ActionCable::Connection::Base#request is private (Rails documents it for
32
45
  # use inside a Connection subclass, not from a channel), so the obvious
@@ -340,7 +340,7 @@ module Railwatch
340
340
  return if last && now - last < THROTTLE_WINDOW
341
341
 
342
342
  LAST_BROADCAST_AT[@environment.id] = now
343
- ActionCable.server.broadcast("environment_#{@environment.id}", { event: "ingested", at: now.iso8601, counts: @counts })
343
+ ActionCable.server.broadcast("railwatch:environment:#{@environment.id}", { event: "ingested", at: now.iso8601, counts: @counts })
344
344
  rescue StandardError, LoadError => e
345
345
  # A live refresh ping is not worth failing a written batch over.
346
346
  # LoadError too: a host whose production cable adapter is redis with
@@ -417,9 +417,7 @@ module Railwatch
417
417
  [ Telemetry::Process, {
418
418
  booted_at: timestamp_string(rec["timestamp"]), pid: rec["pid"], role: rec["role"], server: rec["server"],
419
419
  deploy: rec["deploy"], ruby_version: rec["ruby_version"], rails_version: rec["rails_version"],
420
- # Gems older than 0.1.3 send lantern_version; processes recorded
421
- # by one are still readable.
422
- railwatch_version: rec["railwatch_version"] || rec["lantern_version"], boot_seconds: rec["boot_seconds"],
420
+ railwatch_version: rec["railwatch_version"], boot_seconds: rec["boot_seconds"],
423
421
  detail: rec.slice("app", "environment", "database_adapter", "queue_adapter", "cache_store")
424
422
  } ]
425
423
  end
data/docs/embedded.md CHANGED
@@ -165,7 +165,11 @@ c.base_controller_class = "AdminController" # requires an admin, or redirects
165
165
  ```
166
166
 
167
167
  Your controller's code runs inside the engine, whose route helpers take
168
- precedence; reach your app's with `main_app.root_path`.
168
+ precedence; reach your app's with `main_app.root_path`. The controller does
169
+ not run for Action Cable subscriptions: live updates stay closed unless
170
+ `dashboard_user` also authorizes the connection (as below). Return `nil` or
171
+ `false` for unauthorized users. The resolver names the operator on pages;
172
+ keep the controller or mount constraint as the page authorization gate.
169
173
 
170
174
  Or a routes constraint, which keeps the engine out of it entirely (for
171
175
  example with the sessions Rails' authentication generator creates):
@@ -183,7 +187,11 @@ purpose:
183
187
 
184
188
  ```ruby
185
189
  c.http_basic_auth_enabled = false
186
- c.dashboard_open = true # "something in front of the mount gates this"
190
+ # The mount constraint does not cover /cable. Authorize live updates too:
191
+ c.dashboard_user = ->(request) {
192
+ user = Session.find_by(id: request.cookie_jar.signed[:session_id])&.user
193
+ { id: user.id, name: user.name } if user&.admin?
194
+ }
187
195
  ```
188
196
 
189
197
  ### Deliberately public
@@ -201,7 +209,8 @@ With Basic off and none of `base_controller_class`, `dashboard_user` or
201
209
  forgotten one. It serves the dashboard (a routes constraint it cannot see
202
210
  is a legitimate answer) but logs a warning at every boot outside
203
211
  development, `railwatch:doctor` reports the gate as undeclared, and live
204
- updates are refused. Declaring any of the three settles it.
212
+ updates are refused. A custom base controller declares the page gate only;
213
+ live updates still require their own authorization as described below.
205
214
 
206
215
  ### Live updates and `/cable`
207
216
 
@@ -209,9 +218,11 @@ Action Cable runs on your application's own `/cable` endpoint, not under
209
218
  the engine's mount, so a routes constraint around `/railwatch` does not
210
219
  cover it and a base controller cannot reach it. The live-update channel
211
220
  therefore follows what you declared: HTTP Basic credentials are checked
212
- on the WebSocket handshake, a `dashboard_user` resolver is consulted,
213
- `dashboard_open` and `base_controller_class` are taken at their word, and
214
- an undeclared gate is refused. The channel carries an ingest ping (a
221
+ from the WebSocket handshake, a `dashboard_user` resolver is consulted,
222
+ or `dashboard_open` explicitly permits public live updates. A custom
223
+ `base_controller_class` authorizes pages only and never permits a Cable
224
+ subscription by itself. The gate is rechecked before each live update;
225
+ revocation removes the subscription and notifies the client. The channel carries an ingest ping (a
215
226
  timestamp and per-type counts) and never telemetry records.
216
227
 
217
228
  ### Naming the operator, and what an id may be
@@ -36,8 +36,6 @@ bin/rails generate railwatch:install \
36
36
 
37
37
  - `--prompt-token` reads without echo. `--token-stdin` is available for a
38
38
  secret-manager pipe; an already exported `RAILWATCH_TOKEN` is also detected.
39
- The legacy `--token=` flag warns because command arguments can be visible in
40
- shell history and process listings.
41
39
  - A token is written to `.env` only when Git confirms that `.env` is ignored.
42
40
  A tracked or non-ignored dotenv file is refused; use Rails credentials, a
43
41
  deployment secret manager, or add `.env` to `.gitignore` first. Token values
data/docs/source-maps.md CHANGED
@@ -48,9 +48,7 @@ New default browser issue fingerprints use the resolved original location
48
48
  when a map is already available. Custom fingerprints are preserved. Late
49
49
  uploads improve existing stack displays, Copy for AI, API issue details and
50
50
  MCP `get_issue`, while existing occurrences keep their original grouping.
51
- Missing maps or columns fall back to the raw stack. Upgrade the gem for
52
- new browser occurrences to include columns; older gem versions discarded
53
- them and cannot resolve minified locations accurately.
51
+ Missing maps or columns fall back to the raw stack.
54
52
 
55
53
  For a custom release uploader, POST the raw `.map` bytes to
56
54
  `/ingest/sourcemaps` with `Content-Type: application/octet-stream`,
@@ -13,8 +13,6 @@ module Railwatch
13
13
 
14
14
  class_option :local, type: :boolean, default: false,
15
15
  desc: "Keep telemetry in this app and serve the dashboard at /railwatch: no token, no cloud."
16
- class_option :token, type: :string,
17
- desc: "Deprecated: token in process arguments. Prefer --prompt-token, --token-stdin, or RAILWATCH_TOKEN."
18
16
  class_option :prompt_token, type: :boolean, default: false,
19
17
  desc: "Prompt for the ingest token without echoing it."
20
18
  class_option :token_stdin, type: :boolean, default: false,
@@ -167,10 +165,6 @@ module Railwatch
167
165
  return if options[:local]
168
166
 
169
167
  token = resolved_token
170
- if options[:token]
171
- say("--token exposes #{Railwatch::SecretSafety.token_preview(options[:token])} in process arguments; " \
172
- "use --prompt-token or --token-stdin next time.", :yellow)
173
- end
174
168
  vars = { TOKEN_VAR => token, URL_VAR => options[:url] }.compact
175
169
  return if vars.empty?
176
170
  return say(env_instructions(vars), :yellow) unless dotenv_app?
@@ -463,8 +457,6 @@ module Railwatch
463
457
  ask("Railwatch ingest token (input hidden):", echo: false)
464
458
  elsif options[:token_stdin]
465
459
  $stdin.gets
466
- elsif options[:token]
467
- options[:token]
468
460
  elsif !ENV[TOKEN_VAR].to_s.empty?
469
461
  ENV[TOKEN_VAR]
470
462
  end
@@ -145,9 +145,19 @@ module Railwatch
145
145
  url.start_with?("#{origin}/")
146
146
  end
147
147
 
148
+ MAX_SOURCE_BYTES = 1024 * 1024
149
+
148
150
  def source_snippet(path, line, context: 5)
149
- return nil unless path && File.readable?(path)
150
- lines = File.readlines(path, chomp: true)
151
+ return nil unless path
152
+ # String backtraces can be assigned by libraries. A lexical Rails.root
153
+ # prefix alone allows ../ and symlinks to disclose files outside it.
154
+ root = File.realpath(app_root) + File::SEPARATOR
155
+ source = File.realpath(path)
156
+ return nil unless source.start_with?(root) && File.file?(source)
157
+
158
+ data = File.binread(source, MAX_SOURCE_BYTES + 1)
159
+ return nil if data.bytesize > MAX_SOURCE_BYTES
160
+ lines = data.force_encoding(Encoding::UTF_8).scrub.lines(chomp: true)
151
161
  from = [ line - context - 1, 0 ].max
152
162
  to = [ line + context - 1, lines.size - 1 ].min
153
163
  (from..to).to_h { |i| [ i + 1, lines[i].to_s[0, 200] ] }
@@ -407,12 +407,13 @@ module Railwatch
407
407
  # engine's mount does not cover and a base controller cannot reach, so an
408
408
  # undeclared gate refuses rather than assuming.
409
409
  def dashboard_channel_allowed?(request)
410
- case dashboard_gate
411
- when :basic then http_basic_auth_ok?(request)
412
- when :resolver then !(dashboard_user.respond_to?(:call) ? dashboard_user.call(request) : dashboard_user).nil?
413
- when :open, :controller then true
414
- else false
410
+ return http_basic_auth_ok?(request) if http_basic_auth_enabled
411
+ if dashboard_user
412
+ return !!(dashboard_user.respond_to?(:call) ? dashboard_user.call(request) : dashboard_user)
415
413
  end
414
+
415
+ # A controller name is not evidence of authorization on /cable.
416
+ !!dashboard_open
416
417
  end
417
418
 
418
419
  def resolve_dashboard_user(request)
@@ -105,8 +105,8 @@ module Railwatch
105
105
  Rails.logger.warn(
106
106
  "[railwatch] the dashboard at the engine's mount has no gate this gem can see: HTTP Basic is off and " \
107
107
  "no base_controller_class, dashboard_user or dashboard_open is set. If a routes constraint or your " \
108
- "network already gates it, set `c.dashboard_open = true` to say so (it also enables live updates); " \
109
- "otherwise anyone who can reach the URL can read every query, log line and exception this app records."
108
+ "network already gates it, authorize /cable separately with `c.dashboard_user`, or set `c.dashboard_open = true` " \
109
+ "only when the network protects both endpoints. Otherwise anyone who can reach the dashboard can read every query, log line and exception this app records."
110
110
  )
111
111
  end
112
112
  end
@@ -22,7 +22,14 @@ module Railwatch
22
22
  # Not `send`: shadowing Object#send on a class makes it impossible to
23
23
  # reach the real one, and reads as a coincidence rather than a verb.
24
24
  def deliver(claim, producer_id:)
25
- result = transport_for(claim).deliver_encoded(
25
+ # Snapshot once, then verify and use those exact bytes. Configuration
26
+ # can change after Outbox#claim! checked the destination binding.
27
+ token = @config.resolved_export_token.to_s.dup.freeze
28
+ unless Digest::SHA256.hexdigest(token) == claim.token_digest
29
+ return Outcome.new(disposition: :deferred, status: 401, reason: "export credential changed after claim")
30
+ end
31
+
32
+ result = transport_for(claim, token: token).deliver_encoded(
26
33
  body: claim.body, expected_count: claim.record_count, batch_id: claim.delivery_id,
27
34
  dropped: claim.metadata.fetch("dropped", 0).to_i,
28
35
  dropped_bytes: claim.metadata.fetch("dropped_bytes", 0).to_i,
@@ -54,11 +61,11 @@ module Railwatch
54
61
  # otherwise post bytes admitted for one receiver to another one -- and,
55
62
  # if they belong to different tenants, hand a customer's telemetry to
56
63
  # somebody else while marking it delivered.
57
- def transport_for(claim)
64
+ def transport_for(claim, token:)
58
65
  key = [ claim.url, claim.token_digest ]
59
66
  @transports[key] ||= begin
60
67
  credentialed = @config.dup
61
- credentialed.token = @config.resolved_export_token
68
+ credentialed.token = token
62
69
  Transport::Http.new(credentialed, endpoint: claim.url)
63
70
  end
64
71
  end
@@ -481,19 +481,9 @@ module Railwatch
481
481
  dropped_bytes: batch.dropped_bytes + dropped_bytes, prepared: true)
482
482
  end
483
483
 
484
- # Third-party/test transports written before batch idempotency only accept
485
- # `dropped:`. Keep those working while the HTTP transport receives the
486
- # stable identity required to replay a request safely.
487
484
  def deliver(batch)
488
- parameters = @transport.method(:deliver).parameters
489
- accepts_batch_id = parameters.any? { |kind, name| kind == :keyrest || name == :batch_id }
490
- accepts_dropped_bytes = parameters.any? { |kind, name| kind == :keyrest || name == :dropped_bytes }
491
- accepts_backpressure = parameters.any? { |kind, name| kind == :keyrest || name == :backpressure_factor }
492
- keywords = { dropped: batch.dropped }
493
- keywords[:batch_id] = batch.id if accepts_batch_id
494
- keywords[:dropped_bytes] = batch.dropped_bytes if accepts_dropped_bytes
495
- keywords[:backpressure_factor] = @backpressure_factor if accepts_backpressure
496
- @transport.deliver(batch.records, **keywords)
485
+ @transport.deliver(batch.records, dropped: batch.dropped, batch_id: batch.id,
486
+ dropped_bytes: batch.dropped_bytes, backpressure_factor: @backpressure_factor)
497
487
  end
498
488
 
499
489
  def update_backpressure
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "net/http"
4
+ require "openssl"
4
5
  require "uri"
5
6
  require "pathname"
6
7
  require "json"
@@ -10,6 +11,7 @@ module Railwatch
10
11
  # during release preparation; it never follows map URLs or HTTP redirects.
11
12
  class SourceMaps
12
13
  MAX_BYTES = 10 * 1024 * 1024
14
+ MAX_RESPONSE_BYTES = 64 * 1024
13
15
 
14
16
  def initialize(config)
15
17
  @config = config
@@ -46,7 +48,17 @@ module Railwatch
46
48
  request["X-Railwatch-Deploy"] = @config.deploy
47
49
  request["X-Railwatch-Filename"] = filename
48
50
  request.body = data
49
- response = Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https", open_timeout: 5, read_timeout: 30, write_timeout: 30) { |http| http.request(request) }
51
+ response = Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https", verify_mode: OpenSSL::SSL::VERIFY_PEER,
52
+ open_timeout: 5, read_timeout: 30, write_timeout: 30) do |http|
53
+ http.request(request) do |result|
54
+ body = String.new(encoding: Encoding::BINARY)
55
+ result.read_body do |chunk|
56
+ raise "Source map response exceeds 64 KiB" if body.bytesize + chunk.bytesize > MAX_RESPONSE_BYTES
57
+ body << chunk
58
+ end
59
+ result.body = body
60
+ end
61
+ end
50
62
  unless response.is_a?(Net::HTTPSuccess)
51
63
  raise "Source map upload failed (HTTP #{response.code}) for #{filename}"
52
64
  end
@@ -15,7 +15,7 @@ module Railwatch
15
15
  class MemoryTransport
16
16
  attr_reader :batches
17
17
  def initialize = @batches = []
18
- def deliver(records, dropped: 0, batch_id: nil)
18
+ def deliver(records, dropped: 0, batch_id: nil, **)
19
19
  @batches << records
20
20
  Transport::Http::Result.new(ok: true, status: 200, accepted: records.size, rejected: 0)
21
21
  end
@@ -174,12 +174,22 @@ module Railwatch
174
174
  # explicitly so a Ruby default change cannot silently weaken ingest.
175
175
  options[:verify_mode] = OpenSSL::SSL::VERIFY_PEER if options[:use_ssl]
176
176
  Net::HTTP.start(@uri.host, @uri.port, **options) do |http|
177
- http.request(req)
177
+ http.request(req) do |response|
178
+ body = String.new(encoding: Encoding::BINARY)
179
+ response.read_body do |chunk|
180
+ if body.bytesize + chunk.bytesize > MAX_RESPONSE_BYTES
181
+ raise ResponseTooLarge, "ingest response is larger than #{MAX_RESPONSE_BYTES} bytes"
182
+ end
183
+ body << chunk
184
+ end
185
+ response.body = body
186
+ end
178
187
  end
179
188
  end
180
189
 
181
- # A receiver in trouble can answer with something enormous -- a proxy
182
- # error page, a stack trace. Only ever look at the first slice of it.
190
+ # Enforced while streaming, before Net::HTTP can buffer an unbounded
191
+ # proxy error page or decompressed response inside the monitored app.
192
+ class ResponseTooLarge < StandardError; end
183
193
  MAX_RESPONSE_BYTES = 64 * 1024
184
194
  # The longest delay we will take from a receiver, in either spelling.
185
195
  MAX_RETRY_AFTER = 86_400
@@ -193,8 +203,7 @@ module Railwatch
193
203
  end
194
204
  end
195
205
 
196
- # Bounds what we keep and log, not what Net::HTTP already read off the
197
- # socket -- it buffers the whole response before we ever see it.
206
+ # Keep diagnostics smaller still than the bounded response body.
198
207
  def summarize(body) = body.to_s.byteslice(0, 200).to_s.scrub
199
208
 
200
209
  # Seconds, or an HTTP date. Anything else is not a delay we can trust,
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Railwatch
4
- VERSION = "0.3.5"
4
+ VERSION = "0.3.6"
5
5
  end