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:
1 parent
36dc12b69d
commit
021da93229
29 files changed
+844
-97
No files matched your search
@@ -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 (ограничение
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
0.1.1
|
||||
0.1.2
|
||||
@@ -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"),
|
||||
|
||||
+44
-16
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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
|
||||
|
||||
+129
-20
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
+53
-14
@@ -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)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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})
|
||||
}
|
||||
@@ -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" <<EOF
|
||||
HOST=$HOST
|
||||
FALLBACK_HOST=$FALLBACK_HOST
|
||||
ENROLL_SECRET=${ENROLL_SECRET:-}
|
||||
AGENT_NAME=$AGENT_NAME
|
||||
STATE_PATH=$STATE_FILE
|
||||
|
||||
@@ -86,8 +86,9 @@
|
||||
$('.acct-mode').textContent = d.password_off ? 'вход только по ключу доступа' : d.keys.length ? 'вход по паролю или ключу' : 'вход по паролю';
|
||||
const sw = $('.acct-pw .sw');
|
||||
sw.checked = !d.password_off;
|
||||
sw.disabled = !d.keys.length;
|
||||
$('.acct-pwnote').textContent = !d.keys.length ? 'выключить можно, когда есть хотя бы один ключ'
|
||||
sw.disabled = !d.keys.length || d.pw_locked;
|
||||
$('.acct-pwnote').textContent = d.pw_locked ? 'закреплено администратором' + (d.password_off ? ': только по ключу' : '')
|
||||
: !d.keys.length ? 'выключить можно, когда есть хотя бы один ключ'
|
||||
: d.password_off ? 'выключен: войти можно только ключом' : 'можно выключить и входить только ключом';
|
||||
var ul = $('.acct-keys');
|
||||
ul.innerHTML = d.keys.length ? '' : '<li class="muted">Ключей пока нет.</li>';
|
||||
|
||||
@@ -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'; };
|
||||
})();
|
||||
@@ -12,7 +12,7 @@
|
||||
<div class="brand"><a class="home" href="/" title="К списку машин">LPRA</a> · Удалённый доступ</div>
|
||||
<nav>
|
||||
<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>
|
||||
</nav>
|
||||
</header>
|
||||
@@ -47,13 +47,13 @@
|
||||
<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>
|
||||
</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}}? Сервис на ней продолжит работать, но перестанет быть виден. Чтобы снова подключиться - переустановите агента.')">
|
||||
<input type="hidden" name="csrf" value="{{$.CSRF}}">
|
||||
<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>
|
||||
</button>
|
||||
</form>
|
||||
</form>{{end}}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
@@ -61,8 +61,9 @@
|
||||
</tbody>
|
||||
</table>
|
||||
{{else}}
|
||||
<p class="muted">Пока нет подключённых машин. Добавьте первую в разделе
|
||||
{{if .IsAdmin}}<p class="muted">Пока нет подключённых машин. Добавьте первую в разделе
|
||||
<a href="/settings">Настройки</a> - там одна команда для установки.</p>
|
||||
{{else}}<p class="muted">Вам пока не выдали ни одной машины - попросите администратора.</p>{{end}}
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -112,7 +112,7 @@
|
||||
{{end}}
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
{{if .IsAdmin}}<div class="card">
|
||||
<h2>Удаление</h2>
|
||||
<p class="muted">Удаляет машину из панели. Сервис на самой машине продолжит работать,
|
||||
но перестанет быть виден; чтобы подключиться снова - переустановите агента.</p>
|
||||
@@ -121,7 +121,7 @@
|
||||
<input type="hidden" name="csrf" value="{{.CSRF}}">
|
||||
<button class="btn danger" type="submit">Удалить машину</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>{{end}}
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -8,6 +8,36 @@
|
||||
<script src="{{asset "pin.js"}}"></script>
|
||||
<style>
|
||||
/* 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 pre { padding-right:48px; }
|
||||
.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>
|
||||
<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>
|
||||
{{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">Без переменных окружения скрипт спросит секрет интерактивно:
|
||||
<code>curl -fsSL {{.Host}}/install.sh | sudo sh</code></p>
|
||||
</details>
|
||||
@@ -61,11 +97,18 @@
|
||||
|
||||
<div class="card">
|
||||
<h2>Адрес хоста</h2>
|
||||
<p class="muted">URL, на который звонят агенты. Подставляется в скрипт установки.</p>
|
||||
<form method="post" action="/settings/host" class="row">
|
||||
<form method="post" action="/settings/host" class="hostform">
|
||||
<input type="hidden" name="csrf" value="{{.CSRF}}">
|
||||
<input type="text" name="host" value="{{.Host}}" style="max-width:420px">
|
||||
<button type="submit">Сохранить</button>
|
||||
<label for="host">Основной</label>
|
||||
<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>
|
||||
</div>
|
||||
|
||||
@@ -103,13 +146,27 @@
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>Администраторы</h2>
|
||||
<table>
|
||||
<h2 id="users">Пользователи</h2>
|
||||
<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>
|
||||
{{range .Admins}}
|
||||
<tr>
|
||||
<td>{{.Username}}</td>
|
||||
<td class="muted">{{with index $.KeyCounts .ID}}ключей доступа: {{.}}{{else}}по паролю{{end}}{{if index $.PassOff .ID}} · пароль выключен{{end}}</td>
|
||||
<tr data-id="{{.ID}}" data-name="{{.Username}}">
|
||||
<td>{{.Username}}{{if eq .ID $.Me.ID}} <span class="muted">(вы)</span>{{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">
|
||||
{{if index $.KeyCounts .ID}}
|
||||
<form method="post" action="/settings/admins/keys/reset" style="display:inline"
|
||||
@@ -120,7 +177,7 @@
|
||||
</form>
|
||||
{{end}}
|
||||
<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="id" value="{{.ID}}">
|
||||
<button class="btn danger small" type="submit">Удалить</button>
|
||||
@@ -129,17 +186,35 @@
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
<form method="post" action="/settings/admins/add" style="margin-top:14px">
|
||||
</table></div>
|
||||
<form method="post" action="/settings/admins/add" class="u-add">
|
||||
<input type="hidden" name="csrf" value="{{.CSRF}}">
|
||||
<div class="row">
|
||||
<input type="text" name="username" placeholder="логин" style="max-width:200px" required>
|
||||
<input type="password" name="password" placeholder="пароль (мин. 8)" 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 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>
|
||||
</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>
|
||||
</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">
|
||||
<h2>Firewall (ufw)</h2>
|
||||
{{if not .UfwInstalled}}
|
||||
@@ -230,6 +305,7 @@
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
<script src="{{asset "users.js"}}"></script>
|
||||
<script src="{{asset "account.js"}}"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -97,7 +97,7 @@ func (s *Server) handleAgentWS(w http.ResponseWriter, r *http.Request) {
|
||||
return wsjson.Write(wctx, conn, m)
|
||||
}
|
||||
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 {
|
||||
return
|
||||
|
||||
Reference in new issue
Block a user