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))
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
package migrate
|
||||
|
||||
import (
|
||||
"archive/tar"
|
||||
"bytes"
|
||||
"compress/gzip"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/maeneko/forgetting/internal/jsjson"
|
||||
)
|
||||
|
||||
func testDB(t *testing.T, file string) {
|
||||
t.Helper()
|
||||
db, err := OpenDB(file)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer db.Close()
|
||||
for _, q := range []string{
|
||||
"CREATE TABLE sub_meta (key TEXT PRIMARY KEY, value TEXT NOT NULL)",
|
||||
"INSERT INTO sub_meta VALUES ('panel_id', 'abcdef123456')",
|
||||
"CREATE TABLE nodes (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, pub TEXT, join_hash BLOB, join_expires INTEGER, created_at INTEGER NOT NULL, last_seen INTEGER, version TEXT)",
|
||||
"CREATE TABLE master_keys (id INTEGER PRIMARY KEY)",
|
||||
"INSERT INTO master_keys VALUES (1)",
|
||||
} {
|
||||
if _, err := db.Exec(q); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Архив → раскладка: всё, что зашито в ссылки и у нод, едет как есть; база — целостным
|
||||
// снимком, в котором новая панель заводит ноду #0.
|
||||
func TestBundleRoundTrip(t *testing.T) {
|
||||
src, dst := t.TempDir(), t.TempDir()
|
||||
paths := func(dir string) Paths {
|
||||
return PathsFrom(func(env string) string { return filepath.Join(dir, strings.ToLower(env)) })
|
||||
}
|
||||
from, to := paths(src), paths(dst)
|
||||
testDB(t, from["ui.db"])
|
||||
for name, v := range map[string]string{"sub_sign.key": "sign", "sub_tls.key": "tlskey", "sub_tls.crt": "tlscrt", "node_tls.key": "nodekey"} {
|
||||
os.WriteFile(from[name], []byte(v), 0o600)
|
||||
}
|
||||
os.WriteFile(to["sub_sign.key"], []byte("прежний"), 0o600)
|
||||
|
||||
db, _ := OpenDB(from["ui.db"])
|
||||
var buf bytes.Buffer
|
||||
err := Write(&buf, db, from, jsjson.Object{{Key: "server_ip", Val: "198.51.100.1"}, {Key: "sub_port", Val: 40123},
|
||||
{Key: "sub_tls", Val: true}, {Key: "node_port", Val: 40500}})
|
||||
db.Close()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
b, err := Read(&buf)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer b.Close()
|
||||
if b.Str("panel_id") != "abcdef123456" || b.Num("sub_port") != 40123 || b.Has["node_tls.crt"] {
|
||||
t.Errorf("meta %s, has %v", jsjson.MustMarshal(b.Meta), b.Has)
|
||||
}
|
||||
if n, err := PanelData(to["ui.db"]); err != nil || n != 0 {
|
||||
t.Errorf("PanelData(нет файла) = %d, %v", n, err)
|
||||
}
|
||||
|
||||
plan, err := NewPlan(b, func(string) int { return 0 }, 33000, "203.0.113.7")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got := regexp.MustCompile(`"MOVED_IN_AT","\d+"`).ReplaceAllString(string(jsjson.MustMarshal(plan.Env)), `"MOVED_IN_AT","…"`)
|
||||
if got != `[["SUB_PORT","40123"],["NODE_PORT","40500"],["SUB_HOST","203.0.113.7"],["SUB_TLS","on"],["MOVED_IN_AT","…"],["RELAY_FROM","198.51.100.1"]]` {
|
||||
t.Errorf("env: %s", got)
|
||||
}
|
||||
if _, err := NewPlan(b, func(string) int { return 0 }, 40500, "h"); err == nil {
|
||||
t.Error("UI_PORT = порт хаба: ожидали ошибку")
|
||||
}
|
||||
|
||||
if err := Install(b, to); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for name, want := range map[string]string{"sub_sign.key": "sign", "sub_tls.crt": "tlscrt", "node_tls.key": "nodekey"} {
|
||||
if raw, _ := os.ReadFile(to[name]); string(raw) != want {
|
||||
t.Errorf("%s: %q", name, raw)
|
||||
}
|
||||
}
|
||||
if baks, _ := filepath.Glob(to["sub_sign.key"] + ".bak-*"); len(baks) != 1 {
|
||||
t.Errorf("прежний ключ не отложен: %v", baks)
|
||||
}
|
||||
if n, _ := PanelData(to["ui.db"]); n != 1 {
|
||||
t.Errorf("PanelData(снимок) = %d", n)
|
||||
}
|
||||
if err := AddNodeZero(to["ui.db"], "VPN\n<script>", []byte("0123456789abcdef")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
db, _ = OpenDB(to["ui.db"])
|
||||
var name string
|
||||
db.QueryRow("SELECT name FROM nodes WHERE id = 0").Scan(&name)
|
||||
db.Close()
|
||||
if name != "VPN" {
|
||||
t.Errorf("нода #0: %q", name)
|
||||
}
|
||||
if err := AddNodeZero(to["ui.db"], "VPN", []byte("x")); err == nil {
|
||||
t.Error("вторая нода #0: ожидали ошибку")
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadRejectsForeignEntries(t *testing.T) {
|
||||
for name, entries := range map[string][]string{
|
||||
"лишний файл": {"meta.json", "ui.db", "../../etc/passwd"},
|
||||
"без базы": {"meta.json"},
|
||||
"повтор": {"meta.json", "ui.db", "ui.db"},
|
||||
} {
|
||||
var buf bytes.Buffer
|
||||
gz := gzip.NewWriter(&buf)
|
||||
tw := tar.NewWriter(gz)
|
||||
for _, e := range entries {
|
||||
data := []byte("x")
|
||||
if e == "meta.json" {
|
||||
data = []byte(`{"v":1}`)
|
||||
}
|
||||
tw.WriteHeader(&tar.Header{Name: e, Mode: 0o600, Size: int64(len(data))})
|
||||
tw.Write(data)
|
||||
}
|
||||
tw.Close()
|
||||
gz.Close()
|
||||
if b, err := Read(&buf); err == nil {
|
||||
b.Close()
|
||||
t.Errorf("%s: архив принят", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestJoinString(t *testing.T) {
|
||||
s := JoinString("203.0.113.7", 40500, strings.Repeat("A", 43), 0, []byte("0123456789abcdef"), 33100, 40123)
|
||||
raw, _ := b64url.DecodeString(strings.TrimPrefix(s, "awgjoin://"))
|
||||
// Поля сверх v1 — в конце: install.sh и старые CLI их не замечают.
|
||||
if want := `{"v":1,"h":"203.0.113.7","p":40500,"pin":"` + strings.Repeat("A", 43) + `","n":0,"s":"MDEyMzQ1Njc4OWFiY2RlZg","ui":33100,"sub":40123}`; string(raw) != want {
|
||||
t.Errorf("join: %s", raw)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCodeAndSeal(t *testing.T) {
|
||||
c := Code{Host: "2001:db8::7", Port: 33000, Key: NewKey()}
|
||||
got, err := ParseCode(" " + c.String() + "\n")
|
||||
if err != nil || got.Host != c.Host || got.Port != c.Port || !bytes.Equal(got.Key, c.Key) {
|
||||
t.Fatalf("ParseCode: %+v %v", got, err)
|
||||
}
|
||||
if u := got.URL(); u != "http://[2001:db8::7]:33000/migrate/v1/import" {
|
||||
t.Errorf("URL: %s", u)
|
||||
}
|
||||
for _, bad := range []string{"", "awgmove://", "awgjoin://e30", CodePrefix + "e30",
|
||||
Code{Host: "h/../x", Port: 1, Key: c.Key}.String(), Code{Host: "h", Port: 0, Key: c.Key}.String(), Code{Host: "h", Port: 1, Key: []byte("short")}.String()} {
|
||||
if _, err := ParseCode(bad); err == nil {
|
||||
t.Errorf("ParseCode(%q): ожидали ошибку", bad)
|
||||
}
|
||||
}
|
||||
|
||||
box := SealRequest(c.Key, []byte("архив"))
|
||||
if plain, err := OpenRequest(c.Key, box); err != nil || string(plain) != "архив" {
|
||||
t.Errorf("OpenRequest: %q %v", plain, err)
|
||||
}
|
||||
if _, err := OpenResponse(c.Key, box); err == nil {
|
||||
t.Error("запрос открылся как ответ")
|
||||
}
|
||||
if _, err := OpenRequest(NewKey(), box); err == nil {
|
||||
t.Error("открылся чужим ключом")
|
||||
}
|
||||
box[len(box)-1] ^= 1
|
||||
if _, err := OpenRequest(c.Key, box); err == nil {
|
||||
t.Error("открылся повреждённый")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
package migrate
|
||||
|
||||
// Перенос из панели в панель (вкладка «Перенос»): новая панель выдаёт одноразовый код
|
||||
// awgmove://, старая шлёт по нему архив на её порт панели. Порт панели обычно — голый HTTP,
|
||||
// а в архиве ключ подписи, поэтому и запрос, и ответ зашифрованы AES-256-GCM ключом из кода:
|
||||
// без кода архив не прочесть и не подделать, а верный тег и есть доказательство, что код знают.
|
||||
|
||||
import (
|
||||
"crypto/aes"
|
||||
"crypto/cipher"
|
||||
"crypto/rand"
|
||||
"errors"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/maeneko/forgetting/internal/jsjson"
|
||||
)
|
||||
|
||||
const (
|
||||
CodePrefix = "awgmove://"
|
||||
keyLen = 32
|
||||
// Разные AAD — ответ нельзя выдать за запрос и наоборот.
|
||||
aadRequest = "awgmove-v1 request"
|
||||
aadResponse = "awgmove-v1 response"
|
||||
)
|
||||
|
||||
// Code — что старая панель узнаёт из кода: куда слать и чем шифровать.
|
||||
type Code struct {
|
||||
Host string
|
||||
Port int
|
||||
Key []byte
|
||||
}
|
||||
|
||||
func NewKey() []byte {
|
||||
k := make([]byte, keyLen)
|
||||
rand.Read(k)
|
||||
return k
|
||||
}
|
||||
|
||||
func (c Code) String() string {
|
||||
return CodePrefix + b64url.EncodeToString(jsjson.MustMarshal(jsjson.Object{
|
||||
{Key: "v", Val: Version}, {Key: "h", Val: c.Host}, {Key: "p", Val: c.Port}, {Key: "k", Val: b64url.EncodeToString(c.Key)}}))
|
||||
}
|
||||
|
||||
func ParseCode(s string) (Code, error) {
|
||||
bad := errors.New("код переноса неверного формата — скопируйте его целиком со вкладки «Перенос» новой панели")
|
||||
s = strings.TrimSpace(s)
|
||||
if !strings.HasPrefix(s, CodePrefix) {
|
||||
return Code{}, bad
|
||||
}
|
||||
raw, err := b64url.DecodeString(s[len(CodePrefix):])
|
||||
if err != nil {
|
||||
return Code{}, bad
|
||||
}
|
||||
v, err := jsjson.Decode(raw)
|
||||
o, ok := v.(jsjson.Object)
|
||||
if err != nil || !ok || o.Get2("v") != float64(Version) {
|
||||
return Code{}, bad
|
||||
}
|
||||
host, _ := o.Get2("h").(string)
|
||||
port, _ := o.Get2("p").(float64)
|
||||
k, _ := o.Get2("k").(string)
|
||||
key, err := b64url.DecodeString(k)
|
||||
if host == "" || len(host) > 253 || strings.ContainsAny(host, "/?#@ ") || port <= 0 || port > 65535 ||
|
||||
port != float64(int(port)) || err != nil || len(key) != keyLen {
|
||||
return Code{}, bad
|
||||
}
|
||||
return Code{Host: host, Port: int(port), Key: key}, nil
|
||||
}
|
||||
|
||||
// URL — куда слать архив.
|
||||
func (c Code) URL() string {
|
||||
h := c.Host
|
||||
if strings.Contains(h, ":") && !strings.HasPrefix(h, "[") {
|
||||
h = "[" + h + "]"
|
||||
}
|
||||
return "http://" + h + ":" + strconv.Itoa(c.Port) + "/migrate/v1/import"
|
||||
}
|
||||
|
||||
func gcm(key []byte) (cipher.AEAD, error) {
|
||||
blk, err := aes.NewCipher(key)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return cipher.NewGCM(blk)
|
||||
}
|
||||
|
||||
func seal(key []byte, aad string, plain []byte) []byte {
|
||||
g, err := gcm(key)
|
||||
if err != nil {
|
||||
panic(err) // ключ всегда 32 байта
|
||||
}
|
||||
nonce := make([]byte, g.NonceSize())
|
||||
rand.Read(nonce)
|
||||
return g.Seal(nonce, nonce, plain, []byte(aad))
|
||||
}
|
||||
|
||||
var ErrSeal = errors.New("не расшифровать: код не тот или данные повреждены")
|
||||
|
||||
func open(key []byte, aad string, box []byte) ([]byte, error) {
|
||||
g, err := gcm(key)
|
||||
if err != nil || len(box) < g.NonceSize() {
|
||||
return nil, ErrSeal
|
||||
}
|
||||
plain, err := g.Open(nil, box[:g.NonceSize()], box[g.NonceSize():], []byte(aad))
|
||||
if err != nil {
|
||||
return nil, ErrSeal
|
||||
}
|
||||
return plain, nil
|
||||
}
|
||||
|
||||
func SealRequest(key, plain []byte) []byte { return seal(key, aadRequest, plain) }
|
||||
func OpenRequest(key, box []byte) ([]byte, error) { return open(key, aadRequest, box) }
|
||||
func SealResponse(key, plain []byte) []byte { return seal(key, aadResponse, plain) }
|
||||
func OpenResponse(key, box []byte) ([]byte, error) { return open(key, aadResponse, box) }
|
||||
Reference in New Issue
Block a user