--- 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 @@
@@ -148,9 +151,28 @@
webport = None if a == "off" else int(a)
- wampconf = None if a == "off" else a
+ _PSKpath = os.path.join(a, "wampconf.json") + if os.path.isfile(_PSKpath): + elif os.path.isfile(a) or os.path.isdir(paths.AbsDir(a)): + ConfDir = paths.AbsDir(a) - PSKpath = None if a == "off" else a
+ _PSKpath = os.path.join(a, "psk.txt") + if os.path.isfile(_PSKpath): + elif os.path.isfile(a) or os.path.isdir(paths.AbsDir(a)): + KeyStore = paths.AbsDir(a) fnameanddirname = list(os.path.split(os.path.realpath(a)))
fnameanddirname.reverse()
@@ -529,7 +551,7 @@
- WC.RegisterWampClient(wampconf, PSKpath)
+ WC.RegisterWampClient(wampconf, PSKpath, ConfDir, KeyStore, servicename) WC.RegisterWebSettings(NS)
LogMessageAndException(_("WAMP client startup failed. "))
--- 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 @@ -42,6 +46,29 @@
class WampSession(wamp.ApplicationSession):
+ 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: + key = auth.derive_key(secret, + challenge.extra['salt'], + challenge.extra['iterations'], + challenge.extra['keylen']) + # plain, unsalted secret + signature = auth.compute_wcs(key, challenge.extra['challenge']) + raise Exception("Invalid authmethod {}".format(challenge.method)) def onJoin(self, details):
@@ -54,6 +81,19 @@
print('WAMP session left')
+def MakeSecureContextFactory(verifyHostname, trust_store=None): + if not os.path.exists(trust_store): + raise Exception("Wamp trust store not found") + cert = crypto.load_certificate( + 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 @@
url = urlprefix+"://"+urlpath
+ secret = PSK.GetSecret(confnodesroot.ProjectPath, ID) + # TODO: add x509 certificate management together with PSK management. + confnodesroot.logger.write_error( + _("Connection to {loc} failed with exception {ex}\n").format( def RegisterWampClient():
# start logging to console
@@ -74,7 +124,10 @@
# create a WAMP application session factory
component_config = types.ComponentConfig(
session_factory = wamp.ApplicationSessionFactory(
session_factory.session = cls
@@ -85,20 +138,25 @@
serializers=[MsgPackSerializer()])
+ if transport_factory.isSecure: + contextFactory = MakeSecureContextFactory( + 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)
- AddToDoBeforeQuit = confnodesroot.AppFrame.AddToDoBeforeQuit
_WampConnection = RegisterWampClient()
- AddToDoBeforeQuit(reactor.stop)
+ ToDoBeforeQuit.append(reactor.stop) reactor.run(installSignalHandlers=False)
- class WampPLCObjectProxy(object):
+ class WampPLCObjectProxy(ConnectorBase): --- 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 @@
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
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
@@ -61,14 +67,14 @@
("SetTraceVariablesList", {}),
("GetTraceVariables", {}),
# de-activated dumb wamp config
+ "ID": "wamptest", # replaced by service name (-n in CLI) "url": "ws://127.0.0.1:8888",
@@ -78,7 +84,8 @@
# Those two lists are meant to be filled by customized runtime
@@ -105,24 +112,26 @@
class WampSession(wamp.ApplicationSession):
- if "secret" in self.config.extra:
- user = self.config.extra["ID"]
- self.join("Automation", ["wampcra"], user)
- 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: + key = auth.derive_key(secret, + challenge.extra['salt'], + challenge.extra['iterations'], + challenge.extra['keylen']) - raise Exception("no secret given for authentication")
+ # plain, unsalted secret + signature = auth.compute_wcs(key, challenge.extra['challenge'])
- "don't know how to handle authmethod {}".format(challenge.method))
+ raise Exception("Invalid authmethod {}".format(challenge.method)) def onJoin(self, details):
@@ -148,10 +157,8 @@
def onLeave(self, details):
global _WampSession, _transportFactory
- super(WampSession, self).onLeave(details)
- print(_('WAMP session left'))
def publishWithOwnID(self, eventID, value):
ID = self.config.extra["ID"]
@@ -220,7 +227,7 @@
if os.path.exists(_WampConf):
WampClientConf = json.load(open(_WampConf))
UpdateWithDefault(WampClientConf, defaultWampConfig)
@@ -240,11 +247,6 @@
-def SetWampSecret(wampSecret):
- with open(os.path.realpath(_WampSecret), 'wb') as f:
def SetConfiguration(WampClientConf):
@@ -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 + 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") + _WampTrust = os.path.join(KeyStore, "wampTrustStore.crt") # set config file path only if not already set
@@ -297,10 +309,18 @@
if _WampSecret is not None:
- WampClientConf["secret"] = LoadWampSecret(_WampSecret)
+ if _WampSecret == _WampSecretDefault: + # secret from project dir is raw (no ID prefix) + secret = LoadWampSecret(_WampSecret) + # secret from command line is formated ID:PSK + # fall back to PSK data (works because wampsecret is PSKpath) + _ID, secret = getPSKID() + WampClientConf["secret"] = secret - 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
- connectWS(_transportFactory)
+ if _transportFactory.isSecure: + contextFactory = MakeSecureContextFactory(WampClientConf["verifyHostname"]) + connectWS(_transportFactory, contextFactory) print(_("WAMP client connecting to :"), WampClientConf["url"])
print(_("WAMP client can not connect to :"), WampClientConf["url"])
+def MakeSecureContextFactory(verifyHostname): + if os.path.exists(_WampTrust): + cert = crypto.load_certificate( + 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 = [
"clientFactoryOptions.maxDelay",
"protocolOptions.autoPingInterval",
- "protocolOptions.autoPingTimeout"
+ "protocolOptions.autoPingTimeout", @@ -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()
+ with open(os.path.realpath(_WampSecret), 'w') as destfd: + 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): +class FileUploadDeleteRenderer(webform.FileUploadRenderer): + def input(self, context, slot, data, name, value): + # pylint: disable=expression-not-assigned + slot = webform.FileUploadRenderer.input( + self, context, slot, data, name, value) + file_exists = data.typedValue.getAttribute('file_exists') + if file_exists and file_exists(): + file_delete = data.typedValue.getAttribute('file_delete') + 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") +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) annotate.String(label=_("Current status"),
@@ -473,7 +556,14 @@
default=wampConfigDefault)),
("protocolOptions.autoPingTimeout",
annotate.Integer(label=_("Auto ping timeout (s)"),
- default=wampConfigDefault))
+ default=wampConfigDefault)), + FileUploadDelete(label=_("File containing server certificate"), + file_exists=getTrustStorePresence, + file_delete=getTrustStoreDeleteUrl)), + 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): + return static.Data("TrustStore deleted", 'text/html'), () def RegisterWebSettings(NS):
@@ -499,4 +593,5 @@
WebSettings.addCustomURL(WAMP_SECRET_URL, deliverWampSecret)
+ WebSettings.addCustomURL(WAMP_DELETE_TRUSTSTORE_URL, deleteTrustStore)