Rework SslHandlerFactory
[netconf.git] / apps / callhome-provider / src / main / java / org / opendaylight / netconf / callhome / server / ssh / 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.callhome.server.ssh;
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.callhome.server.CallHomeStatusRecorder;
23 import org.opendaylight.netconf.callhome.server.CallHomeTransportChannelListener;
24 import org.opendaylight.netconf.client.NetconfClientSessionNegotiatorFactory;
25 import org.opendaylight.netconf.shaded.sshd.client.auth.password.UserAuthPasswordFactory;
26 import org.opendaylight.netconf.shaded.sshd.client.auth.pubkey.UserAuthPublicKeyFactory;
27 import org.opendaylight.netconf.shaded.sshd.client.session.ClientSession;
28 import org.opendaylight.netconf.shaded.sshd.common.session.Session;
29 import org.opendaylight.netconf.shaded.sshd.common.session.SessionListener;
30 import org.opendaylight.netconf.transport.api.UnsupportedConfigurationException;
31 import org.opendaylight.netconf.transport.ssh.ClientFactoryManagerConfigurator;
32 import org.opendaylight.netconf.transport.ssh.SSHClient;
33 import org.opendaylight.netconf.transport.ssh.SSHTransportStackFactory;
34 import org.opendaylight.yang.gen.v1.urn.ietf.params.xml.ns.yang.ietf.inet.types.rev130715.IetfInetUtil;
35 import org.opendaylight.yang.gen.v1.urn.ietf.params.xml.ns.yang.ietf.inet.types.rev130715.PortNumber;
36 import org.opendaylight.yang.gen.v1.urn.ietf.params.xml.ns.yang.ietf.netconf.client.rev231228.netconf.client.initiate.stack.grouping.transport.ssh.ssh.SshClientParametersBuilder;
37 import org.opendaylight.yang.gen.v1.urn.ietf.params.xml.ns.yang.ietf.netconf.client.rev231228.netconf.client.listen.stack.grouping.transport.ssh.ssh.TcpServerParametersBuilder;
38 import org.opendaylight.yang.gen.v1.urn.ietf.params.xml.ns.yang.ietf.ssh.client.rev231228.ssh.client.grouping.ClientIdentityBuilder;
39 import org.opendaylight.yang.gen.v1.urn.ietf.params.xml.ns.yang.ietf.tcp.server.rev231228.TcpServerGrouping;
40 import org.opendaylight.yangtools.yang.common.Uint16;
41 import org.slf4j.Logger;
42 import org.slf4j.LoggerFactory;
43
44 public final class CallHomeSshServer implements AutoCloseable {
45     private static final Logger LOG = LoggerFactory.getLogger(CallHomeSshServer.class);
46     private static final long DEFAULT_TIMEOUT_MILLIS = 10000L;
47     private static final int DEFAULT_PORT = 4334;
48
49     private final CallHomeSshAuthProvider authProvider;
50     private final CallHomeStatusRecorder statusRecorder;
51     private final CallHomeSshSessionContextManager contextManager;
52     private final SSHClient client;
53
54     @VisibleForTesting
55     CallHomeSshServer(final TcpServerGrouping tcpServerParams,
56             final SSHTransportStackFactory transportStackFactory,
57             final NetconfClientSessionNegotiatorFactory negotiatorFactory,
58             final CallHomeSshSessionContextManager contextManager,
59             final CallHomeSshAuthProvider authProvider,
60             final CallHomeStatusRecorder statusRecorder) {
61         this.authProvider = requireNonNull(authProvider);
62         this.statusRecorder = requireNonNull(statusRecorder);
63         this.contextManager = requireNonNull(contextManager);
64
65         // netconf layer
66         final var transportChannelListener =
67             new CallHomeTransportChannelListener(negotiatorFactory, contextManager, statusRecorder);
68
69         // SSH transport layer configuration
70         // NB actual username will be assigned dynamically but predefined one is required for transport initialization
71         final var sshClientParams = new SshClientParametersBuilder().setClientIdentity(
72             new ClientIdentityBuilder().setUsername("ignored").build()).build();
73         final ClientFactoryManagerConfigurator configurator = factoryMgr -> {
74             factoryMgr.setServerKeyVerifier(this::verifyServerKey);
75             factoryMgr.addSessionListener(createSessionListener());
76             // supported auth factories
77             factoryMgr.setUserAuthFactories(List.of(new UserAuthPasswordFactory(), new UserAuthPublicKeyFactory()));
78         };
79         try {
80             client = transportStackFactory.listenClient(TransportConstants.SSH_SUBSYSTEM, transportChannelListener,
81                 tcpServerParams, sshClientParams, configurator).get(DEFAULT_TIMEOUT_MILLIS, TimeUnit.MILLISECONDS);
82         } catch (UnsupportedConfigurationException | InterruptedException | ExecutionException | TimeoutException e) {
83             throw new IllegalStateException("Could not start SSH Call-Home server", e);
84         }
85     }
86
87     private SessionListener createSessionListener() {
88         return new SessionListener() {
89             @Override
90             public void sessionClosed(final Session session) {
91                 if (session instanceof ClientSession clientSession) {
92                     final var context = contextManager.findBySshSession(clientSession);
93                     if (context != null) {
94                         contextManager.remove(context.id());
95                         if (!clientSession.isAuthenticated()) {
96                             // threat unauthenticated session closure as authentication failure
97                             // in case there was context object created for the session
98                             statusRecorder.reportFailedAuth(context.id());
99                         } else if (context.settableFuture().isDone()) {
100                             // disconnected after netconf session established
101                             statusRecorder.reportDisconnected(context.id());
102                         }
103                     }
104                 }
105             }
106         };
107     }
108
109     private boolean verifyServerKey(final ClientSession clientSession, final SocketAddress remoteAddress,
110             final PublicKey serverKey) {
111         final CallHomeSshAuthSettings authSettings = authProvider.provideAuth(remoteAddress, serverKey);
112         if (authSettings == null) {
113             // no auth for server key
114             statusRecorder.reportUnknown(remoteAddress, serverKey);
115             LOG.info("No auth settings found. Connection from {} rejected.", remoteAddress);
116             return false;
117         }
118         if (contextManager.exists(authSettings.id())) {
119             LOG.info("Session context with same id {} already exists. Connection from {} rejected.",
120                 authSettings.id(), remoteAddress);
121             return false;
122         }
123         final var context = contextManager.createContext(authSettings.id(), clientSession);
124         if (context == null) {
125             // if there is an issue creating context then the cause expected to be
126             // logged within overridden createContext() method
127             return false;
128         }
129         contextManager.register(context);
130
131         // Session context is ok, apply auth settings to current session
132         authSettings.applyTo(clientSession);
133         LOG.debug("Session context is created for SSH session: {}", context);
134         return true;
135     }
136
137     @Override
138     public void close() throws Exception {
139         contextManager.close();
140         client.shutdown().get();
141     }
142
143     public static Builder builder() {
144         return new Builder();
145     }
146
147     public static final class Builder {
148         private InetAddress address;
149         private int port = DEFAULT_PORT;
150         private SSHTransportStackFactory transportStackFactory;
151         private NetconfClientSessionNegotiatorFactory negotiationFactory;
152         private CallHomeSshAuthProvider authProvider;
153         private CallHomeSshSessionContextManager contextManager;
154         private CallHomeStatusRecorder statusRecorder;
155
156         private Builder() {
157             // on purpose
158         }
159
160         public @NonNull CallHomeSshServer build() {
161             return new CallHomeSshServer(
162                 toServerParams(address, port),
163                 transportStackFactory == null ? defaultTransportStackFactory() : transportStackFactory,
164                 negotiationFactory,
165                 contextManager == null ? new CallHomeSshSessionContextManager() : contextManager,
166                 authProvider, statusRecorder);
167         }
168
169         public Builder withAuthProvider(final CallHomeSshAuthProvider newAuthProvider) {
170             authProvider = newAuthProvider;
171             return this;
172         }
173
174         public Builder withSessionContextManager(final CallHomeSshSessionContextManager newContextManager) {
175             contextManager = newContextManager;
176             return this;
177         }
178
179         public Builder withStatusRecorder(final CallHomeStatusRecorder newStatusRecorder) {
180             statusRecorder = newStatusRecorder;
181             return this;
182         }
183
184         public Builder withAddress(final InetAddress newAddress) {
185             address = newAddress;
186             return this;
187         }
188
189         public Builder withPort(final int newPort) {
190             port = newPort;
191             return this;
192         }
193
194         public Builder withTransportStackFactory(final SSHTransportStackFactory newTransportStackFactory) {
195             transportStackFactory = newTransportStackFactory;
196             return this;
197         }
198
199         public Builder withNegotiationFactory(final NetconfClientSessionNegotiatorFactory newNegotiationFactory) {
200             negotiationFactory = newNegotiationFactory;
201             return this;
202         }
203     }
204
205     private static TcpServerGrouping toServerParams(final InetAddress address, final int port) {
206         final var ipAddress = IetfInetUtil.ipAddressFor(
207             address == null ? InetAddress.getLoopbackAddress() : address);
208         final var portNumber = new PortNumber(Uint16.valueOf(port < 0 ? DEFAULT_PORT : port));
209         return new TcpServerParametersBuilder().setLocalAddress(ipAddress).setLocalPort(portNumber).build();
210     }
211
212     private static SSHTransportStackFactory defaultTransportStackFactory() {
213         return new SSHTransportStackFactory("ssh-call-home-server", 0);
214     }
215 }