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