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

+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)
}