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.
- {texas2step-2.2.2 → texas2step-2.2.4}/PKG-INFO +1 -1
- {texas2step-2.2.2 → texas2step-2.2.4}/docs/access.md +44 -18
- {texas2step-2.2.2 → texas2step-2.2.4}/docs/api.md +14 -10
- {texas2step-2.2.2 → texas2step-2.2.4}/docs/configuration.md +3 -2
- {texas2step-2.2.2 → texas2step-2.2.4}/docs/roadmap.md +15 -0
- {texas2step-2.2.2 → texas2step-2.2.4}/docs/security.md +26 -3
- {texas2step-2.2.2 → texas2step-2.2.4}/src/texas2step/__init__.py +1 -1
- {texas2step-2.2.2 → texas2step-2.2.4}/src/texas2step/api/access.py +49 -13
- {texas2step-2.2.2 → texas2step-2.2.4}/src/texas2step/api/measure.py +2 -1
- {texas2step-2.2.2 → texas2step-2.2.4}/src/texas2step/api/meta.py +10 -4
- {texas2step-2.2.2 → texas2step-2.2.4}/src/texas2step/api/results.py +11 -1
- {texas2step-2.2.2 → texas2step-2.2.4}/src/texas2step/api/wire.py +19 -3
- {texas2step-2.2.2 → texas2step-2.2.4}/src/texas2step/app.py +127 -33
- {texas2step-2.2.2 → texas2step-2.2.4}/src/texas2step/cli.py +18 -4
- {texas2step-2.2.2 → texas2step-2.2.4}/src/texas2step/cluster.py +15 -3
- {texas2step-2.2.2 → texas2step-2.2.4}/src/texas2step/config.py +19 -1
- {texas2step-2.2.2 → texas2step-2.2.4}/src/texas2step/lockouts.py +97 -9
- {texas2step-2.2.2 → texas2step-2.2.4}/src/texas2step/rtc.py +7 -2
- texas2step-2.2.4/src/texas2step/static/assets/app.js +8 -0
- {texas2step-2.2.2 → texas2step-2.2.4}/src/texas2step/static/index.html +1 -1
- texas2step-2.2.2/src/texas2step/static/assets/app.js +0 -8
- {texas2step-2.2.2 → texas2step-2.2.4}/.gitignore +0 -0
- {texas2step-2.2.2 → texas2step-2.2.4}/LICENSE.md +0 -0
- {texas2step-2.2.2 → texas2step-2.2.4}/README.md +0 -0
- {texas2step-2.2.2 → texas2step-2.2.4}/deploy/nginx/texas2step-http.conf +0 -0
- {texas2step-2.2.2 → texas2step-2.2.4}/deploy/nginx/texas2step-log.conf +0 -0
- {texas2step-2.2.2 → texas2step-2.2.4}/deploy/nginx/texas2step-speedtest.conf +0 -0
- {texas2step-2.2.2 → texas2step-2.2.4}/deploy/nginx/texas2step.conf +0 -0
- {texas2step-2.2.2 → texas2step-2.2.4}/deploy/texas2step-update.service +0 -0
- {texas2step-2.2.2 → texas2step-2.2.4}/deploy/texas2step-update.timer +0 -0
- {texas2step-2.2.2 → texas2step-2.2.4}/deploy/texas2step.default +0 -0
- {texas2step-2.2.2 → texas2step-2.2.4}/deploy/texas2step.service +0 -0
- {texas2step-2.2.2 → texas2step-2.2.4}/docs/development.md +0 -0
- {texas2step-2.2.2 → texas2step-2.2.4}/docs/experience.md +0 -0
- {texas2step-2.2.2 → texas2step-2.2.4}/docs/images/hero-dark.jpg +0 -0
- {texas2step-2.2.2 → texas2step-2.2.4}/docs/images/results-dark.jpg +0 -0
- {texas2step-2.2.2 → texas2step-2.2.4}/docs/images/results-light.jpg +0 -0
- {texas2step-2.2.2 → texas2step-2.2.4}/docs/images/upload-light.jpg +0 -0
- {texas2step-2.2.2 → texas2step-2.2.4}/docs/install.md +0 -0
- {texas2step-2.2.2 → texas2step-2.2.4}/docs/methodology.md +0 -0
- {texas2step-2.2.2 → texas2step-2.2.4}/docs/traffic.md +0 -0
- {texas2step-2.2.2 → texas2step-2.2.4}/legacy/README.md +0 -0
- {texas2step-2.2.2 → texas2step-2.2.4}/pyproject.toml +0 -0
- {texas2step-2.2.2 → texas2step-2.2.4}/src/texas2step/__main__.py +0 -0
- {texas2step-2.2.2 → texas2step-2.2.4}/src/texas2step/access.py +0 -0
- {texas2step-2.2.2 → texas2step-2.2.4}/src/texas2step/api/__init__.py +0 -0
- {texas2step-2.2.2 → texas2step-2.2.4}/src/texas2step/config_edit.py +0 -0
- {texas2step-2.2.2 → texas2step-2.2.4}/src/texas2step/config_merge.py +0 -0
- {texas2step-2.2.2 → texas2step-2.2.4}/src/texas2step/config_template.toml +0 -0
- {texas2step-2.2.2 → texas2step-2.2.4}/src/texas2step/engine.py +0 -0
- {texas2step-2.2.2 → texas2step-2.2.4}/src/texas2step/iface.py +0 -0
- {texas2step-2.2.2 → texas2step-2.2.4}/src/texas2step/logs.py +0 -0
- {texas2step-2.2.2 → texas2step-2.2.4}/src/texas2step/page.py +0 -0
- {texas2step-2.2.2 → texas2step-2.2.4}/src/texas2step/payload.py +0 -0
- {texas2step-2.2.2 → texas2step-2.2.4}/src/texas2step/quality.py +0 -0
- {texas2step-2.2.2 → texas2step-2.2.4}/src/texas2step/static/assets/app.css +0 -0
- {texas2step-2.2.2 → texas2step-2.2.4}/src/texas2step/static/assets/swagger/LICENSE +0 -0
- {texas2step-2.2.2 → texas2step-2.2.4}/src/texas2step/static/assets/swagger/NOTICE +0 -0
- {texas2step-2.2.2 → texas2step-2.2.4}/src/texas2step/static/assets/swagger/swagger-ui-bundle.js +0 -0
- {texas2step-2.2.2 → texas2step-2.2.4}/src/texas2step/static/assets/swagger/swagger-ui-bundle.js.LICENSE.txt +0 -0
- {texas2step-2.2.2 → texas2step-2.2.4}/src/texas2step/static/assets/swagger/swagger-ui.css +0 -0
- {texas2step-2.2.2 → texas2step-2.2.4}/src/texas2step/static/favicon.svg +0 -0
- {texas2step-2.2.2 → texas2step-2.2.4}/src/texas2step/stats.py +0 -0
- {texas2step-2.2.2 → texas2step-2.2.4}/src/texas2step/store.py +0 -0
- {texas2step-2.2.2 → texas2step-2.2.4}/src/texas2step/traffic.py +0 -0
- {texas2step-2.2.2 → texas2step-2.2.4}/src/texas2step/update.py +0 -0
- {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.
|
|
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
|
|
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
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
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
|
|
335
|
-
`counting`, the networks with wrong codes
|
|
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
|
|
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
|
|
179
|
-
|
|
180
|
-
|
|
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
|
|
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"}
|
|
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"}
|
|
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"}
|
|
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`.
|
|
11
|
-
|
|
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
|
|
42
|
-
|
|
43
|
-
|
|
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
|
|
|
@@ -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
|
-
|
|
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
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
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
|
-
|
|
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
|
|
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:
|
|
208
|
-
|
|
209
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
195
|
-
|
|
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:
|