Panel redesign, per-server I1 masking, node minimum version 0.5.0, installer output
ci / check (push) Successful in 23m54s

- UI: settings tab with sections (server, API keys, Prometheus, audit, migrate,
  about); server settings moved there; master keys table rework (menu, chips,
  version, compact phone cards); three-state node version chip
- I1: random per-server template (DNS / QUIC Initial / STUN) kept in users.db,
  GET/POST /api/masking, change from the master keys banner
- Hub rejects nodes older than 0.5.0 (close 4026), /ui/nodes reports outdated
- install.sh: spinner for long commands (full output in the log), step progress
  bar, cards; installs older than 0.5.0 require a full reinstall with data reset
- Bump to Beta 0.5.0
This commit is contained in:
2026-10-29 22:59:09 +03:00
parent c7d1333125
commit 5b29993d5d
57 changed files with 3154 additions and 1106 deletions
+3
View File
@@ -511,6 +511,9 @@ func (a *Agent) connectOnce(ctx context.Context, onReady func()) (authFailed boo
if code == closeAuthFailed {
a.c.Errorf("agent: core отклонил ноду (неверный ключ, просроченная или использованная ссылка)")
}
if code == 4026 {
a.c.Errorf("agent: панель требует обновить ноду (install.sh → «Обновить»): %v", err)
}
return code == closeAuthFailed
}
v, err := jsjson.Decode(raw)
+1
View File
@@ -344,6 +344,7 @@ func (s *Server) syncPeers() error {
// vpn:// собирались бы на старых параметрах — например, на 2.0 уже после перехода на 3.1.
func (s *Server) reloadAwgParams() string {
p := ReadParams(s.confPath(), func(m string) { s.log.Warn(m) })
p.I1 = s.getCfg(cfgI1, p.I1)
s.cfgMu.Lock()
s.c.Params = p
s.c.Gen = p.Gen()
+219
View File
@@ -0,0 +1,219 @@
package ctrl
import (
"encoding/binary"
"encoding/hex"
"math/rand/v2"
"regexp"
"strconv"
"strings"
)
// I1 — «сигнатурный» пакет, который клиент шлёт перед рукопожатием, чтобы начало сессии
// выглядело как другой протокол. Сервер I-пакеты отбрасывает (они не совпадают ни с одним
// H-заголовком), поэтому совпадать у сервера и клиента I1 не обязан: выданные vpn:// ключи со
// старым I1 продолжают подключаться, а смена касается только новых конфигов.
//
// Поэтому I1 живёт не в awg1.conf, а в БД awg-ctrl (config.i1): в ядро он не уходит — нет
// риска, что модуль не примет шаблон и интерфейс не поднимется, — и переживает KEEP_DATA вместе
// с базой. Если I1 вписан в awg1.conf руками, на первом старте он и берётся.
//
// Шаблон — один из нескольких правдоподобных (DNS-запрос, начало QUIC Initial, STUN binding
// request) со случайными полями: у каждого сервера свой, общий для всех I1 был бы сигнатурой.
// Только теги <b 0x…> и <r N>: их понимают все клиенты с I1–I5. Идентификаторы транзакций и
// соединений — <r N>, они случайны в каждом пакете, как у настоящих протоколов.
const cfgI1 = "i1"
// Популярные домены, запросы к которым в любой сети выглядят обычно. Заблокированных в РФ
// нет: запрос к ним сам по себе привлекал бы внимание.
var i1Domains = []string{
"google.com", "www.google.com", "youtube.com", "gstatic.com", "googleapis.com",
"apple.com", "icloud.com", "microsoft.com", "bing.com", "live.com", "cloudflare.com",
"amazon.com", "wikipedia.org", "github.com", "yandex.ru", "ya.ru", "vk.com", "mail.ru",
"ok.ru", "avito.ru", "ozon.ru", "wildberries.ru", "gosuslugi.ru", "dzen.ru", "2gis.ru",
}
// GenI1 — случайный I1 из одного из шаблонов.
func GenI1() string {
switch rand.IntN(3) {
case 0:
return i1DNS()
case 1:
return i1QUIC()
default:
return i1STUN()
}
}
func pick[T any](v ...T) T { return v[rand.IntN(len(v))] }
// DNS-запрос: ID — <r 2>, флаги RD, один вопрос A/AAAA/HTTPS, иногда EDNS0 (как у systemd-resolved,
// браузеров и роутеров).
func i1DNS() string {
var b []byte
edns := rand.IntN(2) == 0
ar := uint16(0)
if edns {
ar = 1
}
b = binary.BigEndian.AppendUint16(b, 0x0100) // флаги: стандартный запрос, RD
b = append(b, 0, 1, 0, 0, 0, 0) // QDCOUNT 1, ANCOUNT 0, NSCOUNT 0
b = binary.BigEndian.AppendUint16(b, ar) // ARCOUNT
for _, l := range strings.Split(pick(i1Domains...), ".") { // QNAME
b = append(b, byte(len(l)))
b = append(b, l...)
}
b = append(b, 0)
b = binary.BigEndian.AppendUint16(b, pick[uint16](1, 1, 28, 65)) // QTYPE: A чаще, AAAA, HTTPS
b = append(b, 0, 1) // QCLASS IN
if edns {
b = append(b, 0, 0, 41) // OPT, имя — корень
b = binary.BigEndian.AppendUint16(b, pick[uint16](1232, 4096, 1400))
b = append(b, 0, 0) // расширенный RCODE, версия
b = binary.BigEndian.AppendUint16(b, pick[uint16](0, 0x8000)) // флаг DO
b = append(b, 0, 0) // RDLEN
}
return "<r 2><b 0x" + hex.EncodeToString(b) + ">"
}
// Начало QUIC v1 Initial: длинный заголовок (младшие биты первого байта под header protection —
// случайны), DCID 8 байт, SCID 0 или 8, без токена; остаток до 1200/1250 байт — шифрованная
// полезная нагрузка, её изображает <r N>.
func i1QUIC() string {
scid := pick(0, 8)
total := pick(1200, 1250, 1252)
head := 1 + 4 + 1 + 8 + 1 + scid + 1 + 2
rest := total - head
s := "<b 0x" + hex.EncodeToString([]byte{byte(0xc0 | rand.IntN(16)), 0, 0, 0, 1, 8}) + "><r 8>"
if scid > 0 {
s += "<b 0x08><r 8>"
} else {
s += "<b 0x00>"
}
return s + "<b 0x00" + hex.EncodeToString(binary.BigEndian.AppendUint16(nil, 0x4000|uint16(rest))) + "><r " + strconv.Itoa(rest) + ">"
}
// STUN binding request (RFC 5389): magic cookie, ID транзакции — <r 12>; иногда с
// CHANGE-REQUEST (проверка NAT, RFC 5780).
func i1STUN() string {
attrs := pick([]byte{}, []byte{0, 3, 0, 4, 0, 0, 0, 0})
b := []byte{0, 1}
b = binary.BigEndian.AppendUint16(b, uint16(len(attrs)))
b = append(b, 0x21, 0x12, 0xa4, 0x42)
s := "<b 0x" + hex.EncodeToString(b) + "><r 12>"
if len(attrs) > 0 {
s += "<b 0x" + hex.EncodeToString(attrs) + ">"
}
return s
}
var reI1Tag = regexp.MustCompile(`<(b|r) ([^>]*)>`)
// i1Bytes раскрывает шаблон в байты (<r N> — нули); ok=false для тегов, кроме <b>/<r>.
func i1Bytes(s string) ([]byte, bool) {
var out []byte
rest := strings.TrimSpace(s)
for rest != "" {
m := reI1Tag.FindStringSubmatchIndex(rest)
if m == nil || m[0] != 0 {
return nil, false
}
kind, val := rest[m[2]:m[3]], rest[m[4]:m[5]]
if kind == "b" {
h, err := hex.DecodeString(strings.TrimPrefix(val, "0x"))
if err != nil {
return nil, false
}
out = append(out, h...)
} else {
n, err := strconv.Atoi(val)
if err != nil || n < 0 || n > 65535 {
return nil, false
}
out = append(out, make([]byte, n)...)
}
rest = rest[m[1]:]
}
return out, len(out) > 0
}
// dnsName — имя первого вопроса DNS-пакета.
func dnsName(b []byte) string {
var parts []string
for i := 12; i < len(b) && b[i] != 0; {
n := int(b[i])
if n > 63 || i+1+n > len(b) {
return ""
}
parts = append(parts, string(b[i+1:i+1+n]))
i += 1 + n
}
return strings.Join(parts, ".")
}
// DescribeI1 — под что маскируется I1, для панели: вид (dns, quic, stun, custom, none) и подпись.
func DescribeI1(s string) (kind, label string) {
if strings.TrimSpace(s) == "" {
return "none", "без маскировки"
}
b, ok := i1Bytes(s)
switch {
case !ok:
case len(b) >= 20 && binary.BigEndian.Uint32(b[4:8]) == 0x2112a442 && b[0] == 0 && b[1] == 1:
return "stun", "STUN binding request"
case len(b) >= 6 && b[0]&0xc0 == 0xc0 && binary.BigEndian.Uint32(b[1:5]) == 1:
return "quic", "QUIC Initial, " + strconv.Itoa(len(b)) + " байт"
case len(b) > 12 && b[2]&0x80 == 0 && binary.BigEndian.Uint16(b[4:6]) == 1:
if n := dnsName(b); n != "" {
return "dns", "DNS-запрос к " + n
}
case len(b) > 12 && b[2]&0x80 != 0:
if n := dnsName(b); n != "" {
return "dns", "DNS-ответ от " + n
}
}
return "custom", "свой шаблон"
}
// i1FromConf — I1, вписанный в [Interface] awg1.conf руками (ReadParams без него отдаёт
// значение по умолчанию).
func i1FromConf(confFile string) string {
p := ReadParams(confFile, nil)
if p.I1 == DefaultParams().I1 {
return ""
}
return p.I1
}
// ensureI1 — на старте: I1 этого сервера из БД; нет — из awg1.conf, нет и там — новый случайный.
func (s *Server) ensureI1() string {
if v := s.getCfg(cfgI1, ""); v != "" {
return v
}
v := i1FromConf(s.confPath())
if v == "" {
v = GenI1()
}
s.setCfg(cfgI1, v)
kind, label := DescribeI1(v)
s.log.Info("I1 assigned", "kind", kind, "label", label)
return v
}
// regenI1 — новый случайный I1. Клиенты по sen:// получают его со следующим обновлением
// профиля; vpn:// ключи несут прежний и продолжают работать, пока их не перевыпустят.
func (s *Server) regenI1() string {
v := GenI1()
s.setCfg(cfgI1, v)
s.cfgMu.Lock()
s.c.Params.I1 = v
s.cfgMu.Unlock()
kind, label := DescribeI1(v)
s.log.Info("I1 regenerated", "kind", kind, "label", label)
return v
}
func maskingInfo(i1 string) ob {
kind, label := DescribeI1(i1)
return ob{{"i1", i1}, {"kind", kind}, {"label", label}}
}
+58
View File
@@ -0,0 +1,58 @@
package ctrl
import (
"strings"
"testing"
)
func TestGenI1Templates(t *testing.T) {
seen := map[string]bool{}
for i := 0; i < 500; i++ {
v := GenI1()
b, ok := i1Bytes(v)
if !ok {
t.Fatalf("шаблон не разбирается: %q", v)
}
kind, label := DescribeI1(v)
seen[kind] = true
switch kind {
case "dns":
if !strings.HasPrefix(v, "<r 2>") || !strings.HasPrefix(label, "DNS-запрос к ") {
t.Fatalf("DNS: %q → %q", v, label)
}
case "quic":
if n := len(b); n != 1200 && n != 1250 && n != 1252 {
t.Fatalf("QUIC длиной %d: %q", n, v)
}
case "stun":
if len(b) != 20 && len(b) != 28 {
t.Fatalf("STUN длиной %d: %q", len(b), v)
}
default:
t.Fatalf("неожиданный вид %q: %q", kind, v)
}
// Только <b>/<r>, без пробелов вне тегов — так I1 пишут и клиенты AmneziaVPN.
if strings.Contains(strings.NewReplacer("<b ", "", "<r ", "").Replace(v), " ") {
t.Fatalf("лишние пробелы: %q", v)
}
}
for _, k := range []string{"dns", "quic", "stun"} {
if !seen[k] {
t.Fatalf("шаблон %s ни разу не выпал", k)
}
}
}
func TestDescribeI1(t *testing.T) {
cases := map[string]string{
DefaultParams().I1: "DNS-ответ от icloud.com",
"": "без маскировки",
"<c><t>": "свой шаблон",
"<b 0xdeadbeef>": "свой шаблон",
}
for in, want := range cases {
if _, got := DescribeI1(in); got != want {
t.Errorf("DescribeI1(%q) = %q, want %q", in, got, want)
}
}
}
+6
View File
@@ -300,6 +300,12 @@ func (s *Server) routes() []route {
{"keepalive", cfg.Keepalive}, {"mtu", cfg.MTU}, {"awg", profileAwg(cfg.Params)}})
}),
// Маскировка первого пакета (I1): посмотреть и сменить. Путь под /api/ — его пропускает
// awg-agent нод. Интерфейс не трогается: I1 нужен только клиентам (см. i1.go).
R("GET", "/api/masking", false, a, func(c *rctx) (result, error) { return ok(maskingInfo(s.cfg().Params.I1)) }),
R("POST", "/api/masking", true, a, func(c *rctx) (result, error) { return ok(maskingInfo(s.regenI1())) }),
R("POST", "/awg/restart", true, a, func(c *rctx) (result, error) {
if err := s.restartAwg(); err != nil {
return result{}, err
+1
View File
@@ -146,6 +146,7 @@ func New() (*Server, error) {
sp, _ := strconv.Atoi(portStr)
params := ReadParams(s.confPath(), func(m string) { s.log.Warn(m) })
params.I1 = s.ensureI1() // I1 — из БД, не из conf (см. i1.go)
gen := params.Gen()
module, tools := readVersions()
s.c = Config{
+1 -1
View File
@@ -91,7 +91,7 @@ func TestPrometheusOnNodeZero(t *testing.T) {
ag, err := agent.New(agent.Config{
CoreHost: "127.0.0.1", CorePort: hubPort, CorePin: b64url.EncodeToString(a.nodes.pin), NodeID: 0,
Ctrl: ctrl.URL, NodeKeyFile: keyFile, InternalKey: filepath.Join(dir, "none"),
PromURL: prom.URL, MetricsPort: metricsPort, MonitorHash: hashFile,
PromURL: prom.URL, MetricsPort: metricsPort, MonitorHash: hashFile, Version: MinNodeVersion,
Logf: t.Logf, Errorf: t.Logf,
})
if err != nil {
+39 -3
View File
@@ -159,8 +159,11 @@ func newNodes(a *App) (*Nodes, error) {
keyF, crtF := file("NODE_TLS_KEY_FILE", "node_tls.key"), file("NODE_TLS_CERT_FILE", "node_tls.crt")
if err := func() error {
if !exists(keyF) && !exists(crtF) {
// CN случайный (12 hex, как имя хоста контейнера): постоянный CN — готовый отпечаток для DPI.
cn := make([]byte, 6)
rand.Read(cn)
cmd := exec.Command("openssl", "req", "-x509", "-newkey", "ec", "-pkeyopt", "ec_paramgen_curve:prime256v1", "-nodes",
"-keyout", keyF, "-out", crtF, "-days", "3650", "-subj", "/CN=awg-node")
"-keyout", keyF, "-out", crtF, "-days", "3650", "-subj", fmt.Sprintf("/CN=%x", cn))
if err := cmd.Run(); err != nil {
return err
}
@@ -473,6 +476,30 @@ func (n *Nodes) dropConn(id int64, c *nodeConn) {
n.mu.Unlock()
}
// MinNodeVersion — самая старая версия ноды, с которой работает панель (в UI — MIN_NODE_VERSION).
const MinNodeVersion = "0.5.0"
// closeOutdated — код закрытия для ноды старше MinNodeVersion.
const closeOutdated = 4026
// cmpVersion сравнивает версии вида 0.5.0 (хвост после «-» не учитывается): <0 — a старше b.
func cmpVersion(a, b string) int {
pa, pb := strings.Split(strings.SplitN(a, "-", 2)[0], "."), strings.Split(strings.SplitN(b, "-", 2)[0], ".")
for i := 0; i < max(len(pa), len(pb)); i++ {
var x, y int
if i < len(pa) {
x, _ = strconv.Atoi(pa[i])
}
if i < len(pb) {
y, _ = strconv.Atoi(pb[i])
}
if x != y {
return x - y
}
}
return 0
}
func closeWS(ws *websocket.Conn, code int, reason string) {
go ws.Close(websocket.StatusCode(code), reason)
}
@@ -540,6 +567,13 @@ func (n *Nodes) serveWS(w http.ResponseWriter, r *http.Request) {
version = v
}
n.a.db.Exec("UPDATE nodes SET last_seen = ?, version = ? WHERE id = ?", now(), version, row.ID)
// Нода старше MinNodeVersion (или без версии — агент старше 0.3.4) с этой панелью не работает:
// соединение не принимаем, для панели она «нет связи», пока её не обновят (агент переподключится сам).
if v, _ := version.(string); v == "" || cmpVersion(v, MinNodeVersion) < 0 {
logf("nodes: «%s» (#%d) — версия %q, нужна не ниже %s: соединение отклонено", row.Name, row.ID, v, MinNodeVersion)
closeWS(ws, closeOutdated, "outdated "+MinNodeVersion)
return
}
conn = &nodeConn{ws: ws, alive: true, pending: map[int64]*pending{}}
nodeID = row.ID
@@ -775,7 +809,8 @@ func (n *Nodes) List() []serverInfo {
}
for _, r := range n.all() {
out = append(out, serverInfo{ID: int(r.ID), Name: r.Name, Online: online[r.ID], Pending: !r.Pub.Valid,
LastSeen: nullInt(r.LastSeen), Version: nullStr(r.Version), Health: hs[r.ID]})
LastSeen: nullInt(r.LastSeen), Version: nullStr(r.Version), Health: hs[r.ID],
Outdated: r.Pub.Valid && (!r.Version.Valid || cmpVersion(r.Version.String, MinNodeVersion) < 0)})
}
return out
}
@@ -789,11 +824,12 @@ type serverInfo struct {
LastSeen any
Version any
Health *health
Outdated bool // старше MinNodeVersion: хаб её не подключает, пока не обновят
}
func (s serverInfo) json() jsjson.Object {
return jsjson.Object{{Key: "id", Val: s.ID}, {Key: "name", Val: s.Name}, {Key: "local", Val: s.Local}, {Key: "online", Val: s.Online},
{Key: "pending", Val: s.Pending}, {Key: "last_seen", Val: s.LastSeen}, {Key: "version", Val: s.Version}, {Key: "health", Val: s.Health.json()}}
{Key: "pending", Val: s.Pending}, {Key: "last_seen", Val: s.LastSeen}, {Key: "version", Val: s.Version}, {Key: "health", Val: s.Health.json()}, {Key: "outdated", Val: s.Outdated}}
}
func (n *Nodes) idOf(c *Ctx) (int64, bool) {
+88
View File
@@ -0,0 +1,88 @@
package ui
import (
"context"
"crypto/ed25519"
"crypto/rand"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/coder/websocket"
"github.com/maeneko/forgetting/internal/jsjson"
)
// Нода старше MinNodeVersion (или без версии) с панелью не работает: хаб отклоняет её с 4026 и
// не считает подключённой, но версию запоминает — панель показывает, до чего обновлять.
func TestOutdatedNodeRejected(t *testing.T) {
p := newMovePanel(t, "off", "40500")
pub, priv, _ := ed25519.GenerateKey(rand.Reader)
if _, err := p.a.db.Exec("INSERT INTO nodes (id, name, pub, created_at) VALUES (3, 'Frankfurt', ?, 1)", b64url.EncodeToString(pub)); err != nil {
t.Fatal(err)
}
srv := httptest.NewServer(http.HandlerFunc(p.a.nodes.serveWS))
defer srv.Close()
connect := func(version string) (jsjson.Object, websocket.StatusCode) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
ws, _, err := websocket.Dial(ctx, "ws"+strings.TrimPrefix(srv.URL, "http"), nil)
if err != nil {
t.Fatal(err)
}
defer ws.CloseNow()
read := func() (jsjson.Object, error) {
_, raw, err := ws.Read(ctx)
if err != nil {
return nil, err
}
v, _ := jsjson.Decode(raw)
o, _ := v.(jsjson.Object)
return o, nil
}
hello, _ := read()
auth := jsjson.Object{{Key: "t", Val: "auth"}, {Key: "node_id", Val: 3},
{Key: "sig", Val: b64url.EncodeToString(ed25519.Sign(priv, authMsg(hello.Get2("nonce").(string), 3)))}}
if version != "" {
auth = append(auth, jsjson.KV{Key: "version", Val: version})
}
ws.Write(ctx, websocket.MessageText, jsjson.MustMarshal(auth))
o, err := read()
return o, websocket.CloseStatus(err)
}
for _, v := range []string{"0.4.9", ""} {
if o, code := connect(v); code != closeOutdated {
t.Fatalf("версия %q: ждали закрытия %d, получили %v / %v", v, closeOutdated, o, code)
}
p.a.nodes.mu.Lock()
_, on := p.a.nodes.conns[3]
p.a.nodes.mu.Unlock()
if on {
t.Fatalf("версия %q: нода считается подключённой", v)
}
}
var stored string
p.a.db.QueryRow("SELECT COALESCE(version, '') FROM nodes WHERE id = 3").Scan(&stored)
if stored != "" { // последняя попытка — без версии
t.Fatalf("версия в БД: %q", stored)
}
if o, code := connect(MinNodeVersion); o.Get2("t") != "ready" {
t.Fatalf("актуальная версия: %v / %v", o, code)
}
}
func TestCmpVersion(t *testing.T) {
for _, c := range []struct {
a, b string
sign int
}{{"0.5.0", "0.5.0", 0}, {"0.4.9", "0.5.0", -1}, {"0.10.0", "0.9.9", 1}, {"0.5", "0.5.0", 0}, {"0.5.1-beta", "0.5.1", 0}} {
got := cmpVersion(c.a, c.b)
if (got > 0) != (c.sign > 0) || (got < 0) != (c.sign < 0) {
t.Errorf("cmpVersion(%q, %q) = %d", c.a, c.b, got)
}
}
}
+1 -1
View File
@@ -61,7 +61,7 @@ func TestRetireReadiness(t *testing.T) {
write := func(o jsjson.Object) { ws.Write(ctx, websocket.MessageText, jsjson.MustMarshal(o)) }
nonce := read().Get2("nonce").(string)
write(jsjson.Object{{Key: "t", Val: "auth"}, {Key: "node_id", Val: 3},
{Key: "sig", Val: b64url.EncodeToString(ed25519.Sign(priv, authMsg(nonce, 3)))}})
{Key: "sig", Val: b64url.EncodeToString(ed25519.Sign(priv, authMsg(nonce, 3)))}, {Key: "version", Val: MinNodeVersion}})
if got := string(jsjson.MustMarshal(read())); got != `{"t":"ready","core":"198.51.100.20:40500"}` {
t.Fatalf("ready: %s", got)
}