Make an HTTPS Request in C++ on Raspberry Pi
HTTPS carries HTTP through a protected connection. Keep the request and response framing you already learned, but send and receive application bytes through OpenSSL instead of the raw TCP socket.
Before you start
Complete Certificates, Hostnames and Trust. Keep the Content-Length parser and the nonblocking TLS client nearby.
Run commands on the Pi from the examples repository's network/ directory. Use the OpenSSL development dependency installed for the TLS lesson; no additional package is needed here.
Outcome: request a resource through a verified TLS 1.3 connection, decode its HTTP/1.1 body and distinguish a complete HTTP response from a clean TLS shutdown.
HTTPS: the same HTTP message, a different I/O path
sequenceDiagram
accTitle: HTTP body completion happens before TLS closure
accDescr: After TCP setup and a verified TLS handshake select HTTP/1.1, the client sends a GET through TLS. It parses decrypted headers and reads the exact Content-Length body. Only afterward does it finish the separate TLS close notification exchange.
participant C as C++ HTTPS client
participant S as Local HTTPS server
C->>S: TCP connect and TLS ClientHello
S->>C: TLS handshake, certificate and HTTP/1.1 selection
Note over C: Verify localhost and trusted issuer
C->>S: Finish TLS handshake
C->>S: Protected GET /hello.txt
S->>C: Protected HTTP headers and body
Note over C: Content-Length bytes received, HTTP body complete
S->>C: TLS close_notify
C->>S: TLS close_notify
Note over C: Release TLS objects and close TCP
This is HTTPS using HTTP/1.1 over TLS over TCP. HTTP/2 and HTTP/3 are different protocol paths, not alternative spellings of the text request below.
The application builds these lines; each line ends with CRLF, and an empty line ends the bodyless GET:
| GET /hello.txt HTTP/1.1
Host: localhost:9444
Connection: close
|
OpenSSL protects that plaintext before it reaches TCP. Receiving decrypted bytes still leaves your code responsible for HTTP parsing.
Start a local HTTPS server
Build:
| cmake --preset native-debug
cmake --build --preset native-debug --target https_client --parallel 2
|
In terminal A, generate fresh practice credentials using the earlier generator:
| practice=$(mktemp -d /tmp/network-https-practice.XXXXXX)
python3 fixtures/tls_echo.py certs "$practice/certs"
printf '%s\n' "$practice/certs"
timeout 35s python3 fixtures/https_server.py 9444 "$practice/certs"
|
The fixture prints LISTEN 9444, accepts one loopback connection and serves one GET. It uses the practice CA's localhost certificate and selects http/1.1 through ALPN.
Keep the generated directory private. Do not commit its keys or install its CA into system trust. The certificates expire after two days; generate a new directory when needed. The outer timeout bounds the entire fixture process.
Request the resource
In terminal B, set certs to the certificate-directory path printed by terminal A:
| certs=/tmp/network-https-practice.REPLACE/certs
./build/native/debug/https_client 9444 "$certs/ca.pem"
|
Expected output:
| verified=localhost protocol=TLSv1.3 alpn=http/1.1
status=200 bytes=18
Hello over HTTPS.
|
The destination is 127.0.0.1; the required DNS identity and HTTP Host name are localhost. The corresponding URL is https://localhost:9444/hello.txt. Port 9444 is simply an unprivileged practice port; encryption does not come from a port number.
Restart terminal A's fixture before trying a missing resource:
| ./build/native/debug/https_client 9444 "$certs/ca.pem" /missing
|
Expected:
| verified=localhost protocol=TLSv1.3 alpn=http/1.1
status=404 bytes=10
Not found
|
A 404 is a successfully received HTTP response, not a failed TLS connection. Both 200 and 404 return exit status zero in this profile; authentication, framing, deadline or shutdown failures return one.
Keep protocol selection explicit
The client offers only http/1.1 with ALPN, then checks the server's selection before sending a GET. The ALPN input is a length-prefixed byte sequence, not a null-terminated string. Note that SSL_set_alpn_protos() returns zero on success.
This exercise requires an explicit http/1.1 selection. A server offering only HTTP/2 has no overlap, so no HTTP request is sent. A server omitting ALPN is also outside this teaching profile; that omission does not make every HTTP/1.1 HTTPS server invalid.
ALPN selects the application protocol. Certificate checking authenticates the peer. Passing one check does not replace the other.
Read the standalone C++ source
Trace three regions rather than reading the entire file as one operation:
- Configure trust and
localhost identity, connect TCP, complete TLS and check ALPN.
- Write the GET through
SSL_write_ex(), then feed decrypted bytes from SSL_read_ex() into the familiar HTTP parser.
- After the exact HTTP body is complete, check for unexpected additional application data and finish TLS closure.
There are no custom headers, socket classes or hidden HTTP client calls. Some setup and parsing code repeats earlier lessons so this file can be compiled on its own.
Full standalone client: app/https/client.cpp
| #include <algorithm>
#include <arpa/inet.h>
#include <array>
#include <cerrno>
#include <charconv>
#include <chrono>
#include <csignal>
#include <cstdint>
#include <iostream>
#include <openssl/err.h>
#include <openssl/ssl.h>
#include <poll.h>
#include <stdexcept>
#include <string>
#include <string_view>
#include <sys/socket.h>
#include <system_error>
#include <unistd.h>
int main(int argc, char** argv) {
int fd = -1;
SSL_CTX* context = nullptr;
SSL* tls = nullptr;
int status = 0;
try {
if (argc < 3 || argc > 5) {
throw std::invalid_argument(
"usage: https_client port ca.pem [path [session_ms]]");
}
unsigned int port = 0;
const std::string_view text = argv[1];
const auto [end, error] =
std::from_chars(text.data(), text.data() + text.size(), port);
if (error != std::errc{} || end != text.data() + text.size() ||
port == 0 || port > 65535) {
throw std::invalid_argument("port must be 1..65535");
}
constexpr std::string_view hostname = "localhost";
const std::string_view path = argc >= 4 ? argv[3] : "/hello.txt";
if (path.empty() || path.front() != '/' || path.size() > 200 ||
!std::all_of(path.begin(), path.end(), [](unsigned char c) {
return c > 32 && c < 127 && c != '#';
})) {
throw std::invalid_argument("path must be a visible ASCII origin "
"path without spaces or fragments");
}
int session_ms = 3000;
if (argc == 5) {
const std::string_view budget = argv[4];
const auto [last, invalid] = std::from_chars(
budget.data(), budget.data() + budget.size(), session_ms);
if (invalid != std::errc{} ||
last != budget.data() + budget.size() || session_ms < 1 ||
session_ms > 10000) {
throw std::invalid_argument("session_ms must be 1..10000");
}
}
// OpenSSL writes through the socket BIO, not send(MSG_NOSIGNAL).
if (std::signal(SIGPIPE, SIG_IGN) == SIG_ERR) {
throw std::runtime_error("cannot ignore SIGPIPE");
}
context = SSL_CTX_new(TLS_client_method());
if (!context ||
SSL_CTX_set_min_proto_version(context, TLS1_3_VERSION) != 1 ||
SSL_CTX_load_verify_locations(context, argv[2], nullptr) != 1) {
throw std::runtime_error("cannot configure TLS or load CA file");
}
SSL_CTX_set_verify(context, SSL_VERIFY_PEER, nullptr);
tls = SSL_new(context);
if (!tls || SSL_set1_host(tls, hostname.data()) != 1 ||
SSL_set_tlsext_host_name(tls, hostname.data()) != 1) {
throw std::runtime_error("cannot configure server identity");
}
// ALPN uses length-prefixed protocol names, not a C string.
constexpr unsigned char protocols[] = {8, 'h', 't', 't', 'p',
'/', '1', '.', '1'};
if (SSL_set_alpn_protos(tls, protocols, sizeof(protocols)) != 0) {
throw std::runtime_error("cannot offer HTTP/1.1 through ALPN");
}
fd = ::socket(AF_INET, SOCK_STREAM | SOCK_NONBLOCK | SOCK_CLOEXEC, 0);
if (fd < 0) {
throw std::system_error(errno, std::generic_category(), "socket");
}
if (SSL_set_fd(tls, fd) != 1) {
throw std::runtime_error("SSL_set_fd failed");
}
const auto deadline = std::chrono::steady_clock::now() +
std::chrono::milliseconds(session_ms);
const auto check_deadline = [&] {
if (std::chrono::steady_clock::now() >= deadline) {
throw std::runtime_error("session deadline expired");
}
};
const auto wait_for = [&](short events) {
for (;;) {
check_deadline();
const auto remaining =
std::chrono::ceil<std::chrono::milliseconds>(
deadline - std::chrono::steady_clock::now())
.count();
if (remaining <= 0) {
throw std::runtime_error("session deadline expired");
}
pollfd ready{fd, events, 0};
const int result =
::poll(&ready, 1, static_cast<int>(remaining));
if (result < 0 && errno == EINTR) {
continue;
}
if (result < 0) {
throw std::system_error(errno, std::generic_category(),
"poll");
}
check_deadline();
if (result > 0) {
return; // Retry the socket/TLS operation to inspect errors.
}
}
};
const auto retry_tls = [&](int result) {
// No intervening OpenSSL call: result belongs to the last TLS call.
const int error_code = SSL_get_error(tls, result);
if (error_code == SSL_ERROR_WANT_READ) {
wait_for(POLLIN);
} else if (error_code == SSL_ERROR_WANT_WRITE) {
wait_for(POLLOUT);
} else {
char detail[256]{};
ERR_error_string_n(ERR_get_error(), detail, sizeof(detail));
throw std::runtime_error("TLS operation failed (SSL error " +
std::to_string(error_code) +
"): " + detail);
}
};
sockaddr_in server{};
server.sin_family = AF_INET;
server.sin_port = htons(static_cast<std::uint16_t>(port));
server.sin_addr.s_addr = htonl(INADDR_LOOPBACK);
if (::connect(fd, reinterpret_cast<const sockaddr*>(&server),
sizeof(server)) < 0) {
if (errno != EINPROGRESS) {
throw std::system_error(errno, std::generic_category(),
"connect");
}
wait_for(POLLOUT);
int socket_error = 0;
socklen_t length = sizeof(socket_error);
if (::getsockopt(fd, SOL_SOCKET, SO_ERROR, &socket_error, &length) <
0) {
throw std::system_error(errno, std::generic_category(),
"getsockopt");
}
if (socket_error != 0) {
throw std::system_error(socket_error, std::generic_category(),
"connect");
}
}
for (;;) {
check_deadline();
ERR_clear_error();
const int result = SSL_connect(tls);
if (result == 1) {
break;
}
retry_tls(result);
}
if (SSL_get_verify_result(tls) != X509_V_OK) {
throw std::runtime_error("server certificate verification failed");
}
const unsigned char* selected = nullptr;
unsigned int selected_length = 0;
SSL_get0_alpn_selected(tls, &selected, &selected_length);
if (selected_length != 8 ||
std::string_view(reinterpret_cast<const char*>(selected),
selected_length) != "http/1.1") {
throw std::runtime_error("server did not select HTTP/1.1");
}
const std::string request =
"GET " + std::string(path) +
" HTTP/1.1\r\nHost: localhost:" + std::to_string(port) +
"\r\nConnection: close\r\n\r\n";
std::size_t sent = 0;
while (sent < request.size()) {
check_deadline();
std::size_t written = 0;
ERR_clear_error();
const int result = SSL_write_ex(tls, request.data() + sent,
request.size() - sent, &written);
if (result == 1) {
sent += written;
} else {
retry_tls(result);
}
}
std::array<char, 16> buffer{};
std::string response;
bool headers_done = false;
std::size_t body_start = 0;
std::size_t length = 0;
int http_status = 0;
while (true) {
check_deadline();
// After parsing headers, ask only for the remaining body bytes.
const auto wanted =
headers_done ? std::min(buffer.size(),
body_start + length - response.size())
: buffer.size();
std::size_t received = 0;
ERR_clear_error();
const int result =
SSL_read_ex(tls, buffer.data(), wanted, &received);
if (result != 1) {
retry_tls(result);
continue;
}
response.append(buffer.data(), received);
if (!headers_done) {
const auto boundary = response.find("\r\n\r\n");
if (boundary == std::string::npos) {
if (response.size() > 4096) {
throw std::runtime_error("headers exceed 4096 bytes");
}
continue;
}
body_start = boundary + 4;
if (body_start > 4096) {
throw std::runtime_error("headers exceed 4096 bytes");
}
const auto first_end = response.find("\r\n");
const std::string_view status_line(response.data(), first_end);
if (status_line.size() < 13 ||
!status_line.starts_with("HTTP/1.1 ") ||
status_line[12] != ' ' ||
!std::all_of(status_line.begin(), status_line.end(),
[](unsigned char c) {
return c == '\t' || (c >= 32 && c < 127);
})) {
throw std::runtime_error("invalid status line");
}
const auto code = status_line.substr(9, 3);
if (code != "200" && code != "404") {
throw std::runtime_error(
"status outside this lesson's 200/404 profile");
}
http_status = code == "200" ? 200 : 404;
bool length_seen = false;
std::size_t cursor = first_end + 2;
while (cursor < boundary) {
const auto line_end = response.find("\r\n", cursor);
const std::string_view line(response.data() + cursor,
line_end - cursor);
const auto colon = line.find(':');
if (colon == std::string_view::npos || colon == 0) {
throw std::runtime_error("invalid header line");
}
std::string name(line.substr(0, colon));
const std::string_view punctuation = "!#$%&'*+-.^_\x60|~";
if (!std::all_of(name.begin(), name.end(),
[punctuation](unsigned char c) {
return (c >= 'a' && c <= 'z') ||
(c >= 'A' && c <= 'Z') ||
(c >= '0' && c <= '9') ||
punctuation.find(c) !=
std::string_view::npos;
})) {
throw std::runtime_error("invalid header name");
}
for (auto& c : name) {
if (c >= 'A' && c <= 'Z') {
c += 'a' - 'A';
}
}
auto value = line.substr(colon + 1);
if (!std::all_of(
value.begin(), value.end(), [](unsigned char c) {
return c == '\t' || (c >= 32 && c < 127);
})) {
throw std::runtime_error("invalid header value");
}
while (!value.empty() &&
(value.front() == ' ' || value.front() == '\t')) {
value.remove_prefix(1);
}
while (!value.empty() &&
(value.back() == ' ' || value.back() == '\t')) {
value.remove_suffix(1);
}
if (name == "transfer-encoding" ||
name == "content-encoding") {
throw std::runtime_error(
"encoded response outside this lesson's profile");
}
if (name == "content-length") {
if (length_seen) {
throw std::runtime_error(
"duplicate Content-Length");
}
const auto [last, invalid] = std::from_chars(
value.data(), value.data() + value.size(), length);
if (invalid != std::errc{} ||
last != value.data() + value.size() ||
length > 4096) {
throw std::runtime_error(
"invalid or oversized Content-Length");
}
length_seen = true;
}
cursor = line_end + 2;
}
if (!length_seen) {
throw std::runtime_error("missing Content-Length");
}
headers_done = true;
}
if (response.size() - body_start >= length) {
if (response.size() - body_start != length) {
throw std::runtime_error("surplus response bytes");
}
break; // HTTP framing completes the body, not a TLS read.
}
}
// One extra application byte is enough to reject this one-response
// profile. Read through the peer's authenticated TLS EOF before
// starting our shutdown.
for (;;) {
check_deadline();
char extra = 0;
std::size_t received = 0;
ERR_clear_error();
const int result = SSL_read_ex(tls, &extra, 1, &received);
if (result == 1) {
throw std::runtime_error("surplus response bytes");
}
const int error_code = SSL_get_error(tls, result);
if (error_code == SSL_ERROR_ZERO_RETURN) {
break; // The peer sent close_notify, not merely TCP FIN.
}
if (error_code == SSL_ERROR_WANT_READ) {
wait_for(POLLIN);
} else if (error_code == SSL_ERROR_WANT_WRITE) {
wait_for(POLLOUT);
} else {
throw std::runtime_error(
"TLS ended without a clean close_notify");
}
}
// Require both close_notify alerts within the same session budget.
for (;;) {
check_deadline();
ERR_clear_error();
const int result = SSL_shutdown(tls);
if (result == 1) {
break;
}
if (result < 0) {
retry_tls(result);
}
}
std::cout << "verified=" << hostname
<< " protocol=" << SSL_get_version(tls) << " alpn=http/1.1\n"
<< "status=" << http_status << " bytes=" << length << '\n';
std::cout.write(response.data() + body_start, length);
} catch (const std::exception& error) {
std::cerr << error.what() << '\n';
status = 1;
}
SSL_free(tls);
SSL_CTX_free(context);
if (fd >= 0) {
::close(fd);
}
return status;
}
|
Compile directly, if you prefer:
| c++ -std=c++20 -Wall -Wextra -Wpedantic -Werror \
app/https/client.cpp -o /tmp/https_client \
$(pkg-config --cflags --libs openssl)
|
Let TLS consume its own bytes
Do not call raw recv() for an HTTPS response. It would read TLS wire data rather than the HTTP bytes the parser expects, and can steal records from OpenSSL.
Also do not require socket readability before every TLS read. OpenSSL may already have decrypted bytes buffered. Attempt SSL_read_ex() first; only when it reports WANT_READ or WANT_WRITE does the program poll the requested direction and retry.
The 16-byte application buffer makes header accumulation visible, including \r\n\r\n split across reads. Peer writes and TLS records do not define HTTP message boundaries.
Keep the reduced HTTP profile visible
This client accepts HTTP/1.1 200 or 404 with exactly one valid Content-Length. Headers and body are each limited to 4096 bytes. The body can be empty or binary.
It rejects duplicate lengths, Transfer-Encoding, Content-Encoding, malformed ASCII fields, unsupported status/version and surplus application bytes. Chunked responses were covered separately; this lesson does not silently combine all framing modes into a general HTTPS client.
It sends one GET and never reconnects, follows redirects or retries without verification. HTTP/2, proxies, client certificates and connection pooling are outside its scope.
Three different endings
| Event |
Meaning here |
| All Content-Length body bytes are received |
The HTTP response body is complete. |
OpenSSL reports SSL_ERROR_ZERO_RETURN while reading |
The peer sent a TLS close notification. This alone does not prove the HTTP body was complete. |
| TCP closes without a TLS close notification |
TLS closure is unclean; the HTTP body might be complete or truncated. |
This program requests Connection: close and deliberately requires a clean two-way TLS close before printing successful output. A complete HTTP body followed by an abrupt close therefore fails this sample's shutdown policy, even though HTTP framing may already have completed. Do not describe every missing TLS close notification as a truncated Content-Length body.
After HTTP completion, the client reads through the peer's close notification, rejecting even one unexpected extra application byte, then sends its own notification with SSL_shutdown(). It does not use EOF to discover the body length.
One fixed 3000 ms default session budget covers TCP connect, handshake, request, response and TLS shutdown. Local CA loading and TLS-object setup happen before that budget. An optional final argument changes it to 1–10000 ms; incoming progress never renews it.
Verify success and failure paths
Without starting a manual server, run:
| ctest --preset native-debug -R network.https --output-on-failure
|
The checks include the real fixture's 200/404 responses, exact GET/Host/SNI/ALPN values, split and combined replies, empty/binary bodies, exact limits, malformed or conflicting framing, early clean TLS closure, complete HTTP followed by abrupt TCP closure, unexpected extra data, trust/name failures, ALPN mismatch and stalled or slowly progressing peers.
Wrong identities and unsupported ALPN must receive zero application bytes. These are loopback interoperability checks against Python TLS peers, not public-Internet testing or a cryptographic audit.
Troubleshooting
- Connection refused: the fixture handles one attempt and exits. Restart it before each request.
- Certificate error: check the selected CA,
localhost certificate and system clock. Do not disable verification or use an unrelated practice directory's CA.
- Server did not select HTTP/1.1: use the HTTPS fixture, not the TLS byte-echo fixture. Confirm the agreed protocol before sending HTTP text.
- Encoded response outside profile: a real HTTPS server can legitimately use chunked framing or content coding. This small client deliberately rejects those features.
- Deadline after a complete body: the peer may not have sent its TLS close notification. HTTP completion and the sample's shutdown policy are separate.
- TLS error before a complete body: a valid TLS close does not supply missing HTTP bytes. Check the declared body length and the actual fixture response.
Check your understanding
- Which function supplies HTTP plaintext to the response parser?
- Does a TLS record's end mark the HTTP body's end?
- Why check ALPN before sending the request line?
- Can HTTP body completion happen before the TLS connection ends?
Answers
SSL_read_ex() supplies decrypted application bytes. HTTP framing, not TLS record boundaries, determines the body's length. ALPN prevents sending HTTP/1.1 text when that application protocol was not selected. Yes: this example identifies a complete Content-Length body before separately finishing TLS shutdown.
Continue the course
Previous: Certificates, Hostnames and Trust · Next: WebSocket Opening Handshake
References: HTTPS URI semantics, HTTP body-length rules, OpenSSL ALPN, TLS reads and buffering.