feat: machine status page, speed test, live presence, forwards without ufw (0.0.5)

Status page per machine: CPU load, RAM and disk of the client, sampled
by the agent only while the page is open and visible (one reading
serves every open tab), plus a button that has the agent measure its
link to the host: ping, down and up.

Online/offline now comes from the control polls the hub holds open, so
a dropped agent shows offline at once instead of 45 s later, and the
dashboard keeps the status column live.

A forward rule can be created "without ufw": the panel opens nothing in
ufw for it and so revokes nothing when it is deleted. The checkbox is
shown only while the ufw integration is on.

Dashboard row actions are icons; a status action is added.
This commit is contained in:
redtea committed 2026-10-13 18:56:15 +03:00
1 parent 7f4d645f19
commit 86cb0226ab
22 files changed
+920 -34

No files matched your search

+1 -1
View File
@@ -1 +1 @@
0.0.4
0.0.5
+1 -1
View File
@@ -5,6 +5,7 @@ go 1.26
require (
github.com/creack/pty v1.1.21
golang.org/x/crypto v0.31.0
golang.org/x/sys v0.28.0
modernc.org/sqlite v1.24.0
)
@@ -13,7 +14,6 @@ require (
github.com/google/uuid v1.6.0 // indirect
github.com/mattn/go-isatty v0.0.16 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
golang.org/x/sys v0.28.0 // indirect
modernc.org/libc v1.22.5 // indirect
modernc.org/mathutil v1.5.0 // indirect
modernc.org/memory v1.5.0 // indirect
+122
View File
@@ -0,0 +1,122 @@
package agent
import (
"context"
"crypto/rand"
"encoding/json"
"io"
"net/http"
"time"
"github.com/x-grk/longpolling-remote-access/internal/proto"
)
const (
// speedWindow is how long each direction is measured for.
speedWindow = 3 * time.Second
// speedUpCap bounds the upload, whatever the link does in speedWindow.
speedUpCap = 48 << 20
)
// doSpeed measures the agent's HTTP link to the server: round-trip time of an
// empty request, then how much flows each way in speedWindow.
func (a *Agent) doSpeed(ctx context.Context) proto.TaskResult {
res, err := a.measureSpeed(ctx)
if err != nil {
return proto.TaskResult{Error: err.Error()}
}
data, _ := json.Marshal(res)
return proto.TaskResult{OK: true, Data: data}
}
func (a *Agent) measureSpeed(ctx context.Context) (proto.SysSpeedResult, error) {
var res proto.SysSpeedResult
// Ping: the first request may pay for a new connection, so keep the best.
for i := 0; i < 4; i++ {
start := time.Now()
resp, err := a.speedRequest(ctx, http.MethodGet, "/api/agent/speed/down?bytes=0", nil)
if err != nil {
return res, err
}
io.Copy(io.Discard, resp.Body)
resp.Body.Close()
ms := float64(time.Since(start).Microseconds()) / 1000
if i == 0 || ms < res.PingMs {
res.PingMs = ms
}
}
// Down: read what the server streams until the window closes.
dctx, cancel := context.WithTimeout(ctx, speedWindow)
resp, err := a.speedRequest(dctx, http.MethodGet, "/api/agent/speed/down", nil)
if err != nil {
cancel()
return res, err
}
start := time.Now()
n, _ := io.Copy(io.Discard, resp.Body)
elapsed := time.Since(start)
resp.Body.Close()
cancel()
res.DownBps = bps(n, elapsed)
// Up: send until the window closes or the cap is hit.
body := &speedSource{deadline: time.Now().Add(speedWindow), left: speedUpCap}
start = time.Now()
resp, err = a.speedRequest(ctx, http.MethodPost, "/api/agent/speed/up", body)
if err != nil {
return res, err
}
io.Copy(io.Discard, resp.Body)
resp.Body.Close()
res.UpBps = bps(int64(speedUpCap-body.left), time.Since(start))
return res, nil
}
func (a *Agent) speedRequest(ctx context.Context, method, path string, body io.Reader) (*http.Response, error) {
req, err := http.NewRequestWithContext(ctx, method, a.cfg.Host+path, body)
if err != nil {
return nil, err
}
a.authHeaders(req)
resp, err := a.http.Do(req)
if err != nil {
return nil, err
}
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusNoContent {
resp.Body.Close()
return nil, errStatus(resp.StatusCode)
}
return resp, nil
}
func bps(n int64, d time.Duration) float64 {
if d <= 0 {
return 0
}
return float64(n) * 8 / d.Seconds()
}
// speedSource is an upload body of incompressible bytes that ends at the
// deadline or after `left` bytes.
type speedSource struct {
deadline time.Time
left int
block []byte
}
func (s *speedSource) Read(p []byte) (int, error) {
if s.left <= 0 || !time.Now().Before(s.deadline) {
return 0, io.EOF
}
if s.block == nil {
s.block = make([]byte, 64<<10)
rand.Read(s.block)
}
n := copy(p, s.block)
if n > s.left {
n = s.left
}
s.left -= n
return n, nil
}
+45
View File
@@ -0,0 +1,45 @@
package agent
import (
"runtime"
"sync"
"syscall"
"github.com/x-grk/longpolling-remote-access/internal/proto"
)
// statMu serializes samples: the CPU figure is a delta against the last one.
var statMu sync.Mutex
// sysStat samples the machine's load. It costs nothing while nobody asks: the
// panel requests it only for an open status page.
func sysStat() (proto.SysStatResult, error) {
statMu.Lock()
defer statMu.Unlock()
res := proto.SysStatResult{NCPU: runtime.NumCPU()}
cpu, err := cpuPercent()
if err != nil {
return res, err
}
res.CPU = cpu
if res.MemTotal, res.MemUsed, err = memUsage(); err != nil {
return res, err
}
var fs syscall.Statfs_t
if err := syscall.Statfs("/", &fs); err != nil {
return res, err
}
res.DiskTotal = uint64(fs.Blocks) * uint64(fs.Bsize)
res.DiskFree = uint64(fs.Bavail) * uint64(fs.Bsize)
return res, nil
}
func clampPercent(v float64) float64 {
if v < 0 {
return 0
}
if v > 100 {
return 100
}
return v
}
+43
View File
@@ -0,0 +1,43 @@
package agent
import (
"bytes"
"os/exec"
"runtime"
"strconv"
"golang.org/x/sys/unix"
)
// cpuPercent sums the per-process CPU shares ps reports. macOS exposes the
// host CPU ticks only through Mach calls, which need cgo.
func cpuPercent() (float64, error) {
out, err := exec.Command("/bin/ps", "-A", "-o", "%cpu=").Output()
if err != nil {
return 0, err
}
var sum float64
for _, f := range bytes.Fields(out) {
// Some locales print a decimal comma.
v, _ := strconv.ParseFloat(string(bytes.Replace(f, []byte(","), []byte("."), 1)), 64)
sum += v
}
return clampPercent(sum / float64(runtime.NumCPU())), nil
}
// memUsage takes the kernel's own share of memory still available
// (kern.memorystatus_level, what `memory_pressure` prints).
func memUsage() (total, used uint64, err error) {
total, err = unix.SysctlUint64("hw.memsize")
if err != nil {
return 0, 0, err
}
level, err := unix.SysctlUint32("kern.memorystatus_level")
if err != nil {
return 0, 0, err
}
if level > 100 {
level = 100
}
return total, total / 100 * uint64(100-level), nil
}
+121
View File
@@ -0,0 +1,121 @@
package agent
import (
"bytes"
"errors"
"os"
"strconv"
"time"
)
// Previous /proc/stat reading; the load is the busy share of the ticks since.
var (
cpuPrevIdle, cpuPrevTotal uint64
cpuPrevAt time.Time
)
// cpuStaleAfter is how old the previous reading may be: past it the delta
// would average over a stretch nobody was watching, so take a fresh pair.
const cpuStaleAfter = 15 * time.Second
func cpuPercent() (float64, error) {
if time.Since(cpuPrevAt) > cpuStaleAfter {
idle, total, err := readCPUTicks()
if err != nil {
return 0, err
}
cpuPrevIdle, cpuPrevTotal = idle, total
time.Sleep(200 * time.Millisecond)
}
idle, total, err := readCPUTicks()
if err != nil {
return 0, err
}
dIdle, dTotal := idle-cpuPrevIdle, total-cpuPrevTotal
cpuPrevIdle, cpuPrevTotal, cpuPrevAt = idle, total, time.Now()
if dTotal == 0 || dIdle > dTotal {
return 0, nil
}
return clampPercent(100 * float64(dTotal-dIdle) / float64(dTotal)), nil
}
// readCPUTicks parses the aggregate "cpu" line of /proc/stat:
// user nice system idle iowait irq softirq steal ...
func readCPUTicks() (idle, total uint64, err error) {
var buf [512]byte
f, err := os.Open("/proc/stat")
if err != nil {
return 0, 0, err
}
n, _ := f.Read(buf[:])
f.Close()
line := buf[:n]
if i := bytes.IndexByte(line, '\n'); i >= 0 {
line = line[:i]
}
fields := bytes.Fields(line)
if len(fields) < 5 || string(fields[0]) != "cpu" {
return 0, 0, errors.New("unexpected /proc/stat")
}
for i, f := range fields[1:] {
if i >= 8 { // guest time is already counted in user/nice
break
}
v, _ := strconv.ParseUint(string(f), 10, 64)
total += v
if i == 3 || i == 4 { // idle, iowait
idle += v
}
}
return idle, total, nil
}
// memUsage reads /proc/meminfo; used is everything not available to new
// processes (so page cache does not count as used).
func memUsage() (total, used uint64, err error) {
data, err := os.ReadFile("/proc/meminfo")
if err != nil {
return 0, 0, err
}
var avail, free uint64
var haveAvail bool
for len(data) > 0 {
line := data
if i := bytes.IndexByte(data, '\n'); i >= 0 {
line, data = data[:i], data[i+1:]
} else {
data = nil
}
switch {
case bytes.HasPrefix(line, []byte("MemTotal:")):
total = meminfoKB(line)
case bytes.HasPrefix(line, []byte("MemFree:")):
free = meminfoKB(line)
case bytes.HasPrefix(line, []byte("MemAvailable:")):
avail, haveAvail = meminfoKB(line), true
}
if haveAvail && total > 0 {
break
}
}
if total == 0 {
return 0, 0, errors.New("unexpected /proc/meminfo")
}
if !haveAvail {
avail = free
}
if avail > total {
avail = total
}
return total, total - avail, nil
}
// meminfoKB returns the value of a "Key: 123 kB" line in bytes.
func meminfoKB(line []byte) uint64 {
f := bytes.Fields(line)
if len(f) < 2 {
return 0
}
v, _ := strconv.ParseUint(string(f[1]), 10, 64)
return v * 1024
}
+5 -2
View File
@@ -17,9 +17,12 @@ func (a *Agent) runTask(ctx context.Context, taskID string) {
return
}
var res proto.TaskResult
if env.Type == proto.TaskSysUpdate {
switch env.Type {
case proto.TaskSysUpdate:
res = a.doUpdate()
} else {
case proto.TaskSysSpeed:
res = a.doSpeed(ctx)
default:
res = a.execTask(env)
}
a.postResult(ctx, taskID, res)
+2
View File
@@ -33,6 +33,8 @@ func dispatchTask(env proto.TaskEnvelope) (any, error) {
switch env.Type {
case proto.TaskSysUsers:
return sysUsers(), nil
case proto.TaskSysStat:
return sysStat()
case proto.TaskSysUserAdd:
var a proto.SysUserAddArgs
_ = json.Unmarshal(env.Args, &a)
+20
View File
@@ -89,6 +89,8 @@ const (
TaskSysUpdate = "sys.update" // download the latest agent binary and restart
TaskSysUsers = "sys.users" // report the agent's user and the login-capable users
TaskSysUserAdd = "sys.useradd" // create a new OS user
TaskSysStat = "sys.stat" // CPU load, RAM and disk usage right now
TaskSysSpeed = "sys.speed" // measure the link to the server (ping, down, up)
)
// FSReadCap bounds how many bytes TaskFSRead returns (5 MiB). The panel lets a
@@ -166,3 +168,21 @@ type SysUserAddArgs struct {
Password string `json:"password,omitempty"` // empty leaves the account without a password
Sudo bool `json:"sudo"` // add to the sudo/wheel group
}
// SysStatResult is returned for TaskSysStat. Sizes are in bytes.
type SysStatResult struct {
CPU float64 `json:"cpu"` // load across all cores, 0..100
NCPU int `json:"ncpu"`
MemTotal uint64 `json:"mem_total"`
MemUsed uint64 `json:"mem_used"`
DiskTotal uint64 `json:"disk_total"` // filesystem holding "/"
DiskFree uint64 `json:"disk_free"`
}
// SysSpeedResult is returned for TaskSysSpeed: the agent's HTTP link to the
// server as the agent measured it. Down is server -> agent.
type SysSpeedResult struct {
PingMs float64 `json:"ping_ms"`
DownBps float64 `json:"down_bps"` // bits per second
UpBps float64 `json:"up_bps"`
}
+10
View File
@@ -24,6 +24,16 @@ func (s *Server) authenticateAgent(r *http.Request) *Agent {
return a
}
// online reports whether the agent is connected right now: the hub knows from
// its control polls. Right after a start it cannot tell yet, so the last poll
// recorded in the database decides.
func (s *Server) online(a *Agent) bool {
if on, ok := s.hub.Online(a.ID); ok {
return on
}
return a.recentlySeen()
}
func bearerToken(r *http.Request) string {
h := r.Header.Get("Authorization")
if strings.HasPrefix(h, "Bearer ") {
+12 -9
View File
@@ -105,6 +105,7 @@ CREATE TABLE IF NOT EXISTS forward_rules (
`ALTER TABLE ufw_rules ADD COLUMN rule_id INTEGER NOT NULL DEFAULT 0`,
`ALTER TABLE agents ADD COLUMN run_user TEXT NOT NULL DEFAULT ''`,
`ALTER TABLE agents ADD COLUMN login_user TEXT NOT NULL DEFAULT ''`,
`ALTER TABLE forward_rules ADD COLUMN no_ufw INTEGER NOT NULL DEFAULT 0`,
} {
if _, err := db.sql.Exec(alter); err != nil && !strings.Contains(err.Error(), "duplicate column") {
return err
@@ -238,8 +239,9 @@ func (a Agent) TerminalUser() string {
return firstNonEmpty(a.LoginUser, a.RunUser, "root")
}
// Online reports whether the agent polled recently enough to be considered up.
func (a Agent) Online() bool {
// recentlySeen is the fallback for "online" right after a server start, while
// the hub has not seen the agents' polls yet (see Server.online).
func (a Agent) recentlySeen() bool {
return time.Since(a.LastSeen) < 45*time.Second
}
@@ -327,6 +329,7 @@ type ForwardRule struct {
HostPort int
ListenAddr string
Proto string
NoUfw bool // the panel opens no ufw rule for this forward
}
func (db *DB) CreateForwardRule(r ForwardRule) (int64, error) {
@@ -334,9 +337,9 @@ func (db *DB) CreateForwardRule(r ForwardRule) (int64, error) {
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())
`INSERT INTO forward_rules(agent_id, client_port, host_port, listen_addr, proto, no_ufw, created_at)
VALUES(?, ?, ?, ?, ?, ?, ?)`,
r.AgentID, r.ClientPort, r.HostPort, r.ListenAddr, r.Proto, r.NoUfw, time.Now().Unix())
if err != nil {
return 0, err
}
@@ -345,7 +348,7 @@ func (db *DB) CreateForwardRule(r ForwardRule) (int64, error) {
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`)
`SELECT id, agent_id, client_port, host_port, listen_addr, proto, no_ufw FROM forward_rules ORDER BY id`)
if err != nil {
return nil, err
}
@@ -353,7 +356,7 @@ func (db *DB) ListForwardRules() ([]ForwardRule, error) {
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 {
if err := rows.Scan(&r.ID, &r.AgentID, &r.ClientPort, &r.HostPort, &r.ListenAddr, &r.Proto, &r.NoUfw); err != nil {
return nil, err
}
out = append(out, r)
@@ -378,8 +381,8 @@ func (db *DB) ListForwardRulesByAgent(agentID string) ([]ForwardRule, error) {
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)
`SELECT id, agent_id, client_port, host_port, listen_addr, proto, no_ufw FROM forward_rules WHERE id = ?`, id).
Scan(&r.ID, &r.AgentID, &r.ClientPort, &r.HostPort, &r.ListenAddr, &r.Proto, &r.NoUfw)
if err == sql.ErrNoRows {
return nil, nil
}
+1 -1
View File
@@ -52,7 +52,7 @@ func (s *Server) handlePoll(w http.ResponseWriter, r *http.Request) {
} else {
_ = s.db.TouchAgent(a.ID)
}
msg := s.hub.WaitControl(a.ID, pollHold)
msg := s.hub.WaitControl(r.Context().Done(), a.ID, pollHold)
// Set after the hold so a port changed meanwhile reaches the agent now.
s.setTunnelHeader(w)
writeJSON(w, msg)
+38 -5
View File
@@ -118,7 +118,8 @@ func (s *Server) handleDashboard(w http.ResponseWriter, r *http.Request) {
}
rows := make([]row, 0, len(agents))
for _, a := range agents {
rows = append(rows, row{Agent: a, Online: a.Online(), Seen: humanSince(a.LastSeen)})
on := s.online(&a)
rows = append(rows, row{Agent: a, Online: on, Seen: seenText(on, a.LastSeen)})
}
s.render(w, "dashboard.html", map[string]any{
"Admin": s.currentAdmin(r),
@@ -127,6 +128,36 @@ func (s *Server) handleDashboard(w http.ResponseWriter, r *http.Request) {
})
}
// seenText is the "activity" cell: a connected agent is active now, whatever
// the time of its last recorded poll.
func seenText(online bool, lastSeen time.Time) string {
if online {
return "сейчас"
}
return humanSince(lastSeen)
}
// GET /api/panel/agents/presence — who is online, for the dashboard to keep
// its status column live without reloading.
func (s *Server) handlePresence(w http.ResponseWriter, r *http.Request) {
agents, err := s.db.ListAgents()
if err != nil {
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
type item struct {
ID string `json:"id"`
Online bool `json:"online"`
Seen string `json:"seen"`
}
out := make([]item, 0, len(agents))
for _, a := range agents {
on := s.online(&a)
out = append(out, item{ID: a.ID, Online: on, Seen: seenText(on, a.LastSeen)})
}
writeJSON(w, out)
}
func (s *Server) handleTerminalPage(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
a, err := s.db.GetAgent(id)
@@ -153,6 +184,8 @@ func (s *Server) handleAgentDelete(w http.ResponseWriter, r *http.Request) {
s.removeForward(rule.ID)
}
_ = s.db.DeleteAgent(id)
s.stats.drop(id)
s.hub.Forget(id)
http.Redirect(w, r, "/", http.StatusSeeOther)
}
@@ -173,7 +206,7 @@ func (s *Server) handleAgentEdit(w http.ResponseWriter, r *http.Request) {
"UserErr": r.URL.Query().Get("uerr"),
}
// The user list lives on the machine, so it is only known while it is online.
if a.Online() {
if s.online(a) {
if users, err := s.agentUsers(id); err == nil {
data["Users"] = users
} else {
@@ -224,7 +257,7 @@ func (s *Server) handleAgentSetUser(w http.ResponseWriter, r *http.Request) {
return
}
name := strings.TrimSpace(r.FormValue("user"))
if !a.Online() {
if !s.online(a) {
editUserRedirect(w, r, id, "uerr", "Машина офлайн — сменить пользователя нельзя.")
return
}
@@ -265,7 +298,7 @@ func (s *Server) handleAgentAddUser(w http.ResponseWriter, r *http.Request) {
http.NotFound(w, r)
return
}
if !a.Online() {
if !s.online(a) {
editUserRedirect(w, r, id, "uerr", "Машина офлайн — создать пользователя нельзя.")
return
}
@@ -306,7 +339,7 @@ func (s *Server) handleAgentUpdate(w http.ResponseWriter, r *http.Request) {
http.NotFound(w, r)
return
}
if !a.Online() {
if !s.online(a) {
http.Redirect(w, r, "/agents/"+id+"/edit?updated=0", http.StatusSeeOther)
return
}
+2 -2
View File
@@ -53,7 +53,7 @@ func (s *Server) handleFSOp(w http.ResponseWriter, r *http.Request) {
http.NotFound(w, r)
return
}
if !a.Online() {
if !s.online(a) {
writeJSON(w, proto.TaskResult{Error: "машина офлайн"})
return
}
@@ -83,7 +83,7 @@ func (s *Server) handleFSDownload(w http.ResponseWriter, r *http.Request) {
http.NotFound(w, r)
return
}
if !a.Online() {
if !s.online(a) {
http.Error(w, "машина офлайн", http.StatusServiceUnavailable)
return
}
+3 -1
View File
@@ -30,6 +30,7 @@ func (s *Server) handleForwardsPage(w http.ResponseWriter, r *http.Request) {
"Error": errMsg,
"TunnelPort": s.TunnelPort(),
"PanelPort": panelPort(s.db.HostAddress()),
"UfwEnabled": s.ufw.enabled(),
})
}
@@ -85,7 +86,8 @@ func (s *Server) handleForwardAdd(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "/agents/"+id+"/forwards?err=busy", http.StatusSeeOther)
return
}
rule := ForwardRule{AgentID: id, ClientPort: clientPort, HostPort: hostPort, ListenAddr: listenAddr, Proto: protoKind}
rule := ForwardRule{AgentID: id, ClientPort: clientPort, HostPort: hostPort, ListenAddr: listenAddr, Proto: protoKind,
NoUfw: r.FormValue("no_ufw") != ""}
ruleID, err := s.db.CreateForwardRule(rule)
if err != nil {
http.Redirect(w, r, "/agents/"+id+"/forwards?err=db", http.StatusSeeOther)
+193
View File
@@ -0,0 +1,193 @@
package server
import (
"crypto/rand"
"errors"
"io"
"net/http"
"strconv"
"strings"
"sync"
"time"
"github.com/x-grk/longpolling-remote-access/internal/proto"
)
const (
// statTaskTimeout bounds how long a status request waits for the agent.
statTaskTimeout = 8 * time.Second
// statCacheFor is how long one sample answers every open status page, so
// several tabs cost the agent a single reading.
statCacheFor = 2 * time.Second
// speedTaskTimeout covers the agent's ping + down + up windows with slack.
speedTaskTimeout = 30 * time.Second
// speedDownCap bounds one download stream; speedDownFor ends it if the
// agent does not hang up first.
speedDownCap = 256 << 20
speedDownFor = 6 * time.Second
speedUpCap = 64 << 20
)
// statCache holds the last sample per agent and serializes its refresh.
type statCache struct {
mu sync.Mutex
m map[string]*statEntry
}
type statEntry struct {
mu sync.Mutex
at time.Time
res proto.TaskResult
}
func (c *statCache) entry(agentID string) *statEntry {
c.mu.Lock()
defer c.mu.Unlock()
if c.m == nil {
c.m = map[string]*statEntry{}
}
e := c.m[agentID]
if e == nil {
e = &statEntry{}
c.m[agentID] = e
}
return e
}
func (c *statCache) drop(agentID string) {
c.mu.Lock()
delete(c.m, agentID)
c.mu.Unlock()
}
func (s *Server) handleStatusPage(w http.ResponseWriter, r *http.Request) {
a, err := s.db.GetAgent(r.PathValue("id"))
if err != nil || a == nil {
http.NotFound(w, r)
return
}
s.render(w, "status.html", map[string]any{"Agent": a})
}
// runTask runs a one-shot task on the agent and waits for its result.
func (s *Server) runTask(agentID, typ string, timeout time.Duration) (proto.TaskResult, error) {
taskID, ch := s.hub.CreateTask(agentID, typ, nil)
defer s.hub.DiscardTask(taskID)
t := time.NewTimer(timeout)
defer t.Stop()
select {
case res := <-ch:
return res, nil
case <-t.C:
return proto.TaskResult{}, errors.New("таймаут ожидания клиента")
}
}
// onlineAgent answers the request itself and returns false when the agent is
// unknown or offline.
func (s *Server) onlineAgent(w http.ResponseWriter, r *http.Request) (string, bool) {
id := r.PathValue("id")
a, err := s.db.GetAgent(id)
if err != nil || a == nil {
http.NotFound(w, r)
return "", false
}
if !s.online(a) {
writeJSON(w, proto.TaskResult{Error: "машина офлайн"})
return "", false
}
return id, true
}
// oldAgentError replaces the "unknown task" answer of an agent that predates
// the task.
func oldAgentError(res proto.TaskResult) proto.TaskResult {
if !res.OK && strings.Contains(res.Error, "неизвестная операция") {
res.Error = "клиент не поддерживает эту функцию — обновите его (нужен 0.0.5+)"
}
return res
}
// GET /api/panel/agents/{id}/stat — CPU, RAM and disk of the machine right now.
func (s *Server) handleAgentStat(w http.ResponseWriter, r *http.Request) {
id, ok := s.onlineAgent(w, r)
if !ok {
return
}
e := s.stats.entry(id)
e.mu.Lock()
defer e.mu.Unlock()
if time.Since(e.at) > statCacheFor {
res, err := s.runTask(id, proto.TaskSysStat, statTaskTimeout)
if err != nil {
// Cached too, so pages queued behind this one don't each wait it out.
res = proto.TaskResult{Error: err.Error()}
}
e.res, e.at = oldAgentError(res), time.Now()
}
writeJSON(w, e.res)
}
// POST /api/panel/agents/{id}/speed — have the agent measure its link to us.
func (s *Server) handleAgentSpeed(w http.ResponseWriter, r *http.Request) {
id, ok := s.onlineAgent(w, r)
if !ok {
return
}
res, err := s.runTask(id, proto.TaskSysSpeed, speedTaskTimeout)
if err != nil {
writeJSON(w, proto.TaskResult{Error: err.Error()})
return
}
writeJSON(w, oldAgentError(res))
}
// speedBlock is one chunk of incompressible bytes, made once and sent over and
// over, so a speed test costs the server no CPU beyond the copy.
var speedBlock = sync.OnceValue(func() []byte {
b := make([]byte, 64<<10)
rand.Read(b)
return b
})
// GET /api/agent/speed/down?bytes=N — stream N bytes (default: as much as fits
// in the time window) for the agent to time. bytes=0 is the ping probe.
func (s *Server) handleSpeedDown(w http.ResponseWriter, r *http.Request) {
if s.authenticateAgent(r) == nil {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
left := speedDownCap
if v := r.URL.Query().Get("bytes"); v != "" {
if n, err := strconv.Atoi(v); err == nil && n >= 0 && n < left {
left = n
}
}
w.Header().Set("Content-Type", "application/octet-stream")
w.Header().Set("Cache-Control", "no-store")
// Keep a proxy in front from buffering or compressing the stream.
w.Header().Set("X-Accel-Buffering", "no")
block := speedBlock()
deadline := time.Now().Add(speedDownFor)
for left > 0 && time.Now().Before(deadline) && r.Context().Err() == nil {
chunk := block
if len(chunk) > left {
chunk = chunk[:left]
}
n, err := w.Write(chunk)
if err != nil {
return
}
left -= n
}
}
// POST /api/agent/speed/up — swallow the agent's upload.
func (s *Server) handleSpeedUp(w http.ResponseWriter, r *http.Request) {
if s.authenticateAgent(r) == nil {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
_, _ = io.Copy(io.Discard, http.MaxBytesReader(w, r.Body, speedUpCap))
w.WriteHeader(http.StatusNoContent)
}
+62 -3
View File
@@ -22,6 +22,12 @@ type Hub struct {
sessions map[string]*Session
tasks map[string]*taskEntry
// Presence: control polls held open right now per agent, and when each
// agent's last one ended. started is when this hub came up.
polls map[string]int
lastPoll map[string]time.Time
started time.Time
}
// taskEntry is a pending one-shot task awaiting the agent's result.
@@ -52,6 +58,9 @@ func NewHub() *Hub {
ctrlWaiters: map[string]chan struct{}{},
sessions: map[string]*Session{},
tasks: map[string]*taskEntry{},
polls: map[string]int{},
lastPoll: map[string]time.Time{},
started: time.Now(),
}
}
@@ -97,9 +106,56 @@ func (h *Hub) CreateForward(agentID string, port int) *Session {
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)
// pollGrace is how long an agent stays online between two control polls: it
// re-polls at once, so this only has to cover a slow round trip or one retry.
const pollGrace = 10 * time.Second
// Online reports whether the agent is connected: it holds a control poll open
// or has just finished one. ok is false while that cannot be told yet (right
// after a start, before agents had time to come back).
func (h *Hub) Online(agentID string) (online, ok bool) {
h.mu.Lock()
defer h.mu.Unlock()
if h.polls[agentID] > 0 {
return true, true
}
if t, seen := h.lastPoll[agentID]; seen {
return time.Since(t) < pollGrace, true
}
return false, time.Since(h.started) > pollHold+pollGrace
}
// Forget drops what the hub keeps about a deleted agent.
func (h *Hub) Forget(agentID string) {
h.mu.Lock()
delete(h.jobs, agentID)
delete(h.ctrlWaiters, agentID)
delete(h.lastPoll, agentID)
h.mu.Unlock()
}
// WaitControl blocks until a job is queued for agentID, the timeout elapses
// or the agent hangs up (done). The agent counts as online meanwhile.
func (h *Hub) WaitControl(done <-chan struct{}, agentID string, timeout time.Duration) proto.ControlMessage {
h.mu.Lock()
h.polls[agentID]++
h.mu.Unlock()
defer func() {
h.mu.Lock()
if h.polls[agentID]--; h.polls[agentID] <= 0 {
delete(h.polls, agentID)
}
// A poll cut off by the agent is not a sign of life.
select {
case <-done:
default:
h.lastPoll[agentID] = time.Now()
}
h.mu.Unlock()
}()
t := time.NewTimer(timeout)
defer t.Stop()
deadline := t.C
for {
h.mu.Lock()
if q := h.jobs[agentID]; len(q) > 0 {
@@ -119,6 +175,9 @@ func (h *Hub) WaitControl(agentID string, timeout time.Duration) proto.ControlMe
case <-w:
case <-deadline:
return proto.ControlMessage{Type: proto.CtrlIdle}
case <-done:
// The job, if one comes, stays queued for the next poll.
return proto.ControlMessage{Type: proto.CtrlIdle}
}
}
}
+10
View File
@@ -52,6 +52,8 @@ type Server struct {
tunnelDefault int
tunnelMu sync.Mutex // serializes port changes
tunnelPort atomic.Int64
stats statCache // last load sample per agent, for the status page
}
// New builds the server, parsing embedded templates.
@@ -200,6 +202,7 @@ func (s *Server) Handler() http.Handler {
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}/status", s.requireAdmin(s.handleStatusPage))
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))
@@ -225,6 +228,11 @@ func (s *Server) Handler() http.Handler {
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))
// Machine status (admin).
mux.Handle("GET /api/panel/agents/presence", s.requireAdmin(s.handlePresence))
mux.Handle("GET /api/panel/agents/{id}/stat", s.requireAdmin(s.handleAgentStat))
mux.Handle("POST /api/panel/agents/{id}/speed", s.requireAdmin(s.handleAgentSpeed))
// 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))
@@ -237,6 +245,8 @@ func (s *Server) Handler() http.Handler {
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)
mux.HandleFunc("GET /api/agent/speed/down", s.handleSpeedDown)
mux.HandleFunc("POST /api/agent/speed/up", s.handleSpeedUp)
return s.logRequests(mux)
}
+18 -2
View File
@@ -54,6 +54,16 @@ func (u *ufwManager) status() (installed, active, runnable bool) {
return installed, active, runnable
}
// enabled reports whether the integration is switched on and ufw is there to
// drive. Unlike available it runs nothing, so pages can ask it freely.
func (u *ufwManager) enabled() bool {
if !u.db.UfwEnabled() {
return false
}
_, err := exec.LookPath("ufw")
return err == nil
}
// available reports whether we can and should drive ufw now.
func (u *ufwManager) available() bool {
if !u.db.UfwEnabled() {
@@ -148,7 +158,9 @@ func (u *ufwManager) pruneShared() {
}
used := map[string]bool{}
for _, f := range forwards {
used[f.Proto] = true
if !f.NoUfw {
used[f.Proto] = true
}
}
port := u.tunnelPort()
for _, r := range shared {
@@ -160,7 +172,8 @@ func (u *ufwManager) pruneShared() {
// 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 tunnel port for direct
// tcp/udp modes (bound to 0 — shared across rules).
// tcp/udp modes (bound to 0 — shared across rules). A rule made "without ufw"
// opens nothing, so nothing is revoked with it either.
func (u *ufwManager) allowForward(rule ForwardRule) {
if !u.available() {
return
@@ -169,6 +182,9 @@ func (u *ufwManager) allowForward(rule ForwardRule) {
}
func (u *ufwManager) allowForwardPorts(rule ForwardRule) {
if rule.NoUfw {
return
}
// "http" (long-poll) and "tcp" forwards both listen on TCP on the host.
hostProto, direct := "tcp", rule.Proto == "tcp"
if rule.Proto == "udp" {
+48 -6
View File
@@ -25,17 +25,26 @@
<thead><tr><th>Статус</th><th>Имя</th><th>Hostname</th><th>ОС / Арх</th><th>Активность</th><th></th></tr></thead>
<tbody>
{{range .Agents}}
<tr>
<td class="nowrap"><span class="dot {{if .Online}}on{{else}}off{{end}}"></span>{{if .Online}}online{{else}}offline{{end}}</td>
<tr data-agent="{{.ID}}">
<td class="nowrap"><span class="dot {{if .Online}}on{{else}}off{{end}}"></span><span class="state">{{if .Online}}online{{else}}offline{{end}}</span></td>
<td>{{.Name}}</td>
<td class="muted">{{.Hostname}}</td>
<td class="muted nowrap">{{.OS}}/{{.Arch}}</td>
<td class="muted nowrap">{{.Seen}}</td>
<td class="muted nowrap seen">{{.Seen}}</td>
<td class="actions">
<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 small icon" href="/agents/{{.ID}}/terminal" title="Терминал">
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="4 17 10 11 4 5"/><line x1="12" y1="19" x2="20" y2="19"/></svg>
</a>
<a class="btn ghost small icon" href="/agents/{{.ID}}/files" 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="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/></svg>
</a>
<a class="btn ghost small icon" href="/agents/{{.ID}}/forwards" title="Порты">
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="17 1 21 5 17 9"/><path d="M3 11V9a4 4 0 0 1 4-4h14"/><polyline points="7 23 3 19 7 15"/><path d="M21 13v2a4 4 0 0 1-4 4H3"/></svg>
</a>
<a class="btn ghost small icon" href="/agents/{{.ID}}/status" title="Статус">
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="22 12 18 12 15 21 9 3 6 12 2 12"/></svg>
</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>
@@ -58,5 +67,38 @@
{{end}}
</div>
</div>
<script>
// Keep the status column live; nothing is asked while the tab is hidden.
(function () {
var rows = {};
document.querySelectorAll('tr[data-agent]').forEach(function (tr) { rows[tr.dataset.agent] = tr; });
var timer = null, busy = false;
function schedule() {
clearTimeout(timer);
if (!document.hidden) timer = setTimeout(load, 5000);
}
function load() {
if (busy || document.hidden) return;
busy = true;
fetch('/api/panel/agents/presence', {credentials: 'same-origin'})
.then(function (r) { return r.ok ? r.json() : []; })
.then(function (list) {
list.forEach(function (a) {
var tr = rows[a.id];
if (!tr) return;
tr.querySelector('.dot').className = 'dot ' + (a.online ? 'on' : 'off');
tr.querySelector('.state').textContent = a.online ? 'online' : 'offline';
tr.querySelector('.seen').textContent = a.seen;
});
})
.catch(function () {})
.then(function () { busy = false; schedule(); });
}
document.addEventListener('visibilitychange', function () {
if (document.hidden) clearTimeout(timer); else load();
});
if (Object.keys(rows).length) schedule();
})();
</script>
</body>
</html>
+7 -1
View File
@@ -12,6 +12,7 @@
.frow select, .frow input, .frow button {
height:40px; box-sizing:border-box; font-size:14px; line-height:1; margin:0;
}
label.check { display:inline-flex; align-items:center; gap:8px; margin:14px 0 0; color:var(--text); font-size:14px; cursor:pointer; }
.frow select {
padding:0 32px 0 12px; border-radius:8px; border:1px solid var(--border);
background:#0c0f13; color:var(--text);
@@ -44,7 +45,7 @@
<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 class="muted">{{.ListenAddr}}{{if and .NoUfw $.UfwEnabled}} · без ufw{{end}}</td>
<td style="text-align:right">
<form method="post" action="/forwards/{{.ID}}/delete" style="display:inline"
onsubmit="return confirm('Удалить правило?')">
@@ -88,6 +89,11 @@
</div>
<button type="submit">Создать</button>
</div>
{{if .UfwEnabled}}
<label class="check" title="Порт не будет открыт в ufw, и при удалении правила в ufw ничего не изменится">
<input type="checkbox" name="no_ufw" value="1"> Без ufw правила
</label>
{{end}}
</form>
<p class="muted" id="note" style="font-size:13px; margin-top:12px"></p>
</div>
+156
View File
@@ -0,0 +1,156 @@
<!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>
.meters { display:grid; grid-template-columns:repeat(auto-fit, minmax(240px, 1fr)); gap:20px; margin-bottom:20px; }
.meters .card { margin:0; }
.meter .head { display:flex; align-items:baseline; justify-content:space-between; }
.meter .name { color:var(--muted); font-size:13px; }
.meter .val { font-size:26px; font-weight:600; font-variant-numeric:tabular-nums; }
.meter .sub { color:var(--muted); font-size:13px; margin-top:8px; min-height:20px; font-variant-numeric:tabular-nums; }
.bar { height:8px; border-radius:4px; background:#0c0f13; border:1px solid var(--border); overflow:hidden; margin-top:10px; }
.bar i { display:block; height:100%; width:100%; background:var(--accent); transform-origin:left; transform:scaleX(0); transition:transform .4s; }
.bar i.warn { background:#e6a23c; }
.bar i.crit { background:var(--danger); }
.speed { display:flex; gap:28px; flex-wrap:wrap; margin-top:16px; }
.speed b { display:block; font-size:20px; font-variant-numeric:tabular-nums; }
.speed span { color:var(--muted); font-size:13px; }
</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>
<a href="/agents/{{.Agent.ID}}/forwards">Порты</a>
</nav>
</header>
<div class="wrap">
<h1>Статус</h1>
<div class="err" id="err" hidden></div>
<div class="meters">
<div class="card meter">
<div class="head"><span class="name">Процессор</span><span class="val" id="cpu-val">—</span></div>
<div class="bar"><i id="cpu-bar"></i></div>
<div class="sub" id="cpu-sub"></div>
</div>
<div class="card meter">
<div class="head"><span class="name">Оперативная память</span><span class="val" id="mem-val">—</span></div>
<div class="bar"><i id="mem-bar"></i></div>
<div class="sub" id="mem-sub"></div>
</div>
<div class="card meter">
<div class="head"><span class="name">Диск (/)</span><span class="val" id="disk-val">—</span></div>
<div class="bar"><i id="disk-bar"></i></div>
<div class="sub" id="disk-sub"></div>
</div>
</div>
<div class="card">
<h2>Скорость соединения</h2>
<p class="muted">Клиент замеряет свой канал до хоста панели — тот же, по которому идут терминал, файлы и http-проброс.
Замер занимает около 7 секунд и на это время нагружает канал.</p>
<div class="row">
<button id="speed-btn" type="button">Замерить скорость</button>
<span class="muted" id="speed-note"></span>
</div>
<div class="speed" id="speed" hidden>
<div><b id="sp-ping"></b><span>задержка</span></div>
<div><b id="sp-down"></b><span>хост → клиент</span></div>
<div><b id="sp-up"></b><span>клиент → хост</span></div>
</div>
</div>
</div>
<script>
(function () {
var base = '/api/panel/agents/' + encodeURIComponent({{.Agent.ID}});
var $ = function (id) { return document.getElementById(id); };
var errBox = $('err');
function size(n) {
var u = ['Б', 'КБ', 'МБ', 'ГБ', 'ТБ'], i = 0;
while (n >= 1024 && i < u.length - 1) { n /= 1024; i++; }
return n.toFixed(n < 10 && i > 0 ? 1 : 0) + ' ' + u[i];
}
function rate(bps) {
var u = ['бит/с', 'Кбит/с', 'Мбит/с', 'Гбит/с'], i = 0;
while (bps >= 1000 && i < u.length - 1) { bps /= 1000; i++; }
return bps.toFixed(bps < 100 && i > 0 ? 1 : 0) + ' ' + u[i];
}
function meter(name, pct, sub) {
pct = Math.max(0, Math.min(100, pct));
$(name + '-val').textContent = Math.round(pct) + '%';
var bar = $(name + '-bar');
bar.style.transform = 'scaleX(' + pct / 100 + ')';
bar.className = pct >= 90 ? 'crit' : pct >= 75 ? 'warn' : '';
$(name + '-sub').textContent = sub;
}
function showErr(msg) {
errBox.textContent = msg || '';
errBox.hidden = !msg;
}
function cores(n) {
var d = n % 10, h = n % 100;
return n + (d === 1 && h !== 11 ? ' ядро' : d >= 2 && d <= 4 && (h < 12 || h > 14) ? ' ядра' : ' ядер');
}
// One request at a time, and none while the tab is hidden: the client is
// sampled only for a page somebody is looking at.
var timer = null, busy = false;
function schedule(ms) {
clearTimeout(timer);
if (!document.hidden) timer = setTimeout(load, ms);
}
function load() {
if (busy || document.hidden) return;
busy = true;
var next = 3000;
fetch(base + '/stat', {credentials: 'same-origin'})
.then(function (r) { if (!r.ok) throw new Error('HTTP ' + r.status); return r.json(); })
.then(function (res) {
if (!res.ok) { showErr(res.error || 'ошибка'); next = 10000; return; }
showErr('');
var d = res.data;
meter('cpu', d.cpu, cores(d.ncpu));
meter('mem', d.mem_total ? 100 * d.mem_used / d.mem_total : 0,
'занято ' + size(d.mem_used) + ' из ' + size(d.mem_total) + ' · свободно ' + size(d.mem_total - d.mem_used));
meter('disk', d.disk_total ? 100 * (d.disk_total - d.disk_free) / d.disk_total : 0,
'свободно ' + size(d.disk_free) + ' из ' + size(d.disk_total));
})
.catch(function (e) { showErr('Нет связи с панелью: ' + e.message); next = 10000; })
.then(function () { busy = false; schedule(next); });
}
document.addEventListener('visibilitychange', function () {
if (document.hidden) clearTimeout(timer); else load();
});
load();
var btn = $('speed-btn'), note = $('speed-note');
btn.onclick = function () {
btn.disabled = true;
note.textContent = 'Идёт замер…';
fetch(base + '/speed', {method: 'POST', credentials: 'same-origin'})
.then(function (r) { if (!r.ok) throw new Error('HTTP ' + r.status); return r.json(); })
.then(function (res) {
if (!res.ok) { note.textContent = res.error || 'ошибка'; return; }
var d = res.data;
$('sp-ping').textContent = d.ping_ms.toFixed(d.ping_ms < 10 ? 1 : 0) + ' мс';
$('sp-down').textContent = rate(d.down_bps);
$('sp-up').textContent = rate(d.up_bps);
$('speed').hidden = false;
note.textContent = '';
})
.catch(function (e) { note.textContent = 'Не удалось: ' + e.message; })
.then(function () { btn.disabled = false; });
};
})();
</script>
</body>
</html>