2026-08-25 16:27:57 +02:00
|
|
|
"""Electrum Gate agent: schrijft status.json en neemt de certificaatkeuze aan.
|
|
|
|
|
|
|
|
|
|
Waarom dit bestand een .template is en er toch geen accolade-variabelen in staan.
|
|
|
|
|
umbreld ververst bij een update alleen een whitelist van bestanden en *.template
|
|
|
|
|
staat daarin, dus een gewoon script naast de compose zou na een update ongewijzigd
|
|
|
|
|
blijven staan zonder dat er iets gemeld wordt. Maar umbreld vult bij elke start
|
|
|
|
|
ook elke accolade-variabele in, en die zou hier Python-code slopen. Daarom haalt
|
|
|
|
|
dit script zijn instellingen uit de omgeving, en zet de compose ze daar neer. Het
|
|
|
|
|
ingevulde agent.py is daardoor byte voor byte gelijk aan dit bestand, en dat is
|
|
|
|
|
precies wat de test in tests/ erover aanneemt.
|
|
|
|
|
|
|
|
|
|
Alleen de standaardbibliotheek. De app draait op een kant-en-klare python-image
|
|
|
|
|
en installeert bij het starten niets; dat was een van de dingen die de vorige
|
|
|
|
|
opzet fout deed.
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
import calendar
|
|
|
|
|
import json
|
|
|
|
|
import os
|
|
|
|
|
import re
|
|
|
|
|
import socket
|
|
|
|
|
import ssl
|
|
|
|
|
import sys
|
|
|
|
|
import threading
|
|
|
|
|
import time
|
|
|
|
|
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
|
|
|
|
|
|
|
|
# --- Instellingen uit de omgeving -------------------------------------------
|
|
|
|
|
|
|
|
|
|
STATE_DIR = os.environ.get("GATE_STATE_DIR", "/var/lib/gate")
|
|
|
|
|
CONFIG_DIR = os.path.join(STATE_DIR, "config")
|
|
|
|
|
STATUS_FILE = os.path.join(STATE_DIR, "status.json")
|
|
|
|
|
SELECTED_FILE = os.path.join(CONFIG_DIR, "selected-cert")
|
|
|
|
|
CERT_CONF = os.path.join(STATE_DIR, "cert.conf")
|
|
|
|
|
RELOAD_FLAG = os.path.join(STATE_DIR, "reload")
|
|
|
|
|
STREAM_LOG = os.path.join(STATE_DIR, "stream.log")
|
|
|
|
|
SESSIONS_FILE = os.path.join(STATE_DIR, "sessions")
|
|
|
|
|
|
|
|
|
|
# De map waarin een upload terechtkomt. Expliciet uit de omgeving en niet
|
|
|
|
|
# afgeleid uit GATE_CERT_SOURCES: van die bronnen is er precies één beschrijfbaar,
|
|
|
|
|
# en welke dat is hoort in de compose te staan naast de mount die het mogelijk
|
|
|
|
|
# maakt. Leeg betekent: uploaden staat uit.
|
|
|
|
|
UPLOAD_DIR = os.environ.get("GATE_UPLOAD_DIR", "")
|
|
|
|
|
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"))
|
2026-08-27 15:24:24 +02:00
|
|
|
|
|
|
|
|
# De zelfcontrole: de agent verbindt met de eigen TLS-poort. Zie het plan
|
|
|
|
|
# Webinterface, OPEN.md punt 5.
|
|
|
|
|
#
|
2026-08-27 16:04:13 +02:00
|
|
|
# GATE_TLS_HOST is de nginx-container hiernaast. Niet localhost: dat is een andere
|
|
|
|
|
# container en dus een andere netwerk-namespace.
|
|
|
|
|
#
|
|
|
|
|
# De compose zet hier de volledige containernaam neer en niet de servicenaam. Die
|
|
|
|
|
# stond er eerst, en op het apparaat leverde dat `[Errno 111] Connection refused`
|
|
|
|
|
# op: de naam loste op, maar naar een andere container. Zie de toelichting in
|
|
|
|
|
# docker-compose.yml. De standaard hieronder is alleen de terugval voor een
|
|
|
|
|
# opstelling zonder die variabele.
|
2026-08-27 15:24:24 +02:00
|
|
|
#
|
|
|
|
|
# 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"))
|
2026-08-31 12:14:37 +02:00
|
|
|
|
|
|
|
|
# Na een MISLUKTE meting sneller opnieuw kijken dan na een geslaagde. Toegevoegd
|
|
|
|
|
# op 31-08-2026, samen met de overgeslagen eerste ronde hieronder; de aanleiding
|
|
|
|
|
# staat daar.
|
|
|
|
|
#
|
|
|
|
|
# De 300 seconden hierboven bestaan om het activiteitenlog niet vol te schrijven
|
|
|
|
|
# met onze eigen sessies, en dat argument weegt hier minder zwaar: een mislukking
|
|
|
|
|
# is juist de meting die je wilt herhalen, en een storing die een uur duurt is
|
|
|
|
|
# zestig extra regels waard. Een geslaagde meting blijft op 300, want dat is de
|
|
|
|
|
# waarde die zelden verandert.
|
|
|
|
|
SELF_CHECK_RETRY = int(os.environ.get("GATE_SELF_CHECK_RETRY", "60"))
|
2026-08-25 16:27:57 +02:00
|
|
|
INTERVAL = int(os.environ.get("GATE_INTERVAL", "60"))
|
|
|
|
|
API_PORT = int(os.environ.get("GATE_API_PORT", "8000"))
|
|
|
|
|
HISTORY_SECONDS = 24 * 3600
|
|
|
|
|
LOG_SECONDS = 24 * 3600
|
|
|
|
|
|
|
|
|
|
# Bronnaam naar map. Een bron die niet gemount is, bestaat simpelweg niet en
|
|
|
|
|
# wordt overgeslagen; dat is geen fout maar de normale toestand voor een
|
|
|
|
|
# reverse proxy die de gebruiker niet draait.
|
|
|
|
|
SOURCES = []
|
|
|
|
|
for stuk in os.environ.get("GATE_CERT_SOURCES", "").split(","):
|
|
|
|
|
stuk = stuk.strip()
|
|
|
|
|
if not stuk or "=" not in stuk:
|
|
|
|
|
continue
|
|
|
|
|
naam, pad = stuk.split("=", 1)
|
|
|
|
|
SOURCES.append((naam.strip(), pad.strip()))
|
|
|
|
|
|
|
|
|
|
# Vertaaltabel van backend-adres naar naam. Bewust niet meer dan dit: staat een
|
|
|
|
|
# adres er niet in, dan toont de pagina het adres zonder naam. Zie het plan
|
|
|
|
|
# Webinterface, PLAN.md paragraaf 4c: liever minder informatief dan onwaar.
|
|
|
|
|
BACKEND_NAMES = {
|
|
|
|
|
"10.21.21.10": "Electrs",
|
|
|
|
|
"10.21.21.200": "Fulcrum",
|
|
|
|
|
"10.21.21.199": "ElectrumX",
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def log(bericht):
|
|
|
|
|
"""Naar stdout, want dat is wat docker logs laat zien."""
|
|
|
|
|
sys.stdout.write("[gate] " + bericht + "\n")
|
|
|
|
|
sys.stdout.flush()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# --- X.509 lezen ------------------------------------------------------------
|
|
|
|
|
# Een minimale DER-lezer voor de twee dingen die de app van een certificaat wil
|
|
|
|
|
# weten: tot wanneer het geldig is, en voor welke namen. De standaardbibliotheek
|
|
|
|
|
# heeft hier geen parser voor, en iets bijinstalleren is precies wat deze opzet
|
|
|
|
|
# niet doet.
|
|
|
|
|
|
|
|
|
|
TAG_SEQUENCE = 0x30
|
|
|
|
|
TAG_SET = 0x31
|
|
|
|
|
TAG_UTCTIME = 0x17
|
|
|
|
|
TAG_GENERALIZEDTIME = 0x18
|
|
|
|
|
TAG_OID = 0x06
|
|
|
|
|
TAG_OCTETSTRING = 0x04
|
|
|
|
|
TAG_DNSNAME = 0x82
|
|
|
|
|
TAG_EXTENSIONS = 0xA3
|
|
|
|
|
|
|
|
|
|
OID_SAN = bytes([0x55, 0x1D, 0x11])
|
|
|
|
|
OID_CN = bytes([0x55, 0x04, 0x03])
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _tlv(buf, pos):
|
|
|
|
|
"""Leest een DER-element en geeft (tag, inhoud, positie erna)."""
|
|
|
|
|
tag = buf[pos]
|
|
|
|
|
first = buf[pos + 1]
|
|
|
|
|
if first < 0x80:
|
|
|
|
|
length = first
|
|
|
|
|
start = pos + 2
|
|
|
|
|
else:
|
|
|
|
|
count = first & 0x7F
|
|
|
|
|
if count == 0 or count > 4:
|
|
|
|
|
raise ValueError("onbruikbaar lengteveld")
|
|
|
|
|
length = int.from_bytes(buf[pos + 2:pos + 2 + count], "big")
|
|
|
|
|
start = pos + 2 + count
|
|
|
|
|
end = start + length
|
|
|
|
|
if end > len(buf):
|
|
|
|
|
raise ValueError("element loopt buiten de buffer")
|
|
|
|
|
return tag, buf[start:end], end
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _children(buf):
|
|
|
|
|
"""Alle directe elementen in de inhoud van een samengesteld element."""
|
|
|
|
|
out = []
|
|
|
|
|
pos = 0
|
|
|
|
|
while pos < len(buf):
|
|
|
|
|
tag, body, pos = _tlv(buf, pos)
|
|
|
|
|
out.append((tag, body))
|
|
|
|
|
return out
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _time_to_unix(tag, body):
|
|
|
|
|
"""UTCTime of GeneralizedTime naar unix.
|
|
|
|
|
|
|
|
|
|
Alleen de Zulu-vorm, want dat schrijft DER voor. Een tijd met een
|
|
|
|
|
tijdzone-offset hoort hier hard te falen in plaats van er stil naast te
|
|
|
|
|
zitten: een certificaatdatum die een uur verkeerd is, valt nooit op.
|
|
|
|
|
"""
|
|
|
|
|
text = body.decode("ascii")
|
|
|
|
|
if not text.endswith("Z"):
|
|
|
|
|
raise ValueError("tijd zonder Z: " + text)
|
|
|
|
|
text = text[:-1]
|
|
|
|
|
if tag == TAG_UTCTIME:
|
|
|
|
|
if len(text) != 12:
|
|
|
|
|
raise ValueError("UTCTime met onverwachte lengte: " + text)
|
|
|
|
|
year = int(text[0:2])
|
|
|
|
|
# De eeuwregel uit RFC 5280: 50 en hoger hoort bij de vorige eeuw.
|
|
|
|
|
year += 1900 if year >= 50 else 2000
|
|
|
|
|
rest = text[2:]
|
|
|
|
|
elif tag == TAG_GENERALIZEDTIME:
|
|
|
|
|
if len(text) != 14:
|
|
|
|
|
raise ValueError("GeneralizedTime met onverwachte lengte: " + text)
|
|
|
|
|
year = int(text[0:4])
|
|
|
|
|
rest = text[4:]
|
|
|
|
|
else:
|
|
|
|
|
raise ValueError("geen tijd-tag: " + hex(tag))
|
|
|
|
|
return calendar.timegm((
|
|
|
|
|
year, int(rest[0:2]), int(rest[2:4]),
|
|
|
|
|
int(rest[4:6]), int(rest[6:8]), int(rest[8:10]), 0, 0, 0,
|
|
|
|
|
))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _tbs(der):
|
|
|
|
|
"""De tbsCertificate-inhoud uit een DER-certificaat."""
|
|
|
|
|
tag, body, _ = _tlv(der, 0)
|
|
|
|
|
if tag != TAG_SEQUENCE:
|
|
|
|
|
raise ValueError("buitenste element is geen SEQUENCE")
|
|
|
|
|
tag, tbs, _ = _tlv(body, 0)
|
|
|
|
|
if tag != TAG_SEQUENCE:
|
|
|
|
|
raise ValueError("tbsCertificate is geen SEQUENCE")
|
|
|
|
|
return tbs
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def not_after(der):
|
|
|
|
|
"""Einddatum als unix-tijdstip.
|
|
|
|
|
|
|
|
|
|
Zoekt het eerste SEQUENCE in tbsCertificate dat uit precies twee
|
|
|
|
|
tijd-elementen bestaat; dat is de validity en die vorm komt nergens anders
|
|
|
|
|
voor. Positioneel tellen zou ook kunnen, maar dan zit een certificaat met een
|
|
|
|
|
onverwacht optioneel veld er stil naast.
|
|
|
|
|
"""
|
|
|
|
|
for tag, body in _children(_tbs(der)):
|
|
|
|
|
if tag != TAG_SEQUENCE:
|
|
|
|
|
continue
|
|
|
|
|
try:
|
|
|
|
|
inner = _children(body)
|
|
|
|
|
except ValueError:
|
|
|
|
|
continue
|
|
|
|
|
if len(inner) != 2:
|
|
|
|
|
continue
|
|
|
|
|
if inner[0][0] not in (TAG_UTCTIME, TAG_GENERALIZEDTIME):
|
|
|
|
|
continue
|
|
|
|
|
if inner[1][0] not in (TAG_UTCTIME, TAG_GENERALIZEDTIME):
|
|
|
|
|
continue
|
|
|
|
|
return _time_to_unix(inner[1][0], inner[1][1])
|
|
|
|
|
raise ValueError("geen validity gevonden")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _extensions(tbs):
|
|
|
|
|
"""De Extension-elementen, of een lege lijst."""
|
|
|
|
|
for tag, body in _children(tbs):
|
|
|
|
|
if tag != TAG_EXTENSIONS:
|
|
|
|
|
continue
|
|
|
|
|
outer = _children(body)
|
|
|
|
|
if not outer or outer[0][0] != TAG_SEQUENCE:
|
|
|
|
|
continue
|
|
|
|
|
return _children(outer[0][1])
|
|
|
|
|
return []
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def dns_names(der):
|
|
|
|
|
"""Alle dNSName-waarden uit subjectAltName."""
|
|
|
|
|
names = []
|
|
|
|
|
for tag, ext in _extensions(_tbs(der)):
|
|
|
|
|
if tag != TAG_SEQUENCE:
|
|
|
|
|
continue
|
|
|
|
|
parts = _children(ext)
|
|
|
|
|
if not parts or parts[0][0] != TAG_OID or parts[0][1] != OID_SAN:
|
|
|
|
|
continue
|
|
|
|
|
payload = None
|
|
|
|
|
for ptag, pbody in parts[1:]:
|
|
|
|
|
if ptag == TAG_OCTETSTRING:
|
|
|
|
|
payload = pbody
|
|
|
|
|
if payload is None:
|
|
|
|
|
continue
|
|
|
|
|
gtag, gbody, _ = _tlv(payload, 0)
|
|
|
|
|
if gtag != TAG_SEQUENCE:
|
|
|
|
|
continue
|
|
|
|
|
for ntag, nbody in _children(gbody):
|
|
|
|
|
if ntag == TAG_DNSNAME:
|
|
|
|
|
names.append(nbody.decode("ascii", "replace"))
|
|
|
|
|
return names
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def common_name(der):
|
|
|
|
|
"""De commonName uit het subject, of None.
|
|
|
|
|
|
|
|
|
|
Het subject is de tweede Name in tbsCertificate; de eerste is de issuer. Een
|
|
|
|
|
Name is te herkennen aan een SEQUENCE die alleen SET-elementen bevat, en dat
|
|
|
|
|
onderscheidt hem van de validity en van de sleutelinformatie.
|
|
|
|
|
"""
|
|
|
|
|
names = []
|
|
|
|
|
for tag, body in _children(_tbs(der)):
|
|
|
|
|
if tag != TAG_SEQUENCE:
|
|
|
|
|
continue
|
|
|
|
|
try:
|
|
|
|
|
kids = _children(body)
|
|
|
|
|
except ValueError:
|
|
|
|
|
continue
|
|
|
|
|
if kids and all(t == TAG_SET for t, _ in kids):
|
|
|
|
|
names.append(kids)
|
|
|
|
|
if len(names) < 2:
|
|
|
|
|
return None
|
|
|
|
|
for kids in names[1:]:
|
|
|
|
|
for _, setbody in kids:
|
|
|
|
|
for atag, attr in _children(setbody):
|
|
|
|
|
if atag != TAG_SEQUENCE:
|
|
|
|
|
continue
|
|
|
|
|
pair = _children(attr)
|
|
|
|
|
if len(pair) == 2 and pair[0][0] == TAG_OID and pair[0][1] == OID_CN:
|
|
|
|
|
return pair[1][1].decode("utf-8", "replace")
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def read_pem(path):
|
|
|
|
|
"""Het eerste certificaat uit een PEM-bestand, als DER."""
|
|
|
|
|
with open(path, "rb") as f:
|
|
|
|
|
return der_from_pem(f.read().decode("ascii", "replace"))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def der_from_pem(data):
|
|
|
|
|
"""Het eerste certificaat uit PEM-tekst, als DER.
|
|
|
|
|
|
|
|
|
|
Los van read_pem omdat een upload nog geen bestand is: die tekst komt uit een
|
|
|
|
|
verzoek en wordt pas weggeschreven als hij deze lezer en de sleutelcontrole
|
|
|
|
|
doorstaat.
|
|
|
|
|
"""
|
|
|
|
|
import base64
|
|
|
|
|
|
|
|
|
|
start = data.find("-----BEGIN CERTIFICATE-----")
|
|
|
|
|
if start < 0:
|
|
|
|
|
raise ValueError("geen certificaat in " + path)
|
|
|
|
|
end = data.find("-----END CERTIFICATE-----", start)
|
|
|
|
|
if end < 0:
|
|
|
|
|
raise ValueError("onafgesloten certificaat in " + path)
|
|
|
|
|
body = data[start + len("-----BEGIN CERTIFICATE-----"):end]
|
|
|
|
|
return base64.b64decode("".join(body.split()))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# --- Certificaten vinden ----------------------------------------------------
|
|
|
|
|
|
|
|
|
|
def _pairs_in(directory):
|
|
|
|
|
"""Kandidaat-paren in een map: (naam, certpad, keypad).
|
|
|
|
|
|
|
|
|
|
Drie vormen, want de bronnen benoemen verschillend. Zoraxy schrijft
|
|
|
|
|
naam.pem naast naam.key. Nginx Proxy Manager zet per certificaat een map met
|
|
|
|
|
fullchain.pem en privkey.pem, dus zonder het domein in de naam. Een eigen
|
|
|
|
|
upload mag beide.
|
|
|
|
|
"""
|
|
|
|
|
found = []
|
|
|
|
|
try:
|
|
|
|
|
entries = sorted(os.listdir(directory))
|
|
|
|
|
except OSError:
|
|
|
|
|
return found
|
|
|
|
|
|
|
|
|
|
for entry in entries:
|
|
|
|
|
full = os.path.join(directory, entry)
|
|
|
|
|
if os.path.isdir(full):
|
|
|
|
|
cert = os.path.join(full, "fullchain.pem")
|
|
|
|
|
key = os.path.join(full, "privkey.pem")
|
|
|
|
|
if os.path.isfile(cert) and os.path.isfile(key):
|
|
|
|
|
found.append((entry, cert, key))
|
|
|
|
|
continue
|
|
|
|
|
stem, ext = os.path.splitext(entry)
|
|
|
|
|
if ext not in (".pem", ".crt"):
|
|
|
|
|
continue
|
|
|
|
|
if stem in ("fullchain", "chain", "cert"):
|
|
|
|
|
continue
|
|
|
|
|
for keyname in (stem + ".key", stem + ".pem.key"):
|
|
|
|
|
key = os.path.join(directory, keyname)
|
|
|
|
|
if os.path.isfile(key):
|
|
|
|
|
found.append((stem, full, key))
|
|
|
|
|
break
|
|
|
|
|
return found
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def scan_certificates():
|
|
|
|
|
"""Alles wat in de gemounte bronnen staat, met datum en domein."""
|
|
|
|
|
out = []
|
|
|
|
|
for source, directory in SOURCES:
|
|
|
|
|
for name, cert, key in _pairs_in(directory):
|
|
|
|
|
item = {
|
|
|
|
|
"id": source + "/" + name,
|
|
|
|
|
"source": source,
|
|
|
|
|
"name": name,
|
|
|
|
|
"cert": cert,
|
|
|
|
|
"key": key,
|
|
|
|
|
}
|
|
|
|
|
try:
|
|
|
|
|
der = read_pem(cert)
|
|
|
|
|
item["not_after"] = not_after(der)
|
|
|
|
|
names = dns_names(der)
|
|
|
|
|
item["domain"] = names[0] if names else common_name(der)
|
|
|
|
|
item["domains"] = names
|
|
|
|
|
except Exception as e: # noqa: BLE001 - een onleesbaar certificaat
|
|
|
|
|
# mag de rest van de lijst niet meenemen; hij wordt wel getoond,
|
|
|
|
|
# want hem verzwijgen maakt een verkeerde keuze onvindbaar.
|
|
|
|
|
item["unreadable"] = str(e)
|
|
|
|
|
item["domain"] = name
|
|
|
|
|
out.append(item)
|
|
|
|
|
return out
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# --- Een certificaat aannemen -----------------------------------------------
|
|
|
|
|
# De pagina kan een certificaat en een sleutel opsturen. Dit is het enige pad
|
|
|
|
|
# waarlangs iets van buiten bestanden neerzet, dus hier staat de volledige
|
|
|
|
|
# controle, en die weigert liever dan dat hij plaatst.
|
|
|
|
|
#
|
|
|
|
|
# Waarom dit er is: /certs/own is zonder SSH niet te bereiken, dus die bron was
|
|
|
|
|
# alleen bruikbaar voor wie de shell op durft. Dat is de helft van de reden dat
|
|
|
|
|
# hij bestaat.
|
|
|
|
|
|
|
|
|
|
NAME_OK = re.compile(r"[^A-Za-z0-9._-]")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _file_name(domain):
|
|
|
|
|
"""Een bestandsnaam uit de hostnaam van het certificaat zelf.
|
|
|
|
|
|
|
|
|
|
Niet uit iets wat de gebruiker meestuurt, en dat is de hele reden: een naam
|
|
|
|
|
die van buiten komt moet je tegen padtrucs verdedigen, een naam die je uit het
|
|
|
|
|
certificaat leest niet. Wat er niet in het alfabet zit gaat eruit, dus een
|
|
|
|
|
jokerteken-certificaat voor *.example.org wordt example.org.
|
|
|
|
|
"""
|
|
|
|
|
naam = NAME_OK.sub("", domain or "").strip(".-")
|
|
|
|
|
return naam[:64]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def accept_upload(cert_text, key_text):
|
|
|
|
|
"""Controleert een opgestuurd paar en zet het weg. Geeft (id, fout).
|
|
|
|
|
|
|
|
|
|
De volgorde is die van de foutmeldingen: eerst of het überhaupt een
|
|
|
|
|
certificaat is, dan of er een naam in staat, dan of het nog geldig is, en als
|
|
|
|
|
laatste de duurste controle, namelijk of de sleutel erbij hoort.
|
|
|
|
|
|
|
|
|
|
Die laatste is de belangrijkste en gaat via ssl.SSLContext.load_cert_chain.
|
|
|
|
|
Dat is dezelfde OpenSSL die nginx straks gebruikt, dus een paar dat hier
|
|
|
|
|
doorkomt komt daar ook door. Zonder deze controle levert een verkeerde sleutel
|
|
|
|
|
een nginx die niet meer herlaadt, en dat is dezelfde klasse storing als die
|
|
|
|
|
waardoor 0.0.3 helemaal niet startte.
|
|
|
|
|
"""
|
|
|
|
|
if not UPLOAD_DIR:
|
|
|
|
|
return None, "uploading is switched off in this installation"
|
|
|
|
|
|
|
|
|
|
cert_text = (cert_text or "").strip()
|
|
|
|
|
key_text = (key_text or "").strip()
|
|
|
|
|
if "-----BEGIN CERTIFICATE-----" not in cert_text:
|
|
|
|
|
return None, "that file does not contain a certificate"
|
|
|
|
|
if "-----BEGIN" not in key_text or "PRIVATE KEY-----" not in key_text:
|
|
|
|
|
return None, "that file does not contain a private key"
|
|
|
|
|
# Een versleutelde sleutel kan nginx niet openen zonder wachtwoord, en dat
|
|
|
|
|
# wachtwoord heeft deze app niet en wil hij ook niet hebben. Twee vormen: de
|
|
|
|
|
# PKCS#8-kop en de oudere PEM-kop met Proc-Type.
|
|
|
|
|
if "ENCRYPTED PRIVATE KEY" in key_text or "Proc-Type: 4,ENCRYPTED" in key_text:
|
|
|
|
|
return None, ("that private key is encrypted; nginx cannot open it "
|
|
|
|
|
"without a passphrase")
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
der = der_from_pem(cert_text)
|
|
|
|
|
except Exception as e: # noqa: BLE001 - alles hier betekent hetzelfde
|
|
|
|
|
return None, "that certificate cannot be read (%s)" % e
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
names = dns_names(der)
|
|
|
|
|
domain = names[0] if names else common_name(der)
|
|
|
|
|
except Exception: # noqa: BLE001
|
|
|
|
|
domain = None
|
|
|
|
|
naam = _file_name(domain)
|
|
|
|
|
if not naam:
|
|
|
|
|
return None, ("that certificate has no host name in it, so there is "
|
|
|
|
|
"nothing to match a wallet against")
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
einde = not_after(der)
|
|
|
|
|
except Exception as e: # noqa: BLE001
|
|
|
|
|
return None, "that certificate has no readable end date (%s)" % e
|
|
|
|
|
if einde <= int(time.time()):
|
|
|
|
|
return None, "that certificate expired on " + time.strftime(
|
|
|
|
|
"%d-%m-%Y", time.gmtime(einde))
|
|
|
|
|
|
|
|
|
|
# Pas nu naar schijf, en eerst onder een tijdelijke naam. Die naam staat in
|
|
|
|
|
# dezelfde map als de eindbestemming en niet in /tmp, want os.replace over een
|
|
|
|
|
# bestandssysteemgrens faalt met EXDEV, en /certs/own is een bind-mount. Op
|
|
|
|
|
# ".tmp" ziet de scanner ze niet: die kijkt naar .pem en .crt.
|
|
|
|
|
os.makedirs(UPLOAD_DIR, exist_ok=True)
|
|
|
|
|
doel_cert = os.path.join(UPLOAD_DIR, naam + ".pem")
|
|
|
|
|
doel_key = os.path.join(UPLOAD_DIR, naam + ".key")
|
|
|
|
|
proef_cert = doel_cert + ".tmp"
|
|
|
|
|
proef_key = doel_key + ".tmp"
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
with open(proef_cert, "w", encoding="ascii") as f:
|
|
|
|
|
f.write(cert_text + "\n")
|
|
|
|
|
with open(proef_key, "w", encoding="ascii") as f:
|
|
|
|
|
f.write(key_text + "\n")
|
|
|
|
|
os.chmod(proef_key, 0o600)
|
|
|
|
|
try:
|
|
|
|
|
ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
|
|
|
|
|
# Een wachtwoord meegeven, ook al hoort er geen bij: zonder dit gaat
|
|
|
|
|
# OpenSSL er zelf om vragen, en in een container zonder terminal is
|
|
|
|
|
# dat een fout die nergens uitkomt.
|
|
|
|
|
ctx.load_cert_chain(proef_cert, proef_key, password=lambda: "")
|
|
|
|
|
except Exception as e: # noqa: BLE001 - de reden is voor de pagina, en
|
|
|
|
|
# die is bijna altijd "de sleutel hoort niet bij dit certificaat".
|
|
|
|
|
return None, ("that key does not go with that certificate, or one of "
|
|
|
|
|
"the two is damaged (%s)" % _short(e))
|
|
|
|
|
|
|
|
|
|
vervangt = os.path.exists(doel_cert)
|
|
|
|
|
os.replace(proef_cert, doel_cert)
|
|
|
|
|
os.replace(proef_key, doel_key)
|
|
|
|
|
finally:
|
|
|
|
|
# Wat er na een weigering nog ligt, gaat weg. Anders blijft er bij elke
|
|
|
|
|
# mislukte poging een half paar in de map staan.
|
|
|
|
|
for pad in (proef_cert, proef_key):
|
|
|
|
|
try:
|
|
|
|
|
os.unlink(pad)
|
|
|
|
|
except OSError:
|
|
|
|
|
pass
|
|
|
|
|
|
|
|
|
|
os.chmod(doel_cert, 0o644)
|
|
|
|
|
# De sleutel is het enige geheim dat deze app aanraakt. nginx leest hem als
|
|
|
|
|
# root bij het laden van de configuratie, dus 0600 is genoeg.
|
|
|
|
|
os.chmod(doel_key, 0o600)
|
|
|
|
|
|
|
|
|
|
log("upload accepted: %s (%s)" % (naam, "replaced" if vervangt else "new"))
|
|
|
|
|
return naam, None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def upload_id(naam):
|
|
|
|
|
"""De id waaronder een upload in de lijst komt, of None.
|
|
|
|
|
|
|
|
|
|
De bronnaam komt uit GATE_CERT_SOURCES en niet uit een letterlijke tekst
|
|
|
|
|
hier: staat "Own folder" morgen anders in de compose, dan zou een vaste tekst
|
|
|
|
|
een id opleveren die nergens naar wijst, en dat is precies het soort fout dat
|
|
|
|
|
pas bij het kiezen opvalt.
|
|
|
|
|
"""
|
|
|
|
|
for naam_bron, pad in SOURCES:
|
|
|
|
|
if pad == UPLOAD_DIR:
|
|
|
|
|
return naam_bron + "/" + naam
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _short(e):
|
|
|
|
|
"""De laatste, leesbare regel uit een OpenSSL-fout."""
|
|
|
|
|
tekst = str(e)
|
|
|
|
|
if len(tekst) > 200:
|
|
|
|
|
tekst = tekst[:200] + "..."
|
|
|
|
|
return tekst
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def read_selection():
|
|
|
|
|
"""De gekozen id uit het configuratiebestand, of None."""
|
|
|
|
|
try:
|
|
|
|
|
with open(SELECTED_FILE, "r", encoding="utf-8") as f:
|
|
|
|
|
return f.read().strip() or None
|
|
|
|
|
except OSError:
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def write_selection(value):
|
|
|
|
|
os.makedirs(CONFIG_DIR, exist_ok=True)
|
|
|
|
|
tmp = SELECTED_FILE + ".tmp"
|
|
|
|
|
with open(tmp, "w", encoding="utf-8") as f:
|
|
|
|
|
f.write(value + "\n")
|
|
|
|
|
os.replace(tmp, SELECTED_FILE)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def choose(certs):
|
|
|
|
|
"""Welk certificaat gebruikt wordt, en waarom.
|
|
|
|
|
|
|
|
|
|
De keuze van de gebruiker wint, maar alleen als hij naar iets bestaands
|
|
|
|
|
wijst. Een onbekende id wordt genegeerd in plaats van gehonoreerd: anders
|
|
|
|
|
zou een typefout of een verwijderd certificaat de app laten starten met een
|
|
|
|
|
pad dat niet bestaat, en dan is er geen TLS meer.
|
|
|
|
|
"""
|
|
|
|
|
if not certs:
|
|
|
|
|
return None, "no certificates found in any source"
|
|
|
|
|
|
|
|
|
|
wanted = read_selection()
|
|
|
|
|
if wanted:
|
|
|
|
|
for c in certs:
|
|
|
|
|
if c["id"] == wanted and not c.get("unreadable"):
|
|
|
|
|
# Geen reden meesturen, en dat is de reden: er stond "your
|
|
|
|
|
# choice", en de gebruiker merkte op dat het altijd zijn keuze is
|
|
|
|
|
# (20-08-2026). Dit veld is er om een toestand uit te leggen die
|
|
|
|
|
# niet vanzelf spreekt, zoals een app die niets koos of er zelf
|
|
|
|
|
# een pakte. Een keuze die je zelf gemaakt hebt, spreekt vanzelf.
|
|
|
|
|
return c, None
|
|
|
|
|
log("selected certificate %r is missing or unreadable, "
|
|
|
|
|
"ignoring the selection" % wanted)
|
|
|
|
|
|
|
|
|
|
usable = [c for c in certs if not c.get("unreadable") and c.get("not_after")]
|
|
|
|
|
if not usable:
|
|
|
|
|
return None, "no readable certificate found"
|
|
|
|
|
|
|
|
|
|
now = int(time.time())
|
|
|
|
|
valid = [c for c in usable if c["not_after"] > now]
|
|
|
|
|
if not valid:
|
|
|
|
|
return None, "every certificate found has expired"
|
|
|
|
|
|
|
|
|
|
if len(valid) == 1:
|
|
|
|
|
return valid[0], "the only certificate found"
|
|
|
|
|
|
|
|
|
|
# Bewust géén gok bij meerdere kandidaten, ook niet "de nieuwste". Zo staat
|
|
|
|
|
# het besloten in het plan Configuratie, paragraaf 4c, en de reden is dat een
|
|
|
|
|
# verkeerd certificaat een verbinding oplevert die het lijkt te doen en bij de
|
|
|
|
|
# wallet stukloopt op naamverificatie. Dat is veel lastiger te vinden dan een
|
|
|
|
|
# app die weigert en zegt waarom. De pagina toont de kandidaten, dus kiezen
|
|
|
|
|
# kost één klik.
|
|
|
|
|
#
|
|
|
|
|
# Alleen het aantal, niet de namen. Hier stond eerst de hele opsomming, en op
|
|
|
|
|
# de Umbrel van de gebruiker leverde dat op 20-08-2026 veertien domeinnamen in
|
|
|
|
|
# één foutmelding op: een muur tekst die zegt wat de lijst eronder al toont, en
|
|
|
|
|
# die lijst is waar je klikt. Het aantal blijft er wel in, want dat verklaart
|
|
|
|
|
# waarom de app niets koos.
|
|
|
|
|
return None, ("%d certificates found, so it is not clear which one this app "
|
|
|
|
|
"should use; pick one below" % len(valid))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def apply_certificate(cert):
|
|
|
|
|
"""Schrijft het nginx-fragment en vraagt om een herlading als er iets wijzigt.
|
|
|
|
|
|
|
|
|
|
nginx herladen kan niet vanuit deze container zonder de Docker-socket, en die
|
|
|
|
|
is er bewust uit. Daarom zet deze functie een vlagbestand neer, en herlaadt de
|
|
|
|
|
nginx-container zichzelf zodra hij dat ziet.
|
|
|
|
|
"""
|
|
|
|
|
body = (
|
|
|
|
|
"# Door de agent geschreven; niet met de hand bewerken.\n"
|
|
|
|
|
"ssl_certificate %s;\n"
|
|
|
|
|
"ssl_certificate_key %s;\n"
|
|
|
|
|
) % (cert["cert"], cert["key"])
|
|
|
|
|
|
|
|
|
|
old = None
|
|
|
|
|
try:
|
|
|
|
|
with open(CERT_CONF, "r", encoding="utf-8") as f:
|
|
|
|
|
old = f.read()
|
|
|
|
|
except OSError:
|
|
|
|
|
pass
|
|
|
|
|
|
|
|
|
|
if old == body:
|
|
|
|
|
return False
|
|
|
|
|
|
|
|
|
|
tmp = CERT_CONF + ".tmp"
|
|
|
|
|
with open(tmp, "w", encoding="utf-8") as f:
|
|
|
|
|
f.write(body)
|
|
|
|
|
os.replace(tmp, CERT_CONF)
|
|
|
|
|
with open(RELOAD_FLAG, "w", encoding="utf-8") as f:
|
|
|
|
|
f.write(str(int(time.time())) + "\n")
|
|
|
|
|
log("certificate changed to %s, reload requested" % cert["id"])
|
|
|
|
|
return True
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# --- De backend bevragen ----------------------------------------------------
|
|
|
|
|
|
|
|
|
|
def probe_backend():
|
2026-09-04 09:42:55 +02:00
|
|
|
"""Reactietijd van de Electrum-server, en of hij antwoordt.
|
2026-08-25 16:27:57 +02:00
|
|
|
|
|
|
|
|
Eén verzoek over een verse verbinding. Het Electrum-protocol is
|
|
|
|
|
JSON-RPC met een regeleinde eraan, dus dit is een socket en niets meer.
|
2026-09-04 09:42:55 +02:00
|
|
|
|
|
|
|
|
De methode is nog steeds headers.subscribe: het is het goedkoopste
|
|
|
|
|
verzoek waar een geldig antwoord op komt. De blokhoogte die erin staat
|
|
|
|
|
wordt sinds 0.0.28 niet meer overgenomen, want het is niet aan deze app
|
|
|
|
|
om die te tonen. Wat we van het antwoord gebruiken is dat het er is en
|
|
|
|
|
dat het te ontleden valt.
|
2026-08-25 16:27:57 +02:00
|
|
|
"""
|
|
|
|
|
result = {"address": ELECTRUM_HOST + ":" + str(ELECTRUM_PORT), "reachable": False}
|
|
|
|
|
result["name"] = BACKEND_NAMES.get(ELECTRUM_HOST)
|
|
|
|
|
if not ELECTRUM_HOST:
|
|
|
|
|
result["error"] = "geen backend-adres in de omgeving"
|
|
|
|
|
return result
|
|
|
|
|
|
|
|
|
|
payload = json.dumps({
|
|
|
|
|
"id": 0,
|
|
|
|
|
"method": "blockchain.headers.subscribe",
|
|
|
|
|
"params": [],
|
|
|
|
|
}) + "\n"
|
|
|
|
|
|
|
|
|
|
start = time.monotonic()
|
|
|
|
|
try:
|
|
|
|
|
with socket.create_connection((ELECTRUM_HOST, ELECTRUM_PORT), timeout=10) as s:
|
|
|
|
|
s.sendall(payload.encode("ascii"))
|
|
|
|
|
s.settimeout(10)
|
|
|
|
|
chunks = []
|
|
|
|
|
while True:
|
|
|
|
|
chunk = s.recv(4096)
|
|
|
|
|
if not chunk:
|
|
|
|
|
break
|
|
|
|
|
chunks.append(chunk)
|
|
|
|
|
if b"\n" in chunk:
|
|
|
|
|
break
|
|
|
|
|
elapsed = (time.monotonic() - start) * 1000.0
|
|
|
|
|
line = b"".join(chunks).split(b"\n", 1)[0]
|
2026-09-04 09:42:55 +02:00
|
|
|
json.loads(line.decode("utf-8"))
|
2026-08-25 16:27:57 +02:00
|
|
|
result["reachable"] = True
|
|
|
|
|
result["response_ms"] = int(round(elapsed))
|
|
|
|
|
except Exception as e: # noqa: BLE001 - elke fout betekent hetzelfde voor
|
|
|
|
|
# de pagina: de backend antwoordde niet.
|
|
|
|
|
result["error"] = str(e)
|
|
|
|
|
return result
|
|
|
|
|
|
|
|
|
|
|
2026-08-27 15:24:24 +02:00
|
|
|
# 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",
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
2026-08-31 12:14:37 +02:00
|
|
|
def self_check_wacht(previous_self):
|
|
|
|
|
"""Hoeveel seconden er tussen deze en de vorige meting moet zitten.
|
|
|
|
|
|
|
|
|
|
Alleen `ok` en `off` zijn rustige toestanden. `failed` en `wrong-certificate`
|
|
|
|
|
zijn allebei iets waar de gebruiker wat aan doet, en dan hoort de pagina
|
|
|
|
|
binnen een minuut te volgen in plaats van binnen vijf.
|
|
|
|
|
"""
|
|
|
|
|
if previous_self.get("state") in ("failed", "wrong-certificate"):
|
|
|
|
|
return SELF_CHECK_RETRY
|
|
|
|
|
return SELF_CHECK_INTERVAL
|
|
|
|
|
|
|
|
|
|
|
2026-08-27 15:24:24 +02:00
|
|
|
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
|
|
|
|
|
|
|
|
|
|
|
2026-08-25 16:27:57 +02:00
|
|
|
# --- 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.
|
|
|
|
|
# Dat is een bekende beperking en hij staat in het plan als open punt; hem hier
|
|
|
|
|
# verzinnen zou erger zijn dan hem missen.
|
|
|
|
|
|
|
|
|
|
LOG_LINE = re.compile(
|
|
|
|
|
r"^(?P<t>\S+)\s+(?P<status>\d+)\s+(?P<recv>\d+)\s+(?P<sent>\d+)\s+(?P<dur>[\d.]+)"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def read_stream_log(cutoff):
|
|
|
|
|
"""De sessieregels van nginx, omgezet naar logregels voor de pagina."""
|
|
|
|
|
out = []
|
|
|
|
|
try:
|
|
|
|
|
with open(STREAM_LOG, "r", encoding="utf-8", errors="replace") as f:
|
|
|
|
|
# Alleen het staartje; de lus draait elke minuut en het bestand
|
|
|
|
|
# groeit langzaam, maar ongelimiteerd lezen is een tijdbom.
|
|
|
|
|
lines = f.readlines()[-2000:]
|
|
|
|
|
except OSError:
|
|
|
|
|
return out
|
|
|
|
|
|
|
|
|
|
for raw in lines:
|
|
|
|
|
m = LOG_LINE.match(raw.strip())
|
|
|
|
|
if not m:
|
|
|
|
|
continue
|
|
|
|
|
try:
|
|
|
|
|
ended = int(calendar.timegm(time.strptime(
|
|
|
|
|
m.group("t")[:19], "%Y-%m-%dT%H:%M:%S")))
|
|
|
|
|
except ValueError:
|
|
|
|
|
continue
|
|
|
|
|
if ended < cutoff:
|
|
|
|
|
continue
|
|
|
|
|
duration = float(m.group("dur"))
|
|
|
|
|
status = m.group("status")
|
|
|
|
|
recv = int(m.group("recv"))
|
|
|
|
|
sent = int(m.group("sent"))
|
|
|
|
|
|
|
|
|
|
# Drie soorten, en het onderscheid tussen de laatste twee is er op verzoek
|
|
|
|
|
# van de gebruiker (20-08-2026). Een poort die in de router doorgestuurd
|
|
|
|
|
# staat wordt gescand, en elke scan leverde een regel "refused" op. Dat
|
|
|
|
|
# woord suggereert dat de app iets geweigerd heeft, terwijl er niets
|
|
|
|
|
# gebeurd is, en het log liep er vol met rood.
|
|
|
|
|
#
|
|
|
|
|
# Het onderscheid zit in de bytes en niet in de duur of de status. Deze
|
|
|
|
|
# tellers gaan over de doorgegeven verbinding en niet over de TLS-handdruk:
|
|
|
|
|
# dat is af te lezen aan de sessies van de gebruiker, waar een mislukte
|
|
|
|
|
# verbinding op 0 en 0 uitkwam terwijl er wél een handdruk geprobeerd is.
|
|
|
|
|
# Nul beide kanten op betekent dus: er is nooit iets doorgegeven.
|
|
|
|
|
#
|
|
|
|
|
# Waarom niet op de duur: een scan van tien seconden is nog steeds een
|
|
|
|
|
# scan, en een echte sessie die na een halve seconde omvalt is nog steeds
|
|
|
|
|
# een storing. De bytes zeggen wat er gebeurd is, de klok niet.
|
|
|
|
|
if status == "200":
|
|
|
|
|
event = "disconnect"
|
|
|
|
|
elif recv == 0 and sent == 0:
|
|
|
|
|
event = "probe"
|
|
|
|
|
else:
|
|
|
|
|
event = "refused"
|
|
|
|
|
|
|
|
|
|
entry = {
|
|
|
|
|
"t": ended,
|
|
|
|
|
"event": event,
|
|
|
|
|
"seconds": int(round(duration)),
|
|
|
|
|
"bytes_in": recv,
|
|
|
|
|
"bytes_out": sent,
|
|
|
|
|
}
|
|
|
|
|
if event == "probe":
|
|
|
|
|
# Feitelijk houden: dát het de handdruk was, weten we niet, alleen dat
|
|
|
|
|
# er niets doorgegeven is. Bij status 500 is de handdruk de bijna
|
|
|
|
|
# zekere oorzaak, maar bijna is hier niet genoeg.
|
|
|
|
|
entry["note"] = "nothing exchanged, status " + status
|
|
|
|
|
elif event == "refused":
|
|
|
|
|
entry["note"] = "session ended with status " + status
|
|
|
|
|
out.append(entry)
|
|
|
|
|
return out
|
|
|
|
|
|
|
|
|
|
|
2026-08-27 15:24:24 +02:00
|
|
|
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
|
|
|
|
|
|
|
|
|
|
|
2026-08-25 16:27:57 +02:00
|
|
|
def read_open_sessions():
|
|
|
|
|
"""Hoeveel wallet-verbindingen er nu openstaan, of None.
|
|
|
|
|
|
|
|
|
|
Geteld door de achtergrondlus van de nginx-container en hier alleen gelezen.
|
|
|
|
|
Die omweg via een bestand is nodig omdat /proc/net/tcp per
|
|
|
|
|
netwerk-namespace geldt: deze container ziet de sockets van nginx niet, hoe
|
|
|
|
|
graag hij ook zou willen. Zelfde patroon als de herlaadvlag, en om dezelfde
|
|
|
|
|
reden: geen Docker-socket.
|
|
|
|
|
|
|
|
|
|
None betekent "niet bekend" en niet "nul". Het verschil doet ertoe: een
|
|
|
|
|
nginx-container van vóór deze versie schrijft dit bestand niet, en dan hoort
|
|
|
|
|
de pagina niets te beweren in plaats van rustig nul te tonen.
|
|
|
|
|
"""
|
|
|
|
|
try:
|
|
|
|
|
with open(SESSIONS_FILE, "r", encoding="utf-8") as f:
|
|
|
|
|
waarde = int(f.read().strip())
|
|
|
|
|
except (OSError, ValueError):
|
|
|
|
|
return None
|
|
|
|
|
return waarde if waarde >= 0 else None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# --- De status samenstellen -------------------------------------------------
|
|
|
|
|
|
|
|
|
|
def read_previous():
|
|
|
|
|
try:
|
|
|
|
|
with open(STATUS_FILE, "r", encoding="utf-8") as f:
|
|
|
|
|
return json.load(f)
|
|
|
|
|
except (OSError, ValueError):
|
|
|
|
|
return {}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def write_status(status):
|
|
|
|
|
tmp = STATUS_FILE + ".tmp"
|
|
|
|
|
with open(tmp, "w", encoding="utf-8") as f:
|
|
|
|
|
json.dump(status, f, separators=(",", ":"))
|
|
|
|
|
os.replace(tmp, STATUS_FILE)
|
|
|
|
|
|
|
|
|
|
|
2026-08-31 12:14:37 +02:00
|
|
|
# Waar of onwaar voor de duur van dit proces; `build_status` zet hem bij de eerste
|
|
|
|
|
# aanroep om. Zie de toelichting in die functie bij de zelfcontrole.
|
|
|
|
|
EERSTE_RONDE = True
|
|
|
|
|
|
|
|
|
|
|
2026-08-25 16:27:57 +02:00
|
|
|
def build_status(events):
|
2026-08-31 12:14:37 +02:00
|
|
|
global EERSTE_RONDE
|
|
|
|
|
eerste_ronde = EERSTE_RONDE
|
|
|
|
|
EERSTE_RONDE = False
|
|
|
|
|
|
2026-08-25 16:27:57 +02:00
|
|
|
now = int(time.time())
|
|
|
|
|
previous = read_previous()
|
|
|
|
|
|
|
|
|
|
certs = scan_certificates()
|
|
|
|
|
active, why = choose(certs)
|
|
|
|
|
reloaded = apply_certificate(active) if active else False
|
|
|
|
|
if reloaded:
|
|
|
|
|
events.append({"t": now, "event": "reload", "note": "certificate changed"})
|
|
|
|
|
|
|
|
|
|
# De open verbindingen, en een logregel als er eentje bij is gekomen. Alleen
|
|
|
|
|
# bij een stijging: een gelijk aantal is geen gebeurtenis, en een daling
|
|
|
|
|
# wordt door nginx zelf gelogd zodra de sessie sluit, met duur en bytes erbij.
|
|
|
|
|
#
|
|
|
|
|
# Een onbekend vorig aantal geldt als nul, en dat is met opzet. Anders blijft
|
|
|
|
|
# het log stil over een wallet die al verbonden was voordat de agent begon, en
|
|
|
|
|
# dat is precies het geval waarin iemand op deze pagina komt kijken. Het kost
|
|
|
|
|
# één regel per herstart, en die zegt iets.
|
|
|
|
|
#
|
|
|
|
|
# Wat dit niet ziet: een wallet die binnen één ronde verbindt en weer
|
|
|
|
|
# weggaat. Daar staat de disconnect-regel van nginx tegenover, dus die sessie
|
|
|
|
|
# is niet onzichtbaar, alleen zonder connect-regel.
|
|
|
|
|
open_now = read_open_sessions()
|
|
|
|
|
was_open = (previous.get("tls") or {}).get("open_connections")
|
|
|
|
|
if not isinstance(was_open, int):
|
|
|
|
|
was_open = 0
|
|
|
|
|
if isinstance(open_now, int) and open_now > was_open:
|
|
|
|
|
events.append({
|
|
|
|
|
"t": now,
|
|
|
|
|
"event": "connect",
|
|
|
|
|
"note": "%d open now" % open_now,
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
backend = probe_backend()
|
|
|
|
|
if not backend.get("reachable"):
|
|
|
|
|
events.append({
|
|
|
|
|
"t": now,
|
|
|
|
|
"event": "refused",
|
|
|
|
|
"note": "the Electrum server did not answer",
|
|
|
|
|
})
|
|
|
|
|
|
2026-08-27 15:24:24 +02:00
|
|
|
# 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"}
|
2026-08-31 12:14:37 +02:00
|
|
|
elif eerste_ronde:
|
|
|
|
|
# DE EERSTE RONDE VAN DIT PROCES MEET NIET, en dat is geen voorzichtigheid
|
|
|
|
|
# maar een zekerheid (gemeld door de gebruiker, 31-08-2026).
|
|
|
|
|
#
|
|
|
|
|
# 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). Deze functie draait meteen bij het opstarten, vóór
|
|
|
|
|
# de eerste `WAKE.wait`. Op dat moment luistert nginx dus gegarandeerd nog
|
|
|
|
|
# niet op de TLS-poort, en de meting levert `[Errno 111] Connection
|
|
|
|
|
# refused` op.
|
|
|
|
|
#
|
|
|
|
|
# Dat op zichzelf zou een schoonheidsfoutje zijn; het probleem was dat het
|
|
|
|
|
# BLEEF STAAN. De volgende meting kwam pas na SELF_CHECK_INTERVAL, dus na
|
|
|
|
|
# elke herstart of update las de statuskaart vijf minuten lang "Not
|
|
|
|
|
# answering" terwijl er niets aan de hand was.
|
|
|
|
|
#
|
|
|
|
|
# `pending` heeft geen `at`, dus de rem hieronder laat de vólgende ronde
|
|
|
|
|
# gewoon meten: een minuut later staat er een echte uitkomst.
|
|
|
|
|
self_check = {"state": "pending"}
|
|
|
|
|
elif now - int(previous_self.get("at") or 0) >= self_check_wacht(previous_self):
|
2026-08-27 15:24:24 +02:00
|
|
|
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
|
|
|
|
|
|
2026-08-25 16:27:57 +02:00
|
|
|
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
|
2026-08-27 15:24:24 +02:00
|
|
|
entries = suppress_self_probes(read_stream_log(cutoff), probes)
|
2026-08-25 16:27:57 +02:00
|
|
|
# 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
|
2026-08-27 15:24:24 +02:00
|
|
|
and e.get("event") in ("start", "reload", "refused", "connect",
|
|
|
|
|
"selfcheck"))
|
2026-08-25 16:27:57 +02:00
|
|
|
entries.extend(events)
|
|
|
|
|
# Dubbele meldingen dat de backend niet antwoordt zijn ruis; één per ronde
|
|
|
|
|
# is genoeg en de rest zegt hetzelfde.
|
|
|
|
|
seen = set()
|
|
|
|
|
unique = []
|
|
|
|
|
for e in sorted(entries, key=lambda x: x.get("t", 0)):
|
|
|
|
|
key = (e.get("t"), e.get("event"), e.get("note"))
|
|
|
|
|
if key in seen:
|
|
|
|
|
continue
|
|
|
|
|
seen.add(key)
|
|
|
|
|
unique.append(e)
|
|
|
|
|
del events[:]
|
|
|
|
|
|
|
|
|
|
status = {
|
|
|
|
|
"generated_at": now,
|
|
|
|
|
"interval_seconds": INTERVAL,
|
|
|
|
|
# open_connections blijft weg als het niet bekend is. Zie
|
|
|
|
|
# read_open_sessions: nul beweren is hier erger dan zwijgen.
|
2026-08-27 15:24:24 +02:00
|
|
|
"tls": {"port": TLS_PORT, "self_check": self_check},
|
2026-08-25 16:27:57 +02:00
|
|
|
"backend": backend,
|
|
|
|
|
"history": history,
|
|
|
|
|
"log": unique[-200:],
|
2026-08-27 15:24:24 +02:00
|
|
|
# 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:],
|
2026-08-25 16:27:57 +02:00
|
|
|
"certificates": [
|
|
|
|
|
{
|
|
|
|
|
"id": c["id"],
|
|
|
|
|
"source": c["source"],
|
|
|
|
|
"name": c["name"],
|
|
|
|
|
"domain": c.get("domain"),
|
|
|
|
|
"not_after": c.get("not_after"),
|
|
|
|
|
"unreadable": c.get("unreadable"),
|
|
|
|
|
}
|
|
|
|
|
for c in certs
|
|
|
|
|
],
|
|
|
|
|
"certificate": {"reason": why},
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if isinstance(open_now, int):
|
|
|
|
|
status["tls"]["open_connections"] = open_now
|
|
|
|
|
|
|
|
|
|
if active:
|
|
|
|
|
status["tls"]["domain"] = active.get("domain")
|
|
|
|
|
status["certificate"] = {
|
|
|
|
|
"reason": why,
|
|
|
|
|
"source": active["source"],
|
|
|
|
|
"selected": active["id"],
|
|
|
|
|
"not_after": active.get("not_after"),
|
|
|
|
|
"last_reload": _reload_time(),
|
|
|
|
|
}
|
|
|
|
|
return status
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _reload_time():
|
|
|
|
|
"""Wanneer nginx voor het laatst om een herlading is gevraagd."""
|
|
|
|
|
for path in (RELOAD_FLAG + ".done", RELOAD_FLAG):
|
|
|
|
|
try:
|
|
|
|
|
return int(os.path.getmtime(path))
|
|
|
|
|
except OSError:
|
|
|
|
|
continue
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# --- De API -----------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
class Handler(BaseHTTPRequestHandler):
|
|
|
|
|
server_version = "ElectrumGate"
|
|
|
|
|
|
|
|
|
|
def log_message(self, fmt, *args):
|
|
|
|
|
"""Geen client-adres in de log. Zie het plan Webinterface, 4e."""
|
|
|
|
|
log("api " + (fmt % args))
|
|
|
|
|
|
|
|
|
|
def _send(self, code, body):
|
|
|
|
|
data = json.dumps(body).encode("utf-8")
|
|
|
|
|
self.send_response(code)
|
|
|
|
|
self.send_header("Content-Type", "application/json")
|
|
|
|
|
self.send_header("Content-Length", str(len(data)))
|
|
|
|
|
self.send_header("Cache-Control", "no-store")
|
|
|
|
|
self.end_headers()
|
|
|
|
|
self.wfile.write(data)
|
|
|
|
|
|
|
|
|
|
def do_PUT(self): # noqa: N802 - naam wordt door de basisklasse bepaald
|
|
|
|
|
if self.path.rstrip("/") != "/api/certificate":
|
|
|
|
|
self._send(404, {"error": "unknown path"})
|
|
|
|
|
return
|
|
|
|
|
|
|
|
|
|
length = int(self.headers.get("Content-Length") or 0)
|
|
|
|
|
if length <= 0 or length > 1024:
|
|
|
|
|
self._send(400, {"error": "expected a certificate id in the body"})
|
|
|
|
|
return
|
|
|
|
|
wanted = self.rfile.read(length).decode("utf-8", "replace").strip()
|
|
|
|
|
|
|
|
|
|
# De guard: alleen een id die we zelf gevonden hebben. Dit is het enige
|
|
|
|
|
# pad waarlangs iets van buiten de app-instellingen raakt, dus hier hoort
|
|
|
|
|
# de controle en niet verderop.
|
|
|
|
|
known = {c["id"]: c for c in scan_certificates()}
|
|
|
|
|
if wanted not in known:
|
|
|
|
|
self._send(400, {"error": "unknown certificate", "id": wanted})
|
|
|
|
|
return
|
|
|
|
|
if known[wanted].get("unreadable"):
|
|
|
|
|
self._send(400, {"error": "that certificate cannot be read",
|
|
|
|
|
"id": wanted})
|
|
|
|
|
return
|
|
|
|
|
|
|
|
|
|
write_selection(wanted)
|
|
|
|
|
log("selection saved: " + wanted)
|
|
|
|
|
WAKE.set()
|
|
|
|
|
self._send(200, {"selected": wanted})
|
|
|
|
|
|
|
|
|
|
def do_POST(self): # noqa: N802
|
|
|
|
|
"""Een geupload paar aannemen.
|
|
|
|
|
|
|
|
|
|
POST en niet PUT, want dit maakt iets nieuws aan in plaats van een
|
|
|
|
|
bestaande instelling te zetten. Het formaat is JSON met twee velden en
|
|
|
|
|
niet multipart: dat laatste zou een parser vragen die sinds Python 3.13
|
|
|
|
|
niet meer in de standaardbibliotheek zit, en de pagina kan de twee
|
|
|
|
|
bestanden zelf als tekst inlezen.
|
|
|
|
|
"""
|
|
|
|
|
if self.path.rstrip("/") != "/api/certificate/upload":
|
|
|
|
|
self._send(404, {"error": "unknown path"})
|
|
|
|
|
return
|
|
|
|
|
|
|
|
|
|
length = int(self.headers.get("Content-Length") or 0)
|
|
|
|
|
if length <= 0:
|
|
|
|
|
self._send(400, {"error": "expected a certificate and a key"})
|
|
|
|
|
return
|
|
|
|
|
if length > UPLOAD_MAX:
|
|
|
|
|
self._send(413, {"error": "that is larger than a certificate and a "
|
|
|
|
|
"key should ever be"})
|
|
|
|
|
return
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
payload = json.loads(self.rfile.read(length).decode("utf-8"))
|
|
|
|
|
cert_text = payload["cert"]
|
|
|
|
|
key_text = payload["key"]
|
|
|
|
|
except Exception: # noqa: BLE001 - elke vorm van rommel is hetzelfde
|
|
|
|
|
self._send(400, {"error": "could not read the upload"})
|
|
|
|
|
return
|
|
|
|
|
|
|
|
|
|
naam, fout = accept_upload(cert_text, key_text)
|
|
|
|
|
if fout:
|
|
|
|
|
self._send(400, {"error": fout})
|
|
|
|
|
return
|
|
|
|
|
|
|
|
|
|
# Bewust géén automatische keuze. Uploaden is één handeling en kiezen is
|
|
|
|
|
# een andere; de pagina zet de nieuwe eroverheen in de lijst, dus het kost
|
|
|
|
|
# één klik. Zo blijft er precies één plek waar TLS van certificaat wisselt.
|
|
|
|
|
WAKE.set()
|
|
|
|
|
self._send(200, {"id": upload_id(naam)})
|
|
|
|
|
|
|
|
|
|
def do_GET(self): # noqa: N802
|
|
|
|
|
if self.path.rstrip("/") == "/api/certificate":
|
|
|
|
|
self._send(200, {"selected": read_selection()})
|
|
|
|
|
return
|
|
|
|
|
self._send(404, {"error": "unknown path"})
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
WAKE = threading.Event()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def serve_api():
|
|
|
|
|
server = ThreadingHTTPServer(("0.0.0.0", API_PORT), Handler)
|
|
|
|
|
log("api listening on port %d" % API_PORT)
|
|
|
|
|
server.serve_forever()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def main():
|
|
|
|
|
os.makedirs(CONFIG_DIR, exist_ok=True)
|
|
|
|
|
events = [{"t": int(time.time()), "event": "start",
|
|
|
|
|
"note": "listening on " + str(TLS_PORT)}]
|
|
|
|
|
|
|
|
|
|
threading.Thread(target=serve_api, daemon=True).start()
|
|
|
|
|
|
|
|
|
|
while True:
|
|
|
|
|
try:
|
|
|
|
|
write_status(build_status(events))
|
|
|
|
|
except Exception as e: # noqa: BLE001 - de lus mag nooit stoppen, want
|
|
|
|
|
# dan bevriest de pagina op oude gegevens zonder dat iemand het ziet.
|
|
|
|
|
# De pagina merkt het aan de leeftijd van generated_at.
|
|
|
|
|
log("round failed: %r" % e)
|
|
|
|
|
WAKE.wait(INTERVAL)
|
|
|
|
|
WAKE.clear()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
if __name__ == "__main__":
|
|
|
|
|
main()
|