summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--AGENTS.md6
-rw-r--r--Dockerfile42
-rw-r--r--Makefile22
-rw-r--r--THIRD_PARTY_NOTICES.md9
-rw-r--r--clients/bokfweb.c479
-rw-r--r--clients/client.c1
-rw-r--r--clients/screens_bokslut.c2
-rw-r--r--clients/screens_reports.c4
-rw-r--r--clients/ui.c27
-rw-r--r--clients/ui.h6
-rw-r--r--clients/web.c330
-rw-r--r--clients/web.h100
-rw-r--r--compose.yaml25
-rw-r--r--deploy/Caddyfile47
-rw-r--r--deploy/Dockerfile.cross8
-rwxr-xr-xdeploy/bokftui-web40
-rw-r--r--deploy/cross-build.sh12
-rwxr-xr-xdeploy/web-entrypoint.sh26
-rw-r--r--docs/DECISIONS.md14
-rw-r--r--docs/DEPLOY.md60
-rw-r--r--docs/PROTOCOL.md13
-rw-r--r--docs/STATE.md10
-rw-r--r--docs/TUI-GUIDELINES.md7
-rwxr-xr-xscripts/deploy.sh38
-rwxr-xr-xscripts/tui-golden.py29
-rw-r--r--src/cmd_auth.c67
-rw-r--r--tests/test_core.c30
-rw-r--r--tests/test_web.c175
28 files changed, 1570 insertions, 59 deletions
diff --git a/AGENTS.md b/AGENTS.md
index f28a904..a1adc0c 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -2,7 +2,8 @@
`bokf` is a self-hosted Swedish bookkeeping system in C11: one daemon
(`bokfd`) owning a SQLite database, one JSON protocol, three clients
-(`bokfctl`, `bokftui`, agents). GPL-3.0-or-later.
+(`bokfctl`, `bokftui` — also in the browser via `bokfweb` + ttyd — and
+agents). GPL-3.0-or-later.
## Build and test
@@ -19,7 +20,8 @@ vendored in `vendor/` and pinned; the only system library is libncursesw.
| Path | What |
|---|---|
| `src/` | daemon, protocol, ledger, reports, SIE, seed, formula |
-| `clients/` | `bokfctl.c`, `bokftui.c`, shared `client.c` |
+| `clients/` | `bokfctl.c`, `bokftui.c`, `bokfweb.c` (web login gate), shared `client.c` |
+| `deploy/` | Dockerfile helpers, the web frontend's Caddyfile, entrypoint and `bokftui-web` |
| `docs/` | PROTOCOL.md, SCHEMA.md, COMPLIANCE.md, TUI-GUIDELINES.md |
| `tests/` | in-process protocol/ledger tests (`test_core.c`) |
| `data/` | BAS charts (third-party, see THIRD_PARTY_NOTICES.md) |
diff --git a/Dockerfile b/Dockerfile
index 63d120f..a46e15e 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -3,7 +3,11 @@
FROM alpine:3.24 AS build
# VERSION is declared after the expensive layers: the legacy builder
# invalidates every layer that follows a changed build argument.
-RUN apk add --no-cache build-base openssl-dev
+# Alpine's ncurses has no ncursesw/ include directory; the TUI includes
+# <ncursesw/ncurses.h> like Debian, so point it at the one header.
+RUN apk add --no-cache build-base openssl-dev ncurses-dev \
+ && mkdir -p /usr/include/ncursesw \
+ && ln -s ../ncurses.h /usr/include/ncursesw/ncurses.h
WORKDIR /src
COPY . .
ARG VERSION=0.1.0-dev
@@ -12,16 +16,42 @@ ARG VERSION=0.1.0-dev
# source on this host.
RUN if [ -x .prebuilt/bokfd ]; then \
mkdir -p build && \
- cp .prebuilt/bokfd .prebuilt/bokfctl build/; \
+ cp .prebuilt/bokfd .prebuilt/bokfctl .prebuilt/bokftui \
+ .prebuilt/bokfweb build/; \
else \
- make -j"$(nproc)" backend VERSION="$VERSION" \
+ make -j"$(nproc)" backend build/bokftui build/bokfweb \
+ VERSION="$VERSION" \
&& make test-core VERSION="$VERSION"; \
fi \
- && strip build/bokfd build/bokfctl
+ && strip build/bokfd build/bokfctl build/bokftui build/bokfweb
+
+# The web frontend (image bokf-web, `--target web`): Caddy for routing, the
+# bokfweb login gate, ttyd and bokftui in web mode. It reaches bokfd only
+# through the protocol socket; no database, no secrets, no certificates
+# (TLS is the host's reverse proxy). Everything runs as an unprivileged
+# user; Caddy listens on 8790.
+FROM alpine:3.24 AS web
+RUN apk add --no-cache ca-certificates caddy ttyd ncurses-terminfo-base \
+ ncurses-libs libssl3 libcrypto3 \
+ && addgroup -S bokfd \
+ && adduser -S -D -H -u 10001 -G bokfd -s /sbin/nologin bokfd
+COPY --from=build /src/build/bokftui /src/build/bokfweb /usr/local/bin/
+COPY deploy/bokftui-web deploy/web-entrypoint.sh /usr/local/bin/
+COPY deploy/Caddyfile /etc/caddy/Caddyfile
+RUN chmod 0755 /usr/local/bin/bokftui-web /usr/local/bin/web-entrypoint.sh
+ENV BOKFD_SOCKET=/run/bokfd/bokfd.sock \
+ TERM=xterm-256color \
+ LANG=C.UTF-8
+USER 10001
+EXPOSE 8790
+HEALTHCHECK --interval=15s --timeout=3s --start-period=5s --retries=3 \
+ CMD ["wget", "-q", "-O", "/dev/null", "http://127.0.0.1:7682/healthz"]
+ENTRYPOINT ["/usr/local/bin/web-entrypoint.sh"]
# The runtime image carries the daemon and bokfctl only, statically linked
-# against OpenSSL and the C library; the ncurses TUI is a frontend built on the
-# machine you sit at.
+# against OpenSSL and the C library; the ncurses TUI is a frontend (on the
+# machine you sit at, or in the web image above). It is the last stage, so a
+# plain `docker build` still produces it.
FROM alpine:3.24 AS runtime
RUN apk add --no-cache ca-certificates util-linux \
&& addgroup -S bokfd \
diff --git a/Makefile b/Makefile
index d797efe..dfe5f9f 100644
--- a/Makefile
+++ b/Makefile
@@ -33,7 +33,7 @@ CORE_SRC = src/util.c src/log.c src/config.c src/db.c src/auth.c \
VENDOR_OBJ = $(patsubst %.c,$(BUILD)/%.o,$(VENDOR_SRC))
CORE_OBJ = $(patsubst %.c,$(BUILD)/%.o,$(CORE_SRC))
-all: $(BUILD)/bokfd $(BUILD)/bokfctl $(BUILD)/bokftui
+all: $(BUILD)/bokfd $(BUILD)/bokfctl $(BUILD)/bokftui $(BUILD)/bokfweb
backend: $(BUILD)/bokfd $(BUILD)/bokfctl
@@ -76,6 +76,19 @@ $(BUILD)/test_tui: $(BUILD)/tests/test_tui.o $(BUILD)/clients/tui.o \
$(BUILD)/vendor/sha256.o
$(CC) $(CFLAGS) -o $@ $^ -lm -lncursesw
+# The login gate in front of the browser terminal (deploy/Dockerfile web
+# stage); a protocol client like bokfctl, no ncurses.
+$(BUILD)/bokfweb: $(BUILD)/clients/bokfweb.o $(BUILD)/clients/web.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 $(SSL_LIBS)
+
+$(BUILD)/test_web: $(BUILD)/tests/test_web.o $(BUILD)/clients/web.o \
+ $(BUILD)/src/util.o $(BUILD)/src/log.o \
+ $(BUILD)/vendor/sha256.o
+ $(CC) $(CFLAGS) -o $@ $^ -lm
+
$(BUILD)/test_pdf: $(BUILD)/tests/pdf_check.o $(BUILD)/src/pdf.o \
$(BUILD)/src/util.o $(BUILD)/vendor/sha256.o
$(CC) $(CFLAGS) -o $@ $^ -lm
@@ -97,9 +110,10 @@ gen-protocol: $(BUILD)/gen_protocol
$(BUILD)/gen_protocol --write docs/PROTOCOL.md
test: check $(BUILD)/test_core $(BUILD)/test_tui $(BUILD)/test_pdf \
- $(BUILD)/test_invoice $(BUILD)/test_smtp
+ $(BUILD)/test_invoice $(BUILD)/test_smtp $(BUILD)/test_web
$(BUILD)/test_core
$(BUILD)/test_tui
+ $(BUILD)/test_web
$(BUILD)/test_pdf
$(BUILD)/test_invoice
$(BUILD)/test_smtp
@@ -160,6 +174,8 @@ $(BUILD)/tests/%.o: tests/%.c
$(BUILD)/clients/ui.d $(TUI_SCREEN_DEP) \
$(BUILD)/clients/tui.d $(BUILD)/clients/client.d \
$(BUILD)/clients/drafts.d $(BUILD)/clients/vlist.d \
+ $(BUILD)/clients/web.d $(BUILD)/clients/bokfweb.d \
+ $(BUILD)/tests/test_web.d \
$(BUILD)/tests/test_core.d $(BUILD)/tests/test_tui.d \
$(BUILD)/tests/pdf_check.d $(BUILD)/tests/invoice_check.d \
$(BUILD)/tests/smtp_check.d \
@@ -168,7 +184,7 @@ $(BUILD)/tests/%.o: tests/%.c
install: all
install -d $(DESTDIR)/usr/local/bin $(DESTDIR)/usr/local/share/bokf
install -m 0755 $(BUILD)/bokfd $(BUILD)/bokfctl $(BUILD)/bokftui \
- $(DESTDIR)/usr/local/bin/
+ $(BUILD)/bokfweb $(DESTDIR)/usr/local/bin/
install -m 0644 data/bas_k2.csv data/bas_k3.csv $(DESTDIR)/usr/local/share/bokf/
clean:
diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md
index 7871e67..029bd3f 100644
--- a/THIRD_PARTY_NOTICES.md
+++ b/THIRD_PARTY_NOTICES.md
@@ -10,6 +10,15 @@ components are vendored in `vendor/` and are compatible with that license.
| SHA-256 (Brad Conte) | master@2026-09 | Public domain | https://github.com/B-Con/crypto-algorithms |
| Argon2 reference | 20190702 | CC0-1.0 / Apache-2.0 (`vendor/argon2.LICENSE`) | https://github.com/P-H-C/phc-winner-argon2 |
+The web frontend image (`bokf-web`) installs these from Alpine Linux
+packages at build time; they are not part of this repository:
+
+| Component | License | Source |
+|---|---|---|
+| Caddy | Apache-2.0 | https://caddyserver.com/ |
+| ttyd (with xterm.js) | MIT | https://github.com/tsl0922/ttyd |
+| ncurses | MIT-style (X11) | https://invisible-island.net/ncurses/ |
+
The wordmark outlines in `src/wordmark.h` are derived from the Comfortaa
typeface by Johan Aakerlund, licensed under the SIL Open Font License,
Version 1.1 (https://scripts.sil.org/OFL). The header is generated by
diff --git a/clients/bokfweb.c b/clients/bokfweb.c
new file mode 100644
index 0000000..d04e2d6
--- /dev/null
+++ b/clients/bokfweb.c
@@ -0,0 +1,479 @@
+/* bokfweb: the login gate in front of the browser terminal.
+
+ Caddy terminates TLS and asks this gate (forward_auth, GET /auth) before
+ it proxies anything to ttyd. A login page checks the credentials with
+ bokfd's own session.open, so the web has no password store of its own
+ and bokfd's audit applies. A successful login gets a cookie (never in a
+ URL) and a terminal handle (in the ttyd URL, ?arg=); /auth accepts a
+ request only with a live cookie whose session owns the handle in the
+ URL, and only while the bokfd session is alive. The terminal wrapper
+ trades the handle for the bokfd session on the internal /redeem, so the
+ TUI starts logged in. Failed logins are limited per client address
+ before bokfd's own limiter is reached.
+
+ Listens on BOKFWEB_LISTEN (default 127.0.0.1:7682) and is not exposed
+ directly: only Caddy and the wrapper in the same container talk to it. */
+#include <arpa/inet.h>
+#include <errno.h>
+#include <netinet/in.h>
+#include <signal.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <sys/socket.h>
+#include <sys/time.h>
+#include <unistd.h>
+
+#include "client.h"
+#include "log.h"
+#include "util.h"
+#include "web.h"
+#include "yyjson.h"
+
+#define COOKIE "bokf_web"
+
+struct gate {
+ const char *bokfd; /* bokfd target, e.g. /run/bokfd/bokfd.sock */
+ const char *base; /* URL prefix, e.g. /web */
+ int secure; /* Secure cookie (off only for plain-http tests) */
+ struct web_store st;
+ struct web_rl rl;
+};
+
+/* One call on a fresh connection; the response line or NULL. */
+static char *bokfd_call(const struct gate *g, const char *cmd,
+ const char *session, const char *args)
+{
+ struct client_conn c;
+ if (client_connect(g->bokfd, &c) != 0) {
+ log_warn("bokfd unreachable: %s", client_last_error());
+ return NULL;
+ }
+ char *resp = client_rpc(&c, cmd, session, 0, args);
+ client_close(&c);
+ return resp;
+}
+
+static int bokfd_alive(const struct gate *g, const char *session)
+{
+ char *resp = bokfd_call(g, "session.whoami", session, "{}");
+ int ok = resp && client_ok(resp);
+ free(resp);
+ return ok;
+}
+
+/* error.code of a response line into code ("" when none). */
+static void error_code(const char *resp, char *code, size_t n)
+{
+ code[0] = '\0';
+ yyjson_doc *d = resp ? yyjson_read(resp, strlen(resp), 0) : NULL;
+ yyjson_val *root = d ? yyjson_doc_get_root(d) : NULL;
+ yyjson_val *e = root ? yyjson_obj_get(root, "error") : NULL;
+ yyjson_val *c = e ? yyjson_obj_get(e, "code") : NULL;
+ if (c && yyjson_is_str(c))
+ snprintf(code, n, "%s", yyjson_get_str(c));
+ yyjson_doc_free(d);
+}
+
+/* --- responses ---------------------------------------------------- */
+
+static void send_all(int fd, const char *p, size_t n)
+{
+ while (n > 0) {
+ ssize_t w = send(fd, p, n, MSG_NOSIGNAL);
+ if (w <= 0) {
+ if (w < 0 && errno == EINTR)
+ continue;
+ return;
+ }
+ p += w;
+ n -= (size_t)w;
+ }
+}
+
+static void respond(int fd, const char *status, const char *extra_headers,
+ const char *ctype, const char *body, size_t blen)
+{
+ char h[1536];
+ int n = snprintf(
+ h, sizeof h,
+ "HTTP/1.1 %s\r\n"
+ "Connection: close\r\n"
+ "Cache-Control: no-store\r\n"
+ "X-Content-Type-Options: nosniff\r\n"
+ "X-Frame-Options: DENY\r\n"
+ "Referrer-Policy: no-referrer\r\n"
+ "Content-Security-Policy: default-src 'none'; style-src"
+ " 'unsafe-inline'; form-action 'self'; frame-ancestors 'none'\r\n"
+ "%s%s%s%s"
+ "Content-Length: %zu\r\n\r\n",
+ status, extra_headers ? extra_headers : "",
+ ctype ? "Content-Type: " : "", ctype ? ctype : "",
+ ctype ? "\r\n" : "", blen);
+ if (n <= 0 || (size_t)n >= sizeof h)
+ return;
+ send_all(fd, h, (size_t)n);
+ if (body && blen)
+ send_all(fd, body, blen);
+}
+
+static void redirect(int fd, const char *location, const char *extra)
+{
+ char h[768];
+ snprintf(h, sizeof h, "Location: %s\r\n%s", location, extra ? extra : "");
+ respond(fd, "303 See Other", h, NULL, NULL, 0);
+}
+
+static void text(int fd, const char *status, const char *body)
+{
+ respond(fd, status, NULL, "text/plain; charset=utf-8", body,
+ strlen(body));
+}
+
+static void buf_puts(struct buf *b, const char *s)
+{
+ buf_append(b, s, strlen(s));
+}
+
+static void login_page(int fd, const struct gate *g, const char *error,
+ const char *user)
+{
+ struct buf b;
+ buf_init(&b);
+ static const char head[] =
+ "<!doctype html>\n<html lang=\"sv\"><head><meta charset=\"utf-8\">"
+ "<meta name=\"viewport\" content=\"width=device-width,"
+ "initial-scale=1\"><title>bokf — logga in</title><style>"
+ "body{font-family:system-ui,sans-serif;background:#1d2327;"
+ "color:#e8e8e8;display:flex;min-height:100vh;margin:0;"
+ "align-items:center;justify-content:center}"
+ "form{background:#2a3136;padding:2rem 2.2rem;border-radius:6px;"
+ "width:18rem}h1{font-size:1.3rem;margin:0 0 1.2rem}"
+ "label{display:block;font-size:.85rem;margin:.9rem 0 .3rem}"
+ "input{width:100%;box-sizing:border-box;padding:.55rem;border:1px "
+ "solid #56616a;border-radius:4px;background:#1d2327;color:#fff;"
+ "font-size:1rem}button{margin-top:1.4rem;width:100%;padding:.6rem;"
+ "border:0;border-radius:4px;background:#3d8fd1;color:#fff;"
+ "font-size:1rem;cursor:pointer}.err{background:#5c2b2b;"
+ "padding:.6rem;border-radius:4px;font-size:.9rem}"
+ "p.note{font-size:.8rem;color:#9aa5ad;margin-top:1.2rem}"
+ "</style></head><body>";
+ buf_puts(&b, head);
+ buf_puts(&b, "<form method=\"post\" action=\"");
+ web_html_escape(&b, g->base);
+ buf_puts(&b, "/login\"><h1>bokf</h1>");
+ if (error && *error) {
+ buf_puts(&b, "<div class=\"err\" role=\"alert\">");
+ web_html_escape(&b, error);
+ buf_puts(&b, "</div>");
+ }
+ static const char fields1[] =
+ "<label for=\"u\">Användarnamn</label>"
+ "<input id=\"u\" name=\"username\" autocomplete=\"username\" "
+ "autocapitalize=\"none\" required autofocus value=\"";
+ buf_puts(&b, fields1);
+ web_html_escape(&b, user ? user : "");
+ static const char fields2[] =
+ "\"><label for=\"p\">Lösenord</label>"
+ "<input id=\"p\" name=\"password\" type=\"password\" "
+ "autocomplete=\"current-password\" required>"
+ "<button type=\"submit\">Logga in</button>"
+ "<p class=\"note\">Samma konto som i bokftui. Efter inloggningen "
+ "öppnas bokföringen i en terminal i webbläsaren.</p>"
+ "</form></body></html>\n";
+ buf_puts(&b, fields2);
+ respond(fd, "200 OK", NULL, "text/html; charset=utf-8",
+ (const char *)b.p, b.len);
+ buf_free(&b);
+}
+
+/* --- handlers ----------------------------------------------------- */
+
+static const char *client_addr(const struct web_req *r, char *buf, size_t n)
+{
+ /* Caddy sets X-Forwarded-For to the real client; take the first hop */
+ if (!r->forwarded_for[0])
+ return "direct";
+ snprintf(buf, n, "%s", r->forwarded_for);
+ char *comma = strchr(buf, ',');
+ if (comma)
+ *comma = '\0';
+ return util_str_trim(buf);
+}
+
+static struct web_session *cookie_session(struct gate *g,
+ const struct web_req *r)
+{
+ char tok[128];
+ if (web_cookie_get(r->cookie, COOKIE, tok, sizeof tok) != 0)
+ return NULL;
+ return web_store_by_token(&g->st, tok, util_now());
+}
+
+/* The cookie's session when the bokfd session behind it is alive;
+ a dead one is dropped. */
+static struct web_session *live_session(struct gate *g,
+ const struct web_req *r)
+{
+ struct web_session *s = cookie_session(g, r);
+ if (s && !bokfd_alive(g, s->bokf)) {
+ log_info("web session of %s ended (bokfd session gone)", s->user);
+ web_store_del(s);
+ s = NULL;
+ }
+ return s;
+}
+
+static void to_terminal(int fd, const struct gate *g,
+ const struct web_session *s, const char *extra)
+{
+ char loc[256];
+ snprintf(loc, sizeof loc, "%s/tty/?arg=%s", g->base, s->handle);
+ redirect(fd, loc, extra);
+}
+
+static void h_login_post(int fd, struct gate *g, const struct web_req *r)
+{
+ char abuf[64], user[64] = "", pass[256] = "";
+ const char *addr = client_addr(r, abuf, sizeof abuf);
+ int64_t now = util_now();
+ int64_t wait = web_rl_blocked(&g->rl, addr, now);
+ if (wait > 0) {
+ char msg[128];
+ snprintf(msg, sizeof msg,
+ "För många misslyckade försök. Försök igen om %lld min.",
+ (long long)(wait + 59) / 60);
+ login_page(fd, g, msg, NULL);
+ return;
+ }
+ if (web_form_get(r->body, r->body_len, "username", user, sizeof user) ||
+ web_form_get(r->body, r->body_len, "password", pass, sizeof pass) ||
+ !user[0] || !pass[0]) {
+ login_page(fd, g, "Fyll i användarnamn och lösenord.", user);
+ return;
+ }
+ struct client_conn c;
+ if (client_connect(g->bokfd, &c) != 0) {
+ memset(pass, 0, sizeof pass);
+ log_warn("bokfd unreachable: %s", client_last_error());
+ login_page(fd, g, "Servern svarar inte just nu. Försök igen strax.",
+ user);
+ return;
+ }
+ char *session = NULL, *err = NULL;
+ int rc = client_login(&c, user, pass, &session, &err);
+ client_close(&c);
+ memset(pass, 0, sizeof pass);
+ if (rc != 0) {
+ char code[48];
+ error_code(err, code, sizeof code);
+ free(err);
+ if (strcmp(code, "RATE_LIMITED") == 0) {
+ login_page(fd, g,
+ "Inloggningen är tillfälligt spärrad efter för många "
+ "felaktiga försök. Försök igen om en stund.",
+ user);
+ return;
+ }
+ if (strcmp(code, "AUTH_FAILED") == 0) {
+ web_rl_fail(&g->rl, addr, now);
+ log_info("web login failed for %s from %s", user, addr);
+ login_page(fd, g, "Fel användarnamn eller lösenord.", user);
+ return;
+ }
+ log_warn("web login error for %s: %s", user, code[0] ? code : "?");
+ login_page(fd, g, "Inloggningen misslyckades. Försök igen strax.",
+ user);
+ return;
+ }
+ web_rl_ok(&g->rl, addr);
+ struct web_session *s = web_store_add(&g->st, session, user, now);
+ free(session);
+ log_info("web login %s from %s", user, addr);
+ char cookie[256];
+ snprintf(cookie, sizeof cookie,
+ "Set-Cookie: " COOKIE "=%s; Path=%s; HttpOnly;%s SameSite=Strict;"
+ " Max-Age=%d\r\n",
+ s->token, g->base, g->secure ? " Secure;" : "",
+ WEB_SESSION_TTL);
+ to_terminal(fd, g, s, cookie);
+}
+
+static void h_logout(int fd, struct gate *g, const struct web_req *r)
+{
+ struct web_session *s = cookie_session(g, r);
+ if (s) {
+ free(bokfd_call(g, "session.close", s->bokf, "{}"));
+ log_info("web logout %s", s->user);
+ web_store_del(s);
+ }
+ char clear[192], loc[160];
+ snprintf(clear, sizeof clear,
+ "Set-Cookie: " COOKIE "=; Path=%s; HttpOnly;%s SameSite=Strict;"
+ " Max-Age=0\r\n",
+ g->base, g->secure ? " Secure;" : "");
+ snprintf(loc, sizeof loc, "%s/", g->base);
+ redirect(fd, loc, clear);
+}
+
+/* forward_auth: 200 lets Caddy proxy to ttyd, anything else goes back to
+ the browser. A handle in the URL must belong to the cookie's session. */
+static void h_auth(int fd, struct gate *g, const struct web_req *r)
+{
+ struct web_session *s = live_session(g, r);
+ if (!s) {
+ char loc[160];
+ snprintf(loc, sizeof loc, "%s/", g->base);
+ redirect(fd, loc, NULL);
+ return;
+ }
+ const char *q = strchr(r->forwarded_uri, '?');
+ char arg[128];
+ if (q && web_form_get(q + 1, strlen(q + 1), "arg", arg, sizeof arg) == 0 &&
+ web_store_by_handle(&g->st, arg, util_now()) != s) {
+ text(fd, "403 Forbidden", "fel session\n");
+ return;
+ }
+ respond(fd, "200 OK", NULL, NULL, NULL, 0);
+}
+
+/* The terminal wrapper's call: handle -> bokfd session. Never routed by
+ Caddy; a request that came through a proxy is refused anyway. */
+static void h_redeem(int fd, struct gate *g, const struct web_req *r)
+{
+ char handle[128];
+ struct web_session *s = NULL;
+ if (!r->forwarded_for[0] &&
+ web_form_get(r->body, r->body_len, "handle", handle,
+ sizeof handle) == 0)
+ s = web_store_by_handle(&g->st, handle, util_now());
+ if (!s || !bokfd_alive(g, s->bokf)) {
+ text(fd, "404 Not Found", "\n");
+ return;
+ }
+ text(fd, "200 OK", s->bokf);
+}
+
+static void handle(int fd, struct gate *g, const struct web_req *r)
+{
+ size_t bl = strlen(g->base);
+ const char *rest = strncmp(r->path, g->base, bl) == 0 ? r->path + bl
+ : NULL;
+ int get = strcmp(r->method, "GET") == 0 || strcmp(r->method, "HEAD") == 0;
+ int post = strcmp(r->method, "POST") == 0;
+ if (get && strcmp(r->path, "/healthz") == 0) {
+ text(fd, "200 OK", "ok\n");
+ } else if (get && strcmp(r->path, "/auth") == 0) {
+ h_auth(fd, g, r);
+ } else if (post && strcmp(r->path, "/redeem") == 0) {
+ h_redeem(fd, g, r);
+ } else if (rest && get &&
+ (!*rest || strcmp(rest, "/") == 0 ||
+ strcmp(rest, "/login") == 0)) {
+ struct web_session *s = live_session(g, r);
+ if (s)
+ to_terminal(fd, g, s, NULL);
+ else
+ login_page(fd, g, NULL, NULL);
+ } else if (rest && post && strcmp(rest, "/login") == 0) {
+ h_login_post(fd, g, r);
+ } else if (rest && get && strcmp(rest, "/logout") == 0) {
+ h_logout(fd, g, r);
+ } else {
+ text(fd, "404 Not Found", "not found\n");
+ }
+}
+
+/* --- server ------------------------------------------------------- */
+
+static int listen_on(const char *spec)
+{
+ char host[64] = "127.0.0.1";
+ int port = 7682;
+ const char *colon = strrchr(spec, ':');
+ if (colon) {
+ snprintf(host, sizeof host, "%.*s", (int)(colon - spec), spec);
+ port = atoi(colon + 1);
+ }
+ struct sockaddr_in sa;
+ memset(&sa, 0, sizeof sa);
+ sa.sin_family = AF_INET;
+ sa.sin_port = htons((unsigned short)port);
+ if (port <= 0 || port > 65535 ||
+ inet_pton(AF_INET, host, &sa.sin_addr) != 1) {
+ log_error("BOKFWEB_LISTEN must be ipv4:port, got %s", spec);
+ return -1;
+ }
+ int fd = socket(AF_INET, SOCK_STREAM | SOCK_CLOEXEC, 0);
+ int one = 1;
+ if (fd < 0 ||
+ setsockopt(fd, SOL_SOCKET, SO_REUSEADDR, &one, sizeof one) != 0 ||
+ bind(fd, (struct sockaddr *)&sa, sizeof sa) != 0 ||
+ listen(fd, 64) != 0) {
+ log_error("cannot listen on %s: %s", spec, strerror(errno));
+ if (fd >= 0)
+ close(fd);
+ return -1;
+ }
+ return fd;
+}
+
+static void serve(int cfd, struct gate *g)
+{
+ struct timeval tv = { 5, 0 };
+ setsockopt(cfd, SOL_SOCKET, SO_RCVTIMEO, &tv, sizeof tv);
+ setsockopt(cfd, SOL_SOCKET, SO_SNDTIMEO, &tv, sizeof tv);
+ char buf[WEB_MAX_REQUEST + 1];
+ size_t n = 0;
+ struct web_req r;
+ for (;;) {
+ ssize_t got = recv(cfd, buf + n, sizeof buf - 1 - n, 0);
+ if (got < 0 && errno == EINTR)
+ continue;
+ if (got <= 0)
+ return; /* closed or timed out before a full request */
+ n += (size_t)got;
+ buf[n] = '\0';
+ int pr = web_parse_request(buf, n, &r);
+ if (pr == 0)
+ break;
+ if (pr < 0 || n >= sizeof buf - 1) {
+ text(cfd, "400 Bad Request", "bad request\n");
+ return;
+ }
+ }
+ handle(cfd, g, &r);
+ memset(buf, 0, n); /* the body may hold a password */
+}
+
+int main(void)
+{
+ signal(SIGPIPE, SIG_IGN);
+ const char *lvl = getenv("BOKFWEB_LOG_LEVEL");
+ log_set_level(lvl ? log_level_from_name(lvl) : LOG_INFO);
+ static struct gate g;
+ g.bokfd = getenv("BOKFD_SOCKET");
+ if (!g.bokfd || !*g.bokfd)
+ g.bokfd = "/run/bokfd/bokfd.sock";
+ g.base = getenv("BOKFWEB_BASE");
+ if (!g.base || !*g.base)
+ g.base = "/web";
+ const char *insecure = getenv("BOKFWEB_INSECURE_COOKIE");
+ g.secure = !(insecure && strcmp(insecure, "1") == 0);
+ const char *spec = getenv("BOKFWEB_LISTEN");
+ int lfd = listen_on(spec && *spec ? spec : "127.0.0.1:7682");
+ if (lfd < 0)
+ return 1;
+ log_info("bokfweb listening on %s, base %s, bokfd %s",
+ spec && *spec ? spec : "127.0.0.1:7682", g.base, g.bokfd);
+ for (;;) {
+ int cfd = accept4(lfd, NULL, NULL, SOCK_CLOEXEC);
+ if (cfd < 0) {
+ if (errno != EINTR)
+ log_warn("accept: %s", strerror(errno));
+ continue;
+ }
+ serve(cfd, &g);
+ close(cfd);
+ }
+}
diff --git a/clients/client.c b/clients/client.c
index 30f2a67..549978d 100644
--- a/clients/client.c
+++ b/clients/client.c
@@ -388,6 +388,7 @@ static int session_open(struct client_conn *c, const char *args,
}
*session_out = xstrdup(yyjson_get_str(s));
yyjson_doc_free(d);
+ free(resp);
return 0;
}
diff --git a/clients/screens_bokslut.c b/clients/screens_bokslut.c
index 2328c01..acbb098 100644
--- a/clients/screens_bokslut.c
+++ b/clients/screens_bokslut.c
@@ -844,6 +844,8 @@ static void strip_markup(const char *in, struct buf *out)
static void arsredovisning_save(struct app *a, const char *text)
{
+ if (ui_web_block("Årsredovisning"))
+ return;
const char *home = getenv("HOME");
char def[512];
snprintf(def, sizeof def, "%s/Downloads", home && *home ? home : ".");
diff --git a/clients/screens_reports.c b/clients/screens_reports.c
index a06e914..1077fe0 100644
--- a/clients/screens_reports.c
+++ b/clients/screens_reports.c
@@ -701,6 +701,8 @@ static void fmt_verifikationslista(struct buf *t, const struct app *a,
static void eskd_save(struct app *a, const char *from, const char *to)
{
+ if (ui_web_block("eSKD"))
+ return;
const char *home = getenv("HOME");
char period[8], def[512], full[512];
snprintf(period, sizeof period, "%.4s%.2s", to, to + 5);
@@ -957,6 +959,8 @@ static void fmt_ink2(struct buf *t, const struct app *a, yyjson_val *res)
static void sru_save(struct app *a)
{
+ if (ui_web_block("SRU"))
+ return;
const char *home = getenv("HOME");
char def[512];
snprintf(def, sizeof def, "%s/Downloads", home && *home ? home : ".");
diff --git a/clients/ui.c b/clients/ui.c
index 2eb2dfe..99ad213 100644
--- a/clients/ui.c
+++ b/clients/ui.c
@@ -338,6 +338,8 @@ static void expand_path(const char *in, char *out, size_t n)
Returns a malloc'd path, or NULL on cancel. */
char *file_browser(struct app *a, const char *start_dir)
{
+ if (ui_web_block("Välj fil"))
+ return NULL;
if (g_quit)
return NULL;
char dir[1024];
@@ -478,6 +480,27 @@ static void safe_fname(const char *in, char *out, size_t n)
out[o] = '\0';
}
+int ui_web_mode(void)
+{
+ static int mode = -1;
+ if (mode < 0) {
+ const char *v = getenv("BOKF_WEB");
+ mode = v && strcmp(v, "1") == 0;
+ }
+ return mode;
+}
+
+int ui_web_block(const char *title)
+{
+ if (!ui_web_mode())
+ return 0;
+ tui_message(title,
+ "Inte tillgängligt i webbversionen ännu: filer kan inte\n"
+ "laddas upp, sparas eller öppnas härifrån.\n"
+ "Använd bokftui på datorn för det.");
+ return 1;
+}
+
/* Open a saved file with xdg-open when a desktop session is present;
otherwise (or on fork failure) show where it was saved. */
static void open_saved(const char *path, const char *title)
@@ -507,6 +530,8 @@ static void open_saved(const char *path, const char *title)
static void save_cache_and_open(const unsigned char *data, size_t n,
const char *filename, const char *title)
{
+ if (ui_web_block(title))
+ return;
char dir[512], path[700], safe[600];
pdf_cache_dir(dir, sizeof dir);
safe_fname(filename, safe, sizeof safe);
@@ -614,6 +639,8 @@ void attachment_download(struct app *a, int64_t att_id)
tui_message("Underlag", "Kunde inte avkoda innehållet.");
goto done;
}
+ if (ui_web_block("Underlag"))
+ goto done;
const char *home = getenv("HOME");
char def[600], dl[512];
snprintf(dl, sizeof dl, "%s/Downloads", home && *home ? home : ".");
diff --git a/clients/ui.h b/clients/ui.h
index 0fb533b..c221118 100644
--- a/clients/ui.h
+++ b/clients/ui.h
@@ -72,6 +72,12 @@ void kr_whole(int64_t ore, char *buf, size_t n);
int parse_x_double(const char *s, double *out);
void replace_x_into(const char *src, double x, char *dst, size_t cap);
void app_refresh_context(struct app *a);
+/* Web mode (BOKF_WEB=1, set by the browser-terminal wrapper): the TUI runs
+ on the web frontend, not on the user's machine, so nothing may read or
+ write files there or start programs. ui_web_block() shows why and
+ returns 1 in web mode; callers return early. */
+int ui_web_mode(void);
+int ui_web_block(const char *title);
int select_org(struct app *a);
char *rpc_dry(struct app *a, const char *cmd, const char *args);
char *read_file_b64(const char *path);
diff --git a/clients/web.c b/clients/web.c
new file mode 100644
index 0000000..cc70caf
--- /dev/null
+++ b/clients/web.c
@@ -0,0 +1,330 @@
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <strings.h>
+
+#include "web.h"
+
+/* Copies at most cap-1 bytes of [s, s+n) into out; -1 when it does not fit. */
+static int copy_n(char *out, size_t cap, const char *s, size_t n)
+{
+ if (!cap || n >= cap)
+ return -1;
+ memcpy(out, s, n);
+ out[n] = '\0';
+ return 0;
+}
+
+/* Value of header `name` (case-insensitive) in [h, end), trimmed. */
+static int header_get(const char *h, const char *end, const char *name,
+ char *out, size_t cap)
+{
+ size_t nl = strlen(name);
+ while (h < end) {
+ const char *eol = memchr(h, '\n', (size_t)(end - h));
+ if (!eol)
+ eol = end;
+ const char *line_end = eol > h && eol[-1] == '\r' ? eol - 1 : eol;
+ if ((size_t)(line_end - h) > nl && h[nl] == ':' &&
+ strncasecmp(h, name, nl) == 0) {
+ const char *v = h + nl + 1;
+ while (v < line_end && (*v == ' ' || *v == '\t'))
+ v++;
+ const char *ve = line_end;
+ while (ve > v && (ve[-1] == ' ' || ve[-1] == '\t'))
+ ve--;
+ return copy_n(out, cap, v, (size_t)(ve - v));
+ }
+ h = eol + 1;
+ }
+ if (cap)
+ out[0] = '\0';
+ return -1;
+}
+
+int web_parse_request(const char *buf, size_t n, struct web_req *r)
+{
+ memset(r, 0, sizeof *r);
+ if (n > WEB_MAX_REQUEST)
+ return -1;
+ const char *hend = NULL;
+ for (size_t i = 0; i + 3 < n; i++)
+ if (memcmp(buf + i, "\r\n\r\n", 4) == 0) {
+ hend = buf + i + 4;
+ break;
+ }
+ if (!hend)
+ return n >= WEB_MAX_REQUEST ? -1 : 1;
+
+ /* request line: METHOD SP TARGET SP HTTP/x.y */
+ const char *sp1 = memchr(buf, ' ', (size_t)(hend - buf));
+ if (!sp1 || copy_n(r->method, sizeof r->method, buf,
+ (size_t)(sp1 - buf)) != 0)
+ return -1;
+ const char *tgt = sp1 + 1;
+ const char *sp2 = memchr(tgt, ' ', (size_t)(hend - tgt));
+ if (!sp2 || sp2 == tgt || *tgt != '/')
+ return -1;
+ const char *q = memchr(tgt, '?', (size_t)(sp2 - tgt));
+ const char *pend = q ? q : sp2;
+ if (copy_n(r->path, sizeof r->path, tgt, (size_t)(pend - tgt)) != 0)
+ return -1;
+ if (q && copy_n(r->query, sizeof r->query, q + 1,
+ (size_t)(sp2 - q - 1)) != 0)
+ return -1;
+ if (strncmp(sp2 + 1, "HTTP/1.", 7) != 0)
+ return -1;
+
+ const char *hdrs = memchr(sp2, '\n', (size_t)(hend - sp2));
+ if (!hdrs)
+ return -1;
+ hdrs++;
+ header_get(hdrs, hend, "Cookie", r->cookie, sizeof r->cookie);
+ header_get(hdrs, hend, "X-Forwarded-For", r->forwarded_for,
+ sizeof r->forwarded_for);
+ header_get(hdrs, hend, "X-Forwarded-Uri", r->forwarded_uri,
+ sizeof r->forwarded_uri);
+ char cl[32];
+ size_t body_len = 0;
+ if (header_get(hdrs, hend, "Content-Length", cl, sizeof cl) == 0) {
+ char *e = NULL;
+ long long v = strtoll(cl, &e, 10);
+ if (!*cl || *e || v < 0 || v > WEB_MAX_REQUEST)
+ return -1;
+ body_len = (size_t)v;
+ }
+ char te[32];
+ if (header_get(hdrs, hend, "Transfer-Encoding", te, sizeof te) == 0)
+ return -1; /* chunked bodies are not needed here */
+ size_t have = n - (size_t)(hend - buf);
+ if (have < body_len)
+ return (size_t)(hend - buf) + body_len > WEB_MAX_REQUEST ? -1 : 1;
+ r->body = hend;
+ r->body_len = body_len;
+ return 0;
+}
+
+static int hexval(char c)
+{
+ if (c >= '0' && c <= '9')
+ return c - '0';
+ if (c >= 'a' && c <= 'f')
+ return c - 'a' + 10;
+ if (c >= 'A' && c <= 'F')
+ return c - 'A' + 10;
+ return -1;
+}
+
+int web_form_get(const char *form, size_t len, const char *key, char *out,
+ size_t cap)
+{
+ size_t kl = strlen(key);
+ const char *p = form, *end = form + len;
+ while (p < end) {
+ const char *amp = memchr(p, '&', (size_t)(end - p));
+ const char *pe = amp ? amp : end;
+ const char *eq = memchr(p, '=', (size_t)(pe - p));
+ if (eq && (size_t)(eq - p) == kl && memcmp(p, key, kl) == 0) {
+ size_t o = 0;
+ for (const char *v = eq + 1; v < pe; v++) {
+ char c = *v;
+ if (c == '+') {
+ c = ' ';
+ } else if (c == '%') {
+ if (pe - v < 3)
+ return -1;
+ int hi = hexval(v[1]), lo = hexval(v[2]);
+ if (hi < 0 || lo < 0)
+ return -1;
+ c = (char)(hi * 16 + lo);
+ if (!c)
+ return -1;
+ v += 2;
+ }
+ if (o + 1 >= cap)
+ return -1;
+ out[o++] = c;
+ }
+ out[o] = '\0';
+ return 0;
+ }
+ p = pe + 1;
+ }
+ return -1;
+}
+
+int web_cookie_get(const char *header, const char *name, char *out,
+ size_t cap)
+{
+ size_t nl = strlen(name);
+ const char *p = header;
+ while (p && *p) {
+ while (*p == ' ' || *p == ';')
+ p++;
+ const char *semi = strchr(p, ';');
+ const char *pe = semi ? semi : p + strlen(p);
+ if ((size_t)(pe - p) > nl && p[nl] == '=' &&
+ strncmp(p, name, nl) == 0)
+ return copy_n(out, cap, p + nl + 1, (size_t)(pe - p - nl - 1));
+ p = semi ? semi + 1 : NULL;
+ }
+ return -1;
+}
+
+void web_html_escape(struct buf *b, const char *s)
+{
+ for (; s && *s; s++) {
+ switch (*s) {
+ case '&':
+ buf_append(b, "&amp;", 5);
+ break;
+ case '<':
+ buf_append(b, "&lt;", 4);
+ break;
+ case '>':
+ buf_append(b, "&gt;", 4);
+ break;
+ case '"':
+ buf_append(b, "&quot;", 6);
+ break;
+ case '\'':
+ buf_append(b, "&#39;", 5);
+ break;
+ default:
+ buf_append(b, s, 1);
+ }
+ }
+}
+
+int web_token_ok(const char *s)
+{
+ if (!s || !*s)
+ return 0;
+ for (; *s; s++)
+ if (!((*s >= 'A' && *s <= 'Z') || (*s >= 'a' && *s <= 'z') ||
+ (*s >= '0' && *s <= '9') || *s == '_' || *s == '-'))
+ return 0;
+ return 1;
+}
+
+/* --- sessions ------------------------------------------------------ */
+
+static int session_live(const struct web_session *s, int64_t now)
+{
+ return s->token[0] && now - s->created < WEB_SESSION_TTL;
+}
+
+struct web_session *web_store_add(struct web_store *st, const char *bokf,
+ const char *user, int64_t now)
+{
+ struct web_session *slot = NULL;
+ for (int i = 0; i < WEB_MAX_SESSIONS && !slot; i++)
+ if (!session_live(&st->s[i], now))
+ slot = &st->s[i];
+ if (!slot) { /* full: replace the oldest */
+ slot = &st->s[0];
+ for (int i = 1; i < WEB_MAX_SESSIONS; i++)
+ if (st->s[i].created < slot->created)
+ slot = &st->s[i];
+ }
+ memset(slot, 0, sizeof *slot);
+ char *tok = util_random_id("", 32);
+ char *hdl = util_random_id("", 24);
+ snprintf(slot->token, sizeof slot->token, "%s", tok);
+ snprintf(slot->handle, sizeof slot->handle, "%s", hdl);
+ free(tok);
+ free(hdl);
+ snprintf(slot->bokf, sizeof slot->bokf, "%s", bokf);
+ snprintf(slot->user, sizeof slot->user, "%s", user ? user : "");
+ slot->created = now;
+ return slot;
+}
+
+static struct web_session *store_find(struct web_store *st, const char *v,
+ int by_handle, int64_t now)
+{
+ if (!web_token_ok(v))
+ return NULL;
+ size_t vl = strlen(v);
+ for (int i = 0; i < WEB_MAX_SESSIONS; i++) {
+ struct web_session *s = &st->s[i];
+ const char *k = by_handle ? s->handle : s->token;
+ if (session_live(s, now) && strlen(k) == vl &&
+ util_const_eq(k, v, vl))
+ return s;
+ }
+ return NULL;
+}
+
+struct web_session *web_store_by_token(struct web_store *st,
+ const char *token, int64_t now)
+{
+ return store_find(st, token, 0, now);
+}
+
+struct web_session *web_store_by_handle(struct web_store *st,
+ const char *handle, int64_t now)
+{
+ return store_find(st, handle, 1, now);
+}
+
+void web_store_del(struct web_session *s)
+{
+ if (s)
+ memset(s, 0, sizeof *s);
+}
+
+/* --- limiter ------------------------------------------------------- */
+
+static struct web_rl_entry *rl_find(struct web_rl *rl, const char *addr)
+{
+ for (int i = 0; i < WEB_RL_SLOTS; i++)
+ if (rl->e[i].addr[0] && strcmp(rl->e[i].addr, addr) == 0)
+ return &rl->e[i];
+ return NULL;
+}
+
+int64_t web_rl_blocked(const struct web_rl *rl, const char *addr,
+ int64_t now)
+{
+ for (int i = 0; i < WEB_RL_SLOTS; i++) {
+ const struct web_rl_entry *e = &rl->e[i];
+ if (e->addr[0] && strcmp(e->addr, addr) == 0)
+ return e->fails >= WEB_RL_MAX_FAILS && e->window_end > now
+ ? e->window_end - now
+ : 0;
+ }
+ return 0;
+}
+
+void web_rl_fail(struct web_rl *rl, const char *addr, int64_t now)
+{
+ struct web_rl_entry *e = rl_find(rl, addr);
+ if (!e) {
+ /* a free or expired slot, else the one whose window ends first */
+ e = &rl->e[0];
+ for (int i = 0; i < WEB_RL_SLOTS; i++) {
+ struct web_rl_entry *c = &rl->e[i];
+ if (!c->addr[0] || c->window_end <= now) {
+ e = c;
+ break;
+ }
+ if (c->window_end < e->window_end)
+ e = c;
+ }
+ memset(e, 0, sizeof *e);
+ snprintf(e->addr, sizeof e->addr, "%s", addr);
+ }
+ if (e->fails == 0 || e->window_end <= now) {
+ e->fails = 0;
+ e->window_end = now + WEB_RL_WINDOW;
+ }
+ e->fails++;
+}
+
+void web_rl_ok(struct web_rl *rl, const char *addr)
+{
+ struct web_rl_entry *e = rl_find(rl, addr);
+ if (e)
+ memset(e, 0, sizeof *e);
+}
diff --git a/clients/web.h b/clients/web.h
new file mode 100644
index 0000000..a652498
--- /dev/null
+++ b/clients/web.h
@@ -0,0 +1,100 @@
+#ifndef BOKF_WEB_H
+#define BOKF_WEB_H
+
+#include <stddef.h>
+#include <stdint.h>
+
+#include "util.h"
+
+/* Pure parts of bokfweb, the login gate in front of the browser terminal:
+ HTTP request parsing, form and cookie decoding, HTML escaping, the
+ session store and the per-address login limiter. Unit-tested in
+ tests/test_web.c; bokfweb.c adds sockets and the bokfd calls. */
+
+#define WEB_MAX_REQUEST 8192
+
+struct web_req {
+ char method[8];
+ char path[256]; /* without the query */
+ char query[256];
+ char cookie[512];
+ char forwarded_for[64];
+ char forwarded_uri[512];
+ const char *body; /* points into the parsed buffer */
+ size_t body_len;
+};
+
+/* Parses one HTTP/1.x request in buf[0..n). Returns 0 when complete
+ (headers and Content-Length bytes of body), 1 when more bytes are
+ needed, -1 when malformed or too large. */
+int web_parse_request(const char *buf, size_t n, struct web_req *r);
+
+/* Value of key in an application/x-www-form-urlencoded string (also a
+ query string), decoded ('+' and %XX). 0 when found, -1 otherwise; a
+ value that does not fit or decodes to a NUL byte counts as not found. */
+int web_form_get(const char *form, size_t len, const char *key, char *out,
+ size_t cap);
+
+/* Value of cookie `name` in a Cookie header. 0 when found. */
+int web_cookie_get(const char *header, const char *name, char *out,
+ size_t cap);
+
+/* Appends s to b with & < > " ' escaped. */
+void web_html_escape(struct buf *b, const char *s);
+
+/* Whether s is a non-empty token of [A-Za-z0-9_-] only (cookie values and
+ handles are generated that way; anything else is rejected unread). */
+int web_token_ok(const char *s);
+
+/* --- sessions: cookie token -> bokfd session, plus the terminal handle */
+
+#define WEB_MAX_SESSIONS 64
+#define WEB_SESSION_TTL (12 * 3600) /* absolute; bokfd's idle TTL applies too */
+
+struct web_session {
+ char token[64]; /* the cookie value */
+ char handle[64]; /* goes into the terminal URL (?arg=) */
+ char bokf[128]; /* bokfd session id */
+ char user[64];
+ int64_t created;
+};
+
+struct web_store {
+ struct web_session s[WEB_MAX_SESSIONS];
+};
+
+/* Adds a session with fresh random token and handle; the oldest session is
+ replaced when the store is full. Returns it, or NULL when the random
+ source fails. */
+struct web_session *web_store_add(struct web_store *st, const char *bokf,
+ const char *user, int64_t now);
+/* The live session with this cookie token / terminal handle, or NULL. */
+struct web_session *web_store_by_token(struct web_store *st,
+ const char *token, int64_t now);
+struct web_session *web_store_by_handle(struct web_store *st,
+ const char *handle, int64_t now);
+void web_store_del(struct web_session *s);
+
+/* --- failed-login limiter per client address */
+
+#define WEB_RL_SLOTS 256
+#define WEB_RL_MAX_FAILS 5
+#define WEB_RL_WINDOW 900
+
+struct web_rl_entry {
+ char addr[64];
+ int fails;
+ int64_t window_end;
+};
+
+struct web_rl {
+ struct web_rl_entry e[WEB_RL_SLOTS];
+};
+
+/* Seconds until addr may try again, 0 when it may try now. */
+int64_t web_rl_blocked(const struct web_rl *rl, const char *addr,
+ int64_t now);
+void web_rl_fail(struct web_rl *rl, const char *addr, int64_t now);
+void web_rl_ok(struct web_rl *rl, const char *addr);
+
+#endif
diff --git a/compose.yaml b/compose.yaml
index 25cb1eb..01825e4 100644
--- a/compose.yaml
+++ b/compose.yaml
@@ -22,6 +22,31 @@ services:
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
+ # Web frontend: login gate + browser terminal on http://127.0.0.1:8790.
+ # The host's TLS proxy serves it as https://bokf.makandra.eu/web (see
+ # deploy/Caddyfile and docs/DEPLOY.md). Talks to bokfd over the socket
+ # only; read-only, no capabilities.
+ web:
+ image: ${BOKF_WEB_IMAGE:-bokf-web}:${BOKF_TAG:-latest}
+ restart: unless-stopped
+ depends_on:
+ - bokfd
+ ports:
+ - "127.0.0.1:8790:8790"
+ volumes:
+ - ./var/run:/run/bokfd
+ environment:
+ BOKF_WEB_MAX_SESSIONS: ${BOKF_WEB_MAX_SESSIONS:-20}
+ read_only: true
+ tmpfs:
+ - /tmp:size=256m,mode=1777
+ cap_drop:
+ - ALL
+ security_opt:
+ - no-new-privileges:true
+ pids_limit: 512
+ mem_limit: 2g
+
certs:
image: goacme/lego:latest
restart: unless-stopped
diff --git a/deploy/Caddyfile b/deploy/Caddyfile
new file mode 100644
index 0000000..c86f4e5
--- /dev/null
+++ b/deploy/Caddyfile
@@ -0,0 +1,47 @@
+# Web frontend routing inside the bokf-web container: the login gate
+# (bokfweb, 127.0.0.1:7682) and the browser terminal (ttyd, 127.0.0.1:7681).
+# Nothing reaches ttyd without the gate's OK.
+#
+# Plain HTTP on :8790, published on the host's loopback only. TLS for
+# https://bokf.makandra.eu is the host's reverse proxy (the NAS Caddy):
+#
+# bokf.makandra.eu {
+# tls { dns inwx ... } # as for the other sites
+# reverse_proxy 127.0.0.1:8790
+# }
+{
+ auto_https off
+ admin off
+ servers {
+ # the host proxy's X-Forwarded-For names the real client (the
+ # gate limits failed logins per client address)
+ trusted_proxies static private_ranges
+ }
+}
+
+:8790 {
+ header {
+ Strict-Transport-Security "max-age=31536000"
+ -Server
+ }
+
+ redir / /web/ 302
+
+ # the terminal: only with a live login whose handle is in the URL
+ @tty path /web/tty /web/tty/*
+ handle @tty {
+ forward_auth 127.0.0.1:7682 {
+ uri /auth
+ }
+ reverse_proxy 127.0.0.1:7681
+ }
+
+ # login page, login and logout
+ handle /web* {
+ reverse_proxy 127.0.0.1:7682
+ }
+
+ handle {
+ respond "not found" 404
+ }
+}
diff --git a/deploy/Dockerfile.cross b/deploy/Dockerfile.cross
index ab0504c..a14ac0f 100644
--- a/deploy/Dockerfile.cross
+++ b/deploy/Dockerfile.cross
@@ -1,12 +1,12 @@
-# Cross-compile the static arm64 backend binaries on an amd64 host. The
-# runtime image is assembled later on the target host from the produced
-# binaries; there is no TUI in the runtime image.
+# Cross-compile the static arm64 binaries on an amd64 host: the backend
+# (bokfd, bokfctl) and the web frontend's bokftui and bokfweb. The images
+# are assembled later on the target host from the produced binaries.
FROM debian:bookworm
RUN dpkg --add-architecture arm64 \
&& apt-get update \
&& apt-get install -y --no-install-recommends \
make ca-certificates gcc-aarch64-linux-gnu \
- libc6-dev:arm64 libssl-dev:arm64 \
+ libc6-dev:arm64 libssl-dev:arm64 libncurses-dev:arm64 \
&& rm -rf /var/lib/apt/lists/*
COPY cross-build.sh /usr/local/bin/cross-build
RUN chmod 0755 /usr/local/bin/cross-build
diff --git a/deploy/bokftui-web b/deploy/bokftui-web
new file mode 100755
index 0000000..b6ec9b5
--- /dev/null
+++ b/deploy/bokftui-web
@@ -0,0 +1,40 @@
+#!/bin/sh
+# Started by ttyd for every browser terminal, with the terminal handle from
+# the URL (?arg=) as $1. Trades the handle for the bokfd session at the
+# gate (bokfweb /redeem, internal only), then runs bokftui already logged
+# in, in a private throwaway HOME, in web mode (no local files), with
+# resource limits. No shell is ever offered: when bokftui exits, the
+# terminal ends.
+set -u
+gate="${BOKFWEB_INTERNAL:-http://127.0.0.1:7682}"
+
+msg() {
+ printf '\r\n %s\r\n\r\n' "$1"
+ sleep 4
+ exit 1
+}
+
+handle="${1:-}"
+case "$handle" in
+ "" | *[!A-Za-z0-9_-]*) msg "Ogiltig länk. Öppna /web/ och logga in igen." ;;
+esac
+
+session=$(wget -q -O - --post-data "handle=$handle" "$gate/redeem" 2>/dev/null) ||
+ msg "Sessionen har gått ut. Öppna /web/ och logga in igen."
+case "$session" in
+ "" | *[!A-Za-z0-9_-]*) msg "Sessionen har gått ut. Öppna /web/ och logga in igen." ;;
+esac
+
+home=$(mktemp -d /tmp/bokf-web.XXXXXXXX) || msg "Kunde inte starta sessionen."
+trap 'rm -rf "$home"' EXIT HUP INT TERM
+chmod 0700 "$home"
+
+# per-session limits: memory, CPU time, open files (the process count is
+# capped per container in compose.yaml: every session runs as one uid)
+ulimit -v 524288 2>/dev/null || true
+ulimit -t 7200 2>/dev/null || true
+ulimit -n 256 2>/dev/null || true
+
+HOME="$home" XDG_CONFIG_HOME="$home/.config" XDG_CACHE_HOME="$home/.cache" \
+BOKF_WEB=1 BOKFD_SESSION="$session" \
+ bokftui --socket "${BOKFD_SOCKET:-/run/bokfd/bokfd.sock}"
diff --git a/deploy/cross-build.sh b/deploy/cross-build.sh
index 19d07b7..54a2929 100644
--- a/deploy/cross-build.sh
+++ b/deploy/cross-build.sh
@@ -1,16 +1,20 @@
#!/bin/sh
-# Cross-compile the static aarch64 backend binaries (bokfd, bokfctl).
+# Cross-compile the static aarch64 binaries: bokfd, bokfctl and, for the
+# web image, bokftui and bokfweb.
# Expects the source read-only at /src, writes the binaries to /out and takes
# the version from $VERSION. Run through deploy/Dockerfile.cross. OpenSSL and
# glibc are linked statically, so the Alpine runtime image needs no shared
# libraries (the kernel ABI is all that matters).
set -eu
-make -C /src -j"$(nproc)" BUILD=/tmp/build backend \
+# ncursesw needs its tinfo half spelled out for a static link
+make -C /src -j"$(nproc)" BUILD=/tmp/build \
+ backend /tmp/build/bokftui /tmp/build/bokfweb \
CC=aarch64-linux-gnu-gcc \
CFLAGS="-O2 -g -static -L/usr/lib/aarch64-linux-gnu" \
- SSL_LIBS="-l:libssl.a -l:libcrypto.a -ldl -lpthread" \
+ SSL_LIBS="-l:libssl.a -l:libcrypto.a -l:libtinfo.a -ldl -lpthread" \
VERSION="${VERSION:-0.1.0-dev}"
mkdir -p /out
-cp /tmp/build/bokfd /tmp/build/bokfctl /out/
+cp /tmp/build/bokfd /tmp/build/bokfctl /tmp/build/bokftui \
+ /tmp/build/bokfweb /out/
diff --git a/deploy/web-entrypoint.sh b/deploy/web-entrypoint.sh
new file mode 100755
index 0000000..38e07f9
--- /dev/null
+++ b/deploy/web-entrypoint.sh
@@ -0,0 +1,26 @@
+#!/bin/sh
+# Web frontend container: bokfweb (login gate), ttyd (browser terminal)
+# and Caddy (routing on :8790, behind the host's TLS proxy). The gate and
+# ttyd listen on 127.0.0.1 only. Each helper is restarted if it dies; Caddy
+# is the main process, so the container stops (and compose restarts it)
+# when Caddy does.
+set -eu
+
+export BOKFD_SOCKET="${BOKFD_SOCKET:-/run/bokfd/bokfd.sock}"
+export XDG_DATA_HOME=/tmp/caddy-data XDG_CONFIG_HOME=/tmp/caddy-config
+
+keep() {
+ while :; do
+ "$@" || true
+ echo "web: $1 exited, restarting" >&2
+ sleep 1
+ done
+}
+
+keep bokfweb &
+keep ttyd -i 127.0.0.1 -p 7681 -b /web/tty -a -W -O \
+ -m "${BOKF_WEB_MAX_SESSIONS:-20}" \
+ -t titleFixed=bokf -t fontSize=15 -t disableLeaveAlert=true \
+ bokftui-web &
+
+exec caddy run --config /etc/caddy/Caddyfile --adapter caddyfile
diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md
index c18f49b..e317758 100644
--- a/docs/DECISIONS.md
+++ b/docs/DECISIONS.md
@@ -275,6 +275,20 @@ kept verbatim from the STATE.md they were pruned from (2026-09-21).
the key parts of #11 and #28; `make check` rejects F-keys, `^N` and
`^Enter` in `clients/`.
+30. **Web frontend (2026-09-23)**: bokftui runs in the browser through
+ ttyd in its own container (`bokf-web`), behind a login gate in C
+ (`bokfweb`) that authenticates with bokfd's `session.open` — no second
+ password store — and one login: the gate hands the bokfd session to
+ the TUI through a terminal handle that only works with the login's
+ cookie. Caddy in the container does the routing and `forward_auth`;
+ TLS stays with the host's existing Caddy (port 443 was taken), which
+ proxies `bokf.makandra.eu` to `127.0.0.1:8790`. Every terminal is an
+ isolated process (private HOME, limits, `BOKF_WEB=1`: no local files or
+ programs). bokfd's login limiter became per user name (it was one
+ global counter, so any 5 wrong guesses locked out everybody) and the
+ gate limits per client address. Audience: the owner and Petter first,
+ prepared for more users.
+
## Completed work formerly listed under "Pending decisions"
- Attachments are complete: download (voucher detail `f`, Underlag `Enter`,
diff --git a/docs/DEPLOY.md b/docs/DEPLOY.md
index 6fe6a53..99b7661 100644
--- a/docs/DEPLOY.md
+++ b/docs/DEPLOY.md
@@ -224,6 +224,66 @@ 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).
+## Web frontend (`https://bokf.makandra.eu/web`)
+
+The `web` service (image `bokf-web`, built from the `web` target of the
+same Dockerfile and shipped by `scripts/deploy.sh` with the daemon) serves
+bokftui in the browser:
+
+```
+browser ──443──▶ host Caddy (TLS) ──▶ 127.0.0.1:8790 bokf-web container
+ Caddy: routing + forward_auth
+ ├─ /web, /web/login, /web/logout ─▶ bokfweb (login gate)
+ └─ /web/tty/* (gate OK only) ────▶ ttyd ─▶ bokftui-web ─▶ bokftui
+ │
+ bokfd ◀── unix socket ┘
+```
+
+- **Login gate** (`bokfweb`, `clients/bokfweb.c`): the login page checks
+ the credentials with bokfd's own `session.open` — there is no second
+ password store, and bokfd's audit and per-user lockout apply. The gate
+ limits failed logins per client address (5 per 15 min) before bokfd's
+ limit is reached. A login sets a cookie (`HttpOnly`, `Secure`,
+ `SameSite=Strict`, 12 h) and redirects to `/web/tty/?arg=<handle>`.
+- **One login**: the terminal wrapper (`deploy/bokftui-web`) trades the
+ handle for the bokfd session on the gate's internal `/redeem` (never
+ routed by Caddy) and starts `bokftui` with `BOKFD_SESSION`, so the TUI
+ opens logged in. Caddy's `forward_auth` lets a request reach ttyd only
+ with a live cookie whose session owns the handle in the URL; a leaked
+ URL is useless without the cookie. Quitting the TUI (or `/web/logout`)
+ closes the bokfd session and with it the web session.
+- **Isolation**: every browser terminal is its own bokftui process with a
+ private throwaway `HOME` (config, drafts, log), memory/CPU/file limits,
+ and `BOKF_WEB=1`: the TUI refuses everything that would read or write
+ files or start programs on the frontend (file browser, save prompts,
+ downloads, the PDF viewer). No shell is ever offered. The container runs
+ as uid 10001, read-only root, `/tmp` tmpfs, all capabilities dropped,
+ `no-new-privileges`, pids/memory limits, and it sees bokfd only through
+ the protocol socket (no database, no secrets, no certificates).
+- **TLS** is the host's reverse proxy. On the NAS, add to the existing
+ Caddy (`/mnt/data/caddy/Caddyfile`) a site block like the others and
+ reload it:
+
+ ```
+ bokf.makandra.eu {
+ tls {
+ dns inwx { ... } # as for the other sites
+ }
+ reverse_proxy 127.0.0.1:8790
+ }
+ ```
+
+ Port 443 is already forwarded for the other sites. The container
+ publishes 8790 on the host's loopback only, so it is unreachable until
+ that block exists.
+- `BOKF_WEB_MAX_SESSIONS` (default 20) caps concurrent terminals.
+ `docker compose logs web` shows logins, logouts and failed attempts
+ (never passwords).
+
+Not yet in the web version: uploading and downloading files (attachments,
+bank files, SRU/eSKD/årsredovisning files, PDFs). The TUI says so where it
+applies; use bokftui on a computer for those.
+
## Mock company
```sh
diff --git a/docs/PROTOCOL.md b/docs/PROTOCOL.md
index 709a475..3c8ba54 100644
--- a/docs/PROTOCOL.md
+++ b/docs/PROTOCOL.md
@@ -85,7 +85,11 @@ and returns an opaque, high-entropy session id:
intentional. API tokens survive restarts.
- Sliding TTL, `session_ttl` default 8 h. `session.close` ends one explicitly.
- Passwords are stored as Argon2id hashes. Failed logins are rate limited per
- peer (default: 5 failures per 15 minutes, then `RATE_LIMITED`).
+ user name (5 failures per 15 minutes, then `RATE_LIMITED` for that name
+ only): guessing one account never locks out the others. The table of
+ counters replaces expired or least-failed entries when full, so flooding
+ it with names neither disables the limiter nor lifts a block. The web
+ gate (`bokfweb`) adds its own limit per client address in front of this.
- A user changes their own password with `user.set_password` from a
password session (a token session gets `FORBIDDEN`). The current password
is required — 5 wrong ones per 15 minutes give `RATE_LIMITED` — and the
@@ -946,7 +950,10 @@ commands. Implemented screens (0.1.0-dev):
- **Inloggning** — server, user, password; org picker when several exist.
**Byt bolag** in the main menu reopens the picker during the session;
- **Byt lösenord** calls `user.set_password`.
+ **Byt lösenord** calls `user.set_password`. The same TUI runs in the
+ browser at `/web` (DEPLOY.md "Web frontend"): the login page opens the
+ session with `session.open` and the TUI reuses it; file features are
+ off there.
- **Dashboard** — status line with org, fiscal year, role and user.
- **Verifikat** — list and detail view (rows with column headers, an
underlag section separated by a rule, hash, link to corrected voucher); `c`
@@ -1122,7 +1129,7 @@ beyond the session and calls nothing but public commands.
| `session_ttl` | `8h` | sliding session lifetime |
| `max_line_bytes` | `1048576` | NDJSON line limit |
| `max_attachment_bytes` | `10485760` (10 MiB) | decoded attachment limit |
-| `auth_fail_limit` | `5/15m` | login rate limit per peer |
+| `auth_fail_limit` | `5/15m` | login rate limit per user name (fixed in the code) |
| `synchronous` | `FULL` | SQLite durability (`FULL`/`NORMAL`) |
| `audit_reads` | `false` | log read commands too |
| `allow_org_create` | `true` | any user may create an org |
diff --git a/docs/STATE.md b/docs/STATE.md
index 77ef9a4..93ade1e 100644
--- a/docs/STATE.md
+++ b/docs/STATE.md
@@ -14,6 +14,16 @@ unit tests and the docs consistency check.
## Resume here (2026-09-23)
+- **Web frontend (2026-09-23, branch `feat/web-frontend`, not deployed)**:
+ image `bokf-web` (Caddy routing + `bokfweb` login gate + ttyd +
+ bokftui in web mode) as compose service `web` on `127.0.0.1:8790`; see
+ DEPLOY.md "Web frontend" and decision #30. Tested end to end locally
+ (login, one-login handoff, foreign handle 403, logout, per-address
+ limit) and the aarch64 static build. **To go live**: deploy, then add
+ the `bokf.makandra.eu` block to the NAS Caddy
+ (`/mnt/data/caddy/Caddyfile`) and reload it. bokfd's login limiter is
+ now per user name (was global: 5 wrong guesses locked out everyone).
+ Next: file upload/download through the browser for the web mode.
- **Context menu and web-safe keys (2026-09-23, branch
`eff/context-menu`)**: `→` (or `^O`, also in table cells) opens
"Åtgärder", a box at the right edge with every action of the view and
diff --git a/docs/TUI-GUIDELINES.md b/docs/TUI-GUIDELINES.md
index 650f026..c223514 100644
--- a/docs/TUI-GUIDELINES.md
+++ b/docs/TUI-GUIDELINES.md
@@ -145,6 +145,13 @@ terminal and browser delivers (checked by `make check`):
reload — `^R` is a developer convenience and never the only way).
- Every action is reachable with arrows + `Enter` through the menu; a
letter accelerator is a shortcut, never the only path.
+- **Web mode** (`BOKF_WEB=1`, set by the browser-terminal wrapper): the TUI
+ runs on the web frontend, not on the user's machine, so a screen never
+ reads or writes a local file or starts a program there. Every such path
+ goes through `ui_web_block(title)` (`clients/ui.c`), which shows why and
+ returns 1: the file browser, `save_cache_and_open` (PDF viewer), the
+ attachment download and the SRU/eSKD/årsredovisning save prompts. New
+ file features must call it too (pty scenario `web-mode`).
### Implementation status
diff --git a/scripts/deploy.sh b/scripts/deploy.sh
index b625ac5..29e477f 100755
--- a/scripts/deploy.sh
+++ b/scripts/deploy.sh
@@ -143,19 +143,29 @@ if [ "$DEV" = 1 ]; then
exit 1
fi
+# Two images from one Dockerfile: bokf (the daemon, the last stage) and
+# bokf-web (the web frontend, target "web").
if [ "$BOKF_BUILD" = local ]; then
- echo "deploy: building image bokf:$TAG locally"
+ echo "deploy: building images bokf:$TAG and bokf-web:$TAG locally"
docker build --build-arg "VERSION=$TAG" -t "bokf:$TAG" .
- echo "deploy: shipping image to $BOKF_HOST"
- docker save "bokf:$TAG" | gzip | "${SSH[@]}" 'gunzip | docker load'
+ docker build --build-arg "VERSION=$TAG" --target web \
+ -t "bokf-web:$TAG" .
+ echo "deploy: shipping images to $BOKF_HOST"
+ docker save "bokf:$TAG" "bokf-web:$TAG" | gzip |
+ "${SSH[@]}" 'gunzip | docker load'
else
cross_build
- echo "deploy: assembling image bokf:$TAG on $BOKF_HOST"
- tar -cf - \
- --exclude=./.git --exclude=./build --exclude=./var --exclude=./.env \
- --exclude='./*.se' --exclude='./*.db' --exclude='./*.db-wal' \
- --exclude='./*.db-shm' --exclude=./docs . |
+ source_tar() {
+ tar -cf - \
+ --exclude=./.git --exclude=./build --exclude=./var \
+ --exclude=./.env --exclude='./*.se' --exclude='./*.db' \
+ --exclude='./*.db-wal' --exclude='./*.db-shm' --exclude=./docs .
+ }
+ echo "deploy: assembling images bokf:$TAG and bokf-web:$TAG on $BOKF_HOST"
+ source_tar |
"${SSH[@]}" "docker build --build-arg 'VERSION=$TAG' -t 'bokf:$TAG' -"
+ source_tar |
+ "${SSH[@]}" "docker build --build-arg 'VERSION=$TAG' --target web -t 'bokf-web:$TAG' -"
rm -rf .prebuilt
fi
@@ -182,6 +192,18 @@ remote "cd '$BOKF_REMOTE_DIR' && docker compose up -d --no-build"
if wait_healthy; then
echo "deploy: bokf:$TAG is healthy on $BOKF_HOST"
+ web=""
+ for _ in $(seq 1 15); do
+ web="$(remote "cd '$BOKF_REMOTE_DIR' && docker inspect --format '{{.State.Health.Status}}' \$(docker compose ps -q web) 2>/dev/null" || true)"
+ [ "$web" = healthy ] && break
+ sleep 2
+ done
+ if [ "$web" = healthy ]; then
+ echo "deploy: bokf-web:$TAG is healthy (http://127.0.0.1:8790 on the host)"
+ else
+ echo "deploy: warning: bokf-web is '${web:-not running}'" >&2
+ remote "cd '$BOKF_REMOTE_DIR' && docker compose logs --tail=20 web" >&2 || true
+ fi
exit 0
fi
diff --git a/scripts/tui-golden.py b/scripts/tui-golden.py
index bce00eb..deca6ef 100755
--- a/scripts/tui-golden.py
+++ b/scripts/tui-golden.py
@@ -709,6 +709,31 @@ SCENARIOS = [
],
},
{
+ # Web mode (the browser-terminal wrapper sets BOKF_WEB=1): nothing
+ # may touch the frontend's files or start a viewer there.
+ "name": "web-mode",
+ "screen": "vouchers",
+ "env": {"BOKF_WEB": "1"},
+ "steps": [
+ {
+ "keys": ["home", "enter"],
+ "expect": ["Golden verifikat", "a = bifoga"],
+ },
+ {
+ "keys": ["a"],
+ "expect": ["Inte tillgängligt i webbversionen"],
+ },
+ {
+ "keys": ["enter", "f", "enter", "right", "enter"],
+ "expect": ["Inte tillgängligt i webbversionen"],
+ },
+ {
+ "keys": ["enter"],
+ "expect": ["Underlag"],
+ },
+ ],
+ },
+ {
# Keep last: changes the rig's login password, then restores it so
# a rerun of the list still logs in.
"name": "change-password",
@@ -1362,7 +1387,9 @@ def main(argv):
"--socket", str(sock), "--user", "admin",
"--org", str(org_id), "--fy", str(fy["id"]),
"--screen", sc["screen"]]
- app = PtyApp(argv_tui, env, TERM_COLS, TERM_ROWS)
+ sc_env = dict(env)
+ sc_env.update(sc.get("env", {}))
+ app = PtyApp(argv_tui, sc_env, TERM_COLS, TERM_ROWS)
name = sc["name"]
missing = None
before = current = ""
diff --git a/src/cmd_auth.c b/src/cmd_auth.c
index 86dab12..502057c 100644
--- a/src/cmd_auth.c
+++ b/src/cmd_auth.c
@@ -18,38 +18,33 @@
/* login rate limiting (in-memory, per key) */
/* ------------------------------------------------------------------ */
-#define RL_MAX_KEYS 16
+/* Keyed per user name ("u:<name>") and per user for password changes
+ ("pw:<id>"): wrong guesses lock the guessed account, never everybody.
+ A full table reuses an expired entry, else the least-failed one, so the
+ limiter never switches itself off. */
+#define RL_MAX_KEYS 1024
#define RL_MAX_FAILS 5
#define RL_WINDOW 900
struct rl_entry {
- char key[64];
+ char key[80];
int fails;
int64_t window_end;
};
static struct rl_entry g_rl[RL_MAX_KEYS];
-static struct rl_entry *rl_get(const char *key, int create)
+static struct rl_entry *rl_find(const char *key)
{
- struct rl_entry *slot = NULL;
- for (size_t i = 0; i < RL_MAX_KEYS; i++) {
+ for (size_t i = 0; i < RL_MAX_KEYS; i++)
if (g_rl[i].key[0] && strcmp(g_rl[i].key, key) == 0)
return &g_rl[i];
- if (create && !g_rl[i].key[0] && !slot)
- slot = &g_rl[i];
- }
- if (create && slot) {
- snprintf(slot->key, sizeof slot->key, "%s", key);
- slot->fails = 0;
- slot->window_end = 0;
- }
- return slot;
+ return NULL;
}
static int rl_blocked(const char *key, int64_t *retry_after)
{
- struct rl_entry *e = rl_get(key, 0);
+ struct rl_entry *e = rl_find(key);
if (!e || e->fails < RL_MAX_FAILS)
return 0;
int64_t now = util_now();
@@ -62,22 +57,38 @@ static int rl_blocked(const char *key, int64_t *retry_after)
static void rl_fail(const char *key)
{
- struct rl_entry *e = rl_get(key, 1);
- if (!e)
- return;
int64_t now = util_now();
- if (e->fails == 0 || e->window_end <= now)
+ struct rl_entry *e = rl_find(key);
+ if (!e) {
+ /* a free or expired slot, else the one with the fewest failures
+ (a blocked entry is replaced last, so flooding the table with
+ names does not lift a block) */
+ e = &g_rl[0];
+ for (size_t i = 0; i < RL_MAX_KEYS; i++) {
+ struct rl_entry *c = &g_rl[i];
+ if (!c->key[0] || c->window_end <= now) {
+ e = c;
+ break;
+ }
+ if (c->fails < e->fails ||
+ (c->fails == e->fails && c->window_end < e->window_end))
+ e = c;
+ }
+ memset(e, 0, sizeof *e);
+ snprintf(e->key, sizeof e->key, "%s", key);
+ }
+ if (e->fails == 0 || e->window_end <= now) {
+ e->fails = 0;
e->window_end = now + RL_WINDOW;
+ }
e->fails++;
}
static void rl_ok(const char *key)
{
- struct rl_entry *e = rl_get(key, 0);
- if (e) {
- e->fails = 0;
- e->window_end = 0;
- }
+ struct rl_entry *e = rl_find(key);
+ if (e)
+ memset(e, 0, sizeof *e);
}
/* ------------------------------------------------------------------ */
@@ -188,7 +199,9 @@ static yyjson_mut_val *h_session_open(struct req *r)
return fail(r, "INVALID_ARGS", "username and password are required");
}
int64_t retry = 0;
- if (rl_blocked("local", &retry)) {
+ char rlkey[80];
+ snprintf(rlkey, sizeof rlkey, "u:%s", username);
+ if (rl_blocked(rlkey, &retry)) {
free(reqjson);
return failf(r, "RATE_LIMITED",
"too many failed logins, retry in %lld seconds",
@@ -202,14 +215,14 @@ static yyjson_mut_val *h_session_open(struct req *r)
auth_verify_password(pwhash, password) == 0;
free(pwhash);
if (!ok) {
- rl_fail("local");
+ rl_fail(rlkey);
audit_append(r->db, 0, uid > 0 ? uid : 0, 0, "auth.fail", reqjson,
"AUTH_FAILED", NULL);
free(display);
free(reqjson);
return fail(r, "AUTH_FAILED", "invalid credentials");
}
- rl_ok("local");
+ rl_ok(rlkey);
int64_t active = 0;
yyjson_mut_val *orgs =
orgs_for_user(r->db, r->rdoc, uid, 0, &active);
diff --git a/tests/test_core.c b/tests/test_core.c
index 6376517..a82c833 100644
--- a/tests/test_core.c
+++ b/tests/test_core.c
@@ -4904,7 +4904,15 @@ static void test_rate_limit(struct tctx *t)
(void)t;
yyjson_doc *d;
- /* rate limiting must stay last: it blocks the login key */
+ CHECK(login("admin", "secret123"));
+ d = call(reqf("{\"v\":1,\"id\":\"31\",\"cmd\":\"user.create\","
+ "\"session\":\"%s\",\"args\":{\"username\":\"rluser\","
+ "\"password\":\"rlpassword1\"}}",
+ g_session));
+ CHECK_OK(d);
+ yyjson_doc_free(d);
+
+ /* rate limiting must stay last: it blocks the admin login */
for (int i = 0; i < 5; i++)
CHECK(!login("admin", "wrong"));
d = call("{\"v\":1,\"id\":\"32\",\"cmd\":\"session.open\",\"args\":"
@@ -4912,6 +4920,26 @@ static void test_rate_limit(struct tctx *t)
"\"password\":\"secret123\"}}");
CHECK_STR(d, "error.code", "RATE_LIMITED");
yyjson_doc_free(d);
+ /* the limit is per user name: guessing one account never locks out
+ the others (a public login page must not be a lockout switch) */
+ for (int i = 0; i < 5; i++)
+ CHECK(!login("nobody-here", "wrong"));
+ CHECK(login("rluser", "rlpassword1"));
+ /* flooding the table with names neither frees admin early nor turns
+ the limiter off */
+ char name[32];
+ for (int i = 0; i < 1100; i++) {
+ snprintf(name, sizeof name, "flood%d", i);
+ login(name, "x");
+ }
+ d = call("{\"v\":1,\"id\":\"33\",\"cmd\":\"session.open\",\"args\":"
+ "{\"method\":\"password\",\"username\":\"admin\","
+ "\"password\":\"secret123\"}}");
+ CHECK_STR(d, "error.code", "RATE_LIMITED");
+ yyjson_doc_free(d);
+ for (int i = 0; i < 5; i++)
+ CHECK(!login("rluser", "wrong"));
+ CHECK(!login("rluser", "rlpassword1"));
}
static void test_employees(struct tctx *t)
diff --git a/tests/test_web.c b/tests/test_web.c
new file mode 100644
index 0000000..c5a3bea
--- /dev/null
+++ b/tests/test_web.c
@@ -0,0 +1,175 @@
+/* Unit tests for the pure parts of bokfweb (clients/web.c). */
+#include <stdio.h>
+#include <string.h>
+
+#include "util.h"
+#include "web.h"
+
+static int failures = 0;
+static int checks = 0;
+
+#define CHECK(cond) \
+ do { \
+ checks++; \
+ if (!(cond)) { \
+ failures++; \
+ fprintf(stderr, "FAIL %s:%d: %s\n", __FILE__, __LINE__, \
+ #cond); \
+ } \
+ } while (0)
+
+static int parse(const char *s, struct web_req *r)
+{
+ return web_parse_request(s, strlen(s), r);
+}
+
+static void test_parse(void)
+{
+ struct web_req r;
+ CHECK(parse("GET /web/ HTTP/1.1\r\nHost: x\r\n\r\n", &r) == 0);
+ CHECK(strcmp(r.method, "GET") == 0 && strcmp(r.path, "/web/") == 0);
+ CHECK(r.query[0] == '\0' && r.body_len == 0);
+
+ const char *post = "POST /web/login?next=1 HTTP/1.1\r\n"
+ "content-length: 27\r\n"
+ "Cookie: a=1; bokf_web=tok_EN-9\r\n"
+ "X-Forwarded-For: 203.0.113.7 \r\n\r\n"
+ "username=anna&password=x%21";
+ CHECK(parse(post, &r) == 0);
+ CHECK(strcmp(r.path, "/web/login") == 0);
+ CHECK(strcmp(r.query, "next=1") == 0);
+ CHECK(strcmp(r.forwarded_for, "203.0.113.7") == 0);
+ CHECK(r.body_len == 27 && strncmp(r.body, "username=", 9) == 0);
+
+ /* incomplete: headers or body still coming */
+ CHECK(parse("GET / HTTP/1.1\r\nHost: x\r\n", &r) == 1);
+ CHECK(parse("POST /l HTTP/1.1\r\nContent-Length: 10\r\n\r\nabc", &r) == 1);
+ /* malformed */
+ CHECK(parse("GET\r\n\r\n", &r) == -1);
+ CHECK(parse("GET nopath HTTP/1.1\r\n\r\n", &r) == -1);
+ CHECK(parse("GET / FTP/1.0\r\n\r\n", &r) == -1);
+ CHECK(parse("POST / HTTP/1.1\r\nContent-Length: -1\r\n\r\n", &r) == -1);
+ CHECK(parse("POST / HTTP/1.1\r\nContent-Length: 99999\r\n\r\n", &r) ==
+ -1);
+ CHECK(parse("POST / HTTP/1.1\r\nTransfer-Encoding: chunked\r\n\r\n",
+ &r) == -1);
+ char big[WEB_MAX_REQUEST + 16];
+ memset(big, 'a', sizeof big);
+ memcpy(big, "GET /", 5);
+ CHECK(web_parse_request(big, sizeof big, &r) == -1);
+ /* a path longer than the field is refused, not truncated */
+ char lp[400] = "GET /";
+ memset(lp + 5, 'p', 300);
+ strcpy(lp + 305, " HTTP/1.1\r\n\r\n");
+ CHECK(parse(lp, &r) == -1);
+}
+
+static void test_form_cookie(void)
+{
+ char v[64];
+ const char *f = "username=anna+b&password=p%C3%A5ss%26x&empty=";
+ CHECK(web_form_get(f, strlen(f), "username", v, sizeof v) == 0 &&
+ strcmp(v, "anna b") == 0);
+ CHECK(web_form_get(f, strlen(f), "password", v, sizeof v) == 0 &&
+ strcmp(v, "p\xc3\xa5ss&x") == 0);
+ CHECK(web_form_get(f, strlen(f), "empty", v, sizeof v) == 0 &&
+ v[0] == '\0');
+ CHECK(web_form_get(f, strlen(f), "user", v, sizeof v) == -1);
+ CHECK(web_form_get("a=%2", 4, "a", v, sizeof v) == -1);
+ CHECK(web_form_get("a=%zz", 5, "a", v, sizeof v) == -1);
+ CHECK(web_form_get("a=%00", 5, "a", v, sizeof v) == -1);
+ CHECK(web_form_get("a=12345", 7, "a", v, 4) == -1); /* does not fit */
+ /* the body is not NUL-terminated: len bounds it */
+ CHECK(web_form_get("a=1&b=2XXXX", 7, "b", v, sizeof v) == 0 &&
+ strcmp(v, "2") == 0);
+
+ CHECK(web_cookie_get("a=1; bokf_web=tok; c=3", "bokf_web", v,
+ sizeof v) == 0 &&
+ strcmp(v, "tok") == 0);
+ CHECK(web_cookie_get("xbokf_web=tok", "bokf_web", v, sizeof v) == -1);
+ CHECK(web_cookie_get("", "bokf_web", v, sizeof v) == -1);
+
+ CHECK(web_token_ok("aZ09_-"));
+ CHECK(!web_token_ok(""));
+ CHECK(!web_token_ok("a b"));
+ CHECK(!web_token_ok("a;b"));
+
+ struct buf b;
+ buf_init(&b);
+ web_html_escape(&b, "<a href=\"x\">&'</a>");
+ buf_append(&b, "", 1);
+ CHECK(strcmp((char *)b.p,
+ "&lt;a href=&quot;x&quot;&gt;&amp;&#39;&lt;/a&gt;") == 0);
+ buf_free(&b);
+}
+
+static void test_store(void)
+{
+ static struct web_store st;
+ memset(&st, 0, sizeof st);
+ struct web_session *s = web_store_add(&st, "s_bokfd1", "anna", 1000);
+ CHECK(s && web_token_ok(s->token) && web_token_ok(s->handle));
+ CHECK(strcmp(s->token, s->handle) != 0);
+ char tok[64], hdl[64];
+ snprintf(tok, sizeof tok, "%s", s->token);
+ snprintf(hdl, sizeof hdl, "%s", s->handle);
+ CHECK(web_store_by_token(&st, tok, 1001) == s);
+ CHECK(web_store_by_handle(&st, hdl, 1001) == s);
+ /* token and handle are not interchangeable */
+ CHECK(web_store_by_token(&st, hdl, 1001) == NULL);
+ CHECK(web_store_by_handle(&st, tok, 1001) == NULL);
+ CHECK(web_store_by_token(&st, "nope", 1001) == NULL);
+ CHECK(web_store_by_token(&st, "bad;value", 1001) == NULL);
+ /* absolute expiry */
+ CHECK(web_store_by_token(&st, tok, 1000 + WEB_SESSION_TTL) == NULL);
+ web_store_del(s);
+ CHECK(web_store_by_token(&st, tok, 1001) == NULL);
+
+ /* a full store replaces the oldest session */
+ for (int i = 0; i < WEB_MAX_SESSIONS; i++)
+ web_store_add(&st, "x", "u", 2000 + i);
+ struct web_session *n = web_store_add(&st, "new", "u", 5000);
+ CHECK(n && strcmp(n->bokf, "new") == 0);
+ int oldest_gone = 1;
+ for (int i = 0; i < WEB_MAX_SESSIONS; i++)
+ if (st.s[i].created == 2000)
+ oldest_gone = 0;
+ CHECK(oldest_gone);
+}
+
+static void test_rl(void)
+{
+ static struct web_rl rl;
+ memset(&rl, 0, sizeof rl);
+ for (int i = 0; i < WEB_RL_MAX_FAILS - 1; i++)
+ web_rl_fail(&rl, "203.0.113.7", 100);
+ CHECK(web_rl_blocked(&rl, "203.0.113.7", 100) == 0);
+ web_rl_fail(&rl, "203.0.113.7", 100);
+ CHECK(web_rl_blocked(&rl, "203.0.113.7", 100) == WEB_RL_WINDOW);
+ /* other addresses are not affected */
+ CHECK(web_rl_blocked(&rl, "198.51.100.1", 100) == 0);
+ /* the window ends */
+ CHECK(web_rl_blocked(&rl, "203.0.113.7", 100 + WEB_RL_WINDOW) == 0);
+ web_rl_fail(&rl, "203.0.113.7", 100 + WEB_RL_WINDOW);
+ CHECK(web_rl_blocked(&rl, "203.0.113.7", 100 + WEB_RL_WINDOW) == 0);
+ /* success clears */
+ web_rl_ok(&rl, "203.0.113.7");
+ CHECK(web_rl_blocked(&rl, "203.0.113.7", 101) == 0);
+ /* many addresses: the table never overflows */
+ char a[32];
+ for (int i = 0; i < WEB_RL_SLOTS * 2; i++) {
+ snprintf(a, sizeof a, "10.0.%d.%d", i / 256, i % 256);
+ web_rl_fail(&rl, a, 200);
+ }
+ CHECK(web_rl_blocked(&rl, a, 200) == 0);
+}
+
+int main(void)
+{
+ test_parse();
+ test_form_cookie();
+ test_store();
+ test_rl();
+ printf("test_web: %d checks, %d failures\n", checks, failures);
+ return failures ? 1 : 0;
+}