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:
@@ -464,8 +464,17 @@ awg-ctrl migrate export [файл] # перенос панели (с
|
|||||||
awg-ctrl migrate import <архив>
|
awg-ctrl migrate import <архив>
|
||||||
awg-ctrl migrate cancel
|
awg-ctrl migrate cancel
|
||||||
awg-ctrl update | reinstall | uninstall # обновить, переустановить, удалить (через install.sh)
|
awg-ctrl update | reinstall | uninstall # обновить, переустановить, удалить (через install.sh)
|
||||||
|
awg-ctrl panel [path new|off|<путь>] # адрес панели; новый, свой или без секретного пути
|
||||||
|
awg-ctrl log [<поиск>] [-n N] # журнал VPN-подключений этого сервера
|
||||||
|
awg-ctrl log seen # кто когда был: онлайн, последний раз, не подключался
|
||||||
|
awg-ctrl metrics [token|revoke] # токен и prometheus.yml для своего Prometheus
|
||||||
|
awg-ctrl hub [legacy on|off|auto] # ноды хаба: кто с подписью, кто ещё старый
|
||||||
|
awg-ctrl check # что на сервере выдаёт его сканеру и DPI
|
||||||
```
|
```
|
||||||
|
|
||||||
|
В меню `awg-ctrl` те же возможности — пункты 10–13: адрес и секретный путь панели, журнал,
|
||||||
|
«кто когда был» и проверка скрытности.
|
||||||
|
|
||||||
`update` и `reinstall` скачивают свежий установщик (`INSTALLER_URL` в `cli.env` — своё
|
`update` и `reinstall` скачивают свежий установщик (`INSTALLER_URL` в `cli.env` — своё
|
||||||
зеркало), `uninstall` берёт его копию из установки и работает без сети.
|
зеркало), `uninstall` берёт его копию из установки и работает без сети.
|
||||||
|
|
||||||
|
|||||||
+12
-2
@@ -638,10 +638,10 @@ func (c *CLI) confirmYes(q string) bool {
|
|||||||
|
|
||||||
// ── Точка входа ─────────────────────────────────────────────────────────────
|
// ── Точка входа ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
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>|update|reinstall|uninstall>"
|
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>|update|reinstall|uninstall|panel [path new|off|<путь>]|log [seen] [поиск] [-n N]|metrics [token|revoke]|hub [legacy on|off|auto]|check>"
|
||||||
|
|
||||||
// Commands — команды оператора; main передаёт сюда всё, что не роль сервиса.
|
// Commands — команды оператора; main передаёт сюда всё, что не роль сервиса.
|
||||||
var Commands = []string{"start", "stop", "restart", "status", "credentials", "join", "unjoin", "rehome", "peers", "monitor", "migrate", "update", "reinstall", "uninstall"}
|
var Commands = []string{"start", "stop", "restart", "status", "credentials", "join", "unjoin", "rehome", "peers", "monitor", "migrate", "update", "reinstall", "uninstall", "panel", "log", "metrics", "hub", "check"}
|
||||||
|
|
||||||
// Main выполняет команду CLI (args — без имени бинарника). Пусто — интерактивное меню.
|
// Main выполняет команду CLI (args — без имени бинарника). Пусто — интерактивное меню.
|
||||||
func Main(args []string) {
|
func Main(args []string) {
|
||||||
@@ -686,6 +686,16 @@ func Main(args []string) {
|
|||||||
c.peersCmd(sub, hasSub, a2, hasA2)
|
c.peersCmd(sub, hasSub, a2, hasA2)
|
||||||
case "monitor":
|
case "monitor":
|
||||||
c.monitorCmd(sub)
|
c.monitorCmd(sub)
|
||||||
|
case "panel":
|
||||||
|
c.panelCmd(args[1:])
|
||||||
|
case "log":
|
||||||
|
c.logCmd(args[1:])
|
||||||
|
case "metrics":
|
||||||
|
c.metricsCmd(args[1:])
|
||||||
|
case "hub":
|
||||||
|
c.hubCmd(args[1:])
|
||||||
|
case "check":
|
||||||
|
c.checkCmd()
|
||||||
case "update", "reinstall", "uninstall":
|
case "update", "reinstall", "uninstall":
|
||||||
// Из командной строки — без своего вопроса: установщик сам спросит, что нужно.
|
// Из командной строки — без своего вопроса: установщик сам спросит, что нужно.
|
||||||
c.runInstaller(args[0], false)
|
c.runInstaller(args[0], false)
|
||||||
|
|||||||
+15
-1
@@ -495,9 +495,15 @@ func (c *CLI) interactiveMenu() {
|
|||||||
%s Переустановить
|
%s Переустановить
|
||||||
%s Удалить
|
%s Удалить
|
||||||
%s
|
%s
|
||||||
|
%s Адрес панели и секретный путь
|
||||||
|
%s Журнал подключений
|
||||||
|
%s Кто когда был в VPN
|
||||||
|
%s Проверка скрытности
|
||||||
|
%s
|
||||||
%s Выход
|
%s Выход
|
||||||
|
|
||||||
`, bold("1"), bold("2"), bold("3"), bold("4"), sep, bold("5"), bold("6"), sep, bold("7"), bold("8"), bold("9"), sep, bold("0"))
|
`, bold("1"), bold("2"), bold("3"), bold("4"), sep, bold("5"), bold("6"), sep, bold("7"), bold("8"), bold("9"), sep,
|
||||||
|
bold("10"), bold("11"), bold("12"), bold("13"), sep, bold("0"))
|
||||||
}
|
}
|
||||||
for {
|
for {
|
||||||
printMenu()
|
printMenu()
|
||||||
@@ -528,6 +534,14 @@ func (c *CLI) interactiveMenu() {
|
|||||||
c.runInstaller("reinstall", true)
|
c.runInstaller("reinstall", true)
|
||||||
case "9":
|
case "9":
|
||||||
c.runInstaller("uninstall", true)
|
c.runInstaller("uninstall", true)
|
||||||
|
case "10":
|
||||||
|
c.panelMenu()
|
||||||
|
case "11":
|
||||||
|
c.logCmd(nil)
|
||||||
|
case "12":
|
||||||
|
c.logCmd([]string{"seen"})
|
||||||
|
case "13":
|
||||||
|
c.checkCmd()
|
||||||
case "0":
|
case "0":
|
||||||
os.Exit(0)
|
os.Exit(0)
|
||||||
default:
|
default:
|
||||||
|
|||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user