Этап 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,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"
|
||||
}
|
||||
Reference in New Issue
Block a user