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

This commit is contained in:
redtea committed 2026-10-15 10:56:19 +03:00
1 parent 36dc12b69d
commit 021da93229
29 files changed
+844 -97

No files matched your search

+19
View File
@@ -33,6 +33,15 @@
перезагрузке. На устройстве можно не спрашивать его 5/15/30/60 минут после ввода. перезагрузке. На устройстве можно не спрашивать его 5/15/30/60 минут после ввода.
Пять неверных попыток - выход из сессии. Пять неверных попыток - выход из сессии.
## Пользователи и роли
- **Администратор** видит всё: все машины, настройки, пользователей, ufw.
- **Пользователь** видит только выданные ему машины (терминал, файлы, порты, статус, «Изменить» без удаления);
настроек, других пользователей и чужих машин для него нет.
Роль и машины задаются в «Настройки → Пользователи» (квадратная кнопка у пользователя открывает список машин).
Там же галочка «Пароль» включает или выключает вход по паролю (выключить можно, когда у человека есть ключ доступа),
а «Закрепить» не даёт ему поменять это самому. Последнего администратора нельзя удалить или понизить.
## Компоненты ## Компоненты
- **Сервер** (`cmd/server`) - панель и точка встречи для агентов. Ставится за nginx (TLS). - **Сервер** (`cmd/server`) - панель и точка встречи для агентов. Ставится за nginx (TLS).
- **Агент** (`cmd/agent`) - бинарь для Linux (amd64/arm64), systemd-сервис от root. - **Агент** (`cmd/agent`) - бинарь для Linux (amd64/arm64), systemd-сервис от root.
@@ -69,6 +78,16 @@
на больших выводах); на больших выводах);
- `proxy_read_timeout` больше 25 с (удержание long-poll), `proxy_buffering off`. - `proxy_read_timeout` больше 25 с (удержание long-poll), `proxy_buffering off`.
### Прямой адрес (без Cloudflare)
В «Настройки → Адрес хоста» можно задать второй, прямой адрес - например `https://direct.lpra.example.com`,
DNS-запись без проксирования. Тогда:
- агенты, не достучавшись до основного адреса 30 секунд, переходят на прямой и каждые 5 минут проверяют,
не ожил ли основной; уже установленные агенты получают прямой адрес с сервера сами (он хранится в `state.json`);
- установщик (`FALLBACK_HOST`) качает агента с прямого, если основной не отвечает; в «Полных вариантах»
есть короткая команда и через прямой адрес;
- если хост туннеля не задан, агентам отдаётся хост прямого адреса;
- ключи доступа основного домена работают и на его поддомене.
### Cloudflare ### Cloudflare
Работает за проксирующим Cloudflare: WebSocket он пропускает, файлы идут кусками меньше лимита Работает за проксирующим Cloudflare: WebSocket он пропускает, файлы идут кусками меньше лимита
на запрос, статика с хешем в имени кэшируется. Чтобы панель видела настоящие IP (ограничение на запрос, статика с хешем в имени кэшируется. Чтобы панель видела настоящие IP (ограничение
+1 -1
View File
@@ -1 +1 @@
0.1.1 0.1.2
+2
View File
@@ -11,6 +11,7 @@ import (
"os" "os"
"os/signal" "os/signal"
"strconv" "strconv"
"strings"
"syscall" "syscall"
"github.com/x-grk/longpolling-remote-access/internal/agent" "github.com/x-grk/longpolling-remote-access/internal/agent"
@@ -21,6 +22,7 @@ func main() {
host := envOr("HOST", "https://REDACTED:8445") host := envOr("HOST", "https://REDACTED:8445")
cfg := agent.Config{ cfg := agent.Config{
Host: host, Host: host,
Fallback: strings.TrimRight(os.Getenv("FALLBACK_HOST"), "/"),
EnrollSecret: os.Getenv("ENROLL_SECRET"), EnrollSecret: os.Getenv("ENROLL_SECRET"),
Name: os.Getenv("AGENT_NAME"), Name: os.Getenv("AGENT_NAME"),
StatePath: envOr("STATE_PATH", "/etc/lpagent/state.json"), StatePath: envOr("STATE_PATH", "/etc/lpagent/state.json"),
+44 -16
View File
@@ -31,6 +31,7 @@ var Version = lpra.Version
// Config is the agent's runtime configuration (from env / config.env). // Config is the agent's runtime configuration (from env / config.env).
type Config struct { type Config struct {
Host string // e.g. https://REDACTED:8445 Host string // e.g. https://REDACTED:8445
Fallback string // the direct address, from the installer (FALLBACK_HOST); may be empty
EnrollSecret string EnrollSecret string
Name string // optional friendly name Name string // optional friendly name
StatePath string // where the enrolled id+token are persisted StatePath string // where the enrolled id+token are persisted
@@ -43,6 +44,8 @@ type Config struct {
type state struct { type state struct {
AgentID string `json:"agent_id"` AgentID string `json:"agent_id"`
Token string `json:"token"` Token string `json:"token"`
// Fallback is the direct address as the server told it.
Fallback string `json:"fallback,omitempty"`
} }
// Agent is a running client. // Agent is a running client.
@@ -51,6 +54,8 @@ type Agent struct {
http *http.Client http *http.Client
stream *http.Client stream *http.Client
state state state state
stateMu sync.Mutex
hosts hostState
pollCount int pollCount int
// Direct tunnel currently running (see setTunnelAddr). // Direct tunnel currently running (see setTunnelAddr).
@@ -97,11 +102,20 @@ func (a *Agent) Run(ctx context.Context) error {
return err return err
} }
if a.state.AgentID == "" || a.state.Token == "" { 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) 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 // Direct forward tunnel (low-latency TCP mux + UDP). Its address comes
// from the panel; fall back to the configured one when the server sends // 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 { if ctx.Err() != nil {
return 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() { switch a.mode() {
case proto.TransportWS: case proto.TransportWS:
log.Printf("websocket: %v (retry in %s)", err, backoff) 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. // would sit held on the server first.
if lost && d > 0 { if lost && d > 0 {
if a.reachable(ctx) { if a.reachable(ctx) {
a.reached()
return
}
a.failed()
if a.hostChanged() {
return return
} }
select { select {
@@ -204,6 +232,10 @@ func (a *Agent) longPoll(ctx context.Context, d time.Duration) {
return return
} }
if err != nil { if err != nil {
a.failed()
if a.hostChanged() {
return
}
log.Printf("poll: %v (retry in %s)", err, backoff) log.Printf("poll: %v (retry in %s)", err, backoff)
select { select {
case <-time.After(backoff): case <-time.After(backoff):
@@ -214,22 +246,14 @@ func (a *Agent) longPoll(ctx context.Context, d time.Duration) {
lost = true lost = true
continue continue
} }
a.reached()
backoff = time.Second backoff = time.Second
} }
} }
// reachable reports whether the server answers a quick request. // reachable reports whether the server answers a quick request.
func (a *Agent) reachable(ctx context.Context) bool { func (a *Agent) reachable(ctx context.Context) bool {
ctx, cancel := context.WithTimeout(ctx, 10*time.Second) return a.reachableAt(ctx, a.host())
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
} }
// retryCap bounds the wait between attempts to reach the server, so a // 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, Version: Version,
User: currentUser(), 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("X-Enroll-Secret", a.cfg.EnrollSecret)
req.Header.Set("Content-Type", "application/json") req.Header.Set("Content-Type", "application/json")
resp, err := a.http.Do(req) 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 // The poll itself runs on reqCtx, which may end early; what it starts runs on
// ctx. // ctx.
func (a *Agent) pollOnce(ctx, reqCtx context.Context) error { 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) a.authHeaders(req)
// Report the version and our OS user occasionally (not every poll), so the // Report the version and our OS user occasionally (not every poll), so the
// panel can flag outdated agents without extra traffic. // panel can flag outdated agents without extra traffic.
@@ -295,6 +319,7 @@ func (a *Agent) pollOnce(ctx, reqCtx context.Context) error {
return err return err
} }
a.applyTunnelHeader(ctx, resp.Header) a.applyTunnelHeader(ctx, resp.Header)
a.learnFallback(resp.Header.Get(proto.FallbackHeader))
a.applyTransport(resp.Header.Get(proto.TransportHeader)) a.applyTransport(resp.Header.Get(proto.TransportHeader))
a.dispatch(ctx, msg) a.dispatch(ctx, msg)
return nil return nil
@@ -322,7 +347,7 @@ func (a *Agent) dispatch(ctx context.Context, msg proto.ControlMessage) {
func (a *Agent) fetchConfig(ctx context.Context) bool { func (a *Agent) fetchConfig(ctx context.Context) bool {
ctx2, cancel := context.WithTimeout(ctx, 10*time.Second) ctx2, cancel := context.WithTimeout(ctx, 10*time.Second)
defer cancel() 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) a.authHeaders(req)
resp, err := a.http.Do(req) resp, err := a.http.Do(req)
if err != nil { if err != nil {
@@ -330,6 +355,7 @@ func (a *Agent) fetchConfig(ctx context.Context) bool {
} }
defer resp.Body.Close() defer resp.Body.Close()
a.applyTransport(resp.Header.Get(proto.TransportHeader)) a.applyTransport(resp.Header.Get(proto.TransportHeader))
a.learnFallback(resp.Header.Get(proto.FallbackHeader))
return a.applyTunnelHeader(ctx, resp.Header) return a.applyTunnelHeader(ctx, resp.Header)
} }
@@ -348,7 +374,7 @@ func (a *Agent) applyTunnelAddr(ctx context.Context, v string) bool {
return false return false
} }
if host == "" { if host == "" {
u, err := url.Parse(a.cfg.Host) u, err := url.Parse(a.host())
if err != nil || u.Hostname() == "" { if err != nil || u.Hostname() == "" {
return false return false
} }
@@ -403,7 +429,9 @@ func (a *Agent) saveState() error {
if err := os.MkdirAll(filepath.Dir(a.cfg.StatePath), 0o700); err != nil { if err := os.MkdirAll(filepath.Dir(a.cfg.StatePath), 0o700); err != nil {
return err return err
} }
a.stateMu.Lock()
b, _ := json.MarshalIndent(a.state, "", " ") b, _ := json.MarshalIndent(a.state, "", " ")
a.stateMu.Unlock()
return os.WriteFile(a.cfg.StatePath, b, 0o600) return os.WriteFile(a.cfg.StatePath, b, 0o600)
} }
+1 -1
View File
@@ -41,7 +41,7 @@ type errXferGone string
func (e errXferGone) Error() string { return string(e) } func (e errXferGone) Error() string { return string(e) }
func (a *Agent) xferURL(id string, i int) string { 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. // xferRequest makes one request for a piece, trying again over a failing link.
+170
View File
@@ -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
}
+2 -2
View File
@@ -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) a.authHeaders(req)
resp, err := a.http.Do(req) resp, err := a.http.Do(req)
if err != nil { if err != nil {
@@ -156,7 +156,7 @@ func (a *Agent) postOut(ctx context.Context, sid string, msg proto.OutMessage) b
} }
} }
body, _ := json.Marshal(msg) 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) a.authHeaders(req)
req.Header.Set("Content-Type", "application/json") req.Header.Set("Content-Type", "application/json")
resp, err := a.http.Do(req) resp, err := a.http.Do(req)
+2 -2
View File
@@ -52,7 +52,7 @@ func (a *Agent) reportShip(ctx context.Context, job string, m *shipMeter, cancel
defer t.Stop() defer t.Stop()
for { for {
body, _ := json.Marshal(m.snapshot()) 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) a.authHeaders(req)
req.Header.Set("Content-Type", "application/json") req.Header.Set("Content-Type", "application/json")
if resp, err := a.http.Do(req); err == nil { 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 { 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 // shipRelay posts the tar stream in pieces, a few at once; the last carries
+1 -1
View File
@@ -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) { 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 { if err != nil {
return nil, err return nil, err
} }
+2 -2
View File
@@ -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) { func (a *Agent) fetchTask(ctx context.Context, taskID string) (proto.TaskEnvelope, error) {
var env proto.TaskEnvelope 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) a.authHeaders(req)
resp, err := a.http.Do(req) resp, err := a.http.Do(req)
if err != nil { 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) { func (a *Agent) postResult(ctx context.Context, taskID string, res proto.TaskResult) {
body, _ := json.Marshal(res) 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) a.authHeaders(req)
req.Header.Set("Content-Type", "application/json") req.Header.Set("Content-Type", "application/json")
resp, err := a.http.Do(req) resp, err := a.http.Do(req)
+1 -1
View File
@@ -34,7 +34,7 @@ func (a *Agent) doUpdate() proto.TaskResult {
sudo = sudoPath() 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) req, _ := http.NewRequestWithContext(context.Background(), http.MethodGet, url, nil)
resp, err := a.stream.Do(req) resp, err := a.stream.Do(req)
if err != nil { if err != nil {
+9 -2
View File
@@ -30,8 +30,13 @@ type wsLink struct {
subs map[string]chan wsIn subs map[string]chan wsIn
asked map[string]bool // sessions whose input was asked for asked map[string]bool // sessions whose input was asked for
done chan struct{} 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. // wsIn is what the server sent for a session: its input, or that it is over.
type wsIn struct { type wsIn struct {
frames []proto.InFrame 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. // runWS holds the WebSocket until it ends, dispatching what comes on it.
func (a *Agent) runWS(ctx context.Context) error { 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") u = "ws" + strings.TrimPrefix(u, "http")
hdr := http.Header{} hdr := http.Header{}
hdr.Set("X-Agent-Id", a.state.AgentID) hdr.Set("X-Agent-Id", a.state.AgentID)
@@ -116,7 +121,7 @@ func (a *Agent) runWS(ctx context.Context) error {
} }
c.SetReadLimit(8 << 20) c.SetReadLimit(8 << 20)
lctx, lcancel := context.WithCancel(ctx) 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) a.link.Store(l)
defer func() { defer func() {
a.link.CompareAndSwap(l, nil) a.link.CompareAndSwap(l, nil)
@@ -125,6 +130,7 @@ func (a *Agent) runWS(ctx context.Context) error {
c.CloseNow() c.CloseNow()
}() }()
log.Printf("connected by websocket") log.Printf("connected by websocket")
a.reached()
go func() { go func() {
t := time.NewTicker(wsPing) t := time.NewTicker(wsPing)
defer t.Stop() defer t.Stop()
@@ -159,6 +165,7 @@ func (a *Agent) runWS(ctx context.Context) error {
} }
case "cfg": case "cfg":
a.applyTunnelAddr(ctx, m.Tunnel) a.applyTunnelAddr(ctx, m.Tunnel)
a.learnFallback(m.Fallback)
a.applyTransport(m.Transport) a.applyTransport(m.Transport)
if a.mode() == proto.TransportLP { if a.mode() == proto.TransportLP {
return errors.New("set to long polling") return errors.New("set to long polling")
+6
View File
@@ -72,6 +72,11 @@ const (
// answers. // answers.
const TransportHeader = "X-Transport" 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: // 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 // "ctrl" (Ctrl, as a control poll would answer), "cfg" (Tunnel and
// Transport, also a keep-alive), "in" (Frames for session SID), "gone" // Transport, also a keep-alive), "in" (Frames for session SID), "gone"
@@ -85,6 +90,7 @@ type WSMsg struct {
Out *OutMessage `json:"out,omitempty"` Out *OutMessage `json:"out,omitempty"`
Tunnel string `json:"tunnel,omitempty"` Tunnel string `json:"tunnel,omitempty"`
Transport string `json:"transport,omitempty"` Transport string `json:"transport,omitempty"`
Fallback string `json:"fallback,omitempty"`
} }
// Terminal / stream frame types on the session "in" channel (server -> agent). // Terminal / stream frame types on the session "in" channel (server -> agent).
+19
View File
@@ -3,6 +3,7 @@ package server
import ( import (
"crypto/rand" "crypto/rand"
"encoding/hex" "encoding/hex"
"net/url"
"strconv" "strconv"
lpra "github.com/x-grk/longpolling-remote-access" lpra "github.com/x-grk/longpolling-remote-access"
@@ -18,6 +19,7 @@ const (
cfgUfwEnabled = "ufw_enabled" cfgUfwEnabled = "ufw_enabled"
cfgTunnelHost = "tunnel_host" cfgTunnelHost = "tunnel_host"
cfgTunnelPort = "tunnel_port" cfgTunnelPort = "tunnel_port"
cfgDirectAddr = "direct_address"
) )
// DefaultHostAddress is the public URL agents dial by default. It is baked into // DefaultHostAddress is the public URL agents dial by default. It is baked into
@@ -68,6 +70,23 @@ func (db *DB) HostAddress() string {
return v 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 { func (db *DB) EnrollSecret() string {
v, _ := db.GetConfig(cfgEnrollSecret) v, _ := db.GetConfig(cfgEnrollSecret)
return v return v
+129 -20
View File
@@ -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 admins ADD COLUMN password_off INTEGER NOT NULL DEFAULT 0`,
`ALTER TABLE agents ADD COLUMN transport TEXT NOT NULL DEFAULT 'auto'`, `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 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") { if _, err := db.sql.Exec(alter); err != nil && !strings.Contains(err.Error(), "duplicate column") {
return err return err
@@ -155,24 +161,56 @@ func (db *DB) CountAdmins() (int, error) {
} }
func (db *DB) CreateAdmin(username, passHash string) error { func (db *DB) CreateAdmin(username, passHash string) error {
_, err := db.sql.Exec( _, err := db.CreateUser(username, passHash, RoleAdmin)
`INSERT INTO admins(username, pass_hash, created_at) VALUES(?, ?, ?)`,
username, passHash, time.Now().Unix())
return err 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 { type Admin struct {
ID int64 ID int64
Username string Username string
PassHash 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) { func (db *DB) AdminByUsername(username string) (*Admin, error) {
a := &Admin{} a, err := scanAdmin(db.sql.QueryRow(`SELECT `+adminCols+` FROM admins WHERE username = ?`, username))
err := db.sql.QueryRow(
`SELECT id, username, pass_hash FROM admins WHERE username = ?`, username).
Scan(&a.ID, &a.Username, &a.PassHash)
if err == sql.ErrNoRows { if err == sql.ErrNoRows {
return nil, nil return nil, nil
} }
@@ -180,22 +218,87 @@ func (db *DB) AdminByUsername(username string) (*Admin, error) {
} }
func (db *DB) ListAdmins() ([]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 { if err != nil {
return nil, err return nil, err
} }
defer rows.Close() defer rows.Close()
var out []Admin var out []Admin
for rows.Next() { for rows.Next() {
var a Admin a, err := scanAdmin(rows)
if err := rows.Scan(&a.ID, &a.Username); err != nil { if err != nil {
return nil, err return nil, err
} }
out = append(out, a) out = append(out, *a)
} }
return out, rows.Err() 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 { func (db *DB) DeleteAdmin(id int64) error {
_, err := db.sql.Exec(`DELETE FROM admins WHERE id = ?`, id) _, err := db.sql.Exec(`DELETE FROM admins WHERE id = ?`, id)
return err return err
@@ -214,9 +317,7 @@ type AdminKey struct {
} }
func (db *DB) AdminByID(id int64) (*Admin, error) { func (db *DB) AdminByID(id int64) (*Admin, error) {
a := &Admin{} a, err := scanAdmin(db.sql.QueryRow(`SELECT `+adminCols+` FROM admins WHERE id = ?`, id))
err := db.sql.QueryRow(`SELECT id, username, pass_hash FROM admins WHERE id = ?`, id).
Scan(&a.ID, &a.Username, &a.PassHash)
if err == sql.ErrNoRows { if err == sql.ErrNoRows {
return nil, nil 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. // SessionAdmin returns the admin for a live (non-expired) session, or nil.
func (db *DB) SessionAdmin(sessionID string) (*Admin, error) { func (db *DB) SessionAdmin(sessionID string) (*Admin, error) {
a := &Admin{} a, err := scanAdmin(db.sql.QueryRow(
err := db.sql.QueryRow( `SELECT a.id, a.username, a.pass_hash, a.role, a.pw_locked
`SELECT a.id, a.username, a.pass_hash
FROM admin_sessions s JOIN admins a ON a.id = s.admin_id FROM admin_sessions s JOIN admins a ON a.id = s.admin_id
WHERE s.id = ? AND s.expires_at > ?`, sessionID, time.Now().Unix()). WHERE s.id = ? AND s.expires_at > ?`, sessionID, time.Now().Unix()))
Scan(&a.ID, &a.Username, &a.PassHash)
if err == sql.ErrNoRows { if err == sql.ErrNoRows {
return nil, nil return nil, nil
} }
@@ -455,6 +554,9 @@ func (db *DB) SetAgentLoginUser(id, user string) error {
func (db *DB) DeleteAgent(id string) error { func (db *DB) DeleteAgent(id string) error {
_, err := db.sql.Exec(`DELETE FROM agents WHERE id = ?`, id) _, 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 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(?, ?, ?, ?)`, `INSERT INTO audit(admin_id, agent_id, action, at) VALUES(?, ?, ?, ?)`,
adminID, agentID, action, time.Now().Unix()) adminID, agentID, action, time.Now().Unix())
} }
func boolInt(b bool) int {
if b {
return 1
}
return 0
}
+3
View File
@@ -67,6 +67,9 @@ func (s *Server) setTunnelHeader(w http.ResponseWriter) {
if addr := s.tunnelAdvert(); addr != "" { if addr := s.tunnelAdvert(); addr != "" {
w.Header().Set(tunnelAddrHeader, 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 // GET /api/agent/config - settings an agent picks up when it connects, without
+48 -7
View File
@@ -121,15 +121,20 @@ func (s *Server) handleDashboard(w http.ResponseWriter, r *http.Request) {
Online bool Online bool
Seen string Seen string
} }
me := s.currentAdmin(r)
rows := make([]row, 0, len(agents)) rows := make([]row, 0, len(agents))
for _, a := range agents { for _, a := range agents {
if !s.db.CanUseAgent(me, a.ID) {
continue
}
on := s.online(&a) on := s.online(&a)
rows = append(rows, row{Agent: a, Online: on, Seen: seenText(on, a.LastSeen)}) rows = append(rows, row{Agent: a, Online: on, Seen: seenText(on, a.LastSeen)})
} }
s.render(w, "dashboard.html", map[string]any{ s.render(w, "dashboard.html", map[string]any{
"Admin": s.currentAdmin(r), "Admin": me,
"Agents": rows, "IsAdmin": me.IsAdmin(),
"CSRF": csrfToken(r), "Agents": rows,
"CSRF": csrfToken(r),
}) })
} }
@@ -158,8 +163,12 @@ func (s *Server) handlePresence(w http.ResponseWriter, r *http.Request) {
Online bool `json:"online"` Online bool `json:"online"`
Seen string `json:"seen"` Seen string `json:"seen"`
} }
me := s.currentAdmin(r)
out := make([]item, 0, len(agents)) out := make([]item, 0, len(agents))
for _, a := range agents { for _, a := range agents {
if !s.db.CanUseAgent(me, a.ID) {
continue
}
on := s.online(&a) 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)}) 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{ data := map[string]any{
"Agent": a, "Agent": a,
"IsAdmin": s.currentAdmin(r).IsAdmin(),
"CSRF": csrfToken(r), "CSRF": csrfToken(r),
"ServerVersion": Version, "ServerVersion": Version,
"Outdated": a.Version != "" && a.Version != 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) { func (s *Server) handleSettings(w http.ResponseWriter, r *http.Request) {
admins, _ := s.db.ListAdmins() 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 { for _, a := range admins {
keyCounts[a.ID] = s.db.CountAdminKeys(a.ID) keyCounts[a.ID] = s.db.CountAdminKeys(a.ID)
passOff[a.ID] = s.db.PasswordOff(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() host := s.db.HostAddress()
direct := s.db.DirectAddress()
secret := s.db.EnrollSecret() secret := s.db.EnrollSecret()
code := s.db.InstallCode() code := s.db.InstallCode()
ufwInstalled, ufwActive, ufwRunnable := s.ufw.status() 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{ s.render(w, "settings.html", map[string]any{
"Admin": s.currentAdmin(r), "Admin": s.currentAdmin(r),
"Host": host, "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, "Secret": secret,
"CodeLen": s.db.InstallCodeLen(), "CodeLen": s.db.InstallCodeLen(),
"Admins": admins, "Admins": admins,
"KeyCounts": keyCounts, "KeyCounts": keyCounts,
"AgentCounts": agentCounts,
"AllAgents": allAgents,
"Me": s.currentAdmin(r),
"PassOff": passOff, "PassOff": passOff,
"CSRF": csrfToken(r), "CSRF": csrfToken(r),
"InstallShort": fmt.Sprintf("wget -qO- %s/i/%s | sudo sh", host, code), "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, "TunnelCustom": s.db.TunnelPort() > 0,
"Error": map[string]string{ "Error": map[string]string{
"tunnel_host": "Некорректный хост туннеля: нужен IP или имя без схемы и порта.", "tunnel_host": "Некорректный хост туннеля: нужен IP или имя без схемы и порта.",
"host": "Некорректный адрес: нужен URL вида https://panel.example.com, без пути.",
"tunnel_port": "Некорректный порт туннеля.", "tunnel_port": "Некорректный порт туннеля.",
"ufw_port": "Некорректное правило ufw: нужен порт 1-65535 и протокол tcp или udp.", "ufw_port": "Некорректное правило ufw: нужен порт 1-65535 и протокол tcp или udp.",
"ufw_off": "Правило ufw не добавлено: ufw выключен или интеграция с ним отключена.", "ufw_off": "Правило ufw не добавлено: ufw выключен или интеграция с ним отключена.",
@@ -517,9 +537,20 @@ func (s *Server) handleSetHost(w http.ResponseWriter, r *http.Request) {
return return
} }
host := strings.TrimRight(strings.TrimSpace(r.FormValue("host")), "/") 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 != "" { if host != "" {
_ = s.db.SetConfig(cfgHostAddress, host) _ = s.db.SetConfig(cfgHostAddress, host)
} }
_ = s.db.SetConfig(cfgDirectAddr, direct)
http.Redirect(w, r, "/settings", http.StatusSeeOther) 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")) username := strings.TrimSpace(r.FormValue("username"))
pw := r.FormValue("password") pw := r.FormValue("password")
role := RoleAdmin
if r.FormValue("role") == RoleUser {
role = RoleUser
}
if username != "" && len(pw) >= 8 { if username != "" && len(pw) >= 8 {
if hash, err := hashPassword(pw); err == nil { 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) { 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) id, _ := strconv.ParseInt(r.FormValue("id"), 10, 64)
// Never delete the last admin. // 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) _ = s.db.DeleteAdmin(id)
} }
http.Redirect(w, r, "/settings", http.StatusSeeOther) 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") w.Header().Set("Content-Type", "text/x-shellscript; charset=utf-8")
if err := s.installTmpl.Execute(w, map[string]any{ if err := s.installTmpl.Execute(w, map[string]any{
"Host": s.db.HostAddress(), "Host": s.db.HostAddress(),
"Direct": s.db.DirectAddress(),
"Secret": secret, "Secret": secret,
}); err != nil { }); err != nil {
log.Printf("install.sh render: %v", err) log.Printf("install.sh render: %v", err)
+4
View File
@@ -114,6 +114,10 @@ func (s *Server) handleForwardDelete(w http.ResponseWriter, r *http.Request) {
} }
rid, _ := strconv.ParseInt(r.PathValue("rid"), 10, 64) rid, _ := strconv.ParseInt(r.PathValue("rid"), 10, 64)
rule, _ := s.db.GetForwardRule(rid) rule, _ := s.db.GetForwardRule(rid)
if rule == nil || !s.db.CanUseAgent(s.currentAdmin(r), rule.AgentID) {
http.NotFound(w, r)
return
}
s.removeForward(rid) s.removeForward(rid)
dest := "/" dest := "/"
if rule != nil { if rule != nil {
+15 -2
View File
@@ -75,12 +75,20 @@ func (c *challenges) take(id string, adminID int64) bool {
return ok && ch.adminID == adminID && time.Now().Before(ch.expires) 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 { func rpID(r *http.Request) string {
host := r.Host host := r.Host
if h, _, err := net.SplitHostPort(host); err == nil { if h, _, err := net.SplitHostPort(host); err == nil {
host = h host = h
} }
if m := mainHost(); m != "" && strings.HasSuffix(host, "."+m) {
return m
}
return host return host
} }
@@ -353,7 +361,8 @@ func (s *Server) handleAccount(w http.ResponseWriter, r *http.Request) {
for _, k := range keys { for _, k := range keys {
list = append(list, map[string]any{"id": k.ID, "name": k.Name, "created": k.CreatedAt, "last_used": k.LastUsed}) 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 // 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": "плохой запрос"}) writeJSON(w, map[string]any{"ok": false, "error": "плохой запрос"})
return return
} }
if a.PwLocked {
writeJSON(w, map[string]any{"ok": false, "error": "вход по паролю закрепил администратор"})
return
}
if in.Off && s.db.CountAdminKeys(a.ID) == 0 { if in.Off && s.db.CountAdminKeys(a.ID) == 0 {
writeJSON(w, map[string]any{"ok": false, "error": "сначала добавьте ключ доступа"}) writeJSON(w, map[string]any{"ok": false, "error": "сначала добавьте ключ доступа"})
return return
+53 -14
View File
@@ -71,6 +71,7 @@ func New(opts Options) (*Server, error) {
if err := opts.DB.ensureDefaults(); err != nil { if err := opts.DB.ensureDefaults(); err != nil {
return nil, err return nil, err
} }
mainHost = func() string { return directHost(opts.DB.HostAddress()) }
staticFS, _ := fs.Sub(webFS, "web/static") staticFS, _ := fs.Sub(webFS, "web/static")
assets := assetHashes(staticFS) assets := assetHashes(staticFS)
funcs := template.FuncMap{ 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 // 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 { func (s *Server) tunnelAdvert() string {
port := s.TunnelPort() port := s.TunnelPort()
if port == 0 { if port == 0 {
return "" 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 // 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}/user", s.requireAdmin(s.handleAgentSetUser))
mux.Handle("POST /agents/{id}/transport", s.requireAdmin(s.handleAgentTransport)) 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}/users/add", s.requireAdmin(s.handleAgentAddUser))
mux.Handle("POST /agents/{id}/delete", s.requireAdmin(s.handleAgentDelete)) mux.Handle("POST /agents/{id}/delete", s.adminsOnly(s.handleAgentDelete))
mux.Handle("GET /settings", s.requireAdmin(s.handleSettings)) mux.Handle("GET /settings", s.adminsOnly(s.handleSettings))
mux.Handle("POST /settings/host", s.requireAdmin(s.handleSetHost)) mux.Handle("POST /settings/host", s.adminsOnly(s.handleSetHost))
mux.Handle("POST /settings/tunnel", s.requireAdmin(s.handleSetTunnel)) mux.Handle("POST /settings/tunnel", s.adminsOnly(s.handleSetTunnel))
mux.Handle("POST /settings/secret/rotate", s.requireAdmin(s.handleRotateSecret)) mux.Handle("POST /settings/secret/rotate", s.adminsOnly(s.handleRotateSecret))
mux.Handle("POST /settings/install/code", s.requireAdmin(s.handleInstallCode)) mux.Handle("POST /settings/install/code", s.adminsOnly(s.handleInstallCode))
mux.Handle("POST /settings/admins/add", s.requireAdmin(s.handleAddAdmin)) mux.Handle("POST /settings/admins/add", s.adminsOnly(s.handleAddAdmin))
mux.Handle("POST /settings/admins/delete", s.requireAdmin(s.handleDeleteAdmin)) mux.Handle("POST /settings/admins/delete", s.adminsOnly(s.handleDeleteAdmin))
mux.Handle("POST /settings/admins/keys/reset", s.requireAdmin(s.handleResetAdminKeys)) 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("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/begin", s.requireAdmin(s.handleKeyRegisterBegin))
mux.Handle("POST /api/panel/account/keys/finish", s.requireAdmin(s.handleKeyRegisterFinish)) 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("GET /api/panel/pin", s.requireAdmin(s.handlePinState))
mux.Handle("POST /api/panel/pin/check", s.requireAdmin(s.handlePinCheck)) 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 /api/panel/account/password/change", s.requireAdmin(s.handlePasswordChange))
mux.Handle("POST /settings/ufw/toggle", s.requireAdmin(s.handleUfwToggle)) mux.Handle("POST /settings/ufw/toggle", s.adminsOnly(s.handleUfwToggle))
mux.Handle("POST /settings/ufw/add", s.requireAdmin(s.handleUfwAdd)) mux.Handle("POST /settings/ufw/add", s.adminsOnly(s.handleUfwAdd))
mux.Handle("POST /settings/ufw/revoke", s.requireAdmin(s.handleUfwRevoke)) mux.Handle("POST /settings/ufw/revoke", s.adminsOnly(s.handleUfwRevoke))
// Panel terminal over HTTP long-polling (admin-only). // Panel terminal over HTTP long-polling (admin-only).
mux.Handle("POST /api/panel/term/{agentID}/open", s.requireAdmin(s.handlePanelOpen)) 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) http.Redirect(w, r, "/login", http.StatusSeeOther)
return 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) h(w, r)
}) })
} }
+4
View File
@@ -151,6 +151,10 @@ func (s *Server) handleFSShip(w http.ResponseWriter, r *http.Request) {
writeJSON(w, proto.TaskResult{Error: "не выбрана машина"}) writeJSON(w, proto.TaskResult{Error: "не выбрана машина"})
return return
} }
if !s.db.CanUseAgent(s.currentAdmin(r), q.To) {
writeJSON(w, proto.TaskResult{Error: "нет доступа к той машине"})
return
}
if !s.fsAgent(w, q.To, true) { if !s.fsAgent(w, q.To, true) {
return return
} }
+89
View File
@@ -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})
}
+14 -3
View File
@@ -7,6 +7,9 @@
set -eu set -eu
HOST="${HOST:-{{.Host}}}" 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}}}" {{if .Secret}}ENROLL_SECRET="${ENROLL_SECRET:-{{.Secret}}}"
{{end}} {{end}}
BIN_PATH="/usr/local/bin/lpagent" BIN_PATH="/usr/local/bin/lpagent"
@@ -108,9 +111,9 @@ fi
# --- downloader ------------------------------------------------------------- # --- downloader -------------------------------------------------------------
download() { # download URL DEST download() { # download URL DEST
if command -v curl >/dev/null 2>&1; then 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 elif command -v wget >/dev/null 2>&1; then
wget -qO "$2" "$1" wget -q -T 30 -O "$2" "$1"
else else
red "Нужен curl или wget."; exit 1 red "Нужен curl или wget."; exit 1
fi fi
@@ -118,7 +121,14 @@ download() { # download URL DEST
bold "Скачивание агента ($ARCH) с $HOST…" bold "Скачивание агента ($ARCH) с $HOST…"
TMP="$(mktemp)" 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" install -m 0755 "$TMP" "$BIN_PATH"
rm -f "$TMP" rm -f "$TMP"
green "Бинарник установлен: $BIN_PATH" green "Бинарник установлен: $BIN_PATH"
@@ -129,6 +139,7 @@ chmod 700 "$CONF_DIR"
umask 077 umask 077
cat > "$CONF_FILE" <<EOF cat > "$CONF_FILE" <<EOF
HOST=$HOST HOST=$HOST
FALLBACK_HOST=$FALLBACK_HOST
ENROLL_SECRET=${ENROLL_SECRET:-} ENROLL_SECRET=${ENROLL_SECRET:-}
AGENT_NAME=$AGENT_NAME AGENT_NAME=$AGENT_NAME
STATE_PATH=$STATE_FILE STATE_PATH=$STATE_FILE
+3 -2
View File
@@ -86,8 +86,9 @@
$('.acct-mode').textContent = d.password_off ? 'вход только по ключу доступа' : d.keys.length ? 'вход по паролю или ключу' : 'вход по паролю'; $('.acct-mode').textContent = d.password_off ? 'вход только по ключу доступа' : d.keys.length ? 'вход по паролю или ключу' : 'вход по паролю';
const sw = $('.acct-pw .sw'); const sw = $('.acct-pw .sw');
sw.checked = !d.password_off; sw.checked = !d.password_off;
sw.disabled = !d.keys.length; sw.disabled = !d.keys.length || d.pw_locked;
$('.acct-pwnote').textContent = !d.keys.length ? 'выключить можно, когда есть хотя бы один ключ' $('.acct-pwnote').textContent = d.pw_locked ? 'закреплено администратором' + (d.password_off ? ': только по ключу' : '')
: !d.keys.length ? 'выключить можно, когда есть хотя бы один ключ'
: d.password_off ? 'выключен: войти можно только ключом' : 'можно выключить и входить только ключом'; : d.password_off ? 'выключен: войти можно только ключом' : 'можно выключить и входить только ключом';
var ul = $('.acct-keys'); var ul = $('.acct-keys');
ul.innerHTML = d.keys.length ? '' : '<li class="muted">Ключей пока нет.</li>'; ul.innerHTML = d.keys.length ? '' : '<li class="muted">Ключей пока нет.</li>';
+105
View File
@@ -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 = '<div class="muted">загрузка…</div>';
$('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 = '<input type="checkbox"><span class="dot ' + (a.online ? 'on' : 'off') + '"></span><span class="ua-name"></span><span class="muted ua-host"></span>';
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 = '<div class="muted">машин пока нет</div>';
});
});
$('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'; };
})();
+5 -4
View File
@@ -12,7 +12,7 @@
<div class="brand"><a class="home" href="/" title="К списку машин">LPRA</a> · Удалённый доступ</div> <div class="brand"><a class="home" href="/" title="К списку машин">LPRA</a> · Удалённый доступ</div>
<nav> <nav>
<a href="/">Машины</a> <a href="/">Машины</a>
<a href="/settings">Настройки</a> {{if .IsAdmin}}<a href="/settings">Настройки</a>{{end}}
<button type="button" class="btn ghost icon acct-btn" id="acct" title="Аккаунт" aria-label="Аккаунт"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="8" r="4"/><path d="M4 21a8 8 0 0 1 16 0"/></svg></button> <button type="button" class="btn ghost icon acct-btn" id="acct" title="Аккаунт" aria-label="Аккаунт"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="8" r="4"/><path d="M4 21a8 8 0 0 1 16 0"/></svg></button>
</nav> </nav>
</header> </header>
@@ -47,13 +47,13 @@
<a class="btn ghost small icon" href="/agents/{{.ID}}/edit" data-tip="Изменить" aria-label="Изменить"> <a class="btn ghost small icon" href="/agents/{{.ID}}/edit" data-tip="Изменить" aria-label="Изменить">
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 20h9"/><path d="M16.5 3.5a2.12 2.12 0 0 1 3 3L7 19l-4 1 1-4 12.5-12.5z"/></svg> <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 20h9"/><path d="M16.5 3.5a2.12 2.12 0 0 1 3 3L7 19l-4 1 1-4 12.5-12.5z"/></svg>
</a> </a>
<form method="post" action="/agents/{{.ID}}/delete" style="display:inline" {{if $.IsAdmin}}<form method="post" action="/agents/{{.ID}}/delete" style="display:inline"
onsubmit="return confirm('Удалить машину {{.Name}}? Сервис на ней продолжит работать, но перестанет быть виден. Чтобы снова подключиться - переустановите агента.')"> onsubmit="return confirm('Удалить машину {{.Name}}? Сервис на ней продолжит работать, но перестанет быть виден. Чтобы снова подключиться - переустановите агента.')">
<input type="hidden" name="csrf" value="{{$.CSRF}}"> <input type="hidden" name="csrf" value="{{$.CSRF}}">
<button class="btn danger small icon" type="submit" data-tip="Удалить" aria-label="Удалить"> <button class="btn danger small icon" type="submit" data-tip="Удалить" aria-label="Удалить">
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 6h18"/><path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/><line x1="10" y1="11" x2="10" y2="17"/><line x1="14" y1="11" x2="14" y2="17"/></svg> <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 6h18"/><path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/><line x1="10" y1="11" x2="10" y2="17"/><line x1="14" y1="11" x2="14" y2="17"/></svg>
</button> </button>
</form> </form>{{end}}
</div> </div>
</td> </td>
</tr> </tr>
@@ -61,8 +61,9 @@
</tbody> </tbody>
</table> </table>
{{else}} {{else}}
<p class="muted">Пока нет подключённых машин. Добавьте первую в разделе {{if .IsAdmin}}<p class="muted">Пока нет подключённых машин. Добавьте первую в разделе
<a href="/settings">Настройки</a> - там одна команда для установки.</p> <a href="/settings">Настройки</a> - там одна команда для установки.</p>
{{else}}<p class="muted">Вам пока не выдали ни одной машины - попросите администратора.</p>{{end}}
{{end}} {{end}}
</div> </div>
</div> </div>
+2 -2
View File
@@ -112,7 +112,7 @@
{{end}} {{end}}
</div> </div>
<div class="card"> {{if .IsAdmin}}<div class="card">
<h2>Удаление</h2> <h2>Удаление</h2>
<p class="muted">Удаляет машину из панели. Сервис на самой машине продолжит работать, <p class="muted">Удаляет машину из панели. Сервис на самой машине продолжит работать,
но перестанет быть виден; чтобы подключиться снова - переустановите агента.</p> но перестанет быть виден; чтобы подключиться снова - переустановите агента.</p>
@@ -121,7 +121,7 @@
<input type="hidden" name="csrf" value="{{.CSRF}}"> <input type="hidden" name="csrf" value="{{.CSRF}}">
<button class="btn danger" type="submit">Удалить машину</button> <button class="btn danger" type="submit">Удалить машину</button>
</form> </form>
</div> </div>{{end}}
</div> </div>
</body> </body>
</html> </html>
+90 -14
View File
@@ -8,6 +8,36 @@
<script src="{{asset "pin.js"}}"></script> <script src="{{asset "pin.js"}}"></script>
<style> <style>
/* An install command, with a button to copy it at its right. */ /* An install command, with a button to copy it at its right. */
/* Users: role, machines, password sign-in. */
.tablewrap { overflow-x:auto; }
table.users th { font-size:12px; font-weight:500; color:var(--muted); text-align:left; white-space:nowrap; }
table.users td { vertical-align:middle; }
table.users select.u-role { width:auto; height:30px; padding:2px 30px 2px 8px; font-size:13px; }
table.users .u-agents { vertical-align:middle; margin-left:4px; }
table.users .u-count { font-size:12px; margin-left:2px; }
table.users .u-lock { width:16px; height:16px; cursor:pointer; }
.u-note { font-size:12px; color:var(--ok); opacity:0; }
.u-note.bad { color:var(--danger); }
.u-note.fade { animation:u-note 3s ease forwards; }
@keyframes u-note { 0%, 70% { opacity:1; } 100% { opacity:0; } }
.u-add { margin-top:14px; }
.u-pick { display:flex; flex-wrap:wrap; gap:6px; align-items:center; animation:u-in .2s ease; }
.u-pick[hidden] { display:none; }
.u-pick > .muted:first-child { flex-basis:100%; }
.u-chip { display:inline-flex; align-items:center; gap:6px; margin:0; padding:5px 10px; border:1px solid var(--border); border-radius:16px;
font-size:13px; color:var(--text); cursor:pointer; transition:border-color .15s, background .15s; }
.u-chip:has(input:checked) { border-color:var(--accent); background:#12202f; }
.u-chip input { width:14px; height:14px; margin:0; }
@keyframes u-in { from { opacity:0; transform:translateY(-4px); } }
#uadlg { max-width:480px; }
.ua-tools { display:flex; gap:8px; margin-bottom:10px; }
.ua-tools input { flex:1; height:32px; }
.ua-list { max-height:50vh; overflow:auto; border:1px solid var(--border); border-radius:9px; padding:4px; }
.ua-item { display:flex; align-items:center; gap:10px; margin:0; padding:8px 10px; border-radius:7px; cursor:pointer; color:var(--text); font-size:14px; }
.ua-item:hover { background:#151a21; }
.ua-item[hidden] { display:none; }
.ua-item input { width:16px; height:16px; margin:0; }
.ua-host { margin-left:auto; font-size:12px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
.copybox { position:relative; } .copybox { position:relative; }
.copybox pre { padding-right:48px; } .copybox pre { padding-right:48px; }
.copybox .copy { position:absolute; top:7px; right:7px; width:30px; height:30px; padding:0; .copybox .copy { position:absolute; top:7px; right:7px; width:30px; height:30px; padding:0;
@@ -54,6 +84,12 @@
<div class="copybox"><pre id="curl">{{.InstallCurl}}</pre><button type="button" class="btn ghost small icon sq copy" data-copy="curl" title="Скопировать" aria-label="Скопировать"><svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="12" height="12" rx="2"/><path d="M5 15H4a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1h10a1 1 0 0 1 1 1v1"/></svg></button></div> <div class="copybox"><pre id="curl">{{.InstallCurl}}</pre><button type="button" class="btn ghost small icon sq copy" data-copy="curl" title="Скопировать" aria-label="Скопировать"><svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="12" height="12" rx="2"/><path d="M5 15H4a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1h10a1 1 0 0 1 1 1v1"/></svg></button></div>
<label>wget</label> <label>wget</label>
<div class="copybox"><pre id="wget">{{.InstallWget}}</pre><button type="button" class="btn ghost small icon sq copy" data-copy="wget" title="Скопировать" aria-label="Скопировать"><svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="12" height="12" rx="2"/><path d="M5 15H4a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1h10a1 1 0 0 1 1 1v1"/></svg></button></div> <div class="copybox"><pre id="wget">{{.InstallWget}}</pre><button type="button" class="btn ghost small icon sq copy" data-copy="wget" title="Скопировать" aria-label="Скопировать"><svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="12" height="12" rx="2"/><path d="M5 15H4a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1h10a1 1 0 0 1 1 1v1"/></svg></button></div>
{{if .Direct}}
<label>Короткая через прямой адрес (если основной недоступен)</label>
<div class="copybox"><pre id="dshort">{{.DirectShort}}</pre><button type="button" class="btn ghost small icon sq copy" data-copy="dshort" title="Скопировать" aria-label="Скопировать"><svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="12" height="12" rx="2"/><path d="M5 15H4a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1h10a1 1 0 0 1 1 1v1"/></svg></button></div>
<label>curl через прямой адрес</label>
<div class="copybox"><pre id="dcurl">{{.DirectCurl}}</pre><button type="button" class="btn ghost small icon sq copy" data-copy="dcurl" title="Скопировать" aria-label="Скопировать"><svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="12" height="12" rx="2"/><path d="M5 15H4a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1h10a1 1 0 0 1 1 1v1"/></svg></button></div>
{{end}}
<p class="muted">Без переменных окружения скрипт спросит секрет интерактивно: <p class="muted">Без переменных окружения скрипт спросит секрет интерактивно:
<code>curl -fsSL {{.Host}}/install.sh | sudo sh</code></p> <code>curl -fsSL {{.Host}}/install.sh | sudo sh</code></p>
</details> </details>
@@ -61,11 +97,18 @@
<div class="card"> <div class="card">
<h2>Адрес хоста</h2> <h2>Адрес хоста</h2>
<p class="muted">URL, на который звонят агенты. Подставляется в скрипт установки.</p> <form method="post" action="/settings/host" class="hostform">
<form method="post" action="/settings/host" class="row">
<input type="hidden" name="csrf" value="{{.CSRF}}"> <input type="hidden" name="csrf" value="{{.CSRF}}">
<input type="text" name="host" value="{{.Host}}" style="max-width:420px"> <label for="host">Основной</label>
<button type="submit">Сохранить</button> <input type="text" id="host" name="host" value="{{.Host}}" placeholder="https://lpra.example.com">
<p class="muted">URL, на который звонят агенты. Подставляется в скрипт установки.</p>
<label for="direct">Прямой (без Cloudflare) - необязательно</label>
<input type="text" id="direct" name="direct" value="{{.Direct}}" placeholder="https://direct.lpra.example.com">
<p class="muted">Имя, которое ведёт прямо на сервер, мимо прокси. Агенты переходят на него, если основной
недоступен 30 секунд, и возвращаются, когда он ответит; уже установленные получат его сами. Установщик
качает агента с него, если основной не отвечает. Если хост туннеля ниже не задан, туннель тоже идёт на этот хост.
Ключи доступа с основного домена работают на прямом, если он его поддомен (direct.lpra.example.com).</p>
<div><button type="submit">Сохранить</button></div>
</form> </form>
</div> </div>
@@ -103,13 +146,27 @@
</div> </div>
<div class="card"> <div class="card">
<h2>Администраторы</h2> <h2 id="users">Пользователи</h2>
<table> <p class="muted" style="font-size:13px">Администратор видит всё. Пользователь - только выданные ему машины, без настроек.
«Пароль» - можно ли входить по паролю (выключить можно, когда у человека есть ключ доступа);
«Закрепить» - он не сможет сам поменять это в своём аккаунте.</p>
<div class="tablewrap"><table class="users">
<thead><tr><th>Имя</th><th>Роль</th><th>Вход</th><th title="Вход по паролю">Пароль</th><th title="Не даёт менять вход по паролю самому">Закрепить</th><th></th></tr></thead>
<tbody> <tbody>
{{range .Admins}} {{range .Admins}}
<tr> <tr data-id="{{.ID}}" data-name="{{.Username}}">
<td>{{.Username}}</td> <td>{{.Username}}{{if eq .ID $.Me.ID}} <span class="muted">(вы)</span>{{end}}</td>
<td class="muted">{{with index $.KeyCounts .ID}}ключей доступа: {{.}}{{else}}по паролю{{end}}{{if index $.PassOff .ID}} · пароль выключен{{end}}</td> <td class="nowrap">
<select class="u-role" aria-label="Роль">
<option value="admin"{{if .IsAdmin}} selected{{end}}>Администратор</option>
<option value="user"{{if not .IsAdmin}} selected{{end}}>Пользователь</option>
</select>
<button type="button" class="btn ghost small icon sq u-agents" title="Машины пользователя" aria-label="Машины пользователя"{{if .IsAdmin}} hidden{{end}}><svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="3" width="20" height="14" rx="2"/><path d="M8 21h8M12 17v4"/></svg></button>
<span class="muted u-count"{{if .IsAdmin}} hidden{{end}}>{{index $.AgentCounts .ID}}</span>
</td>
<td class="muted">{{with index $.KeyCounts .ID}}ключей: {{.}}{{else}}только пароль{{end}}</td>
<td><input type="checkbox" class="sw u-pw" aria-label="Вход по паролю"{{if not (index $.PassOff .ID)}} checked{{end}}{{if not (index $.KeyCounts .ID)}} disabled title="Нет ключей доступа - пароль выключить нельзя"{{end}}></td>
<td><input type="checkbox" class="u-lock" aria-label="Закрепить"{{if .PwLocked}} checked{{end}}></td>
<td style="text-align:right" class="nowrap"> <td style="text-align:right" class="nowrap">
{{if index $.KeyCounts .ID}} {{if index $.KeyCounts .ID}}
<form method="post" action="/settings/admins/keys/reset" style="display:inline" <form method="post" action="/settings/admins/keys/reset" style="display:inline"
@@ -120,7 +177,7 @@
</form> </form>
{{end}} {{end}}
<form method="post" action="/settings/admins/delete" style="display:inline" <form method="post" action="/settings/admins/delete" style="display:inline"
onsubmit="return confirm('Удалить администратора {{.Username}}?')"> onsubmit="return confirm('Удалить {{.Username}}?')">
<input type="hidden" name="csrf" value="{{$.CSRF}}"> <input type="hidden" name="csrf" value="{{$.CSRF}}">
<input type="hidden" name="id" value="{{.ID}}"> <input type="hidden" name="id" value="{{.ID}}">
<button class="btn danger small" type="submit">Удалить</button> <button class="btn danger small" type="submit">Удалить</button>
@@ -129,17 +186,35 @@
</tr> </tr>
{{end}} {{end}}
</tbody> </tbody>
</table> </table></div>
<form method="post" action="/settings/admins/add" style="margin-top:14px"> <form method="post" action="/settings/admins/add" class="u-add">
<input type="hidden" name="csrf" value="{{.CSRF}}"> <input type="hidden" name="csrf" value="{{.CSRF}}">
<div class="row"> <div class="row">
<input type="text" name="username" placeholder="логин" style="max-width:200px" required> <input type="text" name="username" placeholder="логин" style="max-width:200px" required autocomplete="off">
<input type="password" name="password" placeholder="пароль (мин. 8)" style="max-width:200px" required> <input type="password" name="password" placeholder="пароль (мин. 8)" style="max-width:200px" required autocomplete="new-password">
<select name="role" id="newrole" style="max-width:180px">
<option value="admin">Администратор</option>
<option value="user">Пользователь</option>
</select>
<button type="submit">Добавить</button> <button type="submit">Добавить</button>
</div> </div>
<div class="u-pick" id="newagents" hidden>
<div class="muted" style="font-size:13px;margin:10px 0 6px">Машины, к которым у него будет доступ:</div>
{{range .AllAgents}}<label class="u-chip"><input type="checkbox" name="agents" value="{{.ID}}"><span>{{.Name}}</span></label>{{else}}<span class="muted">машин пока нет</span>{{end}}
</div>
</form> </form>
</div> </div>
<dialog id="uadlg" tabindex="-1">
<div class="dlg-h"><strong style="flex:1">Машины пользователя <span id="uaname"></span></strong>
<button type="button" class="btn ghost small icon close x" id="uaclose" title="Закрыть" aria-label="Закрыть"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"><path d="M18 6 6 18M6 6l12 12"/></svg></button></div>
<div class="dlg-b">
<div class="ua-tools"><input type="text" id="uafilter" placeholder="поиск" autocomplete="off"><button type="button" class="btn ghost small" id="uaall">Все</button><button type="button" class="btn ghost small" id="uanone">Ни одной</button></div>
<div class="ua-list" id="ualist"></div>
<div class="row" style="justify-content:flex-end;margin-top:14px"><button type="button" class="btn ghost" id="uacancel">Отмена</button><button type="button" id="uasave">Сохранить</button></div>
</div>
</dialog>
<div class="card"> <div class="card">
<h2>Firewall (ufw)</h2> <h2>Firewall (ufw)</h2>
{{if not .UfwInstalled}} {{if not .UfwInstalled}}
@@ -230,6 +305,7 @@
}); });
})(); })();
</script> </script>
<script src="{{asset "users.js"}}"></script>
<script src="{{asset "account.js"}}"></script> <script src="{{asset "account.js"}}"></script>
</body> </body>
</html> </html>
+1 -1
View File
@@ -97,7 +97,7 @@ func (s *Server) handleAgentWS(w http.ResponseWriter, r *http.Request) {
return wsjson.Write(wctx, conn, m) return wsjson.Write(wctx, conn, m)
} }
cfg := func() proto.WSMsg { cfg := func() proto.WSMsg {
return proto.WSMsg{T: "cfg", Tunnel: s.tunnelAdvert(), Transport: s.transportOf(a.ID)} return proto.WSMsg{T: "cfg", Tunnel: s.tunnelAdvert(), Transport: s.transportOf(a.ID), Fallback: s.db.DirectAddress()}
} }
if send(cfg()) != nil { if send(cfg()) != nil {
return return