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:
@@ -0,0 +1,159 @@
|
||||
// Package relay — ретранслятор TCP на сервере, с которого переехала панель (`awg-control
|
||||
// migrate`). В выданных ссылках sen://, у подключённых нод и у клиентов внешнего API остался
|
||||
// старый адрес: relay слушает прежние порты подписки, хаба нод и панели и пересылает соединения
|
||||
// на новую панель как есть — TLS идёт насквозь, ключи и сертификаты у панели прежние. Перед
|
||||
// данными идёт заголовок PROXY v1 с адресом клиента: панель верит ему только от RELAY_FROM.
|
||||
package relay
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
const dialTimeout = 10 * time.Second
|
||||
|
||||
// Route — одна пересылка: локальный порт → адрес новой панели.
|
||||
type Route struct {
|
||||
Listen int
|
||||
Target string // host:port
|
||||
}
|
||||
|
||||
// ParseRoutes — RELAY: «порт=хост:порт» через запятую.
|
||||
func ParseRoutes(s string) ([]Route, error) {
|
||||
var out []Route
|
||||
for _, part := range strings.Split(s, ",") {
|
||||
part = strings.TrimSpace(part)
|
||||
if part == "" {
|
||||
continue
|
||||
}
|
||||
l, t, ok := strings.Cut(part, "=")
|
||||
port, err := strconv.Atoi(l)
|
||||
if !ok || err != nil || port <= 0 || port > 65535 {
|
||||
return nil, fmt.Errorf("relay: неверная пересылка %q (нужно порт=хост:порт)", part)
|
||||
}
|
||||
host, tp, err := net.SplitHostPort(t)
|
||||
if n, perr := strconv.Atoi(tp); err != nil || host == "" || perr != nil || n <= 0 || n > 65535 {
|
||||
return nil, fmt.Errorf("relay: неверный адрес %q", t)
|
||||
}
|
||||
out = append(out, Route{port, t})
|
||||
}
|
||||
if len(out) == 0 {
|
||||
return nil, errors.New("relay: RELAY пуст — пересылать нечего")
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// proxyHeader — строка PROXY v1 для соединения c.
|
||||
func proxyHeader(c net.Conn) string {
|
||||
src, ok1 := c.RemoteAddr().(*net.TCPAddr)
|
||||
dst, ok2 := c.LocalAddr().(*net.TCPAddr)
|
||||
if !ok1 || !ok2 {
|
||||
return "PROXY UNKNOWN\r\n"
|
||||
}
|
||||
sip, dip := src.IP, dst.IP
|
||||
fam := "TCP6"
|
||||
if s4, d4 := sip.To4(), dip.To4(); s4 != nil && d4 != nil {
|
||||
fam, sip, dip = "TCP4", s4, d4
|
||||
} else if s4 != nil || d4 != nil {
|
||||
// Смешанные семейства (v4-mapped на v6-сокете и т. п.) — оба в v6.
|
||||
sip, dip = sip.To16(), dip.To16()
|
||||
}
|
||||
return fmt.Sprintf("PROXY %s %s %s %d %d\r\n", fam, sip, dip, src.Port, dst.Port)
|
||||
}
|
||||
|
||||
// pipe — копирование в обе стороны; конец одной стороны закрывает запись в другую.
|
||||
func pipe(a, b net.Conn) {
|
||||
var wg sync.WaitGroup
|
||||
cp := func(dst, src net.Conn) {
|
||||
defer wg.Done()
|
||||
io.Copy(dst, src)
|
||||
if tc, ok := dst.(*net.TCPConn); ok {
|
||||
tc.CloseWrite()
|
||||
} else {
|
||||
dst.Close()
|
||||
}
|
||||
}
|
||||
wg.Add(2)
|
||||
go cp(a, b)
|
||||
go cp(b, a)
|
||||
wg.Wait()
|
||||
a.Close()
|
||||
b.Close()
|
||||
}
|
||||
|
||||
func serve(ctx context.Context, ln net.Listener, r Route, errorf func(string, ...any)) {
|
||||
var d net.Dialer
|
||||
d.Timeout = dialTimeout
|
||||
for {
|
||||
c, err := ln.Accept()
|
||||
if err != nil {
|
||||
if ctx.Err() != nil {
|
||||
return
|
||||
}
|
||||
if ne, ok := err.(net.Error); ok && ne.Timeout() {
|
||||
continue
|
||||
}
|
||||
errorf("relay: :%d: %v", r.Listen, err)
|
||||
time.Sleep(time.Second)
|
||||
continue
|
||||
}
|
||||
go func() {
|
||||
up, err := d.DialContext(ctx, "tcp", r.Target)
|
||||
if err != nil {
|
||||
errorf("relay: :%d → %s: %v", r.Listen, r.Target, err)
|
||||
c.Close()
|
||||
return
|
||||
}
|
||||
if _, err := io.WriteString(up, proxyHeader(c)); err != nil {
|
||||
c.Close()
|
||||
up.Close()
|
||||
return
|
||||
}
|
||||
pipe(c, up)
|
||||
}()
|
||||
}
|
||||
}
|
||||
|
||||
// Run слушает все порты и пересылает до отмены ctx. Порт, который не открылся, — ошибка сразу:
|
||||
// значит, его ещё держит прежняя панель.
|
||||
func Run(ctx context.Context, routes []Route, logf, errorf func(string, ...any)) error {
|
||||
var lns []net.Listener
|
||||
for _, r := range routes {
|
||||
ln, err := net.Listen("tcp", ":"+strconv.Itoa(r.Listen))
|
||||
if err != nil {
|
||||
for _, l := range lns {
|
||||
l.Close()
|
||||
}
|
||||
return fmt.Errorf("relay: порт %d: %w", r.Listen, err)
|
||||
}
|
||||
lns = append(lns, ln)
|
||||
logf("relay: :%d → %s", r.Listen, r.Target)
|
||||
}
|
||||
for i, r := range routes {
|
||||
go serve(ctx, lns[i], r, errorf)
|
||||
}
|
||||
<-ctx.Done()
|
||||
for _, l := range lns {
|
||||
l.Close()
|
||||
}
|
||||
return ctx.Err()
|
||||
}
|
||||
|
||||
// Main — роль `awg-control relay`: пересылки из RELAY.
|
||||
func Main(ctx context.Context) error {
|
||||
routes, err := ParseRoutes(os.Getenv("RELAY"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
logf := func(f string, a ...any) { fmt.Fprintf(os.Stdout, f+"\n", a...) }
|
||||
errorf := func(f string, a ...any) { fmt.Fprintf(os.Stderr, f+"\n", a...) }
|
||||
return Run(ctx, routes, logf, errorf)
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
package relay
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"io"
|
||||
"net"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestParseRoutes(t *testing.T) {
|
||||
r, err := ParseRoutes(" 40123=203.0.113.7:40123, 8443=[2001:db8::1]:9000 ,")
|
||||
if err != nil || len(r) != 2 || r[0] != (Route{40123, "203.0.113.7:40123"}) || r[1] != (Route{8443, "[2001:db8::1]:9000"}) {
|
||||
t.Fatalf("ParseRoutes: %v %v", r, err)
|
||||
}
|
||||
for _, bad := range []string{"", ",", "40123", "0=h:1", "70000=h:1", "1=h", "1=:5", "1=h:0", "x=h:1"} {
|
||||
if _, err := ParseRoutes(bad); err == nil {
|
||||
t.Errorf("ParseRoutes(%q): ожидали ошибку", bad)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func freePort(t *testing.T) int {
|
||||
t.Helper()
|
||||
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer ln.Close()
|
||||
return ln.Addr().(*net.TCPAddr).Port
|
||||
}
|
||||
|
||||
// Поток идёт насквозь в обе стороны, первой строкой у панели — PROXY с адресом клиента.
|
||||
func TestRelayForwardsWithProxyHeader(t *testing.T) {
|
||||
target, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer target.Close()
|
||||
got := make(chan string, 2)
|
||||
go func() {
|
||||
c, err := target.Accept()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
defer c.Close()
|
||||
r := bufio.NewReader(c)
|
||||
hdr, _ := r.ReadString('\n')
|
||||
got <- hdr
|
||||
body, _ := io.ReadAll(r)
|
||||
got <- string(body)
|
||||
io.WriteString(c, "pong")
|
||||
}()
|
||||
|
||||
port := freePort(t)
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
errs := make(chan error, 1)
|
||||
go func() {
|
||||
errs <- Run(ctx, []Route{{port, target.Addr().String()}}, func(string, ...any) {}, func(f string, a ...any) { t.Logf(f, a...) })
|
||||
}()
|
||||
|
||||
var cl net.Conn
|
||||
for range 50 {
|
||||
if cl, err = net.Dial("tcp", net.JoinHostPort("127.0.0.1", strconv.Itoa(port))); err == nil {
|
||||
break
|
||||
}
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer cl.Close()
|
||||
io.WriteString(cl, "ping")
|
||||
cl.(*net.TCPConn).CloseWrite()
|
||||
|
||||
hdr := <-got
|
||||
src := cl.LocalAddr().(*net.TCPAddr)
|
||||
if want := "PROXY TCP4 127.0.0.1 127.0.0.1 " + strconv.Itoa(src.Port) + " " + strconv.Itoa(port) + "\r\n"; hdr != want {
|
||||
t.Errorf("заголовок %q, ожидали %q", hdr, want)
|
||||
}
|
||||
if body := <-got; body != "ping" {
|
||||
t.Errorf("до панели дошло %q", body)
|
||||
}
|
||||
cl.SetReadDeadline(time.Now().Add(3 * time.Second))
|
||||
if back, _ := io.ReadAll(cl); string(back) != "pong" {
|
||||
t.Errorf("обратно пришло %q", back)
|
||||
}
|
||||
|
||||
// Занятый порт — ошибка сразу: значит, его держит прежняя панель.
|
||||
busy, _ := net.Listen("tcp", ":0")
|
||||
defer busy.Close()
|
||||
if err := Run(context.Background(), []Route{{busy.Addr().(*net.TCPAddr).Port, "127.0.0.1:1"}}, func(string, ...any) {}, t.Logf); err == nil ||
|
||||
!strings.Contains(err.Error(), "порт") {
|
||||
t.Errorf("занятый порт: %v", err)
|
||||
}
|
||||
cancel()
|
||||
<-errs
|
||||
}
|
||||
Reference in New Issue
Block a user