diff --git a/README.md b/README.md index 97ac8b8..ab39dbd 100644 --- a/README.md +++ b/README.md @@ -14,18 +14,43 @@ python -m venv .venv .venv/bin/python web.py ``` -Server vypíše adresu, na které poslouchá. Na notebooku otevři `/board`, -naskenuj QR kód telefonem a hraj. +Server vypíše všechny adresy, na kterých je dostupný: + +``` +Počitadlo je dostupné na: + → http://10.186.234.182:8000 místní síť + http://100.91.0.24:8000 přes Tailscale + http://172.17.0.1:8000 virtuální síť +``` + +Na notebooku otevři `/board`, naskenuj QR kód telefonem a hraj. Tabule +zobrazuje i zbylé adresy — když jedna nefunguje, zkus další. | Proměnná | K čemu je | Výchozí | |---|---|---| | `DICE_DB` | soubor s databází | `dice.db` | | `DICE_PORT` | port | `8000` | -| `DICE_HOST` | adresa v QR kódu, když ji nemá hledat sám | zjistí se za běhu | +| `DICE_HOST` | pevná adresa; vypne hledání | hledá se za běhu | Počítá se s domácí sítí — appka nemá přihlašování a kdokoli na stejné WiFi může zapisovat. +## Když se telefon nepřipojí + +Adresy se hledají při každém načtení tabule, takže přechod z domácí WiFi na +hotspot a zpátky si tabule pohlídá sama a načte se znovu. + +Zbývají tři důvody, proč se telefon nedostane na notebook: + +- **Telefon je na mobilních datech**, notebook na WiFi. Různé sítě, nepotkají + se. Připoj telefon na stejnou WiFi, nebo notebook na hotspot telefonu — + to funguje taky. +- **Zapnutý hotspot na telefonu shodil jeho WiFi.** Většina Androidů to dělá. + Pak je řešením připojit na ten hotspot i notebook. +- **VPN nebo Tailscale exit node na telefonu** posílá veškerý provoz mimo + místní síť. V Tailscale to řeší přepínač „Allow local network access"; + případně použij tailnet adresu notebooku, ta funguje odkudkoli. + ## Pravidla Skóre se zadává ručně po tazích, hází se doopravdy. Tři nulové tahy za sebou @@ -38,6 +63,7 @@ při remíze na prvním místě se hraje dál. | Soubor | Role | |---|---| | `core.py` | pravidla hry, žádný vstup ani výstup | +| `net.py` | hledání adres, na kterých je počitadlo dostupné | | `storage.py` | SQLite: hráči, hry, tahy | | `stats.py` | síň slávy a kariérní statistiky | | `web.py` | Flask aplikace | diff --git a/net.py b/net.py new file mode 100644 index 0000000..bc001d6 --- /dev/null +++ b/net.py @@ -0,0 +1,151 @@ +"""Zjištění adres, na kterých je počitadlo dostupné. + +Telefon a notebook se musí potkat na stejné síti a která to je, se během +večera mění — domácí WiFi, hotspot z telefonu, tailnet. Proto se adresy +hledají za běhu, hledá se jich víc a řadí se podle toho, jak pravděpodobně +povedou k cíli. Vybrat si může člověk; my jen nabídneme. + +Nic se tu nezakazuje podle jména konkrétního rozhraní — rozhoduje rozsah +adresy a obecný typ rozhraní, ne to, co má kdo zrovna nainstalované. +""" + +import ipaddress +import os +import socket +from dataclasses import dataclass + +# Rozsah, ze kterého adresy přiděluje Tailscale (RFC 6598, CGNAT). +TAILNET = ipaddress.ip_network("100.64.0.0/10") + +# Obvyklé předpony virtuálních rozhraní. Adresy z nich nezahazujeme, jen je +# řadíme nakonec — dostat se přes ně k počitadlu jde jen výjimečně. +VIRTUAL = ("docker", "br-", "virbr", "veth", "vmnet", "vboxnet", "tun", "tap") + +KIND_LABELS = { + "lan": "místní síť", + "tailnet": "přes Tailscale", + "public": "veřejná adresa", + "virtual": "virtuální síť", +} + +ORDER = {"lan": 0, "tailnet": 1, "public": 2, "virtual": 3} + + +@dataclass +class Address: + ip: str + interface: str + kind: str + primary: bool = False + + @property + def label(self) -> str: + return KIND_LABELS.get(self.kind, self.kind) + + def url(self, port: int) -> str: + return f"http://{self.ip}:{port}" + + +def classify(ip: str, interface: str) -> str | None: + """Typ adresy podle jejího rozsahu. None znamená nepoužitelná.""" + try: + addr = ipaddress.ip_address(ip) + except ValueError: + return None + + if addr.is_loopback or addr.is_link_local or addr.is_unspecified: + return None + + if addr in TAILNET: + return "tailnet" + + if interface.startswith(VIRTUAL): + return "virtual" + + return "lan" if addr.is_private else "public" + + +def rank(found: list[tuple[str, str]], primary: str | None) -> list[Address]: + """Seřadí nalezené dvojice (rozhraní, adresa). + + První je adresa, přes kterou vede výchozí trasa — na té síti je telefon + nejspíš taky. Zbytek podle typu. Čistá funkce, ať jde otestovat i to, + co zrovna není v systému. + """ + seen: dict[str, Address] = {} + + for interface, ip in found: + kind = classify(ip, interface) + if kind is None or ip in seen: + continue + seen[ip] = Address(ip=ip, interface=interface, kind=kind, + primary=(ip == primary)) + + return sorted(seen.values(), + key=lambda a: (not a.primary, ORDER.get(a.kind, 9), a.ip)) + + +def default_route_address() -> str | None: + """Adresa rozhraní, kudy vede výchozí trasa. + + Připojení UDP socketu nic neposílá, jen si vyžádá směrování. + """ + sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + try: + sock.connect(("10.255.255.255", 1)) + return sock.getsockname()[0] + except OSError: + return None + finally: + sock.close() + + +def _from_interfaces() -> list[tuple[str, str]]: + """Adresy všech rozhraní. Linux, jen standardní knihovna.""" + try: + import fcntl + import struct + except ImportError: + return [] + + siocgifaddr = 0x8915 + found = [] + + with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as sock: + for _index, name in socket.if_nameindex(): + try: + packed = struct.pack("256s", name.encode()[:15]) + info = fcntl.ioctl(sock.fileno(), siocgifaddr, packed) + found.append((name, socket.inet_ntoa(info[20:24]))) + except OSError: + continue # rozhraní bez IPv4 adresy + + return found + + +def _from_hostname() -> list[tuple[str, str]]: + """Záloha pro systémy, kde předchozí cesta nefunguje.""" + try: + infos = socket.getaddrinfo(socket.gethostname(), None, socket.AF_INET) + except OSError: + return [] + + return [("", info[4][0]) for info in infos] + + +def addresses() -> list[Address]: + """Kde všude je počitadlo k dispozici, od nejpravděpodobnější adresy. + + `DICE_HOST` má poslední slovo — když je nastavená, platí jen ona. + """ + forced = os.environ.get("DICE_HOST") + if forced: + return [Address(ip=forced, interface="", kind="lan", primary=True)] + + primary = default_route_address() + + found = _from_interfaces() or _from_hostname() + if primary and primary not in [ip for _name, ip in found]: + found.append(("", primary)) + + return rank(found, primary) diff --git a/static/style.css b/static/style.css index 11d567a..ce95cdb 100644 --- a/static/style.css +++ b/static/style.css @@ -467,3 +467,29 @@ button { font-size: 1rem; color: var(--ink); } + +/* --- další cesty k počitadlu --------------------------------------------- */ + +.alt { + list-style: none; + margin: 0.4rem 0 0; + padding: 0; + font-size: 0.8rem; + color: var(--ink-soft); +} + +.alt li { white-space: nowrap; } + +.alt em { + font-family: var(--font-ui); + font-style: normal; + font-size: 0.68rem; + letter-spacing: 0.06em; + text-transform: uppercase; +} + +.address code { + font-family: var(--font-num); + font-size: 0.85em; + color: var(--ink); +} diff --git a/templates/_head.html b/templates/_head.html index 902d6c8..5cbfc21 100644 --- a/templates/_head.html +++ b/templates/_head.html @@ -1,3 +1,5 @@ +{# data-address si hlídá tabule: když se notebook přesune na jinou síť, + je v QR kódu stará adresa a stránka se sama načte znovu. #}

