texas2step 2.0.0__py3-none-any.whl

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 (42) hide show
  1. texas2step/__init__.py +3 -0
  2. texas2step/__main__.py +3 -0
  3. texas2step/access.py +478 -0
  4. texas2step/api/__init__.py +3 -0
  5. texas2step/api/access.py +356 -0
  6. texas2step/api/measure.py +173 -0
  7. texas2step/api/meta.py +207 -0
  8. texas2step/api/results.py +56 -0
  9. texas2step/api/wire.py +198 -0
  10. texas2step/app.py +458 -0
  11. texas2step/cli.py +1298 -0
  12. texas2step/cluster.py +285 -0
  13. texas2step/config.py +348 -0
  14. texas2step/config_edit.py +165 -0
  15. texas2step/config_merge.py +161 -0
  16. texas2step/config_template.toml +108 -0
  17. texas2step/engine.py +498 -0
  18. texas2step/iface.py +79 -0
  19. texas2step/lockouts.py +316 -0
  20. texas2step/logs.py +73 -0
  21. texas2step/page.py +145 -0
  22. texas2step/payload.py +38 -0
  23. texas2step/rtc.py +300 -0
  24. texas2step/static/assets/app.css +1 -0
  25. texas2step/static/assets/app.js +8 -0
  26. texas2step/static/assets/swagger/LICENSE +202 -0
  27. texas2step/static/assets/swagger/NOTICE +2 -0
  28. texas2step/static/assets/swagger/swagger-ui-bundle.js +2 -0
  29. texas2step/static/assets/swagger/swagger-ui-bundle.js.LICENSE.txt +131 -0
  30. texas2step/static/assets/swagger/swagger-ui.css +2 -0
  31. texas2step/static/favicon.svg +11 -0
  32. texas2step/static/index.html +78 -0
  33. texas2step/stats.py +98 -0
  34. texas2step/store.py +230 -0
  35. texas2step/traffic.py +424 -0
  36. texas2step/update.py +458 -0
  37. texas2step/words.txt +1294 -0
  38. texas2step-2.0.0.dist-info/METADATA +184 -0
  39. texas2step-2.0.0.dist-info/RECORD +42 -0
  40. texas2step-2.0.0.dist-info/WHEEL +4 -0
  41. texas2step-2.0.0.dist-info/entry_points.txt +2 -0
  42. texas2step-2.0.0.dist-info/licenses/LICENSE.md +204 -0
