6 Commits
Author SHA1 Message Date
redtea f3cb7701f2 fix: remove an agent's forward rules with the agent (0.0.4)
Deleting a machine left its forward rules behind: the host ports stayed
bound and "busy" with no page to remove them from. The rules, their
listeners and ufw rules now go with the agent, and rules orphaned by
older versions are dropped on start.

A host port equal to the direct tunnel port gets its own message.
2026-10-13 16:30:53 +03:00
redtea 328ade651c fix: fix ufw 2026-10-13 16:16:31 +03:00
redtea 5e6e0b8a3a chore: run the server unit as root from /opt/lpra
The panel's ufw integration needs root; the database now lives next to
the binary in /opt/lpra.
2026-10-13 14:55:37 +03:00
redtea bcfe909aaa 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.
2026-10-13 14:50:50 +03:00
redtea 08fc2d79a8 fix: take the file edit limit in the panel from the agent's read cap
The limit was repeated in the page; a file of exactly the cap is read
in full, so it is editable too.
2026-10-13 14:50:49 +03:00
redtea 7cb9da8569 chore: take the version from VERSION.txt only (0.0.3)
VERSION.txt is embedded into the server and the agent, so a plain
go build reports the real version and build.sh no longer injects it.
2026-10-13 14:50:49 +03:00
23 changed files with 602 additions and 124 deletions

No files matched your search

