diff --git a/CMakeLists.txt b/CMakeLists.txt index 2c767a2..86abe96 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -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 --------- diff --git a/include/naut/http_client.h b/include/naut/http_client.h new file mode 100644 index 0000000..f2ecd21 --- /dev/null +++ b/include/naut/http_client.h @@ -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 +#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 */ diff --git a/src/net/http_client.c b/src/net/http_client.c new file mode 100644 index 0000000..d9ed57f --- /dev/null +++ b/src/net/http_client.c @@ -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 +#include +#include +#include +#include +#include +#include +#include + +#include +#include + +#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; +}