texas2step/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """texas2step: web-centric throughput, latency, jitter and packet-loss measurement."""
2
+
3
+ __version__ = "2.0.0"
texas2step/__main__.py ADDED
@@ -0,0 +1,3 @@
1
+ from .cli import main
2
+
3
+ main()
texas2step/access.py ADDED
@@ -0,0 +1,478 @@
1
+ """Access codes: the word code a service desk reads out, and the pass it buys.
2
+
3
+ An Internet-facing server can ask for a code before it runs tests (``access_code``).
4
+ Every server of an organization holds the same random key (``access_key_file``), and
5
+ works out the same code from it for the current period: daily, weekly, monthly, or one
6
+ that stays fixed. Nothing is shared between servers at run time:
7
+
8
+ * With the built-in words, the key draws the organization's 512 words from
9
+ ``words.txt``, and a code is two of them and some digits: ``maple.river.42``.
10
+ * With the site's own list (``access_words``), a code is one entry and the digits:
11
+ ``austin.42``, ``fort.bragg.68``.
12
+
13
+ A code someone types is read with any case and any separators, so ``Maple River 42``
14
+ counts. The previous period's code still works for a day after the change.
15
+
16
+ A site can also keep standing phrases of its own (``access_phrases_file``), such as one
17
+ the desk gives a VIP from memory. Each works as a code until it is removed, or until the
18
+ end of its ``until`` day, and each use is logged by the phrase's name.
19
+
20
+ A right code buys a pass: an expiry time signed with the key. The browser sends it as
21
+ ``?pass=`` with every request, to this server and to the others in the picker, which
22
+ check the signature with their copy of the key. A query parameter, not a header: a
23
+ header would make browsers preflight every ping and download, and skew the timings.
24
+ """
25
+
26
+ from __future__ import annotations
27
+
28
+ import base64
29
+ import contextlib
30
+ import hashlib
31
+ import hmac
32
+ import ipaddress
33
+ import json
34
+ import os
35
+ import re
36
+ import secrets
37
+ import time
38
+ import tomllib
39
+ from collections.abc import Sequence
40
+ from dataclasses import dataclass
41
+ from datetime import date, datetime, timedelta
42
+ from pathlib import Path
43
+ from typing import TYPE_CHECKING
44
+ from zoneinfo import ZoneInfo
45
+
46
+ if TYPE_CHECKING:
47
+ from .config import Settings
48
+
49
+ INTERVALS = ("off", "daily", "weekly", "monthly", "fixed")
50
+ WORDS = Path(__file__).with_name("words.txt")
51
+ KEY_BYTES = 32
52
+ DRAW = 512 # built-in words each organization draws by its key
53
+ GRACE = 24 * 3600 # the previous code still works this long after a change
54
+ MIN_SITE_WORDS = 100
55
+ MAX_ENTRY = 40
56
+ GROUP = "texas2step" # the service's group, which must read the key
57
+
58
+
59
+ class AccessError(ValueError):
60
+ """The access settings can't be used, with a message for the operator."""
61
+
62
+
63
+ def normalize(text: str) -> str:
64
+ """Lowercase letters and digits, with any run of other characters as one dot."""
65
+ return ".".join(re.findall(r"[a-z0-9]+", text.lower()))
66
+
67
+
68
+ # --- The key -----------------------------------------------------------------------
69
+
70
+
71
+ def new_key() -> str:
72
+ return base64.urlsafe_b64encode(secrets.token_bytes(KEY_BYTES)).rstrip(b"=").decode()
73
+
74
+
75
+ def parse_key(text: str) -> bytes:
76
+ text = text.strip()
77
+ try:
78
+ key = base64.urlsafe_b64decode(text + "=" * (-len(text) % 4))
79
+ except ValueError:
80
+ key = b""
81
+ if len(key) != KEY_BYTES or not re.fullmatch(r"[A-Za-z0-9_-]+", text):
82
+ raise AccessError(f"not an access key: expected {KEY_BYTES} bytes in base64url")
83
+ return key
84
+
85
+
86
+ def read_key(path: str | Path) -> bytes:
87
+ try:
88
+ text = Path(path).read_text()
89
+ except FileNotFoundError:
90
+ raise AccessError(
91
+ f"{path} is missing; make one with: sudo texas2step access key --new"
92
+ ) from None
93
+ except OSError as exc:
94
+ raise AccessError(f"can't read {path}: {exc.strerror}") from exc
95
+ try:
96
+ return parse_key(text)
97
+ except AccessError as exc:
98
+ raise AccessError(f"{path}: {exc}") from None
99
+
100
+
101
+ def write_key(path: str | Path, text: str) -> None:
102
+ parse_key(text)
103
+ write_secret(path, text.strip() + "\n")
104
+
105
+
106
+ def write_secret(path: str | Path, text: str) -> None:
107
+ """Replace the file atomically: readable by root and the service's group only."""
108
+ path = Path(path)
109
+ tmp = path.with_name(path.name + ".tmp")
110
+ fd = os.open(tmp, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o640)
111
+ try:
112
+ os.write(fd, text.encode())
113
+ if os.geteuid() == 0:
114
+ import grp
115
+
116
+ # Without the service's group yet, root's, until the package makes it.
117
+ with contextlib.suppress(KeyError):
118
+ os.fchown(fd, 0, grp.getgrnam(GROUP).gr_gid)
119
+ os.fchmod(fd, 0o640)
120
+ finally:
121
+ os.close(fd)
122
+ os.replace(tmp, path)
123
+
124
+
125
+ # --- Words -------------------------------------------------------------------------
126
+
127
+
128
+ def _lines(text: str) -> list[tuple[int, str]]:
129
+ return [
130
+ (n, line.strip())
131
+ for n, line in enumerate(text.splitlines(), 1)
132
+ if line.strip() and not line.lstrip().startswith("#")
133
+ ]
134
+
135
+
136
+ def builtin_words() -> list[str]:
137
+ return [word for _, word in _lines(WORDS.read_text())]
138
+
139
+
140
+ def drawn_words(key: bytes) -> list[str]:
141
+ """The organization's share of the built-in words, chosen by its key."""
142
+
143
+ def rank(word: str) -> bytes:
144
+ return hmac.new(key, b"draw|" + word.encode(), hashlib.sha256).digest()
145
+
146
+ return sorted(sorted(builtin_words(), key=rank)[:DRAW])
147
+
148
+
149
+ def site_words(path: str | Path) -> list[str]:
150
+ """A site's own list: one entry per line, each normalized, and checked."""
151
+ try:
152
+ text = Path(path).read_text(encoding="utf-8")
153
+ except OSError as exc:
154
+ raise AccessError(f"access_words: can't read {path}: {exc.strerror}") from exc
155
+ except UnicodeDecodeError:
156
+ raise AccessError(f"access_words: {path} isn't UTF-8 text") from None
157
+ seen: dict[str, int] = {}
158
+ problems: list[str] = []
159
+ for n, line in _lines(text):
160
+ entry = normalize(line)
161
+ where = f"{path}:{n}"
162
+ if not line.isascii():
163
+ problems.append(f"{where}: {line!r} has letters outside ASCII")
164
+ elif not entry or not re.search(r"[a-z]", entry):
165
+ problems.append(f"{where}: {line!r} has no letters")
166
+ elif len(entry) > MAX_ENTRY:
167
+ problems.append(f"{where}: {entry!r} is over {MAX_ENTRY} characters")
168
+ elif entry in seen:
169
+ problems.append(f"{where}: {entry!r} repeats line {seen[entry]}")
170
+ else:
171
+ seen[entry] = n
172
+ if problems:
173
+ more = f" (and {len(problems) - 5} more)" if len(problems) > 5 else ""
174
+ raise AccessError("access_words: " + "; ".join(problems[:5]) + more)
175
+ if len(seen) < MIN_SITE_WORDS:
176
+ raise AccessError(
177
+ f"access_words: {path} has {len(seen)} entries; a list needs at least "
178
+ f"{MIN_SITE_WORDS}, so codes aren't easy to guess"
179
+ )
180
+ return list(seen)
181
+
182
+
183
+ # --- Standing phrases ----------------------------------------------------------------
184
+
185
+ PHRASE_NAME = re.compile(r"[A-Za-z0-9][A-Za-z0-9_.-]{0,39}")
186
+ PHRASES_HEADER = """\
187
+ # Standing access phrases for texas2step (docs/access.md). Each works as an access code
188
+ # beside the day's code, until it is removed or until the end of its until day (in
189
+ # access_timezone). Readable by root and the service only. texas2step access phrase add
190
+ # and remove write this file again, keeping these lines but no comments of your own.
191
+ # After a change: sudo systemctl restart texas2step
192
+ #
193
+ # Two examples, commented out. Make up your own: these are published with texas2step.
194
+ #
195
+ # [[phrase]]
196
+ # name = "future"
197
+ # phrase = "2026 the future is bright"
198
+ # until = 2026-12-31 # works through December 31, 2026
199
+ #
200
+ # [[phrase]]
201
+ # name = "vip"
202
+ # phrase = "vip ticket to ride 1776" # no until: works until it is removed
203
+ """
204
+ # Anyone can read the examples, so they never work as phrases.
205
+ EXAMPLE_PHRASES = ("2026 the future is bright", "vip ticket to ride 1776")
206
+
207
+
208
+ @dataclass(frozen=True)
209
+ class Phrase:
210
+ name: str # what the journal and texas2step access show call it; never the phrase
211
+ text: str # as written
212
+ until: date | None = None # it works through this day, in access_timezone
213
+
214
+ @property
215
+ def code(self) -> str:
216
+ return normalize(self.text)
217
+
218
+
219
+ def as_day(value: object) -> date | None:
220
+ """A phrase's until: a TOML date, or one written as text."""
221
+ if value is None:
222
+ return None
223
+ if isinstance(value, str):
224
+ with contextlib.suppress(ValueError):
225
+ value = date.fromisoformat(value)
226
+ if isinstance(value, date) and not isinstance(value, datetime):
227
+ return value
228
+ raise ValueError("until is a day, such as 2026-12-31")
229
+
230
+
231
+ def parse_phrases(text: str, where: str) -> list[Phrase]:
232
+ """The ``[[phrase]]`` tables of a phrases file, each checked."""
233
+ try:
234
+ data = tomllib.loads(text)
235
+ except tomllib.TOMLDecodeError as exc:
236
+ raise AccessError(f"{where}: {exc}") from None
237
+ entries = data.pop("phrase", [])
238
+ if data or not isinstance(entries, list):
239
+ raise AccessError(f"{where}: expected only [[phrase]] tables")
240
+ phrases: list[Phrase] = []
241
+ for n, entry in enumerate(entries, 1):
242
+ if not isinstance(entry, dict):
243
+ raise AccessError(f"{where}: expected only [[phrase]] tables")
244
+ name = entry.get("name")
245
+ at = f"{where}: phrase {name!r}" if isinstance(name, str) else f"{where}: phrase {n}"
246
+ if unknown := set(entry) - {"name", "phrase", "until"}:
247
+ raise AccessError(f"{at}: unknown {', '.join(sorted(unknown))}")
248
+ if not isinstance(name, str) or not PHRASE_NAME.fullmatch(name):
249
+ raise AccessError(f"{at}: name is up to 40 letters, digits, '-', '_' or '.'")
250
+ text = entry.get("phrase")
251
+ if not isinstance(text, str) or not normalize(text):
252
+ raise AccessError(f"{at}: phrase needs letters or digits")
253
+ if not text.isascii():
254
+ raise AccessError(f"{at}: phrase has letters outside ASCII")
255
+ if normalize(text) in {normalize(example) for example in EXAMPLE_PHRASES}:
256
+ raise AccessError(f"{at}: that is a published example; make up your own")
257
+ try:
258
+ phrase = Phrase(name, text, as_day(entry.get("until")))
259
+ except ValueError as exc:
260
+ raise AccessError(f"{at}: {exc}") from None
261
+ for other in phrases:
262
+ if other.name.lower() == name.lower():
263
+ raise AccessError(f"{at}: the name is taken by an earlier phrase")
264
+ if other.code == phrase.code:
265
+ raise AccessError(f"{at}: the same phrase as {other.name!r}")
266
+ phrases.append(phrase)
267
+ return phrases
268
+
269
+
270
+ def read_phrases(path: str | Path) -> list[Phrase]:
271
+ """The site's standing phrases; none without the file."""
272
+ try:
273
+ text = Path(path).read_text(encoding="utf-8")
274
+ except FileNotFoundError:
275
+ return []
276
+ except OSError as exc:
277
+ raise AccessError(f"can't read {path}: {exc.strerror}") from exc
278
+ except UnicodeDecodeError:
279
+ raise AccessError(f"{path} isn't UTF-8 text") from None
280
+ return parse_phrases(text, str(path))
281
+
282
+
283
+ def render_phrases(phrases: Sequence[Phrase]) -> str:
284
+ blocks = [PHRASES_HEADER]
285
+ for p in phrases:
286
+ lines = ["[[phrase]]", f"name = {json.dumps(p.name)}", f"phrase = {json.dumps(p.text)}"]
287
+ if p.until:
288
+ lines.append(f"until = {p.until.isoformat()}")
289
+ blocks.append("\n".join(lines) + "\n")
290
+ return "\n".join(blocks)
291
+
292
+
293
+ # --- Codes and passes --------------------------------------------------------------
294
+
295
+
296
+ @dataclass(frozen=True)
297
+ class Period:
298
+ id: str # what the code is made from, e.g. 2026-10-22, 2026-W43, 2026-10, fixed
299
+ start: datetime | None # None for a fixed code
300
+ end: datetime | None
301
+
302
+
303
+ @dataclass(frozen=True)
304
+ class Current:
305
+ code: str
306
+ period: Period
307
+ previous: str | None # still accepted until previous_until
308
+ previous_until: datetime | None
309
+
310
+
311
+ class Codes:
312
+ """The codes and passes of one server, from its settings, key and words."""
313
+
314
+ def __init__(
315
+ self,
316
+ key: bytes,
317
+ interval: str,
318
+ zone: str,
319
+ digits: int,
320
+ words: list[str],
321
+ site: bool,
322
+ pass_hours: float,
323
+ phrases: Sequence[Phrase] = (),
324
+ ) -> None:
325
+ self.key = key
326
+ self.interval = interval
327
+ self.zone = ZoneInfo(zone)
328
+ self.zone_name = zone
329
+ self.digits = digits
330
+ self.words = words
331
+ self.site = site
332
+ self.pass_seconds = int(pass_hours * 3600)
333
+ self.phrases = tuple(phrases)
334
+
335
+ @classmethod
336
+ def load(cls, settings: Settings, interval: str | None = None) -> Codes | None:
337
+ """None when access codes are off; AccessError when they can't work. ``interval``
338
+ stands in for ``access_code``, as ``texas2step access show`` does while it's off."""
339
+ interval = interval or settings.access_code
340
+ if interval == "off":
341
+ return None
342
+ key = read_key(settings.access_key_file)
343
+ site = bool(settings.access_words)
344
+ words = site_words(settings.access_words) if site else drawn_words(key)
345
+ return cls(
346
+ key,
347
+ interval,
348
+ settings.access_timezone,
349
+ settings.access_digits,
350
+ words,
351
+ site,
352
+ settings.access_pass_hours,
353
+ read_phrases(settings.access_phrases_file),
354
+ )
355
+
356
+ def _mac(self, *parts: str) -> bytes:
357
+ return hmac.new(self.key, "|".join(parts).encode(), hashlib.sha256).digest()
358
+
359
+ # The periods, in the organization's time zone, so every server changes together.
360
+
361
+ def _midnight(self, day: date) -> datetime:
362
+ return datetime(day.year, day.month, day.day, tzinfo=self.zone)
363
+
364
+ def period(self, now: datetime) -> Period:
365
+ day = now.astimezone(self.zone).date()
366
+ if self.interval == "daily":
367
+ return Period(day.isoformat(), self._midnight(day), self._midnight(day + timedelta(1)))
368
+ if self.interval == "weekly":
369
+ monday = day - timedelta(day.weekday())
370
+ year, week, _ = monday.isocalendar()
371
+ start, end = self._midnight(monday), self._midnight(monday + timedelta(7))
372
+ return Period(f"{year}-W{week:02d}", start, end)
373
+ if self.interval == "monthly":
374
+ first = day.replace(day=1)
375
+ following = (first + timedelta(32)).replace(day=1)
376
+ return Period(f"{first:%Y-%m}", self._midnight(first), self._midnight(following))
377
+ return Period("fixed", None, None)
378
+
379
+ def code_for(self, period: Period) -> str:
380
+ mac = self._mac("code", self.interval, period.id)
381
+ n = len(self.words)
382
+ first = int.from_bytes(mac[0:8]) % n
383
+ parts = [self.words[first]]
384
+ if not self.site: # two different words
385
+ second = int.from_bytes(mac[8:16]) % (n - 1)
386
+ parts.append(self.words[second + (second >= first)])
387
+ number = int.from_bytes(mac[16:24]) % 10**self.digits
388
+ return ".".join([*parts, f"{number:0{self.digits}d}"])
389
+
390
+ def current(self, now: datetime) -> Current:
391
+ period = self.period(now)
392
+ previous = until = None
393
+ if period.start is not None and now < period.start + timedelta(seconds=GRACE):
394
+ previous = self.code_for(self.period(period.start - timedelta(seconds=1)))
395
+ until = period.start + timedelta(seconds=GRACE)
396
+ return Current(self.code_for(period), period, previous, until)
397
+
398
+ def check(self, text: str, now: datetime) -> bool:
399
+ """Whether ``text`` is the current code, or the previous one in its grace day."""
400
+ given = normalize(text)
401
+ head, _, tail = given.rpartition(".")
402
+ if tail.isdigit() and len(tail) <= self.digits: # "7" for "07"
403
+ given = f"{head}.{int(tail):0{self.digits}d}"
404
+ current = self.current(now)
405
+ return any(
406
+ hmac.compare_digest(given.encode(), code.encode())
407
+ for code in (current.code, current.previous)
408
+ if code
409
+ )
410
+
411
+ def phrase(self, text: str) -> Phrase | None:
412
+ """The standing phrase ``text`` is, with any case and separators; expired or not."""
413
+ given = normalize(text).encode()
414
+ found = None
415
+ for phrase in self.phrases: # every one, so the time taken tells nothing
416
+ if hmac.compare_digest(given, phrase.code.encode()):
417
+ found = phrase
418
+ return found
419
+
420
+ def expired(self, phrase: Phrase, now: datetime) -> bool:
421
+ return phrase.until is not None and now.astimezone(self.zone).date() > phrase.until
422
+
423
+ def fingerprint(self) -> str:
424
+ """The same on every server that would give the same codes; reveals nothing."""
425
+ words = hashlib.sha256("\n".join(self.words).encode()).hexdigest()
426
+ what = (
427
+ self.interval,
428
+ self.zone_name,
429
+ str(self.digits),
430
+ "site" if self.site else "built-in",
431
+ )
432
+ if self.phrases: # without any, as before there were phrases
433
+ standing = sorted(f"{p.code}|{p.until or ''}" for p in self.phrases)
434
+ what += (hashlib.sha256("\n".join(standing).encode()).hexdigest(),)
435
+ mac = self._mac("fingerprint", *what, words).hex()
436
+ return f"{mac[0:4]}-{mac[4:8]}-{mac[8:12]}"
437
+
438
+ def issue(self, now: float | None = None) -> tuple[str, int]:
439
+ expires = int(now if now is not None else time.time()) + self.pass_seconds
440
+ return f"{expires}.{self._sign(expires)}", expires
441
+
442
+ def _sign(self, expires: int) -> str:
443
+ mac = self._mac("pass", str(expires))[:18]
444
+ return base64.urlsafe_b64encode(mac).decode()
445
+
446
+ def pass_expires(self, token: str, now: float | None = None) -> int | None:
447
+ """The pass's expiry, if it is ours, current, and no longer than passes are."""
448
+ stamp, _, sig = (token or "").partition(".")
449
+ if not stamp.isdigit() or len(stamp) > 12:
450
+ return None
451
+ expires = int(stamp)
452
+ now = now if now is not None else time.time()
453
+ if not now < expires <= now + self.pass_seconds + 300:
454
+ return None
455
+ return expires if hmac.compare_digest(sig.encode(), self._sign(expires).encode()) else None
456
+
457
+
458
+ # --- Who is inside -----------------------------------------------------------------
459
+
460
+
461
+ def address(text: str) -> ipaddress.IPv4Address | ipaddress.IPv6Address | None:
462
+ try:
463
+ addr = ipaddress.ip_address(text.strip())
464
+ except ValueError:
465
+ return None
466
+ if isinstance(addr, ipaddress.IPv6Address) and addr.ipv4_mapped:
467
+ return addr.ipv4_mapped
468
+ return addr
469
+
470
+
471
+ def is_internal(ip: str, networks: list[str]) -> bool:
472
+ """Loopback, or an address in ``internal_networks``."""
473
+ addr = address(ip)
474
+ if addr is None:
475
+ return False
476
+ return addr.is_loopback or any(
477
+ addr in ipaddress.ip_network(net, strict=False) for net in networks
478
+ )
@@ -0,0 +1,3 @@
1
+ from . import access, measure, meta, results, wire
2
+
3
+ routers = [wire.router, meta.router, access.router, measure.router, results.router]