Deploy MQTT test backend with private viewer and Gitea CI/CD
Test and deploy MQTT backend / test (push) Successful in 2m8s
Test and deploy MQTT backend / deploy (push) Successful in 18s

This commit is contained in:
2026-10-08 14:48:53 +03:30
commit 71ef622b4f
70 changed files with 7123 additions and 0 deletions
+240
View File
@@ -0,0 +1,240 @@
import json
import socket
import ssl
import struct
import time
import urllib.request
import uuid
from pathlib import Path
import argparse
parser = argparse.ArgumentParser(description='Independent test-device/backend receipt ACK checks; does not run the Go simulator.')
parser.add_argument('--credentials', required=True, help='Private JSON handoff or ESP32 connection.txt file')
parser.add_argument('--ca', required=True, help='Test broker public CA file')
parser.add_argument('--host', default='109.122.252.129')
parser.add_argument('--plaintext-port', type=int, default=1884)
parser.add_argument('--tls-port', type=int, default=8884)
args = parser.parse_args()
HOST = args.host
private = Path(args.credentials).read_text()
if args.credentials.endswith('.json'):
connection = json.loads(private)
device_user, device_password = connection['username'], connection['password']
else:
connection = dict(line.split('=', 1) for line in private.splitlines() if '=' in line)
device_user, device_password = connection['mqtt_user'].strip(), connection['mqtt_pass'].strip()
def varint(n):
out = bytearray()
while True:
byte = n % 128
n //= 128
out.append(byte | (128 if n else 0))
if not n:
return bytes(out)
def string(s):
raw = s.encode()
return struct.pack('!H', len(raw)) + raw
def exact(sock, n):
result = b''
while len(result) < n:
chunk = sock.recv(n - len(result))
if not chunk:
raise RuntimeError('broker closed connection')
result += chunk
return result
def read(sock):
header = exact(sock, 1)[0]
length = 0
factor = 1
while True:
digit = exact(sock, 1)[0]
length += (digit & 127) * factor
if not digit & 128:
break
factor *= 128
return header, exact(sock, length)
def send(sock, header, body):
sock.sendall(bytes([header]) + varint(len(body)) + body)
def connect(version, tls, username, password, client_id=None, persistent=False, expect_session=False, anonymous=False):
sock = socket.create_connection((HOST, args.tls_port if tls else args.plaintext_port), timeout=8)
if tls:
context = ssl.create_default_context(cafile=args.ca)
sock = context.wrap_socket(sock, server_hostname=HOST)
body = string('MQTT') + bytes([version, 2 if anonymous else (0xc0 if persistent else 0xc2)]) + struct.pack('!H', 30)
if version == 5:
body += b'\x05\x11' + struct.pack('!I', 30) if persistent else b'\x00'
body += string(client_id or 'rm5-independent-' + uuid.uuid4().hex[:12]) + (b'' if anonymous else string(username) + string(password))
send(sock, 0x10, body)
header, body = read(sock)
if header >> 4 != 2 or body[1] != 0:
raise RuntimeError('authentication/CONNECT rejected: code ' + str(body[1]))
if expect_session and not body[0] & 1:
raise RuntimeError('persistent session absent; retransmission test inconclusive')
return sock
def subscribe(sock, version, topic):
body = b'\x00\x01' + (b'\x00' if version == 5 else b'') + string(topic) + b'\x01'
send(sock, 0x82, body)
header, body = read(sock)
if header >> 4 != 9 or body[-1] >= 128:
raise RuntimeError('backend subscription rejected')
def publish(sock, version, topic, payload):
body = string(topic) + b'\x00\x02' + (b'\x00' if version == 5 else b'') + payload.encode()
send(sock, 0x32, body)
header, body = read(sock)
if header >> 4 != 4 or (version == 5 and len(body) > 2 and body[2] >= 128):
raise RuntimeError('publication denied by broker')
def received(sock, version):
header, body = read(sock)
if header >> 4 != 3:
raise RuntimeError('expected subscribed publication')
topic_len = int.from_bytes(body[:2], 'big')
position = 2 + topic_len
if (header >> 1) & 3:
packet_id = body[position:position+2]
position += 2
send(sock, 0x40, packet_id)
if version == 5:
size = 0
factor = 1
while True:
digit = body[position]
position += 1
size += (digit & 127) * factor
if not digit & 128:
break
factor *= 128
position += size
return body[position:].decode()
def viewer_has(topic, payload, kind):
until = time.monotonic() + 5
while time.monotonic() < until:
with urllib.request.urlopen('http://127.0.0.1:8080/api/snapshot', timeout=3) as response:
snapshot = json.load(response)
if any(event.get('topic') == topic and event.get('kind') == kind and event.get('payload', {}).get('text', '') == payload for event in snapshot['events']):
return
time.sleep(0.1)
raise RuntimeError('broker delivered but viewer did not show ' + kind)
import hashlib
for tls in (False, True):
for version in (4,5):
device='ack-test-'+uuid.uuid4().hex[:8]
source='robot-market/test/v1/'+device+'/telemetry'
acktopic='robot-market/test/v1/'+device+'/acks'
reader=connect(version,tls,device_user,device_password)
writer=connect(version,tls,device_user,device_password)
try:
subscribe(reader,version,acktopic)
for payload in ['hello from device', json.dumps({'message_id':'receipt-001','device_id':'anything','timestamp':'{{$isoTimestamp}}'}), '']:
publish(writer,version,source,payload)
ack_payload=received(reader,version)
ack=json.loads(ack_payload)
viewer_has(source,payload,'message_received')
viewer_has(acktopic,ack_payload,'receipt_ack_sent')
assert ack['type']=='receipt_ack' and ack['ok'] is True
assert ack['source_topic']==source and ack['payload_bytes']==len(payload.encode())
assert ack['payload_sha256']==hashlib.sha256(payload.encode()).hexdigest()
if 'receipt-001' in payload: assert ack['message_id']=='receipt-001'
reader.settimeout(.3)
try:
extra=read(reader)
except socket.timeout:
pass
else:
raise RuntimeError('Unexpected reply loop or extra ACK: '+str(extra[0]))
print(('TLS' if tls else 'plaintext')+' MQTT '+('5' if version==5 else '3.1.1')+': device receives backend ACK for text, arbitrary JSON, empty payload; no loop PASS')
finally:
for sock in (writer,reader):
send(sock,0xe0,b'')
sock.close()
# Native MQTT 5 custom response topic and binary correlation echo.
source='robot-market/test/v1/native-ack-test/telemetry'
response='robot-market/test/v1/native-ack-test/custom-reply'
corr=bytes([0,255,1,2])
reader=connect(5,False,device_user,device_password)
writer=connect(5,False,device_user,device_password)
try:
subscribe(reader,5,response)
props=b'\x08'+string(response)+b'\x09'+struct.pack('!H',len(corr))+corr
body=string(source)+b'\x00\x02'+varint(len(props))+props+b'native properties test'
send(writer,0x32,body)
header,body=read(writer)
assert header>>4==4 and (len(body)==2 or body[2]<128)
header,body=read(reader)
assert header>>4==3
pos=2+int.from_bytes(body[:2],'big')
packetid=body[pos:pos+2];pos+=2
send(reader,0x40,packetid)
size=0;factor=1
while True:
digit=body[pos];pos+=1;size+=(digit&127)*factor
if not digit&128:break
factor*=128
receivedprops=body[pos:pos+size]
assert b'\x09'+struct.pack('!H',len(corr))+corr in receivedprops
ack=json.loads(body[pos+size:])
assert ack['source_topic']==source and ack['ok'] is True
print('MQTT 5 Response Topic and exact binary Correlation Data echo: PASS')
finally:
for sock in (writer,reader):
send(sock,0xe0,b'')
sock.close()
# Controlled wire-level retransmission: same persistent client/session and packet ID,
# original PUBACK deliberately unread, then DUP=1 after a dropped socket.
source = 'robot-market/test/v1/wire-dup-' + uuid.uuid4().hex[:8] + '/telemetry'
acktopic = source.rsplit('/', 1)[0] + '/acks'
reader = connect(5, False, device_user, device_password)
client_id = 'rm5-dup-' + uuid.uuid4().hex[:12]
writer = connect(5, False, device_user, device_password, client_id, True)
try:
subscribe(reader, 5, acktopic)
body = string(source) + b'\x00\x07\x00' + b'controlled wire retransmission'
send(writer, 0x32, body)
first = json.loads(received(reader, 5))
writer.close() # no PUBACK read, no DISCONNECT
time.sleep(.2)
writer = connect(5, False, device_user, device_password, client_id, True, True)
send(writer, 0x3a, body) # QoS 1, DUP set, identical packet identifier and payload
header, response = read(writer)
assert header >> 4 == 4 and response[:2] == b'\x00\x07'
assert len(response) == 2 or response[2] < 128
second = json.loads(received(reader, 5))
assert first['payload_sha256'] == second['payload_sha256']
print('Controlled MQTT 5 wire QoS 1 DUP retransmission after unread PUBACK: PASS; two receipt ACKs, no device action claimed')
finally:
for sock in (writer, reader):
send(sock, 0xe0, b'')
sock.close()
for label, username, password, anonymous in [('Anonymous', '', '', True), ('Invalid password', device_user, uuid.uuid4().hex, False)]:
try:
sock = connect(5, True, username, password, anonymous=anonymous)
except RuntimeError as error:
assert 'CONNECT rejected' in str(error), 'failure was not broker authentication rejection'
print(label + ' authentication rejection: PASS')
else:
sock.close()
raise RuntimeError(label + ' authentication unexpectedly accepted')
for label, context, hostname in [('Untrusted CA', ssl.create_default_context(), HOST), ('Wrong hostname', ssl.create_default_context(cafile=args.ca), 'wrong.invalid')]:
raw = socket.create_connection((HOST, args.tls_port), timeout=8)
try:
secured = context.wrap_socket(raw, server_hostname=hostname)
except ssl.SSLCertVerificationError:
print(label + ' TLS rejection: PASS')
else:
secured.close()
raise RuntimeError(label + ' TLS unexpectedly accepted')
finally:
raw.close()