diff options
| -rw-r--r-- | AGENTS.md | 6 | ||||
| -rw-r--r-- | Dockerfile | 42 | ||||
| -rw-r--r-- | Makefile | 22 | ||||
| -rw-r--r-- | THIRD_PARTY_NOTICES.md | 9 | ||||
| -rw-r--r-- | clients/bokfweb.c | 479 | ||||
| -rw-r--r-- | clients/client.c | 1 | ||||
| -rw-r--r-- | clients/screens_bokslut.c | 2 | ||||
| -rw-r--r-- | clients/screens_reports.c | 4 | ||||
| -rw-r--r-- | clients/ui.c | 27 | ||||
| -rw-r--r-- | clients/ui.h | 6 | ||||
| -rw-r--r-- | clients/web.c | 330 | ||||
| -rw-r--r-- | clients/web.h | 100 | ||||
| -rw-r--r-- | compose.yaml | 25 | ||||
| -rw-r--r-- | deploy/Caddyfile | 47 | ||||
| -rw-r--r-- | deploy/Dockerfile.cross | 8 | ||||
| -rwxr-xr-x | deploy/bokftui-web | 40 | ||||
| -rw-r--r-- | deploy/cross-build.sh | 12 | ||||
| -rwxr-xr-x | deploy/web-entrypoint.sh | 26 | ||||
| -rw-r--r-- | docs/DECISIONS.md | 14 | ||||
| -rw-r--r-- | docs/DEPLOY.md | 60 | ||||
| -rw-r--r-- | docs/PROTOCOL.md | 13 | ||||
| -rw-r--r-- | docs/STATE.md | 10 | ||||
| -rw-r--r-- | docs/TUI-GUIDELINES.md | 7 | ||||
| -rwxr-xr-x | scripts/deploy.sh | 38 | ||||
| -rwxr-xr-x | scripts/tui-golden.py | 29 | ||||
| -rw-r--r-- | src/cmd_auth.c | 67 | ||||
| -rw-r--r-- | tests/test_core.c | 30 | ||||
| -rw-r--r-- | tests/test_web.c | 175 |
28 files changed, 1570 insertions, 59 deletions
@@ -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) | @@ -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 \ @@ -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, "&", 5); + break; + case '<': + buf_append(b, "<", 4); + break; + case '>': + buf_append(b, ">", 4); + break; + case '"': + buf_append(b, """, 6); + break; + case '\'': + buf_append(b, "'", 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, + "<a href="x">&'</a>") == 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; +} |
