summaryrefslogtreecommitdiff
path: root/clients
diff options
context:
space:
mode:
Diffstat (limited to 'clients')
-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
8 files changed, 949 insertions, 0 deletions
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