+17
View File
@@ -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).
В окне «Изменить машину» можно выбрать другого пользователя машины или создать нового В окне «Изменить машину» можно выбрать другого пользователя машины или создать нового
+1 -1
View File
@@ -1 +1 @@
0.0.2 0.0.4
+3 -4
View File
@@ -5,10 +5,9 @@ set -eu
cd "$(dirname "$0")" cd "$(dirname "$0")"
VERSION="${VERSION:-$(cat VERSION.txt 2>/dev/null || echo 0.0.0)}" # The version comes from VERSION.txt, which is embedded into every binary.
PKG="github.com/x-grk/longpolling-remote-access" LDFLAGS="-s -w"
LDFLAGS="-s -w -X $PKG/internal/server.Version=$VERSION -X $PKG/internal/agent.Version=$VERSION" echo ">> version $(cat VERSION.txt)"
echo ">> version $VERSION"
mkdir -p dist bin mkdir -p dist bin
+5 -2
View File
@@ -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" {
+14 -17
View File
@@ -1,24 +1,24 @@
# systemd unit for the LPRA host (server). # systemd unit for the LPRA host (server).
# #
# Unlike the agent, the server does NOT need root: it listens on a high local # The server runs as root so that the panel can open forwarded ports in ufw;
# port (127.0.0.1:8091) behind nginx, so run it as an unprivileged user. # it listens on a high local port (127.0.0.1:8091) behind nginx.
# #
# Install: # Install:
# sudo useradd --system --home /opt/lpra --shell /usr/sbin/nologin lpra # sudo mkdir -p /opt/lpra
# sudo mkdir -p /opt/lpra /var/lib/lpra
# sudo cp bin/lpra-server /opt/lpra/ # sudo cp bin/lpra-server /opt/lpra/
# sudo cp -r dist /opt/lpra/dist # agent binaries served at /download/ # sudo cp -r dist /opt/lpra/dist # agent binaries served at /download/
# sudo chown -R lpra:lpra /opt/lpra /var/lib/lpra
# sudo cp deploy/lpra-server.service /etc/systemd/system/ # sudo cp deploy/lpra-server.service /etc/systemd/system/
# sudo systemctl daemon-reload && sudo systemctl enable --now lpra-server # sudo systemctl daemon-reload && sudo systemctl enable --now lpra-server
# #
# Config overrides can go in /etc/lpra-server.env (see EnvironmentFile below),
# e.g. LPRA_ADDR=127.0.0.1:8091
#
# Direct port forwarding (tcp/udp modes) uses port 8446 on the host — open it in # Direct port forwarding (tcp/udp modes) uses port 8446 on the host — open it in
# 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 root and a
# writable /etc/ufw and /run — see ReadWritePaths below.
[Unit] [Unit]
Description=LPRA remote access host (admin panel + agent rendezvous) Description=LPRA remote access host (admin panel + agent rendezvous)
@@ -26,17 +26,11 @@ After=network-online.target
Wants=network-online.target Wants=network-online.target
[Service] [Service]
Type=simple
User=lpra
Group=lpra
WorkingDirectory=/opt/lpra WorkingDirectory=/opt/lpra
# Optional environment overrides; "-" means the file is not required.
EnvironmentFile=-/etc/lpra-server.env
ExecStart=/opt/lpra/lpra-server \ ExecStart=/opt/lpra/lpra-server \
-addr 127.0.0.1:8091 \ -addr 127.0.0.1:8091 \
-db /var/lib/lpra/lpra.db \ -db /opt/lpra/lpra.db \
-dist /opt/lpra/dist -dist /opt/lpra/dist
Restart=always Restart=always
@@ -51,8 +45,11 @@ PrivateDevices=true
ProtectKernelTunables=true ProtectKernelTunables=true
ProtectControlGroups=true ProtectControlGroups=true
RestrictSUIDSGID=true RestrictSUIDSGID=true
# The server only needs to write its SQLite database here. # The SQLite database, plus what ufw writes: its rules in /etc/ufw and the
ReadWritePaths=/var/lib/lpra # ufw/xtables locks in /run. Without the last two "ufw status" works but
# "ufw allow" fails with "'/etc/ufw/user.rules' is not writable".
ReadWritePaths=/opt/lpra /etc/ufw /run
ReadWritePaths-=/etc/ufw /run
[Install] [Install]
WantedBy=multi-user.target WantedBy=multi-user.target
+79 -7
View File
@@ -13,17 +13,19 @@ 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"
"github.com/x-grk/longpolling-remote-access/internal/proto" "github.com/x-grk/longpolling-remote-access/internal/proto"
) )
// Version is the agent build version, injected at compile time via -ldflags // Version is the agent build version (see VERSION.txt).
// (-X .../internal/agent.Version=...). var Version = lpra.Version
var Version = "dev"
// Config is the agent's runtime configuration (from env / config.env). // Config is the agent's runtime configuration (from env / config.env).
type Config struct { type Config struct {
@@ -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)
+2 -2
View File
@@ -17,8 +17,8 @@ import (
"github.com/x-grk/longpolling-remote-access/internal/proto" "github.com/x-grk/longpolling-remote-access/internal/proto"
) )
// fsReadCap bounds how many bytes a view/read returns (5 MiB). // fsReadCap bounds how many bytes a view/read returns.
const fsReadCap = 5 * 1024 * 1024 const fsReadCap = proto.FSReadCap
// fsDownloadCap bounds a browser download (kept under the 64 MiB result/body // fsDownloadCap bounds a browser download (kept under the 64 MiB result/body
// limit once base64-encoded). // limit once base64-encoded).
+17 -13
View File
@@ -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
} }
+4
View File
@@ -91,6 +91,10 @@ const (
TaskSysUserAdd = "sys.useradd" // create a new OS user TaskSysUserAdd = "sys.useradd" // create a new OS user
) )
// FSReadCap bounds how many bytes TaskFSRead returns (5 MiB). The panel lets a
// file be edited only when it fits, so that a save never cuts it short.
const FSReadCap = 5 * 1024 * 1024
// TaskEnvelope is what the agent fetches for a task id. // TaskEnvelope is what the agent fetches for a task id.
type TaskEnvelope struct { type TaskEnvelope struct {
Type string `json:"type"` Type string `json:"type"`
+26 -3
View File
@@ -3,6 +3,9 @@ package server
import ( import (
"crypto/rand" "crypto/rand"
"encoding/hex" "encoding/hex"
"strconv"
lpra "github.com/x-grk/longpolling-remote-access"
) )
// Config keys stored in the config table. // Config keys stored in the config table.
@@ -12,15 +15,17 @@ 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
// the rendered install.sh and can be changed from the settings page. // the rendered install.sh and can be changed from the settings page.
const DefaultHostAddress = "https://REDACTED:8445" const DefaultHostAddress = "https://REDACTED:8445"
// Version is the server build version, injected at compile time via -ldflags // Version is the server build version (see VERSION.txt). It is the newest
// (-X .../internal/server.Version=...). It is the newest version agents update to. // version agents update to.
var Version = "dev" var Version = lpra.Version
// ensureDefaults seeds the host address and a random enroll secret on first boot. // ensureDefaults seeds the host address and a random enroll secret on first boot.
func (db *DB) ensureDefaults() error { func (db *DB) ensureDefaults() error {
@@ -86,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"
+25 -1
View File
@@ -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.
+80 -1
View File
@@ -5,6 +5,7 @@ import (
"errors" "errors"
"fmt" "fmt"
"log" "log"
"net"
"net/http" "net/http"
"net/url" "net/url"
"slices" "slices"
@@ -145,6 +146,12 @@ func (s *Server) handleAgentDelete(w http.ResponseWriter, r *http.Request) {
return return
} }
id := r.PathValue("id") id := r.PathValue("id")
// Its forward rules go with it: left behind they would keep their host
// ports bound and busy with no page to remove them from.
rules, _ := s.db.ListForwardRulesByAgent(id)
for _, rule := range rules {
s.removeForward(rule.ID)
}
_ = s.db.DeleteAgent(id) _ = s.db.DeleteAgent(id)
http.Redirect(w, r, "/", http.StatusSeeOther) http.Redirect(w, r, "/", http.StatusSeeOther)
} }
@@ -349,9 +356,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 +482,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)
} }
+1 -1
View File
@@ -34,7 +34,7 @@ func (s *Server) handleFilesPage(w http.ResponseWriter, r *http.Request) {
http.NotFound(w, r) http.NotFound(w, r)
return return
} }
s.render(w, "files.html", map[string]any{"Agent": a}) s.render(w, "files.html", map[string]any{"Agent": a, "EditLimit": proto.FSReadCap})
} }
// POST /api/panel/agents/{id}/fs/{op} — run a file-manager operation on the // POST /api/panel/agents/{id}/fs/{op} — run a file-manager operation on the
+34 -5
View File
@@ -3,6 +3,7 @@ package server
import ( import (
"log" "log"
"net/http" "net/http"
"net/url"
"strconv" "strconv"
"strings" "strings"
) )
@@ -17,6 +18,7 @@ func (s *Server) handleForwardsPage(w http.ResponseWriter, r *http.Request) {
rules, _ := s.db.ListForwardRulesByAgent(id) rules, _ := s.db.ListForwardRulesByAgent(id)
errMsg := map[string]string{ errMsg := map[string]string{
"busy": "Этот порт хоста уже занят другим правилом.", "busy": "Этот порт хоста уже занят другим правилом.",
"tunnel": "Этот порт занят прямым туннелем панели (см. Настройки).",
"port": "Некорректный номер порта.", "port": "Некорректный номер порта.",
"listen": "Не удалось занять порт на хосте (возможно, уже используется).", "listen": "Не удалось занять порт на хосте (возможно, уже используется).",
"db": "Ошибка базы данных.", "db": "Ошибка базы данных.",
@@ -26,9 +28,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,7 +76,11 @@ 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 hostPort == s.TunnelPort() {
http.Redirect(w, r, "/agents/"+id+"/forwards?err=tunnel", http.StatusSeeOther)
return
}
if s.db.HostPortUsed(hostPort) { if s.db.HostPortUsed(hostPort) {
http.Redirect(w, r, "/agents/"+id+"/forwards?err=busy", http.StatusSeeOther) http.Redirect(w, r, "/agents/"+id+"/forwards?err=busy", http.StatusSeeOther)
return return
@@ -89,13 +112,19 @@ func (s *Server) handleForwardDelete(w http.ResponseWriter, r *http.Request) {
} }
rid, _ := strconv.ParseInt(r.PathValue("rid"), 10, 64) rid, _ := strconv.ParseInt(r.PathValue("rid"), 10, 64)
rule, _ := s.db.GetForwardRule(rid) rule, _ := s.db.GetForwardRule(rid)
s.fwd.close(rid) s.removeForward(rid)
_ = s.db.DeleteForwardRule(rid)
// Remove the ufw rule bound to this forward rule (shared 8446 stays).
s.ufw.revokeForRule(rid)
dest := "/" dest := "/"
if rule != nil { if rule != nil {
dest = "/agents/" + rule.AgentID + "/forwards" dest = "/agents/" + rule.AgentID + "/forwards"
} }
http.Redirect(w, r, dest, http.StatusSeeOther) http.Redirect(w, r, dest, http.StatusSeeOther)
} }
// removeForward closes a forward rule's listener and deletes the rule, along
// with the ufw rule bound to it (the shared tunnel port stays while another
// direct forward needs it).
func (s *Server) removeForward(rid int64) {
s.fwd.close(rid)
_ = s.db.DeleteForwardRule(rid)
s.ufw.revokeForRule(rid)
}
+89 -15
View File
@@ -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()
@@ -93,6 +161,12 @@ func (s *Server) restoreForwards() {
return return
} }
for _, r := range rules { for _, r := range rules {
// Rules of an agent deleted by an older version: drop them.
if a, err := s.db.GetAgent(r.AgentID); err == nil && a == nil {
log.Printf("forward rule %d: agent %s is gone, removing", r.ID, r.AgentID)
s.removeForward(r.ID)
continue
}
if err := s.fwd.open(r); err != nil { if err := s.fwd.open(r); err != nil {
log.Printf("restore forward rule %d (%s:%d): %v", r.ID, r.ListenAddr, r.HostPort, err) log.Printf("restore forward rule %d (%s:%d): %v", r.ID, r.ListenAddr, r.HostPort, err)
} }
@@ -134,6 +208,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 +232,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 +278,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 {
+20 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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()
}
+5 -1
View File
@@ -34,10 +34,14 @@ header.topbar nav a:hover { color: var(--text); }
h1 { font-size: 22px; margin: 0 0 18px; } h1 { font-size: 22px; margin: 0 0 18px; }
h2 { font-size: 17px; margin: 0 0 14px; } h2 { font-size: 17px; margin: 0 0 14px; }
label { display: block; margin: 12px 0 4px; color: var(--muted); font-size: 13px; } label { display: block; margin: 12px 0 4px; color: var(--muted); font-size: 13px; }
input[type=text], input[type=password] { input[type=text], input[type=password], input[type=number] {
width: 100%; padding: 10px 12px; border-radius: 8px; width: 100%; padding: 10px 12px; border-radius: 8px;
border: 1px solid var(--border); background: #0c0f13; color: var(--text); font-size: 14px; border: 1px solid var(--border); background: #0c0f13; color: var(--text); font-size: 14px;
} }
/* No native spinner: it is drawn light and breaks the dark field. */
input[type=number] { -moz-appearance: textfield; appearance: textfield; }
input[type=number]::-webkit-inner-spin-button,
input[type=number]::-webkit-outer-spin-button { -webkit-appearance: none; margin: 0; }
/* Sized and shaped like .btn.ghost so it lines up with the buttons next to it. */ /* Sized and shaped like .btn.ghost so it lines up with the buttons next to it. */
select { select {
-webkit-appearance: none; appearance: none; cursor: pointer; -webkit-appearance: none; appearance: none; cursor: pointer;
+3 -3
View File
@@ -107,7 +107,7 @@
}).then(r => r.json()).catch(e => ({ok:false, error:'ошибка сети'})).finally(() => setBusy(false)); }).then(r => r.json()).catch(e => ({ok:false, error:'ошибка сети'})).finally(() => setBusy(false));
}; };
const EDIT_LIMIT = 5 * 1024 * 1024; const EDIT_LIMIT = {{.EditLimit}};
// Monochrome inline icons (inherit button color via currentColor). // Monochrome inline icons (inherit button color via currentColor).
const SVG = '<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">'; const SVG = '<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">';
@@ -190,10 +190,10 @@
msg.textContent = 'Размер: '+fmtSize(d.size); msg.textContent = 'Размер: '+fmtSize(d.size);
} else { } else {
ta.value = new TextDecoder().decode(b64ToBytes(d.content)); ta.value = new TextDecoder().decode(b64ToBytes(d.content));
const editable = d.size < EDIT_LIMIT && !d.truncated; const editable = d.size <= EDIT_LIMIT && !d.truncated;
ta.readOnly = !editable; ta.readOnly = !editable;
save.style.display = editable ? '' : 'none'; save.style.display = editable ? '' : 'none';
msg.textContent = 'Размер: '+fmtSize(d.size) + (d.truncated?' · показано первые 5 МБ':'') + (editable?' · редактируемо':' · только чтение (≥ 5 МБ)'); msg.textContent = 'Размер: '+fmtSize(d.size) + (d.truncated?' · показано первые '+fmtSize(EDIT_LIMIT):'') + (editable?' · редактируемо':' · только чтение (больше '+fmtSize(EDIT_LIMIT)+')');
save.onclick = async () => { save.onclick = async () => {
const bytes = new TextEncoder().encode(ta.value); const bytes = new TextEncoder().encode(ta.value);
const w = await api('write', {path: join(cwd, e.name), data: bytesToB64(bytes)}); const w = await api('write', {path: join(cwd, e.name), data: bytesToB64(bytes)});
+9 -9
View File
@@ -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] || ''; }
+23 -1
View File
@@ -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}}">
+14
View File
@@ -0,0 +1,14 @@
// Package lpra holds what the server and the agent share at build time.
package lpra
import (
_ "embed"
"strings"
)
//go:embed VERSION.txt
var versionFile string
// Version is the release version of every binary in this module. VERSION.txt
// is the only place it is defined.
var Version = strings.TrimSpace(versionFile)