De melding van de gebruiker was "Not answering" met [Errno 111] Connection refused, terwijl de gateway van buiten gewoon werkte. Het was een startvolgorde en geen storing: 1. de compose laat `server` ná de agent starten (depends_on: [agent]), want nginx lost http://agent:8000 bij het starten op en breekt af op een onbekende naam; 2. build_status draait meteen bij het opstarten, vóór de eerste WAKE.wait; 3. de branch die de meting uitstelt hing alleen aan `reloaded`, en die is bij een herstart met een ongewijzigd certificaat False, dus er werd gemeten; 4. nginx luisterde toen nog niet op 50022; 5. en het bleef staan, want de volgende meting kwam pas na SELF_CHECK_INTERVAL. Twee reparaties, elk voor een helft. De eerste ronde van een proces meet niet maar zet `pending` (die toestand heeft geen `at`, dus de ronde daarna meet gewoon), en na een mislukking wordt er na GATE_SELF_CHECK_RETRY (60s) opnieuw gemeten in plaats van na 300. Een geslaagde meting blijft op 300, want dat is de waarde die zelden verandert en die het activiteitenlog belast. De fout zat er sinds de zelfcontrole bestaat (27-08-2026) en was alleen binnen vijf minuten na een herstart te zien. Mutatie-getest: de eerste-rondebranch uitschakelen laat twee tests omvallen, de snelle herhaling uitschakelen precies één. Relay: de statuswidget staat nu rechts in de bovenste rij en heet Status in plaats van Relay, gelijk aan Electrum Gate. De twee pagina's zijn één app store en horen op dezelfde manier te lezen, en dit is de widget die als enige begrijpelijk is zonder buur. De id's relay-state en relay-sub blijven. Suite groen: 54 + 28 + 65 + 39 + 43 + 22 + 115. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
435 lines
17 KiB
Python
435 lines
17 KiB
Python
"""Toetst de zelfcontrole van de agent: antwoordt de eigen TLS-poort?
|
|
|
|
Waarom deze test bestaat: dit is de enige controle die over de app zelf gaat, en
|
|
hij kan op twee manieren stil verkeerd zijn. Hij kan "ok" melden terwijl er iets
|
|
anders aan de lijn hangt dan het gekozen certificaat, en hij kan het
|
|
activiteitenlog vullen met zijn eigen verbindingen. Dat tweede is geen schoonheids-
|
|
foutje: elke meting is voor nginx een gewone sessie, dus zonder rem en zonder
|
|
filter gaat het log over onszelf in plaats van over wallets.
|
|
|
|
De nadruk ligt daarom op wat er níet moet gebeuren: niet meten vlak na een
|
|
herlading, niet meten als er net gemeten is, en de eigen sessieregel niet laten
|
|
staan.
|
|
|
|
Draaien:
|
|
|
|
python tests/test_agent_zelfcontrole.py
|
|
|
|
Wat een echte TLS-server vraagt, vraagt openssl. Is dat er niet, dan slaan die
|
|
toetsen zichzelf over en zeggen dat erbij; de rest draait gewoon.
|
|
"""
|
|
|
|
import sys
|
|
|
|
# Zelfde reden als in test_agent_certificates.py: geen .pyc naast de app-map.
|
|
sys.dont_write_bytecode = True
|
|
|
|
import json # noqa: E402
|
|
import os # noqa: E402
|
|
import socket # noqa: E402
|
|
import ssl # noqa: E402
|
|
import tempfile # noqa: E402
|
|
import threading # noqa: E402
|
|
import time # noqa: E402
|
|
|
|
HERE = os.path.dirname(os.path.abspath(__file__))
|
|
if HERE not in sys.path:
|
|
sys.path.insert(0, HERE)
|
|
|
|
# De buurtest is hier de gereedschapskist: hij laadt agent.py.template als module
|
|
# en maakt echte sleutelparen met openssl. Dat tweede keer overdoen zou een tweede
|
|
# versie opleveren die uit elkaar loopt.
|
|
from test_agent_certificates import Uitslag, load_agent, openssl_paar # noqa: E402
|
|
|
|
|
|
class TlsServer:
|
|
"""Een echte TLS-server op een vrije poort van de loopback.
|
|
|
|
Echt en niet nagebootst, want wat hier getoetst wordt is een handdruk. Een
|
|
verzonnen server bewijst niets over `wrap_socket`.
|
|
"""
|
|
|
|
def __init__(self, cert, key):
|
|
self.context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
|
|
self.context.load_cert_chain(cert, key)
|
|
self.sock = socket.socket()
|
|
self.sock.bind(("127.0.0.1", 0))
|
|
self.sock.listen(5)
|
|
self.port = self.sock.getsockname()[1]
|
|
self.stoppen = False
|
|
self.thread = threading.Thread(target=self._loop, daemon=True)
|
|
self.thread.start()
|
|
|
|
def _loop(self):
|
|
while not self.stoppen:
|
|
try:
|
|
rauw, _ = self.sock.accept()
|
|
except OSError:
|
|
return
|
|
try:
|
|
with self.context.wrap_socket(rauw, server_side=True) as tls:
|
|
# Wachten tot de client sluit. De agent sluit meteen na de
|
|
# handdruk, dus dit levert b"" op en niet een hangende lus.
|
|
tls.recv(1)
|
|
except OSError:
|
|
pass
|
|
|
|
def close(self):
|
|
self.stoppen = True
|
|
try:
|
|
self.sock.close()
|
|
except OSError:
|
|
pass
|
|
|
|
|
|
class KaleServer:
|
|
"""Luistert wel, maar spreekt geen TLS. Accepteert en sluit meteen."""
|
|
|
|
def __init__(self):
|
|
self.sock = socket.socket()
|
|
self.sock.bind(("127.0.0.1", 0))
|
|
self.sock.listen(5)
|
|
self.port = self.sock.getsockname()[1]
|
|
self.stoppen = False
|
|
self.thread = threading.Thread(target=self._loop, daemon=True)
|
|
self.thread.start()
|
|
|
|
def _loop(self):
|
|
while not self.stoppen:
|
|
try:
|
|
rauw, _ = self.sock.accept()
|
|
except OSError:
|
|
return
|
|
rauw.close()
|
|
|
|
def close(self):
|
|
self.stoppen = True
|
|
try:
|
|
self.sock.close()
|
|
except OSError:
|
|
pass
|
|
|
|
|
|
def dichte_poort():
|
|
"""Een poortnummer waar zeker niets luistert."""
|
|
s = socket.socket()
|
|
s.bind(("127.0.0.1", 0))
|
|
poort = s.getsockname()[1]
|
|
s.close()
|
|
return poort
|
|
|
|
|
|
def richt(agent, poort):
|
|
agent.TLS_HOST = "127.0.0.1"
|
|
agent.TLS_PORT = poort
|
|
|
|
|
|
def test_zonder_certificaat_wordt_er_niet_verbonden(agent, u):
|
|
"""Geen keuze betekent geen poort 50022, en dus geen storing.
|
|
|
|
Dit is de normale toestand van een verse installatie. Hem als fout melden zou
|
|
de ergste soort onwaarheid zijn: precies daar staat al een foutmelding dat er
|
|
een certificaat gekozen moet worden, en een tweede rode melding erbij leidt
|
|
weg van de knop die het oplost.
|
|
"""
|
|
richt(agent, dichte_poort())
|
|
|
|
for leeg in (None, {}):
|
|
uitkomst = agent.probe_self(leeg)
|
|
u.check("zonder certificaat is de toestand 'off' (%r)" % (leeg,),
|
|
uitkomst.get("state") == "off", "gaf %r" % uitkomst)
|
|
u.check("en dan is er niets gemeten (%r)" % (leeg,),
|
|
"ms" not in uitkomst and "error" not in uitkomst,
|
|
"gaf %r" % uitkomst)
|
|
|
|
|
|
def test_niemand_luistert(agent, u):
|
|
"""Wel een certificaat, geen poort. Dat is wel een storing."""
|
|
richt(agent, dichte_poort())
|
|
uitkomst = agent.probe_self({"domain": "gate.example.org", "cert": "/bestaat/niet"})
|
|
u.check("een dichte poort geeft 'failed'",
|
|
uitkomst.get("state") == "failed", "gaf %r" % uitkomst)
|
|
u.check("en de reden staat erbij",
|
|
bool(uitkomst.get("error")), "gaf %r" % uitkomst)
|
|
u.check("een mislukking meldt geen meettijd",
|
|
"ms" not in uitkomst, "gaf %r" % uitkomst)
|
|
|
|
|
|
def test_luisteren_is_niet_genoeg(agent, u):
|
|
"""Iets dat luistert maar geen TLS spreekt, is ook een storing.
|
|
|
|
Dit onderscheidt de controle van een simpele poortcheck. Een open poort
|
|
bewijst niet dat er een bruikbaar certificaat achter zit, en juist dat is wat
|
|
een wallet nodig heeft.
|
|
"""
|
|
server = KaleServer()
|
|
try:
|
|
richt(agent, server.port)
|
|
uitkomst = agent.probe_self({"domain": "gate.example.org", "cert": "/bestaat/niet"})
|
|
u.check("luisteren zonder TLS geeft 'failed'",
|
|
uitkomst.get("state") == "failed", "gaf %r" % uitkomst)
|
|
finally:
|
|
server.close()
|
|
|
|
|
|
def test_gelukkig_pad_en_verkeerd_certificaat(agent, u):
|
|
"""De handdruk lukt, en dan gaat het erom wélk certificaat er terugkomt.
|
|
|
|
Het tweede geval is de reden dat deze controle niet op vertrouwen leunt maar
|
|
op de bytes van het certificaat: nginx dat na een mislukte herlading nog het
|
|
vorige certificaat serveert, is voor elke trust-controle in orde en is toch
|
|
precies de storing die je zoekt.
|
|
"""
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
paar = openssl_paar(tmp, "gekozen", "gate.example.org")
|
|
ander = openssl_paar(tmp, "ander", "andere.example.org")
|
|
if not paar or not ander:
|
|
print("OVERGESLAGEN: geen openssl, dus de handdruk is niet getoetst")
|
|
return
|
|
cert, key = paar
|
|
ander_cert, _ = ander
|
|
|
|
server = TlsServer(cert, key)
|
|
try:
|
|
richt(agent, server.port)
|
|
|
|
uitkomst = agent.probe_self({"domain": "gate.example.org", "cert": cert})
|
|
u.check("het gekozen certificaat aan de lijn geeft 'ok'",
|
|
uitkomst.get("state") == "ok", "gaf %r" % uitkomst)
|
|
u.check("en een meettijd in milliseconden",
|
|
isinstance(uitkomst.get("ms"), int) and uitkomst["ms"] >= 0,
|
|
"gaf %r" % uitkomst)
|
|
|
|
uitkomst = agent.probe_self({"domain": "andere.example.org",
|
|
"cert": ander_cert})
|
|
u.check("een ander certificaat aan de lijn wordt herkend",
|
|
uitkomst.get("state") == "wrong-certificate", "gaf %r" % uitkomst)
|
|
finally:
|
|
server.close()
|
|
|
|
|
|
def test_eigen_sessies_uit_het_log(agent, u):
|
|
"""De filter die het log over wallets laat gaan in plaats van over onszelf."""
|
|
def regel(t, event="probe", inn=0, uit=0):
|
|
return {"t": t, "event": event, "bytes_in": inn, "bytes_out": uit}
|
|
|
|
entries = [regel(1000), regel(2000)]
|
|
over = agent.suppress_self_probes(entries, [1001])
|
|
u.check("een eigen meting verdwijnt uit het log",
|
|
[e["t"] for e in over] == [2000], "over: %r" % over)
|
|
|
|
over = agent.suppress_self_probes([regel(1000)], [1010])
|
|
u.check("een scan buiten het venster blijft staan",
|
|
len(over) == 1, "over: %r" % over)
|
|
|
|
# Dit is de belangrijkste: een sessie waarin wél iets is doorgegeven, is per
|
|
# definitie niet van ons. Die weglaten zou een storing verbergen.
|
|
echt = regel(1000, event="refused", inn=120, uit=300)
|
|
over = agent.suppress_self_probes([echt], [1000])
|
|
u.check("een sessie met verkeer blijft staan, ook op hetzelfde moment",
|
|
over == [echt], "over: %r" % over)
|
|
|
|
# Eén onthouden meting laat hoogstens één regel weg. Anders zou een enkele
|
|
# meting een reeks echte scans kunnen opslokken.
|
|
over = agent.suppress_self_probes([regel(1000), regel(1001)], [1000])
|
|
u.check("één meting laat hoogstens één regel weg",
|
|
len(over) == 1, "over: %r" % over)
|
|
|
|
u.check("zonder metingen verandert er niets",
|
|
agent.suppress_self_probes([regel(1000)], []) == [regel(1000)])
|
|
|
|
|
|
def test_ronde_meet_niet_elke_keer(agent, u):
|
|
"""De rem, via `build_status`, dus via de ronde die de agent echt draait.
|
|
|
|
Drie dingen op een rij die alle drie stil fout kunnen gaan:
|
|
|
|
1. vlak na een certificaatwissel wordt er niet gemeten. nginx herlaadt binnen
|
|
tien seconden, dus meten zou het vorige certificaat zien en dat als storing
|
|
melden;
|
|
2. de ronde daarna wordt er wel gemeten;
|
|
3. de ronde daarna weer niet, want er is net gemeten. Zonder deze rem staat er
|
|
elke minuut een eigen regel in het activiteitenlog.
|
|
"""
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
bron = os.path.join(tmp, "certs")
|
|
os.makedirs(bron)
|
|
paar = openssl_paar(bron, "gate", "gate.example.org")
|
|
if not paar:
|
|
print("OVERGESLAGEN: geen openssl, dus de rem is niet getoetst")
|
|
return
|
|
cert, key = paar
|
|
|
|
agent.SOURCES = [("Own folder", bron)]
|
|
agent.ELECTRUM_HOST = "" # dan faalt de backend-probe zonder netwerk
|
|
agent.STATE_DIR = tmp
|
|
agent.CONFIG_DIR = os.path.join(tmp, "config")
|
|
agent.SELECTED_FILE = os.path.join(agent.CONFIG_DIR, "selected-cert")
|
|
agent.STATUS_FILE = os.path.join(tmp, "status.json")
|
|
agent.CERT_CONF = os.path.join(tmp, "cert.conf")
|
|
agent.RELOAD_FLAG = os.path.join(tmp, "reload")
|
|
agent.STREAM_LOG = os.path.join(tmp, "stream.log")
|
|
agent.SESSIONS_FILE = os.path.join(tmp, "sessions")
|
|
agent.SELF_CHECK_INTERVAL = 300
|
|
|
|
server = TlsServer(cert, key)
|
|
try:
|
|
richt(agent, server.port)
|
|
|
|
def ronde():
|
|
status = agent.build_status([])
|
|
agent.write_status(status)
|
|
return status
|
|
|
|
eerste = ronde()["tls"]["self_check"]
|
|
u.check("vlak na de eerste keuze wordt er niet gemeten",
|
|
eerste.get("state") == "pending", "gaf %r" % eerste)
|
|
|
|
tweede = ronde()["tls"]["self_check"]
|
|
u.check("de ronde daarna wel, en die lukt",
|
|
tweede.get("state") == "ok", "gaf %r" % tweede)
|
|
|
|
derde = ronde()
|
|
u.check("en daarna niet opnieuw, want er is net gemeten",
|
|
derde["tls"]["self_check"].get("at") == tweede.get("at"),
|
|
"gaf %r" % derde["tls"]["self_check"])
|
|
u.check("er is dus precies één eigen meting onthouden",
|
|
len(derde.get("self_probes", [])) == 1,
|
|
"gaf %r" % derde.get("self_probes"))
|
|
|
|
# En nu het sluitstuk: de sessieregel die nginx van diezelfde meting
|
|
# geschreven zou hebben, hoort niet in het log te komen. Een tweede
|
|
# regel even ervoor is niet van ons en moet blijven staan.
|
|
gemeten = tweede["at"]
|
|
with open(agent.STREAM_LOG, "w", encoding="utf-8") as f:
|
|
for t in (gemeten - 60, gemeten):
|
|
f.write("%s 500 0 0 0.010\n"
|
|
% time.strftime("%Y-%m-%dT%H:%M:%S+00:00", time.gmtime(t)))
|
|
|
|
log = ronde()["log"]
|
|
momenten = [e["t"] for e in log if e.get("event") == "probe"]
|
|
u.check("de eigen sessieregel staat niet in het log",
|
|
gemeten not in momenten, "regels op: %r" % momenten)
|
|
u.check("en de scan van iemand anders wel",
|
|
(gemeten - 60) in momenten, "regels op: %r" % momenten)
|
|
finally:
|
|
server.close()
|
|
|
|
|
|
def test_eerste_ronde_en_snelle_herhaling(agent, u):
|
|
"""De startrace, en de rem die een mislukking vasthield.
|
|
|
|
AANLEIDING (gemeld door de gebruiker, 31-08-2026): na elke herstart of update
|
|
las de statuskaart vijf minuten lang "Not answering" met
|
|
`[Errno 111] Connection refused`, terwijl de gateway van buiten gewoon werkte.
|
|
|
|
De oorzaak was een startvolgorde en geen storing. De compose laat `server` ná
|
|
de agent starten (`depends_on: [agent]`), en `build_status` draait meteen bij
|
|
het opstarten. De allereerste meting valt dus gegarandeerd in het gat waarin
|
|
nginx nog niet luistert. Dat was op zichzelf te verdragen; het probleem was dat
|
|
de volgende meting pas na SELF_CHECK_INTERVAL kwam.
|
|
|
|
Twee dingen worden hier vastgelegd, en ze repareren allebei een helft:
|
|
|
|
1. de eerste ronde van een proces meet niet, ook niet als het certificaat
|
|
ongewijzigd is (dus met `reloaded` op False, en dát is het geval dat de
|
|
bestaande toets hierboven NIET raakt: daar is de eerste ronde `pending`
|
|
omdat het certificaat net gekozen is);
|
|
2. na een mislukking wordt er na SELF_CHECK_RETRY opnieuw gemeten en niet pas
|
|
na SELF_CHECK_INTERVAL.
|
|
"""
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
bron = os.path.join(tmp, "certs")
|
|
os.makedirs(bron)
|
|
paar = openssl_paar(bron, "gate", "gate.example.org")
|
|
if not paar:
|
|
print("OVERGESLAGEN: geen openssl, dus de startrace is niet getoetst")
|
|
return
|
|
cert, key = paar
|
|
|
|
agent.SOURCES = [("Own folder", bron)]
|
|
agent.ELECTRUM_HOST = ""
|
|
agent.STATE_DIR = tmp
|
|
agent.CONFIG_DIR = os.path.join(tmp, "config")
|
|
agent.SELECTED_FILE = os.path.join(agent.CONFIG_DIR, "selected-cert")
|
|
agent.STATUS_FILE = os.path.join(tmp, "status.json")
|
|
agent.CERT_CONF = os.path.join(tmp, "cert.conf")
|
|
agent.RELOAD_FLAG = os.path.join(tmp, "reload")
|
|
agent.STREAM_LOG = os.path.join(tmp, "stream.log")
|
|
agent.SESSIONS_FILE = os.path.join(tmp, "sessions")
|
|
agent.SELF_CHECK_INTERVAL = 300
|
|
agent.SELF_CHECK_RETRY = 60
|
|
|
|
server = TlsServer(cert, key)
|
|
try:
|
|
richt(agent, server.port)
|
|
|
|
def ronde():
|
|
status = agent.build_status([])
|
|
agent.write_status(status)
|
|
return status
|
|
|
|
ronde() # certificaat gekozen: pending via reloaded
|
|
tweede = ronde()["tls"]["self_check"]
|
|
u.check("de meting lukt zodra het certificaat staat",
|
|
tweede.get("state") == "ok", "gaf %r" % tweede)
|
|
|
|
# Nu doen alsof het proces opnieuw begint, met een ONGEWIJZIGD
|
|
# certificaat. Vóór de reparatie mat hij hier gewoon door.
|
|
agent.EERSTE_RONDE = True
|
|
herstart = ronde()["tls"]["self_check"]
|
|
u.check("de eerste ronde na een herstart meet niet",
|
|
herstart.get("state") == "pending", "gaf %r" % herstart)
|
|
u.check("en zonder `at`, zodat de volgende ronde wél meet",
|
|
"at" not in herstart, "gaf %r" % herstart)
|
|
|
|
derde = ronde()["tls"]["self_check"]
|
|
u.check("de ronde daarna meet wel",
|
|
derde.get("state") == "ok", "gaf %r" % derde)
|
|
|
|
# De tweede helft: een mislukking mag niet vijf minuten blijven staan.
|
|
# De opgeslagen meting met de hand op 'failed' zetten, 90 seconden oud:
|
|
# dat is meer dan SELF_CHECK_RETRY en minder dan SELF_CHECK_INTERVAL.
|
|
def zet_vorige(state, ouderdom):
|
|
with open(agent.STATUS_FILE, "r", encoding="utf-8") as f:
|
|
status = json.load(f)
|
|
status["tls"]["self_check"] = {
|
|
"at": int(time.time()) - ouderdom, "state": state,
|
|
}
|
|
with open(agent.STATUS_FILE, "w", encoding="utf-8") as f:
|
|
json.dump(status, f)
|
|
|
|
zet_vorige("failed", 90)
|
|
na_fout = ronde()["tls"]["self_check"]
|
|
u.check("na een mislukking wordt er binnen een minuut opnieuw gemeten",
|
|
na_fout.get("state") == "ok", "gaf %r" % na_fout)
|
|
|
|
# En de tegenproef, want anders toetst het bovenstaande alleen dat er
|
|
# ooit gemeten wordt: een GESLAAGDE meting van dezelfde ouderdom hoort
|
|
# juist te blijven staan.
|
|
zet_vorige("ok", 90)
|
|
na_goed = ronde()["tls"]["self_check"]
|
|
u.check("een geslaagde meting van 90 seconden oud blijft staan",
|
|
na_goed.get("at") == int(time.time()) - 90,
|
|
"gaf %r" % na_goed)
|
|
finally:
|
|
server.close()
|
|
|
|
|
|
def main():
|
|
u = Uitslag()
|
|
agent = load_agent()
|
|
test_zonder_certificaat_wordt_er_niet_verbonden(agent, u)
|
|
test_niemand_luistert(agent, u)
|
|
test_luisteren_is_niet_genoeg(agent, u)
|
|
test_gelukkig_pad_en_verkeerd_certificaat(agent, u)
|
|
test_eigen_sessies_uit_het_log(agent, u)
|
|
# Als laatste, want deze verzet de module-instellingen naar een tijdelijke map.
|
|
test_ronde_meet_niet_elke_keer(agent, u)
|
|
test_eerste_ronde_en_snelle_herhaling(agent, u)
|
|
return u.rapport()
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|