diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ea84cd6..5f3728f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -9,8 +9,10 @@ name: CI # something a shared workflow should know about) and its outputs feed # build-test.yml's run-* inputs directly, preserving the path-based # skip-on-docs-only-PR behavior. `broker-smoke` (host CLI tools against a -# real Mosquitto), `net-smoke` (cross-built mqtt_pub under Copperline's -# HostSocket board against a real Mosquitto - see tests/net/README.md), +# real Mosquitto), `broker-tls-smoke` (same, over a Mosquitto TLS listener +# - gated by the same `broker` filter), `net-smoke` (cross-built mqtt_pub +# under Copperline's HostSocket board against a real Mosquitto - see +# tests/net/README.md), # `library-smoke` (mqtt.library opened and driven on-target - the plain # OpenLibrary/CloseLibrary check, the full API against a real Mosquitto, and # the mco_AutoReconnect backoff/auto-resubscribe check across a broker @@ -92,6 +94,18 @@ jobs: - name: Run broker smoke test run: make broker-smoke + broker-tls-smoke: + name: Broker TLS smoke (host CLIs vs Mosquitto, TLS listener) + runs-on: ubuntu-latest + needs: changes + if: needs.changes.outputs.broker == 'true' + steps: + - uses: actions/checkout@v7 + - name: Install Mosquitto + run: sudo apt-get update && sudo apt-get install -y mosquitto mosquitto-clients + - name: Run broker TLS smoke test + run: make broker-tls-smoke + net-smoke: name: Net smoke (on-target mqtt_pub vs Mosquitto, Copperline HostSocket) runs-on: ubuntu-latest @@ -101,7 +115,7 @@ jobs: steps: - uses: actions/checkout@v7 - name: Install Mosquitto - run: apt-get update && apt-get install -y mosquitto + run: apt-get update && apt-get install -y mosquitto libssl-dev pkg-config - name: Cross-build mqtt_pub, mqtt.library, and the host observer run: PATH=/opt/amiga/bin:$PATH make m68k library cli - name: Run net smoke test @@ -116,7 +130,7 @@ jobs: steps: - uses: actions/checkout@v7 - name: Install Mosquitto - run: apt-get update && apt-get install -y mosquitto + run: apt-get update && apt-get install -y mosquitto libssl-dev pkg-config - name: Run mqtt.library open/close smoke test run: PATH=/opt/amiga/bin:$PATH make library-smoke - name: Run mqtt.library end-to-end API smoke test diff --git a/Makefile b/Makefile index ccf19b7..517f45b 100644 --- a/Makefile +++ b/Makefile @@ -3,6 +3,7 @@ # make test build and run the host-side unit tests (default) # make cli build the host-native mqtt_pub/mqtt_sub tools # make broker-smoke run the host tools against a local Mosquitto +# make broker-tls-smoke run the host tools' TLS support against a local Mosquitto # make m68k cross-build the Amiga binaries (needs amiga-gcc on PATH) # make m68k-docker cross-build inside the CI container (no local toolchain) # make net-smoke on-target network test (Copperline HostSocket; no ROM/WB needed) @@ -23,6 +24,34 @@ CC ?= cc CFLAGS ?= -std=c99 -O2 -Wall -Wextra CObjINC := -Isrc/core +# Host TLS transport (src/host/transport_openssl.c) links host OpenSSL +# 1.1+/3.x. Not on the default compiler search path on macOS (Homebrew +# keeps it out of /usr/include and /usr/lib to avoid shadowing the system +# LibreSSL), so go through pkg-config; fall back to plain -lssl -lcrypto for +# hosts where OpenSSL is where the compiler already expects it. +HOST_SSL_CFLAGS ?= $(shell pkg-config --cflags openssl 2>/dev/null) +HOST_SSL_LIBS ?= $(shell pkg-config --libs openssl 2>/dev/null || echo -lssl -lcrypto) + +# Feature-detect OpenSSL rather than requiring it unconditionally: the +# shared amiga-dev CI image (ci/test-host, ci/build - see +# .github/workflows/ci.yml) has no OpenSSL headers and this repo has no way +# to add an apt-get step to those reusable-workflow jobs, so `cli`/`test` +# must still build cleanly there, just without TLS support in that build. +# Same idiom AmiSSL will use for the m68k side (see issue #3's plan) - +# amiauth's own `make diff` target follows the sibling precedent of keeping +# an optional host OpenSSL dependency out of its core build/test verbs +# entirely; this goes one step further and degrades gracefully instead, +# since -s/-S need to be real flags on the shipped mqtt_pub-host/ +# mqtt_sub-host binaries (broker-tls-smoke's job installs libssl-dev, or +# gets it for free on a stock ubuntu-latest runner, so it always gets full +# TLS support). +HOST_HAS_TLS := $(shell $(CC) $(HOST_SSL_CFLAGS) -c -include openssl/ssl.h -x c /dev/null -o /dev/null >/dev/null 2>&1 && echo 1) +ifeq ($(HOST_HAS_TLS),1) +HOST_TLS_SRCS := src/host/transport_openssl.c +HOST_TLS_CFLAGS := $(HOST_SSL_CFLAGS) -DMIDGE_HOST_TLS +HOST_TLS_LIBS := $(HOST_SSL_LIBS) +endif + # --- m68k cross toolchain (Amiga build) --- # Baseline per the project spec is 68020 (not 68000 - see CLAUDE.md). M68K_CC ?= m68k-amigaos-gcc @@ -49,7 +78,7 @@ DOCKER_USER := --user "$(shell id -u):$(shell id -g)" CORE_SRCS := $(wildcard src/core/*.c) TOOLS_SRCS := $(wildcard src/tools/*.c) -HOST_SRCS := src/host/transport_bsd.c src/host/args.c src/host/clock.c +HOST_SRCS := src/host/transport_bsd.c $(HOST_TLS_SRCS) src/host/args.c src/host/clock.c AMIGA_SRCS := src/amiga/transport_bsdsocket.c src/amiga/args.c src/amiga/clock.c LIB_SRCS := $(wildcard src/library/*.c) TEST_SRCS := $(wildcard tests/test_*.c) @@ -77,7 +106,7 @@ LIB_SFD := src/library/mqtt_lib.sfd LIB_INCDIR := $(BUILD)/include LIB_GENDIR := $(BUILD)/library-gen -.PHONY: all test cli broker-smoke m68k m68k-docker codec-selftest-m68k codec-selftest-m68k-docker net-smoke volamos-smoke volamos-test-target guide api-reference dist clean build test-host test-target lint library-headers library libsmoke-m68k library-smoke volamos-library-smoke libnet-m68k library-net-smoke volamos-library-net-smoke libreconn-m68k library-reconnect-smoke examples example-smoke +.PHONY: all test cli broker-smoke broker-tls-smoke m68k m68k-docker codec-selftest-m68k codec-selftest-m68k-docker net-smoke volamos-smoke volamos-test-target guide api-reference dist clean build test-host test-target lint library-headers library libsmoke-m68k library-smoke volamos-library-smoke libnet-m68k library-net-smoke volamos-library-net-smoke libreconn-m68k library-reconnect-smoke examples example-smoke all: test cli @@ -113,16 +142,20 @@ $(BUILD)/run-tests: $(CORE_SRCS) $(TEST_SRCS) $(CORE_HDRS) $(TEST_HDRS) | $(BUIL cli: $(BUILD)/mqtt_pub-host $(BUILD)/mqtt_sub-host $(BUILD)/mqtt_pub-host: $(CORE_SRCS) $(TOOLS_SRCS) $(HOST_SRCS) src/host/pub_main.c $(CORE_HDRS) $(TOOLS_HDRS) $(HOST_HDRS) | $(BUILD)/.dir - $(CC) $(CFLAGS) $(CObjINC) -Isrc/tools -Isrc/host $(CORE_SRCS) $(TOOLS_SRCS) $(HOST_SRCS) src/host/pub_main.c -o $@ + $(CC) $(CFLAGS) $(CObjINC) -Isrc/tools -Isrc/host $(HOST_TLS_CFLAGS) $(CORE_SRCS) $(TOOLS_SRCS) $(HOST_SRCS) src/host/pub_main.c -o $@ $(HOST_TLS_LIBS) $(BUILD)/mqtt_sub-host: $(CORE_SRCS) $(TOOLS_SRCS) $(HOST_SRCS) src/host/sub_main.c $(CORE_HDRS) $(TOOLS_HDRS) $(HOST_HDRS) | $(BUILD)/.dir - $(CC) $(CFLAGS) $(CObjINC) -Isrc/tools -Isrc/host $(CORE_SRCS) $(TOOLS_SRCS) $(HOST_SRCS) src/host/sub_main.c -o $@ + $(CC) $(CFLAGS) $(CObjINC) -Isrc/tools -Isrc/host $(HOST_TLS_CFLAGS) $(CORE_SRCS) $(TOOLS_SRCS) $(HOST_SRCS) src/host/sub_main.c -o $@ $(HOST_TLS_LIBS) # --- Host: end-to-end smoke test against a local Mosquitto --- broker-smoke: cli MQTT_PUB=$(BUILD)/mqtt_pub-host MQTT_SUB=$(BUILD)/mqtt_sub-host \ sh tests/broker/smoke.sh +broker-tls-smoke: cli + MQTT_PUB=$(BUILD)/mqtt_pub-host MQTT_SUB=$(BUILD)/mqtt_sub-host \ + sh tests/broker/tls-smoke.sh + # --- m68k: Amiga binaries (amiga-gcc on PATH) --- # Four binaries: mqtt_pub/mqtt_sub are the default, library-linked tools # (OpenLibrary("mqtt.library") + the MQTT_* API, see src/amiga/pub_main_lib.c diff --git a/src/amiga/args.c b/src/amiga/args.c index e404478..fb1e9a0 100644 --- a/src/amiga/args.c +++ b/src/amiga/args.c @@ -8,19 +8,20 @@ #define PUB_TEMPLATE \ "HOST/A,PORT/N/K,TOPIC/A,MESSAGE/K,FILE/K,QOS/N/K,CLIENTID/K,USER/K," \ - "PASSWORD/K,KEEPALIVE/N/K,RETAIN/S,VERBOSE/S" + "PASSWORD/K,KEEPALIVE/N/K,RETAIN/S,VERBOSE/S,TLS/S,TLSINSECURE/S" #define SUB_TEMPLATE \ "HOST/A,PORT/N/K,TOPIC/A,QOS/N/K,CLIENTID/K,USER/K,PASSWORD/K," \ - "KEEPALIVE/N/K,COUNT/N/K,VERBOSE/S" + "KEEPALIVE/N/K,COUNT/N/K,VERBOSE/S,TLS/S,TLSINSECURE/S" enum { PUB_HOST, PUB_PORT, PUB_TOPIC, PUB_MESSAGE, PUB_FILE, PUB_QOS, PUB_CLIENTID, PUB_USER, PUB_PASSWORD, PUB_KEEPALIVE, PUB_RETAIN, - PUB_VERBOSE, PUB_NARGS + PUB_VERBOSE, PUB_TLS, PUB_TLSINSECURE, PUB_NARGS }; enum { SUB_HOST, SUB_PORT, SUB_TOPIC, SUB_QOS, SUB_CLIENTID, SUB_USER, - SUB_PASSWORD, SUB_KEEPALIVE, SUB_COUNT, SUB_VERBOSE, SUB_NARGS + SUB_PASSWORD, SUB_KEEPALIVE, SUB_COUNT, SUB_VERBOSE, SUB_TLS, + SUB_TLSINSECURE, SUB_NARGS }; static struct RDArgs *g_rdargs; @@ -33,7 +34,8 @@ int amiga_parse_args(int is_pub, tool_opts *opts) int i; memset(opts, 0, sizeof(*opts)); - opts->port = 1883; + /* opts->port stays 0 (memset) until resolved after parsing, once we + * know whether TLS/TLSINSECURE was given. */ opts->keepalive = 60; for (i = 0; i < nargs; i++) @@ -61,6 +63,10 @@ int amiga_parse_args(int is_pub, tool_opts *opts) opts->keepalive = (uint16_t)*(LONG *)args[PUB_KEEPALIVE]; opts->retain = args[PUB_RETAIN] ? 1 : 0; opts->verbose = args[PUB_VERBOSE] ? 1 : 0; + opts->tls = args[PUB_TLS] ? 1 : 0; + opts->tls_insecure = args[PUB_TLSINSECURE] ? 1 : 0; + if (opts->tls_insecure) + opts->tls = 1; if (!opts->message && !opts->file) { fprintf(stderr, "mqtt_pub: MESSAGE or FILE is required\n"); @@ -88,8 +94,15 @@ int amiga_parse_args(int is_pub, tool_opts *opts) if (args[SUB_COUNT]) opts->count = (int)*(LONG *)args[SUB_COUNT]; opts->verbose = args[SUB_VERBOSE] ? 1 : 0; + opts->tls = args[SUB_TLS] ? 1 : 0; + opts->tls_insecure = args[SUB_TLSINSECURE] ? 1 : 0; + if (opts->tls_insecure) + opts->tls = 1; } + if (opts->port == 0) + opts->port = opts->tls ? 8883 : 1883; + if (opts->qos > 1) { fprintf(stderr, "midge: QoS 2 is not supported (see docs/PROTOCOL.md)\n"); amiga_args_cleanup(); diff --git a/src/host/args.c b/src/host/args.c index 8a0fb18..710a350 100644 --- a/src/host/args.c +++ b/src/host/args.c @@ -14,11 +14,12 @@ int host_parse_args(int argc, char **argv, int is_pub, tool_opts *opts) { int ch; - const char *optstring = is_pub ? "h:p:t:m:f:q:i:u:P:k:rv" - : "h:p:t:q:i:u:P:k:C:v"; + const char *optstring = is_pub ? "h:p:t:m:f:q:i:u:P:k:rvsS" + : "h:p:t:q:i:u:P:k:C:vsS"; memset(opts, 0, sizeof(*opts)); - opts->port = 1883; + /* opts->port stays 0 (memset) until resolved after the option loop, + * once we know whether -s/-S was given. */ opts->keepalive = 60; optind = 1; @@ -37,19 +38,25 @@ int host_parse_args(int argc, char **argv, int is_pub, tool_opts *opts) case 'r': opts->retain = 1; break; case 'C': opts->count = atoi(optarg); break; case 'v': opts->verbose = 1; break; + case 's': opts->tls = 1; break; + case 'S': opts->tls = 1; opts->tls_insecure = 1; break; default: fprintf(stderr, is_pub ? "usage: %s -h host [-p port] -t topic " "(-m message | -f file) [-q qos] [-i id] " - "[-u user] [-P pass] [-k keepalive] [-r] [-v]\n" + "[-u user] [-P pass] [-k keepalive] [-r] [-s] " + "[-S] [-v]\n" : "usage: %s -h host [-p port] -t topic " "[-q qos] [-i id] [-u user] [-P pass] " - "[-k keepalive] [-C count] [-v]\n", + "[-k keepalive] [-C count] [-s] [-S] [-v]\n", argv[0]); return -1; } } + if (opts->port == 0) + opts->port = opts->tls ? 8883 : 1883; + if (!opts->host || !opts->topic) { fprintf(stderr, "%s: -h and -t are required\n", argv[0]); return -1; diff --git a/src/host/pub_main.c b/src/host/pub_main.c index c2ebd6a..5a5ee56 100644 --- a/src/host/pub_main.c +++ b/src/host/pub_main.c @@ -4,12 +4,19 @@ #include "args.h" #include "tool_opts.h" #include "transport_bsd.h" +#ifdef MIDGE_HOST_TLS +#include "transport_openssl.h" +#endif int main(int argc, char **argv) { tool_opts opts; mqtt_transport transport; bsd_ctx ctx; +#ifdef MIDGE_HOST_TLS + openssl_ctx ossl_ctx; +#endif + int connected; /* Sending on a connection the broker has already closed raises SIGPIPE, * which kills the process by default; MSG_NOSIGNAL isn't portable @@ -20,7 +27,25 @@ int main(int argc, char **argv) if (host_parse_args(argc, argv, 1, &opts) != 0) return 2; - if (transport_bsd_connect(&transport, &ctx, opts.host, opts.port) != 0) { +#ifdef MIDGE_HOST_TLS + if (opts.tls) { + connected = transport_openssl_connect(&transport, &ossl_ctx, + opts.host, opts.port, + opts.tls_insecure) == 0; + } else { + connected = transport_bsd_connect(&transport, &ctx, opts.host, + opts.port) == 0; + } +#else + if (opts.tls) { + fprintf(stderr, + "mqtt_pub: TLS not supported - this build has no OpenSSL\n"); + return 1; + } + connected = transport_bsd_connect(&transport, &ctx, opts.host, + opts.port) == 0; +#endif + if (!connected) { fprintf(stderr, "mqtt_pub: cannot connect to %s:%u\n", opts.host, (unsigned)opts.port); return 1; diff --git a/src/host/sub_main.c b/src/host/sub_main.c index 118da1d..b9b58e8 100644 --- a/src/host/sub_main.c +++ b/src/host/sub_main.c @@ -4,12 +4,19 @@ #include "args.h" #include "tool_opts.h" #include "transport_bsd.h" +#ifdef MIDGE_HOST_TLS +#include "transport_openssl.h" +#endif int main(int argc, char **argv) { tool_opts opts; mqtt_transport transport; bsd_ctx ctx; +#ifdef MIDGE_HOST_TLS + openssl_ctx ossl_ctx; +#endif + int connected; /* See pub_main.c's identical signal(SIGPIPE, SIG_IGN) comment. */ signal(SIGPIPE, SIG_IGN); @@ -17,7 +24,25 @@ int main(int argc, char **argv) if (host_parse_args(argc, argv, 0, &opts) != 0) return 2; - if (transport_bsd_connect(&transport, &ctx, opts.host, opts.port) != 0) { +#ifdef MIDGE_HOST_TLS + if (opts.tls) { + connected = transport_openssl_connect(&transport, &ossl_ctx, + opts.host, opts.port, + opts.tls_insecure) == 0; + } else { + connected = transport_bsd_connect(&transport, &ctx, opts.host, + opts.port) == 0; + } +#else + if (opts.tls) { + fprintf(stderr, + "mqtt_sub: TLS not supported - this build has no OpenSSL\n"); + return 1; + } + connected = transport_bsd_connect(&transport, &ctx, opts.host, + opts.port) == 0; +#endif + if (!connected) { fprintf(stderr, "mqtt_sub: cannot connect to %s:%u\n", opts.host, (unsigned)opts.port); return 1; diff --git a/src/host/transport_openssl.c b/src/host/transport_openssl.c new file mode 100644 index 0000000..2f997d2 --- /dev/null +++ b/src/host/transport_openssl.c @@ -0,0 +1,208 @@ +/* transport_openssl.c — TLS mqtt_transport for host builds, via OpenSSL + * (1.1+/3.x API). Structurally mirrors transport_bsd.c: same TCP connect + * loop and SO_RCVTIMEO poll trick, wrapped in an SSL object instead of a + * bare socket. */ + +/* glibc hides struct addrinfo/getaddrinfo/freeaddrinfo (POSIX.1-2001) under + * a strict -std=c99 build unless a feature-test macro says otherwise; must + * be defined before the first system header (see transport_bsd.c). */ +#define _POSIX_C_SOURCE 200112L + +#include "transport_openssl.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include + +static int openssl_send(void *ctx, const uint8_t *buf, size_t len) +{ + openssl_ctx *c = (openssl_ctx *)ctx; + int n; + + ERR_clear_error(); + n = SSL_write(c->ssl, buf, (int)len); + if (n <= 0) { + int err = SSL_get_error(c->ssl, n); + if (err == SSL_ERROR_WANT_READ || err == SSL_ERROR_WANT_WRITE) + return 0; + return -1; + } + return n; +} + +static int openssl_recv(void *ctx, uint8_t *buf, size_t cap) +{ + openssl_ctx *c = (openssl_ctx *)ctx; + int n; + + ERR_clear_error(); + n = SSL_read(c->ssl, buf, (int)cap); + if (n <= 0) { + int err = SSL_get_error(c->ssl, n); + + if (err == SSL_ERROR_WANT_READ || err == SSL_ERROR_WANT_WRITE) + return 0; + if (err == SSL_ERROR_SYSCALL && + (errno == EAGAIN || errno == EWOULDBLOCK || errno == EINTR)) + return 0; /* SO_RCVTIMEO's poll interval elapsed - see transport_bsd.c */ + return -1; /* SSL_ERROR_ZERO_RETURN (clean TLS shutdown) or fatal */ + } + return n; +} + +static void openssl_close(void *ctx) +{ + openssl_ctx *c = (openssl_ctx *)ctx; + + if (c->ssl != NULL) { + SSL_shutdown(c->ssl); /* one call, best-effort - not a full bidirectional close */ + SSL_free(c->ssl); + c->ssl = NULL; + } + if (c->ssl_ctx != NULL) { + SSL_CTX_free(c->ssl_ctx); + c->ssl_ctx = NULL; + } + if (c->fd >= 0) { + close(c->fd); + c->fd = -1; + } +} + +/* Connects a blocking TCP socket to host:port. Returns the fd, or -1 on + * failure. Duplicated from transport_bsd_connect rather than shared, to keep + * transport_bsd.c untouched and this file self-contained. */ +static int tcp_connect(const char *host, uint16_t port) +{ + struct addrinfo hints, *res, *rp; + char portstr[6]; + int fd = -1; + struct timeval tv; + + memset(&hints, 0, sizeof(hints)); + hints.ai_family = AF_UNSPEC; + hints.ai_socktype = SOCK_STREAM; + + snprintf(portstr, sizeof(portstr), "%u", (unsigned)port); + if (getaddrinfo(host, portstr, &hints, &res) != 0) + return -1; + + for (rp = res; rp != NULL; rp = rp->ai_next) { + fd = socket(rp->ai_family, rp->ai_socktype, rp->ai_protocol); + if (fd < 0) + continue; + if (connect(fd, rp->ai_addr, rp->ai_addrlen) == 0) + break; + close(fd); + fd = -1; + } + freeaddrinfo(res); + if (fd < 0) + return -1; + + tv.tv_sec = 1; + tv.tv_usec = 0; + setsockopt(fd, SOL_SOCKET, SO_RCVTIMEO, &tv, sizeof(tv)); + + return fd; +} + +int transport_openssl_connect(mqtt_transport *out, openssl_ctx *ctx, + const char *host, uint16_t port, + int insecure_skip_verify) +{ + int fd; + SSL_CTX *ssl_ctx; + SSL *ssl; + + ctx->fd = -1; + ctx->ssl_ctx = NULL; + ctx->ssl = NULL; + + fd = tcp_connect(host, port); + if (fd < 0) + return -1; + + ssl_ctx = SSL_CTX_new(TLS_client_method()); + if (ssl_ctx == NULL) { + fprintf(stderr, "mqtt: SSL_CTX_new failed\n"); + close(fd); + return -1; + } + + if (insecure_skip_verify) { + SSL_CTX_set_verify(ssl_ctx, SSL_VERIFY_NONE, NULL); + } else { + SSL_CTX_set_verify(ssl_ctx, + SSL_VERIFY_PEER | SSL_VERIFY_FAIL_IF_NO_PEER_CERT, + NULL); + if (!SSL_CTX_set_default_verify_paths(ssl_ctx)) { + fprintf(stderr, "mqtt: failed to load system trust store\n"); + SSL_CTX_free(ssl_ctx); + close(fd); + return -1; + } + } + + ssl = SSL_new(ssl_ctx); + if (ssl == NULL) { + fprintf(stderr, "mqtt: SSL_new failed\n"); + SSL_CTX_free(ssl_ctx); + close(fd); + return -1; + } + + /* SNI, plus hostname verification when we're actually verifying - both + * matter for brokers hosted behind name-based TLS termination. */ + SSL_set_tlsext_host_name(ssl, host); + if (!insecure_skip_verify) + SSL_set1_host(ssl, host); + + if (!SSL_set_fd(ssl, fd)) { + fprintf(stderr, "mqtt: SSL_set_fd failed\n"); + SSL_free(ssl); + SSL_CTX_free(ssl_ctx); + close(fd); + return -1; + } + + ERR_clear_error(); + if (SSL_connect(ssl) != 1) { + /* SSL_get_verify_result() MUST be read before SSL_free() - after + * free it silently reports X509_V_OK regardless of what actually + * happened, which would misreport every handshake failure as a + * verification success. */ + long verify_result = SSL_get_verify_result(ssl); + + fprintf(stderr, "mqtt: TLS handshake with %s:%u failed", host, + (unsigned)port); + if (verify_result != X509_V_OK) + fprintf(stderr, " (%s)", + X509_verify_cert_error_string(verify_result)); + fprintf(stderr, "\n"); + + SSL_free(ssl); + SSL_CTX_free(ssl_ctx); + close(fd); + return -1; + } + + ctx->fd = fd; + ctx->ssl_ctx = ssl_ctx; + ctx->ssl = ssl; + + out->ctx = ctx; + out->send = openssl_send; + out->recv = openssl_recv; + out->close = openssl_close; + return 0; +} diff --git a/src/host/transport_openssl.h b/src/host/transport_openssl.h new file mode 100644 index 0000000..69a5c91 --- /dev/null +++ b/src/host/transport_openssl.h @@ -0,0 +1,29 @@ +#ifndef MIDGE_TRANSPORT_OPENSSL_H +#define MIDGE_TRANSPORT_OPENSSL_H + +#include + +#include + +#include "mqtt_transport.h" + +typedef struct { + int fd; + SSL_CTX *ssl_ctx; + SSL *ssl; +} openssl_ctx; + +/* Resolves host:port, connects a blocking TCP socket with a short receive + * timeout (same rationale as transport_bsd_connect - periodic wakeups for + * mqtt_client_process()), then performs a TLS handshake over it. Certificate + * and hostname verification are on by default (system trust store via + * SSL_CTX_set_default_verify_paths()); pass insecure_skip_verify nonzero to + * disable both (SSL_VERIFY_NONE) - for testing against self-signed brokers + * only, never for production use. Wires `out` up via `ctx` (caller-owned, no + * allocation). Returns 0 on success, -1 on failure (no resources leaked on + * any error path). */ +int transport_openssl_connect(mqtt_transport *out, openssl_ctx *ctx, + const char *host, uint16_t port, + int insecure_skip_verify); + +#endif diff --git a/src/tools/tool_opts.h b/src/tools/tool_opts.h index 1521f91..781fdb1 100644 --- a/src/tools/tool_opts.h +++ b/src/tools/tool_opts.h @@ -13,7 +13,8 @@ typedef struct { const char *host; - uint16_t port; /* default 1883 */ + uint16_t port; /* default 1883 plaintext, 8883 with TLS; parsers + leave 0 until resolved after the option loop */ const char *topic; const char *message; /* mqtt_pub: literal payload; NULL if using `file` */ const char *file; /* mqtt_pub: read payload from this file instead */ @@ -26,6 +27,9 @@ typedef struct { int retain; /* mqtt_pub only */ int verbose; int count; /* mqtt_sub only; <= 0 means unlimited */ + int tls; /* opt-in, never default-on (see issue #3) */ + int tls_insecure; /* skip certificate verification; ignored + without tls */ } tool_opts; /* Connects (assumes `transport` is already connected to the broker), diff --git a/tests/broker/tls-smoke.sh b/tests/broker/tls-smoke.sh new file mode 100644 index 0000000..9adb538 --- /dev/null +++ b/tests/broker/tls-smoke.sh @@ -0,0 +1,79 @@ +#!/bin/sh +# broker/tls-smoke.sh — end-to-end check of the host mqtt_pub/mqtt_sub tools' +# TLS support (-s verify-on, -S skip-verify) against a real Mosquitto broker +# with a TLS listener (real MQTT wire protocol, real TLS handshake; just not +# on m68k - see tests/broker/smoke.sh for the plaintext equivalent). +# +# Usage: MQTT_PUB=build/mqtt_pub-host MQTT_SUB=build/mqtt_sub-host sh tests/broker/tls-smoke.sh +# (invoked via `make broker-tls-smoke`, which builds the tools first) +# +# Starts its own mosquitto on a scratch port so it doesn't collide with a +# broker already running on 1883/8883; needs `mosquitto` on PATH (apt/brew +# package `mosquitto`, or run the CI job which installs it) and `openssl` on +# PATH to generate a throwaway self-signed cert/key pair. +set -eu + +MQTT_PUB="${MQTT_PUB:?set MQTT_PUB to the mqtt_pub binary}" +MQTT_SUB="${MQTT_SUB:?set MQTT_SUB to the mqtt_sub binary}" +PORT=18883 +OUTDIR=$(mktemp -d) +MOSQ_PID= +trap 'kill "$MOSQ_PID" 2>/dev/null || true; rm -rf "$OUTDIR"' EXIT + +command -v mosquitto >/dev/null || { echo "tls-smoke: mosquitto not on PATH" >&2; exit 2; } +command -v openssl >/dev/null || { echo "tls-smoke: openssl not on PATH" >&2; exit 2; } + +# Throwaway self-signed cert/key pair. The SAN matters - the client verifies +# the hostname it connected with (SSL_set1_host), so the cert must cover +# "localhost", which round 1 below connects to. +openssl req -x509 -newkey rsa:2048 -nodes \ + -keyout "$OUTDIR/server.key" -out "$OUTDIR/server.crt" -days 2 \ + -subj "/CN=localhost" -addext "subjectAltName=DNS:localhost,IP:127.0.0.1" \ + 2>/dev/null + +cat > "$OUTDIR/mosquitto.conf" < "$OUTDIR/mosquitto.log" 2>&1 & +MOSQ_PID=$! + +# Wait for the listener to come up (mosquitto has no readiness signal of +# its own beyond its log line - "/dev/tcp/..." is a bash extension dash +# (Ubuntu's /bin/sh) doesn't support, so poll the log instead of a raw +# socket probe; portable everywhere this script's own shebang runs). +i=0 +while ! grep -q "mosquitto version .* running" "$OUTDIR/mosquitto.log" 2>/dev/null; do + i=$((i + 1)) + [ "$i" -ge 50 ] && { echo "tls-smoke: mosquitto did not start"; cat "$OUTDIR/mosquitto.log"; exit 1; } + sleep 0.1 +done + +echo "--- TLS verify-on (-s), trusting the self-signed cert via SSL_CERT_FILE ---" +SSL_CERT_FILE="$OUTDIR/server.crt" "$MQTT_SUB" -h localhost -p "$PORT" -s -t midge/tls-smoke -C 1 > "$OUTDIR/sub-verify.out" & +SUB_PID=$! +sleep 0.3 +SSL_CERT_FILE="$OUTDIR/server.crt" "$MQTT_PUB" -h localhost -p "$PORT" -s -t midge/tls-smoke -m "hello over tls" +wait "$SUB_PID" +grep -qF "hello over tls" "$OUTDIR/sub-verify.out" || { echo "tls-smoke: message not received over verified TLS"; cat "$OUTDIR/sub-verify.out"; exit 1; } +echo "ok: received \"$(cat "$OUTDIR/sub-verify.out")\"" + +echo "--- TLS skip-verify (-S), untrusted cert, no SSL_CERT_FILE ---" +"$MQTT_SUB" -h 127.0.0.1 -p "$PORT" -S -t midge/tls-smoke -C 1 > "$OUTDIR/sub-insecure.out" & +SUB_PID=$! +sleep 0.3 +"$MQTT_PUB" -h 127.0.0.1 -p "$PORT" -S -t midge/tls-smoke -m "hello insecure" +wait "$SUB_PID" +grep -qF "hello insecure" "$OUTDIR/sub-insecure.out" || { echo "tls-smoke: message not received over insecure TLS"; cat "$OUTDIR/sub-insecure.out"; exit 1; } +echo "ok: received \"$(cat "$OUTDIR/sub-insecure.out")\"" + +echo "--- TLS verify-on (-s) with no trust anchor must fail ---" +if "$MQTT_PUB" -h localhost -p "$PORT" -s -t midge/tls-smoke -m "should not connect" 2>"$OUTDIR/pub-fail.out"; then + echo "tls-smoke: mqtt_pub connected with -s and no SSL_CERT_FILE - verification is not actually on"; cat "$OUTDIR/pub-fail.out"; exit 1 +fi +echo "ok: verify-on connect correctly rejected the untrusted cert" + +echo "All broker TLS smoke checks passed." diff --git a/userdocs/CLI-Reference.md b/userdocs/CLI-Reference.md index 76ed7d9..e2fbb7f 100644 --- a/userdocs/CLI-Reference.md +++ b/userdocs/CLI-Reference.md @@ -69,3 +69,20 @@ Example: ``` mqtt_sub HOST 192.168.1.10 TOPIC home/# ``` + +## Host development builds + +The repo also builds host-native `mqtt_pub-host` / `mqtt_sub-host` (via +`make cli`), used for development and by the CI broker smoke tests. They +take getopt-style flags mirroring the Amiga arguments above (`-h HOST`, +`-p PORT`, `-t TOPIC`, and so on), plus two flags that only exist on the +host builds so far: + +| Flag | Meaning | +|---|---| +| `-s` | Enable TLS, with certificate and hostname verification on, checked against the system trust store. When no `-p` is given, the default port becomes 8883 instead of 1883. | +| `-S` | Enable TLS but skip certificate verification. Intended only for testing against self-signed or otherwise untrusted brokers. | + +TLS is opt-in and off by default everywhere in midge. The Amiga-side TLS +transport (via AmiSSL) is not yet wired up - `-s`/`-S` currently apply to +the host builds only.