Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
328ade651c | ||
|
|
5e6e0b8a3a | ||
|
|
bcfe909aaa | ||
|
|
08fc2d79a8 | ||
|
|
7cb9da8569 |
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).
|
||||||
В окне «Изменить машину» можно выбрать другого пользователя машины или создать нового
|
В окне «Изменить машину» можно выбрать другого пользователя машины или создать нового
|
||||||
|
|||||||
+1
-1
@@ -1 +1 @@
|
|||||||
0.0.2
|
0.0.3
|
||||||
@@ -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
@@ -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
@@ -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
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"`
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package server
|
|||||||
import (
|
import (
|
||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"net/url"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
@@ -22,13 +23,30 @@ func (s *Server) handleForwardsPage(w http.ResponseWriter, r *http.Request) {
|
|||||||
"db": "Ошибка базы данных.",
|
"db": "Ошибка базы данных.",
|
||||||
}[r.URL.Query().Get("err")]
|
}[r.URL.Query().Get("err")]
|
||||||
s.render(w, "forwards.html", map[string]any{
|
s.render(w, "forwards.html", map[string]any{
|
||||||
"Agent": a,
|
"Agent": a,
|
||||||
"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 s.tunnelDefault > 0 {
|
||||||
|
port := s.tunnelDefault
|
||||||
|
if p := s.db.TunnelPort(); p > 0 {
|
||||||
|
port = p
|
||||||
}
|
}
|
||||||
if err := udp.listen(opts.TunnelAddr); err != nil {
|
s.tunnelPort.Store(int64(port))
|
||||||
log.Printf("tunnel udp listen %s: %v", opts.TunnelAddr, err)
|
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 {
|
||||||
|
|||||||
+22
-10
@@ -20,9 +20,10 @@ import (
|
|||||||
// registration (agent->server): "R <agentID> <sig>"
|
// registration (agent->server): "R <agentID> <sig>"
|
||||||
// data (both): 'D' | sid(4) | port(2) | payload
|
// data (both): 'D' | sid(4) | port(2) | payload
|
||||||
type udpRelay struct {
|
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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+115
-33
@@ -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
|
||||||
@@ -14,28 +17,40 @@ import (
|
|||||||
// ufw requires root, so this only works when the server process can run ufw
|
// ufw requires root, so this only works when the server process can run ufw
|
||||||
// (e.g. running as root). When it cannot, integration is simply inactive.
|
// (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()
|
||||||
|
u.lastErr = msg
|
||||||
|
u.mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
// LastError returns the last failed ufw command ("" when the last one worked).
|
||||||
|
func (u *ufwManager) LastError() string {
|
||||||
|
u.mu.Lock()
|
||||||
|
defer u.mu.Unlock()
|
||||||
|
return u.lastErr
|
||||||
|
}
|
||||||
|
|
||||||
|
// allow opens port/proto in ufw and records it, bound to the given forward
|
||||||
|
// rule id (0 = shared/manual). The caller has checked available().
|
||||||
func (u *ufwManager) allow(port int, proto string, ruleID int64) {
|
func (u *ufwManager) allow(port int, proto string, ruleID int64) {
|
||||||
if !u.available() {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
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.allowForwardPorts(rule)
|
||||||
u.allow(u.tunnelPort, "udp", 0)
|
}
|
||||||
}
|
|
||||||
case "tcp":
|
func (u *ufwManager) allowForwardPorts(rule ForwardRule) {
|
||||||
u.allow(rule.HostPort, "tcp", rule.ID)
|
// "http" (long-poll) and "tcp" forwards both listen on TCP on the host.
|
||||||
if u.tunnelPort > 0 {
|
hostProto, direct := "tcp", rule.Proto == "tcp"
|
||||||
u.allow(u.tunnelPort, "tcp", 0)
|
if rule.Proto == "udp" {
|
||||||
}
|
hostProto, direct = "udp", true
|
||||||
default: // http long-poll listener is TCP
|
}
|
||||||
u.allow(rule.HostPort, "tcp", rule.ID)
|
// 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()
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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)});
|
||||||
|
|||||||
@@ -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}}">
|
||||||
|
|||||||
+14
@@ -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)
|
||||||
Reference in new issue
Block a user