feat: initial commit

This commit is contained in:
redtea committed 2026-10-13 00:25:29 +03:00
1 parent 353f62e257
commit c6852c6f1b
62 files changed
+5911 -2

No files matched your search

Vendored
BIN
View File
Binary file not shown.
+48 -2
View File
@@ -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-доступ).
+1
View File
@@ -0,0 +1 @@
0.0.1
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
Executable
+47
View File
@@ -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"
BIN
View File
Binary file not shown.
+64
View File
@@ -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"))
}
+62
View File
@@ -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
}
+58
View File
@@ -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
+42
View File
@@ -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;
}
}
Vendored Executable
BIN
View File
Binary file not shown.
Vendored Executable
BIN
View File
Binary file not shown.
+20
View File
@@ -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
)
+28
View File
@@ -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=
BIN
View File
Binary file not shown.
+207
View File
@@ -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
+224
View File
@@ -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
}
+131
View File
@@ -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
}
+97
View File
@@ -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:])
}
+54
View File
@@ -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
}
+374
View File
@@ -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")
}
+76
View File
@@ -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)
}
+146
View File
@@ -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"`
}
BIN
View File
Binary file not shown.
+62
View File
@@ -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 ""
}
+150
View File
@@ -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)
}
+117
View File
@@ -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)
}
+452
View File
@@ -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())
}
+149
View File
@@ -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
}
}
}
}
}
+127
View File
@@ -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)
}
+337
View File
@@ -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))
}
+116
View File
@@ -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)
}
}
+101
View File
@@ -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)
}
+79
View File
@@ -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)
}
+258
View File
@@ -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
}
}
}
+232
View File
@@ -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
}
+241
View File
@@ -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]
}
+246
View File
@@ -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:])
}
+119
View File
@@ -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)
}
}
+26
View File
@@ -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))
}
}
BIN
View File
Binary file not shown.
+165
View File
@@ -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"
+2
View File
@@ -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
+72
View File
@@ -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; }
}
+209
View File
@@ -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;
}
+2
View File
@@ -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>
+62
View File
@@ -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>
+287
View File
@@ -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">Перетащите файлы сюда для загрузки. Клик по файлу — просмотр (правка, если &lt; 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 => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[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>
+116
View File
@@ -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>
+24
View File
@@ -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>
+143
View File
@@ -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>
+27
View File
@@ -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>
+149
View File
@@ -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>
+100
View File
@@ -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
View File
Binary file not shown.
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.