Files
forgetting/internal/cli/cmds.go
T
maeneko 5aa0c3a2f5 Port the CLI to Go: awg-control <start|stop|…>
internal/cli reimplements cli/src: start/stop/restart/status with the
awg-ctrl health gate, credentials, join/unjoin, peers [drop], monitor
install/status/remove and the interactive menu. Same cli.env, pid files,
logs and messages. Without arguments or with an operator command the binary
acts as the CLI; ctrl/ui/agent stay service roles.

- services are started as the same binary (`awg-control ctrl|ui|agent`),
  detached into their own session, from the same working directories
- processes started by the Node CLI (tsx … index.ts / server.ts) are still
  recognised, so `stop` and `status` work across the switch
- after join the fresh restart gets the original environment, so a rejoin
  picks up the new cli.env instead of the inherited old CORE_* values
- install root: AWG_ROOT, else the nearest parent of the binary with cli/ or
  .env, else /opt/awg-control

Checked end to end on stubbed awg: start/status/stop, credentials, join to
the panel's own hub (with and without --no-panel), peers listing and drop,
unjoin, menu.
2026-10-14 13:27:24 +00:00

487 lines
16 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 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 <awgjoin://…> [--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 <awgjoin://…> [--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 = «<id панели>/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 для продолжения..."))
}
}