diff --git a/CMakeLists.txt b/CMakeLists.txt index 70c3bf352..f28003fad 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -80,6 +80,7 @@ set(MIN_HTTPLIB_VERSION "0.26.0") set(MIN_LWS_VERSION "4.3.3") find_package(PkgConfig QUIET) +find_package(OpenSSL REQUIRED) # Try to find system packages with version requirements find_package(nlohmann_json ${MIN_NLOHMANN_JSON_VERSION} QUIET) diff --git a/src/cpp/cli/CMakeLists.txt b/src/cpp/cli/CMakeLists.txt index bd58c60ba..4e638d77a 100644 --- a/src/cpp/cli/CMakeLists.txt +++ b/src/cpp/cli/CMakeLists.txt @@ -127,6 +127,8 @@ add_executable(lemonade target_link_libraries(lemonade PRIVATE nlohmann_json::nlohmann_json lemonade-digest-crypto + OpenSSL::SSL + OpenSSL::Crypto ) # Link httplib based on what's available (set by parent CMakeLists.txt) @@ -177,7 +179,7 @@ if(WIN32) ) endif() -target_compile_definitions(lemonade PRIVATE LEMONADE_CLI) +target_compile_definitions(lemonade PRIVATE LEMONADE_CLI CPPHTTPLIB_OPENSSL_SUPPORT) # Platform-specific settings if(WIN32) diff --git a/src/cpp/cli/lemonade_client.cpp b/src/cpp/cli/lemonade_client.cpp index b91f62c85..d193f7f1b 100644 --- a/src/cpp/cli/lemonade_client.cpp +++ b/src/cpp/cli/lemonade_client.cpp @@ -81,8 +81,57 @@ const std::string& HttpError::response_body() const { return response_body_; } -LemonadeClient::LemonadeClient(const std::string& host, int port, const std::string& api_key) - : host_(host), port_(port), api_key_(api_key) {} +void ParseTargetUrl(const std::string& input_host, std::string& out_clean_host, int& out_port, bool& out_is_ssl) { + std::string remaining = input_host; + bool has_scheme = false; + + if (remaining.rfind("https://", 0) == 0) { + out_is_ssl = true; + remaining = remaining.substr(8); + has_scheme = true; + } else if (remaining.rfind("http://", 0) == 0) { + out_is_ssl = false; + remaining = remaining.substr(7); + has_scheme = true; + } + + // Strip path if present (anything starting with '/') + size_t path_pos = remaining.find('/'); + if (path_pos != std::string::npos) { + remaining = remaining.substr(0, path_pos); + } + + // Parse port + size_t close_bracket = remaining.find(']'); + size_t colon_pos = std::string::npos; + if (remaining.rfind("[", 0) == 0 && close_bracket != std::string::npos) { + size_t post_bracket_colon = remaining.find(':', close_bracket); + if (post_bracket_colon != std::string::npos) { + colon_pos = post_bracket_colon; + } + } else { + colon_pos = remaining.rfind(':'); + } + + if (colon_pos != std::string::npos) { + out_clean_host = remaining.substr(0, colon_pos); + std::string port_str = remaining.substr(colon_pos + 1); + try { + out_port = std::stoi(port_str); + } catch (...) { + } + } else { + out_clean_host = remaining; + if (has_scheme) { + out_port = out_is_ssl ? 443 : 80; + } + } +} + +LemonadeClient::LemonadeClient(const std::string& host, int port, const std::string& api_key, bool is_ssl) + : api_key_(api_key), port_(port), is_ssl_(is_ssl) { + ParseTargetUrl(host, host_, port_, is_ssl_); +} LemonadeClient::~LemonadeClient() {} @@ -94,9 +143,16 @@ std::string LemonadeClient::normalize_host(const std::string& host) const { } // Helper to create and configure httplib::Client (timeouts in milliseconds) -static httplib::Client make_client(const std::string& host, int port, const std::string& api_key, +static httplib::Client make_client(const std::string& host, int port, const std::string& api_key, bool is_ssl, time_t connection_timeout_ms = DEFAULT_CONNECTION_TIMEOUT_MS, time_t read_timeout_ms = DEFAULT_READ_TIMEOUT_MS) { - httplib::Client cli(host, port); +#ifndef CPPHTTPLIB_OPENSSL_SUPPORT + if (is_ssl) { + throw std::runtime_error("HTTPS support is not compiled in this client."); + } +#endif + std::string scheme = is_ssl ? "https" : "http"; + std::string url = scheme + "://" + host + ":" + std::to_string(port); + httplib::Client cli(url); cli.set_connection_timeout(connection_timeout_ms / 1000, (connection_timeout_ms % 1000) * 1000); cli.set_read_timeout(read_timeout_ms / 1000, (read_timeout_ms % 1000) * 1000); @@ -137,7 +193,7 @@ std::string LemonadeClient::make_request(const std::string& path, const std::str const std::string& body, const std::string& content_type, time_t connection_timeout_ms, time_t read_timeout_ms) const { std::string normalized_host = normalize_host(host_); - httplib::Client cli = make_client(normalized_host, port_, api_key_, connection_timeout_ms, read_timeout_ms); + httplib::Client cli = make_client(normalized_host, port_, api_key_, is_ssl_, connection_timeout_ms, read_timeout_ms); httplib::Result res; @@ -225,7 +281,7 @@ bool LemonadeClient::make_request(const std::string& path, const std::string& me time_t connection_timeout_ms, time_t read_timeout_ms, std::function should_abort) const { std::string normalized_host = normalize_host(host_); - httplib::Client cli = make_client(normalized_host, port_, api_key_, connection_timeout_ms, read_timeout_ms); + httplib::Client cli = make_client(normalized_host, port_, api_key_, is_ssl_, connection_timeout_ms, read_timeout_ms); if (method == "POST") { auto res = handle_sse_stream(cli, path, body, content_type, callback, should_abort); @@ -884,7 +940,7 @@ int LemonadeClient::list_recipes(bool show_all) const { << "-" << std::endl; } } else { - for (const auto& backend : recipe.backends) { + for (const auto& backend : recipe.backends) { std::string recipe_col = first_backend ? recipe.name : ""; std::string status_str = backend.state.empty() ? "unsupported" : backend.state; diff --git a/src/cpp/cli/main.cpp b/src/cpp/cli/main.cpp index b8ee744b7..3f55a505b 100644 --- a/src/cpp/cli/main.cpp +++ b/src/cpp/cli/main.cpp @@ -146,6 +146,7 @@ static void hide_new_options(CLI::App& app, size_t start_index) { struct CliConfig { std::string host = "127.0.0.1"; int port = 13305; + bool is_ssl = false; std::string api_key; std::string model; std::string list_filter; @@ -270,7 +271,7 @@ static bool try_lemonade_protocol(const std::string& lemonade_url) { #endif } -static void open_url(const std::string& host, int port, const std::string& path = "/") { +static void open_url(const std::string& host, int port, const std::string& path = "/", bool is_ssl = false) { // Map web path to lemonade:// route and try the desktop app first std::string lemonade_url = "lemonade://open"; if (path == "/?logs=true") { @@ -282,7 +283,8 @@ static void open_url(const std::string& host, int port, const std::string& path } // Fall back to web app in browser - std::string url = "http://" + host + ":" + std::to_string(port) + path; + std::string scheme = is_ssl ? "https" : "http"; + std::string url = scheme + "://" + host + ":" + std::to_string(port) + path; std::cout << "Opening URL: " << url << std::endl; #ifdef _WIN32 @@ -507,7 +509,7 @@ static int handle_run_command(lemonade::LemonadeClient& client, CliConfig& confi return handle_chat_command(client, config); } - open_url(config.host, config.port); + open_url(config.host, config.port, "/", config.is_ssl); return 0; } @@ -712,10 +714,11 @@ static int handle_launch_command(lemonade::LemonadeClient& client, CliConfig& co std::thread([host = config.host, port = config.port, api_key = config.api_key, + is_ssl = config.is_ssl, model = config.model, recipe_options = config.recipe_options]() { try { - lemonade::LemonadeClient async_client(host, port, api_key); + lemonade::LemonadeClient async_client(host, port, api_key, is_ssl); nlohmann::json request_body = recipe_options; request_body["model_name"] = model; request_body["save_options"] = false; @@ -748,9 +751,9 @@ static int handle_launch_command(lemonade::LemonadeClient& client, CliConfig& co // Attempt a quick liveness check against the given host:port static bool try_live_check(const std::string& host, int port, const std::string& api_key, - int timeout_ms = 500) { + bool is_ssl = false, int timeout_ms = 500) { try { - lemonade::LemonadeClient client(host, port, api_key); + lemonade::LemonadeClient client(host, port, api_key, is_ssl); client.make_request("/live", "GET", "", "", timeout_ms, timeout_ms); return true; } catch (const std::exception&) { @@ -1363,6 +1366,17 @@ int main(int argc, char* argv[]) { return app.exit(e); } + // Parse URL scheme and override host, port, is_ssl + { + std::string clean_host; + int clean_port = config.port; + bool is_ssl = false; + lemonade::ParseTargetUrl(config.host, clean_host, clean_port, is_ssl); + config.host = clean_host; + config.port = clean_port; + config.is_ssl = is_ssl; + } + if (load_cmd->count() > 0) { if (load_cmd->count("--pinned") > 0) { config.pinned = load_pinned_flag; @@ -1388,7 +1402,7 @@ int main(int argc, char* argv[]) { config.host == "localhost" || config.host == "0.0.0.0"); int live_timeout_ms = is_local ? 100 : 3000; - if (!try_live_check(config.host, config.port, config.api_key, live_timeout_ms)) { + if (!try_live_check(config.host, config.port, config.api_key, config.is_ssl, live_timeout_ms)) { int discovered_port = discover_local_server_port(); if (discovered_port > 0 && discovered_port != config.port) { config.port = discovered_port; @@ -1403,7 +1417,7 @@ int main(int argc, char* argv[]) { } // Create client - lemonade::LemonadeClient client(config.host, config.port, config.api_key); + lemonade::LemonadeClient client(config.host, config.port, config.api_key, config.is_ssl); // Execute command if (status_cmd->count() > 0) { @@ -1411,7 +1425,7 @@ int main(int argc, char* argv[]) { // Verify the server is actually reachable before reporting its port. // Without this check, we'd report the default port even when no server is running, // which could cause callers to target the wrong process. - bool reachable = try_live_check(config.host, config.port, config.api_key, 500); + bool reachable = try_live_check(config.host, config.port, config.api_key, config.is_ssl, 500); if (!reachable) { std::cerr << "Server is not running" << std::endl; return 1; @@ -1501,7 +1515,7 @@ int main(int argc, char* argv[]) { } else if (launch_cmd->count() > 0) { return handle_launch_command(client, config); } else if (logs_cmd->count() > 0) { - open_url(config.host, config.port, "/?logs=true"); + open_url(config.host, config.port, "/?logs=true", config.is_ssl); return 0; } else if (scan_cmd->count() > 0) { return handle_scan_command(config); diff --git a/src/cpp/include/lemon_cli/lemonade_client.h b/src/cpp/include/lemon_cli/lemonade_client.h index 76cd24125..a9b6b806d 100644 --- a/src/cpp/include/lemon_cli/lemonade_client.h +++ b/src/cpp/include/lemon_cli/lemonade_client.h @@ -30,6 +30,8 @@ class HttpError : public std::runtime_error { std::string extract_server_error_message(const HttpError& error); +void ParseTargetUrl(const std::string& input_host, std::string& out_clean_host, int& out_port, bool& out_is_ssl); + // Helper struct for streaming request state struct StreamingRequestState { std::string last_file; @@ -73,7 +75,7 @@ struct RecipeStatus { // Main CLI client class class LemonadeClient { public: - LemonadeClient(const std::string& host, int port, const std::string& api_key); + LemonadeClient(const std::string& host, int port, const std::string& api_key, bool is_ssl = false); ~LemonadeClient(); // Model management commands @@ -131,6 +133,7 @@ class LemonadeClient { std::string host_; int port_; std::string api_key_; + bool is_ssl_ = false; std::string normalize_host(const std::string& host) const; std::string get_base_url() const; }; diff --git a/test/server_cli2.py b/test/server_cli2.py index 15f22f00d..3005a3451 100644 --- a/test/server_cli2.py +++ b/test/server_cli2.py @@ -2157,6 +2157,74 @@ def test_900_launch_docs_match_help_text(self): self.assertNotIn("--llamacpp", launch_section) +class CLIUrlSchemeTests(unittest.TestCase): + """Tests URL scheme parsing, HTTPS/TLS connections, and port overrides in the C++ CLI client.""" + + @classmethod + def setUpClass(cls): + import http.server + import threading + import socket + + class MockHTTPHandler(http.server.BaseHTTPRequestHandler): + def log_message(self, format, *args): + pass + + def do_GET(self): + if self.path.startswith("/api/v1/models") or self.path.startswith( + "/api/v0/models" + ): + self.send_response(200) + self.send_header("Content-Type", "application/json") + self.end_headers() + self.wfile.write(b'{"data":[]}') + else: + self.send_response(404) + self.end_headers() + + # Find a free port + s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + s.bind(("127.0.0.1", 0)) + cls.mock_port = s.getsockname()[1] + s.close() + + cls.server = http.server.HTTPServer( + ("127.0.0.1", cls.mock_port), MockHTTPHandler + ) + cls.thread = threading.Thread(target=cls.server.serve_forever) + cls.thread.daemon = True + cls.thread.start() + + @classmethod + def tearDownClass(cls): + cls.server.shutdown() + cls.server.server_close() + cls.thread.join() + + def test_http_scheme_port_default(self): + """Should connect successfully and default port when http:// scheme is used.""" + env = os.environ.copy() + env["LEMONADE_HOST"] = f"http://127.0.0.1:{self.mock_port}" + result = run_cli_command(["list"], env=env, timeout=10) + output = result.stdout + result.stderr + self.assertEqual(result.returncode, 0) + self.assertIn("No models available", output) + + def test_https_scheme_connection_attempt(self): + """Should attempt a secure TLS connection when https:// scheme is used.""" + # Connecting https://127.0.0.1:mock_port will attempt a TLS handshake on our HTTP server. + # This will fail (since the server is HTTP), but it verifies that: + # 1. The hostname/port were parsed correctly to 127.0.0.1:mock_port. + # 2. It attempted a TLS handshake. + env = os.environ.copy() + env["LEMONADE_HOST"] = f"https://127.0.0.1:{self.mock_port}" + result = run_cli_command(["list"], env=env, timeout=10) + output = result.stdout + result.stderr + self.assertNotEqual(result.returncode, 0) + # Verify it tried to establish connection or handshake, not throwing a hostname parse error + self.assertIn("Could not connect to Lemonade server", output) + + def run_cli_client_tests(): """Run CLI client tests based on command line arguments.""" args = parse_cli_args() @@ -2171,6 +2239,7 @@ def run_cli_client_tests(): suite = unittest.TestSuite() suite.addTests(loader.loadTestsFromTestCase(PersistentServerCLIClientTests)) suite.addTests(loader.loadTestsFromTestCase(CLIHelpDocsConsistencyTests)) + suite.addTests(loader.loadTestsFromTestCase(CLIUrlSchemeTests)) runner = unittest.TextTestRunner(verbosity=2, buffer=False, failfast=True) result = runner.run(suite)