diff --git a/README.md b/README.md index 67a19c4..d7b72df 100644 --- a/README.md +++ b/README.md @@ -42,6 +42,23 @@ curl -fsSL https://localhost:8445/install.sh | sudo HOST=https://localhost:8445 curl -fsSL https://localhost:8445/install.sh | sudo sh ``` +## Проброс портов и прямой туннель +Правила проброса создаются на странице машины. Режим `http` идёт через адрес панели (long-poll), +режимы `tcp`/`udp` — через прямой туннель: агент держит исходящее соединение к порту туннеля хоста +(по умолчанию `8446`, флаг `-tunnel` / `LPRA_TUNNEL_ADDR`). + +Порт туннеля и хост, к которому для него подключаются агенты (например, IP вместо домена панели), +меняются в `/settings` → «Прямой туннель». Агенты забирают адрес с панели сами — при подключении и +при каждом опросе, переустановка не нужна. Локальные `TUNNEL_ADDR` / `TUNNEL_PORT` в окружении агента +имеют приоритет над настройкой панели (`TUNNEL_ADDR=off` отключает туннель). + +Если на хосте активен ufw и сервер запущен от root, панель сама открывает порт хоста правила +(tcp для `http`/`tcp`, udp для `udp`) и порт туннеля для прямых режимов, а при удалении правила — закрывает. +При `ProtectSystem=strict` в systemd-юните ufw нужны права на запись: `ReadWritePaths=/etc/ufw /run`. + +## Версия +Версия задаётся в одном месте — `VERSION.txt`; файл вшивается в сервер и агент при сборке. + ## Пользователь терминала По умолчанию терминал открывается от пользователя, под которым запущен агент (на Linux — root). В окне «Изменить машину» можно выбрать другого пользователя машины или создать нового diff --git a/cmd/agent/main.go b/cmd/agent/main.go index 87c56bf..0bcce03 100644 --- a/cmd/agent/main.go +++ b/cmd/agent/main.go @@ -23,6 +23,8 @@ func main() { Name: os.Getenv("AGENT_NAME"), StatePath: envOr("STATE_PATH", "/etc/lpagent/state.json"), TunnelAddr: tunnelAddr(host), + // A locally set address wins over the one from the panel settings. + TunnelPinned: os.Getenv("TUNNEL_ADDR") != "" || os.Getenv("TUNNEL_PORT") != "", } if cfg.EnrollSecret == "" { // Only required for the very first run (before enrollment). @@ -47,8 +49,9 @@ func envOr(key, def string) string { } // tunnelAddr derives the direct-tunnel address (host:8446) from the server URL. -// TUNNEL_ADDR overrides it entirely; TUNNEL_PORT overrides just the port. -// Set TUNNEL_ADDR=off to disable the direct tunnel. +// It is only the starting point: the agent follows the host and port set in +// the panel settings, unless TUNNEL_ADDR (the whole address) or TUNNEL_PORT +// (just the port) pins it here. Set TUNNEL_ADDR=off to disable the tunnel. func tunnelAddr(hostURL string) string { if v := os.Getenv("TUNNEL_ADDR"); v != "" { if v == "off" { diff --git a/deploy/lpra-server.service b/deploy/lpra-server.service index a6769e7..d9e1204 100644 --- a/deploy/lpra-server.service +++ b/deploy/lpra-server.service @@ -19,6 +19,13 @@ # the firewall: ufw allow 8446/tcp ; ufw allow 8446/udp # (The "http" forward mode and terminal/files go through nginx :8445 and need # nothing extra.) Override with LPRA_TUNNEL_ADDR, or set it empty to disable. +# The port, and the host agents dial for it, can also be changed in the panel +# settings; agents pick the new address up from the panel by themselves. +# +# ufw integration (the panel opening forwarded ports itself) needs the server +# to run as root, and ufw's state must be writable despite ProtectSystem=strict +# below — otherwise "ufw status" works but "ufw allow" fails: +# ReadWritePaths=/etc/ufw /run [Unit] Description=LPRA remote access host (admin panel + agent rendezvous) diff --git a/internal/agent/client.go b/internal/agent/client.go index 68e9eb0..6b6805a 100644 --- a/internal/agent/client.go +++ b/internal/agent/client.go @@ -13,9 +13,11 @@ import ( "log" "net" "net/http" + "net/url" "os" "path/filepath" "runtime" + "sync" "time" lpra "github.com/x-grk/longpolling-remote-access" @@ -32,6 +34,9 @@ type Config struct { Name string // optional friendly name StatePath string // where the enrolled id+token are persisted TunnelAddr string // host:port for the direct forward tunnel (e.g. REDACTED:8446); empty disables + // TunnelPinned keeps TunnelAddr as configured locally (TUNNEL_ADDR / + // TUNNEL_PORT) instead of following the address set in the panel. + TunnelPinned bool } type state struct { @@ -45,6 +50,11 @@ type Agent struct { http *http.Client state state pollCount int + + // Direct tunnel currently running (see setTunnelAddr). + tunMu sync.Mutex + tunAddr string + tunCancel context.CancelFunc } // New builds an agent. It does not perform any network I/O. @@ -87,10 +97,11 @@ func (a *Agent) Run(ctx context.Context) error { } log.Printf("agent %s connected to %s", a.state.AgentID, a.cfg.Host) - // Direct forward tunnel (low-latency TCP mux + UDP) on 8446, if configured. - if a.cfg.TunnelAddr != "" { - go a.runTunnel(ctx) - go a.runUDP(ctx) + // Direct forward tunnel (low-latency TCP mux + UDP). Its address comes + // from the panel; fall back to the configured one when the server sends + // none (tunnel port left at the default on an older server). + if a.cfg.TunnelPinned || !a.fetchConfig(ctx) { + a.setTunnelAddr(ctx, a.cfg.TunnelAddr) } backoff := time.Second @@ -168,6 +179,7 @@ func (a *Agent) pollOnce(ctx context.Context) error { if err := json.NewDecoder(resp.Body).Decode(&msg); err != nil { return err } + a.applyTunnelHeader(ctx, resp.Header) // Dispatch in the background so we keep polling for more work. switch msg.Type { case proto.CtrlOpenTerminal: @@ -180,6 +192,66 @@ func (a *Agent) pollOnce(ctx context.Context) error { return nil } +// fetchConfig asks the server for the settings it hands to agents and reports +// whether it named a tunnel address. +func (a *Agent) fetchConfig(ctx context.Context) bool { + ctx2, cancel := context.WithTimeout(ctx, 10*time.Second) + defer cancel() + req, _ := http.NewRequestWithContext(ctx2, http.MethodGet, a.cfg.Host+"/api/agent/config", nil) + a.authHeaders(req) + resp, err := a.http.Do(req) + if err != nil { + return false + } + defer resp.Body.Close() + return a.applyTunnelHeader(ctx, resp.Header) +} + +// applyTunnelHeader follows the tunnel address the server advertises as +// "host:port"; an empty host means the host we already reach the server on. +func (a *Agent) applyTunnelHeader(ctx context.Context, h http.Header) bool { + v := h.Get("X-Tunnel-Addr") + if v == "" || a.cfg.TunnelPinned { + return false + } + host, port, err := net.SplitHostPort(v) + if err != nil { + return false + } + if host == "" { + u, err := url.Parse(a.cfg.Host) + if err != nil || u.Hostname() == "" { + return false + } + host = u.Hostname() + } + a.setTunnelAddr(ctx, net.JoinHostPort(host, port)) + return true +} + +// setTunnelAddr points the direct tunnel at addr, restarting it if it was +// running against another address. An empty addr stops it. +func (a *Agent) setTunnelAddr(ctx context.Context, addr string) { + a.tunMu.Lock() + defer a.tunMu.Unlock() + if addr == a.tunAddr { + return + } + if a.tunCancel != nil { + a.tunCancel() + a.tunCancel = nil + } + a.tunAddr = addr + if addr == "" { + return + } + log.Printf("tunnel: using %s", addr) + tctx, cancel := context.WithCancel(ctx) + a.tunCancel = cancel + go a.runTunnel(tctx, addr) + go a.runUDP(tctx, addr) +} + func (a *Agent) authHeaders(req *http.Request) { req.Header.Set("X-Agent-Id", a.state.AgentID) req.Header.Set("Authorization", "Bearer "+a.state.Token) diff --git a/internal/agent/tunnel.go b/internal/agent/tunnel.go index e5dd121..fb3a44b 100644 --- a/internal/agent/tunnel.go +++ b/internal/agent/tunnel.go @@ -15,12 +15,12 @@ import ( // --- direct TCP mux tunnel --- -// runTunnel keeps a persistent connection to the host's 8446 tunnel, dialing -// client-side ports on demand for "tcp" forward rules. -func (a *Agent) runTunnel(ctx context.Context) { +// runTunnel keeps a persistent connection to the host's tunnel at addr, dialing +// client-side ports on demand for "tcp" forward rules, until ctx is cancelled. +func (a *Agent) runTunnel(ctx context.Context, addr string) { backoff := time.Second for ctx.Err() == nil { - if err := a.tunnelOnce(ctx); err != nil { + if err := a.tunnelOnce(ctx, addr); err != nil { select { case <-time.After(backoff): case <-ctx.Done(): @@ -35,13 +35,15 @@ func (a *Agent) runTunnel(ctx context.Context) { } } -func (a *Agent) tunnelOnce(ctx context.Context) error { +func (a *Agent) tunnelOnce(ctx context.Context, addr string) error { d := net.Dialer{Timeout: 10 * time.Second} - conn, err := d.DialContext(ctx, "tcp", a.cfg.TunnelAddr) + conn, err := d.DialContext(ctx, "tcp", addr) if err != nil { return err } defer conn.Close() + // Unblock the read loop when the tunnel is stopped or moved. + defer context.AfterFunc(ctx, func() { conn.Close() })() if tc, ok := conn.(*net.TCPConn); ok { _ = tc.SetNoDelay(true) } @@ -65,7 +67,7 @@ func (a *Agent) tunnelOnce(ctx context.Context) error { return err } _ = conn.SetDeadline(time.Time{}) - log.Printf("tunnel: connected to %s", a.cfg.TunnelAddr) + log.Printf("tunnel: connected to %s", addr) tc := &tunClient{conn: conn, streams: map[uint32]*agentStream{}} return tc.readLoop() @@ -194,12 +196,12 @@ func (t *tunClient) shutdown() { // --- direct UDP forwarding --- -// runUDP maintains the agent's UDP endpoint on the host's 8446 channel and +// runUDP maintains the agent's UDP endpoint on the host's tunnel at addr and // relays datagrams to/from client-side UDP ports for "udp" forward rules. -func (a *Agent) runUDP(ctx context.Context) { +func (a *Agent) runUDP(ctx context.Context, addr string) { backoff := time.Second for ctx.Err() == nil { - if err := a.udpOnce(ctx); err != nil { + if err := a.udpOnce(ctx, addr); err != nil { select { case <-time.After(backoff): case <-ctx.Done(): @@ -214,8 +216,8 @@ func (a *Agent) runUDP(ctx context.Context) { } } -func (a *Agent) udpOnce(ctx context.Context) error { - raddr, err := net.ResolveUDPAddr("udp", a.cfg.TunnelAddr) +func (a *Agent) udpOnce(ctx context.Context, addr string) error { + raddr, err := net.ResolveUDPAddr("udp", addr) if err != nil { return err } @@ -224,6 +226,8 @@ func (a *Agent) udpOnce(ctx context.Context) error { return err } defer conn.Close() + // Unblock the read loop when the tunnel is stopped or moved. + defer context.AfterFunc(ctx, func() { conn.Close() })() key := tunnel.AuthKey(a.state.Token) register := func() { @@ -268,7 +272,7 @@ func (a *Agent) udpOnce(ctx context.Context) error { } type udpClient struct { - server *net.UDPConn // connection back to host:8446 + server *net.UDPConn // connection back to the host tunnel mu sync.Mutex dials map[uint32]*net.UDPConn // sid -> client-side socket } diff --git a/internal/server/config.go b/internal/server/config.go index 5753e90..1b2e1ef 100644 --- a/internal/server/config.go +++ b/internal/server/config.go @@ -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" diff --git a/internal/server/handlers_agent.go b/internal/server/handlers_agent.go index 25cd4ff..0258d25 100644 --- a/internal/server/handlers_agent.go +++ b/internal/server/handlers_agent.go @@ -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. diff --git a/internal/server/handlers_panel.go b/internal/server/handlers_panel.go index e6869de..94a8906 100644 --- a/internal/server/handlers_panel.go +++ b/internal/server/handlers_panel.go @@ -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) } diff --git a/internal/server/handlers_panel_fwd.go b/internal/server/handlers_panel_fwd.go index c098cc6..3f7a5f1 100644 --- a/internal/server/handlers_panel_fwd.go +++ b/internal/server/handlers_panel_fwd.go @@ -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 { diff --git a/internal/server/server.go b/internal/server/server.go index 2074e25..bf6f213 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -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 { diff --git a/internal/server/tunnelserver.go b/internal/server/tunnelserver.go index a45d37e..ce9100a 100644 --- a/internal/server/tunnelserver.go +++ b/internal/server/tunnelserver.go @@ -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 { diff --git a/internal/server/udp.go b/internal/server/udp.go index bf7b986..b49277c 100644 --- a/internal/server/udp.go +++ b/internal/server/udp.go @@ -20,9 +20,10 @@ import ( // registration (agent->server): "R " // 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) } } diff --git a/internal/server/ufw.go b/internal/server/ufw.go index 622e397..ddc960a 100644 --- a/internal/server/ufw.go +++ b/internal/server/ufw.go @@ -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() +} diff --git a/internal/server/web/templates/forwards.html b/internal/server/web/templates/forwards.html index 782539d..4cbf1cf 100644 --- a/internal/server/web/templates/forwards.html +++ b/internal/server/web/templates/forwards.html @@ -70,8 +70,8 @@
@@ -95,18 +95,18 @@

Режимы

    -
  • http — туннель поверх HTTP long-poll (через nginx :8445). Работает везде, где доступна панель, но задержка выше.
  • -
  • tcp — прямой TCP-мультиплексор через порт 8446 хоста. Минимальная задержка, keep-alive, TCP_NODELAY. Нужно открыть 8446/tcp на хосте.
  • -
  • udp — прямой UDP через порт 8446 хоста. Нужно открыть 8446/udp.
  • +
  • http — туннель поверх HTTP long-poll (через адрес панели, порт {{.PanelPort}}). Работает везде, где доступна панель, но задержка выше.
  • +
  • tcp — прямой TCP-мультиплексор через порт {{.TunnelPort}} хоста. Минимальная задержка, keep-alive, TCP_NODELAY. Нужно открыть {{.TunnelPort}}/tcp на хосте.
  • +
  • udp — прямой UDP через порт {{.TunnelPort}} хоста. Нужно открыть {{.TunnelPort}}/udp.
-

Для tcp/udp агент держит исходящее соединение к host:8446; несколько клиентов и портов мультиплексируются на нём.

+

Для tcp/udp агент держит исходящее соединение к host:{{.TunnelPort}}; несколько клиентов и портов мультиплексируются на нём.