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 +4 -4
- data/README.md +49 -0
- data/app/controllers/railwatch/server/proxy_controller.rb +41 -1
- data/lib/railwatch/server/configuration.rb +9 -1
- data/lib/railwatch/server/version.rb +2 -2
- metadata +3 -3
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 044ca51326a9a973769258885495639465aa0d8127c4c8248f761d484324c3db
|
|
4
|
+
data.tar.gz: 5cdddf3a5fb4bdca4f4711ce347e578c26b6f9dc0d4f84836a0e4d124b9459e5
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
-
|
|
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.
|
|
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 = "
|
|
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.
|
|
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.
|
|
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.
|
|
25
|
+
version: '1.4'
|
|
26
26
|
- !ruby/object:Gem::Dependency
|
|
27
27
|
name: railties
|
|
28
28
|
requirement: !ruby/object:Gem::Requirement
|