HTTP API: server, credential and the list of chats (closes #4)
check / check (push) Successful in 1m13s

The bot now serves an HTTP API on `PORT` (default 8080) beside the chat client, whose WebSocket stays on 127.0.0.1 inside the container. Every request needs `Authorization: Bearer` with the credential from the file named by `API_TOKEN_FILE`, compared in constant time; with no credential configured every request is refused, `OPTIONS *` included. `GET /api/v1/chats` lists the bot's chats. Responses carry the security headers from the repository policies; bodies, requests and the server are time- and size-bounded. The chat client stops only after the API has finished its requests.

Disclosures: `contact_deleted` is an extra field; 404 and 405 answer in JSON; requests net/http cannot parse are refused by net/http without the security headers; three gosec findings are suppressed as false positives.

Model: opus-5-5
This commit was merged in pull request #12.
This commit is contained in:
2026-09-29 04:55:49 +02:00
parent ac721390de
commit f10d820ed4
18 changed files with 1214 additions and 47 deletions
+76 -22
View File
@@ -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"
)
@@ -25,11 +28,11 @@ const DisplayName = "calc"
const Welcome = "Send me arithmetic, such as 2 + 2, 5 * 5/2, 2^10 or 7 % 3, " +
"and I will reply with the result."
const (
// chatPort is where the chat client serves its API, on localhost
// inside the bot's own container.
chatPort = 5225
// ChatPort is where the chat client serves its API, on localhost inside
// the bot's own container.
const ChatPort = 5225
const (
// connectTimeout bounds the wait for a freshly started chat client
// to open its API, which includes creating or migrating the
// database.
@@ -42,26 +45,36 @@ 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
// Run starts the chat client with its database in cfg.DataDir, serving
// its API on localhost at chatPort, connects to it, sets up the bot's
// address, then answers messages and serves the bot's API 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)
// 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, chatPort int,
) error {
err := os.MkdirAll(cfg.DataDir, dataDirMode)
if err != nil {
return fmt.Errorf("creating data directory: %w", err)
}
// Cancelling this stops the chat client; the deferred wait makes
// Run return only once it has exited, whatever path Run takes.
cliCtx, stopCLI := context.WithCancel(ctx)
// Cancelling ctx does not reach it, so that the API, stopped first,
// can finish its requests while the chat client still answers.
cliCtx, stopCLI := context.WithCancel(context.WithoutCancel(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()
@@ -74,18 +87,38 @@ func Run(ctx context.Context, log *slog.Logger, dataDir string) error {
<-cli.Done()
}()
client, err := connect(ctx, log, cli)
client, err := connect(ctx, log, cli, chatPort)
if err != nil {
return err
}
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,12 +126,30 @@ 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)
}
}
// connect waits for the chat client to open its API and connects to it.
// 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()
}
}
// connect waits for the chat client to open its API on chatPort and
// connects to it.
func connect(
ctx context.Context, log *slog.Logger, cli *simplex.CLI,
ctx context.Context, log *slog.Logger, cli *simplex.CLI, chatPort int,
) (*simplex.Client, error) {
ctx, cancel := context.WithTimeout(ctx, connectTimeout)
defer cancel()
@@ -125,19 +176,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 +199,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 +208,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 +217,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.