summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorAnders Betts <anders.betts@gmail.com>2026-09-17 21:26:20 +0200
committerAnders Betts <anders.betts@gmail.com>2026-09-17 21:26:20 +0200
commited1c15929d2eb2dbc6432986c26661bf1549964a (patch)
tree4f04934edef686b19d16e1bb2f79bb5c15e142de
parent380195f7cd5e57acf2c1cf2bc41069e6b0b979ed (diff)
downloadbokf-d8175a8f76bc5eefa2524f2b245cb8a5b45864ba.tar.gz
bokf-d8175a8f76bc5eefa2524f2b245cb8a5b45864ba.zip
Add native TLS transport, TLS clients and lego cert sidecarv0.1.1
- 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)
-rw-r--r--.env.example8
-rw-r--r--Dockerfile6
-rw-r--r--Makefile32
-rw-r--r--clients/bokfctl.c236
-rw-r--r--clients/bokftui.c112
-rw-r--r--clients/client.c296
-rw-r--r--clients/client.h26
-rw-r--r--compose.yaml29
-rwxr-xr-xdeploy/docker-entrypoint.sh3
-rw-r--r--docs/DEPLOY.md47
-rw-r--r--docs/PROTOCOL.md30
-rw-r--r--docs/STATE.md16
-rw-r--r--src/bokfd.c267
-rw-r--r--src/config.c24
-rw-r--r--src/config.h4
-rw-r--r--tests/test_core.c127
-rw-r--r--tests/tls_test_cert.pem11
-rw-r--r--tests/tls_test_key.pem5
18 files changed, 897 insertions, 382 deletions
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 <errno.h>
-#include <netdb.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
-#include <sys/socket.h>
-#include <sys/un.h>
-#include <unistd.h>
+#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] <cmd> [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 <arpa/inet.h>
#include <errno.h>
#include <netdb.h>
+#include <openssl/err.h>
+#include <openssl/ssl.h>
+#include <openssl/x509v3.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
@@ -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 <stdint.h>
/* 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='<owner-pw>' bokfd \
+ bokfctl --user admin user.create '{"username":"revisor", ...}'
+docker compose exec -e BOKFD_PASSWORD='<owner-pw>' 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)
+### 3.2 TCP and TLS (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.
+- 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 <errno.h>
#include <fcntl.h>
#include <netdb.h>
+#include <openssl/err.h>
+#include <openssl/ssl.h>
#include <poll.h>
#include <signal.h>
#include <stdio.h>
@@ -10,6 +12,7 @@
#include <sys/stat.h>
#include <sys/un.h>
#include <termios.h>
+#include <time.h>
#include <unistd.h>
#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 <arpa/inet.h>
+#include <netinet/in.h>
+#include <openssl/ssl.h>
+#include <signal.h>
#include <stdarg.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
+#include <sys/socket.h>
#include <sys/stat.h>
+#include <sys/wait.h>
#include <unistd.h>
+#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-----