Files
forgetting/internal/migrate/migrate.go
T
Claude 13da9a7200 Secret URL path for the panel
The panel port is open to the internet, and its login page and JS bundle
gave it away to any scanner in one GET. The installer now generates a
random UI_PREFIX: from outside the panel answers only under
http://IP:PORT/<prefix>/ and gives Go's plain "404 page not found" to
everything else. Loopback needs no prefix (CLI, local Prometheus, nginx on
the same host); while a move code is out, POST /migrate/v1/import is let
through without it, since the code carries no path.

A reinstall with kept data keeps the prefix; UI_PREFIX=off or a custom value
can be set before running the installer. A moved panel brings its prefix in
UI_PREFIX_MOVED, so old bookmarks and API clients keep working through the
relay. awg-ctrl status and credentials show the full panel address.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013bNGYiDst62wH3ZGTRPFLJ
2026-10-28 11:41:09 +00:00

394 lines
14 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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"
"regexp"
"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
}
var reUIPrefix = regexp.MustCompile(`^[A-Za-z0-9_-]{8,64}$`)
// ValidUIPrefix — годится ли строка в секретный префикс панели (UI_PREFIX).
func ValidUIPrefix(s string) bool { return reUIPrefix.MatchString(s) }
// 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)})
// Секретный путь старой панели: по нему ходят её закладки и внешний API через ретранслятор.
// Свой префикс новая панель не меняет (иначе потеряла бы открытую вкладку) — принимает оба.
if px := b.Str("ui_prefix"); ValidUIPrefix(px) {
p.Env = append(p.Env, [2]string{"UI_PREFIX_MOVED", px})
}
// Заголовку 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))
}