feat: initial commit
This commit is contained in:
1 parent
353f62e257
commit
c6852c6f1b
62 files changed
+5911
-2
No files matched your search
@@ -0,0 +1,207 @@
|
||||
// Package agent implements the Linux client that dials home to the server over
|
||||
// HTTP long-polling and exposes a root PTY on demand.
|
||||
package agent
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"time"
|
||||
|
||||
"github.com/x-grk/longpolling-remote-access/internal/proto"
|
||||
)
|
||||
|
||||
// Version is the agent build version, injected at compile time via -ldflags
|
||||
// (-X .../internal/agent.Version=...).
|
||||
var Version = "dev"
|
||||
|
||||
// Config is the agent's runtime configuration (from env / config.env).
|
||||
type Config struct {
|
||||
Host string // e.g. https://REDACTED:8445
|
||||
EnrollSecret string
|
||||
Name string // optional friendly name
|
||||
StatePath string // where the enrolled id+token are persisted
|
||||
TunnelAddr string // host:port for the direct forward tunnel (e.g. REDACTED:8446); empty disables
|
||||
}
|
||||
|
||||
type state struct {
|
||||
AgentID string `json:"agent_id"`
|
||||
Token string `json:"token"`
|
||||
}
|
||||
|
||||
// Agent is a running client.
|
||||
type Agent struct {
|
||||
cfg Config
|
||||
http *http.Client
|
||||
state state
|
||||
pollCount int
|
||||
}
|
||||
|
||||
// New builds an agent. It does not perform any network I/O.
|
||||
func New(cfg Config) *Agent {
|
||||
// Tuned transport for low-latency forwarding: keep-alive connection reuse
|
||||
// (no TCP/TLS handshake per chunk) and a generous idle pool so many
|
||||
// concurrent streams don't fight over 2 connections. Plain HTTP/1.1 keeps
|
||||
// each stream on its own connection for predictable parallelism.
|
||||
tr := &http.Transport{
|
||||
Proxy: http.ProxyFromEnvironment,
|
||||
DialContext: (&net.Dialer{
|
||||
Timeout: 10 * time.Second,
|
||||
KeepAlive: 30 * time.Second,
|
||||
}).DialContext,
|
||||
ForceAttemptHTTP2: false,
|
||||
TLSNextProto: map[string]func(string, *tls.Conn) http.RoundTripper{},
|
||||
MaxIdleConns: 100,
|
||||
MaxIdleConnsPerHost: 32,
|
||||
IdleConnTimeout: 90 * time.Second,
|
||||
TLSHandshakeTimeout: 10 * time.Second,
|
||||
ExpectContinueTimeout: time.Second,
|
||||
}
|
||||
return &Agent{
|
||||
cfg: cfg,
|
||||
// Timeout must exceed the server's long-poll hold (25s) plus slack.
|
||||
http: &http.Client{Timeout: 60 * time.Second, Transport: tr},
|
||||
}
|
||||
}
|
||||
|
||||
// Run enrolls if needed and then loops the control poll forever, reconnecting
|
||||
// on error with a short backoff. It returns only on a fatal config error.
|
||||
func (a *Agent) Run(ctx context.Context) error {
|
||||
if err := a.loadState(); err != nil {
|
||||
return err
|
||||
}
|
||||
if a.state.AgentID == "" || a.state.Token == "" {
|
||||
if err := a.enroll(ctx); err != nil {
|
||||
return fmt.Errorf("enroll: %w", err)
|
||||
}
|
||||
}
|
||||
log.Printf("agent %s connected to %s", a.state.AgentID, a.cfg.Host)
|
||||
|
||||
// Direct forward tunnel (low-latency TCP mux + UDP) on 8446, if configured.
|
||||
if a.cfg.TunnelAddr != "" {
|
||||
go a.runTunnel(ctx)
|
||||
go a.runUDP(ctx)
|
||||
}
|
||||
|
||||
backoff := time.Second
|
||||
for ctx.Err() == nil {
|
||||
if err := a.pollOnce(ctx); err != nil {
|
||||
log.Printf("poll: %v (retry in %s)", err, backoff)
|
||||
select {
|
||||
case <-time.After(backoff):
|
||||
case <-ctx.Done():
|
||||
return nil
|
||||
}
|
||||
if backoff < 15*time.Second {
|
||||
backoff *= 2
|
||||
}
|
||||
continue
|
||||
}
|
||||
backoff = time.Second
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *Agent) enroll(ctx context.Context) error {
|
||||
host, _ := os.Hostname()
|
||||
body, _ := json.Marshal(proto.EnrollRequest{
|
||||
Name: a.cfg.Name,
|
||||
Hostname: host,
|
||||
OS: runtime.GOOS,
|
||||
Arch: runtime.GOARCH,
|
||||
Version: Version,
|
||||
})
|
||||
req, _ := http.NewRequestWithContext(ctx, http.MethodPost, a.cfg.Host+"/api/agent/enroll", bytes.NewReader(body))
|
||||
req.Header.Set("X-Enroll-Secret", a.cfg.EnrollSecret)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp, err := a.http.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
b, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
|
||||
return fmt.Errorf("server said %s: %s", resp.Status, bytes.TrimSpace(b))
|
||||
}
|
||||
var er proto.EnrollResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&er); err != nil {
|
||||
return err
|
||||
}
|
||||
a.state = state{AgentID: er.AgentID, Token: er.Token}
|
||||
return a.saveState()
|
||||
}
|
||||
|
||||
// pollOnce performs one control long-poll and dispatches any instruction.
|
||||
func (a *Agent) pollOnce(ctx context.Context) error {
|
||||
req, _ := http.NewRequestWithContext(ctx, http.MethodGet, a.cfg.Host+"/api/agent/poll", nil)
|
||||
a.authHeaders(req)
|
||||
// Report the version occasionally (not every poll), so the panel can flag
|
||||
// outdated agents without extra traffic.
|
||||
a.pollCount++
|
||||
if a.pollCount%20 == 1 {
|
||||
req.Header.Set("X-Agent-Version", Version)
|
||||
}
|
||||
resp, err := a.http.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode == http.StatusUnauthorized {
|
||||
return fmt.Errorf("unauthorized (token rejected)")
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("status %s", resp.Status)
|
||||
}
|
||||
var msg proto.ControlMessage
|
||||
if err := json.NewDecoder(resp.Body).Decode(&msg); err != nil {
|
||||
return err
|
||||
}
|
||||
// Dispatch in the background so we keep polling for more work.
|
||||
switch msg.Type {
|
||||
case proto.CtrlOpenTerminal:
|
||||
go a.runTerminal(ctx, msg.SessionID)
|
||||
case proto.CtrlOpenForward:
|
||||
go a.runForward(ctx, msg.SessionID, msg.Port)
|
||||
case proto.CtrlTask:
|
||||
go a.runTask(ctx, msg.TaskID)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *Agent) authHeaders(req *http.Request) {
|
||||
req.Header.Set("X-Agent-Id", a.state.AgentID)
|
||||
req.Header.Set("Authorization", "Bearer "+a.state.Token)
|
||||
}
|
||||
|
||||
// --- state persistence ---
|
||||
|
||||
func (a *Agent) loadState() error {
|
||||
b, err := os.ReadFile(a.cfg.StatePath)
|
||||
if os.IsNotExist(err) {
|
||||
return nil
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return json.Unmarshal(b, &a.state)
|
||||
}
|
||||
|
||||
func (a *Agent) saveState() error {
|
||||
if err := os.MkdirAll(filepath.Dir(a.cfg.StatePath), 0o700); err != nil {
|
||||
return err
|
||||
}
|
||||
b, _ := json.MarshalIndent(a.state, "", " ")
|
||||
return os.WriteFile(a.cfg.StatePath, b, 0o600)
|
||||
}
|
||||
|
||||
var b64 = base64.StdEncoding
|
||||
@@ -0,0 +1,224 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/x-grk/longpolling-remote-access/internal/proto"
|
||||
)
|
||||
|
||||
// fsReadCap bounds how many bytes a view/read returns (5 MiB).
|
||||
const fsReadCap = 5 * 1024 * 1024
|
||||
|
||||
// fsDownloadCap bounds a browser download (kept under the 64 MiB result/body
|
||||
// limit once base64-encoded).
|
||||
const fsDownloadCap = 40 * 1024 * 1024
|
||||
|
||||
// dispatchFS runs one native file-manager operation.
|
||||
func dispatchFS(env proto.TaskEnvelope) (any, error) {
|
||||
switch env.Type {
|
||||
case proto.TaskFSList:
|
||||
var a proto.FSPathArgs
|
||||
_ = json.Unmarshal(env.Args, &a)
|
||||
return fsList(a.Path)
|
||||
case proto.TaskFSRead:
|
||||
var a proto.FSPathArgs
|
||||
_ = json.Unmarshal(env.Args, &a)
|
||||
return fsReadCapped(a.Path, fsReadCap)
|
||||
case proto.TaskFSDownload:
|
||||
var a proto.FSPathArgs
|
||||
_ = json.Unmarshal(env.Args, &a)
|
||||
return fsReadCapped(a.Path, fsDownloadCap)
|
||||
case proto.TaskFSWrite:
|
||||
var a proto.FSWriteArgs
|
||||
_ = json.Unmarshal(env.Args, &a)
|
||||
return fsWrite(a.Path, a.Data)
|
||||
case proto.TaskFSMkdir:
|
||||
var a proto.FSPathArgs
|
||||
_ = json.Unmarshal(env.Args, &a)
|
||||
return map[string]string{"path": a.Path}, os.MkdirAll(a.Path, 0o755)
|
||||
case proto.TaskFSDelete:
|
||||
var a proto.FSPathArgs
|
||||
_ = json.Unmarshal(env.Args, &a)
|
||||
return map[string]string{"path": a.Path}, os.Remove(a.Path)
|
||||
case proto.TaskFSDelTree:
|
||||
var a proto.FSPathArgs
|
||||
_ = json.Unmarshal(env.Args, &a)
|
||||
if a.Path == "" || a.Path == "/" {
|
||||
return nil, errors.New("отказ: удаление корня запрещено")
|
||||
}
|
||||
return map[string]string{"path": a.Path}, os.RemoveAll(a.Path)
|
||||
case proto.TaskFSMove:
|
||||
var a proto.FSMoveArgs
|
||||
_ = json.Unmarshal(env.Args, &a)
|
||||
return map[string]string{"src": a.Src, "dst": a.Dst}, os.Rename(a.Src, a.Dst)
|
||||
case proto.TaskFSUnzip:
|
||||
var a proto.FSUnzipArgs
|
||||
_ = json.Unmarshal(env.Args, &a)
|
||||
return fsUnzip(a.Path, a.Dest)
|
||||
case proto.TaskFSZip:
|
||||
var a proto.FSZipArgs
|
||||
_ = json.Unmarshal(env.Args, &a)
|
||||
return fsZip(a.Paths, a.Dest)
|
||||
default:
|
||||
return nil, fmt.Errorf("неизвестная операция %q", env.Type)
|
||||
}
|
||||
}
|
||||
|
||||
func fsList(path string) (proto.FSListResult, error) {
|
||||
if path == "" {
|
||||
path = "/"
|
||||
}
|
||||
abs, _ := filepath.Abs(path)
|
||||
entries, err := os.ReadDir(abs)
|
||||
if err != nil {
|
||||
return proto.FSListResult{}, err
|
||||
}
|
||||
out := make([]proto.FSEntry, 0, len(entries))
|
||||
for _, e := range entries {
|
||||
info, err := e.Info()
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
out = append(out, proto.FSEntry{
|
||||
Name: e.Name(),
|
||||
IsDir: e.IsDir(),
|
||||
Size: info.Size(),
|
||||
Mode: info.Mode().String(),
|
||||
MTime: info.ModTime().Unix(),
|
||||
})
|
||||
}
|
||||
// Directories first, then alphabetical.
|
||||
sort.Slice(out, func(i, j int) bool {
|
||||
if out[i].IsDir != out[j].IsDir {
|
||||
return out[i].IsDir
|
||||
}
|
||||
return strings.ToLower(out[i].Name) < strings.ToLower(out[j].Name)
|
||||
})
|
||||
return proto.FSListResult{Path: abs, Entries: out}, nil
|
||||
}
|
||||
|
||||
func fsReadCapped(path string, cap int) (proto.FSReadResult, error) {
|
||||
info, err := os.Stat(path)
|
||||
if err != nil {
|
||||
return proto.FSReadResult{}, err
|
||||
}
|
||||
if info.IsDir() {
|
||||
return proto.FSReadResult{}, errors.New("это каталог")
|
||||
}
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return proto.FSReadResult{}, err
|
||||
}
|
||||
defer f.Close()
|
||||
buf := make([]byte, cap+1)
|
||||
n, _ := io.ReadFull(f, buf)
|
||||
truncated := n > cap
|
||||
if truncated {
|
||||
n = cap
|
||||
}
|
||||
data := buf[:n]
|
||||
binary := !utf8.Valid(data) || containsNUL(data)
|
||||
return proto.FSReadResult{
|
||||
Path: path,
|
||||
Size: info.Size(),
|
||||
Content: base64.StdEncoding.EncodeToString(data),
|
||||
Truncated: truncated,
|
||||
Binary: binary,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func containsNUL(b []byte) bool {
|
||||
for _, c := range b {
|
||||
if c == 0 {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func fsWrite(path, dataB64 string) (any, error) {
|
||||
data, err := base64.StdEncoding.DecodeString(dataB64)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := os.WriteFile(path, data, 0o644); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return map[string]any{"path": path, "size": len(data)}, nil
|
||||
}
|
||||
|
||||
func fsUnzip(path, dest string) (any, error) {
|
||||
if dest == "" {
|
||||
dest = filepath.Dir(path)
|
||||
}
|
||||
bin, err := exec.LookPath("unzip")
|
||||
if err != nil {
|
||||
return nil, errors.New("unzip не установлен на клиенте")
|
||||
}
|
||||
out, err := exec.Command(bin, "-o", path, "-d", dest).CombinedOutput()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("unzip: %v: %s", err, strings.TrimSpace(string(out)))
|
||||
}
|
||||
return map[string]string{"dest": dest}, nil
|
||||
}
|
||||
|
||||
// fsZip creates dest as a zip archive of the given paths (files and dirs).
|
||||
func fsZip(paths []string, dest string) (any, error) {
|
||||
if dest == "" {
|
||||
return nil, errors.New("не указан путь архива")
|
||||
}
|
||||
if len(paths) == 0 {
|
||||
return nil, errors.New("не выбраны файлы")
|
||||
}
|
||||
f, err := os.Create(dest)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer f.Close()
|
||||
zw := zip.NewWriter(f)
|
||||
defer zw.Close()
|
||||
|
||||
for _, p := range paths {
|
||||
parent := filepath.Dir(p)
|
||||
err := filepath.Walk(p, func(fp string, info os.FileInfo, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rel, err := filepath.Rel(parent, fp)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rel = filepath.ToSlash(rel)
|
||||
if info.IsDir() {
|
||||
_, err := zw.Create(rel + "/")
|
||||
return err
|
||||
}
|
||||
w, err := zw.Create(rel)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
src, err := os.Open(fp)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer src.Close()
|
||||
_, err = io.Copy(w, src)
|
||||
return err
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return map[string]string{"dest": dest}, nil
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"log"
|
||||
"net"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/x-grk/longpolling-remote-access/internal/proto"
|
||||
)
|
||||
|
||||
// runTerminal spawns a PTY and bridges it to the session.
|
||||
func (a *Agent) runTerminal(parent context.Context, sid string) {
|
||||
term, err := StartPTY()
|
||||
if err != nil {
|
||||
log.Printf("term %s: start pty: %v", sid, err)
|
||||
a.postOut(context.Background(), sid, proto.OutMessage{Exit: true})
|
||||
return
|
||||
}
|
||||
log.Printf("term %s: shell started", sid)
|
||||
a.bridgeStream(parent, sid, term, term.Resize)
|
||||
log.Printf("term %s: ended", sid)
|
||||
}
|
||||
|
||||
// runForward dials 127.0.0.1:port on the client and bridges it to the session,
|
||||
// giving the host a reverse TCP tunnel over the same long-poll transport.
|
||||
func (a *Agent) runForward(parent context.Context, sid string, port int) {
|
||||
d := net.Dialer{Timeout: 10 * time.Second}
|
||||
conn, err := d.DialContext(parent, "tcp", net.JoinHostPort("127.0.0.1", strconv.Itoa(port)))
|
||||
if err != nil {
|
||||
log.Printf("forward %s: dial :%d: %v", sid, port, err)
|
||||
a.postOut(context.Background(), sid, proto.OutMessage{Exit: true})
|
||||
return
|
||||
}
|
||||
// TCP_NODELAY (default on in Go) keeps latency low for small packets.
|
||||
if tc, ok := conn.(*net.TCPConn); ok {
|
||||
_ = tc.SetNoDelay(true)
|
||||
}
|
||||
log.Printf("forward %s: connected to :%d", sid, port)
|
||||
a.bridgeStream(parent, sid, conn, nil)
|
||||
}
|
||||
|
||||
// bridgeStream wires a local byte stream (PTY or socket) to a hub session:
|
||||
// server->agent frames are written to rw, and rw output is streamed back.
|
||||
func (a *Agent) bridgeStream(parent context.Context, sid string, rw io.ReadWriteCloser, onResize func(cols, rows uint16)) {
|
||||
ctx, cancel := context.WithCancel(parent)
|
||||
defer cancel()
|
||||
defer rw.Close()
|
||||
|
||||
// Downstream: pull input frames and write them to the stream.
|
||||
go func() {
|
||||
defer cancel()
|
||||
for ctx.Err() == nil {
|
||||
if !a.pollIn(ctx, sid, rw, onResize) {
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
// Upstream: stream output until the stream (or session) ends.
|
||||
buf := make([]byte, 32*1024)
|
||||
for {
|
||||
n, err := rw.Read(buf)
|
||||
if n > 0 {
|
||||
if !a.postOut(ctx, sid, proto.OutMessage{Data: b64.EncodeToString(buf[:n])}) {
|
||||
return
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
a.postOut(ctx, sid, proto.OutMessage{Exit: true})
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// pollIn performs one downstream long-poll and applies the frames.
|
||||
// Returns false when the session is gone and the loop should stop.
|
||||
func (a *Agent) pollIn(ctx context.Context, sid string, w io.Writer, onResize func(cols, rows uint16)) bool {
|
||||
req, _ := http.NewRequestWithContext(ctx, http.MethodGet, a.cfg.Host+"/api/agent/term/"+sid+"/in", nil)
|
||||
a.authHeaders(req)
|
||||
resp, err := a.http.Do(req)
|
||||
if err != nil {
|
||||
return ctx.Err() == nil
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode == http.StatusGone || resp.StatusCode == http.StatusUnauthorized {
|
||||
return false
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return true
|
||||
}
|
||||
var batch proto.InBatch
|
||||
if err := json.NewDecoder(resp.Body).Decode(&batch); err != nil {
|
||||
return true
|
||||
}
|
||||
for _, f := range batch.Frames {
|
||||
switch f.Type {
|
||||
case proto.FrameStdin:
|
||||
if data, err := b64.DecodeString(f.Data); err == nil {
|
||||
_, _ = w.Write(data)
|
||||
}
|
||||
case proto.FrameResize:
|
||||
if onResize != nil {
|
||||
onResize(f.Cols, f.Rows)
|
||||
}
|
||||
case proto.FrameClose:
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// postOut sends one upstream message. Returns false if the session is gone.
|
||||
func (a *Agent) postOut(ctx context.Context, sid string, msg proto.OutMessage) bool {
|
||||
body, _ := json.Marshal(msg)
|
||||
req, _ := http.NewRequestWithContext(ctx, http.MethodPost, a.cfg.Host+"/api/agent/term/"+sid+"/out", bytes.NewReader(body))
|
||||
a.authHeaders(req)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp, err := a.http.Do(req)
|
||||
if err != nil {
|
||||
return ctx.Err() == nil
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
_, _ = io.Copy(io.Discard, resp.Body) // drain so the connection is reused
|
||||
return resp.StatusCode == http.StatusNoContent
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
|
||||
"github.com/x-grk/longpolling-remote-access/internal/proto"
|
||||
)
|
||||
|
||||
// runTask fetches one task, executes it, and posts the result back.
|
||||
func (a *Agent) runTask(ctx context.Context, taskID string) {
|
||||
env, err := a.fetchTask(ctx, taskID)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
var res proto.TaskResult
|
||||
if env.Type == proto.TaskSysUpdate {
|
||||
res = a.doUpdate()
|
||||
} else {
|
||||
res = a.execTask(env)
|
||||
}
|
||||
a.postResult(ctx, taskID, res)
|
||||
}
|
||||
|
||||
func (a *Agent) fetchTask(ctx context.Context, taskID string) (proto.TaskEnvelope, error) {
|
||||
var env proto.TaskEnvelope
|
||||
req, _ := http.NewRequestWithContext(ctx, http.MethodGet, a.cfg.Host+"/api/agent/task/"+taskID, nil)
|
||||
a.authHeaders(req)
|
||||
resp, err := a.http.Do(req)
|
||||
if err != nil {
|
||||
return env, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return env, errStatus(resp.StatusCode)
|
||||
}
|
||||
err = json.NewDecoder(resp.Body).Decode(&env)
|
||||
return env, err
|
||||
}
|
||||
|
||||
func (a *Agent) postResult(ctx context.Context, taskID string, res proto.TaskResult) {
|
||||
body, _ := json.Marshal(res)
|
||||
req, _ := http.NewRequestWithContext(ctx, http.MethodPost, a.cfg.Host+"/api/agent/task/"+taskID+"/result", bytes.NewReader(body))
|
||||
a.authHeaders(req)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp, err := a.http.Do(req)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
io.Copy(io.Discard, resp.Body)
|
||||
resp.Body.Close()
|
||||
}
|
||||
|
||||
// execTask runs a single file-manager operation natively and returns a result.
|
||||
func (a *Agent) execTask(env proto.TaskEnvelope) proto.TaskResult {
|
||||
data, err := dispatchFS(env)
|
||||
if err != nil {
|
||||
return proto.TaskResult{Error: err.Error()}
|
||||
}
|
||||
raw, err := json.Marshal(data)
|
||||
if err != nil {
|
||||
return proto.TaskResult{Error: err.Error()}
|
||||
}
|
||||
return proto.TaskResult{OK: true, Data: raw}
|
||||
}
|
||||
|
||||
type statusErr int
|
||||
|
||||
func (e statusErr) Error() string { return "server status " + itoa(int(e)) }
|
||||
func errStatus(code int) error { return statusErr(code) }
|
||||
|
||||
func itoa(n int) string { return itoa64(int64(n)) }
|
||||
|
||||
func itoa64(n int64) string {
|
||||
if n == 0 {
|
||||
return "0"
|
||||
}
|
||||
neg := n < 0
|
||||
if neg {
|
||||
n = -n
|
||||
}
|
||||
var b [24]byte
|
||||
i := len(b)
|
||||
for n > 0 {
|
||||
i--
|
||||
b[i] = byte('0' + n%10)
|
||||
n /= 10
|
||||
}
|
||||
if neg {
|
||||
i--
|
||||
b[i] = '-'
|
||||
}
|
||||
return string(b[i:])
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"os"
|
||||
"os/exec"
|
||||
|
||||
"github.com/creack/pty"
|
||||
)
|
||||
|
||||
// PTY is a pseudo-terminal running an interactive login shell.
|
||||
type PTY struct {
|
||||
cmd *exec.Cmd
|
||||
f *os.File
|
||||
}
|
||||
|
||||
// StartPTY launches the shell attached to a new PTY. The agent runs as root
|
||||
// (systemd User=root), so this is a root shell by design.
|
||||
func StartPTY() (*PTY, error) {
|
||||
shell := "/bin/bash"
|
||||
if _, err := os.Stat(shell); err != nil {
|
||||
shell = "/bin/sh"
|
||||
}
|
||||
cmd := exec.Command(shell, "-l")
|
||||
cmd.Env = append(os.Environ(), "TERM=xterm-256color")
|
||||
f, err := pty.Start(cmd)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &PTY{cmd: cmd, f: f}, nil
|
||||
}
|
||||
|
||||
// Read reads terminal output.
|
||||
func (p *PTY) Read(b []byte) (int, error) { return p.f.Read(b) }
|
||||
|
||||
// Write writes keystrokes to the terminal.
|
||||
func (p *PTY) Write(b []byte) (int, error) { return p.f.Write(b) }
|
||||
|
||||
// Resize changes the terminal window size.
|
||||
func (p *PTY) Resize(cols, rows uint16) {
|
||||
if cols == 0 || rows == 0 {
|
||||
return
|
||||
}
|
||||
_ = pty.Setsize(p.f, &pty.Winsize{Rows: rows, Cols: cols})
|
||||
}
|
||||
|
||||
// Close terminates the shell and releases the PTY.
|
||||
func (p *PTY) Close() error {
|
||||
err := p.f.Close()
|
||||
if p.cmd.Process != nil {
|
||||
_ = p.cmd.Process.Kill()
|
||||
}
|
||||
_ = p.cmd.Wait()
|
||||
return err
|
||||
}
|
||||
@@ -0,0 +1,374 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/binary"
|
||||
"fmt"
|
||||
"log"
|
||||
"net"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/x-grk/longpolling-remote-access/internal/tunnel"
|
||||
)
|
||||
|
||||
// --- direct TCP mux tunnel ---
|
||||
|
||||
// runTunnel keeps a persistent connection to the host's 8446 tunnel, dialing
|
||||
// client-side ports on demand for "tcp" forward rules.
|
||||
func (a *Agent) runTunnel(ctx context.Context) {
|
||||
backoff := time.Second
|
||||
for ctx.Err() == nil {
|
||||
if err := a.tunnelOnce(ctx); err != nil {
|
||||
select {
|
||||
case <-time.After(backoff):
|
||||
case <-ctx.Done():
|
||||
return
|
||||
}
|
||||
if backoff < 15*time.Second {
|
||||
backoff *= 2
|
||||
}
|
||||
continue
|
||||
}
|
||||
backoff = time.Second
|
||||
}
|
||||
}
|
||||
|
||||
func (a *Agent) tunnelOnce(ctx context.Context) error {
|
||||
d := net.Dialer{Timeout: 10 * time.Second}
|
||||
conn, err := d.DialContext(ctx, "tcp", a.cfg.TunnelAddr)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer conn.Close()
|
||||
if tc, ok := conn.(*net.TCPConn); ok {
|
||||
_ = tc.SetNoDelay(true)
|
||||
}
|
||||
|
||||
// Handshake: server sends "LPRA-TUN1 <nonceHex>", we reply "<agentID> <sig>".
|
||||
_ = conn.SetDeadline(time.Now().Add(10 * time.Second))
|
||||
line, err := readLineConn(conn, 512)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
f := strings.Fields(line)
|
||||
if len(f) != 2 || f[0] != "LPRA-TUN1" {
|
||||
return fmt.Errorf("bad tunnel greeting")
|
||||
}
|
||||
nonce, err := hexDecode(f[1])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
key := tunnel.AuthKey(a.state.Token)
|
||||
if _, err := fmt.Fprintf(conn, "%s %s\n", a.state.AgentID, tunnel.Sign(key, nonce)); err != nil {
|
||||
return err
|
||||
}
|
||||
_ = conn.SetDeadline(time.Time{})
|
||||
log.Printf("tunnel: connected to %s", a.cfg.TunnelAddr)
|
||||
|
||||
tc := &tunClient{conn: conn, streams: map[uint32]*agentStream{}}
|
||||
return tc.readLoop()
|
||||
}
|
||||
|
||||
type tunClient struct {
|
||||
conn net.Conn
|
||||
wmu sync.Mutex
|
||||
mu sync.Mutex
|
||||
streams map[uint32]*agentStream
|
||||
}
|
||||
|
||||
// agentStream buffers downstream data in a channel so bytes that arrive before
|
||||
// the client-side dial completes are not lost (the OPEN/DATA race).
|
||||
type agentStream struct {
|
||||
in chan []byte
|
||||
closed chan struct{}
|
||||
once sync.Once
|
||||
}
|
||||
|
||||
func (s *agentStream) close() { s.once.Do(func() { close(s.closed) }) }
|
||||
|
||||
func (t *tunClient) write(f tunnel.Frame) error {
|
||||
t.wmu.Lock()
|
||||
defer t.wmu.Unlock()
|
||||
return tunnel.WriteFrame(t.conn, f)
|
||||
}
|
||||
|
||||
func (t *tunClient) readLoop() error {
|
||||
for {
|
||||
f, err := tunnel.ReadFrame(t.conn)
|
||||
if err != nil {
|
||||
t.shutdown()
|
||||
return err
|
||||
}
|
||||
switch f.Type {
|
||||
case tunnel.TypeOpen:
|
||||
if len(f.Payload) >= 2 {
|
||||
port := int(f.Payload[0])<<8 | int(f.Payload[1])
|
||||
t.mu.Lock()
|
||||
s := &agentStream{in: make(chan []byte, 256), closed: make(chan struct{})}
|
||||
t.streams[f.Stream] = s
|
||||
t.mu.Unlock()
|
||||
go t.openStream(f.Stream, port, s)
|
||||
}
|
||||
case tunnel.TypeData:
|
||||
t.mu.Lock()
|
||||
s := t.streams[f.Stream]
|
||||
t.mu.Unlock()
|
||||
if s != nil {
|
||||
select {
|
||||
case s.in <- f.Payload:
|
||||
case <-s.closed:
|
||||
}
|
||||
}
|
||||
case tunnel.TypeClose:
|
||||
t.closeStream(f.Stream)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (t *tunClient) openStream(id uint32, port int, s *agentStream) {
|
||||
conn, err := net.DialTimeout("tcp", net.JoinHostPort("127.0.0.1", itoa(port)), 10*time.Second)
|
||||
if err != nil {
|
||||
_ = t.write(tunnel.Frame{Type: tunnel.TypeClose, Stream: id})
|
||||
t.closeStream(id)
|
||||
return
|
||||
}
|
||||
if tc, ok := conn.(*net.TCPConn); ok {
|
||||
_ = tc.SetNoDelay(true)
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
// downstream buffer -> local socket
|
||||
go func() {
|
||||
for {
|
||||
select {
|
||||
case b := <-s.in:
|
||||
if _, err := conn.Write(b); err != nil {
|
||||
s.close()
|
||||
return
|
||||
}
|
||||
case <-s.closed:
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
// local socket -> upstream frames
|
||||
buf := make([]byte, 32*1024)
|
||||
for {
|
||||
n, err := conn.Read(buf)
|
||||
if n > 0 {
|
||||
if t.write(tunnel.Frame{Type: tunnel.TypeData, Stream: id, Payload: append([]byte(nil), buf[:n]...)}) != nil {
|
||||
break
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
break
|
||||
}
|
||||
}
|
||||
_ = t.write(tunnel.Frame{Type: tunnel.TypeClose, Stream: id})
|
||||
t.closeStream(id)
|
||||
}
|
||||
|
||||
func (t *tunClient) closeStream(id uint32) {
|
||||
t.mu.Lock()
|
||||
s := t.streams[id]
|
||||
delete(t.streams, id)
|
||||
t.mu.Unlock()
|
||||
if s != nil {
|
||||
s.close()
|
||||
}
|
||||
}
|
||||
|
||||
func (t *tunClient) shutdown() {
|
||||
t.mu.Lock()
|
||||
streams := t.streams
|
||||
t.streams = map[uint32]*agentStream{}
|
||||
t.mu.Unlock()
|
||||
for _, s := range streams {
|
||||
s.close()
|
||||
}
|
||||
t.conn.Close()
|
||||
}
|
||||
|
||||
// --- direct UDP forwarding ---
|
||||
|
||||
// runUDP maintains the agent's UDP endpoint on the host's 8446 channel and
|
||||
// relays datagrams to/from client-side UDP ports for "udp" forward rules.
|
||||
func (a *Agent) runUDP(ctx context.Context) {
|
||||
backoff := time.Second
|
||||
for ctx.Err() == nil {
|
||||
if err := a.udpOnce(ctx); err != nil {
|
||||
select {
|
||||
case <-time.After(backoff):
|
||||
case <-ctx.Done():
|
||||
return
|
||||
}
|
||||
if backoff < 15*time.Second {
|
||||
backoff *= 2
|
||||
}
|
||||
continue
|
||||
}
|
||||
backoff = time.Second
|
||||
}
|
||||
}
|
||||
|
||||
func (a *Agent) udpOnce(ctx context.Context) error {
|
||||
raddr, err := net.ResolveUDPAddr("udp", a.cfg.TunnelAddr)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
conn, err := net.DialUDP("udp", nil, raddr)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
key := tunnel.AuthKey(a.state.Token)
|
||||
register := func() {
|
||||
m := time.Now().Unix() / 60
|
||||
sig := tunnel.Sign(key, []byte(a.state.AgentID+"|"+itoa64(m)))
|
||||
_, _ = conn.Write([]byte("R" + a.state.AgentID + " " + sig))
|
||||
}
|
||||
register()
|
||||
|
||||
// Keepalive registration so the host keeps our NAT mapping fresh.
|
||||
regCtx, cancel := context.WithCancel(ctx)
|
||||
defer cancel()
|
||||
go func() {
|
||||
t := time.NewTicker(20 * time.Second)
|
||||
defer t.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-regCtx.Done():
|
||||
return
|
||||
case <-t.C:
|
||||
register()
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
uc := &udpClient{server: conn, dials: map[uint32]*net.UDPConn{}}
|
||||
defer uc.shutdown()
|
||||
|
||||
buf := make([]byte, 65535)
|
||||
for {
|
||||
n, err := conn.Read(buf)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if n < 7 || buf[0] != 'D' {
|
||||
continue
|
||||
}
|
||||
sid := binary.BigEndian.Uint32(buf[1:5])
|
||||
port := int(binary.BigEndian.Uint16(buf[5:7]))
|
||||
uc.send(sid, port, append([]byte(nil), buf[7:n]...))
|
||||
}
|
||||
}
|
||||
|
||||
type udpClient struct {
|
||||
server *net.UDPConn // connection back to host:8446
|
||||
mu sync.Mutex
|
||||
dials map[uint32]*net.UDPConn // sid -> client-side socket
|
||||
}
|
||||
|
||||
// send forwards a datagram to 127.0.0.1:port and ensures replies are relayed.
|
||||
func (u *udpClient) send(sid uint32, port int, data []byte) {
|
||||
u.mu.Lock()
|
||||
c := u.dials[sid]
|
||||
u.mu.Unlock()
|
||||
if c == nil {
|
||||
raddr, err := net.ResolveUDPAddr("udp", net.JoinHostPort("127.0.0.1", itoa(port)))
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
c, err = net.DialUDP("udp", nil, raddr)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
u.mu.Lock()
|
||||
u.dials[sid] = c
|
||||
u.mu.Unlock()
|
||||
go u.replyLoop(sid, c)
|
||||
}
|
||||
_, _ = c.Write(data)
|
||||
}
|
||||
|
||||
func (u *udpClient) replyLoop(sid uint32, c *net.UDPConn) {
|
||||
buf := make([]byte, 65535)
|
||||
hdr := make([]byte, 7)
|
||||
hdr[0] = 'D'
|
||||
binary.BigEndian.PutUint32(hdr[1:5], sid)
|
||||
for {
|
||||
_ = c.SetReadDeadline(time.Now().Add(90 * time.Second))
|
||||
n, err := c.Read(buf)
|
||||
if err != nil {
|
||||
u.mu.Lock()
|
||||
delete(u.dials, sid)
|
||||
u.mu.Unlock()
|
||||
c.Close()
|
||||
return
|
||||
}
|
||||
out := append(append([]byte(nil), hdr...), buf[:n]...)
|
||||
_, _ = u.server.Write(out)
|
||||
}
|
||||
}
|
||||
|
||||
func (u *udpClient) shutdown() {
|
||||
u.mu.Lock()
|
||||
dials := u.dials
|
||||
u.dials = map[uint32]*net.UDPConn{}
|
||||
u.mu.Unlock()
|
||||
for _, c := range dials {
|
||||
c.Close()
|
||||
}
|
||||
}
|
||||
|
||||
// --- small helpers ---
|
||||
|
||||
func readLineConn(c net.Conn, max int) (string, error) {
|
||||
var b []byte
|
||||
one := make([]byte, 1)
|
||||
for len(b) < max {
|
||||
n, err := c.Read(one)
|
||||
if n > 0 {
|
||||
if one[0] == '\n' {
|
||||
return string(b), nil
|
||||
}
|
||||
b = append(b, one[0])
|
||||
}
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
return string(b), nil
|
||||
}
|
||||
|
||||
func hexDecode(s string) ([]byte, error) {
|
||||
out := make([]byte, len(s)/2)
|
||||
for i := 0; i < len(out); i++ {
|
||||
hi, err := hexVal(s[i*2])
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
lo, err := hexVal(s[i*2+1])
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out[i] = hi<<4 | lo
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func hexVal(c byte) (byte, error) {
|
||||
switch {
|
||||
case c >= '0' && c <= '9':
|
||||
return c - '0', nil
|
||||
case c >= 'a' && c <= 'f':
|
||||
return c - 'a' + 10, nil
|
||||
case c >= 'A' && c <= 'F':
|
||||
return c - 'A' + 10, nil
|
||||
}
|
||||
return 0, fmt.Errorf("bad hex")
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"time"
|
||||
|
||||
"github.com/x-grk/longpolling-remote-access/internal/proto"
|
||||
)
|
||||
|
||||
// doUpdate downloads the latest agent binary for this platform, atomically
|
||||
// replaces the running executable, and schedules a service restart. It never
|
||||
// uninstalls — only updates.
|
||||
func (a *Agent) doUpdate() proto.TaskResult {
|
||||
exe, err := os.Executable()
|
||||
if err != nil {
|
||||
return proto.TaskResult{Error: "не удалось определить путь бинарника: " + err.Error()}
|
||||
}
|
||||
exe, _ = filepath.EvalSymlinks(exe)
|
||||
|
||||
url := fmt.Sprintf("%s/download/agent-%s-%s", a.cfg.Host, runtime.GOOS, runtime.GOARCH)
|
||||
req, _ := http.NewRequestWithContext(context.Background(), http.MethodGet, url, nil)
|
||||
resp, err := a.http.Do(req)
|
||||
if err != nil {
|
||||
return proto.TaskResult{Error: "скачивание: " + err.Error()}
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return proto.TaskResult{Error: "скачивание: сервер вернул " + resp.Status}
|
||||
}
|
||||
|
||||
tmp := exe + ".new"
|
||||
f, err := os.OpenFile(tmp, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o755)
|
||||
if err != nil {
|
||||
return proto.TaskResult{Error: "создание файла: " + err.Error()}
|
||||
}
|
||||
if _, err := io.Copy(f, resp.Body); err != nil {
|
||||
f.Close()
|
||||
os.Remove(tmp)
|
||||
return proto.TaskResult{Error: "запись: " + err.Error()}
|
||||
}
|
||||
f.Close()
|
||||
|
||||
// Atomic replace: renaming over a running executable is allowed on Linux;
|
||||
// the live process keeps the old inode until it restarts.
|
||||
if err := os.Rename(tmp, exe); err != nil {
|
||||
os.Remove(tmp)
|
||||
return proto.TaskResult{Error: "замена бинарника: " + err.Error()}
|
||||
}
|
||||
|
||||
// Restart shortly, after the result has been reported.
|
||||
go func() {
|
||||
time.Sleep(1500 * time.Millisecond)
|
||||
restartSelf()
|
||||
}()
|
||||
|
||||
data, _ := json.Marshal(map[string]string{"status": "обновлено, перезапуск"})
|
||||
return proto.TaskResult{OK: true, Data: data}
|
||||
}
|
||||
|
||||
// restartSelf restarts the agent so the new binary takes effect.
|
||||
func restartSelf() {
|
||||
if path, err := exec.LookPath("systemctl"); err == nil {
|
||||
_ = exec.Command(path, "restart", "lpagent").Start()
|
||||
return
|
||||
}
|
||||
// No systemd (e.g. launchd/manual run): exit so the supervisor restarts us.
|
||||
os.Exit(0)
|
||||
}
|
||||
Reference in new issue
Block a user