awg-ctrl: commands for the secret path, connection log, metrics, hub, check

The new features were only reachable from the web panel. awg-ctrl now has:

- panel [path new|off|<path>] — the panel address; a new random, own or no
  secret path (cli.env, panel restarted, VPN untouched);
- log [<search>] [-n N] and log seen — the connection log of this server and
  who was online when, with sen:// device and master key names from ui.db when
  the panel is on the same machine;
- metrics [token|revoke] — the metrics-only token for an external Prometheus,
  printed once with a ready prometheus.yml fragment that carries the path;
- hub [legacy on|off|auto] — nodes with and without the pre-upgrade signature
  and the HUB_LEGACY mode;
- check — what this server gives away: shared pre-randomization VPN values or
  no own I1, no secret path, plain-HTTP panel and subscription, the generic
  "sen"/"awg-node" certificate names, an open hub — each with the fix.

The interactive menu gets items 10–13: panel address and path, connection log,
who was online, and the check. Columns are padded by runes, not bytes, so
Cyrillic names line up.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013bNGYiDst62wH3ZGTRPFLJ
This commit is contained in:
Claude
2026-10-28 12:55:39 +00:00
parent ea07914249
commit 0a9eaa8c8e
4 changed files with 601 additions and 3 deletions
+565
View File
@@ -0,0 +1,565 @@
package cli
// Команды для новых возможностей панели — то же, что в веб-интерфейсе, но с сервера:
// panel [path new|off|<путь>] — адрес панели, секретный путь (UI_PREFIX)
// log [seen] [поиск] [-n N] — журнал VPN-подключений этого сервера, «кто когда был»
// metrics [token|revoke] — токен «только метрики» для своего Prometheus
// hub [legacy on|off|auto] — хаб нод: подпись до апгрейда, кто из нод ещё старый
// check — что на этом сервере выдаёт его сканеру и DPI
import (
"crypto/rand"
"crypto/sha256"
"crypto/x509"
"database/sql"
"encoding/pem"
"fmt"
"net/url"
"os"
"path/filepath"
"regexp"
"sort"
"strconv"
"strings"
"time"
"github.com/maeneko/forgetting/internal/migrate"
)
// pad — дополнение пробелами по числу символов, не байт: в Printf %-Ns кириллица съезжает.
func pad(s string, n int) string {
if l := len([]rune(s)); l < n {
return s + strings.Repeat(" ", n-l)
}
return s
}
func yellow(s string) string { return "\x1b[33m" + s + cReset }
func (c *CLI) hasPanel() bool { return contains(c.all, "ui") || env("UI_PORT", "") != "" && !c.isNode }
func (c *CLI) hasVPN() bool { return contains(c.all, "awgctrl") }
func (c *CLI) uiDB() (*sql.DB, error) {
f := c.svcEnv("ui", "UI_DB_FILE")
if !exists(f) {
return nil, fmt.Errorf("базы панели нет (%s)", f)
}
return migrate.OpenDB(f)
}
// restartUI — cli.env изменён: панель перезапускается с новым значением, VPN не трогаем.
func (c *CLI) restartUI(key, value string) {
c.setSvcEnv("ui", key, value)
if c.isRunning("ui") {
c.stop("ui")
c.start("ui")
}
}
func when(ts int64) string {
t := time.Unix(ts, 0)
now := time.Now()
switch {
case t.YearDay() == now.YearDay() && t.Year() == now.Year():
return "сегодня " + t.Format("15:04")
case t.Year() == now.Year():
return t.Format("02.01 15:04")
}
return t.Format("02.01.2006 15:04")
}
func dur(s int64) string {
switch {
case s < 60:
return fmt.Sprintf("%d сек", s)
case s < 3600:
return fmt.Sprintf("%d мин", s/60)
case s < 86400:
return fmt.Sprintf("%d ч %d мин", s/3600, s%3600/60)
}
return fmt.Sprintf("%d дн %d ч", s/86400, s%86400/3600)
}
func bytesStr(n float64) string {
switch {
case n < 1<<10:
return fmt.Sprintf("%.0f B", n)
case n < 1<<20:
return fmt.Sprintf("%.1f KB", n/(1<<10))
case n < 1<<30:
return fmt.Sprintf("%.1f MB", n/(1<<20))
}
return fmt.Sprintf("%.2f GB", n/(1<<30))
}
// ── panel ───────────────────────────────────────────────────────────────────
var rePrefix = regexp.MustCompile(`^[A-Za-z0-9_-]{8,64}$`)
func randPrefix() string {
const abc = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"
b := make([]byte, 16)
rand.Read(b)
for i := range b {
b[i] = abc[int(b[i])%len(abc)]
}
return string(b)
}
func (c *CLI) panelCmd(args []string) {
if !c.hasPanel() {
fmt.Println(" Панели на этой машине нет — это нода")
return
}
if len(args) == 0 {
c.showPanel()
return
}
if args[0] != "path" || len(args) < 2 {
errorf(" Usage: awg-ctrl panel [path new|off|<путь>]")
os.Exit(1)
}
c.setPanelPath(args[1])
}
func (c *CLI) showPanel() {
fmt.Printf("\n Панель %s\n", bold(panelURL()))
if p := env("UI_PREFIX", ""); p != "" {
fmt.Println(" " + dim("Секретный путь: снаружи без него — 404. Сменить: awg-ctrl panel path new"))
} else {
fmt.Println(" " + yellow("Без секретного пути: сканер узнаёт панель по странице логина.") + " " + dim("Включить: awg-ctrl panel path new"))
}
if m := env("UI_PREFIX_MOVED", ""); m != "" {
fmt.Println(" " + dim("Принимается и путь переехавшей панели: /"+m+"/"))
}
fmt.Println()
}
// setPanelPath — new (случайный), off (без пути) или свой. Закладки и адрес внешнего API
// со старым путём перестанут работать — предупреждаем.
func (c *CLI) setPanelPath(arg string) {
v := arg
switch arg {
case "new":
v = randPrefix()
case "off":
v = ""
default:
if !rePrefix.MatchString(arg) {
errorf(" Путь: 8–64 символа A–Z a–z 0–9 _ -, или new / off")
os.Exit(1)
}
}
if err := c.writeEnvFile(updateEnvVar(c.readEnvFile(), "UI_PREFIX", v)); err != nil {
errorf(" Не удалось записать %s: %v", c.envFile, err)
os.Exit(1)
}
os.Setenv("UI_PREFIX", v)
c.restartUI("UI_PREFIX", v)
fmt.Printf("\n %s Панель: %s\n", green("✓"), bold(panelURL()))
fmt.Println(" " + dim("Старый адрес больше не открывается: обновите закладки и адрес внешнего API и Prometheus."))
fmt.Println()
}
// ── log ─────────────────────────────────────────────────────────────────────
var reOwner = regexp.MustCompile(`^(?:([a-z0-9]{8,16})/)?m(\d+)/d(\d+)$`)
// ownerName — «устройство · ключ» для пира sen://, если его панель — на этой машине.
func (c *CLI) ownerName(db *sql.DB, owner string) string {
m := reOwner.FindStringSubmatch(owner)
if m == nil || db == nil || m[1] != "" && m[1] != c.localPanelID() {
return owner
}
var label, dev string
db.QueryRow("SELECT label FROM master_keys WHERE id = ?", m[2]).Scan(&label)
db.QueryRow("SELECT device_name FROM devices WHERE id = ?", m[3]).Scan(&dev)
switch {
case dev != "" && label != "":
return dev + " · «" + label + "»"
case label != "":
return "удалённое устройство · «" + label + "»"
}
return "удалённое устройство"
}
func (c *CLI) logCmd(args []string) {
if !c.hasVPN() {
fmt.Println(" VPN на этой машине нет — журнал серверов во вкладке «Подключения» панели")
return
}
seen, limit, q := false, 50, ""
for i := 0; i < len(args); i++ {
switch a := args[i]; {
case a == "seen":
seen = true
case a == "-n" && i+1 < len(args):
n, err := strconv.Atoi(args[i+1])
if err != nil || n <= 0 {
errorf(" -n: число событий")
os.Exit(1)
}
limit, i = min(n, 500), i+1
default:
q = strings.TrimSpace(q + " " + a)
}
}
v := url.Values{"limit": {strconv.Itoa(limit)}}
if q != "" && !seen {
v.Set("q", q)
}
r, err := c.ctrlCall("GET", "/api/connlog?"+v.Encode(), nil)
if err != nil {
errorf(" awg-ctrl недоступен: %v", err)
os.Exit(1)
}
if r.status == 404 {
errorf(" awg-ctrl этой версии журнала не ведёт — обновите сервер: awg-ctrl update")
os.Exit(1)
}
if r.status != 200 {
errorf(" %v", ctrlError(r))
os.Exit(1)
}
db, _ := c.uiDB()
if db != nil {
defer db.Close()
}
who := func(o any) (string, string) {
name, _ := get(o, "name").(string)
ip, _ := get(o, "ip").(string)
if name == "" {
if owner, _ := get(o, "owner").(string); owner != "" {
name = c.ownerName(db, owner)
} else {
pub, _ := get(o, "pub_key").(string)
name = "ключ " + pub[:min(10, len(pub))] + "…"
}
}
return name, ip
}
fmt.Println()
if since, ok := get(r.data, "since").(float64); ok {
fmt.Printf(" %s\n\n", dim(fmt.Sprintf("Журнал ведётся с %s, хранится %v дней. Время отключения — последняя активность (±30 с).",
when(int64(since)), get(r.data, "keep_days"))))
}
if seen {
type row struct {
name, ip string
online bool
last int64
}
var rows []row
peers, _ := get(r.data, "peers").([]any)
for _, p := range peers {
name, ip := who(p)
if q != "" && !strings.Contains(strings.ToLower(name+" "+ip), strings.ToLower(q)) {
continue
}
last, _ := get(p, "last_seen").(float64)
online, _ := get(p, "online").(bool)
rows = append(rows, row{name, ip, online, int64(last)})
}
sort.SliceStable(rows, func(i, j int) bool {
if rows[i].online != rows[j].online {
return rows[i].online
}
return rows[i].last > rows[j].last
})
for _, x := range rows {
state := dim("не подключался с начала журнала")
switch {
case x.online:
state = green("онлайн")
case x.last > 0:
state = "был " + when(x.last) + dim(" ("+dur(time.Now().Unix()-x.last)+" назад)")
}
fmt.Printf(" %s %s %s\n", pad(x.name, 32), dim(pad(x.ip, 12)), state)
}
if len(rows) == 0 {
fmt.Println(" " + dim("Никого"))
}
fmt.Println()
return
}
events, _ := get(r.data, "events").([]any)
if len(events) == 0 {
fmt.Println(" " + dim("Событий нет"))
}
// Старые сверху: читается как лента.
for i := len(events) - 1; i >= 0; i-- {
e := events[i]
at, _ := get(e, "at").(float64)
name, ip := who(e)
mark, tail := green("→ подключился"), ""
if get(e, "event") == "disconnect" {
mark = dim("← отключился ")
d, _ := get(e, "duration").(float64)
rx, _ := get(e, "rx").(float64)
tx, _ := get(e, "tx").(float64)
tail = dim(fmt.Sprintf(" %s · ↓ %s ↑ %s", dur(int64(d)), bytesStr(tx), bytesStr(rx)))
}
fmt.Printf(" %s %s %s %s%s\n", pad(when(int64(at)), 15), mark, name, dim(ip), tail)
}
fmt.Printf("\n %s\n\n", dim("Больше: awg-ctrl log -n 200 · по имени: awg-ctrl log <имя> · кто когда был: awg-ctrl log seen"))
}
// ── metrics ─────────────────────────────────────────────────────────────────
func (c *CLI) metricsCmd(args []string) {
if !c.hasPanel() {
fmt.Println(" Панели на этой машине нет — токен метрик выдаёт панель")
return
}
db, err := c.uiDB()
if err != nil {
errorf(" %v", err)
os.Exit(1)
}
defer db.Close()
sub := ""
if len(args) > 0 {
sub = args[0]
}
switch sub {
case "":
var prefix string
var created int64
var last sql.NullInt64
if db.QueryRow("SELECT prefix, created_at, last_used FROM metrics_token WHERE id = 1").Scan(&prefix, &created, &last) != nil {
fmt.Print("\n Токена для внешнего Prometheus нет. Выдать: awg-ctrl metrics token\n\n")
return
}
used := "ещё не было"
if last.Valid && last.Int64 > 0 {
used = when(last.Int64)
}
fmt.Printf("\n Токен %s · выдан %s · последний скрейп %s\n", bold(prefix), when(created), used)
fmt.Print(" " + dim("Перевыпустить: awg-ctrl metrics token · отозвать: awg-ctrl metrics revoke") + "\n\n")
case "token":
b := make([]byte, 24)
rand.Read(b)
tok := "awgm_" + b64url.EncodeToString(b)
h := sha256.Sum256([]byte(tok))
if _, err := db.Exec("INSERT OR REPLACE INTO metrics_token (id, token_hash, prefix, created_at, last_used) VALUES (1, ?, ?, ?, NULL)",
h[:], tok[:13]+"…", time.Now().Unix()); err != nil {
errorf(" ui.db: %v (панель этой версии ещё не создала таблицу — запустите её)", err)
os.Exit(1)
}
u, _ := url.Parse(panelURL())
fmt.Printf("\n %s Токен (показывается один раз, прежний больше не работает):\n\n %s\n\n", green("✓"), bold(tok))
fmt.Print(" Фрагмент prometheus.yml:\n\n")
fmt.Printf(" scrape_configs:\n - job_name: vpn-panel\n metrics_path: %smetrics\n authorization:\n credentials: %s\n static_configs:\n - targets: ['%s']\n\n",
u.Path, tok, u.Host)
fmt.Print(" " + dim("По голому HTTP токен и метрики идут открытым текстом — скрейпьте через nginx с TLS или по VPN.") + "\n\n")
case "revoke":
db.Exec("DELETE FROM metrics_token WHERE id = 1")
fmt.Printf(" %s Токен отозван\n", green("✓"))
default:
errorf(" Usage: awg-ctrl metrics [token|revoke]")
os.Exit(1)
}
}
// ── hub ─────────────────────────────────────────────────────────────────────
func (c *CLI) hubCmd(args []string) {
if !c.hasPanel() || env("NODE_PORT", "") == "" {
fmt.Println(" Хаб нод на этой машине не включён (NODE_PORT)")
return
}
if len(args) > 0 {
if args[0] != "legacy" || len(args) < 2 || !regexp.MustCompile(`^(on|off|auto)$`).MatchString(args[1]) {
errorf(" Usage: awg-ctrl hub [legacy on|off|auto]")
os.Exit(1)
}
v := args[1]
if v == "auto" {
v = ""
}
if err := c.writeEnvFile(updateEnvVar(c.readEnvFile(), "HUB_LEGACY", v)); err != nil {
errorf(" %v", err)
os.Exit(1)
}
os.Setenv("HUB_LEGACY", v)
c.restartUI("HUB_LEGACY", v)
fmt.Printf(" %s HUB_LEGACY=%s\n", green("✓"), args[1])
}
db, err := c.uiDB()
if err != nil {
errorf(" %v", err)
os.Exit(1)
}
defer db.Close()
rows, err := db.Query("SELECT id, name, COALESCE(version, ''), COALESCE(last_seen, 0), COALESCE(preauth, 0), pub IS NOT NULL FROM nodes ORDER BY id")
if err != nil {
errorf(" ui.db: %v", err)
os.Exit(1)
}
type node struct {
id int64
name, version string
seen int64
preauth, joined bool
}
var list []node
for rows.Next() {
var n node
if rows.Scan(&n.id, &n.name, &n.version, &n.seen, &n.preauth, &n.joined) == nil {
list = append(list, n)
}
}
rows.Close()
old := 0
fmt.Printf("\n Хаб нод :%s\n\n", env("NODE_PORT", ""))
for _, n := range list {
state := green(pad("с подписью", 28))
if !n.preauth {
state, old = yellow(pad("старый агент — без подписи", 28)), old+1
if !n.joined {
state = dim(pad("ещё не подключалась", 28))
}
}
seen := dim("не подключалась")
if n.seen > 0 {
seen = "был " + when(n.seen)
}
fmt.Printf(" #%-3d %s %s %s %s\n", n.id, pad(n.name, 20), dim(pad(n.version, 12)), state, seen)
}
if len(list) == 0 {
fmt.Println(" " + dim("Нод нет"))
}
mode := env("HUB_LEGACY", "")
fmt.Println()
switch {
case mode == "on":
fmt.Println(" " + yellow("HUB_LEGACY=on: хаб пускает агентов без подписи — сканер узнаёт его по /node/v1"))
case mode == "off":
fmt.Println(" HUB_LEGACY=off: только с подписью. Старые агенты не подключатся, пока ноду не обновят")
case old > 0:
fmt.Println(" " + yellow(fmt.Sprintf("Переходный режим: %d нод(ы) без подписи — хаб пускает и без неё. Обновите их: awg-ctrl update на ноде", old)))
default:
fmt.Println(" " + green("Все ноды с подписью — без неё хаб отвечает 404"))
}
fmt.Print(" " + dim("Принудительно: awg-ctrl hub legacy on|off|auto") + "\n\n")
}
// ── check ───────────────────────────────────────────────────────────────────
// Прежние общие параметры установок до рандомизации — по ним сервер узнаётся DPI.
var legacyAWG = map[string]string{"ListenPort": "47619", "S1": "90", "S2": "45", "H1": "1224800044-2116730834"}
func readConf(file string) map[string]string {
out := map[string]string{}
raw, err := os.ReadFile(file)
if err != nil {
return nil
}
for _, l := range strings.Split(strings.SplitN(string(raw), "[Peer]", 2)[0], "\n") {
if k, v, ok := strings.Cut(l, "="); ok {
out[strings.TrimSpace(k)] = strings.TrimSpace(v)
}
}
return out
}
func certCN(file string) string {
raw, err := os.ReadFile(file)
if err != nil {
return ""
}
blk, _ := pem.Decode(raw)
if blk == nil {
return ""
}
crt, err := x509.ParseCertificate(blk.Bytes)
if err != nil {
return ""
}
return crt.Subject.CommonName
}
// checkCmd — сводка: что на этом сервере выдаёт его сканеру (активное зондирование) и DPI.
func (c *CLI) checkCmd() {
ok := func(s string) { fmt.Println(" " + green("✓") + " " + s) }
bad := func(s, fix string) { fmt.Println(" " + yellow("⚠") + " " + s + "\n " + dim(fix)) }
fmt.Println()
if c.hasVPN() {
conf := readConf(filepath.Join(env("AWG_CONF_DIR", "/etc/amnezia/amneziawg"), "awg1.conf"))
switch {
case conf == nil:
bad("awg1.conf не прочитан", "запустите от root")
default:
var same []string
for k, v := range legacyAWG {
if conf[k] == v {
same = append(same, k)
}
}
sort.Strings(same)
if len(same) > 0 {
bad("VPN: общие для старых установок значения ("+strings.Join(same, ", ")+") — по ним DPI находит все такие серверы сразу",
"переустановка без сохранения данных (awg-ctrl reinstall) и раздача новых ключей")
} else {
ok("VPN: порт и параметры обфускации свои у этого сервера")
}
if conf["I1"] == "" {
bad("VPN: нет своего I1 — ключи получают общий пакет-маскировку", "awg-ctrl reinstall с сохранением данных выдаст свой — старые ключи продолжат работать")
}
}
}
if c.hasPanel() {
if env("UI_PREFIX", "") != "" {
ok("Панель: секретный путь — снаружи без него 404")
} else {
bad("Панель без секретного пути — сканер узнаёт её по странице логина", "awg-ctrl panel path new")
}
bad("Панель по голому HTTP: пути, JSON и токены видны DPI", "nginx с TLS перед панелью (README, «Панель за nginx») или вход по VPN")
if strings.ToLower(env("SUB_TLS", "off")) == "on" {
ok("Подписка sen:// по HTTPS")
} else {
bad("Подписка sen:// по HTTP: ответы подписаны, но видны DPI", "переустановка с SUB_TLS=on — ссылки придётся выдать заново")
}
if cn := certCN(c.svcEnv("ui", "SUB_TLS_CERT_FILE")); cn == "sen" {
bad("Сертификат подписки с общим именем «sen»", "имя меняется только с ключом — при переустановке без сохранения данных")
}
if env("NODE_PORT", "") != "" {
if cn := certCN(c.svcEnv("ui", "NODE_TLS_CERT_FILE")); cn == "awg-node" {
bad("Сертификат хаба с общим именем «awg-node»", "имя меняется только с ключом — ноды придётся подключить заново")
}
switch strings.ToLower(env("HUB_LEGACY", "")) {
case "on":
bad("Хаб пускает агентов без подписи (HUB_LEGACY=on)", "awg-ctrl hub legacy auto")
case "off":
ok("Хаб: только агенты с подписью")
default:
ok("Хаб: подпись до апгрейда; кто из нод ещё без неё — awg-ctrl hub")
}
}
}
fmt.Print("\n " + dim("Фаервол хостера (панель провайдера) отсюда не виден — открыты ли там лишние порты, проверьте сами.") + "\n\n")
}
// panelMenu — пункт меню: адрес панели и смена секретного пути.
func (c *CLI) panelMenu() {
if !c.hasPanel() {
fmt.Println(" Панели на этой машине нет — это нода")
return
}
c.showPanel()
fmt.Println(" 1) Новый случайный путь 2) Свой путь 3) Без пути Enter — назад")
switch strings.TrimSpace(c.ask(" › ")) {
case "1":
c.setPanelPath("new")
case "2":
if p := strings.TrimSpace(c.ask(" Путь (8–64 символа A–Z a–z 0–9 _ -): ")); p != "" {
c.setPanelPath(p)
}
case "3":
if c.confirmYes(" Без пути сканер снова узнает панель по странице логина. Убрать путь?") {
c.setPanelPath("off")
}
}
}