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