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:
1 parent
08fc2d79a8
commit
bcfe909aaa
15 files changed
+530
-87
No files matched your search
@@ -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
|
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).
|
По умолчанию терминал открывается от пользователя, под которым запущен агент (на Linux — root).
|
||||||
В окне «Изменить машину» можно выбрать другого пользователя машины или создать нового
|
В окне «Изменить машину» можно выбрать другого пользователя машины или создать нового
|
||||||
|
|||||||
+5
-2
@@ -23,6 +23,8 @@ func main() {
|
|||||||
Name: os.Getenv("AGENT_NAME"),
|
Name: os.Getenv("AGENT_NAME"),
|
||||||
StatePath: envOr("STATE_PATH", "/etc/lpagent/state.json"),
|
StatePath: envOr("STATE_PATH", "/etc/lpagent/state.json"),
|
||||||
TunnelAddr: tunnelAddr(host),
|
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 == "" {
|
if cfg.EnrollSecret == "" {
|
||||||
// Only required for the very first run (before enrollment).
|
// 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.
|
// tunnelAddr derives the direct-tunnel address (host:8446) from the server URL.
|
||||||
// TUNNEL_ADDR overrides it entirely; TUNNEL_PORT overrides just the port.
|
// It is only the starting point: the agent follows the host and port set in
|
||||||
// Set TUNNEL_ADDR=off to disable the direct tunnel.
|
// 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 {
|
func tunnelAddr(hostURL string) string {
|
||||||
if v := os.Getenv("TUNNEL_ADDR"); v != "" {
|
if v := os.Getenv("TUNNEL_ADDR"); v != "" {
|
||||||
if v == "off" {
|
if v == "off" {
|
||||||
|
|||||||
@@ -19,6 +19,13 @@
|
|||||||
# the firewall: ufw allow 8446/tcp ; ufw allow 8446/udp
|
# the firewall: ufw allow 8446/tcp ; ufw allow 8446/udp
|
||||||
# (The "http" forward mode and terminal/files go through nginx :8445 and need
|
# (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.
|
# 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]
|
[Unit]
|
||||||
Description=LPRA remote access host (admin panel + agent rendezvous)
|
Description=LPRA remote access host (admin panel + agent rendezvous)
|
||||||
|
|||||||
@@ -13,9 +13,11 @@ import (
|
|||||||
"log"
|
"log"
|
||||||
"net"
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"runtime"
|
"runtime"
|
||||||
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
lpra "github.com/x-grk/longpolling-remote-access"
|
lpra "github.com/x-grk/longpolling-remote-access"
|
||||||
@@ -32,6 +34,9 @@ type Config struct {
|
|||||||
Name string // optional friendly name
|
Name string // optional friendly name
|
||||||
StatePath string // where the enrolled id+token are persisted
|
StatePath string // where the enrolled id+token are persisted
|
||||||
TunnelAddr string // host:port for the direct forward tunnel (e.g. REDACTED:8446); empty disables
|
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 {
|
type state struct {
|
||||||
@@ -45,6 +50,11 @@ type Agent struct {
|
|||||||
http *http.Client
|
http *http.Client
|
||||||
state state
|
state state
|
||||||
pollCount int
|
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.
|
// 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)
|
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.
|
// Direct forward tunnel (low-latency TCP mux + UDP). Its address comes
|
||||||
if a.cfg.TunnelAddr != "" {
|
// from the panel; fall back to the configured one when the server sends
|
||||||
go a.runTunnel(ctx)
|
// none (tunnel port left at the default on an older server).
|
||||||
go a.runUDP(ctx)
|
if a.cfg.TunnelPinned || !a.fetchConfig(ctx) {
|
||||||
|
a.setTunnelAddr(ctx, a.cfg.TunnelAddr)
|
||||||
}
|
}
|
||||||
|
|
||||||
backoff := time.Second
|
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 {
|
if err := json.NewDecoder(resp.Body).Decode(&msg); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
a.applyTunnelHeader(ctx, resp.Header)
|
||||||
// Dispatch in the background so we keep polling for more work.
|
// Dispatch in the background so we keep polling for more work.
|
||||||
switch msg.Type {
|
switch msg.Type {
|
||||||
case proto.CtrlOpenTerminal:
|
case proto.CtrlOpenTerminal:
|
||||||
@@ -180,6 +192,66 @@ func (a *Agent) pollOnce(ctx context.Context) error {
|
|||||||
return nil
|
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) {
|
func (a *Agent) authHeaders(req *http.Request) {
|
||||||
req.Header.Set("X-Agent-Id", a.state.AgentID)
|
req.Header.Set("X-Agent-Id", a.state.AgentID)
|
||||||
req.Header.Set("Authorization", "Bearer "+a.state.Token)
|
req.Header.Set("Authorization", "Bearer "+a.state.Token)
|
||||||
|
|||||||
+17
-13
@@ -15,12 +15,12 @@ import (
|
|||||||
|
|
||||||
// --- direct TCP mux tunnel ---
|
// --- direct TCP mux tunnel ---
|
||||||
|
|
||||||
// runTunnel keeps a persistent connection to the host's 8446 tunnel, dialing
|
// runTunnel keeps a persistent connection to the host's tunnel at addr, dialing
|
||||||
// client-side ports on demand for "tcp" forward rules.
|
// client-side ports on demand for "tcp" forward rules, until ctx is cancelled.
|
||||||
func (a *Agent) runTunnel(ctx context.Context) {
|
func (a *Agent) runTunnel(ctx context.Context, addr string) {
|
||||||
backoff := time.Second
|
backoff := time.Second
|
||||||
for ctx.Err() == nil {
|
for ctx.Err() == nil {
|
||||||
if err := a.tunnelOnce(ctx); err != nil {
|
if err := a.tunnelOnce(ctx, addr); err != nil {
|
||||||
select {
|
select {
|
||||||
case <-time.After(backoff):
|
case <-time.After(backoff):
|
||||||
case <-ctx.Done():
|
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}
|
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 {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
defer conn.Close()
|
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 {
|
if tc, ok := conn.(*net.TCPConn); ok {
|
||||||
_ = tc.SetNoDelay(true)
|
_ = tc.SetNoDelay(true)
|
||||||
}
|
}
|
||||||
@@ -65,7 +67,7 @@ func (a *Agent) tunnelOnce(ctx context.Context) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
_ = conn.SetDeadline(time.Time{})
|
_ = 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{}}
|
tc := &tunClient{conn: conn, streams: map[uint32]*agentStream{}}
|
||||||
return tc.readLoop()
|
return tc.readLoop()
|
||||||
@@ -194,12 +196,12 @@ func (t *tunClient) shutdown() {
|
|||||||
|
|
||||||
// --- direct UDP forwarding ---
|
// --- 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.
|
// 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
|
backoff := time.Second
|
||||||
for ctx.Err() == nil {
|
for ctx.Err() == nil {
|
||||||
if err := a.udpOnce(ctx); err != nil {
|
if err := a.udpOnce(ctx, addr); err != nil {
|
||||||
select {
|
select {
|
||||||
case <-time.After(backoff):
|
case <-time.After(backoff):
|
||||||
case <-ctx.Done():
|
case <-ctx.Done():
|
||||||
@@ -214,8 +216,8 @@ func (a *Agent) runUDP(ctx context.Context) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *Agent) udpOnce(ctx context.Context) error {
|
func (a *Agent) udpOnce(ctx context.Context, addr string) error {
|
||||||
raddr, err := net.ResolveUDPAddr("udp", a.cfg.TunnelAddr)
|
raddr, err := net.ResolveUDPAddr("udp", addr)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -224,6 +226,8 @@ func (a *Agent) udpOnce(ctx context.Context) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
defer conn.Close()
|
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)
|
key := tunnel.AuthKey(a.state.Token)
|
||||||
register := func() {
|
register := func() {
|
||||||
@@ -268,7 +272,7 @@ func (a *Agent) udpOnce(ctx context.Context) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type udpClient struct {
|
type udpClient struct {
|
||||||
server *net.UDPConn // connection back to host:8446
|
server *net.UDPConn // connection back to the host tunnel
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
dials map[uint32]*net.UDPConn // sid -> client-side socket
|
dials map[uint32]*net.UDPConn // sid -> client-side socket
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package server
|
|||||||
import (
|
import (
|
||||||
"crypto/rand"
|
"crypto/rand"
|
||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
lpra "github.com/x-grk/longpolling-remote-access"
|
lpra "github.com/x-grk/longpolling-remote-access"
|
||||||
)
|
)
|
||||||
@@ -14,6 +15,8 @@ const (
|
|||||||
cfgSetupDone = "setup_done"
|
cfgSetupDone = "setup_done"
|
||||||
cfgInstallCode = "install_code"
|
cfgInstallCode = "install_code"
|
||||||
cfgUfwEnabled = "ufw_enabled"
|
cfgUfwEnabled = "ufw_enabled"
|
||||||
|
cfgTunnelHost = "tunnel_host"
|
||||||
|
cfgTunnelPort = "tunnel_port"
|
||||||
)
|
)
|
||||||
|
|
||||||
// DefaultHostAddress is the public URL agents dial by default. It is baked into
|
// 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")
|
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 {
|
func (db *DB) SetupDone() bool {
|
||||||
v, _ := db.GetConfig(cfgSetupDone)
|
v, _ := db.GetConfig(cfgSetupDone)
|
||||||
return v == "1"
|
return v == "1"
|
||||||
|
|||||||
@@ -52,7 +52,31 @@ func (s *Server) handlePoll(w http.ResponseWriter, r *http.Request) {
|
|||||||
} else {
|
} else {
|
||||||
_ = s.db.TouchAgent(a.ID)
|
_ = 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.
|
// GET /api/agent/task/{id} — the agent fetches the task to run.
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
"slices"
|
"slices"
|
||||||
@@ -349,9 +350,76 @@ func (s *Server) handleSettings(w http.ResponseWriter, r *http.Request) {
|
|||||||
"UfwRunnable": ufwRunnable,
|
"UfwRunnable": ufwRunnable,
|
||||||
"UfwEnabled": s.db.UfwEnabled(),
|
"UfwEnabled": s.db.UfwEnabled(),
|
||||||
"UfwRules": ufwRules,
|
"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) {
|
func (s *Server) handleSetHost(w http.ResponseWriter, r *http.Request) {
|
||||||
if !checkCSRF(r) {
|
if !checkCSRF(r) {
|
||||||
http.Error(w, "bad csrf", http.StatusForbidden)
|
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)
|
http.Error(w, "bad csrf", http.StatusForbidden)
|
||||||
return
|
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)
|
http.Redirect(w, r, "/settings", http.StatusSeeOther)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package server
|
|||||||
import (
|
import (
|
||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"net/url"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
@@ -26,9 +27,26 @@ func (s *Server) handleForwardsPage(w http.ResponseWriter, r *http.Request) {
|
|||||||
"Rules": rules,
|
"Rules": rules,
|
||||||
"CSRF": csrfToken(r),
|
"CSRF": csrfToken(r),
|
||||||
"Error": errMsg,
|
"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
|
// POST /agents/{id}/forwards/add
|
||||||
func (s *Server) handleForwardAdd(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleForwardAdd(w http.ResponseWriter, r *http.Request) {
|
||||||
if !checkCSRF(r) {
|
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)
|
http.Redirect(w, r, "/agents/"+id+"/forwards?err=port", http.StatusSeeOther)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// Reject a host port already bound by another forward rule.
|
// Reject a host port already bound by another forward rule or the tunnel.
|
||||||
if s.db.HostPortUsed(hostPort) {
|
if s.db.HostPortUsed(hostPort) || hostPort == s.TunnelPort() {
|
||||||
http.Redirect(w, r, "/agents/"+id+"/forwards?err=busy", http.StatusSeeOther)
|
http.Redirect(w, r, "/agents/"+id+"/forwards?err=busy", http.StatusSeeOther)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -91,7 +109,8 @@ func (s *Server) handleForwardDelete(w http.ResponseWriter, r *http.Request) {
|
|||||||
rule, _ := s.db.GetForwardRule(rid)
|
rule, _ := s.db.GetForwardRule(rid)
|
||||||
s.fwd.close(rid)
|
s.fwd.close(rid)
|
||||||
_ = s.db.DeleteForwardRule(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)
|
s.ufw.revokeForRule(rid)
|
||||||
dest := "/"
|
dest := "/"
|
||||||
if rule != nil {
|
if rule != nil {
|
||||||
|
|||||||
+83
-15
@@ -9,6 +9,8 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"sync/atomic"
|
||||||
texttemplate "text/template"
|
texttemplate "text/template"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -24,7 +26,8 @@ type Options struct {
|
|||||||
// nginx TLS); set false only for plain-HTTP local testing.
|
// nginx TLS); set false only for plain-HTTP local testing.
|
||||||
SecureCookies bool
|
SecureCookies bool
|
||||||
// TunnelAddr is the listen address for the direct TCP+UDP forward tunnel
|
// 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
|
TunnelAddr string
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -33,12 +36,22 @@ type Server struct {
|
|||||||
db *DB
|
db *DB
|
||||||
hub *Hub
|
hub *Hub
|
||||||
fwd *forwardManager
|
fwd *forwardManager
|
||||||
|
tun *tunnelHub
|
||||||
|
udp *udpRelay
|
||||||
ufw *ufwManager
|
ufw *ufwManager
|
||||||
tmpl *template.Template
|
tmpl *template.Template
|
||||||
limiter *loginLimiter
|
limiter *loginLimiter
|
||||||
distDir string
|
distDir string
|
||||||
secureCookies bool
|
secureCookies bool
|
||||||
installTmpl *texttemplate.Template
|
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.
|
// New builds the server, parsing embedded templates.
|
||||||
@@ -65,26 +78,81 @@ func New(opts Options) (*Server, error) {
|
|||||||
db: opts.DB,
|
db: opts.DB,
|
||||||
hub: hub,
|
hub: hub,
|
||||||
fwd: newForwardManager(hub, tun, udp),
|
fwd: newForwardManager(hub, tun, udp),
|
||||||
ufw: newUfwManager(opts.DB, portFromAddr(opts.TunnelAddr)),
|
tun: tun,
|
||||||
|
udp: udp,
|
||||||
tmpl: tmpl,
|
tmpl: tmpl,
|
||||||
limiter: newLoginLimiter(),
|
limiter: newLoginLimiter(),
|
||||||
distDir: opts.DistDir,
|
distDir: opts.DistDir,
|
||||||
secureCookies: opts.SecureCookies,
|
secureCookies: opts.SecureCookies,
|
||||||
installTmpl: installTmpl,
|
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 opts.TunnelAddr != "" {
|
||||||
if err := tun.listen(opts.TunnelAddr); err != nil {
|
s.tunnelBind, s.tunnelDefault = splitAddr(opts.TunnelAddr)
|
||||||
log.Printf("tunnel tcp listen %s: %v", opts.TunnelAddr, err)
|
|
||||||
}
|
}
|
||||||
if err := udp.listen(opts.TunnelAddr); err != nil {
|
if s.tunnelDefault > 0 {
|
||||||
log.Printf("tunnel udp listen %s: %v", opts.TunnelAddr, err)
|
port := s.tunnelDefault
|
||||||
|
if p := s.db.TunnelPort(); p > 0 {
|
||||||
|
port = p
|
||||||
|
}
|
||||||
|
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()
|
s.restoreForwards()
|
||||||
|
go s.ufw.sync()
|
||||||
return s, nil
|
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.
|
// restoreForwards re-opens the host listeners for all saved forward rules.
|
||||||
func (s *Server) restoreForwards() {
|
func (s *Server) restoreForwards() {
|
||||||
rules, err := s.db.ListForwardRules()
|
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("POST /agents/{id}/delete", s.requireAdmin(s.handleAgentDelete))
|
||||||
mux.Handle("GET /settings", s.requireAdmin(s.handleSettings))
|
mux.Handle("GET /settings", s.requireAdmin(s.handleSettings))
|
||||||
mux.Handle("POST /settings/host", s.requireAdmin(s.handleSetHost))
|
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/secret/rotate", s.requireAdmin(s.handleRotateSecret))
|
||||||
mux.Handle("POST /settings/admins/add", s.requireAdmin(s.handleAddAdmin))
|
mux.Handle("POST /settings/admins/add", s.requireAdmin(s.handleAddAdmin))
|
||||||
mux.Handle("POST /settings/admins/delete", s.requireAdmin(s.handleDeleteAdmin))
|
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).
|
// Agent API (token/secret auth inside handlers).
|
||||||
mux.HandleFunc("POST /api/agent/enroll", s.handleEnroll)
|
mux.HandleFunc("POST /api/agent/enroll", s.handleEnroll)
|
||||||
mux.HandleFunc("GET /api/agent/poll", s.handlePoll)
|
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("GET /api/agent/term/{sid}/in", s.handleTermIn)
|
||||||
mux.HandleFunc("POST /api/agent/term/{sid}/out", s.handleTermOut)
|
mux.HandleFunc("POST /api/agent/term/{sid}/out", s.handleTermOut)
|
||||||
mux.HandleFunc("GET /api/agent/task/{id}", s.handleTaskFetch)
|
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.
|
// splitAddr splits a "host:port" or ":port" listen address. The port is 0 when
|
||||||
func portFromAddr(addr string) int {
|
// the address has none.
|
||||||
if addr == "" {
|
func splitAddr(addr string) (host string, port int) {
|
||||||
return 0
|
h, p, err := net.SplitHostPort(addr)
|
||||||
}
|
|
||||||
_, p, err := net.SplitHostPort(addr)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 0
|
return "", 0
|
||||||
}
|
}
|
||||||
n, _ := strconv.Atoi(p)
|
n, _ := strconv.Atoi(p)
|
||||||
return n
|
return h, n
|
||||||
}
|
}
|
||||||
|
|
||||||
func clientIP(r *http.Request) string {
|
func clientIP(r *http.Request) string {
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ func readLine(r io.Reader, max int) (string, error) {
|
|||||||
type tunnelHub struct {
|
type tunnelHub struct {
|
||||||
db *DB
|
db *DB
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
|
ln net.Listener
|
||||||
agents map[string]*agentTunnel
|
agents map[string]*agentTunnel
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -57,12 +58,30 @@ type agentTunnel struct {
|
|||||||
streams map[uint32]net.Conn
|
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 {
|
func (h *tunnelHub) listen(addr string) error {
|
||||||
ln, err := net.Listen("tcp", addr)
|
ln, err := net.Listen("tcp", addr)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
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)
|
log.Printf("tunnel: listening on %s (direct TCP forwarding)", addr)
|
||||||
go func() {
|
go func() {
|
||||||
for {
|
for {
|
||||||
|
|||||||
+19
-7
@@ -23,6 +23,7 @@ type udpRelay struct {
|
|||||||
db *DB
|
db *DB
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
ctrl *net.UDPConn
|
ctrl *net.UDPConn
|
||||||
|
gcOnce sync.Once
|
||||||
|
|
||||||
agentAddr map[string]*net.UDPAddr // agentID -> last registered addr
|
agentAddr map[string]*net.UDPAddr // agentID -> last registered addr
|
||||||
sessions map[uint32]*udpSession
|
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 {
|
func (u *udpRelay) listen(addr string) error {
|
||||||
ua, err := net.ResolveUDPAddr("udp", addr)
|
ua, err := net.ResolveUDPAddr("udp", addr)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -58,17 +60,26 @@ func (u *udpRelay) listen(addr string) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
u.mu.Lock()
|
||||||
|
old := u.ctrl
|
||||||
u.ctrl = c
|
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)
|
log.Printf("tunnel: listening on %s/udp (direct UDP forwarding)", addr)
|
||||||
go u.ctrlLoop()
|
go u.ctrlLoop(c)
|
||||||
go u.gc()
|
u.gcOnce.Do(func() { go u.gc() })
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (u *udpRelay) ctrlLoop() {
|
func (u *udpRelay) ctrlLoop(ctrl *net.UDPConn) {
|
||||||
buf := make([]byte, 65535)
|
buf := make([]byte, 65535)
|
||||||
for {
|
for {
|
||||||
n, src, err := u.ctrl.ReadFromUDP(buf)
|
n, src, err := ctrl.ReadFromUDP(buf)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -171,6 +182,7 @@ func (u *udpRelay) rulePortLoop(rule ForwardRule, c *net.UDPConn) {
|
|||||||
}
|
}
|
||||||
u.mu.Lock()
|
u.mu.Lock()
|
||||||
aAddr := u.agentAddr[rule.AgentID]
|
aAddr := u.agentAddr[rule.AgentID]
|
||||||
|
ctrl := u.ctrl
|
||||||
// Find existing session for this (rule, user).
|
// Find existing session for this (rule, user).
|
||||||
var sess *udpSession
|
var sess *udpSession
|
||||||
for _, s := range u.sessions {
|
for _, s := range u.sessions {
|
||||||
@@ -190,7 +202,7 @@ func (u *udpRelay) rulePortLoop(rule ForwardRule, c *net.UDPConn) {
|
|||||||
sess.last = time.Now()
|
sess.last = time.Now()
|
||||||
u.mu.Unlock()
|
u.mu.Unlock()
|
||||||
|
|
||||||
if aAddr == nil {
|
if aAddr == nil || ctrl == nil {
|
||||||
continue // agent has not registered its UDP endpoint yet
|
continue // agent has not registered its UDP endpoint yet
|
||||||
}
|
}
|
||||||
// 'D' | sid(4) | clientPort(2) | payload
|
// '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.PutUint32(out[1:5], sess.id)
|
||||||
binary.BigEndian.PutUint16(out[5:7], uint16(rule.ClientPort))
|
binary.BigEndian.PutUint16(out[5:7], uint16(rule.ClientPort))
|
||||||
copy(out[7:], buf[:n])
|
copy(out[7:], buf[:n])
|
||||||
_, _ = u.ctrl.WriteToUDP(out, aAddr)
|
_, _ = ctrl.WriteToUDP(out, aAddr)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+112
-30
@@ -3,8 +3,11 @@ package server
|
|||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
|
"net"
|
||||||
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ufwManager integrates with the host firewall (ufw) when it is installed and
|
// ufwManager integrates with the host firewall (ufw) when it is installed and
|
||||||
@@ -15,27 +18,39 @@ import (
|
|||||||
// (e.g. running as root). When it cannot, integration is simply inactive.
|
// (e.g. running as root). When it cannot, integration is simply inactive.
|
||||||
type ufwManager struct {
|
type ufwManager struct {
|
||||||
db *DB
|
db *DB
|
||||||
tunnelPort int
|
// 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}
|
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.
|
// status reports whether ufw is installed, active, and runnable by this process.
|
||||||
func (u *ufwManager) status() (installed, active, runnable bool) {
|
func (u *ufwManager) status() (installed, active, runnable bool) {
|
||||||
path, err := exec.LookPath("ufw")
|
if _, err := exec.LookPath("ufw"); err != nil {
|
||||||
if err != nil {
|
|
||||||
return false, false, false
|
return false, false, false
|
||||||
}
|
}
|
||||||
installed = true
|
installed = true
|
||||||
out, err := exec.Command(path, "status").CombinedOutput()
|
out, err := ufwRun("status")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// Typically a permission error (not root) -> not runnable.
|
// Typically a permission error (not root) -> not runnable.
|
||||||
return true, false, false
|
return true, false, false
|
||||||
}
|
}
|
||||||
runnable = true
|
runnable = true
|
||||||
active = strings.Contains(string(out), "Status: active")
|
active = strings.Contains(out, "Status: active")
|
||||||
return installed, active, runnable
|
return installed, active, runnable
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -48,18 +63,36 @@ func (u *ufwManager) available() bool {
|
|||||||
return installed && active && runnable
|
return installed && active && runnable
|
||||||
}
|
}
|
||||||
|
|
||||||
// allow opens port/proto in ufw (if integration is available) and records it,
|
func (u *ufwManager) setErr(msg string) {
|
||||||
// bound to the given forward rule id (0 = shared/manual).
|
u.mu.Lock()
|
||||||
func (u *ufwManager) allow(port int, proto string, ruleID int64) {
|
u.lastErr = msg
|
||||||
if !u.available() {
|
u.mu.Unlock()
|
||||||
return
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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.db.HasUfwRule(port, proto) {
|
if u.db.HasUfwRule(port, proto) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
spec := fmt.Sprintf("%d/%s", port, proto)
|
spec := fmt.Sprintf("%d/%s", port, proto)
|
||||||
if out, err := exec.Command("ufw", "allow", spec).CombinedOutput(); err != nil {
|
out, err := ufwRun("allow", spec)
|
||||||
log.Printf("ufw allow %s: %v: %s", spec, err, strings.TrimSpace(string(out)))
|
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
|
return
|
||||||
}
|
}
|
||||||
_ = u.db.AddUfwRule(port, proto, ruleID)
|
_ = 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.
|
// revokeRule deletes one tracked ufw rule from ufw and the tracking table.
|
||||||
func (u *ufwManager) revokeRule(r UfwRule) {
|
func (u *ufwManager) revokeRule(r UfwRule) {
|
||||||
spec := fmt.Sprintf("%d/%s", r.Port, r.Proto)
|
spec := fmt.Sprintf("%d/%s", r.Port, r.Proto)
|
||||||
if out, err := exec.Command("ufw", "delete", "allow", spec).CombinedOutput(); err != nil {
|
if out, err := ufwRun("delete", "allow", spec); err != nil {
|
||||||
log.Printf("ufw delete allow %s: %v: %s", spec, err, strings.TrimSpace(string(out)))
|
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 {
|
} else {
|
||||||
|
u.setErr("")
|
||||||
log.Printf("ufw: revoked %s", spec)
|
log.Printf("ufw: revoked %s", spec)
|
||||||
}
|
}
|
||||||
_ = u.db.DeleteUfwRule(r.ID)
|
_ = u.db.DeleteUfwRule(r.ID)
|
||||||
@@ -86,8 +121,8 @@ func (u *ufwManager) revoke(id int64) {
|
|||||||
u.revokeRule(*r)
|
u.revokeRule(*r)
|
||||||
}
|
}
|
||||||
|
|
||||||
// revokeForRule removes the ufw rules bound to a specific forward rule. The
|
// revokeForRule removes the ufw rules bound to a specific forward rule, and
|
||||||
// shared tunnel port (rule_id 0) is left alone, since other forwards use it.
|
// the shared tunnel port once no forward needs it any more.
|
||||||
func (u *ufwManager) revokeForRule(ruleID int64) {
|
func (u *ufwManager) revokeForRule(ruleID int64) {
|
||||||
rules, err := u.db.ListUfwRulesByRule(ruleID)
|
rules, err := u.db.ListUfwRulesByRule(ruleID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -96,24 +131,71 @@ func (u *ufwManager) revokeForRule(ruleID int64) {
|
|||||||
for _, r := range rules {
|
for _, r := range rules {
|
||||||
u.revokeRule(r)
|
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
|
// 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
|
// to the rule so it is revoked with it) plus the shared tunnel port for direct
|
||||||
// direct tcp/udp modes (bound to 0 — shared across rules).
|
// tcp/udp modes (bound to 0 — shared across rules).
|
||||||
func (u *ufwManager) allowForward(rule ForwardRule) {
|
func (u *ufwManager) allowForward(rule ForwardRule) {
|
||||||
switch rule.Proto {
|
if !u.available() {
|
||||||
case "udp":
|
return
|
||||||
u.allow(rule.HostPort, "udp", rule.ID)
|
|
||||||
if u.tunnelPort > 0 {
|
|
||||||
u.allow(u.tunnelPort, "udp", 0)
|
|
||||||
}
|
}
|
||||||
case "tcp":
|
u.allowForwardPorts(rule)
|
||||||
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)
|
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()
|
||||||
|
}
|
||||||
@@ -70,8 +70,8 @@
|
|||||||
<label>Тип</label>
|
<label>Тип</label>
|
||||||
<select name="proto" id="proto">
|
<select name="proto" id="proto">
|
||||||
<option value="http">http (long-poll, работает везде)</option>
|
<option value="http">http (long-poll, работает везде)</option>
|
||||||
<option value="tcp">tcp (прямой, низкая задержка, порт 8446)</option>
|
<option value="tcp">tcp (прямой, низкая задержка, порт {{.TunnelPort}})</option>
|
||||||
<option value="udp">udp (прямой, порт 8446)</option>
|
<option value="udp">udp (прямой, порт {{.TunnelPort}})</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div class="fld">
|
<div class="fld">
|
||||||
@@ -95,18 +95,18 @@
|
|||||||
<div class="card">
|
<div class="card">
|
||||||
<h2>Режимы</h2>
|
<h2>Режимы</h2>
|
||||||
<ul class="muted" style="font-size:14px; line-height:1.7">
|
<ul class="muted" style="font-size:14px; line-height:1.7">
|
||||||
<li><b>http</b> — туннель поверх HTTP long-poll (через nginx :8445). Работает везде, где доступна панель, но задержка выше.</li>
|
<li><b>http</b> — туннель поверх HTTP long-poll (через адрес панели, порт {{.PanelPort}}). Работает везде, где доступна панель, но задержка выше.</li>
|
||||||
<li><b>tcp</b> — прямой TCP-мультиплексор через порт <b>8446</b> хоста. Минимальная задержка, keep-alive, TCP_NODELAY. Нужно открыть 8446/tcp на хосте.</li>
|
<li><b>tcp</b> — прямой TCP-мультиплексор через порт <b>{{.TunnelPort}}</b> хоста. Минимальная задержка, keep-alive, TCP_NODELAY. Нужно открыть {{.TunnelPort}}/tcp на хосте.</li>
|
||||||
<li><b>udp</b> — прямой UDP через порт <b>8446</b> хоста. Нужно открыть 8446/udp.</li>
|
<li><b>udp</b> — прямой UDP через порт <b>{{.TunnelPort}}</b> хоста. Нужно открыть {{.TunnelPort}}/udp.</li>
|
||||||
</ul>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
<script>
|
<script>
|
||||||
var notes = {
|
var notes = {
|
||||||
http: 'Трафик идёт через :8445 (long-poll). Порт 8446 не требуется.',
|
http: 'Трафик идёт через порт панели {{.PanelPort}} (long-poll). Порт {{.TunnelPort}} не требуется.',
|
||||||
tcp: 'Требуется открытый порт 8446/tcp на хосте. Самая низкая задержка.',
|
tcp: 'Требуется открытый порт {{.TunnelPort}}/tcp на хосте. Самая низкая задержка.',
|
||||||
udp: 'Требуется открытый порт 8446/udp на хосте.'
|
udp: 'Требуется открытый порт {{.TunnelPort}}/udp на хосте.'
|
||||||
};
|
};
|
||||||
var sel = document.getElementById('proto'), note = document.getElementById('note');
|
var sel = document.getElementById('proto'), note = document.getElementById('note');
|
||||||
function upd(){ note.textContent = notes[sel.value] || ''; }
|
function upd(){ note.textContent = notes[sel.value] || ''; }
|
||||||
|
|||||||
@@ -19,6 +19,7 @@
|
|||||||
</header>
|
</header>
|
||||||
<div class="wrap">
|
<div class="wrap">
|
||||||
<h1>Настройки</h1>
|
<h1>Настройки</h1>
|
||||||
|
{{if .Error}}<div class="err">{{.Error}}</div>{{end}}
|
||||||
|
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<h2>Установка на новую машину</h2>
|
<h2>Установка на новую машину</h2>
|
||||||
@@ -49,6 +50,26 @@
|
|||||||
</form>
|
</form>
|
||||||
</div>
|
</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">
|
<div class="card">
|
||||||
<h2>Enroll-секрет</h2>
|
<h2>Enroll-секрет</h2>
|
||||||
<p class="muted">Общий секрет для регистрации новых агентов.</p>
|
<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}}
|
Статус 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}}
|
· Интеграция: {{if .UfwEnabled}}<span style="color:var(--ok)">включена</span>{{else}}<span class="muted">выключена</span>{{end}}
|
||||||
</p>
|
</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">
|
<form method="post" action="/settings/ufw/toggle" style="display:inline">
|
||||||
<input type="hidden" name="csrf" value="{{.CSRF}}">
|
<input type="hidden" name="csrf" value="{{.CSRF}}">
|
||||||
<input type="hidden" name="enabled" value="{{if .UfwEnabled}}0{{else}}1{{end}}">
|
<input type="hidden" name="enabled" value="{{if .UfwEnabled}}0{{else}}1{{end}}">
|
||||||
|
|||||||
Reference in new issue
Block a user