texas2step 2.2.3__tar.gz → 2.2.4__tar.gz

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 (67) hide show
  1. {texas2step-2.2.3 → texas2step-2.2.4}/PKG-INFO +1 -1
  2. {texas2step-2.2.3 → texas2step-2.2.4}/docs/access.md +38 -16
  3. {texas2step-2.2.3 → texas2step-2.2.4}/docs/api.md +14 -10
  4. {texas2step-2.2.3 → texas2step-2.2.4}/docs/roadmap.md +10 -0
  5. {texas2step-2.2.3 → texas2step-2.2.4}/docs/security.md +26 -3
  6. {texas2step-2.2.3 → texas2step-2.2.4}/src/texas2step/__init__.py +1 -1
  7. {texas2step-2.2.3 → texas2step-2.2.4}/src/texas2step/api/access.py +45 -12
  8. {texas2step-2.2.3 → texas2step-2.2.4}/src/texas2step/api/meta.py +10 -4
  9. {texas2step-2.2.3 → texas2step-2.2.4}/src/texas2step/api/results.py +10 -0
  10. {texas2step-2.2.3 → texas2step-2.2.4}/src/texas2step/api/wire.py +19 -3
  11. {texas2step-2.2.3 → texas2step-2.2.4}/src/texas2step/app.py +127 -33
  12. {texas2step-2.2.3 → texas2step-2.2.4}/src/texas2step/cli.py +6 -3
  13. {texas2step-2.2.3 → texas2step-2.2.4}/src/texas2step/cluster.py +15 -3
  14. {texas2step-2.2.3 → texas2step-2.2.4}/src/texas2step/lockouts.py +97 -9
  15. {texas2step-2.2.3 → texas2step-2.2.4}/src/texas2step/rtc.py +7 -2
  16. texas2step-2.2.4/src/texas2step/static/assets/app.js +8 -0
  17. {texas2step-2.2.3 → texas2step-2.2.4}/src/texas2step/static/index.html +1 -1
  18. texas2step-2.2.3/src/texas2step/static/assets/app.js +0 -8
  19. {texas2step-2.2.3 → texas2step-2.2.4}/.gitignore +0 -0
  20. {texas2step-2.2.3 → texas2step-2.2.4}/LICENSE.md +0 -0
  21. {texas2step-2.2.3 → texas2step-2.2.4}/README.md +0 -0
  22. {texas2step-2.2.3 → texas2step-2.2.4}/deploy/nginx/texas2step-http.conf +0 -0
  23. {texas2step-2.2.3 → texas2step-2.2.4}/deploy/nginx/texas2step-log.conf +0 -0
  24. {texas2step-2.2.3 → texas2step-2.2.4}/deploy/nginx/texas2step-speedtest.conf +0 -0
  25. {texas2step-2.2.3 → texas2step-2.2.4}/deploy/nginx/texas2step.conf +0 -0
  26. {texas2step-2.2.3 → texas2step-2.2.4}/deploy/texas2step-update.service +0 -0
  27. {texas2step-2.2.3 → texas2step-2.2.4}/deploy/texas2step-update.timer +0 -0
  28. {texas2step-2.2.3 → texas2step-2.2.4}/deploy/texas2step.default +0 -0
  29. {texas2step-2.2.3 → texas2step-2.2.4}/deploy/texas2step.service +0 -0
  30. {texas2step-2.2.3 → texas2step-2.2.4}/docs/configuration.md +0 -0
  31. {texas2step-2.2.3 → texas2step-2.2.4}/docs/development.md +0 -0
  32. {texas2step-2.2.3 → texas2step-2.2.4}/docs/experience.md +0 -0
  33. {texas2step-2.2.3 → texas2step-2.2.4}/docs/images/hero-dark.jpg +0 -0
  34. {texas2step-2.2.3 → texas2step-2.2.4}/docs/images/results-dark.jpg +0 -0
  35. {texas2step-2.2.3 → texas2step-2.2.4}/docs/images/results-light.jpg +0 -0
  36. {texas2step-2.2.3 → texas2step-2.2.4}/docs/images/upload-light.jpg +0 -0
  37. {texas2step-2.2.3 → texas2step-2.2.4}/docs/install.md +0 -0
  38. {texas2step-2.2.3 → texas2step-2.2.4}/docs/methodology.md +0 -0
  39. {texas2step-2.2.3 → texas2step-2.2.4}/docs/traffic.md +0 -0
  40. {texas2step-2.2.3 → texas2step-2.2.4}/legacy/README.md +0 -0
  41. {texas2step-2.2.3 → texas2step-2.2.4}/pyproject.toml +0 -0
  42. {texas2step-2.2.3 → texas2step-2.2.4}/src/texas2step/__main__.py +0 -0
  43. {texas2step-2.2.3 → texas2step-2.2.4}/src/texas2step/access.py +0 -0
  44. {texas2step-2.2.3 → texas2step-2.2.4}/src/texas2step/api/__init__.py +0 -0
  45. {texas2step-2.2.3 → texas2step-2.2.4}/src/texas2step/api/measure.py +0 -0
  46. {texas2step-2.2.3 → texas2step-2.2.4}/src/texas2step/config.py +0 -0
  47. {texas2step-2.2.3 → texas2step-2.2.4}/src/texas2step/config_edit.py +0 -0
  48. {texas2step-2.2.3 → texas2step-2.2.4}/src/texas2step/config_merge.py +0 -0
  49. {texas2step-2.2.3 → texas2step-2.2.4}/src/texas2step/config_template.toml +0 -0
  50. {texas2step-2.2.3 → texas2step-2.2.4}/src/texas2step/engine.py +0 -0
  51. {texas2step-2.2.3 → texas2step-2.2.4}/src/texas2step/iface.py +0 -0
  52. {texas2step-2.2.3 → texas2step-2.2.4}/src/texas2step/logs.py +0 -0
  53. {texas2step-2.2.3 → texas2step-2.2.4}/src/texas2step/page.py +0 -0
  54. {texas2step-2.2.3 → texas2step-2.2.4}/src/texas2step/payload.py +0 -0
  55. {texas2step-2.2.3 → texas2step-2.2.4}/src/texas2step/quality.py +0 -0
  56. {texas2step-2.2.3 → texas2step-2.2.4}/src/texas2step/static/assets/app.css +0 -0
  57. {texas2step-2.2.3 → texas2step-2.2.4}/src/texas2step/static/assets/swagger/LICENSE +0 -0
  58. {texas2step-2.2.3 → texas2step-2.2.4}/src/texas2step/static/assets/swagger/NOTICE +0 -0
  59. {texas2step-2.2.3 → texas2step-2.2.4}/src/texas2step/static/assets/swagger/swagger-ui-bundle.js +0 -0
  60. {texas2step-2.2.3 → texas2step-2.2.4}/src/texas2step/static/assets/swagger/swagger-ui-bundle.js.LICENSE.txt +0 -0
  61. {texas2step-2.2.3 → texas2step-2.2.4}/src/texas2step/static/assets/swagger/swagger-ui.css +0 -0
  62. {texas2step-2.2.3 → texas2step-2.2.4}/src/texas2step/static/favicon.svg +0 -0
  63. {texas2step-2.2.3 → texas2step-2.2.4}/src/texas2step/stats.py +0 -0
  64. {texas2step-2.2.3 → texas2step-2.2.4}/src/texas2step/store.py +0 -0
  65. {texas2step-2.2.3 → texas2step-2.2.4}/src/texas2step/traffic.py +0 -0
  66. {texas2step-2.2.3 → texas2step-2.2.4}/src/texas2step/update.py +0 -0
  67. {texas2step-2.2.3 → texas2step-2.2.4}/src/texas2step/words.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: texas2step
