Этап 0: скелет сервера, статика из embed, деплой на bare.xmatic.team
Один бинарь на stdlib, ноль зависимостей: подкоманды serve, vapid, version; статика из embed с ETag и Cache-Control: no-cache; /healthz; заголовки безопасности ADR-021 на всех ответах, включая ошибки; лимит тела 32 КиБ. Путь в логе — в percent-форме: подделать строку журнала запросом нельзя. Ни IP, ни ника, ни query в логах нет. Клиент — знак «Скобы» и слово bare: без script-тегов, inline-стилей и внешних ресурсов. ADR-025: go:embed не выходит за каталог пакета, поэтому объявление статики живёт в корне модуля, а клиент остаётся в web/. ADR-026: 413 отдаётся кодом too_large, неподдерживаемый метод — 404; оба добавлены в перечень protocol.md. Сервер: пользователь bare, /opt/bare, /var/lib/bare, /etc/bare/env 0600, systemd-юнит, nginx с сертификатом certbot (плагин nginx, как у соседей). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015DbCjVfTFq4ZFG8juD45YJ
This commit is contained in:
@@ -0,0 +1,127 @@
|
||||
// Package api собирает маршруты и общие для всех ответов правила:
|
||||
// заголовки безопасности (ADR-021), лимит тела запроса, лог в stdout.
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"time"
|
||||
)
|
||||
|
||||
// MaxBody — предел тела запроса, 32 КиБ (ADR-021).
|
||||
const MaxBody = 32 << 10
|
||||
|
||||
// maxLogPath — сколько байт пути попадает в строку лога.
|
||||
const maxLogPath = 256
|
||||
|
||||
// csp — политика из ADR-021. HSTS ставит nginx, здесь его нет.
|
||||
const csp = "default-src 'self'; img-src 'self' data:; frame-ancestors 'none'; base-uri 'none'; form-action 'self'"
|
||||
|
||||
// New собирает обработчик: /healthz, всё остальное — статика.
|
||||
// log — куда писать строки запросов; nil отключает лог.
|
||||
func New(static http.Handler, logw io.Writer) http.Handler {
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("GET /healthz", healthz)
|
||||
mux.Handle("/", static)
|
||||
return logging(logw, headers(limitBody(mux)))
|
||||
}
|
||||
|
||||
func healthz(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
io.WriteString(w, "ok")
|
||||
}
|
||||
|
||||
// Error пишет ошибку в форме протокола: {"error": код, "message": текст}.
|
||||
// Единственное место, где эта форма собирается, — коды берутся из
|
||||
// перечня в docs/protocol.md.
|
||||
func Error(w http.ResponseWriter, status int, code, message string) {
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
w.WriteHeader(status)
|
||||
json.NewEncoder(w).Encode(map[string]string{
|
||||
"error": code,
|
||||
"message": message,
|
||||
})
|
||||
}
|
||||
|
||||
// NotFound — ответ на неизвестный путь и на неподдерживаемый метод
|
||||
// известного пути (ADR-026).
|
||||
func NotFound(w http.ResponseWriter) {
|
||||
Error(w, http.StatusNotFound, "not_found", "такого пути нет")
|
||||
}
|
||||
|
||||
// headers ставит заголовки безопасности на каждый ответ, включая ошибки.
|
||||
func headers(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
h := w.Header()
|
||||
h.Set("Content-Security-Policy", csp)
|
||||
h.Set("Referrer-Policy", "no-referrer")
|
||||
h.Set("X-Content-Type-Options", "nosniff")
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
// limitBody отрезает тело на 32 КиБ. Заявленный размер сверх лимита
|
||||
// отклоняется сразу, незаявленный — обрывается при чтении.
|
||||
func limitBody(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.ContentLength > MaxBody {
|
||||
Error(w, http.StatusRequestEntityTooLarge, "too_large", "тело запроса больше 32 КиБ")
|
||||
return
|
||||
}
|
||||
if r.Body != nil {
|
||||
r.Body = http.MaxBytesReader(w, r.Body, MaxBody)
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
// logging пишет время, метод, путь, статус и длительность.
|
||||
// Ни IP, ни ник, ни query в лог не попадают.
|
||||
func logging(out io.Writer, next http.Handler) http.Handler {
|
||||
if out == nil {
|
||||
return next
|
||||
}
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
start := time.Now()
|
||||
rec := &recorder{ResponseWriter: w, status: http.StatusOK}
|
||||
next.ServeHTTP(rec, r)
|
||||
fmt.Fprintf(out, "%s %s %s %d %s\n",
|
||||
start.Format(time.RFC3339),
|
||||
r.Method,
|
||||
logPath(r.URL),
|
||||
rec.status,
|
||||
time.Since(start).Round(time.Microsecond))
|
||||
})
|
||||
}
|
||||
|
||||
// logPath даёт путь в percent-форме: перевод строки, escape-последовательности
|
||||
// и прочие управляющие байты в журнал не попадают — иначе любой запрос
|
||||
// подделывал бы строки в journald. Длинный путь обрезается.
|
||||
func logPath(u *url.URL) string {
|
||||
p := u.EscapedPath()
|
||||
if len(p) > maxLogPath {
|
||||
return p[:maxLogPath] + "…"
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
type recorder struct {
|
||||
http.ResponseWriter
|
||||
status int
|
||||
}
|
||||
|
||||
func (r *recorder) WriteHeader(status int) {
|
||||
r.status = status
|
||||
r.ResponseWriter.WriteHeader(status)
|
||||
}
|
||||
|
||||
// Unwrap отдаёт исходный ResponseWriter: через него http.ResponseController
|
||||
// добирается до Flush и Hijack. Без этого SSE (docs/protocol.md, «События»)
|
||||
// буферизовался бы — лог стоит самым внешним слоем и виден всем маршрутам.
|
||||
func (r *recorder) Unwrap() http.ResponseWriter { return r.ResponseWriter }
|
||||
@@ -0,0 +1,160 @@
|
||||
package api_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/xmatic-squad/bare/internal/api"
|
||||
"github.com/xmatic-squad/bare/internal/web"
|
||||
)
|
||||
|
||||
func handler(t *testing.T) http.Handler {
|
||||
t.Helper()
|
||||
static, err := web.New()
|
||||
if err != nil {
|
||||
t.Fatalf("web.New: %v", err)
|
||||
}
|
||||
return api.New(static, nil)
|
||||
}
|
||||
|
||||
func TestHealthz(t *testing.T) {
|
||||
rec := httptest.NewRecorder()
|
||||
handler(t).ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/healthz", nil))
|
||||
|
||||
res := rec.Result()
|
||||
defer res.Body.Close()
|
||||
if res.StatusCode != http.StatusOK {
|
||||
t.Errorf("статус: получено %d, ожидалось 200", res.StatusCode)
|
||||
}
|
||||
body, err := io.ReadAll(res.Body)
|
||||
if err != nil {
|
||||
t.Fatalf("чтение тела: %v", err)
|
||||
}
|
||||
if string(body) != "ok" {
|
||||
t.Errorf("тело: получено %q, ожидалось \"ok\"", body)
|
||||
}
|
||||
|
||||
want := map[string]string{
|
||||
"Content-Security-Policy": "default-src 'self'; img-src 'self' data:; frame-ancestors 'none'; base-uri 'none'; form-action 'self'",
|
||||
"Referrer-Policy": "no-referrer",
|
||||
"X-Content-Type-Options": "nosniff",
|
||||
}
|
||||
for header, value := range want {
|
||||
if got := res.Header.Get(header); got != value {
|
||||
t.Errorf("%s: получено %q, ожидалось %q", header, got, value)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestStaticNotModified(t *testing.T) {
|
||||
h := handler(t)
|
||||
|
||||
first := httptest.NewRecorder()
|
||||
h.ServeHTTP(first, httptest.NewRequest(http.MethodGet, "/app.css", nil))
|
||||
if first.Code != http.StatusOK {
|
||||
t.Fatalf("статус: получено %d, ожидалось 200", first.Code)
|
||||
}
|
||||
etag := first.Header().Get("ETag")
|
||||
if etag == "" {
|
||||
t.Fatal("нет ETag")
|
||||
}
|
||||
if got := first.Header().Get("Cache-Control"); got != "no-cache" {
|
||||
t.Errorf("Cache-Control: получено %q, ожидалось \"no-cache\"", got)
|
||||
}
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/app.css", nil)
|
||||
req.Header.Set("If-None-Match", etag)
|
||||
second := httptest.NewRecorder()
|
||||
h.ServeHTTP(second, req)
|
||||
|
||||
if second.Code != http.StatusNotModified {
|
||||
t.Errorf("статус: получено %d, ожидалось 304", second.Code)
|
||||
}
|
||||
if second.Body.Len() != 0 {
|
||||
t.Errorf("тело 304 не пустое: %q", second.Body.String())
|
||||
}
|
||||
if got := second.Header().Get("ETag"); got != etag {
|
||||
t.Errorf("ETag на 304: получено %q, ожидалось %q", got, etag)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBodyTooLarge(t *testing.T) {
|
||||
body := strings.NewReader(strings.Repeat("a", api.MaxBody+1))
|
||||
rec := httptest.NewRecorder()
|
||||
handler(t).ServeHTTP(rec, httptest.NewRequest(http.MethodPost, "/api/nope", body))
|
||||
|
||||
if rec.Code != http.StatusRequestEntityTooLarge {
|
||||
t.Fatalf("статус: получено %d, ожидалось 413", rec.Code)
|
||||
}
|
||||
var got map[string]string
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil {
|
||||
t.Fatalf("разбор тела: %v", err)
|
||||
}
|
||||
if got["error"] != "too_large" {
|
||||
t.Errorf("код ошибки: получено %q, ожидалось \"too_large\"", got["error"])
|
||||
}
|
||||
}
|
||||
|
||||
// Неподдерживаемый метод на известном пути — 404 not_found (ADR-026).
|
||||
func TestStaticRejectsWrite(t *testing.T) {
|
||||
rec := httptest.NewRecorder()
|
||||
handler(t).ServeHTTP(rec, httptest.NewRequest(http.MethodPost, "/app.css", nil))
|
||||
|
||||
if rec.Code != http.StatusNotFound {
|
||||
t.Fatalf("статус: получено %d, ожидалось 404", rec.Code)
|
||||
}
|
||||
var got map[string]string
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil {
|
||||
t.Fatalf("разбор тела: %v", err)
|
||||
}
|
||||
if got["error"] != "not_found" {
|
||||
t.Errorf("код ошибки: получено %q, ожидалось \"not_found\"", got["error"])
|
||||
}
|
||||
}
|
||||
|
||||
// Путь из запроса не должен уметь дописать строку в журнал.
|
||||
func TestLogPathEscaped(t *testing.T) {
|
||||
static, err := web.New()
|
||||
if err != nil {
|
||||
t.Fatalf("web.New: %v", err)
|
||||
}
|
||||
var log bytes.Buffer
|
||||
h := api.New(static, &log)
|
||||
|
||||
target := "/x%0a2026-01-01T00:00:00Z%20GET%20/fake%20200%201ms"
|
||||
h.ServeHTTP(httptest.NewRecorder(), httptest.NewRequest(http.MethodGet, target, nil))
|
||||
|
||||
line := log.String()
|
||||
if n := strings.Count(line, "\n"); n != 1 {
|
||||
t.Errorf("строк в логе: получено %d, ожидалась 1: %q", n, line)
|
||||
}
|
||||
if !strings.Contains(line, "%0a") {
|
||||
t.Errorf("путь не в percent-форме: %q", line)
|
||||
}
|
||||
|
||||
log.Reset()
|
||||
long := "/" + strings.Repeat("z", 4096)
|
||||
h.ServeHTTP(httptest.NewRecorder(), httptest.NewRequest(http.MethodGet, long, nil))
|
||||
if len(log.String()) > 512 {
|
||||
t.Errorf("длина строки лога: получено %d байт, ожидалось не больше 512", len(log.String()))
|
||||
}
|
||||
}
|
||||
|
||||
// SSE (docs/protocol.md, «События») флашит каждое событие: обёртка логгера
|
||||
// не должна прятать Flush от http.ResponseController.
|
||||
func TestFlushThroughMiddleware(t *testing.T) {
|
||||
var flushErr error
|
||||
h := api.New(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
flushErr = http.NewResponseController(w).Flush()
|
||||
}), io.Discard)
|
||||
|
||||
h.ServeHTTP(httptest.NewRecorder(), httptest.NewRequest(http.MethodGet, "/", nil))
|
||||
if flushErr != nil {
|
||||
t.Errorf("Flush: %v", flushErr)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user