--- a/BeremizIDE.py Fri Feb 07 18:42:43 2025 +0100
+++ b/BeremizIDE.py Fri Feb 28 22:58:27 2025 +0100
@@ -65,7 +65,7 @@
ITEM_RESOURCE, \
ITEM_CONFNODE
-from ProjectController import ProjectController, GetAddMenuItems, MATIEC_ERROR_MODEL
+from ProjectController import ProjectController, GetAddMenuItems, MATIEC_ERROR_MODEL, ToDoBeforeQuit
from IDEFrame import \
TITLE,\
@@ -614,14 +614,6 @@
else:
return IDEFrame.LoadTab(self, notebook, page_infos)
- # Strange hack required by WAMP connector, using twisted.
- # Twisted reactor needs to be stopped only before quit,
- # since it cannot be restarted
- ToDoBeforeQuit = []
-
- def AddToDoBeforeQuit(self, Thing):
- self.ToDoBeforeQuit.append(Thing)
-
def TryCloseFrame(self):
if self.CTR is None or self.CheckSaveBeforeClosing(_("Close Application")):
if self.CTR is not None:
@@ -631,9 +623,9 @@
self.SaveLastState()
- for Thing in self.ToDoBeforeQuit:
+ for Thing in ToDoBeforeQuit:
Thing()
- self.ToDoBeforeQuit = []
+ ToDoBeforeQuit = []
return True
return False
--- a/Beremiz_service.py Fri Feb 07 18:42:43 2025 +0100
+++ b/Beremiz_service.py Fri Feb 28 22:58:27 2025 +0100
@@ -75,8 +75,9 @@
-x enable/disable wxTaskbarIcon (0:disable 1:enable) (default:1)
-t enable/disable Twisted web interface (0:disable 1:enable) (default:1)
-w web server port or "off" to disable web server (default:8009)
- -c WAMP client config file (can be overriden by wampconf.json in project)
- -s PSK secret path (default:PSK disabled)
+ -c WAMP client config file or Configuration directory containing "wamconf.json" (default:Wamp disabled, config dir is working_dir)
+ Note: Wamp config is overriden by wampconf.json given in project files.
+ -s PSK secret file or existing KeyStore directory containing "psk.txt" (default:PSK disabled, KeyStore is working_dir)
-e python extension (absolute path .py)
working_dir - directory where are stored PLC files
@@ -96,6 +97,8 @@
port = 3000
webport = 8009
PSKpath = None
+KeyStore = None
+ConfDir = None
wampconf = None
servicename = None
autostart = False
@@ -148,9 +151,28 @@
elif o == "-w":
webport = None if a == "off" else int(a)
elif o == "-c":
- wampconf = None if a == "off" else a
+ if a == "off":
+ wampconf = None
+ elif os.path.isdir(a):
+ ConfDir = a
+ _PSKpath = os.path.join(a, "wampconf.json")
+ if os.path.isfile(_PSKpath):
+ wampconf = _PSKpath
+ elif os.path.isfile(a) or os.path.isdir(paths.AbsDir(a)):
+ wampconf = a
+ ConfDir = paths.AbsDir(a)
elif o == "-s":
- PSKpath = None if a == "off" else a
+ if a == "off":
+ PSKpath = None
+ elif os.path.isdir(a):
+ KeyStore = a
+ _PSKpath = os.path.join(a, "psk.txt")
+ if os.path.isfile(_PSKpath):
+ PSKpath = _PSKpath
+ elif os.path.isfile(a) or os.path.isdir(paths.AbsDir(a)):
+ PSKpath = a
+ KeyStore = paths.AbsDir(a)
+
elif o == "-e":
fnameanddirname = list(os.path.split(os.path.realpath(a)))
fnameanddirname.reverse()
@@ -529,7 +551,7 @@
if havewamp:
try:
- WC.RegisterWampClient(wampconf, PSKpath)
+ WC.RegisterWampClient(wampconf, PSKpath, ConfDir, KeyStore, servicename)
WC.RegisterWebSettings(NS)
except Exception:
LogMessageAndException(_("WAMP client startup failed. "))
--- a/CLIController.py Fri Feb 07 18:42:43 2025 +0100
+++ b/CLIController.py Fri Feb 28 22:58:27 2025 +0100
@@ -11,7 +11,7 @@
import fake_wx
-from ProjectController import ProjectController
+from ProjectController import ProjectController, ToDoBeforeQuit
from LocalRuntimeMixin import LocalRuntimeMixin
from runtime.loglevels import LogLevelsCount, LogLevels
@@ -154,9 +154,14 @@
def finish(self):
+ global ToDoBeforeQuit
self._Disconnect()
+ for Thing in ToDoBeforeQuit:
+ Thing()
+ ToDoBeforeQuit = []
+
if not self.session.keep:
self.KillLocalRuntime()
--- a/PSKManagement.py Fri Feb 07 18:42:43 2025 +0100
+++ b/PSKManagement.py Fri Feb 28 22:58:27 2025 +0100
@@ -104,13 +104,21 @@
dataForID[COL_URI] = URI
# FIXME : could store time instead os a string and use DVC model's cmp
# then date display could be smarter, etc - sortable sting hack for now
- dataForID[COL_LAST] = time.strftime('%y/%M/%d-%H:%M:%S')
+ dataForID[COL_LAST] = time.strftime('%y/%m/%d-%H:%M:%S')
if _is_new_ID:
data.append(dataForID)
SaveData(project_path, data)
+def GetSecret(project_path, ID):
+ # load PSK from project
+ secpath = os.path.join(project_path, 'psk', ID + '.secret')
+ if not os.path.exists(secpath):
+ raise ValueError(
+ 'Error: Pre-Shared-Key Secret in %s is missing!\n' % secpath)
+ secret = open(secpath).read().partition(':')[2].rstrip('\n\r')
+ return secret
def ExportIDs(project_path, export_zip):
with ZipFile(export_zip, 'w') as zf:
--- a/ProjectController.py Fri Feb 07 18:42:43 2025 +0100
+++ b/ProjectController.py Fri Feb 28 22:58:27 2025 +0100
@@ -2141,3 +2141,10 @@
for d in self.StatusMethods:
if d["method"] == method and d.get("enabled", True) and d.get("shown", True):
getattr(self, method)()
+
+
+# Strange hack required by WAMP connector, using twisted.
+# Twisted reactor needs to be stopped only before quit,
+# since it cannot be restarted
+ToDoBeforeQuit = []
+
--- a/connectors/ERPC/__init__.py Fri Feb 07 18:42:43 2025 +0100
+++ b/connectors/ERPC/__init__.py Fri Feb 28 22:58:27 2025 +0100
@@ -152,15 +152,9 @@
try:
if ID:
# load PSK from project
- secpath = os.path.join(confnodesroot.ProjectPath, 'psk', ID + '.secret')
- if not os.path.exists(secpath):
- confnodesroot.logger.write_error(
- 'Error: Pre-Shared-Key Secret in %s is missing!\n' % secpath)
- return None
- secret = open(secpath).read().partition(':')[2].rstrip('\n\r').encode()
- transport = SSLPSKClientTransport(host, port, (secret, ID.encode())) # type: ignore
+ secret = PSK.GetSecret(confnodesroot.ProjectPath, ID)
+ transport = SSLPSKClientTransport(host, port, (secret.encode(), ID.encode()))
else:
-
transport = erpc.transport.TCPTransport(host, port, False)
clientManager = erpc.client.ClientManager(transport, erpc.basic_codec.BasicCodec)
--- a/connectors/WAMP/__init__.py Fri Feb 07 18:42:43 2025 +0100
+++ b/connectors/WAMP/__init__.py Fri Feb 28 22:58:27 2025 +0100
@@ -29,12 +29,16 @@
from threading import Thread, Event
from twisted.internet import reactor, threads
+from twisted.internet._sslverify import OpenSSLCertificateAuthorities
from autobahn.twisted import wamp
from autobahn.twisted.websocket import WampWebSocketClientFactory, connectWS
-from autobahn.wamp import types
+from autobahn.wamp import types, auth
from autobahn.wamp.exception import TransportLost
from autobahn.wamp.serializer import MsgPackSerializer
+from ProjectController import ToDoBeforeQuit
+from connectors.ConnectorBase import ConnectorBase
+import PSKManagement as PSK
_WampSession = None
_WampConnection = None
@@ -42,6 +46,29 @@
class WampSession(wamp.ApplicationSession):
+ def onConnect(self):
+ user = self.config.extra["ID"]
+ self.config.realm, user))
+ self.join(self.config.realm, ["wampcra"], user)
+
+ def onChallenge(self, challenge):
+ if challenge.method == "wampcra":
+ secret = self.config.extra["secret"]
+ if 'salt' in challenge.extra:
+ # salted secret
+ key = auth.derive_key(secret,
+ challenge.extra['salt'],
+ challenge.extra['iterations'],
+ challenge.extra['keylen'])
+ else:
+ # plain, unsalted secret
+ key = secret
+
+ signature = auth.compute_wcs(key, challenge.extra['challenge'])
+ return signature
+ else:
+ raise Exception("Invalid authmethod {}".format(challenge.method))
+
def onJoin(self, details):
global _WampSession
_WampSession = self
@@ -54,6 +81,19 @@
_WampSession = None
print('WAMP session left')
+def MakeSecureContextFactory(verifyHostname, trust_store=None):
+ if not verifyHostname:
+ return None
+ trustRoot=None
+ if trust_store:
+ if not os.path.exists(trust_store):
+ raise Exception("Wamp trust store not found")
+ cert = crypto.load_certificate(
+ crypto.FILETYPE_PEM,
+ open(trust_store, 'rb').read()
+ )
+ trustRoot=OpenSSLCertificateAuthorities([cert])
+ return optionsForClientTLS(_transportFactory.host, trustRoot=trustRoot)
def _WAMP_connector_factory(cls, uri, confnodesroot):
"""
@@ -66,6 +106,16 @@
"WAMPS": "wss"}[scheme]
url = urlprefix+"://"+urlpath
+ try:
+ secret = PSK.GetSecret(confnodesroot.ProjectPath, ID)
+ # TODO: add x509 certificate management together with PSK management.
+ trust_store = None
+ except Exception as e:
+ confnodesroot.logger.write_error(
+ _("Connection to {loc} failed with exception {ex}\n").format(
+ loc=uri, ex=str(e)))
+ return None
+
def RegisterWampClient():
# start logging to console
@@ -74,7 +124,10 @@
# create a WAMP application session factory
component_config = types.ComponentConfig(
realm=str(realm),
- extra={"ID": ID})
+ extra={
+ "ID": ID,
+ "secret": secret
+ })
session_factory = wamp.ApplicationSessionFactory(
config=component_config)
session_factory.session = cls
@@ -85,20 +138,25 @@
url=url,
serializers=[MsgPackSerializer()])
+ contextFactory=None
+ if transport_factory.isSecure:
+ contextFactory = MakeSecureContextFactory(
+ verifyHostname=True,
+ trust_store=trust_store)
+
# start the client from a Twisted endpoint
- conn = connectWS(transport_factory)
+ conn = connectWS(transport_factory, contextFactory)
confnodesroot.logger.write(_("WAMP connecting to URL : %s\n") % url)
return conn
- AddToDoBeforeQuit = confnodesroot.AppFrame.AddToDoBeforeQuit
def ThreadProc():
global _WampConnection
_WampConnection = RegisterWampClient()
- AddToDoBeforeQuit(reactor.stop)
+ ToDoBeforeQuit.append(reactor.stop)
reactor.run(installSignalHandlers=False)
- class WampPLCObjectProxy(object):
+ class WampPLCObjectProxy(ConnectorBase):
def __init__(self):
global _WampConnection
if not reactor.running:
--- a/controls/IDBrowser.py Fri Feb 07 18:42:43 2025 +0100
+++ b/controls/IDBrowser.py Fri Feb 28 22:58:27 2025 +0100
@@ -12,12 +12,12 @@
from dialogs.IDMergeDialog import IDMergeDialog
-class IDBrowserModel(dv.PyDataViewIndexListModel):
+class IDBrowserModel(dv.DataViewIndexListModel):
def __init__(self, project_path, columncount):
self.project_path = project_path
self.columncount = columncount
self.data = PSK.GetData(project_path)
- dv.PyDataViewIndexListModel.__init__(self, len(self.data))
+ dv.DataViewIndexListModel.__init__(self, len(self.data))
def _saveData(self):
PSK.SaveData(self.project_path, self.data)
--- a/py_ext/py_ext_rt.py Fri Feb 07 18:42:43 2025 +0100
+++ b/py_ext/py_ext_rt.py Fri Feb 28 22:58:27 2025 +0100
@@ -54,7 +54,7 @@
dialect = csv.Sniffer().sniff(csvfile.read(1024))
csvfile.seek(0)
reader = csv.reader(csvfile, dialect)
- first_row = reader.__next__()
+ first_row = reader.next()
data.append(first_row)
col_headers = OrderedDict([(name, index+1) for index, name
in enumerate(first_row[1:])])
--- a/requirements.txt Fri Feb 07 18:42:43 2025 +0100
+++ b/requirements.txt Fri Feb 28 22:58:27 2025 +0100
@@ -1,42 +1,43 @@
aiofiles==24.1.0
-aiosqlite==0.20.0
+aiosqlite==0.21.0
asyncua @ git+https://github.com/FreeOpcUa/opcua-asyncio.git@98a64897a2d171653353de2f36d33085aef65e82
-attrs==24.2.0
-autobahn==24.4.2
+attrs==25.1.0
+autobahn @ git+https://github.com/crossbario/autobahn-python.git@7bc85b34e200640ab98a41cfddb38267f39bc92e
Automat==24.8.1
Brotli==1.1.0
cffi==1.17.1
-click==8.1.7
+click==8.1.8
constantly==23.10.4
contourpy==1.3.1
-cryptography==44.0.0
+cryptography==44.0.1
cycler==0.12.1
erpc @ git+https://git@github.com/beremiz/erpc.git@d8fff72b15274b5f2a8f7895d9bc5c91eef584ec#subdirectory=erpc_python
-fonttools==4.55.3
+fonttools==4.56.0
hyperlink==21.0.0
idna==3.10
ifaddr==0.2.0
incremental==24.7.2
-kiwisolver==1.4.7
-lxml==5.3.0
-matplotlib==3.9.3
+kiwisolver==1.4.8
+lxml==5.3.1
+matplotlib==3.10.1
msgpack==1.1.0
Nevow @ git+https://git@github.com/beremiz/nevow-py3.git@81c2eaeaaa20022540a98a3106f72e0199fbcc1b
-numpy==2.2.0
+numpy==2.2.3
packaging==24.2
-pillow==11.0.0
+pillow==11.1.0
pycountry==24.6.1
pycparser==2.22
-pyparsing==3.2.0
+pyOpenSSL==25.0.0
+pyparsing==3.2.1
python-dateutil==2.9.0.post0
-pytz==2024.2
-setuptools==75.6.0
+pytz==2025.1
six==1.17.0
sortedcontainers==2.4.0
sslpsk @ git+https://git@github.com/beremiz/sslpsk.git@9cb31986629b382f7427eec29ddc168ad21c7d7c
+tomli==2.2.1
Twisted==24.11.0
txaio==23.1.1
typing_extensions==4.12.2
wxPython==4.2.2
-zeroconf==0.136.2
+zeroconf==0.145.1
zope.interface==7.2
--- a/requirements_latest.txt Fri Feb 07 18:42:43 2025 +0100
+++ b/requirements_latest.txt Fri Feb 28 22:58:27 2025 +0100
@@ -8,6 +8,7 @@
matplotlib
msgpack
Nevow @ git+https://git@github.com/beremiz/nevow-py3.git@81c2eaeaaa20022540a98a3106f72e0199fbcc1b
+pyOpenSSL
pycountry
# pyserial
sslpsk @ git+https://git@github.com/beremiz/sslpsk.git@9cb31986629b382f7427eec29ddc168ad21c7d7c
--- a/runtime/PLCObject.py Fri Feb 07 18:42:43 2025 +0100
+++ b/runtime/PLCObject.py Fri Feb 28 22:58:27 2025 +0100
@@ -231,7 +231,7 @@
self._RegisterDebugVariable = self.PLClibraryHandle.RegisterDebugVariable
self._RegisterDebugVariable.restype = ctypes.c_int
- self._RegisterDebugVariable.argtypes = [ctypes.c_int, ctypes.c_void_p, ctypes.c_uint32]
+ self._RegisterDebugVariable.argtypes = [ctypes.c_int, ctypes.c_void_p]
self._FreeDebugData = self.PLClibraryHandle.FreeDebugData
self._FreeDebugData.restype = None
@@ -595,7 +595,12 @@
@RunInMain
def GetPLCID(self):
- return getPSKID(partial(self.LogMessage, 0))
+ try:
+ res = getPSKID()
+ except Exception as e:
+ self.LogMessage(0, str(e))
+ return ("","")
+ return res
def _init_blobs(self):
self.blobs = {} # dict of list
--- a/runtime/Stunnel.py Fri Feb 07 18:42:43 2025 +0100
+++ b/runtime/Stunnel.py Fri Feb 28 22:58:27 2025 +0100
@@ -30,7 +30,7 @@
secret = os.urandom(192) # int(256/1.3333)
secretstring = b2a_base64(secret)
- PSKstring = ID+":"+secretstring
+ PSKstring = ID+":"+secretstring.decode()
with open(PSKpath, 'w') as f:
f.write(PSKstring)
restartStunnel()
@@ -45,12 +45,11 @@
PSKgen(ID, PSKpath)
-def getPSKID(errorlog):
+def getPSKID():
if _PSKpath is not None:
if not os.path.exists(_PSKpath):
- errorlog(
+ raise Exception(
'Error: Pre-Shared-Key Secret in %s is missing!\n' % _PSKpath)
- return ("","")
ID, _sep, PSK = open(_PSKpath).read().partition(':')
PSK = PSK.rstrip('\n\r')
return (ID, PSK)
--- a/runtime/WampClient.py Fri Feb 07 18:42:43 2025 +0100
+++ b/runtime/WampClient.py Fri Feb 28 22:58:27 2025 +0100
@@ -26,17 +26,22 @@
import json
import os
import re
+import shutil
from autobahn.twisted import wamp
from autobahn.twisted.websocket import WampWebSocketClientFactory, connectWS
from autobahn.wamp import types, auth
from autobahn.wamp.serializer import MsgPackSerializer
from twisted.internet.protocol import ReconnectingClientFactory
from twisted.python.components import registerAdapter
+from twisted.internet.ssl import optionsForClientTLS, CertificateOptions
+from twisted.internet._sslverify import OpenSSLCertificateAuthorities
+from OpenSSL import crypto
from formless import annotate, webform
import formless
from nevow import tags, url, static
from runtime import GetPLCObjectSingleton
+from runtime.Stunnel import getPSKID
mandatoryConfigItems = ["ID", "active", "realm", "url"]
@@ -47,6 +52,7 @@
# Find pre-existing project WAMP config file
_WampConf = None
_WampSecret = None
+_WampTrust = None
ExposedCalls = [
("StartPLC", {}),
@@ -61,14 +67,14 @@
("MatchMD5", {}),
("SetTraceVariablesList", {}),
("GetTraceVariables", {}),
- ("RemoteExec", {}),
("GetLogMessage", {}),
- ("ResetLogCount", {})
+ ("ResetLogCount", {}),
+ ("ExtendedCall", {})
]
# de-activated dumb wamp config
defaultWampConfig = {
- "ID": "wamptest",
+ "ID": "wamptest", # replaced by service name (-n in CLI)
"active": False,
"realm": "Automation",
"url": "ws://127.0.0.1:8888",
@@ -78,7 +84,8 @@
"protocolOptions": {
"autoPingInterval": 10,
"autoPingTimeout": 5
- }
+ },
+ "verifyHostname": True
}
# Those two lists are meant to be filled by customized runtime
@@ -105,24 +112,26 @@
class WampSession(wamp.ApplicationSession):
def onConnect(self):
- if "secret" in self.config.extra:
- user = self.config.extra["ID"]
- self.join("Automation", ["wampcra"], user)
- else:
- self.join("Automation")
+ user = self.config.extra["ID"]
+ self.join(self.config.realm, ["wampcra"], user)
def onChallenge(self, challenge):
if challenge.method == "wampcra":
- if "secret" in self.config.extra:
- secret = self.config.extra["secret"].encode('utf8')
- signature = auth.compute_wcs(
- secret, challenge.extra['challenge'].encode('utf8'))
- return signature.decode("ascii")
+ secret = self.config.extra["secret"]
+ if 'salt' in challenge.extra:
+ # salted secret
+ key = auth.derive_key(secret,
+ challenge.extra['salt'],
+ challenge.extra['iterations'],
+ challenge.extra['keylen'])
else:
- raise Exception("no secret given for authentication")
+ # plain, unsalted secret
+ key = secret
+
+ signature = auth.compute_wcs(key, challenge.extra['challenge'])
+ return signature
else:
- raise Exception(
- "don't know how to handle authmethod {}".format(challenge.method))
+ raise Exception("Invalid authmethod {}".format(challenge.method))
def onJoin(self, details):
global _WampSession
@@ -148,10 +157,8 @@
def onLeave(self, details):
global _WampSession, _transportFactory
- super(WampSession, self).onLeave(details)
_WampSession = None
_transportFactory = None
- print(_('WAMP session left'))
def publishWithOwnID(self, eventID, value):
ID = self.config.extra["ID"]
@@ -220,7 +227,7 @@
WampClientConf = None
if os.path.exists(_WampConf):
- try:
+ try:
WampClientConf = json.load(open(_WampConf))
UpdateWithDefault(WampClientConf, defaultWampConfig)
except ValueError:
@@ -240,11 +247,6 @@
return WampClientConf
-def SetWampSecret(wampSecret):
- with open(os.path.realpath(_WampSecret), 'wb') as f:
- f.write(wampSecret)
-
-
def SetConfiguration(WampClientConf):
global lastKnownConfig
@@ -272,10 +274,20 @@
return re.match(r'wss?://[^\s?:#-]+(:[0-9]+)?(/[^\s]*)?$', uri) is not None
-def RegisterWampClient(wampconf=None, wampsecret=None):
- global _WampConf, _WampSecret
- _WampConfDefault = os.path.join(WorkingDir, "wampconf.json")
- _WampSecretDefault = os.path.join(WorkingDir, "wamp.secret")
+def RegisterWampClient(wampconf=None, wampsecret=None, ConfDir=None, KeyStore=None, servicename=None):
+ global _WampConf, _WampSecret, _WampTrust, defaultWampConfig
+
+ if servicename:
+ defaultWampConfig["ID"] = servicename
+
+ ConfDir = ConfDir if ConfDir else WorkingDir
+ KeyStore = KeyStore if KeyStore else WorkingDir
+
+ _WampConfDefault = os.path.join(ConfDir, "wampconf.json")
+ _WampSecretDefault = os.path.join(KeyStore, "wamp.secret")
+
+ if _WampTrust is None:
+ _WampTrust = os.path.join(KeyStore, "wampTrustStore.crt")
# set config file path only if not already set
if _WampConf is None:
@@ -297,10 +309,18 @@
_WampSecret = wampsecret
if _WampSecret is not None:
- WampClientConf["secret"] = LoadWampSecret(_WampSecret)
+ if _WampSecret == _WampSecretDefault:
+ # secret from project dir is raw (no ID prefix)
+ secret = LoadWampSecret(_WampSecret)
+ else:
+ # secret from command line is formated ID:PSK
+ # fall back to PSK data (works because wampsecret is PSKpath)
+ _ID, secret = getPSKID()
+
+ WampClientConf["secret"] = secret
+
else:
- print(_("WAMP authentication has no secret configured"))
- _WampSecret = _WampSecretDefault
+ raise Exception(_("WAMP no secret file given"))
if not WampClientConf["active"]:
print(_("WAMP deactivated in configuration"))
@@ -323,13 +343,28 @@
# start the client from a Twisted endpoint
if _transportFactory:
- connectWS(_transportFactory)
+ contextFactory=None
+ if _transportFactory.isSecure:
+ contextFactory = MakeSecureContextFactory(WampClientConf["verifyHostname"])
+
+ connectWS(_transportFactory, contextFactory)
print(_("WAMP client connecting to :"), WampClientConf["url"])
return True
else:
print(_("WAMP client can not connect to :"), WampClientConf["url"])
return False
+def MakeSecureContextFactory(verifyHostname):
+ if not verifyHostname:
+ return None
+ trustRoot=None
+ if os.path.exists(_WampTrust):
+ cert = crypto.load_certificate(
+ crypto.FILETYPE_PEM,
+ open(_WampTrust, 'rb').read()
+ )
+ trustRoot=OpenSSLCertificateAuthorities([cert])
+ return optionsForClientTLS(_transportFactory.host, trustRoot=trustRoot)
def StopReconnectWampClient():
if _transportFactory is not None:
@@ -377,11 +412,13 @@
# WEB CONFIGURATION INTERFACE
WAMP_SECRET_URL = "secret"
+WAMP_DELETE_TRUSTSTORE_URL = "delete_truststore"
webExposedConfigItems = [
'active', 'url', 'ID',
"clientFactoryOptions.maxDelay",
"protocolOptions.autoPingInterval",
- "protocolOptions.autoPingTimeout"
+ "protocolOptions.autoPingTimeout",
+ "verifyHostname"
]
@@ -403,8 +440,17 @@
if secretfile_field is not None:
secretfile = getattr(secretfile_field, "file", None)
if secretfile is not None:
- secret = secretfile_field.file.read()
- SetWampSecret(secret)
+ with open(os.path.realpath(_WampSecret), 'w') as destfd:
+ secretfile.seek(0)
+ shutil.copyfileobj(secretfile,destfd)
+
+ trustStore_field = kwargs["trustStore"]
+ if trustStore_field is not None:
+ trustStore_file = getattr(trustStore_field, "file", None)
+ if trustStore_file is not None:
+ with open(os.path.realpath(_WampTrust), 'w') as destfd:
+ trustStore_file.seek(0)
+ shutil.copyfileobj(trustStore_file,destfd)
newConfig = lastKnownConfig.copy()
for argname in webExposedConfigItems:
@@ -448,6 +494,43 @@
child(lastKnownConfig["ID"] + ".secret")
+class FileUploadDelete(annotate.FileUpload):
+ pass
+
+
+class FileUploadDeleteRenderer(webform.FileUploadRenderer):
+
+ def input(self, context, slot, data, name, value):
+ # pylint: disable=expression-not-assigned
+ slot[_("Upload:")]
+ slot = webform.FileUploadRenderer.input(
+ self, context, slot, data, name, value)
+ file_exists = data.typedValue.getAttribute('file_exists')
+ if file_exists and file_exists():
+ unique = str(id(self))
+ file_delete = data.typedValue.getAttribute('file_delete')
+ slot = slot[
+ tags.a(href=file_delete, target=unique)[_("Delete")],
+ tags.iframe(srcdoc="File exists", name=unique,
+ height="20", width="150",
+ marginheight="5", marginwidth="5",
+ scrolling="no", frameborder="0")
+ ]
+ return slot
+
+
+registerAdapter(FileUploadDeleteRenderer, FileUploadDelete,
+ formless.iformless.ITypedRenderer)
+
+
+def getTrustStorePresence():
+ return os.path.exists(_WampTrust)
+
+
+def getTrustStoreDeleteUrl(ctx, argument):
+ return url.URL.fromContext(ctx).child(WAMP_DELETE_TRUSTSTORE_URL)
+
+
webFormInterface = [
("status",
annotate.String(label=_("Current status"),
@@ -473,7 +556,14 @@
default=wampConfigDefault)),
("protocolOptions.autoPingTimeout",
annotate.Integer(label=_("Auto ping timeout (s)"),
- default=wampConfigDefault))
+ default=wampConfigDefault)),
+ ("trustStore",
+ FileUploadDelete(label=_("File containing server certificate"),
+ file_exists=getTrustStorePresence,
+ file_delete=getTrustStoreDeleteUrl)),
+ ("verifyHostname",
+ annotate.Boolean(label=_("Verify hostname matches certificate hostname"),
+ default=wampConfigDefault)),
]
def deliverWampSecret(ctx, segments):
@@ -487,6 +577,10 @@
secret = LoadWampSecret(_WampSecret)
return static.Data(secret, 'application/octet-stream'), ()
+def deleteTrustStore(ctx, segments):
+ if os.path.exists(_WampTrust):
+ os.remove(_WampTrust)
+ return static.Data("TrustStore deleted", 'text/html'), ()
def RegisterWebSettings(NS):
@@ -499,4 +593,5 @@
wampConfig)
WebSettings.addCustomURL(WAMP_SECRET_URL, deliverWampSecret)
-
+ WebSettings.addCustomURL(WAMP_DELETE_TRUSTSTORE_URL, deleteTrustStore)
+
--- /dev/null Thu Jan 01 00:00:00 1970 +0000
+++ b/tests/cli_tests/wamp_test.bash Fri Feb 28 22:58:27 2025 +0100
@@ -0,0 +1,197 @@
+#!/bin/bash
+
+rm -f ./CLI_OK ./PLC_OK
+
+# Start runtime one first time to generate PSK
+$BEREMIZPYTHONPATH $BEREMIZPATH/Beremiz_service.py -s psk.txt -n test_wamp_ID -x 0 &
+PLC_PID=$!
+res=110 # default to ETIMEDOUT
+c=5
+while ((c--)); do
+ if [[ -a psk.txt ]]; then
+ echo got PSK.
+ res=0 # OK success
+ break
+ else
+ echo waiting PSK.... $c
+ sleep 1
+ fi
+done
+
+kill $PLC_PID
+
+if [ "$res" != "0" ] ; then
+ echo timeout generating PSK.
+ exit $res
+fi
+
+IFS=':' read -r wamp_ID wamp_secret < psk.txt
+
+# Start crossbar server
+mkdir -p .crossbar
+cat > .crossbar/config.json <<JsonEnd
+{
+ "version": 2,
+ "workers": [
+ {
+ "type": "router",
+ "id": "automation_router",
+ "realms": [
+ {
+ "name": "Automation",
+ "roles": [
+ {
+ "name": "authenticated",
+ "permissions": [
+ {
+ "uri": "",
+ "match": "prefix",
+ "allow": {
+ "call": true,
+ "register": true,
+ "publish": true,
+ "subscribe": true
+ },
+ "disclose": {
+ "caller": false,
+ "publisher": false
+ },
+ "cache": true
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "transports": [
+ {
+ "type": "web",
+ "endpoint": {
+ "type": "tcp",
+ "port": 8888
+ },
+ "paths": {
+ "ws": {
+ "type": "websocket",
+ "auth": {
+ "wampcra": {
+ "type": "static",
+ "users": {
+ "${wamp_ID}": {
+ "secret": "${wamp_secret}",
+ "role": "authenticated"
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ ]
+ }
+ ]
+}
+JsonEnd
+crossbar start &> crossbar_log.txt &
+SERVER_PID=$!
+res=110 # default to ETIMEDOUT
+c=15
+while ((c--)); do
+ if [[ -a .crossbar/node.pid ]]; then
+ echo found crossbar pid
+ res=0 # OK success
+ break
+ else
+ echo wait for crossbar to start.... $c
+ sleep 1
+ fi
+done
+
+if [ "$res" != "0" ] ; then
+ kill $SERVER_PID
+ echo timeout starting crossbar.
+ exit $res
+fi
+
+# give more time to crossbar
+sleep 3
+
+# Prepare runtime Wamp config
+cat > wampconf.json <<JsonEnd
+{
+ "ID": "${wamp_ID}",
+ "active": true,
+ "protocolOptions": {
+ "autoPingInterval": 60,
+ "autoPingTimeout": 20
+
+ },
+ "realm": "Automation",
+ "url": "ws://127.0.0.1:8888/ws"
+}
+JsonEnd
+
+# Start Beremiz runtime again, with wamp enabled
+$BEREMIZPYTHONPATH $BEREMIZPATH/Beremiz_service.py -c wampconf.json -s psk.txt -n test_wamp_ID -x 0 &> >(
+ echo "Start PLC loop"
+ while read line; do
+ # Wait for server to print modified value
+ echo "PLC>> $line"
+ if [[ "$line" == "PLCobject : PLC started" ]]; then
+ echo "PLC was programmed"
+ touch ./PLC_OK
+ fi
+ done
+ echo "End PLC loop"
+) &
+PLC_PID=$!
+
+echo wait for runtime to come up
+sleep 3
+
+# Prepare test project
+cp -a $BEREMIZPATH/tests/projects/wamp .
+# place PSK so that IDE already knows runtime
+mkdir -p wamp/psk
+cp psk.txt wamp/psk/${wamp_ID}.secret
+
+# TODO: patch project's URI to connect to $BEREMIZ_LOCAL_HOST
+# used in tests instead of 127.0.0.1
+
+# Use CLI to build transfer and start PLC
+setsid $BEREMIZPYTHONPATH $BEREMIZPATH/Beremiz_cli.py -k \
+ --project-home wamp build transfer run &> >(
+echo "Start CLI loop"
+while read line; do
+ # Wait for PLC runtime to output expected value on stdout
+ echo "CLI>> $line"
+ if [[ "$line" == "PLC installed successfully." ]]; then
+ echo "CLI did transfer PLC program"
+ touch ./CLI_OK
+ fi
+done
+echo "End CLI loop"
+) &
+CLI_PID=$!
+
+echo all subprocess started, start polling results
+res=110 # default to ETIMEDOUT
+c=30
+while ((c--)); do
+ if [[ -a ./CLI_OK && -a ./PLC_OK ]]; then
+ echo got results.
+ res=0 # OK success
+ break
+ else
+ echo waiting.... $c
+ sleep 1
+ fi
+done
+
+# Kill PLC and subprocess
+echo will kill PLC:$PLC_PID, SERVER:$SERVER_PID and CLI:$CLI_PID
+kill $PLC_PID
+kill $CLI_PID
+kill $SERVER_PID
+
+exit $res
--- a/tests/projects/wamp/.crossbar/config.json Fri Feb 07 18:42:43 2025 +0100
+++ /dev/null Thu Jan 01 00:00:00 1970 +0000
@@ -1,56 +0,0 @@
-{
- "version": 2,
- "controller": {},
- "workers": [
- {
- "type": "router",
- "options": {
- "pythonpath": [
- ".."
- ]
- },
- "realms": [
- {
- "name": "Automation",
- "roles": [
- {
- "name": "anonymous",
- "permissions": [
- {
- "uri": "",
- "match": "prefix",
- "allow": {
- "call": true,
- "register": true,
- "publish": true,
- "subscribe": true
- },
- "disclose": {
- "caller": false,
- "publisher": false
- },
- "cache": true
- }
- ]
- }
- ]
- }
- ],
- "transports": [
- {
- "type": "websocket",
- "debug": true,
- "endpoint": {
- "type": "tcp",
- "port": 8888
- },
- "url": "ws://127.0.0.1:8888/",
- "serializers": [
- "msgpack",
- "json"
- ]
- }
- ]
- }
- ]
-}
--- a/tests/projects/wamp/README Fri Feb 07 18:42:43 2025 +0100
+++ /dev/null Thu Jan 01 00:00:00 1970 +0000
@@ -1,42 +0,0 @@
-/* This project contains wamp client config to be loaded at runtime startup. */
-./project_files/wampconf.json
-
-wampconf.json is in "Project Files", so it is copied to runtime's working directory, and then loaded after program transfer + runtime restart.
-
-Otherwise, wamp config file path can be forced :
-./Beremiz_service.py -c /path/to/my/wampconf.json /working/dir
-
-/* Crossbar install */
-#sudo apt-get update
-#sudo apt-get -y dist-upgrade
-sudo apt-get -y install build-essential libssl-dev libffi-dev libreadline-dev libbz2-dev libsqlite3-dev libncurses5dev
-sudo apt-get -y install python3-pip
-sudo python3 -m pip install -U pip
-sudo pip3 install crossbar
-crossbar version
-
-/* Start Crossbar command: */
-crossbar start
-
-/* Crossbar test router configuration is available in .crossbar directory. */
-Tested on version:
-
- :::::::::::::::::
- ::::: _____ __
- ::::: : ::::: / ___/____ ___ ___ ___ / / ___ _ ____
- ::::::: ::::::: / /__ / __// _ \ (_-< (_-< / _ \/ _ `// __/
- ::::: : ::::: \___//_/ \___//___//___//_.__/\_,_//_/
- :::::
- ::::::::::::::::: Crossbar v18.7.2
-
- Copyright (c) 2013-2018 Crossbar.io Technologies GmbH, licensed under AGPL 3.0.
-
- Crossbar.io : 18.7.2
- Autobahn : 18.7.1
- Twisted : 18.7.0-EPollReactor
- LMDB : 0.94/lmdb-0.9.22
- Python : 3.6.6/CPython
- Frozen executable : no
- Operating system : Linux-4.16.0-2-rt-amd64-x86_64-with-debian-buster-sid
- Host machine : x86_64
- Release key : RWS9T4NltFjmKSMbEtETnOMxRjLhOEZ6e80T5MYzTTh/+NP9Jk20sJmA
--- a/tests/projects/wamp/beremiz.xml Fri Feb 07 18:42:43 2025 +0100
+++ b/tests/projects/wamp/beremiz.xml Fri Feb 28 22:58:27 2025 +0100
@@ -1,4 +1,4 @@
<?xml version='1.0' encoding='utf-8'?>
-<BeremizRoot xmlns:xsd="http://www.w3.org/2001/XMLSchema" URI_location="WAMP://127.0.0.1:8888#Automation#wamptest">
+<BeremizRoot xmlns:xsd="http://www.w3.org/2001/XMLSchema" URI_location="WAMP://127.0.0.1:8888/ws#Automation#test_wamp_ID">
<TargetType/>
</BeremizRoot>
--- a/tests/projects/wamp/project_files/wampconf.json Fri Feb 07 18:42:43 2025 +0100
+++ /dev/null Thu Jan 01 00:00:00 1970 +0000
@@ -1,10 +0,0 @@
-{
- "ID": "wamptest",
- "active": true,
- "protocolOptions": {
- "autoPingInterval": 60,
- "autoPingTimeout": 20
- },
- "realm": "Automation",
- "url": "ws://127.0.0.1:8888"
-}
--- a/tests/tools/Docker/Dockerfile Fri Feb 07 18:42:43 2025 +0100
+++ b/tests/tools/Docker/Dockerfile Fri Feb 28 22:58:27 2025 +0100
@@ -85,17 +85,35 @@
RUN virtualenv ~/beremizenv
+# beremiz python requirements
+# file is placed here by build_docker_image.sh
COPY requirements.txt /home/$UNAME
-
-# beremiz python requirements
RUN ~/beremizenv/bin/pip install -r /home/$UNAME/requirements.txt
-# tests python requirements
-RUN ~/beremizenv/bin/pip install \
- pytest pytest-timeout ddt
-
-#TODO sslpsk posix_spawn
+# tests specific python requirements : pytest pytest-timeout ddt crossbar
+
+## How to upgrade requirements and test requirements together:
+# pip install -r beremiz/requirements_latest.txt
+# pip freeze > beremiz/requirements.txt
+# pip install pytest pytest-timeout ddt crossbar
+# pip freeze > requirements_all.txt
+# sort requirements_all.txt -o requirements_all_sorted.txt
+# sort beremiz/requirements.txt -o requirements_sorted.txt
+# comm -13 requirements_sorted.txt requirements_all_sorted.txt > beremiz/tests/tools/Docker/test_requirements.txt
+# rm requirements_all.txt requirements_sorted.txt requirements_all_sorted.txt
+
+COPY test_requirements.txt /home/$UNAME
+RUN ~/beremizenv/bin/pip install -r /home/$UNAME/test_requirements.txt --no-deps
+# Why --no-deps :
+# - Mandatory because crossbar's dependencies are expressed with github url,
+# targeting master branch or specific tags. This is not reproducable, and
+# test has to be reproducible.
+# - pip freeze generates dependencies pointing to particular commity hash,
+# the way to go for reproducible test
+# - because of broken crossbar requirements, 'pip install -r' fails meeting
+# requirement expressed on a branch or tag with a commit hash based install
+
RUN set -xe && \
cd /home/$UNAME && mkdir tessdata && \
wget -q https://github.com/tesseract-ocr/tessdata/archive/refs/tags/4.1.0.tar.gz \
@@ -108,4 +126,6 @@
# Points to python binary that test will use
ENV BEREMIZPYTHONPATH /home/$UNAME/beremizenv/bin/python
+# use venv python and installed command by default
+ENV PATH="/home/$UNAME/beremizenv/bin:$PATH"
--- /dev/null Thu Jan 01 00:00:00 1970 +0000
+++ b/tests/tools/Docker/test_requirements.txt Fri Feb 28 22:58:27 2025 +0100
@@ -0,0 +1,130 @@
+aiohappyeyeballs==2.4.6
+aiohttp==3.11.13
+aiosignal==1.3.2
+annotated-types==0.7.0
+appdirs==1.4.4
+argon2-cffi==23.1.0
+argon2-cffi-bindings==21.2.0
+arrow==1.3.0
+async-timeout==5.0.1
+base58==2.1.1
+bcrypt==4.3.0
+binaryornot==0.4.4
+bitarray==3.0.0
+bitstring==4.3.0
+blinker==1.9.0
+cached-property==2.0.1
+cbor2==5.6.5
+certifi==2025.1.31
+cfxdb @ git+https://github.com/crossbario/cfxdb.git@c54a8043865abb29726daf6565693265e8830e33
+chardet==5.2.0
+charset-normalizer==3.4.1
+ckzg==2.0.1
+colorama==0.4.6
+cookiecutter==2.6.0
+cramjam==2.9.1
+crossbar @ git+https://git@github.com/crossbario/crossbar.git@c1a78363866be39e9e624226980bbe2d90e9fd65
+cytoolz==1.0.1
+ddt==1.7.2
+docker==7.1.0
+ecdsa==0.19.0
+eth_abi==5.0.1
+eth-account==0.13.5
+eth-hash==0.7.1
+eth-keyfile==0.8.1
+eth-keys==0.6.1
+eth-rlp==2.2.0
+eth-typing==5.2.0
+eth-utils==5.2.0
+exceptiongroup==1.2.2
+Flask==3.1.0
+flatbuffers==25.2.10
+frozenlist==1.5.0
+h2==3.2.0
+hexbytes==1.3.0
+hkdf==0.0.3
+hpack==3.0.0
+humanize==4.12.1
+hyperframe==5.2.0
+importlib_resources==6.5.2
+iniconfig==2.0.0
+iso8601==2.1.0
+itsdangerous==2.2.0
+Jinja2==3.1.5
+jinja2-highlight==0.6.1
+jsonschema==4.23.0
+jsonschema-specifications==2024.10.1
+lmdb==1.6.2
+markdown-it-py==3.0.0
+MarkupSafe==3.0.2
+mdurl==0.1.2
+mistune==3.1.2
+mnemonic==0.21
+morphys==1.0
+multidict==6.1.0
+netaddr==1.3.0
+parsimonious==0.9.0
+passlib==1.7.4
+pluggy==1.5.0
+priority==1.3.0
+prompt_toolkit==3.0.50
+propcache==0.3.0
+psutil==7.0.0
+pyasn1==0.6.1
+pyasn1_modules==0.4.1
+py-cid @ git+https://github.com/crossbario/py-cid.git@e1dc52a43ced53845679405f78d92fb18f093653
+pycryptodome==3.21.0
+pydantic==2.10.6
+pydantic_core==2.27.2
+py-ecc==7.0.1
+py-eth-sig-utils==0.4.0
+Pygments==2.19.1
+py-multibase==1.0.3
+py-multicodec==0.2.1
+py-multihash @ git+https://github.com/crossbario/py-multihash.git@86b54b8f9f0cf14c7370f38ae32f2b4b14f5b5e0
+PyNaCl==1.5.0
+PyQRCode==1.2.1
+pytest==8.3.4
+pytest-timeout==2.3.1
+python-baseconv==1.2.2
+python-slugify==8.0.4
+python-snappy==0.7.3
+PyTrie==0.4.0
+py-ubjson==0.16.1
+pyunormalize==16.0.0
+PyYAML==6.0.2
+qrcode==8.0
+referencing==0.36.2
+regex==2024.11.6
+requests==2.32.3
+rich==13.9.4
+rlp==4.1.0
+rpds-py==0.23.1
+sdnotify==0.3.2
+service-identity==24.2.0
+setproctitle==1.3.5
+spake2==0.9
+stringcase==1.2.0
+tabulate==0.9.0
+text-unidecode==1.3
+toolz==1.0.0
+treq==24.9.1
+txtorcon==24.8.0
+types-python-dateutil==2.9.0.20241206
+types-requests==2.31.0.6
+types-urllib3==1.26.25.14
+ujson==5.10.0
+u-msgpack-python==2.8.0
+urllib3==1.26.20
+validate-email==1.3
+varint==1.0.2
+watchdog==6.0.0
+wcwidth==0.2.13
+web3==7.8.0
+websockets==13.1
+Werkzeug==3.1.3
+wsaccel==0.6.7
+xbr==21.2.1
+yapf==0.29.0
+yarl==1.18.3
+zlmdb @ git+https://github.com/crossbario/zlmdb.git@16270e41d5fadd2615cd735723cb629700fa12e8