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 {