3
- Version: 2.2.3
3
+ Version: 2.2.4
4
4
  Summary: Web-centric throughput, latency, jitter and packet-loss measurement between endpoints.
5
5
  Project-URL: Homepage, https://github.com/robert-patrick-texas/texas2step
6
6
  Project-URL: Documentation, https://robert-patrick-texas.github.io/texas2step/
@@ -234,18 +234,29 @@ texas2step run https://speedtest.example.net/texas2step --code maple.river.42
234
234
 
235
235
  | | Needs |
236
236
  |---|---|
237
- | The page at `/`, which asks for the code; its scripts, styles and logo; `/api/v1/health` (for load balancers, without the version); `/api/v1/access` | Nothing |
238
- | The Monitor and Diag pages, the tests, and saving a result | A pass, or an address in `internal_networks`. Without a pass, `/monitor` and `/diag` send the visitor to `/`. |
237
+ | The page, at `/`, `/monitor` or `/diag`, which asks for the code; its scripts, styles and logo; `/api/v1/health` (for load balancers, without the version); `/api/v1/access` | Nothing |
238
+ | The tests, Monitor and Diag's measurements, and saving a result | A pass, or an address in `internal_networks` |
239
239
  | History, the API tab, `/r/<id>`, reading saved results, `/api/docs`, `/api/openapi.json`, `/api/v1/measure/*`, the current code, [lockouts and blocks](#lockouts-and-blocks) | An address in `internal_networks`; others get 404 |
240
240
 
241
- The pass goes with every request the page makes, as `?pass=`. A page load can't carry
242
- that, so a right code also sets the pass as a cookie for the server's own pages:
243
- `HttpOnly`, `Secure` over HTTPS, `SameSite=Strict`, gone when the pass expires.
244
-
245
- Until a visitor enters a code, nothing tells them the version: the page leaves it out and
246
- `/api/v1/health` answers only `{"status":"ok"}`. Inside `internal_networks`, and once in,
247
- it is there as before. This slows an attacker down rather than stopping one: the page's
248
- scripts are those of a published release.
241
+ The page keeps the pass in the browser's local storage and sends it with every request
242
+ it makes. To its own server it goes in an `Authorization: Bearer` header, and a
243
+ WebSocket's in its subprotocol, which keeps the pass out of the URLs that
244
+ TLS-inspecting proxies log. To the other servers in the picker it goes as `?pass=`: a
245
+ header there would make the browser ask permission (a CORS preflight) before each timed
246
+ request and skew the timings. The server takes the pass in any of these, so the CLI's
247
+ `?pass=` works as before. There is no cookie: a page needs no pass to load, and asks for
248
+ the code itself. A cookie an earlier version set is removed at the next right code.
249
+
250
+ With `internal_networks` set, only those networks see the version. The page,
251
+ `/api/v1/info` and `/api/v1/health` leave it out for everyone else, even after they enter
252
+ a code, and a result they save gets it from the server. Without `internal_networks`, a
253
+ visitor sees it once they have entered a code; until then the page leaves it out and
254
+ `/api/v1/health` answers only `{"status":"ok"}`. This slows an attacker down rather than
255
+ stopping one: the page's scripts are those of a published release.
256
+
257
+ The desk's functions also refuse, with `403`, a browser's request made for a page on
258
+ another site, so that a website an employee opens can't use them through the employee's
259
+ browser ([Security](security.md#the-desks-tools)).
249
260
 
250
261
  Loopback addresses are always inside, so commands on the server itself need no code. The
251
262
  service sees each visitor's address through nginx (`trusted_proxies`), which the packaged
@@ -276,7 +287,8 @@ codes in a row from a network, it is locked out:
276
287
  * A network quiet for 30 minutes starts counting again from nothing, and so does one that
277
288
  enters a right code.
278
289
  * Visitors from `internal_networks` and loopback need no code, so they are never counted
279
- or locked out. Passes already given keep working.
290
+ or locked out. Passes already given keep working through a lockout; a block set by hand
291
+ ends them (below).
280
292
 
281
293
  | Setting | Default | |
282
294
  |---|---|---|
@@ -324,6 +336,14 @@ longer if it keeps trying; blocking it again keeps it going. A block can't overl
324
336
  take effect at once, with no restart, and with
325
337
  [servers that defend together](#servers-that-defend-together), on every server.
326
338
 
339
+ A block also ends the passes used from the network it covers. Until it ends, their
340
+ requests are refused as if they carried none (`401`), and the WebSockets and UDP
341
+ sessions they have open close within a few seconds. The page goes back to asking for the
342
+ code, which the blocked network can't enter. A lockout from wrong codes ends no passes,
343
+ so callers who share a network with a guesser keep testing with the pass they hold. A
344
+ block set or ended through the API counts at once. One from the command line reaches
345
+ each of the service's workers within 5 seconds, and so does one that ends.
346
+
327
347
  **From automation.** The same, as an API that answers only `internal_networks` and the
328
348
  server itself, so a script can carry a block from the server that saw an attack to all
329
349
  the others, and renew it there:
@@ -335,8 +355,9 @@ curl -s -X POST https://houston.example.net/texas2step/api/v1/access/blocks \
335
355
  curl -s -X DELETE 'https://houston.example.net/texas2step/api/v1/access/blocks?network=198.51.100.0/24'
336
356
  ```
337
357
 
338
- A list gives each lockout's `network`, `source`, `wrong_codes`, `since` and `until`, and
339
- `counting`, the networks with wrong codes in a row. A block that overlaps
358
+ A list gives each lockout's `network`, `source`, `wrong_codes`, `since`, `until` and
359
+ `by_hand` (a block, which ends passes too), and `counting`, the networks with wrong codes
360
+ in a row. A block that overlaps
340
361
  `internal_networks`, or is wider than a /16 or a /32, is refused with `400`. These calls
341
362
  change only the server they are sent to; for one that reaches every server by itself, see
342
363
  [Servers that defend together](#servers-that-defend-together).
@@ -372,7 +393,9 @@ access_peer_networks = ["198.51.100.20", "2001:db8:20::20", "203.0.113.30"]
372
393
  third of `access_lockout_minutes`, so the others' blocks don't run out first.
373
394
  `sudo texas2step access block` and `access lockouts --clear` go to every server too:
374
395
  `--clear` on any one of them lets a caller back in everywhere. The
375
- [API's blocks](#lockouts-and-blocks) stay on the server they are sent to.
396
+ [API's blocks](#lockouts-and-blocks) stay on the server they are sent to. A block from
397
+ `access block` ends passes on every server, as here; a lockout ends none anywhere. A
398
+ server on an earlier version sends either as a lockout.
376
399
  * **What a server takes.** Only requests from an address in its `access_peer_networks`,
377
400
  signed with a key the servers work out from the access key they share. Use the
378
401
  addresses the other servers' requests leave from, which behind NAT may not be the
@@ -488,8 +511,7 @@ many as you need (`results_max_rows`). `sudo texas2step results clear` deletes t
488
511
  | Your `access_words` file | Yours: kept by `apt purge` |
489
512
  | `/var/lib/texas2step/access.db` | Wrong codes, lockouts and blocks, and with servers that defend together, the one-time values of their requests; made by the service when access codes are on; removed with `apt purge` |
490
513
 
491
- The pass lives in the visitor's browser (local storage, and the cookie for this server's
492
- pages) until it expires. Passes are not written to the journal: the request log shows
514
+ The pass lives in the visitor's browser, in local storage, until it expires. Passes are not written to the journal: the request log shows
493
515
  `pass=*` in their place. nginx's access log doesn't have them either: the packaged
494
516
  snippets log texas2step's requests to `/var/log/nginx/access.log` without their query
495
517
  strings (the `texas2step` log format, in `/etc/nginx/conf.d/texas2step-log.conf`).
@@ -91,11 +91,11 @@ every port in its `rtc_port_min`–`rtc_port_max` range is taken.
91
91
 
92
92
  | Method | Path | Returns |
93
93
  |---|---|---|
94
- | GET | `/api/v1/info` | name, version, server identity, capabilities (`stamps`: the UDP probe can [stamp](#udp-probe-protocol) its echoes), limits, ICE servers, UI defaults (`one_stream`: whether a test downloads over one stream too, unless asked otherwise), `monitor` (`profile`: Monitor's until a visitor picks; `video`: whether the Video call profile is offered); with a [traffic limit](traffic.md), `traffic` (below) |
94
+ | GET | `/api/v1/info` | name, version (left out for clients outside `internal_networks`), server identity, capabilities (`stamps`: the UDP probe can [stamp](#udp-probe-protocol) its echoes), limits, ICE servers, UI defaults (`one_stream`: whether a test downloads over one stream too, unless asked otherwise), `monitor` (`profile`: Monitor's until a visitor picks; `video`: whether the Video call profile is offered); with a [traffic limit](traffic.md), `traffic` (below) |
95
95
  | GET | `/api/v1/whoami` | client IP, port, family, scheme, HTTP version, user agent |
96
96
  | GET | `/api/v1/diag/http` | both ends of this HTTP connection, its HTTP version and TLS, the server's TCP RTT and clock (below) |
97
97
  | GET | `/api/v1/locations` | servers offered in the picker (this one first) |
98
- | GET | `/api/v1/health` | `{"status": "ok", "version": …}`; without the version for clients that need an [access code](#access-codes) |
98
+ | GET | `/api/v1/health` | `{"status": "ok", "version": …}`; without the version for clients outside `internal_networks`, or that need an [access code](#access-codes) |
99
99
 
100
100
  With a traffic limit, `traffic` says whether this client may start a test, and if not,
101
101
  what it is told:
@@ -175,21 +175,25 @@ curl -s -X POST https://dallas.example.net/texas2step/api/v1/measure/full \
175
175
  ## Access codes
176
176
 
177
177
  On a server with `access_code` on ([Access codes](access.md)), a client from outside
178
- `internal_networks` sends a pass as `?pass=<pass>` with each request under `/api/v1/`
179
- (except `/health` and `/access`); without a valid one it gets `401`. The WebSocket takes
180
- it the same way. With `internal_networks` set, clients from outside them get `404` for
178
+ `internal_networks` sends a pass with each request under `/api/v1/` (except `/health`
179
+ and `/access`); without a valid one it gets `401`. It goes as `?pass=<pass>`, or in an
180
+ `Authorization: Bearer <pass>` header; a WebSocket can also carry it in its subprotocol,
181
+ offering `texas2step` and `texas2step.pass.<pass>`, and is answered with `texas2step`. With `internal_networks` set, clients from outside them get `404` for
181
182
  reading results, `/api/v1/measure/*`, `/api/docs`, `/api/openapi.json`, the current code and
182
- the lockouts. `/api/v1/health` leaves out the version for clients that would need a pass.
183
+ the lockouts, and the browser's requests to these made for a page on another site get
184
+ `403`. With `internal_networks` set, `/api/v1/info` and `/api/v1/health` leave out the
185
+ version for clients outside them; without them, `/api/v1/health` leaves it out for
186
+ clients that would need a pass.
183
187
 
184
188
  | Method | Path | Purpose |
185
189
  |---|---|---|
186
190
  | GET | `/api/v1/access?pass=<pass>` | `{"required", "desk"}`: whether this client needs a pass, and gets the desk's functions; with `pass`, also whether that pass is valid and when it expires |
187
- | POST | `/api/v1/access` | `{"code": "maple.river.42"}` (any case and separators, or a standing phrase) returns `{"pass", "expires"}`, and sets the pass as a cookie for this server's pages. A wrong code is `403`. After `access_lockout_after` wrong codes in a row from one /24 or /48, every code from it gets that same `403`, unread, until `access_lockout_minutes` pass without one ([Lockouts and blocks](access.md#lockouts-and-blocks)). A body over 1 KB is `413` |
191
+ | POST | `/api/v1/access` | `{"code": "maple.river.42"}` (any case and separators, or a standing phrase) returns `{"pass", "expires"}`. A wrong code is `403`. After `access_lockout_after` wrong codes in a row from one /24 or /48, every code from it gets that same `403`, unread, until `access_lockout_minutes` pass without one ([Lockouts and blocks](access.md#lockouts-and-blocks)). A body over 1 KB is `413` |
188
192
  | GET | `/api/v1/access/code` | The current code, when it changes, and the previous one while it still works; only from `internal_networks` |
189
- | GET | `/api/v1/access/blocks` | `{"lockouts": [{"network", "source", "wrong_codes", "since", "until"}], "counting"}`; only from `internal_networks` and the server itself |
190
- | POST | `/api/v1/access/blocks` | `{"network": "198.51.100.0/24", "minutes": 120}` locks a network out by hand, or keeps its lockout going; `minutes` defaults to `access_lockout_minutes`. One that overlaps `internal_networks`, or is wider than a /16 or a /32, is `400` |
193
+ | GET | `/api/v1/access/blocks` | `{"lockouts": [{"network", "source", "wrong_codes", "since", "until", "by_hand"}], "counting"}` (`by_hand`: a block, which ends the passes used from the network too); only from `internal_networks` and the server itself |
194
+ | POST | `/api/v1/access/blocks` | `{"network": "198.51.100.0/24", "minutes": 120}` locks a network out by hand, or keeps its lockout going, and ends the passes used from it; `minutes` defaults to `access_lockout_minutes`. One that overlaps `internal_networks`, or is wider than a /16 or a /32, is `400` |
191
195
  | DELETE | `/api/v1/access/blocks?network=<network>` | Ends the lockouts that overlap it (`all`: every one); returns `{"cleared": [...]}` |
192
- | POST | `/api/v1/access/peer` | For [servers that defend together](access.md#servers-that-defend-together): `{"from", "to", "action": "block" \| "clear" \| "check", "network", "minutes"}`, signed in `X-Texas2step-Time`, `-Nonce` and `-Signature`. Only from `access_peer_networks`, with `access_share_lockouts` on; `404` to everyone else |
196
+ | POST | `/api/v1/access/peer` | For [servers that defend together](access.md#servers-that-defend-together): `{"from", "to", "action": "block" \| "clear" \| "check", "network", "minutes", "kind": "lockout" \| "block"}` (`kind`: a block set by hand, which ends passes there too; `lockout` when left out), signed in `X-Texas2step-Time`, `-Nonce` and `-Signature`. Only from `access_peer_networks`, with `access_share_lockouts` on; `404` to everyone else |
193
197
 
194
198
  ```bash
195
199
  pass=$(curl -s -X POST https://speedtest.example.net/texas2step/api/v1/access \
@@ -116,6 +116,16 @@ links and footer credit. Three places still say texas2step:
116
116
 
117
117
  Newest first. Each release's notes have the details and its upgrade steps.
118
118
 
119
+ ### [2.2.4](https://github.com/robert-patrick-texas/texas2step/releases/tag/v2.2.4) (2026-10-25)
120
+
121
+ - [x] The desk's functions refuse requests made for pages on other sites; with
122
+ `internal_networks` set, only those networks see the version.
123
+ - [x] API answers are kept in no browser cache.
124
+ - [x] A block set by hand ends the passes used from its network, sessions already open
125
+ included.
126
+ - [x] No cookie: the pass stays in local storage and goes to the page's own server in a
127
+ header; a Content-Security-Policy on the page and the API explorer.
128
+
119
129
  ### [2.2.3](https://github.com/robert-patrick-texas/texas2step/releases/tag/v2.2.3) (2026-10-24)
120
130
 
121
131
  - [x] Spaces around any setting or typed value are ignored; a stray one in
@@ -38,9 +38,13 @@ pass.
38
38
  is kept out of the world-readable `config.toml`. `texas2step access show` prints a
39
39
  fingerprint to compare servers by, which reveals nothing about the key.
40
40
  * **Passes.** A right code buys a pass for `access_pass_hours` (12 by default): an expiry
41
- time signed with the key, checked by every server that holds it. The browser sends it
42
- as `?pass=`; this server's own pages get it as a cookie that is `HttpOnly`,
43
- `SameSite=Strict`, and `Secure` over HTTPS.
41
+ time signed with the key, checked by every server that holds it. The page keeps it in
42
+ local storage, with no cookie, and sends it to its own server in an `Authorization`
43
+ header (a WebSocket's in its subprotocol), so it stays out of URLs that proxies log;
44
+ to the other servers, as `?pass=`. A pass is a bearer token: it isn't tied to
45
+ an address or a browser, and works until it expires. A
46
+ [block set by hand](access.md#lockouts-and-blocks) ends it for the network blocked,
47
+ WebSockets and UDP sessions already open included. A new access key ends every pass.
44
48
  * **Standing phrases.** For the desk away from a PC, in their own `0640` file
45
49
  (`access-phrases.toml`), with an optional last day. Each use is a journal warning with
46
50
  the phrase's name, never the phrase. The examples published in the docs are refused.
@@ -97,10 +101,22 @@ server itself), and `404` to everyone else, as if they weren't there:
97
101
  * the API explorer (`/api/docs`, `/api/openapi.json`);
98
102
  * the server-side measurements (`/api/v1/measure/*`).
99
103
 
104
+ They also refuse, with `403`, a browser's request made for a page on another site. They
105
+ trust the address a request comes from, and CORS lets any page read the answer, so a
106
+ website an employee opens inside could otherwise read History, the current code and the
107
+ blocks through that employee's browser, or set blocks. The browser says where a request
108
+ came from in its `Sec-Fetch-Site` header, or, without one, in its `Origin`. A link from
109
+ elsewhere still opens the desk's pages, because the other site can't read them. Scripts,
110
+ the CLI and monitoring send neither header and work as before.
111
+
100
112
  Server-side measurements only reach the servers in `[[locations]]`, unless
101
113
  `allow_any_target` is set, so the server can't be used to make requests to other hosts.
102
114
  At most two run at once on each worker.
103
115
 
116
+ **The version** would tell an attacker which flaws to try. With `internal_networks` set,
117
+ only those networks see it: the page, `/api/v1/info` and `/api/v1/health` leave it out for
118
+ everyone else, even after they enter a code. A result they save gets it from the server.
119
+
104
120
  ## Abuse of the service
105
121
 
106
122
  A speed test exists to move a lot of data, so each part has a cap:
@@ -138,6 +154,13 @@ A speed test exists to move a lot of data, so each part has a cap:
138
154
  figures) belong to the service user. The traffic figures hold no addresses.
139
155
  * **No third parties.** The web page loads nothing from other sites: no CDN, fonts,
140
156
  analytics or telemetry. No public STUN server is used unless listed in `ice_servers`.
157
+ * **A Content-Security-Policy** on the page and the API explorer lets only their own
158
+ scripts run, and the one written into each page by its hash, and lets them connect
159
+ only to this server and the others in `[[locations]]`. A script injected some other
160
+ way can't run, or send what it finds to another site.
161
+ * **Its own hostname.** A browser shares local storage across a whole origin, the scheme,
162
+ host and port. Where texas2step sits beside other applications on one hostname, any page
163
+ there could read the pass, so serve it from a hostname of its own where you can.
141
164
 
142
165
  ## Cryptography
143
166
 
@@ -1,3 +1,3 @@
1
1
  """texas2step: web-centric throughput, latency, jitter and packet-loss measurement."""
2
2
 
3
- __version__ = "2.2.3"
3
+ __version__ = "2.2.4"
@@ -10,6 +10,7 @@ from typing import Any, Literal
10
10
 
11
11
  from fastapi import APIRouter, HTTPException, Query, Request, Response
12
12
  from pydantic import BaseModel, ConfigDict, Field, ValidationError
13
+ from starlette.requests import HTTPConnection
13
14
 
14
15
  from .. import cluster
15
16
  from ..access import Codes, is_internal
@@ -20,7 +21,28 @@ router = APIRouter(prefix="/api/v1/access", tags=["access"])
20
21
  log = logging.getLogger(__name__)
21
22
 
22
23
  WRONG = "that isn't the current access code" # also the answer while a network is locked out
23
- PASS_COOKIE = "texas2step_pass" # the pass again, for loading this server's own pages
24
+ # Earlier versions sent the pass back as this cookie too, for loading the server's own
25
+ # pages; pages need no pass now, and POST /access removes it.
26
+ PASS_COOKIE = "texas2step_pass"
27
+ # The page's WebSockets to its own server carry the pass in their subprotocol, as a
28
+ # browser can't give them a header: "texas2step.pass.<pass>", answered with "texas2step".
29
+ WS_PROTOCOL = "texas2step"
30
+ WS_PASS = WS_PROTOCOL + ".pass."
31
+
32
+
33
+ def given_pass(conn: HTTPConnection) -> str:
34
+ """The pass a request carries: as ?pass= (the CLI, and the page to other servers), in
35
+ an Authorization: Bearer header (the page to its own server, keeping it out of URLs
36
+ that proxies log), or in a WebSocket's subprotocol."""
37
+ if token := conn.query_params.get("pass", ""):
38
+ return token
39
+ scheme, _, token = conn.headers.get("authorization", "").partition(" ")
40
+ if scheme.lower() == "bearer":
41
+ return token.strip()
42
+ for protocol in conn.headers.get("sec-websocket-protocol", "").split(","):
43
+ if (protocol := protocol.strip()).startswith(WS_PASS):
44
+ return protocol[len(WS_PASS) :]
45
+ return ""
24
46
 
25
47
 
26
48
  class CodeIn(BaseModel):
@@ -51,6 +73,9 @@ class PeerIn(BaseModel):
51
73
  action: Literal["block", "clear", "check"]
52
74
  network: str = Field("", max_length=64)
53
75
  minutes: float | None = Field(None, gt=0, le=cluster.MOST_MINUTES)
76
+ # A block set by hand there ends passes here too; one from wrong codes doesn't. Older
77
+ # servers send no kind, so theirs count as lockouts.
78
+ kind: Literal["lockout", "block"] = "lockout"
54
79
 
55
80
 
56
81
  def internal(request: Request) -> bool:
@@ -75,8 +100,13 @@ async def status(
75
100
  "required": codes is not None and not internal(request),
76
101
  "desk": desk(request),
77
102
  }
103
+ pass_ = pass_ or given_pass(request) # the page sends its own server's in a header
78
104
  if codes is not None and pass_:
79
105
  expires = codes.pass_expires(pass_)
106
+ # A block set by hand on this client's network ends its pass (app.AccessGate).
107
+ ip, _ = client_address(request)
108
+ if expires is not None and body["required"] and await request.app.state.blocks.cover(ip):
109
+ expires = None
80
110
  body["pass"] = {"valid": expires is not None, "expires": expires}
81
111
  return body
82
112
 
@@ -128,15 +158,14 @@ async def unlock(request: Request, given: CodeIn, response: Response) -> dict[st
128
158
  if note != "access code": # a standing phrase: every use is logged, so a leaked one shows
129
159
  log.warning("%s used from %s", note, ip)
130
160
  token, expires = codes.issue()
131
- response.set_cookie(
132
- PASS_COOKIE,
133
- token,
134
- max_age=codes.pass_seconds,
135
- path=request.scope.get("root_path", "") + "/",
136
- secure=_https(request),
137
- httponly=True,
138
- samesite="strict",
139
- )
161
+ if PASS_COOKIE in request.cookies: # an earlier version's: the page keeps the pass itself
162
+ response.delete_cookie(
163
+ PASS_COOKIE,
164
+ path=request.scope.get("root_path", "") + "/",
165
+ secure=_https(request),
166
+ httponly=True,
167
+ samesite="strict",
168
+ )
140
169
  return {"pass": token, "expires": expires}
141
170
 
142
171
 
@@ -211,6 +240,7 @@ def _lockout(codes: Codes, lockout: Lockout) -> dict[str, Any]:
211
240
  "network": lockout.network,
212
241
  "source": lockout.source,
213
242
  "wrong_codes": lockout.failures,
243
+ "by_hand": lockout.by_hand, # a block: it ends the passes used from the network too
214
244
  "since": datetime.fromtimestamp(since, codes.zone).isoformat() if since else None,
215
245
  "until": datetime.fromtimestamp(lockout.expires, codes.zone).isoformat(),
216
246
  }
@@ -246,6 +276,7 @@ async def block(request: Request, given: BlockIn) -> dict[str, Any]:
246
276
  lockouts = request.app.state.lockouts
247
277
  why = f"blocked by the API from {ip}"
248
278
  lockout = await asyncio.to_thread(lockouts.block, str(net), minutes, why)
279
+ request.app.state.blocks.forget()
249
280
  codes = request.app.state.codes
250
281
  log.warning("%s blocked until %s, by the API from %s", net, _at(codes, lockout.expires), ip)
251
282
  until = datetime.fromtimestamp(lockout.expires, codes.zone)
@@ -319,7 +350,8 @@ async def peer(request: Request) -> dict[str, Any]:
319
350
  raise HTTPException(403, why)
320
351
  if given.action == "check":
321
352
  return {"server": settings.server_id, "ok": True}
322
- source = f"{SHARED_BY}{given.sender} from {ip}"
353
+ by_hand = given.kind == "block"
354
+ source = f"{SHARED_BY}{given.sender} from {ip}" + (", set by hand" if by_hand else "")
323
355
  if given.action == "clear":
324
356
  try: # ending lockouts can't harm: any network, or all
325
357
  target = None if given.network == "all" else str(parse_network(given.network))
@@ -338,7 +370,8 @@ async def peer(request: Request) -> dict[str, Any]:
338
370
  )
339
371
  raise HTTPException(429, f"this server holds {cluster.MOST_HELD} blocks from others")
340
372
  minutes = given.minutes or settings.access_lockout_minutes
341
- lockout = await asyncio.to_thread(lockouts.block, net, minutes, source)
373
+ lockout = await asyncio.to_thread(lambda: lockouts.block(net, minutes, source, by_hand=by_hand))
374
+ request.app.state.blocks.forget()
342
375
  log.warning("%s blocked until %s, %s", net, _at(codes, lockout.expires), source)
343
376
  until = datetime.fromtimestamp(lockout.expires, codes.zone)
344
377
  cluster.hook_in_background(request.app, "block", net, until, source)
@@ -91,6 +91,10 @@ async def info(request: Request) -> dict[str, Any]:
91
91
  # Monitor's profile until a visitor picks one, and whether the video one is offered.
92
92
  "monitor": {"profile": s.monitor_profile, "video": s.monitor_video},
93
93
  }
94
+ # With internal_networks set, the version is theirs only. Without them, anyone who
95
+ # reaches this has entered the code or needs none (AccessGate).
96
+ if s.internal_networks and not shows_version(request):
97
+ del out["version"]
94
98
  if (meter := request.app.state.traffic) is not None:
95
99
  # Whether a traffic limit lets this client test; for the desk, how much is left.
96
100
  kind = meter.kind(client_address(request)[0])
@@ -204,9 +208,11 @@ async def health(request: Request) -> dict[str, str]:
204
208
 
205
209
 
206
210
  def shows_version(request: HTTPConnection) -> bool:
207
- """Whether this client may see the version: not one that needs an access code first.
208
- Knowing it would tell an attacker which flaws to try."""
209
- if request.app.state.codes is None:
211
+ """Whether this client may see the version: knowing it would tell an attacker which
212
+ flaws to try. With internal_networks set, only they do; without them, not a visitor
213
+ who needs an access code; on a server open to all, everyone."""
214
+ networks = request.app.state.settings.internal_networks
215
+ if request.app.state.codes is None and not networks:
210
216
  return True
211
217
  ip, _ = client_address(request)
212
- return is_internal(ip, request.app.state.settings.internal_networks)
218
+ return is_internal(ip, networks)
@@ -8,6 +8,7 @@ from typing import Any
8
8
  from fastapi import APIRouter, HTTPException, Query, Request
9
9
  from starlette.concurrency import run_in_threadpool
10
10
 
11
+ from .. import __version__
11
12
  from .meta import client_address
12
13
 
13
14
  router = APIRouter(prefix="/api/v1/results", tags=["results"])
@@ -34,6 +35,15 @@ async def save(request: Request) -> dict[str, Any]:
34
35
  raise HTTPException(400, "body must be JSON") from exc
35
36
  if not isinstance(result, dict) or result.get("version") != 1:
36
37
  raise HTTPException(422, "expected a texas2step result object (version 1)")
38
+ # A visitor outside internal_networks isn't told the version, so its page leaves it out.
39
+ # This server served that page, and fills in its own; and its own as the tested
40
+ # server's, when the test was of this server.
41
+ if result.get("tool") == "texas2step-web/dev":
42
+ result["tool"] = f"texas2step-web/{__version__}"
43
+ server = result.get("server")
44
+ own = request.app.state.settings.server_id
45
+ if isinstance(server, dict) and not server.get("version") and server.get("id") == own:
46
+ server["version"] = __version__
37
47
  ip, _ = client_address(request)
38
48
  return await run_in_threadpool(store.add, result, ip)
39
49
 
@@ -20,6 +20,7 @@ from starlette.requests import ClientDisconnect
20
20
  from .. import payload
21
21
  from ..rtc import RtcBusy, RtcUnavailable
22
22
  from ..traffic import Closed, TrafficMeter
23
+ from .access import WS_PROTOCOL
23
24
  from .meta import client_address
24
25
 
25
26
  router = APIRouter(prefix="/api/v1", tags=["wire"])
@@ -155,10 +156,22 @@ async def up(request: Request, test: TestId = None) -> Response:
155
156
  @router.websocket("/ws")
156
157
  async def ws_echo(ws: WebSocket) -> None:
157
158
  """Echoes every message unchanged. Used for latency under load and live monitoring."""
158
- await ws.accept()
159
+ # The page's own sockets carry the pass in a subprotocol (api/access.py): answer it.
160
+ offered = WS_PROTOCOL in ws.scope.get("subprotocols", [])
161
+ await ws.accept(subprotocol=WS_PROTOCOL if offered else None)
162
+ # With access codes, a block set by hand on the client's network ends its socket too,
163
+ # as it ends its pass (app.AccessGate); looked at as messages come, every few seconds.
164
+ ip, _ = client_address(ws)
165
+ blocks = ws.app.state.blocks if ws.app.state.codes is not None else None
166
+ checked = time.monotonic()
159
167
  try:
160
168
  while True:
161
169
  message = await ws.receive()
170
+ if blocks is not None and time.monotonic() - checked >= blocks.EVERY:
171
+ checked = time.monotonic()
172
+ if await blocks.cover(ip):
173
+ await ws.close(code=1008, reason="access code required")
174
+ return
162
175
  if message["type"] == "websocket.disconnect":
163
176
  return
164
177
  if message.get("bytes") is not None:
@@ -191,8 +204,11 @@ async def rtc_offer(offer: Offer, request: Request) -> dict[str, str]:
191
204
  try:
192
205
  # Whose traffic a voice or video profile is, should a traffic limit count it.
193
206
  meter = request.app.state.traffic
194
- whose = meter.kind(client_address(request)[0]) if meter is not None else None
195
- return await request.app.state.rtc.answer(offer.sdp, whose=whose)
207
+ ip, _ = client_address(request)
208
+ whose = meter.kind(ip) if meter is not None else None
209
+ # With access codes, the session ends should a block set by hand cover this client.
210
+ watched = ip if request.app.state.codes is not None else None
211
+ return await request.app.state.rtc.answer(offer.sdp, whose=whose, ip=watched)
196
212
  except RtcUnavailable as exc:
197
213
  raise HTTPException(501, str(exc)) from exc
198
214
  except RtcBusy as exc: