feat: initial commit
This commit is contained in:
1 parent
353f62e257
commit
c6852c6f1b
62 files changed
+5911
-2
No files matched your search
@@ -1,2 +1,48 @@
|
||||
# lpra
|
||||
Long polling remote access
|
||||
# LPRA — LongPolling Remote Access
|
||||
|
||||
Лёгкий self-hosted сервис для доступа к Linux-машинам **без белого IP** (за NAT).
|
||||
Агент на клиенте сам исходяще подключается к серверу по HTTP long-polling; админ
|
||||
в веб-панели видит список машин и открывает по каждой **root-терминал в браузере**.
|
||||
|
||||
```
|
||||
браузер ──WS──> [nginx :8445 TLS] ──> [Go server 127.0.0.1:8091] <──long-poll── агент (root, за NAT)
|
||||
│
|
||||
SQLite
|
||||
```
|
||||
|
||||
## Компоненты
|
||||
- **Сервер** (`cmd/server`) — админ-панель + точка встречи для агентов. За nginx (TLS).
|
||||
- **Агент** (`cmd/agent`) — Linux-бинарь, systemd-сервис от root, PTY по запросу.
|
||||
- **install.sh** — интерактивный установщик, отдаётся сервером как статика.
|
||||
|
||||
## Сборка
|
||||
```sh
|
||||
./build.sh
|
||||
```
|
||||
Получаются `bin/lpra-server` и `dist/agent-linux-{amd64,arm64}`.
|
||||
|
||||
## Запуск сервера
|
||||
```sh
|
||||
./bin/lpra-server -addr 127.0.0.1:8091 -dist ./dist -db ./lpra.db
|
||||
```
|
||||
Поставьте перед ним nginx по `deploy/nginx.conf.example` (TLS, long-poll/WS).
|
||||
Локально без TLS можно добавить `-insecure-cookies`.
|
||||
|
||||
Первый запуск: откройте `/setup` — мастер создаст администратора. Дальше `/settings`:
|
||||
адрес хоста (по умолчанию `https://REDACTED:8445`), enroll-секрет и готовая
|
||||
команда установки.
|
||||
|
||||
## Установка агента на клиент
|
||||
Одной командой (под root):
|
||||
```sh
|
||||
curl -fsSL https://REDACTED:8445/install.sh | sudo HOST=https://REDACTED:8445 ENROLL_SECRET=<секрет> sh
|
||||
```
|
||||
Или интерактивно (спросит секрет, а если агент уже стоит — предложит удалить):
|
||||
```sh
|
||||
curl -fsSL https://REDACTED:8445/install.sh | sudo sh
|
||||
```
|
||||
|
||||
## Безопасность
|
||||
- Пароли админов — bcrypt; сессии в HttpOnly/Secure/SameSite куках; CSRF на формах; rate-limit логина.
|
||||
- Агенты: общий enroll-секрет → индивидуальный токен (хранится хешем, сверка constant-time).
|
||||
- Открытие терминала пишется в таблицу `audit` (терминал даёт root-доступ).
|
||||
@@ -0,0 +1 @@
|
||||
0.0.1
|
||||
Executable
BIN
Binary file not shown.
Executable
BIN
Binary file not shown.
Executable
BIN
Binary file not shown.
Executable
BIN
Binary file not shown.
Executable
BIN
Binary file not shown.
@@ -0,0 +1,47 @@
|
||||
#!/bin/sh
|
||||
# Build the LPRA server (for the local host OS *and* Linux) and cross-compile
|
||||
# the Linux agent binaries served at /download/.
|
||||
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"
|
||||
|
||||
mkdir -p dist bin
|
||||
|
||||
echo ">> building server (local: $(go env GOOS)/$(go env GOARCH))"
|
||||
go build -trimpath -ldflags "$LDFLAGS" -o bin/lpra-server ./cmd/server
|
||||
|
||||
echo ">> building server linux/amd64"
|
||||
GOOS=linux GOARCH=amd64 CGO_ENABLED=0 go build -trimpath -ldflags "$LDFLAGS" \
|
||||
-o bin/lpra-server-linux-amd64 ./cmd/server
|
||||
|
||||
echo ">> building server linux/arm64"
|
||||
GOOS=linux GOARCH=arm64 CGO_ENABLED=0 go build -trimpath -ldflags "$LDFLAGS" \
|
||||
-o bin/lpra-server-linux-arm64 ./cmd/server
|
||||
|
||||
echo ">> building agent linux/amd64"
|
||||
GOOS=linux GOARCH=amd64 CGO_ENABLED=0 go build -trimpath -ldflags "$LDFLAGS" \
|
||||
-o dist/agent-linux-amd64 ./cmd/agent
|
||||
|
||||
echo ">> building agent linux/arm64"
|
||||
GOOS=linux GOARCH=arm64 CGO_ENABLED=0 go build -trimpath -ldflags "$LDFLAGS" \
|
||||
-o dist/agent-linux-arm64 ./cmd/agent
|
||||
|
||||
echo ">> building agent darwin/arm64 + amd64"
|
||||
GOOS=darwin GOARCH=arm64 CGO_ENABLED=0 go build -trimpath -ldflags "$LDFLAGS" \
|
||||
-o bin/agent-darwin-arm64 ./cmd/agent
|
||||
GOOS=darwin GOARCH=amd64 CGO_ENABLED=0 go build -trimpath -ldflags "$LDFLAGS" \
|
||||
-o bin/agent-darwin-amd64 ./cmd/agent
|
||||
|
||||
echo
|
||||
echo "Done."
|
||||
echo " server (local): bin/lpra-server"
|
||||
echo " server (Linux): bin/lpra-server-linux-amd64, bin/lpra-server-linux-arm64"
|
||||
echo " agents (Linux): dist/agent-linux-amd64, dist/agent-linux-arm64"
|
||||
echo
|
||||
echo "On the Linux host copy the matching server binary to /opt/lpra/lpra-server,"
|
||||
echo "e.g.: cp bin/lpra-server-linux-amd64 /opt/lpra/lpra-server"
|
||||
Vendored
BIN
Binary file not shown.
@@ -0,0 +1,64 @@
|
||||
// Command agent is the Linux client installed on machines without a public IP.
|
||||
// It runs as a root systemd service, dials the server over HTTP long-polling,
|
||||
// and opens a root PTY when an admin requests a terminal in the web panel.
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
"net"
|
||||
"net/url"
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
|
||||
"github.com/x-grk/longpolling-remote-access/internal/agent"
|
||||
)
|
||||
|
||||
func main() {
|
||||
host := envOr("HOST", "https://REDACTED:8445")
|
||||
cfg := agent.Config{
|
||||
Host: host,
|
||||
EnrollSecret: os.Getenv("ENROLL_SECRET"),
|
||||
Name: os.Getenv("AGENT_NAME"),
|
||||
StatePath: envOr("STATE_PATH", "/etc/lpagent/state.json"),
|
||||
TunnelAddr: tunnelAddr(host),
|
||||
}
|
||||
if cfg.EnrollSecret == "" {
|
||||
// Only required for the very first run (before enrollment).
|
||||
if _, err := os.Stat(cfg.StatePath); os.IsNotExist(err) {
|
||||
log.Fatal("ENROLL_SECRET is required for first enrollment")
|
||||
}
|
||||
}
|
||||
|
||||
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
|
||||
defer stop()
|
||||
|
||||
if err := agent.New(cfg).Run(ctx); err != nil {
|
||||
log.Fatalf("agent: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func envOr(key, def string) string {
|
||||
if v := os.Getenv(key); v != "" {
|
||||
return v
|
||||
}
|
||||
return def
|
||||
}
|
||||
|
||||
// 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.
|
||||
func tunnelAddr(hostURL string) string {
|
||||
if v := os.Getenv("TUNNEL_ADDR"); v != "" {
|
||||
if v == "off" {
|
||||
return ""
|
||||
}
|
||||
return v
|
||||
}
|
||||
u, err := url.Parse(hostURL)
|
||||
if err != nil || u.Hostname() == "" {
|
||||
return ""
|
||||
}
|
||||
return net.JoinHostPort(u.Hostname(), envOr("TUNNEL_PORT", "8446"))
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
// Command server is the host: it serves the admin web panel and acts as the
|
||||
// rendezvous point that NAT'd agents dial into over HTTP long-polling.
|
||||
//
|
||||
// TLS is terminated by nginx in front of this process, so it listens on plain
|
||||
// HTTP on localhost by default (see deploy/nginx.conf.example).
|
||||
package main
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"github.com/x-grk/longpolling-remote-access/internal/server"
|
||||
)
|
||||
|
||||
func main() {
|
||||
addr := flag.String("addr", envOr("LPRA_ADDR", "127.0.0.1:8091"), "listen address (behind nginx)")
|
||||
dbPath := flag.String("db", envOr("LPRA_DB", "lpra.db"), "path to SQLite database")
|
||||
distDir := flag.String("dist", envOr("LPRA_DIST", "dist"), "directory with agent binaries served at /download/")
|
||||
tunnelAddr := flag.String("tunnel", envOr("LPRA_TUNNEL_ADDR", ":8446"), "listen address for the direct TCP+UDP forward tunnel (empty disables)")
|
||||
insecureCookies := flag.Bool("insecure-cookies", envOr("LPRA_INSECURE_COOKIES", "") == "1",
|
||||
"do not mark cookies Secure (plain-HTTP local testing only)")
|
||||
flag.Parse()
|
||||
|
||||
db, err := server.OpenDB(*dbPath)
|
||||
if err != nil {
|
||||
log.Fatalf("open db: %v", err)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
srv, err := server.New(server.Options{
|
||||
DB: db,
|
||||
DistDir: *distDir,
|
||||
SecureCookies: !*insecureCookies,
|
||||
TunnelAddr: *tunnelAddr,
|
||||
})
|
||||
if err != nil {
|
||||
log.Fatalf("init server: %v", err)
|
||||
}
|
||||
|
||||
httpSrv := &http.Server{
|
||||
Addr: *addr,
|
||||
Handler: srv.Handler(),
|
||||
// No ReadTimeout/WriteTimeout: long-poll connections are intentionally
|
||||
// long-lived. nginx bounds them instead.
|
||||
ReadHeaderTimeout: 15 * time.Second,
|
||||
IdleTimeout: 130 * time.Second,
|
||||
}
|
||||
log.Printf("lpra server listening on %s (db=%s dist=%s)", *addr, *dbPath, *distDir)
|
||||
if err := httpSrv.ListenAndServe(); err != nil {
|
||||
log.Fatalf("serve: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func envOr(key, def string) string {
|
||||
if v := os.Getenv(key); v != "" {
|
||||
return v
|
||||
}
|
||||
return def
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
# 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.
|
||||
#
|
||||
# Install:
|
||||
# sudo useradd --system --home /opt/lpra --shell /usr/sbin/nologin lpra
|
||||
# sudo mkdir -p /opt/lpra /var/lib/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.
|
||||
|
||||
[Unit]
|
||||
Description=LPRA remote access host (admin panel + agent rendezvous)
|
||||
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 \
|
||||
-dist /opt/lpra/dist
|
||||
|
||||
Restart=always
|
||||
RestartSec=3
|
||||
|
||||
# --- hardening ---
|
||||
NoNewPrivileges=true
|
||||
ProtectSystem=strict
|
||||
ProtectHome=true
|
||||
PrivateTmp=true
|
||||
PrivateDevices=true
|
||||
ProtectKernelTunables=true
|
||||
ProtectControlGroups=true
|
||||
RestrictSUIDSGID=true
|
||||
# The server only needs to write its SQLite database here.
|
||||
ReadWritePaths=/var/lib/lpra
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -0,0 +1,42 @@
|
||||
# nginx reverse proxy for the LPRA server.
|
||||
# TLS is terminated here; the Go server listens on 127.0.0.1:8091.
|
||||
#
|
||||
# Put this in /etc/nginx/sites-available/lp_agent and symlink it into
|
||||
# sites-enabled, then: sudo nginx -t && sudo systemctl reload nginx
|
||||
#
|
||||
# Everything (agent + browser terminal) is plain HTTP long-polling, so NO
|
||||
# WebSocket config is needed. The two settings that matter:
|
||||
# - proxy_buffering off -> deliver long-poll responses immediately
|
||||
# - proxy_read_timeout 120s -> longer than the 25s long-poll hold
|
||||
#
|
||||
# NOTE: proxy_pass MUST start with "http://". A bare "proxy_pass 127.0.0.1:8091;"
|
||||
# causes nginx error: [emerg] invalid URL prefix.
|
||||
|
||||
server {
|
||||
listen 8445 ssl;
|
||||
server_name REDACTED;
|
||||
|
||||
ssl_certificate /etc/letsencrypt/live/REDACTED/fullchain.pem; # managed by Certbot
|
||||
ssl_certificate_key /etc/letsencrypt/live/REDACTED/privkey.pem; # managed by Certbot
|
||||
include /etc/letsencrypt/options-ssl-nginx.conf; # managed by Certbot
|
||||
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem; # managed by Certbot
|
||||
|
||||
# Agent binaries can be large; allow the download.
|
||||
client_max_body_size 64m;
|
||||
|
||||
location / {
|
||||
proxy_pass http://127.0.0.1:8091;
|
||||
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
|
||||
# Long-poll behaviour: no buffering, generous read timeout.
|
||||
proxy_buffering off;
|
||||
proxy_cache off;
|
||||
proxy_read_timeout 120s;
|
||||
proxy_send_timeout 120s;
|
||||
}
|
||||
}
|
||||
BIN
Binary file not shown.
BIN
Binary file not shown.
@@ -0,0 +1,20 @@
|
||||
module github.com/x-grk/longpolling-remote-access
|
||||
|
||||
go 1.26
|
||||
|
||||
require (
|
||||
github.com/creack/pty v1.1.21
|
||||
golang.org/x/crypto v0.31.0
|
||||
modernc.org/sqlite v1.24.0
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/mattn/go-isatty v0.0.16 // indirect
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||
golang.org/x/sys v0.28.0 // indirect
|
||||
modernc.org/libc v1.22.5 // indirect
|
||||
modernc.org/mathutil v1.5.0 // indirect
|
||||
modernc.org/memory v1.5.0 // indirect
|
||||
)
|
||||
@@ -0,0 +1,28 @@
|
||||
github.com/creack/pty v1.1.21 h1:1/QdRyBaHHJP61QkWMXlOIBfsgdDeeKfK8SYVUWJKf0=
|
||||
github.com/creack/pty v1.1.21/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr4O4=
|
||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
|
||||
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||
github.com/mattn/go-isatty v0.0.16 h1:bq3VjFmv/sOjHtdEhmkEV4x1AJtvUvOJ2PFAZ5+peKQ=
|
||||
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||
golang.org/x/crypto v0.28.0 h1:GBDwsMXVQi34v5CCYUm2jkJvu4cbtru2U4TN2PSyQnw=
|
||||
golang.org/x/crypto v0.28.0/go.mod h1:rmgy+3RHxRZMyY0jjAJShp2zgEdOqj2AO7U0pYmeQ7U=
|
||||
golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U=
|
||||
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
|
||||
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA=
|
||||
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
modernc.org/libc v1.22.5 h1:91BNch/e5B0uPbJFgqbxXuOnxBQjlS//icfQEGmvyjE=
|
||||
modernc.org/libc v1.22.5/go.mod h1:jj+Z7dTNX8fBScMVNRAYZ/jF91K8fdT2hYMThc3YjBY=
|
||||
modernc.org/mathutil v1.5.0 h1:rV0Ko/6SfM+8G+yKiyI830l3Wuz1zRutdslNoQ0kfiQ=
|
||||
modernc.org/mathutil v1.5.0/go.mod h1:mZW8CKdRPY1v87qxC/wUdX5O1qDzXMP5TH3wjfpga6E=
|
||||
modernc.org/memory v1.5.0 h1:N+/8c5rE6EqugZwHii4IFsaJ7MUhoWX07J5tC/iI5Ds=
|
||||
modernc.org/memory v1.5.0/go.mod h1:PkUhL0Mugw21sHPeskwZW4D6VscE/GQJOnIpCnW6pSU=
|
||||
modernc.org/sqlite v1.24.0 h1:EsClRIWHGhLTCX44p+Ri/JLD+vFGo0QGjasg2/F9TlI=
|
||||
modernc.org/sqlite v1.24.0/go.mod h1:OrDj17Mggn6MhE+iPbBNf7RGKODDE9NFT0f3EwDzJqk=
|
||||
Vendored
BIN
Binary file not shown.
@@ -0,0 +1,207 @@
|
||||
// Package agent implements the Linux client that dials home to the server over
|
||||
// HTTP long-polling and exposes a root PTY on demand.
|
||||
package agent
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"time"
|
||||
|
||||
"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"
|
||||
|
||||
// Config is the agent's runtime configuration (from env / config.env).
|
||||
type Config struct {
|
||||
Host string // e.g. https://REDACTED:8445
|
||||
EnrollSecret string
|
||||
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
|
||||
}
|
||||
|
||||
type state struct {
|
||||
AgentID string `json:"agent_id"`
|
||||
Token string `json:"token"`
|
||||
}
|
||||
|
||||
// Agent is a running client.
|
||||
type Agent struct {
|
||||
cfg Config
|
||||
http *http.Client
|
||||
state state
|
||||
pollCount int
|
||||
}
|
||||
|
||||
// New builds an agent. It does not perform any network I/O.
|
||||
func New(cfg Config) *Agent {
|
||||
// Tuned transport for low-latency forwarding: keep-alive connection reuse
|
||||
// (no TCP/TLS handshake per chunk) and a generous idle pool so many
|
||||
// concurrent streams don't fight over 2 connections. Plain HTTP/1.1 keeps
|
||||
// each stream on its own connection for predictable parallelism.
|
||||
tr := &http.Transport{
|
||||
Proxy: http.ProxyFromEnvironment,
|
||||
DialContext: (&net.Dialer{
|
||||
Timeout: 10 * time.Second,
|
||||
KeepAlive: 30 * time.Second,
|
||||
}).DialContext,
|
||||
ForceAttemptHTTP2: false,
|
||||
TLSNextProto: map[string]func(string, *tls.Conn) http.RoundTripper{},
|
||||
MaxIdleConns: 100,
|
||||
MaxIdleConnsPerHost: 32,
|
||||
IdleConnTimeout: 90 * time.Second,
|
||||
TLSHandshakeTimeout: 10 * time.Second,
|
||||
ExpectContinueTimeout: time.Second,
|
||||
}
|
||||
return &Agent{
|
||||
cfg: cfg,
|
||||
// Timeout must exceed the server's long-poll hold (25s) plus slack.
|
||||
http: &http.Client{Timeout: 60 * time.Second, Transport: tr},
|
||||
}
|
||||
}
|
||||
|
||||
// Run enrolls if needed and then loops the control poll forever, reconnecting
|
||||
// on error with a short backoff. It returns only on a fatal config error.
|
||||
func (a *Agent) Run(ctx context.Context) error {
|
||||
if err := a.loadState(); err != nil {
|
||||
return err
|
||||
}
|
||||
if a.state.AgentID == "" || a.state.Token == "" {
|
||||
if err := a.enroll(ctx); err != nil {
|
||||
return fmt.Errorf("enroll: %w", err)
|
||||
}
|
||||
}
|
||||
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)
|
||||
}
|
||||
|
||||
backoff := time.Second
|
||||
for ctx.Err() == nil {
|
||||
if err := a.pollOnce(ctx); err != nil {
|
||||
log.Printf("poll: %v (retry in %s)", err, backoff)
|
||||
select {
|
||||
case <-time.After(backoff):
|
||||
case <-ctx.Done():
|
||||
return nil
|
||||
}
|
||||
if backoff < 15*time.Second {
|
||||
backoff *= 2
|
||||
}
|
||||
continue
|
||||
}
|
||||
backoff = time.Second
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *Agent) enroll(ctx context.Context) error {
|
||||
host, _ := os.Hostname()
|
||||
body, _ := json.Marshal(proto.EnrollRequest{
|
||||
Name: a.cfg.Name,
|
||||
Hostname: host,
|
||||
OS: runtime.GOOS,
|
||||
Arch: runtime.GOARCH,
|
||||
Version: Version,
|
||||
})
|
||||
req, _ := http.NewRequestWithContext(ctx, http.MethodPost, a.cfg.Host+"/api/agent/enroll", bytes.NewReader(body))
|
||||
req.Header.Set("X-Enroll-Secret", a.cfg.EnrollSecret)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp, err := a.http.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
b, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
|
||||
return fmt.Errorf("server said %s: %s", resp.Status, bytes.TrimSpace(b))
|
||||
}
|
||||
var er proto.EnrollResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&er); err != nil {
|
||||
return err
|
||||
}
|
||||
a.state = state{AgentID: er.AgentID, Token: er.Token}
|
||||
return a.saveState()
|
||||
}
|
||||
|
||||
// pollOnce performs one control long-poll and dispatches any instruction.
|
||||
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.
|
||||
a.pollCount++
|
||||
if a.pollCount%20 == 1 {
|
||||
req.Header.Set("X-Agent-Version", Version)
|
||||
}
|
||||
resp, err := a.http.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode == http.StatusUnauthorized {
|
||||
return fmt.Errorf("unauthorized (token rejected)")
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("status %s", resp.Status)
|
||||
}
|
||||
var msg proto.ControlMessage
|
||||
if err := json.NewDecoder(resp.Body).Decode(&msg); err != nil {
|
||||
return err
|
||||
}
|
||||
// Dispatch in the background so we keep polling for more work.
|
||||
switch msg.Type {
|
||||
case proto.CtrlOpenTerminal:
|
||||
go a.runTerminal(ctx, msg.SessionID)
|
||||
case proto.CtrlOpenForward:
|
||||
go a.runForward(ctx, msg.SessionID, msg.Port)
|
||||
case proto.CtrlTask:
|
||||
go a.runTask(ctx, msg.TaskID)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *Agent) authHeaders(req *http.Request) {
|
||||
req.Header.Set("X-Agent-Id", a.state.AgentID)
|
||||
req.Header.Set("Authorization", "Bearer "+a.state.Token)
|
||||
}
|
||||
|
||||
// --- state persistence ---
|
||||
|
||||
func (a *Agent) loadState() error {
|
||||
b, err := os.ReadFile(a.cfg.StatePath)
|
||||
if os.IsNotExist(err) {
|
||||
return nil
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return json.Unmarshal(b, &a.state)
|
||||
}
|
||||
|
||||
func (a *Agent) saveState() error {
|
||||
if err := os.MkdirAll(filepath.Dir(a.cfg.StatePath), 0o700); err != nil {
|
||||
return err
|
||||
}
|
||||
b, _ := json.MarshalIndent(a.state, "", " ")
|
||||
return os.WriteFile(a.cfg.StatePath, b, 0o600)
|
||||
}
|
||||
|
||||
var b64 = base64.StdEncoding
|
||||
@@ -0,0 +1,224 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
|
||||
"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
|
||||
|
||||
// fsDownloadCap bounds a browser download (kept under the 64 MiB result/body
|
||||
// limit once base64-encoded).
|
||||
const fsDownloadCap = 40 * 1024 * 1024
|
||||
|
||||
// dispatchFS runs one native file-manager operation.
|
||||
func dispatchFS(env proto.TaskEnvelope) (any, error) {
|
||||
switch env.Type {
|
||||
case proto.TaskFSList:
|
||||
var a proto.FSPathArgs
|
||||
_ = json.Unmarshal(env.Args, &a)
|
||||
return fsList(a.Path)
|
||||
case proto.TaskFSRead:
|
||||
var a proto.FSPathArgs
|
||||
_ = json.Unmarshal(env.Args, &a)
|
||||
return fsReadCapped(a.Path, fsReadCap)
|
||||
case proto.TaskFSDownload:
|
||||
var a proto.FSPathArgs
|
||||
_ = json.Unmarshal(env.Args, &a)
|
||||
return fsReadCapped(a.Path, fsDownloadCap)
|
||||
case proto.TaskFSWrite:
|
||||
var a proto.FSWriteArgs
|
||||
_ = json.Unmarshal(env.Args, &a)
|
||||
return fsWrite(a.Path, a.Data)
|
||||
case proto.TaskFSMkdir:
|
||||
var a proto.FSPathArgs
|
||||
_ = json.Unmarshal(env.Args, &a)
|
||||
return map[string]string{"path": a.Path}, os.MkdirAll(a.Path, 0o755)
|
||||
case proto.TaskFSDelete:
|
||||
var a proto.FSPathArgs
|
||||
_ = json.Unmarshal(env.Args, &a)
|
||||
return map[string]string{"path": a.Path}, os.Remove(a.Path)
|
||||
case proto.TaskFSDelTree:
|
||||
var a proto.FSPathArgs
|
||||
_ = json.Unmarshal(env.Args, &a)
|
||||
if a.Path == "" || a.Path == "/" {
|
||||
return nil, errors.New("отказ: удаление корня запрещено")
|
||||
}
|
||||
return map[string]string{"path": a.Path}, os.RemoveAll(a.Path)
|
||||
case proto.TaskFSMove:
|
||||
var a proto.FSMoveArgs
|
||||
_ = json.Unmarshal(env.Args, &a)
|
||||
return map[string]string{"src": a.Src, "dst": a.Dst}, os.Rename(a.Src, a.Dst)
|
||||
case proto.TaskFSUnzip:
|
||||
var a proto.FSUnzipArgs
|
||||
_ = json.Unmarshal(env.Args, &a)
|
||||
return fsUnzip(a.Path, a.Dest)
|
||||
case proto.TaskFSZip:
|
||||
var a proto.FSZipArgs
|
||||
_ = json.Unmarshal(env.Args, &a)
|
||||
return fsZip(a.Paths, a.Dest)
|
||||
default:
|
||||
return nil, fmt.Errorf("неизвестная операция %q", env.Type)
|
||||
}
|
||||
}
|
||||
|
||||
func fsList(path string) (proto.FSListResult, error) {
|
||||
if path == "" {
|
||||
path = "/"
|
||||
}
|
||||
abs, _ := filepath.Abs(path)
|
||||
entries, err := os.ReadDir(abs)
|
||||
if err != nil {
|
||||
return proto.FSListResult{}, err
|
||||
}
|
||||
out := make([]proto.FSEntry, 0, len(entries))
|
||||
for _, e := range entries {
|
||||
info, err := e.Info()
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
out = append(out, proto.FSEntry{
|
||||
Name: e.Name(),
|
||||
IsDir: e.IsDir(),
|
||||
Size: info.Size(),
|
||||
Mode: info.Mode().String(),
|
||||
MTime: info.ModTime().Unix(),
|
||||
})
|
||||
}
|
||||
// Directories first, then alphabetical.
|
||||
sort.Slice(out, func(i, j int) bool {
|
||||
if out[i].IsDir != out[j].IsDir {
|
||||
return out[i].IsDir
|
||||
}
|
||||
return strings.ToLower(out[i].Name) < strings.ToLower(out[j].Name)
|
||||
})
|
||||
return proto.FSListResult{Path: abs, Entries: out}, nil
|
||||
}
|
||||
|
||||
func fsReadCapped(path string, cap int) (proto.FSReadResult, error) {
|
||||
info, err := os.Stat(path)
|
||||
if err != nil {
|
||||
return proto.FSReadResult{}, err
|
||||
}
|
||||
if info.IsDir() {
|
||||
return proto.FSReadResult{}, errors.New("это каталог")
|
||||
}
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return proto.FSReadResult{}, err
|
||||
}
|
||||
defer f.Close()
|
||||
buf := make([]byte, cap+1)
|
||||
n, _ := io.ReadFull(f, buf)
|
||||
truncated := n > cap
|
||||
if truncated {
|
||||
n = cap
|
||||
}
|
||||
data := buf[:n]
|
||||
binary := !utf8.Valid(data) || containsNUL(data)
|
||||
return proto.FSReadResult{
|
||||
Path: path,
|
||||
Size: info.Size(),
|
||||
Content: base64.StdEncoding.EncodeToString(data),
|
||||
Truncated: truncated,
|
||||
Binary: binary,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func containsNUL(b []byte) bool {
|
||||
for _, c := range b {
|
||||
if c == 0 {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func fsWrite(path, dataB64 string) (any, error) {
|
||||
data, err := base64.StdEncoding.DecodeString(dataB64)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := os.WriteFile(path, data, 0o644); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return map[string]any{"path": path, "size": len(data)}, nil
|
||||
}
|
||||
|
||||
func fsUnzip(path, dest string) (any, error) {
|
||||
if dest == "" {
|
||||
dest = filepath.Dir(path)
|
||||
}
|
||||
bin, err := exec.LookPath("unzip")
|
||||
if err != nil {
|
||||
return nil, errors.New("unzip не установлен на клиенте")
|
||||
}
|
||||
out, err := exec.Command(bin, "-o", path, "-d", dest).CombinedOutput()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("unzip: %v: %s", err, strings.TrimSpace(string(out)))
|
||||
}
|
||||
return map[string]string{"dest": dest}, nil
|
||||
}
|
||||
|
||||
// fsZip creates dest as a zip archive of the given paths (files and dirs).
|
||||
func fsZip(paths []string, dest string) (any, error) {
|
||||
if dest == "" {
|
||||
return nil, errors.New("не указан путь архива")
|
||||
}
|
||||
if len(paths) == 0 {
|
||||
return nil, errors.New("не выбраны файлы")
|
||||
}
|
||||
f, err := os.Create(dest)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer f.Close()
|
||||
zw := zip.NewWriter(f)
|
||||
defer zw.Close()
|
||||
|
||||
for _, p := range paths {
|
||||
parent := filepath.Dir(p)
|
||||
err := filepath.Walk(p, func(fp string, info os.FileInfo, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rel, err := filepath.Rel(parent, fp)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rel = filepath.ToSlash(rel)
|
||||
if info.IsDir() {
|
||||
_, err := zw.Create(rel + "/")
|
||||
return err
|
||||
}
|
||||
w, err := zw.Create(rel)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
src, err := os.Open(fp)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer src.Close()
|
||||
_, err = io.Copy(w, src)
|
||||
return err
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return map[string]string{"dest": dest}, nil
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"log"
|
||||
"net"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/x-grk/longpolling-remote-access/internal/proto"
|
||||
)
|
||||
|
||||
// runTerminal spawns a PTY and bridges it to the session.
|
||||
func (a *Agent) runTerminal(parent context.Context, sid string) {
|
||||
term, err := StartPTY()
|
||||
if err != nil {
|
||||
log.Printf("term %s: start pty: %v", sid, err)
|
||||
a.postOut(context.Background(), sid, proto.OutMessage{Exit: true})
|
||||
return
|
||||
}
|
||||
log.Printf("term %s: shell started", sid)
|
||||
a.bridgeStream(parent, sid, term, term.Resize)
|
||||
log.Printf("term %s: ended", sid)
|
||||
}
|
||||
|
||||
// runForward dials 127.0.0.1:port on the client and bridges it to the session,
|
||||
// giving the host a reverse TCP tunnel over the same long-poll transport.
|
||||
func (a *Agent) runForward(parent context.Context, sid string, port int) {
|
||||
d := net.Dialer{Timeout: 10 * time.Second}
|
||||
conn, err := d.DialContext(parent, "tcp", net.JoinHostPort("127.0.0.1", strconv.Itoa(port)))
|
||||
if err != nil {
|
||||
log.Printf("forward %s: dial :%d: %v", sid, port, err)
|
||||
a.postOut(context.Background(), sid, proto.OutMessage{Exit: true})
|
||||
return
|
||||
}
|
||||
// TCP_NODELAY (default on in Go) keeps latency low for small packets.
|
||||
if tc, ok := conn.(*net.TCPConn); ok {
|
||||
_ = tc.SetNoDelay(true)
|
||||
}
|
||||
log.Printf("forward %s: connected to :%d", sid, port)
|
||||
a.bridgeStream(parent, sid, conn, nil)
|
||||
}
|
||||
|
||||
// bridgeStream wires a local byte stream (PTY or socket) to a hub session:
|
||||
// server->agent frames are written to rw, and rw output is streamed back.
|
||||
func (a *Agent) bridgeStream(parent context.Context, sid string, rw io.ReadWriteCloser, onResize func(cols, rows uint16)) {
|
||||
ctx, cancel := context.WithCancel(parent)
|
||||
defer cancel()
|
||||
defer rw.Close()
|
||||
|
||||
// Downstream: pull input frames and write them to the stream.
|
||||
go func() {
|
||||
defer cancel()
|
||||
for ctx.Err() == nil {
|
||||
if !a.pollIn(ctx, sid, rw, onResize) {
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
// Upstream: stream output until the stream (or session) ends.
|
||||
buf := make([]byte, 32*1024)
|
||||
for {
|
||||
n, err := rw.Read(buf)
|
||||
if n > 0 {
|
||||
if !a.postOut(ctx, sid, proto.OutMessage{Data: b64.EncodeToString(buf[:n])}) {
|
||||
return
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
a.postOut(ctx, sid, proto.OutMessage{Exit: true})
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// pollIn performs one downstream long-poll and applies the frames.
|
||||
// Returns false when the session is gone and the loop should stop.
|
||||
func (a *Agent) pollIn(ctx context.Context, sid string, w io.Writer, onResize func(cols, rows uint16)) bool {
|
||||
req, _ := http.NewRequestWithContext(ctx, http.MethodGet, a.cfg.Host+"/api/agent/term/"+sid+"/in", nil)
|
||||
a.authHeaders(req)
|
||||
resp, err := a.http.Do(req)
|
||||
if err != nil {
|
||||
return ctx.Err() == nil
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode == http.StatusGone || resp.StatusCode == http.StatusUnauthorized {
|
||||
return false
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return true
|
||||
}
|
||||
var batch proto.InBatch
|
||||
if err := json.NewDecoder(resp.Body).Decode(&batch); err != nil {
|
||||
return true
|
||||
}
|
||||
for _, f := range batch.Frames {
|
||||
switch f.Type {
|
||||
case proto.FrameStdin:
|
||||
if data, err := b64.DecodeString(f.Data); err == nil {
|
||||
_, _ = w.Write(data)
|
||||
}
|
||||
case proto.FrameResize:
|
||||
if onResize != nil {
|
||||
onResize(f.Cols, f.Rows)
|
||||
}
|
||||
case proto.FrameClose:
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// postOut sends one upstream message. Returns false if the session is gone.
|
||||
func (a *Agent) postOut(ctx context.Context, sid string, msg proto.OutMessage) bool {
|
||||
body, _ := json.Marshal(msg)
|
||||
req, _ := http.NewRequestWithContext(ctx, http.MethodPost, a.cfg.Host+"/api/agent/term/"+sid+"/out", bytes.NewReader(body))
|
||||
a.authHeaders(req)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp, err := a.http.Do(req)
|
||||
if err != nil {
|
||||
return ctx.Err() == nil
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
_, _ = io.Copy(io.Discard, resp.Body) // drain so the connection is reused
|
||||
return resp.StatusCode == http.StatusNoContent
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
|
||||
"github.com/x-grk/longpolling-remote-access/internal/proto"
|
||||
)
|
||||
|
||||
// runTask fetches one task, executes it, and posts the result back.
|
||||
func (a *Agent) runTask(ctx context.Context, taskID string) {
|
||||
env, err := a.fetchTask(ctx, taskID)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
var res proto.TaskResult
|
||||
if env.Type == proto.TaskSysUpdate {
|
||||
res = a.doUpdate()
|
||||
} else {
|
||||
res = a.execTask(env)
|
||||
}
|
||||
a.postResult(ctx, taskID, res)
|
||||
}
|
||||
|
||||
func (a *Agent) fetchTask(ctx context.Context, taskID string) (proto.TaskEnvelope, error) {
|
||||
var env proto.TaskEnvelope
|
||||
req, _ := http.NewRequestWithContext(ctx, http.MethodGet, a.cfg.Host+"/api/agent/task/"+taskID, nil)
|
||||
a.authHeaders(req)
|
||||
resp, err := a.http.Do(req)
|
||||
if err != nil {
|
||||
return env, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return env, errStatus(resp.StatusCode)
|
||||
}
|
||||
err = json.NewDecoder(resp.Body).Decode(&env)
|
||||
return env, err
|
||||
}
|
||||
|
||||
func (a *Agent) postResult(ctx context.Context, taskID string, res proto.TaskResult) {
|
||||
body, _ := json.Marshal(res)
|
||||
req, _ := http.NewRequestWithContext(ctx, http.MethodPost, a.cfg.Host+"/api/agent/task/"+taskID+"/result", bytes.NewReader(body))
|
||||
a.authHeaders(req)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp, err := a.http.Do(req)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
io.Copy(io.Discard, resp.Body)
|
||||
resp.Body.Close()
|
||||
}
|
||||
|
||||
// execTask runs a single file-manager operation natively and returns a result.
|
||||
func (a *Agent) execTask(env proto.TaskEnvelope) proto.TaskResult {
|
||||
data, err := dispatchFS(env)
|
||||
if err != nil {
|
||||
return proto.TaskResult{Error: err.Error()}
|
||||
}
|
||||
raw, err := json.Marshal(data)
|
||||
if err != nil {
|
||||
return proto.TaskResult{Error: err.Error()}
|
||||
}
|
||||
return proto.TaskResult{OK: true, Data: raw}
|
||||
}
|
||||
|
||||
type statusErr int
|
||||
|
||||
func (e statusErr) Error() string { return "server status " + itoa(int(e)) }
|
||||
func errStatus(code int) error { return statusErr(code) }
|
||||
|
||||
func itoa(n int) string { return itoa64(int64(n)) }
|
||||
|
||||
func itoa64(n int64) string {
|
||||
if n == 0 {
|
||||
return "0"
|
||||
}
|
||||
neg := n < 0
|
||||
if neg {
|
||||
n = -n
|
||||
}
|
||||
var b [24]byte
|
||||
i := len(b)
|
||||
for n > 0 {
|
||||
i--
|
||||
b[i] = byte('0' + n%10)
|
||||
n /= 10
|
||||
}
|
||||
if neg {
|
||||
i--
|
||||
b[i] = '-'
|
||||
}
|
||||
return string(b[i:])
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"os"
|
||||
"os/exec"
|
||||
|
||||
"github.com/creack/pty"
|
||||
)
|
||||
|
||||
// PTY is a pseudo-terminal running an interactive login shell.
|
||||
type PTY struct {
|
||||
cmd *exec.Cmd
|
||||
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"
|
||||
}
|
||||
cmd := exec.Command(shell, "-l")
|
||||
cmd.Env = append(os.Environ(), "TERM=xterm-256color")
|
||||
f, err := pty.Start(cmd)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &PTY{cmd: cmd, f: f}, nil
|
||||
}
|
||||
|
||||
// Read reads terminal output.
|
||||
func (p *PTY) Read(b []byte) (int, error) { return p.f.Read(b) }
|
||||
|
||||
// Write writes keystrokes to the terminal.
|
||||
func (p *PTY) Write(b []byte) (int, error) { return p.f.Write(b) }
|
||||
|
||||
// Resize changes the terminal window size.
|
||||
func (p *PTY) Resize(cols, rows uint16) {
|
||||
if cols == 0 || rows == 0 {
|
||||
return
|
||||
}
|
||||
_ = pty.Setsize(p.f, &pty.Winsize{Rows: rows, Cols: cols})
|
||||
}
|
||||
|
||||
// Close terminates the shell and releases the PTY.
|
||||
func (p *PTY) Close() error {
|
||||
err := p.f.Close()
|
||||
if p.cmd.Process != nil {
|
||||
_ = p.cmd.Process.Kill()
|
||||
}
|
||||
_ = p.cmd.Wait()
|
||||
return err
|
||||
}
|
||||
@@ -0,0 +1,374 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/binary"
|
||||
"fmt"
|
||||
"log"
|
||||
"net"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/x-grk/longpolling-remote-access/internal/tunnel"
|
||||
)
|
||||
|
||||
// --- 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) {
|
||||
backoff := time.Second
|
||||
for ctx.Err() == nil {
|
||||
if err := a.tunnelOnce(ctx); err != nil {
|
||||
select {
|
||||
case <-time.After(backoff):
|
||||
case <-ctx.Done():
|
||||
return
|
||||
}
|
||||
if backoff < 15*time.Second {
|
||||
backoff *= 2
|
||||
}
|
||||
continue
|
||||
}
|
||||
backoff = time.Second
|
||||
}
|
||||
}
|
||||
|
||||
func (a *Agent) tunnelOnce(ctx context.Context) error {
|
||||
d := net.Dialer{Timeout: 10 * time.Second}
|
||||
conn, err := d.DialContext(ctx, "tcp", a.cfg.TunnelAddr)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer conn.Close()
|
||||
if tc, ok := conn.(*net.TCPConn); ok {
|
||||
_ = tc.SetNoDelay(true)
|
||||
}
|
||||
|
||||
// Handshake: server sends "LPRA-TUN1 <nonceHex>", we reply "<agentID> <sig>".
|
||||
_ = conn.SetDeadline(time.Now().Add(10 * time.Second))
|
||||
line, err := readLineConn(conn, 512)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
f := strings.Fields(line)
|
||||
if len(f) != 2 || f[0] != "LPRA-TUN1" {
|
||||
return fmt.Errorf("bad tunnel greeting")
|
||||
}
|
||||
nonce, err := hexDecode(f[1])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
key := tunnel.AuthKey(a.state.Token)
|
||||
if _, err := fmt.Fprintf(conn, "%s %s\n", a.state.AgentID, tunnel.Sign(key, nonce)); err != nil {
|
||||
return err
|
||||
}
|
||||
_ = conn.SetDeadline(time.Time{})
|
||||
log.Printf("tunnel: connected to %s", a.cfg.TunnelAddr)
|
||||
|
||||
tc := &tunClient{conn: conn, streams: map[uint32]*agentStream{}}
|
||||
return tc.readLoop()
|
||||
}
|
||||
|
||||
type tunClient struct {
|
||||
conn net.Conn
|
||||
wmu sync.Mutex
|
||||
mu sync.Mutex
|
||||
streams map[uint32]*agentStream
|
||||
}
|
||||
|
||||
// agentStream buffers downstream data in a channel so bytes that arrive before
|
||||
// the client-side dial completes are not lost (the OPEN/DATA race).
|
||||
type agentStream struct {
|
||||
in chan []byte
|
||||
closed chan struct{}
|
||||
once sync.Once
|
||||
}
|
||||
|
||||
func (s *agentStream) close() { s.once.Do(func() { close(s.closed) }) }
|
||||
|
||||
func (t *tunClient) write(f tunnel.Frame) error {
|
||||
t.wmu.Lock()
|
||||
defer t.wmu.Unlock()
|
||||
return tunnel.WriteFrame(t.conn, f)
|
||||
}
|
||||
|
||||
func (t *tunClient) readLoop() error {
|
||||
for {
|
||||
f, err := tunnel.ReadFrame(t.conn)
|
||||
if err != nil {
|
||||
t.shutdown()
|
||||
return err
|
||||
}
|
||||
switch f.Type {
|
||||
case tunnel.TypeOpen:
|
||||
if len(f.Payload) >= 2 {
|
||||
port := int(f.Payload[0])<<8 | int(f.Payload[1])
|
||||
t.mu.Lock()
|
||||
s := &agentStream{in: make(chan []byte, 256), closed: make(chan struct{})}
|
||||
t.streams[f.Stream] = s
|
||||
t.mu.Unlock()
|
||||
go t.openStream(f.Stream, port, s)
|
||||
}
|
||||
case tunnel.TypeData:
|
||||
t.mu.Lock()
|
||||
s := t.streams[f.Stream]
|
||||
t.mu.Unlock()
|
||||
if s != nil {
|
||||
select {
|
||||
case s.in <- f.Payload:
|
||||
case <-s.closed:
|
||||
}
|
||||
}
|
||||
case tunnel.TypeClose:
|
||||
t.closeStream(f.Stream)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (t *tunClient) openStream(id uint32, port int, s *agentStream) {
|
||||
conn, err := net.DialTimeout("tcp", net.JoinHostPort("127.0.0.1", itoa(port)), 10*time.Second)
|
||||
if err != nil {
|
||||
_ = t.write(tunnel.Frame{Type: tunnel.TypeClose, Stream: id})
|
||||
t.closeStream(id)
|
||||
return
|
||||
}
|
||||
if tc, ok := conn.(*net.TCPConn); ok {
|
||||
_ = tc.SetNoDelay(true)
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
// downstream buffer -> local socket
|
||||
go func() {
|
||||
for {
|
||||
select {
|
||||
case b := <-s.in:
|
||||
if _, err := conn.Write(b); err != nil {
|
||||
s.close()
|
||||
return
|
||||
}
|
||||
case <-s.closed:
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
// local socket -> upstream frames
|
||||
buf := make([]byte, 32*1024)
|
||||
for {
|
||||
n, err := conn.Read(buf)
|
||||
if n > 0 {
|
||||
if t.write(tunnel.Frame{Type: tunnel.TypeData, Stream: id, Payload: append([]byte(nil), buf[:n]...)}) != nil {
|
||||
break
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
break
|
||||
}
|
||||
}
|
||||
_ = t.write(tunnel.Frame{Type: tunnel.TypeClose, Stream: id})
|
||||
t.closeStream(id)
|
||||
}
|
||||
|
||||
func (t *tunClient) closeStream(id uint32) {
|
||||
t.mu.Lock()
|
||||
s := t.streams[id]
|
||||
delete(t.streams, id)
|
||||
t.mu.Unlock()
|
||||
if s != nil {
|
||||
s.close()
|
||||
}
|
||||
}
|
||||
|
||||
func (t *tunClient) shutdown() {
|
||||
t.mu.Lock()
|
||||
streams := t.streams
|
||||
t.streams = map[uint32]*agentStream{}
|
||||
t.mu.Unlock()
|
||||
for _, s := range streams {
|
||||
s.close()
|
||||
}
|
||||
t.conn.Close()
|
||||
}
|
||||
|
||||
// --- direct UDP forwarding ---
|
||||
|
||||
// runUDP maintains the agent's UDP endpoint on the host's 8446 channel and
|
||||
// relays datagrams to/from client-side UDP ports for "udp" forward rules.
|
||||
func (a *Agent) runUDP(ctx context.Context) {
|
||||
backoff := time.Second
|
||||
for ctx.Err() == nil {
|
||||
if err := a.udpOnce(ctx); err != nil {
|
||||
select {
|
||||
case <-time.After(backoff):
|
||||
case <-ctx.Done():
|
||||
return
|
||||
}
|
||||
if backoff < 15*time.Second {
|
||||
backoff *= 2
|
||||
}
|
||||
continue
|
||||
}
|
||||
backoff = time.Second
|
||||
}
|
||||
}
|
||||
|
||||
func (a *Agent) udpOnce(ctx context.Context) error {
|
||||
raddr, err := net.ResolveUDPAddr("udp", a.cfg.TunnelAddr)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
conn, err := net.DialUDP("udp", nil, raddr)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
key := tunnel.AuthKey(a.state.Token)
|
||||
register := func() {
|
||||
m := time.Now().Unix() / 60
|
||||
sig := tunnel.Sign(key, []byte(a.state.AgentID+"|"+itoa64(m)))
|
||||
_, _ = conn.Write([]byte("R" + a.state.AgentID + " " + sig))
|
||||
}
|
||||
register()
|
||||
|
||||
// Keepalive registration so the host keeps our NAT mapping fresh.
|
||||
regCtx, cancel := context.WithCancel(ctx)
|
||||
defer cancel()
|
||||
go func() {
|
||||
t := time.NewTicker(20 * time.Second)
|
||||
defer t.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-regCtx.Done():
|
||||
return
|
||||
case <-t.C:
|
||||
register()
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
uc := &udpClient{server: conn, dials: map[uint32]*net.UDPConn{}}
|
||||
defer uc.shutdown()
|
||||
|
||||
buf := make([]byte, 65535)
|
||||
for {
|
||||
n, err := conn.Read(buf)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if n < 7 || buf[0] != 'D' {
|
||||
continue
|
||||
}
|
||||
sid := binary.BigEndian.Uint32(buf[1:5])
|
||||
port := int(binary.BigEndian.Uint16(buf[5:7]))
|
||||
uc.send(sid, port, append([]byte(nil), buf[7:n]...))
|
||||
}
|
||||
}
|
||||
|
||||
type udpClient struct {
|
||||
server *net.UDPConn // connection back to host:8446
|
||||
mu sync.Mutex
|
||||
dials map[uint32]*net.UDPConn // sid -> client-side socket
|
||||
}
|
||||
|
||||
// send forwards a datagram to 127.0.0.1:port and ensures replies are relayed.
|
||||
func (u *udpClient) send(sid uint32, port int, data []byte) {
|
||||
u.mu.Lock()
|
||||
c := u.dials[sid]
|
||||
u.mu.Unlock()
|
||||
if c == nil {
|
||||
raddr, err := net.ResolveUDPAddr("udp", net.JoinHostPort("127.0.0.1", itoa(port)))
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
c, err = net.DialUDP("udp", nil, raddr)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
u.mu.Lock()
|
||||
u.dials[sid] = c
|
||||
u.mu.Unlock()
|
||||
go u.replyLoop(sid, c)
|
||||
}
|
||||
_, _ = c.Write(data)
|
||||
}
|
||||
|
||||
func (u *udpClient) replyLoop(sid uint32, c *net.UDPConn) {
|
||||
buf := make([]byte, 65535)
|
||||
hdr := make([]byte, 7)
|
||||
hdr[0] = 'D'
|
||||
binary.BigEndian.PutUint32(hdr[1:5], sid)
|
||||
for {
|
||||
_ = c.SetReadDeadline(time.Now().Add(90 * time.Second))
|
||||
n, err := c.Read(buf)
|
||||
if err != nil {
|
||||
u.mu.Lock()
|
||||
delete(u.dials, sid)
|
||||
u.mu.Unlock()
|
||||
c.Close()
|
||||
return
|
||||
}
|
||||
out := append(append([]byte(nil), hdr...), buf[:n]...)
|
||||
_, _ = u.server.Write(out)
|
||||
}
|
||||
}
|
||||
|
||||
func (u *udpClient) shutdown() {
|
||||
u.mu.Lock()
|
||||
dials := u.dials
|
||||
u.dials = map[uint32]*net.UDPConn{}
|
||||
u.mu.Unlock()
|
||||
for _, c := range dials {
|
||||
c.Close()
|
||||
}
|
||||
}
|
||||
|
||||
// --- small helpers ---
|
||||
|
||||
func readLineConn(c net.Conn, max int) (string, error) {
|
||||
var b []byte
|
||||
one := make([]byte, 1)
|
||||
for len(b) < max {
|
||||
n, err := c.Read(one)
|
||||
if n > 0 {
|
||||
if one[0] == '\n' {
|
||||
return string(b), nil
|
||||
}
|
||||
b = append(b, one[0])
|
||||
}
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
return string(b), nil
|
||||
}
|
||||
|
||||
func hexDecode(s string) ([]byte, error) {
|
||||
out := make([]byte, len(s)/2)
|
||||
for i := 0; i < len(out); i++ {
|
||||
hi, err := hexVal(s[i*2])
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
lo, err := hexVal(s[i*2+1])
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out[i] = hi<<4 | lo
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func hexVal(c byte) (byte, error) {
|
||||
switch {
|
||||
case c >= '0' && c <= '9':
|
||||
return c - '0', nil
|
||||
case c >= 'a' && c <= 'f':
|
||||
return c - 'a' + 10, nil
|
||||
case c >= 'A' && c <= 'F':
|
||||
return c - 'A' + 10, nil
|
||||
}
|
||||
return 0, fmt.Errorf("bad hex")
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"time"
|
||||
|
||||
"github.com/x-grk/longpolling-remote-access/internal/proto"
|
||||
)
|
||||
|
||||
// doUpdate downloads the latest agent binary for this platform, atomically
|
||||
// replaces the running executable, and schedules a service restart. It never
|
||||
// uninstalls — only updates.
|
||||
func (a *Agent) doUpdate() proto.TaskResult {
|
||||
exe, err := os.Executable()
|
||||
if err != nil {
|
||||
return proto.TaskResult{Error: "не удалось определить путь бинарника: " + err.Error()}
|
||||
}
|
||||
exe, _ = filepath.EvalSymlinks(exe)
|
||||
|
||||
url := fmt.Sprintf("%s/download/agent-%s-%s", a.cfg.Host, runtime.GOOS, runtime.GOARCH)
|
||||
req, _ := http.NewRequestWithContext(context.Background(), http.MethodGet, url, nil)
|
||||
resp, err := a.http.Do(req)
|
||||
if err != nil {
|
||||
return proto.TaskResult{Error: "скачивание: " + err.Error()}
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return proto.TaskResult{Error: "скачивание: сервер вернул " + resp.Status}
|
||||
}
|
||||
|
||||
tmp := exe + ".new"
|
||||
f, err := os.OpenFile(tmp, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o755)
|
||||
if err != nil {
|
||||
return proto.TaskResult{Error: "создание файла: " + err.Error()}
|
||||
}
|
||||
if _, err := io.Copy(f, resp.Body); err != nil {
|
||||
f.Close()
|
||||
os.Remove(tmp)
|
||||
return proto.TaskResult{Error: "запись: " + err.Error()}
|
||||
}
|
||||
f.Close()
|
||||
|
||||
// Atomic replace: renaming over a running executable is allowed on Linux;
|
||||
// the live process keeps the old inode until it restarts.
|
||||
if err := os.Rename(tmp, exe); err != nil {
|
||||
os.Remove(tmp)
|
||||
return proto.TaskResult{Error: "замена бинарника: " + err.Error()}
|
||||
}
|
||||
|
||||
// Restart shortly, after the result has been reported.
|
||||
go func() {
|
||||
time.Sleep(1500 * time.Millisecond)
|
||||
restartSelf()
|
||||
}()
|
||||
|
||||
data, _ := json.Marshal(map[string]string{"status": "обновлено, перезапуск"})
|
||||
return proto.TaskResult{OK: true, Data: data}
|
||||
}
|
||||
|
||||
// restartSelf restarts the agent so the new binary takes effect.
|
||||
func restartSelf() {
|
||||
if path, err := exec.LookPath("systemctl"); err == nil {
|
||||
_ = exec.Command(path, "restart", "lpagent").Start()
|
||||
return
|
||||
}
|
||||
// No systemd (e.g. launchd/manual run): exit so the supervisor restarts us.
|
||||
os.Exit(0)
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
// Package proto describes the wire messages shared by the server and the agent.
|
||||
//
|
||||
// All agent<->server traffic is plain JSON over HTTP(S) long-polling so it
|
||||
// survives NAT and restrictive egress firewalls: the agent only ever makes
|
||||
// outbound requests.
|
||||
package proto
|
||||
|
||||
import "encoding/json"
|
||||
|
||||
// Control poll message types (server -> agent, returned from /api/agent/poll).
|
||||
const (
|
||||
// CtrlIdle is returned when the long-poll times out with nothing to do.
|
||||
CtrlIdle = "idle"
|
||||
// CtrlOpenTerminal tells the agent to spawn a PTY for the session id.
|
||||
CtrlOpenTerminal = "open_terminal"
|
||||
// CtrlOpenForward tells the agent to dial 127.0.0.1:Port and bridge it to
|
||||
// the session id (reverse TCP port-forward).
|
||||
CtrlOpenForward = "open_forward"
|
||||
// CtrlTask tells the agent to fetch and run the one-shot task TaskID.
|
||||
CtrlTask = "task"
|
||||
)
|
||||
|
||||
// EnrollRequest is sent once by a freshly installed agent.
|
||||
type EnrollRequest struct {
|
||||
Name string `json:"name"`
|
||||
Hostname string `json:"hostname"`
|
||||
OS string `json:"os"`
|
||||
Arch string `json:"arch"`
|
||||
Version string `json:"version"`
|
||||
}
|
||||
|
||||
// EnrollResponse carries the identity the agent persists and uses from then on.
|
||||
type EnrollResponse struct {
|
||||
AgentID string `json:"agent_id"`
|
||||
Token string `json:"token"`
|
||||
}
|
||||
|
||||
// ControlMessage is the body returned by the control long-poll.
|
||||
type ControlMessage struct {
|
||||
Type string `json:"type"`
|
||||
SessionID string `json:"session_id,omitempty"`
|
||||
Port int `json:"port,omitempty"` // CtrlOpenForward: client-side port to dial
|
||||
TaskID string `json:"task_id,omitempty"` // CtrlTask
|
||||
}
|
||||
|
||||
// Terminal / stream frame types on the session "in" channel (server -> agent).
|
||||
// Reused for both PTY terminals and raw TCP port-forward streams.
|
||||
const (
|
||||
FrameStdin = "stdin" // base64 bytes for the PTY / socket
|
||||
FrameResize = "resize" // PTY window size (terminal only)
|
||||
FrameClose = "close" // tear the stream down
|
||||
)
|
||||
|
||||
// InFrame is one element of the stream downstream batch (server -> agent).
|
||||
type InFrame struct {
|
||||
Type string `json:"type"`
|
||||
Data string `json:"data,omitempty"` // base64 bytes
|
||||
Cols uint16 `json:"cols,omitempty"`
|
||||
Rows uint16 `json:"rows,omitempty"`
|
||||
}
|
||||
|
||||
// InBatch is the JSON body returned by GET /api/agent/term/{sid}/in.
|
||||
type InBatch struct {
|
||||
Frames []InFrame `json:"frames"`
|
||||
}
|
||||
|
||||
// OutMessage is POSTed by the agent to /api/agent/term/{sid}/out.
|
||||
type OutMessage struct {
|
||||
Data string `json:"data,omitempty"` // base64 bytes
|
||||
Exit bool `json:"exit,omitempty"`
|
||||
}
|
||||
|
||||
// --- one-shot tasks (file manager, native in the agent) ---
|
||||
|
||||
const (
|
||||
TaskFSList = "fs.list" // list a directory
|
||||
TaskFSRead = "fs.read" // read file content (for view/edit)
|
||||
TaskFSWrite = "fs.write" // write/overwrite file (edit save, create, upload)
|
||||
TaskFSMkdir = "fs.mkdir" // create directory
|
||||
TaskFSDelete = "fs.delete" // delete a single file or empty dir
|
||||
TaskFSDelTree = "fs.deltree" // recursively delete a directory
|
||||
TaskFSUnzip = "fs.unzip" // unzip an archive (needs `unzip` on the client)
|
||||
TaskFSZip = "fs.zip" // zip selected paths (native archive/zip)
|
||||
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
|
||||
)
|
||||
|
||||
// TaskEnvelope is what the agent fetches for a task id.
|
||||
type TaskEnvelope struct {
|
||||
Type string `json:"type"`
|
||||
Args json.RawMessage `json:"args"`
|
||||
}
|
||||
|
||||
// TaskResult is POSTed back by the agent.
|
||||
type TaskResult struct {
|
||||
OK bool `json:"ok"`
|
||||
Error string `json:"error,omitempty"`
|
||||
Data json.RawMessage `json:"data,omitempty"`
|
||||
}
|
||||
|
||||
// fs task argument shapes.
|
||||
type FSPathArgs struct {
|
||||
Path string `json:"path"`
|
||||
}
|
||||
type FSWriteArgs struct {
|
||||
Path string `json:"path"`
|
||||
Data string `json:"data"` // base64 content
|
||||
}
|
||||
type FSUnzipArgs struct {
|
||||
Path string `json:"path"`
|
||||
Dest string `json:"dest"`
|
||||
}
|
||||
type FSZipArgs struct {
|
||||
Paths []string `json:"paths"`
|
||||
Dest string `json:"dest"`
|
||||
}
|
||||
type FSMoveArgs struct {
|
||||
Src string `json:"src"`
|
||||
Dst string `json:"dst"`
|
||||
}
|
||||
|
||||
// FSEntry is one directory entry.
|
||||
type FSEntry struct {
|
||||
Name string `json:"name"`
|
||||
IsDir bool `json:"is_dir"`
|
||||
Size int64 `json:"size"`
|
||||
Mode string `json:"mode"`
|
||||
MTime int64 `json:"mtime"`
|
||||
}
|
||||
|
||||
// FSListResult is returned for TaskFSList.
|
||||
type FSListResult struct {
|
||||
Path string `json:"path"`
|
||||
Entries []FSEntry `json:"entries"`
|
||||
}
|
||||
|
||||
// FSReadResult is returned for TaskFSRead.
|
||||
type FSReadResult struct {
|
||||
Path string `json:"path"`
|
||||
Size int64 `json:"size"`
|
||||
Content string `json:"content"` // base64
|
||||
Truncated bool `json:"truncated"`
|
||||
Binary bool `json:"binary"`
|
||||
}
|
||||
Vendored
BIN
Binary file not shown.
@@ -0,0 +1,62 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// authenticateAgent resolves the agent identified by the Bearer token on the
|
||||
// request, verifying the token against the stored hash in constant time.
|
||||
// Returns nil if authentication fails.
|
||||
func (s *Server) authenticateAgent(r *http.Request) *Agent {
|
||||
id := r.Header.Get("X-Agent-Id")
|
||||
token := bearerToken(r)
|
||||
if id == "" || token == "" {
|
||||
return nil
|
||||
}
|
||||
a, err := s.db.GetAgent(id)
|
||||
if err != nil || a == nil {
|
||||
return nil
|
||||
}
|
||||
if !constantTimeEqual(a.TokenHash, hashToken(token)) {
|
||||
return nil
|
||||
}
|
||||
return a
|
||||
}
|
||||
|
||||
func bearerToken(r *http.Request) string {
|
||||
h := r.Header.Get("Authorization")
|
||||
if strings.HasPrefix(h, "Bearer ") {
|
||||
return strings.TrimPrefix(h, "Bearer ")
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// 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) {
|
||||
id = randToken()[:16]
|
||||
token = randToken()
|
||||
a := Agent{
|
||||
ID: id,
|
||||
Name: firstNonEmpty(name, hostname, id),
|
||||
Hostname: hostname,
|
||||
OS: os,
|
||||
Arch: arch,
|
||||
Version: version,
|
||||
TokenHash: hashToken(token),
|
||||
}
|
||||
if err = s.db.CreateAgent(a); err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
return id, token, nil
|
||||
}
|
||||
|
||||
func firstNonEmpty(vals ...string) string {
|
||||
for _, v := range vals {
|
||||
if strings.TrimSpace(v) != "" {
|
||||
return v
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"crypto/subtle"
|
||||
"net/http"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
const (
|
||||
sessionCookie = "lpra_session"
|
||||
csrfCookie = "lpra_csrf"
|
||||
sessionTTL = 12 * time.Hour
|
||||
)
|
||||
|
||||
// hashToken returns a hex SHA-256 of a bearer token, for at-rest storage of
|
||||
// per-agent tokens (we never need the plaintext back, only to compare).
|
||||
func hashToken(token string) string {
|
||||
sum := sha256.Sum256([]byte(token))
|
||||
return string(hexEncode(sum[:]))
|
||||
}
|
||||
|
||||
func hexEncode(b []byte) []byte {
|
||||
const hextable = "0123456789abcdef"
|
||||
out := make([]byte, len(b)*2)
|
||||
for i, v := range b {
|
||||
out[i*2] = hextable[v>>4]
|
||||
out[i*2+1] = hextable[v&0x0f]
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// constantTimeEqual compares two strings without leaking length-independent timing.
|
||||
func constantTimeEqual(a, b string) bool {
|
||||
return subtle.ConstantTimeCompare([]byte(a), []byte(b)) == 1
|
||||
}
|
||||
|
||||
func hashPassword(pw string) (string, error) {
|
||||
h, err := bcrypt.GenerateFromPassword([]byte(pw), bcrypt.DefaultCost)
|
||||
return string(h), err
|
||||
}
|
||||
|
||||
func checkPassword(hash, pw string) bool {
|
||||
return bcrypt.CompareHashAndPassword([]byte(hash), []byte(pw)) == nil
|
||||
}
|
||||
|
||||
// loginLimiter throttles failed logins per client IP to blunt brute force.
|
||||
type loginLimiter struct {
|
||||
mu sync.Mutex
|
||||
fail map[string]*attempt
|
||||
}
|
||||
|
||||
type attempt struct {
|
||||
count int
|
||||
until time.Time
|
||||
}
|
||||
|
||||
func newLoginLimiter() *loginLimiter { return &loginLimiter{fail: map[string]*attempt{}} }
|
||||
|
||||
// blocked reports whether this IP is currently in a lockout window.
|
||||
func (l *loginLimiter) blocked(ip string) bool {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
a := l.fail[ip]
|
||||
return a != nil && time.Now().Before(a.until)
|
||||
}
|
||||
|
||||
func (l *loginLimiter) fin(ip string, ok bool) {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
if ok {
|
||||
delete(l.fail, ip)
|
||||
return
|
||||
}
|
||||
a := l.fail[ip]
|
||||
if a == nil {
|
||||
a = &attempt{}
|
||||
l.fail[ip] = a
|
||||
}
|
||||
a.count++
|
||||
// Exponential-ish backoff after 5 failures, capped at 5 minutes.
|
||||
if a.count >= 5 {
|
||||
d := time.Duration(a.count-4) * 15 * time.Second
|
||||
if d > 5*time.Minute {
|
||||
d = 5 * time.Minute
|
||||
}
|
||||
a.until = time.Now().Add(d)
|
||||
}
|
||||
}
|
||||
|
||||
// currentAdmin returns the logged-in admin for a request, or nil.
|
||||
func (s *Server) currentAdmin(r *http.Request) *Admin {
|
||||
c, err := r.Cookie(sessionCookie)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
a, err := s.db.SessionAdmin(c.Value)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
return a
|
||||
}
|
||||
|
||||
// setSession issues a session cookie and a paired CSRF cookie.
|
||||
func (s *Server) setSession(w http.ResponseWriter, adminID int64) error {
|
||||
sid := randToken()
|
||||
if err := s.db.CreateSession(sid, adminID, sessionTTL); err != nil {
|
||||
return err
|
||||
}
|
||||
secure := s.secureCookies
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: sessionCookie, Value: sid, Path: "/",
|
||||
HttpOnly: true, Secure: secure, SameSite: http.SameSiteLaxMode,
|
||||
MaxAge: int(sessionTTL.Seconds()),
|
||||
})
|
||||
// CSRF token is readable by templates (not HttpOnly) and echoed back in forms.
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: csrfCookie, Value: randToken(), Path: "/",
|
||||
Secure: secure, SameSite: http.SameSiteLaxMode,
|
||||
MaxAge: int(sessionTTL.Seconds()),
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Server) clearSession(w http.ResponseWriter, r *http.Request) {
|
||||
if c, err := r.Cookie(sessionCookie); err == nil {
|
||||
_ = s.db.DeleteSession(c.Value)
|
||||
}
|
||||
for _, name := range []string{sessionCookie, csrfCookie} {
|
||||
http.SetCookie(w, &http.Cookie{Name: name, Value: "", Path: "/", MaxAge: -1})
|
||||
}
|
||||
}
|
||||
|
||||
// csrfToken reads the CSRF cookie value for embedding in a form.
|
||||
func csrfToken(r *http.Request) string {
|
||||
if c, err := r.Cookie(csrfCookie); err == nil {
|
||||
return c.Value
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// checkCSRF verifies the submitted token matches the cookie (double-submit).
|
||||
func checkCSRF(r *http.Request) bool {
|
||||
want := csrfToken(r)
|
||||
got := r.FormValue("csrf")
|
||||
return want != "" && constantTimeEqual(want, got)
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
)
|
||||
|
||||
// Config keys stored in the config table.
|
||||
const (
|
||||
cfgHostAddress = "host_address"
|
||||
cfgEnrollSecret = "enroll_secret"
|
||||
cfgSetupDone = "setup_done"
|
||||
cfgInstallCode = "install_code"
|
||||
cfgUfwEnabled = "ufw_enabled"
|
||||
)
|
||||
|
||||
// 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"
|
||||
|
||||
// ensureDefaults seeds the host address and a random enroll secret on first boot.
|
||||
func (db *DB) ensureDefaults() error {
|
||||
host, err := db.GetConfig(cfgHostAddress)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if host == "" {
|
||||
if err := db.SetConfig(cfgHostAddress, DefaultHostAddress); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
secret, err := db.GetConfig(cfgEnrollSecret)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if secret == "" {
|
||||
if err := db.SetConfig(cfgEnrollSecret, randToken()); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
code, err := db.GetConfig(cfgInstallCode)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if code == "" {
|
||||
if err := db.SetConfig(cfgInstallCode, shortCode()); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (db *DB) HostAddress() string {
|
||||
v, _ := db.GetConfig(cfgHostAddress)
|
||||
if v == "" {
|
||||
return DefaultHostAddress
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
func (db *DB) EnrollSecret() string {
|
||||
v, _ := db.GetConfig(cfgEnrollSecret)
|
||||
return v
|
||||
}
|
||||
|
||||
// InstallCode returns the short code used for the /i/<code> install one-liner.
|
||||
func (db *DB) InstallCode() string {
|
||||
v, _ := db.GetConfig(cfgInstallCode)
|
||||
return v
|
||||
}
|
||||
|
||||
// UfwEnabled reports whether panel-driven ufw integration is on (default yes).
|
||||
func (db *DB) UfwEnabled() bool {
|
||||
v, _ := db.GetConfig(cfgUfwEnabled)
|
||||
return v != "0"
|
||||
}
|
||||
|
||||
func (db *DB) SetUfwEnabled(on bool) error {
|
||||
if on {
|
||||
return db.SetConfig(cfgUfwEnabled, "1")
|
||||
}
|
||||
return db.SetConfig(cfgUfwEnabled, "0")
|
||||
}
|
||||
|
||||
func (db *DB) SetupDone() bool {
|
||||
v, _ := db.GetConfig(cfgSetupDone)
|
||||
return v == "1"
|
||||
}
|
||||
|
||||
func (db *DB) MarkSetupDone() error { return db.SetConfig(cfgSetupDone, "1") }
|
||||
|
||||
// randToken returns 32 bytes of cryptographic randomness as hex (64 chars).
|
||||
func randToken() string {
|
||||
b := make([]byte, 32)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
panic("crypto/rand failed: " + err.Error())
|
||||
}
|
||||
return hex.EncodeToString(b)
|
||||
}
|
||||
|
||||
// shortCode returns a short, URL-safe, typo-resistant code for install links.
|
||||
// It avoids ambiguous characters (0/O, 1/l/I) so it is easy to read and type.
|
||||
func shortCode() string {
|
||||
const alphabet = "abcdefghjkmnpqrstuvwxyz23456789"
|
||||
b := make([]byte, 7)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
panic("crypto/rand failed: " + err.Error())
|
||||
}
|
||||
for i := range b {
|
||||
b[i] = alphabet[int(b[i])%len(alphabet)]
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
@@ -0,0 +1,452 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
_ "modernc.org/sqlite"
|
||||
)
|
||||
|
||||
// DB wraps the SQLite connection and all queries used by the server.
|
||||
type DB struct {
|
||||
sql *sql.DB
|
||||
}
|
||||
|
||||
// OpenDB opens (creating if needed) the SQLite database at path and runs
|
||||
// migrations. The pure-Go driver ("sqlite") needs no CGO.
|
||||
func OpenDB(path string) (*DB, error) {
|
||||
// _pragma busy_timeout avoids "database is locked" under the concurrent
|
||||
// long-poll load; foreign_keys keeps the schema honest.
|
||||
dsn := fmt.Sprintf("file:%s?_pragma=busy_timeout(5000)&_pragma=foreign_keys(1)&_pragma=journal_mode(WAL)", path)
|
||||
sdb, err := sql.Open("sqlite", dsn)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// SQLite writes serialize; a single connection avoids lock churn while the
|
||||
// busy_timeout still covers the long-poll readers.
|
||||
sdb.SetMaxOpenConns(1)
|
||||
if err := sdb.Ping(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
db := &DB{sql: sdb}
|
||||
if err := db.migrate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return db, nil
|
||||
}
|
||||
|
||||
func (db *DB) Close() error { return db.sql.Close() }
|
||||
|
||||
func (db *DB) migrate() error {
|
||||
const schema = `
|
||||
CREATE TABLE IF NOT EXISTS config (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS admins (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
username TEXT UNIQUE NOT NULL,
|
||||
pass_hash TEXT NOT NULL,
|
||||
created_at INTEGER NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS agents (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
hostname TEXT NOT NULL,
|
||||
os TEXT NOT NULL,
|
||||
arch TEXT NOT NULL,
|
||||
token_hash TEXT NOT NULL,
|
||||
version TEXT NOT NULL DEFAULT '',
|
||||
enrolled_at INTEGER NOT NULL,
|
||||
last_seen INTEGER NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS admin_sessions (
|
||||
id TEXT PRIMARY KEY,
|
||||
admin_id INTEGER NOT NULL REFERENCES admins(id) ON DELETE CASCADE,
|
||||
created_at INTEGER NOT NULL,
|
||||
expires_at INTEGER NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS audit (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
admin_id INTEGER,
|
||||
agent_id TEXT,
|
||||
action TEXT NOT NULL,
|
||||
at INTEGER NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS ufw_rules (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
port INTEGER NOT NULL,
|
||||
proto TEXT NOT NULL,
|
||||
rule_id INTEGER NOT NULL DEFAULT 0,
|
||||
created_at INTEGER NOT NULL,
|
||||
UNIQUE(port, proto)
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS forward_rules (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
agent_id TEXT NOT NULL,
|
||||
client_port INTEGER NOT NULL,
|
||||
host_port INTEGER NOT NULL,
|
||||
listen_addr TEXT NOT NULL DEFAULT '0.0.0.0',
|
||||
proto TEXT NOT NULL DEFAULT 'http',
|
||||
created_at INTEGER NOT NULL
|
||||
);`
|
||||
if _, err := db.sql.Exec(schema); err != nil {
|
||||
return err
|
||||
}
|
||||
// Add columns to databases created before they existed. SQLite has no
|
||||
// "IF NOT EXISTS" for columns, so ignore the "duplicate column" error.
|
||||
for _, alter := range []string{
|
||||
`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`,
|
||||
} {
|
||||
if _, err := db.sql.Exec(alter); err != nil && !strings.Contains(err.Error(), "duplicate column") {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// --- config kv ---
|
||||
|
||||
func (db *DB) GetConfig(key string) (string, error) {
|
||||
var v string
|
||||
err := db.sql.QueryRow(`SELECT value FROM config WHERE key = ?`, key).Scan(&v)
|
||||
if err == sql.ErrNoRows {
|
||||
return "", nil
|
||||
}
|
||||
return v, err
|
||||
}
|
||||
|
||||
func (db *DB) SetConfig(key, value string) error {
|
||||
_, err := db.sql.Exec(
|
||||
`INSERT INTO config(key, value) VALUES(?, ?)
|
||||
ON CONFLICT(key) DO UPDATE SET value = excluded.value`, key, value)
|
||||
return err
|
||||
}
|
||||
|
||||
// --- admins ---
|
||||
|
||||
func (db *DB) CountAdmins() (int, error) {
|
||||
var n int
|
||||
err := db.sql.QueryRow(`SELECT COUNT(*) FROM admins`).Scan(&n)
|
||||
return n, err
|
||||
}
|
||||
|
||||
func (db *DB) CreateAdmin(username, passHash string) error {
|
||||
_, err := db.sql.Exec(
|
||||
`INSERT INTO admins(username, pass_hash, created_at) VALUES(?, ?, ?)`,
|
||||
username, passHash, time.Now().Unix())
|
||||
return err
|
||||
}
|
||||
|
||||
// Admin is one row of the admins table.
|
||||
type Admin struct {
|
||||
ID int64
|
||||
Username string
|
||||
PassHash string
|
||||
}
|
||||
|
||||
func (db *DB) AdminByUsername(username string) (*Admin, error) {
|
||||
a := &Admin{}
|
||||
err := db.sql.QueryRow(
|
||||
`SELECT id, username, pass_hash FROM admins WHERE username = ?`, username).
|
||||
Scan(&a.ID, &a.Username, &a.PassHash)
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
return a, err
|
||||
}
|
||||
|
||||
func (db *DB) ListAdmins() ([]Admin, error) {
|
||||
rows, err := db.sql.Query(`SELECT id, username FROM admins ORDER BY username`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []Admin
|
||||
for rows.Next() {
|
||||
var a Admin
|
||||
if err := rows.Scan(&a.ID, &a.Username); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, a)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func (db *DB) DeleteAdmin(id int64) error {
|
||||
_, err := db.sql.Exec(`DELETE FROM admins WHERE id = ?`, id)
|
||||
return err
|
||||
}
|
||||
|
||||
// --- admin sessions ---
|
||||
|
||||
func (db *DB) CreateSession(id string, adminID int64, ttl time.Duration) error {
|
||||
now := time.Now()
|
||||
_, err := db.sql.Exec(
|
||||
`INSERT INTO admin_sessions(id, admin_id, created_at, expires_at) VALUES(?, ?, ?, ?)`,
|
||||
id, adminID, now.Unix(), now.Add(ttl).Unix())
|
||||
return err
|
||||
}
|
||||
|
||||
// SessionAdmin returns the admin for a live (non-expired) session, or nil.
|
||||
func (db *DB) SessionAdmin(sessionID string) (*Admin, error) {
|
||||
a := &Admin{}
|
||||
err := db.sql.QueryRow(
|
||||
`SELECT a.id, a.username, a.pass_hash
|
||||
FROM admin_sessions s JOIN admins a ON a.id = s.admin_id
|
||||
WHERE s.id = ? AND s.expires_at > ?`, sessionID, time.Now().Unix()).
|
||||
Scan(&a.ID, &a.Username, &a.PassHash)
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
return a, err
|
||||
}
|
||||
|
||||
func (db *DB) DeleteSession(id string) error {
|
||||
_, err := db.sql.Exec(`DELETE FROM admin_sessions WHERE id = ?`, id)
|
||||
return err
|
||||
}
|
||||
|
||||
// --- agents ---
|
||||
|
||||
// Agent is one row of the agents table.
|
||||
type Agent struct {
|
||||
ID string
|
||||
Name string
|
||||
Hostname string
|
||||
OS string
|
||||
Arch string
|
||||
TokenHash string
|
||||
Version string
|
||||
LastSeen time.Time
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
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)
|
||||
return err
|
||||
}
|
||||
|
||||
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)
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
a.LastSeen = time.Unix(last, 0)
|
||||
return a, nil
|
||||
}
|
||||
|
||||
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`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []Agent
|
||||
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 {
|
||||
return nil, err
|
||||
}
|
||||
a.LastSeen = time.Unix(last, 0)
|
||||
out = append(out, a)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func (db *DB) TouchAgent(id string) error {
|
||||
_, err := db.sql.Exec(`UPDATE agents SET last_seen = ? WHERE id = ?`, time.Now().Unix(), id)
|
||||
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)
|
||||
return err
|
||||
}
|
||||
|
||||
func (db *DB) DeleteAgent(id string) error {
|
||||
_, err := db.sql.Exec(`DELETE FROM agents WHERE id = ?`, id)
|
||||
return err
|
||||
}
|
||||
|
||||
func (db *DB) RenameAgent(id, name string) error {
|
||||
_, err := db.sql.Exec(`UPDATE agents SET name = ? WHERE id = ?`, name, id)
|
||||
return err
|
||||
}
|
||||
|
||||
// --- forward rules ---
|
||||
|
||||
// ForwardRule maps a port on the agent's localhost to a port on the host.
|
||||
// Proto is "http" (long-poll), "tcp" (direct mux) or "udp" (direct).
|
||||
type ForwardRule struct {
|
||||
ID int64
|
||||
AgentID string
|
||||
ClientPort int
|
||||
HostPort int
|
||||
ListenAddr string
|
||||
Proto string
|
||||
}
|
||||
|
||||
func (db *DB) CreateForwardRule(r ForwardRule) (int64, error) {
|
||||
if r.Proto == "" {
|
||||
r.Proto = "http"
|
||||
}
|
||||
res, err := db.sql.Exec(
|
||||
`INSERT INTO forward_rules(agent_id, client_port, host_port, listen_addr, proto, created_at)
|
||||
VALUES(?, ?, ?, ?, ?, ?)`,
|
||||
r.AgentID, r.ClientPort, r.HostPort, r.ListenAddr, r.Proto, time.Now().Unix())
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return res.LastInsertId()
|
||||
}
|
||||
|
||||
func (db *DB) ListForwardRules() ([]ForwardRule, error) {
|
||||
rows, err := db.sql.Query(
|
||||
`SELECT id, agent_id, client_port, host_port, listen_addr, proto FROM forward_rules ORDER BY id`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []ForwardRule
|
||||
for rows.Next() {
|
||||
var r ForwardRule
|
||||
if err := rows.Scan(&r.ID, &r.AgentID, &r.ClientPort, &r.HostPort, &r.ListenAddr, &r.Proto); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, r)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func (db *DB) ListForwardRulesByAgent(agentID string) ([]ForwardRule, error) {
|
||||
all, err := db.ListForwardRules()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var out []ForwardRule
|
||||
for _, r := range all {
|
||||
if r.AgentID == agentID {
|
||||
out = append(out, r)
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (db *DB) GetForwardRule(id int64) (*ForwardRule, error) {
|
||||
r := &ForwardRule{}
|
||||
err := db.sql.QueryRow(
|
||||
`SELECT id, agent_id, client_port, host_port, listen_addr, proto FROM forward_rules WHERE id = ?`, id).
|
||||
Scan(&r.ID, &r.AgentID, &r.ClientPort, &r.HostPort, &r.ListenAddr, &r.Proto)
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
return r, err
|
||||
}
|
||||
|
||||
func (db *DB) DeleteForwardRule(id int64) error {
|
||||
_, err := db.sql.Exec(`DELETE FROM forward_rules WHERE id = ?`, id)
|
||||
return err
|
||||
}
|
||||
|
||||
// --- ufw rules created by the panel ---
|
||||
|
||||
type UfwRule struct {
|
||||
ID int64
|
||||
Port int
|
||||
Proto string
|
||||
RuleID int64 // bound forward rule id, or 0 for shared/manual (e.g. tunnel 8446)
|
||||
}
|
||||
|
||||
func (db *DB) AddUfwRule(port int, proto string, ruleID int64) error {
|
||||
_, err := db.sql.Exec(
|
||||
`INSERT OR IGNORE INTO ufw_rules(port, proto, rule_id, created_at) VALUES(?, ?, ?, ?)`,
|
||||
port, proto, ruleID, time.Now().Unix())
|
||||
return err
|
||||
}
|
||||
|
||||
func (db *DB) HasUfwRule(port int, proto string) bool {
|
||||
var n int
|
||||
_ = db.sql.QueryRow(`SELECT COUNT(*) FROM ufw_rules WHERE port = ? AND proto = ?`, port, proto).Scan(&n)
|
||||
return n > 0
|
||||
}
|
||||
|
||||
func (db *DB) scanUfwRules(query string, args ...any) ([]UfwRule, error) {
|
||||
rows, err := db.sql.Query(query, args...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []UfwRule
|
||||
for rows.Next() {
|
||||
var r UfwRule
|
||||
if err := rows.Scan(&r.ID, &r.Port, &r.Proto, &r.RuleID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, r)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func (db *DB) ListUfwRules() ([]UfwRule, error) {
|
||||
return db.scanUfwRules(`SELECT id, port, proto, rule_id FROM ufw_rules ORDER BY port, proto`)
|
||||
}
|
||||
|
||||
// ListUfwRulesByRule returns ufw rules bound to a specific forward rule.
|
||||
func (db *DB) ListUfwRulesByRule(ruleID int64) ([]UfwRule, error) {
|
||||
return db.scanUfwRules(`SELECT id, port, proto, rule_id FROM ufw_rules WHERE rule_id = ?`, ruleID)
|
||||
}
|
||||
|
||||
func (db *DB) GetUfwRule(id int64) (*UfwRule, error) {
|
||||
r := &UfwRule{}
|
||||
err := db.sql.QueryRow(`SELECT id, port, proto, rule_id FROM ufw_rules WHERE id = ?`, id).
|
||||
Scan(&r.ID, &r.Port, &r.Proto, &r.RuleID)
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
return r, err
|
||||
}
|
||||
|
||||
func (db *DB) DeleteUfwRule(id int64) error {
|
||||
_, err := db.sql.Exec(`DELETE FROM ufw_rules WHERE id = ?`, id)
|
||||
return err
|
||||
}
|
||||
|
||||
// HostPortUsed reports whether any forward rule already uses this host port.
|
||||
func (db *DB) HostPortUsed(hostPort int) bool {
|
||||
var n int
|
||||
_ = db.sql.QueryRow(`SELECT COUNT(*) FROM forward_rules WHERE host_port = ?`, hostPort).Scan(&n)
|
||||
return n > 0
|
||||
}
|
||||
|
||||
// --- audit ---
|
||||
|
||||
func (db *DB) Audit(adminID int64, agentID, action string) {
|
||||
// Best-effort: an audit failure must not block the operation.
|
||||
_, _ = db.sql.Exec(
|
||||
`INSERT INTO audit(admin_id, agent_id, action, at) VALUES(?, ?, ?, ?)`,
|
||||
adminID, agentID, action, time.Now().Unix())
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"log"
|
||||
"net"
|
||||
"sync"
|
||||
|
||||
"github.com/x-grk/longpolling-remote-access/internal/proto"
|
||||
)
|
||||
|
||||
// forwardManager owns the host-side listeners for port-forward rules. It
|
||||
// supports three transports:
|
||||
// - "http": tunneled over the HTTP long-poll channel (works anywhere nginx
|
||||
// reaches, highest latency);
|
||||
// - "tcp": direct TCP multiplexed over the 8446 tunnel (low latency);
|
||||
// - "udp": direct UDP over the 8446 channel.
|
||||
type forwardManager struct {
|
||||
hub *Hub
|
||||
tun *tunnelHub
|
||||
udp *udpRelay
|
||||
|
||||
mu sync.Mutex
|
||||
lst map[int64]net.Listener // TCP listeners for http/tcp rules
|
||||
}
|
||||
|
||||
func newForwardManager(hub *Hub, tun *tunnelHub, udp *udpRelay) *forwardManager {
|
||||
return &forwardManager{hub: hub, tun: tun, udp: udp, lst: map[int64]net.Listener{}}
|
||||
}
|
||||
|
||||
// open starts the host-side listener(s) for a rule.
|
||||
func (m *forwardManager) open(rule ForwardRule) error {
|
||||
m.close(rule.ID)
|
||||
switch rule.Proto {
|
||||
case "udp":
|
||||
if err := m.udp.openRulePort(rule); err != nil {
|
||||
return err
|
||||
}
|
||||
log.Printf("forward[udp]: %s:%d -> agent %s :%d", rule.ListenAddr, rule.HostPort, rule.AgentID, rule.ClientPort)
|
||||
return nil
|
||||
default: // "http" and "tcp" both listen TCP on the host port
|
||||
addr := fmt.Sprintf("%s:%d", rule.ListenAddr, rule.HostPort)
|
||||
ln, err := net.Listen("tcp", addr)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
m.mu.Lock()
|
||||
m.lst[rule.ID] = ln
|
||||
m.mu.Unlock()
|
||||
log.Printf("forward[%s]: %s -> agent %s :%d", rule.Proto, addr, rule.AgentID, rule.ClientPort)
|
||||
go m.acceptTCP(rule, ln)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func (m *forwardManager) close(ruleID int64) {
|
||||
m.mu.Lock()
|
||||
ln := m.lst[ruleID]
|
||||
delete(m.lst, ruleID)
|
||||
m.mu.Unlock()
|
||||
if ln != nil {
|
||||
_ = ln.Close()
|
||||
}
|
||||
m.udp.closeRulePort(ruleID)
|
||||
}
|
||||
|
||||
func (m *forwardManager) closeAll() {
|
||||
m.mu.Lock()
|
||||
lst := m.lst
|
||||
m.lst = map[int64]net.Listener{}
|
||||
m.mu.Unlock()
|
||||
for _, ln := range lst {
|
||||
_ = ln.Close()
|
||||
}
|
||||
}
|
||||
|
||||
func (m *forwardManager) acceptTCP(rule ForwardRule, ln net.Listener) {
|
||||
for {
|
||||
conn, err := ln.Accept()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
if tc, ok := conn.(*net.TCPConn); ok {
|
||||
_ = tc.SetNoDelay(true)
|
||||
}
|
||||
if rule.Proto == "tcp" {
|
||||
go m.bridgeDirect(rule, conn)
|
||||
} else {
|
||||
go m.bridgeHTTP(rule, conn)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// bridgeDirect wires a connection to the agent's low-latency 8446 tunnel.
|
||||
func (m *forwardManager) bridgeDirect(rule ForwardRule, conn net.Conn) {
|
||||
at := m.tun.get(rule.AgentID)
|
||||
if at == nil {
|
||||
// No direct tunnel (agent offline or 8446 unreachable).
|
||||
conn.Close()
|
||||
return
|
||||
}
|
||||
at.openStream(rule.ClientPort, conn)
|
||||
}
|
||||
|
||||
// bridgeHTTP wires a connection through the HTTP long-poll hub session.
|
||||
func (m *forwardManager) bridgeHTTP(rule ForwardRule, conn net.Conn) {
|
||||
sess := m.hub.CreateForward(rule.AgentID, rule.ClientPort)
|
||||
defer m.hub.CloseSession(sess.ID)
|
||||
defer conn.Close()
|
||||
|
||||
go func() {
|
||||
defer m.hub.CloseSession(sess.ID)
|
||||
buf := make([]byte, 32*1024)
|
||||
for {
|
||||
n, err := conn.Read(buf)
|
||||
if n > 0 {
|
||||
sess.PushIn(proto.InFrame{
|
||||
Type: proto.FrameStdin,
|
||||
Data: base64.StdEncoding.EncodeToString(buf[:n]),
|
||||
})
|
||||
}
|
||||
if err != nil {
|
||||
sess.PushIn(proto.InFrame{Type: proto.FrameClose})
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-sess.Done():
|
||||
return
|
||||
case out, ok := <-sess.Out():
|
||||
if !ok || out.Exit {
|
||||
return
|
||||
}
|
||||
if out.Data != "" {
|
||||
b, err := base64.StdEncoding.DecodeString(out.Data)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
if _, err := conn.Write(b); err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/x-grk/longpolling-remote-access/internal/proto"
|
||||
)
|
||||
|
||||
// Long-poll hold window. Must stay below nginx proxy_read_timeout (120s).
|
||||
const pollHold = 25 * time.Second
|
||||
|
||||
func writeJSON(w http.ResponseWriter, v any) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(v)
|
||||
}
|
||||
|
||||
// POST /api/agent/enroll — exchange the shared enroll secret for a per-agent id+token.
|
||||
func (s *Server) handleEnroll(w http.ResponseWriter, r *http.Request) {
|
||||
secret := r.Header.Get("X-Enroll-Secret")
|
||||
if !constantTimeEqual(secret, s.db.EnrollSecret()) {
|
||||
http.Error(w, "forbidden", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
var req proto.EnrollRequest
|
||||
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<16)).Decode(&req); err != nil {
|
||||
http.Error(w, "bad request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
id, token, err := s.enroll(req.Name, req.Hostname, req.OS, req.Arch, req.Version)
|
||||
if err != nil {
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
writeJSON(w, proto.EnrollResponse{AgentID: id, Token: token})
|
||||
}
|
||||
|
||||
// GET /api/agent/poll — control channel. Held until a job is queued or timeout.
|
||||
func (s *Server) handlePoll(w http.ResponseWriter, r *http.Request) {
|
||||
a := s.authenticateAgent(r)
|
||||
if a == nil {
|
||||
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)
|
||||
} else {
|
||||
_ = s.db.TouchAgent(a.ID)
|
||||
}
|
||||
writeJSON(w, s.hub.WaitControl(a.ID, pollHold))
|
||||
}
|
||||
|
||||
// GET /api/agent/task/{id} — the agent fetches the task to run.
|
||||
func (s *Server) handleTaskFetch(w http.ResponseWriter, r *http.Request) {
|
||||
if s.authenticateAgent(r) == nil {
|
||||
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
env, ok := s.hub.GetTask(r.PathValue("id"))
|
||||
if !ok {
|
||||
http.Error(w, "no task", http.StatusGone)
|
||||
return
|
||||
}
|
||||
writeJSON(w, env)
|
||||
}
|
||||
|
||||
// POST /api/agent/task/{id}/result — the agent returns the task result.
|
||||
func (s *Server) handleTaskResult(w http.ResponseWriter, r *http.Request) {
|
||||
if s.authenticateAgent(r) == nil {
|
||||
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
var res proto.TaskResult
|
||||
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 64<<20)).Decode(&res); err != nil {
|
||||
http.Error(w, "bad request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
s.hub.CompleteTask(r.PathValue("id"), res)
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// GET /api/agent/term/{sid}/in — downstream (panel -> agent) long-poll.
|
||||
func (s *Server) handleTermIn(w http.ResponseWriter, r *http.Request) {
|
||||
if s.authenticateAgent(r) == nil {
|
||||
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
sess := s.hub.Session(r.PathValue("sid"))
|
||||
if sess == nil {
|
||||
http.Error(w, "no session", http.StatusGone)
|
||||
return
|
||||
}
|
||||
frames, alive := sess.PollIn(pollHold)
|
||||
if !alive {
|
||||
http.Error(w, "closed", http.StatusGone)
|
||||
return
|
||||
}
|
||||
writeJSON(w, proto.InBatch{Frames: frames})
|
||||
}
|
||||
|
||||
// POST /api/agent/term/{sid}/out — upstream (agent PTY -> panel).
|
||||
func (s *Server) handleTermOut(w http.ResponseWriter, r *http.Request) {
|
||||
if s.authenticateAgent(r) == nil {
|
||||
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
sess := s.hub.Session(r.PathValue("sid"))
|
||||
if sess == nil {
|
||||
http.Error(w, "no session", http.StatusGone)
|
||||
return
|
||||
}
|
||||
var msg proto.OutMessage
|
||||
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20)).Decode(&msg); err != nil {
|
||||
http.Error(w, "bad request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if !sess.PushOut(msg) {
|
||||
http.Error(w, "closed", http.StatusGone)
|
||||
return
|
||||
}
|
||||
if msg.Exit {
|
||||
s.hub.CloseSession(sess.ID)
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
@@ -0,0 +1,337 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/x-grk/longpolling-remote-access/internal/proto"
|
||||
)
|
||||
|
||||
// --- first-run setup ---
|
||||
|
||||
func (s *Server) handleSetupForm(w http.ResponseWriter, r *http.Request) {
|
||||
if s.db.SetupDone() {
|
||||
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
s.render(w, "setup.html", map[string]any{})
|
||||
}
|
||||
|
||||
func (s *Server) handleSetupSubmit(w http.ResponseWriter, r *http.Request) {
|
||||
if s.db.SetupDone() {
|
||||
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
// Guard against a race where two requests create the first admin.
|
||||
if n, _ := s.db.CountAdmins(); n > 0 {
|
||||
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
username := strings.TrimSpace(r.FormValue("username"))
|
||||
pw := r.FormValue("password")
|
||||
pw2 := r.FormValue("password2")
|
||||
if username == "" || len(pw) < 8 || pw != pw2 {
|
||||
s.render(w, "setup.html", map[string]any{
|
||||
"Error": "Введите имя и пароль (мин. 8 символов), пароли должны совпадать.",
|
||||
})
|
||||
return
|
||||
}
|
||||
hash, err := hashPassword(pw)
|
||||
if err != nil {
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if err := s.db.CreateAdmin(username, hash); err != nil {
|
||||
s.render(w, "setup.html", map[string]any{"Error": "Не удалось создать админа: " + err.Error()})
|
||||
return
|
||||
}
|
||||
_ = s.db.MarkSetupDone()
|
||||
admin, _ := s.db.AdminByUsername(username)
|
||||
if admin != nil {
|
||||
_ = s.setSession(w, admin.ID)
|
||||
}
|
||||
http.Redirect(w, r, "/", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
// --- login / logout ---
|
||||
|
||||
func (s *Server) handleLoginForm(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.db.SetupDone() {
|
||||
http.Redirect(w, r, "/setup", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
if s.currentAdmin(r) != nil {
|
||||
http.Redirect(w, r, "/", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
s.render(w, "login.html", map[string]any{})
|
||||
}
|
||||
|
||||
func (s *Server) handleLoginSubmit(w http.ResponseWriter, r *http.Request) {
|
||||
ip := clientIP(r)
|
||||
if s.limiter.blocked(ip) {
|
||||
s.render(w, "login.html", map[string]any{"Error": "Слишком много попыток. Подождите немного."})
|
||||
return
|
||||
}
|
||||
username := strings.TrimSpace(r.FormValue("username"))
|
||||
pw := r.FormValue("password")
|
||||
admin, _ := s.db.AdminByUsername(username)
|
||||
if admin == nil || !checkPassword(admin.PassHash, pw) {
|
||||
s.limiter.fin(ip, false)
|
||||
s.render(w, "login.html", map[string]any{"Error": "Неверный логин или пароль."})
|
||||
return
|
||||
}
|
||||
s.limiter.fin(ip, true)
|
||||
if err := s.setSession(w, admin.ID); err != nil {
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, "/", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) {
|
||||
s.clearSession(w, r)
|
||||
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
// --- dashboard ---
|
||||
|
||||
func (s *Server) handleDashboard(w http.ResponseWriter, r *http.Request) {
|
||||
agents, err := s.db.ListAgents()
|
||||
if err != nil {
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
type row struct {
|
||||
Agent
|
||||
Online bool
|
||||
Seen string
|
||||
}
|
||||
rows := make([]row, 0, len(agents))
|
||||
for _, a := range agents {
|
||||
rows = append(rows, row{Agent: a, Online: a.Online(), Seen: humanSince(a.LastSeen)})
|
||||
}
|
||||
s.render(w, "dashboard.html", map[string]any{
|
||||
"Admin": s.currentAdmin(r),
|
||||
"Agents": rows,
|
||||
"CSRF": csrfToken(r),
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) handleTerminalPage(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.PathValue("id")
|
||||
a, err := s.db.GetAgent(id)
|
||||
if err != nil || a == nil {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
s.render(w, "terminal.html", map[string]any{
|
||||
"Agent": a,
|
||||
"CSRF": csrfToken(r),
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) handleAgentDelete(w http.ResponseWriter, r *http.Request) {
|
||||
if !checkCSRF(r) {
|
||||
http.Error(w, "bad csrf", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
id := r.PathValue("id")
|
||||
_ = s.db.DeleteAgent(id)
|
||||
http.Redirect(w, r, "/", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func (s *Server) handleAgentEdit(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.PathValue("id")
|
||||
a, err := s.db.GetAgent(id)
|
||||
if err != nil || a == nil {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
s.render(w, "edit.html", map[string]any{
|
||||
"Agent": a,
|
||||
"CSRF": csrfToken(r),
|
||||
"ServerVersion": Version,
|
||||
"Outdated": a.Version != "" && a.Version != Version,
|
||||
"Updated": r.URL.Query().Get("updated") == "1",
|
||||
})
|
||||
}
|
||||
|
||||
// handleAgentUpdate asks the agent to download the latest binary and restart.
|
||||
func (s *Server) handleAgentUpdate(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() {
|
||||
http.Redirect(w, r, "/agents/"+id+"/edit?updated=0", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
taskID, ch := s.hub.CreateTask(id, proto.TaskSysUpdate, nil)
|
||||
defer s.hub.DiscardTask(taskID)
|
||||
select {
|
||||
case <-ch:
|
||||
case <-time.After(30 * time.Second):
|
||||
}
|
||||
http.Redirect(w, r, "/agents/"+id+"/edit?updated=1", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func (s *Server) handleAgentRename(w http.ResponseWriter, r *http.Request) {
|
||||
if !checkCSRF(r) {
|
||||
http.Error(w, "bad csrf", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
id := r.PathValue("id")
|
||||
name := strings.TrimSpace(r.FormValue("name"))
|
||||
if name != "" {
|
||||
_ = s.db.RenameAgent(id, name)
|
||||
}
|
||||
http.Redirect(w, r, "/", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
// --- settings ---
|
||||
|
||||
func (s *Server) handleSettings(w http.ResponseWriter, r *http.Request) {
|
||||
admins, _ := s.db.ListAdmins()
|
||||
host := s.db.HostAddress()
|
||||
secret := s.db.EnrollSecret()
|
||||
code := s.db.InstallCode()
|
||||
ufwInstalled, ufwActive, ufwRunnable := s.ufw.status()
|
||||
ufwRules, _ := s.db.ListUfwRules()
|
||||
s.render(w, "settings.html", map[string]any{
|
||||
"Admin": s.currentAdmin(r),
|
||||
"Host": host,
|
||||
"Secret": secret,
|
||||
"Admins": admins,
|
||||
"CSRF": csrfToken(r),
|
||||
"InstallShort": fmt.Sprintf("wget -qO- %s/i/%s | sudo sh", host, code),
|
||||
"InstallCurl": fmt.Sprintf("curl -fsSL %s/install.sh | sudo HOST=%s ENROLL_SECRET=%s sh", host, host, secret),
|
||||
"InstallWget": fmt.Sprintf("wget -qO- %s/install.sh | sudo HOST=%s ENROLL_SECRET=%s sh", host, host, secret),
|
||||
"ServerVersion": Version,
|
||||
"UfwInstalled": ufwInstalled,
|
||||
"UfwActive": ufwActive,
|
||||
"UfwRunnable": ufwRunnable,
|
||||
"UfwEnabled": s.db.UfwEnabled(),
|
||||
"UfwRules": ufwRules,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) handleSetHost(w http.ResponseWriter, r *http.Request) {
|
||||
if !checkCSRF(r) {
|
||||
http.Error(w, "bad csrf", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
host := strings.TrimRight(strings.TrimSpace(r.FormValue("host")), "/")
|
||||
if host != "" {
|
||||
_ = s.db.SetConfig(cfgHostAddress, host)
|
||||
}
|
||||
http.Redirect(w, r, "/settings", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func (s *Server) handleRotateSecret(w http.ResponseWriter, r *http.Request) {
|
||||
if !checkCSRF(r) {
|
||||
http.Error(w, "bad csrf", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
_ = s.db.SetConfig(cfgEnrollSecret, randToken())
|
||||
// Rotate the short install code too so old /i/<code> links stop working.
|
||||
_ = s.db.SetConfig(cfgInstallCode, shortCode())
|
||||
http.Redirect(w, r, "/settings", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func (s *Server) handleAddAdmin(w http.ResponseWriter, r *http.Request) {
|
||||
if !checkCSRF(r) {
|
||||
http.Error(w, "bad csrf", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
username := strings.TrimSpace(r.FormValue("username"))
|
||||
pw := r.FormValue("password")
|
||||
if username != "" && len(pw) >= 8 {
|
||||
if hash, err := hashPassword(pw); err == nil {
|
||||
_ = s.db.CreateAdmin(username, hash)
|
||||
}
|
||||
}
|
||||
http.Redirect(w, r, "/settings", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func (s *Server) handleDeleteAdmin(w http.ResponseWriter, r *http.Request) {
|
||||
if !checkCSRF(r) {
|
||||
http.Error(w, "bad csrf", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
id, _ := strconv.ParseInt(r.FormValue("id"), 10, 64)
|
||||
// Never delete the last admin.
|
||||
if n, _ := s.db.CountAdmins(); n > 1 {
|
||||
_ = s.db.DeleteAdmin(id)
|
||||
}
|
||||
http.Redirect(w, r, "/settings", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func (s *Server) handleUfwToggle(w http.ResponseWriter, r *http.Request) {
|
||||
if !checkCSRF(r) {
|
||||
http.Error(w, "bad csrf", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
_ = s.db.SetUfwEnabled(r.FormValue("enabled") == "1")
|
||||
http.Redirect(w, r, "/settings", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func (s *Server) handleUfwRevoke(w http.ResponseWriter, r *http.Request) {
|
||||
if !checkCSRF(r) {
|
||||
http.Error(w, "bad csrf", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
id, _ := strconv.ParseInt(r.FormValue("id"), 10, 64)
|
||||
s.ufw.revoke(id)
|
||||
http.Redirect(w, r, "/settings", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
// --- install script + downloads ---
|
||||
|
||||
// handleInstallScript serves the generic installer; the secret is NOT baked in,
|
||||
// so it must be supplied via env or the interactive prompt.
|
||||
func (s *Server) handleInstallScript(w http.ResponseWriter, r *http.Request) {
|
||||
s.renderInstall(w, "")
|
||||
}
|
||||
|
||||
// handleShortInstall serves the installer behind the short /i/<code> link with
|
||||
// HOST and ENROLL_SECRET already embedded, so the one-liner is just
|
||||
//
|
||||
// wget -qO- https://host/i/<code> | sudo sh
|
||||
func (s *Server) handleShortInstall(w http.ResponseWriter, r *http.Request) {
|
||||
code := r.PathValue("code")
|
||||
want := s.db.InstallCode()
|
||||
if want == "" || !constantTimeEqual(code, want) {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
s.renderInstall(w, s.db.EnrollSecret())
|
||||
}
|
||||
|
||||
// renderInstall writes install.sh with the current host and an optional secret.
|
||||
func (s *Server) renderInstall(w http.ResponseWriter, secret string) {
|
||||
w.Header().Set("Content-Type", "text/x-shellscript; charset=utf-8")
|
||||
if err := s.installTmpl.Execute(w, map[string]any{
|
||||
"Host": s.db.HostAddress(),
|
||||
"Secret": secret,
|
||||
}); err != nil {
|
||||
log.Printf("install.sh render: %v", err)
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) downloadHandler() http.Handler {
|
||||
// Agent binaries carry no secrets and are intentionally public so the
|
||||
// install one-liner works before the machine is enrolled.
|
||||
return http.FileServer(http.Dir(s.distDir))
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"path"
|
||||
"time"
|
||||
|
||||
"github.com/x-grk/longpolling-remote-access/internal/proto"
|
||||
)
|
||||
|
||||
// fsOps maps the URL op segment to a proto task type.
|
||||
var fsOps = map[string]string{
|
||||
"list": proto.TaskFSList,
|
||||
"read": proto.TaskFSRead,
|
||||
"write": proto.TaskFSWrite,
|
||||
"mkdir": proto.TaskFSMkdir,
|
||||
"delete": proto.TaskFSDelete,
|
||||
"deltree": proto.TaskFSDelTree,
|
||||
"unzip": proto.TaskFSUnzip,
|
||||
"zip": proto.TaskFSZip,
|
||||
"move": proto.TaskFSMove,
|
||||
}
|
||||
|
||||
// fsTaskTimeout bounds how long we wait for an agent to answer a file op.
|
||||
const fsTaskTimeout = 60 * time.Second
|
||||
|
||||
func (s *Server) handleFilesPage(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.PathValue("id")
|
||||
a, err := s.db.GetAgent(id)
|
||||
if err != nil || a == nil {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
s.render(w, "files.html", map[string]any{"Agent": a})
|
||||
}
|
||||
|
||||
// POST /api/panel/agents/{id}/fs/{op} — run a file-manager operation on the
|
||||
// agent and return its result. The request body is the op's JSON args and is
|
||||
// forwarded verbatim to the agent.
|
||||
func (s *Server) handleFSOp(w http.ResponseWriter, r *http.Request) {
|
||||
agentID := r.PathValue("id")
|
||||
op := r.PathValue("op")
|
||||
taskType, ok := fsOps[op]
|
||||
if !ok {
|
||||
http.Error(w, "unknown op", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
a, err := s.db.GetAgent(agentID)
|
||||
if err != nil || a == nil {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
if !a.Online() {
|
||||
writeJSON(w, proto.TaskResult{Error: "машина офлайн"})
|
||||
return
|
||||
}
|
||||
args, err := io.ReadAll(http.MaxBytesReader(w, r.Body, 64<<20))
|
||||
if err != nil {
|
||||
http.Error(w, "bad request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
taskID, resultCh := s.hub.CreateTask(agentID, taskType, args)
|
||||
defer s.hub.DiscardTask(taskID)
|
||||
|
||||
select {
|
||||
case res := <-resultCh:
|
||||
writeJSON(w, res)
|
||||
case <-time.After(fsTaskTimeout):
|
||||
writeJSON(w, proto.TaskResult{Error: "таймаут ожидания агента"})
|
||||
}
|
||||
}
|
||||
|
||||
// GET /api/panel/agents/{id}/fs/download?path=... — stream a file as a download.
|
||||
func (s *Server) handleFSDownload(w http.ResponseWriter, r *http.Request) {
|
||||
agentID := r.PathValue("id")
|
||||
p := r.URL.Query().Get("path")
|
||||
a, err := s.db.GetAgent(agentID)
|
||||
if err != nil || a == nil {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
if !a.Online() {
|
||||
http.Error(w, "машина офлайн", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
args, _ := json.Marshal(proto.FSPathArgs{Path: p})
|
||||
taskID, ch := s.hub.CreateTask(agentID, proto.TaskFSDownload, args)
|
||||
defer s.hub.DiscardTask(taskID)
|
||||
|
||||
select {
|
||||
case res := <-ch:
|
||||
if !res.OK {
|
||||
http.Error(w, res.Error, http.StatusBadGateway)
|
||||
return
|
||||
}
|
||||
var rr proto.FSReadResult
|
||||
if err := json.Unmarshal(res.Data, &rr); err != nil {
|
||||
http.Error(w, "bad result", http.StatusBadGateway)
|
||||
return
|
||||
}
|
||||
data, _ := base64.StdEncoding.DecodeString(rr.Content)
|
||||
name := path.Base(p)
|
||||
w.Header().Set("Content-Type", "application/octet-stream")
|
||||
w.Header().Set("Content-Disposition", "attachment; filename=\""+name+"\"")
|
||||
if rr.Truncated {
|
||||
w.Header().Set("X-Truncated", "1")
|
||||
}
|
||||
_, _ = w.Write(data)
|
||||
case <-time.After(fsTaskTimeout):
|
||||
http.Error(w, "таймаут ожидания агента", http.StatusGatewayTimeout)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"log"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func (s *Server) handleForwardsPage(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.PathValue("id")
|
||||
a, err := s.db.GetAgent(id)
|
||||
if err != nil || a == nil {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
rules, _ := s.db.ListForwardRulesByAgent(id)
|
||||
errMsg := map[string]string{
|
||||
"busy": "Этот порт хоста уже занят другим правилом.",
|
||||
"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,
|
||||
})
|
||||
}
|
||||
|
||||
// POST /agents/{id}/forwards/add
|
||||
func (s *Server) handleForwardAdd(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
|
||||
}
|
||||
clientPort, _ := strconv.Atoi(strings.TrimSpace(r.FormValue("client_port")))
|
||||
hostPort, _ := strconv.Atoi(strings.TrimSpace(r.FormValue("host_port")))
|
||||
listenAddr := strings.TrimSpace(r.FormValue("listen_addr"))
|
||||
if listenAddr == "" {
|
||||
listenAddr = "0.0.0.0"
|
||||
}
|
||||
protoKind := strings.TrimSpace(r.FormValue("proto"))
|
||||
switch protoKind {
|
||||
case "http", "tcp", "udp":
|
||||
default:
|
||||
protoKind = "http"
|
||||
}
|
||||
if clientPort < 1 || clientPort > 65535 || hostPort < 1 || hostPort > 65535 {
|
||||
http.Redirect(w, r, "/agents/"+id+"/forwards?err=port", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
// Reject a host port already bound by another forward rule.
|
||||
if s.db.HostPortUsed(hostPort) {
|
||||
http.Redirect(w, r, "/agents/"+id+"/forwards?err=busy", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
rule := ForwardRule{AgentID: id, ClientPort: clientPort, HostPort: hostPort, ListenAddr: listenAddr, Proto: protoKind}
|
||||
ruleID, err := s.db.CreateForwardRule(rule)
|
||||
if err != nil {
|
||||
http.Redirect(w, r, "/agents/"+id+"/forwards?err=db", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
rule.ID = ruleID
|
||||
if err := s.fwd.open(rule); err != nil {
|
||||
// Roll back the rule if the port can't be bound.
|
||||
_ = s.db.DeleteForwardRule(ruleID)
|
||||
log.Printf("forward open %s:%d: %v", listenAddr, hostPort, err)
|
||||
http.Redirect(w, r, "/agents/"+id+"/forwards?err=listen", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
// Open the port in ufw too (if integration is enabled and ufw is active).
|
||||
s.ufw.allowForward(rule)
|
||||
http.Redirect(w, r, "/agents/"+id+"/forwards", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
// POST /forwards/{rid}/delete
|
||||
func (s *Server) handleForwardDelete(w http.ResponseWriter, r *http.Request) {
|
||||
if !checkCSRF(r) {
|
||||
http.Error(w, "bad csrf", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
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)
|
||||
dest := "/"
|
||||
if rule != nil {
|
||||
dest = "/agents/" + rule.AgentID + "/forwards"
|
||||
}
|
||||
http.Redirect(w, r, dest, http.StatusSeeOther)
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"log"
|
||||
"net/http"
|
||||
|
||||
"github.com/x-grk/longpolling-remote-access/internal/proto"
|
||||
)
|
||||
|
||||
// The browser terminal talks to the server over plain HTTP long-polling (the
|
||||
// same transport the agent uses), so it works through any reverse proxy without
|
||||
// WebSocket upgrade support.
|
||||
|
||||
// POST /api/panel/term/{agentID}/open — create a terminal session.
|
||||
func (s *Server) handlePanelOpen(w http.ResponseWriter, r *http.Request) {
|
||||
agentID := r.PathValue("agentID")
|
||||
agent, err := s.db.GetAgent(agentID)
|
||||
if err != nil || agent == nil {
|
||||
log.Printf("panel open: agent %q not found (err=%v)", agentID, err)
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
sess := s.hub.CreateSession(agentID)
|
||||
if admin := s.currentAdmin(r); admin != nil {
|
||||
s.db.Audit(admin.ID, agentID, "open_terminal")
|
||||
}
|
||||
writeJSON(w, map[string]string{"session_id": sess.ID})
|
||||
}
|
||||
|
||||
// GET /api/panel/term/{sid}/out — long-poll for PTY output.
|
||||
func (s *Server) handlePanelOut(w http.ResponseWriter, r *http.Request) {
|
||||
sess := s.hub.Session(r.PathValue("sid"))
|
||||
if sess == nil {
|
||||
writeJSON(w, map[string]any{"closed": true})
|
||||
return
|
||||
}
|
||||
msgs, alive := sess.PollOut(pollHold)
|
||||
if !alive {
|
||||
writeJSON(w, map[string]any{"closed": true})
|
||||
return
|
||||
}
|
||||
writeJSON(w, map[string]any{"frames": msgs})
|
||||
}
|
||||
|
||||
// panelInMsg is what the browser posts for keystrokes/resize.
|
||||
type panelInMsg struct {
|
||||
T string `json:"t"` // "i" input, "r" resize
|
||||
D string `json:"d,omitempty"`
|
||||
Cols uint16 `json:"cols,omitempty"`
|
||||
Rows uint16 `json:"rows,omitempty"`
|
||||
}
|
||||
|
||||
// POST /api/panel/term/{sid}/in — send keystrokes/resize to the agent PTY.
|
||||
func (s *Server) handlePanelIn(w http.ResponseWriter, r *http.Request) {
|
||||
sess := s.hub.Session(r.PathValue("sid"))
|
||||
if sess == nil {
|
||||
http.Error(w, "closed", http.StatusGone)
|
||||
return
|
||||
}
|
||||
var m panelInMsg
|
||||
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20)).Decode(&m); err != nil {
|
||||
http.Error(w, "bad request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
switch m.T {
|
||||
case "i":
|
||||
sess.PushIn(proto.InFrame{Type: proto.FrameStdin, Data: m.D})
|
||||
case "r":
|
||||
sess.PushIn(proto.InFrame{Type: proto.FrameResize, Cols: m.Cols, Rows: m.Rows})
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// POST /api/panel/term/{sid}/close — tear the session down.
|
||||
func (s *Server) handlePanelClose(w http.ResponseWriter, r *http.Request) {
|
||||
s.hub.CloseSession(r.PathValue("sid"))
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
@@ -0,0 +1,258 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/x-grk/longpolling-remote-access/internal/proto"
|
||||
)
|
||||
|
||||
// Hub is the in-memory broker between agents (HTTP long-polling) and the panel.
|
||||
// It carries three kinds of work to an agent through its single control poll:
|
||||
// terminal sessions, port-forward streams, and one-shot tasks (file manager).
|
||||
// Nothing here is persisted.
|
||||
type Hub struct {
|
||||
mu sync.Mutex
|
||||
|
||||
// jobs holds control messages queued for an agent but not yet delivered via
|
||||
// its control poll.
|
||||
jobs map[string][]proto.ControlMessage
|
||||
// ctrlWaiters wakes a blocked control poll the moment a job is enqueued.
|
||||
ctrlWaiters map[string]chan struct{}
|
||||
|
||||
sessions map[string]*Session
|
||||
tasks map[string]*taskEntry
|
||||
}
|
||||
|
||||
// taskEntry is a pending one-shot task awaiting the agent's result.
|
||||
type taskEntry struct {
|
||||
env proto.TaskEnvelope
|
||||
result chan proto.TaskResult
|
||||
}
|
||||
|
||||
// Session is one live byte stream between the panel and an agent (a PTY terminal
|
||||
// or a raw TCP forward — the hub treats both identically).
|
||||
type Session struct {
|
||||
ID string
|
||||
AgentID string
|
||||
|
||||
mu sync.Mutex
|
||||
inBuf []proto.InFrame
|
||||
inSignal chan struct{} // size-1; pulsed when inBuf gains data
|
||||
|
||||
out chan proto.OutMessage // agent -> panel
|
||||
|
||||
closed chan struct{}
|
||||
closeOnce sync.Once
|
||||
}
|
||||
|
||||
func NewHub() *Hub {
|
||||
return &Hub{
|
||||
jobs: map[string][]proto.ControlMessage{},
|
||||
ctrlWaiters: map[string]chan struct{}{},
|
||||
sessions: map[string]*Session{},
|
||||
tasks: map[string]*taskEntry{},
|
||||
}
|
||||
}
|
||||
|
||||
// enqueueJob appends a control message for an agent and wakes its poll.
|
||||
func (h *Hub) enqueueJob(agentID string, msg proto.ControlMessage) {
|
||||
h.mu.Lock()
|
||||
h.jobs[agentID] = append(h.jobs[agentID], msg)
|
||||
if w := h.ctrlWaiters[agentID]; w != nil {
|
||||
select {
|
||||
case w <- struct{}{}:
|
||||
default:
|
||||
}
|
||||
}
|
||||
h.mu.Unlock()
|
||||
}
|
||||
|
||||
func (h *Hub) newSession(agentID string) *Session {
|
||||
s := &Session{
|
||||
ID: randToken()[:16],
|
||||
AgentID: agentID,
|
||||
inSignal: make(chan struct{}, 1),
|
||||
out: make(chan proto.OutMessage, 1024),
|
||||
closed: make(chan struct{}),
|
||||
}
|
||||
h.mu.Lock()
|
||||
h.sessions[s.ID] = s
|
||||
h.mu.Unlock()
|
||||
return s
|
||||
}
|
||||
|
||||
// CreateSession registers a terminal session and queues it for the agent.
|
||||
func (h *Hub) CreateSession(agentID string) *Session {
|
||||
s := h.newSession(agentID)
|
||||
h.enqueueJob(agentID, proto.ControlMessage{Type: proto.CtrlOpenTerminal, SessionID: s.ID})
|
||||
return s
|
||||
}
|
||||
|
||||
// CreateForward registers a port-forward stream (agent dials 127.0.0.1:port).
|
||||
func (h *Hub) CreateForward(agentID string, port int) *Session {
|
||||
s := h.newSession(agentID)
|
||||
h.enqueueJob(agentID, proto.ControlMessage{Type: proto.CtrlOpenForward, SessionID: s.ID, Port: port})
|
||||
return s
|
||||
}
|
||||
|
||||
// WaitControl blocks until a job is queued for agentID or timeout elapses.
|
||||
func (h *Hub) WaitControl(agentID string, timeout time.Duration) proto.ControlMessage {
|
||||
deadline := time.After(timeout)
|
||||
for {
|
||||
h.mu.Lock()
|
||||
if q := h.jobs[agentID]; len(q) > 0 {
|
||||
msg := q[0]
|
||||
h.jobs[agentID] = q[1:]
|
||||
h.mu.Unlock()
|
||||
return msg
|
||||
}
|
||||
w := h.ctrlWaiters[agentID]
|
||||
if w == nil {
|
||||
w = make(chan struct{}, 1)
|
||||
h.ctrlWaiters[agentID] = w
|
||||
}
|
||||
h.mu.Unlock()
|
||||
|
||||
select {
|
||||
case <-w:
|
||||
case <-deadline:
|
||||
return proto.ControlMessage{Type: proto.CtrlIdle}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Hub) Session(id string) *Session {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
return h.sessions[id]
|
||||
}
|
||||
|
||||
func (h *Hub) CloseSession(id string) {
|
||||
h.mu.Lock()
|
||||
s := h.sessions[id]
|
||||
delete(h.sessions, id)
|
||||
h.mu.Unlock()
|
||||
if s != nil {
|
||||
s.close()
|
||||
}
|
||||
}
|
||||
|
||||
// --- one-shot tasks ---
|
||||
|
||||
// CreateTask queues a task and returns its id plus a channel for the result.
|
||||
func (h *Hub) CreateTask(agentID, typ string, args []byte) (string, chan proto.TaskResult) {
|
||||
id := randToken()[:16]
|
||||
entry := &taskEntry{
|
||||
env: proto.TaskEnvelope{Type: typ, Args: args},
|
||||
result: make(chan proto.TaskResult, 1),
|
||||
}
|
||||
h.mu.Lock()
|
||||
h.tasks[id] = entry
|
||||
h.mu.Unlock()
|
||||
h.enqueueJob(agentID, proto.ControlMessage{Type: proto.CtrlTask, TaskID: id})
|
||||
return id, entry.result
|
||||
}
|
||||
|
||||
// GetTask returns the envelope the agent should execute.
|
||||
func (h *Hub) GetTask(id string) (proto.TaskEnvelope, bool) {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
e := h.tasks[id]
|
||||
if e == nil {
|
||||
return proto.TaskEnvelope{}, false
|
||||
}
|
||||
return e.env, true
|
||||
}
|
||||
|
||||
// CompleteTask delivers a result to the waiting panel handler.
|
||||
func (h *Hub) CompleteTask(id string, res proto.TaskResult) {
|
||||
h.mu.Lock()
|
||||
e := h.tasks[id]
|
||||
h.mu.Unlock()
|
||||
if e != nil {
|
||||
select {
|
||||
case e.result <- res:
|
||||
default:
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// DiscardTask drops a task from the registry.
|
||||
func (h *Hub) DiscardTask(id string) {
|
||||
h.mu.Lock()
|
||||
delete(h.tasks, id)
|
||||
h.mu.Unlock()
|
||||
}
|
||||
|
||||
func (s *Session) close() { s.closeOnce.Do(func() { close(s.closed) }) }
|
||||
|
||||
// Done returns a channel closed when the session ends.
|
||||
func (s *Session) Done() <-chan struct{} { return s.closed }
|
||||
|
||||
// PushIn appends a downstream frame (panel -> agent) and wakes the agent poll.
|
||||
func (s *Session) PushIn(f proto.InFrame) {
|
||||
s.mu.Lock()
|
||||
s.inBuf = append(s.inBuf, f)
|
||||
s.mu.Unlock()
|
||||
select {
|
||||
case s.inSignal <- struct{}{}:
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
// PollIn drains queued downstream frames, blocking up to timeout for the first.
|
||||
func (s *Session) PollIn(timeout time.Duration) ([]proto.InFrame, bool) {
|
||||
deadline := time.After(timeout)
|
||||
for {
|
||||
s.mu.Lock()
|
||||
if len(s.inBuf) > 0 {
|
||||
b := s.inBuf
|
||||
s.inBuf = nil
|
||||
s.mu.Unlock()
|
||||
return b, true
|
||||
}
|
||||
s.mu.Unlock()
|
||||
|
||||
select {
|
||||
case <-s.inSignal:
|
||||
case <-deadline:
|
||||
return nil, true
|
||||
case <-s.closed:
|
||||
return nil, false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// PushOut delivers agent output to the panel side. Returns false if closed.
|
||||
func (s *Session) PushOut(m proto.OutMessage) bool {
|
||||
select {
|
||||
case s.out <- m:
|
||||
return true
|
||||
case <-s.closed:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Session) Out() <-chan proto.OutMessage { return s.out }
|
||||
|
||||
// PollOut drains queued upstream messages, blocking up to timeout for the first.
|
||||
func (s *Session) PollOut(timeout time.Duration) ([]proto.OutMessage, bool) {
|
||||
var batch []proto.OutMessage
|
||||
select {
|
||||
case m := <-s.out:
|
||||
batch = append(batch, m)
|
||||
case <-time.After(timeout):
|
||||
return nil, true
|
||||
case <-s.closed:
|
||||
return nil, false
|
||||
}
|
||||
for {
|
||||
select {
|
||||
case m := <-s.out:
|
||||
batch = append(batch, m)
|
||||
default:
|
||||
return batch, true
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,232 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"embed"
|
||||
"html/template"
|
||||
"io/fs"
|
||||
"log"
|
||||
"net"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
texttemplate "text/template"
|
||||
)
|
||||
|
||||
//go:embed web
|
||||
var webFS embed.FS
|
||||
|
||||
// Options configures a Server.
|
||||
type Options struct {
|
||||
DB *DB
|
||||
// DistDir holds the compiled agent binaries served under /download/.
|
||||
DistDir string
|
||||
// SecureCookies marks session cookies Secure. True in production (behind
|
||||
// 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.
|
||||
TunnelAddr string
|
||||
}
|
||||
|
||||
// Server is the HTTP host: admin panel + agent rendezvous.
|
||||
type Server struct {
|
||||
db *DB
|
||||
hub *Hub
|
||||
fwd *forwardManager
|
||||
ufw *ufwManager
|
||||
tmpl *template.Template
|
||||
limiter *loginLimiter
|
||||
distDir string
|
||||
secureCookies bool
|
||||
installTmpl *texttemplate.Template
|
||||
}
|
||||
|
||||
// New builds the server, parsing embedded templates.
|
||||
func New(opts Options) (*Server, error) {
|
||||
if err := opts.DB.ensureDefaults(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
tmpl, err := template.ParseFS(webFS, "web/templates/*.html")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
installRaw, err := webFS.ReadFile("web/install.sh.tmpl")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
installTmpl, err := texttemplate.New("install").Parse(string(installRaw))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
hub := NewHub()
|
||||
tun := newTunnelHub(opts.DB)
|
||||
udp := newUDPRelay(opts.DB)
|
||||
s := &Server{
|
||||
db: opts.DB,
|
||||
hub: hub,
|
||||
fwd: newForwardManager(hub, tun, udp),
|
||||
ufw: newUfwManager(opts.DB, portFromAddr(opts.TunnelAddr)),
|
||||
tmpl: tmpl,
|
||||
limiter: newLoginLimiter(),
|
||||
distDir: opts.DistDir,
|
||||
secureCookies: opts.SecureCookies,
|
||||
installTmpl: installTmpl,
|
||||
}
|
||||
// Start the direct forward tunnel (TCP mux + UDP) on 8446.
|
||||
if opts.TunnelAddr != "" {
|
||||
if err := tun.listen(opts.TunnelAddr); err != nil {
|
||||
log.Printf("tunnel tcp listen %s: %v", opts.TunnelAddr, err)
|
||||
}
|
||||
if err := udp.listen(opts.TunnelAddr); err != nil {
|
||||
log.Printf("tunnel udp listen %s: %v", opts.TunnelAddr, err)
|
||||
}
|
||||
}
|
||||
s.restoreForwards()
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// restoreForwards re-opens the host listeners for all saved forward rules.
|
||||
func (s *Server) restoreForwards() {
|
||||
rules, err := s.db.ListForwardRules()
|
||||
if err != nil {
|
||||
log.Printf("restore forwards: %v", err)
|
||||
return
|
||||
}
|
||||
for _, r := range rules {
|
||||
if err := s.fwd.open(r); err != nil {
|
||||
log.Printf("restore forward rule %d (%s:%d): %v", r.ID, r.ListenAddr, r.HostPort, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Handler returns the root http.Handler with all routes mounted.
|
||||
func (s *Server) Handler() http.Handler {
|
||||
mux := http.NewServeMux()
|
||||
|
||||
// Static assets (xterm.js etc.) from the embedded FS.
|
||||
staticFS, _ := fs.Sub(webFS, "web/static")
|
||||
mux.Handle("GET /static/", http.StripPrefix("/static/", http.FileServer(http.FS(staticFS))))
|
||||
|
||||
// Public: install script + agent binary downloads.
|
||||
mux.HandleFunc("GET /install.sh", s.handleInstallScript)
|
||||
mux.HandleFunc("GET /i/{code}", s.handleShortInstall)
|
||||
mux.Handle("GET /download/", http.StripPrefix("/download/", s.downloadHandler()))
|
||||
|
||||
// First-run setup wizard.
|
||||
mux.HandleFunc("GET /setup", s.handleSetupForm)
|
||||
mux.HandleFunc("POST /setup", s.handleSetupSubmit)
|
||||
|
||||
// Auth.
|
||||
mux.HandleFunc("GET /login", s.handleLoginForm)
|
||||
mux.HandleFunc("POST /login", s.handleLoginSubmit)
|
||||
mux.HandleFunc("POST /logout", s.handleLogout)
|
||||
|
||||
// Panel (admin-only).
|
||||
mux.Handle("GET /{$}", s.requireAdmin(s.handleDashboard))
|
||||
mux.Handle("GET /agents/{id}/terminal", s.requireAdmin(s.handleTerminalPage))
|
||||
mux.Handle("GET /agents/{id}/files", s.requireAdmin(s.handleFilesPage))
|
||||
mux.Handle("GET /agents/{id}/forwards", s.requireAdmin(s.handleForwardsPage))
|
||||
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}/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/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))
|
||||
mux.Handle("POST /settings/ufw/toggle", s.requireAdmin(s.handleUfwToggle))
|
||||
mux.Handle("POST /settings/ufw/revoke", s.requireAdmin(s.handleUfwRevoke))
|
||||
|
||||
// Panel terminal over HTTP long-polling (admin-only).
|
||||
mux.Handle("POST /api/panel/term/{agentID}/open", s.requireAdmin(s.handlePanelOpen))
|
||||
mux.Handle("GET /api/panel/term/{sid}/out", s.requireAdmin(s.handlePanelOut))
|
||||
mux.Handle("POST /api/panel/term/{sid}/in", s.requireAdmin(s.handlePanelIn))
|
||||
mux.Handle("POST /api/panel/term/{sid}/close", s.requireAdmin(s.handlePanelClose))
|
||||
|
||||
// Panel file manager (admin-only).
|
||||
mux.Handle("GET /api/panel/agents/{id}/fs/download", s.requireAdmin(s.handleFSDownload))
|
||||
mux.Handle("POST /api/panel/agents/{id}/fs/{op}", s.requireAdmin(s.handleFSOp))
|
||||
|
||||
// Panel port forwarding (admin-only).
|
||||
mux.Handle("POST /agents/{id}/forwards/add", s.requireAdmin(s.handleForwardAdd))
|
||||
mux.Handle("POST /forwards/{rid}/delete", s.requireAdmin(s.handleForwardDelete))
|
||||
|
||||
// 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/term/{sid}/in", s.handleTermIn)
|
||||
mux.HandleFunc("POST /api/agent/term/{sid}/out", s.handleTermOut)
|
||||
mux.HandleFunc("GET /api/agent/task/{id}", s.handleTaskFetch)
|
||||
mux.HandleFunc("POST /api/agent/task/{id}/result", s.handleTaskResult)
|
||||
|
||||
return s.logRequests(mux)
|
||||
}
|
||||
|
||||
// requireAdmin is middleware that redirects anonymous users appropriately.
|
||||
func (s *Server) requireAdmin(h http.HandlerFunc) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.db.SetupDone() {
|
||||
http.Redirect(w, r, "/setup", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
admin := s.currentAdmin(r)
|
||||
if admin == nil {
|
||||
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
h(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) logRequests(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
// Keep terminal/poll paths quiet; they are frequent and long-lived.
|
||||
if !strings.Contains(r.URL.Path, "/api/agent/") && !strings.Contains(r.URL.Path, "/api/panel/") {
|
||||
log.Printf("%s %s %s", clientIP(r), r.Method, r.URL.Path)
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) render(w http.ResponseWriter, name string, data map[string]any) {
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
// Panel pages carry inline JS that changes between releases; never let a
|
||||
// browser or proxy serve a stale copy.
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
if err := s.tmpl.ExecuteTemplate(w, name, data); err != nil {
|
||||
log.Printf("template %s: %v", name, err)
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
||||
// 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)
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
n, _ := strconv.Atoi(p)
|
||||
return n
|
||||
}
|
||||
|
||||
func clientIP(r *http.Request) string {
|
||||
// nginx sets X-Forwarded-For / X-Real-IP; trust the first hop.
|
||||
if v := r.Header.Get("X-Real-IP"); v != "" {
|
||||
return v
|
||||
}
|
||||
if v := r.Header.Get("X-Forwarded-For"); v != "" {
|
||||
if i := strings.IndexByte(v, ','); i >= 0 {
|
||||
return strings.TrimSpace(v[:i])
|
||||
}
|
||||
return strings.TrimSpace(v)
|
||||
}
|
||||
host := r.RemoteAddr
|
||||
if i := strings.LastIndexByte(host, ':'); i >= 0 {
|
||||
host = host[:i]
|
||||
}
|
||||
return host
|
||||
}
|
||||
@@ -0,0 +1,241 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/x-grk/longpolling-remote-access/internal/tunnel"
|
||||
)
|
||||
|
||||
// readLine reads a single '\n'-terminated line, up to max bytes, one byte at a
|
||||
// time so it never buffers past the delimiter into following binary frames.
|
||||
func readLine(r io.Reader, max int) (string, error) {
|
||||
var b []byte
|
||||
buf := make([]byte, 1)
|
||||
for len(b) < max {
|
||||
n, err := r.Read(buf)
|
||||
if n > 0 {
|
||||
if buf[0] == '\n' {
|
||||
return string(b), nil
|
||||
}
|
||||
b = append(b, buf[0])
|
||||
}
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
return string(b), nil
|
||||
}
|
||||
|
||||
// tunnelHub tracks the live direct-tunnel connection for each agent and
|
||||
// multiplexes forwarded TCP streams over it.
|
||||
type tunnelHub struct {
|
||||
db *DB
|
||||
mu sync.Mutex
|
||||
agents map[string]*agentTunnel
|
||||
}
|
||||
|
||||
func newTunnelHub(db *DB) *tunnelHub {
|
||||
return &tunnelHub{db: db, agents: map[string]*agentTunnel{}}
|
||||
}
|
||||
|
||||
// agentTunnel is one authenticated direct tunnel to an agent.
|
||||
type agentTunnel struct {
|
||||
agentID string
|
||||
conn net.Conn
|
||||
wmu sync.Mutex // serialize frame writes
|
||||
|
||||
mu sync.Mutex
|
||||
nextID uint32
|
||||
streams map[uint32]net.Conn
|
||||
}
|
||||
|
||||
// listen runs the 8446 TCP tunnel acceptor until the listener closes.
|
||||
func (h *tunnelHub) listen(addr string) error {
|
||||
ln, err := net.Listen("tcp", addr)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
log.Printf("tunnel: listening on %s (direct TCP forwarding)", addr)
|
||||
go func() {
|
||||
for {
|
||||
conn, err := ln.Accept()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
go h.handleConn(conn)
|
||||
}
|
||||
}()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (h *tunnelHub) handleConn(conn net.Conn) {
|
||||
if tc, ok := conn.(*net.TCPConn); ok {
|
||||
_ = tc.SetNoDelay(true)
|
||||
}
|
||||
agentID, err := h.authenticate(conn)
|
||||
if err != nil {
|
||||
conn.Close()
|
||||
return
|
||||
}
|
||||
at := &agentTunnel{agentID: agentID, conn: conn, streams: map[uint32]net.Conn{}}
|
||||
|
||||
h.mu.Lock()
|
||||
if old := h.agents[agentID]; old != nil {
|
||||
old.conn.Close()
|
||||
}
|
||||
h.agents[agentID] = at
|
||||
h.mu.Unlock()
|
||||
log.Printf("tunnel: agent %s connected", agentID)
|
||||
|
||||
go at.keepalive()
|
||||
at.readLoop()
|
||||
|
||||
h.mu.Lock()
|
||||
if h.agents[agentID] == at {
|
||||
delete(h.agents, agentID)
|
||||
}
|
||||
h.mu.Unlock()
|
||||
at.shutdown()
|
||||
log.Printf("tunnel: agent %s disconnected", agentID)
|
||||
}
|
||||
|
||||
// authenticate performs the nonce/HMAC handshake and returns the agent id.
|
||||
func (h *tunnelHub) authenticate(conn net.Conn) (string, error) {
|
||||
_ = conn.SetDeadline(time.Now().Add(10 * time.Second))
|
||||
nonce := make([]byte, 32)
|
||||
if _, err := rand.Read(nonce); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if _, err := fmt.Fprintf(conn, "LPRA-TUN1 %s\n", hex.EncodeToString(nonce)); err != nil {
|
||||
return "", err
|
||||
}
|
||||
line, err := readLine(conn, 512)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
parts := strings.Fields(strings.TrimSpace(line))
|
||||
if len(parts) != 2 {
|
||||
return "", fmt.Errorf("bad handshake")
|
||||
}
|
||||
agentID, sig := parts[0], parts[1]
|
||||
agent, err := h.db.GetAgent(agentID)
|
||||
if err != nil || agent == nil {
|
||||
return "", fmt.Errorf("unknown agent")
|
||||
}
|
||||
key, err := tunnel.KeyFromHash(agent.TokenHash)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if !tunnel.EqualSig(sig, tunnel.Sign(key, nonce)) {
|
||||
return "", fmt.Errorf("bad signature")
|
||||
}
|
||||
_ = conn.SetDeadline(time.Time{}) // clear
|
||||
return agentID, nil
|
||||
}
|
||||
|
||||
func (at *agentTunnel) writeFrame(f tunnel.Frame) error {
|
||||
at.wmu.Lock()
|
||||
defer at.wmu.Unlock()
|
||||
return tunnel.WriteFrame(at.conn, f)
|
||||
}
|
||||
|
||||
func (at *agentTunnel) keepalive() {
|
||||
t := time.NewTicker(20 * time.Second)
|
||||
defer t.Stop()
|
||||
for range t.C {
|
||||
if at.writeFrame(tunnel.Frame{Type: tunnel.TypePing}) != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// readLoop demultiplexes frames from the agent to their streams.
|
||||
func (at *agentTunnel) readLoop() {
|
||||
for {
|
||||
f, err := tunnel.ReadFrame(at.conn)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
switch f.Type {
|
||||
case tunnel.TypeData:
|
||||
at.mu.Lock()
|
||||
c := at.streams[f.Stream]
|
||||
at.mu.Unlock()
|
||||
if c != nil {
|
||||
_, _ = c.Write(f.Payload)
|
||||
}
|
||||
case tunnel.TypeClose:
|
||||
at.closeStream(f.Stream)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// openStream wires a freshly accepted user connection to a new agent stream
|
||||
// targeting 127.0.0.1:port on the client.
|
||||
func (at *agentTunnel) openStream(port int, userConn net.Conn) {
|
||||
at.mu.Lock()
|
||||
at.nextID++
|
||||
id := at.nextID
|
||||
at.streams[id] = userConn
|
||||
at.mu.Unlock()
|
||||
|
||||
// Tell the agent to dial the client-side port.
|
||||
if at.writeFrame(tunnel.Frame{Type: tunnel.TypeOpen, Stream: id, Payload: []byte{byte(port >> 8), byte(port)}}) != nil {
|
||||
at.closeStream(id)
|
||||
return
|
||||
}
|
||||
|
||||
// user -> agent
|
||||
go func() {
|
||||
buf := make([]byte, 32*1024)
|
||||
for {
|
||||
n, err := userConn.Read(buf)
|
||||
if n > 0 {
|
||||
if at.writeFrame(tunnel.Frame{Type: tunnel.TypeData, Stream: id, Payload: append([]byte(nil), buf[:n]...)}) != nil {
|
||||
break
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
break
|
||||
}
|
||||
}
|
||||
_ = at.writeFrame(tunnel.Frame{Type: tunnel.TypeClose, Stream: id})
|
||||
at.closeStream(id)
|
||||
}()
|
||||
}
|
||||
|
||||
func (at *agentTunnel) closeStream(id uint32) {
|
||||
at.mu.Lock()
|
||||
c := at.streams[id]
|
||||
delete(at.streams, id)
|
||||
at.mu.Unlock()
|
||||
if c != nil {
|
||||
c.Close()
|
||||
}
|
||||
}
|
||||
|
||||
func (at *agentTunnel) shutdown() {
|
||||
at.mu.Lock()
|
||||
conns := at.streams
|
||||
at.streams = map[uint32]net.Conn{}
|
||||
at.mu.Unlock()
|
||||
for _, c := range conns {
|
||||
c.Close()
|
||||
}
|
||||
at.conn.Close()
|
||||
}
|
||||
|
||||
// get returns the live tunnel for an agent, or nil.
|
||||
func (h *tunnelHub) get(agentID string) *agentTunnel {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
return h.agents[agentID]
|
||||
}
|
||||
@@ -0,0 +1,246 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"encoding/binary"
|
||||
"log"
|
||||
"net"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/x-grk/longpolling-remote-access/internal/tunnel"
|
||||
)
|
||||
|
||||
// udpRelay carries UDP forwards over the direct 8446 UDP channel. The agent
|
||||
// registers its UDP return address; end-user datagrams arriving on a rule's
|
||||
// host port are relayed to the agent and replies are sent back.
|
||||
//
|
||||
// Datagram framing on the 8446 control socket:
|
||||
//
|
||||
// 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
|
||||
|
||||
agentAddr map[string]*net.UDPAddr // agentID -> last registered addr
|
||||
sessions map[uint32]*udpSession
|
||||
nextSID uint32
|
||||
rulePorts map[int64]*net.UDPConn // ruleID -> per-rule listener
|
||||
}
|
||||
|
||||
type udpSession struct {
|
||||
id uint32
|
||||
agentID string
|
||||
ruleConn *net.UDPConn
|
||||
userAddr *net.UDPAddr
|
||||
clientPort int
|
||||
last time.Time
|
||||
}
|
||||
|
||||
func newUDPRelay(db *DB) *udpRelay {
|
||||
return &udpRelay{
|
||||
db: db,
|
||||
agentAddr: map[string]*net.UDPAddr{},
|
||||
sessions: map[uint32]*udpSession{},
|
||||
rulePorts: map[int64]*net.UDPConn{},
|
||||
}
|
||||
}
|
||||
|
||||
// listen opens the shared 8446 UDP control socket.
|
||||
func (u *udpRelay) listen(addr string) error {
|
||||
ua, err := net.ResolveUDPAddr("udp", addr)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
c, err := net.ListenUDP("udp", ua)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
u.ctrl = c
|
||||
log.Printf("tunnel: listening on %s/udp (direct UDP forwarding)", addr)
|
||||
go u.ctrlLoop()
|
||||
go u.gc()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (u *udpRelay) ctrlLoop() {
|
||||
buf := make([]byte, 65535)
|
||||
for {
|
||||
n, src, err := u.ctrl.ReadFromUDP(buf)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
if n == 0 {
|
||||
continue
|
||||
}
|
||||
switch buf[0] {
|
||||
case 'R':
|
||||
u.handleReg(string(buf[1:n]), src)
|
||||
case 'D':
|
||||
u.handleAgentData(buf[1:n])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// handleReg validates "agentID sig" and records the agent's UDP addr.
|
||||
// sig = HMAC(key, "agentID|minute") accepting the current or previous minute.
|
||||
func (u *udpRelay) handleReg(line string, src *net.UDPAddr) {
|
||||
f := strings.Fields(strings.TrimSpace(line))
|
||||
if len(f) != 2 {
|
||||
return
|
||||
}
|
||||
agentID, sig := f[0], f[1]
|
||||
agent, err := u.db.GetAgent(agentID)
|
||||
if err != nil || agent == nil {
|
||||
return
|
||||
}
|
||||
key, err := tunnel.KeyFromHash(agent.TokenHash)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
now := time.Now().Unix() / 60
|
||||
ok := false
|
||||
for _, m := range []int64{now, now - 1} {
|
||||
msg := []byte(agentID + "|" + itoa64(m))
|
||||
if tunnel.EqualSig(sig, tunnel.Sign(key, msg)) {
|
||||
ok = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
u.mu.Lock()
|
||||
u.agentAddr[agentID] = src
|
||||
u.mu.Unlock()
|
||||
}
|
||||
|
||||
// handleAgentData routes a reply datagram from an agent back to the end user.
|
||||
func (u *udpRelay) handleAgentData(b []byte) {
|
||||
if len(b) < 6 {
|
||||
return
|
||||
}
|
||||
sid := binary.BigEndian.Uint32(b[0:4])
|
||||
payload := b[6:]
|
||||
u.mu.Lock()
|
||||
s := u.sessions[sid]
|
||||
if s != nil {
|
||||
s.last = time.Now()
|
||||
}
|
||||
u.mu.Unlock()
|
||||
if s != nil {
|
||||
_, _ = s.ruleConn.WriteToUDP(payload, s.userAddr)
|
||||
}
|
||||
}
|
||||
|
||||
// openRulePort starts the per-rule UDP listener on the host port.
|
||||
func (u *udpRelay) openRulePort(rule ForwardRule) error {
|
||||
ua, err := net.ResolveUDPAddr("udp", rule.ListenAddr+":"+itoa(rule.HostPort))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
c, err := net.ListenUDP("udp", ua)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
u.mu.Lock()
|
||||
u.rulePorts[rule.ID] = c
|
||||
u.mu.Unlock()
|
||||
go u.rulePortLoop(rule, c)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (u *udpRelay) closeRulePort(ruleID int64) {
|
||||
u.mu.Lock()
|
||||
c := u.rulePorts[ruleID]
|
||||
delete(u.rulePorts, ruleID)
|
||||
u.mu.Unlock()
|
||||
if c != nil {
|
||||
c.Close()
|
||||
}
|
||||
}
|
||||
|
||||
func (u *udpRelay) rulePortLoop(rule ForwardRule, c *net.UDPConn) {
|
||||
buf := make([]byte, 65535)
|
||||
for {
|
||||
n, userAddr, err := c.ReadFromUDP(buf)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
u.mu.Lock()
|
||||
aAddr := u.agentAddr[rule.AgentID]
|
||||
// Find existing session for this (rule, user).
|
||||
var sess *udpSession
|
||||
for _, s := range u.sessions {
|
||||
if s.ruleConn == c && udpAddrEqual(s.userAddr, userAddr) {
|
||||
sess = s
|
||||
break
|
||||
}
|
||||
}
|
||||
if sess == nil {
|
||||
u.nextSID++
|
||||
sess = &udpSession{
|
||||
id: u.nextSID, agentID: rule.AgentID, ruleConn: c,
|
||||
userAddr: userAddr, clientPort: rule.ClientPort,
|
||||
}
|
||||
u.sessions[sess.id] = sess
|
||||
}
|
||||
sess.last = time.Now()
|
||||
u.mu.Unlock()
|
||||
|
||||
if aAddr == nil {
|
||||
continue // agent has not registered its UDP endpoint yet
|
||||
}
|
||||
// 'D' | sid(4) | clientPort(2) | payload
|
||||
out := make([]byte, 7+n)
|
||||
out[0] = 'D'
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
func (u *udpRelay) gc() {
|
||||
t := time.NewTicker(30 * time.Second)
|
||||
defer t.Stop()
|
||||
for range t.C {
|
||||
cut := time.Now().Add(-90 * time.Second)
|
||||
u.mu.Lock()
|
||||
for id, s := range u.sessions {
|
||||
if s.last.Before(cut) {
|
||||
delete(u.sessions, id)
|
||||
}
|
||||
}
|
||||
u.mu.Unlock()
|
||||
}
|
||||
}
|
||||
|
||||
func udpAddrEqual(a, b *net.UDPAddr) bool {
|
||||
return a != nil && b != nil && a.Port == b.Port && a.IP.Equal(b.IP)
|
||||
}
|
||||
|
||||
func itoa(n int) string { return itoa64(int64(n)) }
|
||||
func itoa64(n int64) string {
|
||||
if n == 0 {
|
||||
return "0"
|
||||
}
|
||||
neg := n < 0
|
||||
if neg {
|
||||
n = -n
|
||||
}
|
||||
var b [24]byte
|
||||
i := len(b)
|
||||
for n > 0 {
|
||||
i--
|
||||
b[i] = byte('0' + n%10)
|
||||
n /= 10
|
||||
}
|
||||
if neg {
|
||||
i--
|
||||
b[i] = '-'
|
||||
}
|
||||
return string(b[i:])
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"os/exec"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// ufwManager integrates with the host firewall (ufw) when it is installed and
|
||||
// active. Opening a forward port in the panel also opens it in ufw; the created
|
||||
// rules are tracked so they can be listed and revoked from Settings.
|
||||
//
|
||||
// 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
|
||||
}
|
||||
|
||||
func newUfwManager(db *DB, tunnelPort int) *ufwManager {
|
||||
return &ufwManager{db: db, tunnelPort: tunnelPort}
|
||||
}
|
||||
|
||||
// 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 {
|
||||
return false, false, false
|
||||
}
|
||||
installed = true
|
||||
out, err := exec.Command(path, "status").CombinedOutput()
|
||||
if err != nil {
|
||||
// Typically a permission error (not root) -> not runnable.
|
||||
return true, false, false
|
||||
}
|
||||
runnable = true
|
||||
active = strings.Contains(string(out), "Status: active")
|
||||
return installed, active, runnable
|
||||
}
|
||||
|
||||
// available reports whether we can and should drive ufw now.
|
||||
func (u *ufwManager) available() bool {
|
||||
if !u.db.UfwEnabled() {
|
||||
return false
|
||||
}
|
||||
installed, active, runnable := u.status()
|
||||
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) 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)))
|
||||
return
|
||||
}
|
||||
_ = u.db.AddUfwRule(port, proto, ruleID)
|
||||
log.Printf("ufw: allowed %s", spec)
|
||||
}
|
||||
|
||||
// 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)))
|
||||
} else {
|
||||
log.Printf("ufw: revoked %s", spec)
|
||||
}
|
||||
_ = u.db.DeleteUfwRule(r.ID)
|
||||
}
|
||||
|
||||
// revoke removes a ufw rule by its tracking id (manual revoke from Settings).
|
||||
func (u *ufwManager) revoke(id int64) {
|
||||
r, err := u.db.GetUfwRule(id)
|
||||
if err != nil || r == nil {
|
||||
return
|
||||
}
|
||||
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.
|
||||
func (u *ufwManager) revokeForRule(ruleID int64) {
|
||||
rules, err := u.db.ListUfwRulesByRule(ruleID)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
for _, r := range rules {
|
||||
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).
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
// humanSince renders a coarse "time ago" label for the dashboard.
|
||||
func humanSince(t time.Time) string {
|
||||
if t.IsZero() {
|
||||
return "никогда"
|
||||
}
|
||||
d := time.Since(t)
|
||||
switch {
|
||||
case d < 10*time.Second:
|
||||
return "только что"
|
||||
case d < time.Minute:
|
||||
return fmt.Sprintf("%d сек назад", int(d.Seconds()))
|
||||
case d < time.Hour:
|
||||
return fmt.Sprintf("%d мин назад", int(d.Minutes()))
|
||||
case d < 24*time.Hour:
|
||||
return fmt.Sprintf("%d ч назад", int(d.Hours()))
|
||||
default:
|
||||
return fmt.Sprintf("%d дн назад", int(d.Hours()/24))
|
||||
}
|
||||
}
|
||||
Vendored
BIN
Binary file not shown.
@@ -0,0 +1,165 @@
|
||||
#!/bin/sh
|
||||
# LPRA agent installer.
|
||||
# Usage (non-interactive):
|
||||
# curl -fsSL {{.Host}}/install.sh | sudo HOST={{.Host}} ENROLL_SECRET=xxxx sh
|
||||
# Usage (interactive, will ask for the secret):
|
||||
# curl -fsSL {{.Host}}/install.sh | sudo sh
|
||||
set -eu
|
||||
|
||||
HOST="${HOST:-{{.Host}}}"
|
||||
{{if .Secret}}ENROLL_SECRET="${ENROLL_SECRET:-{{.Secret}}}"
|
||||
{{end}}
|
||||
BIN_PATH="/usr/local/bin/lpagent"
|
||||
CONF_DIR="/etc/lpagent"
|
||||
CONF_FILE="$CONF_DIR/config.env"
|
||||
STATE_FILE="$CONF_DIR/state.json"
|
||||
SERVICE="/etc/systemd/system/lpagent.service"
|
||||
|
||||
red() { printf '\033[31m%s\033[0m\n' "$1"; }
|
||||
green() { printf '\033[32m%s\033[0m\n' "$1"; }
|
||||
bold() { printf '\033[1m%s\033[0m\n' "$1"; }
|
||||
|
||||
# Read a line from the real terminal even when the script is piped into `sh`.
|
||||
ask() { # ask VARNAME "prompt"
|
||||
_v=""
|
||||
if [ -r /dev/tty ]; then
|
||||
printf '%s' "$2" > /dev/tty
|
||||
read -r _v < /dev/tty || true
|
||||
fi
|
||||
eval "$1=\$_v"
|
||||
}
|
||||
confirm() { # confirm "prompt" -> returns 0 on yes
|
||||
if [ ! -r /dev/tty ]; then return 1; fi
|
||||
printf '%s [y/N]: ' "$1" > /dev/tty
|
||||
read -r _a < /dev/tty || _a=""
|
||||
case "$_a" in y|Y|yes|YES) return 0 ;; *) return 1 ;; esac
|
||||
}
|
||||
|
||||
# --- root check -------------------------------------------------------------
|
||||
if [ "$(id -u)" -ne 0 ]; then
|
||||
red "Нужны права root."
|
||||
echo "Запустите через sudo, например:"
|
||||
echo " curl -fsSL $HOST/install.sh | sudo sh"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# --- systemd check ----------------------------------------------------------
|
||||
if ! command -v systemctl >/dev/null 2>&1; then
|
||||
red "systemctl не найден: поддерживаются только системы с systemd."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
uninstall() {
|
||||
bold "Удаление агента LPRA…"
|
||||
systemctl stop lpagent 2>/dev/null || true
|
||||
systemctl disable lpagent 2>/dev/null || true
|
||||
rm -f "$SERVICE"
|
||||
systemctl daemon-reload 2>/dev/null || true
|
||||
rm -f "$BIN_PATH"
|
||||
rm -rf "$CONF_DIR"
|
||||
green "Агент удалён."
|
||||
}
|
||||
|
||||
# --- existing installation --------------------------------------------------
|
||||
if [ -f "$SERVICE" ] || [ -f "$BIN_PATH" ]; then
|
||||
bold "Агент LPRA уже установлен на этой машине."
|
||||
if confirm "Удалить его сейчас?"; then
|
||||
uninstall
|
||||
if confirm "Установить заново?"; then
|
||||
: # fall through to install
|
||||
else
|
||||
exit 0
|
||||
fi
|
||||
else
|
||||
if confirm "Переустановить / обновить?"; then
|
||||
: # fall through, keep existing state.json (не будет повторной регистрации)
|
||||
else
|
||||
echo "Ничего не делаю."
|
||||
exit 0
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
# --- architecture -----------------------------------------------------------
|
||||
case "$(uname -m)" in
|
||||
x86_64|amd64) ARCH=amd64 ;;
|
||||
aarch64|arm64) ARCH=arm64 ;;
|
||||
*) red "Неподдерживаемая архитектура: $(uname -m)"; exit 1 ;;
|
||||
esac
|
||||
|
||||
# --- machine name -----------------------------------------------------------
|
||||
# Default: <hostname>-<4 random chars>. Only used at the first enrollment.
|
||||
if [ -z "${AGENT_NAME:-}" ]; then
|
||||
SUFFIX="$(head -c16 /dev/urandom 2>/dev/null | od -An -tx1 2>/dev/null | tr -d ' \n' | cut -c1-4)"
|
||||
[ -z "$SUFFIX" ] && SUFFIX="$(date +%s | tail -c 5)"
|
||||
AGENT_NAME="$(hostname 2>/dev/null || echo host)-$SUFFIX"
|
||||
fi
|
||||
|
||||
# --- enroll secret ----------------------------------------------------------
|
||||
# Needed only for the very first enrollment; skipped if state.json already exists.
|
||||
if [ -z "${ENROLL_SECRET:-}" ] && [ ! -f "$STATE_FILE" ]; then
|
||||
ask ENROLL_SECRET "Введите enroll-секрет (из панели, раздел Настройки): "
|
||||
if [ -z "${ENROLL_SECRET:-}" ]; then
|
||||
red "Enroll-секрет обязателен для первой установки."
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
# --- downloader -------------------------------------------------------------
|
||||
download() { # download URL DEST
|
||||
if command -v curl >/dev/null 2>&1; then
|
||||
curl -fSL -o "$2" "$1"
|
||||
elif command -v wget >/dev/null 2>&1; then
|
||||
wget -qO "$2" "$1"
|
||||
else
|
||||
red "Нужен curl или wget."; exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
bold "Скачивание агента ($ARCH) с $HOST…"
|
||||
TMP="$(mktemp)"
|
||||
download "$HOST/download/agent-linux-$ARCH" "$TMP"
|
||||
install -m 0755 "$TMP" "$BIN_PATH"
|
||||
rm -f "$TMP"
|
||||
green "Бинарник установлен: $BIN_PATH"
|
||||
|
||||
# --- config -----------------------------------------------------------------
|
||||
mkdir -p "$CONF_DIR"
|
||||
chmod 700 "$CONF_DIR"
|
||||
umask 077
|
||||
cat > "$CONF_FILE" <<EOF
|
||||
HOST=$HOST
|
||||
ENROLL_SECRET=${ENROLL_SECRET:-}
|
||||
AGENT_NAME=$AGENT_NAME
|
||||
STATE_PATH=$STATE_FILE
|
||||
EOF
|
||||
chmod 600 "$CONF_FILE"
|
||||
|
||||
# --- systemd unit -----------------------------------------------------------
|
||||
cat > "$SERVICE" <<EOF
|
||||
[Unit]
|
||||
Description=LPRA remote access agent
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=root
|
||||
EnvironmentFile=$CONF_FILE
|
||||
ExecStart=$BIN_PATH
|
||||
Restart=always
|
||||
RestartSec=3
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
EOF
|
||||
|
||||
systemctl daemon-reload
|
||||
systemctl enable lpagent >/dev/null 2>&1 || true
|
||||
systemctl restart lpagent
|
||||
|
||||
sleep 1
|
||||
bold "Статус сервиса:"
|
||||
systemctl --no-pager --full status lpagent || true
|
||||
echo
|
||||
green "Готово. Машина должна появиться в панели: $HOST"
|
||||
@@ -0,0 +1,2 @@
|
||||
!function(e,t){"object"==typeof exports&&"object"==typeof module?module.exports=t():"function"==typeof define&&define.amd?define([],t):"object"==typeof exports?exports.FitAddon=t():e.FitAddon=t()}(self,(()=>(()=>{"use strict";var e={};return(()=>{var t=e;Object.defineProperty(t,"__esModule",{value:!0}),t.FitAddon=void 0,t.FitAddon=class{activate(e){this._terminal=e}dispose(){}fit(){const e=this.proposeDimensions();if(!e||!this._terminal||isNaN(e.cols)||isNaN(e.rows))return;const t=this._terminal._core;this._terminal.rows===e.rows&&this._terminal.cols===e.cols||(t._renderService.clear(),this._terminal.resize(e.cols,e.rows))}proposeDimensions(){if(!this._terminal)return;if(!this._terminal.element||!this._terminal.element.parentElement)return;const e=this._terminal._core,t=e._renderService.dimensions;if(0===t.css.cell.width||0===t.css.cell.height)return;const r=0===this._terminal.options.scrollback?0:e.viewport.scrollBarWidth,i=window.getComputedStyle(this._terminal.element.parentElement),o=parseInt(i.getPropertyValue("height")),s=Math.max(0,parseInt(i.getPropertyValue("width"))),n=window.getComputedStyle(this._terminal.element),l=o-(parseInt(n.getPropertyValue("padding-top"))+parseInt(n.getPropertyValue("padding-bottom"))),a=s-(parseInt(n.getPropertyValue("padding-right"))+parseInt(n.getPropertyValue("padding-left")))-r;return{cols:Math.max(2,Math.floor(a/t.css.cell.width)),rows:Math.max(1,Math.floor(l/t.css.cell.height))}}}})(),e})()));
|
||||
//# sourceMappingURL=xterm-addon-fit.js.map
|
||||
@@ -0,0 +1,72 @@
|
||||
:root {
|
||||
--bg: #0f1216;
|
||||
--panel: #171c23;
|
||||
--border: #262d38;
|
||||
--text: #e6e9ef;
|
||||
--muted: #8b95a5;
|
||||
--accent: #4f9dff;
|
||||
--accent-hover: #3b86e6;
|
||||
--danger: #ff5c5c;
|
||||
--ok: #37c871;
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
body {
|
||||
margin: 0;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font: 15px/1.5 -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||
}
|
||||
a { color: var(--accent); text-decoration: none; }
|
||||
a:hover { text-decoration: underline; }
|
||||
header.topbar {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
padding: 14px 24px; border-bottom: 1px solid var(--border); background: var(--panel);
|
||||
}
|
||||
header.topbar .brand { font-weight: 600; }
|
||||
header.topbar nav a { margin-left: 18px; color: var(--muted); }
|
||||
header.topbar nav a:hover { color: var(--text); }
|
||||
.wrap { max-width: 960px; margin: 0 auto; padding: 24px 16px; }
|
||||
.center { max-width: 420px; margin: 8vh auto; padding: 0 16px; }
|
||||
.card {
|
||||
background: var(--panel); border: 1px solid var(--border);
|
||||
border-radius: 10px; padding: 24px; margin-bottom: 20px;
|
||||
}
|
||||
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] {
|
||||
width: 100%; padding: 10px 12px; border-radius: 8px;
|
||||
border: 1px solid var(--border); background: #0c0f13; color: var(--text); font-size: 14px;
|
||||
}
|
||||
button, .btn {
|
||||
display: inline-block; cursor: pointer; border: 0; border-radius: 8px;
|
||||
padding: 10px 16px; font-size: 14px; font-weight: 500;
|
||||
background: var(--accent); color: #fff;
|
||||
}
|
||||
button:hover, .btn:hover { background: var(--accent-hover); text-decoration: none; }
|
||||
.btn.ghost { background: transparent; border: 1px solid var(--border); color: var(--text); }
|
||||
.btn.danger { background: var(--danger); }
|
||||
.btn.small { padding: 6px 10px; font-size: 13px; }
|
||||
.btn.icon { width: 30px; height: 30px; padding: 0; display: inline-flex;
|
||||
align-items: center; justify-content: center; font-size: 14px; line-height: 1; text-decoration: none; }
|
||||
.err { background: #3a1d1d; border: 1px solid #6b2b2b; color: #ffb3b3; padding: 10px 12px; border-radius: 8px; margin-bottom: 14px; font-size: 14px; }
|
||||
table { width: 100%; border-collapse: collapse; }
|
||||
th, td { text-align: left; padding: 10px 8px; border-bottom: 1px solid var(--border); font-size: 14px; }
|
||||
th { color: var(--muted); font-weight: 500; }
|
||||
.dot { display: inline-block; width: 9px; height: 9px; border-radius: 50%; margin-right: 6px; }
|
||||
.dot.on { background: var(--ok); }
|
||||
.dot.off { background: #555d6a; }
|
||||
.muted { color: var(--muted); }
|
||||
code, pre {
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
background: #0c0f13; border: 1px solid var(--border); border-radius: 8px;
|
||||
}
|
||||
code { padding: 2px 6px; font-size: 13px; }
|
||||
pre { padding: 12px; overflow-x: auto; font-size: 13px; white-space: pre-wrap; word-break: break-all; }
|
||||
.row { display: flex; gap: 10px; align-items: center; flex-wrap: wrap; }
|
||||
.spacer { flex: 1; }
|
||||
#terminal { width: 100%; height: calc(100vh - 150px); }
|
||||
@media (max-width: 600px) {
|
||||
header.topbar { padding: 12px 16px; }
|
||||
header.topbar nav a { margin-left: 12px; }
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
/**
|
||||
* Copyright (c) 2014 The xterm.js authors. All rights reserved.
|
||||
* Copyright (c) 2012-2013, Christopher Jeffrey (MIT License)
|
||||
* https://github.com/chjj/term.js
|
||||
* @license MIT
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
* of this software and associated documentation files (the "Software"), to deal
|
||||
* in the Software without restriction, including without limitation the rights
|
||||
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
* copies of the Software, and to permit persons to whom the Software is
|
||||
* furnished to do so, subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in
|
||||
* all copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||
* THE SOFTWARE.
|
||||
*
|
||||
* Originally forked from (with the author's permission):
|
||||
* Fabrice Bellard's javascript vt100 for jslinux:
|
||||
* http://bellard.org/jslinux/
|
||||
* Copyright (c) 2011 Fabrice Bellard
|
||||
* The original design remains. The terminal itself
|
||||
* has been extended to include xterm CSI codes, among
|
||||
* other features.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Default styles for xterm.js
|
||||
*/
|
||||
|
||||
.xterm {
|
||||
cursor: text;
|
||||
position: relative;
|
||||
user-select: none;
|
||||
-ms-user-select: none;
|
||||
-webkit-user-select: none;
|
||||
}
|
||||
|
||||
.xterm.focus,
|
||||
.xterm:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.xterm .xterm-helpers {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
/**
|
||||
* The z-index of the helpers must be higher than the canvases in order for
|
||||
* IMEs to appear on top.
|
||||
*/
|
||||
z-index: 5;
|
||||
}
|
||||
|
||||
.xterm .xterm-helper-textarea {
|
||||
padding: 0;
|
||||
border: 0;
|
||||
margin: 0;
|
||||
/* Move textarea out of the screen to the far left, so that the cursor is not visible */
|
||||
position: absolute;
|
||||
opacity: 0;
|
||||
left: -9999em;
|
||||
top: 0;
|
||||
width: 0;
|
||||
height: 0;
|
||||
z-index: -5;
|
||||
/** Prevent wrapping so the IME appears against the textarea at the correct position */
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
resize: none;
|
||||
}
|
||||
|
||||
.xterm .composition-view {
|
||||
/* TODO: Composition position got messed up somewhere */
|
||||
background: #000;
|
||||
color: #FFF;
|
||||
display: none;
|
||||
position: absolute;
|
||||
white-space: nowrap;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.xterm .composition-view.active {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.xterm .xterm-viewport {
|
||||
/* On OS X this is required in order for the scroll bar to appear fully opaque */
|
||||
background-color: #000;
|
||||
overflow-y: scroll;
|
||||
cursor: default;
|
||||
position: absolute;
|
||||
right: 0;
|
||||
left: 0;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
}
|
||||
|
||||
.xterm .xterm-screen {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.xterm .xterm-screen canvas {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
}
|
||||
|
||||
.xterm .xterm-scroll-area {
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
.xterm-char-measure-element {
|
||||
display: inline-block;
|
||||
visibility: hidden;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: -9999em;
|
||||
line-height: normal;
|
||||
}
|
||||
|
||||
.xterm.enable-mouse-events {
|
||||
/* When mouse events are enabled (eg. tmux), revert to the standard pointer cursor */
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.xterm.xterm-cursor-pointer,
|
||||
.xterm .xterm-cursor-pointer {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.xterm.column-select.focus {
|
||||
/* Column selection mode */
|
||||
cursor: crosshair;
|
||||
}
|
||||
|
||||
.xterm .xterm-accessibility,
|
||||
.xterm .xterm-message {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
right: 0;
|
||||
z-index: 10;
|
||||
color: transparent;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.xterm .live-region {
|
||||
position: absolute;
|
||||
left: -9999px;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.xterm-dim {
|
||||
/* Dim should not apply to background, so the opacity of the foreground color is applied
|
||||
* explicitly in the generated class and reset to 1 here */
|
||||
opacity: 1 !important;
|
||||
}
|
||||
|
||||
.xterm-underline-1 { text-decoration: underline; }
|
||||
.xterm-underline-2 { text-decoration: double underline; }
|
||||
.xterm-underline-3 { text-decoration: wavy underline; }
|
||||
.xterm-underline-4 { text-decoration: dotted underline; }
|
||||
.xterm-underline-5 { text-decoration: dashed underline; }
|
||||
|
||||
.xterm-overline {
|
||||
text-decoration: overline;
|
||||
}
|
||||
|
||||
.xterm-overline.xterm-underline-1 { text-decoration: overline underline; }
|
||||
.xterm-overline.xterm-underline-2 { text-decoration: overline double underline; }
|
||||
.xterm-overline.xterm-underline-3 { text-decoration: overline wavy underline; }
|
||||
.xterm-overline.xterm-underline-4 { text-decoration: overline dotted underline; }
|
||||
.xterm-overline.xterm-underline-5 { text-decoration: overline dashed underline; }
|
||||
|
||||
.xterm-strikethrough {
|
||||
text-decoration: line-through;
|
||||
}
|
||||
|
||||
.xterm-screen .xterm-decoration-container .xterm-decoration {
|
||||
z-index: 6;
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
.xterm-screen .xterm-decoration-container .xterm-decoration.xterm-decoration-top-layer {
|
||||
z-index: 7;
|
||||
}
|
||||
|
||||
.xterm-decoration-overview-ruler {
|
||||
z-index: 8;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.xterm-decoration-top {
|
||||
z-index: 2;
|
||||
position: relative;
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
!function(e,t){if("object"==typeof exports&&"object"==typeof module)module.exports=t();else if("function"==typeof define&&define.amd)define([],t);else{var i=t();for(var s in i)("object"==typeof exports?exports:e)[s]=i[s]}}(self,(()=>(()=>{"use strict";var e={4567:function(e,t,i){var s=this&&this.__decorate||function(e,t,i,s){var r,n=arguments.length,o=n<3?t:null===s?s=Object.getOwnPropertyDescriptor(t,i):s;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)o=Reflect.decorate(e,t,i,s);else for(var a=e.length-1;a>=0;a--)(r=e[a])&&(o=(n<3?r(o):n>3?r(t,i,o):r(t,i))||o);return n>3&&o&&Object.defineProperty(t,i,o),o},r=this&&this.__param||function(e,t){return function(i,s){t(i,s,e)}};Object.defineProperty(t,"__esModule",{value:!0}),t.AccessibilityManager=void 0;const n=i(9042),o=i(6114),a=i(9924),h=i(844),c=i(5596),l=i(4725),d=i(3656);let _=t.AccessibilityManager=class extends h.Disposable{constructor(e,t){super(),this._terminal=e,this._renderService=t,this._liveRegionLineCount=0,this._charsToConsume=[],this._charsToAnnounce="",this._accessibilityContainer=document.createElement("div"),this._accessibilityContainer.classList.add("xterm-accessibility"),this._rowContainer=document.createElement("div"),this._rowContainer.setAttribute("role","list"),this._rowContainer.classList.add("xterm-accessibility-tree"),this._rowElements=[];for(let e=0;e<this._terminal.rows;e++)this._rowElements[e]=this._createAccessibilityTreeNode(),this._rowContainer.appendChild(this._rowElements[e]);if(this._topBoundaryFocusListener=e=>this._handleBoundaryFocus(e,0),this._bottomBoundaryFocusListener=e=>this._handleBoundaryFocus(e,1),this._rowElements[0].addEventListener("focus",this._topBoundaryFocusListener),this._rowElements[this._rowElements.length-1].addEventListener("focus",this._bottomBoundaryFocusListener),this._refreshRowsDimensions(),this._accessibilityContainer.appendChild(this._rowContainer),this._liveRegion=document.createElement("div"),this._liveRegion.classList.add("live-region"),this._liveRegion.setAttribute("aria-live","assertive"),this._accessibilityContainer.appendChild(this._liveRegion),this._liveRegionDebouncer=this.register(new a.TimeBasedDebouncer(this._renderRows.bind(this))),!this._terminal.element)throw new Error("Cannot enable accessibility before Terminal.open");this._terminal.element.insertAdjacentElement("afterbegin",this._accessibilityContainer),this.register(this._terminal.onResize((e=>this._handleResize(e.rows)))),this.register(this._terminal.onRender((e=>this._refreshRows(e.start,e.end)))),this.register(this._terminal.onScroll((()=>this._refreshRows()))),this.register(this._terminal.onA11yChar((e=>this._handleChar(e)))),this.register(this._terminal.onLineFeed((()=>this._handleChar("\n")))),this.register(this._terminal.onA11yTab((e=>this._handleTab(e)))),this.register(this._terminal.onKey((e=>this._handleKey(e.key)))),this.register(this._terminal.onBlur((()=>this._clearLiveRegion()))),this.register(this._renderService.onDimensionsChange((()=>this._refreshRowsDimensions()))),this._screenDprMonitor=new c.ScreenDprMonitor(window),this.register(this._screenDprMonitor),this._screenDprMonitor.setListener((()=>this._refreshRowsDimensions())),this.register((0,d.addDisposableDomListener)(window,"resize",(()=>this._refreshRowsDimensions()))),this._refreshRows(),this.register((0,h.toDisposable)((()=>{this._accessibilityContainer.remove(),this._rowElements.length=0})))}_handleTab(e){for(let t=0;t<e;t++)this._handleChar(" ")}_handleChar(e){this._liveRegionLineCount<21&&(this._charsToConsume.length>0?this._charsToConsume.shift()!==e&&(this._charsToAnnounce+=e):this._charsToAnnounce+=e,"\n"===e&&(this._liveRegionLineCount++,21===this._liveRegionLineCount&&(this._liveRegion.textContent+=n.tooMuchOutput)),o.isMac&&this._liveRegion.textContent&&this._liveRegion.textContent.length>0&&!this._liveRegion.parentNode&&setTimeout((()=>{this._accessibilityContainer.appendChild(this._liveRegion)}),0))}_clearLiveRegion(){this._liveRegion.textContent="",this._liveRegionLineCount=0,o.isMac&&this._liveRegion.remove()}_handleKey(e){this._clearLiveRegion(),/\p{Control}/u.test(e)||this._charsToConsume.push(e)}_refreshRows(e,t){this._liveRegionDebouncer.refresh(e,t,this._terminal.rows)}_renderRows(e,t){const i=this._terminal.buffer,s=i.lines.length.toString();for(let r=e;r<=t;r++){const e=i.translateBufferLineToString(i.ydisp+r,!0),t=(i.ydisp+r+1).toString(),n=this._rowElements[r];n&&(0===e.length?n.innerText=" ":n.textContent=e,n.setAttribute("aria-posinset",t),n.setAttribute("aria-setsize",s))}this._announceCharacters()}_announceCharacters(){0!==this._charsToAnnounce.length&&(this._liveRegion.textContent+=this._charsToAnnounce,this._charsToAnnounce="")}_handleBoundaryFocus(e,t){const i=e.target,s=this._rowElements[0===t?1:this._rowElements.length-2];if(i.getAttribute("aria-posinset")===(0===t?"1":`${this._terminal.buffer.lines.length}`))return;if(e.relatedTarget!==s)return;let r,n;if(0===t?(r=i,n=this._rowElements.pop(),this._rLine truncated
|
||||
//# sourceMappingURL=xterm.js.map
|
||||
@@ -0,0 +1,62 @@
|
||||
<!doctype html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Машины — LPRA</title>
|
||||
<link rel="stylesheet" href="/static/app.css">
|
||||
</head>
|
||||
<body>
|
||||
<header class="topbar">
|
||||
<div class="brand">LPRA · Удалённый доступ</div>
|
||||
<nav>
|
||||
<a href="/">Машины</a>
|
||||
<a href="/settings">Настройки</a>
|
||||
<form method="post" action="/logout" style="display:inline; margin-left:22px">
|
||||
<button class="btn ghost small" type="submit">Выйти</button>
|
||||
</form>
|
||||
</nav>
|
||||
</header>
|
||||
<div class="wrap">
|
||||
<h1>Машины-клиенты</h1>
|
||||
<div class="card">
|
||||
{{if .Agents}}
|
||||
<table>
|
||||
<thead><tr><th>Статус</th><th>Имя</th><th>Hostname</th><th>ОС / Арх</th><th>Активность</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
{{range .Agents}}
|
||||
<tr>
|
||||
<td><span class="dot {{if .Online}}on{{else}}off{{end}}"></span>{{if .Online}}online{{else}}offline{{end}}</td>
|
||||
<td>{{.Name}}</td>
|
||||
<td class="muted">{{.Hostname}}</td>
|
||||
<td class="muted">{{.OS}}/{{.Arch}}</td>
|
||||
<td class="muted">{{.Seen}}</td>
|
||||
<td>
|
||||
<div class="row" style="gap:6px">
|
||||
<a class="btn small" href="/agents/{{.ID}}/terminal">Терминал</a>
|
||||
<a class="btn ghost small" href="/agents/{{.ID}}/files">Файлы</a>
|
||||
<a class="btn ghost small" href="/agents/{{.ID}}/forwards">Порты</a>
|
||||
<a class="btn ghost small icon" href="/agents/{{.ID}}/edit" title="Изменить">
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 20h9"/><path d="M16.5 3.5a2.12 2.12 0 0 1 3 3L7 19l-4 1 1-4 12.5-12.5z"/></svg>
|
||||
</a>
|
||||
<form method="post" action="/agents/{{.ID}}/delete" style="display:inline"
|
||||
onsubmit="return confirm('Удалить машину {{.Name}}? Сервис на ней продолжит работать, но перестанет быть виден. Чтобы снова подключиться — переустановите агента.')">
|
||||
<input type="hidden" name="csrf" value="{{$.CSRF}}">
|
||||
<button class="btn danger small icon" type="submit" title="Удалить">
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 6h18"/><path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/><line x1="10" y1="11" x2="10" y2="17"/><line x1="14" y1="11" x2="14" y2="17"/></svg>
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
{{else}}
|
||||
<p class="muted">Пока нет подключённых машин. Добавьте первую в разделе
|
||||
<a href="/settings">Настройки</a> — там одна команда для установки.</p>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,62 @@
|
||||
<!doctype html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Изменить машину — LPRA</title>
|
||||
<link rel="stylesheet" href="/static/app.css">
|
||||
</head>
|
||||
<body>
|
||||
<header class="topbar">
|
||||
<div class="brand">LPRA · Изменить машину</div>
|
||||
<nav>
|
||||
<a href="/">← Машины</a>
|
||||
</nav>
|
||||
</header>
|
||||
<div class="wrap">
|
||||
<h1>{{.Agent.Name}}</h1>
|
||||
|
||||
<div class="card">
|
||||
<h2>Имя</h2>
|
||||
<form method="post" action="/agents/{{.Agent.ID}}/rename" class="row">
|
||||
<input type="hidden" name="csrf" value="{{.CSRF}}">
|
||||
<input type="text" name="name" value="{{.Agent.Name}}" style="max-width:360px" required>
|
||||
<button type="submit">Сохранить</button>
|
||||
</form>
|
||||
<p class="muted" style="margin-top:12px">
|
||||
Hostname: <code>{{.Agent.Hostname}}</code> · ОС: <code>{{.Agent.OS}}/{{.Agent.Arch}}</code> · ID: <code>{{.Agent.ID}}</code>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>Версия клиента</h2>
|
||||
{{if .Updated}}<div class="err" style="background:#1d3a24;border-color:#2b6b3a;color:#b3ffcb">Команда обновления отправлена. Клиент скачает свежий бинарник и перезапустится.</div>{{end}}
|
||||
<p class="muted">
|
||||
Клиент: <code>{{if .Agent.Version}}{{.Agent.Version}}{{else}}неизвестно{{end}}</code>
|
||||
· Сервер: <code>{{.ServerVersion}}</code>
|
||||
{{if .Outdated}}<span style="color:var(--danger)"> · требуется обновление</span>{{else}}<span style="color:var(--ok)"> · актуальна</span>{{end}}
|
||||
</p>
|
||||
{{if .Outdated}}
|
||||
<form method="post" action="/agents/{{.Agent.ID}}/update"
|
||||
onsubmit="return confirm('Обновить клиента до версии {{.ServerVersion}}? Бинарник будет заменён, сервис перезапущен (без удаления).')">
|
||||
<input type="hidden" name="csrf" value="{{.CSRF}}">
|
||||
<button type="submit">Обновить клиента</button>
|
||||
</form>
|
||||
{{else}}
|
||||
<p class="muted" style="font-size:13px">Обновление доступно, когда версия клиента отличается от версии сервера.</p>
|
||||
{{end}}
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>Удаление</h2>
|
||||
<p class="muted">Удаляет машину из панели. Сервис на самой машине продолжит работать,
|
||||
но перестанет быть виден; чтобы подключиться снова — переустановите агента.</p>
|
||||
<form method="post" action="/agents/{{.Agent.ID}}/delete"
|
||||
onsubmit="return confirm('Удалить машину {{.Agent.Name}}?')">
|
||||
<input type="hidden" name="csrf" value="{{.CSRF}}">
|
||||
<button class="btn danger" type="submit">Удалить машину</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,287 @@
|
||||
<!doctype html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Файлы · {{.Agent.Name}} — LPRA</title>
|
||||
<link rel="stylesheet" href="/static/app.css">
|
||||
<style>
|
||||
.fm-bar { display:flex; gap:8px; align-items:center; flex-wrap:wrap; margin-bottom:12px; }
|
||||
/* Normalize buttons and the file <label> button so they line up on one row. */
|
||||
.fm-bar .btn, .fm-bar button, .fm-bar label.btn {
|
||||
display:inline-flex; align-items:center; justify-content:center;
|
||||
vertical-align:middle; height:34px; line-height:1; margin:0; box-sizing:border-box;
|
||||
}
|
||||
input.fm-path { font-family:ui-monospace,Menlo,monospace; font-size:13px; color:var(--muted);
|
||||
background:#0c0f13; border:1px solid var(--border); border-radius:8px; padding:8px 12px;
|
||||
flex:1; min-width:200px; height:34px; box-sizing:border-box; }
|
||||
input.fm-path:focus { outline:none; border-color:var(--accent); color:var(--text); }
|
||||
.spinner { width:16px; height:16px; border:2px solid var(--border); border-top-color:var(--accent);
|
||||
border-radius:50%; animation:spin .7s linear infinite; display:none; }
|
||||
@keyframes spin { to { transform:rotate(360deg); } }
|
||||
#drop { border:1px dashed var(--border); border-radius:10px; }
|
||||
#drop.drag { border-color:var(--accent); background:#12202f; }
|
||||
table.fm td, table.fm th { padding:7px 8px; }
|
||||
.fm .nm { cursor:pointer; }
|
||||
.fm .nm:hover { color:var(--accent); }
|
||||
.ic { display:inline-block; width:18px; text-align:center; margin-right:6px; }
|
||||
.sz { color:var(--muted); font-variant-numeric:tabular-nums; }
|
||||
.act { display:flex; gap:6px; justify-content:flex-end; }
|
||||
dialog { background:var(--panel); color:var(--text); border:1px solid var(--border);
|
||||
border-radius:12px; padding:0; max-width:900px; width:92vw; }
|
||||
dialog::backdrop { background:rgba(0,0,0,.6); }
|
||||
.dlg-h { padding:14px 18px; border-bottom:1px solid var(--border); display:flex; align-items:center; gap:10px; }
|
||||
.dlg-b { padding:16px 18px; }
|
||||
textarea#edit { width:100%; height:52vh; background:#0c0f13; color:var(--text);
|
||||
border:1px solid var(--border); border-radius:8px; padding:12px;
|
||||
font-family:ui-monospace,Menlo,monospace; font-size:13px; }
|
||||
.toast { position:fixed; bottom:18px; left:50%; transform:translateX(-50%);
|
||||
background:#2a2f3a; border:1px solid var(--border); border-radius:8px;
|
||||
padding:10px 16px; font-size:14px; opacity:0; transition:opacity .2s; pointer-events:none; }
|
||||
.toast.show { opacity:1; }
|
||||
.toast.err { background:#3a1d1d; border-color:#6b2b2b; color:#ffb3b3; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header class="topbar">
|
||||
<div class="brand">LPRA · Файлы — {{.Agent.Name}} <span class="muted">({{.Agent.Hostname}})</span></div>
|
||||
<nav>
|
||||
<a href="/">← Машины</a>
|
||||
<a href="/agents/{{.Agent.ID}}/terminal">Терминал</a>
|
||||
<a href="/agents/{{.Agent.ID}}/forwards">Порты</a>
|
||||
</nav>
|
||||
</header>
|
||||
<div class="wrap" style="max-width:1100px">
|
||||
<div class="fm-bar">
|
||||
<button class="btn ghost small" id="up">↑ Вверх</button>
|
||||
<input class="fm-path" id="path" value="/" spellcheck="false" autocapitalize="off" autocomplete="off">
|
||||
<span class="spinner" id="spin"></span>
|
||||
<button class="btn ghost small" id="refresh">Обновить</button>
|
||||
</div>
|
||||
<div class="fm-bar">
|
||||
<button class="btn small" id="mkdir">+ Папка</button>
|
||||
<button class="btn small" id="newfile">+ Файл</button>
|
||||
<label class="btn small" style="cursor:pointer">+ Загрузить<input type="file" id="upl" multiple hidden></label>
|
||||
<span class="spacer" style="flex:1"></span>
|
||||
<span id="selinfo" class="muted" style="font-size:13px"></span>
|
||||
<button class="btn ghost small" id="zipsel" disabled>В zip</button>
|
||||
<button class="btn danger small" id="delsel" disabled>Удалить выбранное</button>
|
||||
</div>
|
||||
|
||||
<div class="card" id="drop" style="padding:0">
|
||||
<table class="fm">
|
||||
<thead><tr>
|
||||
<th style="width:28px"><input type="checkbox" id="all"></th>
|
||||
<th>Имя</th><th style="width:120px" class="sz">Размер</th>
|
||||
<th style="width:160px" class="sz">Изменён</th><th style="width:160px"></th>
|
||||
</tr></thead>
|
||||
<tbody id="list"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
<p class="muted" id="hint" style="font-size:13px">Перетащите файлы сюда для загрузки. Клик по файлу — просмотр (правка, если < 2 КБ).</p>
|
||||
</div>
|
||||
|
||||
<dialog id="viewer">
|
||||
<div class="dlg-h">
|
||||
<strong id="vname" style="flex:1; overflow:hidden; text-overflow:ellipsis"></strong>
|
||||
<button class="btn small" id="save" style="display:none">Сохранить</button>
|
||||
<button class="btn ghost small" id="closev">Закрыть</button>
|
||||
</div>
|
||||
<div class="dlg-b">
|
||||
<div id="vmsg" class="muted" style="margin-bottom:8px; font-size:13px"></div>
|
||||
<textarea id="edit" spellcheck="false"></textarea>
|
||||
</div>
|
||||
</dialog>
|
||||
|
||||
<div class="toast" id="toast"></div>
|
||||
|
||||
<script>
|
||||
(function () {
|
||||
const agentID = {{.Agent.ID}};
|
||||
let busyN = 0;
|
||||
function setBusy(on) { busyN += on ? 1 : -1; if (busyN < 0) busyN = 0; document.getElementById('spin').style.display = busyN > 0 ? 'inline-block' : 'none'; }
|
||||
const api = (op, body) => {
|
||||
setBusy(true);
|
||||
return fetch('/api/panel/agents/' + encodeURIComponent(agentID) + '/fs/' + op, {
|
||||
method: 'POST', headers: {'Content-Type':'application/json'}, body: JSON.stringify(body || {})
|
||||
}).then(r => r.json()).catch(e => ({ok:false, error:'ошибка сети'})).finally(() => setBusy(false));
|
||||
};
|
||||
|
||||
const EDIT_LIMIT = 5 * 1024 * 1024;
|
||||
|
||||
// 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 ICON_DL = SVG + '<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>';
|
||||
const ICON_MV = SVG + '<polyline points="5 9 2 12 5 15"/><polyline points="9 5 12 2 15 5"/><polyline points="15 19 12 22 9 19"/><polyline points="19 9 22 12 19 15"/><line x1="2" y1="12" x2="22" y2="12"/><line x1="12" y1="2" x2="12" y2="22"/></svg>';
|
||||
const ICON_DEL = SVG + '<path d="M3 6h18"/><path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/><line x1="10" y1="11" x2="10" y2="17"/><line x1="14" y1="11" x2="14" y2="17"/></svg>';
|
||||
let cwd = '/';
|
||||
let entries = [];
|
||||
const selected = new Set();
|
||||
|
||||
const $ = id => document.getElementById(id);
|
||||
const toast = (msg, err) => { const t=$('toast'); t.textContent=msg; t.className='toast show'+(err?' err':''); setTimeout(()=>t.className='toast',2200); };
|
||||
const b64ToBytes = b64 => { const bin=atob(b64); const u=new Uint8Array(bin.length); for(let i=0;i<bin.length;i++)u[i]=bin.charCodeAt(i); return u; };
|
||||
const bytesToB64 = bytes => { let s=''; const CH=0x8000; for(let i=0;i<bytes.length;i+=CH) s+=String.fromCharCode.apply(null, bytes.subarray(i,i+CH)); return btoa(s); };
|
||||
const join = (dir, name) => (dir.endsWith('/') ? dir : dir+'/') + name;
|
||||
const parent = p => { if(p==='/'||p==='') return '/'; const i=p.replace(/\/+$/,'').lastIndexOf('/'); return i<=0 ? '/' : p.slice(0,i); };
|
||||
const fmtSize = n => { if(n<1024)return n+' B'; if(n<1048576)return (n/1024).toFixed(1)+' K'; if(n<1073741824)return (n/1048576).toFixed(1)+' M'; return (n/1073741824).toFixed(1)+' G'; };
|
||||
const fmtTime = t => t ? new Date(t*1000).toLocaleString() : '';
|
||||
|
||||
async function load(path) {
|
||||
const r = await api('list', {path});
|
||||
if (!r.ok) { toast(r.error || 'ошибка', true); return; }
|
||||
cwd = r.data.path; entries = r.data.entries || []; selected.clear();
|
||||
$('path').value = cwd;
|
||||
render();
|
||||
}
|
||||
|
||||
// Editable path: Enter navigates, or reports a missing directory.
|
||||
async function navTo(p) {
|
||||
p = (p || '').trim() || '/';
|
||||
const r = await api('list', {path: p});
|
||||
if (!r.ok) { toast(r.error || 'нет такого каталога', true); $('path').value = cwd; return; }
|
||||
cwd = r.data.path; entries = r.data.entries || []; selected.clear();
|
||||
$('path').value = cwd; render();
|
||||
}
|
||||
|
||||
function render() {
|
||||
const tb = $('list'); tb.innerHTML = '';
|
||||
updateSel();
|
||||
for (const e of entries) {
|
||||
const tr = document.createElement('tr');
|
||||
const isZip = !e.is_dir && /\.zip$/i.test(e.name);
|
||||
const dlHref = '/api/panel/agents/' + encodeURIComponent(agentID) + '/fs/download?path=' + encodeURIComponent(join(cwd, e.name));
|
||||
tr.innerHTML =
|
||||
'<td><input type="checkbox" class="ck" data-n="'+encodeURIComponent(e.name)+'"></td>' +
|
||||
'<td class="nm"><span class="ic">'+(e.is_dir?'📁':'📄')+'</span>'+esc(e.name)+'</td>' +
|
||||
'<td class="sz">'+(e.is_dir?'':fmtSize(e.size))+'</td>' +
|
||||
'<td class="sz">'+fmtTime(e.mtime)+'</td>' +
|
||||
'<td><div class="act">' +
|
||||
(isZip?'<button class="btn ghost small" data-act="unzip">unzip</button>':'') +
|
||||
(e.is_dir?'':'<a class="btn ghost small icon" title="Скачать" href="'+dlHref+'" download>'+ICON_DL+'</a>') +
|
||||
'<button class="btn ghost small icon" data-act="ren" title="Переместить / переименовать">'+ICON_MV+'</button>' +
|
||||
'<button class="btn danger small icon" data-act="del" title="Удалить">'+ICON_DEL+'</button>' +
|
||||
'</div></td>';
|
||||
const nm = tr.querySelector('.nm');
|
||||
nm.onclick = () => e.is_dir ? load(join(cwd, e.name)) : view(e);
|
||||
tr.querySelector('.ck').onchange = ev => { const n=e.name; ev.target.checked?selected.add(n):selected.delete(n); updateSel(); };
|
||||
tr.querySelector('[data-act="del"]').onclick = () => del(e);
|
||||
tr.querySelector('[data-act="ren"]').onclick = () => move(e);
|
||||
const uz = tr.querySelector('[data-act="unzip"]'); if (uz) uz.onclick = () => unzip(e);
|
||||
tb.appendChild(tr);
|
||||
}
|
||||
}
|
||||
const esc = s => s.replace(/[&<>"]/g, c => ({'&':'&','<':'<','>':'>','"':'"'}[c]));
|
||||
|
||||
function updateSel() {
|
||||
const n = selected.size;
|
||||
$('selinfo').textContent = n ? ('выбрано: '+n) : '';
|
||||
$('zipsel').disabled = !n; $('delsel').disabled = !n;
|
||||
}
|
||||
|
||||
async function view(e) {
|
||||
const r = await api('read', {path: join(cwd, e.name)});
|
||||
if (!r.ok) { toast(r.error || 'ошибка', true); return; }
|
||||
const d = r.data;
|
||||
$('vname').textContent = e.name;
|
||||
const ta = $('edit'); const save = $('save'); const msg = $('vmsg');
|
||||
if (d.binary) {
|
||||
ta.value = '(бинарный файл, просмотр недоступен)'; ta.readOnly = true; save.style.display='none';
|
||||
msg.textContent = 'Размер: '+fmtSize(d.size);
|
||||
} else {
|
||||
ta.value = new TextDecoder().decode(b64ToBytes(d.content));
|
||||
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 МБ)');
|
||||
save.onclick = async () => {
|
||||
const bytes = new TextEncoder().encode(ta.value);
|
||||
const w = await api('write', {path: join(cwd, e.name), data: bytesToB64(bytes)});
|
||||
if (w.ok) { toast('сохранено'); $('viewer').close(); load(cwd); } else toast(w.error||'ошибка', true);
|
||||
};
|
||||
}
|
||||
$('viewer').showModal();
|
||||
}
|
||||
$('closev').onclick = () => $('viewer').close();
|
||||
|
||||
async function del(e) {
|
||||
if (e.is_dir) {
|
||||
if (!confirm('Рекурсивно удалить каталог "'+e.name+'" со всем содержимым?')) return;
|
||||
const r = await api('deltree', {path: join(cwd, e.name)});
|
||||
r.ok ? (toast('удалено'), load(cwd)) : toast(r.error||'ошибка', true);
|
||||
} else {
|
||||
if (!confirm('Удалить файл "'+e.name+'"?')) return;
|
||||
const r = await api('delete', {path: join(cwd, e.name)});
|
||||
r.ok ? (toast('удалено'), load(cwd)) : toast(r.error||'ошибка', true);
|
||||
}
|
||||
}
|
||||
|
||||
async function move(e) {
|
||||
const dst = prompt('Переместить / переименовать в (полный путь):', join(cwd, e.name));
|
||||
if (!dst) return;
|
||||
const r = await api('move', {src: join(cwd, e.name), dst});
|
||||
r.ok ? (toast('перемещено'), load(cwd)) : toast(r.error||'ошибка', true);
|
||||
}
|
||||
|
||||
async function unzip(e) {
|
||||
const r = await api('unzip', {path: join(cwd, e.name), dest: cwd});
|
||||
r.ok ? (toast('распаковано'), load(cwd)) : toast(r.error||'ошибка', true);
|
||||
}
|
||||
|
||||
$('mkdir').onclick = async () => {
|
||||
const n = prompt('Имя новой папки:'); if (!n) return;
|
||||
const r = await api('mkdir', {path: join(cwd, n)});
|
||||
r.ok ? (toast('создано'), load(cwd)) : toast(r.error||'ошибка', true);
|
||||
};
|
||||
$('newfile').onclick = async () => {
|
||||
const n = prompt('Имя нового файла:'); if (!n) return;
|
||||
const r = await api('write', {path: join(cwd, n), data: ''});
|
||||
r.ok ? (toast('создано'), load(cwd)) : toast(r.error||'ошибка', true);
|
||||
};
|
||||
$('delsel').onclick = async () => {
|
||||
if (!confirm('Удалить выбранное ('+selected.size+')?')) return;
|
||||
for (const n of selected) {
|
||||
const e = entries.find(x => x.name === n);
|
||||
await api(e && e.is_dir ? 'deltree' : 'delete', {path: join(cwd, n)});
|
||||
}
|
||||
toast('удалено'); load(cwd);
|
||||
};
|
||||
$('zipsel').onclick = async () => {
|
||||
const name = prompt('Имя архива:', 'archive.zip'); if (!name) return;
|
||||
const paths = [...selected].map(n => join(cwd, n));
|
||||
const r = await api('zip', {paths, dest: join(cwd, name)});
|
||||
r.ok ? (toast('архив создан'), load(cwd)) : toast(r.error||'ошибка', true);
|
||||
};
|
||||
$('all').onchange = ev => {
|
||||
selected.clear();
|
||||
if (ev.target.checked) entries.forEach(e => selected.add(e.name));
|
||||
document.querySelectorAll('.ck').forEach(c => c.checked = ev.target.checked);
|
||||
updateSel();
|
||||
};
|
||||
$('up').onclick = () => load(parent(cwd));
|
||||
$('refresh').onclick = () => load(cwd);
|
||||
$('path').addEventListener('keydown', e => {
|
||||
if (e.key === 'Enter') { e.preventDefault(); navTo($('path').value); }
|
||||
});
|
||||
|
||||
// uploads
|
||||
async function uploadFiles(files) {
|
||||
for (const f of files) {
|
||||
if (f.size > 32*1024*1024) { toast('файл '+f.name+' больше 32 МБ', true); continue; }
|
||||
const buf = new Uint8Array(await f.arrayBuffer());
|
||||
const r = await api('write', {path: join(cwd, f.name), data: bytesToB64(buf)});
|
||||
if (!r.ok) { toast(r.error||('ошибка '+f.name), true); return; }
|
||||
}
|
||||
toast('загружено'); load(cwd);
|
||||
}
|
||||
$('upl').onchange = ev => uploadFiles(ev.target.files);
|
||||
const drop = $('drop');
|
||||
['dragenter','dragover'].forEach(x => drop.addEventListener(x, e => { e.preventDefault(); drop.classList.add('drag'); }));
|
||||
['dragleave','drop'].forEach(x => drop.addEventListener(x, e => { e.preventDefault(); if(x!=='drop')drop.classList.remove('drag'); }));
|
||||
drop.addEventListener('drop', e => { drop.classList.remove('drag'); if (e.dataTransfer.files.length) uploadFiles(e.dataTransfer.files); });
|
||||
|
||||
load('/');
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,116 @@
|
||||
<!doctype html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Порты · {{.Agent.Name}} — LPRA</title>
|
||||
<link rel="stylesheet" href="/static/app.css">
|
||||
<style>
|
||||
.frow { display:flex; align-items:flex-end; gap:14px; flex-wrap:wrap; }
|
||||
.frow label { margin:0 0 6px; }
|
||||
.frow .fld { display:flex; flex-direction:column; }
|
||||
.frow select, .frow input, .frow button {
|
||||
height:40px; box-sizing:border-box; font-size:14px; line-height:1; margin:0;
|
||||
}
|
||||
.frow select {
|
||||
padding:0 32px 0 12px; border-radius:8px; border:1px solid var(--border);
|
||||
background:#0c0f13; color:var(--text);
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header class="topbar">
|
||||
<div class="brand">LPRA · Порты — {{.Agent.Name}} <span class="muted">({{.Agent.Hostname}})</span></div>
|
||||
<nav>
|
||||
<a href="/">← Машины</a>
|
||||
<a href="/agents/{{.Agent.ID}}/terminal">Терминал</a>
|
||||
<a href="/agents/{{.Agent.ID}}/files">Файлы</a>
|
||||
</nav>
|
||||
</header>
|
||||
<div class="wrap">
|
||||
<h1>Проброс портов</h1>
|
||||
<p class="muted">Порт сервиса на машине-клиенте становится доступен на порту хоста.
|
||||
Клиент за NAT — трафик идёт исходящим подключением агента.</p>
|
||||
{{if .Error}}<div class="err">{{.Error}}</div>{{end}}
|
||||
|
||||
<div class="card">
|
||||
<h2>Правила</h2>
|
||||
{{if .Rules}}
|
||||
<table>
|
||||
<thead><tr><th>Тип</th><th>Хост (порт)</th><th>→ Клиент (порт)</th><th>Слушать на</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
{{range .Rules}}
|
||||
<tr>
|
||||
<td><code>{{.Proto}}</code></td>
|
||||
<td class="muted">:{{.HostPort}}</td>
|
||||
<td class="muted">127.0.0.1:{{.ClientPort}}</td>
|
||||
<td class="muted">{{.ListenAddr}}</td>
|
||||
<td style="text-align:right">
|
||||
<form method="post" action="/forwards/{{.ID}}/delete" style="display:inline"
|
||||
onsubmit="return confirm('Удалить правило?')">
|
||||
<input type="hidden" name="csrf" value="{{$.CSRF}}">
|
||||
<button class="btn danger small" type="submit">Удалить</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
{{else}}
|
||||
<p class="muted">Правил пока нет.</p>
|
||||
{{end}}
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>Новое правило</h2>
|
||||
<form method="post" action="/agents/{{.Agent.ID}}/forwards/add">
|
||||
<input type="hidden" name="csrf" value="{{.CSRF}}">
|
||||
<div class="frow">
|
||||
<div class="fld">
|
||||
<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>
|
||||
</select>
|
||||
</div>
|
||||
<div class="fld">
|
||||
<label>Порт на клиенте</label>
|
||||
<input type="text" name="client_port" placeholder="напр. 22" style="max-width:140px" required>
|
||||
</div>
|
||||
<div class="fld">
|
||||
<label>Порт на хосте</label>
|
||||
<input type="text" name="host_port" placeholder="напр. 2222" style="max-width:140px" required>
|
||||
</div>
|
||||
<div class="fld">
|
||||
<label>Слушать на хосте</label>
|
||||
<input type="text" name="listen_addr" value="0.0.0.0" style="max-width:160px">
|
||||
</div>
|
||||
<button type="submit">Создать</button>
|
||||
</div>
|
||||
</form>
|
||||
<p class="muted" id="note" style="font-size:13px; margin-top:12px"></p>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
</ul>
|
||||
<p class="muted" style="font-size:13px">Для tcp/udp агент держит исходящее соединение к <code>host:8446</code>; несколько клиентов и портов мультиплексируются на нём.</p>
|
||||
</div>
|
||||
</div>
|
||||
<script>
|
||||
var notes = {
|
||||
http: 'Трафик идёт через :8445 (long-poll). Порт 8446 не требуется.',
|
||||
tcp: 'Требуется открытый порт 8446/tcp на хосте. Самая низкая задержка.',
|
||||
udp: 'Требуется открытый порт 8446/udp на хосте.'
|
||||
};
|
||||
var sel = document.getElementById('proto'), note = document.getElementById('note');
|
||||
function upd(){ note.textContent = notes[sel.value] || ''; }
|
||||
sel.onchange = upd; upd();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,24 @@
|
||||
<!doctype html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Вход — LPRA</title>
|
||||
<link rel="stylesheet" href="/static/app.css">
|
||||
</head>
|
||||
<body>
|
||||
<div class="center">
|
||||
<div class="card">
|
||||
<h1>Вход в панель</h1>
|
||||
{{if .Error}}<div class="err">{{.Error}}</div>{{end}}
|
||||
<form method="post" action="/login">
|
||||
<label>Имя пользователя</label>
|
||||
<input type="text" name="username" autofocus required>
|
||||
<label>Пароль</label>
|
||||
<input type="password" name="password" required>
|
||||
<div style="margin-top:18px"><button type="submit">Войти</button></div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,143 @@
|
||||
<!doctype html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Настройки — LPRA</title>
|
||||
<link rel="stylesheet" href="/static/app.css">
|
||||
</head>
|
||||
<body>
|
||||
<header class="topbar">
|
||||
<div class="brand">LPRA · Удалённый доступ</div>
|
||||
<nav>
|
||||
<a href="/">Машины</a>
|
||||
<a href="/settings">Настройки</a>
|
||||
<form method="post" action="/logout" style="display:inline; margin-left:22px">
|
||||
<button class="btn ghost small" type="submit">Выйти</button>
|
||||
</form>
|
||||
</nav>
|
||||
</header>
|
||||
<div class="wrap">
|
||||
<h1>Настройки</h1>
|
||||
|
||||
<div class="card">
|
||||
<h2>Установка на новую машину</h2>
|
||||
<p class="muted">Выполните на Linux-клиенте под root. Скрипт сам скачает бинарник,
|
||||
поставит сервис и зарегистрирует машину.</p>
|
||||
|
||||
<label>Короткая команда (секрет уже вшит в ссылку)</label>
|
||||
<pre id="short">{{.InstallShort}}</pre>
|
||||
|
||||
<details style="margin-top:10px">
|
||||
<summary class="muted" style="cursor:pointer">Полные варианты (секрет в команде)</summary>
|
||||
<label>curl</label>
|
||||
<pre id="curl">{{.InstallCurl}}</pre>
|
||||
<label>wget</label>
|
||||
<pre id="wget">{{.InstallWget}}</pre>
|
||||
<p class="muted">Без переменных окружения скрипт спросит секрет интерактивно:
|
||||
<code>curl -fsSL {{.Host}}/install.sh | sudo sh</code></p>
|
||||
</details>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>Адрес хоста</h2>
|
||||
<p class="muted">URL, на который звонят агенты. Подставляется в скрипт установки.</p>
|
||||
<form method="post" action="/settings/host" class="row">
|
||||
<input type="hidden" name="csrf" value="{{.CSRF}}">
|
||||
<input type="text" name="host" value="{{.Host}}" style="max-width:420px">
|
||||
<button type="submit">Сохранить</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>Enroll-секрет</h2>
|
||||
<p class="muted">Общий секрет для регистрации новых агентов.</p>
|
||||
<div class="row">
|
||||
<code>{{.Secret}}</code>
|
||||
<form method="post" action="/settings/secret/rotate" style="display:inline"
|
||||
onsubmit="return confirm('Сменить секрет? Старые one-liner перестанут работать для новых установок.')">
|
||||
<input type="hidden" name="csrf" value="{{.CSRF}}">
|
||||
<button class="btn ghost small" type="submit">Сменить</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>Администраторы</h2>
|
||||
<table>
|
||||
<tbody>
|
||||
{{range .Admins}}
|
||||
<tr>
|
||||
<td>{{.Username}}</td>
|
||||
<td style="text-align:right">
|
||||
<form method="post" action="/settings/admins/delete" style="display:inline"
|
||||
onsubmit="return confirm('Удалить администратора {{.Username}}?')">
|
||||
<input type="hidden" name="csrf" value="{{$.CSRF}}">
|
||||
<input type="hidden" name="id" value="{{.ID}}">
|
||||
<button class="btn danger small" type="submit">Удалить</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
<form method="post" action="/settings/admins/add" style="margin-top:14px">
|
||||
<input type="hidden" name="csrf" value="{{.CSRF}}">
|
||||
<div class="row">
|
||||
<input type="text" name="username" placeholder="логин" style="max-width:200px" required>
|
||||
<input type="password" name="password" placeholder="пароль (мин. 8)" style="max-width:200px" required>
|
||||
<button type="submit">Добавить</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>Firewall (ufw)</h2>
|
||||
{{if not .UfwInstalled}}
|
||||
<p class="muted">ufw не установлен на хосте — интеграция недоступна.</p>
|
||||
{{else if not .UfwRunnable}}
|
||||
<p class="muted">ufw установлен, но сервер не может им управлять (нужны права root).
|
||||
Запустите сервер под root или настройте sudo для ufw.</p>
|
||||
{{else}}
|
||||
<p class="muted">
|
||||
Статус 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>
|
||||
<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}}">
|
||||
<button class="btn {{if .UfwEnabled}}ghost{{end}} small" type="submit">
|
||||
{{if .UfwEnabled}}Выключить интеграцию{{else}}Включить интеграцию{{end}}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{{if .UfwRules}}
|
||||
<h2 style="margin-top:18px; font-size:15px">Правила, созданные панелью</h2>
|
||||
<table>
|
||||
<tbody>
|
||||
{{range .UfwRules}}
|
||||
<tr>
|
||||
<td><code>{{.Port}}/{{.Proto}}</code></td>
|
||||
<td style="text-align:right">
|
||||
<form method="post" action="/settings/ufw/revoke" style="display:inline"
|
||||
onsubmit="return confirm('Отменить правило ufw {{.Port}}/{{.Proto}}?')">
|
||||
<input type="hidden" name="csrf" value="{{$.CSRF}}">
|
||||
<input type="hidden" name="id" value="{{.ID}}">
|
||||
<button class="btn danger small" type="submit">Отменить</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
{{else}}
|
||||
<p class="muted" style="font-size:13px; margin-top:10px">Панель пока не открывала портов в ufw.</p>
|
||||
{{end}}
|
||||
{{end}}
|
||||
</div>
|
||||
|
||||
<p class="muted" style="font-size:12px; text-align:center">LPRA сервер · версия {{.ServerVersion}}</p>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,27 @@
|
||||
<!doctype html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Настройка — LPRA</title>
|
||||
<link rel="stylesheet" href="/static/app.css">
|
||||
</head>
|
||||
<body>
|
||||
<div class="center">
|
||||
<div class="card">
|
||||
<h1>Первый запуск</h1>
|
||||
<p class="muted">Создайте учётную запись администратора панели.</p>
|
||||
{{if .Error}}<div class="err">{{.Error}}</div>{{end}}
|
||||
<form method="post" action="/setup">
|
||||
<label>Имя пользователя</label>
|
||||
<input type="text" name="username" autofocus required>
|
||||
<label>Пароль (мин. 8 символов)</label>
|
||||
<input type="password" name="password" required>
|
||||
<label>Повтор пароля</label>
|
||||
<input type="password" name="password2" required>
|
||||
<div style="margin-top:18px"><button type="submit">Создать и войти</button></div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,149 @@
|
||||
<!doctype html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Терминал · {{.Agent.Name}} — LPRA</title>
|
||||
<link rel="stylesheet" href="/static/app.css">
|
||||
<link rel="stylesheet" href="/static/xterm.css">
|
||||
<style>
|
||||
#status.st-ok { color:#37c871; }
|
||||
#status.st-err { color:#ff5c5c; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header class="topbar">
|
||||
<div class="brand">LPRA · Терминал — {{.Agent.Name}} <span class="muted">({{.Agent.Hostname}})</span></div>
|
||||
<nav>
|
||||
<a href="/">← Машины</a>
|
||||
<a href="/agents/{{.Agent.ID}}/files">Файлы</a>
|
||||
<a href="/agents/{{.Agent.ID}}/forwards">Порты</a>
|
||||
<span id="status" class="muted" style="margin-left:28px">подключение…</span>
|
||||
</nav>
|
||||
</header>
|
||||
<div class="wrap" style="max-width:none">
|
||||
<div id="terminal"></div>
|
||||
</div>
|
||||
|
||||
<script src="/static/xterm.js"></script>
|
||||
<script src="/static/addon-fit.js"></script>
|
||||
<script>
|
||||
(function () {
|
||||
const agentID = {{.Agent.ID}};
|
||||
const statusEl = document.getElementById('status');
|
||||
function setStatus(t, cls) { statusEl.textContent = t; statusEl.className = cls || ''; }
|
||||
|
||||
const term = new Terminal({
|
||||
cursorBlink: true,
|
||||
fontSize: 14,
|
||||
fontFamily: 'ui-monospace, SFMono-Regular, Menlo, monospace',
|
||||
theme: { background: '#0c0f13' }
|
||||
});
|
||||
const fit = new FitAddon.FitAddon();
|
||||
term.loadAddon(fit);
|
||||
term.open(document.getElementById('terminal'));
|
||||
fit.fit();
|
||||
|
||||
function bytesToB64(bytes) {
|
||||
let bin = '';
|
||||
for (let i = 0; i < bytes.length; i++) bin += String.fromCharCode(bytes[i]);
|
||||
return btoa(bin);
|
||||
}
|
||||
function b64ToBytes(b64) {
|
||||
const bin = atob(b64);
|
||||
const u = new Uint8Array(bin.length);
|
||||
for (let i = 0; i < bin.length; i++) u[i] = bin.charCodeAt(i);
|
||||
return u;
|
||||
}
|
||||
|
||||
const base = '/api/panel/term/';
|
||||
let sid = null;
|
||||
let closed = false;
|
||||
let connected = false;
|
||||
|
||||
function post(path, body) {
|
||||
return fetch(path, {
|
||||
method: 'POST',
|
||||
headers: body ? { 'Content-Type': 'application/json' } : {},
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
keepalive: true
|
||||
});
|
||||
}
|
||||
|
||||
async function open() {
|
||||
try {
|
||||
const r = await post(base + encodeURIComponent(agentID) + '/open');
|
||||
if (!r.ok) {
|
||||
const body = await r.text().catch(() => '');
|
||||
setStatus('ошибка открытия (' + r.status + ')', 'st-err');
|
||||
term.write('\r\n\x1b[31m[не удалось открыть сессию: HTTP ' + r.status + ']\x1b[0m\r\n');
|
||||
if (body) term.write(body.slice(0, 300).replace(/\n/g, '\r\n') + '\r\n');
|
||||
if (r.status === 404) term.write('\x1b[33mМашина не найдена на сервере — возможно, устаревший бинарник сервера без маршрута /open.\x1b[0m\r\n');
|
||||
return;
|
||||
}
|
||||
const j = await r.json();
|
||||
sid = j.session_id;
|
||||
setStatus('ожидание агента…');
|
||||
sendResize();
|
||||
pollLoop();
|
||||
} catch (e) {
|
||||
setStatus('ошибка сети', 'st-err');
|
||||
term.write('\r\n\x1b[31m[ошибка сети: ' + (e && e.message ? e.message : e) + ']\x1b[0m\r\n');
|
||||
}
|
||||
}
|
||||
|
||||
async function pollLoop() {
|
||||
while (!closed && sid) {
|
||||
let r;
|
||||
try {
|
||||
r = await fetch(base + sid + '/out', { cache: 'no-store' });
|
||||
} catch (e) {
|
||||
// transient network hiccup; brief pause then retry
|
||||
await new Promise(res => setTimeout(res, 1000));
|
||||
continue;
|
||||
}
|
||||
if (!r.ok) { finish('[соединение закрыто]'); return; }
|
||||
const j = await r.json();
|
||||
if (j.closed) { finish('[сессия завершена]'); return; }
|
||||
if (j.frames) {
|
||||
for (const f of j.frames) {
|
||||
if (f.exit) { finish('[сессия завершена]'); return; }
|
||||
if (f.data) {
|
||||
if (!connected) { connected = true; setStatus('подключено', 'st-ok'); }
|
||||
term.write(b64ToBytes(f.data));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function finish(msg) {
|
||||
if (closed) return;
|
||||
closed = true;
|
||||
setStatus('соединение закрыто', 'muted');
|
||||
term.write('\r\n\x1b[33m' + msg + '\x1b[0m\r\n');
|
||||
}
|
||||
|
||||
term.onData(function (data) {
|
||||
if (closed || !sid) return;
|
||||
const bytes = new TextEncoder().encode(data);
|
||||
post(base + sid + '/in', { t: 'i', d: bytesToB64(bytes) });
|
||||
});
|
||||
|
||||
function sendResize() {
|
||||
if (closed || !sid) return;
|
||||
post(base + sid + '/in', { t: 'r', cols: term.cols, rows: term.rows });
|
||||
}
|
||||
window.addEventListener('resize', function () { fit.fit(); sendResize(); });
|
||||
term.onResize(sendResize);
|
||||
|
||||
window.addEventListener('pagehide', function () {
|
||||
if (sid) navigator.sendBeacon(base + sid + '/close');
|
||||
});
|
||||
|
||||
term.focus();
|
||||
open();
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,100 @@
|
||||
// Package tunnel defines the framed multiplexing protocol used for DIRECT port
|
||||
// forwarding over the dedicated host port (default 8446). Unlike the HTTP
|
||||
// long-poll forward, this is a raw TCP stream the agent dials outbound; it
|
||||
// carries many concurrent forwarded connections (streams) for one agent and
|
||||
// has far lower latency than long-polling.
|
||||
//
|
||||
// Frame layout (big-endian):
|
||||
//
|
||||
// type(1) | stream(4) | length(4) | payload(length)
|
||||
package tunnel
|
||||
|
||||
import (
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"encoding/binary"
|
||||
"encoding/hex"
|
||||
"io"
|
||||
)
|
||||
|
||||
// DefaultPort is the host port for the direct tunnel (TCP and UDP).
|
||||
const DefaultPort = 8446
|
||||
|
||||
// Frame types.
|
||||
const (
|
||||
TypeOpen byte = 1 // host->agent: open a TCP stream to 127.0.0.1:port (payload = port uint16)
|
||||
TypeData byte = 2 // both: stream bytes
|
||||
TypeClose byte = 3 // both: close the stream
|
||||
TypePing byte = 4 // keepalive
|
||||
)
|
||||
|
||||
// MaxPayload caps a single frame payload (1 MiB).
|
||||
const MaxPayload = 1 << 20
|
||||
|
||||
// Frame is one multiplexed message.
|
||||
type Frame struct {
|
||||
Type byte
|
||||
Stream uint32
|
||||
Payload []byte
|
||||
}
|
||||
|
||||
// WriteFrame writes a frame. The caller must serialize writes to one conn.
|
||||
func WriteFrame(w io.Writer, f Frame) error {
|
||||
var hdr [9]byte
|
||||
hdr[0] = f.Type
|
||||
binary.BigEndian.PutUint32(hdr[1:5], f.Stream)
|
||||
binary.BigEndian.PutUint32(hdr[5:9], uint32(len(f.Payload)))
|
||||
if _, err := w.Write(hdr[:]); err != nil {
|
||||
return err
|
||||
}
|
||||
if len(f.Payload) > 0 {
|
||||
_, err := w.Write(f.Payload)
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ReadFrame reads one frame.
|
||||
func ReadFrame(r io.Reader) (Frame, error) {
|
||||
var hdr [9]byte
|
||||
if _, err := io.ReadFull(r, hdr[:]); err != nil {
|
||||
return Frame{}, err
|
||||
}
|
||||
n := binary.BigEndian.Uint32(hdr[5:9])
|
||||
if n > MaxPayload {
|
||||
return Frame{}, io.ErrUnexpectedEOF
|
||||
}
|
||||
f := Frame{Type: hdr[0], Stream: binary.BigEndian.Uint32(hdr[1:5])}
|
||||
if n > 0 {
|
||||
f.Payload = make([]byte, n)
|
||||
if _, err := io.ReadFull(r, f.Payload); err != nil {
|
||||
return Frame{}, err
|
||||
}
|
||||
}
|
||||
return f, nil
|
||||
}
|
||||
|
||||
// AuthKey derives the shared HMAC key from an agent token: sha256(token).
|
||||
// The server holds the same value as the stored token hash, so neither side
|
||||
// ever puts the plaintext token on the wire.
|
||||
func AuthKey(token string) []byte {
|
||||
sum := sha256.Sum256([]byte(token))
|
||||
return sum[:]
|
||||
}
|
||||
|
||||
// KeyFromHash decodes a stored hex token hash into the HMAC key bytes.
|
||||
func KeyFromHash(tokenHashHex string) ([]byte, error) {
|
||||
return hex.DecodeString(tokenHashHex)
|
||||
}
|
||||
|
||||
// Sign returns hex(HMAC-SHA256(key, nonce)).
|
||||
func Sign(key, nonce []byte) string {
|
||||
m := hmac.New(sha256.New, key)
|
||||
m.Write(nonce)
|
||||
return hex.EncodeToString(m.Sum(nil))
|
||||
}
|
||||
|
||||
// EqualSig compares two hex signatures in constant time.
|
||||
func EqualSig(a, b string) bool {
|
||||
return hmac.Equal([]byte(a), []byte(b))
|
||||
}
|
||||
BIN
Binary file not shown.
BIN
Binary file not shown.
Reference in new issue
Block a user