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 -6
View File
@@ -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 {
+92 -2
View File
@@ -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)
}
}