Этап 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:
2026-08-22 11:49:11 +03:00
co-authored by Claude Opus 5
parent ae55c846aa
commit 32717cb7dd
16 changed files with 784 additions and 2 deletions
+127
View File
@@ -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 }
+160
View File
@@ -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)
}
}
+57
View File
@@ -0,0 +1,57 @@
// Package config читает конфигурацию из переменных окружения BARE_* (ADR-022).
package config
import (
"fmt"
"os"
"strings"
)
// Config — всё, что сервер знает о своём окружении.
type Config struct {
Addr string // BARE_ADDR — адрес прослушивания
DB string // BARE_DB — путь к файлу SQLite
Origin string // BARE_ORIGIN — единственный допустимый Origin (ADR-021)
VAPIDPublic string // BARE_VAPID_PUBLIC
VAPIDPrivate string // BARE_VAPID_PRIVATE
VAPIDSubject string // BARE_VAPID_SUBJECT
InviteCode string // BARE_INVITE_CODE — пусто означает открытую регистрацию
}
// Значения по умолчанию — локальный запуск без окружения.
const (
defaultAddr = "127.0.0.1:8411"
defaultDB = "bare.db"
defaultOrigin = "http://127.0.0.1:8411"
)
// Load читает окружение. Незаданная переменная берёт значение по умолчанию;
// заданная пустой — ошибка: пустой адрес, путь к базе или origin неработоспособны.
func Load() (*Config, error) {
c := &Config{
Addr: env("BARE_ADDR", defaultAddr),
DB: env("BARE_DB", defaultDB),
Origin: env("BARE_ORIGIN", defaultOrigin),
VAPIDPublic: env("BARE_VAPID_PUBLIC", ""),
VAPIDPrivate: env("BARE_VAPID_PRIVATE", ""),
VAPIDSubject: env("BARE_VAPID_SUBJECT", ""),
InviteCode: env("BARE_INVITE_CODE", ""),
}
for _, v := range []struct{ key, value string }{
{"BARE_ADDR", c.Addr},
{"BARE_DB", c.DB},
{"BARE_ORIGIN", c.Origin},
} {
if v.value == "" {
return nil, fmt.Errorf("%s пуст: уберите переменную, чтобы взять значение по умолчанию, или задайте непустое", v.key)
}
}
return c, nil
}
func env(key, fallback string) string {
if v, ok := os.LookupEnv(key); ok {
return strings.TrimSpace(v)
}
return fallback
}
+134
View File
@@ -0,0 +1,134 @@
// Package web отдаёт клиентскую статику из embed (ADR-025).
//
// Файлы читаются в память один раз при старте: их немного и они неизменны.
// Ни листинга каталогов, ни доступа к файловой системе сервера здесь нет —
// отдаётся только то, что вкомпилировано в бинарь.
package web
import (
"crypto/sha256"
"encoding/hex"
"errors"
"io/fs"
"net/http"
"path"
"strconv"
"strings"
bare "github.com/xmatic-squad/bare"
"github.com/xmatic-squad/bare/internal/api"
)
// Handler — карта «путь в URL → файл».
type Handler struct {
files map[string]file
}
type file struct {
data []byte
etag string // сильный, SHA-256 содержимого, в кавычках
ctype string
}
// New читает web/ из embed и готовит ответы.
func New() (*Handler, error) {
root, err := fs.Sub(bare.Web, "web")
if err != nil {
return nil, err
}
h := &Handler{files: make(map[string]file)}
err = fs.WalkDir(root, ".", func(p string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
if d.IsDir() {
return nil
}
data, err := fs.ReadFile(root, p)
if err != nil {
return err
}
sum := sha256.Sum256(data)
h.files["/"+p] = file{
data: data,
etag: `"` + hex.EncodeToString(sum[:]) + `"`,
ctype: contentType(path.Ext(p)),
}
return nil
})
if err != nil {
return nil, err
}
index, ok := h.files["/index.html"]
if !ok {
return nil, errors.New("web: в embed нет index.html")
}
h.files["/"] = index
return h, nil
}
func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
// Статика читается только чтением. Для прочих методов путь считается
// неизвестным: кода 405 в протоколе нет (ADR-026).
if r.Method != http.MethodGet && r.Method != http.MethodHead {
api.NotFound(w)
return
}
f, ok := h.files[r.URL.Path]
if !ok {
api.NotFound(w)
return
}
head := w.Header()
head.Set("ETag", f.etag)
head.Set("Cache-Control", "no-cache")
if r.URL.Path == "/sw.js" {
head.Set("Service-Worker-Allowed", "/")
}
if match(r.Header.Get("If-None-Match"), f.etag) {
w.WriteHeader(http.StatusNotModified)
return
}
head.Set("Content-Type", f.ctype)
head.Set("Content-Length", strconv.Itoa(len(f.data)))
w.WriteHeader(http.StatusOK)
if r.Method == http.MethodHead {
return
}
w.Write(f.data)
}
// match разбирает If-None-Match: список тегов, «*» или слабые формы W/"...".
func match(header, etag string) bool {
for _, part := range strings.Split(header, ",") {
part = strings.TrimSpace(part)
if part == "" {
continue
}
if part == "*" || strings.TrimPrefix(part, "W/") == etag {
return true
}
}
return false
}
func contentType(ext string) string {
switch ext {
case ".html":
return "text/html; charset=utf-8"
case ".css":
return "text/css; charset=utf-8"
case ".js":
return "text/javascript; charset=utf-8"
case ".json":
return "application/json; charset=utf-8"
case ".svg":
return "image/svg+xml"
case ".png":
return "image/png"
default:
return "application/octet-stream"
}
}