From 17d6be9fe1fc644cc40c810df51b25b296289169 Mon Sep 17 00:00:00 2001 From: clawbot Date: Tue, 29 Sep 2026 00:37:57 +0000 Subject: [PATCH] HTTP API: server, credential and the list of chats (closes #4) The bot now serves an HTTP API on PORT (default 8080) beside the chat client. Every request needs the credential read at startup from the file named by API_TOKEN_FILE, sent as a bearer token; without one, every request is refused. Responses carry the security headers, bodies are capped at 64 KiB and each request's work at 10 seconds. GET /api/v1/chats lists the bot's contacts from the chat client's /_contacts command, ordered by id, and marks the contacts who deleted their chat with the bot, which the chat client keeps listing. bot.Run starts the API after set-up and stops it within 5 seconds; a listener failure ends the bot as a chat client failure does. Model: opus-5-5 --- AGENTS.md | 3 +- Dockerfile | 4 + README.md | 84 ++++++++++++- docs/TODO.md | 3 + go.mod | 1 + go.sum | 2 + internal/api/api.go | 180 +++++++++++++++++++++++++++ internal/api/api_test.go | 214 ++++++++++++++++++++++++++++++++ internal/api/chats.go | 46 +++++++ internal/api/chats_test.go | 64 ++++++++++ internal/bot/bot.go | 78 +++++++++--- internal/cli/run.go | 2 +- internal/config/config.go | 82 +++++++++++- internal/config/config_test.go | 94 +++++++++++++- internal/simplex/client.go | 12 ++ internal/simplex/client_test.go | 46 +++++++ internal/simplex/protocol.go | 28 ++++- 17 files changed, 908 insertions(+), 35 deletions(-) create mode 100644 internal/api/api.go create mode 100644 internal/api/api_test.go create mode 100644 internal/api/chats.go create mode 100644 internal/api/chats_test.go diff --git a/AGENTS.md b/AGENTS.md index 67def55..6fc879a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,6 +1,6 @@ --- title: Agent Guidance -last_modified: 2026-09-28 +last_modified: 2026-09-29 --- This file is the single source of guidance for any automated agent @@ -119,6 +119,7 @@ Do not weaken them. ``` cmd/simplexcalc/ main(), a single call into internal/cli +internal/api/ the HTTP API: its credential, headers and endpoints internal/bot/ startup, address setup, and the reply to a message internal/calc/ the arithmetic: go/parser and go/constant internal/cli/ cobra command tree: run and version diff --git a/Dockerfile b/Dockerfile index 3238e1a..4e01b89 100644 --- a/Dockerfile +++ b/Dockerfile @@ -119,4 +119,8 @@ USER simplexcalc ENV DATA_DIR=/var/lib/simplexcalc +# The API, on its default PORT. The chat client's WebSocket on 5225 is +# not exposed: it has no authentication. +EXPOSE 8080 + CMD ["/app/simplexcalc", "run"] diff --git a/README.md b/README.md index 80aa58b..2d91f6a 100644 --- a/README.md +++ b/README.md @@ -11,18 +11,28 @@ else gets a short explanation instead of a result. ## Getting Started -Build the image and run the bot, with its SimpleX profile on a named -volume: +Build the image, write a random API credential onto a named volume, and +run the bot with its SimpleX profile on the same volume: ```sh git clone git@git.eeqj.de:clawbot/simplexcalc.git cd simplexcalc make docker +docker run --rm -v simplexcalc-data:/var/lib/simplexcalc simplexcalc \ + sh -c 'umask 077 && od -An -N32 -tx1 /dev/urandom | tr -d " \n" \ + >/var/lib/simplexcalc/api-token' docker run -d --name simplexcalc --restart unless-stopped \ - -v simplexcalc-data:/var/lib/simplexcalc simplexcalc + -v simplexcalc-data:/var/lib/simplexcalc \ + -p 127.0.0.1:8080:8080 -e API_TOKEN_FILE=/var/lib/simplexcalc/api-token \ + simplexcalc docker logs simplexcalc 2>&1 | grep '"msg":"ready"' ``` +The credential is 32 random bytes written as 64 hexadecimal characters +to `/var/lib/simplexcalc/api-token`, readable only by the bot's user. +`-p 127.0.0.1:8080:8080` makes the API reachable from this host only; +see [API](#api). + The `ready` log line carries the bot's contact address: `address` is the short link to share, and `full_address` is the same address in the long form that older SimpleX clients need. Open the link in any SimpleX Chat @@ -67,6 +77,60 @@ variables that are absent. `/var/lib/simplexcalc`. - `DEBUG` — `true` or `false`, default `false`. `true` logs every event the chat client sends. +- `PORT` — the TCP port the API listens on, on all interfaces: a whole + number from 1 to 65535, default `8080`. +- `API_TOKEN_FILE` — path of a file holding the API credential, at least + 32 characters not counting the whitespace around them. The file is + read once, at startup; one that cannot be read, or holds a shorter + credential, aborts startup. Absent, the API still listens but refuses + every request, and startup logs a warning saying so. + +## API + +An HTTP API beside the chat client lets another program read the bot's +chats. It speaks JSON on `PORT`. + +**Authentication.** Every request carries the credential from +`API_TOKEN_FILE`: + +``` +Authorization: Bearer {credential} +``` + +A request without it, or with a wrong one, gets `401` with +`WWW-Authenticate: Bearer` and `{"error":"unauthorized"}`. No path is +exempt. Without `API_TOKEN_FILE`, every request gets that answer. The +file is read at startup, so a new credential takes a restart. + +**Errors** are JSON with a short explanation, such as +`{"error":"not found"}`; what went wrong inside goes to the bot's log. + +### `GET /api/v1/chats` + +The bot's chats, ordered by `id`. The bot talks to people only one to +one, so each chat is one of its contacts. + +```sh +TOKEN=$(docker exec simplexcalc cat /var/lib/simplexcalc/api-token) +curl -H "Authorization: Bearer $TOKEN" http://127.0.0.1:8080/api/v1/chats +``` + +```json +{ + "chats": [ + { "id": 3, "display_name": "tester", "contact_deleted": false }, + { "id": 4, "display_name": "tester", "contact_deleted": true } + ] +} +``` + +- `id` — the chat's number, which is its contact's number in the chat + client. +- `display_name` — the name the contact gave themselves. Nothing makes + it unique. +- `contact_deleted` — `true` once the contact has deleted their chat + with the bot. The chat stays in the list, but nothing more reaches + them. ## Entrypoints @@ -142,8 +206,8 @@ container. - **One process tree, one container.** `simplexcalc run` starts the SimpleX Chat command-line client, `simplex-chat`, as a child process, with its database under `$DATA_DIR/simplex` and its WebSocket API on - `127.0.0.1:5225`. The API has no authentication, which is why it is - never exposed outside the container. + `127.0.0.1:5225`. That WebSocket has no authentication, which is why + it is never exposed outside the container. - **The protocol** (`internal/simplex`) is JSON over that WebSocket: a command carries a correlation id, its response carries the same id, and anything without one is an event. Only the fields the bot reads @@ -154,6 +218,16 @@ container. accept every contact request and to greet each new contact. The first start creates the profile itself, a bot profile named `calc`. The address is logged in the `ready` line. +- **The API** (`internal/api`): an HTTP server, routed with chi, that + starts once set-up is done; if it cannot listen, the bot exits with an + error, as when the chat client fails. Every request must carry the + credential, compared in constant time; every response carries headers + that forbid framing, content sniffing, caching and referrers; a + request body is capped at 64 KiB and a request's work at 10 seconds. + Handlers call the chat client on the request's own goroutine, never on + the one that delivers events, which also delivers the chat client's + answers. When the bot stops, requests in progress get 5 seconds to + finish. - **Replies**: for each text message a contact sends in a direct chat, the bot sends back the result, as a reply quoting the message. Group messages, files and the bot's own messages are ignored. diff --git a/docs/TODO.md b/docs/TODO.md index 57eef19..bd8496f 100644 --- a/docs/TODO.md +++ b/docs/TODO.md @@ -27,6 +27,9 @@ with no deprecation warning. # Completed Steps +- 2026-09-29 Added the HTTP API in `internal/api`: the server on `PORT`, + the bearer credential from `API_TOKEN_FILE`, security headers and + limits, and `GET /api/v1/chats` - 2026-09-28 Moved the command tree and the `run` and `version` commands from `cmd/simplexcalc/` into `internal/cli`; `cmd/simplexcalc/main.go` is now a single call to `cli.Main` diff --git a/go.mod b/go.mod index f0c9abc..4e77682 100644 --- a/go.mod +++ b/go.mod @@ -3,6 +3,7 @@ module sneak.berlin/go/simplexcalc go 1.25.0 require ( + github.com/go-chi/chi/v5 v5.3.2 github.com/gorilla/websocket v1.5.3 github.com/joho/godotenv v1.5.1 github.com/spf13/cobra v1.10.2 diff --git a/go.sum b/go.sum index 1fd508c..0bcca44 100644 --- a/go.sum +++ b/go.sum @@ -5,6 +5,8 @@ github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHk github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= +github.com/go-chi/chi/v5 v5.3.2 h1:5YQkICvTCSZ25hoRsyJazN0scjzKGiu4VAUc7H1o1nY= +github.com/go-chi/chi/v5 v5.3.2/go.mod h1:R+tYY2hNuVUUjxoPtqUdgBqevM9s9njzkTLutVsOCto= github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs= github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= diff --git a/internal/api/api.go b/internal/api/api.go new file mode 100644 index 0000000..bbf0aad --- /dev/null +++ b/internal/api/api.go @@ -0,0 +1,180 @@ +// Package api is the bot's HTTP API, through which another program +// reads the bot's chats. +// +// Every request must carry the credential, as "Authorization: Bearer +// {credential}"; with no credential configured, every request is +// refused. No path is exempt. +// +// Handlers call the chat client on the request's own goroutine. Doing +// so from the chat client's event handler would wait forever, since +// that goroutine also delivers the responses (see simplex.EventHandler). +package api + +import ( + "context" + "crypto/subtle" + "encoding/json" + "log/slog" + "net/http" + "strconv" + "strings" + "time" + + "github.com/go-chi/chi/v5" + "github.com/go-chi/chi/v5/middleware" + "sneak.berlin/go/simplexcalc/internal/simplex" +) + +const ( + // maxBodyBytes caps a request body. + maxBodyBytes = 64 << 10 + + // requestTimeout bounds the work behind one request, which is + // mostly waiting for the chat client. + requestTimeout = 10 * time.Second + + // Limits on clients that send or read slowly. writeTimeout outlasts + // requestTimeout, so a handler that ran out of time can still + // answer. + readHeaderTimeout = 5 * time.Second + readTimeout = 10 * time.Second + writeTimeout = requestTimeout + 5*time.Second + idleTimeout = 60 * time.Second +) + +// ChatClient is the part of the chat client the API uses. +// *simplex.Client provides it. +type ChatClient interface { + // Contacts returns the contacts of the user userID. + Contacts(ctx context.Context, userID int64) ([]simplex.Contact, error) +} + +// Params configures New. +type Params struct { + Log *slog.Logger + + // Client is the chat client, and UserID the bot's user profile in + // it. + Client ChatClient + UserID int64 + + // Port is the TCP port to listen on, on all interfaces. + Port int + + // Token is the credential every request must carry. Empty refuses + // every request. + Token string +} + +// New returns the API's server. The caller starts it with +// ListenAndServe and stops it with Shutdown. +func New(p Params) *http.Server { + if p.Token == "" { + p.Log.Warn("API_TOKEN_FILE is not set, so the API refuses every request") + } + + h := &handlers{log: p.Log, client: p.Client, userID: p.UserID, token: p.Token} + + router := chi.NewRouter() + router.Use(securityHeaders, h.authenticate, + middleware.RequestSize(maxBodyBytes), withTimeout) + router.NotFound(func(w http.ResponseWriter, _ *http.Request) { + h.respondError(w, http.StatusNotFound, "not found") + }) + router.MethodNotAllowed(func(w http.ResponseWriter, _ *http.Request) { + h.respondError(w, http.StatusMethodNotAllowed, "method not allowed") + }) + router.Route("/api/v1", func(r chi.Router) { + r.Get("/chats", h.handleChats()) + }) + + return &http.Server{ + Addr: ":" + strconv.Itoa(p.Port), + Handler: router, + ReadHeaderTimeout: readHeaderTimeout, + ReadTimeout: readTimeout, + WriteTimeout: writeTimeout, + IdleTimeout: idleTimeout, + // net/http's own messages, such as a handler's panic, go to the + // same JSON log as everything else. + ErrorLog: slog.NewLogLogger(p.Log.Handler(), slog.LevelError), + } +} + +// handlers holds what the handlers share. +type handlers struct { + log *slog.Logger + client ChatClient + userID int64 + token string +} + +// authenticate lets a request through only if it carries the +// credential. With no credential it refuses everything, and must: +// ConstantTimeCompare finds two empty strings equal, so an empty bearer +// would get in. +func (h *handlers) authenticate(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + scheme, credential, _ := strings.Cut(r.Header.Get("Authorization"), " ") + + if h.token == "" || !strings.EqualFold(scheme, "Bearer") || + subtle.ConstantTimeCompare([]byte(credential), []byte(h.token)) != 1 { + w.Header().Set("WWW-Authenticate", "Bearer") + h.respondError(w, http.StatusUnauthorized, "unauthorized") + + return + } + + next.ServeHTTP(w, r) + }) +} + +// respond sends v as the JSON body of a response with status. +func (h *handlers) respond(w http.ResponseWriter, status int, v any) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + + err := json.NewEncoder(w).Encode(v) + if err != nil { + h.log.Warn("sending a response", "error", err) + } +} + +// respondError sends status with a chosen sentence. An error's own text +// never goes to the client, since it can describe the machine; it goes +// to the log. +func (h *handlers) respondError(w http.ResponseWriter, status int, sentence string) { + h.respond(w, status, struct { + Error string `json:"error"` + }{sentence}) +} + +// securityHeaders go on every response. The API returns JSON to +// programs, so a browser may not frame, sniff, cache or refer from it, +// and must reach it over HTTPS. +func securityHeaders(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + header := w.Header() + header.Set("X-Content-Type-Options", "nosniff") + header.Set("Content-Security-Policy", + "default-src 'none'; frame-ancestors 'none'") + header.Set("X-Frame-Options", "DENY") + header.Set("Referrer-Policy", "no-referrer") + header.Set("Strict-Transport-Security", + "max-age=31536000; includeSubDomains") + header.Set("Cache-Control", "no-store") + + next.ServeHTTP(w, r) + }) +} + +// withTimeout ends each request's context after requestTimeout, so a +// handler waiting on the chat client gives up and answers. +func withTimeout(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + ctx, cancel := context.WithTimeout(r.Context(), requestTimeout) + defer cancel() + + next.ServeHTTP(w, r.WithContext(ctx)) + }) +} diff --git a/internal/api/api_test.go b/internal/api/api_test.go new file mode 100644 index 0000000..5056e81 --- /dev/null +++ b/internal/api/api_test.go @@ -0,0 +1,214 @@ +package api_test + +import ( + "bytes" + "context" + "errors" + "log/slog" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "sneak.berlin/go/simplexcalc/internal/api" + "sneak.berlin/go/simplexcalc/internal/simplex" +) + +const ( + // credential is what the API under test is configured with. + credential = "a-credential-for-these-tests" //nolint:gosec // G101: invented for tests + bearer = "Bearer " + credential + + chatsPath = "/api/v1/chats" + unauthorized = `{"error":"unauthorized"}` + "\n" +) + +var errChat = errors.New("sqlite: database is locked at /var/lib/simplexcalc") + +// fakeClient stands in for the chat client. It answers with contacts, +// or with err, and remembers what it was asked. +type fakeClient struct { + contacts []simplex.Contact + err error + + userID int64 + hadDeadline bool +} + +func (f *fakeClient) Contacts( + ctx context.Context, userID int64, +) ([]simplex.Contact, error) { + f.userID = userID + _, f.hadDeadline = ctx.Deadline() + + return f.contacts, f.err +} + +func newAPI(token string, client api.ChatClient) *http.Server { + return api.New(api.Params{ + Log: slog.New(slog.DiscardHandler), + Client: client, + UserID: 1, + Port: 8080, + Token: token, + }) +} + +// request sends srv one request, with the Authorization header auth +// unless that is empty. +func request( + t *testing.T, srv *http.Server, method, path, auth string, +) *httptest.ResponseRecorder { + t.Helper() + + req := httptest.NewRequestWithContext(t.Context(), method, path, nil) + if auth != "" { + req.Header.Set("Authorization", auth) + } + + rec := httptest.NewRecorder() + srv.Handler.ServeHTTP(rec, req) + + return rec +} + +// TestCredential: only the configured credential, sent as a bearer, +// gets in. With none configured, nothing does, an empty bearer +// included. +func TestCredential(t *testing.T) { + t.Parallel() + + for name, tc := range map[string]struct { + token, auth string + in bool + }{ + "right": {credential, bearer, true}, + "right, scheme in lower case": {credential, "bearer " + credential, true}, + "wrong": {credential, strings.ToUpper(bearer), false}, + "missing": {credential, "", false}, + "another scheme": {credential, "Basic " + credential, false}, + "no scheme": {credential, credential, false}, + "empty bearer": {credential, "Bearer ", false}, + "none configured": {"", bearer, false}, + "none configured, empty bearer": {"", "Bearer ", false}, + } { + t.Run(name, func(t *testing.T) { + t.Parallel() + + rec := request(t, newAPI(tc.token, &fakeClient{}), + http.MethodGet, chatsPath, tc.auth) + + if tc.in { + if rec.Code != http.StatusOK { + t.Errorf("status = %d, want 200", rec.Code) + } + + return + } + + if rec.Code != http.StatusUnauthorized { + t.Fatalf("status = %d, want 401", rec.Code) + } + + if got := rec.Header().Get("WWW-Authenticate"); got != "Bearer" { + t.Errorf("WWW-Authenticate = %q, want Bearer", got) + } + + if rec.Body.String() != unauthorized { + t.Errorf("body = %q, want %q", rec.Body.String(), unauthorized) + } + }) + } +} + +// TestNoCredentialWarns: an API without a credential says at startup +// that it refuses every request. +func TestNoCredentialWarns(t *testing.T) { + t.Parallel() + + var logged bytes.Buffer + + api.New(api.Params{ + Log: slog.New(slog.NewJSONHandler(&logged, nil)), + Client: &fakeClient{}, + Port: 8080, + }) + + if !strings.Contains(logged.String(), `"level":"WARN"`) || + !strings.Contains(logged.String(), "API_TOKEN_FILE is not set") { + t.Errorf("log = %q, want a warning that API_TOKEN_FILE is not set", + logged.String()) + } +} + +// TestNoPathIsExempt: an unknown path or method needs the credential +// like everything else, and then gets a JSON error. +func TestNoPathIsExempt(t *testing.T) { + t.Parallel() + + srv := newAPI(credential, &fakeClient{}) + + for _, tc := range []struct { + method, path, auth string + want int + body string + }{ + {http.MethodGet, "/", "", http.StatusUnauthorized, unauthorized}, + { + http.MethodGet, "/.well-known/healthcheck", "", + http.StatusUnauthorized, unauthorized, + }, + { + http.MethodGet, "/api/v1/nothing", bearer, + http.StatusNotFound, `{"error":"not found"}` + "\n", + }, + { + http.MethodPost, chatsPath, bearer, + http.StatusMethodNotAllowed, `{"error":"method not allowed"}` + "\n", + }, + } { + rec := request(t, srv, tc.method, tc.path, tc.auth) + if rec.Code != tc.want || rec.Body.String() != tc.body { + t.Errorf("%s %s: %d %q, want %d %q", tc.method, tc.path, + rec.Code, rec.Body.String(), tc.want, tc.body) + } + } +} + +// TestHeaders: every response, whatever its status, carries the +// security headers, and none lets another origin in. +func TestHeaders(t *testing.T) { + t.Parallel() + + want := map[string]string{ + "X-Content-Type-Options": "nosniff", + "Content-Security-Policy": "default-src 'none'; frame-ancestors 'none'", + "X-Frame-Options": "DENY", + "Referrer-Policy": "no-referrer", + "Strict-Transport-Security": "max-age=31536000; includeSubDomains", + "Cache-Control": "no-store", + } + + for _, tc := range []struct { + client *fakeClient + path, auth string + }{ + {&fakeClient{}, chatsPath, bearer}, + {&fakeClient{}, chatsPath, ""}, + {&fakeClient{}, "/nothing", bearer}, + {&fakeClient{err: errChat}, chatsPath, bearer}, + } { + rec := request(t, newAPI(credential, tc.client), + http.MethodGet, tc.path, tc.auth) + + for key, value := range want { + if got := rec.Header().Get(key); got != value { + t.Errorf("%d response: %s = %q, want %q", rec.Code, key, got, value) + } + } + + if got := rec.Header().Get("Access-Control-Allow-Origin"); got != "" { + t.Errorf("%d response: Access-Control-Allow-Origin = %q", rec.Code, got) + } + } +} diff --git a/internal/api/chats.go b/internal/api/chats.go new file mode 100644 index 0000000..10c70fe --- /dev/null +++ b/internal/api/chats.go @@ -0,0 +1,46 @@ +package api + +import ( + "cmp" + "net/http" + "slices" +) + +// handleChats lists the bot's chats, ordered by id. The bot talks to +// people only one to one, so its chats are its contacts, and a chat's +// id is its contact's. +func (h *handlers) handleChats() http.HandlerFunc { + type chat struct { + ID int64 `json:"id"` + DisplayName string `json:"display_name"` + ContactDeleted bool `json:"contact_deleted"` + } + + type response struct { + Chats []chat `json:"chats"` + } + + return func(w http.ResponseWriter, r *http.Request) { + contacts, err := h.client.Contacts(r.Context(), h.userID) + if err != nil { + h.log.Error("listing the chats", "error", err) + h.respondError(w, http.StatusInternalServerError, + "the chats could not be read") + + return + } + + chats := make([]chat, 0, len(contacts)) + for _, c := range contacts { + chats = append(chats, chat{ + ID: c.ContactID, + DisplayName: c.Profile.DisplayName, + ContactDeleted: c.Deleted(), + }) + } + + slices.SortFunc(chats, func(a, b chat) int { return cmp.Compare(a.ID, b.ID) }) + + h.respond(w, http.StatusOK, response{Chats: chats}) + } +} diff --git a/internal/api/chats_test.go b/internal/api/chats_test.go new file mode 100644 index 0000000..3cc6b9f --- /dev/null +++ b/internal/api/chats_test.go @@ -0,0 +1,64 @@ +package api_test + +import ( + "net/http" + "testing" + + "sneak.berlin/go/simplexcalc/internal/simplex" +) + +// TestChats: the chats are the bot's contacts, ordered by id, deleted +// ones marked, and the chat client is asked for the bot's user with a +// deadline. +func TestChats(t *testing.T) { + t.Parallel() + + client := &fakeClient{contacts: []simplex.Contact{ + {ContactID: 3, Profile: simplex.Profile{DisplayName: "bob"}, Status: "deleted"}, + {ContactID: 2, Profile: simplex.Profile{DisplayName: "alice"}, Status: "active"}, + }} + + rec := request(t, newAPI(credential, client), http.MethodGet, chatsPath, bearer) + + want := `{"chats":[{"id":2,"display_name":"alice","contact_deleted":false},` + + `{"id":3,"display_name":"bob","contact_deleted":true}]}` + "\n" + if rec.Code != http.StatusOK || rec.Body.String() != want { + t.Errorf("response = %d %q, want 200 %q", rec.Code, rec.Body.String(), want) + } + + if got := rec.Header().Get("Content-Type"); got != "application/json" { + t.Errorf("Content-Type = %q, want application/json", got) + } + + if client.userID != 1 || !client.hadDeadline { + t.Errorf("the chat client was asked for user %d, deadline %v; "+ + "want user 1 with a deadline", client.userID, client.hadDeadline) + } +} + +// TestNoChats: no contacts is an empty list, not null. +func TestNoChats(t *testing.T) { + t.Parallel() + + rec := request(t, newAPI(credential, &fakeClient{}), + http.MethodGet, chatsPath, bearer) + + want := `{"chats":[]}` + "\n" + if rec.Code != http.StatusOK || rec.Body.String() != want { + t.Errorf("response = %d %q, want 200 %q", rec.Code, rec.Body.String(), want) + } +} + +// TestChatsFailure: when the chat client fails, the response says so +// in a chosen sentence, never in the error's own text. +func TestChatsFailure(t *testing.T) { + t.Parallel() + + rec := request(t, newAPI(credential, &fakeClient{err: errChat}), + http.MethodGet, chatsPath, bearer) + + want := `{"error":"the chats could not be read"}` + "\n" + if rec.Code != http.StatusInternalServerError || rec.Body.String() != want { + t.Errorf("response = %d %q, want 500 %q", rec.Code, rec.Body.String(), want) + } +} diff --git a/internal/bot/bot.go b/internal/bot/bot.go index 4ba2eb0..18966bb 100644 --- a/internal/bot/bot.go +++ b/internal/bot/bot.go @@ -8,12 +8,15 @@ import ( "errors" "fmt" "log/slog" + "net/http" "os" "path/filepath" "strconv" "time" + "sneak.berlin/go/simplexcalc/internal/api" "sneak.berlin/go/simplexcalc/internal/calc" + "sneak.berlin/go/simplexcalc/internal/config" "sneak.berlin/go/simplexcalc/internal/simplex" ) @@ -42,17 +45,22 @@ const ( // retryInterval paces the connection attempts. retryInterval = 250 * time.Millisecond + // apiStopTimeout is how long API requests in progress get to finish + // when the bot stops, before their connections are closed. + apiStopTimeout = 5 * time.Second + dataDirMode = 0o700 ) var errExited = errors.New("simplex-chat exited") -// Run starts the chat client with its database in dataDir, connects to -// it, sets up the bot's address, and answers messages until ctx is -// cancelled — which is a clean stop and returns nil — or until the chat -// client or the connection to it fails, which returns the error. -func Run(ctx context.Context, log *slog.Logger, dataDir string) error { - err := os.MkdirAll(dataDir, dataDirMode) +// Run starts the chat client with its database in cfg.DataDir, connects +// to it, sets up the bot's address, then answers messages and serves the +// API until ctx is cancelled — which is a clean stop and returns nil — +// or until the chat client, the connection to it or the API's listener +// fails, which returns the error. +func Run(ctx context.Context, log *slog.Logger, cfg *config.Config) error { + err := os.MkdirAll(cfg.DataDir, dataDirMode) if err != nil { return fmt.Errorf("creating data directory: %w", err) } @@ -61,7 +69,7 @@ func Run(ctx context.Context, log *slog.Logger, dataDir string) error { // Run return only once it has exited, whatever path Run takes. cliCtx, stopCLI := context.WithCancel(ctx) - cli, err := simplex.StartCLI(cliCtx, log, filepath.Join(dataDir, "simplex"), + cli, err := simplex.StartCLI(cliCtx, log, filepath.Join(cfg.DataDir, "simplex"), DisplayName, chatPort) if err != nil { stopCLI() @@ -81,11 +89,31 @@ func Run(ctx context.Context, log *slog.Logger, dataDir string) error { defer func() { _ = client.Close() }() - err = setUp(ctx, log, client) + user, err := setUp(ctx, log, client) if err != nil { return err } + srv := api.New(api.Params{ + Log: log, + Client: client, + UserID: user.UserID, + Port: cfg.Port, + Token: cfg.APIToken, + }) + + served := make(chan error, 1) + + go func() { + served <- srv.ListenAndServe() + }() + + // Deferred last, so it runs first: requests in progress finish while + // the chat client is still there to answer them. + defer stopAPI(ctx, log, srv) + + log.Info("starting the API", "port", cfg.Port) + select { case <-ctx.Done(): return nil @@ -93,6 +121,23 @@ func Run(ctx context.Context, log *slog.Logger, dataDir string) error { return fmt.Errorf("%w: %w", simplex.ErrClosed, client.Err()) case <-cli.Done(): return fmt.Errorf("%w: %w", errExited, cli.Err()) + case err := <-served: + return fmt.Errorf("serving the API: %w", err) + } +} + +// stopAPI stops the API server, giving requests in progress up to +// apiStopTimeout to finish before closing their connections. +func stopAPI(ctx context.Context, log *slog.Logger, srv *http.Server) { + // ctx is usually cancelled by now: that is why the bot is stopping. + ctx, cancel := context.WithTimeout(context.WithoutCancel(ctx), apiStopTimeout) + defer cancel() + + err := srv.Shutdown(ctx) + if err != nil { + log.Warn("stopping the API", "error", err) + + _ = srv.Close() } } @@ -125,19 +170,22 @@ func connect( // setUp gives the bot a long-term contact address, creating it on the // first start, and sets it to accept every contact request and to greet // each new contact. The settings are written on every start, so an -// address whose settings were changed by hand is put right. -func setUp(ctx context.Context, log *slog.Logger, client *simplex.Client) error { +// address whose settings were changed by hand is put right. It returns +// the bot's user profile. +func setUp( + ctx context.Context, log *slog.Logger, client *simplex.Client, +) (simplex.User, error) { ctx, cancel := context.WithTimeout(ctx, setupTimeout) defer cancel() user, err := client.ActiveUser(ctx) if err != nil { - return fmt.Errorf("reading the bot's profile: %w", err) + return user, fmt.Errorf("reading the bot's profile: %w", err) } link, ok, err := client.Address(ctx, user.UserID) if err != nil { - return fmt.Errorf("reading the bot's address: %w", err) + return user, fmt.Errorf("reading the bot's address: %w", err) } if !ok { @@ -145,7 +193,7 @@ func setUp(ctx context.Context, log *slog.Logger, client *simplex.Client) error link, err = client.CreateAddress(ctx, user.UserID) if err != nil { - return fmt.Errorf("creating the bot's address: %w", err) + return user, fmt.Errorf("creating the bot's address: %w", err) } } @@ -154,7 +202,7 @@ func setUp(ctx context.Context, log *slog.Logger, client *simplex.Client) error AutoReply: &simplex.MsgContent{Type: "text", Text: Welcome}, }) if err != nil { - return fmt.Errorf("setting the bot's address to accept everyone: %w", err) + return user, fmt.Errorf("setting the bot's address to accept everyone: %w", err) } log.Info("ready", @@ -163,7 +211,7 @@ func setUp(ctx context.Context, log *slog.Logger, client *simplex.Client) error "full_address", link.FullLink, ) - return nil + return user, nil } // handle answers each text message a contact sends. diff --git a/internal/cli/run.go b/internal/cli/run.go index bbf0782..8392856 100644 --- a/internal/cli/run.go +++ b/internal/cli/run.go @@ -45,7 +45,7 @@ func run(ctx context.Context, version string) error { ctx, stop := signal.NotifyContext(ctx, syscall.SIGINT, syscall.SIGTERM) defer stop() - err = bot.Run(ctx, log, cfg.DataDir) + err = bot.Run(ctx, log, cfg) if err != nil { log.Error("stopped", "error", err) diff --git a/internal/config/config.go b/internal/config/config.go index 7e321d7..0b5eb56 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -16,8 +16,10 @@ package config import ( "errors" "fmt" + "os" "strconv" "strings" + "unicode/utf8" "github.com/spf13/viper" @@ -34,12 +36,23 @@ import ( // Environment variable names. Bare names, no prefix: this matches the // other services and keeps a compose file readable. const ( - EnvDataDir = "DATA_DIR" - EnvDebug = "DEBUG" + EnvDataDir = "DATA_DIR" + EnvDebug = "DEBUG" + EnvPort = "PORT" + EnvAPITokenFile = "API_TOKEN_FILE" //nolint:gosec // G101: a name, not a credential ) -// DefaultDataDir applies when DATA_DIR is absent. -const DefaultDataDir = "./data" +// Defaults, for the variables that are absent. +const ( + DefaultDataDir = "./data" + DefaultPort = 8080 +) + +// MinAPITokenLength is the fewest characters the API credential may +// have, not counting whitespace around it. +const MinAPITokenLength = 32 + +const maxPort = 65535 // ErrInvalidConfig is the sentinel every configuration failure wraps, // so callers can distinguish "the operator got it wrong" from "the @@ -55,6 +68,14 @@ type Config struct { // address and its contacts. Losing it loses the address. DataDir string Debug bool + + // Port is the API's TCP port. + Port int + + // APIToken is the credential every API request must carry, read + // from the file named by API_TOKEN_FILE. Empty when that is absent, + // and then the API refuses every request. Never log it. + APIToken string } // loader parses one environment into a Config, accumulating every @@ -109,6 +130,53 @@ func (l *loader) boolean(key string, def bool) bool { return b } +// port accepts a whole number from 1 to 65535 and refuses everything +// else. +func (l *loader) port(key string, def int) int { + s, ok := l.raw(key) + if !ok { + return def + } + + n, err := strconv.Atoi(s) + if err != nil || n < 1 || n > maxPort { + l.fail(key, s, "not a port (use a whole number from 1 to 65535)") + + return def + } + + return n +} + +// tokenFile returns the credential held in the file named by key, with +// the whitespace around it trimmed, or "" when key is absent. A file +// that cannot be read, or holds fewer than MinAPITokenLength +// characters, is a failure; the message names the file, never what it +// holds. +func (l *loader) tokenFile(key string) string { + path, ok := l.raw(key) + if !ok { + return "" + } + + b, err := os.ReadFile(path) //nolint:gosec // G304: the operator names the file. + if err != nil { + l.fail(key, path, "unreadable: "+err.Error()) + + return "" + } + + token := strings.TrimSpace(string(b)) + if utf8.RuneCountInString(token) < MinAPITokenLength { + l.fail(key, path, fmt.Sprintf("a file holding fewer than %d characters", + MinAPITokenLength)) + + return "" + } + + return token +} + // New parses and validates the environment. An error here aborts // startup before the chat client is launched, so there is no partially // configured running state to reason about. @@ -125,8 +193,10 @@ func load(v *viper.Viper) (*Config, error) { l := &loader{v: v} c := &Config{ - DataDir: l.str(EnvDataDir, DefaultDataDir), - Debug: l.boolean(EnvDebug, false), + DataDir: l.str(EnvDataDir, DefaultDataDir), + Debug: l.boolean(EnvDebug, false), + Port: l.port(EnvPort, DefaultPort), + APIToken: l.tokenFile(EnvAPITokenFile), } if len(l.errs) > 0 { diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 3a3a378..3e1fb2f 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -2,6 +2,9 @@ package config_test import ( "errors" + "os" + "path/filepath" + "strings" "testing" "github.com/spf13/viper" @@ -26,8 +29,11 @@ func TestAbsentValuesTakeDefaults(t *testing.T) { t.Parallel() for name, kv := range map[string]map[string]string{ - "unset": nil, - "whitespace only": {config.EnvDataDir: " ", config.EnvDebug: " "}, + "unset": nil, + "whitespace only": { + config.EnvDataDir: " ", config.EnvDebug: " ", + config.EnvPort: " ", config.EnvAPITokenFile: "\t", + }, } { t.Run(name, func(t *testing.T) { t.Parallel() @@ -44,6 +50,14 @@ func TestAbsentValuesTakeDefaults(t *testing.T) { if c.Debug { t.Error("Debug must default off") } + + if c.Port != config.DefaultPort { + t.Errorf("Port = %d, want %d", c.Port, config.DefaultPort) + } + + if c.APIToken != "" { + t.Error("APIToken must default to none") + } }) } } @@ -94,3 +108,79 @@ func TestValidValuesAreUsed(t *testing.T) { t.Error("Debug = false, want true") } } + +// TestPort: PORT is a whole number from 1 to 65535, and anything else +// aborts. +func TestPort(t *testing.T) { + t.Parallel() + + for raw, want := range map[string]int{"1": 1, "8081": 8081, "65535": 65535} { + c, err := config.Load(env(map[string]string{config.EnvPort: raw})) + if err != nil { + t.Errorf("PORT=%q was rejected: %v", raw, err) + + continue + } + + if c.Port != want { + t.Errorf("PORT=%q: Port = %d, want %d", raw, c.Port, want) + } + } + + for _, raw := range []string{"0", "65536", "-1", "80.5", "8080x", "http"} { + _, err := config.Load(env(map[string]string{config.EnvPort: raw})) + if !errors.Is(err, config.ErrInvalidConfig) { + t.Errorf("PORT=%q: error = %v, want ErrInvalidConfig", raw, err) + } + } +} + +// TestAPITokenFile: the credential is the file's content without the +// whitespace around it. A file that cannot be read, or holds too short +// a credential, aborts, and the error never shows what the file holds. +func TestAPITokenFile(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + token := strings.Repeat("k", config.MinAPITokenLength) + short := strings.Repeat("s", config.MinAPITokenLength-1) + + for name, content := range map[string]string{ + "good": " " + token + "\n", + "short": "\n" + short + " \n", + } { + err := os.WriteFile(filepath.Join(dir, name), []byte(content), 0o600) + if err != nil { + t.Fatal(err) + } + } + + load := func(name string) (*config.Config, error) { + return config.Load(env(map[string]string{ + config.EnvAPITokenFile: filepath.Join(dir, name), + })) + } + + c, err := load("good") + if err != nil { + t.Fatalf("a good file was rejected: %v", err) + } + + if c.APIToken != token { + t.Errorf("APIToken = %q, want %q", c.APIToken, token) + } + + _, err = load("missing") + if !errors.Is(err, config.ErrInvalidConfig) { + t.Errorf("a missing file: error = %v, want ErrInvalidConfig", err) + } + + _, err = load("short") + if !errors.Is(err, config.ErrInvalidConfig) { + t.Fatalf("a short credential: error = %v, want ErrInvalidConfig", err) + } + + if strings.Contains(err.Error(), short) { + t.Errorf("the error shows the file's content: %v", err) + } +} diff --git a/internal/simplex/client.go b/internal/simplex/client.go index e4bc309..db12d59 100644 --- a/internal/simplex/client.go +++ b/internal/simplex/client.go @@ -168,6 +168,18 @@ func (c *Client) SetAddressSettings( return c.command(ctx, cmd, TypeUserContactLinkUpdated, nil) } +// Contacts returns the user's contacts: everyone it has a direct chat +// with. +func (c *Client) Contacts(ctx context.Context, userID int64) ([]Contact, error) { + var r struct { + Contacts []Contact `json:"contacts"` + } + + err := c.command(ctx, cmdListContacts(userID), TypeContactsList, &r) + + return r.Contacts, err +} + // SendText sends a text message to a contact, as a reply to the message // quotedItemID (0 for none). It does not wait for the chat client to // accept it; a failure is logged when the client's answer arrives. diff --git a/internal/simplex/client_test.go b/internal/simplex/client_test.go index feaa9f9..ce33fc3 100644 --- a/internal/simplex/client_test.go +++ b/internal/simplex/client_test.go @@ -7,6 +7,7 @@ import ( "log/slog" "net/http" "net/http/httptest" + "slices" "strings" "sync" "testing" @@ -43,6 +44,19 @@ const ( contactConnected = `{"type":"contactConnected","user":{"userId":1}, "contact":{"contactId":3,"localDisplayName":"alice"}}` + + // Two contacts with the same display name, which the chat client + // tells apart by the local name it gives the second. The second has + // deleted its chat with the bot, and is still listed. + contactsList = `{"type":"contactsList","user":{"userId":1},"contacts":[ + {"contactId":3,"localDisplayName":"tester","profile":{"profileId":3, + "displayName":"tester","fullName":"","localAlias":""}, + "activeConn":{"connId":2,"connStatus":{"type":"ready"}}, + "contactUsed":true,"contactStatus":"active","chatDeleted":false}, + {"contactId":4,"localDisplayName":"tester_1","profile":{"profileId":4, + "displayName":"tester","fullName":"","localAlias":""}, + "activeConn":{"connId":3,"connStatus":{"type":"deleted"}}, + "contactUsed":true,"contactStatus":"deleted","chatDeleted":false}]}` ) // fakeChat stands in for the chat client's API. It answers each command @@ -220,6 +234,38 @@ func TestAddressSetup(t *testing.T) { } } +// TestContacts: the contacts of the given user come back with their +// ids, display names and whether they are deleted. +func TestContacts(t *testing.T) { + t.Parallel() + + f, url := newFakeChat(t, map[string]string{"/_contacts": contactsList}) + c, ctx := dial(t, url, nil) + + contacts, err := c.Contacts(ctx, 1) + if err != nil { + t.Fatalf("Contacts: %v", err) + } + + if got := f.next(t); got != "/_contacts 1" { + t.Errorf("command = %s, want /_contacts 1", got) + } + + tester := simplex.Profile{DisplayName: "tester"} + + want := []simplex.Contact{ + {ContactID: 3, Profile: tester, Status: "active"}, + {ContactID: 4, Profile: tester, Status: "deleted"}, + } + if !slices.Equal(contacts, want) { + t.Fatalf("Contacts = %+v\nwant %+v", contacts, want) + } + + if contacts[0].Deleted() || !contacts[1].Deleted() { + t.Error("Deleted must be false for contact 3 and true for contact 4") + } +} + // TestRefusedCommand: a command the chat client refuses is an error // that names the reason. func TestRefusedCommand(t *testing.T) { diff --git a/internal/simplex/protocol.go b/internal/simplex/protocol.go index 66bb80f..3251727 100644 --- a/internal/simplex/protocol.go +++ b/internal/simplex/protocol.go @@ -14,6 +14,7 @@ const ( TypeUserContactLink = "userContactLink" TypeUserContactLinkCreated = "userContactLinkCreated" TypeUserContactLinkUpdated = "userContactLinkUpdated" + TypeContactsList = "contactsList" TypeNewChatItems = "newChatItems" TypeContactConnected = "contactConnected" TypeChatCmdError = "chatCmdError" @@ -46,10 +47,14 @@ func (e Event) Decode(v any) error { type ( // User is the chat client's local user profile: the bot itself. User struct { - UserID int64 `json:"userId"` - Profile struct { - DisplayName string `json:"displayName"` - } `json:"profile"` + UserID int64 `json:"userId"` + Profile Profile `json:"profile"` + } + + // Profile is how the bot or a contact presents itself. Nothing + // makes a display name unique. + Profile struct { + DisplayName string `json:"displayName"` } // ConnLink is a SimpleX link. The short form is what people share; @@ -61,7 +66,9 @@ type ( // Contact is a person connected to the bot. Contact struct { - ContactID int64 `json:"contactId"` + ContactID int64 `json:"contactId"` + Profile Profile `json:"profile"` + Status string `json:"contactStatus"` } // NewChatItems is the record of a newChatItems event: messages @@ -142,6 +149,13 @@ type ( } ) +// Deleted reports whether the contact is gone, as it is once the person +// deletes their chat with the bot. The chat client still lists such a +// contact, with its chat, but nothing more reaches them. +func (c Contact) Deleted() bool { + return c.Status != "active" +} + // Message is a text message a contact sent to the bot. type Message struct { ContactID int64 @@ -190,6 +204,10 @@ func cmdSetAddressSettings(userID int64, s AddressSettings) (string, error) { return "/_address_settings " + strconv.FormatInt(userID, 10) + " " + string(b), nil } +func cmdListContacts(userID int64) string { + return "/_contacts " + strconv.FormatInt(userID, 10) +} + func cmdSendText(contactID, quotedItemID int64, text string) (string, error) { b, err := json.Marshal([]composedMessage{{ QuotedItemID: quotedItemID,