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,382 @@
|
||||
// Package migrate — перенос панели с сервера «панель + VPN» на отдельную панель без VPN: архив
|
||||
// (ui.db, ключи подписки и хаба, порты), его раскладка на новой панели и нода #0. Общий для
|
||||
// CLI (`awg-control migrate`) и вкладки «Перенос» в панели. Порядок и смысл —
|
||||
// docs/node-protocol.md, «Перенос панели».
|
||||
//
|
||||
// Старый сервер остаётся нодой #0: id сервера в ui.db не меняется, поэтому конфиг клиента
|
||||
// (servers[] и ключи) тот же байт в байт, а пиры на его awg-ctrl уже принадлежат области
|
||||
// перенесённой панели (sub_meta.panel_id едет вместе с ui.db).
|
||||
package migrate
|
||||
|
||||
import (
|
||||
"archive/tar"
|
||||
"compress/gzip"
|
||||
"crypto/sha256"
|
||||
"crypto/x509"
|
||||
"database/sql"
|
||||
"encoding/base64"
|
||||
"encoding/pem"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
_ "modernc.org/sqlite"
|
||||
|
||||
"github.com/maeneko/forgetting/internal/jsjson"
|
||||
)
|
||||
|
||||
var b64url = base64.RawURLEncoding
|
||||
|
||||
const (
|
||||
Version = 1
|
||||
maxFile = 1 << 30 // ui.db крупнее гигабайта — явно не то
|
||||
JoinTTL = 3600 // как у панели: одноразовый секрет живёт час
|
||||
)
|
||||
|
||||
// File — что едет в архиве: имя внутри архива и переменная пути в окружении awg-ui.
|
||||
type File struct {
|
||||
Name, Env string
|
||||
Required bool
|
||||
}
|
||||
|
||||
var Files = []File{
|
||||
{"ui.db", "UI_DB_FILE", true},
|
||||
// 🛑 Ключ подписи и TLS подписки зашиты во все выданные sen://, сертификат хаба — во все
|
||||
// join-строки и подключённые ноды: без них перенос ломает и ссылки, и ноды.
|
||||
{"sub_sign.key", "SUB_SIGN_KEY_FILE", false},
|
||||
{"sub_tls.key", "SUB_TLS_KEY_FILE", false},
|
||||
{"sub_tls.crt", "SUB_TLS_CERT_FILE", false},
|
||||
{"node_tls.key", "NODE_TLS_KEY_FILE", false},
|
||||
{"node_tls.crt", "NODE_TLS_CERT_FILE", false},
|
||||
}
|
||||
|
||||
// Paths — имя файла в архиве → путь на этой машине.
|
||||
type Paths map[string]string
|
||||
|
||||
// PathsFrom — пути из переменных окружения awg-ui (get(env) → путь).
|
||||
func PathsFrom(get func(env string) string) Paths {
|
||||
p := Paths{}
|
||||
for _, f := range Files {
|
||||
p[f.Name] = get(f.Env)
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
// OpenDB — ui.db тем же драйвером, что у awg-ui.
|
||||
func OpenDB(path string) (*sql.DB, error) {
|
||||
db, err := sql.Open("sqlite", "file:"+(&url.URL{Path: path}).EscapedPath()+"?_pragma=busy_timeout(5000)")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
db.SetMaxOpenConns(1)
|
||||
if err := db.Ping(); err != nil {
|
||||
db.Close()
|
||||
return nil, err
|
||||
}
|
||||
return db, nil
|
||||
}
|
||||
|
||||
// ── Архив ───────────────────────────────────────────────────────────────────
|
||||
|
||||
// Write — архив в w: снимок db (VACUUM INTO — целостный, вместе с WAL), файлы из p и meta
|
||||
// (+ panel_id из базы). Отсутствующие необязательные файлы пропускаются.
|
||||
func Write(w io.Writer, db *sql.DB, p Paths, meta jsjson.Object) error {
|
||||
tmp, err := os.MkdirTemp("", "forgetting-migrate-")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer os.RemoveAll(tmp)
|
||||
snap := filepath.Join(tmp, "ui.db")
|
||||
if _, err := db.Exec("VACUUM INTO ?", snap); err != nil {
|
||||
return fmt.Errorf("снимок ui.db: %w", err)
|
||||
}
|
||||
var panelID string
|
||||
db.QueryRow("SELECT value FROM sub_meta WHERE key = 'panel_id'").Scan(&panelID)
|
||||
meta = append(jsjson.Object{{Key: "v", Val: Version}, {Key: "panel_id", Val: panelID}}, meta...)
|
||||
|
||||
gz := gzip.NewWriter(w)
|
||||
tw := tar.NewWriter(gz)
|
||||
add := func(name string, data []byte) error {
|
||||
if err := tw.WriteHeader(&tar.Header{Name: name, Mode: 0o600, Size: int64(len(data)), ModTime: time.Now()}); err != nil {
|
||||
return err
|
||||
}
|
||||
_, err := tw.Write(data)
|
||||
return err
|
||||
}
|
||||
err = add("meta.json", jsjson.MustMarshal(meta))
|
||||
for _, f := range Files {
|
||||
if err != nil {
|
||||
break
|
||||
}
|
||||
src := p[f.Name]
|
||||
if f.Name == "ui.db" {
|
||||
src = snap
|
||||
}
|
||||
raw, rerr := os.ReadFile(src)
|
||||
if errors.Is(rerr, os.ErrNotExist) && !f.Required {
|
||||
continue
|
||||
}
|
||||
if rerr != nil {
|
||||
err = rerr
|
||||
break
|
||||
}
|
||||
err = add(f.Name, raw)
|
||||
}
|
||||
if cerr := tw.Close(); err == nil {
|
||||
err = cerr
|
||||
}
|
||||
if cerr := gz.Close(); err == nil {
|
||||
err = cerr
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
type Bundle struct {
|
||||
Dir string
|
||||
Meta jsjson.Object
|
||||
Has map[string]bool
|
||||
}
|
||||
|
||||
func (b *Bundle) Close() { os.RemoveAll(b.Dir) }
|
||||
|
||||
func (b *Bundle) Num(k string) int {
|
||||
f, _ := b.Meta.Get2(k).(float64)
|
||||
return int(f)
|
||||
}
|
||||
|
||||
func (b *Bundle) Str(k string) string {
|
||||
s, _ := b.Meta.Get2(k).(string)
|
||||
return s
|
||||
}
|
||||
|
||||
// Read — распаковка во временный каталог; в архиве допустимы только известные имена.
|
||||
func Read(r io.Reader) (*Bundle, error) {
|
||||
gz, err := gzip.NewReader(r)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("не архив переноса: %w", err)
|
||||
}
|
||||
dir, err := os.MkdirTemp("", "forgetting-import-")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
b := &Bundle{Dir: dir, Has: map[string]bool{}}
|
||||
fail := func(err error) (*Bundle, error) { b.Close(); return nil, err }
|
||||
allowed := map[string]bool{"meta.json": true}
|
||||
for _, f := range Files {
|
||||
allowed[f.Name] = true
|
||||
}
|
||||
tr := tar.NewReader(gz)
|
||||
for {
|
||||
h, err := tr.Next()
|
||||
if err == io.EOF {
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
return fail(fmt.Errorf("архив повреждён: %w", err))
|
||||
}
|
||||
if h.Typeflag != tar.TypeReg || !allowed[h.Name] || b.Has[h.Name] || h.Size > maxFile {
|
||||
return fail(fmt.Errorf("в архиве лишнее: %q", h.Name))
|
||||
}
|
||||
dst, err := os.OpenFile(filepath.Join(dir, h.Name), os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600)
|
||||
if err == nil {
|
||||
_, err = io.Copy(dst, io.LimitReader(tr, maxFile))
|
||||
if cerr := dst.Close(); err == nil {
|
||||
err = cerr
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
return fail(err)
|
||||
}
|
||||
b.Has[h.Name] = true
|
||||
}
|
||||
raw, err := os.ReadFile(filepath.Join(dir, "meta.json"))
|
||||
var v any
|
||||
if err == nil {
|
||||
v, err = jsjson.Decode(raw)
|
||||
}
|
||||
meta, ok := v.(jsjson.Object)
|
||||
if err != nil || !ok || meta.Get2("v") != float64(Version) || !b.Has["ui.db"] {
|
||||
return fail(errors.New("не архив переноса или от другой версии (нужны meta.json v1 и ui.db)"))
|
||||
}
|
||||
b.Meta = meta
|
||||
return b, nil
|
||||
}
|
||||
|
||||
// PanelData — сколько в базе того, что перенос затёр бы (мастер-ключи, ноды, ключи API).
|
||||
func PanelData(file string) (int, error) {
|
||||
if _, err := os.Stat(file); errors.Is(err, os.ErrNotExist) {
|
||||
return 0, nil
|
||||
}
|
||||
db, err := OpenDB(file)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
defer db.Close()
|
||||
return Count(db), nil
|
||||
}
|
||||
|
||||
func Count(db *sql.DB) int {
|
||||
total := 0
|
||||
for _, t := range []string{"master_keys", "nodes", "api_keys"} {
|
||||
var n int
|
||||
// Таблицы может не быть — awg-ui ещё не запускался.
|
||||
if db.QueryRow("SELECT COUNT(*) FROM "+t).Scan(&n) == nil {
|
||||
total += n
|
||||
}
|
||||
}
|
||||
return total
|
||||
}
|
||||
|
||||
// ── Раскладка на новой панели ──────────────────────────────────────────────
|
||||
|
||||
// Plan — порты и переменные cli.env новой панели. Подписка и хаб — на прежних портах: они в
|
||||
// ссылках, у нод и в DNS, если адрес — домен.
|
||||
type Plan struct {
|
||||
SubPort, NodePort, UIPort int
|
||||
Host string
|
||||
Env [][2]string
|
||||
}
|
||||
|
||||
// NewPlan — env(k) → текущий порт этой панели (0 — не задан), host — её публичный адрес.
|
||||
func NewPlan(b *Bundle, env func(k string) int, uiPort int, host string) (*Plan, error) {
|
||||
p := &Plan{SubPort: b.Num("sub_port"), NodePort: b.Num("node_port"), UIPort: uiPort, Host: host}
|
||||
if p.SubPort == 0 {
|
||||
p.SubPort = env("SUB_PORT")
|
||||
}
|
||||
if p.NodePort == 0 {
|
||||
p.NodePort = env("NODE_PORT")
|
||||
}
|
||||
if p.NodePort == 0 {
|
||||
return nil, errors.New("приём нод не настроен (NODE_PORT): старый сервер подключится к панели нодой")
|
||||
}
|
||||
if p.UIPort != 0 && (p.UIPort == p.SubPort || p.UIPort == p.NodePort) || p.SubPort != 0 && p.SubPort == p.NodePort {
|
||||
return nil, fmt.Errorf("порты пересекаются: панель %d, подписка %d, хаб %d — смените UI_PORT", p.UIPort, p.SubPort, p.NodePort)
|
||||
}
|
||||
for _, kv := range [][2]string{{"SUB_PORT", strconv.Itoa(p.SubPort)}, {"NODE_PORT", strconv.Itoa(p.NodePort)}, {"SUB_HOST", host}} {
|
||||
if kv[1] != "0" && kv[1] != "" {
|
||||
p.Env = append(p.Env, kv)
|
||||
}
|
||||
}
|
||||
tlsOn := "off"
|
||||
if b.Has["sub_tls.crt"] && b.Meta.Get2("sub_tls") == true {
|
||||
tlsOn = "on"
|
||||
}
|
||||
p.Env = append(p.Env, [2]string{"SUB_TLS", tlsOn})
|
||||
// С этого момента панель считает, кто из клиентов и нод узнал её новый адрес.
|
||||
p.Env = append(p.Env, [2]string{"MOVED_IN_AT", strconv.FormatInt(time.Now().Unix(), 10)})
|
||||
// Заголовку PROXY от ретранслятора верим только с адреса старого сервера.
|
||||
if ip := b.Str("server_ip"); ip != "" {
|
||||
p.Env = append(p.Env, [2]string{"RELAY_FROM", ip})
|
||||
}
|
||||
return p, nil
|
||||
}
|
||||
|
||||
// TrustRelay — ещё один адрес ретранслятора: старый сервер может выходить в сеть не с того IP,
|
||||
// что у него в SERVER_IP (NAT, второй адрес). Панель видит, откуда пришёл архив, — его и берём.
|
||||
func (p *Plan) TrustRelay(ip string) {
|
||||
if ip == "" {
|
||||
return
|
||||
}
|
||||
for i, kv := range p.Env {
|
||||
if kv[0] == "RELAY_FROM" {
|
||||
for _, have := range strings.Split(kv[1], ",") {
|
||||
if have == ip {
|
||||
return
|
||||
}
|
||||
}
|
||||
p.Env[i][1] += "," + ip
|
||||
return
|
||||
}
|
||||
}
|
||||
p.Env = append(p.Env, [2]string{"RELAY_FROM", ip})
|
||||
}
|
||||
|
||||
// Install — файлы архива на свои места; прежние уходят в *.bak-<время>.
|
||||
func Install(b *Bundle, p Paths) error {
|
||||
stamp := time.Now().Format("20060102-150405")
|
||||
for _, f := range Files {
|
||||
if !b.Has[f.Name] {
|
||||
continue
|
||||
}
|
||||
dst := p[f.Name]
|
||||
olds := []string{dst}
|
||||
if f.Name == "ui.db" {
|
||||
olds = append(olds, dst+"-wal", dst+"-shm")
|
||||
}
|
||||
for _, o := range olds {
|
||||
if _, err := os.Stat(o); err == nil {
|
||||
if err := os.Rename(o, o+".bak-"+stamp); err != nil {
|
||||
return fmt.Errorf("не удалось отложить %s: %w", o, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
raw, err := os.ReadFile(filepath.Join(b.Dir, f.Name))
|
||||
if err == nil {
|
||||
err = os.MkdirAll(filepath.Dir(dst), 0o700)
|
||||
}
|
||||
if err == nil {
|
||||
err = os.WriteFile(dst, raw, 0o600)
|
||||
}
|
||||
if err != nil {
|
||||
return fmt.Errorf("не удалось записать %s: %w", dst, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// AddNodeZero — старый сервер в реестре нод под прежним id 0, ждёт первого подключения.
|
||||
func AddNodeZero(dbFile, name string, secret []byte) error {
|
||||
if name == "" || len([]rune(name)) > 40 || strings.ContainsAny(name, "\r\n<>") {
|
||||
name = "VPN"
|
||||
}
|
||||
db, err := OpenDB(dbFile)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer db.Close()
|
||||
h := sha256.Sum256(secret)
|
||||
now := time.Now().Unix()
|
||||
if _, err := db.Exec("INSERT INTO nodes (id, name, join_hash, join_expires, created_at) VALUES (0, ?, ?, ?, ?)",
|
||||
name, h[:], now+JoinTTL, now); err != nil {
|
||||
return fmt.Errorf("нода #0 не заведена (база со старой версии? обновите старый сервер): %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// CertPin — SHA-256 SPKI сертификата, base64url: то же, что pin в join-строке.
|
||||
func CertPin(file string) (string, error) {
|
||||
raw, err := os.ReadFile(file)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
blk, _ := pem.Decode(raw)
|
||||
if blk == nil {
|
||||
return "", errors.New("не PEM")
|
||||
}
|
||||
cert, err := x509.ParseCertificate(blk.Bytes)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
sum := sha256.Sum256(cert.RawSubjectPublicKeyInfo)
|
||||
return b64url.EncodeToString(sum[:]), nil
|
||||
}
|
||||
|
||||
// JoinString — как у панели (nodes.go), плюс ui/sub: куда ретранслятору старого сервера слать
|
||||
// прежние порты панели и подписки. Остальные читатели строки лишних полей не видят.
|
||||
func JoinString(host string, port int, pin string, id int64, secret []byte, uiPort, subPort int) string {
|
||||
o := jsjson.Object{{Key: "v", Val: 1}, {Key: "h", Val: host}, {Key: "p", Val: port},
|
||||
{Key: "pin", Val: pin}, {Key: "n", Val: id}, {Key: "s", Val: b64url.EncodeToString(secret)}}
|
||||
if uiPort > 0 {
|
||||
o = append(o, jsjson.KV{Key: "ui", Val: uiPort})
|
||||
}
|
||||
if subPort > 0 {
|
||||
o = append(o, jsjson.KV{Key: "sub", Val: subPort})
|
||||
}
|
||||
return "awgjoin://" + b64url.EncodeToString(jsjson.MustMarshal(o))
|
||||
}
|
||||
Reference in New Issue
Block a user