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/<prefix>/ 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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013bNGYiDst62wH3ZGTRPFLJ
This commit is contained in:
@@ -165,6 +165,9 @@ func New() *CLI {
|
||||
name: "ui", label: "awg-ui", cwd: filepath.Join(c.root, "awg-ui"), legacy: "server.ts",
|
||||
env: [][2]string{
|
||||
{"PORT", env("UI_PORT", "8080")},
|
||||
// Секретный путь панели; _MOVED — путь панели, переехавшей сюда (принимаются оба).
|
||||
{"UI_PREFIX", env("UI_PREFIX", "")},
|
||||
{"UI_PREFIX_MOVED", env("UI_PREFIX_MOVED", "")},
|
||||
{"AWGCTRL_PORT", env("AWGCTRL_PORT", "3005")},
|
||||
{"INTERNAL_AUTH_KEY_FILE", env("INTERNAL_AUTH_KEY_FILE", "/etc/amnezia/amneziawg/internal_auth_private.key")},
|
||||
{"BRAND", c.brand}, {"CHANNEL", c.channel}, {"VERSION", c.version},
|
||||
@@ -477,11 +480,33 @@ func (c *CLI) serviceLine(name string, menu bool) string {
|
||||
return fmt.Sprintf(" %s %-12s %s", dot, c.svc[name].label, info)
|
||||
}
|
||||
|
||||
// panelURL — адрес панели с секретным путём; без UI_PORT (нода) — пусто.
|
||||
func panelURL() string {
|
||||
port := env("UI_PORT", "")
|
||||
if port == "" {
|
||||
return ""
|
||||
}
|
||||
host := env("SUB_HOST", env("SERVER_IP", "IP"))
|
||||
if strings.Contains(host, ":") {
|
||||
host = "[" + host + "]"
|
||||
}
|
||||
u := "http://" + host + ":" + port + "/"
|
||||
if p := env("UI_PREFIX", ""); p != "" {
|
||||
u += p + "/"
|
||||
}
|
||||
return u
|
||||
}
|
||||
|
||||
func (c *CLI) status() {
|
||||
fmt.Println()
|
||||
for _, n := range c.all {
|
||||
fmt.Println(c.serviceLine(n, false))
|
||||
}
|
||||
if contains(c.all, "ui") {
|
||||
if u := panelURL(); u != "" {
|
||||
fmt.Println(" " + dim("Панель "+u))
|
||||
}
|
||||
}
|
||||
if c.isRunning("awgctrl") {
|
||||
h := fetchHealth("http://127.0.0.1:" + c.envOf("awgctrl") + "/health")
|
||||
if gen, ok := get(h, "gen").(string); ok && gen != "" {
|
||||
|
||||
@@ -454,6 +454,9 @@ func (c *CLI) setCredentials(field string) {
|
||||
if pass != nil {
|
||||
fmt.Printf(" %s Пароль: %s\n", green("✓"), *pass)
|
||||
}
|
||||
if u := panelURL(); u != "" {
|
||||
fmt.Printf(" %s Панель: %s\n", green("✓"), u)
|
||||
}
|
||||
fmt.Printf("\n %s\n", dim("Перезапусти UI чтобы изменения вступили в силу."))
|
||||
}
|
||||
|
||||
|
||||
@@ -127,7 +127,7 @@ func (c *CLI) writeExport(out, dbFile string) error {
|
||||
return err
|
||||
}
|
||||
err = migrate.Write(f, db, c.paths(), exportMeta(c.version, name, ip, envPort("UI_PORT"), envPort("SUB_PORT"),
|
||||
strings.ToLower(env("SUB_TLS", "off")) == "on", envPort("NODE_PORT")))
|
||||
strings.ToLower(env("SUB_TLS", "off")) == "on", envPort("NODE_PORT"), env("UI_PREFIX", "")))
|
||||
if cerr := f.Close(); err == nil {
|
||||
err = cerr
|
||||
}
|
||||
@@ -135,11 +135,12 @@ func (c *CLI) writeExport(out, dbFile string) error {
|
||||
}
|
||||
|
||||
// exportMeta — meta.json архива (v и panel_id добавляет migrate.Write).
|
||||
func exportMeta(version, name, ip string, uiPort, subPort int, subTLS bool, nodePort int) jsjson.Object {
|
||||
func exportMeta(version, name, ip string, uiPort, subPort int, subTLS bool, nodePort int, uiPrefix string) jsjson.Object {
|
||||
return jsjson.Object{
|
||||
{Key: "version", Val: version}, {Key: "created_at", Val: time.Now().Unix()},
|
||||
{Key: "server_name", Val: name}, {Key: "server_ip", Val: ip},
|
||||
{Key: "ui_port", Val: uiPort}, {Key: "sub_port", Val: subPort}, {Key: "sub_tls", Val: subTLS}, {Key: "node_port", Val: nodePort},
|
||||
{Key: "ui_prefix", Val: uiPrefix},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -247,6 +248,9 @@ func (c *CLI) migrateImport(file string) {
|
||||
fmt.Println()
|
||||
fmt.Println(" " + dim("Старый сервер станет нодой #0 и будет пересылать сюда прежние порты — ссылки sen://, ноды и"))
|
||||
fmt.Println(" " + dim("внешний API продолжат работать. Логин и пароль панели — этой установки, а не старой."))
|
||||
if !migrate.ValidUIPrefix(b.Str("ui_prefix")) && env("UI_PREFIX", "") != "" {
|
||||
fmt.Println(" " + dim("У старой панели не было секретного пути: её закладкам и внешнему API нужен путь этой — /"+env("UI_PREFIX", "")+"/"))
|
||||
}
|
||||
}
|
||||
|
||||
// ── cancel (старый сервер) ─────────────────────────────────────────────────
|
||||
|
||||
@@ -22,6 +22,7 @@ import (
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -243,6 +244,11 @@ type Plan struct {
|
||||
Env [][2]string
|
||||
}
|
||||
|
||||
var reUIPrefix = regexp.MustCompile(`^[A-Za-z0-9_-]{8,64}$`)
|
||||
|
||||
// ValidUIPrefix — годится ли строка в секретный префикс панели (UI_PREFIX).
|
||||
func ValidUIPrefix(s string) bool { return reUIPrefix.MatchString(s) }
|
||||
|
||||
// NewPlan — env(k) → текущий порт этой панели (0 — не задан), host — её публичный адрес.
|
||||
func NewPlan(b *Bundle, env func(k string) int, uiPort int, host string) (*Plan, error) {
|
||||
p := &Plan{SubPort: b.Num("sub_port"), NodePort: b.Num("node_port"), UIPort: uiPort, Host: host}
|
||||
@@ -270,6 +276,11 @@ func NewPlan(b *Bundle, env func(k string) int, uiPort int, host string) (*Plan,
|
||||
p.Env = append(p.Env, [2]string{"SUB_TLS", tlsOn})
|
||||
// С этого момента панель считает, кто из клиентов и нод узнал её новый адрес.
|
||||
p.Env = append(p.Env, [2]string{"MOVED_IN_AT", strconv.FormatInt(time.Now().Unix(), 10)})
|
||||
// Секретный путь старой панели: по нему ходят её закладки и внешний API через ретранслятор.
|
||||
// Свой префикс новая панель не меняет (иначе потеряла бы открытую вкладку) — принимает оба.
|
||||
if px := b.Str("ui_prefix"); ValidUIPrefix(px) {
|
||||
p.Env = append(p.Env, [2]string{"UI_PREFIX_MOVED", px})
|
||||
}
|
||||
// Заголовку PROXY от ретранслятора верим только с адреса старого сервера.
|
||||
if ip := b.Str("server_ip"); ip != "" {
|
||||
p.Env = append(p.Env, [2]string{"RELAY_FROM", ip})
|
||||
|
||||
+13
-1
@@ -211,6 +211,18 @@ func (m *Migration) newCode(c *Ctx) bool {
|
||||
return m.status(c)
|
||||
}
|
||||
|
||||
// awaitsImport — запрос на приём архива, пока выдан код: старая панель шлёт его на
|
||||
// /migrate/v1/import без секретного префикса (в коде переноса его нет), prefixGate пропускает.
|
||||
// Без кода путь снаружи не виден, как и остальные.
|
||||
func (m *Migration) awaitsImport(r *http.Request) bool {
|
||||
if r.Method != "POST" || r.URL.Path != "/migrate/v1/import" {
|
||||
return false
|
||||
}
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
return m.key != nil && time.Now().Before(m.expires)
|
||||
}
|
||||
|
||||
// importHandler — POST /migrate/v1/import, без JWT: доступ даёт только ключ кода (тег GCM).
|
||||
// Ответ с ошибкой — открытым текстом и только пока ничего не тронуто.
|
||||
func (m *Migration) importHandler(c *Ctx) bool {
|
||||
@@ -420,7 +432,7 @@ func (m *Migration) exportMeta() jsjson.Object {
|
||||
{Key: "version", Val: m.a.version}, {Key: "created_at", Val: now()},
|
||||
{Key: "server_name", Val: name}, {Key: "server_ip", Val: ip},
|
||||
{Key: "ui_port", Val: m.a.port}, {Key: "sub_port", Val: m.a.sub.port}, {Key: "sub_tls", Val: m.a.sub.useTLS},
|
||||
{Key: "node_port", Val: m.a.nodes.port},
|
||||
{Key: "node_port", Val: m.a.nodes.port}, {Key: "ui_prefix", Val: os.Getenv("UI_PREFIX")},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
package ui
|
||||
|
||||
// Секретный префикс панели (UI_PREFIX): снаружи панель отвечает только на /<префикс>/…, на всё
|
||||
// остальное — как пустой 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)
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
+11
-9
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user