Prometheus on a node; panel under a URL prefix; open ports on migrate import
A panel without VPN (e.g. after the move) can use a Prometheus that lives on a node, such as the one with history left on the old server (node #0). Both directions go through the agent's WSS: the node's Prometheus scrapes 127.0.0.1:AGENT_METRICS_PORT/metrics, the agent checks its token and asks core (metrics → metrics_res, the same exposition as /metrics, at most once per 5 s per connection); the monitor tab reads it back through the agent, which forwards only GET /-/ready and /api/v1/query[_range]. The panel finds it by itself (local first, then the lowest-id node), and the install/remove buttons work on a panel without VPN. `monitor install` on a node without its own panel picks AGENT_METRICS_PORT, writes it to cli.env and restarts the agent; on the old server it keeps the collected data. The panel works behind nginx in a subfolder: relative asset and API paths, relative directory redirects, and the client address from X-Forwarded-For / X-Real-IP only from trusted proxies (loopback, TRUST_PROXY). migrate import opens the moved SUB_PORT/NODE_PORT in UFW when it is active.
This commit is contained in:
+176
-6
@@ -1,7 +1,9 @@
|
||||
// Package agent — агент ноды (порт awg-agent/index.ts). Сам открывает исходящее
|
||||
// WSS-соединение к core (awg-ui), доказывает свою личность подписью Ed25519 и исполняет
|
||||
// присланные запросы на локальном awg-ctrl (127.0.0.1). Входящих портов у ноды нет,
|
||||
// awg-ctrl остаётся закрытым от сети. Протокол и формат join-строки — docs/node-protocol.md.
|
||||
// awg-ctrl остаётся закрытым от сети. Если на ноде стоит Prometheus (`awg-ctrl monitor install`),
|
||||
// агент отдаёт ему метрики панели, взятые у core, и пропускает к нему запросы вкладки «Монитор».
|
||||
// Протокол и формат join-строки — docs/node-protocol.md.
|
||||
package agent
|
||||
|
||||
import (
|
||||
@@ -10,9 +12,11 @@ import (
|
||||
"crypto/ed25519"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"crypto/subtle"
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"encoding/pem"
|
||||
"errors"
|
||||
"fmt"
|
||||
@@ -44,6 +48,8 @@ const (
|
||||
ctrlTimeout = 110 * time.Second
|
||||
maxBackoff = 60 * time.Second
|
||||
closeAuthFailed = 4003
|
||||
promTimeout = 8 * time.Second // как у панели к своему Prometheus
|
||||
metricsTimeout = 9 * time.Second // меньше scrape_timeout Prometheus (10 с по умолчанию)
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
@@ -58,6 +64,9 @@ type Config struct {
|
||||
CLIEnvFile string
|
||||
JoinSecret string // одноразовый, живёт до первого успешного подключения
|
||||
Version string
|
||||
PromURL string // Prometheus этой машины: к нему агент пропускает запросы панели (/prom/…)
|
||||
MetricsPort int // 127.0.0.1:порт, где Prometheus ноды скрейпит метрики панели; 0 — мониторинга на ноде нет
|
||||
MonitorHash string // хэш токена этого Prometheus (его пишет `awg-ctrl monitor install`)
|
||||
Logf, Errorf func(format string, a ...any)
|
||||
}
|
||||
|
||||
@@ -100,6 +109,9 @@ func ConfigFromEnv() (Config, error) {
|
||||
CLIEnvFile: os.Getenv("CLI_ENV_FILE"),
|
||||
JoinSecret: os.Getenv("JOIN_SECRET"),
|
||||
Version: os.Getenv("VERSION"),
|
||||
PromURL: or("PROM_URL", "http://127.0.0.1:9090"),
|
||||
MetricsPort: atoi("AGENT_METRICS_PORT"),
|
||||
MonitorHash: or("MONITOR_HASH_FILE", "/etc/amnezia/amneziawg/monitor_token.sha256"),
|
||||
}
|
||||
if c.Version == "" {
|
||||
c.Version = envFromFile(filepath.Join(cwd, "..", ".env"), "VERSION")
|
||||
@@ -120,8 +132,19 @@ type Agent struct {
|
||||
joinSecret string
|
||||
addrs []string // куда звонить: адрес от core, затем из join (CORE_HOST:CORE_PORT)
|
||||
cur int
|
||||
link *coreLink // соединение после ready; nil — core сейчас недоступен
|
||||
|
||||
http *http.Client
|
||||
prom *http.Client
|
||||
}
|
||||
|
||||
// coreLink — запросы ноды к core (пока один: метрики панели для Prometheus ноды).
|
||||
type coreLink struct {
|
||||
send func(v any)
|
||||
done <-chan struct{}
|
||||
mu sync.Mutex
|
||||
seq int64
|
||||
pending map[int64]chan reply
|
||||
}
|
||||
|
||||
// loadNodeKey — личность ноды (Ed25519): создаётся один раз, приватная часть с ноды не уходит.
|
||||
@@ -159,6 +182,7 @@ func New(c Config) (*Agent, error) {
|
||||
nodePubRaw: b64.EncodeToString(key.Public().(ed25519.PublicKey)),
|
||||
// awg-ctrl на loopback: системный прокси (HTTPS_PROXY и т. п.) сюда не нужен.
|
||||
http: &http.Client{Transport: &http.Transport{Proxy: nil}, Timeout: ctrlTimeout},
|
||||
prom: &http.Client{Transport: &http.Transport{Proxy: nil}, Timeout: promTimeout},
|
||||
}
|
||||
a.setAddrs(c.CoreEndpoint)
|
||||
if ik, err := iauth.LoadPrivate(c.InternalKey); err == nil {
|
||||
@@ -213,11 +237,13 @@ func errReply(status int, msg string) reply {
|
||||
return reply{status, jsjson.Object{{Key: "error", Val: msg}}}
|
||||
}
|
||||
|
||||
// execute выполняет запрос core на локальном awg-ctrl. Ответ возвращается как его разобрал
|
||||
// бы axios: JSON — значением, иначе строкой (пустое тело — "").
|
||||
// execute выполняет запрос core на локальном awg-ctrl (или, для /prom/…, на Prometheus).
|
||||
func (a *Agent) execute(m jsjson.Object) reply {
|
||||
method, _ := m.Get2("method").(string)
|
||||
method = strings.ToUpper(method)
|
||||
if s, _ := m.Get2("path").(string); strings.HasPrefix(s, "/prom/") {
|
||||
return a.execProm(method, s)
|
||||
}
|
||||
p, ok := pathAllowed(m.Get2("path"))
|
||||
if !methods[method] || !ok {
|
||||
return errReply(400, "Запрос не разрешён агентом")
|
||||
@@ -243,14 +269,19 @@ func (a *Agent) execute(m jsjson.Object) reply {
|
||||
if a.internalKey != nil {
|
||||
req.Header.Set("Authorization", "Bearer "+iauth.Mint(a.internalKey, "awg-agent", time.Now()))
|
||||
}
|
||||
resp, err := a.http.Do(req)
|
||||
return do(a.http, req, "awg-ctrl недоступен")
|
||||
}
|
||||
|
||||
// do — ответ как его разобрал бы axios: JSON — значением, иначе строкой (пустое тело — "").
|
||||
func do(c *http.Client, req *http.Request, down string) reply {
|
||||
resp, err := c.Do(req)
|
||||
if err != nil {
|
||||
return errReply(502, "awg-ctrl недоступен")
|
||||
return errReply(502, down)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
raw, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return errReply(502, "awg-ctrl недоступен")
|
||||
return errReply(502, down)
|
||||
}
|
||||
if len(raw) == 0 {
|
||||
return reply{resp.StatusCode, ""}
|
||||
@@ -261,6 +292,40 @@ func (a *Agent) execute(m jsjson.Object) reply {
|
||||
return reply{resp.StatusCode, string(raw)}
|
||||
}
|
||||
|
||||
// promPaths — всё, что панель читает у Prometheus: готовность и фиксированные запросы вкладки.
|
||||
var promPaths = map[string]bool{"/prom/-/ready": true, "/prom/api/v1/query": true, "/prom/api/v1/query_range": true}
|
||||
|
||||
// promAllowed — запрос панели к Prometheus этой машины: только GET и только пути из promPaths.
|
||||
func promAllowed(method string, p any) (string, bool) {
|
||||
s, ok := p.(string)
|
||||
if method != "GET" || !ok || len(s) > 8192 || !rePath.MatchString(s) {
|
||||
return "", false
|
||||
}
|
||||
pathname, query, _ := strings.Cut(s, "?")
|
||||
if !promPaths[pathname] || strings.Contains(query, "#") {
|
||||
return "", false
|
||||
}
|
||||
return strings.TrimPrefix(s, "/prom"), true
|
||||
}
|
||||
|
||||
// execProm — Prometheus ноды для вкладки «Монитор» панели. Пропускаем, только если он
|
||||
// скрейпит панель через этого агента (AGENT_METRICS_PORT): чужой Prometheus на машине
|
||||
// панели не отдаём, да и метрик панели в нём нет.
|
||||
func (a *Agent) execProm(method string, p any) reply {
|
||||
if a.c.MetricsPort == 0 {
|
||||
return errReply(404, "Prometheus на этой ноде не подключён к панели")
|
||||
}
|
||||
rest, ok := promAllowed(method, p)
|
||||
if !ok {
|
||||
return errReply(400, "Запрос не разрешён агентом")
|
||||
}
|
||||
req, err := http.NewRequest("GET", strings.TrimSuffix(a.c.PromURL, "/")+rest, nil)
|
||||
if err != nil {
|
||||
return errReply(400, "Запрос не разрешён агентом")
|
||||
}
|
||||
return do(a.prom, req, "Prometheus не отвечает")
|
||||
}
|
||||
|
||||
// ── Соединение с core ──────────────────────────────────────────────────────
|
||||
|
||||
func spkiPin(rawCert []byte) (string, error) {
|
||||
@@ -426,6 +491,7 @@ func (a *Agent) connectOnce(ctx context.Context, onReady func()) (authFailed boo
|
||||
return false
|
||||
}
|
||||
defer ws.CloseNow()
|
||||
defer a.setLink(nil)
|
||||
ws.SetReadLimit(maxPayload)
|
||||
|
||||
send := func(v any) {
|
||||
@@ -472,6 +538,7 @@ func (a *Agent) connectOnce(ctx context.Context, onReady func()) (authFailed boo
|
||||
case t == "ready":
|
||||
ready = true
|
||||
armDead()
|
||||
a.setLink(&coreLink{send: send, done: ctx.Done(), pending: map[int64]chan reply{}})
|
||||
if onReady != nil {
|
||||
onReady()
|
||||
}
|
||||
@@ -485,6 +552,8 @@ func (a *Agent) connectOnce(ctx context.Context, onReady func()) (authFailed boo
|
||||
if core, ok := m.Get2("core").(string); ok && a.learnCore(core) {
|
||||
send(jsjson.Object{{Key: "t", Val: "core_ok"}, {Key: "core", Val: core}})
|
||||
}
|
||||
case t == "metrics_res" && ready:
|
||||
a.deliver(m)
|
||||
case t == "req" && ready:
|
||||
id, ok := m.Get2("id").(float64)
|
||||
if !ok || id != float64(int64(id)) {
|
||||
@@ -499,6 +568,104 @@ func (a *Agent) connectOnce(ctx context.Context, onReady func()) (authFailed boo
|
||||
}
|
||||
}
|
||||
|
||||
// ── Метрики панели для Prometheus ноды ─────────────────────────────────────
|
||||
|
||||
func (a *Agent) setLink(l *coreLink) {
|
||||
a.mu.Lock()
|
||||
a.link = l
|
||||
a.mu.Unlock()
|
||||
}
|
||||
|
||||
// deliver — ответ core на запрос метрик.
|
||||
func (a *Agent) deliver(m jsjson.Object) {
|
||||
id, ok := m.Get2("id").(float64)
|
||||
a.mu.Lock()
|
||||
l := a.link
|
||||
a.mu.Unlock()
|
||||
if !ok || l == nil {
|
||||
return
|
||||
}
|
||||
l.mu.Lock()
|
||||
ch := l.pending[int64(id)]
|
||||
delete(l.pending, int64(id))
|
||||
l.mu.Unlock()
|
||||
if ch == nil {
|
||||
return
|
||||
}
|
||||
st, _ := m.Get2("status").(float64)
|
||||
text, _ := m.Get2("text").(string)
|
||||
ch <- reply{int(st), text}
|
||||
}
|
||||
|
||||
// coreMetrics — экспозиция /metrics панели, как её отдал бы сам core.
|
||||
func (a *Agent) coreMetrics(ctx context.Context) reply {
|
||||
a.mu.Lock()
|
||||
l := a.link
|
||||
a.mu.Unlock()
|
||||
if l == nil {
|
||||
return reply{503, "core недоступен\n"}
|
||||
}
|
||||
ch := make(chan reply, 1)
|
||||
l.mu.Lock()
|
||||
l.seq++
|
||||
id := l.seq
|
||||
l.pending[id] = ch
|
||||
l.mu.Unlock()
|
||||
defer func() {
|
||||
l.mu.Lock()
|
||||
delete(l.pending, id)
|
||||
l.mu.Unlock()
|
||||
}()
|
||||
l.send(jsjson.Object{{Key: "t", Val: "metrics"}, {Key: "id", Val: id}})
|
||||
select {
|
||||
case r := <-ch:
|
||||
return r
|
||||
case <-l.done:
|
||||
return reply{503, "core недоступен\n"}
|
||||
case <-ctx.Done():
|
||||
return reply{504, "core не ответил\n"}
|
||||
}
|
||||
}
|
||||
|
||||
// tokenOK — тот же токен, что встроенный Prometheus предъявил бы самой панели: хэш в файле,
|
||||
// перечитываем на каждый скрейп (раз в 15 с), чтобы переустановка не требовала перезапуска.
|
||||
func (a *Agent) tokenOK(r *http.Request) bool {
|
||||
given, ok := strings.CutPrefix(r.Header.Get("Authorization"), "Bearer ")
|
||||
raw, err := os.ReadFile(a.c.MonitorHash)
|
||||
if !ok || !strings.HasPrefix(given, "awgm_") || err != nil {
|
||||
return false
|
||||
}
|
||||
want, err := hex.DecodeString(strings.TrimSpace(string(raw)))
|
||||
sum := sha256.Sum256([]byte(given))
|
||||
return err == nil && len(want) == len(sum) && subtle.ConstantTimeCompare(sum[:], want) == 1
|
||||
}
|
||||
|
||||
// serveMetrics — 127.0.0.1:AGENT_METRICS_PORT/metrics для Prometheus этой ноды: метрики всех
|
||||
// серверов панели, полученные у core по уже открытому WSS. Наружу порт не открывается.
|
||||
func (a *Agent) serveMetrics(ctx context.Context) {
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/metrics", func(w http.ResponseWriter, r *http.Request) {
|
||||
if !a.tokenOK(r) {
|
||||
w.Header().Set("WWW-Authenticate", "Bearer")
|
||||
http.Error(w, "unauthorized", 401)
|
||||
return
|
||||
}
|
||||
cctx, cancel := context.WithTimeout(r.Context(), metricsTimeout)
|
||||
defer cancel()
|
||||
res := a.coreMetrics(cctx)
|
||||
text, _ := res.data.(string)
|
||||
w.Header().Set("Content-Type", "text/plain; version=0.0.4; charset=utf-8")
|
||||
w.WriteHeader(res.status)
|
||||
io.WriteString(w, text)
|
||||
})
|
||||
srv := &http.Server{Addr: "127.0.0.1:" + strconv.Itoa(a.c.MetricsPort), Handler: mux, ReadHeaderTimeout: 10 * time.Second}
|
||||
go func() { <-ctx.Done(); srv.Close() }()
|
||||
a.c.Logf("agent: метрики панели для Prometheus — http://%s/metrics", srv.Addr)
|
||||
if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
|
||||
a.c.Errorf("agent: метрики для Prometheus не подняты: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func hostPort(host string, port int) string {
|
||||
if strings.Contains(host, ":") && !strings.HasPrefix(host, "[") {
|
||||
host = "[" + host + "]"
|
||||
@@ -509,6 +676,9 @@ func hostPort(host string, port int) string {
|
||||
// Run переподключается вечно: экспоненциальная пауза до минуты, после отказа core — минута.
|
||||
func (a *Agent) Run(ctx context.Context) error {
|
||||
a.c.Logf("agent: нода #%d → wss://%s", a.c.NodeID, a.addr())
|
||||
if a.c.MetricsPort > 0 {
|
||||
go a.serveMetrics(ctx)
|
||||
}
|
||||
backoff := time.Second
|
||||
for {
|
||||
ready := false
|
||||
|
||||
@@ -4,9 +4,12 @@ import (
|
||||
"context"
|
||||
"crypto/ed25519"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"crypto/x509"
|
||||
"encoding/hex"
|
||||
"encoding/pem"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
@@ -351,3 +354,118 @@ func TestFallsBackToJoinAddress(t *testing.T) {
|
||||
t.Fatal("агент не перешёл на адрес из join")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPromAllowed(t *testing.T) {
|
||||
for p, want := range map[string]string{
|
||||
"/prom/-/ready": "/-/ready",
|
||||
"/prom/api/v1/query?query=up&time=1": "/api/v1/query?query=up&time=1",
|
||||
"/prom/api/v1/query_range?query=up": "/api/v1/query_range?query=up",
|
||||
"/prom/api/v1/admin/tsdb/delete_series": "",
|
||||
"/prom/api/v1/query/../admin": "",
|
||||
"/prom//api/v1/query": "",
|
||||
"/prom/api/v1/query?query=up#x": "",
|
||||
"/prom/api/v1/query?query=up\nHost: x": "",
|
||||
"/prom/api/v1/labels": "",
|
||||
} {
|
||||
got, ok := promAllowed("GET", p)
|
||||
if want == "" && ok || want != "" && got != want {
|
||||
t.Errorf("promAllowed(%q) = %q, %v", p, got, ok)
|
||||
}
|
||||
}
|
||||
if _, ok := promAllowed("POST", "/prom/api/v1/query"); ok {
|
||||
t.Error("POST к Prometheus пропущен")
|
||||
}
|
||||
a := &Agent{c: Config{PromURL: "http://127.0.0.1:1"}}
|
||||
if r := a.execProm("GET", "/prom/-/ready"); r.status != 404 {
|
||||
t.Errorf("без AGENT_METRICS_PORT: %d", r.status)
|
||||
}
|
||||
}
|
||||
|
||||
// Prometheus на ноде: запросы панели доходят до него, а его скрейп получает метрики core.
|
||||
func TestPrometheusOnNode(t *testing.T) {
|
||||
prom := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case "/api/v1/query":
|
||||
io.WriteString(w, `{"status":"success","q":"`+r.URL.Query().Get("query")+`"}`)
|
||||
case "/-/ready":
|
||||
io.WriteString(w, "Prometheus Server is Ready.\n")
|
||||
default:
|
||||
w.WriteHeader(500)
|
||||
}
|
||||
}))
|
||||
defer prom.Close()
|
||||
|
||||
results := make(chan jsjson.Object, 8)
|
||||
c := newCore(t, func(ctx context.Context, ws *websocket.Conn, read func() jsjson.Object) {
|
||||
send(ctx, ws, jsjson.Object{{Key: "t", Val: "hello"}, {Key: "nonce", Val: "n"}})
|
||||
if read() == nil {
|
||||
return
|
||||
}
|
||||
send(ctx, ws, jsjson.Object{{Key: "t", Val: "ready"}})
|
||||
send(ctx, ws, jsjson.Object{{Key: "t", Val: "req"}, {Key: "id", Val: 1}, {Key: "method", Val: "GET"}, {Key: "path", Val: "/prom/api/v1/query?query=up"}})
|
||||
send(ctx, ws, jsjson.Object{{Key: "t", Val: "req"}, {Key: "id", Val: 2}, {Key: "method", Val: "GET"}, {Key: "path", Val: "/prom/api/v1/admin/tsdb/snapshot"}})
|
||||
send(ctx, ws, jsjson.Object{{Key: "t", Val: "req"}, {Key: "id", Val: 3}, {Key: "method", Val: "GET"}, {Key: "path", Val: "/prom/-/ready"}})
|
||||
for {
|
||||
m := read()
|
||||
if m == nil {
|
||||
return
|
||||
}
|
||||
switch m.Get2("t") {
|
||||
case "res":
|
||||
results <- m
|
||||
case "metrics":
|
||||
send(ctx, ws, jsjson.Object{{Key: "t", Val: "metrics_res"}, {Key: "id", Val: m.Get2("id")}, {Key: "status", Val: 200}, {Key: "text", Val: "awg_up 1\n"}})
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
l, _ := net.Listen("tcp", "127.0.0.1:0")
|
||||
port := l.Addr().(*net.TCPAddr).Port
|
||||
l.Close()
|
||||
a, _ := newAgent(t, c, c.pin(t), "http://127.0.0.1:1")
|
||||
a.c.PromURL, a.c.MetricsPort = prom.URL, port
|
||||
a.c.MonitorHash = filepath.Join(t.TempDir(), "monitor_token.sha256")
|
||||
sum := sha256.Sum256([]byte("awgm_good"))
|
||||
os.WriteFile(a.c.MonitorHash, []byte(hex.EncodeToString(sum[:])+"\n"), 0o644)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
go a.Run(ctx)
|
||||
|
||||
byID := map[float64]string{}
|
||||
for range 3 {
|
||||
select {
|
||||
case r := <-results:
|
||||
byID[r.Get2("id").(float64)] = string(jsjson.MustMarshal(r))
|
||||
case <-ctx.Done():
|
||||
t.Fatal("нет ответов агента")
|
||||
}
|
||||
}
|
||||
for id, want := range map[float64]string{
|
||||
1: `{"t":"res","id":1,"status":200,"data":{"status":"success","q":"up"}}`,
|
||||
2: `{"t":"res","id":2,"status":400,"data":{"error":"Запрос не разрешён агентом"}}`,
|
||||
3: `{"t":"res","id":3,"status":200,"data":"Prometheus Server is Ready.\n"}`,
|
||||
} {
|
||||
if byID[id] != want {
|
||||
t.Errorf("res %v:\n got %s\nwant %s", id, byID[id], want)
|
||||
}
|
||||
}
|
||||
|
||||
scrape := func(tok string) (int, string) {
|
||||
req, _ := http.NewRequest("GET", "http://127.0.0.1:"+strconv.Itoa(port)+"/metrics", nil)
|
||||
req.Header.Set("Authorization", "Bearer "+tok)
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
b, _ := io.ReadAll(resp.Body)
|
||||
return resp.StatusCode, string(b)
|
||||
}
|
||||
if st, _ := scrape("awgm_bad"); st != 401 {
|
||||
t.Errorf("чужой токен: %d", st)
|
||||
}
|
||||
if st, body := scrape("awgm_good"); st != 200 || body != "awg_up 1\n" {
|
||||
t.Errorf("скрейп: %d %q", st, body)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user