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;
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;
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;
49 private final CallHomeSshAuthProvider authProvider;
50 private final CallHomeStatusRecorder statusRecorder;
51 private final CallHomeSshSessionContextManager contextManager;
52 private final SSHClient client;
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);
66 final var transportChannelListener =
67 new CallHomeTransportChannelListener(negotiatorFactory, contextManager, statusRecorder);
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()));
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);
87 private SessionListener createSessionListener() {
88 return new SessionListener() {
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());
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);
118 if (contextManager.exists(authSettings.id())) {
119 LOG.info("Session context with same id {} already exists. Connection from {} rejected.",
120 authSettings.id(), remoteAddress);
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
129 contextManager.register(context);
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);
138 public void close() throws Exception {
139 contextManager.close();
140 client.shutdown().get();
143 public static Builder builder() {
144 return new Builder();
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;
160 public @NonNull CallHomeSshServer build() {
161 return new CallHomeSshServer(
162 toServerParams(address, port),
163 transportStackFactory == null ? defaultTransportStackFactory() : transportStackFactory,
165 contextManager == null ? new CallHomeSshSessionContextManager() : contextManager,
166 authProvider, statusRecorder);
169 public Builder withAuthProvider(final CallHomeSshAuthProvider newAuthProvider) {
170 authProvider = newAuthProvider;
174 public Builder withSessionContextManager(final CallHomeSshSessionContextManager newContextManager) {
175 contextManager = newContextManager;
179 public Builder withStatusRecorder(final CallHomeStatusRecorder newStatusRecorder) {
180 statusRecorder = newStatusRecorder;
184 public Builder withAddress(final InetAddress newAddress) {
185 address = newAddress;
189 public Builder withPort(final int newPort) {
194 public Builder withTransportStackFactory(final SSHTransportStackFactory newTransportStackFactory) {
195 transportStackFactory = newTransportStackFactory;
199 public Builder withNegotiationFactory(final NetconfClientSessionNegotiatorFactory newNegotiationFactory) {
200 negotiationFactory = newNegotiationFactory;
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();
212 private static SSHTransportStackFactory defaultTransportStackFactory() {
213 return new SSHTransportStackFactory("ssh-call-home-server", 0);