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.callhome.server.ssh;
10 import static java.util.Objects.requireNonNull;
11 import static org.opendaylight.netconf.client.NetconfClientSessionNegotiatorFactory.DEFAULT_CLIENT_CAPABILITIES;
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;
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;
52 private final CallHomeSshAuthProvider authProvider;
53 private final CallHomeStatusRecorder statusRecorder;
54 private final CallHomeSshSessionContextManager contextManager;
55 private final SSHClient client;
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);
69 final var transportChannelListener =
70 new CallHomeTransportChannelListener(negotiatorFactory, contextManager, statusRecorder);
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()));
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);
90 private SessionListener createSessionListener() {
91 return new SessionListener() {
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());
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);
121 if (contextManager.exists(authSettings.id())) {
122 LOG.info("Session context with same id {} already exists. Connection from {} rejected.",
123 authSettings.id(), remoteAddress);
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
132 contextManager.register(context);
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);
141 public void close() throws Exception {
142 contextManager.close();
143 client.shutdown().get();
146 public static Builder builder() {
147 return new Builder();
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;
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);
172 public Builder withAuthProvider(final CallHomeSshAuthProvider newAuthProvider) {
173 this.authProvider = newAuthProvider;
177 public Builder withSessionContextManager(final CallHomeSshSessionContextManager newContextManager) {
178 this.contextManager = newContextManager;
182 public Builder withStatusRecorder(final CallHomeStatusRecorder newStatusRecorder) {
183 this.statusRecorder = newStatusRecorder;
187 public Builder withAddress(final InetAddress newAddress) {
188 this.address = newAddress;
192 public Builder withPort(final int newPort) {
197 public Builder withTransportStackFactory(final SSHTransportStackFactory newTransportStackFactory) {
198 this.transportStackFactory = newTransportStackFactory;
202 public Builder withNegotiationFactory(final NetconfClientSessionNegotiatorFactory newNegotiationFactory) {
203 this.negotiationFactory = newNegotiationFactory;
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();
215 private static SSHTransportStackFactory defaultTransportStackFactory() {
216 return new SSHTransportStackFactory("ssh-call-home-server", 0);
219 private static NetconfClientSessionNegotiatorFactory defaultNegotiationFactory() {
220 return new NetconfClientSessionNegotiatorFactory(new HashedWheelTimer(),
221 Optional.empty(), DEFAULT_TIMEOUT_MILLIS, DEFAULT_CLIENT_CAPABILITIES);