Files
maeneko 70bb587216 Port awg-ui to Go: awg-control ui
internal/ui reimplements the awg-ui backend with the same HTTP surface; the
React frontend is unchanged and served from awg-ui/dist.

- web.go: Express-like router (express.json semantics, mounts, :params,
  HTML 400/404/413 pages, SPA fallback)
- server.go: JWT login with rate limit, API keys and their server sets,
  awg-ctrl client and proxy, /api/v1/users, delete hooks
- nodes.go: WSS hub for agents (pin, challenge-response, join strings),
  RPC with timeouts, health, /ui/nodes
- sub.go, audit.go, sub_admin.go: sen:// master keys and devices,
  per-server reconcile with tombstones and /api/peers/sync, signed /sub/v1
  listener, audit with two-pass confirmation and cleaning, panel and
  /api/v1/masterkeys routers
- metrics.go, monitor.go: /metrics exposition with tokens, /ui/monitor
- sen.go: link codec and protocol signatures, tested byte for byte against
  fixtures produced by awg-ui/sen.ts (tools/golden/sen.ts)

Differential bench (tools/diffbench/local-ui.sh, scenario-ui.ts,
compare-ui.mjs): 158 steps over every route group, including signed device
requests and a Go agent joining as a node, plus the resulting ui.db and
awg-ctrl state. TS and Go are identical after masking random values, across
repeated runs and with the race detector on.
2026-10-14 13:27:24 +00:00

431 lines
12 KiB
Go

