diff --git a/src/io/calimero/knxnetip/ClientConnection.java b/src/io/calimero/knxnetip/ClientConnection.java
index 72578aa7..ee66e4c8 100644
--- a/src/io/calimero/knxnetip/ClientConnection.java
+++ b/src/io/calimero/knxnetip/ClientConnection.java
@@ -48,6 +48,7 @@
import java.net.DatagramSocket;
import java.net.InetAddress;
import java.net.InetSocketAddress;
+import java.net.SocketAddress;
import java.time.Duration;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.locks.Condition;
@@ -116,14 +117,14 @@ public abstract class ClientConnection extends ConnectionBase
private volatile boolean cleanup;
- final boolean tcp;
- private final TcpConnection connection;
+ final boolean stream;
+ private final StreamConnection connection;
// logger is initialized in connect, when name of connection is available
protected ClientConnection(final int serviceRequest, final int serviceAck, final int maxSendAttempts,
- final int responseTimeout, final TcpConnection connection) {
+ final int responseTimeout, final StreamConnection connection) {
super(serviceRequest, serviceAck, maxSendAttempts, responseTimeout);
- tcp = connection != TcpConnection.Udp;
+ stream = connection != TcpConnection.Udp;
this.connection = connection;
}
@@ -132,11 +133,11 @@ protected ClientConnection(final int serviceRequest, final int serviceAck, final
this(serviceRequest, serviceAck, maxSendAttempts, responseTimeout, TcpConnection.Udp);
}
- protected void connect(final TcpConnection c, final CRI cri) throws KNXException, InterruptedException {
+ protected void connect(final StreamConnection c, final CRI cri) throws KNXException, InterruptedException {
try {
c.connect();
c.registerConnectRequest(this);
- connect(c.localEndpoint(), c.server(), cri, false);
+ doConnect(c, cri);
}
catch (final IOException e) {
throw new KNXException("connecting " + connection, e);
@@ -146,6 +147,64 @@ protected void connect(final TcpConnection c, final CRI cri) throws KNXException
}
}
+ void doConnect(final StreamConnection c, final CRI cri) throws KNXException, InterruptedException {
+ controlEndpoint = c.server();
+ if (c instanceof final TcpConnection tcpConn)
+ connect(tcpConn.localEndpoint(), tcpConn.server(), cri, false);
+ else if (c instanceof UnixDomainSocketConnection)
+ connectToUnixSocket(c, cri);
+ else
+ throw new IllegalStateException();
+ }
+
+ void connectToUnixSocket(final StreamConnection c, final CRI cri) throws KNXException, InterruptedException {
+ if (state != CLOSED)
+ throw new IllegalStateException("open connection");
+ logger = LogService.getLogger("io.calimero.knxnetip." + name());
+
+ final SocketAddress server = c.server();
+ try {
+ logger.log(DEBUG, "establish connection to {0} ({1})", server, "uds");
+ final var hpai = HPAI.Tcp;
+ final byte[] buf = PacketHelper.toPacket(protocolVersion(), new ConnectRequest(cri, hpai, hpai));
+ send(buf, null);
+ }
+ catch (IOException | SecurityException e) {
+ logger.log(ERROR, "communication failure on connect", e);
+ throw new KNXException("connecting to " + server + ": " + e.getMessage());
+ }
+
+ logger.log(DEBUG, "wait for connect response from {0} ...", server);
+ try {
+ final boolean changed = waitForStateChange(CLOSED, CONNECT_REQ_TIMEOUT);
+ if (state == OK) {
+ Executor.execute(heartbeat, "KNXnet/IP heartbeat monitor");
+
+ String optionalConnectionInfo = "";
+ if (tunnelingAddress != null)
+ optionalConnectionInfo = ", tunneling address " + tunnelingAddress;
+ logger.log(INFO, "connection established (data endpoint {0}:{1}, channel {2}{3})",
+ dataEndpt.getAddress().getHostAddress(), dataEndpt.getPort(), channelId,
+ optionalConnectionInfo);
+ return;
+ }
+ final KNXException e;
+ if (!changed)
+ e = new KNXTimeoutException("timeout connecting to control endpoint " + server);
+ else if (state == ACK_ERROR)
+ e = new KNXRemoteException("error response from control endpoint " + server + ": " + status);
+ else
+ e = new KNXInvalidResponseException("invalid connect response from " + server);
+ // quit, cleanup and notify user
+ connectCleanup(e);
+ throw e;
+ }
+ catch (final InterruptedException e) {
+ connectCleanup(e);
+ throw e;
+ }
+ }
+
/**
* Opens a new IP communication channel to a remote server.
*
@@ -169,6 +228,10 @@ protected void connect(final TcpConnection c, final CRI cri) throws KNXException
protected void connect(final InetSocketAddress localEP, final InetSocketAddress serverCtrlEP,
final CRI cri, final boolean useNAT) throws KNXException, InterruptedException
{
+ // if we allow localEP to be null, we would create an unbound socket
+ if (localEP == null)
+ throw new KNXIllegalArgumentException("no local endpoint specified");
+
if (state != CLOSED)
throw new IllegalStateException("open connection");
ctrlEndpt = serverCtrlEP;
@@ -177,22 +240,20 @@ protected void connect(final InetSocketAddress localEP, final InetSocketAddress
if (ctrlEndpt.getAddress().isMulticastAddress())
throw new KNXIllegalArgumentException("server control endpoint cannot be a multicast address ("
+ ctrlEndpt.getAddress().getHostAddress() + ")");
+ controlEndpoint = ctrlEndpt;
useNat = useNAT;
logger = LogService.getLogger("io.calimero.knxnetip." + name());
- // if we allow localEP to be null, we would create an unbound socket
- if (localEP == null)
- throw new KNXIllegalArgumentException("no local endpoint specified");
- final InetSocketAddress local = Net.matchRemoteEndpoint(localEP, serverCtrlEP, useNAT);
+ final InetSocketAddress local = stream ? localEP : Net.matchRemoteEndpoint(localEP, serverCtrlEP, useNAT);
try {
- if (!tcp) {
+ if (!stream) {
socket = new DatagramSocket(local);
ctrlSocket = socket;
}
final var lsa = localSocketAddress();
- logger.log(DEBUG, "establish connection from {0} to {1} ({2})", hostPort(lsa), hostPort(ctrlEndpt), tcp ? "tcp" : "udp");
+ logger.log(DEBUG, "establish connection from {0} to {1} ({2})", hostPort(lsa), hostPort(ctrlEndpt), stream ? "tcp" : "udp");
// HPAI throws if wildcard local address (0.0.0.0) is supplied
- final var hpai = tcp ? HPAI.Tcp : useNat ? HPAI.Nat : new HPAI(HPAI.IPV4_UDP, lsa);
+ final var hpai = stream ? HPAI.Tcp : useNat ? HPAI.Nat : new HPAI(HPAI.IPV4_UDP, lsa);
final byte[] buf = PacketHelper.toPacket(protocolVersion(), new ConnectRequest(cri, hpai, hpai));
send(buf, ctrlEndpt);
}
@@ -206,7 +267,7 @@ protected void connect(final InetSocketAddress localEP, final InetSocketAddress
}
logger.log(DEBUG, "wait for connect response from {0} ...", hostPort(ctrlEndpt));
- if (!tcp)
+ if (!stream)
startReceiver();
try {
final boolean changed = waitForStateChange(CLOSED, CONNECT_REQ_TIMEOUT);
@@ -240,7 +301,7 @@ else if (state == ACK_ERROR)
@Override
protected void send(final byte[] packet, final InetSocketAddress dst) throws IOException {
- if (tcp)
+ if (stream)
connection.send(packet);
else
super.send(packet, dst);
@@ -305,9 +366,9 @@ else if (svc == KNXnetIPHeader.CONNECT_RES) {
}
// address info is only != null on no error
final HPAI ep = res.getDataEndpoint();
- if (res.getStatus() == ErrorCodes.NO_ERROR && tcp == (ep.hostProtocol() == HPAI.IPV4_TCP)) {
+ if (res.getStatus() == ErrorCodes.NO_ERROR && stream == (ep.hostProtocol() == HPAI.IPV4_TCP)) {
channelId = res.getChannelID();
- if (tcp || (useNat && ep.nat()))
+ if (stream || (useNat && ep.nat()))
dataEndpt = new InetSocketAddress(src, port);
else
dataEndpt = ep.endpoint();
@@ -333,7 +394,7 @@ else if (svc == KNXnetIPHeader.CONNECTIONSTATE_RES) {
heartbeat.setResponse(new ConnectionstateResponse(data, offset));
}
else if (svc == KNXnetIPHeader.DISCONNECT_REQ) {
- if (ctrlEndpt.getAddress().equals(src) && ctrlEndpt.getPort() == port)
+ if (ctrlEndpt == null || ctrlEndpt.getAddress().equals(src) && ctrlEndpt.getPort() == port)
disconnectRequested(new DisconnectRequest(data, offset));
}
else if (svc == KNXnetIPHeader.DISCONNECT_RES) {
@@ -345,7 +406,7 @@ else if (svc == KNXnetIPHeader.DISCONNECT_RES) {
}
else if (svc == serviceAck) {
// with tcp, service acks are not required and just ignored
- if (tcp)
+ if (stream)
return true;
final ServiceAck res = new ServiceAck(svc, data, offset);
@@ -380,8 +441,11 @@ String connectionState() {
};
}
+ /** Experimental; do not use. */
+ public SocketAddress remoteAddress() { return controlEndpoint; }
+
private InetSocketAddress localSocketAddress() {
- return (InetSocketAddress) (tcp ? connection.localEndpoint() : socket.getLocalSocketAddress());
+ return (InetSocketAddress) (stream ? connection.localEndpoint() : socket.getLocalSocketAddress());
}
private void disconnectRequested(final DisconnectRequest req)
@@ -457,7 +521,7 @@ private final class HeartbeatMonitor implements Runnable
public void run()
{
thread = Thread.currentThread();
- final var hpai = tcp ? HPAI.Tcp : useNat ? HPAI.Nat : new HPAI(HPAI.IPV4_UDP, localSocketAddress());
+ final var hpai = stream ? HPAI.Tcp : useNat ? HPAI.Nat : new HPAI(HPAI.IPV4_UDP, localSocketAddress());
final byte[] buf = PacketHelper.toPacket(protocolVersion(), new ConnectionstateRequest(channelId, hpai));
try {
while (!stop) {
diff --git a/src/io/calimero/knxnetip/ConnectionBase.java b/src/io/calimero/knxnetip/ConnectionBase.java
index 0f8b91a8..68446dbf 100644
--- a/src/io/calimero/knxnetip/ConnectionBase.java
+++ b/src/io/calimero/knxnetip/ConnectionBase.java
@@ -50,6 +50,8 @@
import java.net.DatagramSocket;
import java.net.InetAddress;
import java.net.InetSocketAddress;
+import java.net.SocketAddress;
+import java.net.UnixDomainSocketAddress;
import java.util.HexFormat;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.locks.Condition;
@@ -99,6 +101,7 @@ public abstract class ConnectionBase implements KNXnetIPConnection
/** Remote control endpoint. */
protected InetSocketAddress ctrlEndpt;
+ volatile SocketAddress controlEndpoint;
/** Remote data endpoint. */
protected InetSocketAddress dataEndpt;
@@ -313,6 +316,8 @@ public final int getState()
@Override
public String name()
{
+ if (ctrlEndpt == null)
+ return controlEndpoint.toString();
// only the control endpoint is set when our logger is initialized (the data
// endpoint gets assigned later in connect)
// to keep the name short, avoid a prepended host name as done by InetAddress
@@ -378,12 +383,18 @@ protected void fireFrameReceived(final CEMI frame)
listeners.fire(l -> l.frameReceived(fe));
}
- boolean handleServiceType(final KNXnetIPHeader h, final byte[] data, final int offset,
- final InetSocketAddress source) throws KNXFormatException, IOException {
+ boolean handleServiceType(final KNXnetIPHeader h, final byte[] data, final int offset, final SocketAddress source)
+ throws KNXFormatException, IOException {
final int hdrStart = offset - h.getStructLength();
- logger.log(TRACE, "from {0}: {1}: {2}", Net.hostPort(source), h,
+ final var socketName = source instanceof final InetSocketAddress isa ? Net.hostPort(isa) : source;
+ logger.log(TRACE, "from {0}: {1}: {2}", socketName, h,
HexFormat.ofDelimiter(" ").formatHex(data, hdrStart, hdrStart + h.getTotalLength()));
- return handleServiceType(h, data, offset, source.getAddress(), source.getPort());
+ if (source instanceof final InetSocketAddress isa)
+ return handleServiceType(h, data, offset, isa.getAddress(), isa.getPort());
+ else if (source instanceof UnixDomainSocketAddress)
+ return handleServiceType(h, data, offset, InetAddress.getByAddress(new byte[4]), 0); // TODO UDS remote
+ else
+ throw new IllegalStateException();
}
/**
diff --git a/src/io/calimero/knxnetip/Discoverer.java b/src/io/calimero/knxnetip/Discoverer.java
index a2b01401..d8c259cd 100644
--- a/src/io/calimero/knxnetip/Discoverer.java
+++ b/src/io/calimero/knxnetip/Discoverer.java
@@ -83,7 +83,7 @@
import io.calimero.KnxRuntimeException;
import io.calimero.internal.Executor;
import io.calimero.internal.UdpSocketLooper;
-import io.calimero.knxnetip.TcpConnection.SecureSession;
+import io.calimero.knxnetip.StreamConnection.SecureSession;
import io.calimero.knxnetip.servicetype.DescriptionRequest;
import io.calimero.knxnetip.servicetype.DescriptionResponse;
import io.calimero.knxnetip.servicetype.KNXnetIPHeader;
@@ -225,6 +225,10 @@ public static DiscovererTcp tcp(final TcpConnection c) {
return new DiscovererTcp(c);
}
+ public static DiscovererTcp uds(final UnixDomainSocketConnection c) {
+ return new DiscovererTcp(c);
+ }
+
/**
* Returns a discoverer which uses the supplied secure session for discovery & description requests.
*
diff --git a/src/io/calimero/knxnetip/DiscovererTcp.java b/src/io/calimero/knxnetip/DiscovererTcp.java
index de8de58a..de9082c6 100644
--- a/src/io/calimero/knxnetip/DiscovererTcp.java
+++ b/src/io/calimero/knxnetip/DiscovererTcp.java
@@ -54,9 +54,10 @@
import io.calimero.KNXFormatException;
import io.calimero.KNXIllegalArgumentException;
import io.calimero.KNXTimeoutException;
+import io.calimero.ServiceType;
import io.calimero.knxnetip.Discoverer.Result;
import io.calimero.knxnetip.KNXnetIPTunnel.TunnelingLayer;
-import io.calimero.knxnetip.TcpConnection.SecureSession;
+import io.calimero.knxnetip.StreamConnection.SecureSession;
import io.calimero.knxnetip.servicetype.DescriptionRequest;
import io.calimero.knxnetip.servicetype.DescriptionResponse;
import io.calimero.knxnetip.servicetype.KNXnetIPHeader;
@@ -72,13 +73,13 @@
* KNX IP Secure connections.
*/
public class DiscovererTcp {
- private final TcpConnection connection;
+ private final StreamConnection connection;
private final SecureSession session;
private volatile Duration timeout = Duration.ofSeconds(10);
- DiscovererTcp(final TcpConnection c) {
+ DiscovererTcp(final StreamConnection c) {
this.connection = c;
this.session = null;
}
@@ -139,7 +140,7 @@ private CompletableFuture> send(final byte[] packet) {
private final class Tunnel extends KNXnetIPTunnel {
private final CompletableFuture> cf;
- Tunnel(final TunnelingLayer knxLayer, final TcpConnection connection,
+ Tunnel(final TunnelingLayer knxLayer, final StreamConnection connection,
final IndividualAddress tunnelingAddress, final CompletableFuture> cf) throws KNXException,
InterruptedException {
super(knxLayer, connection, tunnelingAddress);
@@ -154,9 +155,16 @@ protected boolean handleServiceType(final KNXnetIPHeader h, final byte[] data, f
if (svc == KNXnetIPHeader.SearchResponse || svc == KNXnetIPHeader.DESCRIPTION_RES) {
final var sr = svc == KNXnetIPHeader.SearchResponse ? SearchResponse.from(h, data, offset)
: new DescriptionResponse(data, offset, h.getTotalLength() - h.getStructLength());
- final var result = new Result<>(sr,
- NetworkInterface.getByInetAddress(connection.localEndpoint().getAddress()),
- connection.localEndpoint(), connection.server());
+
+ final Result result;
+ if (connection instanceof final TcpConnection tcp)
+ result = new Result<>(sr, NetworkInterface.getByInetAddress(tcp.localEndpoint().getAddress()),
+ tcp.localEndpoint(), tcp.server());
+ else if (connection instanceof UnixDomainSocketConnection)
+ result = new Result<>(sr, Net.defaultNetif(), new InetSocketAddress(0), new InetSocketAddress(0));
+ else
+ throw new IllegalStateException();
+
complete(result);
return true;
}
@@ -170,11 +178,12 @@ protected boolean handleServiceType(final KNXnetIPHeader h, final byte[] data, f
public String name() {
final String lock = new String(Character.toChars(0x1F512));
final String secure = session != null ? (" " + lock) : "";
- return "KNX IP" + secure + " Tunneling " + hostPort(ctrlEndpt);
+ final String remote = ctrlEndpt != null ? hostPort(ctrlEndpt) : controlEndpoint.toString();
+ return "KNX IP" + secure + " Tunneling " + remote;
}
@Override
- protected void connect(final TcpConnection c, final CRI cri) throws KNXException, InterruptedException {
+ protected void connect(final StreamConnection c, final CRI cri) throws KNXException, InterruptedException {
if (session == null) {
super.connect(c, cri);
return;
@@ -183,7 +192,7 @@ protected void connect(final TcpConnection c, final CRI cri) throws KNXException
session.ensureOpen();
session.registerConnectRequest(this);
try {
- super.connect(c.localEndpoint(), c.server(), cri, false);
+ doConnect(c, cri);
}
finally {
session.unregisterConnectRequest(this);
diff --git a/src/io/calimero/knxnetip/KNXnetIPDevMgmt.java b/src/io/calimero/knxnetip/KNXnetIPDevMgmt.java
index 54fe86bd..70bda238 100644
--- a/src/io/calimero/knxnetip/KNXnetIPDevMgmt.java
+++ b/src/io/calimero/knxnetip/KNXnetIPDevMgmt.java
@@ -1,6 +1,6 @@
/*
Calimero 2 - A library for KNX network access
- Copyright (c) 2006, 2022 B. Malinowsky
+ Copyright (c) 2006, 2024 B. Malinowsky
This program is free software; you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
@@ -63,7 +63,8 @@
import io.calimero.log.LogService;
/**
- * KNXnet/IP connection for KNX local device management, communication on OSI layer 4 is done using UDP.
+ * KNXnet/IP connection for KNX local device management, communication on OSI layer 4 is done using UDP, TCP, or
+ * Unix domain sockets (UDS).
*
* @author B. Malinowsky
*/
@@ -110,7 +111,7 @@ public KNXnetIPDevMgmt(final InetSocketAddress localEP, final InetSocketAddress
* @throws KNXInvalidResponseException if connect response is in wrong format
* @throws InterruptedException on interrupted thread while creating the management connection
*/
- public KNXnetIPDevMgmt(final TcpConnection connection) throws KNXException, InterruptedException {
+ public KNXnetIPDevMgmt(final StreamConnection connection) throws KNXException, InterruptedException {
super(KNXnetIPHeader.DEVICE_CONFIGURATION_REQ, KNXnetIPHeader.DEVICE_CONFIGURATION_ACK, 4,
CONFIGURATION_REQ_TIMEOUT, connection);
connect(connection, cri);
@@ -159,7 +160,7 @@ protected boolean handleServiceType(final KNXnetIPHeader h, final byte[] data, f
final int status = h.getVersion() == KNXNETIP_VERSION_10 ? ErrorCodes.NO_ERROR
: ErrorCodes.VERSION_NOT_SUPPORTED;
- if (!tcp) {
+ if (!stream) {
final int seq = req.getSequenceNumber();
if (seq == getSeqRcv()) {
final byte[] buf = PacketHelper.toPacket(new ServiceAck(serviceAck, channelId, seq, status));
diff --git a/src/io/calimero/knxnetip/KNXnetIPTunnel.java b/src/io/calimero/knxnetip/KNXnetIPTunnel.java
index 9d8d589b..7aa21418 100644
--- a/src/io/calimero/knxnetip/KNXnetIPTunnel.java
+++ b/src/io/calimero/knxnetip/KNXnetIPTunnel.java
@@ -135,11 +135,12 @@ public final int getCode() {
private final TunnelingLayer layer;
- public static KNXnetIPTunnel newTcpTunnel(final TunnelingLayer knxLayer, final TcpConnection connection,
+ public static KNXnetIPTunnel newTcpTunnel(final TunnelingLayer knxLayer, final StreamConnection connection,
final IndividualAddress tunnelingAddress) throws KNXException, InterruptedException {
return new KNXnetIPTunnel(knxLayer, connection, tunnelingAddress);
}
+
/**
* Creates a new KNXnet/IP tunneling connection to a remote server.
*
@@ -174,7 +175,7 @@ public KNXnetIPTunnel(final TunnelingLayer knxLayer, final InetSocketAddress loc
connect(localEP, serverCtrlEP, cri, useNAT);
}
- protected KNXnetIPTunnel(final TunnelingLayer knxLayer, final TcpConnection connection,
+ protected KNXnetIPTunnel(final TunnelingLayer knxLayer, final StreamConnection connection,
final IndividualAddress tunnelingAddress) throws KNXException, InterruptedException {
super(KNXnetIPHeader.TUNNELING_REQ, KNXnetIPHeader.TUNNELING_ACK, 1, TUNNELING_REQ_TIMEOUT, connection);
layer = Objects.requireNonNull(knxLayer, "Tunneling Layer");
@@ -334,7 +335,7 @@ protected boolean handleServiceType(final KNXnetIPHeader h, final byte[] data, f
return true;
// tunneling sequence and ack is only used over udp connections, not tcp
- if (!tcp) {
+ if (!stream) {
final int seq = req.getSequenceNumber();
final boolean missed = ((seq - 1) & 0xFF) == getSeqRcv();
if (missed) {
diff --git a/src/io/calimero/knxnetip/SecureConnection.java b/src/io/calimero/knxnetip/SecureConnection.java
index e129f5ab..4b11f2dc 100644
--- a/src/io/calimero/knxnetip/SecureConnection.java
+++ b/src/io/calimero/knxnetip/SecureConnection.java
@@ -1,6 +1,6 @@
/*
Calimero 2 - A library for KNX network access
- Copyright (c) 2018, 2023 B. Malinowsky
+ Copyright (c) 2018, 2024 B. Malinowsky
This program is free software; you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
@@ -73,7 +73,7 @@
import io.calimero.KNXIllegalArgumentException;
import io.calimero.SerialNumber;
import io.calimero.knxnetip.KNXnetIPTunnel.TunnelingLayer;
-import io.calimero.knxnetip.TcpConnection.SecureSession;
+import io.calimero.knxnetip.StreamConnection.SecureSession;
import io.calimero.knxnetip.servicetype.KNXnetIPHeader;
import io.calimero.link.medium.KNXMediumSettings;
import io.calimero.secure.KnxSecureException;
diff --git a/src/io/calimero/knxnetip/SecureDeviceManagement.java b/src/io/calimero/knxnetip/SecureDeviceManagement.java
index 97009a94..543f4bc5 100644
--- a/src/io/calimero/knxnetip/SecureDeviceManagement.java
+++ b/src/io/calimero/knxnetip/SecureDeviceManagement.java
@@ -1,6 +1,6 @@
/*
Calimero 2 - A library for KNX network access
- Copyright (c) 2018, 2021 B. Malinowsky
+ Copyright (c) 2018, 2024 B. Malinowsky
This program is free software; you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
@@ -42,7 +42,7 @@
import java.net.InetSocketAddress;
import io.calimero.KNXException;
-import io.calimero.knxnetip.TcpConnection.SecureSession;
+import io.calimero.knxnetip.StreamConnection.SecureSession;
import io.calimero.knxnetip.util.CRI;
final class SecureDeviceManagement extends KNXnetIPDevMgmt {
@@ -55,7 +55,7 @@ final class SecureDeviceManagement extends KNXnetIPDevMgmt {
session.registerConnectRequest(this);
try {
final var cri = CRI.createRequest(DEVICE_MGMT_CONNECTION);
- super.connect(session.connection().localEndpoint(), session.connection().server(), cri, false);
+ doConnect(session.connection(), cri);
}
finally {
session.unregisterConnectRequest(this);
@@ -64,11 +64,12 @@ final class SecureDeviceManagement extends KNXnetIPDevMgmt {
@Override
public String name() {
- return "KNX IP " + SecureConnection.secureSymbol + " Management " + hostPort(ctrlEndpt);
+ final var remote = remoteAddress() instanceof final InetSocketAddress isa ? hostPort(isa) : remoteAddress();
+ return "KNX IP " + SecureConnection.secureSymbol + " Management " + remote;
}
@Override
- protected void connect(final TcpConnection c, final CRI cri) {
+ protected void connect(final StreamConnection c, final CRI cri) {
// we don't have session assigned yet, connect in ctor
}
diff --git a/src/io/calimero/knxnetip/SecureSessionUdp.java b/src/io/calimero/knxnetip/SecureSessionUdp.java
index c5ec7dfa..2294815b 100644
--- a/src/io/calimero/knxnetip/SecureSessionUdp.java
+++ b/src/io/calimero/knxnetip/SecureSessionUdp.java
@@ -66,7 +66,7 @@
import io.calimero.KNXIllegalArgumentException;
import io.calimero.KNXTimeoutException;
import io.calimero.SerialNumber;
-import io.calimero.knxnetip.TcpConnection.SecureSession;
+import io.calimero.knxnetip.StreamConnection.SecureSession;
import io.calimero.knxnetip.servicetype.KNXnetIPHeader;
import io.calimero.knxnetip.servicetype.PacketHelper;
import io.calimero.knxnetip.util.HPAI;
diff --git a/src/io/calimero/knxnetip/SecureTunnel.java b/src/io/calimero/knxnetip/SecureTunnel.java
index 2bf7b111..9a17e132 100644
--- a/src/io/calimero/knxnetip/SecureTunnel.java
+++ b/src/io/calimero/knxnetip/SecureTunnel.java
@@ -1,6 +1,6 @@
/*
Calimero 2 - A library for KNX network access
- Copyright (c) 2018, 2021 B. Malinowsky
+ Copyright (c) 2018, 2024 B. Malinowsky
This program is free software; you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
@@ -43,7 +43,7 @@
import io.calimero.IndividualAddress;
import io.calimero.KNXException;
-import io.calimero.knxnetip.TcpConnection.SecureSession;
+import io.calimero.knxnetip.StreamConnection.SecureSession;
import io.calimero.knxnetip.util.CRI;
import io.calimero.knxnetip.util.TunnelCRI;
import io.calimero.link.medium.KNXMediumSettings;
@@ -60,7 +60,7 @@ final class SecureTunnel extends KNXnetIPTunnel {
: new TunnelCRI(knxLayer, tunnelingAddress);
session.registerConnectRequest(this);
try {
- super.connect(session.connection().localEndpoint(), session.connection().server(), cri, false);
+ doConnect(session.connection(), cri);
}
finally {
session.unregisterConnectRequest(this);
@@ -69,11 +69,12 @@ final class SecureTunnel extends KNXnetIPTunnel {
@Override
public String name() {
- return "KNX IP " + SecureConnection.secureSymbol + " Tunneling " + hostPort(ctrlEndpt);
+ final String remote = ctrlEndpt != null ? hostPort(ctrlEndpt) : controlEndpoint.toString();
+ return "KNX IP " + SecureConnection.secureSymbol + " Tunneling " + remote;
}
@Override
- protected void connect(final TcpConnection c, final CRI cri) {
+ protected void connect(final StreamConnection c, final CRI cri) {
// we don't have session assigned yet, connect in ctor
}
diff --git a/src/io/calimero/knxnetip/StreamConnection.java b/src/io/calimero/knxnetip/StreamConnection.java
new file mode 100644
index 00000000..23555508
--- /dev/null
+++ b/src/io/calimero/knxnetip/StreamConnection.java
@@ -0,0 +1,809 @@
+/*
+ Calimero 2 - A library for KNX network access
+ Copyright (c) 2019, 2024 B. Malinowsky
+
+ This program is free software; you can redistribute it and/or modify
+ it under the terms of the GNU General Public License as published by
+ the Free Software Foundation; either version 2 of the License, or
+ (at your option) any later version.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License
+ along with this program; if not, write to the Free Software
+ Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA
+
+ Linking this library statically or dynamically with other modules is
+ making a combined work based on this library. Thus, the terms and
+ conditions of the GNU General Public License cover the whole
+ combination.
+
+ As a special exception, the copyright holders of this library give you
+ permission to link this library with independent modules to produce an
+ executable, regardless of the license terms of these independent
+ modules, and to copy and distribute the resulting executable under terms
+ of your choice, provided that you also meet, for each linked independent
+ module, the terms and conditions of the license of that module. An
+ independent module is a module which is not derived from or based on
+ this library. If you modify this library, you may extend this exception
+ to your version of the library, but you are not obligated to do so. If
+ you do not wish to do so, delete this exception statement from your
+ version.
+*/
+
+package io.calimero.knxnetip;
+
+import static io.calimero.knxnetip.SecureConnection.secureSymbol;
+import static java.lang.System.Logger.Level.DEBUG;
+import static java.lang.System.Logger.Level.ERROR;
+import static java.lang.System.Logger.Level.INFO;
+import static java.lang.System.Logger.Level.TRACE;
+import static java.lang.System.Logger.Level.WARNING;
+import static java.text.MessageFormat.format;
+
+import java.io.Closeable;
+import java.io.IOException;
+import java.io.InterruptedIOException;
+import java.lang.System.Logger;
+import java.lang.System.Logger.Level;
+import java.math.BigInteger;
+import java.net.SocketAddress;
+import java.nio.ByteBuffer;
+import java.security.GeneralSecurityException;
+import java.security.Key;
+import java.security.KeyPair;
+import java.security.KeyPairGenerator;
+import java.security.NoSuchAlgorithmException;
+import java.security.PrivateKey;
+import java.security.interfaces.XECPublicKey;
+import java.time.Duration;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.HexFormat;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.Future;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicLong;
+import java.util.concurrent.locks.Lock;
+import java.util.concurrent.locks.ReentrantLock;
+
+import javax.crypto.Cipher;
+import javax.crypto.SecretKey;
+import javax.crypto.spec.IvParameterSpec;
+
+import io.calimero.CloseEvent;
+import io.calimero.KNXFormatException;
+import io.calimero.KNXIllegalArgumentException;
+import io.calimero.KNXTimeoutException;
+import io.calimero.SerialNumber;
+import io.calimero.internal.Executor;
+import io.calimero.knxnetip.servicetype.KNXnetIPHeader;
+import io.calimero.knxnetip.servicetype.PacketHelper;
+import io.calimero.knxnetip.util.HPAI;
+import io.calimero.log.LogService;
+import io.calimero.secure.Keyring;
+import io.calimero.secure.Keyring.Interface.Type;
+import io.calimero.secure.KnxSecureException;
+
+/**
+ * Connection management for stream connections to KNXnet/IP servers, and for KNX IP secure sessions.
+ */
+public sealed abstract class StreamConnection implements Closeable
+ permits TcpConnection, UnixDomainSocketConnection {
+
+ private final SocketAddress server;
+ private final Logger logger;
+
+ // session ID -> secure session
+ final Map sessions = new ConcurrentHashMap<>();
+ // communication channel ID -> plain connection
+ private final Map unsecuredConnections = new ConcurrentHashMap<>();
+ // we expect fifo processing by the server with multiple ongoing connect requests
+ private final List ongoingConnectRequests = Collections.synchronizedList(new ArrayList<>());
+
+ private final Lock sessionRequestLock = new ReentrantLock();
+ private volatile SecureSession inSessionRequestStage;
+
+
+
+ /**
+ * A KNX IP secure session used over a TCP connection.
+ */
+ public static final class SecureSession implements AutoCloseable {
+
+ // service codes
+ private static final int SecureWrapper = 0x0950;
+ private static final int SecureSessionResponse = 0x0952;
+ private static final int SecureSessionAuth = 0x0953;
+ private static final int SecureSessionStatus = 0x0954;
+
+ // session status codes
+ private static final int AuthSuccess = 0;
+ private static final int AuthFailed = 1;
+ private static final int Unauthenticated = 2;
+ private static final int Timeout = 3;
+ private static final int KeepAlive = 4;
+ private static final int Close = 5;
+ // internal session status we use for initial setup
+ private static final int Setup = 6;
+
+ private static final int keyLength = 32; // [bytes]
+ private static final int macSize = 16; // [bytes]
+
+
+ // timeout session.req -> session.res, and session.auth -> session.status
+ private static final int sessionSetupTimeout = 10_000; // [ms]
+
+ private static final Duration keepAliveInvterval = Duration.ofSeconds(30);
+
+ private static final byte[] emptyUserPwdHash = { (byte) 0xe9, (byte) 0xc3, 0x04, (byte) 0xb9, 0x14, (byte) 0xa3,
+ 0x51, 0x75, (byte) 0xfd, 0x7d, 0x1c, 0x67, 0x3a, (byte) 0xb5, 0x2f, (byte) 0xe1 };
+
+
+ private enum SessionState { Idle, Unauthenticated, Authenticated }
+
+
+ private final StreamConnection conn;
+ private final int user;
+ private final SecretKey userKey;
+ private final SecretKey deviceAuthKey;
+
+ private PrivateKey privateKey;
+ private final byte[] publicKey = new byte[keyLength];
+
+ private final SerialNumber sno;
+
+ private int sessionId;
+ private volatile SessionState sessionState = SessionState.Idle;
+ private volatile int sessionStatus = Setup;
+ Key secretKey;
+
+ private final AtomicLong sendSeq = new AtomicLong();
+ private final AtomicLong rcvSeq = new AtomicLong();
+
+ // assign dummy to have it initialized
+ private Future> keepAliveFuture = CompletableFuture.completedFuture(Void.TYPE);
+
+ // communication channel ID -> secured connection
+ final Map securedConnections = new ConcurrentHashMap<>();
+ // we expect fifo processing by the server with multiple ongoing connect requests
+ private final List ongoingConnectRequests = Collections.synchronizedList(new ArrayList<>());
+
+ private final Logger logger;
+
+
+ SecureSession(final StreamConnection connection, final int user, final byte[] userKey,
+ final byte[] deviceAuthCode, final SerialNumber serialNumber) {
+ this.conn = connection;
+ if (user < 1 || user > 127)
+ throw new KNXIllegalArgumentException("user " + user + " out of range [1..127]");
+ this.user = user;
+
+ final byte[] key = userKey.length == 0 ? emptyUserPwdHash.clone() : userKey;
+ this.userKey = SecureConnection.createSecretKey(key);
+
+ final var authCode = deviceAuthCode.length == 0 ? new byte[16] : deviceAuthCode;
+ this.deviceAuthKey = SecureConnection.createSecretKey(authCode);
+
+ sno = serialNumber;
+
+ logger = LogService.getLogger("io.calimero.knxnetip." + secureSymbol + " Session " + conn.socketName(conn.server));
+ }
+
+ /**
+ * @return the session identifier assigned by the server
+ */
+ public int id() { return sessionId; }
+
+ public int user() { return user; }
+
+ public SecretKey userKey() { return userKey; }
+
+ public SerialNumber serialNumber() { return sno; }
+
+ public StreamConnection connection() { return conn; }
+
+ @Override
+ public void close() {
+ close(CloseEvent.USER_REQUEST, "user request");
+ }
+
+ void close(final int initiator, final String reason) {
+ if (sessionState == SessionState.Idle)
+ return;
+
+ sessionState = SessionState.Idle;
+ keepAliveFuture.cancel(false);
+ securedConnections.values().forEach(c -> c.close(initiator, reason, Level.DEBUG, null));
+ securedConnections.clear();
+ conn.sessions.remove(sessionId);
+
+ if (conn.streamClosed())
+ return;
+ try {
+ conn.send(newStatusInfo(sessionId, nextSendSeq(), Close));
+ }
+ catch (final IOException e) {
+ logger.log(INFO, "I/O error closing secure session " + sessionId, e);
+ }
+ }
+
+ @Override
+ public String toString() {
+ return secureSymbol + " session " + sessionId + " (user " + user + "): " + sessionState;
+ }
+
+ SecretKey deviceAuthKey() { return deviceAuthKey; }
+
+ void ensureOpen() throws KNXTimeoutException, KNXConnectionClosedException, InterruptedException {
+ if (sessionState == SessionState.Authenticated)
+ return;
+ setupSecureSession();
+ }
+
+ void registerConnectRequest(final ClientConnection c) { ongoingConnectRequests.add(c); }
+
+ void unregisterConnectRequest(final ClientConnection c) {
+ ongoingConnectRequests.remove(c);
+ if (c.getState() == KNXnetIPConnection.OK)
+ securedConnections.put(c.channelId, c);
+ }
+
+ long nextSendSeq() { return sendSeq.getAndIncrement(); }
+
+ long nextReceiveSeq() { return rcvSeq.getAndIncrement(); }
+
+ static int newChannelStatus(final KNXnetIPHeader h, final byte[] data, final int offset)
+ throws KNXFormatException {
+
+ if (h.getServiceType() != SecureSessionStatus)
+ throw new KNXIllegalArgumentException("no secure channel status");
+ if (h.getTotalLength() != 8)
+ throw new KNXFormatException("invalid length " + h.getTotalLength() + " for a secure channel status");
+
+ // 0: auth success
+ // 1: auth failed
+ // 2: error unauthorized
+ // 3: timeout
+ final int status = data[offset] & 0xff;
+ return status;
+ }
+
+ private void setupSecureSession()
+ throws KNXTimeoutException, KNXConnectionClosedException, InterruptedException {
+ conn.sessionRequestLock.lock();
+ final var socketName = socketName(conn.server);
+ try {
+ if (sessionState == SessionState.Authenticated)
+ return;
+ sessionState = SessionState.Idle;
+ sessionStatus = Setup;
+ conn.inSessionRequestStage = this;
+
+ logger.log(DEBUG, "setup secure session with {0}", socketName);
+
+ initKeys();
+ conn.connect();
+ final byte[] sessionReq = PacketHelper.newChannelRequest(HPAI.Tcp, publicKey);
+ conn.send(sessionReq);
+ awaitAuthenticationStatus();
+
+ if (sessionState == SessionState.Unauthenticated || sessionStatus != AuthSuccess) {
+ sessionState = SessionState.Idle;
+ throw new KnxSecureException("secure session " + SecureConnection.statusMsg(sessionStatus));
+ }
+ if (sessionState == SessionState.Idle)
+ throw new KNXTimeoutException("timeout establishing secure session with " + socketName);
+
+ final var delay = keepAliveInvterval.toMillis();
+ keepAliveFuture = Executor.scheduledExecutor().scheduleWithFixedDelay(this::sendKeepAlive, delay, delay,
+ TimeUnit.MILLISECONDS);
+ }
+ catch (final GeneralSecurityException e) {
+ throw new KnxSecureException("error creating key pair for " + socketName, e);
+ }
+ catch (final IOException e) {
+ close();
+ final String reason = "I/O error establishing secure session with " + socketName;
+ conn.close(CloseEvent.INTERNAL, reason);
+ throw new KNXConnectionClosedException(reason, e);
+ }
+ finally {
+ conn.sessionRequestLock.unlock();
+ Arrays.fill(publicKey, (byte) 0);
+ }
+ }
+
+ private void initKeys() throws NoSuchAlgorithmException {
+ final var keyPair = generateKeyPair();
+ privateKey = keyPair.getPrivate();
+
+ final BigInteger u = ((XECPublicKey) keyPair.getPublic()).getU();
+ // we need public key in little endian
+ final byte[] tmp = u.toByteArray();
+ reverse(tmp);
+ System.arraycopy(tmp, 0, publicKey, 0, tmp.length);
+ Arrays.fill(tmp, (byte) 0);
+ }
+
+ private void awaitAuthenticationStatus() throws InterruptedException, KNXTimeoutException {
+ long end = System.nanoTime() / 1_000_000 + sessionSetupTimeout;
+ long remaining = sessionSetupTimeout;
+ boolean inAuth = false;
+ while (remaining > 0 && sessionState != SessionState.Authenticated && sessionStatus == Setup) {
+ synchronized (this) {
+ wait(remaining);
+ }
+ remaining = end - System.nanoTime() / 1_000_000;
+ if (sessionState == SessionState.Unauthenticated && !inAuth) {
+ inAuth = true;
+ end = end - remaining + sessionSetupTimeout;
+ }
+ }
+ if (remaining <= 0)
+ throw new KNXTimeoutException("timeout establishing secure session with " + socketName(conn.server));
+ }
+
+ boolean acceptServiceType(final KNXnetIPHeader h, final byte[] data, final int offset, final int length)
+ throws KNXFormatException {
+ final int svc = h.getServiceType();
+ if (!h.isSecure())
+ throw new KnxSecureException(String.format("dispatched insecure service type 0x%h to %s", svc, this));
+
+ // ensure minimum secure wrapper frame size (6 header, 16 security info, 6 encapsulated header, 16 MAC)
+ if (h.getTotalLength() < 44)
+ return false;
+
+ if (svc == SecureSessionResponse) {
+ if (sessionState != SessionState.Idle) {
+ logger.log(WARNING, "received session response in state {0} - ignore", sessionState);
+ return true;
+ }
+ try {
+ final byte[] serverPublicKey = parseSessionResponse(h, data, offset, conn.server);
+ final byte[] auth = newSessionAuth(serverPublicKey);
+ sessionState = SessionState.Unauthenticated;
+ final byte[] packet = wrap(auth);
+ logger.log(DEBUG, "secure session {0}, request access for user {1}", sessionId, user);
+ conn.send(packet);
+ }
+ catch (IOException | RuntimeException e) {
+ sessionStatus = AuthFailed;
+ logger.log(ERROR, "negotiating session key failed", e);
+ }
+ synchronized (this) {
+ notifyAll();
+ }
+ }
+ else if (svc == SecureWrapper) {
+ final byte[] packet = unwrap(h, data, offset);
+ final var plainHeader = new KNXnetIPHeader(packet, 0);
+ final var hdrLen = plainHeader.getStructLength();
+
+ if (plainHeader.getServiceType() == SecureSessionStatus) {
+ sessionStatus = newChannelStatus(plainHeader, packet, hdrLen);
+
+ if (sessionState == SessionState.Unauthenticated) {
+ if (sessionStatus == AuthSuccess)
+ sessionState = SessionState.Authenticated;
+
+ logger.log(sessionStatus == AuthSuccess ? DEBUG : ERROR, "{0} {1}",
+ SecureConnection.statusMsg(sessionStatus), this);
+ synchronized (this) {
+ notifyAll();
+ }
+ }
+ else if (sessionStatus == Timeout || sessionStatus == Unauthenticated) {
+ logger.log(ERROR, "{0} {1}", SecureConnection.statusMsg(sessionStatus), this);
+ close();
+ }
+ }
+ else
+ dispatchToConnection(plainHeader, packet, hdrLen, plainHeader.getTotalLength() - hdrLen);
+ }
+ else
+ logger.log(WARNING, "received unsupported secure service type 0x{0} - ignore", Integer.toHexString(svc));
+
+ return true;
+ }
+
+ private void dispatchToConnection(final KNXnetIPHeader header, final byte[] data, final int offset,
+ final int length) {
+
+ final int svcType = header.getServiceType();
+ if (svcType == KNXnetIPHeader.SearchResponse || svcType == KNXnetIPHeader.DESCRIPTION_RES) {
+ for (final var client : securedConnections.values())
+ try {
+ client.handleServiceType(header, data, offset, conn.server);
+ }
+ catch (KNXFormatException | IOException e) {
+ logger.log(WARNING, format("{0} error processing {1}", client, header), e);
+ }
+ return;
+ }
+
+
+ final var channelId = channelId(header, data, offset);
+ var connection = securedConnections.get(channelId);
+ if (connection == null) {
+ synchronized (ongoingConnectRequests) {
+ if (!ongoingConnectRequests.isEmpty())
+ connection = ongoingConnectRequests.remove(0);
+ }
+ }
+
+ try {
+ if (connection != null) {
+ connection.handleServiceType(header, data, offset, conn.server);
+ if (header.getServiceType() == KNXnetIPHeader.DISCONNECT_RES) {
+ logger.log(TRACE, "remove connection {0}", connection);
+ securedConnections.remove(channelId);
+ }
+ }
+ else
+ logger.log(WARNING, "communication channel {0} does not exist", channelId);
+ }
+ catch (KNXFormatException | IOException e) {
+ logger.log(WARNING, format("{0} error processing {1}", connection, header), e);
+ }
+ }
+
+ private void sendKeepAlive() {
+ try {
+ logger.log(TRACE, "sending keep-alive");
+ conn.send(newStatusInfo(sessionId, nextSendSeq(), KeepAlive));
+ }
+ catch (final IOException e) {
+ if (sessionState == SessionState.Authenticated && !conn.streamClosed()) {
+ logger.log(WARNING, "error sending keep-alive: {0}", e.getMessage());
+ close();
+ conn.close(CloseEvent.INTERNAL, "error sending keep-alive");
+ }
+ }
+ }
+
+ private String socketName(final SocketAddress addr) {
+ return conn.socketName(addr);
+ }
+
+ private byte[] wrap(final byte[] plainPacket) {
+ return SecureConnection.newSecurePacket(sessionId, nextSendSeq(), sno, 0, plainPacket, secretKey);
+ }
+
+ private byte[] unwrap(final KNXnetIPHeader h, final byte[] data, final int offset) throws KNXFormatException {
+ final Object[] fields = SecureConnection.unwrap(h, data, offset, secretKey);
+
+ final int sid = (int) fields[0];
+ if (sid != sessionId)
+ throw new KnxSecureException("secure session mismatch: received ID " + sid + ", expected " + sessionId);
+
+ final long seq = (long) fields[1];
+ if (seq < rcvSeq.get())
+ throw new KnxSecureException("received secure packet with sequence " + seq + " < expected " + rcvSeq);
+ rcvSeq.incrementAndGet();
+
+ final var sn = (SerialNumber) fields[2];
+ final int tag = (int) fields[3];
+ if (tag != 0)
+ throw new KnxSecureException("expected message tag 0, received " + tag);
+ final byte[] knxipPacket = (byte[]) fields[4];
+ logger.log(TRACE, "received (seq {0} S/N {1}) {2}", seq, sn, HexFormat.ofDelimiter(" ").formatHex(knxipPacket));
+ return knxipPacket;
+ }
+
+ private byte[] parseSessionResponse(final KNXnetIPHeader h, final byte[] data, final int offset,
+ final SocketAddress remote) throws KNXFormatException {
+
+ if (h.getServiceType() != SecureSessionResponse)
+ throw new KNXIllegalArgumentException("no secure channel response");
+ if (h.getTotalLength() != 0x38)
+ throw new KNXFormatException("invalid length " + data.length + " for a secure session response");
+
+ final ByteBuffer buffer = ByteBuffer.wrap(data, offset, h.getTotalLength() - h.getStructLength());
+
+ sessionId = buffer.getShort() & 0xffff;
+ if (sessionId == 0)
+ throw new KnxSecureException("no more free secure sessions, or remote endpoint busy");
+
+ final byte[] serverPublicKey = new byte[keyLength];
+ buffer.get(serverPublicKey);
+
+ final byte[] sharedSecret = SecureConnection.keyAgreement(privateKey, serverPublicKey);
+ final byte[] sessionKey = SecureConnection.sessionKey(sharedSecret);
+ synchronized (this) {
+ secretKey = SecureConnection.createSecretKey(sessionKey);
+ }
+
+ conn.sessions.put(sessionId, this);
+ conn.inSessionRequestStage = null;
+
+ final boolean skipDeviceAuth = Arrays.equals(deviceAuthKey.getEncoded(), new byte[16]);
+ if (skipDeviceAuth) {
+ logger.log(WARNING, "skipping device authentication of {0} (no device key)", socketName(remote));
+ }
+ else {
+ final ByteBuffer mac = SecureConnection.decrypt(buffer, deviceAuthKey,
+ SecureConnection.securityInfo(new byte[16], 0, 0xff00));
+
+ final int msgLen = h.getStructLength() + 2 + keyLength;
+ final ByteBuffer macInput = ByteBuffer.allocate(16 + 2 + msgLen);
+ macInput.put(new byte[16]);
+ macInput.put((byte) 0);
+ macInput.put((byte) msgLen);
+ macInput.put(h.toByteArray());
+ macInput.putShort((short) sessionId);
+ macInput.put(SecureConnection.xor(serverPublicKey, 0, publicKey, 0, keyLength));
+
+ final byte[] verifyAgainst = cbcMacSimple(deviceAuthKey, macInput.array(), 0, macInput.capacity());
+ final boolean authenticated = Arrays.equals(mac.array(), verifyAgainst);
+ if (!authenticated) {
+ final String packet = HexFormat.ofDelimiter(" ").formatHex(data, offset - 6, offset - 6 + 0x38);
+ throw new KnxSecureException("authentication failed for session response " + packet);
+ }
+ }
+
+ return serverPublicKey;
+ }
+
+ private byte[] newSessionAuth(final byte[] serverPublicKey) {
+ final var header = new KNXnetIPHeader(SecureSessionAuth, 2 + macSize);
+
+ final ByteBuffer buffer = ByteBuffer.allocate(header.getTotalLength());
+ buffer.put(header.toByteArray());
+ buffer.putShort((short) user);
+
+ final int msgLen = 6 + 2 + keyLength;
+ final ByteBuffer macInput = ByteBuffer.allocate(16 + 2 + msgLen);
+ macInput.put(new byte[16]);
+ macInput.put((byte) 0);
+ macInput.put((byte) msgLen);
+ macInput.put(buffer.array(), 0, buffer.position());
+ macInput.put(SecureConnection.xor(serverPublicKey, 0, publicKey, 0, keyLength));
+ final byte[] mac = cbcMacSimple(userKey, macInput.array(), 0, macInput.capacity());
+ SecureConnection.encrypt(mac, 0, userKey, SecureConnection.securityInfo(new byte[16], 8, 0xff00));
+
+ buffer.put(mac);
+ return buffer.array();
+ }
+
+ private byte[] newStatusInfo(final int sessionId, final long seq, final int status) {
+ final ByteBuffer packet = ByteBuffer.allocate(6 + 2);
+ packet.put(new KNXnetIPHeader(SecureSessionStatus, 2).toByteArray());
+ packet.put((byte) status);
+ final int msgTag = 0;
+ return SecureConnection.newSecurePacket(sessionId, seq, sno, msgTag, packet.array(), secretKey);
+ }
+
+ private byte[] cbcMacSimple(final Key secretKey, final byte[] data, final int offset, final int length) {
+ final byte[] log = Arrays.copyOfRange(data, offset, offset + length);
+ logger.log(TRACE, "authenticating (length {0}): {1}", length, HexFormat.ofDelimiter(" ").formatHex(log));
+
+ try {
+ final var cipher = Cipher.getInstance("AES/CBC/NoPadding");
+ final var ivSpec = new IvParameterSpec(new byte[16]);
+ cipher.init(Cipher.ENCRYPT_MODE, secretKey, ivSpec);
+
+ final byte[] padded = Arrays.copyOfRange(data, offset, (length + 15) / 16 * 16);
+ final byte[] result = cipher.doFinal(padded);
+ final byte[] mac = Arrays.copyOfRange(result, result.length - macSize, result.length);
+ return mac;
+ }
+ catch (final GeneralSecurityException e) {
+ throw new KnxSecureException("calculating CBC-MAC of " + HexFormat.ofDelimiter(" ").formatHex(log), e);
+ }
+ }
+
+ private static KeyPair generateKeyPair() throws NoSuchAlgorithmException {
+ final KeyPairGenerator gen = KeyPairGenerator.getInstance("X25519");
+ return gen.generateKeyPair();
+ }
+ }
+
+ StreamConnection(final SocketAddress server) {
+ this.server = server;
+ logger = LogService.getLogger("io.calimero.knxnetip." + socketName(server));
+ }
+
+ /**
+ * Creates a new secure session for this TCP connection.
+ *
+ * @param user user to authenticate for the session, {@code 0 < user < 128}
+ * @param userKey user key with {@code userKey.length == 16}
+ * @param deviceAuthCode device authentication code with {@code deviceAuthCode.length == 16}, a
+ * {@code deviceAuthCode.length == 0} will skip device authentication
+ * @return new secure session
+ */
+ // TODO check if for domain sockets we can pass a nicer serial number
+ public SecureSession newSecureSession(final int user, final byte[] userKey, final byte[] deviceAuthCode) {
+ return new SecureSession(this, user, userKey, deviceAuthCode, SerialNumber.Zero);
+ }
+
+ public SecureSession newSecureSession(final Keyring.DecryptedInterface tunnelInterface) {
+ if (tunnelInterface.type() != Type.Tunneling)
+ throw new IllegalArgumentException("'" + tunnelInterface + "' is not a tunneling interface");
+ return newSecureSession(tunnelInterface.user(), tunnelInterface.userKey(), tunnelInterface.deviceAuthCode());
+ }
+
+ abstract SocketAddress localEndpoint();
+
+ public SocketAddress server() { return server; }
+
+ public abstract boolean isConnected();
+
+ abstract boolean streamClosed();
+
+ /**
+ * Closes this connection and all its contained KNXnet/IP connections and secure sessions.
+ */
+ @Override
+ public void close() {
+ close(CloseEvent.USER_REQUEST, "user request");
+ }
+
+ void close(final int initiator, final String reason) {
+ unsecuredConnections.values().forEach(t -> t.close(initiator, reason, Level.DEBUG, null));
+ unsecuredConnections.clear();
+
+ sessions.values().forEach(s -> s.close(initiator, reason));
+ sessions.clear();
+ }
+
+ abstract void send(final byte[] data) throws IOException;
+
+ void registerConnectRequest(final ClientConnection c) { ongoingConnectRequests.add(c); }
+
+ void unregisterConnectRequest(final ClientConnection c) {
+ ongoingConnectRequests.remove(c);
+ registerConnection(c);
+ }
+
+ public void registerConnection(final ClientConnection c) {
+ if (c.getState() == KNXnetIPConnection.OK)
+ unsecuredConnections.put(c.channelId, c);
+ }
+
+ public abstract void connect() throws IOException;
+
+ void startReceiver() {
+ Executor.execute(this::runReceiveLoop, "KNXnet/IP receiver " + socketName(server()));
+ }
+
+ abstract String socketName(SocketAddress addr);
+
+ void runReceiveLoop() {
+ final int rcvBufferSize = 512;
+ final byte[] data = new byte[rcvBufferSize];
+ int offset = 0;
+
+ int initiator = CloseEvent.USER_REQUEST;
+ String reason = "user request";
+ try {
+ while (!streamClosed()) {
+ if (offset >= 6) {
+ try {
+ final var header = new KNXnetIPHeader(data, 0);
+ if (header.getTotalLength() <= offset) {
+ final int length = header.getTotalLength() - header.getStructLength();
+ final int leftover = offset - header.getTotalLength();
+ offset = leftover;
+
+ if (header.isSecure())
+ dispatchToSession(header, data, header.getStructLength(), length);
+ else
+ dispatchToConnection(header, data, header.getStructLength());
+
+ if (leftover > 0) {
+ System.arraycopy(data, header.getTotalLength(), data, 0, leftover);
+ continue;
+ }
+ }
+ // skip bodies which do not fit into rcv buffer
+ else if (header.getTotalLength() > rcvBufferSize) {
+ int skip = header.getTotalLength() - offset;
+ while (skip-- > 0 && read(new byte[1], 0) != -1);
+ offset = 0;
+ }
+ }
+ catch (KNXFormatException | KnxSecureException e) {
+ logger.log(WARNING, "received invalid frame", e);
+ offset = 0;
+ }
+ }
+
+ final int read = read(data, offset);
+ if (read == -1) {
+ initiator = CloseEvent.SERVER_REQUEST;
+ reason = "server request";
+ return;
+ }
+ offset += read;
+ }
+ }
+ catch (final InterruptedIOException e) {
+ Thread.currentThread().interrupt();
+ }
+ catch (IOException | RuntimeException e) {
+ if (!streamClosed()) {
+ initiator = CloseEvent.INTERNAL;
+ reason = e.getMessage();
+ logger.log(ERROR, "receiver communication failure", e);
+ }
+ }
+ finally {
+ close(initiator, reason);
+ }
+ }
+
+ abstract int read(byte[] data, int offset) throws IOException;
+
+ private void dispatchToSession(final KNXnetIPHeader header, final byte[] data, final int offset, final int length)
+ throws KNXFormatException {
+ final var sessionId = ByteBuffer.wrap(data, offset, length).getShort() & 0xffff;
+ if (sessionId == 0)
+ throw new KnxSecureException("no more free secure sessions, or remote endpoint busy");
+
+ var session = sessions.get(sessionId);
+ if (session == null && header.getServiceType() == SecureSession.SecureSessionResponse)
+ session = inSessionRequestStage;
+
+ if (session != null)
+ session.acceptServiceType(header, data, offset, length);
+ else
+ logger.log(WARNING, "session {0} does not exist", sessionId);
+ }
+
+ private void dispatchToConnection(final KNXnetIPHeader header, final byte[] data, final int offset)
+ throws IOException, KNXFormatException {
+ final int svcType = header.getServiceType();
+ if (svcType == KNXnetIPHeader.SearchResponse || svcType == KNXnetIPHeader.DESCRIPTION_RES) {
+ for (final var client : unsecuredConnections.values())
+ client.handleServiceType(header, data, offset, server);
+ return;
+ }
+
+ final var channelId = channelId(header, data, offset);
+ var connection = unsecuredConnections.get(channelId);
+ if (connection == null) {
+ synchronized (ongoingConnectRequests) {
+ if (!ongoingConnectRequests.isEmpty())
+ connection = ongoingConnectRequests.remove(0);
+ }
+ }
+
+ if (connection != null) {
+ connection.handleServiceType(header, data, offset, server);
+ if (svcType == KNXnetIPHeader.DISCONNECT_RES)
+ unsecuredConnections.remove(channelId);
+ }
+ else
+ logger.log(WARNING, "communication channel {0} does not exist", channelId);
+ }
+
+ private static int channelId(final KNXnetIPHeader header, final byte[] data, final int offset) {
+ // communication channel ID in the connection header of a tunneling/config request has a different offset
+ // than in connection management services
+ final int channelIdOffset = switch (header.getServiceType()) {
+ case KNXnetIPHeader.TUNNELING_REQ, KNXnetIPHeader.DEVICE_CONFIGURATION_REQ,
+ KNXnetIPHeader.TunnelingFeatureResponse, KNXnetIPHeader.TunnelingFeatureInfo,
+ KNXnetIPHeader.ObjectServerRequest, KNXnetIPHeader.ObjectServerAck -> offset + 1;
+ default -> offset;
+ };
+ final var channelId = data[channelIdOffset] & 0xff;
+ return channelId;
+ }
+
+ private static void reverse(final byte[] array) {
+ for (int i = 0; i < array.length / 2; i++) {
+ final byte b = array[i];
+ array[i] = array[array.length - 1 - i];
+ array[array.length - 1 - i] = b;
+ }
+ }
+}
diff --git a/src/io/calimero/knxnetip/TcpConnection.java b/src/io/calimero/knxnetip/TcpConnection.java
index be5e720d..7d8bae96 100644
--- a/src/io/calimero/knxnetip/TcpConnection.java
+++ b/src/io/calimero/knxnetip/TcpConnection.java
@@ -36,72 +36,23 @@
package io.calimero.knxnetip;
-import static io.calimero.knxnetip.Net.hostPort;
-import static io.calimero.knxnetip.SecureConnection.secureSymbol;
-import static java.lang.System.Logger.Level.DEBUG;
-import static java.lang.System.Logger.Level.ERROR;
-import static java.lang.System.Logger.Level.INFO;
-import static java.lang.System.Logger.Level.TRACE;
-import static java.lang.System.Logger.Level.WARNING;
-import static java.text.MessageFormat.format;
-
-import java.io.Closeable;
import java.io.IOException;
-import java.io.InterruptedIOException;
-import java.lang.System.Logger;
-import java.lang.System.Logger.Level;
-import java.math.BigInteger;
import java.net.InetSocketAddress;
import java.net.NetworkInterface;
import java.net.Socket;
+import java.net.SocketAddress;
import java.net.SocketException;
-import java.nio.ByteBuffer;
-import java.security.GeneralSecurityException;
-import java.security.Key;
-import java.security.KeyPair;
-import java.security.KeyPairGenerator;
-import java.security.NoSuchAlgorithmException;
-import java.security.PrivateKey;
-import java.security.interfaces.XECPublicKey;
import java.time.Duration;
-import java.util.ArrayList;
import java.util.Arrays;
-import java.util.Collections;
-import java.util.HexFormat;
-import java.util.List;
-import java.util.Map;
-import java.util.concurrent.CompletableFuture;
-import java.util.concurrent.ConcurrentHashMap;
-import java.util.concurrent.Future;
-import java.util.concurrent.TimeUnit;
-import java.util.concurrent.atomic.AtomicLong;
-import java.util.concurrent.locks.Lock;
-import java.util.concurrent.locks.ReentrantLock;
-
-import javax.crypto.Cipher;
-import javax.crypto.SecretKey;
-import javax.crypto.spec.IvParameterSpec;
-import io.calimero.CloseEvent;
import io.calimero.KNXException;
-import io.calimero.KNXFormatException;
-import io.calimero.KNXIllegalArgumentException;
-import io.calimero.KNXTimeoutException;
import io.calimero.KnxRuntimeException;
import io.calimero.SerialNumber;
-import io.calimero.internal.Executor;
-import io.calimero.knxnetip.servicetype.KNXnetIPHeader;
-import io.calimero.knxnetip.servicetype.PacketHelper;
-import io.calimero.knxnetip.util.HPAI;
-import io.calimero.log.LogService;
-import io.calimero.secure.Keyring;
-import io.calimero.secure.Keyring.Interface.Type;
-import io.calimero.secure.KnxSecureException;
/**
* Connection management for TCP connections to KNXnet/IP servers, and for KNX IP secure sessions.
*/
-public final class TcpConnection implements Closeable {
+public final class TcpConnection extends StreamConnection {
// pseudo connection, so we can still run with udp
static final TcpConnection Udp = new TcpConnection(new InetSocketAddress(0));
@@ -109,529 +60,8 @@ public final class TcpConnection implements Closeable {
private static final Duration connectionTimeout = Duration.ofMillis(5000);
private volatile InetSocketAddress localEndpoint;
- // ??? we currently cannot reuse a connection once it got closed
- private final InetSocketAddress server;
private final Socket socket;
- private final Logger logger;
-
- // session ID -> secure session
- final Map sessions = new ConcurrentHashMap<>();
- // communication channel ID -> plain connection
- private final Map unsecuredConnections = new ConcurrentHashMap<>();
- // we expect fifo processing by the server with multiple ongoing connect requests
- private final List ongoingConnectRequests = Collections.synchronizedList(new ArrayList<>());
-
- private final Lock sessionRequestLock = new ReentrantLock();
- private volatile SecureSession inSessionRequestStage;
-
-
-
- /**
- * A KNX IP secure session used over a TCP connection.
- */
- public static final class SecureSession implements AutoCloseable {
-
- // service codes
- private static final int SecureWrapper = 0x0950;
- private static final int SecureSessionResponse = 0x0952;
- private static final int SecureSessionAuth = 0x0953;
- private static final int SecureSessionStatus = 0x0954;
-
- // session status codes
- private static final int AuthSuccess = 0;
- private static final int AuthFailed = 1;
- private static final int Unauthenticated = 2;
- private static final int Timeout = 3;
- private static final int KeepAlive = 4;
- private static final int Close = 5;
- // internal session status we use for initial setup
- private static final int Setup = 6;
-
- private static final int keyLength = 32; // [bytes]
- private static final int macSize = 16; // [bytes]
-
-
- // timeout session.req -> session.res, and session.auth -> session.status
- private static final int sessionSetupTimeout = 10_000; // [ms]
-
- private static final Duration keepAliveInvterval = Duration.ofSeconds(30);
-
- private static final byte[] emptyUserPwdHash = { (byte) 0xe9, (byte) 0xc3, 0x04, (byte) 0xb9, 0x14, (byte) 0xa3,
- 0x51, 0x75, (byte) 0xfd, 0x7d, 0x1c, 0x67, 0x3a, (byte) 0xb5, 0x2f, (byte) 0xe1 };
-
-
- private enum SessionState { Idle, Unauthenticated, Authenticated }
-
-
- private final TcpConnection conn;
- private final int user;
- private final SecretKey userKey;
- private final SecretKey deviceAuthKey;
-
- private PrivateKey privateKey;
- private final byte[] publicKey = new byte[keyLength];
-
- private final SerialNumber sno;
-
- private int sessionId;
- private volatile SessionState sessionState = SessionState.Idle;
- private volatile int sessionStatus = Setup;
- Key secretKey;
-
- private final AtomicLong sendSeq = new AtomicLong();
- private final AtomicLong rcvSeq = new AtomicLong();
-
- // assign dummy to have it initialized
- private Future> keepAliveFuture = CompletableFuture.completedFuture(Void.TYPE);
-
- // communication channel ID -> secured connection
- final Map securedConnections = new ConcurrentHashMap<>();
- // we expect fifo processing by the server with multiple ongoing connect requests
- private final List ongoingConnectRequests = Collections.synchronizedList(new ArrayList<>());
-
- private final Logger logger;
-
-
- private SecureSession(final TcpConnection connection, final int user, final byte[] userKey,
- final byte[] deviceAuthCode) {
- this.conn = connection;
- if (user < 1 || user > 127)
- throw new KNXIllegalArgumentException("user " + user + " out of range [1..127]");
- this.user = user;
-
- final byte[] key = userKey.length == 0 ? emptyUserPwdHash.clone() : userKey;
- this.userKey = SecureConnection.createSecretKey(key);
-
- final var authCode = deviceAuthCode.length == 0 ? new byte[16] : deviceAuthCode;
- this.deviceAuthKey = SecureConnection.createSecretKey(authCode);
-
- sno = deriveSerialNumber(conn.localEndpoint());
-
- logger = LogService.getLogger("io.calimero.knxnetip." + secureSymbol + " Session " + hostPort(conn.server));
- }
-
- /**
- * @return the session identifier assigned by the server
- */
- public int id() { return sessionId; }
-
- public int user() { return user; }
-
- public SecretKey userKey() { return userKey; }
-
- public SerialNumber serialNumber() { return sno; }
-
- public TcpConnection connection() { return conn; }
-
- @Override
- public void close() {
- if (sessionState == SessionState.Idle)
- return;
-
- sessionState = SessionState.Idle;
- keepAliveFuture.cancel(false);
- securedConnections.values().forEach(ClientConnection::close);
- securedConnections.clear();
- conn.sessions.remove(sessionId);
-
- if (conn.socket.isClosed())
- return;
- try {
- conn.send(newStatusInfo(sessionId, nextSendSeq(), Close));
- }
- catch (final IOException e) {
- logger.log(INFO, "I/O error closing secure session " + sessionId, e);
- }
- }
-
- @Override
- public String toString() {
- return secureSymbol + " session " + sessionId + " (user " + user + "): " + sessionState;
- }
-
- SecretKey deviceAuthKey() { return deviceAuthKey; }
-
- void ensureOpen() throws KNXTimeoutException, KNXConnectionClosedException, InterruptedException {
- if (sessionState == SessionState.Authenticated)
- return;
- setupSecureSession();
- }
-
- void registerConnectRequest(final ClientConnection c) { ongoingConnectRequests.add(c); }
-
- void unregisterConnectRequest(final ClientConnection c) {
- ongoingConnectRequests.remove(c);
- if (c.getState() == KNXnetIPConnection.OK)
- securedConnections.put(c.channelId, c);
- }
-
- long nextSendSeq() { return sendSeq.getAndIncrement(); }
-
- long nextReceiveSeq() { return rcvSeq.getAndIncrement(); }
-
- static int newChannelStatus(final KNXnetIPHeader h, final byte[] data, final int offset)
- throws KNXFormatException {
-
- if (h.getServiceType() != SecureSessionStatus)
- throw new KNXIllegalArgumentException("no secure channel status");
- if (h.getTotalLength() != 8)
- throw new KNXFormatException("invalid length " + h.getTotalLength() + " for a secure channel status");
-
- // 0: auth success
- // 1: auth failed
- // 2: error unauthorized
- // 3: timeout
- final int status = data[offset] & 0xff;
- return status;
- }
-
- private void setupSecureSession()
- throws KNXTimeoutException, KNXConnectionClosedException, InterruptedException {
- conn.sessionRequestLock.lock();
- final var hostPort = hostPort(conn.server);
- try {
- if (sessionState == SessionState.Authenticated)
- return;
- sessionState = SessionState.Idle;
- sessionStatus = Setup;
- conn.inSessionRequestStage = this;
-
- logger.log(DEBUG, "setup secure session with {0}", hostPort);
-
- initKeys();
- conn.connect();
- final byte[] sessionReq = PacketHelper.newChannelRequest(HPAI.Tcp, publicKey);
- conn.send(sessionReq);
- awaitAuthenticationStatus();
-
- if (sessionState == SessionState.Unauthenticated || sessionStatus != AuthSuccess) {
- sessionState = SessionState.Idle;
- throw new KnxSecureException("secure session " + SecureConnection.statusMsg(sessionStatus));
- }
- if (sessionState == SessionState.Idle)
- throw new KNXTimeoutException("timeout establishing secure session with " + hostPort);
-
- final var delay = keepAliveInvterval.toMillis();
- keepAliveFuture = Executor.scheduledExecutor().scheduleWithFixedDelay(this::sendKeepAlive, delay, delay,
- TimeUnit.MILLISECONDS);
- }
- catch (final GeneralSecurityException e) {
- throw new KnxSecureException("error creating key pair for " + hostPort, e);
- }
- catch (final IOException e) {
- close();
- final String reason = "I/O error establishing secure session with " + hostPort;
- conn.close(CloseEvent.INTERNAL, reason);
- throw new KNXConnectionClosedException(reason, e);
- }
- finally {
- conn.sessionRequestLock.unlock();
- Arrays.fill(publicKey, (byte) 0);
- }
- }
-
- private void initKeys() throws NoSuchAlgorithmException {
- final var keyPair = generateKeyPair();
- privateKey = keyPair.getPrivate();
-
- final BigInteger u = ((XECPublicKey) keyPair.getPublic()).getU();
- // we need public key in little endian
- final byte[] tmp = u.toByteArray();
- reverse(tmp);
- System.arraycopy(tmp, 0, publicKey, 0, tmp.length);
- Arrays.fill(tmp, (byte) 0);
- }
-
- private void awaitAuthenticationStatus() throws InterruptedException, KNXTimeoutException {
- long end = System.nanoTime() / 1_000_000 + sessionSetupTimeout;
- long remaining = sessionSetupTimeout;
- boolean inAuth = false;
- while (remaining > 0 && sessionState != SessionState.Authenticated && sessionStatus == Setup) {
- synchronized (this) {
- wait(remaining);
- }
- remaining = end - System.nanoTime() / 1_000_000;
- if (sessionState == SessionState.Unauthenticated && !inAuth) {
- inAuth = true;
- end = end - remaining + sessionSetupTimeout;
- }
- }
- if (remaining <= 0)
- throw new KNXTimeoutException("timeout establishing secure session with " + hostPort(conn.server));
- }
-
- private boolean acceptServiceType(final KNXnetIPHeader h, final byte[] data, final int offset, final int length)
- throws KNXFormatException {
- final int svc = h.getServiceType();
- if (!h.isSecure())
- throw new KnxSecureException(String.format("dispatched insecure service type 0x%h to %s", svc, this));
-
- // ensure minimum secure wrapper frame size (6 header, 16 security info, 6 encapsulated header, 16 MAC)
- if (h.getTotalLength() < 44)
- return false;
-
- if (svc == SecureSessionResponse) {
- if (sessionState != SessionState.Idle) {
- logger.log(WARNING, "received session response in state {0} - ignore", sessionState);
- return true;
- }
- try {
- final byte[] serverPublicKey = parseSessionResponse(h, data, offset, conn.server);
- final byte[] auth = newSessionAuth(serverPublicKey);
- sessionState = SessionState.Unauthenticated;
- final byte[] packet = wrap(auth);
- logger.log(DEBUG, "secure session {0}, request access for user {1}", sessionId, user);
- conn.send(packet);
- }
- catch (IOException | RuntimeException e) {
- sessionStatus = AuthFailed;
- logger.log(ERROR, "negotiating session key failed", e);
- }
- synchronized (this) {
- notifyAll();
- }
- }
- else if (svc == SecureWrapper) {
- final byte[] packet = unwrap(h, data, offset);
- final var plainHeader = new KNXnetIPHeader(packet, 0);
- final var hdrLen = plainHeader.getStructLength();
-
- if (plainHeader.getServiceType() == SecureSessionStatus) {
- sessionStatus = newChannelStatus(plainHeader, packet, hdrLen);
-
- if (sessionState == SessionState.Unauthenticated) {
- if (sessionStatus == AuthSuccess)
- sessionState = SessionState.Authenticated;
-
- logger.log(sessionStatus == AuthSuccess ? DEBUG : ERROR, "{0} {1}",
- SecureConnection.statusMsg(sessionStatus), this);
- synchronized (this) {
- notifyAll();
- }
- }
- else if (sessionStatus == Timeout || sessionStatus == Unauthenticated) {
- logger.log(ERROR, "{0} {1}", SecureConnection.statusMsg(sessionStatus), this);
- close();
- }
- }
- else
- dispatchToConnection(plainHeader, packet, hdrLen, plainHeader.getTotalLength() - hdrLen);
- }
- else
- logger.log(WARNING, "received unsupported secure service type 0x{0} - ignore", Integer.toHexString(svc));
-
- return true;
- }
-
- private void dispatchToConnection(final KNXnetIPHeader header, final byte[] data, final int offset,
- final int length) {
-
- final int svcType = header.getServiceType();
- if (svcType == KNXnetIPHeader.SearchResponse || svcType == KNXnetIPHeader.DESCRIPTION_RES) {
- for (final var client : securedConnections.values())
- try {
- client.handleServiceType(header, data, offset, conn.server);
- }
- catch (KNXFormatException | IOException e) {
- logger.log(WARNING, format("{0} error processing {1}", client, header), e);
- }
- return;
- }
-
-
- final var channelId = channelId(header, data, offset);
- var connection = securedConnections.get(channelId);
- if (connection == null) {
- synchronized (ongoingConnectRequests) {
- if (!ongoingConnectRequests.isEmpty())
- connection = ongoingConnectRequests.remove(0);
- }
- }
-
- try {
- if (connection != null) {
- connection.handleServiceType(header, data, offset, conn.server);
- if (header.getServiceType() == KNXnetIPHeader.DISCONNECT_RES) {
- logger.log(TRACE, "remove connection {0}", connection);
- securedConnections.remove(channelId);
- }
- }
- else
- logger.log(WARNING, "communication channel {0} does not exist", channelId);
- }
- catch (KNXFormatException | IOException e) {
- logger.log(WARNING, format("{0} error processing {1}", connection, header), e);
- }
- }
-
- private void sendKeepAlive() {
- try {
- logger.log(TRACE, "sending keep-alive");
- conn.send(newStatusInfo(sessionId, nextSendSeq(), KeepAlive));
- }
- catch (final IOException e) {
- if (sessionState == SessionState.Authenticated && !conn.socket.isClosed()) {
- logger.log(WARNING, "error sending keep-alive: {0}", e.getMessage());
- close();
- conn.close(CloseEvent.INTERNAL, "error sending keep-alive");
- }
- }
- }
-
- private byte[] wrap(final byte[] plainPacket) {
- return SecureConnection.newSecurePacket(sessionId, nextSendSeq(), sno, 0, plainPacket, secretKey);
- }
-
- private byte[] unwrap(final KNXnetIPHeader h, final byte[] data, final int offset) throws KNXFormatException {
- final Object[] fields = SecureConnection.unwrap(h, data, offset, secretKey);
-
- final int sid = (int) fields[0];
- if (sid != sessionId)
- throw new KnxSecureException("secure session mismatch: received ID " + sid + ", expected " + sessionId);
-
- final long seq = (long) fields[1];
- if (seq < rcvSeq.get())
- throw new KnxSecureException("received secure packet with sequence " + seq + " < expected " + rcvSeq);
- rcvSeq.incrementAndGet();
-
- final var sn = (SerialNumber) fields[2];
- final int tag = (int) fields[3];
- if (tag != 0)
- throw new KnxSecureException("expected message tag 0, received " + tag);
- final byte[] knxipPacket = (byte[]) fields[4];
- logger.log(TRACE, "received (seq {0} S/N {1}) {2}", seq, sn, HexFormat.ofDelimiter(" ").formatHex(knxipPacket));
- return knxipPacket;
- }
-
- private byte[] parseSessionResponse(final KNXnetIPHeader h, final byte[] data, final int offset,
- final InetSocketAddress remote) throws KNXFormatException {
-
- if (h.getServiceType() != SecureSessionResponse)
- throw new KNXIllegalArgumentException("no secure channel response");
- if (h.getTotalLength() != 0x38)
- throw new KNXFormatException("invalid length " + data.length + " for a secure session response");
-
- final ByteBuffer buffer = ByteBuffer.wrap(data, offset, h.getTotalLength() - h.getStructLength());
-
- sessionId = buffer.getShort() & 0xffff;
- if (sessionId == 0)
- throw new KnxSecureException("no more free secure sessions, or remote endpoint busy");
-
- final byte[] serverPublicKey = new byte[keyLength];
- buffer.get(serverPublicKey);
-
- final byte[] sharedSecret = SecureConnection.keyAgreement(privateKey, serverPublicKey);
- final byte[] sessionKey = SecureConnection.sessionKey(sharedSecret);
- synchronized (this) {
- secretKey = SecureConnection.createSecretKey(sessionKey);
- }
-
- conn.sessions.put(sessionId, this);
- conn.inSessionRequestStage = null;
-
- final boolean skipDeviceAuth = Arrays.equals(deviceAuthKey.getEncoded(), new byte[16]);
- if (skipDeviceAuth) {
- logger.log(WARNING, "skipping device authentication of {0} (no device key)", hostPort(remote));
- }
- else {
- final ByteBuffer mac = SecureConnection.decrypt(buffer, deviceAuthKey,
- SecureConnection.securityInfo(new byte[16], 0, 0xff00));
-
- final int msgLen = h.getStructLength() + 2 + keyLength;
- final ByteBuffer macInput = ByteBuffer.allocate(16 + 2 + msgLen);
- macInput.put(new byte[16]);
- macInput.put((byte) 0);
- macInput.put((byte) msgLen);
- macInput.put(h.toByteArray());
- macInput.putShort((short) sessionId);
- macInput.put(SecureConnection.xor(serverPublicKey, 0, publicKey, 0, keyLength));
-
- final byte[] verifyAgainst = cbcMacSimple(deviceAuthKey, macInput.array(), 0, macInput.capacity());
- final boolean authenticated = Arrays.equals(mac.array(), verifyAgainst);
- if (!authenticated) {
- final String packet = HexFormat.ofDelimiter(" ").formatHex(data, offset - 6, offset - 6 + 0x38);
- throw new KnxSecureException("authentication failed for session response " + packet);
- }
- }
-
- return serverPublicKey;
- }
-
- private byte[] newSessionAuth(final byte[] serverPublicKey) {
- final var header = new KNXnetIPHeader(SecureSessionAuth, 2 + macSize);
-
- final ByteBuffer buffer = ByteBuffer.allocate(header.getTotalLength());
- buffer.put(header.toByteArray());
- buffer.putShort((short) user);
-
- final int msgLen = 6 + 2 + keyLength;
- final ByteBuffer macInput = ByteBuffer.allocate(16 + 2 + msgLen);
- macInput.put(new byte[16]);
- macInput.put((byte) 0);
- macInput.put((byte) msgLen);
- macInput.put(buffer.array(), 0, buffer.position());
- macInput.put(SecureConnection.xor(serverPublicKey, 0, publicKey, 0, keyLength));
- final byte[] mac = cbcMacSimple(userKey, macInput.array(), 0, macInput.capacity());
- SecureConnection.encrypt(mac, 0, userKey, SecureConnection.securityInfo(new byte[16], 8, 0xff00));
-
- buffer.put(mac);
- return buffer.array();
- }
-
- private byte[] newStatusInfo(final int sessionId, final long seq, final int status) {
- final ByteBuffer packet = ByteBuffer.allocate(6 + 2);
- packet.put(new KNXnetIPHeader(SecureSessionStatus, 2).toByteArray());
- packet.put((byte) status);
- final int msgTag = 0;
- return SecureConnection.newSecurePacket(sessionId, seq, sno, msgTag, packet.array(), secretKey);
- }
-
- private byte[] cbcMacSimple(final Key secretKey, final byte[] data, final int offset, final int length) {
- final byte[] log = Arrays.copyOfRange(data, offset, offset + length);
- logger.log(TRACE, "authenticating (length {0}): {1}", length, HexFormat.ofDelimiter(" ").formatHex(log));
-
- try {
- final var cipher = Cipher.getInstance("AES/CBC/NoPadding");
- final var ivSpec = new IvParameterSpec(new byte[16]);
- cipher.init(Cipher.ENCRYPT_MODE, secretKey, ivSpec);
-
- final byte[] padded = Arrays.copyOfRange(data, offset, (length + 15) / 16 * 16);
- final byte[] result = cipher.doFinal(padded);
- final byte[] mac = Arrays.copyOfRange(result, result.length - macSize, result.length);
- return mac;
- }
- catch (final GeneralSecurityException e) {
- throw new KnxSecureException("calculating CBC-MAC of " + HexFormat.ofDelimiter(" ").formatHex(log), e);
- }
- }
-
- private static KeyPair generateKeyPair() throws NoSuchAlgorithmException {
- final KeyPairGenerator gen = KeyPairGenerator.getInstance("X25519");
- return gen.generateKeyPair();
- }
-
- private static SerialNumber deriveSerialNumber(final InetSocketAddress localEP) {
- try {
- if (localEP != null)
- return deriveSerialNumber(NetworkInterface.getByInetAddress(localEP.getAddress()));
- }
- catch (final SocketException ignore) {}
- return SerialNumber.Zero;
- }
-
- private static SerialNumber deriveSerialNumber(final NetworkInterface netif) {
- try {
- if (netif != null) {
- final byte[] hardwareAddress = netif.getHardwareAddress();
- if (hardwareAddress != null)
- return SerialNumber.from(Arrays.copyOf(hardwareAddress, 6));
- }
- }
- catch (final SocketException ignore) {}
- return SerialNumber.Zero;
- }
- }
/**
* Creates a new TCP connection to a KNXnet/IP server.
@@ -655,10 +85,9 @@ public static TcpConnection newTcpConnection(final InetSocketAddress local, fina
}
private TcpConnection(final InetSocketAddress server) {
- this.server = server;
+ super(server);
socket = new Socket();
localEndpoint = new InetSocketAddress(0);
- logger = LogService.getLogger("io.calimero.knxnetip.tcp " + hostPort(server));
}
private TcpConnection(final InetSocketAddress local, final InetSocketAddress server) {
@@ -687,56 +116,48 @@ private TcpConnection(final InetSocketAddress local, final InetSocketAddress ser
* {@code deviceAuthCode.length == 0} will skip device authentication
* @return new secure session
*/
+ @Override
public SecureSession newSecureSession(final int user, final byte[] userKey, final byte[] deviceAuthCode) {
- return new SecureSession(this, user, userKey, deviceAuthCode);
+ final SerialNumber sno = deriveSerialNumber(this.localEndpoint());
+ return new SecureSession(this, user, userKey, deviceAuthCode, sno);
}
- public SecureSession newSecureSession(final Keyring.DecryptedInterface tunnelInterface) {
- if (tunnelInterface.type() != Type.Tunneling)
- throw new IllegalArgumentException("'" + tunnelInterface + "' is not a tunneling interface");
- return newSecureSession(tunnelInterface.user(), tunnelInterface.userKey(), tunnelInterface.deviceAuthCode());
- }
-
- public InetSocketAddress localEndpoint() { return localEndpoint; }
-
- public InetSocketAddress server() { return server; }
-
- public boolean isConnected() {
- final var connected = socket.isConnected();
- if (socket.isClosed())
- return false;
- return connected;
- }
-
- /**
- * Closes this connection and all its contained KNXnet/IP connections and secure sessions.
- */
- @Override
- public void close() {
- close(CloseEvent.USER_REQUEST, "user request");
+ private static SerialNumber deriveSerialNumber(final InetSocketAddress localEP) {
+ try {
+ if (localEP != null)
+ return deriveSerialNumber(NetworkInterface.getByInetAddress(localEP.getAddress()));
+ }
+ catch (final SocketException ignore) {}
+ return SerialNumber.Zero;
}
- void close(final int initiator, final String reason) {
- unsecuredConnections.values().forEach(t -> t.close(initiator, reason, Level.DEBUG, null));
- unsecuredConnections.clear();
-
- sessions.values().forEach(SecureSession::close);
- sessions.clear();
-
+ private static SerialNumber deriveSerialNumber(final NetworkInterface netif) {
try {
- socket.close();
+ if (netif != null) {
+ final byte[] hardwareAddress = netif.getHardwareAddress();
+ if (hardwareAddress != null)
+ return SerialNumber.from(Arrays.copyOf(hardwareAddress, 6));
+ }
}
- catch (final IOException ignore) {}
+ catch (final SocketException ignore) {}
+ return SerialNumber.Zero;
}
+ InetSocketAddress localEndpoint() { return localEndpoint; }
+
+ Socket socket() { return socket; }
+
@Override
public String toString() {
final var state = socket.isClosed() ? "closed"
: socket.isConnected() ? "connected" : socket.isBound() ? "bound" : "unbound";
- return hostPort(localEndpoint()) + "<=>" + hostPort(server) + " (" + state +")";
+ return socketName(localEndpoint) + "<=>" + socketName(server()) + " (" + state +")";
}
- Socket socket() { return socket; }
+ @Override
+ String socketName(final SocketAddress addr) {
+ return Net.hostPort((InetSocketAddress) addr);
+ }
void send(final byte[] data) throws IOException {
final var os = socket.getOutputStream();
@@ -744,156 +165,42 @@ void send(final byte[] data) throws IOException {
os.flush();
}
- void registerConnectRequest(final ClientConnection c) { ongoingConnectRequests.add(c); }
-
- void unregisterConnectRequest(final ClientConnection c) {
- ongoingConnectRequests.remove(c);
- registerConnection(c);
- }
-
- public void registerConnection(final ClientConnection c) {
- if (c.getState() == KNXnetIPConnection.OK)
- unsecuredConnections.put(c.channelId, c);
- }
-
+ @Override
public synchronized void connect() throws IOException {
if (!socket.isConnected()) {
- socket.connect(server, (int) connectionTimeout.toMillis());
+ socket.connect(server(), (int) connectionTimeout.toMillis());
localEndpoint = (InetSocketAddress) socket.getLocalSocketAddress();
- startTcpReceiver();
+ startReceiver();
}
}
- private void startTcpReceiver() {
- Executor.execute(this::runReceiveLoop, "KNXnet/IP tcp receiver " + hostPort(server));
- }
-
- private void runReceiveLoop() {
- final int rcvBufferSize = 512;
- final byte[] data = new byte[rcvBufferSize];
- int offset = 0;
-
- int initiator = CloseEvent.USER_REQUEST;
- String reason = "user request";
- try {
- final var in = socket.getInputStream();
- while (!socket.isClosed()) {
- if (offset >= 6) {
- try {
- final var header = new KNXnetIPHeader(data, 0);
- if (header.getTotalLength() <= offset) {
- final int length = header.getTotalLength() - header.getStructLength();
- final int leftover = offset - header.getTotalLength();
- offset = leftover;
-
- if (header.isSecure())
- dispatchToSession(header, data, header.getStructLength(), length);
- else
- dispatchToConnection(header, data, header.getStructLength());
-
- if (leftover > 0) {
- System.arraycopy(data, header.getTotalLength(), data, 0, leftover);
- continue;
- }
- }
- // skip bodies which do not fit into rcv buffer
- else if (header.getTotalLength() > rcvBufferSize) {
- int skip = header.getTotalLength() - offset;
- while (skip-- > 0 && in.read() != -1);
- offset = 0;
- }
- }
- catch (KNXFormatException | KnxSecureException e) {
- logger.log(WARNING, "received invalid frame", e);
- offset = 0;
- }
- }
-
- final int read = in.read(data, offset, data.length - offset);
- if (read == -1) {
- initiator = CloseEvent.SERVER_REQUEST;
- reason = "server request";
- return;
- }
- offset += read;
- }
- }
- catch (final InterruptedIOException e) {
- Thread.currentThread().interrupt();
- }
- catch (IOException | RuntimeException e) {
- if (!socket.isClosed()) {
- initiator = CloseEvent.INTERNAL;
- reason = e.getMessage();
- logger.log(ERROR, "receiver communication failure", e);
- }
- }
- finally {
- close(initiator, reason);
- }
- }
-
- private void dispatchToSession(final KNXnetIPHeader header, final byte[] data, final int offset, final int length)
- throws KNXFormatException {
- final var sessionId = ByteBuffer.wrap(data, offset, length).getShort() & 0xffff;
- if (sessionId == 0)
- throw new KnxSecureException("no more free secure sessions, or remote endpoint busy");
-
- var session = sessions.get(sessionId);
- if (session == null && header.getServiceType() == SecureSession.SecureSessionResponse)
- session = inSessionRequestStage;
+ @Override
+ public InetSocketAddress server() { return (InetSocketAddress) super.server(); }
- if (session != null)
- session.acceptServiceType(header, data, offset, length);
- else
- logger.log(WARNING, "session {0} does not exist", sessionId);
+ @Override
+ public boolean isConnected() {
+ final var connected = socket.isConnected();
+ if (socket.isClosed())
+ return false;
+ return connected;
}
- private void dispatchToConnection(final KNXnetIPHeader header, final byte[] data, final int offset)
- throws IOException, KNXFormatException {
- final int svcType = header.getServiceType();
- if (svcType == KNXnetIPHeader.SearchResponse || svcType == KNXnetIPHeader.DESCRIPTION_RES) {
- for (final var client : unsecuredConnections.values())
- client.handleServiceType(header, data, offset, server);
- return;
- }
-
- final var channelId = channelId(header, data, offset);
- var connection = unsecuredConnections.get(channelId);
- if (connection == null) {
- synchronized (ongoingConnectRequests) {
- if (!ongoingConnectRequests.isEmpty())
- connection = ongoingConnectRequests.remove(0);
- }
- }
-
- if (connection != null) {
- connection.handleServiceType(header, data, offset, server);
- if (svcType == KNXnetIPHeader.DISCONNECT_RES)
- unsecuredConnections.remove(channelId);
+ @Override
+ void close(final int initiator, final String reason) {
+ super.close(initiator, reason);
+ try {
+ socket.close();
}
- else
- logger.log(WARNING, "communication channel {0} does not exist", channelId);
+ catch (final IOException ignore) {}
}
- private static int channelId(final KNXnetIPHeader header, final byte[] data, final int offset) {
- // communication channel ID in the connection header of a tunneling/config request has a different offset
- // than in connection management services
- final int channelIdOffset = switch (header.getServiceType()) {
- case KNXnetIPHeader.TUNNELING_REQ, KNXnetIPHeader.DEVICE_CONFIGURATION_REQ,
- KNXnetIPHeader.TunnelingFeatureResponse, KNXnetIPHeader.TunnelingFeatureInfo,
- KNXnetIPHeader.ObjectServerRequest, KNXnetIPHeader.ObjectServerAck -> offset + 1;
- default -> offset;
- };
- final var channelId = data[channelIdOffset] & 0xff;
- return channelId;
+ @Override
+ boolean streamClosed() {
+ return socket.isClosed();
}
- private static void reverse(final byte[] array) {
- for (int i = 0; i < array.length / 2; i++) {
- final byte b = array[i];
- array[i] = array[array.length - 1 - i];
- array[array.length - 1 - i] = b;
- }
+ @Override
+ int read(final byte[] data, final int offset) throws IOException {
+ return socket.getInputStream().read(data, offset, data.length - offset);
}
}
diff --git a/src/io/calimero/knxnetip/UnixDomainSocketConnection.java b/src/io/calimero/knxnetip/UnixDomainSocketConnection.java
new file mode 100644
index 00000000..f67a0c92
--- /dev/null
+++ b/src/io/calimero/knxnetip/UnixDomainSocketConnection.java
@@ -0,0 +1,109 @@
+/*
+ Calimero 2 - A library for KNX network access
+ Copyright (c) 2024, 2024 B. Malinowsky
+
+ This program is free software; you can redistribute it and/or modify
+ it under the terms of the GNU General Public License as published by
+ the Free Software Foundation; either version 2 of the License, or
+ (at your option) any later version.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License
+ along with this program; if not, write to the Free Software
+ Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA
+
+ Linking this library statically or dynamically with other modules is
+ making a combined work based on this library. Thus, the terms and
+ conditions of the GNU General Public License cover the whole
+ combination.
+
+ As a special exception, the copyright holders of this library give you
+ permission to link this library with independent modules to produce an
+ executable, regardless of the license terms of these independent
+ modules, and to copy and distribute the resulting executable under terms
+ of your choice, provided that you also meet, for each linked independent
+ module, the terms and conditions of the license of that module. An
+ independent module is a module which is not derived from or based on
+ this library. If you modify this library, you may extend this exception
+ to your version of the library, but you are not obligated to do so. If
+ you do not wish to do so, delete this exception statement from your
+ version.
+*/
+
+package io.calimero.knxnetip;
+
+import java.io.IOException;
+import java.net.SocketAddress;
+import java.net.StandardProtocolFamily;
+import java.net.UnixDomainSocketAddress;
+import java.nio.ByteBuffer;
+import java.nio.channels.SocketChannel;
+import java.nio.file.Path;
+
+/**
+ * Support for Unix Domain Socket connections to KNXnet/IP servers.
+ */
+public final class UnixDomainSocketConnection extends StreamConnection {
+ private final SocketChannel channel;
+
+
+ public static UnixDomainSocketConnection newConnection(final Path path) throws IOException {
+ return new UnixDomainSocketConnection(path);
+ }
+
+
+ private UnixDomainSocketConnection(final Path path) throws IOException {
+ super(UnixDomainSocketAddress.of(path));
+ channel = SocketChannel.open(StandardProtocolFamily.UNIX);
+ }
+
+ @Override
+ public synchronized void connect() throws IOException {
+ if (isConnected())
+ return;
+ channel.connect(server());
+ startReceiver();
+ }
+
+ @Override
+ public boolean isConnected() { return channel.isConnected(); }
+
+ @Override
+ SocketAddress localEndpoint() { return UnixDomainSocketAddress.of(""); }
+
+ @Override
+ public UnixDomainSocketAddress server() { return (UnixDomainSocketAddress) super.server(); }
+
+ @Override
+ void close(final int initiator, final String reason) {
+ super.close(initiator, reason);
+ try {
+ channel.close();
+ }
+ catch (final IOException ignore) {}
+ }
+
+ @Override
+ public String toString() {
+ final var state = channel.isOpen() ? channel.isConnected() ? "connected" : "open" : "closed";
+ return socketName(server()) + " (" + state +")";
+ }
+
+ @Override
+ void send(final byte[] data) throws IOException { channel.write(ByteBuffer.wrap(data)); }
+
+ @Override
+ String socketName(final SocketAddress addr) { return addr.toString(); }
+
+ @Override
+ boolean streamClosed() { return !channel.isOpen(); }
+
+ @Override
+ int read(final byte[] data, final int offset) throws IOException {
+ return channel.read(ByteBuffer.wrap(data, offset, data.length - offset));
+ }
+}
diff --git a/src/io/calimero/link/KNXNetworkLinkIP.java b/src/io/calimero/link/KNXNetworkLinkIP.java
index d960a065..60560794 100644
--- a/src/io/calimero/link/KNXNetworkLinkIP.java
+++ b/src/io/calimero/link/KNXNetworkLinkIP.java
@@ -75,8 +75,9 @@
import io.calimero.knxnetip.RoutingBusyEvent;
import io.calimero.knxnetip.RoutingListener;
import io.calimero.knxnetip.SecureConnection;
+import io.calimero.knxnetip.StreamConnection;
+import io.calimero.knxnetip.StreamConnection.SecureSession;
import io.calimero.knxnetip.TcpConnection;
-import io.calimero.knxnetip.TcpConnection.SecureSession;
import io.calimero.knxnetip.TunnelingListener;
import io.calimero.knxnetip.servicetype.TunnelingFeature;
import io.calimero.knxnetip.servicetype.TunnelingFeature.InterfaceFeature;
@@ -173,7 +174,7 @@ public static KNXNetworkLinkIP newTunnelingLink(final InetSocketAddress localEP,
* @throws KNXException on failure establishing the link
* @throws InterruptedException on interrupted thread while establishing link
*/
- public static KNXNetworkLinkIP newTunnelingLink(final TcpConnection connection, final KNXMediumSettings settings)
+ public static KNXNetworkLinkIP newTunnelingLink(final StreamConnection connection, final KNXMediumSettings settings)
throws KNXException, InterruptedException {
return new KNXNetworkLinkIP(TunnelingV2, KNXnetIPTunnel.newTcpTunnel(LinkLayer, connection,
settings.getDeviceAddress()), settings);
diff --git a/src/io/calimero/link/KNXNetworkMonitorIP.java b/src/io/calimero/link/KNXNetworkMonitorIP.java
index 3713e8e5..0e753a26 100644
--- a/src/io/calimero/link/KNXNetworkMonitorIP.java
+++ b/src/io/calimero/link/KNXNetworkMonitorIP.java
@@ -1,6 +1,6 @@
/*
Calimero 2 - A library for KNX network access
- Copyright (c) 2006, 2022 B. Malinowsky
+ Copyright (c) 2006, 2024 B. Malinowsky
This program is free software; you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
@@ -36,8 +36,8 @@
package io.calimero.link;
-import static java.lang.System.Logger.Level.INFO;
import static io.calimero.knxnetip.KNXnetIPTunnel.TunnelingLayer.BusMonitorLayer;
+import static java.lang.System.Logger.Level.INFO;
import java.net.InetSocketAddress;
@@ -45,8 +45,8 @@
import io.calimero.knxnetip.KNXnetIPConnection;
import io.calimero.knxnetip.KNXnetIPTunnel;
import io.calimero.knxnetip.SecureConnection;
-import io.calimero.knxnetip.TcpConnection;
-import io.calimero.knxnetip.TcpConnection.SecureSession;
+import io.calimero.knxnetip.StreamConnection;
+import io.calimero.knxnetip.StreamConnection.SecureSession;
import io.calimero.link.medium.KNXMediumSettings;
/**
@@ -73,7 +73,7 @@ public class KNXNetworkMonitorIP extends AbstractMonitor
* @throws KNXException on failure establishing the link
* @throws InterruptedException on interrupted thread while establishing link
*/
- public static KNXNetworkMonitorIP newMonitorLink(final TcpConnection connection, final KNXMediumSettings settings)
+ public static KNXNetworkMonitorIP newMonitorLink(final StreamConnection connection, final KNXMediumSettings settings)
throws KNXException, InterruptedException {
return new KNXNetworkMonitorIP(KNXnetIPTunnel.newTcpTunnel(BusMonitorLayer, connection,
settings.getDeviceAddress()), settings);
@@ -150,6 +150,8 @@ private static InetSocketAddress localEndpoint(final InetSocketAddress local)
private static String monitorName(final InetSocketAddress remote)
{
+ if (remote == null)
+ return "monitor uds"; // TODO use UDS path
// do our own IP:port string, since InetAddress.toString() always prepends a '/'
final String host = (remote.isUnresolved() ? remote.getHostString() : remote.getAddress().getHostAddress());
return "monitor " + host + ":" + remote.getPort();
diff --git a/src/io/calimero/mgmt/LocalDeviceManagementIp.java b/src/io/calimero/mgmt/LocalDeviceManagementIp.java
index cb4b25fb..61e1eba0 100644
--- a/src/io/calimero/mgmt/LocalDeviceManagementIp.java
+++ b/src/io/calimero/mgmt/LocalDeviceManagementIp.java
@@ -1,6 +1,6 @@
/*
Calimero 2 - A library for KNX network access
- Copyright (c) 2006, 2022 B. Malinowsky
+ Copyright (c) 2006, 2024 B. Malinowsky
This program is free software; you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
@@ -45,12 +45,12 @@
import io.calimero.KNXTimeoutException;
import io.calimero.cemi.CEMI;
import io.calimero.cemi.CEMIDevMgmt;
-import io.calimero.knxnetip.TcpConnection;
-import io.calimero.knxnetip.TcpConnection.SecureSession;
import io.calimero.knxnetip.KNXConnectionClosedException;
import io.calimero.knxnetip.KNXnetIPConnection;
import io.calimero.knxnetip.KNXnetIPDevMgmt;
import io.calimero.knxnetip.SecureConnection;
+import io.calimero.knxnetip.StreamConnection.SecureSession;
+import io.calimero.knxnetip.TcpConnection;
/**
* Adapter for KNX property services using KNXnet/IP local device management.
diff --git a/src/io/calimero/mgmt/LocalDeviceManagementUds.java b/src/io/calimero/mgmt/LocalDeviceManagementUds.java
new file mode 100644
index 00000000..cdc1f57c
--- /dev/null
+++ b/src/io/calimero/mgmt/LocalDeviceManagementUds.java
@@ -0,0 +1,130 @@
+/*
+ Calimero 2 - A library for KNX network access
+ Copyright (c) 2024, 2024 B. Malinowsky
+
+ This program is free software; you can redistribute it and/or modify
+ it under the terms of the GNU General Public License as published by
+ the Free Software Foundation; either version 2 of the License, or
+ (at your option) any later version.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License
+ along with this program; if not, write to the Free Software
+ Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA
+
+ Linking this library statically or dynamically with other modules is
+ making a combined work based on this library. Thus, the terms and
+ conditions of the GNU General Public License cover the whole
+ combination.
+
+ As a special exception, the copyright holders of this library give you
+ permission to link this library with independent modules to produce an
+ executable, regardless of the license terms of these independent
+ modules, and to copy and distribute the resulting executable under terms
+ of your choice, provided that you also meet, for each linked independent
+ module, the terms and conditions of the license of that module. An
+ independent module is a module which is not derived from or based on
+ this library. If you modify this library, you may extend this exception
+ to your version of the library, but you are not obligated to do so. If
+ you do not wish to do so, delete this exception statement from your
+ version.
+*/
+
+package io.calimero.mgmt;
+
+import java.net.UnixDomainSocketAddress;
+import java.util.function.Consumer;
+
+import io.calimero.CloseEvent;
+import io.calimero.Connection.BlockingMode;
+import io.calimero.KNXException;
+import io.calimero.KNXTimeoutException;
+import io.calimero.cemi.CEMI;
+import io.calimero.cemi.CEMIDevMgmt;
+import io.calimero.knxnetip.ClientConnection;
+import io.calimero.knxnetip.KNXConnectionClosedException;
+import io.calimero.knxnetip.KNXnetIPConnection;
+import io.calimero.knxnetip.KNXnetIPDevMgmt;
+import io.calimero.knxnetip.SecureConnection;
+import io.calimero.knxnetip.StreamConnection.SecureSession;
+import io.calimero.knxnetip.UnixDomainSocketConnection;
+
+/**
+ * Adapter for KNX property services using KNXnet/IP local device management over a Unix domain socket.
+ *
+ * @see KNXnetIPDevMgmt
+ */
+public final class LocalDeviceManagementUds extends LocalDeviceManagement {
+
+ private final UnixDomainSocketAddress remote;
+
+ /**
+ * Creates a new property service adapter for local device management over a Unix domain socket connection.
+ *
+ * @param connection the Unix domain socket connection to the server
+ * @param adapterClosed receives the notification if the adapter got closed
+ * @return a new local device management connection
+ * @throws KNXException on failure establishing local device management connection or failure while initializing the
+ * property adapter
+ * @throws InterruptedException on interrupted thread while initializing the adapter
+ */
+ public static LocalDeviceManagementUds newAdapter(final UnixDomainSocketConnection connection,
+ final Consumer adapterClosed) throws KNXException, InterruptedException {
+ final var mgmt = new KNXnetIPDevMgmt(connection);
+ return new LocalDeviceManagementUds(mgmt, adapterClosed, false);
+ }
+
+ /**
+ * Creates a new secure property service adapter for local device management of a KNXnet/IP server using the
+ * supplied secure session.
+ *
+ * @param session secure session with a KNXnet/IP server
+ * @param adapterClosed receives the notification if the adapter got closed
+ * @return a new local device management connection
+ * @throws KNXException if establishing the local device management connection or initializing the property adapter
+ * fails
+ * @throws InterruptedException on interrupted thread while initializing the adapter
+ */
+ public static LocalDeviceManagementUds newSecureAdapter(final SecureSession session,
+ final Consumer adapterClosed) throws KNXException, InterruptedException {
+ final var mgmt = SecureConnection.newDeviceManagement(session);
+ return new LocalDeviceManagementUds(mgmt, adapterClosed, false);
+ }
+
+ LocalDeviceManagementUds(final KNXnetIPConnection mgmt, final Consumer adapterClosed,
+ final boolean queryWriteEnable) throws KNXException, InterruptedException {
+ super(mgmt, adapterClosed, queryWriteEnable);
+ remote = (UnixDomainSocketAddress) ((ClientConnection) mgmt).remoteAddress();
+ c.addConnectionListener(new KNXListenerImpl());
+ init();
+ }
+
+ /**
+ * Sends a reset request to the KNXnet/IP server. A successful reset request causes the KNXnet/IP server to close
+ * the KNXnet/IP device management connection.
+ *
+ * @throws KNXConnectionClosedException on closed connection
+ * @throws KNXTimeoutException if a timeout regarding a response message was encountered
+ * @throws InterruptedException on thread interrupt
+ */
+ public void reset() throws KNXException, InterruptedException {
+ send(new CEMIDevMgmt(CEMIDevMgmt.MC_RESET_REQ), BlockingMode.Ack);
+ }
+
+ /**
+ * The name for this adapter starts with "Local-DM " + KNXnet/IP server control endpoint, allowing
+ * easier distinction of adapter types.
+ */
+ @Override
+ public String getName() { return "Local-DM " + remote; }
+
+ @Override
+ protected void send(final CEMIDevMgmt frame, final BlockingMode mode)
+ throws KNXException, InterruptedException {
+ c.send(frame, mode);
+ }
+}
diff --git a/test/io/calimero/knxnetip/TcpConnectionTest.java b/test/io/calimero/knxnetip/TcpConnectionTest.java
index 887cb41e..e76e26d7 100644
--- a/test/io/calimero/knxnetip/TcpConnectionTest.java
+++ b/test/io/calimero/knxnetip/TcpConnectionTest.java
@@ -1,6 +1,6 @@
/*
Calimero 2 - A library for KNX network access
- Copyright (c) 2019, 2023 B. Malinowsky
+ Copyright (c) 2019, 2024 B. Malinowsky
This program is free software; you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
@@ -42,15 +42,15 @@
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
-import tag.KnxnetIPSequential;
-import tag.Slow;
import io.calimero.IndividualAddress;
import io.calimero.KNXException;
import io.calimero.Util;
import io.calimero.knxnetip.KNXnetIPTunnel.TunnelingLayer;
-import io.calimero.knxnetip.TcpConnection.SecureSession;
+import io.calimero.knxnetip.StreamConnection.SecureSession;
import io.calimero.link.medium.KNXMediumSettings;
import io.calimero.secure.Keyring;
+import tag.KnxnetIPSequential;
+import tag.Slow;
@KnxnetIPSequential
class TcpConnectionTest {