summaryrefslogtreecommitdiff
path: root/clients/bokfweb.c
diff options
context:
space:
mode:
Diffstat (limited to 'clients/bokfweb.c')
-rw-r--r--clients/bokfweb.c479
1 files changed, 479 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);
+ }
+}