Refactor ClientFactoryManagerConfigurator
[netconf.git] / apps / callhome-provider / src / main / java / org / opendaylight / netconf / topology / callhome / CallHomeSshServer.java
1 /*
2  * Copyright (c) 2023 PANTHEON.tech s.r.o. and others. All rights reserved.
3  *
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
7  */
8 package org.opendaylight.netconf.topology.callhome;
9
10 import static java.util.Objects.requireNonNull;
11
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;
42
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;
47
48     private final CallHomeSshAuthProvider authProvider;
49     private final CallHomeStatusRecorder statusRecorder;
50     private final CallHomeSshSessionContextManager contextManager;
51     private final SSHClient client;
52
53     @VisibleForTesting
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);
63
64         // netconf layer
65         final var transportChannelListener =
66             new CallHomeTransportChannelListener(negotiatorFactory, contextManager, statusRecorder);
67
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() {
73             @Override
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()));
82             }
83         };
84         try {
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);
89         }
90     }
91
92     private SessionListener createSessionListener() {
93         return new SessionListener() {
94             @Override
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());
107                         }
108                     }
109                 }
110             }
111         };
112     }
113
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);
121             return false;
122         }
123         if (contextManager.exists(authSettings.id())) {
124             LOG.info("Session context with same id {} already exists. Connection from {} rejected.",
125                 authSettings.id(), remoteAddress);
126             return false;
127         }
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
132             return false;
133         }
134         contextManager.register(context);
135
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);
139         return true;
140     }
141
142     @Override
143     public void close() throws Exception {
144         contextManager.close();
145         client.shutdown().get();
146     }
147
148     public static Builder builder() {
149         return new Builder();
150     }
151
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;
160
161         private Builder() {
162             // on purpose
163         }
164
165         public @NonNull CallHomeSshServer build() {
166             return new CallHomeSshServer(
167                 toServerParams(address, port),
168                 transportStackFactory == null ? defaultTransportStackFactory() : transportStackFactory,
169                 negotiationFactory,
170                 contextManager == null ? new CallHomeSshSessionContextManager() : contextManager,
171                 authProvider, statusRecorder);
172         }
173
174         public Builder withAuthProvider(final CallHomeSshAuthProvider newAuthProvider) {
175             authProvider = newAuthProvider;
176             return this;
177         }
178
179         public Builder withSessionContextManager(final CallHomeSshSessionContextManager newContextManager) {
180             contextManager = newContextManager;
181             return this;
182         }
183
184         public Builder withStatusRecorder(final CallHomeStatusRecorder newStatusRecorder) {
185             statusRecorder = newStatusRecorder;
186             return this;
187         }
188
189         public Builder withAddress(final InetAddress newAddress) {
190             address = newAddress;
191             return this;
192         }
193
194         public Builder withPort(final int newPort) {
195             port = newPort;
196             return this;
197         }
198
199         public Builder withTransportStackFactory(final SSHTransportStackFactory newTransportStackFactory) {
200             transportStackFactory = newTransportStackFactory;
201             return this;
202         }
203
204         public Builder withNegotiationFactory(final NetconfClientSessionNegotiatorFactory newNegotiationFactory) {
205             negotiationFactory = newNegotiationFactory;
206             return this;
207         }
208     }
209
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();
215     }
216
217     private static SSHTransportStackFactory defaultTransportStackFactory() {
218         return new SSHTransportStackFactory("ssh-call-home-server", 0);
219     }
220 }