texas2step 2.2.2__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.2 → texas2step-2.2.4}/PKG-INFO +1 -1
  2. {texas2step-2.2.2 → texas2step-2.2.4}/docs/access.md +44 -18
  3. {texas2step-2.2.2 → texas2step-2.2.4}/docs/api.md +14 -10
  4. {texas2step-2.2.2 → texas2step-2.2.4}/docs/configuration.md +3 -2
  5. {texas2step-2.2.2 → texas2step-2.2.4}/docs/roadmap.md +15 -0
  6. {texas2step-2.2.2 → texas2step-2.2.4}/docs/security.md +26 -3
  7. {texas2step-2.2.2 → texas2step-2.2.4}/src/texas2step/__init__.py +1 -1
  8. {texas2step-2.2.2 → texas2step-2.2.4}/src/texas2step/api/access.py +49 -13
  9. {texas2step-2.2.2 → texas2step-2.2.4}/src/texas2step/api/measure.py +2 -1
  10. {texas2step-2.2.2 → texas2step-2.2.4}/src/texas2step/api/meta.py +10 -4
  11. {texas2step-2.2.2 → texas2step-2.2.4}/src/texas2step/api/results.py +11 -1
  12. {texas2step-2.2.2 → texas2step-2.2.4}/src/texas2step/api/wire.py +19 -3
  13. {texas2step-2.2.2 → texas2step-2.2.4}/src/texas2step/app.py +127 -33
  14. {texas2step-2.2.2 → texas2step-2.2.4}/src/texas2step/cli.py +18 -4
  15. {texas2step-2.2.2 → texas2step-2.2.4}/src/texas2step/cluster.py +15 -3
  16. {texas2step-2.2.2 → texas2step-2.2.4}/src/texas2step/config.py +19 -1
  17. {texas2step-2.2.2 → texas2step-2.2.4}/src/texas2step/lockouts.py +97 -9
  18. {texas2step-2.2.2 → texas2step-2.2.4}/src/texas2step/rtc.py +7 -2
  19. texas2step-2.2.4/src/texas2step/static/assets/app.js +8 -0
  20. {texas2step-2.2.2 → texas2step-2.2.4}/src/texas2step/static/index.html +1 -1
  21. texas2step-2.2.2/src/texas2step/static/assets/app.js +0 -8
  22. {texas2step-2.2.2 → texas2step-2.2.4}/.gitignore +0 -0
  23. {texas2step-2.2.2 → texas2step-2.2.4}/LICENSE.md +0 -0
  24. {texas2step-2.2.2 → texas2step-2.2.4}/README.md +0 -0
  25. {texas2step-2.2.2 → texas2step-2.2.4}/deploy/nginx/texas2step-http.conf +0 -0
  26. {texas2step-2.2.2 → texas2step-2.2.4}/deploy/nginx/texas2step-log.conf +0 -0
  27. {texas2step-2.2.2 → texas2step-2.2.4}/deploy/nginx/texas2step-speedtest.conf +0 -0
  28. {texas2step-2.2.2 → texas2step-2.2.4}/deploy/nginx/texas2step.conf +0 -0
  29. {texas2step-2.2.2 → texas2step-2.2.4}/deploy/texas2step-update.service +0 -0
  30. {texas2step-2.2.2 → texas2step-2.2.4}/deploy/texas2step-update.timer +0 -0
  31. {texas2step-2.2.2 → texas2step-2.2.4}/deploy/texas2step.default +0 -0
  32. {texas2step-2.2.2 → texas2step-2.2.4}/deploy/texas2step.service +0 -0
  33. {texas2step-2.2.2 → texas2step-2.2.4}/docs/development.md +0 -0
  34. {texas2step-2.2.2 → texas2step-2.2.4}/docs/experience.md +0 -0
  35. {texas2step-2.2.2 → texas2step-2.2.4}/docs/images/hero-dark.jpg +0 -0
  36. {texas2step-2.2.2 → texas2step-2.2.4}/docs/images/results-dark.jpg +0 -0
  37. {texas2step-2.2.2 → texas2step-2.2.4}/docs/images/results-light.jpg +0 -0
  38. {texas2step-2.2.2 → texas2step-2.2.4}/docs/images/upload-light.jpg +0 -0
  39. {texas2step-2.2.2 → texas2step-2.2.4}/docs/install.md +0 -0
  40. {texas2step-2.2.2 → texas2step-2.2.4}/docs/methodology.md +0 -0
  41. {texas2step-2.2.2 → texas2step-2.2.4}/docs/traffic.md +0 -0
  42. {texas2step-2.2.2 → texas2step-2.2.4}/legacy/README.md +0 -0
  43. {texas2step-2.2.2 → texas2step-2.2.4}/pyproject.toml +0 -0
  44. {texas2step-2.2.2 → texas2step-2.2.4}/src/texas2step/__main__.py +0 -0
  45. {texas2step-2.2.2 → texas2step-2.2.4}/src/texas2step/access.py +0 -0
  46. {texas2step-2.2.2 → texas2step-2.2.4}/src/texas2step/api/__init__.py +0 -0
  47. {texas2step-2.2.2 → texas2step-2.2.4}/src/texas2step/config_edit.py +0 -0
  48. {texas2step-2.2.2 → texas2step-2.2.4}/src/texas2step/config_merge.py +0 -0
  49. {texas2step-2.2.2 → texas2step-2.2.4}/src/texas2step/config_template.toml +0 -0
  50. {texas2step-2.2.2 → texas2step-2.2.4}/src/texas2step/engine.py +0 -0
  51. {texas2step-2.2.2 → texas2step-2.2.4}/src/texas2step/iface.py +0 -0
  52. {texas2step-2.2.2 → texas2step-2.2.4}/src/texas2step/logs.py +0 -0
  53. {texas2step-2.2.2 → texas2step-2.2.4}/src/texas2step/page.py +0 -0
  54. {texas2step-2.2.2 → texas2step-2.2.4}/src/texas2step/payload.py +0 -0
  55. {texas2step-2.2.2 → texas2step-2.2.4}/src/texas2step/quality.py +0 -0
  56. {texas2step-2.2.2 → texas2step-2.2.4}/src/texas2step/static/assets/app.css +0 -0
  57. {texas2step-2.2.2 → texas2step-2.2.4}/src/texas2step/static/assets/swagger/LICENSE +0 -0
  58. {texas2step-2.2.2 → texas2step-2.2.4}/src/texas2step/static/assets/swagger/NOTICE +0 -0
  59. {texas2step-2.2.2 → texas2step-2.2.4}/src/texas2step/static/assets/swagger/swagger-ui-bundle.js +0 -0
  60. {texas2step-2.2.2 → texas2step-2.2.4}/src/texas2step/static/assets/swagger/swagger-ui-bundle.js.LICENSE.txt +0 -0
  61. {texas2step-2.2.2 → texas2step-2.2.4}/src/texas2step/static/assets/swagger/swagger-ui.css +0 -0
  62. {texas2step-2.2.2 → texas2step-2.2.4}/src/texas2step/static/favicon.svg +0 -0
  63. {texas2step-2.2.2 → texas2step-2.2.4}/src/texas2step/stats.py +0 -0
  64. {texas2step-2.2.2 → texas2step-2.2.4}/src/texas2step/store.py +0 -0
  65. {texas2step-2.2.2 → texas2step-2.2.4}/src/texas2step/traffic.py +0 -0
  66. {texas2step-2.2.2 → texas2step-2.2.4}/src/texas2step/update.py +0 -0
  67. {texas2step-2.2.2 → 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.2
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/
@@ -49,10 +49,14 @@ networks, as addresses or CIDR blocks. Include the corporate addresses the desk'
49
49
  browsers reach the servers from.
50
50
 
51
51
  ```bash
52
- sudo texas2step config set access_code=daily internal_networks=10.0.0.0/8,198.51.100.0/24 --write
52
+ sudo texas2step config set access_code=daily internal_networks=10.0.0.0/8,2001:db8::/32,198.51.100.0/24 --write
53
53
  sudo systemctl restart texas2step
54
54
  ```
55
55
 
56
+ On the command line, separate the networks with commas. In `config.toml` they are a list
57
+ of quoted strings: `internal_networks = ["10.0.0.0/8", "2001:db8::/32", "198.51.100.0/24"]`.
58
+ One string of networks separated by spaces is refused.
59
+
56
60
  **3. Check** that the servers agree. On each:
57
61
 
58
62
  ```bash
@@ -87,7 +91,7 @@ To turn codes off again: `sudo texas2step config set access_code=off --write`, t
87
91
  | `access_pass_hours` | `12` | How long an entered code lets a browser keep testing |
88
92
  | `access_key_file` | `/etc/texas2step/access.key` | The key |
89
93
  | `access_phrases_file` | `/etc/texas2step/access-phrases.toml` | [Standing phrases](#standing-phrases), if the file exists |
90
- | `internal_networks` | `[]` | Your own networks: no code, and the desk's tools |
94
+ | `internal_networks` | `[]` | Your own networks: no code, and the desk's tools. A list of IPv4 and IPv6 addresses and networks in CIDR form, mixed freely: `["192.168.0.0/16", "2001:db8::/32", "203.0.113.7"]`. An address alone is a single host. Bits past the prefix are ignored, so `10.2.1.30/24` means all of `10.2.1.0/24`. Give the addresses as the server sees them: staff who come in through your firewall's NAT arrive from its public address. |
91
95
 
92
96
  **Built-in words.** A code is two words and the digits: `maple.river.42`. The words come
93
97
  from the EFF's short word list of easily spelled words (credited in the package's
@@ -230,18 +234,29 @@ texas2step run https://speedtest.example.net/texas2step --code maple.river.42
230
234
 
231
235
  | | Needs |
232
236
  |---|---|
233
- | 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 |
234
- | 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` |
235
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 |
236
240
 
237
- The pass goes with every request the page makes, as `?pass=`. A page load can't carry
238
- that, so a right code also sets the pass as a cookie for the server's own pages:
239
- `HttpOnly`, `Secure` over HTTPS, `SameSite=Strict`, gone when the pass expires.
240
-
241
- Until a visitor enters a code, nothing tells them the version: the page leaves it out and
242
- `/api/v1/health` answers only `{"status":"ok"}`. Inside `internal_networks`, and once in,
243
- it is there as before. This slows an attacker down rather than stopping one: the page's
244
- 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)).
245
260
 
