net: add blocking HTTP/HTTPS GET client

A small libssl-backed client (naut_http_get) that fetches a URL over
plain HTTP or TLS, follows 3xx redirects, and decodes Content-Length
and chunked bodies. Foundation for the RSS poller and Torznab search.
Built as a PIC static lib so it can link into the webui plugin module.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
ookami125 2026-06-23 00:41:31 -04:00
parent dbd0d6f78e
commit 0393ed429b
3 changed files with 299 additions and 2 deletions

View file

@ -37,7 +37,7 @@ find_path(URING_INC liburing.h)
if(NOT URING_LIB OR NOT URING_INC)
message(FATAL_ERROR "liburing not found (install liburing-dev)")
endif()
find_package(OpenSSL REQUIRED COMPONENTS Crypto)
find_package(OpenSSL REQUIRED COMPONENTS Crypto SSL)
# --- external download engine + tracker/DHT protocol libraries --------------
# torrent-peer: multi-peer download engine (engine.h) — replaces Naut's own peer
@ -163,6 +163,13 @@ add_library(naut_discovery STATIC
src/discovery/tracker_client.c src/discovery/dht_client.c)
target_link_libraries(naut_discovery PUBLIC naut_core torrenttracker)
# --- net: blocking HTTP/HTTPS client (RSS feeds, Torznab search) -------------
# PIC so it can be linked into the webui plugin module; naut_log symbols resolve
# from the host executable at load time, like the rest of the plugin.
add_library(naut_net STATIC src/net/http_client.c)
set_target_properties(naut_net PROPERTIES POSITION_INDEPENDENT_CODE ON)
target_link_libraries(naut_net PUBLIC OpenSSL::SSL OpenSSL::Crypto)
# --- peer: wire protocol codec (sans-IO), retained for magnet metadata -------
add_library(naut_peer STATIC
src/peer/wire.c src/peer/extension.c src/peer/metadata.c)
@ -215,7 +222,7 @@ set_target_properties(naut_example PROPERTIES PREFIX "")
add_library(naut_webui MODULE plugins/webui/webui.c)
target_include_directories(naut_webui PRIVATE ${CMAKE_SOURCE_DIR}/include)
target_link_libraries(naut_webui PRIVATE ${NAUT_JANSSON_TARGET} pthread)
target_link_libraries(naut_webui PRIVATE ${NAUT_JANSSON_TARGET} naut_net pthread)
set_target_properties(naut_webui PROPERTIES PREFIX "")
# --- swarm: multi-peer download driver over the torrent-peer engine ---------

View file

@ -0,0 +1,26 @@
/* http_client.h — minimal blocking HTTP/HTTPS GET client.
*
* Used by the web UI for RSS feed polling and Torznab indexer search. Supports
* plain HTTP and TLS (via OpenSSL), follows redirects, and returns the decoded
* response body. Intended for occasional fetches off the hot path. */
#ifndef NAUT_HTTP_CLIENT_H
#define NAUT_HTTP_CLIENT_H
#include <stddef.h>
#include "naut/common.h"
typedef struct {
long status; /* HTTP status code (e.g. 200) */
char *body; /* malloc'd, NUL-terminated response body */
size_t body_len; /* length of body, excluding the NUL */
} naut_http_response;
/* GET `url` (http:// or https://), following up to a handful of redirects.
* On NAUT_OK, `out` owns `out->body` (free with naut_http_response_free).
* Returns NAUT_ERR_* on transport/protocol failure. A non-2xx HTTP status is
* still returned as NAUT_OK with out->status set, so callers can inspect it. */
naut_err naut_http_get(const char *url, naut_http_response *out);
void naut_http_response_free(naut_http_response *r);
#endif /* NAUT_HTTP_CLIENT_H */

264
src/net/http_client.c Normal file
View file

