diff options
Diffstat (limited to 'clients/bokfweb.c')
| -rw-r--r-- | clients/bokfweb.c | 479 |
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); + } +} |