Zápisník · hra {{ game_id }}

{% if winner %}Konec hry{% else %}Na tahu {{ current }}{% endif %}

do {{ game.final_score|cislo }} bodů · kolo {{ game.round_number }}

diff --git a/templates/_join.html b/templates/_join.html new file mode 100644 index 0000000..a9126c7 --- /dev/null +++ b/templates/_join.html @@ -0,0 +1,22 @@ +{# Kudy se na počitadlo dostane telefon. Nabídneme víc cest, vybrat si může člověk. #} +{% if primary %} +
+ {{ target|qr }} +
+ Zapisuje se z telefonu + {{ target }} + {% if others %} + + {% endif %} +
+
+{% else %} +
+

Adresu se nepodařilo zjistit + Spusť server s DICE_HOST=<adresa>.

+
+{% endif %} diff --git a/templates/_panel.html b/templates/_panel.html index e8c1cd5..ef24f89 100644 --- a/templates/_panel.html +++ b/templates/_panel.html @@ -1,3 +1,3 @@ {# Fragment pro tabuli: obě části, které se během hry mění. #} - +
{% include "_sheet.html" %}
diff --git a/templates/board.html b/templates/board.html index 73850b4..a430dfc 100644 --- a/templates/board.html +++ b/templates/board.html @@ -4,11 +4,8 @@ {% block body %}
- -
- {{ qr }} -

Zapisuje se z telefonu{{ address }}

-
+ + {% include "_join.html" %}
{% include "_sheet.html" %}
@@ -16,7 +13,6 @@