De app kijkt naar zijn eigen voordeur, en meet minder dan het plan beloofde
Open punt 5 van Webinterface, goedgekeurd en gebouwd. De agent verbindt met de eigen TLS-poort, maakt de handdruk af en vergelijkt het getoonde certificaat byte voor byte met het gekozen bestand. Die vergelijking blijkt waardevoller dan de handdruk. Ze vangt een certificaatwissel die nginx nooit heeft toegepast, en dat is precies het geval waar een controle op vertrouwen blind voor is. Er wordt daarom bewust niet tegen de certificaatwinkel van het besturingssysteem geverifieerd: een zelfondertekend certificaat uploaden is een ondersteunde bron en die opstelling zou dan als kapot gemeld worden. Twee dingen liepen anders dan het plan zei en staan nu rechtgezet in PLAN 4a2, OPEN punt 5 en de changelog. De belofte "luisteren, certificaat en doorverbinding in een keer" klopt voor twee van de drie: na de handdruk wordt er niets verstuurd. Een echt verzoek zou de sessie bytes geven, en sessies met bytes worden nooit uit het activiteitenlog gefilterd, want die kunnen een storing zijn. En dat filteren was de tweede verrassing. Elke meting is voor nginx een gewone sessie en levert dus een logregel op; zonder rem ging het log over onszelf in plaats van over wallets. Er zit nu een rem van vijf minuten op, er wordt niet gemeten vlak na een herlading omdat het vorige certificaat er dan nog staat, en de eigen regels worden weggelaten op grond van het moment. Tests: 22 nieuw, met de nadruk op de niet-gelukkige paden en op de rem, want dat is wat het log bruikbaar houdt. Alle vier de beslissende regels mutatie-getest. De changelog kreeg ook de ontbrekende 0.0.15 erbij. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -46,6 +46,22 @@ UPLOAD_MAX = 96 * 1024
|
||||
ELECTRUM_HOST = os.environ.get("GATE_ELECTRUM_HOST", "")
|
||||
ELECTRUM_PORT = int(os.environ.get("GATE_ELECTRUM_PORT", "50001"))
|
||||
TLS_PORT = int(os.environ.get("GATE_TLS_PORT", "50022"))
|
||||
|
||||
# De zelfcontrole: de agent verbindt met de eigen TLS-poort. Zie het plan
|
||||
# Webinterface, OPEN.md punt 5.
|
||||
#
|
||||
# GATE_TLS_HOST is de nginx-container hiernaast. Servicenamen resolven binnen het
|
||||
# compose-netwerk; dat is dezelfde weg die nginx andersom gebruikt om bij deze
|
||||
# agent te komen. Niet localhost: dat is een andere container en dus een andere
|
||||
# netwerk-namespace.
|
||||
#
|
||||
# Waarom deze controle niet elke ronde draait: elke verbinding met poort 50022 is
|
||||
# voor nginx een gewone sessie en levert dus een regel in het activiteitenlog op.
|
||||
# Elke minuut een eigen regel maakt dat log onbruikbaar. Vijf minuten is vaak
|
||||
# genoeg voor een waarde die zelden verandert, en na een certificaatwissel wordt
|
||||
# er sowieso gemeten.
|
||||
TLS_HOST = os.environ.get("GATE_TLS_HOST", "server")
|
||||
SELF_CHECK_INTERVAL = int(os.environ.get("GATE_SELF_CHECK_INTERVAL", "300"))
|
||||
INTERVAL = int(os.environ.get("GATE_INTERVAL", "60"))
|
||||
API_PORT = int(os.environ.get("GATE_API_PORT", "8000"))
|
||||
HISTORY_SECONDS = 24 * 3600
|
||||
@@ -643,6 +659,101 @@ def probe_backend():
|
||||
return result
|
||||
|
||||
|
||||
# Wat er in het activiteitenlog komt als de uitkomst verandert. Engels, want dit
|
||||
# belandt op de pagina. Alleen bij een wijziging: elke ronde dezelfde regel is
|
||||
# ruis, en de toestand zelf staat in status.json.
|
||||
SELF_CHECK_NOTES = {
|
||||
"ok": "the gateway answered on its own TLS port",
|
||||
"failed": "the gateway did not answer on its own TLS port",
|
||||
"wrong-certificate": "the gateway answered with a different certificate than the one selected",
|
||||
"off": "TLS is off; no certificate selected",
|
||||
}
|
||||
|
||||
|
||||
def probe_self(active):
|
||||
"""Antwoordt de eigen TLS-poort, en met het gekozen certificaat?
|
||||
|
||||
Dit is de enige controle die gaat over wat deze app zelf doet. De agent kijkt
|
||||
al of de Electrum-server antwoordt, maar dat zegt niets over de voordeur, en
|
||||
de voordeur is de hele app. Eén verbinding met een afgemaakte handdruk bewijst
|
||||
drie dingen tegelijk: nginx luistert, er ligt een certificaat, en het is te
|
||||
gebruiken.
|
||||
|
||||
Bewust géén controle op vertrouwen. `verify_mode` staat op CERT_NONE en de
|
||||
hostnaam wordt niet nagelopen, en dat is geen slordigheid maar het verschil
|
||||
tussen deze controle en die van een wallet:
|
||||
|
||||
- een gebruiker mag een zelfondertekend certificaat uploaden, dat is een
|
||||
ondersteunde bron. Verifiëren tegen de certificaatwinkel van het
|
||||
besturingssysteem zou die opstelling als kapot melden terwijl hij werkt;
|
||||
- de winkel hoeft in de image niet te bestaan. De bestaande test slaat de
|
||||
vergelijking met `ssl` om die reden soms over, en een controle die zichzelf
|
||||
overslaat op het apparaat is geen controle.
|
||||
|
||||
Wat er in plaats daarvan gebeurt is scherper: het getoonde certificaat wordt
|
||||
byte voor byte vergeleken met het gekozen bestand. Dat vangt het geval waar
|
||||
trust-verificatie juist blind voor is, namelijk dat nginx nog het vórige
|
||||
certificaat serveert omdat een herlading niet gelukt is.
|
||||
|
||||
**Wat dit niet bewijst, en dat is minder dan het plan beloofde.** Er wordt na
|
||||
de handdruk niets verstuurd, dus de doorverbinding naar de Electrum-server zit
|
||||
er níet in. Dat is een bewuste ruil: één echt verzoek zou de hele keten
|
||||
bewijzen, maar dan draagt de sessie bytes, en sessies met bytes worden nooit
|
||||
uit het activiteitenlog gefilterd. Zie suppress_self_probes: een sessie waarin
|
||||
iets is doorgegeven kan een storing zijn, en die verbergen is erger dan deze
|
||||
controle mislopen. De backend wordt bovendien apart bevraagd door
|
||||
probe_backend; wat tussen wal en schip valt is alleen de proxy_pass zelf.
|
||||
|
||||
Evenmin bewezen: de weg van buiten naar binnen. Deze verbinding loopt van
|
||||
container naar container over het compose-netwerk, dus de poortmapping naar de
|
||||
host en de doorstuurregel in de router blijven ongetest.
|
||||
|
||||
Geeft altijd een dict met een `state` terug, nooit een uitzondering:
|
||||
|
||||
- `off` er is geen certificaat gekozen, dus poort 50022 luistert
|
||||
niet. Geen storing maar de normale begintoestand;
|
||||
- `ok` handdruk afgemaakt en het gekozen certificaat kwam terug;
|
||||
- `wrong-certificate` handdruk afgemaakt, maar met een ander certificaat;
|
||||
- `failed` geen verbinding of geen handdruk, met de reden erbij.
|
||||
"""
|
||||
if not active:
|
||||
return {"at": int(time.time()), "state": "off"}
|
||||
|
||||
context = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
|
||||
# In deze volgorde: verify_mode op CERT_NONE zetten terwijl check_hostname
|
||||
# nog aanstaat is een ValueError.
|
||||
context.check_hostname = False
|
||||
context.verify_mode = ssl.CERT_NONE
|
||||
|
||||
# De naam die een wallet ook zou sturen. Er is maar één server-blok, dus
|
||||
# nginx kiest hier niets op; het houdt de meting alleen gelijk aan de echte
|
||||
# situatie. Leeg mag niet, dus dan liever niets.
|
||||
server_name = active.get("domain") or None
|
||||
|
||||
start = time.monotonic()
|
||||
try:
|
||||
with socket.create_connection((TLS_HOST, TLS_PORT), timeout=10) as raw:
|
||||
with context.wrap_socket(raw, server_hostname=server_name) as tls:
|
||||
presented = tls.getpeercert(binary_form=True)
|
||||
elapsed = int(round((time.monotonic() - start) * 1000.0))
|
||||
except Exception as e: # noqa: BLE001 - elke fout betekent hetzelfde voor de
|
||||
# pagina: de voordeur antwoordde niet.
|
||||
return {"at": int(time.time()), "state": "failed", "error": str(e)}
|
||||
|
||||
result = {"at": int(time.time()), "ms": elapsed}
|
||||
try:
|
||||
expected = read_pem(active["cert"])
|
||||
except Exception: # noqa: BLE001 - een onleesbaar bestand is al elders
|
||||
# gemeld; hier zou het alleen een tweede, vagere melding opleveren.
|
||||
expected = None
|
||||
|
||||
if expected is not None and presented is not None and presented != expected:
|
||||
result["state"] = "wrong-certificate"
|
||||
return result
|
||||
result["state"] = "ok"
|
||||
return result
|
||||
|
||||
|
||||
# --- Het activiteitenlog ----------------------------------------------------
|
||||
# nginx schrijft per stream-sessie één regel, en pas bij het sluiten. Een wallet
|
||||
# houdt zijn verbinding uren open, dus een lopende sessie staat er nog niet in.
|
||||
@@ -721,6 +832,44 @@ def read_stream_log(cutoff):
|
||||
return out
|
||||
|
||||
|
||||
def suppress_self_probes(entries, probes):
|
||||
"""Haalt de sessieregels van de eigen zelfcontrole uit het log.
|
||||
|
||||
Nodig omdat die controle een echte verbinding met poort 50022 is: nginx ziet
|
||||
geen verschil met een wallet en schrijft dus een regel. Die regel komt als
|
||||
`probe` binnen, want er wordt niets doorgegeven, en zonder deze filter zou het
|
||||
activiteitenlog voor het grootste deel uit onze eigen controles bestaan.
|
||||
|
||||
Er is niets in de logregel waaraan onze verbinding te herkennen is, en dat is
|
||||
met opzet: er staat geen client-adres in en dat blijft zo. Wat er wél is, is
|
||||
het moment. De agent onthoudt wanneer hij zelf verbond, en dat wordt hier
|
||||
tegen de eindtijd van de sessie gelegd.
|
||||
|
||||
Twee dingen om te weten voordat je hierop leunt:
|
||||
|
||||
- **de speling is twee seconden**, want de logregel draagt de eindtijd van de
|
||||
sessie en de agent noteert het moment vlak erna;
|
||||
- **er kan een échte scan verdwijnen** die toevallig in datzelfde venster
|
||||
eindigde. Per onthouden controle wordt hoogstens één regel weggelaten, dus
|
||||
het blijft bij die ene. Dat is de prijs, en hij is lager dan een log dat
|
||||
elke vijf minuten over onszelf gaat.
|
||||
"""
|
||||
rest = list(probes)
|
||||
out = []
|
||||
for e in entries:
|
||||
if e.get("event") == "probe" and not e.get("bytes_in") and not e.get("bytes_out"):
|
||||
match = None
|
||||
for t in rest:
|
||||
if abs(e.get("t", 0) - t) <= 2:
|
||||
match = t
|
||||
break
|
||||
if match is not None:
|
||||
rest.remove(match)
|
||||
continue
|
||||
out.append(e)
|
||||
return out
|
||||
|
||||
|
||||
def read_open_sessions():
|
||||
"""Hoeveel wallet-verbindingen er nu openstaan, of None.
|
||||
|
||||
@@ -800,20 +949,52 @@ def build_status(events):
|
||||
"note": "the Electrum server did not answer",
|
||||
})
|
||||
|
||||
# De zelfcontrole op de eigen TLS-poort. Niet elke ronde; zie
|
||||
# SELF_CHECK_INTERVAL voor waarom.
|
||||
previous_self = (previous.get("tls") or {}).get("self_check") or {}
|
||||
probes = [t for t in previous.get("self_probes", [])
|
||||
if isinstance(t, int) and t > now - LOG_SECONDS]
|
||||
|
||||
if reloaded:
|
||||
# Nu meten zou vrijwel zeker "wrong-certificate" opleveren en dat zou
|
||||
# onwaar zijn: nginx herlaadt binnen tien seconden en tot die tijd staat
|
||||
# het vorige certificaat er nog. Een ronde overslaan en het dan meten
|
||||
# geeft juist de interessantste meting die er is, want een herlading die
|
||||
# niet aankomt is precies wat deze controle moet vinden.
|
||||
self_check = {"state": "pending"}
|
||||
elif now - int(previous_self.get("at") or 0) >= SELF_CHECK_INTERVAL:
|
||||
self_check = probe_self(active)
|
||||
if self_check.get("state") != "off":
|
||||
# Ook bij een mislukking: een geweigerde handdruk is voor nginx nog
|
||||
# steeds een sessie en levert dus een regel op. Alleen als er niet
|
||||
# verbonden is, is er niets weg te filteren.
|
||||
probes.append(self_check["at"])
|
||||
was = previous_self.get("state")
|
||||
if was and was != self_check["state"]:
|
||||
events.append({
|
||||
"t": now,
|
||||
"event": "selfcheck",
|
||||
"note": SELF_CHECK_NOTES.get(
|
||||
self_check["state"], "self-check: " + self_check["state"]),
|
||||
})
|
||||
else:
|
||||
self_check = previous_self
|
||||
|
||||
history = [h for h in previous.get("history", [])
|
||||
if isinstance(h, dict) and h.get("t", 0) > now - HISTORY_SECONDS]
|
||||
if "response_ms" in backend:
|
||||
history.append({"t": now, "response_ms": backend["response_ms"]})
|
||||
|
||||
cutoff = now - LOG_SECONDS
|
||||
entries = read_stream_log(cutoff)
|
||||
entries = suppress_self_probes(read_stream_log(cutoff), probes)
|
||||
# De eigen gebeurtenissen van de agent uit de vorige ronde weer meenemen; de
|
||||
# sessieregels komen uit de nginx-log en staan daar al. Vergeet je er één in
|
||||
# deze opsomming, dan verdwijnt dat soort regel na één minuut weer van de
|
||||
# pagina, en dat is precies wat een connect-regel onbruikbaar maakt.
|
||||
entries.extend(e for e in previous.get("log", [])
|
||||
if isinstance(e, dict) and e.get("t", 0) > cutoff
|
||||
and e.get("event") in ("start", "reload", "refused", "connect"))
|
||||
and e.get("event") in ("start", "reload", "refused", "connect",
|
||||
"selfcheck"))
|
||||
entries.extend(events)
|
||||
# Dubbele meldingen dat de backend niet antwoordt zijn ruis; één per ronde
|
||||
# is genoeg en de rest zegt hetzelfde.
|
||||
@@ -832,10 +1013,14 @@ def build_status(events):
|
||||
"interval_seconds": INTERVAL,
|
||||
# open_connections blijft weg als het niet bekend is. Zie
|
||||
# read_open_sessions: nul beweren is hier erger dan zwijgen.
|
||||
"tls": {"port": TLS_PORT},
|
||||
"tls": {"port": TLS_PORT, "self_check": self_check},
|
||||
"backend": backend,
|
||||
"history": history,
|
||||
"log": unique[-200:],
|
||||
# Wanneer de agent zelf verbond. Boekhouding voor suppress_self_probes en
|
||||
# niet iets om te tonen; het venster is even lang als dat van het log,
|
||||
# want daar wordt het tegen gelegd.
|
||||
"self_probes": probes[-500:],
|
||||
"certificates": [
|
||||
{
|
||||
"id": c["id"],
|
||||
|
||||
@@ -28,6 +28,16 @@ services:
|
||||
GATE_ELECTRUM_HOST: ${APP_ELECTRS_NODE_IP}
|
||||
GATE_ELECTRUM_PORT: ${APP_ELECTRS_NODE_PORT}
|
||||
GATE_TLS_PORT: "50022"
|
||||
# De zelfcontrole: de agent verbindt met de TLS-poort van de server
|
||||
# hieronder en maakt de handdruk af. De servicenaam resolveert binnen het
|
||||
# compose-netwerk, en dat is dezelfde weg die nginx andersom gebruikt met
|
||||
# zijn proxy_pass naar 'agent'. Niet localhost: dat is een andere container.
|
||||
GATE_TLS_HOST: server
|
||||
# Niet elke ronde meten, want elke meting is voor nginx een gewone sessie
|
||||
# en levert dus een regel in het activiteitenlog op. Vijf minuten is vaak
|
||||
# genoeg voor een waarde die zelden verandert; na een certificaatwissel
|
||||
# wordt er sowieso gemeten.
|
||||
GATE_SELF_CHECK_INTERVAL: "300"
|
||||
GATE_INTERVAL: "60"
|
||||
GATE_API_PORT: "8000"
|
||||
GATE_STATE_DIR: /var/lib/gate
|
||||
|
||||
@@ -9,7 +9,7 @@ manifestVersion: 1
|
||||
id: whatsnext-electrum-gate
|
||||
category: bitcoin
|
||||
name: Electrum Gate
|
||||
version: "0.0.15"
|
||||
version: "0.0.16"
|
||||
tagline: Your own node from anywhere, without waiting for Tor
|
||||
description: >-
|
||||
The privacy win is already yours: you run the Electrum server. A public one gets asked for
|
||||
@@ -56,18 +56,32 @@ description: >-
|
||||
# staan. In 0.0.9 stond er daardoor drie keer "Earlier releases" en twee keer
|
||||
# dezelfde 0.0.4-regel. De toets let er nu op.
|
||||
releaseNotes: >-
|
||||
The app store this app comes from has moved to a new address, and every link in this listing now
|
||||
points there. The app itself is unchanged. If you added the store at its old address, remove it in
|
||||
umbrelOS and add the new one.
|
||||
The app now checks its own front door. Until this release it watched whether your Electrum server was
|
||||
answering, but never whether the gateway itself was, which is the one thing it exists to do.
|
||||
|
||||
|
||||
The move is because the store now carries a second app, so naming it after this one no longer made
|
||||
sense.
|
||||
Every few minutes it opens a connection to its own TLS port, completes the handshake, and compares the
|
||||
certificate it gets back with the one you picked. That last part catches something nothing else did: a
|
||||
certificate change that nginx never actually applied, which looks fine from the outside and fails at
|
||||
your wallet.
|
||||
|
||||
|
||||
For now the result appears as a line in the activity log when it changes. A tile on the dashboard
|
||||
follows in a later release. The check runs every few minutes rather than every minute, and its own
|
||||
connections are kept out of the activity log, so the log stays about your wallets.
|
||||
|
||||
|
||||
What it does not tell you: whether your gateway can be reached from outside. This check runs inside the
|
||||
app, so a router that stopped forwarding the port still reads as fine here.
|
||||
|
||||
|
||||
Earlier releases:
|
||||
|
||||
|
||||
0.0.15 moved the app store this app comes from to a new address, and pointed every link in this listing
|
||||
there. If you added the store at its old address, remove it in umbrelOS and add the new one.
|
||||
|
||||
|
||||
0.0.14 stopped calling a scan from the internet a refusal. Those now read "probe", in grey, and red
|
||||
is kept for a session that did carry traffic and then broke. The panels also got more room between
|
||||
them.
|
||||
|
||||
Reference in New Issue
Block a user