From 021da932293cdc5686ac9b37e39da4e06392a0b6 Mon Sep 17 00:00:00 2001 From: iRedTea Date: Thu, 15 Oct 2026 10:56:19 +0300 Subject: [PATCH] feat: user roles - admins and users limited to the machines given to them, user editor with machines, password sign-in and its lock; direct address without Cloudflare as the agents' fallback and in the installer; version 0.1.2 --- README.md | 19 +++ VERSION.txt | 2 +- cmd/agent/main.go | 2 + internal/agent/client.go | 60 +++++-- internal/agent/fsstream.go | 2 +- internal/agent/hosts.go | 170 +++++++++++++++++++ internal/agent/session.go | 4 +- internal/agent/ship.go | 4 +- internal/agent/speed.go | 2 +- internal/agent/task.go | 4 +- internal/agent/update.go | 2 +- internal/agent/wslink.go | 11 +- internal/proto/proto.go | 6 + internal/server/config.go | 19 +++ internal/server/db.go | 149 +++++++++++++--- internal/server/handlers_agent.go | 3 + internal/server/handlers_panel.go | 55 +++++- internal/server/handlers_panel_fwd.go | 4 + internal/server/passkeys.go | 17 +- internal/server/server.go | 67 ++++++-- internal/server/ship.go | 4 + internal/server/users.go | 89 ++++++++++ internal/server/web/install.sh.tmpl | 17 +- internal/server/web/static/account.js | 5 +- internal/server/web/static/users.js | 105 ++++++++++++ internal/server/web/templates/dashboard.html | 9 +- internal/server/web/templates/edit.html | 4 +- internal/server/web/templates/settings.html | 104 ++++++++++-- internal/server/wsagent.go | 2 +- 29 files changed, 844 insertions(+), 97 deletions(-) create mode 100644 internal/agent/hosts.go create mode 100644 internal/server/users.go create mode 100644 internal/server/web/static/users.js diff --git a/README.md b/README.md index 7d9c3fd..8abc9e8 100644 --- a/README.md +++ b/README.md @@ -33,6 +33,15 @@ перезагрузке. На устройстве можно не спрашивать его 5/15/30/60 минут после ввода. Пять неверных попыток - выход из сессии. +## Пользователи и роли +- **Администратор** видит всё: все машины, настройки, пользователей, ufw. +- **Пользователь** видит только выданные ему машины (терминал, файлы, порты, статус, «Изменить» без удаления); + настроек, других пользователей и чужих машин для него нет. + +Роль и машины задаются в «Настройки → Пользователи» (квадратная кнопка у пользователя открывает список машин). +Там же галочка «Пароль» включает или выключает вход по паролю (выключить можно, когда у человека есть ключ доступа), +а «Закрепить» не даёт ему поменять это самому. Последнего администратора нельзя удалить или понизить. + ## Компоненты - **Сервер** (`cmd/server`) - панель и точка встречи для агентов. Ставится за nginx (TLS). - **Агент** (`cmd/agent`) - бинарь для Linux (amd64/arm64), systemd-сервис от root. @@ -69,6 +78,16 @@ на больших выводах); - `proxy_read_timeout` больше 25 с (удержание long-poll), `proxy_buffering off`. +### Прямой адрес (без Cloudflare) +В «Настройки → Адрес хоста» можно задать второй, прямой адрес - например `https://direct.lpra.example.com`, +DNS-запись без проксирования. Тогда: +- агенты, не достучавшись до основного адреса 30 секунд, переходят на прямой и каждые 5 минут проверяют, + не ожил ли основной; уже установленные агенты получают прямой адрес с сервера сами (он хранится в `state.json`); +- установщик (`FALLBACK_HOST`) качает агента с прямого, если основной не отвечает; в «Полных вариантах» + есть короткая команда и через прямой адрес; +- если хост туннеля не задан, агентам отдаётся хост прямого адреса; +- ключи доступа основного домена работают и на его поддомене. + ### Cloudflare Работает за проксирующим Cloudflare: WebSocket он пропускает, файлы идут кусками меньше лимита на запрос, статика с хешем в имени кэшируется. Чтобы панель видела настоящие IP (ограничение diff --git a/VERSION.txt b/VERSION.txt index 17e51c3..d917d3e 100644 --- a/VERSION.txt +++ b/VERSION.txt @@ -1 +1 @@ -0.1.1 +0.1.2 diff --git a/cmd/agent/main.go b/cmd/agent/main.go index fae8377..7384a7b 100644 --- a/cmd/agent/main.go +++ b/cmd/agent/main.go @@ -11,6 +11,7 @@ import ( "os" "os/signal" "strconv" + "strings" "syscall" "github.com/x-grk/longpolling-remote-access/internal/agent" @@ -21,6 +22,7 @@ func main() { host := envOr("HOST", "https://REDACTED:8445") cfg := agent.Config{ Host: host, + Fallback: strings.TrimRight(os.Getenv("FALLBACK_HOST"), "/"), EnrollSecret: os.Getenv("ENROLL_SECRET"), Name: os.Getenv("AGENT_NAME"), StatePath: envOr("STATE_PATH", "/etc/lpagent/state.json"), diff --git a/internal/agent/client.go b/internal/agent/client.go index abaec96..74f8448 100644 --- a/internal/agent/client.go +++ b/internal/agent/client.go @@ -31,6 +31,7 @@ var Version = lpra.Version // Config is the agent's runtime configuration (from env / config.env). type Config struct { Host string // e.g. https://REDACTED:8445 + Fallback string // the direct address, from the installer (FALLBACK_HOST); may be empty EnrollSecret string Name string // optional friendly name StatePath string // where the enrolled id+token are persisted @@ -43,6 +44,8 @@ type Config struct { type state struct { AgentID string `json:"agent_id"` Token string `json:"token"` + // Fallback is the direct address as the server told it. + Fallback string `json:"fallback,omitempty"` } // Agent is a running client. @@ -51,6 +54,8 @@ type Agent struct { http *http.Client stream *http.Client state state + stateMu sync.Mutex + hosts hostState pollCount int // Direct tunnel currently running (see setTunnelAddr). @@ -97,11 +102,20 @@ func (a *Agent) Run(ctx context.Context) error { return err } if a.state.AgentID == "" || a.state.Token == "" { - if err := a.enroll(ctx); err != nil { + err := a.enroll(ctx) + // The main address out of reach at install: the direct one. + if err != nil && a.cfg.Fallback != "" && a.cfg.Fallback != a.cfg.Host { + log.Printf("enroll at %s: %v - trying %s", a.cfg.Host, err, a.cfg.Fallback) + a.setHost(a.cfg.Fallback) + a.hostChanged() + err = a.enroll(ctx) + } + if err != nil { return fmt.Errorf("enroll: %w", err) } } - log.Printf("agent %s connected to %s", a.state.AgentID, a.cfg.Host) + go a.watchMain(ctx) + log.Printf("agent %s connected to %s", a.state.AgentID, a.host()) // Direct forward tunnel (low-latency TCP mux + UDP). Its address comes // from the panel; fall back to the configured one when the server sends @@ -123,6 +137,15 @@ func (a *Agent) Run(ctx context.Context) error { if ctx.Err() != nil { return nil } + // Moved to the other address: the WebSocket goes there at once. + if a.hostChanged() { + backoff = time.Second + continue + } + a.failed() + if a.hostChanged() { + continue + } switch a.mode() { case proto.TransportWS: log.Printf("websocket: %v (retry in %s)", err, backoff) @@ -181,6 +204,11 @@ func (a *Agent) longPoll(ctx context.Context, d time.Duration) { // would sit held on the server first. if lost && d > 0 { if a.reachable(ctx) { + a.reached() + return + } + a.failed() + if a.hostChanged() { return } select { @@ -204,6 +232,10 @@ func (a *Agent) longPoll(ctx context.Context, d time.Duration) { return } if err != nil { + a.failed() + if a.hostChanged() { + return + } log.Printf("poll: %v (retry in %s)", err, backoff) select { case <-time.After(backoff): @@ -214,22 +246,14 @@ func (a *Agent) longPoll(ctx context.Context, d time.Duration) { lost = true continue } + a.reached() backoff = time.Second } } // reachable reports whether the server answers a quick request. func (a *Agent) reachable(ctx context.Context) bool { - ctx, cancel := context.WithTimeout(ctx, 10*time.Second) - defer cancel() - req, _ := http.NewRequestWithContext(ctx, http.MethodGet, a.cfg.Host+"/api/agent/config", nil) - a.authHeaders(req) - resp, err := a.http.Do(req) - if err != nil { - return false - } - resp.Body.Close() - return resp.StatusCode < 300 + return a.reachableAt(ctx, a.host()) } // retryCap bounds the wait between attempts to reach the server, so a @@ -246,7 +270,7 @@ func (a *Agent) enroll(ctx context.Context) error { Version: Version, User: currentUser(), }) - req, _ := http.NewRequestWithContext(ctx, http.MethodPost, a.cfg.Host+"/api/agent/enroll", bytes.NewReader(body)) + req, _ := http.NewRequestWithContext(ctx, http.MethodPost, a.host()+"/api/agent/enroll", bytes.NewReader(body)) req.Header.Set("X-Enroll-Secret", a.cfg.EnrollSecret) req.Header.Set("Content-Type", "application/json") resp, err := a.http.Do(req) @@ -270,7 +294,7 @@ func (a *Agent) enroll(ctx context.Context) error { // The poll itself runs on reqCtx, which may end early; what it starts runs on // ctx. func (a *Agent) pollOnce(ctx, reqCtx context.Context) error { - req, _ := http.NewRequestWithContext(reqCtx, http.MethodGet, a.cfg.Host+"/api/agent/poll", nil) + req, _ := http.NewRequestWithContext(reqCtx, http.MethodGet, a.host()+"/api/agent/poll", nil) a.authHeaders(req) // Report the version and our OS user occasionally (not every poll), so the // panel can flag outdated agents without extra traffic. @@ -295,6 +319,7 @@ func (a *Agent) pollOnce(ctx, reqCtx context.Context) error { return err } a.applyTunnelHeader(ctx, resp.Header) + a.learnFallback(resp.Header.Get(proto.FallbackHeader)) a.applyTransport(resp.Header.Get(proto.TransportHeader)) a.dispatch(ctx, msg) return nil @@ -322,7 +347,7 @@ func (a *Agent) dispatch(ctx context.Context, msg proto.ControlMessage) { func (a *Agent) fetchConfig(ctx context.Context) bool { ctx2, cancel := context.WithTimeout(ctx, 10*time.Second) defer cancel() - req, _ := http.NewRequestWithContext(ctx2, http.MethodGet, a.cfg.Host+"/api/agent/config", nil) + req, _ := http.NewRequestWithContext(ctx2, http.MethodGet, a.host()+"/api/agent/config", nil) a.authHeaders(req) resp, err := a.http.Do(req) if err != nil { @@ -330,6 +355,7 @@ func (a *Agent) fetchConfig(ctx context.Context) bool { } defer resp.Body.Close() a.applyTransport(resp.Header.Get(proto.TransportHeader)) + a.learnFallback(resp.Header.Get(proto.FallbackHeader)) return a.applyTunnelHeader(ctx, resp.Header) } @@ -348,7 +374,7 @@ func (a *Agent) applyTunnelAddr(ctx context.Context, v string) bool { return false } if host == "" { - u, err := url.Parse(a.cfg.Host) + u, err := url.Parse(a.host()) if err != nil || u.Hostname() == "" { return false } @@ -403,7 +429,9 @@ func (a *Agent) saveState() error { if err := os.MkdirAll(filepath.Dir(a.cfg.StatePath), 0o700); err != nil { return err } + a.stateMu.Lock() b, _ := json.MarshalIndent(a.state, "", " ") + a.stateMu.Unlock() return os.WriteFile(a.cfg.StatePath, b, 0o600) } diff --git a/internal/agent/fsstream.go b/internal/agent/fsstream.go index 0e3e413..bdaeccc 100644 --- a/internal/agent/fsstream.go +++ b/internal/agent/fsstream.go @@ -41,7 +41,7 @@ type errXferGone string func (e errXferGone) Error() string { return string(e) } func (a *Agent) xferURL(id string, i int) string { - return a.cfg.Host + "/api/agent/fs/xfer/" + id + "/" + strconv.Itoa(i) + return a.host() + "/api/agent/fs/xfer/" + id + "/" + strconv.Itoa(i) } // xferRequest makes one request for a piece, trying again over a failing link. diff --git a/internal/agent/hosts.go b/internal/agent/hosts.go new file mode 100644 index 0000000..4a23602 --- /dev/null +++ b/internal/agent/hosts.go @@ -0,0 +1,170 @@ +package agent + +import ( + "context" + "log" + "net/http" + "strings" + "sync" + "time" + + "github.com/x-grk/longpolling-remote-access/internal/proto" +) + +// The server can be reached at two addresses: the main one (behind +// Cloudflare, say) and a direct one that goes to the server itself. The +// direct one comes from the installer or, for agents installed before it was +// set, from the server, and is kept in the state file. When the address in +// use has been out of reach for a while the agent tries the other; on the +// direct one it keeps checking the main one and goes back when it answers. + +const ( + // switchAfter is how long the address in use may fail before the other + // is tried. + switchAfter = 30 * time.Second + // mainCheck is how often the main address is checked from the direct one. + mainCheck = 5 * time.Minute +) + +type hostState struct { + mu sync.Mutex + cur string + downSince time.Time + changed bool // the address changed: the WebSocket goes there at once +} + +// host is the server address in use now. +func (a *Agent) host() string { + a.hosts.mu.Lock() + defer a.hosts.mu.Unlock() + if a.hosts.cur == "" { + return a.cfg.Host + } + return a.hosts.cur +} + +// fallback is the direct address: the installer's, else the one the server +// told. +func (a *Agent) fallback() string { + if a.cfg.Fallback != "" { + return a.cfg.Fallback + } + a.stateMu.Lock() + defer a.stateMu.Unlock() + return a.state.Fallback +} + +// learnFallback keeps the direct address the server tells. +func (a *Agent) learnFallback(v string) { + v = strings.TrimRight(strings.TrimSpace(v), "/") + if v == "" || a.cfg.Fallback != "" { + return + } + a.stateMu.Lock() + same := a.state.Fallback == v + if !same { + a.state.Fallback = v + } + a.stateMu.Unlock() + if !same { + log.Printf("direct address: %s", v) + _ = a.saveState() + } +} + +// setHost moves to addr: requests go there from now on, and the WebSocket +// is dropped to reconnect there. +func (a *Agent) setHost(addr string) { + a.hosts.mu.Lock() + if a.hosts.cur == addr || (a.hosts.cur == "" && addr == a.cfg.Host) { + a.hosts.mu.Unlock() + return + } + a.hosts.cur, a.hosts.downSince, a.hosts.changed = addr, time.Time{}, true + a.hosts.mu.Unlock() + log.Printf("server address: %s", addr) + if l := a.link.Load(); l != nil { + l.close() + } +} + +// hostChanged reports, once, that the address changed. +func (a *Agent) hostChanged() bool { + a.hosts.mu.Lock() + defer a.hosts.mu.Unlock() + c := a.hosts.changed + a.hosts.changed = false + return c +} + +// reached notes the server answered; failed notes it did not, and after +// switchAfter of that moves to the other address. +func (a *Agent) reached() { + a.hosts.mu.Lock() + a.hosts.downSince = time.Time{} + a.hosts.mu.Unlock() +} + +func (a *Agent) failed() { + fb := a.fallback() + if fb == "" || fb == a.cfg.Host { + return + } + a.hosts.mu.Lock() + now := time.Now() + if a.hosts.downSince.IsZero() { + a.hosts.downSince = now + a.hosts.mu.Unlock() + return + } + long := now.Sub(a.hosts.downSince) >= switchAfter + cur := a.hosts.cur + a.hosts.mu.Unlock() + if !long { + return + } + next := fb + if cur == fb { + next = a.cfg.Host + } + log.Printf("server out of reach for %s, trying %s", switchAfter, next) + a.setHost(next) +} + +// watchMain moves back to the main address once it answers again. +func (a *Agent) watchMain(ctx context.Context) { + t := time.NewTicker(mainCheck) + defer t.Stop() + for { + select { + case <-ctx.Done(): + return + case <-t.C: + } + if a.host() == a.cfg.Host { + continue + } + if a.reachableAt(ctx, a.cfg.Host) { + log.Printf("main address answers again") + a.setHost(a.cfg.Host) + } + } +} + +// reachableAt reports whether the server answers a quick request at base. +func (a *Agent) reachableAt(ctx context.Context, base string) bool { + ctx, cancel := context.WithTimeout(ctx, 10*time.Second) + defer cancel() + req, _ := http.NewRequestWithContext(ctx, http.MethodGet, base+"/api/agent/config", nil) + a.authHeaders(req) + resp, err := a.http.Do(req) + if err != nil { + return false + } + resp.Body.Close() + if resp.StatusCode < 300 { + a.learnFallback(resp.Header.Get(proto.FallbackHeader)) + return true + } + return false +} diff --git a/internal/agent/session.go b/internal/agent/session.go index 7f4b77f..ab2e194 100644 --- a/internal/agent/session.go +++ b/internal/agent/session.go @@ -108,7 +108,7 @@ func (a *Agent) pollIn(ctx context.Context, sid string, w io.Writer, onResize fu } } } - req, _ := http.NewRequestWithContext(ctx, http.MethodGet, a.cfg.Host+"/api/agent/term/"+sid+"/in", nil) + req, _ := http.NewRequestWithContext(ctx, http.MethodGet, a.host()+"/api/agent/term/"+sid+"/in", nil) a.authHeaders(req) resp, err := a.http.Do(req) if err != nil { @@ -156,7 +156,7 @@ func (a *Agent) postOut(ctx context.Context, sid string, msg proto.OutMessage) b } } body, _ := json.Marshal(msg) - req, _ := http.NewRequestWithContext(ctx, http.MethodPost, a.cfg.Host+"/api/agent/term/"+sid+"/out", bytes.NewReader(body)) + req, _ := http.NewRequestWithContext(ctx, http.MethodPost, a.host()+"/api/agent/term/"+sid+"/out", bytes.NewReader(body)) a.authHeaders(req) req.Header.Set("Content-Type", "application/json") resp, err := a.http.Do(req) diff --git a/internal/agent/ship.go b/internal/agent/ship.go index 178fb5b..05cfaba 100644 --- a/internal/agent/ship.go +++ b/internal/agent/ship.go @@ -52,7 +52,7 @@ func (a *Agent) reportShip(ctx context.Context, job string, m *shipMeter, cancel defer t.Stop() for { body, _ := json.Marshal(m.snapshot()) - req, _ := http.NewRequestWithContext(ctx, http.MethodPost, a.cfg.Host+"/api/agent/fs/job/"+job+"/progress", bytes.NewReader(body)) + req, _ := http.NewRequestWithContext(ctx, http.MethodPost, a.host()+"/api/agent/fs/job/"+job+"/progress", bytes.NewReader(body)) a.authHeaders(req) req.Header.Set("Content-Type", "application/json") if resp, err := a.http.Do(req); err == nil { @@ -213,7 +213,7 @@ func (a *Agent) doShipOut(parent context.Context, env proto.TaskEnvelope) proto. } func (a *Agent) relayURL(id string, i int) string { - return a.cfg.Host + "/api/agent/fs/relay/" + id + "/" + strconv.Itoa(i) + return a.host() + "/api/agent/fs/relay/" + id + "/" + strconv.Itoa(i) } // shipRelay posts the tar stream in pieces, a few at once; the last carries diff --git a/internal/agent/speed.go b/internal/agent/speed.go index ccb6409..b45ce02 100644 --- a/internal/agent/speed.go +++ b/internal/agent/speed.go @@ -119,7 +119,7 @@ func (a *Agent) speedDown(ctx context.Context, path string, window time.Duration } 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.host()+path, body) if err != nil { return nil, err } diff --git a/internal/agent/task.go b/internal/agent/task.go index bbf58c2..337ef55 100644 --- a/internal/agent/task.go +++ b/internal/agent/task.go @@ -38,7 +38,7 @@ func (a *Agent) runTask(ctx context.Context, taskID string) { func (a *Agent) fetchTask(ctx context.Context, taskID string) (proto.TaskEnvelope, error) { var env proto.TaskEnvelope - req, _ := http.NewRequestWithContext(ctx, http.MethodGet, a.cfg.Host+"/api/agent/task/"+taskID, nil) + req, _ := http.NewRequestWithContext(ctx, http.MethodGet, a.host()+"/api/agent/task/"+taskID, nil) a.authHeaders(req) resp, err := a.http.Do(req) if err != nil { @@ -54,7 +54,7 @@ func (a *Agent) fetchTask(ctx context.Context, taskID string) (proto.TaskEnvelop func (a *Agent) postResult(ctx context.Context, taskID string, res proto.TaskResult) { body, _ := json.Marshal(res) - req, _ := http.NewRequestWithContext(ctx, http.MethodPost, a.cfg.Host+"/api/agent/task/"+taskID+"/result", bytes.NewReader(body)) + req, _ := http.NewRequestWithContext(ctx, http.MethodPost, a.host()+"/api/agent/task/"+taskID+"/result", bytes.NewReader(body)) a.authHeaders(req) req.Header.Set("Content-Type", "application/json") resp, err := a.http.Do(req) diff --git a/internal/agent/update.go b/internal/agent/update.go index d7bc1d6..f685448 100644 --- a/internal/agent/update.go +++ b/internal/agent/update.go @@ -34,7 +34,7 @@ func (a *Agent) doUpdate() proto.TaskResult { sudo = sudoPath() } - url := fmt.Sprintf("%s/download/agent-%s-%s", a.cfg.Host, runtime.GOOS, runtime.GOARCH) + url := fmt.Sprintf("%s/download/agent-%s-%s", a.host(), runtime.GOOS, runtime.GOARCH) req, _ := http.NewRequestWithContext(context.Background(), http.MethodGet, url, nil) resp, err := a.stream.Do(req) if err != nil { diff --git a/internal/agent/wslink.go b/internal/agent/wslink.go index 55d672b..9bb1d12 100644 --- a/internal/agent/wslink.go +++ b/internal/agent/wslink.go @@ -30,8 +30,13 @@ type wsLink struct { subs map[string]chan wsIn asked map[string]bool // sessions whose input was asked for done chan struct{} + stop context.CancelFunc } +// close drops the link: the agent then connects again, to the address in +// use by then. +func (l *wsLink) close() { l.stop() } + // wsIn is what the server sent for a session: its input, or that it is over. type wsIn struct { frames []proto.InFrame @@ -95,7 +100,7 @@ func (l *wsLink) deliver(sid string, in wsIn) { // runWS holds the WebSocket until it ends, dispatching what comes on it. func (a *Agent) runWS(ctx context.Context) error { - u := a.cfg.Host + "/api/agent/ws" + u := a.host() + "/api/agent/ws" u = "ws" + strings.TrimPrefix(u, "http") hdr := http.Header{} hdr.Set("X-Agent-Id", a.state.AgentID) @@ -116,7 +121,7 @@ func (a *Agent) runWS(ctx context.Context) error { } c.SetReadLimit(8 << 20) lctx, lcancel := context.WithCancel(ctx) - l := &wsLink{c: c, ctx: lctx, subs: map[string]chan wsIn{}, done: make(chan struct{})} + l := &wsLink{c: c, ctx: lctx, subs: map[string]chan wsIn{}, done: make(chan struct{}), stop: lcancel} a.link.Store(l) defer func() { a.link.CompareAndSwap(l, nil) @@ -125,6 +130,7 @@ func (a *Agent) runWS(ctx context.Context) error { c.CloseNow() }() log.Printf("connected by websocket") + a.reached() go func() { t := time.NewTicker(wsPing) defer t.Stop() @@ -159,6 +165,7 @@ func (a *Agent) runWS(ctx context.Context) error { } case "cfg": a.applyTunnelAddr(ctx, m.Tunnel) + a.learnFallback(m.Fallback) a.applyTransport(m.Transport) if a.mode() == proto.TransportLP { return errors.New("set to long polling") diff --git a/internal/proto/proto.go b/internal/proto/proto.go index 525b3a4..c401a71 100644 --- a/internal/proto/proto.go +++ b/internal/proto/proto.go @@ -72,6 +72,11 @@ const ( // answers. const TransportHeader = "X-Transport" +// FallbackHeader carries the server's direct address (one not behind a +// proxy like Cloudflare) on poll and config answers, for the agent to turn +// to when the main one is out of reach. +const FallbackHeader = "X-Fallback-Host" + // WSMsg is one message on an agent's WebSocket, either way. Server to agent: // "ctrl" (Ctrl, as a control poll would answer), "cfg" (Tunnel and // Transport, also a keep-alive), "in" (Frames for session SID), "gone" @@ -85,6 +90,7 @@ type WSMsg struct { Out *OutMessage `json:"out,omitempty"` Tunnel string `json:"tunnel,omitempty"` Transport string `json:"transport,omitempty"` + Fallback string `json:"fallback,omitempty"` } // Terminal / stream frame types on the session "in" channel (server -> agent). diff --git a/internal/server/config.go b/internal/server/config.go index 5813502..7b2dc9b 100644 --- a/internal/server/config.go +++ b/internal/server/config.go @@ -3,6 +3,7 @@ package server import ( "crypto/rand" "encoding/hex" + "net/url" "strconv" lpra "github.com/x-grk/longpolling-remote-access" @@ -18,6 +19,7 @@ const ( cfgUfwEnabled = "ufw_enabled" cfgTunnelHost = "tunnel_host" cfgTunnelPort = "tunnel_port" + cfgDirectAddr = "direct_address" ) // DefaultHostAddress is the public URL agents dial by default. It is baked into @@ -68,6 +70,23 @@ func (db *DB) HostAddress() string { return v } +// DirectAddress is the server's direct URL, one that is not behind a proxy +// like Cloudflare: the agents' fallback, and a way to install when the main +// address is out of reach. Empty when not set. +func (db *DB) DirectAddress() string { + v, _ := db.GetConfig(cfgDirectAddr) + return v +} + +// directHost is the direct address's host name, without scheme and port. +func directHost(addr string) string { + u, err := url.Parse(addr) + if err != nil { + return "" + } + return u.Hostname() +} + func (db *DB) EnrollSecret() string { v, _ := db.GetConfig(cfgEnrollSecret) return v diff --git a/internal/server/db.go b/internal/server/db.go index b5c58a0..73809c3 100644 --- a/internal/server/db.go +++ b/internal/server/db.go @@ -120,6 +120,12 @@ CREATE TABLE IF NOT EXISTS forward_rules ( `ALTER TABLE admins ADD COLUMN password_off INTEGER NOT NULL DEFAULT 0`, `ALTER TABLE agents ADD COLUMN transport TEXT NOT NULL DEFAULT 'auto'`, `ALTER TABLE admins ADD COLUMN pin_hash TEXT NOT NULL DEFAULT ''`, + `ALTER TABLE admins ADD COLUMN role TEXT NOT NULL DEFAULT 'admin'`, + `ALTER TABLE admins ADD COLUMN pw_locked INTEGER NOT NULL DEFAULT 0`, + `CREATE TABLE IF NOT EXISTS admin_agents ( + admin_id INTEGER NOT NULL REFERENCES admins(id) ON DELETE CASCADE, + agent_id TEXT NOT NULL, + PRIMARY KEY (admin_id, agent_id))`, } { if _, err := db.sql.Exec(alter); err != nil && !strings.Contains(err.Error(), "duplicate column") { return err @@ -155,24 +161,56 @@ func (db *DB) CountAdmins() (int, error) { } func (db *DB) CreateAdmin(username, passHash string) error { - _, err := db.sql.Exec( - `INSERT INTO admins(username, pass_hash, created_at) VALUES(?, ?, ?)`, - username, passHash, time.Now().Unix()) + _, err := db.CreateUser(username, passHash, RoleAdmin) return err } -// Admin is one row of the admins table. +// CreateUser adds a panel user with the role given and returns its id. +func (db *DB) CreateUser(username, passHash, role string) (int64, error) { + res, err := db.sql.Exec( + `INSERT INTO admins(username, pass_hash, created_at, role) VALUES(?, ?, ?, ?)`, + username, passHash, time.Now().Unix(), role) + if err != nil { + return 0, err + } + return res.LastInsertId() +} + +// Roles of panel users: an admin sees and runs everything; a user only the +// machines given to them, and none of the settings. +const ( + RoleAdmin = "admin" + RoleUser = "user" +) + +// Admin is one row of the admins table: a panel user, an admin or not. type Admin struct { ID int64 Username string PassHash string + Role string + // PwLocked keeps the user from turning the password sign-in on or off + // themselves: an admin set it. + PwLocked bool +} + +// IsAdmin reports whether the user has the run of the whole panel. +func (a *Admin) IsAdmin() bool { return a != nil && a.Role != RoleUser } + +const adminCols = `id, username, pass_hash, role, pw_locked` + +func scanAdmin(row interface{ Scan(...any) error }) (*Admin, error) { + a := &Admin{} + var locked int + if err := row.Scan(&a.ID, &a.Username, &a.PassHash, &a.Role, &locked); err != nil { + return nil, err + } + a.PwLocked = locked == 1 + return a, nil } func (db *DB) AdminByUsername(username string) (*Admin, error) { - a := &Admin{} - err := db.sql.QueryRow( - `SELECT id, username, pass_hash FROM admins WHERE username = ?`, username). - Scan(&a.ID, &a.Username, &a.PassHash) + a, err := scanAdmin(db.sql.QueryRow(`SELECT `+adminCols+` FROM admins WHERE username = ?`, username)) if err == sql.ErrNoRows { return nil, nil } @@ -180,22 +218,87 @@ func (db *DB) AdminByUsername(username string) (*Admin, error) { } func (db *DB) ListAdmins() ([]Admin, error) { - rows, err := db.sql.Query(`SELECT id, username FROM admins ORDER BY username`) + rows, err := db.sql.Query(`SELECT ` + adminCols + ` FROM admins ORDER BY role, username`) if err != nil { return nil, err } defer rows.Close() var out []Admin for rows.Next() { - var a Admin - if err := rows.Scan(&a.ID, &a.Username); err != nil { + a, err := scanAdmin(rows) + if err != nil { return nil, err } - out = append(out, a) + out = append(out, *a) } return out, rows.Err() } +// CountRole is how many users have the role. +func (db *DB) CountRole(role string) int { + var n int + _ = db.sql.QueryRow(`SELECT COUNT(*) FROM admins WHERE role = ?`, role).Scan(&n) + return n +} + +func (db *DB) SetUserRole(id int64, role string) error { + _, err := db.sql.Exec(`UPDATE admins SET role = ? WHERE id = ?`, role, id) + return err +} + +func (db *DB) SetPwLocked(id int64, locked bool) error { + _, err := db.sql.Exec(`UPDATE admins SET pw_locked = ? WHERE id = ?`, boolInt(locked), id) + return err +} + +// UserAgents are the machines a user may use. +func (db *DB) UserAgents(adminID int64) map[string]bool { + out := map[string]bool{} + rows, err := db.sql.Query(`SELECT agent_id FROM admin_agents WHERE admin_id = ?`, adminID) + if err != nil { + return out + } + defer rows.Close() + for rows.Next() { + var id string + if rows.Scan(&id) == nil { + out[id] = true + } + } + return out +} + +// SetUserAgents gives the user exactly these machines. +func (db *DB) SetUserAgents(adminID int64, agentIDs []string) error { + tx, err := db.sql.Begin() + if err != nil { + return err + } + defer tx.Rollback() + if _, err := tx.Exec(`DELETE FROM admin_agents WHERE admin_id = ?`, adminID); err != nil { + return err + } + for _, id := range agentIDs { + if _, err := tx.Exec(`INSERT OR IGNORE INTO admin_agents(admin_id, agent_id) VALUES(?, ?)`, adminID, id); err != nil { + return err + } + } + return tx.Commit() +} + +// CanUseAgent reports whether the user may use the machine. +func (db *DB) CanUseAgent(a *Admin, agentID string) bool { + if a == nil { + return false + } + if a.IsAdmin() { + return true + } + var n int + _ = db.sql.QueryRow(`SELECT COUNT(*) FROM admin_agents WHERE admin_id = ? AND agent_id = ?`, a.ID, agentID).Scan(&n) + return n > 0 +} + func (db *DB) DeleteAdmin(id int64) error { _, err := db.sql.Exec(`DELETE FROM admins WHERE id = ?`, id) return err @@ -214,9 +317,7 @@ type AdminKey struct { } func (db *DB) AdminByID(id int64) (*Admin, error) { - a := &Admin{} - err := db.sql.QueryRow(`SELECT id, username, pass_hash FROM admins WHERE id = ?`, id). - Scan(&a.ID, &a.Username, &a.PassHash) + a, err := scanAdmin(db.sql.QueryRow(`SELECT `+adminCols+` FROM admins WHERE id = ?`, id)) if err == sql.ErrNoRows { return nil, nil } @@ -338,12 +439,10 @@ func (db *DB) CreateSession(id string, adminID int64, ttl time.Duration) error { // SessionAdmin returns the admin for a live (non-expired) session, or nil. func (db *DB) SessionAdmin(sessionID string) (*Admin, error) { - a := &Admin{} - err := db.sql.QueryRow( - `SELECT a.id, a.username, a.pass_hash + a, err := scanAdmin(db.sql.QueryRow( + `SELECT a.id, a.username, a.pass_hash, a.role, a.pw_locked FROM admin_sessions s JOIN admins a ON a.id = s.admin_id - WHERE s.id = ? AND s.expires_at > ?`, sessionID, time.Now().Unix()). - Scan(&a.ID, &a.Username, &a.PassHash) + WHERE s.id = ? AND s.expires_at > ?`, sessionID, time.Now().Unix())) if err == sql.ErrNoRows { return nil, nil } @@ -455,6 +554,9 @@ func (db *DB) SetAgentLoginUser(id, user string) error { func (db *DB) DeleteAgent(id string) error { _, err := db.sql.Exec(`DELETE FROM agents WHERE id = ?`, id) + if err == nil { + _, _ = db.sql.Exec(`DELETE FROM admin_agents WHERE agent_id = ?`, id) + } return err } @@ -631,3 +733,10 @@ func (db *DB) Audit(adminID int64, agentID, action string) { `INSERT INTO audit(admin_id, agent_id, action, at) VALUES(?, ?, ?, ?)`, adminID, agentID, action, time.Now().Unix()) } + +func boolInt(b bool) int { + if b { + return 1 + } + return 0 +} diff --git a/internal/server/handlers_agent.go b/internal/server/handlers_agent.go index 0873b10..9dddd3b 100644 --- a/internal/server/handlers_agent.go +++ b/internal/server/handlers_agent.go @@ -67,6 +67,9 @@ func (s *Server) setTunnelHeader(w http.ResponseWriter) { if addr := s.tunnelAdvert(); addr != "" { w.Header().Set(tunnelAddrHeader, addr) } + if d := s.db.DirectAddress(); d != "" { + w.Header().Set(proto.FallbackHeader, d) + } } // GET /api/agent/config - settings an agent picks up when it connects, without diff --git a/internal/server/handlers_panel.go b/internal/server/handlers_panel.go index 70bf5a6..9936a3d 100644 --- a/internal/server/handlers_panel.go +++ b/internal/server/handlers_panel.go @@ -121,15 +121,20 @@ func (s *Server) handleDashboard(w http.ResponseWriter, r *http.Request) { Online bool Seen string } + me := s.currentAdmin(r) rows := make([]row, 0, len(agents)) for _, a := range agents { + if !s.db.CanUseAgent(me, a.ID) { + continue + } 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), - "Agents": rows, - "CSRF": csrfToken(r), + "Admin": me, + "IsAdmin": me.IsAdmin(), + "Agents": rows, + "CSRF": csrfToken(r), }) } @@ -158,8 +163,12 @@ func (s *Server) handlePresence(w http.ResponseWriter, r *http.Request) { Online bool `json:"online"` Seen string `json:"seen"` } + me := s.currentAdmin(r) out := make([]item, 0, len(agents)) for _, a := range agents { + if !s.db.CanUseAgent(me, a.ID) { + continue + } on := s.online(&a) out = append(out, item{ID: a.ID, Name: a.Name, Hostname: a.Hostname, User: a.TerminalUser(), Online: on, Seen: seenText(on, a.LastSeen)}) } @@ -207,6 +216,7 @@ func (s *Server) handleAgentEdit(w http.ResponseWriter, r *http.Request) { } data := map[string]any{ "Agent": a, + "IsAdmin": s.currentAdmin(r).IsAdmin(), "CSRF": csrfToken(r), "ServerVersion": Version, "Outdated": a.Version != "" && a.Version != Version, @@ -409,12 +419,15 @@ func (s *Server) handleAgentRename(w http.ResponseWriter, r *http.Request) { func (s *Server) handleSettings(w http.ResponseWriter, r *http.Request) { admins, _ := s.db.ListAdmins() - keyCounts, passOff := map[int64]int{}, map[int64]bool{} + keyCounts, passOff, agentCounts := map[int64]int{}, map[int64]bool{}, map[int64]int{} for _, a := range admins { keyCounts[a.ID] = s.db.CountAdminKeys(a.ID) passOff[a.ID] = s.db.PasswordOff(a.ID) + agentCounts[a.ID] = len(s.db.UserAgents(a.ID)) } + allAgents, _ := s.db.ListAgents() host := s.db.HostAddress() + direct := s.db.DirectAddress() secret := s.db.EnrollSecret() code := s.db.InstallCode() ufwInstalled, ufwActive, ufwRunnable := s.ufw.status() @@ -422,10 +435,16 @@ func (s *Server) handleSettings(w http.ResponseWriter, r *http.Request) { s.render(w, "settings.html", map[string]any{ "Admin": s.currentAdmin(r), "Host": host, + "Direct": direct, + "DirectShort": fmt.Sprintf("wget -qO- %s/i/%s | sudo sh", direct, code), + "DirectCurl": fmt.Sprintf("curl -fsSL %s/install.sh | sudo HOST=%s FALLBACK_HOST=%s ENROLL_SECRET=%s sh", direct, host, direct, secret), "Secret": secret, "CodeLen": s.db.InstallCodeLen(), "Admins": admins, "KeyCounts": keyCounts, + "AgentCounts": agentCounts, + "AllAgents": allAgents, + "Me": s.currentAdmin(r), "PassOff": passOff, "CSRF": csrfToken(r), "InstallShort": fmt.Sprintf("wget -qO- %s/i/%s | sudo sh", host, code), @@ -444,6 +463,7 @@ func (s *Server) handleSettings(w http.ResponseWriter, r *http.Request) { "TunnelCustom": s.db.TunnelPort() > 0, "Error": map[string]string{ "tunnel_host": "Некорректный хост туннеля: нужен IP или имя без схемы и порта.", + "host": "Некорректный адрес: нужен URL вида https://panel.example.com, без пути.", "tunnel_port": "Некорректный порт туннеля.", "ufw_port": "Некорректное правило ufw: нужен порт 1-65535 и протокол tcp или udp.", "ufw_off": "Правило ufw не добавлено: ufw выключен или интеграция с ним отключена.", @@ -517,9 +537,20 @@ func (s *Server) handleSetHost(w http.ResponseWriter, r *http.Request) { return } host := strings.TrimRight(strings.TrimSpace(r.FormValue("host")), "/") + direct := strings.TrimRight(strings.TrimSpace(r.FormValue("direct")), "/") + for _, v := range []string{host, direct} { + if v == "" { + continue + } + if u, err := url.Parse(v); err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" || (u.Path != "" && u.Path != "/") { + http.Redirect(w, r, "/settings?err=host", http.StatusSeeOther) + return + } + } if host != "" { _ = s.db.SetConfig(cfgHostAddress, host) } + _ = s.db.SetConfig(cfgDirectAddr, direct) http.Redirect(w, r, "/settings", http.StatusSeeOther) } @@ -556,12 +587,21 @@ func (s *Server) handleAddAdmin(w http.ResponseWriter, r *http.Request) { } username := strings.TrimSpace(r.FormValue("username")) pw := r.FormValue("password") + role := RoleAdmin + if r.FormValue("role") == RoleUser { + role = RoleUser + } if username != "" && len(pw) >= 8 { if hash, err := hashPassword(pw); err == nil { - _ = s.db.CreateAdmin(username, hash) + if id, err := s.db.CreateUser(username, hash, role); err == nil && role == RoleUser { + _ = s.db.SetUserAgents(id, r.Form["agents"]) + } + if me := s.currentAdmin(r); me != nil { + s.db.Audit(me.ID, "", "user_add:"+username+":"+role) + } } } - http.Redirect(w, r, "/settings", http.StatusSeeOther) + http.Redirect(w, r, "/settings#users", http.StatusSeeOther) } func (s *Server) handleDeleteAdmin(w http.ResponseWriter, r *http.Request) { @@ -571,7 +611,7 @@ func (s *Server) handleDeleteAdmin(w http.ResponseWriter, r *http.Request) { } id, _ := strconv.ParseInt(r.FormValue("id"), 10, 64) // Never delete the last admin. - if n, _ := s.db.CountAdmins(); n > 1 { + if u, _ := s.db.AdminByID(id); u != nil && (!u.IsAdmin() || s.db.CountRole(RoleAdmin) > 1) { _ = s.db.DeleteAdmin(id) } http.Redirect(w, r, "/settings", http.StatusSeeOther) @@ -654,6 +694,7 @@ func (s *Server) renderInstall(w http.ResponseWriter, secret string) { w.Header().Set("Content-Type", "text/x-shellscript; charset=utf-8") if err := s.installTmpl.Execute(w, map[string]any{ "Host": s.db.HostAddress(), + "Direct": s.db.DirectAddress(), "Secret": secret, }); err != nil { log.Printf("install.sh render: %v", err) diff --git a/internal/server/handlers_panel_fwd.go b/internal/server/handlers_panel_fwd.go index 6a8202f..8dc590e 100644 --- a/internal/server/handlers_panel_fwd.go +++ b/internal/server/handlers_panel_fwd.go @@ -114,6 +114,10 @@ func (s *Server) handleForwardDelete(w http.ResponseWriter, r *http.Request) { } rid, _ := strconv.ParseInt(r.PathValue("rid"), 10, 64) rule, _ := s.db.GetForwardRule(rid) + if rule == nil || !s.db.CanUseAgent(s.currentAdmin(r), rule.AgentID) { + http.NotFound(w, r) + return + } s.removeForward(rid) dest := "/" if rule != nil { diff --git a/internal/server/passkeys.go b/internal/server/passkeys.go index 2df064e..202ebe0 100644 --- a/internal/server/passkeys.go +++ b/internal/server/passkeys.go @@ -75,12 +75,20 @@ func (c *challenges) take(id string, adminID int64) bool { return ok && ch.adminID == adminID && time.Now().Before(ch.expires) } -// rpID is the site the keys belong to: the host the browser is on. +// mainHost is the main address's host name, set when the server starts. +var mainHost = func() string { return "" } + +// rpID is the site the keys belong to: the host the browser is on, or the +// main address's host when the browser is on it or under it - so the keys +// made on lpra.example.com also sign in on direct.lpra.example.com. func rpID(r *http.Request) string { host := r.Host if h, _, err := net.SplitHostPort(host); err == nil { host = h } + if m := mainHost(); m != "" && strings.HasSuffix(host, "."+m) { + return m + } return host } @@ -353,7 +361,8 @@ func (s *Server) handleAccount(w http.ResponseWriter, r *http.Request) { for _, k := range keys { list = append(list, map[string]any{"id": k.ID, "name": k.Name, "created": k.CreatedAt, "last_used": k.LastUsed}) } - writeJSON(w, map[string]any{"username": a.Username, "keys": list, "password_off": s.db.PasswordOff(a.ID), "pin": s.db.AdminPin(a.ID) != ""}) + writeJSON(w, map[string]any{"username": a.Username, "keys": list, "password_off": s.db.PasswordOff(a.ID), "pin": s.db.AdminPin(a.ID) != "", + "pw_locked": a.PwLocked, "admin": a.IsAdmin()}) } // POST /api/panel/account/password/change - a new password, given the @@ -406,6 +415,10 @@ func (s *Server) handlePasswordToggle(w http.ResponseWriter, r *http.Request) { writeJSON(w, map[string]any{"ok": false, "error": "плохой запрос"}) return } + if a.PwLocked { + writeJSON(w, map[string]any{"ok": false, "error": "вход по паролю закрепил администратор"}) + return + } if in.Off && s.db.CountAdminKeys(a.ID) == 0 { writeJSON(w, map[string]any{"ok": false, "error": "сначала добавьте ключ доступа"}) return diff --git a/internal/server/server.go b/internal/server/server.go index 5c6fd00..4350e85 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -71,6 +71,7 @@ func New(opts Options) (*Server, error) { if err := opts.DB.ensureDefaults(); err != nil { return nil, err } + mainHost = func() string { return directHost(opts.DB.HostAddress()) } staticFS, _ := fs.Sub(webFS, "web/static") assets := assetHashes(staticFS) funcs := template.FuncMap{ @@ -139,13 +140,19 @@ func (s *Server) tunnelListenAddr(port int) string { } // tunnelAdvert is the tunnel address handed to agents as "host:port". The host -// is empty unless one is set in Settings; agents then use the panel's host. +// is the one set in Settings, else the direct address's (the main one may be +// behind a proxy that carries no raw TCP), else empty: agents then use the +// panel's host. func (s *Server) tunnelAdvert() string { port := s.TunnelPort() if port == 0 { return "" } - return net.JoinHostPort(s.db.TunnelHost(), strconv.Itoa(port)) + host := s.db.TunnelHost() + if host == "" { + host = directHost(s.db.DirectAddress()) + } + return net.JoinHostPort(host, strconv.Itoa(port)) } // setTunnelPort moves the direct tunnel to another port. Connected agents are @@ -229,15 +236,17 @@ func (s *Server) Handler() http.Handler { mux.Handle("POST /agents/{id}/user", s.requireAdmin(s.handleAgentSetUser)) mux.Handle("POST /agents/{id}/transport", s.requireAdmin(s.handleAgentTransport)) mux.Handle("POST /agents/{id}/users/add", s.requireAdmin(s.handleAgentAddUser)) - mux.Handle("POST /agents/{id}/delete", s.requireAdmin(s.handleAgentDelete)) - mux.Handle("GET /settings", s.requireAdmin(s.handleSettings)) - mux.Handle("POST /settings/host", s.requireAdmin(s.handleSetHost)) - mux.Handle("POST /settings/tunnel", s.requireAdmin(s.handleSetTunnel)) - mux.Handle("POST /settings/secret/rotate", s.requireAdmin(s.handleRotateSecret)) - mux.Handle("POST /settings/install/code", s.requireAdmin(s.handleInstallCode)) - 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/keys/reset", s.requireAdmin(s.handleResetAdminKeys)) + mux.Handle("POST /agents/{id}/delete", s.adminsOnly(s.handleAgentDelete)) + mux.Handle("GET /settings", s.adminsOnly(s.handleSettings)) + mux.Handle("POST /settings/host", s.adminsOnly(s.handleSetHost)) + mux.Handle("POST /settings/tunnel", s.adminsOnly(s.handleSetTunnel)) + mux.Handle("POST /settings/secret/rotate", s.adminsOnly(s.handleRotateSecret)) + mux.Handle("POST /settings/install/code", s.adminsOnly(s.handleInstallCode)) + mux.Handle("POST /settings/admins/add", s.adminsOnly(s.handleAddAdmin)) + mux.Handle("POST /settings/admins/delete", s.adminsOnly(s.handleDeleteAdmin)) + mux.Handle("GET /api/panel/users/{id}/agents", s.adminsOnly(s.handleUserAgents)) + mux.Handle("POST /api/panel/users/{id}", s.adminsOnly(s.handleUserEdit)) + mux.Handle("POST /settings/admins/keys/reset", s.adminsOnly(s.handleResetAdminKeys)) mux.Handle("GET /api/panel/account", s.requireAdmin(s.handleAccount)) mux.Handle("POST /api/panel/account/keys/begin", s.requireAdmin(s.handleKeyRegisterBegin)) mux.Handle("POST /api/panel/account/keys/finish", s.requireAdmin(s.handleKeyRegisterFinish)) @@ -247,9 +256,9 @@ func (s *Server) Handler() http.Handler { mux.Handle("GET /api/panel/pin", s.requireAdmin(s.handlePinState)) mux.Handle("POST /api/panel/pin/check", s.requireAdmin(s.handlePinCheck)) mux.Handle("POST /api/panel/account/password/change", s.requireAdmin(s.handlePasswordChange)) - 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/toggle", s.adminsOnly(s.handleUfwToggle)) + mux.Handle("POST /settings/ufw/add", s.adminsOnly(s.handleUfwAdd)) + mux.Handle("POST /settings/ufw/revoke", s.adminsOnly(s.handleUfwRevoke)) // Panel terminal over HTTP long-polling (admin-only). mux.Handle("POST /api/panel/term/{agentID}/open", s.requireAdmin(s.handlePanelOpen)) @@ -317,6 +326,36 @@ func (s *Server) requireAdmin(h http.HandlerFunc) http.Handler { http.Redirect(w, r, "/login", http.StatusSeeOther) return } + // A user who is not an admin uses only the machines given to them: + // any other is as if it were not there. + if !admin.IsAdmin() { + p := r.URL.Path + if strings.HasPrefix(p, "/agents/") || strings.HasPrefix(p, "/api/panel/agents/") || strings.HasPrefix(p, "/api/panel/term/") { + id := r.PathValue("id") + if id == "" { + id = r.PathValue("agentID") + } + if id != "" && id != "presence" && !s.db.CanUseAgent(admin, id) { + http.NotFound(w, r) + return + } + } + } + h(w, r) + }) +} + +// adminsOnly lets only admins through: the settings, users, the firewall. +func (s *Server) adminsOnly(h http.HandlerFunc) http.Handler { + return s.requireAdmin(func(w http.ResponseWriter, r *http.Request) { + if !s.currentAdmin(r).IsAdmin() { + if r.Method == http.MethodGet && !strings.HasPrefix(r.URL.Path, "/api/") { + http.Redirect(w, r, "/", http.StatusSeeOther) + return + } + http.Error(w, "только для администраторов", http.StatusForbidden) + return + } h(w, r) }) } diff --git a/internal/server/ship.go b/internal/server/ship.go index a3c0083..52d1b3e 100644 --- a/internal/server/ship.go +++ b/internal/server/ship.go @@ -151,6 +151,10 @@ func (s *Server) handleFSShip(w http.ResponseWriter, r *http.Request) { writeJSON(w, proto.TaskResult{Error: "не выбрана машина"}) return } + if !s.db.CanUseAgent(s.currentAdmin(r), q.To) { + writeJSON(w, proto.TaskResult{Error: "нет доступа к той машине"}) + return + } if !s.fsAgent(w, q.To, true) { return } diff --git a/internal/server/users.go b/internal/server/users.go new file mode 100644 index 0000000..590ee8b --- /dev/null +++ b/internal/server/users.go @@ -0,0 +1,89 @@ +package server + +import ( + "encoding/json" + "net/http" + "strconv" +) + +// GET /api/panel/users/{id}/agents - every machine, and which of them the +// user may use, for the editor of a user's machines. +func (s *Server) handleUserAgents(w http.ResponseWriter, r *http.Request) { + id, _ := strconv.ParseInt(r.PathValue("id"), 10, 64) + u, _ := s.db.AdminByID(id) + if u == nil { + http.NotFound(w, r) + return + } + agents, _ := s.db.ListAgents() + mine := s.db.UserAgents(id) + type item struct { + ID string `json:"id"` + Name string `json:"name"` + Hostname string `json:"hostname"` + Online bool `json:"online"` + On bool `json:"on"` + } + out := make([]item, 0, len(agents)) + for _, a := range agents { + out = append(out, item{a.ID, a.Name, a.Hostname, s.online(&a), mine[a.ID]}) + } + writeJSON(w, map[string]any{"username": u.Username, "agents": out}) +} + +// POST /api/panel/users/{id} - change a user: the role, the machines, the +// password sign-in, and whether they may switch it themselves. Fields left +// out stay as they are. +func (s *Server) handleUserEdit(w http.ResponseWriter, r *http.Request) { + me := s.currentAdmin(r) + id, _ := strconv.ParseInt(r.PathValue("id"), 10, 64) + u, _ := s.db.AdminByID(id) + if u == nil { + writeJSON(w, map[string]any{"ok": false, "error": "нет такого пользователя"}) + return + } + var in struct { + Role *string `json:"role"` + Agents *[]string `json:"agents"` + PasswordOff *bool `json:"password_off"` + PwLocked *bool `json:"pw_locked"` + } + if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 64<<10)).Decode(&in); err != nil { + writeJSON(w, map[string]any{"ok": false, "error": "плохой запрос"}) + return + } + fail := func(msg string) { writeJSON(w, map[string]any{"ok": false, "error": msg}) } + if in.Role != nil { + role := *in.Role + if role != RoleAdmin && role != RoleUser { + fail("нет такой роли") + return + } + if role == RoleUser && u.IsAdmin() && s.db.CountRole(RoleAdmin) <= 1 { + fail("это последний администратор") + return + } + _ = s.db.SetUserRole(id, role) + s.db.Audit(me.ID, "", "user_role:"+u.Username+":"+role) + } + if in.Agents != nil { + _ = s.db.SetUserAgents(id, *in.Agents) + s.db.Audit(me.ID, "", "user_agents:"+u.Username+":"+strconv.Itoa(len(*in.Agents))) + } + if in.PasswordOff != nil { + if *in.PasswordOff && s.db.CountAdminKeys(id) == 0 { + fail("у пользователя нет ключей доступа - без пароля он не войдёт") + return + } + _ = s.db.SetPasswordOff(id, *in.PasswordOff) + action := "password_on" + if *in.PasswordOff { + action = "password_off" + } + s.db.Audit(me.ID, "", action+":"+u.Username) + } + if in.PwLocked != nil { + _ = s.db.SetPwLocked(id, *in.PwLocked) + } + writeJSON(w, map[string]any{"ok": true}) +} diff --git a/internal/server/web/install.sh.tmpl b/internal/server/web/install.sh.tmpl index 4c5371c..6424496 100644 --- a/internal/server/web/install.sh.tmpl +++ b/internal/server/web/install.sh.tmpl @@ -7,6 +7,9 @@ set -eu HOST="${HOST:-{{.Host}}}" +# The direct address (not behind a proxy like Cloudflare): the agent turns to +# it when the main one is out of reach. +FALLBACK_HOST="${FALLBACK_HOST:-{{.Direct}}}" {{if .Secret}}ENROLL_SECRET="${ENROLL_SECRET:-{{.Secret}}}" {{end}} BIN_PATH="/usr/local/bin/lpagent" @@ -108,9 +111,9 @@ fi # --- downloader ------------------------------------------------------------- download() { # download URL DEST if command -v curl >/dev/null 2>&1; then - curl -fSL -o "$2" "$1" + curl -fSL --connect-timeout 15 -o "$2" "$1" elif command -v wget >/dev/null 2>&1; then - wget -qO "$2" "$1" + wget -q -T 30 -O "$2" "$1" else red "Нужен curl или wget."; exit 1 fi @@ -118,7 +121,14 @@ download() { # download URL DEST bold "Скачивание агента ($ARCH) с $HOST…" TMP="$(mktemp)" -download "$HOST/download/agent-linux-$ARCH" "$TMP" +if ! download "$HOST/download/agent-linux-$ARCH" "$TMP"; then + if [ -n "$FALLBACK_HOST" ] && [ "$FALLBACK_HOST" != "$HOST" ]; then + bold "Основной адрес недоступен, скачивание с $FALLBACK_HOST…" + download "$FALLBACK_HOST/download/agent-linux-$ARCH" "$TMP" + else + red "Не удалось скачать агента."; exit 1 + fi +fi install -m 0755 "$TMP" "$BIN_PATH" rm -f "$TMP" green "Бинарник установлен: $BIN_PATH" @@ -129,6 +139,7 @@ chmod 700 "$CONF_DIR" umask 077 cat > "$CONF_FILE" <Ключей пока нет.'; diff --git a/internal/server/web/static/users.js b/internal/server/web/static/users.js new file mode 100644 index 0000000..ad53961 --- /dev/null +++ b/internal/server/web/static/users.js @@ -0,0 +1,105 @@ +// The users table in Settings: the role, the machines a user may use (a +// dialog with a list to tick), the password sign-in and whether the user +// may switch it themselves. Each change is saved at once. +(function () { + 'use strict'; + var table = document.querySelector('table.users'); + if (!table) return; + var $ = function (id) { return document.getElementById(id); }; + + function save(id, body) { + return fetch('/api/panel/users/' + id, {method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify(body)}) + .then(function (r) { return r.json(); }) + .catch(function () { return {ok: false, error: 'ошибка сети'}; }); + } + function note(tr, text, bad) { + var n = tr.querySelector('.u-note'); + if (!n) { + n = document.createElement('div'); + n.className = 'u-note'; + tr.cells[0].appendChild(n); + } + n.textContent = text; + n.classList.toggle('bad', !!bad); + n.classList.remove('fade'); + void n.offsetWidth; + n.classList.add('fade'); + } + + table.addEventListener('change', function (ev) { + var tr = ev.target.closest('tr'), id = tr && tr.dataset.id, el = ev.target; + if (!id) return; + var body = {}, undo; + if (el.classList.contains('u-role')) { + body.role = el.value; + undo = function () { el.value = el.value === 'user' ? 'admin' : 'user'; }; + } else if (el.classList.contains('u-pw')) { + body.password_off = !el.checked; + undo = function () { el.checked = !el.checked; }; + } else if (el.classList.contains('u-lock')) { + body.pw_locked = el.checked; + undo = function () { el.checked = !el.checked; }; + } else { + return; + } + save(id, body).then(function (r) { + if (!r.ok) { undo(); note(tr, r.error || 'не сохранилось', true); return; } + note(tr, 'сохранено'); + if (body.role) { + var user = body.role === 'user'; + tr.querySelector('.u-agents').hidden = !user; + tr.querySelector('.u-count').hidden = !user; + } + }); + }); + + // ---- the machines of a user ---- + var dlg = $('uadlg'), list = $('ualist'), cur = null; + function render(filter) { + var f = (filter || '').toLowerCase(); + list.querySelectorAll('label').forEach(function (l) { + l.hidden = f && l.textContent.toLowerCase().indexOf(f) < 0; + }); + } + table.addEventListener('click', function (ev) { + var b = ev.target.closest('.u-agents'); + if (!b) return; + var tr = b.closest('tr'); + cur = tr; + $('uaname').textContent = tr.dataset.name; + list.innerHTML = '
загрузка…
'; + $('uafilter').value = ''; + dlg.showModal(); + fetch('/api/panel/users/' + tr.dataset.id + '/agents', {cache: 'no-store'}).then(function (r) { return r.json(); }).then(function (d) { + list.innerHTML = ''; + d.agents.forEach(function (a) { + var l = document.createElement('label'); + l.className = 'ua-item'; + l.innerHTML = ''; + l.querySelector('input').value = a.id; + l.querySelector('input').checked = a.on; + l.querySelector('.ua-name').textContent = a.name; + l.querySelector('.ua-host').textContent = a.hostname; + list.appendChild(l); + }); + if (!d.agents.length) list.innerHTML = '
машин пока нет
'; + }); + }); + $('uafilter').addEventListener('input', function (ev) { render(ev.target.value); }); + $('uaall').onclick = function () { list.querySelectorAll('label:not([hidden]) input').forEach(function (i) { i.checked = true; }); }; + $('uanone').onclick = function () { list.querySelectorAll('label:not([hidden]) input').forEach(function (i) { i.checked = false; }); }; + $('uaclose').onclick = $('uacancel').onclick = function () { dlg.close(); }; + $('uasave').onclick = function () { + var ids = [].map.call(list.querySelectorAll('input:checked'), function (i) { return i.value; }); + save(cur.dataset.id, {agents: ids}).then(function (r) { + if (!r.ok) { note(cur, r.error || 'не сохранилось', true); return; } + cur.querySelector('.u-count').textContent = ids.length; + note(cur, 'машин: ' + ids.length); + dlg.close(); + }); + }; + + // ---- a new user: the machines appear for a plain user ---- + var role = $('newrole'), pick = $('newagents'); + if (role) role.onchange = function () { pick.hidden = role.value !== 'user'; }; +})(); diff --git a/internal/server/web/templates/dashboard.html b/internal/server/web/templates/dashboard.html index d9af25a..85d6629 100644 --- a/internal/server/web/templates/dashboard.html +++ b/internal/server/web/templates/dashboard.html @@ -12,7 +12,7 @@
LPRA · Удалённый доступ
@@ -47,13 +47,13 @@ -
-
+ {{end}} @@ -61,8 +61,9 @@ {{else}} -

Пока нет подключённых машин. Добавьте первую в разделе + {{if .IsAdmin}}

Пока нет подключённых машин. Добавьте первую в разделе Настройки - там одна команда для установки.

+ {{else}}

Вам пока не выдали ни одной машины - попросите администратора.

{{end}} {{end}} diff --git a/internal/server/web/templates/edit.html b/internal/server/web/templates/edit.html index 13f3cc1..845f38b 100644 --- a/internal/server/web/templates/edit.html +++ b/internal/server/web/templates/edit.html @@ -112,7 +112,7 @@ {{end}} -
+ {{if .IsAdmin}}

Удаление

Удаляет машину из панели. Сервис на самой машине продолжит работать, но перестанет быть виден; чтобы подключиться снова - переустановите агента.

@@ -121,7 +121,7 @@ -
+
{{end}} diff --git a/internal/server/web/templates/settings.html b/internal/server/web/templates/settings.html index d6523e4..52dedfc 100644 --- a/internal/server/web/templates/settings.html +++ b/internal/server/web/templates/settings.html @@ -8,6 +8,36 @@