From 13da9a72000b1c40b8f823b435df1ed9a39c17c7 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 28 Oct 2026 11:41:09 +0000 Subject: [PATCH] Secret URL path for the panel The panel port is open to the internet, and its login page and JS bundle gave it away to any scanner in one GET. The installer now generates a random UI_PREFIX: from outside the panel answers only under http://IP:PORT// and gives Go's plain "404 page not found" to everything else. Loopback needs no prefix (CLI, local Prometheus, nginx on the same host); while a move code is out, POST /migrate/v1/import is let through without it, since the code carries no path. A reinstall with kept data keeps the prefix; UI_PREFIX=off or a custom value can be set before running the installer. A moved panel brings its prefix in UI_PREFIX_MOVED, so old bookmarks and API clients keep working through the relay. awg-ctrl status and credentials show the full panel address. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013bNGYiDst62wH3ZGTRPFLJ --- README.md | 18 +++++++++ install.sh | 32 ++++++++++++++- internal/cli/cli.go | 25 ++++++++++++ internal/cli/cmds.go | 3 ++ internal/cli/migrate.go | 8 +++- internal/migrate/migrate.go | 11 +++++ internal/ui/migrate.go | 14 ++++++- internal/ui/prefix.go | 81 +++++++++++++++++++++++++++++++++++++ internal/ui/prefix_test.go | 57 ++++++++++++++++++++++++++ internal/ui/server.go | 20 ++++----- 10 files changed, 256 insertions(+), 13 deletions(-) create mode 100644 internal/ui/prefix.go create mode 100644 internal/ui/prefix_test.go diff --git a/README.md b/README.md index 899db98..f4bc909 100644 --- a/README.md +++ b/README.md @@ -57,6 +57,24 @@ AWG_GEN=auto bash <(curl -Ls https://raw.githubusercontent.com/maeneko/forgettin `REPO_BASE`, `REPO_FALLBACK`, `ARCHIVE_URL`, `BRAND` перед запуском установщика (подробности — в комментариях `install.sh` и в `.env`). +### Секретный путь панели + +Установщик выдаёт панели случайный путь: она открывается по `http://IP:PORT/<путь>/`, а на +любой другой адрес отвечает `404 page not found`, как пустой веб-сервер. Порт панели открыт +в интернет, и без пути сканер узнавал её по странице логина. Путь хранится в `UI_PREFIX` +в `/opt/awg-control/cli/cli.env`; полный адрес показывают `awg-ctrl status` и +`awg-ctrl credentials`. Внешнему API нужен тот же путь: `http://HOST:PORT/<путь>/api/v1/…` +(в примерах ниже `HOST:PORT` — вместе с путём). + +- При переустановке с сохранением данных путь остаётся прежним. Свой путь — + `UI_PREFIX=<8–64 символа A–Z a–z 0–9 _ ->`, без пути — `UI_PREFIX=off` перед запуском + установщика (или правка `cli.env` и `awg-ctrl restart`). +- С этой же машины (loopback) путь не нужен: CLI, локальный Prometheus и nginx ниже + ходят как раньше. +- При переносе панели (вкладка «Перенос», `awg-ctrl migrate`) новая панель принимает и свой + путь, и путь старой (`UI_PREFIX_MOVED`) — закладки и API через ретранслятор продолжают + работать. Пока выдан код переноса, `POST /migrate/v1/import` принимается и без пути. + ### Панель за nginx Панель работает и в корне домена, и в любой подпапке — пути в ней относительные, diff --git a/install.sh b/install.sh index 6ed4501..427d8f9 100644 --- a/install.sh +++ b/install.sh @@ -804,6 +804,29 @@ fi [[ "$ROLE" != "core" ]] && choose_awg_params +if [[ "$ROLE" != "node" ]]; then + # Секретный путь панели: снаружи она отвечает только на http://IP:PORT/<путь>/, на остальное — + # 404, как пустой сервер. Без него сканер узнавал панель по странице логина одним GET. + # При переустановке путь прежний; UI_PREFIX=off — без пути, UI_PREFIX=<свой> — свой + # (8–64 символа A–Z a–z 0–9 _ -). + UI_PREFIX="${UI_PREFIX:-}" + UI_PREFIX_NEW="n" + if [[ "$UI_PREFIX" == "off" ]]; then + UI_PREFIX="" + elif [[ -z "$UI_PREFIX" ]]; then + [[ "$KEEP_DATA" == "y" ]] && UI_PREFIX="$(prev_val UI_PREFIX)" + if [[ -z "$UI_PREFIX" ]]; then + UI_PREFIX=$(rand_alnum 16) + [[ -n "$PREV_CLI_ENV" ]] && UI_PREFIX_NEW="y" + fi + fi + [[ -z "$UI_PREFIX" || "$UI_PREFIX" =~ ^[A-Za-z0-9_-]{8,64}$ ]] \ + || fail "UI_PREFIX «${UI_PREFIX}» — нужно 8–64 символа A–Z a–z 0–9 _ -" + # Путь переехавшей сюда панели (migrate) — её закладкам и API через ретранслятор. + UI_PREFIX_MOVED="" + [[ "$KEEP_DATA" == "y" ]] && UI_PREFIX_MOVED="$(prev_val UI_PREFIX_MOVED)" +fi + # Нода: к какой панели подключаться. Строку awgjoin://… даёт панель («Добавить сервер»): # base64url от JSON {v, h, p, pin, n, s}. Разбираем здесь, до установки, чтобы опечатка не # обнаружилась через десять минут сборки модуля. Значения в строке ограничены @@ -1498,6 +1521,8 @@ cat </…, на всё +// остальное — как пустой Go-сервер (`404 page not found`). Порт панели открыт в интернет, и без +// префикса сканер узнавал её по странице логина и JS-бандлу — одним GET. +// +// UI_PREFIX_MOVED — префикс панели, переехавшей сюда (`migrate`): её закладки и внешний API +// приходят через ретранслятор старого сервера со своим путём. Принимаются оба. +// +// С loopback префикс не нужен: CLI, Prometheus этой машины (127.0.0.1:PORT/metrics) и nginx +// на этой же машине ходят по-старому (nginx в подпапке и есть свой секретный путь). Адрес — +// соединения, не X-Forwarded-For: заголовок подделывается. + +import ( + "net" + "net/http" + "os" + "strings" + + "github.com/maeneko/forgetting/internal/migrate" +) + +// uiPrefixes — UI_PREFIX и UI_PREFIX_MOVED из окружения; пустые и кривые пропускаются. +func uiPrefixes() []string { + var out []string + for _, k := range []string{"UI_PREFIX", "UI_PREFIX_MOVED"} { + p := strings.Trim(strings.TrimSpace(os.Getenv(k)), "/") + switch { + case p == "": + case !migrate.ValidUIPrefix(p): + warnf("ui: %s «%s» не подходит (8–64 символа A–Z a–z 0–9 _ -) — пропущен", k, p) + default: + out = append(out, p) + } + } + return out +} + +func isLoopback(remoteAddr string) bool { + host, _, err := net.SplitHostPort(remoteAddr) + if err != nil { + host = remoteAddr + } + ip := net.ParseIP(host) + return ip != nil && ip.IsLoopback() +} + +// prefixGate пропускает в панель только запросы под префиксом и срезает его: дальше роутер +// видит те же пути, что без префикса. allow — исключения вне префикса (приём переноса). +func prefixGate(prefixes []string, next http.Handler, allow func(*http.Request) bool) http.Handler { + if len(prefixes) == 0 { + return next + } + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + p := r.URL.Path + for _, px := range prefixes { + root := "/" + px + if p == root { + // Относительный адрес, как у static: за прокси в подпапке абсолютный увёл бы из неё. + w.Header().Set("Location", px+"/") + w.WriteHeader(http.StatusMovedPermanently) + return + } + if strings.HasPrefix(p, root+"/") { + r2 := r.Clone(r.Context()) + r2.URL.Path = p[len(root):] + if r.URL.RawPath != "" { + r2.URL.RawPath = strings.TrimPrefix(r.URL.RawPath, root) + } + r2.RequestURI = r2.URL.RequestURI() + next.ServeHTTP(w, r2) + return + } + } + if isLoopback(r.RemoteAddr) || allow != nil && allow(r) { + next.ServeHTTP(w, r) + return + } + http.NotFound(w, r) + }) +} diff --git a/internal/ui/prefix_test.go b/internal/ui/prefix_test.go new file mode 100644 index 0000000..1506691 --- /dev/null +++ b/internal/ui/prefix_test.go @@ -0,0 +1,57 @@ +package ui + +import ( + "net/http" + "net/http/httptest" + "testing" +) + +func TestPrefixGate(t *testing.T) { + var got string + next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + got = r.URL.Path + w.WriteHeader(200) + }) + allow := func(r *http.Request) bool { return r.Method == "POST" && r.URL.Path == "/migrate/v1/import" } + h := prefixGate([]string{"s3cretPath", "0ldPanel_x"}, next, allow) + + cases := []struct { + method, remote, path string + code int + seen, location string + }{ + {"GET", "203.0.113.5:4000", "/", 404, "", ""}, + {"GET", "203.0.113.5:4000", "/ui/brand", 404, "", ""}, + {"GET", "203.0.113.5:4000", "/s3cretPathX/", 404, "", ""}, + {"GET", "203.0.113.5:4000", "/s3cretPath", 301, "", "s3cretPath/"}, + {"GET", "203.0.113.5:4000", "/s3cretPath/", 200, "/", ""}, + {"GET", "203.0.113.5:4000", "/s3cretPath/assets/a.js", 200, "/assets/a.js", ""}, + {"GET", "203.0.113.5:4000", "/0ldPanel_x/api/v1/users", 200, "/api/v1/users", ""}, + {"POST", "203.0.113.5:4000", "/migrate/v1/import", 200, "/migrate/v1/import", ""}, + {"GET", "203.0.113.5:4000", "/migrate/v1/import", 404, "", ""}, + {"GET", "127.0.0.1:4000", "/metrics", 200, "/metrics", ""}, + {"GET", "[::1]:4000", "/", 200, "/", ""}, + } + for _, c := range cases { + got = "" + r := httptest.NewRequest(c.method, c.path, nil) + r.RemoteAddr = c.remote + w := httptest.NewRecorder() + h.ServeHTTP(w, r) + if w.Code != c.code || got != c.seen || w.Header().Get("Location") != c.location { + t.Errorf("%s %s от %s: %d, роутер видит %q, Location %q; ждали %d, %q, %q", + c.method, c.path, c.remote, w.Code, got, w.Header().Get("Location"), c.code, c.seen, c.location) + } + if c.code == 404 && w.Body.String() != "404 page not found\n" { + t.Errorf("%s: тело 404 не как у пустого Go-сервера: %q", c.path, w.Body.String()) + } + } + + got = "" + r := httptest.NewRequest("GET", "/ui/brand", nil) + r.RemoteAddr = "203.0.113.5:4000" + prefixGate(nil, next, nil).ServeHTTP(httptest.NewRecorder(), r) + if got != "/ui/brand" { + t.Errorf("без префикса запрос должен доходить до роутера как есть, дошёл %q", got) + } +} diff --git a/internal/ui/server.go b/internal/ui/server.go index 3b8a6cd..462aa89 100644 --- a/internal/ui/server.go +++ b/internal/ui/server.go @@ -91,6 +91,7 @@ func decodeLikeAxios(raw []byte) any { type App struct { port int + prefixes []string // UI_PREFIX, UI_PREFIX_MOVED: секретные пути панели (prefix.go) uiUser string uiPass string jwtSecret []byte @@ -131,14 +132,15 @@ func New() (*App, error) { return def } a := &App{ - port: envNum("PORT"), - uiUser: envOr("UI_USER", "admin"), - uiPass: os.Getenv("UI_PASS"), - brand: pick("BRAND", "Forgetting"), - channel: pick("CHANNEL", "Beta"), - version: pick("VERSION", ""), - ctrlURL: "http://127.0.0.1:" + envOr("AWGCTRL_PORT", "3005"), - baseDir: cwd, + port: envNum("PORT"), + prefixes: uiPrefixes(), + uiUser: envOr("UI_USER", "admin"), + uiPass: os.Getenv("UI_PASS"), + brand: pick("BRAND", "Forgetting"), + channel: pick("CHANNEL", "Beta"), + version: pick("VERSION", ""), + ctrlURL: "http://127.0.0.1:" + envOr("AWGCTRL_PORT", "3005"), + baseDir: cwd, // awg-ctrl на loopback: системный прокси сюда не нужен. httpLocal: &http.Client{Transport: &http.Transport{Proxy: nil}}, revoked: map[string]bool{}, @@ -950,7 +952,7 @@ func (a *App) Run() error { if err != nil { return err } - srv := &http.Server{Handler: a.routes(), ReadHeaderTimeout: 30 * time.Second} + srv := &http.Server{Handler: prefixGate(a.prefixes, a.routes(), a.migration.awaitsImport), ReadHeaderTimeout: 30 * time.Second} logf("ui: listening on :%d", a.port) a.nodes.Start() a.sub.Start()