Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions src/inc/mini_buffer.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,27 @@ namespace ohtoai
data = new char[capacity];
}

mini_buffer(const mini_buffer&) = delete;
mini_buffer& operator=(const mini_buffer&) = delete;

mini_buffer(mini_buffer&& other) noexcept
: data(other.data), size(other.size), capacity(other.capacity) {
other.data = nullptr;
other.size = 0;
other.capacity = 0;
}
mini_buffer& operator=(mini_buffer&& other) noexcept {
if (this != &other) {
delete[] data;
data = other.data;
size = other.size;
capacity = other.capacity;
other.data = nullptr;
other.size = 0;
other.capacity = 0;
}
return *this;
}

~mini_buffer() {
delete[] data;
Expand Down
203 changes: 149 additions & 54 deletions src/main.cpp
Original file line number Diff line number Diff line change
@@ -1,9 +1,16 @@
#include "ssh_connection/ssh_connection.h"

#include <spdlog/spdlog.h>
#include <spdlog/fmt/fmt.h>
#include <chrono>
#include <cstring>
#include <map>
#include <stdexcept>
#include <string>
#include <thread>
#include <set>
#include <mutex>
#include <vector>
#include <hv/WebSocketServer.h>
#include <hv/HttpServer.h>
#include <hv/EventLoop.h>
Expand All @@ -16,6 +23,8 @@ class ssh_context : public std::mutex {
std::set<WebSocketChannelPtr> channels_write;
void close() {
std::lock_guard lock(*this);
if (closed_) return;
closed_ = true;
for (auto& channel : channels_read) {
channel->close();
}
Expand All @@ -32,11 +41,40 @@ class ssh_context : public std::mutex {
close();
}
inline static std::map<ohtoai::ssh::channel_id_t, std::weak_ptr<ssh_context>> ssh_contexts;
inline static std::mutex ssh_contexts_mutex;
private:
bool closed_ = false;
};

// Poll interval when no SSH data is available.
// 10ms gives good responsiveness without busy-looping.
static constexpr int kSshPollIntervalMs = 10;

int main(int argc, char *argv[]) {
if (argc > 1 && strcmp(argv[1], "-d") == 0 ) {
spdlog::set_level(spdlog::level::debug);
int port = 8080;
std::string host = "0.0.0.0";

for (int i = 1; i < argc; ++i) {
if (strcmp(argv[i], "-d") == 0) {
spdlog::set_level(spdlog::level::debug);
} else if (strcmp(argv[i], "-p") == 0 && i + 1 < argc) {
try {
port = std::stoi(argv[++i]);
if (port <= 0 || port > 65535) throw std::out_of_range("port out of range");
} catch (const std::exception& e) {
fmt::print(stderr, "Invalid port: {}\n", argv[i]);
return 1;
}
} else if (strcmp(argv[i], "-a") == 0 && i + 1 < argc) {
host = argv[++i];
} else if (strcmp(argv[i], "-h") == 0 || strcmp(argv[i], "--help") == 0) {
fmt::print("Usage: {} [-d] [-p port] [-a address]\n", argv[0]);
fmt::print(" -d Enable debug logging\n");
fmt::print(" -p port Listen port (default: 8080)\n");
fmt::print(" -a address Listen address (default: 0.0.0.0)\n");
fmt::print(" -h Show this help\n");
return 0;
}
}

hv::WebSocketService ws;
Expand All @@ -45,28 +83,40 @@ int main(int argc, char *argv[]) {
auto channel_id = req->GetParam("id");
if (channel_id.empty()) {
spdlog::error("channel_id is empty");
channel->close();
return;
}

// find ssh_context by channel_id
auto it = ssh_context::ssh_contexts.find(channel_id);
if (it != ssh_context::ssh_contexts.end()) {
auto ctx = it->second.lock();
if (ctx != nullptr) {
ctx->channels_read.emplace(channel);
spdlog::info("[{}] Add channel to existing ssh_context", channel_id);
return;
// Find or create ssh_context for this channel_id
{
std::lock_guard<std::mutex> guard(ssh_context::ssh_contexts_mutex);
auto it = ssh_context::ssh_contexts.find(channel_id);
if (it != ssh_context::ssh_contexts.end()) {
auto ctx = it->second.lock();
if (ctx != nullptr) {
// Attach as read-only observer to an existing session
std::lock_guard lock(*ctx);
ctx->channels_read.emplace(channel);

Copilot AI Apr 1, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In the "attach as read-only observer" path, the WebSocket channel is added to ctx->channels_read and returns without associating ctx as the channel's context (no newContextPtr/shared context set). As a result onmessage/onclose will see getContextPtr<ssh_context>() == nullptr, so observer connections won't be cleaned up on close and may log "ctx is null"; additionally, disconnected observers won't be removed until SSH output arrives (the cleanup currently only happens in the send loop). Attach the shared context to the observer channel (or store the channel_id in per-channel context and look up the shared ssh_context in handlers) so cleanup and read-only enforcement work reliably.

Suggested change
ctx->channels_read.emplace(channel);
ctx->channels_read.emplace(channel);
// Associate this WebSocket channel with the existing ssh_context
channel->setContext(ctx);

Copilot uses AI. Check for mistakes.
channel->setContextPtr(ctx);
spdlog::info("[{}] Attached read-only WebSocket to existing ssh_context", channel_id);
return;
}
}
}

auto ctx = channel->newContextPtr<ssh_context>();
ssh_context::ssh_contexts.emplace(channel_id, ctx);
{
std::lock_guard<std::mutex> guard(ssh_context::ssh_contexts_mutex);
ssh_context::ssh_contexts.insert_or_assign(channel_id, ctx);
}
ctx->ssh_channel_id = channel_id;
ctx->channels_read.emplace(channel);
ctx->channels_write.emplace(channel);

auto ssh_channel = ohtoai::ssh::ssh_pty_connection_manager::get_instance().get_channel(ctx->ssh_channel_id);
if (ssh_channel == nullptr) {
spdlog::error("[{}] ssh_channel is null", channel_id);
channel->close();
return;
}

Expand All @@ -77,23 +127,41 @@ int main(int argc, char *argv[]) {
spdlog::error("[{}] ssh_channel is null", ctx->ssh_channel_id);
break;
}
if (ctx->channels_write.empty()) {
spdlog::error("[{}] channels_write is empty", ctx->ssh_channel_id);

{
std::lock_guard lock(*ctx);
if (ctx->channels_write.empty()) {
spdlog::info("[{}] All write channels gone, stopping read loop", ctx->ssh_channel_id);
break;
}
}

long rc = 0;
try {
rc = ssh_channel->read();
}
catch (const std::exception& e) {
spdlog::error("[{}] SSH read failed: {}", ctx->ssh_channel_id, e.what());
break;
}
if (ssh_channel->read() <= 0) {
std::this_thread::sleep_for(std::chrono::milliseconds(100));

if (rc == 0) {
std::this_thread::sleep_for(std::chrono::milliseconds(kSshPollIntervalMs));
continue;
}

std::lock_guard lock(*ctx);
for (auto& channel : ctx->channels_read) {
if (!channel->isConnected()) {
ctx->channels_read.erase(channel);
ctx->channels_write.erase(channel);
std::vector<WebSocketChannelPtr> to_remove;
for (auto& ws_channel : ctx->channels_read) {
if (!ws_channel->isConnected()) {
to_remove.push_back(ws_channel);
continue;
}
channel->send(ssh_channel->get_buffer().data, ssh_channel->get_buffer().size);
ws_channel->send(ssh_channel->get_buffer().data, ssh_channel->get_buffer().size);
}
for (auto& ws_channel : to_remove) {
ctx->channels_read.erase(ws_channel);
ctx->channels_write.erase(ws_channel);
}
}
ctx->close();
Expand All @@ -106,41 +174,58 @@ int main(int argc, char *argv[]) {
spdlog::error("ctx is null");
return;
}

// Read-only channels must not send data
{
std::lock_guard lock(*ctx);
if (ctx->channels_write.find(channel) == ctx->channels_write.end()) {
spdlog::debug("[{}] Ignoring message from read-only channel", ctx->ssh_channel_id);
return;
}
}

auto ssh_channel = ohtoai::ssh::ssh_pty_connection_manager::get_instance().get_channel(ctx->ssh_channel_id);
if (ssh_channel == nullptr) {
spdlog::error("ssh_channel is null");
ctx->close();
return;
}

hv::Json j = hv::Json::parse(msg);
if (j.contains("resize")) {
int width = j["resize"][0];
int height = j["resize"][1];
ssh_channel->resize_pty(width, height);
} else if (j.contains("data")) {
auto data = j["data"].get<std::string>();
try {
try {
hv::Json j = hv::Json::parse(msg);
if (j.contains("resize")) {
int width = j["resize"][0];
int height = j["resize"][1];
ssh_channel->resize_pty(width, height);
} else if (j.contains("data")) {
auto data = j["data"].get<std::string>();
ssh_channel->write(data);
}
catch (const std::exception& e) {
spdlog::error("[{}] Try to write {} bytes, but failed.", ssh_channel->id, data.size());
spdlog::error("{}", e.what());
}
}
catch (const std::exception& e) {
spdlog::error("[{}] Failed to handle message: {}", ctx->ssh_channel_id, e.what());
}
};
ws.onclose = [](const WebSocketChannelPtr& channel) {
spdlog::debug("{}", channel->peeraddr());
auto ctx = channel->getContextPtr<ssh_context>();
if (ctx == nullptr) {
spdlog::error("ctx is null");
return;
}
auto ssh_channel = ohtoai::ssh::ssh_pty_connection_manager::get_instance().get_channel(ctx->ssh_channel_id);
if (ssh_channel == nullptr) {
spdlog::error("ssh_channel is null");
ctx->close();
return;
{
std::lock_guard lock(*ctx);
ctx->channels_read.erase(channel);
ctx->channels_write.erase(channel);
if (!ctx->channels_write.empty()) {
// Other write-capable connections remain; keep SSH alive
channel->deleteContextPtr();
return;
}
}
ctx->close();
Comment on lines 224 to +225

Copilot AI Mar 31, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When the last write-capable WebSocket disconnects, ctx->close() is called but the corresponding entry in the global ssh_context::ssh_contexts map is never removed. This leaves stale keys behind and can cause unbounded growth over time, as well as interfering with future reconnects for the same channel_id. Consider erasing the channel_id entry under ssh_contexts_mutex once the SSH session is being torn down (or periodically cleaning up expired entries).

Suggested change
}
ctx->close();
}
// No more write-capable WebSocket channels remain for this SSH context.
// Tear down the SSH session and remove the corresponding entry from the
// global ssh_context::ssh_contexts map to avoid stale entries.
ctx->close();
{
std::lock_guard<std::mutex> guard(ssh_context::ssh_contexts_mutex);
ssh_context::ssh_contexts.erase(ctx->ssh_channel_id);
}

Copilot uses AI. Check for mistakes.
{
std::lock_guard<std::mutex> guard(ssh_context::ssh_contexts_mutex);
ssh_context::ssh_contexts.erase(ctx->ssh_channel_id);
}
channel->deleteContextPtr();
};
Expand All @@ -156,33 +241,39 @@ int main(int argc, char *argv[]) {
auto term = ctx->get("term");
auto channel_id = ctx->get("channel");

spdlog::debug("Receive login request: hostname={}, port={}, username={}, password={}, term={}, channel_id={}",
hostname, port, username, password, term, channel_id);
spdlog::debug("Receive login request: hostname={}, port={}, username={}, term={}, channel_id={}",
hostname, port, username, term, channel_id);

ohtoai::ssh::ssh_channel_ptr ssh_channel {};
if (!channel_id.empty()) {
ssh_channel = ohtoai::ssh::ssh_pty_connection_manager::get_instance().get_channel(channel_id);
if (ssh_channel == nullptr) {
ctx->setStatus(HTTP_STATUS_FORBIDDEN);
return ctx->send("ssh_channel is null");
ctx->setStatus(HTTP_STATUS_NOT_FOUND);
return ctx->send("channel not found");
}
// todo: Need attach to original ssh_channel
}
else {
if (hostname.empty() || username.empty() || password.empty()) {
ctx->setStatus(HTTP_STATUS_FORBIDDEN);
return ctx->send("hostname, username, password are required");
ctx->setStatus(HTTP_STATUS_BAD_REQUEST);
return ctx->send("hostname, username and password are required");
}
ssh_channel = ohtoai::ssh::ssh_pty_connection_manager::get_instance().get_channel(hostname, port, username, password);
if (ssh_channel == nullptr) {
try {
ssh_channel = ohtoai::ssh::ssh_pty_connection_manager::get_instance().get_channel(hostname, port, username, password);
if (ssh_channel == nullptr) {
Comment on lines 255 to +262

Copilot AI Apr 1, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The HTTP login handler no longer validates that password is present (hostname/username only), but ssh_session::authenticate() always calls libssh2_userauth_password(..., password.c_str()), so an omitted password will deterministically fail authentication and return a 403. Either keep password as a required field (return 400 when missing), or add an alternative auth path (e.g., publickey) before making password optional.

Copilot uses AI. Check for mistakes.
ctx->setStatus(HTTP_STATUS_INTERNAL_SERVER_ERROR);
return ctx->send("failed to create ssh channel");
}
ssh_channel->set_env("LC_WSSH_WEBSOCKET_HOST", ctx->host());
ssh_channel->set_env("LC_WSSH_WEBSOCKET_URL", ctx->url());
ssh_channel->set_env("LC_WSSH_WEBSOCKET_CLIENT_IP", ctx->header("X-Real-IP", ctx->ip()));
ssh_channel->request_pty(term.empty() ? "xterm-256color" : term);
ssh_channel->shell();
}
catch (const std::exception& e) {
spdlog::error("SSH connection failed: {}", e.what());
ctx->setStatus(HTTP_STATUS_FORBIDDEN);
return ctx->send("ssh_channel is null");
return ctx->send("SSH connection failed");
}
ssh_channel->set_env("LC_WSSH_WEBSOCKET_HOST", ctx->host());
ssh_channel->set_env("LC_WSSH_WEBSOCKET_URL", ctx->url());
ssh_channel->set_env("LC_WSSH_WEBSOCKET_CLIENT_IP", ctx->header("X-Real-IP", ctx->ip()));
ssh_channel->request_pty(term);
ssh_channel->shell();
}

hv::Json resp;
Expand All @@ -192,10 +283,14 @@ int main(int argc, char *argv[]) {
});

hv::WebSocketServer server;
server.port = 8080;
server.port = port;
strncpy(server.host, host.c_str(), sizeof(server.host) - 1);
server.host[sizeof(server.host) - 1] = '\0';

server.registerHttpService(&http);
server.registerWebSocketService(&ws);

spdlog::info("WebSSH server starting on {}:{}", host, port);
server.run();
return 0;
}
Loading
Loading