Этап 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
+145
View File
@@ -0,0 +1,145 @@
// Команда bare: сервер чата одним бинарём.
//
// bare serve запустить http-сервер
// bare vapid напечатать пару vapid-ключей
// bare version напечатать ревизию сборки
package main
import (
"context"
"crypto/ecdh"
"crypto/rand"
"encoding/base64"
"errors"
"fmt"
"net"
"net/http"
"os"
"os/signal"
"runtime/debug"
"syscall"
"time"
"github.com/xmatic-squad/bare/internal/api"
"github.com/xmatic-squad/bare/internal/config"
"github.com/xmatic-squad/bare/internal/web"
)
func main() {
if len(os.Args) < 2 {
usage()
os.Exit(2)
}
var err error
switch os.Args[1] {
case "serve":
err = serve()
case "vapid":
err = vapid()
case "version":
version()
default:
usage()
os.Exit(2)
}
if err != nil {
fmt.Fprintln(os.Stderr, "bare:", err)
os.Exit(1)
}
}
func usage() {
fmt.Fprint(os.Stderr, `bare — сервер чата
использование:
bare serve запустить http-сервер
bare vapid напечатать пару vapid-ключей
bare version напечатать ревизию сборки
настройка — переменные окружения BARE_*, см. docs/deploy.md
`)
}
func serve() error {
cfg, err := config.Load()
if err != nil {
return err
}
static, err := web.New()
if err != nil {
return err
}
srv := &http.Server{
Handler: api.New(static, os.Stdout),
ReadHeaderTimeout: 10 * time.Second,
IdleTimeout: 120 * time.Second,
// OPTIONS * иначе обслуживает net/http сам, в обход middleware:
// ответ уходил бы без заголовков безопасности (ADR-021).
DisableGeneralOptionsHandler: true,
// WriteTimeout не задаётся: впереди SSE с долгими ответами (ADR-004).
}
// Сначала bind, потом сообщение: строка в журнале означает, что порт занят
// нами, а не то, что мы собирались его занять.
ln, err := net.Listen("tcp", cfg.Addr)
if err != nil {
return err
}
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
failed := make(chan error, 1)
go func() {
if err := srv.Serve(ln); err != nil && !errors.Is(err, http.ErrServerClosed) {
failed <- err
}
}()
fmt.Printf("bare слушает %s, origin %s\n", ln.Addr(), cfg.Origin)
select {
case err := <-failed:
return err
case <-ctx.Done():
}
// Второй сигнал больше не перехватываем: он завершает процесс сразу.
stop()
fmt.Println("bare завершается")
shutdown, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
return srv.Shutdown(shutdown)
}
// vapid печатает пару ключей P-256 в формате, который ждёт webpush-go:
// приватный — 32 байта скаляра, публичный — 65 байт несжатой точки,
// оба base64url без паддинга.
func vapid() error {
priv, err := ecdh.P256().GenerateKey(rand.Reader)
if err != nil {
return err
}
b64 := base64.RawURLEncoding
fmt.Printf("BARE_VAPID_PUBLIC=%s\n", b64.EncodeToString(priv.PublicKey().Bytes()))
fmt.Printf("BARE_VAPID_PRIVATE=%s\n", b64.EncodeToString(priv.Bytes()))
return nil
}
func version() {
fmt.Println(revision())
}
func revision() string {
info, ok := debug.ReadBuildInfo()
if !ok {
return "unknown"
}
for _, s := range info.Settings {
if s.Key == "vcs.revision" {
return s.Value
}
}
return "unknown"
}