package ui
// sen:// — ссылка-подписка для SenAWG и подписи протокола /sub/v1 (порт awg-ui/sen.ts).
// Байтовая раскладка и схема подписей описаны в docs/sen-link.md; клиент реализуется по
// ней, поэтому любое изменение здесь — изменение протокола.
import (
"bytes"
"crypto/ed25519"
"crypto/sha256"
"crypto/x509"
"encoding/base64"
"encoding/binary"
"encoding/hex"
"encoding/pem"
"errors"
"fmt"
"hash/crc32"
"regexp"
"strconv"
"strings"
"unicode/utf8"
"github.com/maeneko/forgetting/internal/jsjson"
)
const (
senPrefix = "sen://"
senVersion = 1
flagTLS = 0x01
addrV4 = 0x04
addrV6 = 0x06
addrDomain = 0x44
)
type senLinkError struct{ msg string }
func (e *senLinkError) Error() string { return e.msg }
func senErr(f string, a ...any) error { return &senLinkError{fmt.Sprintf(f, a...)} }
type SenAddr struct {
Host string `json:"host"`
Port int `json:"port"`
}
type SenLink struct {
TLS bool
Addrs []SenAddr
Secret []byte // 16 байт
SignPub []byte // 32 байта, Ed25519
TLSPin []byte // 32 байта, только при TLS
Name string
}
// b64url — Buffer.toString("base64url"): без паддинга.
var b64url = base64.RawURLEncoding
// fromB64url повторяет Buffer.from(s, "base64url") в Node: терпит паддинг и «+/», хвостовой
// одиночный символ (неполный байт) молча отбрасывает, мусор после первого чужого символа — тоже.
func fromB64url(s string) []byte {
s = strings.NewReplacer("+", "-", "/", "_").Replace(s)
if i := strings.IndexFunc(s, func(r rune) bool {
return !(r >= 'A' && r <= 'Z' || r >= 'a' && r <= 'z' || r >= '0' && r <= '9' || r == '-' || r == '_')
}); i >= 0 {
s = s[:i]
}
if len(s)%4 == 1 {
s = s[:len(s)-1]
}
b, _ := b64url.DecodeString(s)
return b
}
// fromB64 — Buffer.from(s, "base64") (обычный алфавит, тоже нестрого).
func fromB64(s string) []byte {
return fromB64url(strings.TrimRight(s, "="))
}
// ── Адреса ─────────────────────────────────────────────────────────────────
var (
reHex4 = regexp.MustCompile(`^[0-9a-fA-F]{1,4}$`)
reIPv4 = regexp.MustCompile(`^\d{1,3}(\.\d{1,3}){3}$`)
reDomain = regexp.MustCompile(`^[A-Za-z0-9.-]+$`)
)
func ipv6ToBytes(host string) []byte {
parts := strings.Split(host, "::")
if len(parts) > 2 {
return nil
}
var head, tail []string
if parts[0] != "" {
head = strings.Split(parts[0], ":")
}
if len(parts) == 2 && parts[1] != "" {
tail = strings.Split(parts[1], ":")
}
missing := 8 - len(head) - len(tail)
if len(parts) == 1 && len(head) != 8 || len(parts) == 2 && missing < 1 {
return nil
}
groups := append([]string{}, head...)
if len(parts) == 2 {
for range missing {
groups = append(groups, "0")
}
}
groups = append(groups, tail...)
if len(groups) != 8 {
return nil
}
out := make([]byte, 16)
for i, g := range groups {
if !reHex4.MatchString(g) {
return nil
}
n, _ := strconv.ParseUint(g, 16, 16)
binary.BigEndian.PutUint16(out[i*2:], uint16(n))
}
return out
}
func bytesToIPv6(b []byte) string {
g := make([]string, 8)
for i := range 8 {
g[i] = strconv.FormatUint(uint64(binary.BigEndian.Uint16(b[i*2:])), 16)
}
// Самый длинный run нулей сворачиваем в «::» — как принято в текстовой записи.
bestStart, bestLen := -1, 0
for i := 0; i < 8; {
if g[i] != "0" {
i++
continue
}
j := i
for j < 8 && g[j] == "0" {
j++
}
if j-i > bestLen {
bestStart, bestLen = i, j-i
}
i = j
}
if bestLen < 2 {
return strings.Join(g, ":")
}
return strings.Join(g[:bestStart], ":") + "::" + strings.Join(g[bestStart+bestLen:], ":")
}
func encodeAddr(a SenAddr) ([]byte, error) {
if a.Port < 1 || a.Port > 65535 {
return nil, senErr("Неверный порт: %d", a.Port)
}
p := binary.BigEndian.AppendUint16(nil, uint16(a.Port))
if reIPv4.MatchString(a.Host) {
out := []byte{addrV4}
for _, o := range strings.Split(a.Host, ".") {
n, _ := strconv.Atoi(o)
if n > 255 {
return nil, senErr("Неверный IPv4: %s", a.Host)
}
out = append(out, byte(n))
}
return append(out, p...), nil
}
if strings.Contains(a.Host, ":") {
b := ipv6ToBytes(a.Host)
if b == nil {
return nil, senErr("Неверный IPv6: %s", a.Host)
}
return append(append([]byte{addrV6}, b...), p...), nil
}
if len(a.Host) < 1 || len(a.Host) > 253 || !reDomain.MatchString(a.Host) {
return nil, senErr("Неверный домен: %s", a.Host)
}
return append(append([]byte{addrDomain, byte(len(a.Host))}, a.Host...), p...), nil
}
// ── Ссылка ─────────────────────────────────────────────────────────────────
func EncodeSenLink(l SenLink) (string, error) {
if len(l.Addrs) < 1 || len(l.Addrs) > 3 {
return "", senErr("Адресов должно быть от 1 до 3")
}
if len(l.Secret) != 16 {
return "", senErr("secret: 16 байт")
}
if len(l.SignPub) != 32 {
return "", senErr("signPub: 32 байта")
}
if l.TLS && len(l.TLSPin) != 32 {
return "", senErr("tlsPin: 32 байта при TLS")
}
if len(l.Name) > 64 {
return "", senErr("Имя: до 64 байт")
}
var flags byte
if l.TLS {
flags = flagTLS
}
body := []byte{senVersion, flags, byte(len(l.Addrs))}
for _, a := range l.Addrs {
b, err := encodeAddr(a)
if err != nil {
return "", err
}
body = append(body, b...)
}
body = append(body, l.Secret...)
body = append(body, l.SignPub...)
if l.TLS {
body = append(body, l.TLSPin...)
}
body = append(body, byte(len(l.Name)))
body = append(body, l.Name...)
body = binary.BigEndian.AppendUint32(body, crc32.ChecksumIEEE(body))
return senPrefix + b64url.EncodeToString(body), nil
}
var (
reB64urlChars = regexp.MustCompile(`^[A-Za-z0-9_-]+$`)
reSpaces = regexp.MustCompile(`\s+`)
)
func DecodeSenLink(link string) (SenLink, error) {
var l SenLink
t := strings.TrimSpace(link)
if !strings.HasPrefix(strings.ToLower(t), senPrefix) {
return l, senErr("Ссылка должна начинаться с sen://")
}
payload := reSpaces.ReplaceAllString(t[len(senPrefix):], "")
if !reB64urlChars.MatchString(payload) {
return l, senErr("Ссылка повреждена: недопустимые символы")
}
raw := fromB64url(payload)
if len(raw) < 3+4 {
return l, senErr("Ссылка слишком короткая")
}
body := raw[:len(raw)-4]
if binary.BigEndian.Uint32(raw[len(raw)-4:]) != crc32.ChecksumIEEE(body) {
return l, senErr("Ссылка повреждена: не сошлась контрольная сумма")
}
off := 0
errCut := senErr("Ссылка обрезана")
take := func(n int) ([]byte, error) {
if off+n > len(body) {
return nil, errCut
}
s := body[off : off+n]
off += n
return s, nil
}
b1 := func() (byte, error) {
b, err := take(1)
if err != nil {
return 0, err
}
return b[0], nil
}
version, err := b1()
if err != nil {
return l, err
}
if version != senVersion {
return l, senErr("Неизвестная версия ссылки: %d", version)
}
flags, err := b1()
if err != nil {
return l, err
}
if flags&^flagTLS != 0 {
return l, senErr("Неизвестные флаги ссылки")
}
n, err := b1()
if err != nil {
return l, err
}
if n < 1 || n > 3 {
return l, senErr("Неверное число адресов")
}
for range n {
typ, err := b1()
if err != nil {
return l, err
}
var host string
switch typ {
case addrV4:
b, err := take(4)
if err != nil {
return l, err
}
host = fmt.Sprintf("%d.%d.%d.%d", b[0], b[1], b[2], b[3])
case addrV6:
b, err := take(16)
if err != nil {
return l, err
}
host = bytesToIPv6(b)
case addrDomain:
ln, err := b1()
if err != nil {
return l, err
}
if ln < 1 {
return l, senErr("Пустой домен")
}
b, err := take(int(ln))
if err != nil {
return l, err
}
host = jsUTF8(b)
if !reDomain.MatchString(host) {
return l, senErr("Неверный домен")
}
default:
return l, senErr("Неизвестный тип адреса: %d", typ)
}
pb, err := take(2)
if err != nil {
return l, err
}
port := int(binary.BigEndian.Uint16(pb))
if port == 0 {
return l, senErr("Порт 0")
}
l.Addrs = append(l.Addrs, SenAddr{host, port})
}
if l.Secret, err = take(16); err != nil {
return l, err
}
if l.SignPub, err = take(32); err != nil {
return l, err
}
l.TLS = flags&flagTLS != 0
if l.TLS {
if l.TLSPin, err = take(32); err != nil {
return l, err
}
}
nameLen, err := b1()
if err != nil {
return l, err
}
if nameLen > 64 {
return l, senErr("Имя длиннее 64 байт")
}
nb, err := take(int(nameLen))
if err != nil {
return l, err
}
l.Name = jsUTF8(nb)
if off != len(body) {
return l, senErr("Лишние данные в конце ссылки")
}
l.Secret, l.SignPub = bytes.Clone(l.Secret), bytes.Clone(l.SignPub)
l.TLSPin = bytes.Clone(l.TLSPin)
return l, nil
}
// jsUTF8 — Buffer.toString("utf8"): битые последовательности заменяются на U+FFFD.
func jsUTF8(b []byte) string {
if utf8.Valid(b) {
return string(b)
}
return strings.ToValidUTF8(string(b), "�")
}
// ── Ключи Ed25519 ──────────────────────────────────────────────────────────
// loadEd25519Private читает PKCS#8 PEM (как crypto.createPrivateKey в Node).
func loadEd25519Private(raw []byte) (ed25519.PrivateKey, error) {
blk, _ := pem.Decode(raw)
if blk == nil {
return nil, errors.New("not a PEM key")
}
k, err := x509.ParsePKCS8PrivateKey(blk.Bytes)
if err != nil {
return nil, err
}
priv, ok := k.(ed25519.PrivateKey)
if !ok {
return nil, errors.New("not an Ed25519 key")
}
return priv, nil
}
// spkiPin — SHA-256 от SPKI DER сертификата (первого в PEM) — то, что кладётся в ссылку как pin.
func spkiPin(certPEM []byte) ([]byte, error) {
blk, _ := pem.Decode(certPEM)
if blk == nil {
return nil, errors.New("not a PEM certificate")
}
c, err := x509.ParseCertificate(blk.Bytes)
if err != nil {
return nil, err
}
sum := sha256.Sum256(c.RawSubjectPublicKeyInfo)
return sum[:], nil
}
// ── Подписи протокола ──────────────────────────────────────────────────────
// signedResponse — {"body": "<JSON-строка>", "sig": "<base64url>"}; sig — Ed25519 над
// UTF-8 байтами строки body. body — именно строка, чтобы клиенту не нужна была
// каноническая сериализация JSON.
func signResponse(payload any, key ed25519.PrivateKey) jsjson.Object {
body := string(jsjson.MustMarshal(payload))
return jsjson.Object{{Key: "body", Val: body}, {Key: "sig", Val: b64url.EncodeToString(ed25519.Sign(key, []byte(body)))}}
}
// requestSigString — METHOD \n /path \n ts \n hex(sha256(тело запроса)); path — без query.
func requestSigString(method, path string, ts int64, body []byte) string {
h := sha256.Sum256(body)
return strings.ToUpper(method) + "\n" + path + "\n" + strconv.FormatInt(ts, 10) + "\n" + hex.EncodeToString(h[:])
}
func verifyRequest(method, path string, ts int64, body []byte, sig string, authPub []byte) bool {
if len(authPub) != ed25519.PublicKeySize {
return false
}
return ed25519.Verify(authPub, []byte(requestSigString(method, path, ts, body)), fromB64url(sig))
}
func crc32IEEE(b []byte) uint32 { return crc32.ChecksumIEEE(b) }