HTTP API: server, credential and the list of chats (closes #4)
check / check (push) Successful in 1m13s
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:
+76
-22
@@ -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.
|
||||
|
||||
@@ -0,0 +1,233 @@
|
||||
package bot_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/netip"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gorilla/websocket"
|
||||
"sneak.berlin/go/simplexcalc/internal/bot"
|
||||
"sneak.berlin/go/simplexcalc/internal/config"
|
||||
"sneak.berlin/go/simplexcalc/internal/simplex"
|
||||
)
|
||||
|
||||
const (
|
||||
// credential is what the bot's API is configured with here.
|
||||
credential = "a-credential-for-these-tests" //nolint:gosec // G101: invented for tests
|
||||
|
||||
// asked is the file the stand-in chat client creates in the data
|
||||
// directory when it is asked for the contacts.
|
||||
asked = "asked-for-contacts"
|
||||
|
||||
// contactsDelay is how long the stand-in then holds its answer:
|
||||
// long enough for the test to stop the bot meanwhile, and well
|
||||
// within the 5 seconds the API gets to finish its requests.
|
||||
contactsDelay = time.Second
|
||||
)
|
||||
|
||||
// TestMain lets this test binary be the chat client as well: started
|
||||
// under the chat client's name, as TestStopDuringRequest arranges, it is
|
||||
// the stand-in instead of running the tests.
|
||||
func TestMain(m *testing.M) {
|
||||
if filepath.Base(os.Args[0]) == simplex.Binary {
|
||||
standIn() // never returns
|
||||
}
|
||||
|
||||
m.Run()
|
||||
}
|
||||
|
||||
// standIn plays the chat client: it serves the WebSocket API on the
|
||||
// port it is given, on localhost, and answers the commands the bot
|
||||
// sends. SIGTERM ends it, as it ends the real client. Unlike the real
|
||||
// client, it also exits when the bot hangs up, so that it never
|
||||
// outlives a test run that was cut short.
|
||||
func standIn() {
|
||||
arg := func(name string) string {
|
||||
return os.Args[slices.Index(os.Args, name)+1]
|
||||
}
|
||||
|
||||
marker := filepath.Join(filepath.Dir(arg("--database")), asked)
|
||||
|
||||
srv := &http.Server{
|
||||
Addr: "127.0.0.1:" + arg("--chat-server-port"),
|
||||
ReadHeaderTimeout: time.Second,
|
||||
Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
answer(w, r, marker)
|
||||
}),
|
||||
}
|
||||
|
||||
err := srv.ListenAndServe()
|
||||
_, _ = fmt.Fprintln(os.Stderr, err)
|
||||
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// answer answers the commands on one connection with records reduced to
|
||||
// the fields the bot reads. Asked for the contacts, it first creates
|
||||
// the file marker, then holds its answer for contactsDelay.
|
||||
func answer(w http.ResponseWriter, r *http.Request, marker string) {
|
||||
conn, err := (&websocket.Upgrader{}).Upgrade(w, r, nil)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
records := map[string]string{
|
||||
"/user": `{"type":"activeUser","user":{"userId":1}}`,
|
||||
"/_show_address": `{"type":"userContactLink","contactLink":{}}`,
|
||||
"/_address_settings": `{"type":"userContactLinkUpdated"}`,
|
||||
"/_contacts": `{"type":"contactsList","contacts":[{"contactId":3,` +
|
||||
`"profile":{"displayName":"tester"},"contactStatus":"active"}]}`,
|
||||
}
|
||||
|
||||
for {
|
||||
var cmd map[string]string
|
||||
|
||||
err = conn.ReadJSON(&cmd)
|
||||
if err != nil {
|
||||
os.Exit(0)
|
||||
}
|
||||
|
||||
name, _, _ := strings.Cut(cmd["cmd"], " ")
|
||||
|
||||
record, ok := records[name]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
if name == "/_contacts" {
|
||||
_ = os.WriteFile(marker, nil, 0o600)
|
||||
|
||||
time.Sleep(contactsDelay)
|
||||
}
|
||||
|
||||
_ = conn.WriteJSON(map[string]any{
|
||||
"corrId": cmd["corrId"],
|
||||
"resp": json.RawMessage(record),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestStopDuringRequest: a request that is waiting on the chat client
|
||||
// when the bot is told to stop still gets the chat client's answer,
|
||||
// because the chat client is stopped only once the API has stopped.
|
||||
func TestStopDuringRequest(t *testing.T) {
|
||||
// Run starts the chat client from PATH: put this test binary there
|
||||
// under the chat client's name.
|
||||
bin := t.TempDir()
|
||||
|
||||
exe, err := os.Executable()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
err = os.Symlink(exe, filepath.Join(bin, simplex.Binary))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
t.Setenv("PATH", bin)
|
||||
|
||||
cfg := &config.Config{DataDir: t.TempDir(), Port: freePort(t), APIToken: credential}
|
||||
// Never bot.ChatPort: a real chat client may be listening there.
|
||||
chatPort := freePort(t)
|
||||
ctx, stop := context.WithCancel(t.Context())
|
||||
done := make(chan struct{})
|
||||
|
||||
var runErr error
|
||||
|
||||
go func() {
|
||||
defer close(done)
|
||||
|
||||
runErr = bot.Run(ctx, slog.New(slog.DiscardHandler), cfg, chatPort)
|
||||
}()
|
||||
|
||||
t.Cleanup(func() {
|
||||
stop()
|
||||
<-done
|
||||
|
||||
if runErr != nil {
|
||||
t.Errorf("Run: %v", runErr)
|
||||
}
|
||||
})
|
||||
|
||||
// Stop the bot once the request below is waiting on the chat client.
|
||||
go func() {
|
||||
for ctx.Err() == nil {
|
||||
_, err := os.Stat(filepath.Join(cfg.DataDir, asked))
|
||||
if err == nil {
|
||||
stop()
|
||||
}
|
||||
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
}
|
||||
}()
|
||||
|
||||
status, body := getChats(t, cfg.Port, done)
|
||||
|
||||
want := `{"chats":[{"id":3,"display_name":"tester","contact_deleted":false}]}` + "\n"
|
||||
if status != http.StatusOK || body != want {
|
||||
t.Errorf("GET /api/v1/chats = %d %q, want 200 %q", status, body, want)
|
||||
}
|
||||
}
|
||||
|
||||
// freePort returns a TCP port that nothing listens on at the moment.
|
||||
func freePort(t *testing.T) int {
|
||||
t.Helper()
|
||||
|
||||
l, err := (&net.ListenConfig{}).Listen(t.Context(), "tcp", ":0")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
defer func() { _ = l.Close() }()
|
||||
|
||||
return int(netip.MustParseAddrPort(l.Addr().String()).Port())
|
||||
}
|
||||
|
||||
// getChats asks the bot's API for the chats, trying again while the API
|
||||
// is not listening yet, and returns the answer's status and body. It
|
||||
// fails the test if Run returns first, which closes done.
|
||||
func getChats(t *testing.T, port int, done <-chan struct{}) (int, string) {
|
||||
t.Helper()
|
||||
|
||||
url := "http://127.0.0.1:" + strconv.Itoa(port) + "/api/v1/chats"
|
||||
|
||||
for {
|
||||
req, err := http.NewRequestWithContext(t.Context(), http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
req.Header.Set("Authorization", "Bearer "+credential)
|
||||
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err == nil {
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
_ = resp.Body.Close()
|
||||
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
return resp.StatusCode, string(body)
|
||||
}
|
||||
|
||||
select {
|
||||
case <-done:
|
||||
t.Fatal("Run returned before the API answered")
|
||||
case <-time.After(50 * time.Millisecond):
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user