2 * Copyright (c) 2023 PANTHEON.tech s.r.o. and others. All rights reserved.
4 * This program and the accompanying materials are made available under the
5 * terms of the Eclipse Public License v1.0 which accompanies this distribution,
6 * and is available at http://www.eclipse.org/legal/epl-v10.html
8 package org.opendaylight.netconf.topology.callhome;
10 import static java.util.Objects.requireNonNull;
12 import com.google.common.annotations.VisibleForTesting;
13 import java.net.InetAddress;
14 import java.net.SocketAddress;
15 import java.security.PublicKey;
16 import java.util.List;
17 import java.util.concurrent.ExecutionException;
18 import java.util.concurrent.TimeUnit;
19 import java.util.concurrent.TimeoutException;
20 import org.eclipse.jdt.annotation.NonNull;
21 import org.opendaylight.netconf.api.TransportConstants;
22 import org.opendaylight.netconf.client.NetconfClientSessionNegotiatorFactory;
23 import org.opendaylight.netconf.shaded.sshd.client.ClientFactoryManager;
24 import org.opendaylight.netconf.shaded.sshd.client.auth.password.UserAuthPasswordFactory;
25 import org.opendaylight.netconf.shaded.sshd.client.auth.pubkey.UserAuthPublicKeyFactory;
26 import org.opendaylight.netconf.shaded.sshd.client.session.ClientSession;
27 import org.opendaylight.netconf.shaded.sshd.common.session.Session;
28 import org.opendaylight.netconf.shaded.sshd.common.session.SessionListener;
29 import org.opendaylight.netconf.transport.api.UnsupportedConfigurationException;
30 import org.opendaylight.netconf.transport.ssh.ClientFactoryManagerConfigurator;
31 import org.opendaylight.netconf.transport.ssh.SSHClient;
32 import org.opendaylight.netconf.transport.ssh.SSHTransportStackFactory;
33 import org.opendaylight.yang.gen.v1.urn.ietf.params.xml.ns.yang.ietf.inet.types.rev130715.IetfInetUtil;
34 import org.opendaylight.yang.gen.v1.urn.ietf.params.xml.ns.yang.ietf.inet.types.rev130715.PortNumber;
35 import org.opendaylight.yang.gen.v1.urn.ietf.params.xml.ns.yang.ietf.netconf.client.rev240208.netconf.client.initiate.stack.grouping.transport.ssh.ssh.SshClientParametersBuilder;
36 import org.opendaylight.yang.gen.v1.urn.ietf.params.xml.ns.yang.ietf.netconf.client.rev240208.netconf.client.listen.stack.grouping.transport.ssh.ssh.TcpServerParametersBuilder;
37 import org.opendaylight.yang.gen.v1.urn.ietf.params.xml.ns.yang.ietf.ssh.client.rev240208.ssh.client.grouping.ClientIdentityBuilder;
38 import org.opendaylight.yang.gen.v1.urn.ietf.params.xml.ns.yang.ietf.tcp.server.rev240208.TcpServerGrouping;
39 import org.opendaylight.yangtools.yang.common.Uint16;
40 import org.slf4j.Logger;
41 import org.slf4j.LoggerFactory;
43 public final class CallHomeSshServer implements AutoCloseable {
44 private static final Logger LOG = LoggerFactory.getLogger(CallHomeSshServer.class);
45 private static final long DEFAULT_TIMEOUT_MILLIS = 10000L;
46 private static final int DEFAULT_PORT = 4334;
48 private final CallHomeSshAuthProvider authProvider;
49 private final CallHomeStatusRecorder statusRecorder;
50 private final CallHomeSshSessionContextManager contextManager;
51 private final SSHClient client;
54 CallHomeSshServer(final TcpServerGrouping tcpServerParams,
55 final SSHTransportStackFactory transportStackFactory,
56 final NetconfClientSessionNegotiatorFactory negotiatorFactory,
57 final CallHomeSshSessionContextManager contextManager,
58 final CallHomeSshAuthProvider authProvider,
59 final CallHomeStatusRecorder statusRecorder) {
60 this.authProvider = requireNonNull(authProvider);
61 this.statusRecorder = requireNonNull(statusRecorder);
62 this.contextManager = requireNonNull(contextManager);
65 final var transportChannelListener =
66 new CallHomeTransportChannelListener(negotiatorFactory, contextManager, statusRecorder);
68 // SSH transport layer configuration
69 // NB actual username will be assigned dynamically but predefined one is required for transport initialization
70 final var sshClientParams = new SshClientParametersBuilder().setClientIdentity(
71 new ClientIdentityBuilder().setUsername("ignored").build()).build();
72 final var configurator = new ClientFactoryManagerConfigurator() {
74 protected void configureClientFactoryManager(final ClientFactoryManager factoryManager) {
75 factoryManager.setServerKeyVerifier((clientSession, remoteAddress, serverKey)
76 -> verifyServerKey(clientSession, remoteAddress, serverKey));
77 factoryManager.addSessionListener(createSessionListener());
78 // supported auth factories
79 factoryManager.setUserAuthFactories(List.of(
80 new UserAuthPasswordFactory(),
81 new UserAuthPublicKeyFactory()));
85 client = transportStackFactory.listenClient(TransportConstants.SSH_SUBSYSTEM, transportChannelListener,
86 tcpServerParams, sshClientParams, configurator).get(DEFAULT_TIMEOUT_MILLIS, TimeUnit.MILLISECONDS);
87 } catch (UnsupportedConfigurationException | InterruptedException | ExecutionException | TimeoutException e) {
88 throw new IllegalStateException("Could not start SSH Call-Home server", e);
92 private SessionListener createSessionListener() {
93 return new SessionListener() {
95 public void sessionClosed(final Session session) {
96 if (session instanceof ClientSession clientSession) {
97 final var context = contextManager.findBySshSession(clientSession);
98 if (context != null) {
99 contextManager.remove(context.id());
100 if (!clientSession.isAuthenticated()) {
101 // threat unauthenticated session closure as authentication failure
102 // in case there was context object created for the session
103 statusRecorder.reportFailedAuth(context.id());
104 } else if (context.settableFuture().isDone()) {
105 // disconnected after netconf session established
106 statusRecorder.reportDisconnected(context.id());
114 private boolean verifyServerKey(final ClientSession clientSession, final SocketAddress remoteAddress,
115 final PublicKey serverKey) {
116 final CallHomeSshAuthSettings authSettings = authProvider.provideAuth(remoteAddress, serverKey);
117 if (authSettings == null) {
118 // no auth for server key
119 statusRecorder.reportUnknown(remoteAddress, serverKey);
120 LOG.info("No auth settings found. Connection from {} rejected.", remoteAddress);
123 if (contextManager.exists(authSettings.id())) {
124 LOG.info("Session context with same id {} already exists. Connection from {} rejected.",
125 authSettings.id(), remoteAddress);
128 final var context = contextManager.createContext(authSettings.id(), clientSession);
129 if (context == null) {
130 // if there is an issue creating context then the cause expected to be
131 // logged within overridden createContext() method
134 contextManager.register(context);
136 // Session context is ok, apply auth settings to current session
137 authSettings.applyTo(clientSession);
138 LOG.debug("Session context is created for SSH session: {}", context);
143 public void close() throws Exception {
144 contextManager.close();
145 client.shutdown().get();
148 public static Builder builder() {
149 return new Builder();
152 public static final class Builder {
153 private InetAddress address;
154 private int port = DEFAULT_PORT;
155 private SSHTransportStackFactory transportStackFactory;
156 private NetconfClientSessionNegotiatorFactory negotiationFactory;
157 private CallHomeSshAuthProvider authProvider;
158 private CallHomeSshSessionContextManager contextManager;
159 private CallHomeStatusRecorder statusRecorder;
165 public @NonNull CallHomeSshServer build() {
166 return new CallHomeSshServer(
167 toServerParams(address, port),
168 transportStackFactory == null ? defaultTransportStackFactory() : transportStackFactory,
170 contextManager == null ? new CallHomeSshSessionContextManager() : contextManager,
171 authProvider, statusRecorder);
174 public Builder withAuthProvider(final CallHomeSshAuthProvider newAuthProvider) {
175 authProvider = newAuthProvider;
179 public Builder withSessionContextManager(final CallHomeSshSessionContextManager newContextManager) {
180 contextManager = newContextManager;
184 public Builder withStatusRecorder(final CallHomeStatusRecorder newStatusRecorder) {
185 statusRecorder = newStatusRecorder;
189 public Builder withAddress(final InetAddress newAddress) {
190 address = newAddress;
194 public Builder withPort(final int newPort) {
199 public Builder withTransportStackFactory(final SSHTransportStackFactory newTransportStackFactory) {
200 transportStackFactory = newTransportStackFactory;
204 public Builder withNegotiationFactory(final NetconfClientSessionNegotiatorFactory newNegotiationFactory) {
205 negotiationFactory = newNegotiationFactory;
210 private static TcpServerGrouping toServerParams(final InetAddress address, final int port) {
211 final var ipAddress = IetfInetUtil.ipAddressFor(
212 address == null ? InetAddress.getLoopbackAddress() : address);
213 final var portNumber = new PortNumber(Uint16.valueOf(port < 0 ? DEFAULT_PORT : port));
214 return new TcpServerParametersBuilder().setLocalAddress(ipAddress).setLocalPort(portNumber).build();
217 private static SSHTransportStackFactory defaultTransportStackFactory() {
218 return new SSHTransportStackFactory("ssh-call-home-server", 0);