package cli import ( "bytes" "crypto/rand" "encoding/base64" "fmt" "io" "net/http" "os" "path/filepath" "regexp" "strings" "time" "github.com/maeneko/forgetting/internal/iauth" "github.com/maeneko/forgetting/internal/jsjson" ) var b64url = base64.RawURLEncoding // fromB64url — Buffer.from(s, "base64url"): нестрого, как в Node. func fromB64url(s string) []byte { s = strings.NewReplacer("+", "-", "/", "_").Replace(strings.TrimRight(s, "=")) if len(s)%4 == 1 { s = s[:len(s)-1] } b, _ := b64url.DecodeString(s) return b } // ── join / unjoin ─────────────────────────────────────────────────────────── var ( reJoinHost = regexp.MustCompile(`^[A-Za-z0-9.:_-]{1,253}$`) reJoinPin = regexp.MustCompile(`^[A-Za-z0-9_-]{43}$`) reJoinSec = regexp.MustCompile(`^[A-Za-z0-9_-]{22}$`) ) func isPosInt(v any, max float64) (int64, bool) { f, ok := v.(float64) return int64(f), ok && f == float64(int64(f)) && f > 0 && f < max } // joinCore — `join [--no-panel]`: подключить эту установку к главной панели как // ноду. Рядом запускается агент; awg-ctrl и пользователи остаются как были. Своя панель // продолжает работать, если она здесь есть, — с --no-panel она останавливается. func (c *CLI) joinCore(link string, hasLink bool, flag string, hasFlag bool) { if !hasLink || !strings.HasPrefix(link, "awgjoin://") || hasFlag && flag != "--no-panel" { errorf(" Usage: awg-ctrl join [--no-panel] (ссылку даёт панель: «Добавить сервер»)") os.Exit(1) } v, err := jsjson.Decode(fromB64url(link[len("awgjoin://"):])) j, isObj := v.(jsjson.Object) if err != nil || !isObj { errorf(" Ссылка повреждена") os.Exit(1) } h, hok := j.Get2("h").(string) pin, pinok := j.Get2("pin").(string) sec, sok := j.Get2("s").(string) port, pok := isPosInt(j.Get2("p"), 65536) id, iok := isPosInt(j.Get2("n"), 9007199254740992) if j.Get2("v") != 1.0 || !hok || !reJoinHost.MatchString(h) || !pok || !pinok || !reJoinPin.MatchString(pin) || !iok || !sok || !reJoinSec.MatchString(sec) { errorf(" Ссылка неверного формата или от другой версии панели") os.Exit(1) } if c.isNode && !c.confirmYes(fmt.Sprintf(" Эта нода уже подключена к %s. Перепривязать?", os.Getenv("CORE_HOST"))) { return } hasPanel := os.Getenv("UI_PASS") != "" keepPanel := !hasFlag && hasPanel content := c.readEnvFile() np := "off" if keepPanel { np = "on" } for _, kv := range [][2]string{{"CORE_HOST", h}, {"CORE_PORT", fmt.Sprint(port)}, {"CORE_PIN", pin}, {"NODE_ID", fmt.Sprint(id)}, {"JOIN_SECRET", sec}, {"NODE_PANEL", np}} { content = updateEnvVar(content, kv[0], kv[1]) } c.writeEnvFile(content) // Панель останавливается, а пиры её устройств остаются на awg-ctrl — управлять ими больше // некому. Решает оператор; спрашиваем до остановки, снимаем после (иначе панель вернёт их). dropPeers := !keepPanel && hasPanel && c.askDropPanelPeers() if !keepPanel { c.stop("ui") } if dropPeers { c.dropArea(c.localPanelID()) } tail := "" if keepPanel { tail = " · своя панель продолжает работать" } else if hasPanel { tail = " · своя панель остановлена" } fmt.Printf("\n %s Настроено: core %s:%d, нода #%d%s\n", green("✓"), h, port, id, tail) c.restartFresh() } func (c *CLI) unjoinCore() { if !c.isNode { fmt.Println(" Эта установка не подключена к core") return } c.stop("agent") content := c.readEnvFile() for _, k := range []string{"CORE_HOST", "CORE_PORT", "CORE_PIN", "NODE_ID", "JOIN_SECRET", "NODE_PANEL"} { content = removeEnvVar(content, k) } c.writeEnvFile(content) fmt.Printf(" %s Нода отвязана. Удалите её в панели (сервер) и запустите: awg-ctrl restart\n", green("✓")) // Нода, поставленная install.sh с ролью «нода», панели не имеет — без пароля в неё не войти. if os.Getenv("UI_PASS") == "" { fmt.Println(" " + dim("Панель на этой машине не настроена: задайте логин и пароль — awg-ctrl credentials")) } } // ── Пиры подписки (sen://) на локальном awg-ctrl ──────────────────────────── // Пира ставит панель, owner = «/m<ключ>/d<устройство>»: id — область панели. Пиры // без области («m1/d2») выданы до областей. Здесь — ручной рычаг для оператора: панель // выключена или core пропал насовсем. По времени ничего не удаляется. var ( reArea = regexp.MustCompile(`^[a-z0-9]{8,16}$`) reLegacyOwner = regexp.MustCompile(`^m\d+/d\d+$`) ) func (c *CLI) localPanelID() string { raw, err := os.ReadFile(filepath.Join(c.root, "awg-ui", "panel.id")) if err != nil { return "" } if id := strings.TrimSpace(string(raw)); reArea.MatchString(id) { return id } return "" } type ctrlResp struct { status int data any } // ctrlCall — запрос к локальному awg-ctrl с тем же короткоживущим токеном, что у панели и агента. func (c *CLI) ctrlCall(method, path string, body any) (*ctrlResp, error) { key, err := iauth.LoadPrivate(c.svcEnv("ui", "INTERNAL_AUTH_KEY_FILE")) if err != nil { return nil, err } var rd io.Reader if body != nil { rd = bytes.NewReader(jsjson.MustMarshal(body)) } req, err := http.NewRequest(method, "http://127.0.0.1:"+c.envOf("awgctrl")+path, rd) if err != nil { return nil, err } req.Header.Set("Authorization", "Bearer "+iauth.Mint(key, "awg-cli", time.Now())) req.Header.Set("Content-Type", "application/json") cl := &http.Client{Timeout: 30 * time.Second, Transport: &http.Transport{Proxy: nil}} resp, err := cl.Do(req) if err != nil { return nil, err } defer resp.Body.Close() raw, _ := io.ReadAll(resp.Body) data, err := jsjson.Decode(raw) if err != nil { data = nil } return &ctrlResp{resp.StatusCode, data}, nil } type ctrlPeer struct { pub, ip, owner string online bool } func ctrlError(r *ctrlResp) error { if e, ok := get(r.data, "error").(string); ok { return fmt.Errorf("%s", e) } return fmt.Errorf("awg-ctrl ответил %d", r.status) } func (c *CLI) listPeers() ([]ctrlPeer, error) { r, err := c.ctrlCall("GET", "/api/peers", nil) if err != nil { return nil, err } if r.status != 200 { return nil, ctrlError(r) } var out []ctrlPeer list, _ := get(r.data, "peers").([]any) for _, p := range list { on, _ := get(p, "online").(bool) out = append(out, ctrlPeer{pub: strOr(get(p, "pub_key"), ""), ip: strOr(get(p, "ip"), ""), owner: strOr(get(p, "owner"), ""), online: on}) } return out, nil } // areaOf — область пира: id панели, «legacy» (до областей) или "" (чужой формат — не наш). func areaOf(owner string) string { head := strings.SplitN(owner, "/", 2)[0] if strings.Contains(owner, "/") && reArea.MatchString(head) { return head } if reLegacyOwner.MatchString(owner) { return "legacy" } return "" } func peersWord(n int) string { switch { case n%10 == 1 && n%100 != 11: return "пир" case n%10 >= 2 && n%10 <= 4 && !(n%100 >= 12 && n%100 <= 14): return "пира" } return "пиров" } func (c *CLI) askDropPanelPeers() bool { own := c.localPanelID() if own == "" { return false } peers, err := c.listPeers() if err != nil { fmt.Fprintln(os.Stderr, " "+dim("Не удалось посмотреть пиров подписки: "+err.Error())) return false } n := 0 for _, p := range peers { if areaOf(p.owner) == own { n++ } } if n == 0 { return false } switch strings.ToLower(os.Getenv("NODE_PEERS")) { case "keep": return false case "drop": return true } fmt.Printf("\n На этой панели %d %s подписки (устройства мастер-ключей). Без панели ими некому управлять:\n", n, peersWord(n)) fmt.Println(" 1) Оставить — продолжат работать, отозвать их можно будет только после unjoin") fmt.Println(" 2) Отключить — снять их с VPN (ключи останутся в ui.db и вернутся после unjoin)") return strings.TrimSpace(c.ask(" Выбор [1]: ")) == "2" } // dropArea — снять всю область: для панели — пустым списком через sync, для legacy — по одному. func (c *CLI) dropArea(area string) { if area == "legacy" { peers, err := c.listPeers() if err != nil { errorf(" Не удалось снять пиров: %v", err) return } n := 0 for _, p := range peers { if areaOf(p.owner) != "legacy" { continue } if _, err := c.ctrlCall("DELETE", "/api/peers/"+b64url.EncodeToString(stdB64(p.pub)), nil); err != nil { errorf(" Не удалось снять пиров: %v", err) return } n++ } fmt.Printf(" %s Снято пиров без области: %d\n", green("✓"), n) return } r, err := c.ctrlCall("PUT", "/api/peers/sync", jsjson.Object{{Key: "owner", Val: area}, {Key: "keep", Val: []any{}}}) if err == nil && r.status != 200 { err = ctrlError(r) } if err != nil { errorf(" Не удалось снять пиров: %v", err) return } fmt.Printf(" %s Снято пиров области %s: %s\n", green("✓"), area, jsString(get(r.data, "removed"))) } func stdB64(s string) []byte { b, err := base64.StdEncoding.DecodeString(s) if err != nil { return fromB64url(s) } return b } // jsString — `${v}` для вывода числа или строки из ответа. func jsString(v any) string { switch x := v.(type) { case nil: return "undefined" case string: return x case float64: return jsjson.Number(x) } return string(jsjson.MustMarshal(v)) } func (c *CLI) peersCmd(sub string, hasSub bool, arg string, hasArg bool) { own := c.localPanelID() peers, err := c.listPeers() if err != nil { errorf(" awg-ctrl недоступен: %v", err) os.Exit(1) } var order []string areas := map[string][]ctrlPeer{} for _, p := range peers { if a := areaOf(p.owner); a != "" { if _, seen := areas[a]; !seen { order = append(order, a) } areas[a] = append(areas[a], p) } } if !hasSub { if len(areas) == 0 { fmt.Println(" Пиров подписки (sen://) на этом сервере нет") return } fmt.Print("\n Пиры подписки (sen://) на этом сервере:\n\n") for _, a := range order { list := areas[a] mark := "" if a == own { mark = "эта панель" } else if a == "legacy" { mark = "без области (выданы до областей)" } online := 0 for _, p := range list { if p.online { online++ } } fmt.Printf(" %s %4d %-5s онлайн %d %s\n", bold(fmt.Sprintf("%-16s", a)), len(list), peersWord(len(list)), online, dim(mark)) } fmt.Printf("\n %s\n", dim("Снять область целиком: awg-ctrl peers drop <область>")) return } if sub != "drop" || !hasArg || arg == "" || !(arg == "legacy" || reArea.MatchString(arg)) { errorf(" Usage: awg-ctrl peers [drop <область|legacy>]") os.Exit(1) } n := len(areas[arg]) if n == 0 { fmt.Printf(" В области %s пиров нет\n", arg) return } // Панель, которая ведёт эту область, вернёт пиров при следующем сведении — снимать бесполезно. if arg == own && c.isRunning("ui") { errorf(" Эту область ведёт панель на этой машине — удаляйте мастер-ключи или устройства в ней") os.Exit(1) } fmt.Println(" " + dim("Если панель этой области ещё ведёт сервер, она вернёт пиров при следующем сведении —")) fmt.Println(" " + dim("сперва удалите сервер в ней (или мастер-ключи).")) if !c.confirmYes(fmt.Sprintf(" Снять %d %s области %s? Их устройства потеряют доступ к этому серверу.", n, peersWord(n), arg)) { return } c.dropArea(arg) } // ── Логин и пароль панели ─────────────────────────────────────────────────── func (c *CLI) setCredentials(field string) { var user, pass *string if field == "" || field == "user" { cur := env("UI_USER", "admin") u := strings.TrimSpace(c.ask(fmt.Sprintf("\n Логин [%s]: ", cur))) if u == "" { u = cur } user = &u } if field == "" || field == "pass" { b := make([]byte, 9) rand.Read(b) generated := b64url.EncodeToString(b)[:12] p := strings.TrimSpace(c.ask(fmt.Sprintf(" Пароль [%s]: ", generated))) if p == "" { p = generated } pass = &p } content := c.readEnvFile() if user != nil { content = updateEnvVar(content, "UI_USER", *user) os.Setenv("UI_USER", *user) } if pass != nil { content = updateEnvVar(content, "UI_PASS", *pass) os.Setenv("UI_PASS", *pass) } c.writeEnvFile(content) if user != nil { fmt.Printf("\n %s Логин: %s\n", green("✓"), *user) } if pass != nil { fmt.Printf(" %s Пароль: %s\n", green("✓"), *pass) } fmt.Printf("\n %s\n", dim("Перезапусти UI чтобы изменения вступили в силу.")) } // ── Интерактивное меню ────────────────────────────────────────────────────── func clearScreen() { if st, err := os.Stdout.Stat(); err == nil && st.Mode()&os.ModeCharDevice != 0 { fmt.Print("\x1b[1;1H\x1b[0J") } } func (c *CLI) interactiveMenu() { printMenu := func() { clearScreen() var parts []string for _, p := range []string{c.brand, c.channel, c.version} { if p != "" { parts = append(parts, p) } } fmt.Printf("\n%s\n\n", bold(cBlue+"── "+strings.Join(parts, " ")+" ──"+cReset)) for _, n := range c.all { fmt.Println(c.serviceLine(n, true)) } sep := dim("──────────────") fmt.Printf(` %s Запустить %s Остановить %s Перезапустить %s Статус %s %s Сменить логин UI %s Сменить пароль UI %s %s Выход `, bold("1"), bold("2"), bold("3"), bold("4"), sep, bold("5"), bold("6"), sep, bold("0")) } for { printMenu() fmt.Print(" › ") choice, err := c.in.ReadString('\n') if err != nil && choice == "" { fmt.Println() return // stdin закрыт } choice = strings.TrimSpace(choice) fmt.Println() switch choice { case "1": c.startAll() case "2": c.stopAll() case "3": c.restartAll() case "4": c.status() case "5": c.setCredentials("user") case "6": c.setCredentials("pass") case "0": os.Exit(0) default: fmt.Println(grey(" Неверный выбор")) } c.ask("\n " + dim("Enter для продолжения...")) } }