feat: tunnel host and port in the panel settings, ufw kept in sync

The direct tunnel port (8446 by default) and the host agents dial for
it can be set in Settings. The server moves its listeners at once and
agents pick the address up from the panel: on connect and with every
poll, unless TUNNEL_ADDR / TUNNEL_PORT pin it locally.

ufw: run with a C locale (the output is translated), do not adopt rules
that existed before the panel, skip loopback-only listeners, re-open
what saved forwards need on start and when the integration is turned
on, move the shared tunnel rule with the port and drop it when no
direct forward is left. The last ufw error is shown in Settings.

The forwards page shows the real tunnel and panel ports.
This commit is contained in:
redtea committed 2026-10-13 14:50:50 +03:00
1 parent 08fc2d79a8
commit bcfe909aaa
15 files changed
+540 -97

No files matched your search

+21
View File
@@ -3,6 +3,7 @@ package server
import (
"crypto/rand"
"encoding/hex"
"strconv"
lpra "github.com/x-grk/longpolling-remote-access"
)
@@ -14,6 +15,8 @@ const (
cfgSetupDone = "setup_done"
cfgInstallCode = "install_code"
cfgUfwEnabled = "ufw_enabled"
cfgTunnelHost = "tunnel_host"
cfgTunnelPort = "tunnel_port"
)
// DefaultHostAddress is the public URL agents dial by default. It is baked into
@@ -88,6 +91,24 @@ func (db *DB) SetUfwEnabled(on bool) error {
return db.SetConfig(cfgUfwEnabled, "0")
}
// TunnelHost is the host agents dial for the direct tunnel. Empty means the
// host of the URL they already reach the panel on.
func (db *DB) TunnelHost() string {
v, _ := db.GetConfig(cfgTunnelHost)
return v
}
// TunnelPort is the direct-tunnel port set in Settings, or 0 when it is left
// at the port of the server's -tunnel address.
func (db *DB) TunnelPort() int {
v, _ := db.GetConfig(cfgTunnelPort)
n, _ := strconv.Atoi(v)
if n < 1 || n > 65535 {
return 0
}
return n
}
func (db *DB) SetupDone() bool {
v, _ := db.GetConfig(cfgSetupDone)
return v == "1"
+25 -1
View File
@@ -52,7 +52,31 @@ func (s *Server) handlePoll(w http.ResponseWriter, r *http.Request) {
} else {
_ = s.db.TouchAgent(a.ID)
}
writeJSON(w, s.hub.WaitControl(a.ID, pollHold))
msg := s.hub.WaitControl(a.ID, pollHold)
// Set after the hold so a port changed meanwhile reaches the agent now.
s.setTunnelHeader(w)
writeJSON(w, msg)
}
// tunnelAddrHeader carries the direct-tunnel address ("host:port", host empty
// = the panel's host) to agents, on every poll and on /api/agent/config.
const tunnelAddrHeader = "X-Tunnel-Addr"
func (s *Server) setTunnelHeader(w http.ResponseWriter) {
if addr := s.tunnelAdvert(); addr != "" {
w.Header().Set(tunnelAddrHeader, addr)
}
}
// GET /api/agent/config — settings an agent picks up when it connects, without
// waiting out a poll hold.
func (s *Server) handleAgentConfig(w http.ResponseWriter, r *http.Request) {
if s.authenticateAgent(r) == nil {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
s.setTunnelHeader(w)
w.WriteHeader(http.StatusNoContent)
}
// GET /api/agent/task/{id} — the agent fetches the task to run.
+74 -1
View File
@@ -5,6 +5,7 @@ import (
"errors"
"fmt"
"log"
"net"
"net/http"
"net/url"
"slices"
@@ -349,9 +350,76 @@ func (s *Server) handleSettings(w http.ResponseWriter, r *http.Request) {
"UfwRunnable": ufwRunnable,
"UfwEnabled": s.db.UfwEnabled(),
"UfwRules": ufwRules,
"UfwError": s.ufw.LastError(),
"TunnelPort": s.TunnelPort(),
"TunnelDefault": s.tunnelDefault,
"TunnelHost": s.db.TunnelHost(),
"TunnelCustom": s.db.TunnelPort() > 0,
"Error": map[string]string{
"tunnel_host": "Некорректный хост туннеля: нужен IP или имя без схемы и порта.",
"tunnel_port": "Некорректный порт туннеля.",
"tunnel_listen": "Не удалось занять новый порт туннеля на хосте (возможно, уже используется). Порт не изменён.",
}[r.URL.Query().Get("err")],
})
}
// POST /settings/tunnel — host and port agents use for the direct tunnel.
func (s *Server) handleSetTunnel(w http.ResponseWriter, r *http.Request) {
if !checkCSRF(r) {
http.Error(w, "bad csrf", http.StatusForbidden)
return
}
if s.tunnelDefault == 0 {
http.Redirect(w, r, "/settings", http.StatusSeeOther)
return
}
host := strings.TrimSpace(r.FormValue("tunnel_host"))
host = strings.TrimSuffix(strings.TrimPrefix(host, "["), "]")
if !validTunnelHost(host) {
http.Redirect(w, r, "/settings?err=tunnel_host", http.StatusSeeOther)
return
}
// An empty port goes back to the one the server was started with.
port, custom := s.tunnelDefault, ""
if v := strings.TrimSpace(r.FormValue("tunnel_port")); v != "" {
n, err := strconv.Atoi(v)
if err != nil || n < 1 || n > 65535 {
http.Redirect(w, r, "/settings?err=tunnel_port", http.StatusSeeOther)
return
}
port = n
if n != s.tunnelDefault {
custom = v
}
}
if err := s.setTunnelPort(port); err != nil {
log.Printf("tunnel: move to port %d: %v", port, err)
http.Redirect(w, r, "/settings?err=tunnel_listen", http.StatusSeeOther)
return
}
_ = s.db.SetConfig(cfgTunnelPort, custom)
_ = s.db.SetConfig(cfgTunnelHost, host)
http.Redirect(w, r, "/settings", http.StatusSeeOther)
}
// validTunnelHost accepts an empty value, an IP, or a bare host name.
func validTunnelHost(host string) bool {
if host == "" || net.ParseIP(host) != nil {
return true
}
if len(host) > 253 {
return false
}
for _, c := range host {
switch {
case c >= 'a' && c <= 'z', c >= 'A' && c <= 'Z', c >= '0' && c <= '9', c == '-', c == '.', c == '_':
default:
return false
}
}
return true
}
func (s *Server) handleSetHost(w http.ResponseWriter, r *http.Request) {
if !checkCSRF(r) {
http.Error(w, "bad csrf", http.StatusForbidden)
@@ -408,7 +476,12 @@ func (s *Server) handleUfwToggle(w http.ResponseWriter, r *http.Request) {
http.Error(w, "bad csrf", http.StatusForbidden)
return
}
_ = s.db.SetUfwEnabled(r.FormValue("enabled") == "1")
on := r.FormValue("enabled") == "1"
_ = s.db.SetUfwEnabled(on)
if on {
// Open what forwards created while the integration was off need.
s.ufw.sync()
}
http.Redirect(w, r, "/settings", http.StatusSeeOther)
}
+26 -7
View File
@@ -3,6 +3,7 @@ package server
import (
"log"
"net/http"
"net/url"
"strconv"
"strings"
)
@@ -22,13 +23,30 @@ func (s *Server) handleForwardsPage(w http.ResponseWriter, r *http.Request) {
"db": "Ошибка базы данных.",
}[r.URL.Query().Get("err")]
s.render(w, "forwards.html", map[string]any{
"Agent": a,
"Rules": rules,
"CSRF": csrfToken(r),
"Error": errMsg,
"Agent": a,
"Rules": rules,
"CSRF": csrfToken(r),
"Error": errMsg,
"TunnelPort": s.TunnelPort(),
"PanelPort": panelPort(s.db.HostAddress()),
})
}
// panelPort is the port of the panel URL agents dial, for the mode hints.
func panelPort(host string) string {
u, err := url.Parse(host)
if err != nil {
return ""
}
if p := u.Port(); p != "" {
return p
}
if u.Scheme == "http" {
return "80"
}
return "443"
}
// POST /agents/{id}/forwards/add
func (s *Server) handleForwardAdd(w http.ResponseWriter, r *http.Request) {
if !checkCSRF(r) {
@@ -57,8 +75,8 @@ func (s *Server) handleForwardAdd(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "/agents/"+id+"/forwards?err=port", http.StatusSeeOther)
return
}
// Reject a host port already bound by another forward rule.
if s.db.HostPortUsed(hostPort) {
// Reject a host port already bound by another forward rule or the tunnel.
if s.db.HostPortUsed(hostPort) || hostPort == s.TunnelPort() {
http.Redirect(w, r, "/agents/"+id+"/forwards?err=busy", http.StatusSeeOther)
return
}
@@ -91,7 +109,8 @@ func (s *Server) handleForwardDelete(w http.ResponseWriter, r *http.Request) {
rule, _ := s.db.GetForwardRule(rid)
s.fwd.close(rid)
_ = s.db.DeleteForwardRule(rid)
// Remove the ufw rule bound to this forward rule (shared 8446 stays).
// Remove the ufw rule bound to this forward rule (the shared tunnel port
// stays while another direct forward needs it).
s.ufw.revokeForRule(rid)
dest := "/"
if rule != nil {
+83 -15
View File
@@ -9,6 +9,8 @@ import (
"net/http"
"strconv"
"strings"
"sync"
"sync/atomic"
texttemplate "text/template"
)
@@ -24,7 +26,8 @@ type Options struct {
// nginx TLS); set false only for plain-HTTP local testing.
SecureCookies bool
// TunnelAddr is the listen address for the direct TCP+UDP forward tunnel
// (default ":8446"). Empty disables direct forwarding.
// (default ":8446"). Empty disables direct forwarding. The port can be
// overridden from the settings page.
TunnelAddr string
}
@@ -33,12 +36,22 @@ type Server struct {
db *DB
hub *Hub
fwd *forwardManager
tun *tunnelHub
udp *udpRelay
ufw *ufwManager
tmpl *template.Template
limiter *loginLimiter
distDir string
secureCookies bool
installTmpl *texttemplate.Template
// Direct tunnel: bind host and port of the -tunnel address, and the port
// currently listened on (the settings override, else the default).
// tunnelDefault 0 means the tunnel is disabled.
tunnelBind string
tunnelDefault int
tunnelMu sync.Mutex // serializes port changes
tunnelPort atomic.Int64
}
// New builds the server, parsing embedded templates.
@@ -65,26 +78,81 @@ func New(opts Options) (*Server, error) {
db: opts.DB,
hub: hub,
fwd: newForwardManager(hub, tun, udp),
ufw: newUfwManager(opts.DB, portFromAddr(opts.TunnelAddr)),
tun: tun,
udp: udp,
tmpl: tmpl,
limiter: newLoginLimiter(),
distDir: opts.DistDir,
secureCookies: opts.SecureCookies,
installTmpl: installTmpl,
}
// Start the direct forward tunnel (TCP mux + UDP) on 8446.
s.ufw = newUfwManager(opts.DB, s.TunnelPort)
// Start the direct forward tunnel (TCP mux + UDP), on the port from the
// settings page if one is set there.
if opts.TunnelAddr != "" {
if err := tun.listen(opts.TunnelAddr); err != nil {
log.Printf("tunnel tcp listen %s: %v", opts.TunnelAddr, err)
s.tunnelBind, s.tunnelDefault = splitAddr(opts.TunnelAddr)
}
if s.tunnelDefault > 0 {
port := s.tunnelDefault
if p := s.db.TunnelPort(); p > 0 {
port = p
}
if err := udp.listen(opts.TunnelAddr); err != nil {
log.Printf("tunnel udp listen %s: %v", opts.TunnelAddr, err)
s.tunnelPort.Store(int64(port))
addr := s.tunnelListenAddr(port)
if err := tun.listen(addr); err != nil {
log.Printf("tunnel tcp listen %s: %v", addr, err)
}
if err := udp.listen(addr); err != nil {
log.Printf("tunnel udp listen %s: %v", addr, err)
}
}
s.restoreForwards()
go s.ufw.sync()
return s, nil
}
// TunnelPort is the port the direct tunnel listens on (0 = disabled).
func (s *Server) TunnelPort() int { return int(s.tunnelPort.Load()) }
func (s *Server) tunnelListenAddr(port int) string {
return net.JoinHostPort(s.tunnelBind, strconv.Itoa(port))
}
// tunnelAdvert is the tunnel address handed to agents as "host:port". The host
// is empty unless one is set in Settings; agents then use the panel's host.
func (s *Server) tunnelAdvert() string {
port := s.TunnelPort()
if port == 0 {
return ""
}
return net.JoinHostPort(s.db.TunnelHost(), strconv.Itoa(port))
}
// setTunnelPort moves the direct tunnel to another port. Connected agents are
// dropped and redial the new port once their next poll tells them about it.
func (s *Server) setTunnelPort(port int) error {
s.tunnelMu.Lock()
defer s.tunnelMu.Unlock()
old := s.TunnelPort()
if old == 0 || port == old {
return nil
}
addr := s.tunnelListenAddr(port)
if err := s.tun.listen(addr); err != nil {
return err
}
if err := s.udp.listen(addr); err != nil {
// Keep TCP and UDP on the same port: go back to the old one.
if rerr := s.tun.listen(s.tunnelListenAddr(old)); rerr != nil {
log.Printf("tunnel tcp listen %s: %v", s.tunnelListenAddr(old), rerr)
}
return err
}
s.tunnelPort.Store(int64(port))
go s.ufw.sync()
return nil
}
// restoreForwards re-opens the host listeners for all saved forward rules.
func (s *Server) restoreForwards() {
rules, err := s.db.ListForwardRules()
@@ -134,6 +202,7 @@ func (s *Server) Handler() http.Handler {
mux.Handle("POST /agents/{id}/delete", s.requireAdmin(s.handleAgentDelete))
mux.Handle("GET /settings", s.requireAdmin(s.handleSettings))
mux.Handle("POST /settings/host", s.requireAdmin(s.handleSetHost))
mux.Handle("POST /settings/tunnel", s.requireAdmin(s.handleSetTunnel))
mux.Handle("POST /settings/secret/rotate", s.requireAdmin(s.handleRotateSecret))
mux.Handle("POST /settings/admins/add", s.requireAdmin(s.handleAddAdmin))
mux.Handle("POST /settings/admins/delete", s.requireAdmin(s.handleDeleteAdmin))
@@ -157,6 +226,7 @@ func (s *Server) Handler() http.Handler {
// Agent API (token/secret auth inside handlers).
mux.HandleFunc("POST /api/agent/enroll", s.handleEnroll)
mux.HandleFunc("GET /api/agent/poll", s.handlePoll)
mux.HandleFunc("GET /api/agent/config", s.handleAgentConfig)
mux.HandleFunc("GET /api/agent/term/{sid}/in", s.handleTermIn)
mux.HandleFunc("POST /api/agent/term/{sid}/out", s.handleTermOut)
mux.HandleFunc("GET /api/agent/task/{id}", s.handleTaskFetch)
@@ -202,17 +272,15 @@ func (s *Server) render(w http.ResponseWriter, name string, data map[string]any)
}
}
// portFromAddr extracts the numeric port from a "host:port" or ":port" address.
func portFromAddr(addr string) int {
if addr == "" {
return 0
}
_, p, err := net.SplitHostPort(addr)
// splitAddr splits a "host:port" or ":port" listen address. The port is 0 when
// the address has none.
func splitAddr(addr string) (host string, port int) {
h, p, err := net.SplitHostPort(addr)
if err != nil {
return 0
return "", 0
}
n, _ := strconv.Atoi(p)
return n
return h, n
}
func clientIP(r *http.Request) string {
+20 -1
View File
@@ -39,6 +39,7 @@ func readLine(r io.Reader, max int) (string, error) {
type tunnelHub struct {
db *DB
mu sync.Mutex
ln net.Listener
agents map[string]*agentTunnel
}
@@ -57,12 +58,30 @@ type agentTunnel struct {
streams map[uint32]net.Conn
}
// listen runs the 8446 TCP tunnel acceptor until the listener closes.
// listen runs the TCP tunnel acceptor until the listener closes. Listening
// again moves the tunnel: the previous listener is closed and its agents are
// dropped, so they redial the new address.
func (h *tunnelHub) listen(addr string) error {
ln, err := net.Listen("tcp", addr)
if err != nil {
return err
}
h.mu.Lock()
old := h.ln
h.ln = ln
var stale []*agentTunnel
if old != nil {
for _, at := range h.agents {
stale = append(stale, at)
}
}
h.mu.Unlock()
if old != nil {
old.Close()
}
for _, at := range stale {
at.conn.Close()
}
log.Printf("tunnel: listening on %s (direct TCP forwarding)", addr)
go func() {
for {
+22 -10
View File
@@ -20,9 +20,10 @@ import (
// registration (agent->server): "R <agentID> <sig>"
// data (both): 'D' | sid(4) | port(2) | payload
type udpRelay struct {
db *DB
mu sync.Mutex
ctrl *net.UDPConn
db *DB
mu sync.Mutex
ctrl *net.UDPConn
gcOnce sync.Once
agentAddr map[string]*net.UDPAddr // agentID -> last registered addr
sessions map[uint32]*udpSession
@@ -48,7 +49,8 @@ func newUDPRelay(db *DB) *udpRelay {
}
}
// listen opens the shared 8446 UDP control socket.
// listen opens the shared UDP control socket. Listening again moves it: the
// previous socket is closed and agents register anew on the new address.
func (u *udpRelay) listen(addr string) error {
ua, err := net.ResolveUDPAddr("udp", addr)
if err != nil {
@@ -58,17 +60,26 @@ func (u *udpRelay) listen(addr string) error {
if err != nil {
return err
}
u.mu.Lock()
old := u.ctrl
u.ctrl = c
if old != nil {
u.agentAddr = map[string]*net.UDPAddr{}
}
u.mu.Unlock()
if old != nil {
old.Close()
}
log.Printf("tunnel: listening on %s/udp (direct UDP forwarding)", addr)
go u.ctrlLoop()
go u.gc()
go u.ctrlLoop(c)
u.gcOnce.Do(func() { go u.gc() })
return nil
}
func (u *udpRelay) ctrlLoop() {
func (u *udpRelay) ctrlLoop(ctrl *net.UDPConn) {
buf := make([]byte, 65535)
for {
n, src, err := u.ctrl.ReadFromUDP(buf)
n, src, err := ctrl.ReadFromUDP(buf)
if err != nil {
return
}
@@ -171,6 +182,7 @@ func (u *udpRelay) rulePortLoop(rule ForwardRule, c *net.UDPConn) {
}
u.mu.Lock()
aAddr := u.agentAddr[rule.AgentID]
ctrl := u.ctrl
// Find existing session for this (rule, user).
var sess *udpSession
for _, s := range u.sessions {
@@ -190,7 +202,7 @@ func (u *udpRelay) rulePortLoop(rule ForwardRule, c *net.UDPConn) {
sess.last = time.Now()
u.mu.Unlock()
if aAddr == nil {
if aAddr == nil || ctrl == nil {
continue // agent has not registered its UDP endpoint yet
}
// 'D' | sid(4) | clientPort(2) | payload
@@ -199,7 +211,7 @@ func (u *udpRelay) rulePortLoop(rule ForwardRule, c *net.UDPConn) {
binary.BigEndian.PutUint32(out[1:5], sess.id)
binary.BigEndian.PutUint16(out[5:7], uint16(rule.ClientPort))
copy(out[7:], buf[:n])
_, _ = u.ctrl.WriteToUDP(out, aAddr)
_, _ = ctrl.WriteToUDP(out, aAddr)
}
}
+115 -33
View File
@@ -3,8 +3,11 @@ package server
import (
"fmt"
"log"
"net"
"os"
"os/exec"
"strings"
"sync"
)
// ufwManager integrates with the host firewall (ufw) when it is installed and
@@ -14,28 +17,40 @@ import (
// ufw requires root, so this only works when the server process can run ufw
// (e.g. running as root). When it cannot, integration is simply inactive.
type ufwManager struct {
db *DB
tunnelPort int
db *DB
// tunnelPort returns the current direct-tunnel port (0 = tunnel disabled).
tunnelPort func() int
mu sync.Mutex
lastErr string // last failed ufw command, shown in Settings
}
func newUfwManager(db *DB, tunnelPort int) *ufwManager {
func newUfwManager(db *DB, tunnelPort func() int) *ufwManager {
return &ufwManager{db: db, tunnelPort: tunnelPort}
}
// ufwRun runs ufw with a fixed C locale: its output is translated, and we
// match on the English text.
func ufwRun(args ...string) (string, error) {
cmd := exec.Command("ufw", args...)
cmd.Env = append(os.Environ(), "LC_ALL=C", "LANG=C", "LANGUAGE=C")
out, err := cmd.CombinedOutput()
return strings.TrimSpace(string(out)), err
}
// status reports whether ufw is installed, active, and runnable by this process.
func (u *ufwManager) status() (installed, active, runnable bool) {
path, err := exec.LookPath("ufw")
if err != nil {
if _, err := exec.LookPath("ufw"); err != nil {
return false, false, false
}
installed = true
out, err := exec.Command(path, "status").CombinedOutput()
out, err := ufwRun("status")
if err != nil {
// Typically a permission error (not root) -> not runnable.
return true, false, false
}
runnable = true
active = strings.Contains(string(out), "Status: active")
active = strings.Contains(out, "Status: active")
return installed, active, runnable
}
@@ -48,18 +63,36 @@ func (u *ufwManager) available() bool {
return installed && active && runnable
}
// allow opens port/proto in ufw (if integration is available) and records it,
// bound to the given forward rule id (0 = shared/manual).
func (u *ufwManager) setErr(msg string) {
u.mu.Lock()
u.lastErr = msg
u.mu.Unlock()
}
// LastError returns the last failed ufw command ("" when the last one worked).
func (u *ufwManager) LastError() string {
u.mu.Lock()
defer u.mu.Unlock()
return u.lastErr
}
// 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) {
if !u.available() {
return
}
if u.db.HasUfwRule(port, proto) {
return
}
spec := fmt.Sprintf("%d/%s", port, proto)
if out, err := exec.Command("ufw", "allow", spec).CombinedOutput(); err != nil {
log.Printf("ufw allow %s: %v: %s", spec, err, strings.TrimSpace(string(out)))
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
}
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
}
_ = u.db.AddUfwRule(port, proto, ruleID)
@@ -69,9 +102,11 @@ func (u *ufwManager) allow(port int, proto string, ruleID int64) {
// revokeRule deletes one tracked ufw rule from ufw and the tracking table.
func (u *ufwManager) revokeRule(r UfwRule) {
spec := fmt.Sprintf("%d/%s", r.Port, r.Proto)
if out, err := exec.Command("ufw", "delete", "allow", spec).CombinedOutput(); err != nil {
log.Printf("ufw delete allow %s: %v: %s", spec, err, strings.TrimSpace(string(out)))
if out, err := ufwRun("delete", "allow", spec); err != nil {
log.Printf("ufw delete allow %s: %v: %s", spec, err, out)
u.setErr(fmt.Sprintf("ufw delete allow %s: %v: %s", spec, err, out))
} else {
u.setErr("")
log.Printf("ufw: revoked %s", spec)
}
_ = u.db.DeleteUfwRule(r.ID)
@@ -86,8 +121,8 @@ func (u *ufwManager) revoke(id int64) {
u.revokeRule(*r)
}
// revokeForRule removes the ufw rules bound to a specific forward rule. The
// shared tunnel port (rule_id 0) is left alone, since other forwards use it.
// revokeForRule removes the ufw rules bound to a specific forward rule, and
// the shared tunnel port once no forward needs it any more.
func (u *ufwManager) revokeForRule(ruleID int64) {
rules, err := u.db.ListUfwRulesByRule(ruleID)
if err != nil {
@@ -96,24 +131,71 @@ func (u *ufwManager) revokeForRule(ruleID int64) {
for _, r := range rules {
u.revokeRule(r)
}
u.pruneShared()
}
// pruneShared revokes shared (rule_id 0) ufw rules that nothing uses: anything
// but the current tunnel port, and the tunnel port itself for a protocol that
// has no direct forward left.
func (u *ufwManager) pruneShared() {
shared, err := u.db.ListUfwRulesByRule(0)
if err != nil || len(shared) == 0 {
return
}
forwards, err := u.db.ListForwardRules()
if err != nil {
return
}
used := map[string]bool{}
for _, f := range forwards {
used[f.Proto] = true
}
port := u.tunnelPort()
for _, r := range shared {
if r.Port != port || !used[r.Proto] {
u.revokeRule(r)
}
}
}
// allowForward opens the host ports a forward rule needs: its host port (bound
// to the rule so it is revoked with it) plus the shared 8446 tunnel port for
// direct tcp/udp modes (bound to 0 — shared across rules).
// to the rule so it is revoked with it) plus the shared tunnel port for direct
// tcp/udp modes (bound to 0 — shared across rules).
func (u *ufwManager) allowForward(rule ForwardRule) {
switch rule.Proto {
case "udp":
u.allow(rule.HostPort, "udp", rule.ID)
if u.tunnelPort > 0 {
u.allow(u.tunnelPort, "udp", 0)
}
case "tcp":
u.allow(rule.HostPort, "tcp", rule.ID)
if u.tunnelPort > 0 {
u.allow(u.tunnelPort, "tcp", 0)
}
default: // http long-poll listener is TCP
u.allow(rule.HostPort, "tcp", rule.ID)
if !u.available() {
return
}
u.allowForwardPorts(rule)
}
func (u *ufwManager) allowForwardPorts(rule ForwardRule) {
// "http" (long-poll) and "tcp" forwards both listen on TCP on the host.
hostProto, direct := "tcp", rule.Proto == "tcp"
if rule.Proto == "udp" {
hostProto, direct = "udp", true
}
// A listener bound to loopback is not reachable from outside anyway.
if ip := net.ParseIP(rule.ListenAddr); ip == nil || !ip.IsLoopback() {
u.allow(rule.HostPort, hostProto, rule.ID)
}
if port := u.tunnelPort(); direct && port > 0 {
u.allow(port, hostProto, 0)
}
}
// sync brings ufw in line with the saved forward rules: it opens what is
// missing (rules made while ufw or the integration was off, or a tunnel port
// that has since changed) and drops shared rules nothing uses.
func (u *ufwManager) sync() {
if !u.available() {
return
}
forwards, err := u.db.ListForwardRules()
if err != nil {
return
}
for _, f := range forwards {
u.allowForwardPorts(f)
}
u.pruneShared()
}
+9 -9
View File
@@ -70,8 +70,8 @@
<label>Тип</label>
<select name="proto" id="proto">
<option value="http">http (long-poll, работает везде)</option>
<option value="tcp">tcp (прямой, низкая задержка, порт 8446)</option>
<option value="udp">udp (прямой, порт 8446)</option>
<option value="tcp">tcp (прямой, низкая задержка, порт {{.TunnelPort}})</option>
<option value="udp">udp (прямой, порт {{.TunnelPort}})</option>
</select>
</div>
<div class="fld">
@@ -95,18 +95,18 @@
<div class="card">
<h2>Режимы</h2>
<ul class="muted" style="font-size:14px; line-height:1.7">
<li><b>http</b> — туннель поверх HTTP long-poll (через nginx :8445). Работает везде, где доступна панель, но задержка выше.</li>
<li><b>tcp</b> — прямой TCP-мультиплексор через порт <b>8446</b> хоста. Минимальная задержка, keep-alive, TCP_NODELAY. Нужно открыть 8446/tcp на хосте.</li>
<li><b>udp</b> — прямой UDP через порт <b>8446</b> хоста. Нужно открыть 8446/udp.</li>
<li><b>http</b> — туннель поверх HTTP long-poll (через адрес панели, порт {{.PanelPort}}). Работает везде, где доступна панель, но задержка выше.</li>
<li><b>tcp</b> — прямой TCP-мультиплексор через порт <b>{{.TunnelPort}}</b> хоста. Минимальная задержка, keep-alive, TCP_NODELAY. Нужно открыть {{.TunnelPort}}/tcp на хосте.</li>
<li><b>udp</b> — прямой UDP через порт <b>{{.TunnelPort}}</b> хоста. Нужно открыть {{.TunnelPort}}/udp.</li>
</ul>
<p class="muted" style="font-size:13px">Для tcp/udp агент держит исходящее соединение к <code>host:8446</code>; несколько клиентов и портов мультиплексируются на нём.</p>
<p class="muted" style="font-size:13px">Для tcp/udp агент держит исходящее соединение к <code>host:{{.TunnelPort}}</code>; несколько клиентов и портов мультиплексируются на нём.</p>
</div>
</div>
<script>
var notes = {
http: 'Трафик идёт через :8445 (long-poll). Порт 8446 не требуется.',
tcp: 'Требуется открытый порт 8446/tcp на хосте. Самая низкая задержка.',
udp: 'Требуется открытый порт 8446/udp на хосте.'
http: 'Трафик идёт через порт панели {{.PanelPort}} (long-poll). Порт {{.TunnelPort}} не требуется.',
tcp: 'Требуется открытый порт {{.TunnelPort}}/tcp на хосте. Самая низкая задержка.',
udp: 'Требуется открытый порт {{.TunnelPort}}/udp на хосте.'
};
var sel = document.getElementById('proto'), note = document.getElementById('note');
function upd(){ note.textContent = notes[sel.value] || ''; }
+23 -1
View File
@@ -19,6 +19,7 @@
</header>
<div class="wrap">
<h1>Настройки</h1>
{{if .Error}}<div class="err">{{.Error}}</div>{{end}}
<div class="card">
<h2>Установка на новую машину</h2>
@@ -49,6 +50,26 @@
</form>
</div>
<div class="card">
<h2>Прямой туннель (tcp/udp проброс)</h2>
{{if not .TunnelDefault}}
<p class="muted">Прямой туннель отключён при запуске сервера (пустой <code>-tunnel</code> / <code>LPRA_TUNNEL_ADDR</code>).</p>
{{else}}
<p class="muted">Адрес, к которому агенты подключаются для проброса в режимах tcp/udp. Агенты забирают его
с панели сами — при подключении и при каждом опросе, так что смена применяется без переустановки.
Хост можно оставить пустым (тогда берётся хост из адреса панели) или указать IP/другое имя.</p>
<form method="post" action="/settings/tunnel" class="row">
<input type="hidden" name="csrf" value="{{.CSRF}}">
<input type="text" name="tunnel_host" value="{{.TunnelHost}}" placeholder="хост (как у панели)" style="max-width:260px">
<input type="number" name="tunnel_port" min="1" max="65535" value="{{if .TunnelCustom}}{{.TunnelPort}}{{end}}" placeholder="{{.TunnelDefault}}" style="max-width:120px">
<button type="submit">Сохранить</button>
</form>
<p class="muted" style="font-size:13px">Сейчас сервер слушает порт <b>{{.TunnelPort}}</b> (tcp и udp). При смене порта сервер
сразу начинает слушать новый, агенты переподключаются в течение ~30 секунд, правило ufw переносится.
Агенты старше 0.0.3 и агенты с заданными <code>TUNNEL_ADDR</code>/<code>TUNNEL_PORT</code> адрес с панели не берут.</p>
{{end}}
</div>
<div class="card">
<h2>Enroll-секрет</h2>
<p class="muted">Общий секрет для регистрации новых агентов.</p>
@@ -103,7 +124,8 @@
Статус ufw: {{if .UfwActive}}<span style="color:var(--ok)">активен</span>{{else}}<span style="color:var(--danger)">выключен</span>{{end}}
· Интеграция: {{if .UfwEnabled}}<span style="color:var(--ok)">включена</span>{{else}}<span class="muted">выключена</span>{{end}}
</p>
<p class="muted" style="font-size:13px">При создании правила проброса порт открывается в ufw автоматически (для tcp/udp — также порт 8446).</p>
<p class="muted" style="font-size:13px">При создании правила проброса порт открывается в ufw автоматически (для tcp/udp — также порт туннеля{{if .TunnelPort}} {{.TunnelPort}}{{end}}); для http-проброса открывается tcp-порт хоста. Порты правил, слушающих только 127.0.0.1, не открываются.</p>
{{if .UfwError}}<div class="err">Последняя команда ufw завершилась ошибкой: <code>{{.UfwError}}</code></div>{{end}}
<form method="post" action="/settings/ufw/toggle" style="display:inline">
<input type="hidden" name="csrf" value="{{.CSRF}}">
<input type="hidden" name="enabled" value="{{if .UfwEnabled}}0{{else}}1{{end}}">