From 789e1da93b05474727e6d15f15ffae43f360e266 Mon Sep 17 00:00:00 2001 From: iRedTea Date: Tue, 13 Oct 2026 20:42:09 +0300 Subject: [PATCH] feat: open a port in ufw by hand from the panel settings Co-Authored-By: Claude Opus 5.5 --- internal/server/db.go | 15 ++++++++++- internal/server/handlers_panel.go | 22 +++++++++++++++ internal/server/server.go | 1 + internal/server/ufw.go | 30 +++++++++++++++++---- internal/server/web/templates/settings.html | 15 +++++++++++ 5 files changed, 77 insertions(+), 6 deletions(-) diff --git a/internal/server/db.go b/internal/server/db.go index 30698d0..ae1adec 100644 --- a/internal/server/db.go +++ b/internal/server/db.go @@ -402,7 +402,20 @@ type UfwRule struct { ID int64 Port int Proto string - RuleID int64 // bound forward rule id, or 0 for shared/manual (e.g. the tunnel port) + RuleID int64 // bound forward rule id, 0 for shared (the tunnel port), ufwManual for one added in Settings +} + +// ufwManual is the RuleID of a ufw rule the admin added by hand: no forward +// owns it, so only the admin removes it. +const ufwManual = -1 + +// Manual reports whether the admin added the rule by hand. +func (r UfwRule) Manual() bool { return r.RuleID == ufwManual } + +// SetUfwRuleManual hands a tracked rule over to the admin. +func (db *DB) SetUfwRuleManual(port int, proto string) error { + _, err := db.sql.Exec(`UPDATE ufw_rules SET rule_id = ? WHERE port = ? AND proto = ?`, ufwManual, port, proto) + return err } func (db *DB) AddUfwRule(port int, proto string, ruleID int64) error { diff --git a/internal/server/handlers_panel.go b/internal/server/handlers_panel.go index 27c41b0..ba7aa57 100644 --- a/internal/server/handlers_panel.go +++ b/internal/server/handlers_panel.go @@ -398,6 +398,10 @@ func (s *Server) handleSettings(w http.ResponseWriter, r *http.Request) { "Error": map[string]string{ "tunnel_host": "Некорректный хост туннеля: нужен IP или имя без схемы и порта.", "tunnel_port": "Некорректный порт туннеля.", + "ufw_port": "Некорректное правило ufw: нужен порт 1-65535 и протокол tcp или udp.", + "ufw_off": "Правило ufw не добавлено: ufw выключен или интеграция с ним отключена.", + "ufw_failed": "Правило ufw не добавлено: команда ufw завершилась ошибкой.", + "ufw_exists": "Этот порт уже открыт в ufw не панелью - правило осталось как было, панель его не отслеживает.", "tunnel_listen": "Не удалось занять новый порт туннеля на хосте (возможно, уже используется). Порт не изменён.", }[r.URL.Query().Get("err")], }) @@ -525,6 +529,24 @@ func (s *Server) handleUfwToggle(w http.ResponseWriter, r *http.Request) { http.Redirect(w, r, "/settings", http.StatusSeeOther) } +func (s *Server) handleUfwAdd(w http.ResponseWriter, r *http.Request) { + if !checkCSRF(r) { + http.Error(w, "bad csrf", http.StatusForbidden) + return + } + port, _ := strconv.Atoi(strings.TrimSpace(r.FormValue("port"))) + proto := r.FormValue("proto") + if port < 1 || port > 65535 || (proto != "tcp" && proto != "udp") { + http.Redirect(w, r, "/settings?err=ufw_port", http.StatusSeeOther) + return + } + target := "/settings" + if key := s.ufw.allowManual(port, proto); key != "" { + target += "?err=" + key + } + http.Redirect(w, r, target, http.StatusSeeOther) +} + func (s *Server) handleUfwRevoke(w http.ResponseWriter, r *http.Request) { if !checkCSRF(r) { http.Error(w, "bad csrf", http.StatusForbidden) diff --git a/internal/server/server.go b/internal/server/server.go index 67537b2..972595b 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -217,6 +217,7 @@ func (s *Server) Handler() http.Handler { mux.Handle("POST /settings/admins/add", s.requireAdmin(s.handleAddAdmin)) mux.Handle("POST /settings/admins/delete", s.requireAdmin(s.handleDeleteAdmin)) mux.Handle("POST /settings/ufw/toggle", s.requireAdmin(s.handleUfwToggle)) + mux.Handle("POST /settings/ufw/add", s.requireAdmin(s.handleUfwAdd)) mux.Handle("POST /settings/ufw/revoke", s.requireAdmin(s.handleUfwRevoke)) // Panel terminal over HTTP long-polling (admin-only). diff --git a/internal/server/ufw.go b/internal/server/ufw.go index dcc02a0..92fa716 100644 --- a/internal/server/ufw.go +++ b/internal/server/ufw.go @@ -87,26 +87,46 @@ func (u *ufwManager) LastError() string { } // allow opens port/proto in ufw and records it, bound to the given forward -// rule id (0 = shared/manual). The caller has checked available(). -func (u *ufwManager) allow(port int, proto string, ruleID int64) { +// rule id (0 = shared, ufwManual = added in Settings). It reports whether the +// panel now tracks the rule. The caller has checked available(). +func (u *ufwManager) allow(port int, proto string, ruleID int64) bool { if u.db.HasUfwRule(port, proto) { - return + return true } spec := fmt.Sprintf("%d/%s", port, proto) out, err := ufwRun("allow", spec) if err != nil { log.Printf("ufw allow %s: %v: %s", spec, err, out) u.setErr(fmt.Sprintf("ufw allow %s: %v: %s", spec, err, out)) - return + return false } u.setErr("") if strings.Contains(out, "Skipping") { // The rule was there before the panel asked for it, so it is not ours // to track (and later delete). - return + return false } _ = u.db.AddUfwRule(port, proto, ruleID) log.Printf("ufw: allowed %s", spec) + return true +} + +// allowManual opens a port the admin asked for in Settings. A port the panel +// already opened for a forward or the tunnel becomes the admin's, so it stays +// when they no longer need it. It returns the error key for the settings page +// ("" on success). +func (u *ufwManager) allowManual(port int, proto string) string { + if !u.available() { + return "ufw_off" + } + if !u.allow(port, proto, ufwManual) { + if u.LastError() != "" { + return "ufw_failed" + } + return "ufw_exists" + } + _ = u.db.SetUfwRuleManual(port, proto) + return "" } // revokeRule deletes one tracked ufw rule from ufw and the tracking table. diff --git a/internal/server/web/templates/settings.html b/internal/server/web/templates/settings.html index a3349d6..870cb88 100644 --- a/internal/server/web/templates/settings.html +++ b/internal/server/web/templates/settings.html @@ -141,6 +141,7 @@ {{range .UfwRules}} {{.Port}}/{{.Proto}} + {{if .Manual}}вручную{{else if .RuleID}}проброс{{else}}туннель{{end}}
@@ -156,6 +157,20 @@ {{else}}

Панель пока не открывала портов в ufw.

{{end}} + + {{if and .UfwActive .UfwEnabled}} +

Открыть порт вручную

+ + + + + +
+

Такое правило не привязано к пробросу и остаётся, пока его не отменить здесь.

+ {{end}} {{end}}