Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cb2673d941 | ||
|
|
cf855fdc23 | ||
|
|
d456ffe757 | ||
|
|
81a2c96e0b | ||
|
|
e0dc15740d | ||
|
|
fbd8f30de3 | ||
|
|
67c02ad6b4 | ||
|
|
5155bc36cd | ||
|
|
ab8751d436 | ||
|
|
6441b4329d | ||
|
|
189050b2b6 | ||
|
|
fd75f324b8 | ||
|
|
789e1da93b | ||
|
|
87921a1381 | ||
|
|
b51363bf72 | ||
|
|
201d63ee1e |
No files matched your search
+1
-1
@@ -1 +1 @@
|
|||||||
0.0.5
|
0.0.6
|
||||||
@@ -36,6 +36,9 @@ server {
|
|||||||
# Long-poll behaviour: no buffering, generous read timeout.
|
# Long-poll behaviour: no buffering, generous read timeout.
|
||||||
proxy_buffering off;
|
proxy_buffering off;
|
||||||
proxy_cache off;
|
proxy_cache off;
|
||||||
|
# Pass uploads on as they arrive: the speed test through the browser
|
||||||
|
# times them on the client.
|
||||||
|
proxy_request_buffering off;
|
||||||
proxy_read_timeout 120s;
|
proxy_read_timeout 120s;
|
||||||
proxy_send_timeout 120s;
|
proxy_send_timeout 120s;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,7 +9,9 @@ import (
|
|||||||
"io"
|
"io"
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
|
"os/user"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"runtime"
|
||||||
"sort"
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
"unicode/utf8"
|
"unicode/utf8"
|
||||||
@@ -79,8 +81,18 @@ func fsList(path string) (proto.FSListResult, error) {
|
|||||||
if path == "" {
|
if path == "" {
|
||||||
path = "/"
|
path = "/"
|
||||||
}
|
}
|
||||||
|
// "~" / "~name" is where the file manager starts: the home directory, or
|
||||||
|
// the root when there is none to open.
|
||||||
|
home := strings.HasPrefix(path, "~") && !strings.Contains(path, "/")
|
||||||
|
if home {
|
||||||
|
path = homeDir(path[1:])
|
||||||
|
}
|
||||||
abs, _ := filepath.Abs(path)
|
abs, _ := filepath.Abs(path)
|
||||||
entries, err := os.ReadDir(abs)
|
entries, err := os.ReadDir(abs)
|
||||||
|
if err != nil && home && abs != "/" {
|
||||||
|
abs = "/"
|
||||||
|
entries, err = os.ReadDir(abs)
|
||||||
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return proto.FSListResult{}, err
|
return proto.FSListResult{}, err
|
||||||
}
|
}
|
||||||
@@ -222,3 +234,30 @@ func fsZip(paths []string, dest string) (any, error) {
|
|||||||
}
|
}
|
||||||
return map[string]string{"dest": dest}, nil
|
return map[string]string{"dest": dest}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// homeDir returns the home directory of the named user ("" = the user the
|
||||||
|
// agent runs as), or "/" when it cannot be told.
|
||||||
|
func homeDir(name string) string {
|
||||||
|
if name != "" {
|
||||||
|
if validUserName(name) {
|
||||||
|
if u, err := user.Lookup(name); err == nil && u.HomeDir != "" {
|
||||||
|
return u.HomeDir
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "/"
|
||||||
|
}
|
||||||
|
if u, err := user.Current(); err == nil && u.HomeDir != "" {
|
||||||
|
return u.HomeDir
|
||||||
|
}
|
||||||
|
if h := os.Getenv("HOME"); h != "" {
|
||||||
|
return h
|
||||||
|
}
|
||||||
|
// A root service often has neither a passwd entry we can read nor $HOME.
|
||||||
|
if os.Geteuid() == 0 {
|
||||||
|
if runtime.GOOS == "darwin" {
|
||||||
|
return "/var/root"
|
||||||
|
}
|
||||||
|
return "/root"
|
||||||
|
}
|
||||||
|
return "/"
|
||||||
|
}
|
||||||
+66
-21
@@ -4,6 +4,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"crypto/rand"
|
"crypto/rand"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"time"
|
"time"
|
||||||
@@ -12,16 +13,30 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
// speedWindow is how long each direction is measured for.
|
// speedWindow is how long each direction is measured for, unless the
|
||||||
speedWindow = 3 * time.Second
|
// server asks for another window; speedWindowMax bounds what it may ask.
|
||||||
// speedUpCap bounds the upload, whatever the link does in speedWindow.
|
speedWindow = 3 * time.Second
|
||||||
|
speedWindowMax = 30 * time.Second
|
||||||
|
// speedWait is how long the download may take to start: in a relayed test
|
||||||
|
// its bytes come from the browser.
|
||||||
|
speedWait = 10 * time.Second
|
||||||
|
// speedUpCap bounds the upload, whatever the link does in the window.
|
||||||
speedUpCap = 48 << 20
|
speedUpCap = 48 << 20
|
||||||
)
|
)
|
||||||
|
|
||||||
// doSpeed measures the agent's HTTP link to the server: round-trip time of an
|
// 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.
|
// empty request, then how much flows each way in the window. In a relayed
|
||||||
func (a *Agent) doSpeed(ctx context.Context) proto.TaskResult {
|
// test the server pipes both streams to the browser, so they run end to end.
|
||||||
res, err := a.measureSpeed(ctx)
|
func (a *Agent) doSpeed(ctx context.Context, env proto.TaskEnvelope) proto.TaskResult {
|
||||||
|
var args proto.SysSpeedArgs
|
||||||
|
if len(env.Args) > 0 {
|
||||||
|
_ = json.Unmarshal(env.Args, &args)
|
||||||
|
}
|
||||||
|
window := speedWindow
|
||||||
|
if args.WindowMs > 0 {
|
||||||
|
window = min(time.Duration(args.WindowMs)*time.Millisecond, speedWindowMax)
|
||||||
|
}
|
||||||
|
res, err := a.measureSpeed(ctx, args.Relay, window)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return proto.TaskResult{Error: err.Error()}
|
return proto.TaskResult{Error: err.Error()}
|
||||||
}
|
}
|
||||||
@@ -29,8 +44,12 @@ func (a *Agent) doSpeed(ctx context.Context) proto.TaskResult {
|
|||||||
return proto.TaskResult{OK: true, Data: data}
|
return proto.TaskResult{OK: true, Data: data}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *Agent) measureSpeed(ctx context.Context) (proto.SysSpeedResult, error) {
|
func (a *Agent) measureSpeed(ctx context.Context, relay bool, window time.Duration) (proto.SysSpeedResult, error) {
|
||||||
var res proto.SysSpeedResult
|
res := proto.SysSpeedResult{Relay: relay}
|
||||||
|
streams := "/api/agent/speed"
|
||||||
|
if relay {
|
||||||
|
streams += "/relay"
|
||||||
|
}
|
||||||
// Ping: the first request may pay for a new connection, so keep the best.
|
// Ping: the first request may pay for a new connection, so keep the best.
|
||||||
for i := 0; i < 4; i++ {
|
for i := 0; i < 4; i++ {
|
||||||
start := time.Now()
|
start := time.Now()
|
||||||
@@ -46,24 +65,16 @@ func (a *Agent) measureSpeed(ctx context.Context) (proto.SysSpeedResult, error)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Down: read what the server streams until the window closes.
|
down, err := a.speedDown(ctx, streams+"/down", window)
|
||||||
dctx, cancel := context.WithTimeout(ctx, speedWindow)
|
|
||||||
resp, err := a.speedRequest(dctx, http.MethodGet, "/api/agent/speed/down", nil)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
cancel()
|
|
||||||
return res, err
|
return res, err
|
||||||
}
|
}
|
||||||
start := time.Now()
|
res.DownBps = down
|
||||||
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.
|
// Up: send until the window closes or the cap is hit.
|
||||||
body := &speedSource{deadline: time.Now().Add(speedWindow), left: speedUpCap}
|
body := &speedSource{deadline: time.Now().Add(window), left: speedUpCap}
|
||||||
start = time.Now()
|
start := time.Now()
|
||||||
resp, err = a.speedRequest(ctx, http.MethodPost, "/api/agent/speed/up", body)
|
resp, err := a.speedRequest(ctx, http.MethodPost, streams+"/up", body)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return res, err
|
return res, err
|
||||||
}
|
}
|
||||||
@@ -73,6 +84,40 @@ func (a *Agent) measureSpeed(ctx context.Context) (proto.SysSpeedResult, error)
|
|||||||
return res, nil
|
return res, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// speedDown reads what the server streams for the window, counted from the
|
||||||
|
// first byte, and returns the rate.
|
||||||
|
func (a *Agent) speedDown(ctx context.Context, path string, window time.Duration) (float64, error) {
|
||||||
|
ctx, cancel := context.WithCancel(ctx)
|
||||||
|
defer cancel()
|
||||||
|
t := time.AfterFunc(speedWait, cancel)
|
||||||
|
defer t.Stop()
|
||||||
|
resp, err := a.speedRequest(ctx, http.MethodGet, path, nil)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
var (
|
||||||
|
n int64
|
||||||
|
start time.Time
|
||||||
|
buf = make([]byte, 64<<10)
|
||||||
|
)
|
||||||
|
for {
|
||||||
|
k, err := resp.Body.Read(buf)
|
||||||
|
if k > 0 && start.IsZero() {
|
||||||
|
start = time.Now()
|
||||||
|
t.Reset(window)
|
||||||
|
}
|
||||||
|
n += int64(k)
|
||||||
|
if err != nil {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if start.IsZero() {
|
||||||
|
return 0, errors.New("нет данных для замера")
|
||||||
|
}
|
||||||
|
return bps(n, time.Since(start)), nil
|
||||||
|
}
|
||||||
|
|
||||||
func (a *Agent) speedRequest(ctx context.Context, method, path string, body io.Reader) (*http.Response, error) {
|
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)
|
req, err := http.NewRequestWithContext(ctx, method, a.cfg.Host+path, body)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ func (a *Agent) runTask(ctx context.Context, taskID string) {
|
|||||||
case proto.TaskSysUpdate:
|
case proto.TaskSysUpdate:
|
||||||
res = a.doUpdate()
|
res = a.doUpdate()
|
||||||
case proto.TaskSysSpeed:
|
case proto.TaskSysSpeed:
|
||||||
res = a.doSpeed(ctx)
|
res = a.doSpeed(ctx, env)
|
||||||
default:
|
default:
|
||||||
res = a.execTask(env)
|
res = a.execTask(env)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -179,10 +179,24 @@ type SysStatResult struct {
|
|||||||
DiskFree uint64 `json:"disk_free"`
|
DiskFree uint64 `json:"disk_free"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SysSpeedArgs are the arguments of TaskSysSpeed.
|
||||||
|
type SysSpeedArgs struct {
|
||||||
|
// Relay makes the agent use the relayed streams, which the server pipes
|
||||||
|
// to and from the browser.
|
||||||
|
Relay bool `json:"relay,omitempty"`
|
||||||
|
// WindowMs is how long to measure each direction for; 0 leaves it to the
|
||||||
|
// agent (3 s, all an agent that predates this argument does).
|
||||||
|
WindowMs int `json:"window_ms,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
// SysSpeedResult is returned for TaskSysSpeed: the agent's HTTP link to the
|
// SysSpeedResult is returned for TaskSysSpeed: the agent's HTTP link to the
|
||||||
// server as the agent measured it. Down is server -> agent.
|
// server as the agent measured it. Down is server -> agent.
|
||||||
type SysSpeedResult struct {
|
type SysSpeedResult struct {
|
||||||
PingMs float64 `json:"ping_ms"`
|
PingMs float64 `json:"ping_ms"`
|
||||||
DownBps float64 `json:"down_bps"` // bits per second
|
DownBps float64 `json:"down_bps"` // bits per second
|
||||||
UpBps float64 `json:"up_bps"`
|
UpBps float64 `json:"up_bps"`
|
||||||
|
Relay bool `json:"relay,omitempty"` // the streams went through the relay
|
||||||
|
// Partial marks a test the server cut short: the numbers are what it had
|
||||||
|
// counted on its own side by then.
|
||||||
|
Partial bool `json:"partial,omitempty"`
|
||||||
}
|
}
|
||||||
+24
-9
@@ -106,6 +106,7 @@ CREATE TABLE IF NOT EXISTS forward_rules (
|
|||||||
`ALTER TABLE agents ADD COLUMN run_user TEXT NOT NULL DEFAULT ''`,
|
`ALTER TABLE agents ADD COLUMN run_user TEXT NOT NULL DEFAULT ''`,
|
||||||
`ALTER TABLE agents ADD COLUMN login_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`,
|
`ALTER TABLE forward_rules ADD COLUMN no_ufw INTEGER NOT NULL DEFAULT 0`,
|
||||||
|
`ALTER TABLE forward_rules ADD COLUMN label TEXT NOT NULL DEFAULT ''`,
|
||||||
} {
|
} {
|
||||||
if _, err := db.sql.Exec(alter); err != nil && !strings.Contains(err.Error(), "duplicate column") {
|
if _, err := db.sql.Exec(alter); err != nil && !strings.Contains(err.Error(), "duplicate column") {
|
||||||
return err
|
return err
|
||||||
@@ -329,7 +330,8 @@ type ForwardRule struct {
|
|||||||
HostPort int
|
HostPort int
|
||||||
ListenAddr string
|
ListenAddr string
|
||||||
Proto string
|
Proto string
|
||||||
NoUfw bool // the panel opens no ufw rule for this forward
|
NoUfw bool // the panel opens no ufw rule for this forward
|
||||||
|
Label string // optional note shown next to the rule
|
||||||
}
|
}
|
||||||
|
|
||||||
func (db *DB) CreateForwardRule(r ForwardRule) (int64, error) {
|
func (db *DB) CreateForwardRule(r ForwardRule) (int64, error) {
|
||||||
@@ -337,9 +339,9 @@ func (db *DB) CreateForwardRule(r ForwardRule) (int64, error) {
|
|||||||
r.Proto = "http"
|
r.Proto = "http"
|
||||||
}
|
}
|
||||||
res, err := db.sql.Exec(
|
res, err := db.sql.Exec(
|
||||||
`INSERT INTO forward_rules(agent_id, client_port, host_port, listen_addr, proto, no_ufw, created_at)
|
`INSERT INTO forward_rules(agent_id, client_port, host_port, listen_addr, proto, no_ufw, label, created_at)
|
||||||
VALUES(?, ?, ?, ?, ?, ?, ?)`,
|
VALUES(?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||||
r.AgentID, r.ClientPort, r.HostPort, r.ListenAddr, r.Proto, r.NoUfw, time.Now().Unix())
|
r.AgentID, r.ClientPort, r.HostPort, r.ListenAddr, r.Proto, r.NoUfw, r.Label, time.Now().Unix())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, err
|
return 0, err
|
||||||
}
|
}
|
||||||
@@ -348,7 +350,7 @@ func (db *DB) CreateForwardRule(r ForwardRule) (int64, error) {
|
|||||||
|
|
||||||
func (db *DB) ListForwardRules() ([]ForwardRule, error) {
|
func (db *DB) ListForwardRules() ([]ForwardRule, error) {
|
||||||
rows, err := db.sql.Query(
|
rows, err := db.sql.Query(
|
||||||
`SELECT id, agent_id, client_port, host_port, listen_addr, proto, no_ufw FROM forward_rules ORDER BY id`)
|
`SELECT id, agent_id, client_port, host_port, listen_addr, proto, no_ufw, label FROM forward_rules ORDER BY id`)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -356,7 +358,7 @@ func (db *DB) ListForwardRules() ([]ForwardRule, error) {
|
|||||||
var out []ForwardRule
|
var out []ForwardRule
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var r ForwardRule
|
var r ForwardRule
|
||||||
if err := rows.Scan(&r.ID, &r.AgentID, &r.ClientPort, &r.HostPort, &r.ListenAddr, &r.Proto, &r.NoUfw); err != nil {
|
if err := rows.Scan(&r.ID, &r.AgentID, &r.ClientPort, &r.HostPort, &r.ListenAddr, &r.Proto, &r.NoUfw, &r.Label); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
out = append(out, r)
|
out = append(out, r)
|
||||||
@@ -381,8 +383,8 @@ func (db *DB) ListForwardRulesByAgent(agentID string) ([]ForwardRule, error) {
|
|||||||
func (db *DB) GetForwardRule(id int64) (*ForwardRule, error) {
|
func (db *DB) GetForwardRule(id int64) (*ForwardRule, error) {
|
||||||
r := &ForwardRule{}
|
r := &ForwardRule{}
|
||||||
err := db.sql.QueryRow(
|
err := db.sql.QueryRow(
|
||||||
`SELECT id, agent_id, client_port, host_port, listen_addr, proto, no_ufw FROM forward_rules WHERE id = ?`, id).
|
`SELECT id, agent_id, client_port, host_port, listen_addr, proto, no_ufw, label FROM forward_rules WHERE id = ?`, id).
|
||||||
Scan(&r.ID, &r.AgentID, &r.ClientPort, &r.HostPort, &r.ListenAddr, &r.Proto, &r.NoUfw)
|
Scan(&r.ID, &r.AgentID, &r.ClientPort, &r.HostPort, &r.ListenAddr, &r.Proto, &r.NoUfw, &r.Label)
|
||||||
if err == sql.ErrNoRows {
|
if err == sql.ErrNoRows {
|
||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
@@ -400,7 +402,20 @@ type UfwRule struct {
|
|||||||
ID int64
|
ID int64
|
||||||
Port int
|
Port int
|
||||||
Proto string
|
Proto string
|
||||||
RuleID int64 // bound forward rule id, or 0 for shared/manual (e.g. the tunnel port)
|
RuleID int64 // bound forward rule id, 0 for shared (the tunnel port), ufwManual for one added in Settings
|
||||||
|
}
|
||||||
|
|
||||||
|
// ufwManual is the RuleID of a ufw rule the admin added by hand: no forward
|
||||||
|
// owns it, so only the admin removes it.
|
||||||
|
const ufwManual = -1
|
||||||
|
|
||||||
|
// Manual reports whether the admin added the rule by hand.
|
||||||
|
func (r UfwRule) Manual() bool { return r.RuleID == ufwManual }
|
||||||
|
|
||||||
|
// SetUfwRuleManual hands a tracked rule over to the admin.
|
||||||
|
func (db *DB) SetUfwRuleManual(port int, proto string) error {
|
||||||
|
_, err := db.sql.Exec(`UPDATE ufw_rules SET rule_id = ? WHERE port = ? AND proto = ?`, ufwManual, port, proto)
|
||||||
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
func (db *DB) AddUfwRule(port int, proto string, ruleID int64) error {
|
func (db *DB) AddUfwRule(port int, proto string, ruleID int64) error {
|
||||||
|
|||||||
@@ -185,6 +185,7 @@ func (s *Server) handleAgentDelete(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
_ = s.db.DeleteAgent(id)
|
_ = s.db.DeleteAgent(id)
|
||||||
s.stats.drop(id)
|
s.stats.drop(id)
|
||||||
|
s.relays.drop(id, nil)
|
||||||
s.hub.Forget(id)
|
s.hub.Forget(id)
|
||||||
http.Redirect(w, r, "/", http.StatusSeeOther)
|
http.Redirect(w, r, "/", http.StatusSeeOther)
|
||||||
}
|
}
|
||||||
@@ -397,6 +398,10 @@ func (s *Server) handleSettings(w http.ResponseWriter, r *http.Request) {
|
|||||||
"Error": map[string]string{
|
"Error": map[string]string{
|
||||||
"tunnel_host": "Некорректный хост туннеля: нужен IP или имя без схемы и порта.",
|
"tunnel_host": "Некорректный хост туннеля: нужен IP или имя без схемы и порта.",
|
||||||
"tunnel_port": "Некорректный порт туннеля.",
|
"tunnel_port": "Некорректный порт туннеля.",
|
||||||
|
"ufw_port": "Некорректное правило ufw: нужен порт 1-65535 и протокол tcp или udp.",
|
||||||
|
"ufw_off": "Правило ufw не добавлено: ufw выключен или интеграция с ним отключена.",
|
||||||
|
"ufw_failed": "Правило ufw не добавлено: команда ufw завершилась ошибкой.",
|
||||||
|
"ufw_exists": "Этот порт уже открыт в ufw не панелью - правило осталось как было, панель его не отслеживает.",
|
||||||
"tunnel_listen": "Не удалось занять новый порт туннеля на хосте (возможно, уже используется). Порт не изменён.",
|
"tunnel_listen": "Не удалось занять новый порт туннеля на хосте (возможно, уже используется). Порт не изменён.",
|
||||||
}[r.URL.Query().Get("err")],
|
}[r.URL.Query().Get("err")],
|
||||||
})
|
})
|
||||||
@@ -524,6 +529,24 @@ func (s *Server) handleUfwToggle(w http.ResponseWriter, r *http.Request) {
|
|||||||
http.Redirect(w, r, "/settings", http.StatusSeeOther)
|
http.Redirect(w, r, "/settings", http.StatusSeeOther)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleUfwAdd(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if !checkCSRF(r) {
|
||||||
|
http.Error(w, "bad csrf", http.StatusForbidden)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
port, _ := strconv.Atoi(strings.TrimSpace(r.FormValue("port")))
|
||||||
|
proto := r.FormValue("proto")
|
||||||
|
if port < 1 || port > 65535 || (proto != "tcp" && proto != "udp") {
|
||||||
|
http.Redirect(w, r, "/settings?err=ufw_port", http.StatusSeeOther)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
target := "/settings"
|
||||||
|
if key := s.ufw.allowManual(port, proto); key != "" {
|
||||||
|
target += "?err=" + key
|
||||||
|
}
|
||||||
|
http.Redirect(w, r, target, http.StatusSeeOther)
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Server) handleUfwRevoke(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleUfwRevoke(w http.ResponseWriter, r *http.Request) {
|
||||||
if !checkCSRF(r) {
|
if !checkCSRF(r) {
|
||||||
http.Error(w, "bad csrf", http.StatusForbidden)
|
http.Error(w, "bad csrf", http.StatusForbidden)
|
||||||
|
|||||||
@@ -87,7 +87,7 @@ func (s *Server) handleForwardAdd(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
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") != ""}
|
NoUfw: r.FormValue("no_ufw") != "", Label: forwardLabel(r.FormValue("label"))}
|
||||||
ruleID, err := s.db.CreateForwardRule(rule)
|
ruleID, err := s.db.CreateForwardRule(rule)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Redirect(w, r, "/agents/"+id+"/forwards?err=db", http.StatusSeeOther)
|
http.Redirect(w, r, "/agents/"+id+"/forwards?err=db", http.StatusSeeOther)
|
||||||
@@ -130,3 +130,15 @@ func (s *Server) removeForward(rid int64) {
|
|||||||
_ = s.db.DeleteForwardRule(rid)
|
_ = s.db.DeleteForwardRule(rid)
|
||||||
s.ufw.revokeForRule(rid)
|
s.ufw.revokeForRule(rid)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// forwardLabelMax bounds a rule's label, in characters.
|
||||||
|
const forwardLabelMax = 80
|
||||||
|
|
||||||
|
// forwardLabel tidies the optional note of a rule: one line, bounded length.
|
||||||
|
func forwardLabel(v string) string {
|
||||||
|
v = strings.Join(strings.Fields(v), " ")
|
||||||
|
if r := []rune(v); len(r) > forwardLabelMax {
|
||||||
|
v = string(r[:forwardLabelMax])
|
||||||
|
}
|
||||||
|
return v
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ package server
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"crypto/rand"
|
"crypto/rand"
|
||||||
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
@@ -19,13 +20,20 @@ const (
|
|||||||
// statCacheFor is how long one sample answers every open status page, so
|
// statCacheFor is how long one sample answers every open status page, so
|
||||||
// several tabs cost the agent a single reading.
|
// several tabs cost the agent a single reading.
|
||||||
statCacheFor = 2 * time.Second
|
statCacheFor = 2 * time.Second
|
||||||
// speedTaskTimeout covers the agent's ping + down + up windows with slack.
|
// speedWindow is how long the agent is asked to measure each direction
|
||||||
speedTaskTimeout = 30 * time.Second
|
// for. An agent that predates the argument keeps to its own 3 s.
|
||||||
|
speedWindow = 7 * time.Second
|
||||||
|
// speedTaskTimeout is how long a speed test may take. One that is not done
|
||||||
|
// by then is answered with what the server has counted itself.
|
||||||
|
speedTaskTimeout = 35 * time.Second
|
||||||
// speedDownCap bounds one download stream; speedDownFor ends it if the
|
// speedDownCap bounds one download stream; speedDownFor ends it if the
|
||||||
// agent does not hang up first.
|
// agent does not hang up first.
|
||||||
speedDownCap = 256 << 20
|
speedDownCap = 1 << 30
|
||||||
speedDownFor = 6 * time.Second
|
speedDownFor = 2 * speedWindow
|
||||||
speedUpCap = 64 << 20
|
speedUpCap = 64 << 20
|
||||||
|
// speedRelayUpFor is how long the agent's upload is passed on to the
|
||||||
|
// browser: the agent's own window with slack.
|
||||||
|
speedRelayUpFor = speedWindow + time.Second
|
||||||
)
|
)
|
||||||
|
|
||||||
// statCache holds the last sample per agent and serializes its refresh.
|
// statCache holds the last sample per agent and serializes its refresh.
|
||||||
@@ -66,12 +74,12 @@ func (s *Server) handleStatusPage(w http.ResponseWriter, r *http.Request) {
|
|||||||
http.NotFound(w, r)
|
http.NotFound(w, r)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
s.render(w, "status.html", map[string]any{"Agent": a})
|
s.render(w, "status.html", map[string]any{"Agent": a, "RelayUpMs": speedRelayUpFor.Milliseconds()})
|
||||||
}
|
}
|
||||||
|
|
||||||
// runTask runs a one-shot task on the agent and waits for its result.
|
// 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) {
|
func (s *Server) runTask(agentID, typ string, args []byte, timeout time.Duration) (proto.TaskResult, error) {
|
||||||
taskID, ch := s.hub.CreateTask(agentID, typ, nil)
|
taskID, ch := s.hub.CreateTask(agentID, typ, args)
|
||||||
defer s.hub.DiscardTask(taskID)
|
defer s.hub.DiscardTask(taskID)
|
||||||
t := time.NewTimer(timeout)
|
t := time.NewTimer(timeout)
|
||||||
defer t.Stop()
|
defer t.Stop()
|
||||||
@@ -118,7 +126,7 @@ func (s *Server) handleAgentStat(w http.ResponseWriter, r *http.Request) {
|
|||||||
e.mu.Lock()
|
e.mu.Lock()
|
||||||
defer e.mu.Unlock()
|
defer e.mu.Unlock()
|
||||||
if time.Since(e.at) > statCacheFor {
|
if time.Since(e.at) > statCacheFor {
|
||||||
res, err := s.runTask(id, proto.TaskSysStat, statTaskTimeout)
|
res, err := s.runTask(id, proto.TaskSysStat, nil, statTaskTimeout)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// Cached too, so pages queued behind this one don't each wait it out.
|
// Cached too, so pages queued behind this one don't each wait it out.
|
||||||
res = proto.TaskResult{Error: err.Error()}
|
res = proto.TaskResult{Error: err.Error()}
|
||||||
@@ -129,17 +137,188 @@ func (s *Server) handleAgentStat(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// POST /api/panel/agents/{id}/speed - have the agent measure its link to us.
|
// POST /api/panel/agents/{id}/speed - have the agent measure its link to us.
|
||||||
|
// With relay=1 its streams run through the relay opened for it, to and from
|
||||||
|
// the browser.
|
||||||
func (s *Server) handleAgentSpeed(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleAgentSpeed(w http.ResponseWriter, r *http.Request) {
|
||||||
id, ok := s.onlineAgent(w, r)
|
id, ok := s.onlineAgent(w, r)
|
||||||
if !ok {
|
if !ok {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
res, err := s.runTask(id, proto.TaskSysSpeed, speedTaskTimeout)
|
relay := r.URL.Query().Get("relay") != ""
|
||||||
|
m := s.meters.start(id)
|
||||||
|
defer s.meters.stop(id, m)
|
||||||
|
if relay {
|
||||||
|
rl := s.relays.get(id)
|
||||||
|
if rl == nil {
|
||||||
|
writeJSON(w, proto.TaskResult{Error: "замер через браузер не открыт"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer s.relays.drop(id, rl)
|
||||||
|
}
|
||||||
|
args, _ := json.Marshal(proto.SysSpeedArgs{Relay: relay, WindowMs: int(speedWindow.Milliseconds())})
|
||||||
|
res, err := s.runTask(id, proto.TaskSysSpeed, args, speedTaskTimeout)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeJSON(w, proto.TaskResult{Error: err.Error()})
|
part, ok := m.result()
|
||||||
|
if !ok {
|
||||||
|
writeJSON(w, proto.TaskResult{Error: err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
part.Relay = relay
|
||||||
|
data, _ := json.Marshal(part)
|
||||||
|
res = proto.TaskResult{OK: true, Data: data}
|
||||||
|
}
|
||||||
|
res = oldAgentError(res)
|
||||||
|
if relay && res.OK {
|
||||||
|
// An agent that predates the relay ignores the argument and measures
|
||||||
|
// its own link.
|
||||||
|
var d proto.SysSpeedResult
|
||||||
|
if json.Unmarshal(res.Data, &d) != nil || !d.Relay {
|
||||||
|
res = proto.TaskResult{Error: "клиент не умеет замер через браузер - обновите его (нужен 0.0.6+)"}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
writeJSON(w, res)
|
||||||
|
}
|
||||||
|
|
||||||
|
// speedMeter counts what flows through the streams of one speed test as the
|
||||||
|
// server sees it: for the live reading on the page, and for the result of a
|
||||||
|
// test that ran out of time.
|
||||||
|
type speedMeter struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
start time.Time
|
||||||
|
phase string // "ping", "down", "up"
|
||||||
|
pingAt time.Time
|
||||||
|
pingMs float64
|
||||||
|
down speedCount
|
||||||
|
up speedCount
|
||||||
|
}
|
||||||
|
|
||||||
|
// speedCount is the bytes of one direction and when they went through. The
|
||||||
|
// first piece only starts the clock.
|
||||||
|
type speedCount struct {
|
||||||
|
n, skip int64
|
||||||
|
first, last time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *speedCount) add(n int) {
|
||||||
|
now := time.Now()
|
||||||
|
if c.first.IsZero() {
|
||||||
|
c.first, c.skip = now, int64(n)
|
||||||
|
}
|
||||||
|
c.n += int64(n)
|
||||||
|
c.last = now
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *speedCount) bps() float64 {
|
||||||
|
d := c.last.Sub(c.first).Seconds()
|
||||||
|
if d <= 0 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return float64(c.n-c.skip) * 8 / d
|
||||||
|
}
|
||||||
|
|
||||||
|
// ping notes one of the agent's ping probes: they come one right after
|
||||||
|
// another, so the gap between two is its round trip.
|
||||||
|
func (m *speedMeter) ping() {
|
||||||
|
if m == nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
writeJSON(w, oldAgentError(res))
|
m.mu.Lock()
|
||||||
|
now := time.Now()
|
||||||
|
if !m.pingAt.IsZero() {
|
||||||
|
if ms := float64(now.Sub(m.pingAt).Microseconds()) / 1000; m.pingMs == 0 || ms < m.pingMs {
|
||||||
|
m.pingMs = ms
|
||||||
|
}
|
||||||
|
}
|
||||||
|
m.pingAt = now
|
||||||
|
m.mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *speedMeter) add(up bool, n int) {
|
||||||
|
if m == nil || n <= 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
m.mu.Lock()
|
||||||
|
if up {
|
||||||
|
m.phase = "up"
|
||||||
|
m.up.add(n)
|
||||||
|
} else {
|
||||||
|
m.phase = "down"
|
||||||
|
m.down.add(n)
|
||||||
|
}
|
||||||
|
m.mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
// result is the test as counted so far; false with nothing measured yet.
|
||||||
|
func (m *speedMeter) result() (proto.SysSpeedResult, bool) {
|
||||||
|
m.mu.Lock()
|
||||||
|
defer m.mu.Unlock()
|
||||||
|
res := proto.SysSpeedResult{PingMs: m.pingMs, DownBps: m.down.bps(), UpBps: m.up.bps(), Partial: true}
|
||||||
|
return res, res.DownBps > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// meterWriter counts what it passes on; a nil meter counts nothing.
|
||||||
|
type meterWriter struct {
|
||||||
|
w io.Writer
|
||||||
|
m *speedMeter
|
||||||
|
up bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c meterWriter) Write(p []byte) (int, error) {
|
||||||
|
n, err := c.w.Write(p)
|
||||||
|
c.m.add(c.up, n)
|
||||||
|
return n, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// speedMeters holds the meter of each agent under test.
|
||||||
|
type speedMeters struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
m map[string]*speedMeter
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *speedMeters) start(agentID string) *speedMeter {
|
||||||
|
m := &speedMeter{start: time.Now(), phase: "ping"}
|
||||||
|
c.mu.Lock()
|
||||||
|
if c.m == nil {
|
||||||
|
c.m = map[string]*speedMeter{}
|
||||||
|
}
|
||||||
|
c.m[agentID] = m
|
||||||
|
c.mu.Unlock()
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *speedMeters) get(agentID string) *speedMeter {
|
||||||
|
c.mu.Lock()
|
||||||
|
defer c.mu.Unlock()
|
||||||
|
return c.m[agentID]
|
||||||
|
}
|
||||||
|
|
||||||
|
// stop forgets the agent's meter if it is still m.
|
||||||
|
func (c *speedMeters) stop(agentID string, m *speedMeter) {
|
||||||
|
c.mu.Lock()
|
||||||
|
if c.m[agentID] == m {
|
||||||
|
delete(c.m, agentID)
|
||||||
|
}
|
||||||
|
c.mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /api/panel/agents/{id}/speed/progress - the bytes counted so far in the
|
||||||
|
// agent's running speed test, for the page to turn into a live rate. An empty
|
||||||
|
// phase means no test is running.
|
||||||
|
func (s *Server) handleSpeedProgress(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var out struct {
|
||||||
|
Phase string `json:"phase"`
|
||||||
|
Down int64 `json:"down"`
|
||||||
|
Up int64 `json:"up"`
|
||||||
|
Ms int64 `json:"ms"` // since the test began, by the server's clock
|
||||||
|
}
|
||||||
|
if m := s.meters.get(r.PathValue("id")); m != nil {
|
||||||
|
m.mu.Lock()
|
||||||
|
out.Phase, out.Down, out.Up = m.phase, m.down.n, m.up.n
|
||||||
|
out.Ms = time.Since(m.start).Milliseconds()
|
||||||
|
m.mu.Unlock()
|
||||||
|
}
|
||||||
|
w.Header().Set("Cache-Control", "no-store")
|
||||||
|
writeJSON(w, out)
|
||||||
}
|
}
|
||||||
|
|
||||||
// speedBlock is one chunk of incompressible bytes, made once and sent over and
|
// speedBlock is one chunk of incompressible bytes, made once and sent over and
|
||||||
@@ -150,23 +329,32 @@ var speedBlock = sync.OnceValue(func() []byte {
|
|||||||
return b
|
return b
|
||||||
})
|
})
|
||||||
|
|
||||||
|
func speedStreamHeaders(w http.ResponseWriter) {
|
||||||
|
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")
|
||||||
|
}
|
||||||
|
|
||||||
// GET /api/agent/speed/down?bytes=N - stream N bytes (default: as much as fits
|
// 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.
|
// 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) {
|
func (s *Server) handleSpeedDown(w http.ResponseWriter, r *http.Request) {
|
||||||
if s.authenticateAgent(r) == nil {
|
a := s.authenticateAgent(r)
|
||||||
|
if a == nil {
|
||||||
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
m := s.meters.get(a.ID)
|
||||||
left := speedDownCap
|
left := speedDownCap
|
||||||
if v := r.URL.Query().Get("bytes"); v != "" {
|
if v := r.URL.Query().Get("bytes"); v != "" {
|
||||||
if n, err := strconv.Atoi(v); err == nil && n >= 0 && n < left {
|
if n, err := strconv.Atoi(v); err == nil && n >= 0 && n < left {
|
||||||
left = n
|
left = n
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
w.Header().Set("Content-Type", "application/octet-stream")
|
if left == 0 {
|
||||||
w.Header().Set("Cache-Control", "no-store")
|
m.ping()
|
||||||
// Keep a proxy in front from buffering or compressing the stream.
|
}
|
||||||
w.Header().Set("X-Accel-Buffering", "no")
|
speedStreamHeaders(w)
|
||||||
block := speedBlock()
|
block := speedBlock()
|
||||||
deadline := time.Now().Add(speedDownFor)
|
deadline := time.Now().Add(speedDownFor)
|
||||||
for left > 0 && time.Now().Before(deadline) && r.Context().Err() == nil {
|
for left > 0 && time.Now().Before(deadline) && r.Context().Err() == nil {
|
||||||
@@ -178,16 +366,177 @@ func (s *Server) handleSpeedDown(w http.ResponseWriter, r *http.Request) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
m.add(false, n)
|
||||||
left -= n
|
left -= n
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// POST /api/agent/speed/up - swallow the agent's upload.
|
// POST /api/agent/speed/up - swallow the agent's upload.
|
||||||
func (s *Server) handleSpeedUp(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleSpeedUp(w http.ResponseWriter, r *http.Request) {
|
||||||
if s.authenticateAgent(r) == nil {
|
a := s.authenticateAgent(r)
|
||||||
|
if a == nil {
|
||||||
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
_, _ = io.Copy(io.Discard, http.MaxBytesReader(w, r.Body, speedUpCap))
|
sink := meterWriter{w: io.Discard, m: s.meters.get(a.ID), up: true}
|
||||||
|
_, _ = io.Copy(sink, http.MaxBytesReader(w, r.Body, speedUpCap))
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
// speedRelay joins the agent's speed test streams to the browser's, so the
|
||||||
|
// bytes travel browser - panel - client. Both pipes are unbuffered: a slow
|
||||||
|
// reader holds its writer back, and the rate is that of the whole path.
|
||||||
|
type speedRelay struct {
|
||||||
|
downR *io.PipeReader // browser -> agent
|
||||||
|
downW *io.PipeWriter
|
||||||
|
upR *io.PipeReader // agent -> browser
|
||||||
|
upW *io.PipeWriter
|
||||||
|
}
|
||||||
|
|
||||||
|
func (rl *speedRelay) close() {
|
||||||
|
rl.downR.CloseWithError(io.ErrClosedPipe)
|
||||||
|
rl.downW.CloseWithError(io.ErrClosedPipe)
|
||||||
|
rl.upR.CloseWithError(io.ErrClosedPipe)
|
||||||
|
rl.upW.CloseWithError(io.ErrClosedPipe)
|
||||||
|
}
|
||||||
|
|
||||||
|
// speedRelays holds the relay of each agent under test, one per agent.
|
||||||
|
type speedRelays struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
m map[string]*speedRelay
|
||||||
|
}
|
||||||
|
|
||||||
|
// open starts a fresh relay for the agent, ending the one it had.
|
||||||
|
func (c *speedRelays) open(agentID string) {
|
||||||
|
rl := &speedRelay{}
|
||||||
|
rl.downR, rl.downW = io.Pipe()
|
||||||
|
rl.upR, rl.upW = io.Pipe()
|
||||||
|
c.mu.Lock()
|
||||||
|
if c.m == nil {
|
||||||
|
c.m = map[string]*speedRelay{}
|
||||||
|
}
|
||||||
|
old := c.m[agentID]
|
||||||
|
c.m[agentID] = rl
|
||||||
|
c.mu.Unlock()
|
||||||
|
if old != nil {
|
||||||
|
old.close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *speedRelays) get(agentID string) *speedRelay {
|
||||||
|
c.mu.Lock()
|
||||||
|
defer c.mu.Unlock()
|
||||||
|
return c.m[agentID]
|
||||||
|
}
|
||||||
|
|
||||||
|
// drop ends the agent's relay; with rl given, only if it is still that one.
|
||||||
|
func (c *speedRelays) drop(agentID string, rl *speedRelay) {
|
||||||
|
c.mu.Lock()
|
||||||
|
cur := c.m[agentID]
|
||||||
|
if cur != nil && (rl == nil || rl == cur) {
|
||||||
|
delete(c.m, agentID)
|
||||||
|
} else {
|
||||||
|
cur = nil
|
||||||
|
}
|
||||||
|
c.mu.Unlock()
|
||||||
|
if cur != nil {
|
||||||
|
cur.close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /api/panel/ping - an empty answer for the browser to time.
|
||||||
|
func (s *Server) handlePanelPing(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Cache-Control", "no-store")
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /api/panel/agents/{id}/speed/relay - open the relay of a speed test
|
||||||
|
// that runs through the browser. The test itself ends it.
|
||||||
|
func (s *Server) handleSpeedRelayOpen(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id, ok := s.onlineAgent(w, r)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.relays.open(id)
|
||||||
|
writeJSON(w, proto.TaskResult{OK: true})
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /api/panel/agents/{id}/speed/down - a piece of the browser's upload,
|
||||||
|
// passed on to the agent. 204 asks for the next piece, 200 says the agent has
|
||||||
|
// stopped reading.
|
||||||
|
func (s *Server) handleSpeedRelayFeed(w http.ResponseWriter, r *http.Request) {
|
||||||
|
body := http.MaxBytesReader(w, r.Body, speedUpCap)
|
||||||
|
rl := s.relays.get(r.PathValue("id"))
|
||||||
|
if rl != nil {
|
||||||
|
if _, err := io.Copy(rl.downW, body); err == nil {
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Take the rest of the piece before answering: an answer sent over an
|
||||||
|
// unread body makes Go drop the connection, which a proxy in front turns
|
||||||
|
// into a 502.
|
||||||
|
_, _ = io.Copy(io.Discard, body)
|
||||||
|
w.Header().Set("Cache-Control", "no-store")
|
||||||
|
_, _ = io.WriteString(w, "done")
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /api/panel/agents/{id}/speed/up - the agent's upload, for the browser to
|
||||||
|
// time. Ends when the agent is done sending.
|
||||||
|
func (s *Server) handleSpeedRelayDrain(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id := r.PathValue("id")
|
||||||
|
rl := s.relays.get(id)
|
||||||
|
if rl == nil {
|
||||||
|
http.Error(w, "gone", http.StatusGone)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
speedStreamHeaders(w)
|
||||||
|
_, _ = io.Copy(meterWriter{w: w, m: s.meters.get(id), up: true}, rl.upR)
|
||||||
|
// The browser may have left: don't let the agent keep sending to nobody.
|
||||||
|
rl.upR.CloseWithError(io.ErrClosedPipe)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /api/agent/speed/relay/down - what the browser uploads, for the agent to
|
||||||
|
// time.
|
||||||
|
func (s *Server) handleSpeedRelayDown(w http.ResponseWriter, r *http.Request) {
|
||||||
|
a := s.authenticateAgent(r)
|
||||||
|
if a == nil {
|
||||||
|
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
rl := s.relays.get(a.ID)
|
||||||
|
if rl == nil {
|
||||||
|
http.NotFound(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
speedStreamHeaders(w)
|
||||||
|
// The agent waits on the headers, the browser may not have sent a byte yet.
|
||||||
|
_ = http.NewResponseController(w).Flush()
|
||||||
|
_, _ = io.Copy(meterWriter{w: w, m: s.meters.get(a.ID)}, rl.downR)
|
||||||
|
// The agent hung up at the end of its window: stop the browser's upload.
|
||||||
|
rl.downR.CloseWithError(io.ErrClosedPipe)
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /api/agent/speed/relay/up - the agent's upload, passed on to the browser.
|
||||||
|
func (s *Server) handleSpeedRelayUp(w http.ResponseWriter, r *http.Request) {
|
||||||
|
a := s.authenticateAgent(r)
|
||||||
|
if a == nil {
|
||||||
|
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
rl := s.relays.get(a.ID)
|
||||||
|
if rl == nil {
|
||||||
|
http.NotFound(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
body := http.MaxBytesReader(w, r.Body, speedUpCap)
|
||||||
|
// A proxy in front may hand over the whole upload at once: pass on only
|
||||||
|
// what the browser takes in the window, or the agent would wait for the
|
||||||
|
// rest to trickle through a slower link.
|
||||||
|
t := time.AfterFunc(speedRelayUpFor, func() { rl.upW.Close() })
|
||||||
|
_, _ = io.Copy(rl.upW, body)
|
||||||
|
t.Stop()
|
||||||
|
rl.upW.Close()
|
||||||
|
_, _ = io.Copy(io.Discard, body)
|
||||||
w.WriteHeader(http.StatusNoContent)
|
w.WriteHeader(http.StatusNoContent)
|
||||||
}
|
}
|
||||||
@@ -53,7 +53,9 @@ type Server struct {
|
|||||||
tunnelMu sync.Mutex // serializes port changes
|
tunnelMu sync.Mutex // serializes port changes
|
||||||
tunnelPort atomic.Int64
|
tunnelPort atomic.Int64
|
||||||
|
|
||||||
stats statCache // last load sample per agent, for the status page
|
stats statCache // last load sample per agent, for the status page
|
||||||
|
relays speedRelays // speed tests that run through the browser
|
||||||
|
meters speedMeters // byte counts of the speed tests under way
|
||||||
}
|
}
|
||||||
|
|
||||||
// New builds the server, parsing embedded templates.
|
// New builds the server, parsing embedded templates.
|
||||||
@@ -216,6 +218,7 @@ func (s *Server) Handler() http.Handler {
|
|||||||
mux.Handle("POST /settings/admins/add", s.requireAdmin(s.handleAddAdmin))
|
mux.Handle("POST /settings/admins/add", s.requireAdmin(s.handleAddAdmin))
|
||||||
mux.Handle("POST /settings/admins/delete", s.requireAdmin(s.handleDeleteAdmin))
|
mux.Handle("POST /settings/admins/delete", s.requireAdmin(s.handleDeleteAdmin))
|
||||||
mux.Handle("POST /settings/ufw/toggle", s.requireAdmin(s.handleUfwToggle))
|
mux.Handle("POST /settings/ufw/toggle", s.requireAdmin(s.handleUfwToggle))
|
||||||
|
mux.Handle("POST /settings/ufw/add", s.requireAdmin(s.handleUfwAdd))
|
||||||
mux.Handle("POST /settings/ufw/revoke", s.requireAdmin(s.handleUfwRevoke))
|
mux.Handle("POST /settings/ufw/revoke", s.requireAdmin(s.handleUfwRevoke))
|
||||||
|
|
||||||
// Panel terminal over HTTP long-polling (admin-only).
|
// Panel terminal over HTTP long-polling (admin-only).
|
||||||
@@ -232,6 +235,11 @@ func (s *Server) Handler() http.Handler {
|
|||||||
mux.Handle("GET /api/panel/agents/presence", s.requireAdmin(s.handlePresence))
|
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("GET /api/panel/agents/{id}/stat", s.requireAdmin(s.handleAgentStat))
|
||||||
mux.Handle("POST /api/panel/agents/{id}/speed", s.requireAdmin(s.handleAgentSpeed))
|
mux.Handle("POST /api/panel/agents/{id}/speed", s.requireAdmin(s.handleAgentSpeed))
|
||||||
|
mux.Handle("GET /api/panel/agents/{id}/speed/progress", s.requireAdmin(s.handleSpeedProgress))
|
||||||
|
mux.Handle("GET /api/panel/ping", s.requireAdmin(s.handlePanelPing))
|
||||||
|
mux.Handle("POST /api/panel/agents/{id}/speed/relay", s.requireAdmin(s.handleSpeedRelayOpen))
|
||||||
|
mux.Handle("POST /api/panel/agents/{id}/speed/down", s.requireAdmin(s.handleSpeedRelayFeed))
|
||||||
|
mux.Handle("GET /api/panel/agents/{id}/speed/up", s.requireAdmin(s.handleSpeedRelayDrain))
|
||||||
|
|
||||||
// Panel port forwarding (admin-only).
|
// Panel port forwarding (admin-only).
|
||||||
mux.Handle("POST /agents/{id}/forwards/add", s.requireAdmin(s.handleForwardAdd))
|
mux.Handle("POST /agents/{id}/forwards/add", s.requireAdmin(s.handleForwardAdd))
|
||||||
@@ -247,6 +255,8 @@ func (s *Server) Handler() http.Handler {
|
|||||||
mux.HandleFunc("POST /api/agent/task/{id}/result", s.handleTaskResult)
|
mux.HandleFunc("POST /api/agent/task/{id}/result", s.handleTaskResult)
|
||||||
mux.HandleFunc("GET /api/agent/speed/down", s.handleSpeedDown)
|
mux.HandleFunc("GET /api/agent/speed/down", s.handleSpeedDown)
|
||||||
mux.HandleFunc("POST /api/agent/speed/up", s.handleSpeedUp)
|
mux.HandleFunc("POST /api/agent/speed/up", s.handleSpeedUp)
|
||||||
|
mux.HandleFunc("GET /api/agent/speed/relay/down", s.handleSpeedRelayDown)
|
||||||
|
mux.HandleFunc("POST /api/agent/speed/relay/up", s.handleSpeedRelayUp)
|
||||||
|
|
||||||
return s.logRequests(mux)
|
return s.logRequests(mux)
|
||||||
}
|
}
|
||||||
|
|||||||
+25
-5
@@ -87,26 +87,46 @@ func (u *ufwManager) LastError() string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// allow opens port/proto in ufw and records it, bound to the given forward
|
// allow opens port/proto in ufw and records it, bound to the given forward
|
||||||
// rule id (0 = shared/manual). The caller has checked available().
|
// rule id (0 = shared, ufwManual = added in Settings). It reports whether the
|
||||||
func (u *ufwManager) allow(port int, proto string, ruleID int64) {
|
// panel now tracks the rule. The caller has checked available().
|
||||||
|
func (u *ufwManager) allow(port int, proto string, ruleID int64) bool {
|
||||||
if u.db.HasUfwRule(port, proto) {
|
if u.db.HasUfwRule(port, proto) {
|
||||||
return
|
return true
|
||||||
}
|
}
|
||||||
spec := fmt.Sprintf("%d/%s", port, proto)
|
spec := fmt.Sprintf("%d/%s", port, proto)
|
||||||
out, err := ufwRun("allow", spec)
|
out, err := ufwRun("allow", spec)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("ufw allow %s: %v: %s", spec, err, out)
|
log.Printf("ufw allow %s: %v: %s", spec, err, out)
|
||||||
u.setErr(fmt.Sprintf("ufw allow %s: %v: %s", spec, err, out))
|
u.setErr(fmt.Sprintf("ufw allow %s: %v: %s", spec, err, out))
|
||||||
return
|
return false
|
||||||
}
|
}
|
||||||
u.setErr("")
|
u.setErr("")
|
||||||
if strings.Contains(out, "Skipping") {
|
if strings.Contains(out, "Skipping") {
|
||||||
// The rule was there before the panel asked for it, so it is not ours
|
// The rule was there before the panel asked for it, so it is not ours
|
||||||
// to track (and later delete).
|
// to track (and later delete).
|
||||||
return
|
return false
|
||||||
}
|
}
|
||||||
_ = u.db.AddUfwRule(port, proto, ruleID)
|
_ = u.db.AddUfwRule(port, proto, ruleID)
|
||||||
log.Printf("ufw: allowed %s", spec)
|
log.Printf("ufw: allowed %s", spec)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// allowManual opens a port the admin asked for in Settings. A port the panel
|
||||||
|
// already opened for a forward or the tunnel becomes the admin's, so it stays
|
||||||
|
// when they no longer need it. It returns the error key for the settings page
|
||||||
|
// ("" on success).
|
||||||
|
func (u *ufwManager) allowManual(port int, proto string) string {
|
||||||
|
if !u.available() {
|
||||||
|
return "ufw_off"
|
||||||
|
}
|
||||||
|
if !u.allow(port, proto, ufwManual) {
|
||||||
|
if u.LastError() != "" {
|
||||||
|
return "ufw_failed"
|
||||||
|
}
|
||||||
|
return "ufw_exists"
|
||||||
|
}
|
||||||
|
_ = u.db.SetUfwRuleManual(port, proto)
|
||||||
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
// revokeRule deletes one tracked ufw rule from ufw and the tracking table.
|
// revokeRule deletes one tracked ufw rule from ufw and the tracking table.
|
||||||
|
|||||||
@@ -10,6 +10,8 @@
|
|||||||
--ok: #37c871;
|
--ok: #37c871;
|
||||||
}
|
}
|
||||||
* { box-sizing: border-box; }
|
* { box-sizing: border-box; }
|
||||||
|
/* The hidden attribute wins over any display set by a class. */
|
||||||
|
[hidden] { display: none !important; }
|
||||||
body {
|
body {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
background: var(--bg);
|
background: var(--bg);
|
||||||
@@ -94,7 +96,31 @@ pre { padding: 12px; overflow-x: auto; font-size: 13px; white-space: pre-wrap; w
|
|||||||
.row { display: flex; gap: 10px; align-items: center; flex-wrap: wrap; }
|
.row { display: flex; gap: 10px; align-items: center; flex-wrap: wrap; }
|
||||||
.spacer { flex: 1; }
|
.spacer { flex: 1; }
|
||||||
#terminal { width: 100%; height: calc(100vh - 150px); }
|
#terminal { width: 100%; height: calc(100vh - 150px); }
|
||||||
@media (max-width: 600px) {
|
/* Phones. A table marked .stack turns each row into a block of
|
||||||
header.topbar { padding: 12px 16px; }
|
"label: value" lines, the labels coming from data-l on the cells; a cell
|
||||||
header.topbar nav a { margin-left: 12px; }
|
marked .lead goes first, as the row's title. */
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
header.topbar { flex-wrap: wrap; gap: 6px 12px; padding: 10px 12px; }
|
||||||
|
header.topbar .brand { max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 14px; }
|
||||||
|
header.topbar nav { display: flex; align-items: center; gap: 16px; max-width: 100%;
|
||||||
|
overflow-x: auto; white-space: nowrap; scrollbar-width: none; }
|
||||||
|
header.topbar nav::-webkit-scrollbar { display: none; }
|
||||||
|
header.topbar nav > * { margin-left: 0 !important; flex: none; }
|
||||||
|
.wrap { padding: 16px 12px; }
|
||||||
|
.card { padding: 16px; border-radius: 8px; }
|
||||||
|
h1 { font-size: 20px; }
|
||||||
|
code { word-break: break-all; }
|
||||||
|
/* 16px and up, or iOS zooms the page into a field on focus. */
|
||||||
|
input[type=text], input[type=password], input[type=number], select, textarea { font-size: 16px; }
|
||||||
|
table.stack thead { display: none; }
|
||||||
|
table.stack, table.stack tbody { display: block; }
|
||||||
|
table.stack tr { display: flex; flex-direction: column; gap: 2px; padding: 12px 0; border-bottom: 1px solid var(--border); }
|
||||||
|
table.stack tr:first-child { padding-top: 0; }
|
||||||
|
table.stack tr:last-child { border-bottom: 0; padding-bottom: 0; }
|
||||||
|
table.stack td { display: flex; justify-content: flex-start; align-items: center; gap: 0;
|
||||||
|
width: auto; padding: 2px 0; border: 0; text-align: right !important; overflow-wrap: anywhere; }
|
||||||
|
table.stack td[data-l]::before { content: attr(data-l); flex: none; margin-right: auto; padding-right: 12px; color: var(--muted); font-size: 13px; text-align: left; }
|
||||||
|
table.stack td.lead { order: -1; justify-content: flex-start; text-align: left !important; font-size: 16px; font-weight: 600; }
|
||||||
|
table.stack td.actions { width: auto; padding-top: 8px; justify-content: flex-start; }
|
||||||
|
table.stack td:empty { display: none; }
|
||||||
}
|
}
|
||||||
@@ -21,16 +21,16 @@
|
|||||||
<h1>Машины-клиенты</h1>
|
<h1>Машины-клиенты</h1>
|
||||||
<div class="card">
|
<div class="card">
|
||||||
{{if .Agents}}
|
{{if .Agents}}
|
||||||
<table>
|
<table class="stack">
|
||||||
<thead><tr><th>Статус</th><th>Имя</th><th>Hostname</th><th>ОС / Арх</th><th>Активность</th><th>Действия</th></tr></thead>
|
<thead><tr><th>Статус</th><th>Имя</th><th>Hostname</th><th>ОС / Арх</th><th>Активность</th><th>Действия</th></tr></thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{{range .Agents}}
|
{{range .Agents}}
|
||||||
<tr data-agent="{{.ID}}">
|
<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 class="nowrap" data-l="Статус"><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="lead">{{.Name}}</td>
|
||||||
<td class="muted">{{.Hostname}}</td>
|
<td class="muted" data-l="Hostname">{{.Hostname}}</td>
|
||||||
<td class="muted nowrap">{{.OS}}/{{.Arch}}</td>
|
<td class="muted nowrap" data-l="ОС / Арх">{{.OS}}/{{.Arch}}</td>
|
||||||
<td class="muted nowrap seen">{{.Seen}}</td>
|
<td class="muted nowrap seen" data-l="Активность">{{.Seen}}</td>
|
||||||
<td class="actions">
|
<td class="actions">
|
||||||
<div class="row" style="gap:6px">
|
<div class="row" style="gap:6px">
|
||||||
<a class="btn small icon" href="/agents/{{.ID}}/terminal" data-tip="Терминал" aria-label="Терминал">
|
<a class="btn small icon" href="/agents/{{.ID}}/terminal" data-tip="Терминал" aria-label="Терминал">
|
||||||
|
|||||||
@@ -27,6 +27,20 @@
|
|||||||
.ic { display:inline-block; width:18px; text-align:center; margin-right:6px; }
|
.ic { display:inline-block; width:18px; text-align:center; margin-right:6px; }
|
||||||
.sz { color:var(--muted); font-variant-numeric:tabular-nums; }
|
.sz { color:var(--muted); font-variant-numeric:tabular-nums; }
|
||||||
.act { display:flex; gap:6px; justify-content:flex-end; }
|
.act { display:flex; gap:6px; justify-content:flex-end; }
|
||||||
|
table.fm .c-sz { width:120px; }
|
||||||
|
table.fm .c-mt { width:160px; }
|
||||||
|
table.fm .c-act { width:160px; }
|
||||||
|
.fm .nm { overflow-wrap:anywhere; }
|
||||||
|
/* Phones: no date column, and the row buttons only as wide as they are. */
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
table.fm .c-mt, table.fm td.mt { display:none; }
|
||||||
|
table.fm .c-sz { width:auto; white-space:nowrap; }
|
||||||
|
table.fm .c-act { width:1%; }
|
||||||
|
table.fm td, table.fm th { padding:7px 4px; }
|
||||||
|
.ic { margin-right:4px; }
|
||||||
|
.fm-bar input.fm-path { flex:1 1 100%; order:-1; }
|
||||||
|
textarea#edit { font-size:13px; }
|
||||||
|
}
|
||||||
dialog { background:var(--panel); color:var(--text); border:1px solid var(--border);
|
dialog { background:var(--panel); color:var(--text); border:1px solid var(--border);
|
||||||
border-radius:12px; padding:0; max-width:900px; width:92vw; }
|
border-radius:12px; padding:0; max-width:900px; width:92vw; }
|
||||||
dialog::backdrop { background:rgba(0,0,0,.6); }
|
dialog::backdrop { background:rgba(0,0,0,.6); }
|
||||||
@@ -74,8 +88,8 @@
|
|||||||
<table class="fm">
|
<table class="fm">
|
||||||
<thead><tr>
|
<thead><tr>
|
||||||
<th style="width:28px"><input type="checkbox" id="all"></th>
|
<th style="width:28px"><input type="checkbox" id="all"></th>
|
||||||
<th>Имя</th><th style="width:120px" class="sz">Размер</th>
|
<th>Имя</th><th class="sz c-sz">Размер</th>
|
||||||
<th style="width:160px" class="sz">Изменён</th><th style="width:160px"></th>
|
<th class="sz c-mt">Изменён</th><th class="c-act"></th>
|
||||||
</tr></thead>
|
</tr></thead>
|
||||||
<tbody id="list"></tbody>
|
<tbody id="list"></tbody>
|
||||||
</table>
|
</table>
|
||||||
@@ -156,8 +170,8 @@
|
|||||||
tr.innerHTML =
|
tr.innerHTML =
|
||||||
'<td><input type="checkbox" class="ck" data-n="'+encodeURIComponent(e.name)+'"></td>' +
|
'<td><input type="checkbox" class="ck" data-n="'+encodeURIComponent(e.name)+'"></td>' +
|
||||||
'<td class="nm"><span class="ic">'+(e.is_dir?'📁':'📄')+'</span>'+esc(e.name)+'</td>' +
|
'<td class="nm"><span class="ic">'+(e.is_dir?'📁':'📄')+'</span>'+esc(e.name)+'</td>' +
|
||||||
'<td class="sz">'+(e.is_dir?'':fmtSize(e.size))+'</td>' +
|
'<td class="sz c-sz">'+(e.is_dir?'':fmtSize(e.size))+'</td>' +
|
||||||
'<td class="sz">'+fmtTime(e.mtime)+'</td>' +
|
'<td class="sz mt">'+fmtTime(e.mtime)+'</td>' +
|
||||||
'<td><div class="act">' +
|
'<td><div class="act">' +
|
||||||
(isZip?'<button class="btn ghost small" data-act="unzip">unzip</button>':'') +
|
(isZip?'<button class="btn ghost small" data-act="unzip">unzip</button>':'') +
|
||||||
(e.is_dir?'':'<a class="btn ghost small icon" title="Скачать" href="'+dlHref+'" download>'+ICON_DL+'</a>') +
|
(e.is_dir?'':'<a class="btn ghost small icon" title="Скачать" href="'+dlHref+'" download>'+ICON_DL+'</a>') +
|
||||||
@@ -282,7 +296,15 @@
|
|||||||
['dragleave','drop'].forEach(x => drop.addEventListener(x, e => { e.preventDefault(); if(x!=='drop')drop.classList.remove('drag'); }));
|
['dragleave','drop'].forEach(x => drop.addEventListener(x, e => { e.preventDefault(); if(x!=='drop')drop.classList.remove('drag'); }));
|
||||||
drop.addEventListener('drop', e => { drop.classList.remove('drag'); if (e.dataTransfer.files.length) uploadFiles(e.dataTransfer.files); });
|
drop.addEventListener('drop', e => { drop.classList.remove('drag'); if (e.dataTransfer.files.length) uploadFiles(e.dataTransfer.files); });
|
||||||
|
|
||||||
load('/');
|
// Start in the home directory of the machine's user; an agent too old to
|
||||||
|
// know "~" fails that listing, so fall back to the root.
|
||||||
|
(async () => {
|
||||||
|
const r = await api('list', {path: '~' + {{.Agent.LoginUser}}});
|
||||||
|
if (!r.ok) { load('/'); return; }
|
||||||
|
cwd = r.data.path; entries = r.data.entries || []; selected.clear();
|
||||||
|
$('path').value = cwd;
|
||||||
|
render();
|
||||||
|
})();
|
||||||
})();
|
})();
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
@@ -6,16 +6,32 @@
|
|||||||
<title>Порты · {{.Agent.Name}} - LPRA</title>
|
<title>Порты · {{.Agent.Name}} - LPRA</title>
|
||||||
<link rel="stylesheet" href="/static/app.css">
|
<link rel="stylesheet" href="/static/app.css">
|
||||||
<style>
|
<style>
|
||||||
.frow { display:flex; align-items:flex-end; gap:14px; flex-wrap:wrap; }
|
/* One line: fixed narrow fields, the label takes what is left. */
|
||||||
.frow label { margin:0 0 6px; }
|
.frow { display:flex; align-items:flex-end; gap:12px; flex-wrap:wrap; }
|
||||||
.frow .fld { display:flex; flex-direction:column; }
|
.frow label { margin:0 0 6px; white-space:nowrap; }
|
||||||
.frow select, .frow input, .frow button {
|
.frow .fld { display:flex; flex-direction:column; flex:none; width:108px; }
|
||||||
|
.frow .fld.addr { width:128px; }
|
||||||
|
.frow .fld.grow { flex:1; width:auto; min-width:150px; }
|
||||||
|
.frow select, .frow input[type=text], .frow button {
|
||||||
height:40px; box-sizing:border-box; font-size:14px; line-height:1; margin:0;
|
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 { min-width:0; width:100%; padding:0 30px 0 12px; background-position:right 10px center; }
|
||||||
.frow select {
|
/* The ufw switch hangs under the button, centred on it, level with the note. */
|
||||||
padding:0 32px 0 12px; border-radius:8px; border:1px solid var(--border);
|
.frow .act { position:relative; }
|
||||||
background:#0c0f13; color:var(--text);
|
.frow .act label.check {
|
||||||
|
position:absolute; top:100%; left:50%; transform:translateX(-50%);
|
||||||
|
display:inline-flex; align-items:center; gap:8px; height:20px; margin:12px 0 0;
|
||||||
|
color:var(--text); font-size:14px; cursor:pointer;
|
||||||
|
}
|
||||||
|
.under { min-height:20px; margin:12px 110px 0 0; font-size:13px; line-height:20px; }
|
||||||
|
/* Phones: two fields a line, the label and the button each a line of their own. */
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.frow .fld, .frow .fld.addr { width:calc(50% - 6px); }
|
||||||
|
.frow .fld.grow { flex:1 1 100%; }
|
||||||
|
.frow .act { display:flex; align-items:center; gap:16px; width:100%; }
|
||||||
|
.frow .act button { flex:1; }
|
||||||
|
.frow .act label.check { position:static; transform:none; margin:0; }
|
||||||
|
.under { margin-right:0; }
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
@@ -39,16 +55,17 @@
|
|||||||
<div class="card">
|
<div class="card">
|
||||||
<h2>Правила</h2>
|
<h2>Правила</h2>
|
||||||
{{if .Rules}}
|
{{if .Rules}}
|
||||||
<table>
|
<table class="stack">
|
||||||
<thead><tr><th>Тип</th><th>Хост (порт)</th><th>→ Клиент (порт)</th><th>Слушать на</th><th></th></tr></thead>
|
<thead><tr><th>Тип</th><th>Хост (порт)</th><th>→ Клиент (порт)</th><th>Слушать на</th><th>Метка</th><th></th></tr></thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{{range .Rules}}
|
{{range .Rules}}
|
||||||
<tr>
|
<tr>
|
||||||
<td><code>{{.Proto}}</code></td>
|
<td data-l="Тип"><code>{{.Proto}}</code></td>
|
||||||
<td class="muted">:{{.HostPort}}</td>
|
<td class="muted" data-l="Хост">:{{.HostPort}}</td>
|
||||||
<td class="muted">127.0.0.1:{{.ClientPort}}</td>
|
<td class="muted" data-l="→ Клиент">127.0.0.1:{{.ClientPort}}</td>
|
||||||
<td class="muted">{{.ListenAddr}}{{if and .NoUfw $.UfwEnabled}} · без ufw{{end}}</td>
|
<td class="muted" data-l="Слушать на">{{.ListenAddr}}{{if and .NoUfw $.UfwEnabled}} · без ufw{{end}}</td>
|
||||||
<td style="text-align:right">
|
<td class="lead">{{.Label}}</td>
|
||||||
|
<td class="actions" style="text-align:right">
|
||||||
<form method="post" action="/forwards/{{.ID}}/delete" style="display:inline"
|
<form method="post" action="/forwards/{{.ID}}/delete" style="display:inline"
|
||||||
onsubmit="return confirm('Удалить правило?')">
|
onsubmit="return confirm('Удалить правило?')">
|
||||||
<input type="hidden" name="csrf" value="{{$.CSRF}}">
|
<input type="hidden" name="csrf" value="{{$.CSRF}}">
|
||||||
@@ -72,38 +89,44 @@
|
|||||||
<div class="fld">
|
<div class="fld">
|
||||||
<label>Тип</label>
|
<label>Тип</label>
|
||||||
<select name="proto" id="proto">
|
<select name="proto" id="proto">
|
||||||
<option value="http">http (long-poll, работает везде)</option>
|
<option value="http">http-lp</option>
|
||||||
<option value="tcp">tcp (прямой, низкая задержка, порт {{.TunnelPort}})</option>
|
<option value="tcp">tcp</option>
|
||||||
<option value="udp">udp (прямой, порт {{.TunnelPort}})</option>
|
<option value="udp">udp</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div class="fld">
|
<div class="fld">
|
||||||
<label>Порт на клиенте</label>
|
<label>Порт клиента</label>
|
||||||
<input type="text" name="client_port" placeholder="напр. 22" style="max-width:140px" required>
|
<input type="text" name="client_port" placeholder="22" required>
|
||||||
</div>
|
</div>
|
||||||
<div class="fld">
|
<div class="fld">
|
||||||
<label>Порт на хосте</label>
|
<label>Порт хоста</label>
|
||||||
<input type="text" name="host_port" placeholder="напр. 2222" style="max-width:140px" required>
|
<input type="text" name="host_port" placeholder="2222" required>
|
||||||
</div>
|
</div>
|
||||||
<div class="fld">
|
<div class="fld addr">
|
||||||
<label>Слушать на хосте</label>
|
<label>Слушать на</label>
|
||||||
<input type="text" name="listen_addr" value="0.0.0.0" style="max-width:160px">
|
<input type="text" name="listen_addr" value="0.0.0.0">
|
||||||
|
</div>
|
||||||
|
<div class="fld grow">
|
||||||
|
<label>Метка</label>
|
||||||
|
<input type="text" name="label" maxlength="80" placeholder="необязательно">
|
||||||
|
</div>
|
||||||
|
<div class="act">
|
||||||
|
<button type="submit">Создать</button>
|
||||||
|
{{if .UfwEnabled}}
|
||||||
|
<label class="check" title="Порт не будет открыт в ufw, и при удалении правила в ufw ничего не изменится">
|
||||||
|
<input type="checkbox" name="no_ufw" value="1"> Без ufw
|
||||||
|
</label>
|
||||||
|
{{end}}
|
||||||
</div>
|
</div>
|
||||||
<button type="submit">Создать</button>
|
|
||||||
</div>
|
</div>
|
||||||
{{if .UfwEnabled}}
|
<div class="under"><span class="muted" id="note"></span></div>
|
||||||
<label class="check" title="Порт не будет открыт в ufw, и при удалении правила в ufw ничего не изменится">
|
|
||||||
<input type="checkbox" name="no_ufw" value="1"> Без ufw правила
|
|
||||||
</label>
|
|
||||||
{{end}}
|
|
||||||
</form>
|
</form>
|
||||||
<p class="muted" id="note" style="font-size:13px; margin-top:12px"></p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<h2>Режимы</h2>
|
<h2>Режимы</h2>
|
||||||
<ul class="muted" style="font-size:14px; line-height:1.7">
|
<ul class="muted" style="font-size:14px; line-height:1.7">
|
||||||
<li><b>http</b> - туннель поверх HTTP long-poll (через адрес панели, порт {{.PanelPort}}). Работает везде, где доступна панель, но задержка выше.</li>
|
<li><b>http-lp</b> - туннель поверх HTTP long-poll (через адрес панели, порт {{.PanelPort}}). Работает везде, где доступна панель, но задержка выше.</li>
|
||||||
<li><b>tcp</b> - прямой TCP-мультиплексор через порт <b>{{.TunnelPort}}</b> хоста. Минимальная задержка, keep-alive, TCP_NODELAY. Нужно открыть {{.TunnelPort}}/tcp на хосте.</li>
|
<li><b>tcp</b> - прямой TCP-мультиплексор через порт <b>{{.TunnelPort}}</b> хоста. Минимальная задержка, keep-alive, TCP_NODELAY. Нужно открыть {{.TunnelPort}}/tcp на хосте.</li>
|
||||||
<li><b>udp</b> - прямой UDP через порт <b>{{.TunnelPort}}</b> хоста. Нужно открыть {{.TunnelPort}}/udp.</li>
|
<li><b>udp</b> - прямой UDP через порт <b>{{.TunnelPort}}</b> хоста. Нужно открыть {{.TunnelPort}}/udp.</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|||||||
@@ -141,6 +141,7 @@
|
|||||||
{{range .UfwRules}}
|
{{range .UfwRules}}
|
||||||
<tr>
|
<tr>
|
||||||
<td><code>{{.Port}}/{{.Proto}}</code></td>
|
<td><code>{{.Port}}/{{.Proto}}</code></td>
|
||||||
|
<td class="muted">{{if .Manual}}вручную{{else if .RuleID}}проброс{{else}}туннель{{end}}</td>
|
||||||
<td style="text-align:right">
|
<td style="text-align:right">
|
||||||
<form method="post" action="/settings/ufw/revoke" style="display:inline"
|
<form method="post" action="/settings/ufw/revoke" style="display:inline"
|
||||||
onsubmit="return confirm('Отменить правило ufw {{.Port}}/{{.Proto}}?')">
|
onsubmit="return confirm('Отменить правило ufw {{.Port}}/{{.Proto}}?')">
|
||||||
@@ -156,6 +157,20 @@
|
|||||||
{{else}}
|
{{else}}
|
||||||
<p class="muted" style="font-size:13px; margin-top:10px">Панель пока не открывала портов в ufw.</p>
|
<p class="muted" style="font-size:13px; margin-top:10px">Панель пока не открывала портов в ufw.</p>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|
||||||
|
{{if and .UfwActive .UfwEnabled}}
|
||||||
|
<h2 style="margin-top:18px; font-size:15px">Открыть порт вручную</h2>
|
||||||
|
<form method="post" action="/settings/ufw/add" class="row">
|
||||||
|
<input type="hidden" name="csrf" value="{{.CSRF}}">
|
||||||
|
<input type="number" name="port" min="1" max="65535" placeholder="порт" style="max-width:120px" required>
|
||||||
|
<select name="proto" style="max-width:100px">
|
||||||
|
<option value="tcp">tcp</option>
|
||||||
|
<option value="udp">udp</option>
|
||||||
|
</select>
|
||||||
|
<button type="submit">Открыть</button>
|
||||||
|
</form>
|
||||||
|
<p class="muted" style="font-size:13px">Такое правило не привязано к пробросу и остаётся, пока его не отменить здесь.</p>
|
||||||
|
{{end}}
|
||||||
{{end}}
|
{{end}}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -19,6 +19,25 @@
|
|||||||
.speed { display:flex; gap:28px; flex-wrap:wrap; margin-top:16px; }
|
.speed { display:flex; gap:28px; flex-wrap:wrap; margin-top:16px; }
|
||||||
.speed b { display:block; font-size:20px; font-variant-numeric:tabular-nums; }
|
.speed b { display:block; font-size:20px; font-variant-numeric:tabular-nums; }
|
||||||
.speed span { color:var(--muted); font-size:13px; }
|
.speed span { color:var(--muted); font-size:13px; }
|
||||||
|
.live { display:flex; align-items:center; gap:28px; flex-wrap:wrap; margin-top:20px; }
|
||||||
|
.gauge { position:relative; width:240px; flex:none; }
|
||||||
|
.gauge svg { display:block; width:100%; }
|
||||||
|
.gauge path { fill:none; stroke-width:14; stroke-linecap:round; }
|
||||||
|
.gauge .track { stroke:#0c0f13; }
|
||||||
|
.gauge .fill { stroke:var(--accent); stroke-dasharray:251.4; stroke-dashoffset:251.4; transition:stroke-dashoffset .25s linear, stroke .3s; }
|
||||||
|
.gauge .num { position:absolute; left:0; right:0; bottom:26px; text-align:center; font-size:38px; font-weight:600; line-height:1; font-variant-numeric:tabular-nums; }
|
||||||
|
.gauge .unit { position:absolute; left:0; right:0; bottom:4px; text-align:center; color:var(--muted); font-size:13px; }
|
||||||
|
.live .side { flex:1; min-width:220px; }
|
||||||
|
.live .phase { font-size:15px; margin-bottom:8px; }
|
||||||
|
.live .phase i { font-style:normal; color:var(--accent); margin-right:6px; }
|
||||||
|
.live.up .phase i { color:var(--ok); }
|
||||||
|
.live.up .gauge .fill { stroke:var(--ok); }
|
||||||
|
.spark { display:block; width:100%; height:64px; }
|
||||||
|
.spark polyline { fill:none; stroke:var(--accent); stroke-width:2; vector-effect:non-scaling-stroke; stroke-linejoin:round; }
|
||||||
|
.spark polygon { fill:var(--accent); opacity:.15; }
|
||||||
|
.live.up .spark polyline { stroke:var(--ok); }
|
||||||
|
.live.up .spark polygon { fill:var(--ok); }
|
||||||
|
label.check { display:inline-flex; align-items:center; gap:8px; margin:0; color:var(--text); font-size:14px; cursor:pointer; }
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
@@ -57,15 +76,36 @@
|
|||||||
<div class="card">
|
<div class="card">
|
||||||
<h2>Скорость соединения</h2>
|
<h2>Скорость соединения</h2>
|
||||||
<p class="muted">Клиент замеряет свой канал до хоста панели - тот же, по которому идут терминал, файлы и http-проброс.
|
<p class="muted">Клиент замеряет свой канал до хоста панели - тот же, по которому идут терминал, файлы и http-проброс.
|
||||||
Замер занимает около 7 секунд и на это время нагружает канал.</p>
|
С галочкой «До браузера» данные идут дальше, через панель до этого браузера и обратно: так виден весь путь клиент - панель - браузер.
|
||||||
|
Замер занимает около 15 секунд, не дольше 40, и на это время нагружает канал.</p>
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<button id="speed-btn" type="button">Замерить скорость</button>
|
<button id="speed-btn" type="button">Замерить скорость</button>
|
||||||
|
<label class="check" title="Данные пойдут не только между клиентом и хостом панели, а через панель до этого браузера и обратно">
|
||||||
|
<input type="checkbox" id="speed-relay"> До браузера
|
||||||
|
</label>
|
||||||
<span class="muted" id="speed-note"></span>
|
<span class="muted" id="speed-note"></span>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="live" id="live" hidden>
|
||||||
|
<div class="gauge">
|
||||||
|
<svg viewBox="0 0 200 110">
|
||||||
|
<path class="track" d="M20 100 A80 80 0 0 1 180 100"/>
|
||||||
|
<path class="fill" id="lv-arc" d="M20 100 A80 80 0 0 1 180 100"/>
|
||||||
|
</svg>
|
||||||
|
<div class="num" id="lv-num">-</div>
|
||||||
|
<div class="unit" id="lv-unit"></div>
|
||||||
|
</div>
|
||||||
|
<div class="side">
|
||||||
|
<div class="phase"><i id="lv-ico"></i><span id="lv-phase"></span></div>
|
||||||
|
<svg class="spark" viewBox="0 0 100 100" preserveAspectRatio="none">
|
||||||
|
<polygon id="lv-area" points=""/>
|
||||||
|
<polyline id="lv-line" points=""/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div class="speed" id="speed" hidden>
|
<div class="speed" id="speed" hidden>
|
||||||
<div><b id="sp-ping"></b><span>задержка</span></div>
|
<div><b id="sp-ping"></b><span>задержка</span></div>
|
||||||
<div><b id="sp-down"></b><span>хост → клиент</span></div>
|
<div><b id="sp-down"></b><span id="sp-down-lbl"></span></div>
|
||||||
<div><b id="sp-up"></b><span>клиент → хост</span></div>
|
<div><b id="sp-up"></b><span id="sp-up-lbl"></span></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -80,11 +120,12 @@
|
|||||||
while (n >= 1024 && i < u.length - 1) { n /= 1024; i++; }
|
while (n >= 1024 && i < u.length - 1) { n /= 1024; i++; }
|
||||||
return n.toFixed(n < 10 && i > 0 ? 1 : 0) + ' ' + u[i];
|
return n.toFixed(n < 10 && i > 0 ? 1 : 0) + ' ' + u[i];
|
||||||
}
|
}
|
||||||
function rate(bps) {
|
function rateParts(bps) {
|
||||||
var u = ['бит/с', 'Кбит/с', 'Мбит/с', 'Гбит/с'], i = 0;
|
var u = ['бит/с', 'Кбит/с', 'Мбит/с', 'Гбит/с'], i = 0;
|
||||||
while (bps >= 1000 && i < u.length - 1) { bps /= 1000; i++; }
|
while (bps >= 1000 && i < u.length - 1) { bps /= 1000; i++; }
|
||||||
return bps.toFixed(bps < 100 && i > 0 ? 1 : 0) + ' ' + u[i];
|
return [bps.toFixed(bps < 100 && i > 0 ? 1 : 0), u[i]];
|
||||||
}
|
}
|
||||||
|
function rate(bps) { return rateParts(bps).join(' '); }
|
||||||
function meter(name, pct, sub) {
|
function meter(name, pct, sub) {
|
||||||
pct = Math.max(0, Math.min(100, pct));
|
pct = Math.max(0, Math.min(100, pct));
|
||||||
$(name + '-val').textContent = Math.round(pct) + '%';
|
$(name + '-val').textContent = Math.round(pct) + '%';
|
||||||
@@ -134,22 +175,170 @@
|
|||||||
load();
|
load();
|
||||||
|
|
||||||
var btn = $('speed-btn'), note = $('speed-note');
|
var btn = $('speed-btn'), note = $('speed-note');
|
||||||
|
function json(r) { if (!r.ok) throw new Error('HTTP ' + r.status); return r.json(); }
|
||||||
|
function post(path) { return fetch(base + path, {method: 'POST', credentials: 'same-origin'}).then(json); }
|
||||||
|
|
||||||
|
// The browser's round trip to the panel: the first request may pay for a
|
||||||
|
// new connection, so keep the best.
|
||||||
|
function panelPing() {
|
||||||
|
var best = Infinity, left = 4;
|
||||||
|
function one() {
|
||||||
|
var t = performance.now();
|
||||||
|
return fetch('/api/panel/ping', {credentials: 'same-origin', cache: 'no-store'})
|
||||||
|
.then(function (r) { if (!r.ok) throw new Error('HTTP ' + r.status); return r.arrayBuffer(); })
|
||||||
|
.then(function () {
|
||||||
|
best = Math.min(best, performance.now() - t);
|
||||||
|
return --left > 0 ? one() : best;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return one();
|
||||||
|
}
|
||||||
|
// Keeps the client's download fed until stopped or the panel says the
|
||||||
|
// client has read enough; the pieces grow while they go through quickly.
|
||||||
|
function feed(stop) {
|
||||||
|
var block = new Uint8Array(65536), n = 4;
|
||||||
|
crypto.getRandomValues(block);
|
||||||
|
function one() {
|
||||||
|
var parts = [], t = performance.now();
|
||||||
|
for (var i = 0; i < n; i++) parts.push(block);
|
||||||
|
return fetch(base + '/speed/down', {method: 'POST', body: new Blob(parts), credentials: 'same-origin', signal: stop.signal})
|
||||||
|
.then(function (r) {
|
||||||
|
if (r.status !== 204) return;
|
||||||
|
if (performance.now() - t < 500 && n < 256) n *= 2;
|
||||||
|
return one();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
one().catch(function () {});
|
||||||
|
}
|
||||||
|
// Reads what the client sends and times it from the first byte. It stops by
|
||||||
|
// itself a little after the client's window, so bytes still queued on a slow
|
||||||
|
// link don't hold the result back. st.n is the count so far, for the live
|
||||||
|
// reading.
|
||||||
|
function drain(st, onFirst) {
|
||||||
|
var ac = new AbortController(), last = 0;
|
||||||
|
st.stop = function () { ac.abort(); };
|
||||||
|
function done() { return st.start && last > st.start ? st.n * 8000 / (last - st.start) : 0; }
|
||||||
|
return fetch(base + '/speed/up', {credentials: 'same-origin', cache: 'no-store', signal: ac.signal}).then(function (r) {
|
||||||
|
if (!r.ok) throw new Error('HTTP ' + r.status);
|
||||||
|
var rd = r.body.getReader();
|
||||||
|
function pump() {
|
||||||
|
return rd.read().then(function (c) {
|
||||||
|
if (c.done) return done();
|
||||||
|
last = performance.now();
|
||||||
|
if (!st.start) { st.start = last; setTimeout(st.stop, {{.RelayUpMs}}); onFirst(); }
|
||||||
|
else st.n += c.value.length;
|
||||||
|
return pump();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return pump();
|
||||||
|
}).catch(function (e) { if (st.start) return done(); throw e; });
|
||||||
|
}
|
||||||
|
// The test through the browser: the client's download is what this page
|
||||||
|
// uploads, and its upload is timed here, where it arrives.
|
||||||
|
function relayTest(st) {
|
||||||
|
return panelPing().then(function (ping) {
|
||||||
|
return post('/speed/relay').then(function (res) {
|
||||||
|
if (!res.ok) return res;
|
||||||
|
var stop = new AbortController();
|
||||||
|
var up = drain(st, function () { stop.abort(); });
|
||||||
|
up.catch(function () {});
|
||||||
|
feed(stop);
|
||||||
|
return post('/speed?relay=1').then(function (res) {
|
||||||
|
stop.abort();
|
||||||
|
// No upload under way by now: none is coming.
|
||||||
|
if (!res.ok || !st.start) { st.stop(); }
|
||||||
|
if (!res.ok) return res;
|
||||||
|
return up.catch(function () { return 0; }).then(function (bps) {
|
||||||
|
res.data.ping_ms += ping;
|
||||||
|
if (bps) res.data.up_bps = bps;
|
||||||
|
return res;
|
||||||
|
});
|
||||||
|
}, function (e) { stop.abort(); st.stop(); throw e; });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// The live reading: the rate over the last second, from the byte counts the
|
||||||
|
// panel keeps for the running test - or, for the client's upload in a test
|
||||||
|
// through the browser, from what has arrived here.
|
||||||
|
var ARC = 251.4, lv = null;
|
||||||
|
function liveStart(relay, st) {
|
||||||
|
lv = {relay: relay, st: st, phase: '', pts: [], hist: [], busy: false};
|
||||||
|
$('live').hidden = false;
|
||||||
|
liveShow('ping', 0);
|
||||||
|
lv.timer = setInterval(liveTick, 200);
|
||||||
|
}
|
||||||
|
function liveStop() {
|
||||||
|
if (!lv) return;
|
||||||
|
clearInterval(lv.timer);
|
||||||
|
lv = null;
|
||||||
|
$('live').hidden = true;
|
||||||
|
}
|
||||||
|
function liveTick() {
|
||||||
|
var cur = lv;
|
||||||
|
if (cur.relay && cur.st.start) { livePoint(cur, 'up', cur.st.n, performance.now()); return; }
|
||||||
|
if (cur.busy) return;
|
||||||
|
cur.busy = true;
|
||||||
|
fetch(base + '/speed/progress', {credentials: 'same-origin', cache: 'no-store'})
|
||||||
|
.then(json)
|
||||||
|
.then(function (d) {
|
||||||
|
if (cur !== lv || !d.phase || (cur.relay && cur.st.start)) return;
|
||||||
|
livePoint(cur, d.phase, d.phase === 'up' ? d.up : d.down, d.ms);
|
||||||
|
})
|
||||||
|
.catch(function () {})
|
||||||
|
.then(function () { cur.busy = false; });
|
||||||
|
}
|
||||||
|
function livePoint(cur, phase, bytes, ms) {
|
||||||
|
if (phase !== cur.phase) { cur.phase = phase; cur.pts = []; cur.hist = []; }
|
||||||
|
if (phase === 'ping') { liveShow(phase, 0); return; }
|
||||||
|
var pts = cur.pts;
|
||||||
|
pts.push([ms, bytes]);
|
||||||
|
while (pts.length > 2 && ms - pts[0][0] > 1000) pts.shift();
|
||||||
|
if (pts.length < 2 || ms <= pts[0][0]) return;
|
||||||
|
var bps = (bytes - pts[0][1]) * 8000 / (ms - pts[0][0]);
|
||||||
|
cur.hist.push(bps);
|
||||||
|
liveShow(phase, bps, cur);
|
||||||
|
}
|
||||||
|
function liveShow(phase, bps, cur) {
|
||||||
|
var relay = lv && lv.relay, p = rateParts(bps);
|
||||||
|
$('live').className = 'live' + (phase === 'up' ? ' up' : '');
|
||||||
|
$('lv-ico').textContent = phase === 'up' ? '↑' : phase === 'down' ? '↓' : '';
|
||||||
|
$('lv-phase').textContent = phase === 'ping' ? 'Задержка…'
|
||||||
|
: phase === 'up' ? (relay ? 'клиент → браузер' : 'клиент → хост')
|
||||||
|
: (relay ? 'браузер → клиент' : 'хост → клиент');
|
||||||
|
$('lv-num').textContent = phase === 'ping' ? '…' : p[0];
|
||||||
|
$('lv-unit').textContent = phase === 'ping' ? '' : p[1];
|
||||||
|
// A log scale up to 1 Гбит/с, so a slow link moves the needle too.
|
||||||
|
var f = Math.min(1, Math.log(1 + bps / 1e6) / Math.log(1001));
|
||||||
|
$('lv-arc').style.strokeDashoffset = ARC * (1 - f);
|
||||||
|
var h = cur ? cur.hist : [], max = 1.25 * Math.max.apply(null, h.concat(1)), line = '';
|
||||||
|
for (var i = 0; i < h.length; i++) {
|
||||||
|
line += (h.length > 1 ? 100 * i / (h.length - 1) : 0).toFixed(1) + ',' + (98 - 94 * h[i] / max).toFixed(1) + ' ';
|
||||||
|
}
|
||||||
|
$('lv-line').setAttribute('points', line);
|
||||||
|
$('lv-area').setAttribute('points', h.length > 1 ? '0,100 ' + line + '100,100' : '');
|
||||||
|
}
|
||||||
|
|
||||||
btn.onclick = function () {
|
btn.onclick = function () {
|
||||||
|
var relay = $('speed-relay').checked, st = {n: 0, start: 0, stop: function () {}};
|
||||||
btn.disabled = true;
|
btn.disabled = true;
|
||||||
note.textContent = 'Идёт замер…';
|
note.textContent = 'Идёт замер…';
|
||||||
fetch(base + '/speed', {method: 'POST', credentials: 'same-origin'})
|
$('speed').hidden = true;
|
||||||
.then(function (r) { if (!r.ok) throw new Error('HTTP ' + r.status); return r.json(); })
|
liveStart(relay, st);
|
||||||
|
(relay ? relayTest(st) : post('/speed'))
|
||||||
.then(function (res) {
|
.then(function (res) {
|
||||||
if (!res.ok) { note.textContent = res.error || 'ошибка'; return; }
|
if (!res.ok) { note.textContent = res.error || 'ошибка'; return; }
|
||||||
var d = res.data;
|
var d = res.data;
|
||||||
$('sp-ping').textContent = d.ping_ms.toFixed(d.ping_ms < 10 ? 1 : 0) + ' мс';
|
$('sp-ping').textContent = d.ping_ms ? d.ping_ms.toFixed(d.ping_ms < 10 ? 1 : 0) + ' мс' : '-';
|
||||||
$('sp-down').textContent = rate(d.down_bps);
|
$('sp-down').textContent = rate(d.down_bps);
|
||||||
$('sp-up').textContent = rate(d.up_bps);
|
$('sp-up').textContent = d.up_bps ? rate(d.up_bps) : '-';
|
||||||
|
$('sp-down-lbl').textContent = relay ? 'браузер → клиент' : 'хост → клиент';
|
||||||
|
$('sp-up-lbl').textContent = relay ? 'клиент → браузер' : 'клиент → хост';
|
||||||
$('speed').hidden = false;
|
$('speed').hidden = false;
|
||||||
note.textContent = '';
|
note.textContent = d.partial ? 'Замер остановлен по времени - показано то, что успело набраться' : '';
|
||||||
})
|
})
|
||||||
.catch(function (e) { note.textContent = 'Не удалось: ' + e.message; })
|
.catch(function (e) { note.textContent = 'Не удалось: ' + e.message; })
|
||||||
.then(function () { btn.disabled = false; });
|
.then(function () { liveStop(); btn.disabled = false; });
|
||||||
};
|
};
|
||||||
})();
|
})();
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -9,6 +9,24 @@
|
|||||||
<style>
|
<style>
|
||||||
#status.st-ok { color:#37c871; }
|
#status.st-ok { color:#37c871; }
|
||||||
#status.st-err { color:#ff5c5c; }
|
#status.st-err { color:#ff5c5c; }
|
||||||
|
/* Touch screens: the page is as tall as what the on-screen keyboard leaves,
|
||||||
|
with a row of extra keys at the bottom, the way Termux does it. */
|
||||||
|
.keys { display:none; }
|
||||||
|
body.touch { position:fixed; left:0; right:0; top:0; height:100%; display:flex; flex-direction:column; overflow:hidden; }
|
||||||
|
body.touch .wrap { flex:1; min-height:0; display:flex; width:100%; margin:0; padding:6px; }
|
||||||
|
body.touch #terminal { flex:1; min-width:0; height:auto; }
|
||||||
|
body.touch .keys {
|
||||||
|
display:grid; grid-template-columns:repeat(7, 1fr); gap:4px; flex:none;
|
||||||
|
padding:4px 4px calc(4px + env(safe-area-inset-bottom)); background:var(--panel); border-top:1px solid var(--border);
|
||||||
|
}
|
||||||
|
.keys button {
|
||||||
|
height:38px; padding:0; border:1px solid var(--border); border-radius:6px;
|
||||||
|
background:#0c0f13; color:var(--text); font:500 13px ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||||
|
touch-action:manipulation; user-select:none; -webkit-user-select:none; -webkit-tap-highlight-color:transparent;
|
||||||
|
}
|
||||||
|
.keys button:active { background:var(--border); }
|
||||||
|
.keys button.on { background:var(--accent); border-color:var(--accent); color:#fff; }
|
||||||
|
.keys button.lock { background:var(--accent); border-color:#fff; color:#fff; }
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
@@ -26,6 +44,22 @@
|
|||||||
<div class="wrap" style="max-width:none">
|
<div class="wrap" style="max-width:none">
|
||||||
<div id="terminal"></div>
|
<div id="terminal"></div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="keys" id="keys">
|
||||||
|
<button type="button" data-k="esc">ESC</button>
|
||||||
|
<button type="button" data-k="/">/</button>
|
||||||
|
<button type="button" data-k="-">-</button>
|
||||||
|
<button type="button" data-k="home">HOME</button>
|
||||||
|
<button type="button" data-k="up">↑</button>
|
||||||
|
<button type="button" data-k="end">END</button>
|
||||||
|
<button type="button" data-k="pgup">PGUP</button>
|
||||||
|
<button type="button" data-k="tab">TAB</button>
|
||||||
|
<button type="button" data-k="ctrl" data-mod="ctrl">CTRL</button>
|
||||||
|
<button type="button" data-k="alt" data-mod="alt">ALT</button>
|
||||||
|
<button type="button" data-k="left">←</button>
|
||||||
|
<button type="button" data-k="down">↓</button>
|
||||||
|
<button type="button" data-k="right">→</button>
|
||||||
|
<button type="button" data-k="pgdn">PGDN</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<script src="/static/xterm.js"></script>
|
<script src="/static/xterm.js"></script>
|
||||||
<script src="/static/addon-fit.js"></script>
|
<script src="/static/addon-fit.js"></script>
|
||||||
@@ -33,11 +67,13 @@
|
|||||||
(function () {
|
(function () {
|
||||||
const agentID = {{.Agent.ID}};
|
const agentID = {{.Agent.ID}};
|
||||||
const statusEl = document.getElementById('status');
|
const statusEl = document.getElementById('status');
|
||||||
|
const touch = window.matchMedia('(pointer: coarse)').matches;
|
||||||
|
if (touch) document.body.classList.add('touch');
|
||||||
function setStatus(t, cls) { statusEl.textContent = t; statusEl.className = cls || ''; }
|
function setStatus(t, cls) { statusEl.textContent = t; statusEl.className = cls || ''; }
|
||||||
|
|
||||||
const term = new Terminal({
|
const term = new Terminal({
|
||||||
cursorBlink: true,
|
cursorBlink: true,
|
||||||
fontSize: 14,
|
fontSize: touch ? 13 : 14,
|
||||||
fontFamily: 'ui-monospace, SFMono-Regular, Menlo, monospace',
|
fontFamily: 'ui-monospace, SFMono-Regular, Menlo, monospace',
|
||||||
theme: { background: '#0c0f13' }
|
theme: { background: '#0c0f13' }
|
||||||
});
|
});
|
||||||
@@ -126,17 +162,104 @@
|
|||||||
term.write('\r\n\x1b[33m' + msg + '\x1b[0m\r\n');
|
term.write('\r\n\x1b[33m' + msg + '\x1b[0m\r\n');
|
||||||
}
|
}
|
||||||
|
|
||||||
term.onData(function (data) {
|
function send(data) {
|
||||||
if (closed || !sid) return;
|
if (closed || !sid) return;
|
||||||
const bytes = new TextEncoder().encode(data);
|
const bytes = new TextEncoder().encode(data);
|
||||||
post(base + sid + '/in', { t: 'i', d: bytesToB64(bytes) });
|
post(base + sid + '/in', { t: 'i', d: bytesToB64(bytes) });
|
||||||
|
}
|
||||||
|
// What the keyboard types gets the CTRL / ALT of the extra keys, if armed.
|
||||||
|
term.onData(function (data) { send(withMods(data)); });
|
||||||
|
|
||||||
|
// --- Extra keys (touch screens) ---
|
||||||
|
// CTRL and ALT: a tap arms the key for the next key press, a long press
|
||||||
|
// keeps it on until tapped again.
|
||||||
|
const mods = { ctrl: 0, alt: 0 }; // 0 off, 1 next key, 2 locked
|
||||||
|
const modBtn = {};
|
||||||
|
document.querySelectorAll('#keys [data-mod]').forEach(function (b) { modBtn[b.dataset.mod] = b; });
|
||||||
|
function showMods() {
|
||||||
|
for (const m in mods) {
|
||||||
|
modBtn[m].classList.toggle('on', mods[m] === 1);
|
||||||
|
modBtn[m].classList.toggle('lock', mods[m] === 2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function useMods() {
|
||||||
|
const used = { ctrl: !!mods.ctrl, alt: !!mods.alt };
|
||||||
|
for (const m in mods) if (mods[m] === 1) mods[m] = 0;
|
||||||
|
showMods();
|
||||||
|
return used;
|
||||||
|
}
|
||||||
|
function ctrlChar(c) {
|
||||||
|
if (c === ' ' || c === '@') return '\x00';
|
||||||
|
if (c === '?') return '\x7f';
|
||||||
|
const code = c.toUpperCase().charCodeAt(0);
|
||||||
|
return code >= 64 && code <= 95 ? String.fromCharCode(code & 31) : c;
|
||||||
|
}
|
||||||
|
function withMods(data) {
|
||||||
|
if (!mods.ctrl && !mods.alt) return data;
|
||||||
|
const m = useMods();
|
||||||
|
if (m.ctrl && data.length === 1) data = ctrlChar(data);
|
||||||
|
return m.alt ? '\x1b' + data : data;
|
||||||
|
}
|
||||||
|
// xterm's modifier parameter: 1 + shift(1) + alt(2) + ctrl(4).
|
||||||
|
const CSI = { up: 'A', down: 'B', right: 'C', left: 'D', home: 'H', end: 'F' };
|
||||||
|
const TILDE = { pgup: 5, pgdn: 6 };
|
||||||
|
function keySeq(k) {
|
||||||
|
const m = useMods(), p = 1 + (m.alt ? 2 : 0) + (m.ctrl ? 4 : 0);
|
||||||
|
if (CSI[k]) {
|
||||||
|
if (p > 1) return '\x1b[1;' + p + CSI[k];
|
||||||
|
return (term.modes.applicationCursorKeysMode ? '\x1bO' : '\x1b[') + CSI[k];
|
||||||
|
}
|
||||||
|
if (TILDE[k]) return '\x1b[' + TILDE[k] + (p > 1 ? ';' + p : '') + '~';
|
||||||
|
let s = k === 'esc' ? '\x1b' : k === 'tab' ? '\t' : k;
|
||||||
|
if (m.ctrl && s.length === 1) s = ctrlChar(s);
|
||||||
|
return m.alt ? '\x1b' + s : s;
|
||||||
|
}
|
||||||
|
let press = null;
|
||||||
|
document.querySelectorAll('#keys button').forEach(function (b) {
|
||||||
|
// Keep the focus (and the on-screen keyboard) on the terminal.
|
||||||
|
b.addEventListener('pointerdown', function (e) {
|
||||||
|
e.preventDefault();
|
||||||
|
const mod = b.dataset.mod;
|
||||||
|
if (mod) press = { mod: mod, timer: setTimeout(function () { mods[mod] = 2; press = null; showMods(); }, 450) };
|
||||||
|
});
|
||||||
|
b.addEventListener('pointerup', function (e) {
|
||||||
|
e.preventDefault();
|
||||||
|
const mod = b.dataset.mod;
|
||||||
|
if (mod) {
|
||||||
|
if (press && press.mod === mod) {
|
||||||
|
clearTimeout(press.timer);
|
||||||
|
press = null;
|
||||||
|
mods[mod] = mods[mod] ? 0 : 1;
|
||||||
|
showMods();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
send(keySeq(b.dataset.k));
|
||||||
|
});
|
||||||
|
b.addEventListener('pointercancel', function () { if (press) { clearTimeout(press.timer); press = null; } });
|
||||||
|
b.addEventListener('contextmenu', function (e) { e.preventDefault(); });
|
||||||
});
|
});
|
||||||
|
|
||||||
function sendResize() {
|
function sendResize() {
|
||||||
if (closed || !sid) return;
|
if (closed || !sid) return;
|
||||||
post(base + sid + '/in', { t: 'r', cols: term.cols, rows: term.rows });
|
post(base + sid + '/in', { t: 'r', cols: term.cols, rows: term.rows });
|
||||||
}
|
}
|
||||||
window.addEventListener('resize', function () { fit.fit(); sendResize(); });
|
function refit() {
|
||||||
|
if (touch && window.visualViewport) {
|
||||||
|
// Follow the visible area, so the extra keys sit right above the
|
||||||
|
// on-screen keyboard.
|
||||||
|
const vv = window.visualViewport;
|
||||||
|
document.body.style.top = vv.offsetTop + 'px';
|
||||||
|
document.body.style.height = vv.height + 'px';
|
||||||
|
}
|
||||||
|
fit.fit();
|
||||||
|
}
|
||||||
|
window.addEventListener('resize', refit);
|
||||||
|
if (touch && window.visualViewport) {
|
||||||
|
window.visualViewport.addEventListener('resize', refit);
|
||||||
|
window.visualViewport.addEventListener('scroll', refit);
|
||||||
|
}
|
||||||
|
refit();
|
||||||
term.onResize(sendResize);
|
term.onResize(sendResize);
|
||||||
|
|
||||||
window.addEventListener('pagehide', function () {
|
window.addEventListener('pagehide', function () {
|
||||||
|
|||||||
Reference in new issue
Block a user