railwatch-server 0.1.3 → 0.1.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: fcacb047d9cf5bb22240714ce151df68c886a9e9ded531bf0937b487c3d11983
4
- data.tar.gz: c5b475ace2a3fcceffa5f7a1d029e14923bae9bc6e17045f3c7e876ee027b631
3
+ metadata.gz: 044ca51326a9a973769258885495639465aa0d8127c4c8248f761d484324c3db
4
+ data.tar.gz: 5cdddf3a5fb4bdca4f4711ce347e578c26b6f9dc0d4f84836a0e4d124b9459e5
5
5
  SHA512:
6
- metadata.gz: a255c06462451b89c159853645fd715d0029db8388e4b581908fd53095a264b7161cdc7332c45082badc50297ae823d12985c0e55bb4e6661da5785e383abe62
7
- data.tar.gz: 8ac3ddbf0926b87ee811cf4ffbb1018d6e6c34cc37f53ebbd94549ad1f7efa47342a4bcc27aa8cc6d4a4b29fa2601266da07bfc903901e986223a86f83dc6eb7
6
+ metadata.gz: d3ce595cd4b47ffec3e8c61581d70e5c68808644d81868efcf865ac8908dbc7b13d02914be28f62ccbfe9e9177423c7cf19e3ad0bf7eb94ee77d7b3bd03d7ba6
7
+ data.tar.gz: e1d58c1d34eb13b433e3a83418cb6171f8822d7be3bd729050173158ff5b5a8c832fa331aec536d9a2628da8c71255c0014a01be53bbd9e0f7caa66f206e899b
data/README.md CHANGED
@@ -91,6 +91,7 @@ whether the server is up, and where the agent reports.
91
91
  | `RAILWATCH_SERVER_PROXY_CONNECT_TIMEOUT` | `2` | seconds to connect to the server on loopback; past it, 503 |
92
92
  | `RAILWATCH_SERVER_PROXY_FIRST_BYTE_TIMEOUT` | `25` | seconds for the server to start answering a dashboard request; past it, 504 with a message. Under 30 s on purpose: Thruster (its write timeout) and kamal-proxy drop a response that hasn't started after 30 s, and the visitor gets an empty reply instead |
93
93
  | `RAILWATCH_SERVER_PROXY_IDLE_TIMEOUT` | `60` | seconds a response that has started may go without a byte moving; past it the connection is closed |
