Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f3cb7701f2 | ||
|
|
328ade651c | ||
|
|
5e6e0b8a3a | ||
|
|
bcfe909aaa | ||
|
|
08fc2d79a8 | ||
|
|
7cb9da8569 | ||
|
|
93673d3570 | ||
|
|
3dde07f403 |
No files matched your search
@@ -2,7 +2,7 @@
|
||||
|
||||
Лёгкий self-hosted сервис для доступа к Linux-машинам **без белого IP** (за NAT).
|
||||
Агент на клиенте сам исходяще подключается к серверу по HTTP long-polling; админ
|
||||
в веб-панели видит список машин и открывает по каждой **root-терминал в браузере**.
|
||||
в веб-панели видит список машин и открывает по каждой **терминал в браузере** (по умолчанию от root, пользователя можно сменить).
|
||||
|
||||
```
|
||||
браузер ──WS──> [nginx :8445 TLS] ──> [Go server 127.0.0.1:8091] <──long-poll── агент (root, за NAT)
|
||||
@@ -42,10 +42,33 @@ curl -fsSL https://localhost:8445/install.sh | sudo HOST=https://localhost:8445
|
||||
curl -fsSL https://localhost:8445/install.sh | sudo sh
|
||||
```
|
||||
|
||||
## Проброс портов и прямой туннель
|
||||
Правила проброса создаются на странице машины. Режим `http` идёт через адрес панели (long-poll),
|
||||
режимы `tcp`/`udp` — через прямой туннель: агент держит исходящее соединение к порту туннеля хоста
|
||||
(по умолчанию `8446`, флаг `-tunnel` / `LPRA_TUNNEL_ADDR`).
|
||||
|
||||
Порт туннеля и хост, к которому для него подключаются агенты (например, IP вместо домена панели),
|
||||
меняются в `/settings` → «Прямой туннель». Агенты забирают адрес с панели сами — при подключении и
|
||||
при каждом опросе, переустановка не нужна. Локальные `TUNNEL_ADDR` / `TUNNEL_PORT` в окружении агента
|
||||
имеют приоритет над настройкой панели (`TUNNEL_ADDR=off` отключает туннель).
|
||||
|
||||
Если на хосте активен ufw и сервер запущен от root, панель сама открывает порт хоста правила
|
||||
(tcp для `http`/`tcp`, udp для `udp`) и порт туннеля для прямых режимов, а при удалении правила — закрывает.
|
||||
При `ProtectSystem=strict` в systemd-юните ufw нужны права на запись: `ReadWritePaths=/etc/ufw /run`.
|
||||
|
||||
## Версия
|
||||
Версия задаётся в одном месте — `VERSION.txt`; файл вшивается в сервер и агент при сборке.
|
||||
|
||||
## Пользователь терминала
|
||||
По умолчанию терминал открывается от пользователя, под которым запущен агент (на Linux — root).
|
||||
В окне «Изменить машину» можно выбрать другого пользователя машины или создать нового
|
||||
(имя, пароль, опционально — добавить в sudo). Смена и создание работают, только если агент запущен от root.
|
||||
Файловый менеджер по-прежнему работает от пользователя агента.
|
||||
|
||||
## Безопасность
|
||||
- Пароли админов — bcrypt; сессии в HttpOnly/Secure/SameSite куках; CSRF на формах; rate-limit логина.
|
||||
- Агенты: общий enroll-секрет → индивидуальный токен (хранится хешем, сверка constant-time).
|
||||
- Открытие терминала пишется в таблицу `audit` (терминал даёт root-доступ).
|
||||
- Открытие терминала пишется в таблицу `audit` (терминал даёт root-доступ), как и смена/создание пользователя.
|
||||
|
||||
## Скриншоты
|
||||
<img width="1256" height="709" alt="image" src="https://github.com/user-attachments/assets/5f0b30f1-d5e3-4b62-817e-4041641df6e3" />
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
0.0.1
|
||||
0.0.4
|
||||
@@ -5,10 +5,9 @@ set -eu
|
||||
|
||||
cd "$(dirname "$0")"
|
||||
|
||||
VERSION="${VERSION:-$(cat VERSION.txt 2>/dev/null || echo 0.0.0)}"
|
||||
PKG="github.com/x-grk/longpolling-remote-access"
|
||||
LDFLAGS="-s -w -X $PKG/internal/server.Version=$VERSION -X $PKG/internal/agent.Version=$VERSION"
|
||||
echo ">> version $VERSION"
|
||||
# The version comes from VERSION.txt, which is embedded into every binary.
|
||||
LDFLAGS="-s -w"
|
||||
echo ">> version $(cat VERSION.txt)"
|
||||
|
||||
mkdir -p dist bin
|
||||
|
||||
|
||||
+5
-2
@@ -23,6 +23,8 @@ func main() {
|
||||
Name: os.Getenv("AGENT_NAME"),
|
||||
StatePath: envOr("STATE_PATH", "/etc/lpagent/state.json"),
|
||||
TunnelAddr: tunnelAddr(host),
|
||||
// A locally set address wins over the one from the panel settings.
|
||||
TunnelPinned: os.Getenv("TUNNEL_ADDR") != "" || os.Getenv("TUNNEL_PORT") != "",
|
||||
}
|
||||
if cfg.EnrollSecret == "" {
|
||||
// Only required for the very first run (before enrollment).
|
||||
@@ -47,8 +49,9 @@ func envOr(key, def string) string {
|
||||
}
|
||||
|
||||
// tunnelAddr derives the direct-tunnel address (host:8446) from the server URL.
|
||||
// TUNNEL_ADDR overrides it entirely; TUNNEL_PORT overrides just the port.
|
||||
// Set TUNNEL_ADDR=off to disable the direct tunnel.
|
||||
// It is only the starting point: the agent follows the host and port set in
|
||||
// the panel settings, unless TUNNEL_ADDR (the whole address) or TUNNEL_PORT
|
||||
// (just the port) pins it here. Set TUNNEL_ADDR=off to disable the tunnel.
|
||||
func tunnelAddr(hostURL string) string {
|
||||
if v := os.Getenv("TUNNEL_ADDR"); v != "" {
|
||||
if v == "off" {
|
||||
|
||||
+14
-17
@@ -1,24 +1,24 @@
|
||||
# systemd unit for the LPRA host (server).
|
||||
#
|
||||
# Unlike the agent, the server does NOT need root: it listens on a high local
|
||||
# port (127.0.0.1:8091) behind nginx, so run it as an unprivileged user.
|
||||
# The server runs as root so that the panel can open forwarded ports in ufw;
|
||||
# it listens on a high local port (127.0.0.1:8091) behind nginx.
|
||||
#
|
||||
# Install:
|
||||
# sudo useradd --system --home /opt/lpra --shell /usr/sbin/nologin lpra
|
||||
# sudo mkdir -p /opt/lpra /var/lib/lpra
|
||||
# sudo mkdir -p /opt/lpra
|
||||
# sudo cp bin/lpra-server /opt/lpra/
|
||||
# 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 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
|
||||
# the firewall: ufw allow 8446/tcp ; ufw allow 8446/udp
|
||||
# (The "http" forward mode and terminal/files go through nginx :8445 and need
|
||||
# nothing extra.) Override with LPRA_TUNNEL_ADDR, or set it empty to disable.
|
||||
# The port, and the host agents dial for it, can also be changed in the panel
|
||||
# settings; agents pick the new address up from the panel by themselves.
|
||||
#
|
||||
# ufw integration (the panel opening forwarded ports itself) needs root and a
|
||||
# writable /etc/ufw and /run — see ReadWritePaths below.
|
||||
|
||||
[Unit]
|
||||
Description=LPRA remote access host (admin panel + agent rendezvous)
|
||||
@@ -26,17 +26,11 @@ After=network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=lpra
|
||||
Group=lpra
|
||||
WorkingDirectory=/opt/lpra
|
||||
|
||||
# Optional environment overrides; "-" means the file is not required.
|
||||
EnvironmentFile=-/etc/lpra-server.env
|
||||
|
||||
ExecStart=/opt/lpra/lpra-server \
|
||||
-addr 127.0.0.1:8091 \
|
||||
-db /var/lib/lpra/lpra.db \
|
||||
-db /opt/lpra/lpra.db \
|
||||
-dist /opt/lpra/dist
|
||||
|
||||
Restart=always
|
||||
@@ -51,8 +45,11 @@ PrivateDevices=true
|
||||
ProtectKernelTunables=true
|
||||
ProtectControlGroups=true
|
||||
RestrictSUIDSGID=true
|
||||
# The server only needs to write its SQLite database here.
|
||||
ReadWritePaths=/var/lib/lpra
|
||||
# The SQLite database, plus what ufw writes: its rules in /etc/ufw and the
|
||||
# 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]
|
||||
WantedBy=multi-user.target
|
||||
+84
-10
@@ -13,17 +13,19 @@ import (
|
||||
"log"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
lpra "github.com/x-grk/longpolling-remote-access"
|
||||
"github.com/x-grk/longpolling-remote-access/internal/proto"
|
||||
)
|
||||
|
||||
// Version is the agent build version, injected at compile time via -ldflags
|
||||
// (-X .../internal/agent.Version=...).
|
||||
var Version = "dev"
|
||||
// Version is the agent build version (see VERSION.txt).
|
||||
var Version = lpra.Version
|
||||
|
||||
// Config is the agent's runtime configuration (from env / config.env).
|
||||
type Config struct {
|
||||
@@ -32,6 +34,9 @@ type Config struct {
|
||||
Name string // optional friendly name
|
||||
StatePath string // where the enrolled id+token are persisted
|
||||
TunnelAddr string // host:port for the direct forward tunnel (e.g. REDACTED:8446); empty disables
|
||||
// TunnelPinned keeps TunnelAddr as configured locally (TUNNEL_ADDR /
|
||||
// TUNNEL_PORT) instead of following the address set in the panel.
|
||||
TunnelPinned bool
|
||||
}
|
||||
|
||||
type state struct {
|
||||
@@ -45,6 +50,11 @@ type Agent struct {
|
||||
http *http.Client
|
||||
state state
|
||||
pollCount int
|
||||
|
||||
// Direct tunnel currently running (see setTunnelAddr).
|
||||
tunMu sync.Mutex
|
||||
tunAddr string
|
||||
tunCancel context.CancelFunc
|
||||
}
|
||||
|
||||
// New builds an agent. It does not perform any network I/O.
|
||||
@@ -87,10 +97,11 @@ func (a *Agent) Run(ctx context.Context) error {
|
||||
}
|
||||
log.Printf("agent %s connected to %s", a.state.AgentID, a.cfg.Host)
|
||||
|
||||
// Direct forward tunnel (low-latency TCP mux + UDP) on 8446, if configured.
|
||||
if a.cfg.TunnelAddr != "" {
|
||||
go a.runTunnel(ctx)
|
||||
go a.runUDP(ctx)
|
||||
// Direct forward tunnel (low-latency TCP mux + UDP). Its address comes
|
||||
// from the panel; fall back to the configured one when the server sends
|
||||
// none (tunnel port left at the default on an older server).
|
||||
if a.cfg.TunnelPinned || !a.fetchConfig(ctx) {
|
||||
a.setTunnelAddr(ctx, a.cfg.TunnelAddr)
|
||||
}
|
||||
|
||||
backoff := time.Second
|
||||
@@ -120,6 +131,7 @@ func (a *Agent) enroll(ctx context.Context) error {
|
||||
OS: runtime.GOOS,
|
||||
Arch: runtime.GOARCH,
|
||||
Version: Version,
|
||||
User: currentUser(),
|
||||
})
|
||||
req, _ := http.NewRequestWithContext(ctx, http.MethodPost, a.cfg.Host+"/api/agent/enroll", bytes.NewReader(body))
|
||||
req.Header.Set("X-Enroll-Secret", a.cfg.EnrollSecret)
|
||||
@@ -145,11 +157,12 @@ func (a *Agent) enroll(ctx context.Context) error {
|
||||
func (a *Agent) pollOnce(ctx context.Context) error {
|
||||
req, _ := http.NewRequestWithContext(ctx, http.MethodGet, a.cfg.Host+"/api/agent/poll", nil)
|
||||
a.authHeaders(req)
|
||||
// Report the version occasionally (not every poll), so the panel can flag
|
||||
// outdated agents without extra traffic.
|
||||
// Report the version and our OS user occasionally (not every poll), so the
|
||||
// panel can flag outdated agents without extra traffic.
|
||||
a.pollCount++
|
||||
if a.pollCount%20 == 1 {
|
||||
req.Header.Set("X-Agent-Version", Version)
|
||||
req.Header.Set("X-Agent-User", currentUser())
|
||||
}
|
||||
resp, err := a.http.Do(req)
|
||||
if err != nil {
|
||||
@@ -166,10 +179,11 @@ func (a *Agent) pollOnce(ctx context.Context) error {
|
||||
if err := json.NewDecoder(resp.Body).Decode(&msg); err != nil {
|
||||
return err
|
||||
}
|
||||
a.applyTunnelHeader(ctx, resp.Header)
|
||||
// Dispatch in the background so we keep polling for more work.
|
||||
switch msg.Type {
|
||||
case proto.CtrlOpenTerminal:
|
||||
go a.runTerminal(ctx, msg.SessionID)
|
||||
go a.runTerminal(ctx, msg.SessionID, msg.User)
|
||||
case proto.CtrlOpenForward:
|
||||
go a.runForward(ctx, msg.SessionID, msg.Port)
|
||||
case proto.CtrlTask:
|
||||
@@ -178,6 +192,66 @@ func (a *Agent) pollOnce(ctx context.Context) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// fetchConfig asks the server for the settings it hands to agents and reports
|
||||
// whether it named a tunnel address.
|
||||
func (a *Agent) fetchConfig(ctx context.Context) bool {
|
||||
ctx2, cancel := context.WithTimeout(ctx, 10*time.Second)
|
||||
defer cancel()
|
||||
req, _ := http.NewRequestWithContext(ctx2, http.MethodGet, a.cfg.Host+"/api/agent/config", nil)
|
||||
a.authHeaders(req)
|
||||
resp, err := a.http.Do(req)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
return a.applyTunnelHeader(ctx, resp.Header)
|
||||
}
|
||||
|
||||
// applyTunnelHeader follows the tunnel address the server advertises as
|
||||
// "host:port"; an empty host means the host we already reach the server on.
|
||||
func (a *Agent) applyTunnelHeader(ctx context.Context, h http.Header) bool {
|
||||
v := h.Get("X-Tunnel-Addr")
|
||||
if v == "" || a.cfg.TunnelPinned {
|
||||
return false
|
||||
}
|
||||
host, port, err := net.SplitHostPort(v)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
if host == "" {
|
||||
u, err := url.Parse(a.cfg.Host)
|
||||
if err != nil || u.Hostname() == "" {
|
||||
return false
|
||||
}
|
||||
host = u.Hostname()
|
||||
}
|
||||
a.setTunnelAddr(ctx, net.JoinHostPort(host, port))
|
||||
return true
|
||||
}
|
||||
|
||||
// setTunnelAddr points the direct tunnel at addr, restarting it if it was
|
||||
// running against another address. An empty addr stops it.
|
||||
func (a *Agent) setTunnelAddr(ctx context.Context, addr string) {
|
||||
a.tunMu.Lock()
|
||||
defer a.tunMu.Unlock()
|
||||
if addr == a.tunAddr {
|
||||
return
|
||||
}
|
||||
if a.tunCancel != nil {
|
||||
a.tunCancel()
|
||||
a.tunCancel = nil
|
||||
}
|
||||
a.tunAddr = addr
|
||||
if addr == "" {
|
||||
return
|
||||
}
|
||||
log.Printf("tunnel: using %s", addr)
|
||||
tctx, cancel := context.WithCancel(ctx)
|
||||
a.tunCancel = cancel
|
||||
go a.runTunnel(tctx, addr)
|
||||
go a.runUDP(tctx, addr)
|
||||
}
|
||||
|
||||
func (a *Agent) authHeaders(req *http.Request) {
|
||||
req.Header.Set("X-Agent-Id", a.state.AgentID)
|
||||
req.Header.Set("Authorization", "Bearer "+a.state.Token)
|
||||
|
||||
@@ -17,8 +17,8 @@ import (
|
||||
"github.com/x-grk/longpolling-remote-access/internal/proto"
|
||||
)
|
||||
|
||||
// fsReadCap bounds how many bytes a view/read returns (5 MiB).
|
||||
const fsReadCap = 5 * 1024 * 1024
|
||||
// fsReadCap bounds how many bytes a view/read returns.
|
||||
const fsReadCap = proto.FSReadCap
|
||||
|
||||
// fsDownloadCap bounds a browser download (kept under the 64 MiB result/body
|
||||
// limit once base64-encoded).
|
||||
|
||||
@@ -15,14 +15,17 @@ import (
|
||||
)
|
||||
|
||||
// runTerminal spawns a PTY and bridges it to the session.
|
||||
func (a *Agent) runTerminal(parent context.Context, sid string) {
|
||||
term, err := StartPTY()
|
||||
// user is the OS user to run the shell as ("" = the agent's own user).
|
||||
func (a *Agent) runTerminal(parent context.Context, sid, user string) {
|
||||
term, err := StartPTY(user)
|
||||
if err != nil {
|
||||
log.Printf("term %s: start pty: %v", sid, err)
|
||||
// Show the reason in the browser terminal before closing it.
|
||||
a.postOut(context.Background(), sid, proto.OutMessage{Data: b64.EncodeToString([]byte("lpra: " + err.Error() + "\r\n"))})
|
||||
a.postOut(context.Background(), sid, proto.OutMessage{Exit: true})
|
||||
return
|
||||
}
|
||||
log.Printf("term %s: shell started", sid)
|
||||
log.Printf("term %s: shell started (user %q)", sid, user)
|
||||
a.bridgeStream(parent, sid, term, term.Resize)
|
||||
log.Printf("term %s: ended", sid)
|
||||
}
|
||||
|
||||
@@ -54,9 +54,10 @@ func (a *Agent) postResult(ctx context.Context, taskID string, res proto.TaskRes
|
||||
resp.Body.Close()
|
||||
}
|
||||
|
||||
// execTask runs a single file-manager operation natively and returns a result.
|
||||
// execTask runs a single file-manager or user-management operation natively
|
||||
// and returns a result.
|
||||
func (a *Agent) execTask(env proto.TaskEnvelope) proto.TaskResult {
|
||||
data, err := dispatchFS(env)
|
||||
data, err := dispatchTask(env)
|
||||
if err != nil {
|
||||
return proto.TaskResult{Error: err.Error()}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
|
||||
@@ -13,14 +14,30 @@ type PTY struct {
|
||||
f *os.File
|
||||
}
|
||||
|
||||
// StartPTY launches the shell attached to a new PTY. The agent runs as root
|
||||
// (systemd User=root), so this is a root shell by design.
|
||||
func StartPTY() (*PTY, error) {
|
||||
shell := "/bin/bash"
|
||||
if _, err := os.Stat(shell); err != nil {
|
||||
shell = "/bin/sh"
|
||||
// StartPTY launches a login shell attached to a new PTY. With user empty (or
|
||||
// equal to the agent's own user) it is the agent's shell — root by default,
|
||||
// since the agent runs as a root systemd service. Any other user is entered via
|
||||
// `su -l`, which only a root agent can do without a password.
|
||||
func StartPTY(user string) (*PTY, error) {
|
||||
var cmd *exec.Cmd
|
||||
if user == "" || user == currentUser() {
|
||||
cmd = exec.Command(defaultShell(), "-l")
|
||||
} else {
|
||||
if !validUserName(user) {
|
||||
return nil, fmt.Errorf("недопустимое имя пользователя %q", user)
|
||||
}
|
||||
if os.Geteuid() != 0 {
|
||||
return nil, fmt.Errorf("агент запущен от %s и не может открыть сессию от %s", currentUser(), user)
|
||||
}
|
||||
if !userExists(user) {
|
||||
return nil, fmt.Errorf("пользователь %s не найден", user)
|
||||
}
|
||||
su, err := exec.LookPath("su")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("su не найден: %w", err)
|
||||
}
|
||||
cmd = exec.Command(su, "-l", user)
|
||||
}
|
||||
cmd := exec.Command(shell, "-l")
|
||||
cmd.Env = append(os.Environ(), "TERM=xterm-256color")
|
||||
f, err := pty.Start(cmd)
|
||||
if err != nil {
|
||||
|
||||
+17
-13
@@ -15,12 +15,12 @@ import (
|
||||
|
||||
// --- direct TCP mux tunnel ---
|
||||
|
||||
// runTunnel keeps a persistent connection to the host's 8446 tunnel, dialing
|
||||
// client-side ports on demand for "tcp" forward rules.
|
||||
func (a *Agent) runTunnel(ctx context.Context) {
|
||||
// runTunnel keeps a persistent connection to the host's tunnel at addr, dialing
|
||||
// client-side ports on demand for "tcp" forward rules, until ctx is cancelled.
|
||||
func (a *Agent) runTunnel(ctx context.Context, addr string) {
|
||||
backoff := time.Second
|
||||
for ctx.Err() == nil {
|
||||
if err := a.tunnelOnce(ctx); err != nil {
|
||||
if err := a.tunnelOnce(ctx, addr); err != nil {
|
||||
select {
|
||||
case <-time.After(backoff):
|
||||
case <-ctx.Done():
|
||||
@@ -35,13 +35,15 @@ func (a *Agent) runTunnel(ctx context.Context) {
|
||||
}
|
||||
}
|
||||
|
||||
func (a *Agent) tunnelOnce(ctx context.Context) error {
|
||||
func (a *Agent) tunnelOnce(ctx context.Context, addr string) error {
|
||||
d := net.Dialer{Timeout: 10 * time.Second}
|
||||
conn, err := d.DialContext(ctx, "tcp", a.cfg.TunnelAddr)
|
||||
conn, err := d.DialContext(ctx, "tcp", addr)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer conn.Close()
|
||||
// Unblock the read loop when the tunnel is stopped or moved.
|
||||
defer context.AfterFunc(ctx, func() { conn.Close() })()
|
||||
if tc, ok := conn.(*net.TCPConn); ok {
|
||||
_ = tc.SetNoDelay(true)
|
||||
}
|
||||
@@ -65,7 +67,7 @@ func (a *Agent) tunnelOnce(ctx context.Context) error {
|
||||
return err
|
||||
}
|
||||
_ = conn.SetDeadline(time.Time{})
|
||||
log.Printf("tunnel: connected to %s", a.cfg.TunnelAddr)
|
||||
log.Printf("tunnel: connected to %s", addr)
|
||||
|
||||
tc := &tunClient{conn: conn, streams: map[uint32]*agentStream{}}
|
||||
return tc.readLoop()
|
||||
@@ -194,12 +196,12 @@ func (t *tunClient) shutdown() {
|
||||
|
||||
// --- direct UDP forwarding ---
|
||||
|
||||
// runUDP maintains the agent's UDP endpoint on the host's 8446 channel and
|
||||
// runUDP maintains the agent's UDP endpoint on the host's tunnel at addr and
|
||||
// relays datagrams to/from client-side UDP ports for "udp" forward rules.
|
||||
func (a *Agent) runUDP(ctx context.Context) {
|
||||
func (a *Agent) runUDP(ctx context.Context, addr string) {
|
||||
backoff := time.Second
|
||||
for ctx.Err() == nil {
|
||||
if err := a.udpOnce(ctx); err != nil {
|
||||
if err := a.udpOnce(ctx, addr); err != nil {
|
||||
select {
|
||||
case <-time.After(backoff):
|
||||
case <-ctx.Done():
|
||||
@@ -214,8 +216,8 @@ func (a *Agent) runUDP(ctx context.Context) {
|
||||
}
|
||||
}
|
||||
|
||||
func (a *Agent) udpOnce(ctx context.Context) error {
|
||||
raddr, err := net.ResolveUDPAddr("udp", a.cfg.TunnelAddr)
|
||||
func (a *Agent) udpOnce(ctx context.Context, addr string) error {
|
||||
raddr, err := net.ResolveUDPAddr("udp", addr)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -224,6 +226,8 @@ func (a *Agent) udpOnce(ctx context.Context) error {
|
||||
return err
|
||||
}
|
||||
defer conn.Close()
|
||||
// Unblock the read loop when the tunnel is stopped or moved.
|
||||
defer context.AfterFunc(ctx, func() { conn.Close() })()
|
||||
|
||||
key := tunnel.AuthKey(a.state.Token)
|
||||
register := func() {
|
||||
@@ -268,7 +272,7 @@ func (a *Agent) udpOnce(ctx context.Context) error {
|
||||
}
|
||||
|
||||
type udpClient struct {
|
||||
server *net.UDPConn // connection back to host:8446
|
||||
server *net.UDPConn // connection back to the host tunnel
|
||||
mu sync.Mutex
|
||||
dials map[uint32]*net.UDPConn // sid -> client-side socket
|
||||
}
|
||||
|
||||
@@ -0,0 +1,235 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"os/user"
|
||||
"regexp"
|
||||
"runtime"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/x-grk/longpolling-remote-access/internal/proto"
|
||||
)
|
||||
|
||||
// userNameRe is the login name shape accepted for existing users; it keeps a
|
||||
// name from ever being parsed as an option by su. newUserNameRe is the stricter
|
||||
// shape useradd accepts everywhere.
|
||||
var (
|
||||
userNameRe = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_.-]{0,31}$`)
|
||||
newUserNameRe = regexp.MustCompile(`^[a-z_][a-z0-9_-]{0,31}$`)
|
||||
)
|
||||
|
||||
func validUserName(name string) bool { return userNameRe.MatchString(name) }
|
||||
|
||||
// dispatchTask runs one task: user management here, everything else is a
|
||||
// file-manager operation.
|
||||
func dispatchTask(env proto.TaskEnvelope) (any, error) {
|
||||
switch env.Type {
|
||||
case proto.TaskSysUsers:
|
||||
return sysUsers(), nil
|
||||
case proto.TaskSysUserAdd:
|
||||
var a proto.SysUserAddArgs
|
||||
_ = json.Unmarshal(env.Args, &a)
|
||||
return nil, addUser(a)
|
||||
}
|
||||
return dispatchFS(env)
|
||||
}
|
||||
|
||||
// currentUser is the OS user the agent process runs as.
|
||||
func currentUser() string {
|
||||
if u, err := user.Current(); err == nil && u.Username != "" {
|
||||
return u.Username
|
||||
}
|
||||
if os.Geteuid() == 0 {
|
||||
return "root"
|
||||
}
|
||||
if n := os.Getenv("USER"); n != "" {
|
||||
return n
|
||||
}
|
||||
return strconv.Itoa(os.Geteuid())
|
||||
}
|
||||
|
||||
func userExists(name string) bool {
|
||||
if _, err := user.Lookup(name); err == nil {
|
||||
return true
|
||||
}
|
||||
// Without cgo, os/user only reads /etc/passwd, which macOS doesn't use.
|
||||
if runtime.GOOS == "darwin" {
|
||||
for _, n := range darwinUsers() {
|
||||
if n == name {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func defaultShell() string {
|
||||
if _, err := os.Stat("/bin/bash"); err == nil {
|
||||
return "/bin/bash"
|
||||
}
|
||||
return "/bin/sh"
|
||||
}
|
||||
|
||||
// sysUsers reports who the agent is and which users a terminal can be opened as.
|
||||
func sysUsers() proto.SysUsersResult {
|
||||
cur := currentUser()
|
||||
res := proto.SysUsersResult{Current: cur, CanSwitch: os.Geteuid() == 0, Users: []string{cur}}
|
||||
if !res.CanSwitch {
|
||||
return res
|
||||
}
|
||||
seen := map[string]bool{cur: true}
|
||||
for _, n := range loginUsers() {
|
||||
if !seen[n] && validUserName(n) {
|
||||
seen[n] = true
|
||||
res.Users = append(res.Users, n)
|
||||
}
|
||||
}
|
||||
sort.Strings(res.Users[1:])
|
||||
return res
|
||||
}
|
||||
|
||||
// loginUsers lists root plus the regular (non-system) accounts with a real shell.
|
||||
func loginUsers() []string {
|
||||
if runtime.GOOS == "darwin" {
|
||||
return darwinUsers()
|
||||
}
|
||||
b, err := os.ReadFile("/etc/passwd")
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
var out []string
|
||||
for _, line := range strings.Split(string(b), "\n") {
|
||||
// name:passwd:uid:gid:gecos:home:shell
|
||||
f := strings.Split(line, ":")
|
||||
if len(f) < 7 || strings.HasPrefix(line, "#") {
|
||||
continue
|
||||
}
|
||||
uid, err := strconv.Atoi(f[2])
|
||||
if err != nil || (uid != 0 && (uid < 1000 || uid == 65534)) {
|
||||
continue
|
||||
}
|
||||
if sh := f[6]; strings.HasSuffix(sh, "/nologin") || strings.HasSuffix(sh, "/false") {
|
||||
continue
|
||||
}
|
||||
out = append(out, f[0])
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func darwinUsers() []string {
|
||||
b, err := exec.Command("dscl", ".", "-list", "/Users", "UniqueID").Output()
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
var out []string
|
||||
for _, line := range strings.Split(string(b), "\n") {
|
||||
f := strings.Fields(line)
|
||||
if len(f) != 2 {
|
||||
continue
|
||||
}
|
||||
if uid, err := strconv.Atoi(f[1]); err == nil && (uid == 0 || uid >= 500) {
|
||||
out = append(out, f[0])
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// addUser creates an OS user with a home directory, optionally sets its
|
||||
// password and adds it to the sudo group.
|
||||
func addUser(a proto.SysUserAddArgs) error {
|
||||
if runtime.GOOS != "linux" {
|
||||
return errors.New("создание пользователей поддерживается только на Linux")
|
||||
}
|
||||
if os.Geteuid() != 0 {
|
||||
return errors.New("агент запущен не от root и не может создавать пользователей")
|
||||
}
|
||||
if !newUserNameRe.MatchString(a.Name) {
|
||||
return errors.New("недопустимое имя: строчные латинские буквы, цифры, '_' и '-', до 32 символов")
|
||||
}
|
||||
if strings.ContainsAny(a.Password, "\r\n") {
|
||||
return errors.New("пароль не должен содержать перевод строки")
|
||||
}
|
||||
if userExists(a.Name) {
|
||||
return fmt.Errorf("пользователь %s уже существует", a.Name)
|
||||
}
|
||||
// Resolve the sudo group up front so we don't leave a half-configured user.
|
||||
group := ""
|
||||
if a.Sudo {
|
||||
if group = sudoGroup(); group == "" {
|
||||
return errors.New("на машине нет группы sudo или wheel")
|
||||
}
|
||||
}
|
||||
|
||||
switch {
|
||||
case hasCmd("useradd"):
|
||||
if err := run(nil, "useradd", "-m", "-s", defaultShell(), a.Name); err != nil {
|
||||
return err
|
||||
}
|
||||
case hasCmd("adduser"): // BusyBox / Alpine
|
||||
if err := run(nil, "adduser", "-D", "-s", defaultShell(), a.Name); err != nil {
|
||||
return err
|
||||
}
|
||||
default:
|
||||
return errors.New("не найдены useradd/adduser")
|
||||
}
|
||||
|
||||
if a.Password != "" {
|
||||
if err := run([]byte(a.Name+":"+a.Password+"\n"), "chpasswd"); err != nil {
|
||||
return fmt.Errorf("пользователь создан, но пароль не задан: %w", err)
|
||||
}
|
||||
}
|
||||
if group != "" {
|
||||
var err error
|
||||
switch {
|
||||
case hasCmd("usermod"):
|
||||
err = run(nil, "usermod", "-aG", group, a.Name)
|
||||
case hasCmd("gpasswd"):
|
||||
err = run(nil, "gpasswd", "-a", a.Name, group)
|
||||
default: // BusyBox: adduser USER GROUP
|
||||
err = run(nil, "adduser", a.Name, group)
|
||||
}
|
||||
if err != nil {
|
||||
return fmt.Errorf("пользователь создан, но не добавлен в %s: %w", group, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// sudoGroup returns the group that grants sudo on this distro, or "".
|
||||
func sudoGroup() string {
|
||||
for _, g := range []string{"sudo", "wheel"} {
|
||||
if _, err := user.LookupGroup(g); err == nil {
|
||||
return g
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func hasCmd(name string) bool {
|
||||
_, err := exec.LookPath(name)
|
||||
return err == nil
|
||||
}
|
||||
|
||||
// run executes a command (optionally feeding stdin) and folds its output into
|
||||
// the error so the panel can show why it failed.
|
||||
func run(stdin []byte, name string, args ...string) error {
|
||||
cmd := exec.Command(name, args...)
|
||||
if stdin != nil {
|
||||
cmd.Stdin = bytes.NewReader(stdin)
|
||||
}
|
||||
out, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
if msg := strings.TrimSpace(string(out)); msg != "" {
|
||||
return fmt.Errorf("%s: %s", name, msg)
|
||||
}
|
||||
return fmt.Errorf("%s: %w", name, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
+23
-1
@@ -27,6 +27,7 @@ type EnrollRequest struct {
|
||||
OS string `json:"os"`
|
||||
Arch string `json:"arch"`
|
||||
Version string `json:"version"`
|
||||
User string `json:"user,omitempty"` // OS user the agent runs as
|
||||
}
|
||||
|
||||
// EnrollResponse carries the identity the agent persists and uses from then on.
|
||||
@@ -41,6 +42,7 @@ type ControlMessage struct {
|
||||
SessionID string `json:"session_id,omitempty"`
|
||||
Port int `json:"port,omitempty"` // CtrlOpenForward: client-side port to dial
|
||||
TaskID string `json:"task_id,omitempty"` // CtrlTask
|
||||
User string `json:"user,omitempty"` // CtrlOpenTerminal: OS user for the shell ("" = the agent's own)
|
||||
}
|
||||
|
||||
// Terminal / stream frame types on the session "in" channel (server -> agent).
|
||||
@@ -84,9 +86,15 @@ const (
|
||||
TaskFSMove = "fs.move" // move/rename
|
||||
TaskFSDownload = "fs.download" // read a whole file (larger cap) for browser download
|
||||
|
||||
TaskSysUpdate = "sys.update" // download the latest agent binary and restart
|
||||
TaskSysUpdate = "sys.update" // download the latest agent binary and restart
|
||||
TaskSysUsers = "sys.users" // report the agent's user and the login-capable users
|
||||
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.
|
||||
type TaskEnvelope struct {
|
||||
Type string `json:"type"`
|
||||
@@ -144,3 +152,17 @@ type FSReadResult struct {
|
||||
Truncated bool `json:"truncated"`
|
||||
Binary bool `json:"binary"`
|
||||
}
|
||||
|
||||
// SysUsersResult is returned for TaskSysUsers.
|
||||
type SysUsersResult struct {
|
||||
Current string `json:"current"` // user the agent runs as
|
||||
CanSwitch bool `json:"can_switch"` // agent is root, so it can open shells as (and create) other users
|
||||
Users []string `json:"users"`
|
||||
}
|
||||
|
||||
// SysUserAddArgs are the arguments of TaskSysUserAdd.
|
||||
type SysUserAddArgs struct {
|
||||
Name string `json:"name"`
|
||||
Password string `json:"password,omitempty"` // empty leaves the account without a password
|
||||
Sudo bool `json:"sudo"` // add to the sudo/wheel group
|
||||
}
|
||||
@@ -34,7 +34,7 @@ func bearerToken(r *http.Request) string {
|
||||
|
||||
// enroll creates a new agent record for a valid enroll secret and returns the
|
||||
// generated id plus a one-time plaintext token (only the hash is stored).
|
||||
func (s *Server) enroll(name, hostname, os, arch, version string) (id, token string, err error) {
|
||||
func (s *Server) enroll(name, hostname, os, arch, version, runUser string) (id, token string, err error) {
|
||||
id = randToken()[:16]
|
||||
token = randToken()
|
||||
a := Agent{
|
||||
@@ -44,6 +44,7 @@ func (s *Server) enroll(name, hostname, os, arch, version string) (id, token str
|
||||
OS: os,
|
||||
Arch: arch,
|
||||
Version: version,
|
||||
RunUser: runUser,
|
||||
TokenHash: hashToken(token),
|
||||
}
|
||||
if err = s.db.CreateAgent(a); err != nil {
|
||||
|
||||
@@ -3,6 +3,9 @@ package server
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"strconv"
|
||||
|
||||
lpra "github.com/x-grk/longpolling-remote-access"
|
||||
)
|
||||
|
||||
// Config keys stored in the config table.
|
||||
@@ -12,15 +15,17 @@ const (
|
||||
cfgSetupDone = "setup_done"
|
||||
cfgInstallCode = "install_code"
|
||||
cfgUfwEnabled = "ufw_enabled"
|
||||
cfgTunnelHost = "tunnel_host"
|
||||
cfgTunnelPort = "tunnel_port"
|
||||
)
|
||||
|
||||
// DefaultHostAddress is the public URL agents dial by default. It is baked into
|
||||
// the rendered install.sh and can be changed from the settings page.
|
||||
const DefaultHostAddress = "https://REDACTED:8445"
|
||||
|
||||
// Version is the server build version, injected at compile time via -ldflags
|
||||
// (-X .../internal/server.Version=...). It is the newest version agents update to.
|
||||
var Version = "dev"
|
||||
// Version is the server build version (see VERSION.txt). It is the newest
|
||||
// version agents update to.
|
||||
var Version = lpra.Version
|
||||
|
||||
// ensureDefaults seeds the host address and a random enroll secret on first boot.
|
||||
func (db *DB) ensureDefaults() error {
|
||||
@@ -86,6 +91,24 @@ func (db *DB) SetUfwEnabled(on bool) error {
|
||||
return db.SetConfig(cfgUfwEnabled, "0")
|
||||
}
|
||||
|
||||
// TunnelHost is the host agents dial for the direct tunnel. Empty means the
|
||||
// host of the URL they already reach the panel on.
|
||||
func (db *DB) TunnelHost() string {
|
||||
v, _ := db.GetConfig(cfgTunnelHost)
|
||||
return v
|
||||
}
|
||||
|
||||
// TunnelPort is the direct-tunnel port set in Settings, or 0 when it is left
|
||||
// at the port of the server's -tunnel address.
|
||||
func (db *DB) TunnelPort() int {
|
||||
v, _ := db.GetConfig(cfgTunnelPort)
|
||||
n, _ := strconv.Atoi(v)
|
||||
if n < 1 || n > 65535 {
|
||||
return 0
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
func (db *DB) SetupDone() bool {
|
||||
v, _ := db.GetConfig(cfgSetupDone)
|
||||
return v == "1"
|
||||
|
||||
+30
-11
@@ -59,6 +59,8 @@ CREATE TABLE IF NOT EXISTS agents (
|
||||
arch TEXT NOT NULL,
|
||||
token_hash TEXT NOT NULL,
|
||||
version TEXT NOT NULL DEFAULT '',
|
||||
run_user TEXT NOT NULL DEFAULT '',
|
||||
login_user TEXT NOT NULL DEFAULT '',
|
||||
enrolled_at INTEGER NOT NULL,
|
||||
last_seen INTEGER NOT NULL
|
||||
);
|
||||
@@ -101,6 +103,8 @@ CREATE TABLE IF NOT EXISTS forward_rules (
|
||||
`ALTER TABLE forward_rules ADD COLUMN proto TEXT NOT NULL DEFAULT 'http'`,
|
||||
`ALTER TABLE agents ADD COLUMN version TEXT NOT NULL DEFAULT ''`,
|
||||
`ALTER TABLE ufw_rules ADD COLUMN rule_id INTEGER NOT NULL DEFAULT 0`,
|
||||
`ALTER TABLE agents ADD COLUMN run_user TEXT NOT NULL DEFAULT ''`,
|
||||
`ALTER TABLE agents ADD COLUMN login_user TEXT NOT NULL DEFAULT ''`,
|
||||
} {
|
||||
if _, err := db.sql.Exec(alter); err != nil && !strings.Contains(err.Error(), "duplicate column") {
|
||||
return err
|
||||
@@ -222,9 +226,18 @@ type Agent struct {
|
||||
Arch string
|
||||
TokenHash string
|
||||
Version string
|
||||
RunUser string // OS user the agent process runs as (reported by the agent)
|
||||
LoginUser string // OS user terminals are opened as; "" = RunUser
|
||||
LastSeen time.Time
|
||||
}
|
||||
|
||||
// TerminalUser is the OS user a terminal on this agent runs as. Agents are
|
||||
// installed as a root systemd service, so that is the fallback for agents too
|
||||
// old to report their user.
|
||||
func (a Agent) TerminalUser() string {
|
||||
return firstNonEmpty(a.LoginUser, a.RunUser, "root")
|
||||
}
|
||||
|
||||
// Online reports whether the agent polled recently enough to be considered up.
|
||||
func (a Agent) Online() bool {
|
||||
return time.Since(a.LastSeen) < 45*time.Second
|
||||
@@ -233,9 +246,9 @@ func (a Agent) Online() bool {
|
||||
func (db *DB) CreateAgent(a Agent) error {
|
||||
now := time.Now().Unix()
|
||||
_, err := db.sql.Exec(
|
||||
`INSERT INTO agents(id, name, hostname, os, arch, token_hash, version, enrolled_at, last_seen)
|
||||
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
a.ID, a.Name, a.Hostname, a.OS, a.Arch, a.TokenHash, a.Version, now, now)
|
||||
`INSERT INTO agents(id, name, hostname, os, arch, token_hash, version, run_user, enrolled_at, last_seen)
|
||||
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
a.ID, a.Name, a.Hostname, a.OS, a.Arch, a.TokenHash, a.Version, a.RunUser, now, now)
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -243,8 +256,8 @@ func (db *DB) GetAgent(id string) (*Agent, error) {
|
||||
a := &Agent{}
|
||||
var last int64
|
||||
err := db.sql.QueryRow(
|
||||
`SELECT id, name, hostname, os, arch, token_hash, version, last_seen FROM agents WHERE id = ?`, id).
|
||||
Scan(&a.ID, &a.Name, &a.Hostname, &a.OS, &a.Arch, &a.TokenHash, &a.Version, &last)
|
||||
`SELECT id, name, hostname, os, arch, token_hash, version, run_user, login_user, last_seen FROM agents WHERE id = ?`, id).
|
||||
Scan(&a.ID, &a.Name, &a.Hostname, &a.OS, &a.Arch, &a.TokenHash, &a.Version, &a.RunUser, &a.LoginUser, &last)
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
@@ -257,7 +270,7 @@ func (db *DB) GetAgent(id string) (*Agent, error) {
|
||||
|
||||
func (db *DB) ListAgents() ([]Agent, error) {
|
||||
rows, err := db.sql.Query(
|
||||
`SELECT id, name, hostname, os, arch, version, last_seen FROM agents ORDER BY name`)
|
||||
`SELECT id, name, hostname, os, arch, version, run_user, login_user, last_seen FROM agents ORDER BY name`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -266,7 +279,7 @@ func (db *DB) ListAgents() ([]Agent, error) {
|
||||
for rows.Next() {
|
||||
var a Agent
|
||||
var last int64
|
||||
if err := rows.Scan(&a.ID, &a.Name, &a.Hostname, &a.OS, &a.Arch, &a.Version, &last); err != nil {
|
||||
if err := rows.Scan(&a.ID, &a.Name, &a.Hostname, &a.OS, &a.Arch, &a.Version, &a.RunUser, &a.LoginUser, &last); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
a.LastSeen = time.Unix(last, 0)
|
||||
@@ -280,10 +293,16 @@ func (db *DB) TouchAgent(id string) error {
|
||||
return err
|
||||
}
|
||||
|
||||
// TouchAgentVersion updates last_seen and the reported version together.
|
||||
func (db *DB) TouchAgentVersion(id, version string) error {
|
||||
_, err := db.sql.Exec(`UPDATE agents SET last_seen = ?, version = ? WHERE id = ?`,
|
||||
time.Now().Unix(), version, id)
|
||||
// TouchAgentInfo updates last_seen together with the reported version and OS user.
|
||||
func (db *DB) TouchAgentInfo(id, version, runUser string) error {
|
||||
_, err := db.sql.Exec(`UPDATE agents SET last_seen = ?, version = ?, run_user = ? WHERE id = ?`,
|
||||
time.Now().Unix(), version, runUser, id)
|
||||
return err
|
||||
}
|
||||
|
||||
// SetAgentLoginUser sets the OS user terminals are opened as ("" = the agent's own).
|
||||
func (db *DB) SetAgentLoginUser(id, user string) error {
|
||||
_, err := db.sql.Exec(`UPDATE agents SET login_user = ? WHERE id = ?`, user, id)
|
||||
return err
|
||||
}
|
||||
|
||||
|
||||
@@ -28,7 +28,7 @@ func (s *Server) handleEnroll(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, "bad request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
id, token, err := s.enroll(req.Name, req.Hostname, req.OS, req.Arch, req.Version)
|
||||
id, token, err := s.enroll(req.Name, req.Hostname, req.OS, req.Arch, req.Version, req.User)
|
||||
if err != nil {
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
@@ -43,13 +43,40 @@ func (s *Server) handlePoll(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
// The agent sends its version occasionally; record it when present.
|
||||
if v := r.Header.Get("X-Agent-Version"); v != "" && v != a.Version {
|
||||
_ = s.db.TouchAgentVersion(a.ID, v)
|
||||
// The agent sends its version and OS user occasionally; record them when
|
||||
// present. Agents older than 0.0.2 send no user, so keep what we have.
|
||||
v := r.Header.Get("X-Agent-Version")
|
||||
u := firstNonEmpty(r.Header.Get("X-Agent-User"), a.RunUser)
|
||||
if v != "" && (v != a.Version || u != a.RunUser) {
|
||||
_ = s.db.TouchAgentInfo(a.ID, v, u)
|
||||
} else {
|
||||
_ = s.db.TouchAgent(a.ID)
|
||||
}
|
||||
writeJSON(w, s.hub.WaitControl(a.ID, pollHold))
|
||||
msg := s.hub.WaitControl(a.ID, pollHold)
|
||||
// Set after the hold so a port changed meanwhile reaches the agent now.
|
||||
s.setTunnelHeader(w)
|
||||
writeJSON(w, msg)
|
||||
}
|
||||
|
||||
// tunnelAddrHeader carries the direct-tunnel address ("host:port", host empty
|
||||
// = the panel's host) to agents, on every poll and on /api/agent/config.
|
||||
const tunnelAddrHeader = "X-Tunnel-Addr"
|
||||
|
||||
func (s *Server) setTunnelHeader(w http.ResponseWriter) {
|
||||
if addr := s.tunnelAdvert(); addr != "" {
|
||||
w.Header().Set(tunnelAddrHeader, addr)
|
||||
}
|
||||
}
|
||||
|
||||
// GET /api/agent/config — settings an agent picks up when it connects, without
|
||||
// waiting out a poll hold.
|
||||
func (s *Server) handleAgentConfig(w http.ResponseWriter, r *http.Request) {
|
||||
if s.authenticateAgent(r) == nil {
|
||||
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
s.setTunnelHeader(w)
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// GET /api/agent/task/{id} — the agent fetches the task to run.
|
||||
@@ -121,7 +148,11 @@ func (s *Server) handleTermOut(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
if msg.Exit {
|
||||
s.hub.CloseSession(sess.ID)
|
||||
// End the stream now, but keep the session reachable for a moment so
|
||||
// the panel can still collect the last output (e.g. why the shell
|
||||
// failed to start).
|
||||
sess.close()
|
||||
time.AfterFunc(pollHold, func() { s.hub.CloseSession(sess.ID) })
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
@@ -1,9 +1,14 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -141,6 +146,12 @@ func (s *Server) handleAgentDelete(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
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)
|
||||
http.Redirect(w, r, "/", http.StatusSeeOther)
|
||||
}
|
||||
@@ -152,13 +163,135 @@ func (s *Server) handleAgentEdit(w http.ResponseWriter, r *http.Request) {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
s.render(w, "edit.html", map[string]any{
|
||||
data := map[string]any{
|
||||
"Agent": a,
|
||||
"CSRF": csrfToken(r),
|
||||
"ServerVersion": Version,
|
||||
"Outdated": a.Version != "" && a.Version != Version,
|
||||
"Updated": r.URL.Query().Get("updated") == "1",
|
||||
"UserOK": r.URL.Query().Get("uok"),
|
||||
"UserErr": r.URL.Query().Get("uerr"),
|
||||
}
|
||||
// The user list lives on the machine, so it is only known while it is online.
|
||||
if a.Online() {
|
||||
if users, err := s.agentUsers(id); err == nil {
|
||||
data["Users"] = users
|
||||
} else {
|
||||
data["UsersErr"] = err.Error()
|
||||
}
|
||||
}
|
||||
s.render(w, "edit.html", data)
|
||||
}
|
||||
|
||||
// usersTaskTimeout bounds how long the edit page waits for the agent.
|
||||
const usersTaskTimeout = 8 * time.Second
|
||||
|
||||
// agentUsers asks the agent who it runs as and which users it can log in as.
|
||||
func (s *Server) agentUsers(agentID string) (*proto.SysUsersResult, error) {
|
||||
taskID, ch := s.hub.CreateTask(agentID, proto.TaskSysUsers, nil)
|
||||
defer s.hub.DiscardTask(taskID)
|
||||
select {
|
||||
case res := <-ch:
|
||||
if !res.OK {
|
||||
// Agents older than 0.0.2 don't know this task.
|
||||
return nil, errors.New("клиент не поддерживает выбор пользователя — обновите его")
|
||||
}
|
||||
var out proto.SysUsersResult
|
||||
if err := json.Unmarshal(res.Data, &out); err != nil {
|
||||
return nil, errors.New("некорректный ответ клиента")
|
||||
}
|
||||
return &out, nil
|
||||
case <-time.After(usersTaskTimeout):
|
||||
return nil, errors.New("таймаут ожидания клиента")
|
||||
}
|
||||
}
|
||||
|
||||
// editUserRedirect returns to the edit page with a result banner.
|
||||
func editUserRedirect(w http.ResponseWriter, r *http.Request, id, key, msg string) {
|
||||
http.Redirect(w, r, "/agents/"+id+"/edit?"+key+"="+url.QueryEscape(msg), http.StatusSeeOther)
|
||||
}
|
||||
|
||||
// handleAgentSetUser picks the OS user terminals on this machine run as.
|
||||
func (s *Server) handleAgentSetUser(w http.ResponseWriter, r *http.Request) {
|
||||
if !checkCSRF(r) {
|
||||
http.Error(w, "bad csrf", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
id := r.PathValue("id")
|
||||
a, err := s.db.GetAgent(id)
|
||||
if err != nil || a == nil {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
name := strings.TrimSpace(r.FormValue("user"))
|
||||
if !a.Online() {
|
||||
editUserRedirect(w, r, id, "uerr", "Машина офлайн — сменить пользователя нельзя.")
|
||||
return
|
||||
}
|
||||
// Only accept a user the machine itself reports, so a typo (or an agent too
|
||||
// old to honour the choice) can't silently leave the terminal as root.
|
||||
users, err := s.agentUsers(id)
|
||||
if err != nil {
|
||||
editUserRedirect(w, r, id, "uerr", err.Error())
|
||||
return
|
||||
}
|
||||
if !slices.Contains(users.Users, name) {
|
||||
editUserRedirect(w, r, id, "uerr", "Пользователя "+name+" нет на машине.")
|
||||
return
|
||||
}
|
||||
if name == users.Current {
|
||||
name = "" // follow the agent's own user
|
||||
}
|
||||
if err := s.db.SetAgentLoginUser(id, name); err != nil {
|
||||
editUserRedirect(w, r, id, "uerr", "Не удалось сохранить.")
|
||||
return
|
||||
}
|
||||
if admin := s.currentAdmin(r); admin != nil {
|
||||
s.db.Audit(admin.ID, id, "set_user:"+firstNonEmpty(name, users.Current))
|
||||
}
|
||||
editUserRedirect(w, r, id, "uok", "Терминал будет открываться от "+firstNonEmpty(name, users.Current)+".")
|
||||
}
|
||||
|
||||
// handleAgentAddUser creates a new OS user on the machine and switches the
|
||||
// terminal to it.
|
||||
func (s *Server) handleAgentAddUser(w http.ResponseWriter, r *http.Request) {
|
||||
if !checkCSRF(r) {
|
||||
http.Error(w, "bad csrf", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
id := r.PathValue("id")
|
||||
a, err := s.db.GetAgent(id)
|
||||
if err != nil || a == nil {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
if !a.Online() {
|
||||
editUserRedirect(w, r, id, "uerr", "Машина офлайн — создать пользователя нельзя.")
|
||||
return
|
||||
}
|
||||
name := strings.TrimSpace(r.FormValue("name"))
|
||||
args, _ := json.Marshal(proto.SysUserAddArgs{
|
||||
Name: name,
|
||||
Password: r.FormValue("password"),
|
||||
Sudo: r.FormValue("sudo") == "1",
|
||||
})
|
||||
taskID, ch := s.hub.CreateTask(id, proto.TaskSysUserAdd, args)
|
||||
defer s.hub.DiscardTask(taskID)
|
||||
select {
|
||||
case res := <-ch:
|
||||
if !res.OK {
|
||||
editUserRedirect(w, r, id, "uerr", "Не удалось создать пользователя: "+res.Error)
|
||||
return
|
||||
}
|
||||
case <-time.After(30 * time.Second):
|
||||
editUserRedirect(w, r, id, "uerr", "Таймаут ожидания клиента.")
|
||||
return
|
||||
}
|
||||
_ = s.db.SetAgentLoginUser(id, name)
|
||||
if admin := s.currentAdmin(r); admin != nil {
|
||||
s.db.Audit(admin.ID, id, "create_user:"+name)
|
||||
}
|
||||
editUserRedirect(w, r, id, "uok", "Пользователь "+name+" создан и выбран для терминала.")
|
||||
}
|
||||
|
||||
// handleAgentUpdate asks the agent to download the latest binary and restart.
|
||||
@@ -223,9 +356,76 @@ func (s *Server) handleSettings(w http.ResponseWriter, r *http.Request) {
|
||||
"UfwRunnable": ufwRunnable,
|
||||
"UfwEnabled": s.db.UfwEnabled(),
|
||||
"UfwRules": ufwRules,
|
||||
"UfwError": s.ufw.LastError(),
|
||||
"TunnelPort": s.TunnelPort(),
|
||||
"TunnelDefault": s.tunnelDefault,
|
||||
"TunnelHost": s.db.TunnelHost(),
|
||||
"TunnelCustom": s.db.TunnelPort() > 0,
|
||||
"Error": map[string]string{
|
||||
"tunnel_host": "Некорректный хост туннеля: нужен IP или имя без схемы и порта.",
|
||||
"tunnel_port": "Некорректный порт туннеля.",
|
||||
"tunnel_listen": "Не удалось занять новый порт туннеля на хосте (возможно, уже используется). Порт не изменён.",
|
||||
}[r.URL.Query().Get("err")],
|
||||
})
|
||||
}
|
||||
|
||||
// POST /settings/tunnel — host and port agents use for the direct tunnel.
|
||||
func (s *Server) handleSetTunnel(w http.ResponseWriter, r *http.Request) {
|
||||
if !checkCSRF(r) {
|
||||
http.Error(w, "bad csrf", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
if s.tunnelDefault == 0 {
|
||||
http.Redirect(w, r, "/settings", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
host := strings.TrimSpace(r.FormValue("tunnel_host"))
|
||||
host = strings.TrimSuffix(strings.TrimPrefix(host, "["), "]")
|
||||
if !validTunnelHost(host) {
|
||||
http.Redirect(w, r, "/settings?err=tunnel_host", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
// An empty port goes back to the one the server was started with.
|
||||
port, custom := s.tunnelDefault, ""
|
||||
if v := strings.TrimSpace(r.FormValue("tunnel_port")); v != "" {
|
||||
n, err := strconv.Atoi(v)
|
||||
if err != nil || n < 1 || n > 65535 {
|
||||
http.Redirect(w, r, "/settings?err=tunnel_port", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
port = n
|
||||
if n != s.tunnelDefault {
|
||||
custom = v
|
||||
}
|
||||
}
|
||||
if err := s.setTunnelPort(port); err != nil {
|
||||
log.Printf("tunnel: move to port %d: %v", port, err)
|
||||
http.Redirect(w, r, "/settings?err=tunnel_listen", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
_ = s.db.SetConfig(cfgTunnelPort, custom)
|
||||
_ = s.db.SetConfig(cfgTunnelHost, host)
|
||||
http.Redirect(w, r, "/settings", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
// validTunnelHost accepts an empty value, an IP, or a bare host name.
|
||||
func validTunnelHost(host string) bool {
|
||||
if host == "" || net.ParseIP(host) != nil {
|
||||
return true
|
||||
}
|
||||
if len(host) > 253 {
|
||||
return false
|
||||
}
|
||||
for _, c := range host {
|
||||
switch {
|
||||
case c >= 'a' && c <= 'z', c >= 'A' && c <= 'Z', c >= '0' && c <= '9', c == '-', c == '.', c == '_':
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func (s *Server) handleSetHost(w http.ResponseWriter, r *http.Request) {
|
||||
if !checkCSRF(r) {
|
||||
http.Error(w, "bad csrf", http.StatusForbidden)
|
||||
@@ -282,7 +482,12 @@ func (s *Server) handleUfwToggle(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, "bad csrf", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
_ = s.db.SetUfwEnabled(r.FormValue("enabled") == "1")
|
||||
on := r.FormValue("enabled") == "1"
|
||||
_ = s.db.SetUfwEnabled(on)
|
||||
if on {
|
||||
// Open what forwards created while the integration was off need.
|
||||
s.ufw.sync()
|
||||
}
|
||||
http.Redirect(w, r, "/settings", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
|
||||
@@ -34,7 +34,7 @@ func (s *Server) handleFilesPage(w http.ResponseWriter, r *http.Request) {
|
||||
http.NotFound(w, r)
|
||||
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
|
||||
|
||||
@@ -3,6 +3,7 @@ package server
|
||||
import (
|
||||
"log"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
@@ -17,18 +18,36 @@ func (s *Server) handleForwardsPage(w http.ResponseWriter, r *http.Request) {
|
||||
rules, _ := s.db.ListForwardRulesByAgent(id)
|
||||
errMsg := map[string]string{
|
||||
"busy": "Этот порт хоста уже занят другим правилом.",
|
||||
"tunnel": "Этот порт занят прямым туннелем панели (см. Настройки).",
|
||||
"port": "Некорректный номер порта.",
|
||||
"listen": "Не удалось занять порт на хосте (возможно, уже используется).",
|
||||
"db": "Ошибка базы данных.",
|
||||
}[r.URL.Query().Get("err")]
|
||||
s.render(w, "forwards.html", map[string]any{
|
||||
"Agent": a,
|
||||
"Rules": rules,
|
||||
"CSRF": csrfToken(r),
|
||||
"Error": errMsg,
|
||||
"Agent": a,
|
||||
"Rules": rules,
|
||||
"CSRF": csrfToken(r),
|
||||
"Error": errMsg,
|
||||
"TunnelPort": s.TunnelPort(),
|
||||
"PanelPort": panelPort(s.db.HostAddress()),
|
||||
})
|
||||
}
|
||||
|
||||
// panelPort is the port of the panel URL agents dial, for the mode hints.
|
||||
func panelPort(host string) string {
|
||||
u, err := url.Parse(host)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
if p := u.Port(); p != "" {
|
||||
return p
|
||||
}
|
||||
if u.Scheme == "http" {
|
||||
return "80"
|
||||
}
|
||||
return "443"
|
||||
}
|
||||
|
||||
// POST /agents/{id}/forwards/add
|
||||
func (s *Server) handleForwardAdd(w http.ResponseWriter, r *http.Request) {
|
||||
if !checkCSRF(r) {
|
||||
@@ -57,7 +76,11 @@ func (s *Server) handleForwardAdd(w http.ResponseWriter, r *http.Request) {
|
||||
http.Redirect(w, r, "/agents/"+id+"/forwards?err=port", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
// Reject a host port already bound by another forward rule.
|
||||
// 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) {
|
||||
http.Redirect(w, r, "/agents/"+id+"/forwards?err=busy", http.StatusSeeOther)
|
||||
return
|
||||
@@ -89,13 +112,19 @@ func (s *Server) handleForwardDelete(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
rid, _ := strconv.ParseInt(r.PathValue("rid"), 10, 64)
|
||||
rule, _ := s.db.GetForwardRule(rid)
|
||||
s.fwd.close(rid)
|
||||
_ = s.db.DeleteForwardRule(rid)
|
||||
// Remove the ufw rule bound to this forward rule (shared 8446 stays).
|
||||
s.ufw.revokeForRule(rid)
|
||||
s.removeForward(rid)
|
||||
dest := "/"
|
||||
if rule != nil {
|
||||
dest = "/agents/" + rule.AgentID + "/forwards"
|
||||
}
|
||||
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)
|
||||
}
|
||||
@@ -21,9 +21,9 @@ func (s *Server) handlePanelOpen(w http.ResponseWriter, r *http.Request) {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
sess := s.hub.CreateSession(agentID)
|
||||
sess := s.hub.CreateSession(agentID, agent.LoginUser)
|
||||
if admin := s.currentAdmin(r); admin != nil {
|
||||
s.db.Audit(admin.ID, agentID, "open_terminal")
|
||||
s.db.Audit(admin.ID, agentID, "open_terminal:"+agent.TerminalUser())
|
||||
}
|
||||
writeJSON(w, map[string]string{"session_id": sess.ID})
|
||||
}
|
||||
|
||||
+12
-4
@@ -82,10 +82,11 @@ func (h *Hub) newSession(agentID string) *Session {
|
||||
return s
|
||||
}
|
||||
|
||||
// CreateSession registers a terminal session and queues it for the agent.
|
||||
func (h *Hub) CreateSession(agentID string) *Session {
|
||||
// CreateSession registers a terminal session and queues it for the agent. user
|
||||
// is the OS user to run the shell as ("" = the agent's own user).
|
||||
func (h *Hub) CreateSession(agentID, user string) *Session {
|
||||
s := h.newSession(agentID)
|
||||
h.enqueueJob(agentID, proto.ControlMessage{Type: proto.CtrlOpenTerminal, SessionID: s.ID})
|
||||
h.enqueueJob(agentID, proto.ControlMessage{Type: proto.CtrlOpenTerminal, SessionID: s.ID, User: user})
|
||||
return s
|
||||
}
|
||||
|
||||
@@ -245,7 +246,14 @@ func (s *Session) PollOut(timeout time.Duration) ([]proto.OutMessage, bool) {
|
||||
case <-time.After(timeout):
|
||||
return nil, true
|
||||
case <-s.closed:
|
||||
return nil, false
|
||||
// Hand over output queued before the close (e.g. the reason a shell
|
||||
// failed to start) instead of dropping it.
|
||||
select {
|
||||
case m := <-s.out:
|
||||
batch = append(batch, m)
|
||||
default:
|
||||
return nil, false
|
||||
}
|
||||
}
|
||||
for {
|
||||
select {
|
||||
|
||||
+91
-15
@@ -9,6 +9,8 @@ import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
texttemplate "text/template"
|
||||
)
|
||||
|
||||
@@ -24,7 +26,8 @@ type Options struct {
|
||||
// nginx TLS); set false only for plain-HTTP local testing.
|
||||
SecureCookies bool
|
||||
// TunnelAddr is the listen address for the direct TCP+UDP forward tunnel
|
||||
// (default ":8446"). Empty disables direct forwarding.
|
||||
// (default ":8446"). Empty disables direct forwarding. The port can be
|
||||
// overridden from the settings page.
|
||||
TunnelAddr string
|
||||
}
|
||||
|
||||
@@ -33,12 +36,22 @@ type Server struct {
|
||||
db *DB
|
||||
hub *Hub
|
||||
fwd *forwardManager
|
||||
tun *tunnelHub
|
||||
udp *udpRelay
|
||||
ufw *ufwManager
|
||||
tmpl *template.Template
|
||||
limiter *loginLimiter
|
||||
distDir string
|
||||
secureCookies bool
|
||||
installTmpl *texttemplate.Template
|
||||
|
||||
// Direct tunnel: bind host and port of the -tunnel address, and the port
|
||||
// currently listened on (the settings override, else the default).
|
||||
// tunnelDefault 0 means the tunnel is disabled.
|
||||
tunnelBind string
|
||||
tunnelDefault int
|
||||
tunnelMu sync.Mutex // serializes port changes
|
||||
tunnelPort atomic.Int64
|
||||
}
|
||||
|
||||
// New builds the server, parsing embedded templates.
|
||||
@@ -65,26 +78,81 @@ func New(opts Options) (*Server, error) {
|
||||
db: opts.DB,
|
||||
hub: hub,
|
||||
fwd: newForwardManager(hub, tun, udp),
|
||||
ufw: newUfwManager(opts.DB, portFromAddr(opts.TunnelAddr)),
|
||||
tun: tun,
|
||||
udp: udp,
|
||||
tmpl: tmpl,
|
||||
limiter: newLoginLimiter(),
|
||||
distDir: opts.DistDir,
|
||||
secureCookies: opts.SecureCookies,
|
||||
installTmpl: installTmpl,
|
||||
}
|
||||
// Start the direct forward tunnel (TCP mux + UDP) on 8446.
|
||||
s.ufw = newUfwManager(opts.DB, s.TunnelPort)
|
||||
// Start the direct forward tunnel (TCP mux + UDP), on the port from the
|
||||
// settings page if one is set there.
|
||||
if opts.TunnelAddr != "" {
|
||||
if err := tun.listen(opts.TunnelAddr); err != nil {
|
||||
log.Printf("tunnel tcp listen %s: %v", opts.TunnelAddr, err)
|
||||
s.tunnelBind, s.tunnelDefault = splitAddr(opts.TunnelAddr)
|
||||
}
|
||||
if s.tunnelDefault > 0 {
|
||||
port := s.tunnelDefault
|
||||
if p := s.db.TunnelPort(); p > 0 {
|
||||
port = p
|
||||
}
|
||||
if err := udp.listen(opts.TunnelAddr); err != nil {
|
||||
log.Printf("tunnel udp listen %s: %v", opts.TunnelAddr, err)
|
||||
s.tunnelPort.Store(int64(port))
|
||||
addr := s.tunnelListenAddr(port)
|
||||
if err := tun.listen(addr); err != nil {
|
||||
log.Printf("tunnel tcp listen %s: %v", addr, err)
|
||||
}
|
||||
if err := udp.listen(addr); err != nil {
|
||||
log.Printf("tunnel udp listen %s: %v", addr, err)
|
||||
}
|
||||
}
|
||||
s.restoreForwards()
|
||||
go s.ufw.sync()
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// TunnelPort is the port the direct tunnel listens on (0 = disabled).
|
||||
func (s *Server) TunnelPort() int { return int(s.tunnelPort.Load()) }
|
||||
|
||||
func (s *Server) tunnelListenAddr(port int) string {
|
||||
return net.JoinHostPort(s.tunnelBind, strconv.Itoa(port))
|
||||
}
|
||||
|
||||
// tunnelAdvert is the tunnel address handed to agents as "host:port". The host
|
||||
// is empty unless one is set in Settings; agents then use the panel's host.
|
||||
func (s *Server) tunnelAdvert() string {
|
||||
port := s.TunnelPort()
|
||||
if port == 0 {
|
||||
return ""
|
||||
}
|
||||
return net.JoinHostPort(s.db.TunnelHost(), strconv.Itoa(port))
|
||||
}
|
||||
|
||||
// setTunnelPort moves the direct tunnel to another port. Connected agents are
|
||||
// dropped and redial the new port once their next poll tells them about it.
|
||||
func (s *Server) setTunnelPort(port int) error {
|
||||
s.tunnelMu.Lock()
|
||||
defer s.tunnelMu.Unlock()
|
||||
old := s.TunnelPort()
|
||||
if old == 0 || port == old {
|
||||
return nil
|
||||
}
|
||||
addr := s.tunnelListenAddr(port)
|
||||
if err := s.tun.listen(addr); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.udp.listen(addr); err != nil {
|
||||
// Keep TCP and UDP on the same port: go back to the old one.
|
||||
if rerr := s.tun.listen(s.tunnelListenAddr(old)); rerr != nil {
|
||||
log.Printf("tunnel tcp listen %s: %v", s.tunnelListenAddr(old), rerr)
|
||||
}
|
||||
return err
|
||||
}
|
||||
s.tunnelPort.Store(int64(port))
|
||||
go s.ufw.sync()
|
||||
return nil
|
||||
}
|
||||
|
||||
// restoreForwards re-opens the host listeners for all saved forward rules.
|
||||
func (s *Server) restoreForwards() {
|
||||
rules, err := s.db.ListForwardRules()
|
||||
@@ -93,6 +161,12 @@ func (s *Server) restoreForwards() {
|
||||
return
|
||||
}
|
||||
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 {
|
||||
log.Printf("restore forward rule %d (%s:%d): %v", r.ID, r.ListenAddr, r.HostPort, err)
|
||||
}
|
||||
@@ -129,9 +203,12 @@ func (s *Server) Handler() http.Handler {
|
||||
mux.Handle("GET /agents/{id}/edit", s.requireAdmin(s.handleAgentEdit))
|
||||
mux.Handle("POST /agents/{id}/rename", s.requireAdmin(s.handleAgentRename))
|
||||
mux.Handle("POST /agents/{id}/update", s.requireAdmin(s.handleAgentUpdate))
|
||||
mux.Handle("POST /agents/{id}/user", s.requireAdmin(s.handleAgentSetUser))
|
||||
mux.Handle("POST /agents/{id}/users/add", s.requireAdmin(s.handleAgentAddUser))
|
||||
mux.Handle("POST /agents/{id}/delete", s.requireAdmin(s.handleAgentDelete))
|
||||
mux.Handle("GET /settings", s.requireAdmin(s.handleSettings))
|
||||
mux.Handle("POST /settings/host", s.requireAdmin(s.handleSetHost))
|
||||
mux.Handle("POST /settings/tunnel", s.requireAdmin(s.handleSetTunnel))
|
||||
mux.Handle("POST /settings/secret/rotate", s.requireAdmin(s.handleRotateSecret))
|
||||
mux.Handle("POST /settings/admins/add", s.requireAdmin(s.handleAddAdmin))
|
||||
mux.Handle("POST /settings/admins/delete", s.requireAdmin(s.handleDeleteAdmin))
|
||||
@@ -155,6 +232,7 @@ func (s *Server) Handler() http.Handler {
|
||||
// Agent API (token/secret auth inside handlers).
|
||||
mux.HandleFunc("POST /api/agent/enroll", s.handleEnroll)
|
||||
mux.HandleFunc("GET /api/agent/poll", s.handlePoll)
|
||||
mux.HandleFunc("GET /api/agent/config", s.handleAgentConfig)
|
||||
mux.HandleFunc("GET /api/agent/term/{sid}/in", s.handleTermIn)
|
||||
mux.HandleFunc("POST /api/agent/term/{sid}/out", s.handleTermOut)
|
||||
mux.HandleFunc("GET /api/agent/task/{id}", s.handleTaskFetch)
|
||||
@@ -200,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.
|
||||
func portFromAddr(addr string) int {
|
||||
if addr == "" {
|
||||
return 0
|
||||
}
|
||||
_, p, err := net.SplitHostPort(addr)
|
||||
// splitAddr splits a "host:port" or ":port" listen address. The port is 0 when
|
||||
// the address has none.
|
||||
func splitAddr(addr string) (host string, port int) {
|
||||
h, p, err := net.SplitHostPort(addr)
|
||||
if err != nil {
|
||||
return 0
|
||||
return "", 0
|
||||
}
|
||||
n, _ := strconv.Atoi(p)
|
||||
return n
|
||||
return h, n
|
||||
}
|
||||
|
||||
func clientIP(r *http.Request) string {
|
||||
|
||||
@@ -39,6 +39,7 @@ func readLine(r io.Reader, max int) (string, error) {
|
||||
type tunnelHub struct {
|
||||
db *DB
|
||||
mu sync.Mutex
|
||||
ln net.Listener
|
||||
agents map[string]*agentTunnel
|
||||
}
|
||||
|
||||
@@ -57,12 +58,30 @@ type agentTunnel struct {
|
||||
streams map[uint32]net.Conn
|
||||
}
|
||||
|
||||
// listen runs the 8446 TCP tunnel acceptor until the listener closes.
|
||||
// listen runs the TCP tunnel acceptor until the listener closes. Listening
|
||||
// again moves the tunnel: the previous listener is closed and its agents are
|
||||
// dropped, so they redial the new address.
|
||||
func (h *tunnelHub) listen(addr string) error {
|
||||
ln, err := net.Listen("tcp", addr)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
h.mu.Lock()
|
||||
old := h.ln
|
||||
h.ln = ln
|
||||
var stale []*agentTunnel
|
||||
if old != nil {
|
||||
for _, at := range h.agents {
|
||||
stale = append(stale, at)
|
||||
}
|
||||
}
|
||||
h.mu.Unlock()
|
||||
if old != nil {
|
||||
old.Close()
|
||||
}
|
||||
for _, at := range stale {
|
||||
at.conn.Close()
|
||||
}
|
||||
log.Printf("tunnel: listening on %s (direct TCP forwarding)", addr)
|
||||
go func() {
|
||||
for {
|
||||
|
||||
+22
-10
@@ -20,9 +20,10 @@ import (
|
||||
// registration (agent->server): "R <agentID> <sig>"
|
||||
// data (both): 'D' | sid(4) | port(2) | payload
|
||||
type udpRelay struct {
|
||||
db *DB
|
||||
mu sync.Mutex
|
||||
ctrl *net.UDPConn
|
||||
db *DB
|
||||
mu sync.Mutex
|
||||
ctrl *net.UDPConn
|
||||
gcOnce sync.Once
|
||||
|
||||
agentAddr map[string]*net.UDPAddr // agentID -> last registered addr
|
||||
sessions map[uint32]*udpSession
|
||||
@@ -48,7 +49,8 @@ func newUDPRelay(db *DB) *udpRelay {
|
||||
}
|
||||
}
|
||||
|
||||
// listen opens the shared 8446 UDP control socket.
|
||||
// listen opens the shared UDP control socket. Listening again moves it: the
|
||||
// previous socket is closed and agents register anew on the new address.
|
||||
func (u *udpRelay) listen(addr string) error {
|
||||
ua, err := net.ResolveUDPAddr("udp", addr)
|
||||
if err != nil {
|
||||
@@ -58,17 +60,26 @@ func (u *udpRelay) listen(addr string) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
u.mu.Lock()
|
||||
old := u.ctrl
|
||||
u.ctrl = c
|
||||
if old != nil {
|
||||
u.agentAddr = map[string]*net.UDPAddr{}
|
||||
}
|
||||
u.mu.Unlock()
|
||||
if old != nil {
|
||||
old.Close()
|
||||
}
|
||||
log.Printf("tunnel: listening on %s/udp (direct UDP forwarding)", addr)
|
||||
go u.ctrlLoop()
|
||||
go u.gc()
|
||||
go u.ctrlLoop(c)
|
||||
u.gcOnce.Do(func() { go u.gc() })
|
||||
return nil
|
||||
}
|
||||
|
||||
func (u *udpRelay) ctrlLoop() {
|
||||
func (u *udpRelay) ctrlLoop(ctrl *net.UDPConn) {
|
||||
buf := make([]byte, 65535)
|
||||
for {
|
||||
n, src, err := u.ctrl.ReadFromUDP(buf)
|
||||
n, src, err := ctrl.ReadFromUDP(buf)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
@@ -171,6 +182,7 @@ func (u *udpRelay) rulePortLoop(rule ForwardRule, c *net.UDPConn) {
|
||||
}
|
||||
u.mu.Lock()
|
||||
aAddr := u.agentAddr[rule.AgentID]
|
||||
ctrl := u.ctrl
|
||||
// Find existing session for this (rule, user).
|
||||
var sess *udpSession
|
||||
for _, s := range u.sessions {
|
||||
@@ -190,7 +202,7 @@ func (u *udpRelay) rulePortLoop(rule ForwardRule, c *net.UDPConn) {
|
||||
sess.last = time.Now()
|
||||
u.mu.Unlock()
|
||||
|
||||
if aAddr == nil {
|
||||
if aAddr == nil || ctrl == nil {
|
||||
continue // agent has not registered its UDP endpoint yet
|
||||
}
|
||||
// 'D' | sid(4) | clientPort(2) | payload
|
||||
@@ -199,7 +211,7 @@ func (u *udpRelay) rulePortLoop(rule ForwardRule, c *net.UDPConn) {
|
||||
binary.BigEndian.PutUint32(out[1:5], sess.id)
|
||||
binary.BigEndian.PutUint16(out[5:7], uint16(rule.ClientPort))
|
||||
copy(out[7:], buf[:n])
|
||||
_, _ = u.ctrl.WriteToUDP(out, aAddr)
|
||||
_, _ = ctrl.WriteToUDP(out, aAddr)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+115
-33
@@ -3,8 +3,11 @@ package server
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"net"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// ufwManager integrates with the host firewall (ufw) when it is installed and
|
||||
@@ -14,28 +17,40 @@ import (
|
||||
// ufw requires root, so this only works when the server process can run ufw
|
||||
// (e.g. running as root). When it cannot, integration is simply inactive.
|
||||
type ufwManager struct {
|
||||
db *DB
|
||||
tunnelPort int
|
||||
db *DB
|
||||
// tunnelPort returns the current direct-tunnel port (0 = tunnel disabled).
|
||||
tunnelPort func() int
|
||||
|
||||
mu sync.Mutex
|
||||
lastErr string // last failed ufw command, shown in Settings
|
||||
}
|
||||
|
||||
func newUfwManager(db *DB, tunnelPort int) *ufwManager {
|
||||
func newUfwManager(db *DB, tunnelPort func() int) *ufwManager {
|
||||
return &ufwManager{db: db, tunnelPort: tunnelPort}
|
||||
}
|
||||
|
||||
// ufwRun runs ufw with a fixed C locale: its output is translated, and we
|
||||
// match on the English text.
|
||||
func ufwRun(args ...string) (string, error) {
|
||||
cmd := exec.Command("ufw", args...)
|
||||
cmd.Env = append(os.Environ(), "LC_ALL=C", "LANG=C", "LANGUAGE=C")
|
||||
out, err := cmd.CombinedOutput()
|
||||
return strings.TrimSpace(string(out)), err
|
||||
}
|
||||
|
||||
// status reports whether ufw is installed, active, and runnable by this process.
|
||||
func (u *ufwManager) status() (installed, active, runnable bool) {
|
||||
path, err := exec.LookPath("ufw")
|
||||
if err != nil {
|
||||
if _, err := exec.LookPath("ufw"); err != nil {
|
||||
return false, false, false
|
||||
}
|
||||
installed = true
|
||||
out, err := exec.Command(path, "status").CombinedOutput()
|
||||
out, err := ufwRun("status")
|
||||
if err != nil {
|
||||
// Typically a permission error (not root) -> not runnable.
|
||||
return true, false, false
|
||||
}
|
||||
runnable = true
|
||||
active = strings.Contains(string(out), "Status: active")
|
||||
active = strings.Contains(out, "Status: active")
|
||||
return installed, active, runnable
|
||||
}
|
||||
|
||||
@@ -48,18 +63,36 @@ func (u *ufwManager) available() bool {
|
||||
return installed && active && runnable
|
||||
}
|
||||
|
||||
// allow opens port/proto in ufw (if integration is available) and records it,
|
||||
// bound to the given forward rule id (0 = shared/manual).
|
||||
func (u *ufwManager) setErr(msg string) {
|
||||
u.mu.Lock()
|
||||
u.lastErr = msg
|
||||
u.mu.Unlock()
|
||||
}
|
||||
|
||||
// LastError returns the last failed ufw command ("" when the last one worked).
|
||||
func (u *ufwManager) LastError() string {
|
||||
u.mu.Lock()
|
||||
defer u.mu.Unlock()
|
||||
return u.lastErr
|
||||
}
|
||||
|
||||
// allow opens port/proto in ufw and records it, bound to the given forward
|
||||
// rule id (0 = shared/manual). The caller has checked available().
|
||||
func (u *ufwManager) allow(port int, proto string, ruleID int64) {
|
||||
if !u.available() {
|
||||
return
|
||||
}
|
||||
if u.db.HasUfwRule(port, proto) {
|
||||
return
|
||||
}
|
||||
spec := fmt.Sprintf("%d/%s", port, proto)
|
||||
if out, err := exec.Command("ufw", "allow", spec).CombinedOutput(); err != nil {
|
||||
log.Printf("ufw allow %s: %v: %s", spec, err, strings.TrimSpace(string(out)))
|
||||
out, err := ufwRun("allow", spec)
|
||||
if err != nil {
|
||||
log.Printf("ufw allow %s: %v: %s", spec, err, out)
|
||||
u.setErr(fmt.Sprintf("ufw allow %s: %v: %s", spec, err, out))
|
||||
return
|
||||
}
|
||||
u.setErr("")
|
||||
if strings.Contains(out, "Skipping") {
|
||||
// The rule was there before the panel asked for it, so it is not ours
|
||||
// to track (and later delete).
|
||||
return
|
||||
}
|
||||
_ = u.db.AddUfwRule(port, proto, ruleID)
|
||||
@@ -69,9 +102,11 @@ func (u *ufwManager) allow(port int, proto string, ruleID int64) {
|
||||
// revokeRule deletes one tracked ufw rule from ufw and the tracking table.
|
||||
func (u *ufwManager) revokeRule(r UfwRule) {
|
||||
spec := fmt.Sprintf("%d/%s", r.Port, r.Proto)
|
||||
if out, err := exec.Command("ufw", "delete", "allow", spec).CombinedOutput(); err != nil {
|
||||
log.Printf("ufw delete allow %s: %v: %s", spec, err, strings.TrimSpace(string(out)))
|
||||
if out, err := ufwRun("delete", "allow", spec); err != nil {
|
||||
log.Printf("ufw delete allow %s: %v: %s", spec, err, out)
|
||||
u.setErr(fmt.Sprintf("ufw delete allow %s: %v: %s", spec, err, out))
|
||||
} else {
|
||||
u.setErr("")
|
||||
log.Printf("ufw: revoked %s", spec)
|
||||
}
|
||||
_ = u.db.DeleteUfwRule(r.ID)
|
||||
@@ -86,8 +121,8 @@ func (u *ufwManager) revoke(id int64) {
|
||||
u.revokeRule(*r)
|
||||
}
|
||||
|
||||
// revokeForRule removes the ufw rules bound to a specific forward rule. The
|
||||
// shared tunnel port (rule_id 0) is left alone, since other forwards use it.
|
||||
// revokeForRule removes the ufw rules bound to a specific forward rule, and
|
||||
// the shared tunnel port once no forward needs it any more.
|
||||
func (u *ufwManager) revokeForRule(ruleID int64) {
|
||||
rules, err := u.db.ListUfwRulesByRule(ruleID)
|
||||
if err != nil {
|
||||
@@ -96,24 +131,71 @@ func (u *ufwManager) revokeForRule(ruleID int64) {
|
||||
for _, r := range rules {
|
||||
u.revokeRule(r)
|
||||
}
|
||||
u.pruneShared()
|
||||
}
|
||||
|
||||
// pruneShared revokes shared (rule_id 0) ufw rules that nothing uses: anything
|
||||
// but the current tunnel port, and the tunnel port itself for a protocol that
|
||||
// has no direct forward left.
|
||||
func (u *ufwManager) pruneShared() {
|
||||
shared, err := u.db.ListUfwRulesByRule(0)
|
||||
if err != nil || len(shared) == 0 {
|
||||
return
|
||||
}
|
||||
forwards, err := u.db.ListForwardRules()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
used := map[string]bool{}
|
||||
for _, f := range forwards {
|
||||
used[f.Proto] = true
|
||||
}
|
||||
port := u.tunnelPort()
|
||||
for _, r := range shared {
|
||||
if r.Port != port || !used[r.Proto] {
|
||||
u.revokeRule(r)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// allowForward opens the host ports a forward rule needs: its host port (bound
|
||||
// to the rule so it is revoked with it) plus the shared 8446 tunnel port for
|
||||
// direct tcp/udp modes (bound to 0 — shared across rules).
|
||||
// to the rule so it is revoked with it) plus the shared tunnel port for direct
|
||||
// tcp/udp modes (bound to 0 — shared across rules).
|
||||
func (u *ufwManager) allowForward(rule ForwardRule) {
|
||||
switch rule.Proto {
|
||||
case "udp":
|
||||
u.allow(rule.HostPort, "udp", rule.ID)
|
||||
if u.tunnelPort > 0 {
|
||||
u.allow(u.tunnelPort, "udp", 0)
|
||||
}
|
||||
case "tcp":
|
||||
u.allow(rule.HostPort, "tcp", rule.ID)
|
||||
if u.tunnelPort > 0 {
|
||||
u.allow(u.tunnelPort, "tcp", 0)
|
||||
}
|
||||
default: // http long-poll listener is TCP
|
||||
u.allow(rule.HostPort, "tcp", rule.ID)
|
||||
if !u.available() {
|
||||
return
|
||||
}
|
||||
u.allowForwardPorts(rule)
|
||||
}
|
||||
|
||||
func (u *ufwManager) allowForwardPorts(rule ForwardRule) {
|
||||
// "http" (long-poll) and "tcp" forwards both listen on TCP on the host.
|
||||
hostProto, direct := "tcp", rule.Proto == "tcp"
|
||||
if rule.Proto == "udp" {
|
||||
hostProto, direct = "udp", true
|
||||
}
|
||||
// A listener bound to loopback is not reachable from outside anyway.
|
||||
if ip := net.ParseIP(rule.ListenAddr); ip == nil || !ip.IsLoopback() {
|
||||
u.allow(rule.HostPort, hostProto, rule.ID)
|
||||
}
|
||||
if port := u.tunnelPort(); direct && port > 0 {
|
||||
u.allow(port, hostProto, 0)
|
||||
}
|
||||
}
|
||||
|
||||
// sync brings ufw in line with the saved forward rules: it opens what is
|
||||
// missing (rules made while ufw or the integration was off, or a tunnel port
|
||||
// that has since changed) and drops shared rules nothing uses.
|
||||
func (u *ufwManager) sync() {
|
||||
if !u.available() {
|
||||
return
|
||||
}
|
||||
forwards, err := u.db.ListForwardRules()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
for _, f := range forwards {
|
||||
u.allowForwardPorts(f)
|
||||
}
|
||||
u.pruneShared()
|
||||
}
|
||||
@@ -34,10 +34,24 @@ header.topbar nav a:hover { color: var(--text); }
|
||||
h1 { font-size: 22px; margin: 0 0 18px; }
|
||||
h2 { font-size: 17px; margin: 0 0 14px; }
|
||||
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;
|
||||
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. */
|
||||
select {
|
||||
-webkit-appearance: none; appearance: none; cursor: pointer;
|
||||
min-width: 180px; padding: 10px 38px 10px 14px; border-radius: 8px;
|
||||
border: 1px solid var(--border); color: var(--text);
|
||||
font: inherit; font-size: 14px; font-weight: 500; line-height: 1.5;
|
||||
background: #0c0f13 url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%238b95a5' stroke-width='3' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M6 9l6 6 6-6'/%3E%3C/svg%3E") no-repeat right 14px center;
|
||||
}
|
||||
select:hover, select:focus { border-color: var(--accent); outline: none; }
|
||||
select:disabled, button:disabled { opacity: .5; cursor: not-allowed; }
|
||||
button, .btn {
|
||||
display: inline-block; cursor: pointer; border: 0; border-radius: 8px;
|
||||
padding: 10px 16px; font-size: 14px; font-weight: 500;
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Изменить машину — LPRA</title>
|
||||
<link rel="stylesheet" href="/static/app.css">
|
||||
<link rel="stylesheet" href="/static/app.css?v={{.ServerVersion}}-2">
|
||||
</head>
|
||||
<body>
|
||||
<header class="topbar">
|
||||
@@ -28,6 +28,46 @@
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>Пользователь</h2>
|
||||
{{if .UserOK}}<div class="err" style="background:#1d3a24;border-color:#2b6b3a;color:#b3ffcb">{{.UserOK}}</div>{{end}}
|
||||
{{if .UserErr}}<div class="err">{{.UserErr}}</div>{{end}}
|
||||
<p class="muted">
|
||||
Клиент запущен от: <code>{{if .Users}}{{.Users.Current}}{{else if .Agent.RunUser}}{{.Agent.RunUser}}{{else}}неизвестно{{end}}</code>
|
||||
· Терминал открывается от: <code>{{.Agent.TerminalUser}}</code>
|
||||
</p>
|
||||
{{if .Users}}
|
||||
<form method="post" action="/agents/{{.Agent.ID}}/user" class="row">
|
||||
<input type="hidden" name="csrf" value="{{.CSRF}}">
|
||||
<select name="user" {{if not .Users.CanSwitch}}disabled{{end}}>
|
||||
{{range .Users.Users}}<option value="{{.}}" {{if eq . $.Agent.TerminalUser}}selected{{end}}>{{.}}</option>{{end}}
|
||||
</select>
|
||||
<button type="submit" {{if not .Users.CanSwitch}}disabled{{end}}>Сохранить</button>
|
||||
{{if .Users.CanSwitch}}<button type="button" class="btn ghost" onclick="document.getElementById('newuser').hidden = !document.getElementById('newuser').hidden">Создать нового</button>{{end}}
|
||||
</form>
|
||||
{{if .Users.CanSwitch}}
|
||||
<form id="newuser" method="post" action="/agents/{{.Agent.ID}}/users/add" style="max-width:360px; margin-top:16px" hidden>
|
||||
<input type="hidden" name="csrf" value="{{.CSRF}}">
|
||||
<label for="nu-name">Имя нового пользователя</label>
|
||||
<input type="text" id="nu-name" name="name" pattern="[a-z_][a-z0-9_\-]{0,31}" autocomplete="off" required>
|
||||
<label for="nu-pass">Пароль</label>
|
||||
<input type="password" id="nu-pass" name="password" autocomplete="new-password">
|
||||
<label style="color:var(--text); font-size:14px">
|
||||
<input type="checkbox" name="sudo" value="1"> Добавить в sudo
|
||||
</label>
|
||||
<p class="muted" style="font-size:13px">Без пароля вход по паролю (и sudo) для него работать не будет — только терминал из панели.</p>
|
||||
<button type="submit">Создать пользователя</button>
|
||||
</form>
|
||||
{{else}}
|
||||
<p class="muted" style="font-size:13px; margin-top:12px">Клиент запущен не от root, поэтому сменить или создать пользователя нельзя.</p>
|
||||
{{end}}
|
||||
{{else if .UsersErr}}
|
||||
<p class="muted" style="font-size:13px">Список пользователей недоступен: {{.UsersErr}}.</p>
|
||||
{{else}}
|
||||
<p class="muted" style="font-size:13px">Машина офлайн — сменить пользователя можно, когда она на связи.</p>
|
||||
{{end}}
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>Версия клиента</h2>
|
||||
{{if .Updated}}<div class="err" style="background:#1d3a24;border-color:#2b6b3a;color:#b3ffcb">Команда обновления отправлена. Клиент скачает свежий бинарник и перезапустится.</div>{{end}}
|
||||
|
||||
@@ -107,7 +107,7 @@
|
||||
}).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).
|
||||
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);
|
||||
} else {
|
||||
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;
|
||||
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 () => {
|
||||
const bytes = new TextEncoder().encode(ta.value);
|
||||
const w = await api('write', {path: join(cwd, e.name), data: bytesToB64(bytes)});
|
||||
|
||||
@@ -70,8 +70,8 @@
|
||||
<label>Тип</label>
|
||||
<select name="proto" id="proto">
|
||||
<option value="http">http (long-poll, работает везде)</option>
|
||||
<option value="tcp">tcp (прямой, низкая задержка, порт 8446)</option>
|
||||
<option value="udp">udp (прямой, порт 8446)</option>
|
||||
<option value="tcp">tcp (прямой, низкая задержка, порт {{.TunnelPort}})</option>
|
||||
<option value="udp">udp (прямой, порт {{.TunnelPort}})</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="fld">
|
||||
@@ -95,18 +95,18 @@
|
||||
<div class="card">
|
||||
<h2>Режимы</h2>
|
||||
<ul class="muted" style="font-size:14px; line-height:1.7">
|
||||
<li><b>http</b> — туннель поверх HTTP long-poll (через nginx :8445). Работает везде, где доступна панель, но задержка выше.</li>
|
||||
<li><b>tcp</b> — прямой TCP-мультиплексор через порт <b>8446</b> хоста. Минимальная задержка, keep-alive, TCP_NODELAY. Нужно открыть 8446/tcp на хосте.</li>
|
||||
<li><b>udp</b> — прямой UDP через порт <b>8446</b> хоста. Нужно открыть 8446/udp.</li>
|
||||
<li><b>http</b> — туннель поверх HTTP long-poll (через адрес панели, порт {{.PanelPort}}). Работает везде, где доступна панель, но задержка выше.</li>
|
||||
<li><b>tcp</b> — прямой TCP-мультиплексор через порт <b>{{.TunnelPort}}</b> хоста. Минимальная задержка, keep-alive, TCP_NODELAY. Нужно открыть {{.TunnelPort}}/tcp на хосте.</li>
|
||||
<li><b>udp</b> — прямой UDP через порт <b>{{.TunnelPort}}</b> хоста. Нужно открыть {{.TunnelPort}}/udp.</li>
|
||||
</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>
|
||||
<script>
|
||||
var notes = {
|
||||
http: 'Трафик идёт через :8445 (long-poll). Порт 8446 не требуется.',
|
||||
tcp: 'Требуется открытый порт 8446/tcp на хосте. Самая низкая задержка.',
|
||||
udp: 'Требуется открытый порт 8446/udp на хосте.'
|
||||
http: 'Трафик идёт через порт панели {{.PanelPort}} (long-poll). Порт {{.TunnelPort}} не требуется.',
|
||||
tcp: 'Требуется открытый порт {{.TunnelPort}}/tcp на хосте. Самая низкая задержка.',
|
||||
udp: 'Требуется открытый порт {{.TunnelPort}}/udp на хосте.'
|
||||
};
|
||||
var sel = document.getElementById('proto'), note = document.getElementById('note');
|
||||
function upd(){ note.textContent = notes[sel.value] || ''; }
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
</header>
|
||||
<div class="wrap">
|
||||
<h1>Настройки</h1>
|
||||
{{if .Error}}<div class="err">{{.Error}}</div>{{end}}
|
||||
|
||||
<div class="card">
|
||||
<h2>Установка на новую машину</h2>
|
||||
@@ -49,6 +50,26 @@
|
||||
</form>
|
||||
</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">
|
||||
<h2>Enroll-секрет</h2>
|
||||
<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}}
|
||||
· Интеграция: {{if .UfwEnabled}}<span style="color:var(--ok)">включена</span>{{else}}<span class="muted">выключена</span>{{end}}
|
||||
</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">
|
||||
<input type="hidden" name="csrf" value="{{.CSRF}}">
|
||||
<input type="hidden" name="enabled" value="{{if .UfwEnabled}}0{{else}}1{{end}}">
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
</head>
|
||||
<body>
|
||||
<header class="topbar">
|
||||
<div class="brand">LPRA · Терминал — {{.Agent.Name}} <span class="muted">({{.Agent.Hostname}})</span></div>
|
||||
<div class="brand">LPRA · Терминал — {{.Agent.Name}} <span class="muted">({{.Agent.TerminalUser}}@{{.Agent.Hostname}})</span></div>
|
||||
<nav>
|
||||
<a href="/">← Машины</a>
|
||||
<a href="/agents/{{.Agent.ID}}/files">Файлы</a>
|
||||
|
||||
+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