feat: open a port in ufw by hand from the panel settings

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
redteaandClaude Opus 5.5 committed 2026-10-13 20:42:09 +03:00
1 parent 87921a1381
commit 789e1da93b
5 files changed
+77 -6

No files matched your search

+14 -1
View File
@@ -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 {
+22
View File
@@ -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)
+1
View File
@@ -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).
+25 -5
View File
@@ -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.
@@ -141,6 +141,7 @@
{{range .UfwRules}}
<tr>
<td><code>{{.Port}}/{{.Proto}}</code></td>
<td class="muted">{{if .Manual}}вручную{{else if .RuleID}}проброс{{else}}туннель{{end}}</td>
<td style="text-align:right">
<form method="post" action="/settings/ufw/revoke" style="display:inline"
onsubmit="return confirm('Отменить правило ufw {{.Port}}/{{.Proto}}?')">
@@ -156,6 +157,20 @@
{{else}}
<p class="muted" style="font-size:13px; margin-top:10px">Панель пока не открывала портов в ufw.</p>
{{end}}
{{if and .UfwActive .UfwEnabled}}
<h2 style="margin-top:18px; font-size:15px">Открыть порт вручную</h2>
<form method="post" action="/settings/ufw/add" class="row">
<input type="hidden" name="csrf" value="{{.CSRF}}">
<input type="number" name="port" min="1" max="65535" placeholder="порт" style="max-width:120px" required>
<select name="proto" style="max-width:100px">
<option value="tcp">tcp</option>
<option value="udp">udp</option>
</select>
<button type="submit">Открыть</button>
</form>
<p class="muted" style="font-size:13px">Такое правило не привязано к пробросу и остаётся, пока его не отменить здесь.</p>
{{end}}
{{end}}
</div>