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