diff --git a/docs/architecture.md b/docs/architecture.md index 61cb66d..1926fa8 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -135,6 +135,21 @@ The panel daemon runs as the dedicated unprivileged `vps` system user `vps config set net.v4_forward true`) regenerates every file from the DB and deletes orphans, repairing any drift. All timestamps are stored as UTC; the admin domain page renders them in the browser's timezone. +- **Blocked domains**: the admin domain panel keeps a blocked-domains list (a + `settings` key, `blocked_domains`, one domain per line, managed via a + textarea). `mgr.AddDomain` — the single add path for the user panel — refuses + a domain that exactly equals a blocked entry or is a subdomain of one (label + boundary: `example.co.uk` blocks `a.example.co.uk` but not + `forexample.co.uk`). No wildcards or regex; entries and additions share the + same normalization: lowercase, trailing dots stripped, only `[a-z0-9.-]` + allowed (anything else is a hard error — pasting a URL is rejected, not + silently rewritten), must end with a letter (which also excludes dotted-quad + IPs), must contain at least one dot (no single-word names), every label must + start/end with a letter or digit (consecutive hyphens mid-label like + `xn--p1ai` are fine), total ≤ 253 and label ≤ 63. Only the add + path is guarded — domains bound before an entry was blocked stay untouched. + The save handler validates each line and + reports invalid lines by their numbers while saving the rest. - **Audit log**: resource-heavy user actions (power start/stop/restart, reinstall, reset root password, domain config changes) are recorded in the `audit_log` table (actor / action / UTC timestamp). The actor encodes who diff --git a/src/internal/admin/admin.go b/src/internal/admin/admin.go index 3e04127..5be4d50 100644 --- a/src/internal/admin/admin.go +++ b/src/internal/admin/admin.go @@ -63,6 +63,7 @@ func (s *Server) Handler() http.Handler { mux.HandleFunc("/domains", s.requireAuth(s.handleDomains)) mux.HandleFunc("/domain-del", s.requireAuth(s.requirePost(s.handleDomainDel))) mux.HandleFunc("/domain-update", s.requireAuth(s.requirePost(s.handleDomainUpdate))) + mux.HandleFunc("/blocked-domains", s.requireAuth(s.requirePost(s.handleBlockedDomains))) mux.HandleFunc("/ipv6pool", s.requireAuth(s.handleIPv6Pool)) mux.HandleFunc("/ipv6pool-add", s.requireAuth(s.requirePost(s.handleIPv6PoolAdd))) mux.HandleFunc("/ipv6pool-del", s.requireAuth(s.requirePost(s.handleIPv6PoolDel))) diff --git a/src/internal/admin/admin_test.go b/src/internal/admin/admin_test.go index 5602841..d1bd687 100644 --- a/src/internal/admin/admin_test.go +++ b/src/internal/admin/admin_test.go @@ -530,3 +530,74 @@ func TestAdminPassChangeInvalidatesOtherSessions(t *testing.T) { t.Fatalf("changer session overview = %d, want 200", rr.Code) } } + +func TestBlockedDomainsSavePartial(t *testing.T) { + srv, _ := newTestServer(t) + setAdminPass(t, srv, "correct-horse-battery") + h := srv.Handler() + prefix := "/" + testAdminSecret + cookie := adminLogin(t, h, prefix, "correct-horse-battery") + + // A mixed list: line 1 and 4 are invalid, the rest are saved. + rr := doReqOrigin(t, h, http.MethodPost, prefix+"/blocked-domains", + url.Values{"blocked": {"example.co.uk\nbad_domain\nok.example.com\nbad.com-"}}, + cookie, "https://"+testHost) + if rr.Code != http.StatusFound { + t.Fatalf("blocked-domains save = %d, want 302", rr.Code) + } + got, err := srv.db.GetBlockedDomains() + if err != nil { + t.Fatal(err) + } + want := []string{"example.co.uk", "ok.example.com"} + if len(got) != len(want) || got[0] != want[0] || got[1] != want[1] { + t.Fatalf("saved list = %v, want %v", got, want) + } + + // The flash banner names the skipped line numbers (2, 4). + rr = doReq(t, h, http.MethodPost, prefix+"/flash", nil, cookie) + if rr.Code != http.StatusOK { + t.Fatalf("flash = %d", rr.Code) + } + if !strings.Contains(rr.Body.String(), "2, 4") { + t.Errorf("flash does not name the bad lines: %s", rr.Body.String()) + } + + // The domains page pre-fills the textarea with the saved list. + rr = doReq(t, h, http.MethodGet, prefix+"/domains", nil, cookie) + if rr.Code != http.StatusOK { + t.Fatalf("GET /domains = %d", rr.Code) + } + if !strings.Contains(rr.Body.String(), "example.co.uk") || !strings.Contains(rr.Body.String(), "Blocked domains") { + t.Errorf("domains page missing blocked list textarea: %s", rr.Body.String()) + } +} + +func TestBlockedDomainsSaveAllValid(t *testing.T) { + srv, _ := newTestServer(t) + setAdminPass(t, srv, "correct-horse-battery") + h := srv.Handler() + prefix := "/" + testAdminSecret + cookie := adminLogin(t, h, prefix, "correct-horse-battery") + + rr := doReqOrigin(t, h, http.MethodPost, prefix+"/blocked-domains", + url.Values{"blocked": {"a.com\nb.com"}}, cookie, "https://"+testHost) + if rr.Code != http.StatusFound { + t.Fatalf("save = %d, want 302", rr.Code) + } + got, err := srv.db.GetBlockedDomains() + if err != nil { + t.Fatal(err) + } + if len(got) != 2 { + t.Fatalf("saved list = %v", got) + } + // No skip-banner when every line is valid. + rr = doReq(t, h, http.MethodPost, prefix+"/flash", nil, cookie) + if rr.Code != http.StatusOK { + t.Fatalf("flash = %d", rr.Code) + } + if strings.Contains(rr.Body.String(), "skipped") { + t.Errorf("unexpected skip banner: %s", rr.Body.String()) + } +} diff --git a/src/internal/admin/handlers.go b/src/internal/admin/handlers.go index 66aa50e..378e95f 100644 --- a/src/internal/admin/handlers.go +++ b/src/internal/admin/handlers.go @@ -505,6 +505,7 @@ type domainsPageData struct { Msg string Err string Domains []domainView + Blocked string // blocked-domains list, one domain per line (textarea) Lang string } @@ -528,7 +529,7 @@ func (s *Server) renderDomains(w http.ResponseWriter, r *http.Request, d domains } // handleDomains renders the admin domain panel: every domain with its owner -// and last-modified time, newest first. +// and last-modified time, newest first, plus the blocked-domains list. func (s *Server) handleDomains(w http.ResponseWriter, r *http.Request) { d := domainsPageData{Title: "VPS Manager Admin — Domains", Prefix: s.prefix()} all, err := s.mgr.AllDomains() @@ -539,6 +540,14 @@ func (s *Server) handleDomains(w http.ResponseWriter, r *http.Request) { d.Domains = append(d.Domains, domainView{Domain: x.Domain, Username: x.Username, UpdatedAt: x.UpdatedAt, ProxyProtocol: x.ProxyProtocol}) } } + if blocked, err := s.db.GetBlockedDomains(); err != nil { + if d.Err != "" { + d.Err += " " + } + d.Err += err.Error() + } else { + d.Blocked = strings.Join(blocked, "\n") + } s.renderDomains(w, r, d) } @@ -597,6 +606,31 @@ func (s *Server) handleDomainUpdate(w http.ResponseWriter, r *http.Request) { s.redirect(w, r, s.p("/domains"), s.t(r, "domains_updated")) } +// handleBlockedDomains saves the admin blocked-domains list from the textarea. +// Every line is validated individually: invalid lines are skipped (and +// reported by their 1-based line numbers in a flash banner), the valid lines +// are saved. The check lives in mgr.AddDomain, so all add paths are covered. +func (s *Server) handleBlockedDomains(w http.ResponseWriter, r *http.Request) { + if err := r.ParseForm(); err != nil { + http.Error(w, err.Error(), 400) + return + } + valid, bad := mgr.ParseBlockedList(r.FormValue("blocked")) + if err := s.db.SetBlockedDomains(valid); err != nil { + s.redirect(w, r, s.p("/domains"), "error: "+err.Error()) + return + } + if len(bad) > 0 { + lines := make([]string, 0, len(bad)) + for _, n := range bad { + lines = append(lines, strconv.Itoa(n)) + } + s.redirect(w, r, s.p("/domains"), s.t(r, "blocked_skipped_lines", strings.Join(lines, ", "))) + return + } + s.redirect(w, r, s.p("/domains"), s.t(r, "blocked_saved")) +} + // ---- IPv6 pool management ---- type ipv6PoolPageData struct { diff --git a/src/internal/admin/i18n.go b/src/internal/admin/i18n.go index 85ca7ab..376756d 100644 --- a/src/internal/admin/i18n.go +++ b/src/internal/admin/i18n.go @@ -79,22 +79,24 @@ func (s *Server) t(r *http.Request, key string, args ...any) string { // stable identifiers; args are %-formatted into the chosen language's template. func tr(l, key string, args ...any) string { m := map[string][2]string{ // key -> [zh, en] - "err_bad_login": {"管理员密码错误", "invalid admin password"}, - "err_too_many": {"尝试过于频繁,请 1 分钟后再试", "Too many attempts, please wait 1 minute"}, - "err_not_configured": {"管理员尚未初始化 — 请运行 `vps admin-passwd`", "admin not configured yet — run `vps admin-passwd`"}, - "err_pass_mismatch": {"两次输入的密码不一致", "The two passwords do not match"}, - "err_pass_short": {"管理员密码至少需要 14 位", "admin password must be at least 14 characters"}, - "user_created": {"用户已创建:\n%[1]v", "user created:\n%[1]v"}, - "user_deleted": {"用户 %[1]v 已删除", "user %[1]v deleted"}, - "quota_updated": {"已更新 %[1]v 的配额", "quotas updated for %[1]v"}, - "power_ok": {"%[1]v %[2]v 成功", "%[2]v %[1]v ok"}, - "new_panel_password": {"用户 %[1]v 的面板密码已重置:\n%[2]v\n面板:%[3]v", "%[1]v panel password reset:\n%[2]v\npanel: %[3]v"}, - "admin_pass_changed": {"管理员密码已修改", "admin password changed"}, - "err_invalid_disk": {"磁盘必须是整数 GiB", "disk must be an integer (GiB)"}, - "domain_deleted": {"域名 %[1]v 已删除", "domain %[1]v deleted"}, - "domains_updated": {"域名设置已保存", "domain settings saved"}, - "pool_added": {"已添加 %[1]v 个 IPv6 地址到池", "added %[1]v IPv6 address(es) to the pool"}, - "pool_removed": {"已从池中移除 %[1]v", "removed %[1]v from the pool"}, + "err_bad_login": {"管理员密码错误", "invalid admin password"}, + "err_too_many": {"尝试过于频繁,请 1 分钟后再试", "Too many attempts, please wait 1 minute"}, + "err_not_configured": {"管理员尚未初始化 — 请运行 `vps admin-passwd`", "admin not configured yet — run `vps admin-passwd`"}, + "err_pass_mismatch": {"两次输入的密码不一致", "The two passwords do not match"}, + "err_pass_short": {"管理员密码至少需要 14 位", "admin password must be at least 14 characters"}, + "user_created": {"用户已创建:\n%[1]v", "user created:\n%[1]v"}, + "user_deleted": {"用户 %[1]v 已删除", "user %[1]v deleted"}, + "quota_updated": {"已更新 %[1]v 的配额", "quotas updated for %[1]v"}, + "power_ok": {"%[1]v %[2]v 成功", "%[2]v %[1]v ok"}, + "new_panel_password": {"用户 %[1]v 的面板密码已重置:\n%[2]v\n面板:%[3]v", "%[1]v panel password reset:\n%[2]v\npanel: %[3]v"}, + "admin_pass_changed": {"管理员密码已修改", "admin password changed"}, + "err_invalid_disk": {"磁盘必须是整数 GiB", "disk must be an integer (GiB)"}, + "domain_deleted": {"域名 %[1]v 已删除", "domain %[1]v deleted"}, + "domains_updated": {"域名设置已保存", "domain settings saved"}, + "blocked_saved": {"封禁域名列表已保存", "blocked-domains list saved"}, + "blocked_skipped_lines": {"第 %[1]v 行不是有效域名,已跳过;其余已保存", "line(s) %[1]v are not valid domains and were skipped; the rest were saved"}, + "pool_added": {"已添加 %[1]v 个 IPv6 地址到池", "added %[1]v IPv6 address(es) to the pool"}, + "pool_removed": {"已从池中移除 %[1]v", "removed %[1]v from the pool"}, } pair, ok := m[key] if !ok { diff --git a/src/internal/admin/templates/admin_domains.html b/src/internal/admin/templates/admin_domains.html index f494abd..416d6cc 100644 --- a/src/internal/admin/templates/admin_domains.html +++ b/src/internal/admin/templates/admin_domains.html @@ -93,6 +93,17 @@ code { background: #f3f4f6; padding: 1px 6px; border-radius: 5px; font-size: 12p
{{if eq .Lang "zh"}}暂无域名{{else}}No domains{{end}}
{{end}} + +
+

{{if eq .Lang "zh"}}禁止添加的域名{{else}}Blocked domains{{end}}

+

{{if eq .Lang "zh"}}此处封禁的域名本身及其所有子域名都无法被添加,不影响系统中已有的域名。每行一个域名,只需输入域名本身(不支持通配符)。例如 example.co.uk 会封禁 example.co.uk、a.example.co.uk、a.b.c.example.co.uk,但不会封禁 forexample.co.uk。{{else}}Domains listed here — and all of their subdomains — cannot be added anymore; existing domains are unaffected. One domain per line, just the bare domain (no wildcards). E.g. example.co.uk blocks example.co.uk, a.example.co.uk and a.b.c.example.co.uk, but not forexample.co.uk.{{end}}

+
+ +
+ +
+
+