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.
431 lines
12 KiB
Go
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) }
|