Move the panel to a server without VPN; servers for all master keys at once

The «Перенос» tab moves a panel + VPN server to a panel-only server without
users noticing: the new panel issues a one-time awgmove:// code, the old one
sends its ui.db, subscription keys and hub certificate encrypted with it, and
becomes node #0 of the new panel. Server id 0 is kept, so config servers[] is
byte-identical. The old server relays its former ports (awg-relay, PROXY v1
header trusted only from RELAY_FROM), so sen:// links, nodes and the API keep
working. The same is available from the CLI: migrate export/import/cancel,
rehome.

Nodes now learn the panel's address: the hub sends it in ready, the agent
stores it in CORE_ENDPOINT, dials it first and confirms with core_ok. The
new panel shows when the old server can be turned off: devices that got the
new address, nodes that confirmed it, master keys never opened.

«Серверы для всех» on the master keys tab adds or removes servers on every
live key in one go, with a dry run that warns about API keys that would lose
sight of some keys, and sets the servers new keys get.
This commit is contained in:
2026-10-21 15:59:33 +03:00
parent 0687650d26
commit eb19cacf93
33 changed files with 3603 additions and 106 deletions
+149
View File
@@ -0,0 +1,149 @@
package ui
// PROXY protocol v1 от ретранслятора (`awg-control relay`): после переноса панели старый
// сервер пересылает на неё подписку, хаб нод и панель. Без заголовка все клиенты пришли бы с
// его IP и делили бы на всех лимиты по адресу (неудачные запросы, регистрации, вход). Заголовку
// верим только от адресов из RELAY_FROM; от остальных он — просто байты соединения.
import (
"bufio"
"net"
"os"
"strconv"
"strings"
"time"
)
const proxyHeaderTimeout = 5 * time.Second
// relayFrom — RELAY_FROM: IP ретрансляторов через запятую.
func relayFrom() map[string]bool {
out := map[string]bool{}
for _, s := range strings.Split(os.Getenv("RELAY_FROM"), ",") {
if ip := net.ParseIP(strings.TrimSpace(s)); ip != nil {
out[ip.String()] = true
}
}
return out
}
// listen — net.Listen("tcp", :port), а при заданном RELAY_FROM — с разбором PROXY v1.
func listen(port int) (net.Listener, error) {
ln, err := net.Listen("tcp", ":"+strconv.Itoa(port))
if err != nil {
return nil, err
}
if trusted := relayFrom(); len(trusted) > 0 {
return newProxyListener(ln, trusted), nil
}
return ln, nil
}
type accepted struct {
c net.Conn
err error
}
// proxyListener читает заголовок в своей горутине на соединение: медленный ретранслятор не
// держит Accept для остальных.
type proxyListener struct {
net.Listener
trusted map[string]bool
ch chan accepted
}
func newProxyListener(ln net.Listener, trusted map[string]bool) *proxyListener {
l := &proxyListener{Listener: ln, trusted: trusted, ch: make(chan accepted)}
go l.loop()
return l
}
func (l *proxyListener) loop() {
for {
c, err := l.Listener.Accept()
if err != nil {
l.ch <- accepted{nil, err}
if ne, ok := err.(net.Error); ok && ne.Timeout() {
continue
}
return
}
host, _, _ := net.SplitHostPort(c.RemoteAddr().String())
if ip := net.ParseIP(host); ip == nil || !l.trusted[ip.String()] {
l.ch <- accepted{c, nil}
continue
}
go func() {
if pc := readProxyHeader(c); pc != nil {
l.ch <- accepted{pc, nil}
}
}()
}
}
func (l *proxyListener) Accept() (net.Conn, error) {
a := <-l.ch
return a.c, a.err
}
type proxyConn struct {
net.Conn
r *bufio.Reader
remote net.Addr
}
func (c *proxyConn) Read(b []byte) (int, error) { return c.r.Read(b) }
func (c *proxyConn) RemoteAddr() net.Addr { return c.remote }
// readProxyHeader — соединение без заголовка остаётся как есть (с ретранслятора может прийти и
// обычный клиент); битый заголовок — закрываем. nil — соединение закрыто.
func readProxyHeader(c net.Conn) net.Conn {
c.SetReadDeadline(time.Now().Add(proxyHeaderTimeout))
defer c.SetReadDeadline(time.Time{})
r := bufio.NewReaderSize(c, 256)
pc := &proxyConn{Conn: c, r: r, remote: c.RemoteAddr()}
if sig, err := r.Peek(6); err != nil || string(sig) != "PROXY " {
if err != nil && len(sig) == 0 {
c.Close()
return nil
}
return pc
}
// Строка v1 — не длиннее 107 байт вместе с CRLF.
line, err := r.ReadSlice('\n')
if err != nil || len(line) > 107 || !strings.HasSuffix(string(line), "\r\n") {
c.Close()
return nil
}
if addr, ok := parseProxyV1(strings.TrimSuffix(string(line), "\r\n")); ok {
if addr != nil {
pc.remote = addr
}
return pc
}
c.Close()
return nil
}
// parseProxyV1 — «PROXY TCP4|TCP6 src dst sport dport» или «PROXY UNKNOWN …» (addr == nil).
func parseProxyV1(line string) (*net.TCPAddr, bool) {
f := strings.Split(line, " ")
if len(f) >= 2 && f[0] == "PROXY" && f[1] == "UNKNOWN" {
return nil, true
}
if len(f) != 6 || f[0] != "PROXY" || f[1] != "TCP4" && f[1] != "TCP6" {
return nil, false
}
ip := net.ParseIP(f[2])
if ip == nil || net.ParseIP(f[3]) == nil || (ip.To4() != nil) != (f[1] == "TCP4") {
return nil, false
}
port, err := strconv.Atoi(f[4])
if err != nil || port < 0 || port > 65535 {
return nil, false
}
if p, err := strconv.Atoi(f[5]); err != nil || p < 0 || p > 65535 {
return nil, false
}
return &net.TCPAddr{IP: ip, Port: port}, true
}