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

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>