497 lines
13 KiB
Go
497 lines
13 KiB
Go
package server
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"net/http"
|
|
"path"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/x-grk/longpolling-remote-access/internal/proto"
|
|
)
|
|
|
|
// Sending a file or folder to another machine. The sending agent packs it as
|
|
// a tar stream; to another agent the stream goes in pieces through a relay
|
|
// here, a few pieces held at most, and the receiving agent unpacks it as it
|
|
// comes. To an SSH host the sending agent writes it there itself. The panel
|
|
// follows a job: how far the sender is, and the result.
|
|
|
|
// relay carries one stream between two agents, in numbered pieces.
|
|
type relay struct {
|
|
id, from, to string
|
|
|
|
mu sync.Mutex
|
|
changed chan struct{}
|
|
chunks map[int][]byte
|
|
low int // pieces below it were taken
|
|
end int // the last piece's number, -1 until it came
|
|
err string
|
|
}
|
|
|
|
func (x *relay) notify() {
|
|
close(x.changed)
|
|
x.changed = make(chan struct{})
|
|
}
|
|
|
|
// await waits until ready() holds and returns true with x.mu held.
|
|
func (x *relay) await(ctx context.Context, d time.Duration, ready func() bool) bool {
|
|
t := time.NewTimer(d)
|
|
defer t.Stop()
|
|
for {
|
|
x.mu.Lock()
|
|
if ready() {
|
|
return true
|
|
}
|
|
ch := x.changed
|
|
x.mu.Unlock()
|
|
select {
|
|
case <-ch:
|
|
case <-t.C:
|
|
return false
|
|
case <-ctx.Done():
|
|
return false
|
|
}
|
|
}
|
|
}
|
|
|
|
func (x *relay) fail(msg string) {
|
|
x.mu.Lock()
|
|
if x.err == "" {
|
|
x.err = msg
|
|
x.chunks = nil
|
|
x.notify()
|
|
}
|
|
x.mu.Unlock()
|
|
}
|
|
|
|
type shipJob struct {
|
|
id string
|
|
mu sync.Mutex
|
|
changed chan struct{}
|
|
ver int
|
|
prog proto.ShipProgress
|
|
done bool
|
|
res proto.TaskResult
|
|
cancel context.CancelFunc
|
|
stopped bool
|
|
}
|
|
|
|
func (j *shipJob) update(fn func()) {
|
|
j.mu.Lock()
|
|
fn()
|
|
j.ver++
|
|
close(j.changed)
|
|
j.changed = make(chan struct{})
|
|
j.mu.Unlock()
|
|
}
|
|
|
|
type shipping struct {
|
|
mu sync.Mutex
|
|
relays map[string]*relay
|
|
jobs map[string]*shipJob
|
|
}
|
|
|
|
func (c *shipping) relay(id string) *relay {
|
|
c.mu.Lock()
|
|
defer c.mu.Unlock()
|
|
return c.relays[id]
|
|
}
|
|
|
|
func (c *shipping) job(id string) *shipJob {
|
|
c.mu.Lock()
|
|
defer c.mu.Unlock()
|
|
return c.jobs[id]
|
|
}
|
|
|
|
// shipReq is what the panel asks for: Src on this agent to Dir/Name on agent
|
|
// To, or on the SSH host.
|
|
type shipReq struct {
|
|
Src string `json:"src"`
|
|
Name string `json:"name"`
|
|
Dir string `json:"dir"`
|
|
Conflict string `json:"conflict"`
|
|
Move bool `json:"move"`
|
|
To string `json:"to"`
|
|
SSH *proto.SSHTarget `json:"ssh"`
|
|
// FromConn, ToConn: that side is an SSH host held by its agent for a
|
|
// file pane (its connection id); To is still the agent holding ToConn.
|
|
FromConn string `json:"from_conn"`
|
|
ToConn string `json:"to_conn"`
|
|
// More: other things of Src's folder sent along, under their own names.
|
|
More []string `json:"more"`
|
|
}
|
|
|
|
// POST /api/panel/agents/{id}/fs/ship - start sending; the answer names the
|
|
// job to follow.
|
|
func (s *Server) handleFSShip(w http.ResponseWriter, r *http.Request) {
|
|
from := r.PathValue("id")
|
|
var q shipReq
|
|
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 64<<10)).Decode(&q); err != nil {
|
|
writeJSON(w, proto.TaskResult{Error: "bad request"})
|
|
return
|
|
}
|
|
q.Name, q.Dir = strings.TrimSpace(q.Name), strings.TrimSpace(q.Dir)
|
|
if q.Src == "" || q.Name == "" || strings.Contains(q.Name, "/") || q.Dir == "" {
|
|
writeJSON(w, proto.TaskResult{Error: "не указано, что и куда"})
|
|
return
|
|
}
|
|
if !s.fsAgent(w, from, true) {
|
|
return
|
|
}
|
|
where := ""
|
|
if err := s.fillSaved(s.currentAdmin(r).ID, q.SSH); err != nil {
|
|
writeJSON(w, proto.TaskResult{Error: err.Error()})
|
|
return
|
|
}
|
|
if q.SSH != nil {
|
|
t := q.SSH
|
|
t.Host, t.User = strings.TrimSpace(t.Host), strings.TrimSpace(t.User)
|
|
if t.Port == 0 {
|
|
t.Port = 22
|
|
}
|
|
if t.Host == "" || t.User == "" || t.Port < 1 || t.Port > 65535 {
|
|
writeJSON(w, proto.TaskResult{Error: "нужны хост, порт и пользователь"})
|
|
return
|
|
}
|
|
where = "ssh:" + t.User + "@" + t.Host + ":" + strconv.Itoa(t.Port)
|
|
} else {
|
|
if q.To == "" && q.ToConn != "" {
|
|
q.To = from
|
|
}
|
|
if q.To == "" {
|
|
writeJSON(w, proto.TaskResult{Error: "не выбрана машина"})
|
|
return
|
|
}
|
|
if !s.db.CanUseAgent(s.currentAdmin(r), q.To) {
|
|
writeJSON(w, proto.TaskResult{Error: "нет доступа к той машине"})
|
|
return
|
|
}
|
|
if !s.fsAgent(w, q.To, true) {
|
|
return
|
|
}
|
|
where = "agent:" + q.To
|
|
}
|
|
ctx, cancel := context.WithTimeout(context.Background(), xferTimeout)
|
|
j := &shipJob{id: randToken()[:24], changed: make(chan struct{}), cancel: cancel}
|
|
s.ship.mu.Lock()
|
|
if s.ship.jobs == nil {
|
|
s.ship.jobs, s.ship.relays = map[string]*shipJob{}, map[string]*relay{}
|
|
}
|
|
s.ship.jobs[j.id] = j
|
|
s.ship.mu.Unlock()
|
|
if admin := s.currentAdmin(r); admin != nil {
|
|
op := "copy"
|
|
if q.Move {
|
|
op = "move"
|
|
}
|
|
s.db.Audit(admin.ID, from, "fs.ship."+op+":"+q.Src+" -> "+where+":"+strings.TrimSuffix(q.Dir, "/")+"/"+q.Name)
|
|
}
|
|
go s.runShip(ctx, j, from, q)
|
|
data, _ := json.Marshal(map[string]string{"job": j.id})
|
|
writeJSON(w, proto.TaskResult{OK: true, Data: data})
|
|
}
|
|
|
|
func (s *Server) runShip(ctx context.Context, j *shipJob, from string, q shipReq) {
|
|
defer j.cancel()
|
|
res := s.ship1(ctx, j, from, q)
|
|
if !res.OK && j.stopped {
|
|
res.Error = "отменено"
|
|
}
|
|
j.update(func() { j.done, j.res = true, res })
|
|
// The result waits a while for the panel to come for it.
|
|
time.AfterFunc(xferIdle, func() {
|
|
s.ship.mu.Lock()
|
|
delete(s.ship.jobs, j.id)
|
|
s.ship.mu.Unlock()
|
|
})
|
|
}
|
|
|
|
func oldAgent(res proto.TaskResult) proto.TaskResult {
|
|
if unknownOp(res) {
|
|
res.Error = "агент не умеет отправлять на другие машины - обновите его"
|
|
}
|
|
return res
|
|
}
|
|
|
|
func (s *Server) ship1(ctx context.Context, j *shipJob, from string, q shipReq) proto.TaskResult {
|
|
if len(q.More) > 0 && !s.shipsSeveral(from, q) {
|
|
// One at a time: an SSH address, or an agent from before 0.2.0.
|
|
var last proto.TaskResult
|
|
done, skipped := 0, 0
|
|
for i, p := range append([]string{q.Src}, q.More...) {
|
|
one := q
|
|
one.Src, one.More = p, nil
|
|
if i > 0 {
|
|
one.Name = path.Base(path.Clean(p))
|
|
}
|
|
if last = s.ship1(ctx, j, from, one); !last.OK {
|
|
return last
|
|
}
|
|
var d struct{ Done, Skipped int }
|
|
_ = json.Unmarshal(last.Data, &d)
|
|
done, skipped = done+d.Done, skipped+d.Skipped
|
|
}
|
|
data, _ := json.Marshal(map[string]int{"done": done, "skipped": skipped})
|
|
return proto.TaskResult{OK: true, Data: data}
|
|
}
|
|
args := proto.FSShipArgs{Src: q.Src, Name: q.Name, Dir: q.Dir, Conflict: q.Conflict, Job: j.id, More: q.More}
|
|
if q.SSH != nil {
|
|
args.SSH, args.Move = q.SSH, q.Move
|
|
res, err := s.waitTask(ctx, from, proto.TaskFSShipOut, args, xferTimeout)
|
|
if err != nil {
|
|
return proto.TaskResult{Error: err.Error()}
|
|
}
|
|
return oldAgent(res)
|
|
}
|
|
|
|
x := &relay{id: randToken()[:24], from: from, to: q.To, changed: make(chan struct{}), chunks: map[int][]byte{}, end: -1}
|
|
s.ship.mu.Lock()
|
|
s.ship.relays[x.id] = x
|
|
s.ship.mu.Unlock()
|
|
defer func() {
|
|
s.ship.mu.Lock()
|
|
delete(s.ship.relays, x.id)
|
|
s.ship.mu.Unlock()
|
|
}()
|
|
go func() { <-ctx.Done(); x.fail("отменено") }()
|
|
args.Relay, args.Chunk = x.id, xferChunk
|
|
inArgs := args
|
|
args.Conn, inArgs.Conn = q.FromConn, q.ToConn
|
|
|
|
var in proto.TaskResult
|
|
inDone := make(chan struct{})
|
|
go func() {
|
|
defer close(inDone)
|
|
res, err := s.waitTask(ctx, q.To, proto.TaskFSShipIn, inArgs, xferTimeout)
|
|
if err != nil {
|
|
res = proto.TaskResult{Error: err.Error()}
|
|
}
|
|
in = oldAgent(res)
|
|
if !in.OK {
|
|
x.fail(in.Error)
|
|
}
|
|
}()
|
|
out, err := s.waitTask(ctx, from, proto.TaskFSShipOut, args, xferTimeout)
|
|
if err != nil {
|
|
out = proto.TaskResult{Error: err.Error()}
|
|
}
|
|
out = oldAgent(out)
|
|
if !out.OK {
|
|
x.fail(out.Error)
|
|
}
|
|
<-inDone
|
|
if !in.OK {
|
|
return proto.TaskResult{Error: "на той машине: " + in.Error}
|
|
}
|
|
if !out.OK {
|
|
return out
|
|
}
|
|
if q.Move {
|
|
for _, p := range append([]string{q.Src}, q.More...) {
|
|
task, del := proto.TaskFSDelTree, any(proto.FSPathArgs{Path: p})
|
|
if q.FromConn != "" {
|
|
raw, _ := json.Marshal(proto.FSPathArgs{Path: p})
|
|
task, del = proto.TaskSSHFSOp, proto.SSHFSArgs{Conn: q.FromConn, Op: "deltree", Args: raw}
|
|
}
|
|
res, err := s.waitTask(ctx, from, task, del, fsTaskTimeout)
|
|
if err == nil && !res.OK {
|
|
err = errText(res.Error)
|
|
}
|
|
if err != nil {
|
|
return proto.TaskResult{Error: "отправлено, но здесь не удалилось: " + err.Error()}
|
|
}
|
|
}
|
|
}
|
|
// The receiver's count: what it put down and what it kept as it was.
|
|
return in
|
|
}
|
|
|
|
type errText string
|
|
|
|
func (e errText) Error() string { return string(e) }
|
|
|
|
// GET /api/panel/fs/job/{jid}?v=N - the job's state once it differs from
|
|
// version N, or after a while anyway.
|
|
func (s *Server) handleShipJob(w http.ResponseWriter, r *http.Request) {
|
|
j := s.ship.job(r.PathValue("jid"))
|
|
if j == nil {
|
|
writeJSON(w, map[string]any{"done": true, "result": proto.TaskResult{Error: "задание не найдено"}})
|
|
return
|
|
}
|
|
seen, _ := strconv.Atoi(r.URL.Query().Get("v"))
|
|
t := time.NewTimer(25 * time.Second)
|
|
defer t.Stop()
|
|
for {
|
|
j.mu.Lock()
|
|
if j.ver != seen || j.done {
|
|
out := map[string]any{"v": j.ver, "progress": j.prog, "done": j.done}
|
|
if j.done {
|
|
out["result"] = j.res
|
|
}
|
|
j.mu.Unlock()
|
|
writeJSON(w, out)
|
|
return
|
|
}
|
|
ch := j.changed
|
|
j.mu.Unlock()
|
|
select {
|
|
case <-ch:
|
|
case <-t.C:
|
|
seen = -1 // answer with what there is
|
|
case <-r.Context().Done():
|
|
return
|
|
}
|
|
}
|
|
}
|
|
|
|
// POST /api/panel/fs/job/{jid}/cancel
|
|
func (s *Server) handleShipCancel(w http.ResponseWriter, r *http.Request) {
|
|
if j := s.ship.job(r.PathValue("jid")); j != nil {
|
|
j.update(func() { j.stopped = true })
|
|
j.cancel()
|
|
}
|
|
w.WriteHeader(http.StatusNoContent)
|
|
}
|
|
|
|
// POST /api/agent/fs/job/{jid}/progress - the sender's progress. 410: the
|
|
// job is over or cancelled, stop.
|
|
func (s *Server) handleShipProgress(w http.ResponseWriter, r *http.Request) {
|
|
if s.authenticateAgent(r) == nil {
|
|
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
|
return
|
|
}
|
|
j := s.ship.job(r.PathValue("jid"))
|
|
var p proto.ShipProgress
|
|
if j == nil || json.NewDecoder(http.MaxBytesReader(w, r.Body, 4096)).Decode(&p) != nil {
|
|
http.Error(w, "gone", http.StatusGone)
|
|
return
|
|
}
|
|
stop := false
|
|
j.update(func() { j.prog, stop = p, j.stopped || j.done })
|
|
if stop {
|
|
http.Error(w, "gone", http.StatusGone)
|
|
return
|
|
}
|
|
w.WriteHeader(http.StatusNoContent)
|
|
}
|
|
|
|
// POST /api/agent/fs/relay/{rid}/{i} - piece i of the stream from the
|
|
// sender, X-Last on the last. 503: no room yet, again; 410: it is over.
|
|
func (s *Server) handleRelayPost(w http.ResponseWriter, r *http.Request) {
|
|
a := s.authenticateAgent(r)
|
|
if a == nil {
|
|
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
|
return
|
|
}
|
|
x := s.ship.relay(r.PathValue("rid"))
|
|
i, err := strconv.Atoi(r.PathValue("i"))
|
|
if x == nil || x.from != a.ID || err != nil || i < 0 {
|
|
http.Error(w, "передача не найдена", http.StatusGone)
|
|
return
|
|
}
|
|
data, err := readPiece(w, r)
|
|
if err != nil {
|
|
http.Error(w, "кусок не дошёл", http.StatusBadRequest)
|
|
return
|
|
}
|
|
if !x.await(r.Context(), xferWait, func() bool { return x.err != "" || i < x.low+xferBuffer }) {
|
|
http.Error(w, "нет места, повторите", http.StatusServiceUnavailable)
|
|
return
|
|
}
|
|
defer x.mu.Unlock()
|
|
if x.err != "" {
|
|
http.Error(w, x.err, http.StatusGone)
|
|
return
|
|
}
|
|
if i >= x.low && x.chunks[i] == nil {
|
|
x.chunks[i] = data
|
|
if r.Header.Get("X-Last") != "" {
|
|
x.end = i
|
|
}
|
|
x.notify()
|
|
}
|
|
w.WriteHeader(http.StatusNoContent)
|
|
}
|
|
|
|
// GET /api/agent/fs/relay/{rid}/{i} - piece i for the receiver, X-Last on
|
|
// the last. Asking for i says every piece below it is written, so those are
|
|
// let go. 503: not here yet, ask again; 410: it is over.
|
|
func (s *Server) handleRelayGet(w http.ResponseWriter, r *http.Request) {
|
|
a := s.authenticateAgent(r)
|
|
if a == nil {
|
|
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
|
return
|
|
}
|
|
x := s.ship.relay(r.PathValue("rid"))
|
|
i, err := strconv.Atoi(r.PathValue("i"))
|
|
if x == nil || x.to != a.ID || err != nil || i < 0 {
|
|
http.Error(w, "передача не найдена", http.StatusGone)
|
|
return
|
|
}
|
|
x.mu.Lock()
|
|
if i > x.low {
|
|
for k := x.low; k < i; k++ {
|
|
delete(x.chunks, k)
|
|
}
|
|
x.low = i
|
|
x.notify()
|
|
}
|
|
x.mu.Unlock()
|
|
if !x.await(r.Context(), xferWait, func() bool { return x.err != "" || x.chunks[i] != nil }) {
|
|
http.Error(w, "кусок ещё не пришёл", http.StatusServiceUnavailable)
|
|
return
|
|
}
|
|
if x.err != "" {
|
|
msg := x.err
|
|
x.mu.Unlock()
|
|
http.Error(w, msg, http.StatusGone)
|
|
return
|
|
}
|
|
data, last := x.chunks[i], i == x.end
|
|
x.mu.Unlock()
|
|
w.Header().Set("Content-Type", "application/octet-stream")
|
|
w.Header().Set("Content-Length", strconv.Itoa(len(data)))
|
|
if last {
|
|
w.Header().Set("X-Last", "1")
|
|
}
|
|
_, _ = w.Write(data)
|
|
}
|
|
|
|
// shipsSeveral: both agents take several things in one stream (0.2.0 on).
|
|
func (s *Server) shipsSeveral(from string, q shipReq) bool {
|
|
if q.SSH != nil {
|
|
return false
|
|
}
|
|
for _, id := range []string{from, q.To} {
|
|
a, _ := s.db.GetAgent(id)
|
|
if a == nil || !versionAtLeast(a.Version, "0.2.0") {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
// versionAtLeast: version v (dotted numbers) is min or newer; anything else
|
|
// is taken for older.
|
|
func versionAtLeast(v, min string) bool {
|
|
a, b := strings.Split(v, "."), strings.Split(min, ".")
|
|
for i := range b {
|
|
var x, y int
|
|
if i < len(a) {
|
|
n, err := strconv.Atoi(a[i])
|
|
if err != nil {
|
|
return false
|
|
}
|
|
x = n
|
|
}
|
|
y, _ = strconv.Atoi(b[i])
|
|
if x != y {
|
|
return x > y
|
|
}
|
|
}
|
|
return true
|
|
}
|