94
+ | `RAILWATCH_SERVER_AGENT_ACCESS` | on in development for this machine only, off elsewhere | whether `<mount>/mcp` and `<mount>/api/` (an AI agent's requests) are signed for the visitor like the dashboard, so the agent needs no token. See [AI agents](#ai-agents) |
94
95
  | `RAILWATCH_SERVER_NICE` | `10` | how far below the app's CPU priority the server runs (0-19; 0: the same). A busy dashboard can keep every core busy; measured in a 2-CPU container under 30 dashboard clients at the app's priority, the app's `/up` p50 went from 2 to 15 ms. Not under a CPU quota (`docker --cpus`, Kubernetes CPU limits): the kernel throttles the container's processes together whatever their priority, and in that 2-CPU container under `--cpus 2` nice 10 measured no better than nice 0 (`/up` p99 190-580 ms at either); on 2 dedicated cores (`--cpuset-cpus`) it took `/up` p99 from 207-363 ms to 18-22 ms. In a quota-limited container give it real cores (a cpuset, or a VM) or keep dashboard use light |
95
96
 
96
97
  **Environments.** One environment per data dir, named after `Rails.env`:
@@ -143,6 +144,54 @@ end
143
144
  Without it, the HTTP Basic user name (or "Operator") is an admin. Users are
144
145
  created in the server on first sight. Nobody signs in to the server itself.
145
146
 
147
+ ## AI agents
148
+
149
+ The server's MCP endpoint and JSON API are under the mount too
150
+ (`/railwatch/mcp`, `/railwatch/api/v1`). The bundled railwatch-rs takes the
151
+ proxy's signed user on them, so an agent on the same machine needs no token
152
+ in development:
153
+
154
+ ```sh
155
+ claude mcp add --transport http railwatch http://localhost:3000/railwatch/mcp
156
+ ```
157
+
158
+ (or `bin/rails generate railwatch:agents` from the railwatch gem, which
159
+ writes `.mcp.json` and the instructions). The proxy signs those requests
160
+ for the visitor exactly as it signs dashboard pages, and the server takes
161
+ the signed header as that user on MCP and the API: every read tool, and the
162
+ issue writes (`update_issue`, `add_comment`), attributed to the user and
163
+ "agent".
164
+
165
+ **What that opens, and to whom.** By default (`agent_access` unset) only
166
+ when all of these hold:
167
+
168
+ - `Rails.env.development?` (the server's environment, `config.environment`);
169
+ - the request comes from this machine (`request.local?`: `127.0.0.1` or
170
+ `::1`). A dev server bound to `0.0.0.0` still refuses the LAN;
171
+ - its `Host` is `localhost`, `127.0.0.1` or `::1`. A web page whose own name
172
+ was rebound to `127.0.0.1` (DNS rebinding) arrives under that name, not
173
+ these, and gets no signature;
174
+ - the dashboard's gate passes, as for any page (open in development unless
175
+ you configured HTTP Basic or a base controller).
176
+
177
+ The server also refuses a signed MCP or API request the browser marks as
178
+ coming from another site (`Origin` that is not the `Host`, or
179
+ `Sec-Fetch-Site: cross-site`), so a page you visit can't call them through
180
+ your browser. What is left: anything that can send a request to
181
+ `localhost:3000` from your machine (another local user, a local process)
182
+ can read the development app's telemetry and comment on its issues. That is
183
+ already true of the dashboard at `/railwatch` in development, and of the
184
+ dev app itself.
185
+
186
+ Everywhere else agent requests are forwarded without the visitor and the
187
+ server answers 401 until the agent sends a token (`Authorization: Bearer
188
+ rwp_...` from Settings -> Profile), which the proxy passes through
189
+ unchanged; a token always wins over the signature, in development too.
190
+ `config.agent_access = true` (or `RAILWATCH_SERVER_AGENT_ACCESS=1`) signs
191
+ agent requests for whoever passes the gate in any environment: only behind
192
+ a gate that authenticates people (`base_controller_class` with your login),
193
+ since the agent then acts as them. `false` turns it off in development too.
194
+
146
195
  ## How it works
147
196
 
148
197
  ```
@@ -103,6 +103,8 @@ module Railwatch
103
103
  Purpose Sec-Purpose Sec-Fetch-Site Sec-Fetch-Mode Sec-Fetch-Dest If-None-Match If-Modified-Since
104
104
  ].freeze
105
105
  HOP_BY_HOP = %w[connection keep-alive transfer-encoding content-length te trailer upgrade proxy-connection].freeze
106
+ # Host names that only reach this machine (agent_access?).
107
+ LOCAL_HOSTS = %w[localhost 127.0.0.1 ::1 [::1]].freeze
106
108
 
107
109
  skip_forgery_protection if respond_to?(:skip_forgery_protection)
108
110
  before_action :authenticate_by_http_basic
@@ -147,9 +149,15 @@ module Railwatch
147
149
 
148
150
  private
149
151
 
152
+ # An agent's Bearer token takes the one Authorization header Basic would
153
+ # need, so a request to MCP or the API with one is left to the server,
154
+ # which checks the token (401 without a valid one) as a standalone
155
+ # railwatch-rs does. The app's own before_actions (base_controller_class)
156
+ # still run.
150
157
  def authenticate_by_http_basic
151
158
  config = Railwatch.config
152
159
  return unless config.http_basic_auth_enabled
160
+ return if agent_bearer
153
161
  return if config.http_basic_auth_ok?(request)
154
162
 
155
163
  request_http_basic_authentication("Railwatch")
@@ -172,6 +180,32 @@ module Railwatch
172
180
  ProxyAuth.sign(secret, email: v[:email], name: v[:name], role: v[:role])
173
181
  end
174
182
 
183
+ # The server's MCP endpoint and JSON API: what an AI agent calls.
184
+ def agent_request?
185
+ mount = Railwatch::Server.config.normalized_mount_path
186
+ rest = mount == "/" ? request.path : request.path.delete_prefix(mount)
187
+ rest == "/mcp" || rest.start_with?("/api/")
188
+ end
189
+
190
+ # Whether an agent request is signed for the visitor (config.agent_access).
191
+ # By default only in development, and only for a request from this
192
+ # machine (request.local?: a dev server bound to 0.0.0.0 still refuses
193
+ # the LAN) to a local host name (a page on another site that rebinds
194
+ # its own name to 127.0.0.1 arrives with that name as Host).
195
+ def agent_access?
196
+ setting = Railwatch::Server.config.agent_access
197
+ return setting unless setting.nil?
198
+
199
+ Railwatch::Server.config.environment == "development" && request.local? && LOCAL_HOSTS.include?(request.host)
200
+ end
201
+
202
+ # An agent's own token, which the server checks: sent on as it came,
203
+ # and the visitor is not signed (the server would ignore them anyway).
204
+ def agent_bearer
205
+ value = request.authorization.to_s
206
+ value if agent_request? && value.start_with?("Bearer ")
207
+ end
208
+
175
209
  def upstream_headers(secret)
176
210
  headers = FORWARDED_HEADERS.each_with_object({}) do |name, h|
177
211
  value = request.headers[name]
@@ -188,7 +222,13 @@ module Railwatch
188
222
  # and only that: the server (RAILWATCH_TRUST_PROXY) keys its per-IP
189
223
  # limits on the last address here.
190
224
  headers["X-Forwarded-For"] = request.remote_ip
191
- headers[ProxyAuth::HEADER] = signed_header(secret)
225
+ # An agent request without access goes on unsigned: the server's 401
226
+ # tells the agent to send a token.
227
+ if (bearer = agent_bearer)
228
+ headers["Authorization"] = bearer
229
+ elsif !agent_request? || agent_access?
230
+ headers[ProxyAuth::HEADER] = signed_header(secret)
231
+ end
192
232
  headers
193
233
  end
194
234
 
@@ -16,7 +16,8 @@ module Railwatch
16
16
  class Configuration
17
17
  attr_accessor :enabled, :port, :data_dir, :mount_path, :binary, :url, :stop_timeout, :setup_timeout, :user, :wire_agent,
18
18
  :max_live_connections, :max_proxied_requests, :proxy_connect_timeout, :proxy_first_byte_timeout,
19
- :proxy_idle_timeout, :nice, :smtp, :mail_from, :backup, :google, :retention, :alert_delivery, :server_env
19
+ :proxy_idle_timeout, :nice, :smtp, :mail_from, :backup, :google, :retention, :alert_delivery, :server_env,
20
+ :agent_access
20
21
 
21
22
  def initialize
22
23
  # nil: on, except under Rails.env.test (a test run on a machine with
@@ -48,6 +49,13 @@ module Railwatch
48
49
  # Who the dashboard's visitor is, as the server should see them:
49
50
  # ->(controller) { { email:, name:, role: "admin" | "member" } }.
50
51
  @user = nil
52
+ # Whether <mount>/mcp and <mount>/api/ (what an AI agent calls) are
53
+ # signed for the visitor like the dashboard, so the agent needs no
54
+ # token. nil: only in development, for a request from this machine
55
+ # to a local host name (ProxyController#agent_access?). true: for
56
+ # every request that passes the gate, in any environment. false:
57
+ # never; an agent sends a token (Authorization: Bearer rwp_...).
58
+ @agent_access = env_bool("RAILWATCH_SERVER_AGENT_ACCESS", nil)
51
59
  # Point the railwatch agent at the bundled server when the app has not
52
60
  # configured one (no RAILWATCH_TOKEN). Off: the app wires it itself.
53
61
  @wire_agent = env_bool("RAILWATCH_SERVER_WIRE_AGENT", true)
@@ -2,9 +2,9 @@
2
2
 
3
3
  module Railwatch
4
4
  module Server
5
- VERSION = "0.1.3"
5
+ VERSION = "0.1.4"
6
6
  # The published railwatch-rs commit the bundled binaries are built from
7
7
  # (README, "The server side"); the Corresponding Source under AGPL-3.0.
8
- RAILWATCH_RS = "d465705aa111c0f264315d2f640a364513015fd7"
8
+ RAILWATCH_RS = "36cd8771f1a6da2cf1b94052f7b2052a8a1ea630"
9
9
  end
10
10
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: railwatch-server
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.3
4
+ version: 0.1.4
5
5
  platform: ruby
6
6
  authors:
7
7
  - Rebulk
@@ -15,14 +15,14 @@ dependencies:
15
15
  requirements:
16
16
  - - ">="
17
17
  - !ruby/object:Gem::Version
18
- version: '1.2'
18
+ version: '1.4'
19
19
  type: :runtime
20
20
  prerelease: false
21
21
  version_requirements: !ruby/object:Gem::Requirement
22
22
  requirements:
23
23
  - - ">="
24
24
  - !ruby/object:Gem::Version
25
- version: '1.2'
25
+ version: '1.4'
26
26
  - !ruby/object:Gem::Dependency
27
27
  name: railties
28
28
  requirement: !ruby/object:Gem::Requirement