@ -0,0 +1,264 @@
/* http_client.c — blocking HTTP/HTTPS GET with redirect handling.
*
* A small, dependency-light client: raw sockets for HTTP, OpenSSL for HTTPS.
* It reads the whole response into memory (capped), handles both Content-Length
* and chunked transfer-encoding, and follows 3xx redirects. This is deliberately
* simple it serves RSS/Torznab fetches, not a general-purpose user agent. */
#include "naut/http_client.h"
#include "naut/log.h"
#include <errno.h>
#include <netdb.h>
#include <stdlib.h>
#include <string.h>
#include <strings.h>
#include <unistd.h>
#include <sys/socket.h>
#include <sys/time.h>
#include <openssl/ssl.h>
#include <openssl/err.h>
#define HTTP_MAX_BODY (16 * 1024 * 1024) /* 16 MiB cap */
#define HTTP_MAX_REDIR 5
/* A transport: either a plain fd or an SSL session over it. */
typedef struct {
int fd;
SSL_CTX *ctx;
SSL *ssl;
} conn_t;
static void conn_close(conn_t *c) {
if (c->ssl) { SSL_shutdown(c->ssl); SSL_free(c->ssl); c->ssl = NULL; }
if (c->ctx) { SSL_CTX_free(c->ctx); c->ctx = NULL; }
if (c->fd >= 0) { close(c->fd); c->fd = -1; }
}
static int dial(const char *host, const char *port) {
struct addrinfo hints, *res = NULL, *ai;
memset(&hints, 0, sizeof hints);
hints.ai_family = AF_UNSPEC;
hints.ai_socktype = SOCK_STREAM;
if (getaddrinfo(host, port, &hints, &res) != 0) return -1;
int fd = -1;
for (ai = res; ai; ai = ai->ai_next) {
fd = socket(ai->ai_family, ai->ai_socktype, ai->ai_protocol);
if (fd < 0) continue;
struct timeval tv = { .tv_sec = 15, .tv_usec = 0 };
setsockopt(fd, SOL_SOCKET, SO_RCVTIMEO, &tv, sizeof tv);
setsockopt(fd, SOL_SOCKET, SO_SNDTIMEO, &tv, sizeof tv);
if (connect(fd, ai->ai_addr, ai->ai_addrlen) == 0) break;
close(fd); fd = -1;
}
freeaddrinfo(res);
return fd;
}
static bool conn_open(conn_t *c, const char *host, const char *port, bool tls) {
memset(c, 0, sizeof *c);
c->fd = dial(host, port);
if (c->fd < 0) { NAUT_WARN("http: connect %s:%s failed", host, port); return false; }
if (!tls) return true;
c->ctx = SSL_CTX_new(TLS_client_method());
if (!c->ctx) { conn_close(c); return false; }
SSL_CTX_set_verify(c->ctx, SSL_VERIFY_NONE, NULL); /* best-effort fetch */
c->ssl = SSL_new(c->ctx);
if (!c->ssl) { conn_close(c); return false; }
SSL_set_fd(c->ssl, c->fd);
SSL_set_tlsext_host_name(c->ssl, host); /* SNI */
if (SSL_connect(c->ssl) != 1) {
NAUT_WARN("http: TLS handshake with %s failed", host);
conn_close(c);
return false;
}
return true;
}
static bool conn_write(conn_t *c, const void *data, size_t len) {
const char *p = data;
while (len) {
int n = c->ssl ? SSL_write(c->ssl, p, (int)len)
: (int)write(c->fd, p, len);
if (n <= 0) {
if (!c->ssl && n < 0 && errno == EINTR) continue;
return false;
}
p += n; len -= (size_t)n;
}
return true;
}
static int conn_read(conn_t *c, void *buf, size_t len) {
for (;;) {
int n = c->ssl ? SSL_read(c->ssl, buf, (int)len)
: (int)read(c->fd, buf, len);
if (n < 0 && !c->ssl && errno == EINTR) continue;
return n;
}
}
/* Parse "scheme://host[:port]/path". Fills host/port/path; sets *tls. */
static bool parse_url(const char *url, char *host, size_t hostsz,
char *port, size_t portsz, char *path, size_t pathsz,
bool *tls) {
const char *h;
if (strncasecmp(url, "https://", 8) == 0) { *tls = true; h = url + 8; }
else if (strncasecmp(url, "http://", 7) == 0) { *tls = false; h = url + 7; }
else return false;
const char *slash = strchr(h, '/');
const char *hostend = slash ? slash : h + strlen(h);
const char *colon = memchr(h, ':', (size_t)(hostend - h));
size_t hlen = colon ? (size_t)(colon - h) : (size_t)(hostend - h);
if (hlen == 0 || hlen >= hostsz) return false;
memcpy(host, h, hlen); host[hlen] = 0;
if (colon) {
size_t plen = (size_t)(hostend - colon - 1);
if (plen == 0 || plen >= portsz) return false;
memcpy(port, colon + 1, plen); port[plen] = 0;
} else {
snprintf(port, portsz, "%s", *tls ? "443" : "80");
}
if (slash) { if (strlen(slash) >= pathsz) return false; snprintf(path, pathsz, "%s", slash); }
else snprintf(path, pathsz, "/");
return true;
}
/* Decode a chunked-transfer body in place; returns new length. */
static size_t dechunk(char *body, size_t len) {
char *out = body;
const char *in = body, *end = body + len;
while (in < end) {
char *nl = (char *)memchr(in, '\n', (size_t)(end - in));
if (!nl) break;
long sz = strtol(in, NULL, 16);
in = nl + 1;
if (sz <= 0) break;
if (in + sz > end) sz = (long)(end - in);
memmove(out, in, (size_t)sz);
out += sz;
in += sz;
/* skip trailing CRLF after the chunk */
if (in < end && *in == '\r') in++;
if (in < end && *in == '\n') in++;
}
*out = 0;
return (size_t)(out - body);
}
/* One request/response round-trip. On a 3xx with Location, writes the target
* into `redirect` (caller retries) and returns NAUT_OK with out->body == NULL. */
static naut_err fetch_once(const char *url, naut_http_response *out,
char *redirect, size_t redirsz) {
char host[256], port[16], path[2048];
bool tls;
if (!parse_url(url, host, sizeof host, port, sizeof port,
path, sizeof path, &tls))
return NAUT_ERR_INVAL;
conn_t c;
if (!conn_open(&c, host, port, tls)) return NAUT_ERR_IO;
char req[3072];
int rn = snprintf(req, sizeof req,
"GET %s HTTP/1.1\r\nHost: %s\r\nUser-Agent: Naut/0.1\r\n"
"Accept: */*\r\nConnection: close\r\n\r\n", path, host);
if (rn < 0 || (size_t)rn >= sizeof req || !conn_write(&c, req, (size_t)rn)) {
conn_close(&c); return NAUT_ERR_IO;
}
size_t cap = 1 << 16, len = 0;
char *buf = malloc(cap);
if (!buf) { conn_close(&c); return NAUT_ERR_NOMEM; }
for (;;) {
if (len + 1 >= cap) {
if (cap >= HTTP_MAX_BODY) break;
size_t ncap = cap * 2 > HTTP_MAX_BODY ? HTTP_MAX_BODY : cap * 2;
char *nb = realloc(buf, ncap);
if (!nb) { free(buf); conn_close(&c); return NAUT_ERR_NOMEM; }
buf = nb; cap = ncap;
}
int r = conn_read(&c, buf + len, cap - len - 1);
if (r < 0) { free(buf); conn_close(&c); return NAUT_ERR_IO; }
if (r == 0) break;
len += (size_t)r;
}
conn_close(&c);
buf[len] = 0;
if (len < 12 || memcmp(buf, "HTTP/", 5) != 0) { free(buf); return NAUT_ERR_PROTO; }
long status = strtol(buf + 9, NULL, 10);
char *hdr_end = NULL;
for (size_t i = 0; i + 3 < len; i++)
if (buf[i]=='\r'&&buf[i+1]=='\n'&&buf[i+2]=='\r'&&buf[i+3]=='\n') {
hdr_end = buf + i + 4; break;
}
if (!hdr_end) { free(buf); return NAUT_ERR_PROTO; }
/* Headers are everything before hdr_end; scan them case-insensitively. */
size_t hdr_len = (size_t)(hdr_end - buf);
bool chunked = false;
char *loc = NULL;
for (char *p = buf; p < buf + hdr_len; ) {
char *eol = memchr(p, '\n', (size_t)(buf + hdr_len - p));
size_t line = eol ? (size_t)(eol - p) : (size_t)(buf + hdr_len - p);
if (strncasecmp(p, "Transfer-Encoding:", 18) == 0 &&
line < 256 && memmem(p, line, "chunked", 7))
chunked = true;
if (strncasecmp(p, "Location:", 9) == 0) loc = p + 9;
if (!eol) break;
p = eol + 1;
}
if (status >= 300 && status < 400 && loc && redirect) {
while (*loc == ' ' || *loc == '\t') loc++;
size_t n = strcspn(loc, "\r\n");
if (n && n < redirsz) { memcpy(redirect, loc, n); redirect[n] = 0; }
else redirect[0] = 0;
free(buf);
out->body = NULL; out->status = status; out->body_len = 0;
return NAUT_OK;
}
/* Move body to the front of the allocation so the caller owns one buffer. */
size_t blen = len - hdr_len;
memmove(buf, hdr_end, blen);
buf[blen] = 0;
if (chunked) blen = dechunk(buf, blen);
out->status = status;
out->body = buf;
out->body_len = blen;
if (redirect) redirect[0] = 0;
return NAUT_OK;
}
naut_err naut_http_get(const char *url, naut_http_response *out) {
if (!url || !out) return NAUT_ERR_INVAL;
out->body = NULL; out->status = 0; out->body_len = 0;
char current[2048];
if (strlen(url) >= sizeof current) return NAUT_ERR_INVAL;
snprintf(current, sizeof current, "%s", url);
for (int hop = 0; hop <= HTTP_MAX_REDIR; hop++) {
char redirect[2048] = {0};
naut_err e = fetch_once(current, out, redirect, sizeof redirect);
if (e != NAUT_OK) return e;
if (out->body) return NAUT_OK; /* got a real response */
if (!redirect[0]) return NAUT_ERR_PROTO;
/* Relative redirect: only absolute URLs are followed here. */
if (strncasecmp(redirect, "http", 4) != 0) return NAUT_ERR_PROTO;
snprintf(current, sizeof current, "%s", redirect);
}
return NAUT_ERR_PROTO; /* too many redirects */
}
void naut_http_response_free(naut_http_response *r) {
if (!r) return;
free(r->body);
r->body = NULL; r->body_len = 0; r->status = 0;
}