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:
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user