Skip to content

Commit

Permalink
[knx] Add support for KNX IP Secure (openhab#12709)
Browse files Browse the repository at this point in the history
* [knx] Add support for KNX IP Secure

* add support for KNX IP Secure, new options SECURETUNNEL and
  SECUREROUTER, refers to openhab#8872
* add config options for credentials for secure connections
* update user documentation
* add test cases

Signed-off-by: Holger Friedrich <mail@holger-friedrich.de>
  • Loading branch information
holgerfriedrich authored and psmedley committed Feb 23, 2023
1 parent 2f7ae31 commit 6f2d61f
Show file tree
Hide file tree
Showing 14 changed files with 625 additions and 57 deletions.
35 changes: 34 additions & 1 deletion bundles/org.openhab.binding.knx/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ The IP Gateway is the most commonly used way to connect to the KNX bus. At its b

| Name | Required | Description | Default value |
|---------------------|--------------|--------------------------------------------------------------------------------------------------------------|------------------------------------------------------|
| type | Yes | The IP connection type for connecting to the KNX bus (`TUNNEL` or `ROUTER`) | - |
| type | Yes | The IP connection type for connecting to the KNX bus (`TUNNEL`, `ROUTER`, `SECURETUNNEL` or `SECUREROUTER`) | - |
| ipAddress | for `TUNNEL` | Network address of the KNX/IP gateway. If type `ROUTER` is set, the IPv4 Multicast Address can be set. | for `TUNNEL`: \<nothing\>, for `ROUTER`: 224.0.23.12 |
| portNumber | for `TUNNEL` | Port number of the KNX/IP gateway | 3671 |
| localIp | No | Network address of the local host to be used to set up the connection to the KNX/IP gateway | the system-wide configured primary interface address |
Expand All @@ -39,6 +39,10 @@ The IP Gateway is the most commonly used way to connect to the KNX bus. At its b
| responseTimeout | No | Timeout in seconds to wait for a response from the KNX bus | 10 |
| readRetriesLimit | No | Limits the read retries while initialization from the KNX bus | 3 |
| autoReconnectPeriod | No | Seconds between connect retries when KNX link has been lost (0 means never). | 0 |
| routerBackboneKey | No | KNX secure: Backbone key for secure router mode | - |
| tunnelUserId | No | KNX secure: Tunnel user id for secure tunnel mode (if specified, it must be a number >0) | - |
| tunnelUserPassword | No | KNX secure: Tunnel user key for secure tunnel mode | - |
| tunnelDeviceAuthentication | No | KNX secure: Tunnel device authentication for secure tunnel mode | - |


### Serial Gateway
Expand Down Expand Up @@ -208,6 +212,35 @@ Each configuration parameter has a `mainGA` where commands are written to and op
The `dpt` element is optional. If omitted, the corresponding default value will be used (see the channel descriptions above).


## KNX Secure

> NOTE: Support for KNX Secure is partly implemented for openHAB and should be considered as experimental.
### KNX IP Secure

KNX IP Secure protects the traffic between openHAB and your KNX installation.
It **requires a KNX Secure Router or a Secure IP Interface** and a KNX installation **with security features enabled in ETS tool**.

For *Secure routing* mode, the so called `backbone key` needs to be configured in openHAB.
It is created by the ETS tool and cannot be changed via the ETS user interface.

- The backbone key can be extracted from Security report (ETS, Reports, Security, look for a 32-digit key) and specified in parameter `routerBackboneKey`.

For *Secure tunneling* with a Secure IP Interface (or a router in tunneling mode), more parameters are required.
A unique device authentication key, and a specific tunnel identifier and password need to be available.

- All information can be looked up in ETS and provided separately: `tunnelDeviceAuthentication`, `tunnelUserPassword`.
`tunnelUserId` is a number which is not directly visible in ETS, but can be looked up in keyring export or deduced (typically 2 for the first tunnel of a device, 3 for the second one, ...).
`tunnelUserPasswort` is set in ETS in the properties of the tunnel (below the IP interface you will see the different tunnels listed) denoted as "Password". `tunnelDeviceAuthentication` is set in the properties of the IP interface itself, check for a tab "IP" and a description "Authentication Code".

### KNX Data Secure

KNX Data Secure protects the content of messages on the KNX bus. In a KNX installation, both classic and secure group addresses can coexist.
Data Secure does _not_ necessarily require a KNX Secure Router or a Secure IP Interface, but a KNX installation with newer KNX devices which support Data Secure and with **security features enabled in ETS tool**.

> NOTE: **openHAB currently ignores messages with secure group addresses.**

## Examples

The following two templates are sufficient for almost all purposes.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,10 @@ public class KNXBindingConstants {
public static final String PORT_NUMBER = "portNumber";
public static final String SERIAL_PORT = "serialPort";
public static final String USE_CEMI = "useCemi";
public static final String ROUTER_BACKBONE_GROUP_KEY = "routerBackboneGroupKey";
public static final String TUNNEL_USER_ID = "tunnelUserId";
public static final String TUNNEL_USER_PASSWORD = "tunnelUserPassword";
public static final String TUNNEL_DEVICE_AUTHENTICATION = "tunnelDeviceAuthentication";

// The default multicast ip address (see <a
// href="http://www.iana.org/assignments/multicast-addresses/multicast-addresses.xml">iana</a> EIBnet/IP
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
package org.openhab.binding.knx.internal.client;

import java.time.Duration;
import java.util.Optional;
import java.util.Set;
import java.util.concurrent.CancellationException;
import java.util.concurrent.CopyOnWriteArraySet;
Expand Down Expand Up @@ -40,6 +41,7 @@
import tuwien.auto.calimero.GroupAddress;
import tuwien.auto.calimero.IndividualAddress;
import tuwien.auto.calimero.KNXException;
import tuwien.auto.calimero.KNXIllegalArgumentException;
import tuwien.auto.calimero.datapoint.CommandDP;
import tuwien.auto.calimero.datapoint.Datapoint;
import tuwien.auto.calimero.device.ProcessCommunicationResponder;
Expand All @@ -55,6 +57,7 @@
import tuwien.auto.calimero.process.ProcessCommunicatorImpl;
import tuwien.auto.calimero.process.ProcessEvent;
import tuwien.auto.calimero.process.ProcessListener;
import tuwien.auto.calimero.secure.KnxSecureException;
import tuwien.auto.calimero.secure.SecureApplicationLayer;
import tuwien.auto.calimero.secure.Security;

Expand All @@ -66,6 +69,14 @@
*/
@NonNullByDefault
public abstract class AbstractKNXClient implements NetworkLinkListener, KNXClient {
public enum ClientState {
INIT,
RUNNING,
INTERRUPTED,
DISPOSE
}

private ClientState state = ClientState.INIT;

private static final int MAX_SEND_ATTEMPTS = 2;

Expand Down Expand Up @@ -146,15 +157,19 @@ public void initialize() {

private boolean scheduleReconnectJob() {
if (autoReconnectPeriod > 0) {
connectJob = knxScheduler.schedule(this::connect, autoReconnectPeriod, TimeUnit.SECONDS);
// schedule connect job, for the first connection ignore autoReconnectPeriod and use 1 sec
final long reconnectDelayS = (state == ClientState.INIT) ? 1 : autoReconnectPeriod;
final String prefix = (state == ClientState.INIT) ? "re" : "";
logger.debug("Bridge {} scheduling {}connect in {}s", thingUID, prefix, reconnectDelayS);
connectJob = knxScheduler.schedule(this::connect, reconnectDelayS, TimeUnit.SECONDS);
return true;
} else {
return false;
}
}

private void cancelReconnectJob() {
ScheduledFuture<?> currentReconnectJob = connectJob;
final ScheduledFuture<?> currentReconnectJob = connectJob;
if (currentReconnectJob != null) {
currentReconnectJob.cancel(true);
connectJob = null;
Expand All @@ -171,78 +186,139 @@ private synchronized boolean connectIfNotAutomatic() {
}

private synchronized boolean connect() {
if (state == ClientState.INIT) {
state = ClientState.RUNNING;
} else if (state == ClientState.DISPOSE) {
logger.trace("connect() ignored, closing down");
return false;
}

if (isConnected()) {
return true;
}
try {
// We have a valid "connection" object, this is ensured by IPClient.java.
// "releaseConnection" is actually removing all registered users of this connection and stopping
// all threads.
// Note that this will also kill this function in the following call to sleep in case of a
// connection loss -> restart is via triggered via scheduledReconnect in handler for InterruptedException.
releaseConnection();
Thread.sleep(1000);
logger.debug("Bridge {} is connecting to KNX bus", thingUID);

logger.debug("Bridge {} is connecting to the KNX bus", thingUID);

// now establish (possibly encrypted) connection, according to settings (tunnel, routing, secure...)
KNXNetworkLink link = establishConnection();
this.link = link;

// ManagementProcedures provided by Calimero: allow managing other KNX devices, e.g. check if an address is
// reachable.
// Note for KNX Secure: ManagmentProcedueresImpl currently does not provide a ctor with external SAL,
// it internally creates an instance of ManagementClientImpl, which uses
// Security.defaultInstallation().deviceToolKeys()
// Protected ctor using given ManagementClientImpl is avalable (custom class to be inherited)
managementProcedures = new ManagementProceduresImpl(link);

// ManagementClient provided by Calimero: allow reading device info, etc.
// Note for KNX Secure: ManagementClientImpl does not provide a ctor with external SAL in Calimero 2.5,
// is uses global Security.defaultInstalltion().deviceToolKeys()
// Current main branch includes a protected ctor (custom class to be inherited)
// TODO Calimero>2.5: check if there is a new way to provide security info, there is a new protected ctor
// TODO check if we can avoid creating another ManagementClient and re-use this from ManagemntProcedures
ManagementClient managementClient = new ManagementClientImpl(link);
managementClient.responseTimeout(Duration.ofSeconds(responseTimeout));
this.managementClient = managementClient;

// OH helper for reading device info, based on managementClient above
deviceInfoClient = new DeviceInfoClientImpl(managementClient);

// ProcessCommunicator provides main KNX communication (Calimero).
// Note for KNX Secure: SAL to be provided
ProcessCommunicator processCommunicator = new ProcessCommunicatorImpl(link);
processCommunicator.responseTimeout(Duration.ofSeconds(responseTimeout));
processCommunicator.addProcessListener(processListener);
this.processCommunicator = processCommunicator;

// ProcessCommunicationResponder provides responses to requests from KNX bus (Calimero).
// Note for KNX Secure: SAL to be provided
ProcessCommunicationResponder responseCommunicator = new ProcessCommunicationResponder(link,
new SecureApplicationLayer(link, Security.defaultInstallation()));
this.responseCommunicator = responseCommunicator;

// register this class, callbacks will be triggered
link.addLinkListener(this);

// create a job carrying out read requests
busJob = knxScheduler.scheduleWithFixedDelay(() -> readNextQueuedDatapoint(), 0, readingPause,
TimeUnit.MILLISECONDS);

statusUpdateCallback.updateStatus(ThingStatus.ONLINE);
connectJob = null;

logger.info("Bridge {} connected to KNX bus", thingUID);

state = ClientState.RUNNING;
return true;
} catch (KNXException | InterruptedException e) {
logger.debug("Error connecting to the bus: {}", e.getMessage(), e);
} catch (InterruptedException e) {
final var lastState = state;
state = ClientState.INTERRUPTED;

logger.trace("Bridge {}, connection interrupted", thingUID);

disconnect(e);
if (lastState != ClientState.DISPOSE) {
scheduleReconnectJob();
}

return false;
} catch (KNXException | KnxSecureException e) {
logger.debug("Bridge {} cannot connect: {}", thingUID, e.getMessage());
disconnect(e);
scheduleReconnectJob();
return false;
} catch (KNXIllegalArgumentException e) {
logger.debug("Bridge {} cannot connect: {}", thingUID, e.getMessage());
disconnect(e, Optional.of(ThingStatusDetail.CONFIGURATION_ERROR));
return false;
}
}

private void disconnect(@Nullable Exception e) {
disconnect(e, Optional.empty());
}

private synchronized void disconnect(@Nullable Exception e, Optional<ThingStatusDetail> detail) {
releaseConnection();
if (e != null) {
String message = e.getLocalizedMessage();
statusUpdateCallback.updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
final String message = e.getLocalizedMessage();
statusUpdateCallback.updateStatus(ThingStatus.OFFLINE, detail.orElse(ThingStatusDetail.COMMUNICATION_ERROR),
message != null ? message : "");
} else {
statusUpdateCallback.updateStatus(ThingStatus.OFFLINE);
}
}

@SuppressWarnings("null")
private void releaseConnection() {
logger.debug("Bridge {} is disconnecting from the KNX bus", thingUID);
readDatapoints.clear();
protected void releaseConnection() {
logger.debug("Bridge {} is disconnecting from KNX bus", thingUID);
var tmplink = link;
if (tmplink != null) {
link.removeLinkListener(this);
}
busJob = nullify(busJob, j -> j.cancel(true));
deviceInfoClient = null;
managementProcedures = nullify(managementProcedures, mp -> mp.detach());
managementClient = nullify(managementClient, mc -> mc.detach());
link = nullify(link, l -> l.close());
processCommunicator = nullify(processCommunicator, pc -> {
pc.removeProcessListener(processListener);
pc.detach();
});
readDatapoints.clear();
responseCommunicator = nullify(responseCommunicator, rc -> {
rc.removeProcessListener(processListener);
rc.detach();
});
processCommunicator = nullify(processCommunicator, pc -> {
pc.removeProcessListener(processListener);
pc.detach();
});
deviceInfoClient = null;
managementClient = nullify(managementClient, mc -> mc.detach());
managementProcedures = nullify(managementProcedures, mp -> mp.detach());
link = nullify(link, l -> l.close());
logger.trace("Bridge {} disconnected from KNX bus", thingUID);
}

private <T> @Nullable T nullify(T target, @Nullable Consumer<T> lastWill) {
Expand Down Expand Up @@ -276,6 +352,7 @@ private String toDPTValue(Type type, String dpt) {
return typeHelper.toDPTValue(type, dpt);
}

// datapoint is null at end of the list, warning is misleading
@SuppressWarnings("null")
private void readNextQueuedDatapoint() {
if (!connectIfNotAutomatic()) {
Expand Down Expand Up @@ -316,6 +393,8 @@ private void readNextQueuedDatapoint() {
}

public void dispose() {
state = ClientState.DISPOSE;

cancelReconnectJob();
disconnect(null);
}
Expand Down Expand Up @@ -420,7 +499,7 @@ public void writeToKNX(OutboundSpec commandSpec) throws KNXException {
ProcessCommunicator processCommunicator = this.processCommunicator;
KNXNetworkLink link = this.link;
if (processCommunicator == null || link == null) {
logger.debug("Cannot write to the KNX bus (processCommuicator: {}, link: {})",
logger.debug("Cannot write to KNX bus (processCommuicator: {}, link: {})",
processCommunicator == null ? "Not OK" : "OK",
link == null ? "Not OK" : (link.isOpen() ? "Open" : "Closed"));
return;
Expand All @@ -439,7 +518,7 @@ public void respondToKNX(OutboundSpec responseSpec) throws KNXException {
ProcessCommunicationResponder responseCommunicator = this.responseCommunicator;
KNXNetworkLink link = this.link;
if (responseCommunicator == null || link == null) {
logger.debug("Cannot write to the KNX bus (responseCommunicator: {}, link: {})",
logger.debug("Cannot write to KNX bus (responseCommunicator: {}, link: {})",
responseCommunicator == null ? "Not OK" : "OK",
link == null ? "Not OK" : (link.isOpen() ? "Open" : "Closed"));
return;
Expand Down Expand Up @@ -475,10 +554,10 @@ private void sendToKNX(ProcessCommunication communicator, KNXNetworkLink link, G
break;
} catch (KNXException e) {
if (i < MAX_SEND_ATTEMPTS - 1) {
logger.debug("Value '{}' could not be sent to the KNX bus using datapoint '{}': {}. Will retry.",
type, datapoint, e.getLocalizedMessage());
logger.debug("Value '{}' could not be sent to KNX bus using datapoint '{}': {}. Will retry.", type,
datapoint, e.getLocalizedMessage());
} else {
logger.warn("Value '{}' could not be sent to the KNX bus using datapoint '{}': {}. Giving up now.",
logger.warn("Value '{}' could not be sent to KNX bus using datapoint '{}': {}. Giving up now.",
type, datapoint, e.getLocalizedMessage());
throw e;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
public class CustomKNXNetworkLinkIP extends KNXNetworkLinkIP {

public static final int TUNNELING = KNXNetworkLinkIP.TUNNELING;
public static final int TUNNELINGV2 = KNXNetworkLinkIP.TunnelingV2;
public static final int ROUTING = KNXNetworkLinkIP.ROUTING;

CustomKNXNetworkLinkIP(final int serviceMode, KNXnetIPConnection conn, KNXMediumSettings settings)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,11 @@ private interface ReadFunction<T, R> {
return result;
} catch (KNXException e) {
logger.debug("Could not {} of {}: {}", task, address, e.getMessage());
try {
// avoid trashing the log on connection loss
Thread.sleep(1000);
} catch (InterruptedException ignored) {
}
} catch (InterruptedException e) {
logger.trace("Interrupted to {}", task);
return null;
Expand Down
Loading

0 comments on commit 6f2d61f

Please sign in to comment.