From ed1c15929d2eb2dbc6432986c26661bf1549964a Mon Sep 17 00:00:00 2001 From: Anders Betts Date: Thu, 17 Sep 2026 21:26:20 +0200 Subject: Add native TLS transport, TLS clients and lego cert sidecar - bokfd: optional TLS listener (OpenSSL), certificate reload on change - clients: tls:host:port targets with chain and host verification - compose: port 8788 and an INWX/lego renewal sidecar - Makefile: header dependency tracking (-MMD -MP) --- .env.example | 8 ++ Dockerfile | 6 +- Makefile | 32 +++-- clients/bokfctl.c | 236 +++++------------------------------ clients/bokftui.c | 112 +++++++++-------- clients/client.c | 296 +++++++++++++++++++++++++++++++++++--------- clients/client.h | 26 ++-- compose.yaml | 29 +++++ deploy/docker-entrypoint.sh | 3 +- docs/DEPLOY.md | 47 ++++++- docs/PROTOCOL.md | 32 +++-- docs/STATE.md | 16 ++- src/bokfd.c | 267 ++++++++++++++++++++++++++++++++++----- src/config.c | 24 ++++ src/config.h | 4 + tests/test_core.c | 127 +++++++++++++++++++ tests/tls_test_cert.pem | 11 ++ tests/tls_test_key.pem | 5 + 18 files changed, 898 insertions(+), 383 deletions(-) create mode 100644 tests/tls_test_cert.pem create mode 100644 tests/tls_test_key.pem diff --git a/.env.example b/.env.example index bb20a6f..ab0dd6f 100644 --- a/.env.example +++ b/.env.example @@ -11,3 +11,11 @@ BOKF_REMOTE_DIR=/srv/bokf # Local compose runs: "docker compose up --build" builds this image name. BOKF_IMAGE=bokf BOKF_TAG=dev +# +# TLS: the certs sidecar renews a Let's Encrypt certificate via INWX DNS-01 +# and bokfd serves the clients on port 8788. Keep the credentials in the +# host's .env only; they are never committed. +# LEGO_DOMAIN=bokf.makandra.eu +# LEGO_EMAIL=anders@makandra.eu +# INWX_USERNAME=... +# INWX_PASSWORD=... diff --git a/Dockerfile b/Dockerfile index ba76b97..0c22b19 100644 --- a/Dockerfile +++ b/Dockerfile @@ -3,6 +3,7 @@ FROM debian:bookworm-slim AS build RUN apt-get update \ && apt-get install -y --no-install-recommends build-essential libncurses-dev \ + libssl-dev \ && rm -rf /var/lib/apt/lists/* WORKDIR /src COPY . . @@ -10,12 +11,13 @@ RUN make -j"$(nproc)" && make test FROM debian:bookworm-slim AS runtime RUN apt-get update \ - && apt-get install -y --no-install-recommends libncursesw6 ca-certificates \ + && apt-get install -y --no-install-recommends libncursesw6 libssl3 \ + ca-certificates util-linux \ && rm -rf /var/lib/apt/lists/* \ && useradd --system --uid 10001 --home-dir /var/lib/bokfd \ --shell /usr/sbin/nologin bokfd \ && install -d -o bokfd -g bokfd /var/lib/bokfd /var/lib/bokfd/backup \ - /var/lib/bokfd/export /run/bokfd + /var/lib/bokfd/export /var/lib/bokfd/certs /run/bokfd COPY --from=build /src/build/bokfd /src/build/bokfctl /src/build/bokftui \ /usr/local/bin/ COPY --from=build /src/data/bas_k2.csv /src/data/bas_k3.csv \ diff --git a/Makefile b/Makefile index 72dff20..6912473 100644 --- a/Makefile +++ b/Makefile @@ -4,7 +4,8 @@ VERSION = 0.1.0-dev WARN = -Wall -Wextra -Wshadow -Wstrict-prototypes -Wmissing-prototypes \ -Wpointer-arith -Wvla -Wformat=2 -CPPFLAGS = -Isrc -Ivendor -Ivendor/argon2 +DEPFLAGS = -MMD -MP +CPPFLAGS = -Isrc -Iclients -Ivendor -Ivendor/argon2 DEFS = -D_GNU_SOURCE -DARGON2_NO_THREADS -DBOKF_VERSION=\"$(VERSION)\" BUILD = build @@ -23,40 +24,47 @@ CORE_OBJ = $(patsubst %.c,$(BUILD)/%.o,$(CORE_SRC)) all: $(BUILD)/bokfd $(BUILD)/bokfctl $(BUILD)/bokftui +SSL_LIBS = -lssl -lcrypto + $(BUILD)/bokfd: $(BUILD)/src/bokfd.o $(CORE_OBJ) $(VENDOR_OBJ) - $(CC) $(CFLAGS) -o $@ $^ -lm + $(CC) $(CFLAGS) -o $@ $^ -lm $(SSL_LIBS) -$(BUILD)/bokfctl: $(BUILD)/clients/bokfctl.o $(BUILD)/src/util.o \ - $(BUILD)/src/log.o $(BUILD)/vendor/yyjson.o \ +$(BUILD)/bokfctl: $(BUILD)/clients/bokfctl.o $(BUILD)/clients/client.o \ + $(BUILD)/src/util.o $(BUILD)/src/log.o $(BUILD)/vendor/yyjson.o \ $(BUILD)/vendor/sha256.o - $(CC) $(CFLAGS) -o $@ $^ -lm + $(CC) $(CFLAGS) -o $@ $^ -lm $(SSL_LIBS) $(BUILD)/bokftui: $(BUILD)/clients/bokftui.o $(BUILD)/clients/client.o \ $(BUILD)/src/util.o $(BUILD)/src/log.o $(BUILD)/src/formula.o \ $(BUILD)/vendor/yyjson.o $(BUILD)/vendor/sha256.o - $(CC) $(CFLAGS) -o $@ $^ -lm -lncursesw + $(CC) $(CFLAGS) -o $@ $^ -lm -lncursesw $(SSL_LIBS) -$(BUILD)/test_core: $(BUILD)/tests/test_core.o $(CORE_OBJ) $(VENDOR_OBJ) - $(CC) $(CFLAGS) -o $@ $^ -lm +$(BUILD)/test_core: $(BUILD)/tests/test_core.o $(BUILD)/clients/client.o \ + $(CORE_OBJ) $(VENDOR_OBJ) + $(CC) $(CFLAGS) -o $@ $^ -lm $(SSL_LIBS) test: $(BUILD)/test_core $(BUILD)/test_core $(BUILD)/vendor/%.o: vendor/%.c @mkdir -p $(dir $@) - $(CC) $(CFLAGS) $(CPPFLAGS) $(DEFS) -w -c $< -o $@ + $(CC) $(CFLAGS) $(DEPFLAGS) $(CPPFLAGS) $(DEFS) -w -c $< -o $@ $(BUILD)/src/%.o: src/%.c @mkdir -p $(dir $@) - $(CC) $(CFLAGS) $(WARN) $(CPPFLAGS) $(DEFS) -c $< -o $@ + $(CC) $(CFLAGS) $(DEPFLAGS) $(WARN) $(CPPFLAGS) $(DEFS) -c $< -o $@ $(BUILD)/clients/%.o: clients/%.c @mkdir -p $(dir $@) - $(CC) $(CFLAGS) $(WARN) $(CPPFLAGS) $(DEFS) -c $< -o $@ + $(CC) $(CFLAGS) $(DEPFLAGS) $(WARN) $(CPPFLAGS) $(DEFS) -c $< -o $@ $(BUILD)/tests/%.o: tests/%.c @mkdir -p $(dir $@) - $(CC) $(CFLAGS) $(WARN) $(CPPFLAGS) $(DEFS) -c $< -o $@ + $(CC) $(CFLAGS) $(DEPFLAGS) $(WARN) $(CPPFLAGS) $(DEFS) -c $< -o $@ + +-include $(VENDOR_OBJ:.o=.d) $(CORE_OBJ:.o=.d) $(BUILD)/src/bokfd.d \ + $(BUILD)/clients/bokfctl.d $(BUILD)/clients/bokftui.d \ + $(BUILD)/clients/client.d $(BUILD)/tests/test_core.d install: all install -d $(DESTDIR)/usr/local/bin $(DESTDIR)/usr/local/share/bokf diff --git a/clients/bokfctl.c b/clients/bokfctl.c index c541fec..1f1372c 100644 --- a/clients/bokfctl.c +++ b/clients/bokfctl.c @@ -1,168 +1,19 @@ -#include -#include #include #include #include -#include -#include -#include +#include "client.h" #include "util.h" #include "version.h" #include "yyjson.h" -static ssize_t write_all(int fd, const char *buf, size_t len) -{ - size_t off = 0; - while (off < len) { - ssize_t w = write(fd, buf + off, len - off); - if (w < 0) { - if (errno == EINTR) - continue; - return -1; - } - off += (size_t)w; - } - return (ssize_t)off; -} - -static char *read_line_fd(int fd) -{ - struct buf b; - buf_init(&b); - char chunk[4096]; - for (;;) { - ssize_t r = read(fd, chunk, sizeof chunk); - if (r < 0) { - if (errno == EINTR) - continue; - buf_free(&b); - return NULL; - } - if (r == 0) - break; - unsigned char *nl = memchr(chunk, '\n', (size_t)r); - if (nl) { - buf_append(&b, chunk, (size_t)(nl - (unsigned char *)chunk)); - break; - } - buf_append(&b, chunk, (size_t)r); - } - char *out = xmalloc(b.len + 1); - memcpy(out, b.p ? (char *)b.p : "", b.len); - out[b.len] = '\0'; - buf_free(&b); - return out; -} - -static int tcp_connect_addr(const char *addrport) -{ - char host[256] = "127.0.0.1"; - char port[16] = "8787"; - const char *colon = strrchr(addrport, ':'); - if (colon) { - size_t hl = (size_t)(colon - addrport); - if (hl < sizeof host) { - memcpy(host, addrport, hl); - host[hl] = '\0'; - } - snprintf(port, sizeof port, "%s", colon + 1); - } else { - snprintf(port, sizeof port, "%s", addrport); - } - struct addrinfo hints, *res = NULL; - 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 (struct addrinfo *ai = res; ai; ai = ai->ai_next) { - fd = socket(ai->ai_family, ai->ai_socktype, ai->ai_protocol); - if (fd < 0) - continue; - if (connect(fd, ai->ai_addr, ai->ai_addrlen) == 0) - break; - close(fd); - fd = -1; - } - freeaddrinfo(res); - return fd; -} - -static int connect_target(const char *target) -{ - if (strncmp(target, "tcp:", 4) == 0) - return tcp_connect_addr(target + 4); - struct sockaddr_un sa; - memset(&sa, 0, sizeof sa); - sa.sun_family = AF_UNIX; - if (strlen(target) >= sizeof sa.sun_path) { - errno = ENAMETOOLONG; - return -1; - } - snprintf(sa.sun_path, sizeof sa.sun_path, "%s", target); - int fd = socket(AF_UNIX, SOCK_STREAM, 0); - if (fd < 0) - return -1; - if (connect(fd, (struct sockaddr *)&sa, sizeof sa) != 0) { - close(fd); - return -1; - } - return fd; -} - -static char *make_request(const char *cmd, const char *session, int64_t org, - const char *args_json, const char *id) -{ - yyjson_doc *adoc = NULL; - if (args_json) { - adoc = yyjson_read(args_json, strlen(args_json), 0); - if (!adoc || !yyjson_is_obj(yyjson_doc_get_root(adoc))) { - yyjson_doc_free(adoc); - return NULL; - } - } - yyjson_mut_doc *d = yyjson_mut_doc_new(NULL); - yyjson_mut_val *o = yyjson_mut_obj(d); - yyjson_mut_doc_set_root(d, o); - yyjson_mut_obj_add_int(d, o, "v", 1); - yyjson_mut_obj_add_str(d, o, "id", id ? id : "cli"); - yyjson_mut_obj_add_str(d, o, "cmd", cmd); - if (session) - yyjson_mut_obj_add_str(d, o, "session", session); - if (org > 0) - yyjson_mut_obj_add_int(d, o, "org", org); - if (adoc) { - yyjson_mut_val *args = yyjson_val_mut_copy(d, yyjson_doc_get_root(adoc)); - yyjson_mut_obj_add_val(d, o, "args", args); - yyjson_doc_free(adoc); - } - char *s = yyjson_mut_write(d, 0, NULL); - yyjson_mut_doc_free(d); - return s; -} - -static char *make_login_args(const char *user, const char *password) -{ - yyjson_mut_doc *d = yyjson_mut_doc_new(NULL); - yyjson_mut_val *o = yyjson_mut_obj(d); - yyjson_mut_doc_set_root(d, o); - yyjson_mut_obj_add_str(d, o, "method", "password"); - yyjson_mut_obj_add_str(d, o, "username", user); - yyjson_mut_obj_add_str(d, o, "password", password); - char *s = yyjson_mut_write(d, 0, NULL); - yyjson_mut_doc_free(d); - return s; -} - static void usage(void) { fprintf(stderr, "usage: bokfctl [options] [args-json]\n" "\n" "options:\n" - " --socket TARGET unix socket path or tcp:host:port\n" + " --socket TARGET unix path, tcp:host:port or tls:host:port\n" " (env BOKFD_SOCKET, default /run/bokfd/bokfd.sock)\n" " --user NAME login user (env BOKFD_USER)\n" " --password PW login password (env BOKFD_PASSWORD)\n" @@ -175,6 +26,12 @@ static void usage(void) " bokfctl describe\n"); } +static int is_local_cmd(const char *cmd) +{ + return strcmp(cmd, "health") == 0 || strcmp(cmd, "meta") == 0 || + strcmp(cmd, "session.open") == 0; +} + int main(int argc, char **argv) { const char *target = getenv("BOKFD_SOCKET"); @@ -245,90 +102,61 @@ int main(int argc, char **argv) return 2; } - int fd = connect_target(target); - if (fd < 0) { + struct client_conn conn; + if (client_connect(target, &conn) != 0) { fprintf(stderr, "bokfctl: cannot connect to %s: %s\n", target, - strerror(errno)); + client_last_error()); return 2; } - char session[128] = ""; - if (strcmp(cmd, "health") != 0 && strcmp(cmd, "meta") != 0 && - strcmp(cmd, "session.open") != 0) { + char *session = NULL; + if (!is_local_cmd(cmd)) { if (!user || !password) { fprintf(stderr, "bokfctl: set BOKFD_USER and BOKFD_PASSWORD (or --user/--password) to log in\n"); - close(fd); + client_close(&conn); return 2; } - char *largs = make_login_args(user, password); - char *lreq = make_request("session.open", NULL, 0, largs, "login"); - free(largs); - if (!lreq || write_all(fd, lreq, strlen(lreq)) < 0 || - write_all(fd, "\n", 1) < 0) { - fprintf(stderr, "bokfctl: send failed\n"); - free(lreq); - close(fd); - return 2; - } - free(lreq); - char *lresp = read_line_fd(fd); - if (!lresp) { - fprintf(stderr, "bokfctl: no response\n"); - close(fd); - return 2; - } - yyjson_doc *ld = yyjson_read(lresp, strlen(lresp), 0); - int ok = ld && yyjson_is_obj(yyjson_doc_get_root(ld)) && - yyjson_get_bool(yyjson_obj_get(yyjson_doc_get_root(ld), "ok")); - const char *sid = NULL; - if (ok) { - yyjson_val *r = yyjson_obj_get(yyjson_doc_get_root(ld), "result"); - yyjson_val *s = r ? yyjson_obj_get(r, "session") : NULL; - if (s && yyjson_is_str(s)) - sid = yyjson_get_str(s); - } - if (!ok || !sid) { - fprintf(stderr, "%s\n", lresp); - yyjson_doc_free(ld); - free(lresp); - close(fd); - return 1; + char *lerr = NULL; + if (client_login(&conn, user, password, &session, &lerr) != 0) { + fprintf(stderr, "%s\n", lerr ? lerr : "login failed"); + int rc = lerr && lerr[0] == '{' ? 1 : 2; + free(lerr); + client_close(&conn); + return rc; } - snprintf(session, sizeof session, "%s", sid); - yyjson_doc_free(ld); - free(lresp); } char *req = NULL; if (strcmp(cmd, "raw") == 0) { if (!args_json) { fprintf(stderr, "bokfctl: raw requires a full request JSON\n"); - close(fd); + client_close(&conn); return 2; } req = xstrdup(args_json); } else { - req = make_request(cmd, session[0] ? session : NULL, org, args_json, - "cli"); + req = client_make_request(cmd, session, org, args_json, "cli"); if (!req) { fprintf(stderr, "bokfctl: args must be a JSON object\n"); - close(fd); + free(session); + client_close(&conn); return 2; } } - if (write_all(fd, req, strlen(req)) < 0 || write_all(fd, "\n", 1) < 0) { - fprintf(stderr, "bokfctl: send failed\n"); + free(session); + if (client_send_line(&conn, req) != 0) { + fprintf(stderr, "bokfctl: send failed: %s\n", client_last_error()); free(req); - close(fd); + client_close(&conn); return 2; } free(req); - char *resp = read_line_fd(fd); - close(fd); + char *resp = client_read_line(&conn); + client_close(&conn); if (!resp) { - fprintf(stderr, "bokfctl: no response\n"); + fprintf(stderr, "bokfctl: no response: %s\n", client_last_error()); return 2; } yyjson_doc *rd = yyjson_read(resp, strlen(resp), 0); diff --git a/clients/bokftui.c b/clients/bokftui.c index 1939eeb..c26d188 100644 --- a/clients/bokftui.c +++ b/clients/bokftui.c @@ -32,7 +32,7 @@ static int ui_getch(void) } struct app { - int fd; + struct client_conn conn; char socket[256]; char session[128]; char username[64]; @@ -895,10 +895,10 @@ static void app_refresh_context(struct app *a) { char args[64]; snprintf(args, sizeof args, "{\"org\":%lld}", (long long)a->org); - char *resp = client_rpc(a->fd, "session.use_org", a->session, 0, args); + char *resp = client_rpc(&a->conn, "session.use_org", a->session, 0, args); free(resp); - resp = client_rpc(a->fd, "org.get", a->session, a->org, "{}"); + resp = client_rpc(&a->conn, "org.get", a->session, a->org, "{}"); if (resp && client_ok(resp)) { char *name = jstr_dup(resp, "result.name"); if (name) { @@ -908,7 +908,7 @@ static void app_refresh_context(struct app *a) } free(resp); - resp = client_rpc(a->fd, "org.list", a->session, 0, "{}"); + resp = client_rpc(&a->conn, "org.list", a->session, 0, "{}"); if (resp) { size_t n = jarr_size(resp, "result.items"); for (size_t i = 0; i < n; i++) { @@ -928,7 +928,7 @@ static void app_refresh_context(struct app *a) snprintf(a->default_series, sizeof a->default_series, "%s", "A"); a->attachment_dir[0] = '\0'; - resp = client_rpc(a->fd, "settings.get", a->session, a->org, "{}"); + resp = client_rpc(&a->conn, "settings.get", a->session, a->org, "{}"); if (resp && client_ok(resp)) { char *ser = jstr_dup(resp, "result.default_series"); if (ser && *ser) @@ -942,7 +942,7 @@ static void app_refresh_context(struct app *a) free(resp); a->max_attachment_bytes = 10 * 1024 * 1024; - resp = client_rpc(a->fd, "meta", NULL, 0, NULL); + resp = client_rpc(&a->conn, "meta", NULL, 0, NULL); if (resp && client_ok(resp)) { int64_t v = jint_val(resp, "result.limits.max_attachment_bytes", 0); @@ -951,7 +951,7 @@ static void app_refresh_context(struct app *a) } free(resp); - resp = client_rpc(a->fd, "fiscal_year.get", a->session, a->org, "{}"); + resp = client_rpc(&a->conn, "fiscal_year.get", a->session, a->org, "{}"); if (resp && client_ok(resp)) { a->fy = jint_val(resp, "result.id", 0); char *label = jstr_dup(resp, "result.label"); @@ -983,7 +983,7 @@ static int64_t fy_new_form(struct app *a) { char label[32] = "", start[16] = "", end[16] = ""; char *resp = - client_rpc(a->fd, "fiscal_year.list", a->session, a->org, "{}"); + client_rpc(&a->conn, "fiscal_year.list", a->session, a->org, "{}"); if (resp && client_ok(resp)) { size_t n = jarr_size(resp, "result.items"); char max_end[16] = ""; @@ -1098,7 +1098,7 @@ static int64_t fy_new_form(struct app *a) yyjson_mut_obj_add_strcpy(d, o, "end_date", end); char *args = yyjson_mut_write(d, 0, NULL); yyjson_mut_doc_free(d); - resp = client_rpc(a->fd, "fiscal_year.open", a->session, a->org, + resp = client_rpc(&a->conn, "fiscal_year.open", a->session, a->org, args); free(args); if (resp && client_ok(resp)) { @@ -1131,7 +1131,7 @@ static void select_fiscal_year(struct app *a) if (g_quit) return; char *resp = - client_rpc(a->fd, "fiscal_year.list", a->session, a->org, "{}"); + client_rpc(&a->conn, "fiscal_year.list", a->session, a->org, "{}"); if (!resp || !client_ok(resp)) { show_error("Räkenskapsår", resp); free(resp); @@ -1375,7 +1375,7 @@ static int voucher_detail(struct app *a, int64_t id, int64_t *out_new) snprintf(args, sizeof args, "{\"id\":%lld}", (long long)id); for (;;) { char *resp = - client_rpc(a->fd, "voucher.get", a->session, a->org, args); + client_rpc(&a->conn, "voucher.get", a->session, a->org, args); if (!resp || !client_ok(resp)) { show_error("Verifikat", resp); free(resp); @@ -1494,7 +1494,7 @@ static int voucher_detail(struct app *a, int64_t id, int64_t *out_new) yyjson_mut_obj_add_strcpy(d, o, "client_ref", ref); char *cargs = yyjson_mut_write(d, 0, NULL); yyjson_mut_doc_free(d); - char *r = client_rpc(a->fd, "voucher.correct", a->session, + char *r = client_rpc(&a->conn, "voucher.correct", a->session, a->org, cargs); if (r && client_ok(r)) { int64_t new_id = jint_val(r, "result.id", 0); @@ -1612,7 +1612,7 @@ static void acct_cache_load(struct app *a) if (g_accts.loaded && g_accts.org == a->org) return; acct_cache_free(); - char *resp = client_rpc(a->fd, "account.list", a->session, a->org, + char *resp = client_rpc(&a->conn, "account.list", a->session, a->org, "{\"active_only\":true}"); if (!resp || !client_ok(resp)) { free(resp); @@ -1844,7 +1844,7 @@ static int64_t vouchers_new(struct app *a) } if (ch == KEY_F(4)) { char tname[128] = ""; - char *lresp = client_rpc(a->fd, "template.list", a->session, + char *lresp = client_rpc(&a->conn, "template.list", a->session, a->org, "{\"active_only\":true}"); if (!lresp || !client_ok(lresp)) { show_error("Mallar", lresp); @@ -1901,7 +1901,7 @@ static int64_t vouchers_new(struct app *a) yyjson_mut_obj_add_strcpy(d, to, "name", tbuf); char *targs = yyjson_mut_write(d, 0, NULL); yyjson_mut_doc_free(d); - char *resp = client_rpc(a->fd, "template.get", a->session, + char *resp = client_rpc(&a->conn, "template.get", a->session, a->org, targs); free(targs); if (!resp || !client_ok(resp)) { @@ -2030,8 +2030,8 @@ static int64_t vouchers_new(struct app *a) a->session, (long long)a->org, args); free(args); char *r = NULL; - if (client_send_line(a->fd, raw) == 0) - r = client_read_line(a->fd); + if (client_send_line(&a->conn, raw) == 0) + r = client_read_line(&a->conn); free(raw); if (r && client_ok(r)) { att_ids[natt] = jint_val(r, "result.id", 0); @@ -2107,8 +2107,8 @@ static int64_t vouchers_new(struct app *a) "\"session\":\"%s\",\"org\":%lld,\"dry_run\":true," "\"args\":%s}", a->session, (long long)a->org, args); - if (client_send_line(a->fd, raw) == 0) { - char *r = client_read_line(a->fd); + if (client_send_line(&a->conn, raw) == 0) { + char *r = client_read_line(&a->conn); if (r && client_ok(r)) { int64_t num = jint_val(r, "result.number", 0); message("Validering OK", @@ -2122,7 +2122,7 @@ static int64_t vouchers_new(struct app *a) } free(raw); } else { - char *r = client_rpc(a->fd, "voucher.post", a->session, a->org, + char *r = client_rpc(&a->conn, "voucher.post", a->session, a->org, args); if (r && client_ok(r)) { int64_t id = jint_val(r, "result.id", 0); @@ -2221,7 +2221,7 @@ static void vouchers_screen(struct app *a) snprintf(largs, sizeof largs, "{\"limit\":200,\"fiscal_year\":%lld}", (long long)a->fy); - char *resp = client_rpc(a->fd, "voucher.list", a->session, a->org, + char *resp = client_rpc(&a->conn, "voucher.list", a->session, a->org, largs); if (!resp || !client_ok(resp)) { show_error("Verifikat", resp); @@ -2401,7 +2401,7 @@ static const char *vat_display(const char *name, const char *vat_code, static void accounts_report(struct app *a) { for (;;) { - char *resp = client_rpc(a->fd, "account.list", a->session, a->org, + char *resp = client_rpc(&a->conn, "account.list", a->session, a->org, "{\"active_only\":false}"); if (!resp || !client_ok(resp)) { show_error("Kontolista", resp); @@ -2523,7 +2523,7 @@ static void reports_screen(struct app *a) } } for (;;) { - char *resp = client_rpc(a->fd, cmd, a->session, a->org, args); + char *resp = client_rpc(&a->conn, cmd, a->session, a->org, args); if (!resp || !client_ok(resp)) { show_error("Rapport", resp); free(resp); @@ -2550,7 +2550,7 @@ static void inbox_screen(struct app *a) for (;;) { if (g_quit) return; - char *resp = client_rpc(a->fd, "attachment.list", a->session, a->org, + char *resp = client_rpc(&a->conn, "attachment.list", a->session, a->org, "{\"unlinked\":true,\"limit\":200}"); if (!resp || !client_ok(resp)) { show_error("Underlag", resp); @@ -2639,8 +2639,8 @@ static void inbox_screen(struct app *a) a->session, (long long)a->org, args); free(args); char *r = NULL; - if (client_send_line(a->fd, raw) == 0) - r = client_read_line(a->fd); + if (client_send_line(&a->conn, raw) == 0) + r = client_read_line(&a->conn); free(raw); if (r && client_ok(r)) message("Underlag", "Sparat."); @@ -2663,7 +2663,7 @@ static void audit_screen(struct app *a) return; if (sel == 0) { char *resp = - client_rpc(a->fd, "audit.verify", a->session, a->org, "{}"); + client_rpc(&a->conn, "audit.verify", a->session, a->org, "{}"); if (!resp || !client_ok(resp)) { show_error("Revision", resp); free(resp); @@ -2677,7 +2677,7 @@ static void audit_screen(struct app *a) free(resp); } else { for (;;) { - char *resp = client_rpc(a->fd, "audit.list", a->session, + char *resp = client_rpc(&a->conn, "audit.list", a->session, a->org, "{\"limit\":200}"); if (!resp || !client_ok(resp)) { show_error("Revision", resp); @@ -2779,7 +2779,7 @@ static int template_form(struct app *a, const char *load_name) char *args = yyjson_mut_write(d, 0, NULL); yyjson_mut_doc_free(d); char *resp = - client_rpc(a->fd, "template.get", a->session, a->org, args); + client_rpc(&a->conn, "template.get", a->session, a->org, args); free(args); if (!resp || !client_ok(resp)) { show_error("Mall", resp); @@ -3037,7 +3037,7 @@ static int template_form(struct app *a, const char *load_name) char *args = yyjson_mut_write(d, 0, NULL); yyjson_mut_doc_free(d); const char *cmd = tpl_id ? "template.update" : "template.create"; - char *resp = client_rpc(a->fd, cmd, a->session, a->org, args); + char *resp = client_rpc(&a->conn, cmd, a->session, a->org, args); free(args); if (resp && client_ok(resp)) { if (ch == KEY_F(5)) { @@ -3106,7 +3106,7 @@ static void template_archive_ui(struct app *a) char *args = yyjson_mut_write(d, 0, NULL); yyjson_mut_doc_free(d); char *resp = - client_rpc(a->fd, "template.archive", a->session, a->org, args); + client_rpc(&a->conn, "template.archive", a->session, a->org, args); free(args); if (resp && client_ok(resp)) message("Mall", "Mallen '%s' arkiverad.", name); @@ -3170,7 +3170,7 @@ static int ib_load(struct app *a, char ***out_acc, int64_t **out_amt, "\"limit\":200}", (long long)a->fy); char *resp = - client_rpc(a->fd, "voucher.list", a->session, a->org, args); + client_rpc(&a->conn, "voucher.list", a->session, a->org, args); if (!resp || !client_ok(resp)) { show_error("Ingående balans", resp); free(resp); @@ -3188,7 +3188,7 @@ static int ib_load(struct app *a, char ***out_acc, int64_t **out_amt, continue; char vargs[64]; snprintf(vargs, sizeof vargs, "{\"id\":%lld}", (long long)id); - char *v = client_rpc(a->fd, "voucher.get", a->session, a->org, vargs); + char *v = client_rpc(&a->conn, "voucher.get", a->session, a->org, vargs); if (!v || !client_ok(v)) { free(v); continue; @@ -3474,7 +3474,7 @@ static int ib_form(struct app *a, char **old_acc, int64_t *old_amt, int nold) char *args = yyjson_mut_write(d, 0, NULL); yyjson_mut_doc_free(d); char *resp = - client_rpc(a->fd, "voucher.post", a->session, a->org, args); + client_rpc(&a->conn, "voucher.post", a->session, a->org, args); free(args); if (resp && client_ok(resp)) { int64_t num = jint_val(resp, "result.number", 0); @@ -3618,7 +3618,7 @@ static void settings_screen(struct app *a) if (g_quit) return; char *resp = - client_rpc(a->fd, "settings.get", a->session, a->org, "{}"); + client_rpc(&a->conn, "settings.get", a->session, a->org, "{}"); if (!resp || !client_ok(resp)) { show_error("Inställningar", resp); free(resp); @@ -3673,7 +3673,7 @@ static void settings_screen(struct app *a) yyjson_mut_obj_add_strcpy(d, o, "value", val); char *args = yyjson_mut_write(d, 0, NULL); yyjson_mut_doc_free(d); - char *r = client_rpc(a->fd, "settings.set", a->session, + char *r = client_rpc(&a->conn, "settings.set", a->session, a->org, args); free(args); if (r && client_ok(r)) { @@ -3731,7 +3731,7 @@ static void templates_screen(struct app *a) continue; } for (;;) { - char *resp = client_rpc(a->fd, "template.list", a->session, + char *resp = client_rpc(&a->conn, "template.list", a->session, a->org, "{\"active_only\":true}"); if (!resp || !client_ok(resp)) { show_error("Mallar", resp); @@ -3936,22 +3936,26 @@ static int login_screen(struct app *a) /* attempt login */ snprintf(a->socket, sizeof a->socket, "%s", socket_path); - a->fd = client_connect(a->socket); - if (a->fd < 0) { - message("Fel", "Kunde inte ansluta till %s", a->socket); + if (client_connect(a->socket, &a->conn) != 0) { + message("Fel", "Kunde inte ansluta till %s: %s", a->socket, + client_last_error()); continue; } char *err = NULL, *session = NULL; - if (client_login(a->fd, user, pass, &session, &err) != 0) { - char *code = jstr_dup(err, "error.code"); - char *msg = jstr_dup(err, "error.message"); - message("Inloggning misslyckades", "%s: %s", - code ? code : "fel", msg ? msg : "okänt fel"); - free(code); - free(msg); + if (client_login(&a->conn, user, pass, &session, &err) != 0) { + if (err && err[0] == '{') { + char *code = jstr_dup(err, "error.code"); + char *msg = jstr_dup(err, "error.message"); + message("Inloggning misslyckades", "%s: %s", + code ? code : "fel", msg ? msg : "okänt fel"); + free(code); + free(msg); + } else { + message("Inloggning misslyckades", "%s", + err ? err : "transportfel"); + } free(err); - close(a->fd); - a->fd = -1; + client_close(&a->conn); continue; } snprintf(a->session, sizeof a->session, "%s", session); @@ -3963,7 +3967,7 @@ static int login_screen(struct app *a) static int select_org(struct app *a) { - char *resp = client_rpc(a->fd, "session.list_orgs", a->session, 0, "{}"); + char *resp = client_rpc(&a->conn, "session.list_orgs", a->session, 0, "{}"); if (!resp || !client_ok(resp)) { show_error("Organisationer", resp); free(resp); @@ -4010,7 +4014,7 @@ static void usage(FILE *f) { fprintf(f, "usage: bokftui [options]\n" - " --socket TARGET unix socket or tcp:host:port\n" + " --socket TARGET unix path, tcp:host:port or tls:host:port\n" " --user NAME prefill username\n" " --org ID select org directly\n" " --version\n"); @@ -4020,7 +4024,7 @@ int main(int argc, char **argv) { struct app app; memset(&app, 0, sizeof app); - app.fd = -1; + app.conn.fd = -1; const char *socket = getenv("BOKFD_SOCKET"); if (!socket) socket = "/run/bokfd/bokfd.sock"; @@ -4068,9 +4072,9 @@ int main(int argc, char **argv) update_status(&app); dashboard(&app); - client_rpc(app.fd, "session.close", app.session, 0, "{}"); + client_rpc(&app.conn, "session.close", app.session, 0, "{}"); acct_cache_free(); - close(app.fd); + client_close(&app.conn); endwin(); return 0; } diff --git a/clients/client.c b/clients/client.c index ea5b906..8fb604b 100644 --- a/clients/client.c +++ b/clients/client.c @@ -1,7 +1,11 @@ #include "client.h" +#include #include #include +#include +#include +#include #include #include #include @@ -12,6 +16,28 @@ #include "util.h" #include "yyjson.h" +static char g_last_error[256]; + +const char *client_last_error(void) +{ + return g_last_error[0] ? g_last_error : "connection error"; +} + +static void set_error(const char *msg) +{ + snprintf(g_last_error, sizeof g_last_error, "%s", msg); +} + +static void set_tls_error(const char *what) +{ + unsigned long e = ERR_get_error(); + char buf[200] = "unknown TLS error"; + if (e) + ERR_error_string_n(e, buf, sizeof buf); + snprintf(g_last_error, sizeof g_last_error, "%s: %s", what, buf); + ERR_clear_error(); +} + static ssize_t write_all(int fd, const char *buf, size_t len) { size_t off = 0; @@ -27,63 +53,34 @@ static ssize_t write_all(int fd, const char *buf, size_t len) return (ssize_t)off; } -int client_send_line(int fd, const char *line) +static void split_addrport(const char *addrport, char *host, size_t host_sz, + char *port, size_t port_sz, const char *def_port) { - if (write_all(fd, line, strlen(line)) < 0) - return -1; - return write_all(fd, "\n", 1) < 0 ? -1 : 0; -} - -char *client_read_line(int fd) -{ - struct buf b; - buf_init(&b); - char chunk[4096]; - for (;;) { - ssize_t r = read(fd, chunk, sizeof chunk); - if (r < 0) { - if (errno == EINTR) - continue; - buf_free(&b); - return NULL; - } - if (r == 0) - break; - unsigned char *nl = memchr(chunk, '\n', (size_t)r); - if (nl) { - buf_append(&b, chunk, (size_t)(nl - (unsigned char *)chunk)); - break; - } - buf_append(&b, chunk, (size_t)r); - } - char *out = xmalloc(b.len + 1); - memcpy(out, b.p ? (char *)b.p : "", b.len); - out[b.len] = '\0'; - buf_free(&b); - return out; -} - -static int tcp_connect_addr(const char *addrport) -{ - char host[256] = "127.0.0.1"; - char port[16] = "8787"; + snprintf(host, host_sz, "127.0.0.1"); + snprintf(port, port_sz, "%s", def_port); const char *colon = strrchr(addrport, ':'); if (colon) { size_t hl = (size_t)(colon - addrport); - if (hl < sizeof host) { + if (hl < host_sz) { memcpy(host, addrport, hl); host[hl] = '\0'; } - snprintf(port, sizeof port, "%s", colon + 1); - } else { - snprintf(port, sizeof port, "%s", addrport); + snprintf(port, port_sz, "%s", colon + 1); + } else if (*addrport) { + snprintf(port, port_sz, "%s", addrport); } +} + +static int connect_tcp(const char *host, const char *port) +{ struct addrinfo hints, *res = NULL; memset(&hints, 0, sizeof hints); hints.ai_family = AF_UNSPEC; hints.ai_socktype = SOCK_STREAM; - if (getaddrinfo(host, port, &hints, &res) != 0) + if (getaddrinfo(host, port, &hints, &res) != 0) { + set_error("cannot resolve host"); return -1; + } int fd = -1; for (struct addrinfo *ai = res; ai; ai = ai->ai_next) { fd = socket(ai->ai_family, ai->ai_socktype, ai->ai_protocol); @@ -94,30 +91,217 @@ static int tcp_connect_addr(const char *addrport) close(fd); fd = -1; } + if (fd < 0) + set_error(strerror(errno)); freeaddrinfo(res); return fd; } -int client_connect(const char *target) +static int set_verify_host(SSL *ssl, const char *host) +{ + X509_VERIFY_PARAM *param = SSL_get0_param(ssl); + struct in_addr in4; + struct in6_addr in6; + X509_VERIFY_PARAM_set_hostflags(param, + X509_CHECK_FLAG_NO_PARTIAL_WILDCARDS); + if (inet_pton(AF_INET, host, &in4) == 1 || + inet_pton(AF_INET6, host, &in6) == 1) + return X509_VERIFY_PARAM_set1_ip_asc(param, host); + return X509_VERIFY_PARAM_set1_host(param, host, 0); +} + +static int tls_connect_addr(const char *addrport, struct client_conn *out) { - if (strncmp(target, "tcp:", 4) == 0) - return tcp_connect_addr(target + 4); + char host[256], port[16]; + split_addrport(addrport, host, sizeof host, port, sizeof port, "8788"); + int fd = connect_tcp(host, port); + if (fd < 0) + return -1; + + SSL_CTX *ctx = SSL_CTX_new(TLS_client_method()); + if (!ctx) { + set_tls_error("TLS context"); + close(fd); + return -1; + } + SSL_CTX_set_min_proto_version(ctx, TLS1_2_VERSION); + SSL_CTX_set_verify(ctx, SSL_VERIFY_PEER, NULL); + SSL_CTX_set_default_verify_paths(ctx); + const char *ca = getenv("BOKFD_TLS_CA"); + if (ca && *ca && SSL_CTX_load_verify_locations(ctx, ca, NULL) != 1) { + set_tls_error("TLS CA file"); + SSL_CTX_free(ctx); + close(fd); + return -1; + } + SSL *ssl = SSL_new(ctx); + if (!ssl) { + set_tls_error("TLS connection"); + SSL_CTX_free(ctx); + close(fd); + return -1; + } + SSL_set_fd(ssl, fd); + SSL_set_tlsext_host_name(ssl, host); + if (set_verify_host(ssl, host) != 1) { + set_error("invalid TLS host name"); + SSL_free(ssl); + SSL_CTX_free(ctx); + close(fd); + return -1; + } + if (SSL_connect(ssl) != 1) { + long vr = SSL_get_verify_result(ssl); + if (vr != X509_V_OK) + snprintf(g_last_error, sizeof g_last_error, + "certificate verification failed: %s", + X509_verify_cert_error_string(vr)); + else + set_tls_error("TLS handshake"); + SSL_free(ssl); + SSL_CTX_free(ctx); + close(fd); + return -1; + } + out->fd = fd; + out->ssl = ssl; + out->ctx = ctx; + return 0; +} + +int client_connect(const char *target, struct client_conn *out) +{ + memset(out, 0, sizeof *out); + out->fd = -1; + g_last_error[0] = '\0'; + + if (strncmp(target, "tls:", 4) == 0) + return tls_connect_addr(target + 4, out); + if (strncmp(target, "tcp:", 4) == 0) { + char host[256], port[16]; + split_addrport(target + 4, host, sizeof host, port, sizeof port, + "8787"); + out->fd = connect_tcp(host, port); + return out->fd < 0 ? -1 : 0; + } + struct sockaddr_un sa; memset(&sa, 0, sizeof sa); sa.sun_family = AF_UNIX; if (strlen(target) >= sizeof sa.sun_path) { + set_error("socket path too long"); errno = ENAMETOOLONG; return -1; } snprintf(sa.sun_path, sizeof sa.sun_path, "%s", target); int fd = socket(AF_UNIX, SOCK_STREAM, 0); - if (fd < 0) + if (fd < 0) { + set_error(strerror(errno)); return -1; + } if (connect(fd, (struct sockaddr *)&sa, sizeof sa) != 0) { + set_error(strerror(errno)); close(fd); return -1; } - return fd; + out->fd = fd; + return 0; +} + +void client_close(struct client_conn *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; + } +} + +int client_send_line(struct client_conn *c, const char *line) +{ + size_t len = strlen(line); + if (c->ssl) { + size_t off = 0; + while (off < len) { + int n = SSL_write(c->ssl, line + off, (int)(len - off)); + if (n <= 0) { + int e = SSL_get_error(c->ssl, n); + if (e == SSL_ERROR_WANT_READ || e == SSL_ERROR_WANT_WRITE) + set_error("TLS write would block"); + else + set_tls_error("TLS write"); + return -1; + } + off += (size_t)n; + } + int n = SSL_write(c->ssl, "\n", 1); + if (n <= 0) { + set_tls_error("TLS write"); + return -1; + } + return 0; + } + if (write_all(c->fd, line, len) < 0) + return -1; + return write_all(c->fd, "\n", 1) < 0 ? -1 : 0; +} + +char *client_read_line(struct client_conn *c) +{ + struct buf b; + buf_init(&b); + char chunk[4096]; + for (;;) { + ssize_t r; + if (c->ssl) { + int n = SSL_read(c->ssl, chunk, sizeof chunk); + if (n > 0) { + r = n; + } else { + int e = SSL_get_error(c->ssl, n); + if (e == SSL_ERROR_ZERO_RETURN) { + r = 0; + } else { + if (e == SSL_ERROR_WANT_READ || e == SSL_ERROR_WANT_WRITE) + set_error("TLS read would block"); + else + set_tls_error("TLS read"); + buf_free(&b); + return NULL; + } + } + } else { + r = read(c->fd, chunk, sizeof chunk); + if (r < 0) { + if (errno == EINTR) + continue; + set_error(strerror(errno)); + buf_free(&b); + return NULL; + } + } + if (r == 0) + break; + unsigned char *nl = memchr(chunk, '\n', (size_t)r); + if (nl) { + buf_append(&b, chunk, (size_t)(nl - (unsigned char *)chunk)); + break; + } + buf_append(&b, chunk, (size_t)r); + } + char *out = xmalloc(b.len + 1); + memcpy(out, b.p ? (char *)b.p : "", b.len); + out[b.len] = '\0'; + buf_free(&b); + return out; } char *client_make_request(const char *cmd, const char *session, int64_t org, @@ -164,20 +348,20 @@ char *client_make_login_args(const char *user, const char *password) return s; } -char *client_rpc(int fd, const char *cmd, const char *session, int64_t org, - const char *args_json) +char *client_rpc(struct client_conn *c, const char *cmd, const char *session, + int64_t org, const char *args_json) { char *req = client_make_request(cmd, session, org, args_json, "rpc"); if (!req) return NULL; - int rc = client_send_line(fd, req); + int rc = client_send_line(c, req); free(req); if (rc != 0) return NULL; - return client_read_line(fd); + return client_read_line(c); } -int client_login(int fd, const char *user, const char *password, +int client_login(struct client_conn *c, const char *user, const char *password, char **session_out, char **err_out) { *session_out = NULL; @@ -187,10 +371,10 @@ int client_login(int fd, const char *user, const char *password, *err_out = xstrdup("could not build login request"); return -1; } - char *resp = client_rpc(fd, "session.open", NULL, 0, args); + char *resp = client_rpc(c, "session.open", NULL, 0, args); free(args); if (!resp) { - *err_out = xstrdup(strerror(errno)); + *err_out = xstrdup(client_last_error()); return -1; } if (!client_ok(resp)) { diff --git a/clients/client.h b/clients/client.h index 7e18a23..9372e72 100644 --- a/clients/client.h +++ b/clients/client.h @@ -4,11 +4,23 @@ #include /* Thin protocol client shared by bokfctl and bokftui. Connects to a unix - socket path or "tcp:host:port". */ + socket path, "tcp:host:port" (plaintext) or "tls:host:port". The TLS + client verifies the certificate chain and host name; BOKFD_TLS_CA adds a + PEM file to the trust store (for private CAs and tests). */ -int client_connect(const char *target); -int client_send_line(int fd, const char *line); -char *client_read_line(int fd); +struct client_conn { + int fd; + void *ssl; + void *ctx; +}; + +int client_connect(const char *target, struct client_conn *out); +void client_close(struct client_conn *c); +int client_send_line(struct client_conn *c, const char *line); +char *client_read_line(struct client_conn *c); + +/* Human-readable reason for the last failed call. */ +const char *client_last_error(void); char *client_make_request(const char *cmd, const char *session, int64_t org, const char *args_json, const char *id); @@ -16,12 +28,12 @@ char *client_make_login_args(const char *user, const char *password); /* Sends one command and returns the raw response line (malloc'd), or NULL on a transport error. */ -char *client_rpc(int fd, const char *cmd, const char *session, int64_t org, - const char *args_json); +char *client_rpc(struct client_conn *c, const char *cmd, const char *session, + int64_t org, const char *args_json); /* Password login. Returns 0 and sets *session_out on success; on failure returns -1 and sets *err_out to the response line or an error message. */ -int client_login(int fd, const char *user, const char *password, +int client_login(struct client_conn *c, const char *user, const char *password, char **session_out, char **err_out); /* Convenience: true when the response line has "ok":true. */ diff --git a/compose.yaml b/compose.yaml index d1739fd..5f824c0 100644 --- a/compose.yaml +++ b/compose.yaml @@ -6,6 +6,8 @@ services: build: context: . restart: unless-stopped + ports: + - "8788:8788" volumes: - ./var/db:/var/lib/bokfd - ./var/run:/run/bokfd @@ -15,3 +17,30 @@ services: BOKFD_BACKUP_DIR: /var/lib/bokfd/backup BOKFD_EXPORT_DIR: /var/lib/bokfd/export BOKFD_LOG_LEVEL: info + BOKFD_TLS: 0.0.0.0:8788 + BOKFD_TLS_CERT: /var/lib/bokfd/certs/certificates/${LEGO_DOMAIN:-bokf.makandra.eu}.crt + BOKFD_TLS_KEY: /var/lib/bokfd/certs/certificates/${LEGO_DOMAIN:-bokf.makandra.eu}.key + + certs: + image: goacme/lego:latest + restart: unless-stopped + entrypoint: ["/bin/sh", "-c"] + command: + - | + while :; do + if [ -n "$INWX_USERNAME" ] && [ -n "$INWX_PASSWORD" ]; then + /lego --path=/certs --email="$LEGO_EMAIL" --dns=inwx \ + --domains="$LEGO_DOMAIN" --accept-tos run --days 30 || true + chown -R 10001:10001 /certs || true + else + echo "certs: set INWX_USERNAME and INWX_PASSWORD in .env" + fi + sleep 12h + done + environment: + LEGO_EMAIL: ${LEGO_EMAIL:-anders@makandra.eu} + LEGO_DOMAIN: ${LEGO_DOMAIN:-bokf.makandra.eu} + INWX_USERNAME: ${INWX_USERNAME:-} + INWX_PASSWORD: ${INWX_PASSWORD:-} + volumes: + - ./var/db/certs:/certs diff --git a/deploy/docker-entrypoint.sh b/deploy/docker-entrypoint.sh index 8eacc01..3cab4bd 100755 --- a/deploy/docker-entrypoint.sh +++ b/deploy/docker-entrypoint.sh @@ -7,7 +7,8 @@ set -eu : "${BOKFD_EXPORT_DIR:=/var/lib/bokfd/export}" export BOKFD_DB BOKFD_SOCKET BOKFD_BACKUP_DIR BOKFD_EXPORT_DIR -mkdir -p "$BOKFD_BACKUP_DIR" "$BOKFD_EXPORT_DIR" "$(dirname "$BOKFD_SOCKET")" +mkdir -p "$BOKFD_BACKUP_DIR" "$BOKFD_EXPORT_DIR" "$(dirname "$BOKFD_SOCKET")" \ + "$(dirname "$BOKFD_DB")/certs" chown -R bokfd:bokfd /var/lib/bokfd "$(dirname "$BOKFD_SOCKET")" case "${1:-}" in diff --git a/docs/DEPLOY.md b/docs/DEPLOY.md index 5def5df..8d36453 100644 --- a/docs/DEPLOY.md +++ b/docs/DEPLOY.md @@ -15,9 +15,13 @@ State lives in two bind mounts next to `compose.yaml`: | Host path | Container | Contents | |---|---|---| -| `var/db` | `/var/lib/bokfd` | SQLite database, `backup/`, `export/` | +| `var/db` | `/var/lib/bokfd` | SQLite database, `backup/`, `export/`, `certs/` | | `var/run` | `/run/bokfd` | Unix socket (mode 0660, owned by uid 10001) | +The `bokfd` service publishes port 8788 for the TLS listener; the `certs` +sidecar renews the certificate with lego via INWX DNS-01. Plain TCP stays +off (loopback-only if enabled); the Unix socket is for clients on the host. + The database is a single SQLite file. Back up with `backup.snapshot` (`VACUUM INTO`) and point restic at `var/db/backup` — never at the live file. @@ -112,6 +116,47 @@ The clients honor `BOKFD_SOCKET`; a host-installed client can also point at `var/run/bokfd.sock`, but that file is owned by uid 10001, so the host user must be in that group (or use `sudo`). +## TLS and external users + +Put the ACME DNS credentials in the host's `.env` once (they are never +committed): + +```sh +LEGO_DOMAIN=bokf.makandra.eu +LEGO_EMAIL=anders@makandra.eu +INWX_USERNAME=... +INWX_PASSWORD=... +``` + +Forward port 8788 on the router to the host. The `certs` sidecar obtains and +renews the certificate into `var/db/certs/certificates/`; `bokfd` reloads it +in place. On the very first `up`, `bokfd` may restart a few times until the +certificate exists — check `docker compose logs certs`. + +Clients outside the LAN connect with the DNS name: + +```sh +export BOKFD_SOCKET=tls:bokf.makandra.eu:8788 +bokftui +``` + +No CA override is needed with a public ACME certificate (the system trust +store suffices); `BOKFD_TLS_CA=/path/ca.pem` exists for private CAs. + +To invite someone, create an account and give it only the role and org it +needs — no VPN, no SSH: + +```sh +docker compose exec -e BOKFD_PASSWORD='' bokfd \ + bokfctl --user admin user.create '{"username":"revisor", ...}' +docker compose exec -e BOKFD_PASSWORD='' bokfd \ + bokfctl --user admin org.member_add '{"org":1,"username":"revisor","role":"viewer"}' +``` + +Agents use `token.create` instead of a password; tokens are scoped and +revocable. Only the TLS port is forwarded, and every command still requires +authentication (`meta` and `health` excepted). + ## Mock company ```sh diff --git a/docs/PROTOCOL.md b/docs/PROTOCOL.md index 6dc8c7b..e9c4331 100644 --- a/docs/PROTOCOL.md +++ b/docs/PROTOCOL.md @@ -41,15 +41,20 @@ by the daemon (`backup.snapshot`), not by clients. first line of defense. - Intended for `bokftui`, `bokfctl` and agents running on the same host. -### 3.2 TCP (optional) - -- Disabled by default. When enabled it binds `127.0.0.1` unless explicitly - configured otherwise. -- TLS is **not implemented in v1**. The intended deployments are: - - loopback + SSH tunnel (`ssh -L`), or - - a private overlay network (Tailscale/WireGuard), or - - a reverse proxy that terminates TLS in front of `bokfd`. -- Every command on TCP requires authentication, including read commands. +### 3.2 TCP and TLS (optional) + +- Plain TCP is disabled by default. When enabled it binds `127.0.0.1` unless + explicitly configured otherwise. It is intended for loopback, an SSH + tunnel (`ssh -L`) or a private overlay network (Tailscale/WireGuard). +- A separate TLS listener (`tls`, e.g. `0.0.0.0:8788`) serves exactly the + same protocol over TLS 1.2+ using a PEM certificate chain and key + (`tls_cert`, `tls_key`). The daemon reloads the certificate when the files + change, so an ACME renewer can replace them without a restart. +- Clients select the transport with `BOKFD_SOCKET`/`--socket`: a Unix socket + path, `tcp:host:port` or `tls:host:port`. The TLS client verifies the + certificate chain and host name against the system trust store; + `BOKFD_TLS_CA` adds a PEM file for private CAs. +- Every TCP/TLS command requires authentication, including read commands. `meta` and `health` are the only unauthenticated commands. ### 3.3 Framing @@ -434,7 +439,9 @@ beyond the session and calls nothing but public commands. ## 10. Security notes - Bind nothing publicly by default. Loopback or Unix socket unless the operator - opts in. + opts in; use the TLS listener when clients connect from outside the LAN and + forward only that port. Each person or agent gets their own account or + token, never VPN access to the host network. - Passwords: Argon2id (vendored reference implementation). Tokens: 256-bit random, stored hashed, revocable, never logged. Sessions: memory only. - Audit and logs redact secrets: `session.open` records username and outcome, @@ -453,7 +460,10 @@ beyond the session and calls nothing but public commands. | Key | Default | Meaning | |---|---|---| | `socket` | `/run/bokfd/bokfd.sock` | Unix socket path | -| `tcp` | off | `host:port` to enable TCP | +| `tcp` | off | `host:port` to enable plain TCP | +| `tls` | off | `host:port` to enable the TLS listener | +| `tls_cert` | `/var/lib/bokfd/certs/fullchain.pem` | PEM certificate chain | +| `tls_key` | `/var/lib/bokfd/certs/privkey.pem` | PEM private key | | `db` | `/var/lib/bokfd/bokfd.db` | SQLite database | | `backup_dir` | `/var/lib/bokfd/backup` | destination for `backup.snapshot` | | `export_dir` | `/var/lib/bokfd/export` | SIE exports | diff --git a/docs/STATE.md b/docs/STATE.md index d9edcb0..f4a563a 100644 --- a/docs/STATE.md +++ b/docs/STATE.md @@ -20,7 +20,8 @@ server/protocol/ledger only. tenant isolation, append-only triggers, `VACUUM INTO` snapshots. Postgres deliberately rejected for now; keep DB access behind one layer for a later port. -4. **Protocol**: NDJSON over Unix socket (+ optional token TCP), protocol v1. +4. **Protocol**: NDJSON over Unix socket (+ optional plain TCP and a native + TLS listener), protocol v1. `dry_run` on every mutation, `client_ref` idempotency, stable error codes, `describe` + `agent.instructions`, money in integer öre. 5. **Auth**: multi-org; memberships owner/bookkeeper/viewer; API tokens bound @@ -55,6 +56,14 @@ server/protocol/ledger only. on the dashboard. `--org ID` bypasses the picker. 13. **Settings**: `settings.get`/`settings.set`; keys `default_series`, `attachment_dir`. +14. **Transport**: Unix socket for host clients; plain TCP loopback-only; + native TLS listener (`BOKFD_TLS`, `BOKFD_TLS_CERT/KEY`, OpenSSL, TLS 1.2+, + cert reload on file change) with client targets `tls:host:port` and + system-trust verification (`BOKFD_TLS_CA` for private CAs). Certificates + come from a lego sidecar using INWX DNS-01 (`compose.yaml`). Externals + get accounts/roles/tokens, never VPN access. `scripts/deploy.sh` builds + locally and ships over SSH, or builds on the host when architectures + differ. ## Pending decisions @@ -79,7 +88,10 @@ server/protocol/ledger only. implemented). 6. `describe` argument schemas (currently name/summary/permission only). 7. Pre-migration `VACUUM INTO` snapshot (promised in SCHEMA.md, not built). -8. Docker image + compose (multi-arch amd64/arm64, GHCR) and systemd unit. +8. ~~Docker image + compose (multi-arch amd64/arm64, GHCR) and systemd unit.~~ + Done as a Dockerfile + `compose.yaml` (amd64/arm64 build stage) and + `scripts/deploy.sh` over SSH; no registry and no systemd unit (the + container is the unit). 9. Password change, user disable, TOTP. 10. Bank import/reconciliation (CSV first, then PSD2), invoicing/reskontra, AGI/payroll if employees. diff --git a/src/bokfd.c b/src/bokfd.c index 70b654a..944e910 100644 --- a/src/bokfd.c +++ b/src/bokfd.c @@ -1,6 +1,8 @@ #include #include #include +#include +#include #include #include #include @@ -10,6 +12,7 @@ #include #include #include +#include #include #include "auth.h" @@ -26,6 +29,8 @@ struct conn { int fd; + SSL *ssl; + int handshaking; struct buf in; struct buf out; size_t out_sent; @@ -34,6 +39,10 @@ struct conn { static volatile sig_atomic_t g_stop = 0; static size_t g_line_limit = 1024 * 1024; +static SSL_CTX *g_tls_ctx = NULL; +static time_t g_tls_cert_mtime = 0; +static time_t g_tls_key_mtime = 0; +static time_t g_tls_next_try = 0; static void on_signal(int sig) { @@ -208,7 +217,92 @@ static int tcp_listen(const char *addrport) return fd; } -static void accept_conns(int lfd, struct conn *conns, size_t *nconns) +static void log_tls_error(const char *what) +{ + unsigned long e = ERR_get_error(); + char buf[256] = "unknown error"; + if (e) + ERR_error_string_n(e, buf, sizeof buf); + log_error("%s: %s", what, buf); + ERR_clear_error(); +} + +static int tls_load_certs(void) +{ + SSL_CTX *ctx = SSL_CTX_new(TLS_server_method()); + if (!ctx) { + log_tls_error("TLS context"); + return -1; + } + SSL_CTX_set_min_proto_version(ctx, TLS1_2_VERSION); + SSL_CTX_set_options(ctx, SSL_OP_NO_COMPRESSION | + SSL_OP_CIPHER_SERVER_PREFERENCE | + SSL_OP_NO_RENEGOTIATION); + if (SSL_CTX_use_certificate_chain_file(ctx, g_cfg.tls_cert) != 1) { + log_tls_error(g_cfg.tls_cert); + SSL_CTX_free(ctx); + return -1; + } + if (SSL_CTX_use_PrivateKey_file(ctx, g_cfg.tls_key, SSL_FILETYPE_PEM) != + 1) { + log_tls_error(g_cfg.tls_key); + SSL_CTX_free(ctx); + return -1; + } + if (SSL_CTX_check_private_key(ctx) != 1) { + log_error("TLS key does not match certificate %s", g_cfg.tls_cert); + SSL_CTX_free(ctx); + return -1; + } + SSL_CTX *old = g_tls_ctx; + g_tls_ctx = ctx; + if (old) + SSL_CTX_free(old); + struct stat st; + if (stat(g_cfg.tls_cert, &st) == 0) + g_tls_cert_mtime = st.st_mtime; + if (stat(g_cfg.tls_key, &st) == 0) + g_tls_key_mtime = st.st_mtime; + return 0; +} + +static void tls_reload_check(void) +{ + struct stat st; + time_t cert = 0, key = 0; + if (stat(g_cfg.tls_cert, &st) == 0) + cert = st.st_mtime; + if (stat(g_cfg.tls_key, &st) == 0) + key = st.st_mtime; + if (cert == 0 || key == 0 || + (cert == g_tls_cert_mtime && key == g_tls_key_mtime)) + return; + time_t now = time(NULL); + if (now < g_tls_next_try) + return; + g_tls_next_try = now + 60; + if (tls_load_certs() == 0) + log_info("TLS certificate reloaded"); + else + log_error("TLS reload failed; keeping previous certificate"); +} + +static void conn_handshake(struct conn *c) +{ + int r = SSL_accept(c->ssl); + if (r == 1) { + c->handshaking = 0; + return; + } + int e = SSL_get_error(c->ssl, r); + if (e == SSL_ERROR_WANT_READ || e == SSL_ERROR_WANT_WRITE) + return; + log_warn("TLS handshake failed from fd %d", c->fd); + ERR_clear_error(); + c->closing = 1; +} + +static void accept_conns(int lfd, struct conn *conns, size_t *nconns, int tls) { for (;;) { int fd = accept4(lfd, NULL, NULL, SOCK_NONBLOCK | SOCK_CLOEXEC); @@ -223,22 +317,54 @@ static void accept_conns(int lfd, struct conn *conns, size_t *nconns) c->fd = fd; buf_init(&c->in); buf_init(&c->out); + if (tls) { + c->ssl = SSL_new(g_tls_ctx); + if (!c->ssl) { + log_tls_error("TLS connection"); + close(fd); + buf_free(&c->in); + buf_free(&c->out); + (*nconns)--; + continue; + } + SSL_set_fd(c->ssl, fd); + SSL_set_accept_state(c->ssl); + c->handshaking = 1; + } } } static void conn_read(struct conn *c, sqlite3 *db) { - char tmp[READ_CHUNK]; - ssize_t r = read(c->fd, tmp, sizeof tmp); - if (r == 0) { - c->closing = 1; - return; + if (c->ssl && c->handshaking) { + conn_handshake(c); + if (c->closing || c->handshaking) + return; } - if (r < 0) { - if (errno == EAGAIN || errno == EWOULDBLOCK || errno == EINTR) + char tmp[READ_CHUNK]; + ssize_t r; + if (c->ssl) { + r = SSL_read(c->ssl, tmp, sizeof tmp); + if (r <= 0) { + int e = SSL_get_error(c->ssl, (int)r); + if (e == SSL_ERROR_WANT_READ || e == SSL_ERROR_WANT_WRITE) + return; + ERR_clear_error(); + c->closing = 1; return; - c->closing = 1; - return; + } + } else { + r = read(c->fd, tmp, sizeof tmp); + if (r == 0) { + c->closing = 1; + return; + } + if (r < 0) { + if (errno == EAGAIN || errno == EWOULDBLOCK || errno == EINTR) + return; + c->closing = 1; + return; + } } buf_append(&c->in, tmp, (size_t)r); for (;;) { @@ -262,18 +388,39 @@ static void conn_read(struct conn *c, sqlite3 *db) static void conn_flush(struct conn *c) { + if (c->ssl && c->handshaking) { + conn_handshake(c); + if (c->closing || c->handshaking) + return; + } while (c->out_sent < c->out.len) { - ssize_t w = write(c->fd, c->out.p + c->out_sent, c->out.len - c->out_sent); - if (w > 0) { - c->out_sent += (size_t)w; - continue; - } - if (w < 0 && (errno == EAGAIN || errno == EWOULDBLOCK)) + ssize_t w; + if (c->ssl) { + w = SSL_write(c->ssl, c->out.p + c->out_sent, + (int)(c->out.len - c->out_sent)); + if (w <= 0) { + int e = SSL_get_error(c->ssl, (int)w); + if (e == SSL_ERROR_WANT_READ || e == SSL_ERROR_WANT_WRITE) + return; + ERR_clear_error(); + c->closing = 1; + return; + } + } else { + w = write(c->fd, c->out.p + c->out_sent, + c->out.len - c->out_sent); + if (w > 0) { + c->out_sent += (size_t)w; + continue; + } + if (w < 0 && (errno == EAGAIN || errno == EWOULDBLOCK)) + return; + if (w < 0 && errno == EINTR) + continue; + c->closing = 1; return; - if (w < 0 && errno == EINTR) - continue; - c->closing = 1; - return; + } + c->out_sent += (size_t)w; } c->out.len = 0; c->out_sent = 0; @@ -292,7 +439,10 @@ static void usage(FILE *f) " --socket PATH unix socket (default /run/bokfd/bokfd.sock)\n" " --backup-dir DIR snapshot destination\n" " --export-dir DIR SIE export destination\n" - " --log-level L error|warn|info|debug\n"); + " --log-level L error|warn|info|debug\n" + "\n" + "config/env: tls (host:port, BOKFD_TLS), tls_cert (BOKFD_TLS_CERT),\n" + " tls_key (BOKFD_TLS_KEY); TLS is off unless tls is set\n"); } static const char *opt_value(const char *arg, const char *name, int *i, @@ -404,6 +554,32 @@ int main(int argc, char **argv) g_cfg.tcp_addr ? g_cfg.tcp_addr : "127.0.0.1:8787"); } + int tlsfd = -1; + if (g_cfg.tls_enabled) { + if (tls_load_certs() != 0) { + log_error("cannot load TLS certificate %s / key %s", + g_cfg.tls_cert, g_cfg.tls_key); + close(lfd); + if (tfd >= 0) + close(tfd); + sqlite3_close(db); + config_free(); + return 1; + } + tlsfd = tcp_listen(g_cfg.tls_addr ? g_cfg.tls_addr : "127.0.0.1:8788"); + if (tlsfd < 0) { + log_error("cannot listen on tls %s", + g_cfg.tls_addr ? g_cfg.tls_addr : "127.0.0.1:8788"); + close(lfd); + if (tfd >= 0) + close(tfd); + sqlite3_close(db); + config_free(); + return 1; + } + log_info("bokfd listening on tls %s", g_cfg.tls_addr); + } + signal(SIGINT, on_signal); signal(SIGTERM, on_signal); signal(SIGPIPE, SIG_IGN); @@ -418,29 +594,40 @@ int main(int argc, char **argv) struct conn conns[MAX_CONNS]; size_t nconns = 0; - struct pollfd pfds[MAX_CONNS + 2]; - struct conn *map[MAX_CONNS + 2]; + struct pollfd pfds[MAX_CONNS + 3]; + struct conn *map[MAX_CONNS + 3]; + int lfds[3]; + int ltls[3]; while (!g_stop) { int n = 0; int nlisteners = 0; - pfds[n].fd = lfd; - pfds[n].events = POLLIN; - map[n] = NULL; - n++; + lfds[nlisteners] = lfd; + ltls[nlisteners] = 0; nlisteners++; if (tfd >= 0) { - pfds[n].fd = tfd; + lfds[nlisteners] = tfd; + ltls[nlisteners] = 0; + nlisteners++; + } + if (tlsfd >= 0) { + lfds[nlisteners] = tlsfd; + ltls[nlisteners] = 1; + nlisteners++; + } + for (int i = 0; i < nlisteners; i++) { + pfds[n].fd = lfds[i]; pfds[n].events = POLLIN; map[n] = NULL; n++; - nlisteners++; } for (size_t i = 0; i < nconns; i++) { pfds[n].fd = conns[i].fd; - pfds[n].events = POLLIN | (conns[i].out.len > conns[i].out_sent - ? POLLOUT - : 0); + pfds[n].events = POLLIN | + ((conns[i].out.len > conns[i].out_sent || + conns[i].handshaking) + ? POLLOUT + : 0); map[n] = &conns[i]; n++; } @@ -453,7 +640,7 @@ int main(int argc, char **argv) } for (int i = 0; i < nlisteners; i++) if (pfds[i].revents & POLLIN) - accept_conns(pfds[i].fd, conns, &nconns); + accept_conns(lfds[i], conns, &nconns, ltls[i]); for (int i = nlisteners; i < n; i++) { struct conn *c = map[i]; if (c->fd < 0) @@ -463,12 +650,18 @@ int main(int argc, char **argv) if (pfds[i].revents & POLLOUT) conn_flush(c); if (c->closing && c->out.len == c->out_sent) { + if (c->ssl) { + SSL_free(c->ssl); + c->ssl = NULL; + } close(c->fd); buf_free(&c->in); buf_free(&c->out); c->fd = -1; } } + if (tlsfd >= 0) + tls_reload_check(); size_t keep = 0; for (size_t i = 0; i < nconns; i++) { if (conns[i].fd >= 0) @@ -479,6 +672,8 @@ int main(int argc, char **argv) log_info("shutting down"); for (size_t i = 0; i < nconns; i++) { + if (conns[i].ssl) + SSL_free(conns[i].ssl); close(conns[i].fd); buf_free(&conns[i].in); buf_free(&conns[i].out); @@ -486,6 +681,12 @@ int main(int argc, char **argv) close(lfd); if (tfd >= 0) close(tfd); + if (tlsfd >= 0) + close(tlsfd); + if (g_tls_ctx) { + SSL_CTX_free(g_tls_ctx); + g_tls_ctx = NULL; + } unlink(g_cfg.socket_path); sessions_free_all(); sqlite3_close(db); diff --git a/src/config.c b/src/config.c index 3bce6e9..d1f392a 100644 --- a/src/config.c +++ b/src/config.c @@ -24,6 +24,10 @@ void config_defaults(void) g_cfg.export_dir = xstrdup("/var/lib/bokfd/export"); g_cfg.tcp_addr = NULL; g_cfg.tcp_enabled = 0; + g_cfg.tls_addr = NULL; + g_cfg.tls_enabled = 0; + g_cfg.tls_cert = xstrdup("/var/lib/bokfd/certs/fullchain.pem"); + g_cfg.tls_key = xstrdup("/var/lib/bokfd/certs/privkey.pem"); g_cfg.log_level = LOG_INFO; g_cfg.allow_org_create = 1; g_cfg.audit_reads = 0; @@ -52,6 +56,17 @@ static int apply_kv(const char *key, const char *val, char **err) set_str(&g_cfg.tcp_addr, val); g_cfg.tcp_enabled = 1; } + } else if (strcmp(key, "tls") == 0) { + if (strcmp(val, "off") == 0 || strcmp(val, "") == 0) { + g_cfg.tls_enabled = 0; + } else { + set_str(&g_cfg.tls_addr, val); + g_cfg.tls_enabled = 1; + } + } else if (strcmp(key, "tls_cert") == 0) { + set_str(&g_cfg.tls_cert, val); + } else if (strcmp(key, "tls_key") == 0) { + set_str(&g_cfg.tls_key, val); } else if (strcmp(key, "log_level") == 0) { g_cfg.log_level = log_level_from_name(val); } else if (strcmp(key, "allow_org_create") == 0) { @@ -137,6 +152,12 @@ void config_apply_env(void) set_str(&g_cfg.export_dir, v); if ((v = getenv("BOKFD_TCP"))) apply_kv("tcp", v, NULL); + if ((v = getenv("BOKFD_TLS"))) + apply_kv("tls", v, NULL); + if ((v = getenv("BOKFD_TLS_CERT"))) + set_str(&g_cfg.tls_cert, v); + if ((v = getenv("BOKFD_TLS_KEY"))) + set_str(&g_cfg.tls_key, v); if ((v = getenv("BOKFD_LOG_LEVEL"))) g_cfg.log_level = log_level_from_name(v); if ((v = getenv("BOKFD_ALLOW_ORG_CREATE"))) @@ -164,6 +185,9 @@ void config_free(void) free(g_cfg.backup_dir); free(g_cfg.export_dir); free(g_cfg.tcp_addr); + free(g_cfg.tls_addr); + free(g_cfg.tls_cert); + free(g_cfg.tls_key); free(g_cfg.synchronous); free(g_cfg.chart_k2_file); free(g_cfg.chart_k3_file); diff --git a/src/config.h b/src/config.h index dad9737..41d8c41 100644 --- a/src/config.h +++ b/src/config.h @@ -10,6 +10,10 @@ struct config { char *export_dir; char *tcp_addr; int tcp_enabled; + char *tls_addr; + int tls_enabled; + char *tls_cert; + char *tls_key; int log_level; int allow_org_create; int audit_reads; diff --git a/tests/test_core.c b/tests/test_core.c index db3bfb2..b2c828d 100644 --- a/tests/test_core.c +++ b/tests/test_core.c @@ -1,10 +1,17 @@ +#include +#include +#include +#include #include #include #include #include +#include #include +#include #include +#include "client.h" #include "config.h" #include "db.h" #include "protocol.h" @@ -170,6 +177,124 @@ static int login(const char *user, const char *pass) return ok; } +static void echo_child(int wfd, int tls) +{ + int sfd = socket(AF_INET, SOCK_STREAM | SOCK_CLOEXEC, 0); + if (sfd < 0) + _exit(1); + struct sockaddr_in sa; + memset(&sa, 0, sizeof sa); + sa.sin_family = AF_INET; + sa.sin_addr.s_addr = htonl(INADDR_LOOPBACK); + sa.sin_port = 0; + if (bind(sfd, (struct sockaddr *)&sa, sizeof sa) != 0 || + listen(sfd, 1) != 0) + _exit(1); + socklen_t slen = sizeof sa; + if (getsockname(sfd, (struct sockaddr *)&sa, &slen) != 0) + _exit(1); + int port = ntohs(sa.sin_port); + if (write(wfd, &port, sizeof port) != (ssize_t)sizeof port) + _exit(1); + close(wfd); + int cfd = accept(sfd, NULL, NULL); + if (cfd < 0) + _exit(1); + char buf[256]; + if (tls) { + SSL_CTX *ctx = SSL_CTX_new(TLS_server_method()); + if (!ctx || + SSL_CTX_use_certificate_chain_file(ctx, + "tests/tls_test_cert.pem") != 1 || + SSL_CTX_use_PrivateKey_file(ctx, "tests/tls_test_key.pem", + SSL_FILETYPE_PEM) != 1) + _exit(1); + SSL *ssl = SSL_new(ctx); + SSL_set_fd(ssl, cfd); + if (SSL_accept(ssl) != 1) + _exit(1); + size_t got = 0; + while (got < sizeof buf) { + int n = SSL_read(ssl, buf + got, (int)(sizeof buf - got)); + if (n <= 0) + break; + got += (size_t)n; + if (memchr(buf, '\n', got)) + break; + } + (void)SSL_write(ssl, "tls-ok", 6); + SSL_shutdown(ssl); + SSL_free(ssl); + SSL_CTX_free(ctx); + } else { + size_t got = 0; + while (got < sizeof buf) { + ssize_t n = read(cfd, buf + got, sizeof buf - got); + if (n <= 0) + break; + got += (size_t)n; + if (memchr(buf, '\n', got)) + break; + } + (void)write(cfd, "tcp-ok", 6); + } + close(cfd); + close(sfd); + _exit(0); +} + +/* variant 0: plaintext tcp; 1: TLS trusting the test CA; 2: TLS without the + CA, which must fail certificate verification. */ +static void test_transport(void) +{ + signal(SIGPIPE, SIG_IGN); + for (int variant = 0; variant < 3; variant++) { + int tls = variant > 0; + int pfd[2]; + if (pipe(pfd) != 0) { + CHECK(0 && "pipe"); + return; + } + pid_t pid = fork(); + if (pid == 0) { + close(pfd[0]); + echo_child(pfd[1], tls); + } + close(pfd[1]); + int port = 0; + if (read(pfd[0], &port, sizeof port) != (ssize_t)sizeof port) + port = 0; + close(pfd[0]); + if (port <= 0) { + CHECK(0 && "no test server port"); + waitpid(pid, NULL, 0); + continue; + } + if (variant == 1) + setenv("BOKFD_TLS_CA", "tests/tls_test_cert.pem", 1); + else + unsetenv("BOKFD_TLS_CA"); + char target[64]; + snprintf(target, sizeof target, "%s:localhost:%d", + tls ? "tls" : "tcp", port); + struct client_conn c; + if (variant == 2) { + CHECK(client_connect(target, &c) != 0); + waitpid(pid, NULL, 0); + continue; + } + CHECK(client_connect(target, &c) == 0); + CHECK(client_send_line(&c, "{\"v\":1}") == 0); + char *line = client_read_line(&c); + CHECK(line && strncmp(line, tls ? "tls-ok" : "tcp-ok", 6) == 0); + if (!line) + fprintf(stderr, "transport error: %s\n", client_last_error()); + free(line); + client_close(&c); + waitpid(pid, NULL, 0); + } +} + int main(void) { char tmpdir[] = "/tmp/bokf-test-XXXXXX"; @@ -1059,6 +1184,8 @@ int main(void) CHECK(jint(d, "result.checked") > 20); yyjson_doc_free(d); + test_transport(); + /* rate limiting must stay last: it blocks the login key */ for (int i = 0; i < 5; i++) CHECK(!login("admin", "wrong")); diff --git a/tests/tls_test_cert.pem b/tests/tls_test_cert.pem new file mode 100644 index 0000000..c559354 --- /dev/null +++ b/tests/tls_test_cert.pem @@ -0,0 +1,11 @@ +-----BEGIN CERTIFICATE----- +MIIBmTCCAT+gAwIBAgIUGXc7xLB5t5mnvBH4vPYvo6ACfowwCgYIKoZIzj0EAwIw +FDESMBAGA1UEAwwJbG9jYWxob3N0MB4XDTI2MDkxNzE4MjAzMloXDTM2MDkxNDE4 +MjAzMlowFDESMBAGA1UEAwwJbG9jYWxob3N0MFkwEwYHKoZIzj0CAQYIKoZIzj0D +AQcDQgAE66+gpPbkPDOANXFk3UfqpE3djA17Z/EzZPBgmuKpbNyZftyjESkSf0Ra +LG3sZ97J9SMFWJJwLtB70I3dZO+NxaNvMG0wHQYDVR0OBBYEFHv6wMxHetBdatyF +ydr8XLg+aIVQMB8GA1UdIwQYMBaAFHv6wMxHetBdatyFydr8XLg+aIVQMA8GA1Ud +EwEB/wQFMAMBAf8wGgYDVR0RBBMwEYIJbG9jYWxob3N0hwR/AAABMAoGCCqGSM49 +BAMCA0gAMEUCIDACquqAztvZ4gMH5MlbuZ8Hkqie1txpmIl6yW9qCm9MAiEAllS3 +LDzwrm8g5KgLAgURVZcdBLth3HXjYgQQrn/uwEQ= +-----END CERTIFICATE----- diff --git a/tests/tls_test_key.pem b/tests/tls_test_key.pem new file mode 100644 index 0000000..b084830 --- /dev/null +++ b/tests/tls_test_key.pem @@ -0,0 +1,5 @@ +-----BEGIN PRIVATE KEY----- +MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgjif72KjB8IkhmHF2 +FFNqe3eSRHi5cXOIba+FC5/Jd4ehRANCAATrr6Ck9uQ8M4A1cWTdR+qkTd2MDXtn +8TNk8GCa4qls3Jl+3KMRKRJ/RFosbexn3sn1IwVYknAu0HvQjd1k743F +-----END PRIVATE KEY----- -- cgit v1.3