railwatch-server 0.1.0-aarch64-linux

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.
data/NOTICE ADDED
@@ -0,0 +1,24 @@
1
+ railwatch-server bundles the Railwatch server.
2
+
3
+ The platform gems of railwatch-server (x86_64-linux, aarch64-linux,
4
+ x86_64-darwin, arm64-darwin) contain exe/<platform>/railwatch, a compiled
5
+ build of Railwatch (railwatch-rs), https://github.com/Rebulk/railwatch-rs,
6
+ which is free software under the GNU Affero General Public License,
7
+ version 3 only (AGPL-3.0-only). This gem's own Ruby code is under the same
8
+ license; see LICENSE.
9
+
10
+ The Corresponding Source of the bundled binary is the railwatch-rs commit
11
+ named in lib/railwatch/server/version.rb (RAILWATCH_RS), unmodified. The
12
+ running server also links to its source on every response (the Link header and /source).
13
+ Third-party code compiled into the binary keeps its own licenses, listed in
14
+ railwatch-rs's THIRD-PARTY-LICENSES.
15
+
16
+ Running this gem in your application does not make your application a
17
+ derivative of Railwatch: the server is a separate program, started as its
18
+ own process and spoken to over HTTP. If you modify the server and offer it
19
+ to users over a network, AGPL-3.0 section 13 asks you to offer them the
20
+ source of your version.
21
+
22
+ The railwatch gem (https://github.com/Rebulk/railwatch), the agent that
23
+ reports your app's telemetry, is a separate gem under the MIT license. This
24
+ gem depends on it; it is not part of this gem.
data/README.md ADDED
@@ -0,0 +1,368 @@
1
+ # railwatch-server
2
+
3
+ The Railwatch server, bundled as a gem. Add it to a Rails app and you get
4
+ the **full** Railwatch dashboard at `/railwatch` (issues, alerts, the API
5
+ and MCP included), behind your app's own login, with no server to install.
6
+ The server runs as **its own process** next to the app, started by Puma's
7
+ master. It never runs inside a Puma worker.
8
+
9
+ ## How it relates to the other two
10
+
11
+ | | what it is | license |
12
+ |---|---|---|
13
+ | [`railwatch`](https://github.com/Rebulk/railwatch) | the agent in your app; on its own it reports to Railwatch Cloud or a self-hosted server | MIT |
14
+ | [`railwatch-rs`](https://github.com/Rebulk/railwatch-rs) | the server, one static binary; self-hosted on a VPS for many apps | AGPL-3.0-only |
15
+ | **`railwatch-server`** (this) | `railwatch-rs`, prebuilt per platform, run and wired for one app | AGPL-3.0-only |
16
+
17
+ This gem depends on `railwatch` and points the agent at the bundled server.
18
+ It is the same server you would self-host, at the same version, just started
19
+ for you. Moving later to a shared self-hosted install means changing
20
+ `RAILWATCH_INGEST_URL` and `RAILWATCH_TOKEN` (and copying `storage/railwatch`
21
+ if you want the history).
22
+
23
+ ## Install
24
+
25
+ ```ruby
26
+ # Gemfile
27
+ gem "railwatch"
28
+ gem "railwatch-server"
29
+ ```
30
+
31
+ ```ruby
32
+ # config/puma.rb
33
+ plugin :railwatch_server
34
+ ```
35
+
36
+ ```ruby
37
+ # config/initializers/railwatch.rb: the dashboard's gate (the railwatch gem's settings)
38
+ Railwatch.configure do |c|
39
+ c.http_basic_auth_user = "admin" # or bin/rails railwatch:authentication:configure
40
+ c.http_basic_auth_password = ENV["RAILWATCH_DASHBOARD_PASSWORD"]
41
+ end
42
+ ```
43
+
44
+ `bin/rails s`, then open `/railwatch`. On first boot the gem runs
45
+ `railwatch setup` against `storage/railwatch/<Rails.env>`, so the account,
46
+ app, environment, ingest token and proxy secret are created once and kept
47
+ there. It then starts `railwatch serve` on `127.0.0.1:28790` and points the
48
+ agent at it.
49
+
50
+ Only a process that starts the server creates that directory: Puma with the
51
+ plugin (`bin/rails server` or `puma`) and `bin/rails railwatch_server:start`.
52
+ Anything else (`assets:precompile` in a Dockerfile, a console, a Solid Queue
53
+ worker) reads it and, until it exists, leaves the agent alone and says so.
54
+ So an image build ships no token, proxy secret or `secret_key_base`; each
55
+ volume gets its own on first start. A job worker started before the first
56
+ server reports from its next restart. A `railwatch setup` that hangs never
57
+ holds up the boot past `RAILWATCH_SERVER_SETUP_TIMEOUT` (below).
58
+
59
+ Bundler picks the platform gem with the binary for your machine:
60
+ x86_64-linux, aarch64-linux, x86_64-darwin or arm64-darwin. On anything
61
+ else the plain gem installs and tells you to set `RAILWATCH_SERVER_BINARY`
62
+ to a `railwatch` you installed yourself.
63
+
64
+ ### Without Puma
65
+
66
+ Use `bin/rails railwatch_server:start` (foreground, TERM to stop), e.g. as a
67
+ Procfile line:
68
+
69
+ ```
70
+ railwatch: bin/rails railwatch_server:start
71
+ ```
72
+
73
+ `bin/rails railwatch_server:status` reports the binary, the data dir,
74
+ whether the server is up, and where the agent reports.
75
+
76
+ ## Configuration
77
+
78
+ | env | default | |
79
+ |---|---|---|
80
+ | `RAILWATCH_SERVER_ENABLED` | on, off under `Rails.env.test` | off: no server, no route, the agent untouched |
81
+ | `RAILWATCH_SERVER_PORT` | `28790` | loopback port. Fixed, not picked at boot, so every process of the app finds the same server |
82
+ | `RAILWATCH_SERVER_DATA_DIR` | `storage/railwatch/<Rails.env>` | put it on the volume that survives deploys |
83
+ | `RAILWATCH_SERVER_MOUNT_PATH` | `/railwatch` | |
84
+ | `RAILWATCH_SERVER_URL` | the loopback URL | public URL of the app, for links in alert emails and Secure cookies |
85
+ | `RAILWATCH_SERVER_BINARY` | the platform gem's | your own `railwatch` |
86
+ | `RAILWATCH_SERVER_STOP_TIMEOUT` | `15` | seconds Puma's exit waits for the server to drain |
87
+ | `RAILWATCH_SERVER_SETUP_TIMEOUT` | `30` | seconds the first start of a data dir waits for `railwatch setup` (about a second), before Puma listens. Past it the setup is KILLed, the app boots without the server (agent not wired, the dashboard's 503 says why) and the next start retries |
88
+ | `RAILWATCH_SERVER_WIRE_AGENT` | `true` | off: you configure the agent yourself |
89
+ | `RAILWATCH_SERVER_MAX_LIVE_CONNECTIONS` | `64` | open live-update connections per app process (one per dashboard tab); past it the dashboard works without live updates |
90
+ | `RAILWATCH_SERVER_MAX_PROXIED_REQUESTS` | `64` | dashboard requests in flight per app process (waiting on the server or streaming back); past it a dashboard request gets 503 with `Retry-After: 1` at once. The app's own requests are never affected |
91
+ | `RAILWATCH_SERVER_PROXY_CONNECT_TIMEOUT` | `2` | seconds to connect to the server on loopback; past it, 503 |
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
+ | `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_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
+ **Environments.** One environment per data dir, named after `Rails.env`:
97
+ development, staging and production usually run on different machines,
98
+ and each keeps its own history in `storage/railwatch/<env>`. Under
99
+ `Rails.env.test` the server is off by default (a test run would otherwise
100
+ report into the development server). The data dir records the environment
101
+ it was set up for (`environment`), and an app booted in another
102
+ environment against it refuses to wire the agent and says so, rather than
103
+ filing staging's telemetry under production. To see several environments
104
+ in one dashboard, run one shared self-hosted `railwatch-rs` and point each
105
+ environment's agent at it (`RAILWATCH_INGEST_URL`, `RAILWATCH_TOKEN`).
106
+
107
+ **The server's own settings** (alert and digest email, backups, Google
108
+ sign-in, retention, whether alerts are sent) come from the app, written on
109
+ every server start to `<data dir>/server.env` (0600) and never logged
110
+ (the log names only what was set: "settings from the app: smtp, backup"):
111
+
112
+ | server setting | from |
113
+ |---|---|
114
+ | SMTP | `config.smtp`, else Action Mailer's `smtp_settings` when `delivery_method` is `:smtp` |
115
+ | From | `config.mail_from`, else `ActionMailer::Base.default[:from]` |
116
+ | S3 backups | `config.backup`, else `credentials.railwatch_server.backup` (`endpoint bucket access_key_id secret_access_key`, optional `region prefix interval_seconds`) |
117
+ | Google sign-in | `config.google`, else `credentials.railwatch_server.google` (`client_id client_secret`) |
118
+ | retention | `config.retention = { detail_days:, core_days_max: }` |
119
+ | alert delivery | `config.alert_delivery = false` records alerts without sending |
120
+ | anything else | `config.server_env = { "RAILWATCH_NEW_ISSUES_PER_HOUR" => 50 }` |
121
+
122
+ `false` or `{}` turns one off where the app would otherwise supply it. An
123
+ incomplete backup is refused whole (logged, no backups) rather than
124
+ half-applied. `railwatch doctor` with `RAILWATCH_ENV_FILE` pointed at the
125
+ file shows what the server reads.
126
+
127
+ The agent is wired only when the app has not configured a destination:
128
+ when `RAILWATCH_TOKEN` is set, or the agent uses `transport :local`, it is
129
+ left alone and the bundled server serves only the dashboard.
130
+
131
+ **Who the server sees.** The dashboard's gate is the `railwatch` gem's:
132
+ HTTP Basic, on and closed by default; or, with Basic off,
133
+ `base_controller_class` (the proxy controller inherits from it, so your
134
+ `before_action :require_admin` runs first). Past the gate, the visitor is
135
+ named to the server by `Railwatch::Server.config.user`:
136
+
137
+ ```ruby
138
+ Railwatch::Server.configure do |c|
139
+ c.user = ->(controller) { u = controller.current_user; { email: u.email, name: u.name, role: u.admin? ? "admin" : "member" } }
140
+ end
141
+ ```
142
+
143
+ Without it, the HTTP Basic user name (or "Operator") is an admin. Users are
144
+ created in the server on first sight. Nobody signs in to the server itself.
145
+
146
+ ## How it works
147
+
148
+ ```
149
+ browser ── /railwatch/* ──> Rails (gate) ──> ProxyController ── 127.0.0.1:28790 ──> railwatch serve
150
+ + X-Railwatch-Proxy-Auth (signed, 60 s) (RAILWATCH_BASE_PATH=/railwatch)
151
+ Puma workers, jobs ── railwatch agent ── /ingest ─────────────────────────────────────┘
152
+ ```
153
+
154
+ - **Process.** `plugin :railwatch_server` spawns `railwatch serve` from
155
+ Puma's master after boot, in its own process group: a Ctrl-C reaches
156
+ Puma, Puma drains, and only then is the server stopped. A supervisor
157
+ thread checks every second and restarts a dead server with backoff
158
+ (1, 2, 4, 8, 15, 30 s), logging the death and the last lines of
159
+ `storage/railwatch/server.log`. Each check also asks the server's `/up`
160
+ (2 s timeout): one that is alive but stops answering (stopped, wedged)
161
+ is logged once as NOT ANSWERING, and after 8 s more of silence is
162
+ KILLed and restarted the same way (measured: a SIGSTOPped server was
163
+ replaced and answering in 11 s). At boot Puma waits at most 5 s for a
164
+ new server's `/up`, then boots without it and says so; a server that
165
+ hasn't answered once within 60 s of starting is killed and restarted. On stop: the railwatch agent's samplers
166
+ stop, the reporter flushes into the still-running server, then the
167
+ server gets SIGTERM (finishes in-flight requests, commits queued writes)
168
+ and KILL past the deadline. `server.pid` records "server parent"; a
169
+ server orphaned by a SIGKILLed Puma is stopped by the next start.
170
+ - **Proxy.** `/railwatch/*` is a prepended route to
171
+ `Railwatch::Server::ProxyController`. It forwards the server's cookies
172
+ and Inertia headers, sets `Host`/`X-Forwarded-Proto`, sets
173
+ `X-Forwarded-For` to the visitor's `request.remote_ip` (replacing any the
174
+ browser sent; the server runs with `RAILWATCH_TRUST_PROXY`, so its per-IP
175
+ sign-in and MCP limits count visitors, not the app), adds the signed
176
+ header, and hands the request off: **no Puma thread waits for the
177
+ server.** Past the gate the controller connects to the server and leaves
178
+ the request to `ProxyHandoff`, a middleware outside every other one,
179
+ which takes the browser's socket with Rack hijack and gives both
180
+ sockets to the process's one `Relay` thread (the live channel's, below).
181
+ The Relay writes the request (Puma has already read the body; a large
182
+ one comes from Puma's tempfile) and passes the response back as it
183
+ arrives, byte for byte except the head: `Connection: close` (the browser,
184
+ or the proxy in front of the app, opens a new connection for the next
185
+ request) and the app's own `Set-Cookie` (a rolling session) added. A
186
+ response the server hasn't started within the first-byte timeout is a
187
+ 504 and one it drops before answering a 502, both written by the Relay;
188
+ one that goes quiet mid-body is cut off after the idle timeout.
189
+ Measured (`test/e2e/slow_dashboard.rb`, Puma 1 worker x 3 threads, pages
190
+ taking 3 s): before, 6 slow loads made the app's `/up` wait 5.7 s;
191
+ after, 4 ms. With 30 slow loads at once the worker kept its 13 threads,
192
+ RSS rose 131 -> 137 MB and `/up` stayed under 12 ms. Encodings pass
193
+ through untouched. Without Rack hijack, and under Puma's own TLS (`ssl_bind`;
194
+ its socket wrapper holds decrypted bytes `IO.select` can't see), the
195
+ response is streamed from the Puma thread instead, as before: headers
196
+ as soon as the server sends them, the body as Puma writes it. Behind
197
+ Thruster, kamal-proxy or nginx the app sees plain HTTP/1.1 and the
198
+ handoff applies; HTTP/2 ends at that proxy. It doesn't rewrite paths:
199
+ the server itself serves under `/railwatch` (below). CSRF is the
200
+ server's own (Origin check plus its token), so Rails' check is off for
201
+ this controller, and so is inertia_rails' (its `XSRF-TOKEN` at
202
+ `Path=/` shadowed the server's, refusing every write). inertia_rails'
203
+ middleware would also answer every dashboard click with a 409 full
204
+ reload, since the dashboard's Inertia version is the server's, not the
205
+ app's; the proxy removes `X-Inertia` from the request once it has been
206
+ forwarded, so the middleware leaves the response alone.
207
+ Commenting, resolving, saving a view and changing a setting are tested
208
+ through the proxy (`test/e2e/browser_writes.rb`, and by hand in Chromium).
209
+ - **Live channel.** `/railwatch/cable` is a Rack hijack too; the browser's
210
+ and the server's sockets are spliced by the same `IO.select` thread per
211
+ app process (`Relay`), not two threads per socket: 30 open dashboards
212
+ cost one thread. At most `max_live_connections` per process, counted
213
+ apart from dashboard requests; past it the handshake gets 503 and the
214
+ dashboard falls back to reloading.
215
+ - **Trust.** `storage/railwatch/proxy_secret` (0600, generated) is the
216
+ HMAC key. The header is `v1.<claims>.<HMAC-SHA256>` with
217
+ `email, name, role, exp`; the server refuses a bad signature, an expired
218
+ one, one valid for more than 5 minutes, and any role but admin/member
219
+ (401, logged). It never falls back to "signed out". The server binds
220
+ loopback only, so nothing outside the host can reach it unproxied.
221
+
222
+ ## The server side (Rebulk/railwatch-rs)
223
+
224
+ The server features this gem relies on are upstream, in Rebulk/railwatch-rs
225
+ `main` from `ef87f72` (PR #16), and the dashboard's mount handling is in the
226
+ railwatch gem from 0.9.3. Every setting is off by default, so a self-hosted
227
+ install is unchanged. Documented in railwatch-rs `docs/SELF-HOST.md`,
228
+ "Running behind an app".
229
+
230
+ - **`RAILWATCH_BASE_PATH`**: pages are served under a prefix (`/railwatch`)
231
+ while `/ingest` and `/up` stay at the root, so the agent's URL doesn't
232
+ change. Redirects, cookie paths, asset/icon/entry URLs, emails, MCP
233
+ discovery and the Google callback all carry it.
234
+ - **`RAILWATCH_TRUSTED_PROXY_SECRET_FILE`**: the server accepts an
235
+ HMAC-signed `X-Railwatch-Proxy-Auth` header (email, name, admin/member
236
+ role, `exp` at most 300 s ahead) from this gem's proxy as the signed-in
237
+ user. The header is stripped from every request before any route sees it,
238
+ even with the mode off; when on, an invalid one is a 401, never a
239
+ fall-back. The proxy can't create or demote an owner.
240
+ - **`RAILWATCH_STANDBY_RELAY`**: the server holding `serve.lock` also
241
+ listens on `<data_dir>/serve.sock` (0600, peer uid checked); a second
242
+ `serve` on the same data dir binds its own loopback port at once and
243
+ relays to that socket until it gets the lock. This is what makes a
244
+ two-container deploy overlap lose nothing (see "Deploys" below).
245
+
246
+ **Why a base path, not rewriting in the proxy:** URLs are made in four
247
+ places (Rust redirects, Inertia's 409 `X-Inertia-Location`, js-routes in
248
+ the bundle, CSS/JS asset URLs) plus cookies and the cable URL. Rewriting
249
+ means parsing HTML, JSON page props and JS chunks, and it breaks the first
250
+ time the server adds a URL the proxy doesn't know about. With a base path
251
+ the server says the right URL once and the proxy stays byte-transparent.
252
+
253
+ ## Building
254
+
255
+ ```sh
256
+ script/build-server /path/to/railwatch-rs # railwatch-rs at or after ef87f72, frontend/dist built (bin/build, SKIP_CARGO=1)
257
+ rake gem:all # pkg/: ruby + 4 platform gems (~10 MB each)
258
+ ```
259
+
260
+ Builds use the pinned `cargo-zigbuild` image from `bin/release`: static musl
261
+ for Linux, and that image's MacOSX 11.3 SDK for both darwin targets. All
262
+ four built here (2-3 min each, 4 jobs).
263
+
264
+ **macOS: built natively, signed ad hoc, run in CI.** `.github/workflows/macos.yml`
265
+ builds the frontend once on Linux, then on `macos-14` (arm64) and
266
+ `macos-15-intel` (x86_64; GitHub retired the macos-13 runners) builds the
267
+ server from the pinned railwatch-rs commit (`MACOSX_DEPLOYMENT_TARGET=11.0`, checked
268
+ with `vtool`: minos 11.0), signs it `codesign -s -`, verifies the
269
+ signature and the arch, and runs it: `--version`, `setup` in a temp dir,
270
+ `serve`, `/up` and `/ingest/ping`, then a second `serve` on the same data
271
+ dir that comes up as a standby and takes over when the first gets TERM.
272
+ Then it builds the darwin platform gem, installs it, checks the installed
273
+ binary's signature and runs it. Artifacts: the binary and the `.gem` per
274
+ arch. The zigbuild cross-build in `script/build-server` still works for
275
+ Linux; for darwin, use the workflow's artifacts (they are signed).
276
+
277
+ ## Deploys and two processes
278
+
279
+ - **Same host, same data dir** (two Pumas, or `phased-restart` overlap):
280
+ the second app process finds the port already answered, starts its own
281
+ server anyway (it waits on `serve.lock`) and logs "standby". Its agent
282
+ reports to the first server, which owns the data. When the first app
283
+ process stops, its server drains and exits, the standby takes the lock
284
+ and the port (the log says "took over"). Measured: 10 + 10 requests
285
+ during the overlap and 10 after, 30 of 30 stored; one server writes at
286
+ any time.
287
+ - **Two containers sharing a volume** (Kamal blue/green): each container
288
+ has its own loopback, so the new container's agent can't reach the old
289
+ container's server. The new container's server (`RAILWATCH_STANDBY_RELAY`,
290
+ railwatch-rs `RAILWATCH_STANDBY_RELAY`) binds its own `127.0.0.1:28790` at once and splices
291
+ every connection, byte for byte, to `storage/railwatch/serve.sock`, where
292
+ the server holding `serve.lock` also listens. A Unix socket on the shared
293
+ volume crosses network namespaces (same kernel, same inode). When the old
294
+ container stops, its server drains and exits, the standby takes the lock
295
+ and serves the same listener itself; connections in that gap retry the
296
+ socket for up to 30 s. The log says "as a standby" then "took over".
297
+
298
+ Why this mattered (measured, `test/overlap/`, 3 runs before the fix): the
299
+ new container's first delivery was refused, the agent retained it and
300
+ retried on its ladder, and every retained retry doubled its backpressure
301
+ factor (1, 2, 4, 8). The sampler divides the request sample rate by that
302
+ factor, so for the ~12 s until the old server exited the new container
303
+ recorded 1 in 2, 1 in 4, then 1 in 8 of its requests. The dropped ones were
304
+ never recorded at all; nothing that reached the agent's buffer was lost.
305
+ 186, 184 and 197 of ~480 requests went unrecorded, every one at a factor
306
+ of 2, 4 or 8, none at 1. With `RAILWATCH_BACKPRESSURE=false` the same
307
+ overlap stored 476/476: the batches themselves all arrived on retry.
308
+ Starting the server before Puma accepts requests (under `bin/rails s`)
309
+ also removes the one refused delivery at boot that cost 5-9 requests.
310
+
311
+ | handover, ~800 requests | served | stored once | lost |
312
+ |---|---|---|---|
313
+ | before, `docker stop` | 795 | 602 | 193 (new container: 186) |
314
+ | before, SIGKILL | 793 | 582 | 211 |
315
+ | after, `docker stop` | 795 | 795 | 0 |
316
+ | after, SIGKILL of the old one | 789 | 771 | 18, all the old container's last second |
317
+ | after, fresh `rails new` app (Rails 8.1, Solid Queue), `docker stop` | 865 | 865 | 0 |
318
+ | after, fresh `rails new` app, SIGKILL of the old one | 845 | 828 | 17, the old container's last second |
319
+
320
+ A SIGKILLed app loses what its agent had not flushed yet (up to
321
+ `flush_interval`, here 1 s): the records were in that process's memory.
322
+ The new container lost nothing in either run, and no record was stored
323
+ twice. Volumes on two different hosts are not supported (flock and Unix
324
+ sockets don't cross hosts; each host would need its own data dir, which
325
+ splits the history).
326
+
327
+ ## Cost (same app, same 1000 requests, single-mode Puma)
328
+
329
+ | | idle RSS | RSS after 1000 req | CPU for 1000 req |
330
+ |---|---|---|---|
331
+ | this gem: `railwatch serve` | 12 MB | 51 MB | 0.15 s |
332
+ | railwatch gem embedded: writer process | 87 MB | 138 MB | 0.08 s |
333
+ | Puma itself (either way) | 126 MB | 162-166 MB | 3.2-3.8 s |
334
+
335
+ The Rust server is a third of the Ruby writer's memory. The Ruby writer
336
+ spends less CPU only because it writes rows and does no grouping,
337
+ rollups, alerts or the dashboard. Rendering the dashboard costs the
338
+ sidecar, not Puma (Puma only proxies), whereas the embedded dashboard
339
+ renders inside Puma's workers.
340
+
341
+ ## Limitations and TODOs
342
+
343
+ - Dashboard connections close after each response (no keep-alive
344
+ between the browser and the proxy for `/railwatch`). On loopback or
345
+ behind a reverse proxy that costs nothing measurable; a browser talking
346
+ straight to Puma over the internet opens a new TCP connection per
347
+ dashboard request.
348
+ - The cap is per process and checked before connecting, so a burst can
349
+ pass it by as many requests as Puma has threads.
350
+ - macOS binaries are ad-hoc signed only: fine for a gem installed with
351
+ Bundler (RubyGems sets no quarantine flag). A notarized release needs an
352
+ Apple Developer ID certificate in CI (`codesign -s "Developer ID
353
+ Application: ..." --options runtime --timestamp`, then `notarytool
354
+ submit --wait`); a bare executable can't be stapled, so Gatekeeper checks
355
+ it online the first time it runs from a quarantined download.
356
+ - One environment per data dir (above).
357
+ - The account owner is a placeholder user with a random password nobody
358
+ knows; the proxied users are admins/members. Transferring ownership
359
+ works in Settings > Members.
360
+ - Releases must keep building the platform gems from a published
361
+ railwatch-rs commit (AGPL section 6): `RAILWATCH_RS_SHA` in
362
+ `.github/workflows/macos.yml`, and the same commit for the Linux builds.
363
+
364
+ ## License
365
+
366
+ AGPL-3.0-only (LICENSE). The platform gems contain the Railwatch server
367
+ binary; NOTICE explains what that means for your app (nothing: it runs as
368
+ a separate program) and where the corresponding source is.