WAMP: enable TLS support. Add web settings for hostname verification and server certificate.
authorEdouard Tisserant <edouard@beremiz.fr>
Tue, 18 Feb 2025 16:22:54 +0100 (3 weeks ago)
changeset 4113 fe41562050ed
parent 4112 ef2620af8ad0
child 4114 8a9986e26dfc
WAMP: enable TLS support. Add web settings for hostname verification and server certificate.
Beremiz_service.py
runtime/WampClient.py
--- a/Beremiz_service.py	Tue Feb 18 16:19:23 2025 +0100
+++ b/Beremiz_service.py	Tue Feb 18 16:22:54 2025 +0100
@@ -551,7 +551,7 @@
 
     if havewamp:
         try:
-            WC.RegisterWampClient(wampconf, PSKpath)
+            WC.RegisterWampClient(wampconf, PSKpath, ConfDir, KeyStore)
             WC.RegisterWebSettings(NS)
         except Exception:
             LogMessageAndException(_("WAMP client startup failed. "))
--- a/runtime/WampClient.py	Tue Feb 18 16:19:23 2025 +0100
+++ b/runtime/WampClient.py	Tue Feb 18 16:22:54 2025 +0100
@@ -26,12 +26,16 @@
 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
@@ -47,6 +51,7 @@
 # Find pre-existing project WAMP config file
 _WampConf = None
 _WampSecret = None
+_WampTrust = None
 
 ExposedCalls = [
     ("StartPLC", {}),
@@ -78,7 +83,8 @@
     "protocolOptions": {
         "autoPingInterval": 10,
         "autoPingTimeout": 5
-    }
+    },
+    "verifyHostname": True
 }
 
 # Those two lists are meant to be filled by customized runtime
@@ -220,7 +226,7 @@
     WampClientConf = None
 
     if os.path.exists(_WampConf):
-        try: 
+        try:
             WampClientConf = json.load(open(_WampConf))
             UpdateWithDefault(WampClientConf, defaultWampConfig)
         except ValueError:
@@ -240,11 +246,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 +273,16 @@
     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):
+    global _WampConf, _WampSecret, _WampTrust
+    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:
@@ -323,13 +330,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,
+            six.u(open(_WampTrust, 'r').read())
+        )
+        trustRoot=OpenSSLCertificateAuthorities([cert])
+    return optionsForClientTLS(_transportFactory.host, trustRoot=trustRoot)
 
 def StopReconnectWampClient():
     if _transportFactory is not None:
@@ -377,11 +399,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 +427,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 +481,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 +543,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 +564,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 +580,5 @@
         wampConfig)
 
     WebSettings.addCustomURL(WAMP_SECRET_URL, deliverWampSecret)
-
+    WebSettings.addCustomURL(WAMP_DELETE_TRUSTSTORE_URL, deleteTrustStore)
+