246
261
  Loopback addresses are always inside, so commands on the server itself need no code. The
247
262
  service sees each visitor's address through nginx (`trusted_proxies`), which the packaged
@@ -272,7 +287,8 @@ codes in a row from a network, it is locked out:
272
287
  * A network quiet for 30 minutes starts counting again from nothing, and so does one that
273
288
  enters a right code.
274
289
  * Visitors from `internal_networks` and loopback need no code, so they are never counted
275
- 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).
276
292
 
277
293
  | Setting | Default | |
278
294
  |---|---|---|
@@ -320,6 +336,14 @@ longer if it keeps trying; blocking it again keeps it going. A block can't overl
320
336
  take effect at once, with no restart, and with
321
337
  [servers that defend together](#servers-that-defend-together), on every server.
322
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
+
323
347
  **From automation.** The same, as an API that answers only `internal_networks` and the
324
348
  server itself, so a script can carry a block from the server that saw an attack to all
325
349
  the others, and renew it there:
@@ -331,8 +355,9 @@ curl -s -X POST https://houston.example.net/texas2step/api/v1/access/blocks \
331
355
  curl -s -X DELETE 'https://houston.example.net/texas2step/api/v1/access/blocks?network=198.51.100.0/24'
332
356
  ```
333
357
 
334
- A list gives each lockout's `network`, `source`, `wrong_codes`, `since` and `until`, and
335
- `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
336
361
  `internal_networks`, or is wider than a /16 or a /32, is refused with `400`. These calls
337
362
  change only the server they are sent to; for one that reaches every server by itself, see
338
363
  [Servers that defend together](#servers-that-defend-together).
@@ -368,7 +393,9 @@ access_peer_networks = ["198.51.100.20", "2001:db8:20::20", "203.0.113.30"]
368
393
  third of `access_lockout_minutes`, so the others' blocks don't run out first.
369
394
  `sudo texas2step access block` and `access lockouts --clear` go to every server too:
370
395
  `--clear` on any one of them lets a caller back in everywhere. The
371
- [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.
372
399
  * **What a server takes.** Only requests from an address in its `access_peer_networks`,
373
400
  signed with a key the servers work out from the access key they share. Use the
374
401
  addresses the other servers' requests leave from, which behind NAT may not be the
@@ -484,8 +511,7 @@ many as you need (`results_max_rows`). `sudo texas2step results clear` deletes t
484
511
  | Your `access_words` file | Yours: kept by `apt purge` |
485
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` |
486
513
 
487
- The pass lives in the visitor's browser (local storage, and the cookie for this server's
488
- 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
489
515
  `pass=*` in their place. nginx's access log doesn't have them either: the packaged
490
516
  snippets log texas2step's requests to `/var/log/nginx/access.log` without their query
491
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 \
@@ -7,8 +7,9 @@ Settings come from, in increasing priority:
7
7
  3. environment variables `TEXAS2STEP_<SETTING>` (upper case).
8
8
 
9
9
  Lists in environment variables are comma separated. Booleans accept `1/0`,
10
- `true/false`, `yes/no` and `on/off`. Unknown keys in the TOML file are an error, so
11
- typos don't fail silently. The package installs a fully commented file listing every
10
+ `true/false`, `yes/no` and `on/off`. Spaces around any value, or around each item of a
11
+ list, are ignored, wherever the setting comes from. Unknown keys in the TOML file are an
12
+ error, so typos don't fail silently. The package installs a fully commented file listing every
12
13
  setting and its default as `/etc/texas2step/config.toml` (source:
13
14
  [`src/texas2step/config_template.toml`](../src/texas2step/config_template.toml)); restart
14
15
  the service after editing it. After an upgrade, `texas2step config merge` brings your
@@ -116,6 +116,21 @@ 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
+
129
+ ### [2.2.3](https://github.com/robert-patrick-texas/texas2step/releases/tag/v2.2.3) (2026-10-24)
130
+
131
+ - [x] Spaces around any setting or typed value are ignored; a stray one in
132
+ `internal_networks` no longer breaks every request.
133
+
119
134
  ### [2.2.2](https://github.com/robert-patrick-texas/texas2step/releases/tag/v2.2.2) (2026-10-24)
120
135
 
121
136
  - [x] UDP works in Firefox: Monitor, Diag and the Test page's packet loss no longer fall
@@ -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.2"
3
+ __version__ = "2.2.4"
@@ -9,7 +9,8 @@ from datetime import UTC, datetime
9
9
  from typing import Any, Literal
10
10
 
11
11
  from fastapi import APIRouter, HTTPException, Query, Request, Response
12
- from pydantic import BaseModel, Field, ValidationError
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,14 +21,37 @@ 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):
49
+ model_config = ConfigDict(str_strip_whitespace=True) # as typed or pasted
27
50
  code: str = Field(max_length=200, description="The code, with any case and separators")
28
51
 
29
52
 
30
53
  class BlockIn(BaseModel):
54
+ model_config = ConfigDict(str_strip_whitespace=True)
31
55
  network: str = Field(
32
56
  max_length=64,
33
57
  description="An address or network outside internal_networks, e.g. 203.0.113.0/24",
@@ -49,6 +73,9 @@ class PeerIn(BaseModel):
49
73
  action: Literal["block", "clear", "check"]
50
74
  network: str = Field("", max_length=64)
51
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"
52
79
 
53
80
 
54
81
  def internal(request: Request) -> bool:
@@ -73,8 +100,13 @@ async def status(
73
100
  "required": codes is not None and not internal(request),
74
101
  "desk": desk(request),
75
102
  }
103
+ pass_ = pass_ or given_pass(request) # the page sends its own server's in a header
76
104
  if codes is not None and pass_:
77
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
78
110
  body["pass"] = {"valid": expires is not None, "expires": expires}
79
111
  return body
80
112
 
@@ -126,15 +158,14 @@ async def unlock(request: Request, given: CodeIn, response: Response) -> dict[st
126
158
  if note != "access code": # a standing phrase: every use is logged, so a leaked one shows
127
159
  log.warning("%s used from %s", note, ip)
128
160
  token, expires = codes.issue()
129
- response.set_cookie(
130
- PASS_COOKIE,
131
- token,
132
- max_age=codes.pass_seconds,
133
- path=request.scope.get("root_path", "") + "/",
134
- secure=_https(request),
135
- httponly=True,
136
- samesite="strict",
137
- )
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
+ )
138
169
  return {"pass": token, "expires": expires}
139
170
 
140
171
 
@@ -209,6 +240,7 @@ def _lockout(codes: Codes, lockout: Lockout) -> dict[str, Any]:
209
240
  "network": lockout.network,
210
241
  "source": lockout.source,
211
242
  "wrong_codes": lockout.failures,
243
+ "by_hand": lockout.by_hand, # a block: it ends the passes used from the network too
212
244
  "since": datetime.fromtimestamp(since, codes.zone).isoformat() if since else None,
213
245
  "until": datetime.fromtimestamp(lockout.expires, codes.zone).isoformat(),
214
246
  }
@@ -244,6 +276,7 @@ async def block(request: Request, given: BlockIn) -> dict[str, Any]:
244
276
  lockouts = request.app.state.lockouts
245
277
  why = f"blocked by the API from {ip}"
246
278
  lockout = await asyncio.to_thread(lockouts.block, str(net), minutes, why)
279
+ request.app.state.blocks.forget()
247
280
  codes = request.app.state.codes
248
281
  log.warning("%s blocked until %s, by the API from %s", net, _at(codes, lockout.expires), ip)
249
282
  until = datetime.fromtimestamp(lockout.expires, codes.zone)
@@ -261,6 +294,7 @@ async def unblock(
261
294
  ),
262
295
  ) -> dict[str, Any]:
263
296
  ip = _desk_client(request)
297
+ network = network.strip()
264
298
  try:
265
299
  target = None if network == "all" else str(parse_network(network))
266
300
  except ValueError as exc:
@@ -316,7 +350,8 @@ async def peer(request: Request) -> dict[str, Any]:
316
350
  raise HTTPException(403, why)
317
351
  if given.action == "check":
318
352
  return {"server": settings.server_id, "ok": True}
319
- 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 "")
320
355
  if given.action == "clear":
321
356
  try: # ending lockouts can't harm: any network, or all
322
357
  target = None if given.network == "all" else str(parse_network(given.network))
@@ -335,7 +370,8 @@ async def peer(request: Request) -> dict[str, Any]:
335
370
  )
336
371
  raise HTTPException(429, f"this server holds {cluster.MOST_HELD} blocks from others")
337
372
  minutes = given.minutes or settings.access_lockout_minutes
338
- 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()
339
375
  log.warning("%s blocked until %s, %s", net, _at(codes, lockout.expires), source)
340
376
  until = datetime.fromtimestamp(lockout.expires, codes.zone)
341
377
  cluster.hook_in_background(request.app, "block", net, until, source)
@@ -15,7 +15,7 @@ from typing import Any
15
15
 
16
16
  import httpx
17
17
  from fastapi import APIRouter, HTTPException, Request
18
- from pydantic import BaseModel, Field
18
+ from pydantic import BaseModel, ConfigDict, Field
19
19
 
20
20
  from .. import engine
21
21
  from ..store import TRANSFERS
@@ -27,6 +27,7 @@ _slots = asyncio.Semaphore(2)
27
27
 
28
28
 
29
29
  class Target(BaseModel):
30
+ model_config = ConfigDict(str_strip_whitespace=True)
30
31
  target: str = Field(description="Base URL of a texas2step server, e.g. https://peer.example")
31
32
 
32
33
 
@@ -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
 
@@ -50,7 +60,7 @@ async def recent(
50
60
 
51
61
  @router.get("/{rid}", summary="A saved result, in full")
52
62
  async def one(rid: str, request: Request) -> dict[str, Any]:
53
- result = await run_in_threadpool(_store(request).get, rid)
63
+ result = await run_in_threadpool(_store(request).get, rid.strip())
54
64
  if result is None:
55
65
  raise HTTPException(404, "no such result")
56
66
  return result
@@ -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: