mirror of
https://github.com/SerenityOS/serenity
synced 2026-04-25 17:15:42 +02:00
530 lines
19 KiB
C++
530 lines
19 KiB
C++
/*
|
|
* Copyright (c) 2026, Lucas Chollet <lucas.chollet@serenityos.org>
|
|
*
|
|
* SPDX-License-Identifier: BSD-2-Clause
|
|
*/
|
|
|
|
#include "SSHClient.h"
|
|
|
|
#include <AK/ByteBuffer.h>
|
|
#include <AK/Debug.h>
|
|
#include <AK/Format.h>
|
|
#include <AK/MemoryStream.h>
|
|
#include <AK/Random.h>
|
|
#include <LibCore/Account.h>
|
|
#include <LibCore/Command.h>
|
|
#include <LibCore/Socket.h>
|
|
#include <LibCrypto/Curves/Ed25519.h>
|
|
#include <LibCrypto/Curves/X25519.h>
|
|
#include <LibSSH/DataTypes.h>
|
|
#include <LibSSH/IdentificationString.h>
|
|
#include <Services/SSHServer/ServerConfiguration.h>
|
|
|
|
namespace SSH::Server {
|
|
|
|
ErrorOr<SSHClient::ShouldDisconnect> SSHClient::handle_data(ByteBuffer& data)
|
|
{
|
|
switch (m_state) {
|
|
case State::Constructed:
|
|
TRY(handle_protocol_version(data));
|
|
break;
|
|
case State::WaitingForKeyProtocolExchange:
|
|
TRY(handle_key_protocol_exchange(data));
|
|
break;
|
|
case State::WaitingForKeyExchange:
|
|
TRY(handle_key_exchange(data));
|
|
break;
|
|
case State::WaitingForNewKeysMessage:
|
|
TRY(handle_new_keys_message(data));
|
|
m_state = State::KeyExchanged;
|
|
break;
|
|
case State::KeyExchanged:
|
|
TRY(handle_service_request(TRY(unpack_generic_message(data))));
|
|
break;
|
|
case State::WaitingForUserAuthentication:
|
|
TRY(handle_user_authentication(TRY(unpack_generic_message(data))));
|
|
break;
|
|
case State::Authentified:
|
|
return handle_generic_packet(TRY(unpack_generic_message(data)));
|
|
}
|
|
return ShouldDisconnect::No;
|
|
}
|
|
|
|
// 4.2. Protocol Version Exchange
|
|
// https://datatracker.ietf.org/doc/html/rfc4253#section-4.2
|
|
|
|
ErrorOr<void> SSHClient::handle_protocol_version(ByteBuffer& data)
|
|
{
|
|
TRY(validate_identification_string(data));
|
|
|
|
auto full_protocol_bytes = data.bytes().trim(data.bytes().size() - 2);
|
|
m_key_exchange_data.client_identification_string = TRY(ByteBuffer::copy(full_protocol_bytes));
|
|
m_key_exchange_data.server_identification_string = TRY(ByteBuffer::copy(
|
|
PROTOCOL_STRING.substring_view(0, PROTOCOL_STRING.length() - 2).bytes()));
|
|
|
|
data.clear();
|
|
|
|
TRY(m_tcp_socket.write_until_depleted(PROTOCOL_STRING));
|
|
|
|
m_state = State::WaitingForKeyProtocolExchange;
|
|
|
|
return {};
|
|
}
|
|
|
|
// 7. Key Exchange
|
|
// https://datatracker.ietf.org/doc/html/rfc4253#section-7
|
|
ErrorOr<void> SSHClient::handle_key_protocol_exchange(ByteBuffer& data)
|
|
{
|
|
auto payload = TRY(read_packet(data));
|
|
|
|
auto stream = FixedMemoryStream { payload.bytes() };
|
|
|
|
m_key_exchange_data.client_key_init_payload = payload;
|
|
|
|
auto message_id = TRY(stream.read_value<u8>());
|
|
if (message_id != to_underlying(MessageID::KEXINIT))
|
|
return Error::from_string_literal("Expected Key exchange message");
|
|
|
|
// byte[16] cookie (random bytes)
|
|
TRY(stream.discard(16));
|
|
|
|
// FIXME: Actually read the key exchange message.
|
|
// Right now, we just send back our favorites and assume that
|
|
// the client can use them. This is true for modern OpenSSH.
|
|
|
|
TRY(send_key_protocol_message());
|
|
|
|
m_state = State::WaitingForKeyExchange;
|
|
|
|
dbgln_if(SSH_DEBUG, "KEXINIT message sent");
|
|
|
|
return {};
|
|
}
|
|
|
|
// 7.1. Algorithm Negotiation
|
|
// https://datatracker.ietf.org/doc/html/rfc4253#section-7.1
|
|
|
|
static constexpr auto KEX_ALGORITHMS = to_array({ "curve25519-sha256"sv });
|
|
static constexpr auto SERVER_HOST_KEY_ALGORITHMS = to_array({ "ssh-ed25519"sv });
|
|
static constexpr auto ENCRYPTION_ALGORITHMS_CLIENT_TO_SERVER = to_array({ "chacha20-poly1305@openssh.com"sv });
|
|
static constexpr auto ENCRYPTION_ALGORITHMS_SERVER_TO_CLIENT = to_array({ "chacha20-poly1305@openssh.com"sv });
|
|
static constexpr Array<StringView, 0> MAC_ALGORITHMS_CLIENT_TO_SERVER {};
|
|
static constexpr Array<StringView, 0> MAC_ALGORITHMS_SERVER_TO_CLIENT {};
|
|
|
|
// Per 5. Negotiation:
|
|
// https://datatracker.ietf.org/doc/html/draft-ietf-sshm-chacha20-poly1305-02#section-5
|
|
// The "chacha20-poly1305" offers both encryption and authentication. As
|
|
// such, no separate MAC is required. If the "chacha20-poly1305" cipher is
|
|
// selected in key exchange, the offered MAC algorithms are ignored and no
|
|
// MAC is required to be negotiated.
|
|
|
|
static constexpr auto COMPRESSION_ALGORITHMS_CLIENT_TO_SERVER = to_array({ "none"sv });
|
|
static constexpr auto COMPRESSION_ALGORITHMS_SERVER_TO_CLIENT = to_array({ "none"sv });
|
|
static constexpr Array<StringView, 0> LANGUAGES_CLIENT_TO_SERVER {};
|
|
static constexpr Array<StringView, 0> LANGUAGES_SERVER_TO_CLIENT {};
|
|
|
|
ErrorOr<void> SSHClient::send_key_protocol_message()
|
|
{
|
|
AllocatingMemoryStream stream;
|
|
|
|
TRY(stream.write_value<u8>(to_underlying(MessageID::KEXINIT)));
|
|
|
|
auto cookie = TRY(ByteBuffer::create_uninitialized(16));
|
|
fill_with_random(cookie);
|
|
TRY(stream.write_until_depleted(cookie));
|
|
|
|
TRY(encode_name_list(stream, KEX_ALGORITHMS));
|
|
TRY(encode_name_list(stream, SERVER_HOST_KEY_ALGORITHMS));
|
|
TRY(encode_name_list(stream, ENCRYPTION_ALGORITHMS_CLIENT_TO_SERVER));
|
|
TRY(encode_name_list(stream, ENCRYPTION_ALGORITHMS_SERVER_TO_CLIENT));
|
|
TRY(encode_name_list(stream, MAC_ALGORITHMS_CLIENT_TO_SERVER));
|
|
TRY(encode_name_list(stream, MAC_ALGORITHMS_SERVER_TO_CLIENT));
|
|
TRY(encode_name_list(stream, COMPRESSION_ALGORITHMS_CLIENT_TO_SERVER));
|
|
TRY(encode_name_list(stream, COMPRESSION_ALGORITHMS_SERVER_TO_CLIENT));
|
|
TRY(encode_name_list(stream, LANGUAGES_CLIENT_TO_SERVER));
|
|
TRY(encode_name_list(stream, LANGUAGES_SERVER_TO_CLIENT));
|
|
|
|
// first_kex_packet_follows
|
|
TRY(stream.write_value(static_cast<u8>(false)));
|
|
|
|
// "reserved for future extension"
|
|
TRY(stream.write_value(static_cast<u32>(0)));
|
|
|
|
auto payload = TRY(stream.read_until_eof());
|
|
m_key_exchange_data.server_key_init_payload = payload;
|
|
TRY(write_packet(payload));
|
|
return {};
|
|
}
|
|
|
|
// 4. ECDH Key Exchange
|
|
// https://datatracker.ietf.org/doc/html/rfc5656#section-4
|
|
ErrorOr<void> SSHClient::handle_key_exchange(ByteBuffer& data)
|
|
{
|
|
auto packet = TRY(read_packet(data));
|
|
|
|
auto stream = FixedMemoryStream { packet.bytes() };
|
|
|
|
auto message_id = TRY(stream.read_value<u8>());
|
|
if (message_id != to_underlying(MessageID::KEX_ECDH_INIT))
|
|
return Error::from_string_literal("Expected Key ECDH exchange message");
|
|
|
|
auto Q_C = TRY(decode_string(stream));
|
|
|
|
// The host key type is determined, we can put it in the cryptographic data.
|
|
m_key_exchange_data.server_public_host_key = ServerConfiguration::the().ssh_ed25519_server_public_key();
|
|
|
|
TRY(send_ecdh_reply(move(Q_C)));
|
|
|
|
TRY(send_new_keys_message());
|
|
|
|
m_state = State::WaitingForNewKeysMessage;
|
|
return {};
|
|
}
|
|
|
|
ErrorOr<void> SSHClient::send_ecdh_reply(ByteBuffer&& client_public_key)
|
|
{
|
|
// "Verify received key is valid."
|
|
// FIXME: Do the above step.
|
|
if (client_public_key.size() != 32)
|
|
return Error::from_string_literal("Expected 32 byte ECDH public key");
|
|
|
|
m_key_exchange_data.client_ephemeral_publickey = client_public_key;
|
|
|
|
// "Generate ephemeral key pair."
|
|
Crypto::Curves::X25519 curve;
|
|
auto private_key = TRY(curve.generate_private_key());
|
|
auto public_key = TRY(curve.generate_public_key(private_key));
|
|
m_key_exchange_data.server_ephemeral_publickey = public_key;
|
|
|
|
// "Compute shared secret."
|
|
auto shared_secret = TRY(curve.compute_coordinate(private_key, client_public_key));
|
|
m_key_exchange_data.shared_secret = shared_secret;
|
|
set_shared_secret(move(shared_secret));
|
|
|
|
// FIXME: Abort if shared_point is not valid (at least when it's all zero, maybe there are other cases too).
|
|
|
|
// Generate and sign exchange hash.
|
|
auto hash = TRY(m_key_exchange_data.compute_sha_256());
|
|
set_hash(hash);
|
|
|
|
// "The server responds with:
|
|
// byte SSH_MSG_KEX_ECDH_REPLY
|
|
// string K_S, server's public host key
|
|
// string Q_S, server's ephemeral public key octet string
|
|
// string the signature on the exchange hash"
|
|
|
|
AllocatingMemoryStream stream;
|
|
|
|
TRY(stream.write_value(to_underlying(MessageID::KEX_ECDH_REPLY)));
|
|
TRY(m_key_exchange_data.server_public_host_key.encode(stream));
|
|
TRY(encode_string(stream, m_key_exchange_data.server_ephemeral_publickey));
|
|
|
|
auto signature = TRY(Crypto::Curves::Ed25519::sign(
|
|
ServerConfiguration::the().ssh_ed25519_server_public_key().key,
|
|
ServerConfiguration::the().ssh_ed25519_server_private_key().key,
|
|
hash.bytes()));
|
|
|
|
TypedBlob signature_and_type {
|
|
.type = TypedBlob::Type::SSH_ED25519,
|
|
.key = move(signature),
|
|
};
|
|
|
|
TRY(signature_and_type.encode(stream));
|
|
|
|
TRY(write_packet(TRY(stream.read_until_eof())));
|
|
|
|
dbgln_if(SSH_DEBUG, "KEX_ECDH_REPLY message sent");
|
|
|
|
return {};
|
|
}
|
|
|
|
ErrorOr<GenericMessage> SSHClient::unpack_generic_message(ByteBuffer& data)
|
|
{
|
|
auto payload = TRY(read_packet(data));
|
|
|
|
// This is ensured by read_packet().
|
|
VERIFY(payload.size() >= 1);
|
|
|
|
return GenericMessage { move(payload) };
|
|
}
|
|
|
|
// 10. Service Request
|
|
// https://datatracker.ietf.org/doc/html/rfc4253#section-10
|
|
ErrorOr<void> SSHClient::handle_service_request(GenericMessage message)
|
|
{
|
|
if (message.type != MessageID::SERVICE_REQUEST) {
|
|
dbgln_if(SSH_DEBUG, "Received packet type: {}", to_underlying(message.type));
|
|
return Error::from_string_literal("Expected packet of type SERVICE_REQUEST");
|
|
}
|
|
|
|
auto service_name = TRY(decode_string(message.payload));
|
|
dbgln_if(SSH_DEBUG, "Service '{:s}' requested", service_name.bytes());
|
|
|
|
if (service_name == "ssh-userauth"sv.bytes()) {
|
|
// "If the server supports the service (and permits the client to use
|
|
// it), it MUST respond with the following:
|
|
// byte SSH_MSG_SERVICE_ACCEPT
|
|
// string service name
|
|
// "
|
|
|
|
TRY(send_service_accept(service_name));
|
|
m_state = State::WaitingForUserAuthentication;
|
|
return {};
|
|
}
|
|
|
|
// FIXME: "If the server rejects the service request, it SHOULD send an
|
|
// appropriate SSH_MSG_DISCONNECT message and MUST disconnect."
|
|
|
|
return Error::from_string_literal("Unexpected service name");
|
|
}
|
|
|
|
ErrorOr<void> SSHClient::send_service_accept(StringView service_name)
|
|
{
|
|
AllocatingMemoryStream stream;
|
|
TRY(stream.write_value(MessageID::SERVICE_ACCEPT));
|
|
TRY(encode_string(stream, service_name));
|
|
TRY(write_packet(TRY(stream.read_until_eof())));
|
|
return {};
|
|
}
|
|
|
|
// 5. Authentication Requests
|
|
// https://datatracker.ietf.org/doc/html/rfc4252#section-5
|
|
ErrorOr<void> SSHClient::handle_user_authentication(GenericMessage message)
|
|
{
|
|
if (message.type != MessageID::USERAUTH_REQUEST)
|
|
return Error::from_string_literal("Expected packet of type USERAUTH_REQUEST");
|
|
|
|
auto username = TRY(decode_string(message.payload));
|
|
auto service_name = TRY(decode_string(message.payload));
|
|
auto method_name = TRY(decode_string(message.payload));
|
|
|
|
dbgln_if(SSH_DEBUG, "User authentication username: {:s}", username.bytes());
|
|
dbgln_if(SSH_DEBUG, "User authentication service_name: {:s}", service_name.bytes());
|
|
dbgln_if(SSH_DEBUG, "User authentication method name: {:s}", method_name.bytes());
|
|
|
|
if (username != TRY(Core::Account::self(Core::Account::Read::PasswdOnly)).username())
|
|
return Error::from_string_literal("Can't authenticate for another user account");
|
|
|
|
// "The 'service name' specifies the service to start after authentication. There may
|
|
// be several different authenticated services provided. If the requested service is
|
|
// not available, the server MAY disconnect immediately or at any later time. Sending
|
|
// a proper disconnect message is RECOMMENDED. In any case, if the service does not
|
|
// exist, authentication MUST NOT be accepted."
|
|
if (service_name != "ssh-connection"sv.bytes())
|
|
return Error::from_string_literal("Unknown service name.");
|
|
|
|
if (method_name == "none"sv.bytes()) {
|
|
// FIXME: Implement proper authentication!!!
|
|
|
|
m_state = State::Authentified;
|
|
TRY(send_user_authentication_success());
|
|
|
|
// FIXME: Also send a cool banner :^)
|
|
|
|
return {};
|
|
}
|
|
|
|
return Error::from_string_literal("Unsupported userauth method");
|
|
}
|
|
|
|
// 5.1. Responses to Authentication Requests
|
|
// https://datatracker.ietf.org/doc/html/rfc4252#section-5.1
|
|
ErrorOr<void> SSHClient::send_user_authentication_success()
|
|
{
|
|
AllocatingMemoryStream stream;
|
|
TRY(stream.write_value(MessageID::USERAUTH_SUCCESS));
|
|
TRY(write_packet(TRY(stream.read_until_eof())));
|
|
return {};
|
|
}
|
|
|
|
ErrorOr<SSHClient::ShouldDisconnect> SSHClient::handle_generic_packet(GenericMessage&& message)
|
|
{
|
|
switch (message.type) {
|
|
case MessageID::DISCONNECT:
|
|
TRY(handle_disconnect_message(message.data));
|
|
return ShouldDisconnect::Yes;
|
|
case MessageID::CHANNEL_OPEN:
|
|
TRY(handle_channel_open_message(message));
|
|
break;
|
|
case MessageID::CHANNEL_REQUEST:
|
|
TRY(handle_channel_request(message));
|
|
break;
|
|
case MessageID::CHANNEL_CLOSE:
|
|
TRY(handle_channel_close(message));
|
|
break;
|
|
default:
|
|
dbgln_if(SSH_DEBUG, "Unexpected packet: {}", to_underlying(message.type));
|
|
return Error::from_string_literal("Unexpected packet type");
|
|
}
|
|
return ShouldDisconnect::No;
|
|
}
|
|
|
|
// 5.1. Opening a Channel
|
|
// https://datatracker.ietf.org/doc/html/rfc4254#section-5.1
|
|
ErrorOr<void> SSHClient::handle_channel_open_message(GenericMessage& message)
|
|
{
|
|
auto channel_type = TRY(decode_string(message.payload));
|
|
u32 sender_channel_id = TRY(message.payload.read_value<NetworkOrdered<u32>>());
|
|
u32 initial_window_size = TRY(message.payload.read_value<NetworkOrdered<u32>>());
|
|
u32 maximum_packet_size = TRY(message.payload.read_value<NetworkOrdered<u32>>());
|
|
|
|
dbgln_if(SSH_DEBUG, "Channel open request with: {:s} - {} - {} - {}",
|
|
channel_type.bytes(), sender_channel_id, initial_window_size, maximum_packet_size);
|
|
|
|
if (channel_type != "session"sv.bytes())
|
|
return Error::from_string_literal("Unexpected channel type");
|
|
|
|
m_sessions.empend(TRY(Session::create(sender_channel_id, initial_window_size, maximum_packet_size)));
|
|
|
|
TRY(send_channel_open_confirmation(m_sessions.last()));
|
|
|
|
return {};
|
|
}
|
|
|
|
// 5.1. Opening a Channel
|
|
// https://datatracker.ietf.org/doc/html/rfc4254#section-5.1
|
|
ErrorOr<void> SSHClient::send_channel_open_confirmation(Session const& session)
|
|
{
|
|
AllocatingMemoryStream stream;
|
|
|
|
// byte SSH_MSG_CHANNEL_OPEN_CONFIRMATION
|
|
// uint32 recipient channel
|
|
// uint32 sender channel
|
|
// uint32 initial window size
|
|
// uint32 maximum packet size
|
|
|
|
TRY(stream.write_value(MessageID::CHANNEL_OPEN_CONFIRMATION));
|
|
// "The 'recipient channel' is the channel number given in the original
|
|
// open request, and 'sender channel' is the channel number allocated by
|
|
// the other side."
|
|
TRY(stream.write_value<NetworkOrdered<u32>>(session.sender_channel_id));
|
|
TRY(stream.write_value<NetworkOrdered<u32>>(session.local_channel_id));
|
|
|
|
TRY(stream.write_value<NetworkOrdered<u32>>(session.window.size()));
|
|
TRY(stream.write_value<NetworkOrdered<u32>>(session.maximum_packet_size));
|
|
|
|
TRY(write_packet(TRY(stream.read_until_eof())));
|
|
return {};
|
|
}
|
|
|
|
ErrorOr<Session*> SSHClient::find_session(u32 sender_channel_id)
|
|
{
|
|
for (auto& session : m_sessions) {
|
|
if (session.sender_channel_id == sender_channel_id)
|
|
return &session;
|
|
}
|
|
return Error::from_string_literal("Session not found");
|
|
}
|
|
|
|
// 5.4. Channel-Specific Requests
|
|
// https://datatracker.ietf.org/doc/html/rfc4254#section-5.4
|
|
ErrorOr<void> SSHClient::handle_channel_request(GenericMessage& message)
|
|
{
|
|
auto recipient_channel_id = TRY(message.payload.read_value<NetworkOrdered<u32>>());
|
|
auto request_type = TRY(decode_string(message.payload));
|
|
auto want_reply = TRY(message.payload.read_value<bool>());
|
|
|
|
auto& session = *TRY(find_session(recipient_channel_id));
|
|
|
|
dbgln_if(SSH_DEBUG, "CHANNEL_REQUEST id({}): {:s}", session.local_channel_id, request_type.bytes());
|
|
|
|
if (request_type == "env"sv.bytes() && !want_reply) {
|
|
dbgln("FIXME: Ignored channel request: {:s}", request_type.bytes());
|
|
return {};
|
|
}
|
|
|
|
if (request_type == "exec"sv.bytes()) {
|
|
TRY(handle_channel_exec(session, message));
|
|
return {};
|
|
}
|
|
|
|
return Error::from_string_literal("Unsupported channel request");
|
|
}
|
|
|
|
// 6.5. Starting a Shell or a Command
|
|
// https://datatracker.ietf.org/doc/html/rfc4254#section-6.5
|
|
ErrorOr<void> SSHClient::handle_channel_exec(Session& session, GenericMessage& message)
|
|
{
|
|
auto command = TRY(decode_string(message.payload));
|
|
|
|
// FIXME: This is a naive implementation, we should stream the result back
|
|
// to the user and not block the event loop during the execution of
|
|
// the command.
|
|
// We should also use the user's shell rather than hardcoding it.
|
|
|
|
#ifdef AK_OS_SERENITY
|
|
auto shell = "/bin/Shell"sv;
|
|
#else
|
|
auto shell = "/bin/sh"sv;
|
|
#endif
|
|
|
|
Vector<ByteString> args;
|
|
args.append(shell);
|
|
args.append("-c");
|
|
args.append(ByteString(command.bytes()));
|
|
|
|
Vector<char const*> raw_args;
|
|
raw_args.ensure_capacity(args.size() + 1);
|
|
for (auto& arg : args)
|
|
raw_args.append(arg.characters());
|
|
|
|
raw_args.append(nullptr);
|
|
|
|
auto child = TRY(Core::Command::create(shell, raw_args.data()));
|
|
auto output = TRY(child->read_all());
|
|
auto status = TRY(child->status());
|
|
|
|
if (status != Core::Command::ProcessResult::DoneWithZeroExitCode)
|
|
return Error::from_string_literal("Unable to run command");
|
|
|
|
TRY(send_channel_success_message(session));
|
|
TRY(send_channel_data(session, output.standard_output));
|
|
TRY(send_channel_close(session));
|
|
return {};
|
|
}
|
|
|
|
ErrorOr<void> SSHClient::send_channel_success_message(Session const& session)
|
|
{
|
|
AllocatingMemoryStream stream;
|
|
TRY(stream.write_value(MessageID::CHANNEL_SUCCESS));
|
|
TRY(stream.write_value<NetworkOrdered<u32>>(session.local_channel_id));
|
|
TRY(write_packet(TRY(stream.read_until_eof())));
|
|
return {};
|
|
}
|
|
|
|
ErrorOr<void> SSHClient::send_channel_data(Session const& session, ReadonlyBytes data)
|
|
{
|
|
AllocatingMemoryStream stream;
|
|
TRY(stream.write_value(MessageID::CHANNEL_DATA));
|
|
TRY(stream.write_value<NetworkOrdered<u32>>(session.local_channel_id));
|
|
TRY(encode_string(stream, data));
|
|
TRY(write_packet(TRY(stream.read_until_eof())));
|
|
return {};
|
|
}
|
|
|
|
ErrorOr<void> SSHClient::handle_channel_close(GenericMessage& message)
|
|
{
|
|
auto recipient_channel_id = TRY(message.payload.read_value<NetworkOrdered<u32>>());
|
|
auto& session = *TRY(find_session(recipient_channel_id));
|
|
|
|
if (!session.is_closed)
|
|
TRY(send_channel_close(session));
|
|
|
|
m_sessions.remove_first_matching([&](Session const& s) { return s.local_channel_id == session.local_channel_id; });
|
|
|
|
return {};
|
|
}
|
|
|
|
ErrorOr<void> SSHClient::send_channel_close(Session& session)
|
|
{
|
|
session.is_closed = true;
|
|
|
|
AllocatingMemoryStream stream;
|
|
TRY(stream.write_value(MessageID::CHANNEL_CLOSE));
|
|
TRY(stream.write_value<NetworkOrdered<u32>>(session.local_channel_id));
|
|
TRY(write_packet(TRY(stream.read_until_eof())));
|
|
return {};
|
|
}
|
|
|
|
} // SSHServer
|