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

+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 {