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:
2026-10-21 19:25:54 +03:00
parent 7ee126af30
commit 96c21408e6
20 changed files with 980 additions and 65 deletions
+4
View File
@@ -280,6 +280,10 @@ func (m *Migration) importHandler(c *Ctx) bool {
if err := envfile.Set(m.envFile, plan.Env); err != nil {
return fail(err)
}
// Подписка и хаб переезжают на порты старого сервера — их надо открыть, как install.sh.
if opened, failed := migrate.OpenFirewall(plan.SubPort, plan.NodePort); len(opened)+len(failed) > 0 {
logf("migrate: UFW открыт %v, не открылись %v", opened, failed)
}
join := migrate.JoinString(host, plan.NodePort, pin, 0, secret, plan.UIPort, plan.SubPort)
m.state = moveImported
logf("migrate: панель «%s» принята с %s — перезапуск", b.Str("server_name"), c.remoteIP())
+155 -39
View File
@@ -2,10 +2,12 @@ package ui
// Вкладка «Монитор» (порт awg-ui/monitor.ts): встроенный Prometheus и основные графики.
// GET /ui/monitor — готов ли Prometheus, можно ли поставить его кнопкой, ход установки
// POST /ui/monitor/install — попросить локальный awg-ctrl выполнить `awg-ctrl monitor install` (root)
// POST /ui/monitor/install — попросить awg-ctrl выполнить `awg-ctrl monitor install` (root)
// POST /ui/monitor/remove — то же для `awg-ctrl monitor remove`
// GET /ui/monitor/overview — серии для графиков за 1 ч / 6 ч / 24 ч / 7 дн / 30 дн
// Панель сама пакетов не ставит и в Prometheus ходит только фиксированными запросами.
// Prometheus — на этой машине или на ноде (панель без VPN, например после переноса): тогда и
// запросы к нему, и его скрейп метрик панели идут через агента ноды по её WSS.
import (
"errors"
@@ -80,8 +82,22 @@ type Monitor struct {
promURL string
localEnabled bool
http *http.Client
srcMu sync.Mutex
src *promSource // найденный Prometheus; nil — ещё не искали или не нашли
srcAt time.Time
}
// promSource — где Prometheus: local — на этой машине, иначе на ноде node (через её агента).
type promSource struct {
local bool
node int
}
// Найденный Prometheus помним недолго; ненайденный ищем при каждом запросе (вкладка
// запрашивает статус редко, а во время установки должна увидеть его сразу).
const promSourceTTL = 30 * time.Second
func newMonitor(a *App) *Monitor {
return &Monitor{
a: a, promURL: envOr("PROM_URL", "http://127.0.0.1:9090"),
@@ -92,14 +108,72 @@ func newMonitor(a *App) *Monitor {
var errProm = errors.New("Prometheus не отвечает")
func (m *Monitor) rangeQuery(query string, start, end, step int64) (*series, error) {
q := url.Values{}
q.Set("query", query)
q.Set("start", strconv.FormatInt(start, 10))
q.Set("end", strconv.FormatInt(end, 10))
q.Set("step", strconv.FormatInt(step, 10))
// source — свой Prometheus, если он есть; иначе первый по id сервер, чей агент отдаёт свой.
func (m *Monitor) source() *promSource {
m.srcMu.Lock()
if m.src != nil && time.Since(m.srcAt) < promSourceTTL {
s := m.src
m.srcMu.Unlock()
return s
}
m.srcMu.Unlock()
s := m.findSource()
m.srcMu.Lock()
m.src, m.srcAt = s, time.Now()
m.srcMu.Unlock()
return s
}
// forgetSource — Prometheus поставили, удалили или он перестал отвечать: искать заново.
func (m *Monitor) forgetSource() {
m.srcMu.Lock()
m.src = nil
m.srcMu.Unlock()
}
func (m *Monitor) findSource() *promSource {
if m.promReady() {
return &promSource{local: true}
}
ids := m.a.nodes.IDs()
ready := make([]bool, len(ids))
var wg sync.WaitGroup
for i, id := range ids {
// Сервер 0 со своим awg-ctrl — эта же машина: её Prometheus уже проверен выше.
if id == 0 && m.localEnabled || !m.a.nodes.IsOnline(id) {
continue
}
wg.Add(1)
go func() {
defer wg.Done()
r, err := m.a.nodes.CtrlFor(id)("GET", "/prom/-/ready", nil)
ready[i] = err == nil && r.Status == 200
}()
}
wg.Wait()
for i, id := range ids {
if ready[i] {
return &promSource{node: id}
}
}
return nil
}
// promGet — JSON-ответ Prometheus (path с запросом): напрямую или через агента ноды.
func (m *Monitor) promGet(src *promSource, path string, q url.Values) (any, error) {
if src == nil {
return nil, errProm
}
path += "?" + q.Encode()
if !src.local {
r, err := m.a.nodes.CtrlFor(src.node)("GET", "/prom"+path, nil)
if err != nil || r.Status < 200 || r.Status >= 300 {
return nil, errProm
}
return r.Data, nil
}
c := &http.Client{Transport: m.http.Transport, Timeout: 8 * time.Second}
resp, err := c.Get(m.promURL + "/api/v1/query_range?" + q.Encode())
resp, err := c.Get(m.promURL + path)
if err != nil {
return nil, errProm
}
@@ -108,8 +182,21 @@ func (m *Monitor) rangeQuery(query string, start, end, step int64) (*series, err
if err != nil || resp.StatusCode < 200 || resp.StatusCode >= 300 {
return nil, errProm
}
return decodeLikeAxios(raw), nil
}
func (m *Monitor) rangeQuery(src *promSource, query string, start, end, step int64) (*series, error) {
q := url.Values{}
q.Set("query", query)
q.Set("start", strconv.FormatInt(start, 10))
q.Set("end", strconv.FormatInt(end, 10))
q.Set("step", strconv.FormatInt(step, 10))
data, err := m.promGet(src, "/api/v1/query_range", q)
if err != nil {
return nil, err
}
out := &series{m: map[int]*seriesOne{}}
for _, r := range arr(field(decodeLikeAxios(raw), "data", "result")) {
for _, r := range arr(field(data, "data", "result")) {
metric := field(r, "metric")
idf := jsToNumberAny(field(metric, "server_id"))
if field(metric, "server_id") == nil {
@@ -146,22 +233,16 @@ func (m *Monitor) rangeQuery(query string, start, end, step int64) (*series, err
}
// instantQuery — значение на момент time по каждому server_id.
func (m *Monitor) instantQuery(query string, time_ int64) (map[int]float64, error) {
func (m *Monitor) instantQuery(src *promSource, query string, time_ int64) (map[int]float64, error) {
q := url.Values{}
q.Set("query", query)
q.Set("time", strconv.FormatInt(time_, 10))
c := &http.Client{Transport: m.http.Transport, Timeout: 8 * time.Second}
resp, err := c.Get(m.promURL + "/api/v1/query?" + q.Encode())
data, err := m.promGet(src, "/api/v1/query", q)
if err != nil {
return nil, errProm
}
defer resp.Body.Close()
raw, err := io.ReadAll(resp.Body)
if err != nil || resp.StatusCode < 200 || resp.StatusCode >= 300 {
return nil, errProm
return nil, err
}
out := map[int]float64{}
for _, r := range arr(field(decodeLikeAxios(raw), "data", "result")) {
for _, r := range arr(field(data, "data", "result")) {
metric := field(r, "metric")
if field(metric, "server_id") == nil {
continue
@@ -228,12 +309,43 @@ type monitorJob struct {
log string
}
// jobState — установка возможна, только если рядом свой awg-ctrl и он знает маршрут.
func (m *Monitor) jobState() monitorJob {
if !m.localEnabled {
// target — чей awg-ctrl ставит и удаляет Prometheus: того сервера, где он уже стоит; если его
// нет — свой (панель + VPN) или выбранный в сервер-баре (панель без VPN). -1 — некому: например,
// Prometheus на машине панели без VPN поставлен руками.
func (m *Monitor) target(src *promSource, qs url.Values) int {
switch {
case src != nil && !src.local:
return src.node
case m.localEnabled:
return 0
case src != nil:
return -1
}
if f := jsToNumber(strings.Join(qs["server"], ",")); f == float64(int(f)) && f >= 0 && m.a.nodes.Exists(int(f)) {
return int(f)
}
return -1
}
// host — сервер target, если это не машина панели (её вкладка так и называет: «этот сервер»).
func (m *Monitor) host(target int) any {
if target < 0 || target == 0 && m.localEnabled {
return nil
}
for _, s := range m.a.nodes.List() {
if s.ID == target {
return jsjson.Object{{Key: "id", Val: s.ID}, {Key: "name", Val: s.Name}}
}
}
return nil
}
// jobState — установка возможна, только если у target есть awg-ctrl и он знает маршрут.
func (m *Monitor) jobState(target int) monitorJob {
if target < 0 {
return monitorJob{}
}
r, err := m.a.ctrlLocal("GET", "/api/monitor/job", nil)
r, err := m.a.nodes.CtrlFor(target)("GET", "/api/monitor/job", nil)
if err != nil || r.Status != 200 {
return monitorJob{}
}
@@ -251,27 +363,26 @@ func (m *Monitor) jobState() monitorJob {
func (m *Monitor) router() *Router {
rt := newRouter()
rt.Get("/", func(c *Ctx) bool {
var ready bool
var job monitorJob
var wg sync.WaitGroup
wg.Add(2)
go func() { defer wg.Done(); ready = m.promReady() }()
go func() { defer wg.Done(); job = m.jobState() }()
wg.Wait()
src := m.source()
ready := src != nil
target := m.target(src, c.R.URL.Query())
job := m.jobState(target)
var jobOut any
if job.can && truthy(job.action) {
jobOut = jsjson.Object{{Key: "action", Val: job.action}, {Key: "running", Val: job.running}, {Key: "exit", Val: job.exit}, {Key: "log", Val: job.log}}
}
return c.JSON(200, jsjson.Object{{Key: "ready", Val: ready}, {Key: "can_install", Val: job.can}, {Key: "job", Val: jobOut},
{Key: "command", Val: "awg-ctrl monitor install"}})
{Key: "command", Val: "awg-ctrl monitor install"}, {Key: "host", Val: m.host(target)}})
})
// Установка и удаление — одно и то же: просим локальный awg-ctrl, он запускает CLI от root.
// Установка и удаление — одно и то же: просим awg-ctrl сервера, он запускает CLI от root.
ask := func(action string) handler {
return func(c *Ctx) bool {
if !m.localEnabled {
return c.Error(409, "У этой панели нет своего VPN-сервера: выполните команду на машине")
target := m.target(m.source(), c.R.URL.Query())
if target < 0 {
return c.Error(409, "Некому поставить Prometheus: выберите VPN-сервер или выполните команду на машине")
}
r, err := m.a.ctrlLocal("POST", "/api/monitor/"+action, nil)
m.forgetSource()
r, err := m.a.nodes.CtrlFor(target)("POST", "/api/monitor/"+action, nil)
if err != nil {
return c.Error(502, "awg-ctrl недоступен")
}
@@ -294,6 +405,7 @@ func (m *Monitor) router() *Router {
if !ok {
return c.Error(400, "range: 1h, 6h, 24h, 7d или 30d")
}
src := m.source()
end := now()
start := end - rg.seconds
window := promRateWindow
@@ -310,18 +422,21 @@ func (m *Monitor) router() *Router {
var wg sync.WaitGroup
for i, pq := range promQueries {
wg.Add(1)
go func() { defer wg.Done(); got[i], errs[i] = m.rangeQuery(withWindow(pq.q, window), start, end, rg.step) }()
go func() {
defer wg.Done()
got[i], errs[i] = m.rangeQuery(src, withWindow(pq.q, window), start, end, rg.step)
}()
}
span := strconv.FormatInt(rg.seconds, 10) + "s"
n := len(promQueries)
wg.Add(2)
go func() {
defer wg.Done()
usedDown, errs[n] = m.instantQuery(`sum by (server_id, server) (increase(awg_link_transmit_bytes_total[`+span+`]))`, end)
usedDown, errs[n] = m.instantQuery(src, `sum by (server_id, server) (increase(awg_link_transmit_bytes_total[`+span+`]))`, end)
}()
go func() {
defer wg.Done()
usedUp, errs[n+1] = m.instantQuery(`sum by (server_id, server) (increase(awg_link_receive_bytes_total[`+span+`]))`, end)
usedUp, errs[n+1] = m.instantQuery(src, `sum by (server_id, server) (increase(awg_link_receive_bytes_total[`+span+`]))`, end)
}()
if window != promRateWindow {
for j, k := range rated {
@@ -332,7 +447,7 @@ func (m *Monitor) router() *Router {
wg.Add(1)
go func() {
defer wg.Done()
v, err := m.instantQuery(pq.q, end)
v, err := m.instantQuery(src, pq.q, end)
errs[n+2+j] = err
mu.Lock()
nowVals[k] = v
@@ -344,6 +459,7 @@ func (m *Monitor) router() *Router {
wg.Wait()
for _, err := range errs {
if err != nil {
m.forgetSource()
return c.Error(502, err.Error())
}
}
+143
View File
@@ -0,0 +1,143 @@
package ui
import (
"context"
"crypto/ed25519"
"crypto/rand"
"crypto/sha256"
"crypto/x509"
"encoding/hex"
"encoding/pem"
"io"
"net"
"net/http"
"net/http/httptest"
"os"
"os/exec"
"path/filepath"
"strconv"
"strings"
"testing"
"time"
"github.com/maeneko/forgetting/internal/agent"
)
func freePort(t *testing.T) int {
l, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
defer l.Close()
return l.Addr().(*net.TCPAddr).Port
}
// Панель без VPN, Prometheus — на ноде #0 (сервер, с которого панель переехала): вкладка
// читает его через агента, а сам он скрейпит метрики панели у агента, тот — у core.
func TestPrometheusOnNodeZero(t *testing.T) {
if _, err := exec.LookPath("openssl"); err != nil {
t.Skip("нет openssl для сертификата хаба")
}
dir := t.TempDir()
hubPort := freePort(t)
t.Setenv("NODE_TLS_KEY_FILE", filepath.Join(dir, "node_tls.key"))
t.Setenv("NODE_TLS_CERT_FILE", filepath.Join(dir, "node_tls.crt"))
a := testApp(t, dir, "off")
t.Setenv("NODE_PORT", strconv.Itoa(hubPort))
var err error
if a.nodes, err = newNodes(a); err != nil {
t.Fatal(err)
}
if a.metrics, err = newMetrics(a); err != nil {
t.Fatal(err)
}
pub, priv, _ := ed25519.GenerateKey(rand.Reader)
der, _ := x509.MarshalPKCS8PrivateKey(priv)
keyFile := filepath.Join(dir, "node.key")
os.WriteFile(keyFile, pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: der}), 0o600)
if _, err := a.db.Exec("INSERT INTO nodes (id, name, pub, created_at) VALUES (0, 'VPN', ?, 1)", b64url.EncodeToString(pub)); err != nil {
t.Fatal(err)
}
ctrl := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/api/metrics":
io.WriteString(w, `{"up":true,"peers":{"total":2,"online":1}}`)
case "/health":
io.WriteString(w, `{"status":"ok","server":"VPN"}`)
default:
w.WriteHeader(404)
}
}))
defer ctrl.Close()
prom := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/-/ready":
io.WriteString(w, "Prometheus Server is Ready.\n")
case "/api/v1/query_range":
io.WriteString(w, `{"status":"success","data":{"resultType":"matrix","result":[{"metric":{"server":"VPN","server_id":"0"},"values":[[100,"3"],[115,"4"]]}]}}`)
default:
w.WriteHeader(400)
}
}))
defer prom.Close()
a.nodes.Start()
metricsPort := freePort(t)
hashFile := filepath.Join(dir, "monitor_token.sha256")
sum := sha256.Sum256([]byte("awgm_node"))
os.WriteFile(hashFile, []byte(hex.EncodeToString(sum[:])+"\n"), 0o644)
ag, err := agent.New(agent.Config{
CoreHost: "127.0.0.1", CorePort: hubPort, CorePin: b64url.EncodeToString(a.nodes.pin), NodeID: 0,
Ctrl: ctrl.URL, NodeKeyFile: keyFile, InternalKey: filepath.Join(dir, "none"),
PromURL: prom.URL, MetricsPort: metricsPort, MonitorHash: hashFile,
Logf: t.Logf, Errorf: t.Logf,
})
if err != nil {
t.Fatal(err)
}
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
go ag.Run(ctx)
for !a.nodes.IsOnline(0) {
if ctx.Err() != nil {
t.Fatal("нода #0 не подключилась")
}
time.Sleep(20 * time.Millisecond)
}
m := newMonitor(a)
m.promURL = "http://127.0.0.1:1" // своего Prometheus у панели нет
src := m.source()
if src == nil || src.local || src.node != 0 {
t.Fatalf("Prometheus не найден на ноде #0: %+v", src)
}
s, err := m.rangeQuery(src, "awg_peers_online", 100, 115, 15)
if err != nil || len(s.ids) != 1 || s.m[0].name != "VPN" || len(s.m[0].points) != 2 || s.m[0].points[1][1] != 4 {
t.Fatalf("query_range через агента: %+v %v", s, err)
}
if h := m.host(m.target(src, nil)); h == nil {
t.Error("вкладка не знает, на каком сервере Prometheus")
}
req, _ := http.NewRequest("GET", "http://127.0.0.1:"+strconv.Itoa(metricsPort)+"/metrics", nil)
req.Header.Set("Authorization", "Bearer awgm_node")
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatal(err)
}
body, _ := io.ReadAll(resp.Body)
resp.Body.Close()
if resp.StatusCode != 200 || !strings.Contains(string(body), `awg_peers_online{server="VPN",server_id="0"} 1`) {
t.Errorf("скрейп через агента: %d\n%s", resp.StatusCode, body)
}
// Второй скрейп сразу же — отказ: каждый опрашивает все серверы.
resp, err = http.DefaultClient.Do(req)
if err == nil {
resp.Body.Close()
if resp.StatusCode != 429 {
t.Errorf("частый скрейп: %d", resp.StatusCode)
}
}
}
+34 -2
View File
@@ -43,8 +43,10 @@ const (
rpcTimeout = 15 * time.Second
rpcSlow = 120 * time.Second // /awg/*: рестарт и upgrade идут долго
hubMaxFrame = 4 * 1024 * 1024
failWindow = 15 * time.Minute
failMaxHits = 20
// Метрики для Prometheus на ноде: каждый ответ опрашивает все серверы — не чаще этого.
metricsEvery = 5 * time.Second
failWindow = 15 * time.Minute
failMaxHits = 20
)
var (
@@ -110,6 +112,9 @@ type nodeConn struct {
alive bool
pending map[int64]*pending
closed bool
// Запрос метрик от ноды (Prometheus на ней): идёт ли сейчас и когда начат последний.
metricsBusy bool
metricsAt time.Time
}
type deleteVeto struct {
@@ -585,6 +590,10 @@ func (n *Nodes) serveWS(w http.ResponseWriter, r *http.Request) {
}
continue
}
if m.Get2("t") == "metrics" && isInt(m.Get2("id")) {
go n.nodeMetrics(conn, m.Get2("id"))
continue
}
if m.Get2("t") == "res" && isInt(m.Get2("id")) {
rid := int64(m.Get2("id").(float64))
conn.mu.Lock()
@@ -608,6 +617,29 @@ func (n *Nodes) serveWS(w http.ResponseWriter, r *http.Request) {
}
}
// nodeMetrics — Prometheus на ноде скрейпит метрики панели через её агента: та же экспозиция,
// что /metrics ядра, со всеми серверами (docs/node-protocol.md, «Мониторинг на ноде»).
func (n *Nodes) nodeMetrics(c *nodeConn, id any) {
c.mu.Lock()
t := time.Now()
busy := c.metricsBusy || t.Sub(c.metricsAt) < metricsEvery
if !busy {
c.metricsBusy, c.metricsAt = true, t
}
c.mu.Unlock()
res := jsjson.Object{{Key: "t", Val: "metrics_res"}, {Key: "id", Val: id}}
if busy {
res = append(res, jsjson.KV{Key: "status", Val: 429}, jsjson.KV{Key: "text", Val: "слишком часто\n"})
} else {
text := n.a.metrics.render()
c.mu.Lock()
c.metricsBusy = false
c.mu.Unlock()
res = append(res, jsjson.KV{Key: "status", Val: 200}, jsjson.KV{Key: "text", Val: text})
}
c.ws.Write(context.Background(), websocket.MessageText, jsjson.MustMarshal(res))
}
// Start поднимает хаб (если настроен) и периодические ping/health.
func (n *Nodes) Start() {
if n.local {
+4 -1
View File
@@ -14,6 +14,7 @@ import (
"net/http"
"net/url"
"os"
"path"
"path/filepath"
"regexp"
"sort"
@@ -908,7 +909,9 @@ func (a *App) static(c *Ctx) bool {
if st, err := os.Stat(f); err == nil && !hidden {
if st.IsDir() {
if !strings.HasSuffix(c.R.URL.Path, "/") {
http.Redirect(c.W, c.R, c.R.URL.Path+"/", http.StatusMovedPermanently)
// Относительный адрес: за прокси в подпапке абсолютный увёл бы из неё.
c.W.Header().Set("Location", path.Base(c.R.URL.Path)+"/")
c.W.WriteHeader(http.StatusMovedPermanently)
return false
}
f = filepath.Join(f, "index.html")
+88
View File
@@ -0,0 +1,88 @@
package ui
// Панель за reverse-proxy (nginx и т. п., в корне домена или в подпапке): соединение приходит от
// прокси, а адрес клиента — в X-Forwarded-For / X-Real-IP. Иначе все клиенты были бы одним IP и
// делили бы лимиты по адресу (неудачные входы, регистрации). Заголовкам верим только от прокси:
// loopback (прокси на этой же машине) всегда, остальные адреса и сети — из TRUST_PROXY. От всех
// прочих заголовки игнорируются — подделать свой адрес клиенту нечем.
import (
"net"
"os"
"strconv"
"strings"
"sync"
)
// trustedProxies — TRUST_PROXY: IP и сети (CIDR) через запятую; loopback доверен без него.
var trustedProxies = sync.OnceValue(func() []*net.IPNet { return parseTrustProxy(os.Getenv("TRUST_PROXY")) })
func parseTrustProxy(v string) []*net.IPNet {
var out []*net.IPNet
for _, s := range strings.Split(v, ",") {
s = strings.TrimSpace(s)
if s == "" {
continue
}
if !strings.Contains(s, "/") {
if ip := net.ParseIP(s); ip != nil {
bits := 128
if ip.To4() != nil {
bits = 32
}
s = ip.String() + "/" + strconv.Itoa(bits)
}
}
if _, n, err := net.ParseCIDR(s); err == nil {
out = append(out, n)
} else {
warnf("TRUST_PROXY: «%s» — не IP и не сеть, пропущено", s)
}
}
return out
}
func isTrustedProxy(ip net.IP, nets []*net.IPNet) bool {
if ip.IsLoopback() {
return true
}
for _, n := range nets {
if n.Contains(ip) {
return true
}
}
return false
}
// clientIP — адрес клиента: от доверенного прокси — из X-Forwarded-For (справа налево, первый
// адрес не из доверенных: левые части клиент мог дописать сам) или X-Real-IP; иначе — сам peer.
func clientIP(peer string, h interface{ Values(string) []string }, nets []*net.IPNet) string {
ip := net.ParseIP(peer)
if ip == nil || !isTrustedProxy(ip, nets) {
return peer
}
var hops []string
for _, v := range h.Values("X-Forwarded-For") {
hops = append(hops, strings.Split(v, ",")...)
}
found := ""
for i := len(hops) - 1; i >= 0; i-- {
hop := net.ParseIP(strings.TrimSpace(hops[i]))
if hop == nil {
break
}
found = hop.String()
if !isTrustedProxy(hop, nets) {
return found
}
}
if found != "" {
return found
}
for _, v := range h.Values("X-Real-IP") {
if r := net.ParseIP(strings.TrimSpace(v)); r != nil {
return r.String()
}
}
return peer
}
+57
View File
@@ -0,0 +1,57 @@
package ui
import (
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"testing"
)
// За reverse-proxy адрес клиента берётся из его заголовков, но только когда соединение пришло от
// самого прокси (loopback или TRUST_PROXY): напрямую подставить себе чужой IP нельзя.
func TestClientIP(t *testing.T) {
nets := parseTrustProxy("10.0.0.0/8, 192.0.2.7, мусор")
for _, c := range []struct {
name, peer string
xff []string
real, want string
}{
{"напрямую, заголовки игнорируются", "203.0.113.5", []string{"1.2.3.4"}, "1.2.3.4", "203.0.113.5"},
{"nginx на этой машине", "127.0.0.1", []string{"203.0.113.5"}, "", "203.0.113.5"},
{"IPv6 loopback", "::1", []string{"2001:db8::5"}, "", "2001:db8::5"},
{"клиент дописал себе адрес слева", "127.0.0.1", []string{"6.6.6.6, 203.0.113.5"}, "", "203.0.113.5"},
{"цепочка доверенных прокси", "10.1.1.1", []string{"203.0.113.5, 10.2.2.2", "192.0.2.7"}, "", "203.0.113.5"},
{"только X-Real-IP", "127.0.0.1", nil, "203.0.113.9", "203.0.113.9"},
{"прокси без заголовков", "127.0.0.1", nil, "", "127.0.0.1"},
{"мусор в X-Forwarded-For", "127.0.0.1", []string{"unknown"}, "203.0.113.9", "203.0.113.9"},
{"чужой прокси не доверен", "198.51.100.1", []string{"203.0.113.5"}, "", "198.51.100.1"},
} {
h := http.Header{}
for _, v := range c.xff {
h.Add("X-Forwarded-For", v)
}
if c.real != "" {
h.Set("X-Real-IP", c.real)
}
if got := clientIP(c.peer, h, nets); got != c.want {
t.Errorf("%s: %s, ожидали %s", c.name, got, c.want)
}
}
if len(nets) != 2 {
t.Errorf("TRUST_PROXY разобран в %d сетей", len(nets))
}
}
// Каталог без слэша — относительный редирект: за прокси в подпапке абсолютный увёл бы из неё.
func TestStaticRelativeRedirect(t *testing.T) {
dir := t.TempDir()
os.MkdirAll(filepath.Join(dir, "dist", "assets"), 0o755)
os.WriteFile(filepath.Join(dir, "dist", "assets", "index.html"), []byte("x"), 0o644)
a := &App{baseDir: dir}
w := httptest.NewRecorder()
a.static(&Ctx{W: w, R: httptest.NewRequest("GET", "/assets", nil)})
if w.Code != 301 || w.Header().Get("Location") != "assets/" {
t.Errorf("%d %q", w.Code, w.Header().Get("Location"))
}
}
+2 -1
View File
@@ -212,12 +212,13 @@ func (c *Ctx) Obj() jsjson.Object {
}
// remoteIP — req.socket.remoteAddress.
// remoteIP — адрес клиента; за доверенным reverse-proxy — из его заголовков (trustproxy.go).
func (c *Ctx) remoteIP() string {
host, _, err := net.SplitHostPort(c.R.RemoteAddr)
if err != nil {
return "unknown"
}
return host
return clientIP(host, c.R.Header, trustedProxies())
}
// hostname — req.hostname в Express (trust proxy выключен): Host без порта, у IPv6 — в скобках.