merge
authorEdouard Tisserant <edouard.tisserant@gmail.com>
Fri, 28 Feb 2025 22:58:27 +0100 (4 weeks ago)
changeset 4127 7da45bd15fc8
parent 4109 2fb97bc2158a (current diff)
parent 4126 80c52609d1ad (diff)
child 4128 15770ee8c716
merge
--- 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