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:
+50
-19
@@ -17,6 +17,7 @@ import (
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/maeneko/forgetting/internal/envfile"
|
||||
"github.com/maeneko/forgetting/internal/jsjson"
|
||||
)
|
||||
|
||||
@@ -181,6 +182,12 @@ func New() *CLI {
|
||||
{"SUB_HOST", env("SUB_HOST", "")},
|
||||
// Приём нод (core): без NODE_PORT хаб выключен. LOCAL_NODE=off — core без своего VPN.
|
||||
{"NODE_PORT", env("NODE_PORT", "")},
|
||||
// IP ретрансляторов (старый сервер после переноса): им — доверие к заголовку PROXY.
|
||||
{"RELAY_FROM", env("RELAY_FROM", "")},
|
||||
// Когда панель приняла перенос: с этого момента считаются клиенты, узнавшие новый адрес.
|
||||
{"MOVED_IN_AT", env("MOVED_IN_AT", "")},
|
||||
// Вкладка «Перенос» пишет сюда порты, PANEL_MOVED и RELAY_FROM.
|
||||
{"CLI_ENV_FILE", cliEnv},
|
||||
// Часовой аудит пиров на всех серверах — только у core.
|
||||
{"AUDIT", audit},
|
||||
{"METRICS_PEERS", env("METRICS_PEERS", "off")},
|
||||
@@ -196,6 +203,7 @@ func New() *CLI {
|
||||
env: [][2]string{
|
||||
{"CORE_HOST", env("CORE_HOST", "")},
|
||||
{"CORE_PORT", env("CORE_PORT", "")},
|
||||
{"CORE_ENDPOINT", env("CORE_ENDPOINT", "")},
|
||||
{"CORE_PIN", env("CORE_PIN", "")},
|
||||
{"NODE_ID", env("NODE_ID", "")},
|
||||
{"NODE_KEY_FILE", env("NODE_KEY_FILE", "/etc/amnezia/amneziawg/node.key")},
|
||||
@@ -206,6 +214,12 @@ func New() *CLI {
|
||||
},
|
||||
pidFile: "/tmp/awg-agent.pid", logFile: filepath.Join(c.logs, "awg-agent.log"),
|
||||
},
|
||||
// Ретранслятор на сервере, с которого переехала панель: прежние порты → новая панель.
|
||||
"relay": {
|
||||
name: "relay", label: "awg-relay", cwd: c.root,
|
||||
env: [][2]string{{"RELAY", env("RELAY", "")}},
|
||||
pidFile: "/tmp/awg-relay.pid", logFile: filepath.Join(c.logs, "awg-relay.log"),
|
||||
},
|
||||
}
|
||||
|
||||
// Роль установки. node — VPN-сервер без панели (есть CORE_HOST, его пишет `join`); core с
|
||||
@@ -224,9 +238,29 @@ func New() *CLI {
|
||||
default:
|
||||
c.all = []string{"awgctrl", "ui"}
|
||||
}
|
||||
// Панель переехала (`migrate export`): здесь её больше не запускаем — вторая копия ui.db
|
||||
// разошлась бы с перенесённой.
|
||||
if c.panelMoved() {
|
||||
c.all = without(c.all, "ui")
|
||||
}
|
||||
if os.Getenv("RELAY") != "" {
|
||||
c.all = append(c.all, "relay")
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
func (c *CLI) panelMoved() bool { return strings.ToLower(os.Getenv("PANEL_MOVED")) == "on" }
|
||||
|
||||
func without(s []string, v string) []string {
|
||||
var out []string
|
||||
for _, x := range s {
|
||||
if x != v {
|
||||
out = append(out, x)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (c *CLI) envOf(name string) string {
|
||||
for _, kv := range c.svc[name].env {
|
||||
if kv[0] == "PORT" {
|
||||
@@ -285,6 +319,9 @@ func (c *CLI) pidMatches(name string, pid int) bool {
|
||||
if len(args) >= 2 && filepath.Base(args[0]) == filepath.Base(c.exe) && args[1] == s.name {
|
||||
return true
|
||||
}
|
||||
if s.legacy == "" {
|
||||
return false
|
||||
}
|
||||
// tsx запускается из node_modules сервиса: путь различает index.ts awg-ctrl и awg-agent.
|
||||
return strings.Contains(string(raw), s.legacy) && strings.Contains(string(raw), filepath.Base(s.cwd))
|
||||
}
|
||||
@@ -476,10 +513,14 @@ func (c *CLI) startAll() {
|
||||
}
|
||||
return
|
||||
}
|
||||
// Ретранслятор от awg-ctrl не зависит: подписка и панель через него не должны ждать VPN.
|
||||
if contains(c.all, "relay") {
|
||||
c.start("relay")
|
||||
}
|
||||
c.start("awgctrl")
|
||||
var rest, labels []string
|
||||
for _, n := range c.all {
|
||||
if n != "awgctrl" {
|
||||
if n != "awgctrl" && n != "relay" {
|
||||
rest = append(rest, n)
|
||||
labels = append(labels, c.svc[n].label)
|
||||
}
|
||||
@@ -515,23 +556,9 @@ func contains(s []string, v string) bool {
|
||||
|
||||
// ── cli.env ─────────────────────────────────────────────────────────────────
|
||||
|
||||
func updateEnvVar(content, key, value string) string {
|
||||
re := regexp.MustCompile(`(?m)^` + regexp.QuoteMeta(key) + `=.*$`)
|
||||
if loc := re.FindStringIndex(content); loc != nil {
|
||||
return content[:loc[0]] + key + "=" + value + content[loc[1]:]
|
||||
}
|
||||
return strings.TrimRightFunc(content, func(r rune) bool { return r == ' ' || r == '\n' || r == '\t' || r == '\r' }) + "\n" + key + "=" + value + "\n"
|
||||
}
|
||||
func updateEnvVar(content, key, value string) string { return envfile.Update(content, key, value) }
|
||||
|
||||
func removeEnvVar(content, key string) string {
|
||||
var kept []string
|
||||
for _, l := range strings.Split(content, "\n") {
|
||||
if !strings.HasPrefix(l, key+"=") {
|
||||
kept = append(kept, l)
|
||||
}
|
||||
}
|
||||
return strings.Join(kept, "\n")
|
||||
}
|
||||
func removeEnvVar(content, key string) string { return envfile.Remove(content, key) }
|
||||
|
||||
func (c *CLI) readEnvFile() string {
|
||||
raw, _ := os.ReadFile(c.envFile)
|
||||
@@ -568,10 +595,10 @@ func (c *CLI) confirmYes(q string) bool {
|
||||
|
||||
// ── Точка входа ─────────────────────────────────────────────────────────────
|
||||
|
||||
const usage = "Usage: cli <start|stop|restart|status|credentials [user|pass]|join <awgjoin://…> [--no-panel]|unjoin|peers [drop <область>]|monitor <install|status|remove>>"
|
||||
const usage = "Usage: cli <start|stop|restart|status|credentials [user|pass]|join <awgjoin://…> [--no-panel]|unjoin|rehome <хост[:порт]>|peers [drop <область>]|monitor <install|status|remove>|migrate <export [файл]|import <файл>|cancel>>"
|
||||
|
||||
// Commands — команды оператора; main передаёт сюда всё, что не роль сервиса.
|
||||
var Commands = []string{"start", "stop", "restart", "status", "credentials", "join", "unjoin", "peers", "monitor"}
|
||||
var Commands = []string{"start", "stop", "restart", "status", "credentials", "join", "unjoin", "rehome", "peers", "monitor", "migrate"}
|
||||
|
||||
// Main выполняет команду CLI (args — без имени бинарника). Пусто — интерактивное меню.
|
||||
func Main(args []string) {
|
||||
@@ -608,6 +635,10 @@ func Main(args []string) {
|
||||
c.joinCore(sub, hasSub, a2, hasA2)
|
||||
case "unjoin":
|
||||
c.unjoinCore()
|
||||
case "rehome":
|
||||
c.rehome(sub, hasSub)
|
||||
case "migrate":
|
||||
c.migrateCmd(sub, a2, hasA2)
|
||||
case "peers":
|
||||
c.peersCmd(sub, hasSub, a2, hasA2)
|
||||
case "monitor":
|
||||
|
||||
+42
-3
@@ -6,6 +6,7 @@ import (
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
@@ -61,6 +62,10 @@ func (c *CLI) joinCore(link string, hasLink bool, flag string, hasFlag bool) {
|
||||
sec, sok := j.Get2("s").(string)
|
||||
port, pok := isPosInt(j.Get2("p"), 65536)
|
||||
id, iok := isPosInt(j.Get2("n"), 9007199254740992)
|
||||
// Нода #0 — этот же сервер после переноса панели (`migrate import` выдаёт строку с n = 0).
|
||||
if n, ok := j.Get2("n").(float64); ok && n == 0 {
|
||||
id, iok = 0, true
|
||||
}
|
||||
if j.Get2("v") != 1.0 || !hok || !reJoinHost.MatchString(h) || !pok || !pinok || !reJoinPin.MatchString(pin) ||
|
||||
!iok || !sok || !reJoinSec.MatchString(sec) {
|
||||
errorf(" Ссылка неверного формата или от другой версии панели")
|
||||
@@ -71,7 +76,8 @@ func (c *CLI) joinCore(link string, hasLink bool, flag string, hasFlag bool) {
|
||||
}
|
||||
|
||||
hasPanel := os.Getenv("UI_PASS") != ""
|
||||
keepPanel := !hasFlag && hasPanel
|
||||
moved := c.panelMoved()
|
||||
keepPanel := !hasFlag && hasPanel && !moved
|
||||
content := c.readEnvFile()
|
||||
np := "off"
|
||||
if keepPanel {
|
||||
@@ -81,11 +87,18 @@ func (c *CLI) joinCore(link string, hasLink bool, flag string, hasFlag bool) {
|
||||
{"JOIN_SECRET", sec}, {"NODE_PANEL", np}} {
|
||||
content = updateEnvVar(content, kv[0], kv[1])
|
||||
}
|
||||
content = removeEnvVar(content, "CORE_ENDPOINT") // адрес прежней панели к новой не относится
|
||||
relay := ""
|
||||
if moved {
|
||||
relay = relayRoutes(h, port, j)
|
||||
content = updateEnvVar(content, "RELAY", relay)
|
||||
}
|
||||
c.writeEnvFile(content)
|
||||
|
||||
// Панель останавливается, а пиры её устройств остаются на awg-ctrl — управлять ими больше
|
||||
// некому. Решает оператор; спрашиваем до остановки, снимаем после (иначе панель вернёт их).
|
||||
dropPeers := !keepPanel && hasPanel && c.askDropPanelPeers()
|
||||
// После переноса этими пирами управляет новая панель: снимать нечего.
|
||||
dropPeers := !keepPanel && hasPanel && !moved && c.askDropPanelPeers()
|
||||
if !keepPanel {
|
||||
c.stop("ui")
|
||||
}
|
||||
@@ -99,9 +112,35 @@ func (c *CLI) joinCore(link string, hasLink bool, flag string, hasFlag bool) {
|
||||
tail = " · своя панель остановлена"
|
||||
}
|
||||
fmt.Printf("\n %s Настроено: core %s:%d, нода #%d%s\n", green("✓"), h, port, id, tail)
|
||||
if relay != "" {
|
||||
fmt.Println(" " + dim("Ретранслятор прежних портов на новую панель: "+relay))
|
||||
}
|
||||
c.restartFresh()
|
||||
}
|
||||
|
||||
// relayRoutes — прежние порты этого сервера → новая панель. Подписка и хаб там на тех же
|
||||
// портах (так их ставит `migrate import`), порт панели — из поля ui join-строки.
|
||||
func relayRoutes(host string, hubPort int64, j jsjson.Object) string {
|
||||
hp := func(p any) string { return net.JoinHostPort(host, fmt.Sprint(p)) }
|
||||
var routes []string
|
||||
add := func(local int, target string) {
|
||||
if local > 0 {
|
||||
routes = append(routes, fmt.Sprintf("%d=%s", local, target))
|
||||
}
|
||||
}
|
||||
sub := envPort("SUB_PORT")
|
||||
if p, ok := isPosInt(j.Get2("sub"), 65536); ok {
|
||||
add(sub, hp(p))
|
||||
} else {
|
||||
add(sub, hp(sub))
|
||||
}
|
||||
add(envPort("NODE_PORT"), hp(hubPort))
|
||||
if p, ok := isPosInt(j.Get2("ui"), 65536); ok {
|
||||
add(envPort("UI_PORT"), hp(p))
|
||||
}
|
||||
return strings.Join(routes, ",")
|
||||
}
|
||||
|
||||
func (c *CLI) unjoinCore() {
|
||||
if !c.isNode {
|
||||
fmt.Println(" Эта установка не подключена к core")
|
||||
@@ -109,7 +148,7 @@ func (c *CLI) unjoinCore() {
|
||||
}
|
||||
c.stop("agent")
|
||||
content := c.readEnvFile()
|
||||
for _, k := range []string{"CORE_HOST", "CORE_PORT", "CORE_PIN", "NODE_ID", "JOIN_SECRET", "NODE_PANEL"} {
|
||||
for _, k := range []string{"CORE_HOST", "CORE_PORT", "CORE_PIN", "NODE_ID", "JOIN_SECRET", "NODE_PANEL", "CORE_ENDPOINT"} {
|
||||
content = removeEnvVar(content, k)
|
||||
}
|
||||
c.writeEnvFile(content)
|
||||
|
||||
@@ -0,0 +1,292 @@
|
||||
package cli
|
||||
|
||||
// `migrate export|import|cancel` — перенос панели с сервера «панель + VPN» на отдельную панель
|
||||
// без VPN; архив и раскладка — internal/migrate (их же зовёт вкладка «Перенос»):
|
||||
//
|
||||
// старый: migrate export → архив: ui.db, ключи подписки и хаба, порты
|
||||
// новый: migrate import → раскладывает архив, заводит ноду #0 и печатает join-строку
|
||||
// старый: join '<строка>' → становится нодой #0 и поднимает ретранслятор прежних портов
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"fmt"
|
||||
"net"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/maeneko/forgetting/internal/jsjson"
|
||||
"github.com/maeneko/forgetting/internal/migrate"
|
||||
)
|
||||
|
||||
func envPort(k string) int {
|
||||
n, err := strconv.Atoi(strings.TrimSpace(os.Getenv(k)))
|
||||
if err != nil || n <= 0 || n > 65535 {
|
||||
return 0
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
func (c *CLI) paths() migrate.Paths {
|
||||
return migrate.PathsFrom(func(env string) string { return c.svcEnv("ui", env) })
|
||||
}
|
||||
|
||||
func (c *CLI) migrateCmd(sub, arg string, hasArg bool) {
|
||||
switch sub {
|
||||
case "export":
|
||||
c.migrateExport(arg, hasArg)
|
||||
case "import":
|
||||
if !hasArg {
|
||||
errorf(" Usage: awg-ctrl migrate import <архив>")
|
||||
os.Exit(1)
|
||||
}
|
||||
c.migrateImport(arg)
|
||||
case "cancel":
|
||||
c.migrateCancel()
|
||||
default:
|
||||
errorf(" Usage: awg-ctrl migrate <export [файл]|import <архив>|cancel>")
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
// ── export (старый сервер) ─────────────────────────────────────────────────
|
||||
|
||||
func (c *CLI) migrateExport(out string, hasOut bool) {
|
||||
if c.isNode {
|
||||
errorf(" Это нода: её панель — на core, переносить отсюда нечего")
|
||||
os.Exit(1)
|
||||
}
|
||||
if !contains(c.svcList(), "awgctrl") {
|
||||
errorf(" У этой панели нет своего VPN (LOCAL_NODE=off) — перенос рассчитан на сервер «панель + VPN»")
|
||||
os.Exit(1)
|
||||
}
|
||||
dbFile := c.paths()["ui.db"]
|
||||
if !exists(dbFile) {
|
||||
errorf(" Нет базы панели: %s", dbFile)
|
||||
os.Exit(1)
|
||||
}
|
||||
if !hasOut {
|
||||
out = fmt.Sprintf("forgetting-migrate-%s.tar.gz", time.Now().Format("20060102-150405"))
|
||||
}
|
||||
|
||||
fmt.Println()
|
||||
fmt.Println(" Перенос панели на отдельный сервер. Сейчас:")
|
||||
fmt.Println(" · панель и подписка sen:// здесь остановятся и больше не запустятся сами;")
|
||||
fmt.Println(" · VPN продолжит работать: пиры и awg-ctrl не трогаются;")
|
||||
fmt.Println(" · архив с базой панели и ключами подписки — " + bold(out))
|
||||
if !c.confirmYes(" Продолжить?") {
|
||||
return
|
||||
}
|
||||
|
||||
c.stop("ui")
|
||||
// Флаг — до снимка: перезапуск между ними поднял бы панель с базой, которая уже уехала.
|
||||
content := updateEnvVar(c.readEnvFile(), "PANEL_MOVED", "on")
|
||||
if err := c.writeEnvFile(content); err != nil {
|
||||
errorf(" Не удалось записать %s: %v", c.envFile, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
if err := c.writeExport(out, dbFile); err != nil {
|
||||
errorf(" Архив не создан: %v", err)
|
||||
c.writeEnvFile(removeEnvVar(c.readEnvFile(), "PANEL_MOVED"))
|
||||
os.Remove(out)
|
||||
c.start("ui")
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
fmt.Printf("\n %s Архив: %s %s\n", green("✓"), out, dim("(в нём ключ подписи — храните как пароль, после переноса удалите)"))
|
||||
fmt.Println("\n Дальше:")
|
||||
fmt.Println(" 1. Новая панель: install.sh, роль «только панель»")
|
||||
fmt.Println(" 2. Скопируйте архив туда: " + dim("scp "+out+" root@<новая-панель>:"))
|
||||
fmt.Println(" 3. На новой панели: " + bold("awg-ctrl migrate import "+filepath.Base(out)))
|
||||
fmt.Println(" 4. Здесь — строку, которую она напечатает: " + bold("awg-ctrl join '<строка>'"))
|
||||
fmt.Println(" " + dim("Передумали до шага 4: awg-ctrl migrate cancel"))
|
||||
}
|
||||
|
||||
// svcList — сервисы роли без поправки на PANEL_MOVED и ретранслятор.
|
||||
func (c *CLI) svcList() []string {
|
||||
return without(without(c.all, "relay"), "ui")
|
||||
}
|
||||
|
||||
func (c *CLI) writeExport(out, dbFile string) error {
|
||||
db, err := migrate.OpenDB(dbFile)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer db.Close()
|
||||
name, ip := "VPN", os.Getenv("SERVER_IP")
|
||||
if h := fetchHealth("http://127.0.0.1:" + c.envOf("awgctrl") + "/health"); h != nil {
|
||||
name = strOr(get(h, "server"), name)
|
||||
if ip == "" {
|
||||
ip = strOr(get(h, "ip"), "")
|
||||
}
|
||||
}
|
||||
f, err := os.OpenFile(out, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
err = migrate.Write(f, db, c.paths(), exportMeta(c.version, name, ip, envPort("UI_PORT"), envPort("SUB_PORT"),
|
||||
strings.ToLower(env("SUB_TLS", "off")) == "on", envPort("NODE_PORT")))
|
||||
if cerr := f.Close(); err == nil {
|
||||
err = cerr
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// exportMeta — meta.json архива (v и panel_id добавляет migrate.Write).
|
||||
func exportMeta(version, name, ip string, uiPort, subPort int, subTLS bool, nodePort int) jsjson.Object {
|
||||
return jsjson.Object{
|
||||
{Key: "version", Val: version}, {Key: "created_at", Val: time.Now().Unix()},
|
||||
{Key: "server_name", Val: name}, {Key: "server_ip", Val: ip},
|
||||
{Key: "ui_port", Val: uiPort}, {Key: "sub_port", Val: subPort}, {Key: "sub_tls", Val: subTLS}, {Key: "node_port", Val: nodePort},
|
||||
}
|
||||
}
|
||||
|
||||
// ── import (новая панель) ──────────────────────────────────────────────────
|
||||
|
||||
func (c *CLI) migrateImport(file string) {
|
||||
if c.isNode {
|
||||
errorf(" Это нода — импортировать панель нужно на core")
|
||||
os.Exit(1)
|
||||
}
|
||||
if contains(c.svcList(), "awgctrl") {
|
||||
errorf(" У этой панели свой VPN (LOCAL_NODE=on). Импорт — на панель без VPN: install.sh, роль «только панель»")
|
||||
os.Exit(1)
|
||||
}
|
||||
f, err := os.Open(file)
|
||||
if err != nil {
|
||||
errorf(" %v", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
b, err := migrate.Read(f)
|
||||
f.Close()
|
||||
if err != nil {
|
||||
errorf(" %v", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
defer b.Close()
|
||||
|
||||
paths := c.paths()
|
||||
if n, err := migrate.PanelData(paths["ui.db"]); err != nil || n > 0 {
|
||||
errorf(" На этой панели уже есть данные (%s) — импорт их затёр бы. Нужна чистая панель.", paths["ui.db"])
|
||||
os.Exit(1)
|
||||
}
|
||||
host := strings.TrimSpace(os.Getenv("SUB_HOST"))
|
||||
for host == "" || !reJoinHost.MatchString(host) {
|
||||
host = strings.TrimSpace(c.ask(" Публичный адрес этой панели (IP или домен): "))
|
||||
if host == "" {
|
||||
return
|
||||
}
|
||||
}
|
||||
plan, err := migrate.NewPlan(b, envPort, envPort("UI_PORT"), host)
|
||||
if err != nil {
|
||||
errorf(" %v (%s)", err, c.envFile)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
fmt.Println()
|
||||
from := b.Str("server_ip")
|
||||
if v := b.Str("version"); v != "" {
|
||||
from += ", версия " + v
|
||||
}
|
||||
fmt.Printf(" Импорт панели «%s» (%s)\n", b.Str("server_name"), from)
|
||||
fmt.Printf(" · подписка :%d, хаб нод :%d, адрес панели %s\n", plan.SubPort, plan.NodePort, host)
|
||||
fmt.Println(" · прежние файлы панели уйдут в *.bak-<время>")
|
||||
if !b.Has["sub_sign.key"] {
|
||||
fmt.Println(" · " + dim("ключа подписи в архиве нет — на старом сервере подписка не была настроена"))
|
||||
}
|
||||
if !c.confirmYes(" Продолжить?") {
|
||||
return
|
||||
}
|
||||
|
||||
c.stop("ui")
|
||||
secret := make([]byte, 16)
|
||||
rand.Read(secret)
|
||||
if err := migrate.Install(b, paths); err != nil {
|
||||
errorf(" %v", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
if err := migrate.AddNodeZero(paths["ui.db"], b.Str("server_name"), secret); err != nil {
|
||||
errorf(" ui.db: %v", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
content := c.readEnvFile()
|
||||
for _, kv := range plan.Env {
|
||||
content = updateEnvVar(content, kv[0], kv[1])
|
||||
}
|
||||
if err := c.writeEnvFile(content); err != nil {
|
||||
errorf(" Не удалось записать %s: %v", c.envFile, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
c.restartFresh()
|
||||
|
||||
// Сертификат хаба приехал в архиве, а нет — awg-ui создаёт его при старте.
|
||||
var pin string
|
||||
for range 50 {
|
||||
if pin, err = migrate.CertPin(paths["node_tls.crt"]); err == nil {
|
||||
break
|
||||
}
|
||||
time.Sleep(200 * time.Millisecond)
|
||||
}
|
||||
if err != nil {
|
||||
errorf(" Нет сертификата хаба %s — проверьте лог awg-ui и выдайте строку в панели (сервер #0)", paths["node_tls.crt"])
|
||||
os.Exit(1)
|
||||
}
|
||||
join := migrate.JoinString(host, plan.NodePort, pin, 0, secret, plan.UIPort, plan.SubPort)
|
||||
fmt.Printf("\n %s Панель перенесена. На старом сервере выполните (строка действует час):\n\n", green("✓"))
|
||||
fmt.Println(" awg-ctrl join '" + join + "'")
|
||||
fmt.Println()
|
||||
fmt.Println(" " + dim("Старый сервер станет нодой #0 и будет пересылать сюда прежние порты — ссылки sen://, ноды и"))
|
||||
fmt.Println(" " + dim("внешний API продолжат работать. Логин и пароль панели — этой установки, а не старой."))
|
||||
}
|
||||
|
||||
// ── cancel (старый сервер) ─────────────────────────────────────────────────
|
||||
|
||||
func (c *CLI) migrateCancel() {
|
||||
if !c.panelMoved() {
|
||||
fmt.Println(" Переноса не было: панель на месте")
|
||||
return
|
||||
}
|
||||
if c.isNode {
|
||||
errorf(" Сервер уже нода новой панели — база там, и вернуть её сюда так нельзя.")
|
||||
errorf(" Если всё же нужно: awg-ctrl unjoin, затем верните ui.db с новой панели вручную.")
|
||||
os.Exit(1)
|
||||
}
|
||||
content := c.readEnvFile()
|
||||
for _, k := range []string{"PANEL_MOVED", "RELAY"} {
|
||||
content = removeEnvVar(content, k)
|
||||
}
|
||||
c.writeEnvFile(content)
|
||||
fmt.Printf(" %s Перенос отменён, панель запускается здесь снова\n", green("✓"))
|
||||
fmt.Println(" " + dim("Если архив уже импортирован на новую панель — не запускайте там ui, пока она не очищена"))
|
||||
c.restartFresh()
|
||||
}
|
||||
|
||||
// ── rehome (нода) ──────────────────────────────────────────────────────────
|
||||
|
||||
// rehome — панель переехала на новый адрес: тот же pin и id, другой CORE_HOST[:CORE_PORT].
|
||||
func (c *CLI) rehome(addr string, hasAddr bool) {
|
||||
if !c.isNode {
|
||||
errorf(" Это не нода — менять адрес core нечему")
|
||||
os.Exit(1)
|
||||
}
|
||||
host, port := addr, ""
|
||||
if h, p, err := net.SplitHostPort(addr); err == nil {
|
||||
host, port = h, p
|
||||
}
|
||||
if p, err := strconv.Atoi(port); !hasAddr || !reJoinHost.MatchString(host) || port != "" && (err != nil || p <= 0 || p > 65535) {
|
||||
errorf(" Usage: awg-ctrl rehome <хост[:порт]> (адрес новой панели; pin и id ноды остаются)")
|
||||
os.Exit(1)
|
||||
}
|
||||
content := updateEnvVar(c.readEnvFile(), "CORE_HOST", host)
|
||||
if port != "" {
|
||||
content = updateEnvVar(content, "CORE_PORT", port)
|
||||
} else {
|
||||
port = os.Getenv("CORE_PORT")
|
||||
}
|
||||
// Адрес, указанный руками, главнее того, что сообщила панель.
|
||||
c.writeEnvFile(removeEnvVar(content, "CORE_ENDPOINT"))
|
||||
fmt.Printf(" %s Core: %s:%s\n", green("✓"), host, port)
|
||||
c.restartFresh()
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/maeneko/forgetting/internal/jsjson"
|
||||
"github.com/maeneko/forgetting/internal/migrate"
|
||||
)
|
||||
|
||||
func decodeJoin(t *testing.T, s string) jsjson.Object {
|
||||
t.Helper()
|
||||
v, err := jsjson.Decode(fromB64url(strings.TrimPrefix(s, "awgjoin://")))
|
||||
o, ok := v.(jsjson.Object)
|
||||
if err != nil || !ok {
|
||||
t.Fatalf("join-строка не разбирается: %v", err)
|
||||
}
|
||||
return o
|
||||
}
|
||||
|
||||
// export берёт пути и порты из окружения сервисов, как их видит awg-ui.
|
||||
func TestWriteExportFromEnv(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
sign := filepath.Join(dir, "sign")
|
||||
os.WriteFile(sign, []byte("sign"), 0o600)
|
||||
dbFile := filepath.Join(dir, "ui.db")
|
||||
db, err := migrate.OpenDB(dbFile)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
db.Exec("CREATE TABLE sub_meta (key TEXT PRIMARY KEY, value TEXT NOT NULL)")
|
||||
db.Exec("INSERT INTO sub_meta VALUES ('panel_id', 'abcdef123456')")
|
||||
db.Close()
|
||||
for k, v := range map[string]string{
|
||||
"AWG_ROOT": dir, "UI_DB_FILE": dbFile, "SUB_SIGN_KEY_FILE": sign, "AWGCTRL_PORT": "1", "SERVER_IP": "198.51.100.1",
|
||||
"SUB_PORT": "40123", "SUB_TLS": "on", "NODE_PORT": "40500", "UI_PORT": "33000",
|
||||
"SUB_TLS_KEY_FILE": filepath.Join(dir, "нет"), "SUB_TLS_CERT_FILE": filepath.Join(dir, "нет"),
|
||||
"NODE_TLS_KEY_FILE": filepath.Join(dir, "нет"), "NODE_TLS_CERT_FILE": filepath.Join(dir, "нет"),
|
||||
} {
|
||||
t.Setenv(k, v)
|
||||
}
|
||||
out := filepath.Join(dir, "bundle.tar.gz")
|
||||
if err := New().writeExport(out, dbFile); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if st, _ := os.Stat(out); st.Mode().Perm() != 0o600 {
|
||||
t.Errorf("права архива %v — в нём ключ подписи", st.Mode().Perm())
|
||||
}
|
||||
f, _ := os.Open(out)
|
||||
b, err := migrate.Read(f)
|
||||
f.Close()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer b.Close()
|
||||
if b.Str("panel_id") != "abcdef123456" || b.Str("server_ip") != "198.51.100.1" || b.Str("server_name") != "VPN" ||
|
||||
b.Num("sub_port") != 40123 || b.Num("node_port") != 40500 || b.Num("ui_port") != 33000 || b.Meta.Get2("sub_tls") != true ||
|
||||
!b.Has["sub_sign.key"] || b.Has["sub_tls.key"] {
|
||||
t.Errorf("meta %s, has %v", jsjson.MustMarshal(b.Meta), b.Has)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRelayRoutes(t *testing.T) {
|
||||
j := decodeJoin(t, migrate.JoinString("203.0.113.7", 40500, strings.Repeat("A", 43), 0, []byte("0123456789abcdef"), 33100, 40123))
|
||||
t.Setenv("SUB_PORT", "40123")
|
||||
t.Setenv("NODE_PORT", "40400")
|
||||
t.Setenv("UI_PORT", "32000")
|
||||
if got, want := relayRoutes("203.0.113.7", 40500, j), "40123=203.0.113.7:40123,40400=203.0.113.7:40500,32000=203.0.113.7:33100"; got != want {
|
||||
t.Errorf("relay = %s, ожидали %s", got, want)
|
||||
}
|
||||
// Строка, выданная панелью заново (без ui/sub): подписка — на тот же порт, панель не пересылаем.
|
||||
t.Setenv("NODE_PORT", "")
|
||||
plain := decodeJoin(t, migrate.JoinString("2001:db8::7", 40500, strings.Repeat("A", 43), 0, []byte("0123456789abcdef"), 0, 0))
|
||||
if got, want := relayRoutes("2001:db8::7", 40500, plain), "40123=[2001:db8::7]:40123"; got != want {
|
||||
t.Errorf("relay = %s, ожидали %s", got, want)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user