aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--.gitignore1
-rw-r--r--README.md155
-rw-r--r--clients/bokftui.c10
-rw-r--r--clients/client.c3
-rw-r--r--clients/screens_bokslut.c2
-rw-r--r--clients/screens_dashboard.c53
-rw-r--r--clients/screens_ib.c2
-rw-r--r--clients/screens_invoices.c2
-rw-r--r--clients/screens_payroll.c5
-rw-r--r--clients/screens_settings.c97
-rw-r--r--clients/screens_templates.c2
-rw-r--r--clients/screens_vouchers.c2
-rw-r--r--clients/tui.c35
-rw-r--r--clients/ui.c19
-rw-r--r--clients/ui.h3
-rw-r--r--docs/PAYROLL.md6
-rw-r--r--docs/PROTOCOL.md33
-rw-r--r--docs/STATE.md37
-rw-r--r--docs/TUI-GUIDELINES.md14
-rwxr-xr-xscripts/tui-golden.py113
-rw-r--r--src/smtp.c3
-rw-r--r--src/tax_table.c3
-rw-r--r--src/tls_ca.h31
23 files changed, 492 insertions, 139 deletions
diff --git a/.gitignore b/.gitignore
index 89d9e89..3f9cea2 100644
--- a/.gitignore
+++ b/.gitignore
@@ -14,3 +14,4 @@ dropzone/
var/
.env
build-gate*/
+__pycache__/
diff --git a/README.md b/README.md
index 8559ca2..fc1aa33 100644
--- a/README.md
+++ b/README.md
@@ -3,13 +3,61 @@
Self-hosted bookkeeping for Swedish organizations. One C daemon, one SQLite
file, one JSON API shared by the CLI, the ncurses UI and LLM agents.
-Status: **0.1.0-dev — walking skeleton.** Not production ready. See
+Status: **0.1.0-dev — working, not production-proven.** See
`docs/COMPLIANCE.md` §9.
+## Features
+
+Everything below lives in the daemon and one SQLite file; every feature is a
+JSON command in `docs/PROTOCOL.md`, so the TUI, the CLI and an AI agent can
+do the same things.
+
+**Bookkeeping**
+- BAS chart of accounts (K2 and K3 variants), per organization; multi-org with
+ owner/bookkeeper/viewer roles.
+- Verifikat with unbroken numbering per series and fiscal year, balanced
+ rows in integer öre, ändringsverifikat for corrections — nothing is ever
+ edited or deleted.
+- Konteringsmallar: reusable templates with a small formula language
+ (`x*0.25`, `-x`), applied at posting time.
+- Underlag: attachments stored in the database (SHA-256 verified,
+ append-only), an inbox for unlinked documents.
+- Fiscal years, period locks, ingående balans, year-end close.
+
+**Reports and filing**
+- Saldobalans, resultatrapport, balansrapport, huvudbok,
+ verifikationslista — Kapitas-style tables in the TUI, JSON on the API.
+- Momsrapport ruta för ruta with per-org editable rules; eSKD XML export
+ for the momsdeklaration.
+- Bokslut: automated year-end postings (avskrivningar, periodiseringsfond,
+ skatt, resultatdisposition) from a dry-run plan.
+- Årsredovisning K2 text draft and INK2/SRU files (INK2, INK2R, INK2S).
+- SIE 4 import and export (CP437), multi-year history import.
+
+**Invoicing, bank and payroll**
+- Customer register; invoices rendered as PDF (own renderer, no
+ dependencies), OCR numbers, issue = number + PDF + voucher in one
+ transaction, e-mail over SMTP with the password encrypted at rest.
+- Bank reconciliation: import SEB CSV statements, match against vouchers,
+ create prefilled vouchers from unmatched transactions.
+- Payroll engine: employees, Skatteverket tax tables, monthly runs with
+ preview/post, payslips (PDF + e-mail) and AGI underlag — API and TUI
+ complete (Lönekörningar, Anställda, Skattetabeller).
+
+**Compliance and operations** (`docs/COMPLIANCE.md`)
+- SHA-256 hash chains over vouchers and the audit log; `audit.verify`
+ recomputes them and re-hashes attachments.
+- Every mutation is audited; secrets never reach the log.
+- `backup.snapshot` (`VACUUM INTO`) for consistent backups; pre-migration
+ snapshots before schema upgrades.
+- Unix socket, optional TLS listener with certificate reload, Argon2id
+ passwords, scoped revocable API tokens, login rate limiting.
+
## Build
-Requirements: Linux, a C11 compiler, GNU make, and libncursesw for the TUI.
-No other system libraries — SQLite, yyjson, SHA-256 and Argon2 are vendored.
+Requirements: Linux, a C11 compiler, GNU make, libncursesw for the TUI and
+OpenSSL (`libssl-dev`) for TLS and SMTP. SQLite, yyjson, SHA-256 and Argon2
+are vendored.
```sh
make -j$(nproc)
@@ -28,7 +76,9 @@ Produces `build/bokfd` (daemon), `build/bokfctl` (CLI) and `build/bokftui`
```sh
./build/bokfd init --db /var/lib/bokfd/bokfd.db --user admin
-BOKFD_PASSWORD=secret ./build/bokfd init ... # non-interactive
+# non-interactive, same command with the password in the environment:
+BOKFD_PASSWORD=secret ./build/bokfd init --db /var/lib/bokfd/bokfd.db \
+ --user admin
./build/bokfd --db /var/lib/bokfd/bokfd.db --socket /run/bokfd/bokfd.sock
```
@@ -43,11 +93,108 @@ BOKFD_USER=admin BOKFD_PASSWORD=secret \
./build/bokfctl --socket /run/bokfd/bokfd.sock org.create '{"name":"AB Ett","org_nr":"5560123456"}'
```
+## Run the backend with Docker
+
+The image carries `bokfd` and `bokfctl` only (Alpine, ~30 MB). It is built
+from the checkout; there is no registry. The TUI is built on the machine you
+sit at and talks to the container over TLS.
+
+```sh
+# 1. Image
+docker build -t bokf:dev .
+
+# 2. A directory for compose.yaml, .env and the state (var/db, var/run)
+mkdir -p ~/bokf && cp compose.yaml ~/bokf/ && cd ~/bokf
+printf 'BOKF_IMAGE=bokf\nBOKF_TAG=dev\nLEGO_DOMAIN=localhost\n' > .env
+
+# 3. A certificate. Self-signed is fine on a LAN; compose.yaml also has a
+# lego sidecar for Let's Encrypt via DNS-01 (see docs/DEPLOY.md).
+mkdir -p var/db/certs/certificates
+openssl req -x509 -newkey ec -pkeyopt ec_paramgen_curve:prime256v1 -nodes \
+ -days 3650 -subj /CN=localhost -addext subjectAltName=DNS:localhost \
+ -keyout var/db/certs/certificates/localhost.key \
+ -out var/db/certs/certificates/localhost.crt
+
+# 4. Create the database and the admin user, then start
+docker compose run --rm -e BOKFD_PASSWORD=secret123 bokfd init --user admin
+docker compose up -d bokfd
+docker compose ps # wait for "healthy"
+```
+
+Then from any machine that trusts the certificate:
+
+```sh
+export BOKFD_SOCKET=tls:localhost:8788 \
+ BOKFD_TLS_CA=~/bokf/var/db/certs/certificates/localhost.crt
+BOKFD_USER=admin BOKFD_PASSWORD=secret123 ./build/bokfctl health
+BOKFD_USER=admin BOKFD_PASSWORD=secret123 ./build/bokftui
+```
+
+`init` refuses to run twice. State lives in `var/db` (database, `backup/`,
+`export/`, `certs/`) and `var/run` (the Unix socket, for clients on the same
+host). Set `BOKFD_SECRET_KEY` in `.env` (32 random bytes, hex or base64)
+before storing SMTP passwords or employee personnummer (payroll needs it).
+Use `LEGO_DOMAIN`, `LEGO_EMAIL`,
+`INWX_USERNAME`/`INWX_PASSWORD` for a real certificate, and
+`scripts/deploy.sh` to ship releases to a remote host over SSH — both in
+`docs/DEPLOY.md`.
+
+## Working with an AI agent
+
+bokf is designed so that an LLM agent can do the bookkeeping, with a human
+approving what matters:
+
+- **One protocol, self-describing.** `describe` returns every command with
+ its permission, argument schema and defaults; `agent.instructions` returns
+ the workflow rules in Markdown. An agent needs no other documentation.
+- **Safe by construction.** Every mutation supports `dry_run:true`, and
+ postings carry an idempotent `client_ref`; the ledger is append-only, so a
+ wrong posting is corrected by a new voucher, never hidden. Locked periods
+ and closed years are hard stops the agent cannot override.
+- **Least privilege.** Give the agent a scoped API token (`read`, `write`,
+ never `admin`), bound to one organization, shown once and revocable. Owner
+ actions (closing a year in the TUI, token administration in `bokfctl`)
+ stay with the human.
+- **Fully audited.** Every call the agent makes is in the hash-chained audit
+ log with the token that made it.
+
+Setup is one command per agent:
+
+```sh
+BOKFD_USER=admin BOKFD_PASSWORD=… ./build/bokfctl --org 1 token.create \
+ '{"label":"claude","scopes":["read","write"]}'
+```
+
+Point the agent at the socket (or `tls:host:8788`) and the token, tell it to
+fetch `agent.instructions` first, and prompts like these are enough:
+
+- *"Post the September invoices from `~/underlag/2026-09/` — attach each PDF,
+ use the `Försäljning 25 %` template, dry-run everything and show me the
+ list before posting."*
+- *"Import `seb-2026-09.csv`, match what you can against existing vouchers,
+ and prepare — but don't post — vouchers for the rest."*
+- *"Give me the momsrapport for Q3, explain any box that changed more than
+ 20 % from Q2, and save the eSKD file."*
+- *"Run the October payroll preview for all employees and list the
+ differences from September."*
+
+`bokfctl` speaks the same NDJSON protocol from shell scripts and MCP-style
+tool wrappers alike: `bokfctl <command> '<json args>'`.
+
+The same applies to developing bokf. `AGENTS.md` holds the invariants and
+the recipe for adding a command; `make check` fails when code and
+`docs/PROTOCOL.md` drift; `make gate` is the pre-push bar. Most of the
+project was written by agents working from short briefs.
+
## Documentation
- `docs/PROTOCOL.md` — transport, auth, command reference
- `docs/SCHEMA.md` — database schema, invariants, hash chains
- `docs/COMPLIANCE.md` — BFL/BFNAR mapping and operator duties
+- `docs/DEPLOY.md` — Docker, TLS certificates, deploying to a remote host
+- `docs/INVOICING.md`, `docs/PAYROLL.md` — design notes for those modules
+- `docs/TUI-GUIDELINES.md` — keys, widgets and conventions for the TUI
+- `AGENTS.md` — how to work on the code (for humans and agents)
## License
diff --git a/clients/bokftui.c b/clients/bokftui.c
index a047ef5..69d6690 100644
--- a/clients/bokftui.c
+++ b/clients/bokftui.c
@@ -45,7 +45,7 @@ static const struct scene SCENES[] = {
{ "audit", audit_screen },
{ "company", company_screen },
{ "settings", company_screen }, /* ^R/--screen compatibility */
- { "system", system_settings_screen },
+ { "system", system_screen },
{ "bokslut", bokslut_screen },
{ "yearinfo", bokslut_screen }, /* ^R compatibility */
};
@@ -146,11 +146,14 @@ static void config_load(struct app *a)
snprintf(a->socket, sizeof a->socket, "%s", val);
else if (strcmp(line, "user") == 0 && !a->username[0])
snprintf(a->username, sizeof a->username, "%s", val);
+ else if (strcmp(line, "attachment_dir") == 0 &&
+ !a->attachment_dir[0])
+ snprintf(a->attachment_dir, sizeof a->attachment_dir, "%s", val);
}
fclose(f);
}
-static void config_save(const struct app *a)
+void config_save(struct app *a)
{
char path[512];
config_path(path, sizeof path);
@@ -161,6 +164,8 @@ static void config_save(const struct app *a)
fprintf(f, "server=%s\n", a->socket);
if (a->username[0])
fprintf(f, "user=%s\n", a->username);
+ if (a->attachment_dir[0])
+ fprintf(f, "attachment_dir=%s\n", a->attachment_dir);
fclose(f);
chmod(path, 0600);
}
@@ -341,6 +346,7 @@ static void usage(FILE *f)
static void reset_modify_keys(void)
{
+ putp("\033[<u");
putp("\033[>4m");
fflush(stdout);
}
diff --git a/clients/client.c b/clients/client.c
index fd119f1..30f2a67 100644
--- a/clients/client.c
+++ b/clients/client.c
@@ -1,4 +1,5 @@
#include "client.h"
+#include "tls_ca.h"
#include <arpa/inet.h>
#include <errno.h>
@@ -126,7 +127,7 @@ static int tls_connect_addr(const char *addrport, struct client_conn *out)
}
SSL_CTX_set_min_proto_version(ctx, TLS1_2_VERSION);
SSL_CTX_set_verify(ctx, SSL_VERIFY_PEER, NULL);
- SSL_CTX_set_default_verify_paths(ctx);
+ tls_load_default_cas(ctx);
const char *ca = getenv("BOKFD_TLS_CA");
if (ca && *ca && SSL_CTX_load_verify_locations(ctx, ca, NULL) != 1) {
set_tls_error("TLS CA file");
diff --git a/clients/screens_bokslut.c b/clients/screens_bokslut.c
index df47196..3f69982 100644
--- a/clients/screens_bokslut.c
+++ b/clients/screens_bokslut.c
@@ -242,7 +242,7 @@ void bokslut_screen(struct app *a)
ff[7].cap = sizeof rate;
ff[7].kind = TUI_F_TEXT;
tui_form_hint("upp/ned/Tab = flytta Enter = ändra/utför F5 = visa"
- " bokslutsplan ^Enter = bokför planen (frågar"
+ " bokslutsplan ^Enter/F9 = bokför planen (frågar"
" först) Esc/q = tillbaka ^C = avsluta");
int r = tui_form_run_actions("Bokslut", ff, nf + 2, can_edit, acts, 5,
NULL, &sel);
diff --git a/clients/screens_dashboard.c b/clients/screens_dashboard.c
index a386dae..83b4f6a 100644
--- a/clients/screens_dashboard.c
+++ b/clients/screens_dashboard.c
@@ -268,19 +268,11 @@ static void select_fiscal_year(struct app *a)
}
}
static const char *const MAIN_ITEMS[] = {
- MK_SECTION "Bokföring",
"Verifikat", "Underlag (inkorg)", "Bankavstämning", "Mallar",
- MK_SECTION "Fakturering",
"Fakturor",
- MK_SECTION "Lön",
"Lönekörningar",
- MK_SECTION "Rapporter & bokslut",
"Rapporter", "Bokslut",
- MK_SECTION "Företag",
- "Anställda", "Kunder", "Bolaget", "Momsregler",
- MK_SECTION "System",
- "Skattetabeller", "Revision", "Systeminställningar",
- MK_SECTION "Räkenskapsår",
+ "Bolaget", "System",
"Ingående balans", "Räkenskapsår",
"Logga ut / avsluta",
};
@@ -306,61 +298,46 @@ void dashboard(struct app *a)
if (sel < 0)
continue; /* Esc/back at the top level never exits */
switch (sel) {
- case 1:
+ case 0:
open_scene(a, "vouchers");
break;
- case 2:
+ case 1:
open_scene(a, "inbox");
break;
- case 3:
+ case 2:
open_scene(a, "bank");
break;
- case 4:
+ case 3:
open_scene(a, "templates");
break;
- case 6:
+ case 4:
open_scene(a, "invoices");
break;
- case 8:
+ case 5:
open_scene(a, "payroll");
break;
- case 10:
+ case 6:
open_scene(a, "reports");
break;
- case 11:
+ case 7:
open_scene(a, "bokslut");
break;
- case 13:
- open_scene(a, "employees");
- break;
- case 14:
- open_scene(a, "customers");
- break;
- case 15:
+ case 8:
open_scene(a, "company");
break;
- case 16:
- open_scene(a, "rules");
- break;
- case 18:
- open_scene(a, "tax_tables");
- break;
- case 19:
- open_scene(a, "audit");
- break;
- case 20:
+ case 9:
open_scene(a, "system");
break;
- case 22:
+ case 10:
open_scene(a, "ib");
break;
- case 23:
+ case 11:
select_fiscal_year(a);
break;
- case 24:
+ case 12:
return;
default:
- break; /* section header */
+ break;
}
}
}
diff --git a/clients/screens_ib.c b/clients/screens_ib.c
index ca35562..8950667 100644
--- a/clients/screens_ib.c
+++ b/clients/screens_ib.c
@@ -272,7 +272,7 @@ static int ib_form(struct app *a, char **old_acc, int64_t *old_amt, int nold)
for (;;) {
int rr = tui_rt_run("Ingående balans", &ib.rt,
"Tab = byta fält F5 = validera ^X = rensa rad"
- " ^Enter = spara Esc = avbryt");
+ " ^Enter/F9 = spara Esc = avbryt");
if (rr == -1)
return 0;
char msg[256];
diff --git a/clients/screens_invoices.c b/clients/screens_invoices.c
index b8cc8dd..ca59eef 100644
--- a/clients/screens_invoices.c
+++ b/clients/screens_invoices.c
@@ -1092,7 +1092,7 @@ static int64_t invoices_new(struct app *a)
tui_rt_set_fields(&f.rt, ff, 7);
const char *hint =
"Enter = välj/ändra Tab = byta fält F5 = förhandsvisa"
- " ^Enter = utfärda Esc = avbryt";
+ " ^Enter/F9 = utfärda Esc = avbryt";
int64_t out = 0;
for (;;) {
int rr = tui_rt_run("Ny faktura", &f.rt, hint);
diff --git a/clients/screens_payroll.c b/clients/screens_payroll.c
index 9bfaf38..23d765f 100644
--- a/clients/screens_payroll.c
+++ b/clients/screens_payroll.c
@@ -1101,7 +1101,8 @@ static int64_t payroll_run_screen(struct app *a, int64_t id)
{ status, -1, NULL },
{ totals, -1, NULL },
{ "Förhandsvisa (F5)", 1, NULL },
- { "Bokför körning (^Enter)", post_reason ? 0 : 1, post_reason },
+ { "Bokför körning (^Enter/F9)", post_reason ? 0 : 1,
+ post_reason },
{ "Lönebesked (PDF)", payslip_reason ? 0 : 1, payslip_reason },
{ "AGI-underlag", agi_reason ? 0 : 1, agi_reason },
{ "Betala skatt & avgifter", pay_reason ? 0 : 1, pay_reason },
@@ -1123,7 +1124,7 @@ static int64_t payroll_run_screen(struct app *a, int64_t id)
snprintf(title, sizeof title, "Ny lönekörning");
int editable = can_write && !posted;
tui_form_hint("upp/ned/Tab = flytta Enter = ändra/utför"
- " F5 = förhandsvisa ^Enter = bokför"
+ " F5 = förhandsvisa ^Enter/F9 = bokför"
" Esc/q = tillbaka ^C = avsluta");
int ret = tui_form_run_actions(title, ff, 2, editable, acts, 7, NULL,
&focus);
diff --git a/clients/screens_settings.c b/clients/screens_settings.c
index c6a46c5..8a31620 100644
--- a/clients/screens_settings.c
+++ b/clients/screens_settings.c
@@ -321,10 +321,6 @@ static const struct setting_field SMTP_SETTINGS[] = {
{ "smtp_password", "SMTP-lösenord", NULL, 0, 1 },
};
-static const struct setting_field SYSTEM_SETTINGS[] = {
- { "attachment_dir", "Bilagornas mapp", NULL, 0, 0 },
-};
-
static int settings_can_write(struct app *a)
{
return a->role[0] && (strcmp(a->role, "owner") == 0 ||
@@ -347,27 +343,40 @@ static void smtp_settings_screen(struct app *a)
settings_can_write(a), 1, &sel);
}
-void system_settings_screen(struct app *a)
+static const char *const SYSTEM_ITEMS[] = {
+ "Skattetabeller",
+ "Revision",
+};
+
+void system_screen(struct app *a)
{
static int sel = 0;
- settings_form(a, "Systeminställningar", SYSTEM_SETTINGS,
- (int)(sizeof SYSTEM_SETTINGS / sizeof SYSTEM_SETTINGS[0]),
- settings_can_write(a), 0, &sel);
+ for (;;) {
+ if (g_quit)
+ return;
+ int r = tui_menu("System", SYSTEM_ITEMS,
+ (int)(sizeof SYSTEM_ITEMS / sizeof SYSTEM_ITEMS[0]),
+ 0, &sel);
+ if (r < 0)
+ return;
+ if (r == 0)
+ payroll_tax_tables_screen(a);
+ else if (r == 1)
+ audit_screen(a);
+ }
}
-void company_screen(struct app *a)
+static void company_data_screen(struct app *a)
{
static int sel = 0;
const int nf = (int)(sizeof COMPANY_FIELDS / sizeof COMPANY_FIELDS[0]);
int owner = a->role[0] && strcmp(a->role, "owner") == 0;
- int writer = settings_can_write(a);
- const char *settings_reason = writer ? NULL : "du har inte behörighet";
for (;;) {
if (g_quit)
return;
char *resp = client_rpc(&a->conn, "org.get", a->session, a->org, "{}");
if (!resp || !client_ok(resp)) {
- show_error("Bolaget", resp);
+ show_error("Företagsuppgifter", resp);
free(resp);
return;
}
@@ -392,15 +401,7 @@ void company_screen(struct app *a)
ff[i].kind = TUI_F_TEXT;
}
free(resp);
- struct tui_form_action acts[3] = {
- { "Fakturauppgifter…", writer ? 1 : 0, settings_reason },
- { "E-post (SMTP)…", writer ? 1 : 0, settings_reason },
- { "Styrelseledamöter", 1, NULL },
- };
- tui_form_hint("upp/ned/Tab = flytta Enter = ändra/utför"
- " F5 = uppdatera Esc/q = tillbaka ^C = avsluta");
- int r = tui_form_run_actions("Bolaget", ff, nf, owner, acts, 3, NULL,
- &sel);
+ int r = tui_form_run("Företagsuppgifter", ff, nf, owner, &sel);
if (r >= 0 && r < nf) {
if (!company_field_valid(&COMPANY_FIELDS[r], vals[r])) {
tui_message("Ogiltigt värde", "%s", COMPANY_FIELDS[r].label);
@@ -426,14 +427,58 @@ void company_screen(struct app *a)
}
for (int i = 0; i < nf; i++)
free(vals[i]);
- if (r == TUI_FORM_ACTION + 0)
+ if (r == TUI_FORM_BACK)
+ return;
+ }
+}
+
+static const char *const COMPANY_ITEMS[] = {
+ "Företagsuppgifter",
+ "Fakturauppgifter",
+ "E-post (SMTP)",
+ "Styrelseledamöter",
+ MK_SECTION "Register",
+ "Anställda",
+ "Kunder",
+ "Momsregler",
+};
+
+void company_screen(struct app *a)
+{
+ static int sel = 0;
+ for (;;) {
+ if (g_quit)
+ return;
+ int r = tui_menu("Bolaget", COMPANY_ITEMS,
+ (int)(sizeof COMPANY_ITEMS / sizeof COMPANY_ITEMS[0]),
+ 0, &sel);
+ if (r < 0)
+ return;
+ switch (r) {
+ case 0:
+ company_data_screen(a);
+ break;
+ case 1:
invoice_settings_screen(a);
- else if (r == TUI_FORM_ACTION + 1)
+ break;
+ case 2:
smtp_settings_screen(a);
- else if (r == TUI_FORM_ACTION + 2)
+ break;
+ case 3:
board_screen(a);
- else if (r == TUI_FORM_BACK)
- return;
+ break;
+ case 5:
+ employees_screen(a);
+ break;
+ case 6:
+ customers_screen(a);
+ break;
+ case 7:
+ rules_screen(a);
+ break;
+ default:
+ break;
+ }
}
}
diff --git a/clients/screens_templates.c b/clients/screens_templates.c
index b2cd7ab..5777742 100644
--- a/clients/screens_templates.c
+++ b/clients/screens_templates.c
@@ -234,7 +234,7 @@ static int template_form(struct app *a, const char *load_name)
tui_rt_set_fields(&tf.rt, ff, 3);
const char *title = load_name ? "Redigera mall" : "Ny mall";
const char *hint = "Enter = ändra fält Tab = byta fält F5 = validera"
- " ^X = rensa rad ^Enter = spara Esc = avbryt";
+ " ^X = rensa rad ^Enter/F9 = spara Esc = avbryt";
for (;;) {
int rr = tui_rt_run(title, &tf.rt, hint);
if (rr == -1)
diff --git a/clients/screens_vouchers.c b/clients/screens_vouchers.c
index fedffe9..5e1b006 100644
--- a/clients/screens_vouchers.c
+++ b/clients/screens_vouchers.c
@@ -836,7 +836,7 @@ int64_t vouchers_new_prefill(struct app *a, const struct voucher_prefill *p)
tui_rt_normalize(&vf.rt);
const char *hint = "Enter = ändra fält Tab = byta fält F4 = mall"
" ^F = bifoga fil F5 = validera ^X = rensa rad"
- " ^Enter = bokför Esc = avbryt";
+ " ^Enter/F9 = bokför Esc = avbryt";
for (;;) {
int rr = tui_rt_run("Nytt verifikat", &vf.rt, hint);
if (rr == -1)
diff --git a/clients/tui.c b/clients/tui.c
index 031fad0..30761a8 100644
--- a/clients/tui.c
+++ b/clients/tui.c
@@ -159,9 +159,34 @@ int tui_sticky_split(char **lines, int n, char **head, int *nhead)
void tui_keys_setup(void)
{
- /* ^Enter has no control code; enable xterm modifyOtherKeys and bind the
- sequence it produces. Terminals without it ignore the request. */
+ /* ^Enter has no control code; enable xterm modifyOtherKeys level 2 (level
+ 1 leaves control+Enter alone) and the Kitty keyboard protocol, then bind
+ the sequences they produce. Terminals with neither keep F9 working. */
+ putp("\033[>4;2m");
+ putp("\033[>1u");
+ fflush(stdout);
define_key("\033[27;5;13~", TUI_KEY_CTRL_ENTER);
+ define_key("\033[13;5u", TUI_KEY_CTRL_ENTER);
+ define_key("\033[27;2;9~", KEY_BTAB);
+ define_key("\033[9;2u", KEY_BTAB);
+ define_key("\033[27u", 27);
+ define_key("\033[27;1u", 27);
+ define_key("\033[27;5;97~", 1);
+ define_key("\033[97;5u", 1);
+ define_key("\033[27;5;99~", 3);
+ define_key("\033[99;5u", 3);
+ define_key("\033[27;5;101~", 5);
+ define_key("\033[101;5u", 5);
+ define_key("\033[27;5;102~", 6);
+ define_key("\033[102;5u", 6);
+ define_key("\033[27;5;110~", 14);
+ define_key("\033[110;5u", 14);
+ define_key("\033[27;5;114~", 18);
+ define_key("\033[114;5u", 18);
+ define_key("\033[27;5;117~", 21);
+ define_key("\033[117;5u", 21);
+ define_key("\033[27;5;120~", 24);
+ define_key("\033[120;5u", 24);
/* Home/End arrive as CSI or SS3 depending on the terminal */
define_key("\033[1~", KEY_HOME);
define_key("\033OH", KEY_HOME);
@@ -184,8 +209,6 @@ void tui_keys_setup(void)
init_pair(TUI_CP_RULE, COLOR_BLUE, -1);
init_pair(TUI_CP_STATUS, COLOR_CYAN, -1);
}
- putp("\033[>4;1m");
- fflush(stdout);
}
@@ -1592,7 +1615,7 @@ static int form_run(const char *title, struct tui_form_field *f, int nf,
h = na > 0
? (can_edit
? "upp/ned/Tab = flytta Enter = ändra/utför"
- " F5 = uppdatera ^Enter = spara"
+ " F5 = uppdatera ^Enter/F9 = spara"
" Esc/q = tillbaka ^C = avsluta"
: "upp/ned/Tab = flytta Enter = utför"
" F5 = uppdatera Esc/q = tillbaka"
@@ -1600,7 +1623,7 @@ static int form_run(const char *title, struct tui_form_field *f, int nf,
" (endast behöriga kan ändra)")
: (can_edit
? "upp/ned/Home/End Enter = ändra"
- " F5 = uppdatera ^Enter = spara"
+ " F5 = uppdatera ^Enter/F9 = spara"
" Esc/q = tillbaka ^C = avsluta"
: "upp/ned/Home/End F5 = uppdatera"
" Esc/q = tillbaka ^C = avsluta"
diff --git a/clients/ui.c b/clients/ui.c
index a95ce44..ca6906f 100644
--- a/clients/ui.c
+++ b/clients/ui.c
@@ -217,17 +217,12 @@ void app_refresh_context(struct app *a)
free(resp);
snprintf(a->default_series, sizeof a->default_series, "%s", "A");
- a->attachment_dir[0] = '\0';
resp = client_rpc(&a->conn, "settings.get", a->session, a->org, "{}");
if (resp && client_ok(resp)) {
char *ser = jstr_dup(resp, "result.default_series");
if (ser && *ser)
snprintf(a->default_series, sizeof a->default_series, "%s", ser);
free(ser);
- char *dir = jstr_dup(resp, "result.attachment_dir");
- if (dir && *dir)
- snprintf(a->attachment_dir, sizeof a->attachment_dir, "%s", dir);
- free(dir);
}
free(resp);
@@ -332,14 +327,18 @@ 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)
{
- (void)a;
if (g_quit)
return NULL;
char dir[1024];
- const char *start = (start_dir && *start_dir) ? start_dir : getenv("HOME");
+ const char *home = getenv("HOME");
+ const char *start = (start_dir && *start_dir) ? start_dir : home;
expand_path(start, dir, sizeof dir);
- if (!dir[0])
- snprintf(dir, sizeof dir, ".");
+ struct stat sb;
+ if (!dir[0] || stat(dir, &sb) != 0 || !S_ISDIR(sb.st_mode)) {
+ expand_path(home && *home ? home : ".", dir, sizeof dir);
+ if (!dir[0])
+ snprintf(dir, sizeof dir, ".");
+ }
for (;;) {
DIR *d = opendir(dir);
if (!d) {
@@ -408,6 +407,8 @@ char *file_browser(struct app *a, const char *start_dir)
continue;
}
free(ents);
+ snprintf(a->attachment_dir, sizeof a->attachment_dir, "%.255s", dir);
+ config_save(a);
return xstrdup(full);
}
}
diff --git a/clients/ui.h b/clients/ui.h
index 2b93106..b37eb4f 100644
--- a/clients/ui.h
+++ b/clients/ui.h
@@ -102,6 +102,7 @@ extern const struct is_group IS_GROUPS[IS_GROUPS_N];
/* defined in bokftui.c, used by screens */
void open_scene(struct app *a, const char *name);
void config_mkdirs(const char *path);
+void config_save(struct app *a);
/* Optional prefill for the new-voucher form (bank reconciliation). */
struct voucher_prefill {
@@ -130,12 +131,12 @@ void audit_screen(struct app *a);
void templates_screen(struct app *a);
void ib_screen(struct app *a);
void company_screen(struct app *a);
-void system_settings_screen(struct app *a);
void invoices_screen(struct app *a);
void customers_screen(struct app *a);
void bokslut_screen(struct app *a);
void employees_screen(struct app *a);
void payroll_screen(struct app *a);
void payroll_tax_tables_screen(struct app *a);
+void system_screen(struct app *a);
#endif
diff --git a/docs/PAYROLL.md b/docs/PAYROLL.md
index 23e7fb1..b78aeba 100644
--- a/docs/PAYROLL.md
+++ b/docs/PAYROLL.md
@@ -228,9 +228,9 @@ the base is the gross.
`SMTP_FAILED` (the stored attachment stays linked). `dry_run` validates,
renders and stores nothing. See `PROTOCOL.md` §7.12.
- **TUI — Lön** (done, `clients/screens_payroll.c`): `Lönekörningar` (list,
- Ctrl+N for a new run) under Lön, `Anställda` under Företag, and a
- **Skattetabeller** screen (System, since the tables are national) with
- status, the Skatteverket fetch and the offline file import.
+ Ctrl+N for a new run) under Lön, `Anställda` under Bolaget, and a
+ **Skattetabeller** screen (via System, since the tables are national)
+ with status, the Skatteverket fetch and the offline file import.
- The run screen shows the preview (F5), posts with Ctrl+Enter after
confirmation, then offers the buttons **Lönebesked** (saves/opens the
PDF), **AGI-underlag** (`payroll.agi` in a pager, owner only) and
diff --git a/docs/PROTOCOL.md b/docs/PROTOCOL.md
index 01f5dd1..76197d9 100644
--- a/docs/PROTOCOL.md
+++ b/docs/PROTOCOL.md
@@ -378,8 +378,9 @@ resolved rows in a dry run.
`default_series` (1–8 characters, e.g. `A`, `V-`, `A `) is used when
`voucher.post` carries no `series` and as the default series for new
-templates. `attachment_dir` (a path, up to 255 characters) is the folder the
-TUI file browser opens in when attaching underlag. `bank_account` (digits
+templates. `attachment_dir` (a path, up to 255 characters) is accepted for
+compatibility, but the TUI file browser now remembers the directory of the
+last picked attachment client-side. `bank_account` (digits
only, up to 10 characters, default `1930`) is the account `bank.import` uses
when the request carries no `account`. `invoice_receivable_account` (default
`1510`) and `invoice_revenue_account` (default `3001`) are the receivable and
@@ -941,8 +942,8 @@ commands. Implemented screens (0.1.0-dev):
tax table/column, e-mail). Ctrl+N creates, Enter edits (an empty
personnummer keeps the stored one), `d` archives/reactivates; F5
validates with a dry run and Ctrl+Enter saves.
-- **Skattetabeller** (System) — stored tax table years, the current year's
- status, fetch time and source. Owners fetch Skatteverket's official
+- **Skattetabeller** (via **System**) — stored tax table years, the current
+ year's status, fetch time and source. Owners fetch Skatteverket's official
monthly table (`payroll.tax_tables_fetch`) or import a file offline
(`payroll.tax_tables_import`).
- **Rapporter** — saldobalans, resultaträkning, balansräkning, moms,
@@ -959,16 +960,20 @@ commands. Implemented screens (0.1.0-dev):
events, AGM and payment dates, proposed dividend, employees, other notes),
edited per field; a new fiscal year inherits the stable fields. The
årsredovisning draft reads them without prompting.
-- **Bolaget** — the org record (name, org number, VAT number, address,
- e-mail, phone, moms period, framework, fiscal-year start month), editable
- in place by owners; others see it read-only. The sub-forms
- **Fakturauppgifter…** (standardserie, fordringskonto, intäktskonto,
- bankgiro, vår referens) and **E-post (SMTP)…** (host, port, user, sender,
- reply-to, security, password) are `settings.set` and open to bookkeepers,
- and **Styrelseledamöter** lists the board.
-- **Systeminställningar** (System) — bilagornas mapp, the file browser's
- start directory.
-- **Revision** (System) — chain verification and behandlingshistorik.
+- **Bolaget** — the dashboard's hub for the org's master data:
+ **Företagsuppgifter** (name, org number, VAT number, address, e-mail,
+ phone, moms period, framework, fiscal-year start month), editable in
+ place by owners, others see it read-only; **Fakturauppgifter**
+ (standardserie, fordringskonto, intäktskonto, bankgiro, vår referens) and
+ **E-post (SMTP)** (host, port, user, sender, reply-to, security,
+ password), both `settings.set` and open to bookkeepers;
+ **Styrelseledamöter**; and the registers **Anställda**, **Kunder** and
+ **Momsregler**.
+- **System** — the hub with **Skattetabeller** and **Revision**. The file
+ browser starts in the directory of the last picked attachment (remembered
+ in the client's `tui.conf`) and falls back to `$HOME` when it is gone.
+- **Revision** (via **System**) — chain verification and
+ behandlingshistorik.
**Ctrl+N is the universal "add" key**: it starts a new verifikat from the
dashboard, the voucher list and the voucher detail view; a new mall from the
diff --git a/docs/STATE.md b/docs/STATE.md
index bd38963..929ddf1 100644
--- a/docs/STATE.md
+++ b/docs/STATE.md
@@ -14,13 +14,19 @@ unit tests and the docs consistency check.
## Resume here (2026-09-21)
-- **Deployed**: `v0.1.55`, healthy on `nas` (Alpine runtime, static aarch64
+- **Deployed**: `v0.1.59`, healthy on `nas` (Alpine runtime, static aarch64
binaries cross-compiled on this machine — ~20 s, the host only assembles
- the image; image 33.7 MB, no `libssl3`). It brings the payroll server
- waves (schema v11: employees, tax tables, lönebesked) and the payroll
- TUI with the Företag/System menu; the live database migrated v9 → v11 on
- startup with the automatic pre-migration snapshot in `var/db/backup/`.
- `main` and the tag `v0.1.55` are pushed to `nas`.
+ the image; image 33.7 MB, no `libssl3`). `v0.1.55` brought the payroll
+ server waves (schema v11: employees, tax tables, lönebesked) and the
+ payroll TUI; `v0.1.56` the **Bolaget** hub; `v0.1.57` the flat main menu
+ without section headings, the **System** hub (Skattetabeller, Revision)
+ and the client-side remembered attachment directory; `v0.1.58` loads the
+ system CA bundle explicitly so the static binaries can verify TLS (the
+ Skatteverket fetch, SMTP, static clients); `v0.1.59` fixes form saving on
+ modern terminals (modifyOtherKeys level 2, Kitty keyboard protocol, and
+ `^Enter/F9` in the hints). The live database migrated v9 → v11 on the
+ v0.1.55 startup with the automatic pre-migration snapshot in
+ `var/db/backup/`. `main` and the tags are pushed to `nas`.
- **Next tasks (payroll follow-ups)**: fold `payroll.settings_get/set` into
`settings.get/set` (cmd_settings.c was busy during wave 1); settle the
over-80k % rule (SKV 433 leaves it ambiguous — currently a clear
@@ -33,13 +39,16 @@ unit tests and the docs consistency check.
avgifter), Anställda under Företag and the Skattetabeller
fetch/import/status screen under System. A pty scenario posts a run and
fetches its lönebesked, so the payslip PDF path is exercised end to end.
-- **Menu (2026-09-21)**: the dashboard has the sections Bokföring (with
- Mallar), Fakturering, Lön, Rapporter & bokslut, Företag (Anställda,
- Kunder, Bolaget, Momsregler) and System (Skattetabeller, Revision,
- Systeminställningar). Inställningar is gone: standardserie,
- fordrings-/intäktskonto, bankgiro, vår referens and SMTP now live under
- Bolaget (the Fakturauppgifter and E-post sub-forms) and bilagornas mapp
- under System. `--screen settings` remains an alias for Bolaget.
+- **Menu (2026-09-21)**: the dashboard is one flat list without section
+ headings: Verifikat, Underlag, Bankavstämning, Mallar, Fakturor,
+ Lönekörningar, Rapporter, Bokslut, Bolaget, System, Ingående balans,
+ Räkenskapsår, Logga ut. **Bolaget** is the master-data hub
+ (Företagsuppgifter, Fakturauppgifter, E-post (SMTP), Styrelseledamöter
+ and the registers Anställda, Kunder, Momsregler); **System** holds
+ Skattetabeller and Revision. Inställningar is gone, and bilagornas mapp
+ is no longer a setting: the file browser remembers the last pick
+ directory in `tui.conf` and falls back to `$HOME`. `--screen settings`
+ remains an alias for Bolaget.
## Open decisions
@@ -82,7 +91,7 @@ Original entries for the struck items are in `docs/DECISIONS.md`.
## Environment / how to run
-- **Deployed**: `scripts/deploy.sh` (latest `v0.1.55`, healthy on nas).
+- **Deployed**: `scripts/deploy.sh` (latest `v0.1.59`, healthy on nas).
Live daemon `tls:bokf.makandra.eu:8788`, token
`~/.config/bokf/migration-token` (scopes `read,write`; owner-only actions
like closing years must be done by the human in the TUI). Git remote
diff --git a/docs/TUI-GUIDELINES.md b/docs/TUI-GUIDELINES.md
index 7e0d404..190a5c5 100644
--- a/docs/TUI-GUIDELINES.md
+++ b/docs/TUI-GUIDELINES.md
@@ -33,7 +33,7 @@ there.
| `Ctrl+F` | Attach a file via the file browser (voucher form and voucher detail) |
| `k` | Underlag: link the highlighted attachment to a voucher picked from a list |
| `Ctrl+X` | Clear the current row — only inside row editors (never "new") |
-| `Ctrl+Enter` | Save/post the current form. Enabled via xterm `modifyOtherKeys`; terminals that cannot send it keep `F9` working |
+| `Ctrl+Enter` | Save/post the current form. Enabled via xterm `modifyOtherKeys` level 2 or the Kitty keyboard protocol; terminals that send neither keep `F9` working, and the hints show `^Enter/F9` |
Every screen prints its keys in the footer via `hints()`. If a key exists, the
footer shows it; if the footer shows it, the key works. Control keys are
@@ -135,11 +135,13 @@ written compactly as `^N`, `^A`, `^C`, `^R` to save width.
`BACK`, `REFRESH` or `SUBMIT` (the widget returns as on `Esc`/`F5`/
`Ctrl+Enter`). Hooks exist on `tui_form_run_hook`, `tui_rt_set_key`,
`tui_pager_hook` and `tui_select_list_hook`.
-- File browser (`file_browser`): starts in the org's `attachment_dir` (or
- `$HOME`; a leading `~` is expanded to `$HOME`), `.. (uppåt)` is the first row, directories sort first with a
- trailing `/`, hidden files are skipped. Enter enters a directory or picks a
- file; `Esc` cancels. Selected files are uploaded immediately as unlinked
- underlag and linked when the voucher is posted.
+- File browser (`file_browser`): starts in the directory where the last
+ attachment was picked (remembered in `tui.conf`), falling back to `$HOME`
+ when it is gone (a leading `~` is expanded to `$HOME`), `.. (uppåt)` is
+ the first row, directories sort first with a trailing `/`, hidden files
+ are skipped. Enter enters a directory or picks a file; `Esc` cancels.
+ Picking a file remembers its directory. Selected files are uploaded
+ immediately as unlinked underlag and linked when the voucher is posted.
## Messages
diff --git a/scripts/tui-golden.py b/scripts/tui-golden.py
index fbcfc57..f5d5b7f 100755
--- a/scripts/tui-golden.py
+++ b/scripts/tui-golden.py
@@ -56,7 +56,9 @@ KEYS = {
"ctrlc": "\x03",
"ctrln": "\x0e",
"ctrlenter": "\x1b[27;5;13~",
+ "ctrlenter-kitty": "\x1b[13;5u",
"f5": "\x1b[15~",
+ "f9": "\x1b[20~",
}
# {org_name} {org_nr} {fy_label} {fy_start} {fy_end} are substituted at run
@@ -66,7 +68,7 @@ SCENARIOS = [
"name": "dashboard",
"screen": "dashboard",
"expect": ["{org_name}", "{fy_label}", "{fy_start}", "{fy_end}",
- "Företag", "Bolaget"],
+ "Bolaget", "System", "Räkenskapsår"],
},
{
"name": "audit-ok",
@@ -120,6 +122,21 @@ SCENARIOS = [
],
},
{
+ "name": "inbox-file-browser",
+ "screen": "inbox",
+ "expect": ["Underlag (inkorg)"],
+ "steps": [
+ {
+ "keys": ["a"],
+ "expect": ["Välj fil — /"],
+ },
+ {
+ "keys": ["esc"],
+ "expect": ["Underlag (inkorg)"],
+ },
+ ],
+ },
+ {
"name": "bank-new-voucher",
"screen": "bank",
"expect": ["Bankavstämning", "GOLDEN INSÄTTNING", "CDON"],
@@ -219,26 +236,104 @@ SCENARIOS = [
"expect": ["Anställda", "Testanställd", "Ny anställd"],
},
{
+ "name": "employee-new-f9",
+ "screen": "employees",
+ "steps": [
+ {
+ "keys": ["ctrln"],
+ "expect": ["Ny anställd", "Personnummer"],
+ },
+ {
+ "keys": ["enter", "Testperson", "enter"],
+ "expect": ["Ny anställd", "Testperson"],
+ },
+ {
+ "keys": ["down", "enter", "19900101-1234", "enter"],
+ "expect": ["19900101-1234"],
+ },
+ {
+ "keys": ["f9"],
+ "expect": ["Anställd skapad."],
+ },
+ {
+ "keys": ["enter"],
+ "expect": ["Anställda", "Testperson"],
+ },
+ ],
+ },
+ {
+ "name": "employee-new-kitty",
+ "screen": "employees",
+ "steps": [
+ {
+ "keys": ["ctrln"],
+ "expect": ["Ny anställd", "Personnummer"],
+ },
+ {
+ "keys": ["enter", "Kitty Person", "enter"],
+ "expect": ["Kitty Person"],
+ },
+ {
+ "keys": ["down", "enter", "19900202-5678", "enter"],
+ "expect": ["19900202-5678"],
+ },
+ {
+ "keys": ["ctrlenter-kitty"],
+ "expect": ["Anställd skapad."],
+ },
+ {
+ "keys": ["enter"],
+ "expect": ["Anställda", "Kitty Person"],
+ },
+ ],
+ },
+ {
"name": "company",
"screen": "company",
- "expect": ["Bolaget", "Fakturauppgifter", "E-post (SMTP)",
- "Styrelseledamöter", "Test AB"],
+ "expect": ["Bolaget", "Företagsuppgifter", "Fakturauppgifter",
+ "E-post (SMTP)", "Anställda", "Kunder", "Momsregler"],
"steps": [
{
- "keys": ["end", "up", "enter"],
+ "keys": ["3"],
"expect": ["E-post (SMTP)", "SMTP-server",
"lämna tomt för oförändrat"],
},
{
"keys": ["esc"],
- "expect": ["Bolaget", "Fakturauppgifter"],
+ "expect": ["Bolaget", "Företagsuppgifter"],
+ },
+ {
+ "keys": ["5"],
+ "expect": ["Anställda", "Testanställd"],
+ },
+ {
+ "keys": ["esc"],
+ "expect": ["Bolaget", "Kunder"],
},
],
},
{
"name": "system",
"screen": "system",
- "expect": ["Systeminställningar", "Bilagornas mapp"],
+ "expect": ["System", "Skattetabeller", "Revision"],
+ "steps": [
+ {
+ "keys": ["1"],
+ "expect": ["Skattetabeller", "Status: skattetabeller för"],
+ },
+ {
+ "keys": ["esc"],
+ "expect": ["System", "Revision"],
+ },
+ {
+ "keys": ["2"],
+ "expect": ["Revision", "Verifiera hashkedjan"],
+ },
+ {
+ "keys": ["esc"],
+ "expect": ["System", "Skattetabeller"],
+ },
+ ],
},
{
"name": "tax-tables",
@@ -906,6 +1001,12 @@ def main(argv):
report_failure(name, missing, "", before)
failures += 1
continue
+ if (b"\x1b[>4;2m" not in app.raw or
+ b"\x1b[>1u" not in app.raw):
+ report_failure(name, ["keyboard protocol enable "
+ "sequence"], "", before)
+ failures += 1
+ continue
current = before
for step in sc.get("steps", []):
keys = resolve_keys(step["keys"])
diff --git a/src/smtp.c b/src/smtp.c
index 6a90424..18834fa 100644
--- a/src/smtp.c
+++ b/src/smtp.c
@@ -18,6 +18,7 @@
#include <openssl/ssl.h>
#include <openssl/x509.h>
+#include "tls_ca.h"
#include "util.h"
#define SMTP_TIMEOUT_SEC 30
@@ -345,7 +346,7 @@ static int tls_start(struct smtp_conn *c, const char *host, char *err,
SSL_CTX_set_options(c->ctx, SSL_OP_NO_COMPRESSION |
SSL_OP_NO_RENEGOTIATION);
SSL_CTX_set_verify(c->ctx, SSL_VERIFY_PEER, NULL);
- if (SSL_CTX_set_default_verify_paths(c->ctx) != 1) {
+ if (tls_load_default_cas(c->ctx) != 1) {
set_err(err, errlen, "smtp: cannot load system CA certificates");
return -1;
}
diff --git a/src/tax_table.c b/src/tax_table.c
index 1cf40c1..6405a70 100644
--- a/src/tax_table.c
+++ b/src/tax_table.c
@@ -18,6 +18,7 @@
#include <openssl/x509.h>
#include "db.h"
+#include "tls_ca.h"
#include "version.h"
#define TT_LINE_LEN 49
@@ -478,7 +479,7 @@ static int tt_get_once(const struct tt_url *u, struct buf *body, int *status,
SSL_CTX_set_min_proto_version(ctx, TLS1_2_VERSION);
SSL_CTX_set_options(ctx, SSL_OP_NO_COMPRESSION | SSL_OP_NO_RENEGOTIATION);
SSL_CTX_set_verify(ctx, SSL_VERIFY_PEER, NULL);
- if (SSL_CTX_set_default_verify_paths(ctx) != 1) {
+ if (tls_load_default_cas(ctx) != 1) {
set_err(err, "cannot load system CA certificates");
goto done;
}
diff --git a/src/tls_ca.h b/src/tls_ca.h
new file mode 100644
index 0000000..3b31345
--- /dev/null
+++ b/src/tls_ca.h
@@ -0,0 +1,31 @@
+#ifndef BOKF_TLS_CA_H
+#define BOKF_TLS_CA_H
+
+#include <openssl/ssl.h>
+
+/* Load the system trust store. A statically linked OpenSSL keeps the build
+ machine's compiled-in directory (e.g. Debian's /usr/lib/ssl), which may
+ not exist where the binary runs, so the common bundle locations are also
+ tried explicitly. Returns 1 when any store was loaded. */
+static inline int tls_load_default_cas(SSL_CTX *ctx)
+{
+ int ok = SSL_CTX_set_default_verify_paths(ctx) == 1;
+ static const char *const files[] = {
+ "/etc/ssl/certs/ca-certificates.crt",
+ "/etc/pki/tls/certs/ca-bundle.crt",
+ NULL,
+ };
+ static const char *const dirs[] = {
+ "/etc/ssl/certs",
+ NULL,
+ };
+ for (int i = 0; files[i]; i++)
+ if (SSL_CTX_load_verify_locations(ctx, files[i], NULL) == 1)
+ ok = 1;
+ for (int i = 0; dirs[i]; i++)
+ if (SSL_CTX_load_verify_locations(ctx, NULL, dirs[i]) == 1)
+ ok = 1;
+ return ok;
+}
+
+#endif