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()