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:
parent
dbd0d6f78e
commit
0393ed429b
3 changed files with 299 additions and 2 deletions
|
|
@ -37,7 +37,7 @@ find_path(URING_INC liburing.h)
|
||||||
if(NOT URING_LIB OR NOT URING_INC)
|
if(NOT URING_LIB OR NOT URING_INC)
|
||||||
message(FATAL_ERROR "liburing not found (install liburing-dev)")
|
message(FATAL_ERROR "liburing not found (install liburing-dev)")
|
||||||
endif()
|
endif()
|
||||||
find_package(OpenSSL REQUIRED COMPONENTS Crypto)
|
find_package(OpenSSL REQUIRED COMPONENTS Crypto SSL)
|
||||||
|
|
||||||
# --- external download engine + tracker/DHT protocol libraries --------------
|
# --- external download engine + tracker/DHT protocol libraries --------------
|
||||||
# torrent-peer: multi-peer download engine (engine.h) — replaces Naut's own peer
|
# 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)
|
src/discovery/tracker_client.c src/discovery/dht_client.c)
|
||||||
target_link_libraries(naut_discovery PUBLIC naut_core torrenttracker)
|
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 -------
|
# --- peer: wire protocol codec (sans-IO), retained for magnet metadata -------
|
||||||
add_library(naut_peer STATIC
|
add_library(naut_peer STATIC
|
||||||
src/peer/wire.c src/peer/extension.c src/peer/metadata.c)
|
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)
|
add_library(naut_webui MODULE plugins/webui/webui.c)
|
||||||
target_include_directories(naut_webui PRIVATE ${CMAKE_SOURCE_DIR}/include)
|
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 "")
|
set_target_properties(naut_webui PROPERTIES PREFIX "")
|
||||||
|
|
||||||
# --- swarm: multi-peer download driver over the torrent-peer engine ---------
|
# --- swarm: multi-peer download driver over the torrent-peer engine ---------
|
||||||
|
|
|
||||||
26
include/naut/http_client.h
Normal file
26
include/naut/http_client.h
Normal 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
264
src/net/http_client.c
Normal 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;
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue