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

+44 -16
View File
@@ -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)
}