VERSION.txt is embedded into the server and the agent, so a plain go build reports the real version and build.sh no longer injects it.
210 lines
5.7 KiB
Go
210 lines
5.7 KiB
Go
// 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"
|
|
|
|
lpra "github.com/x-grk/longpolling-remote-access"
|
|
"github.com/x-grk/longpolling-remote-access/internal/proto"
|
|
)
|
|
|
|
// Version is the agent build version (see VERSION.txt).
|
|
var Version = lpra.Version
|
|
|
|
// 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,
|
|
User: currentUser(),
|
|
})
|
|
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 and our OS user 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)
|
|
req.Header.Set("X-Agent-User", currentUser())
|
|
}
|
|
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, msg.User)
|
|
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
|