diff --git a/README.md b/README.md index 8abc9e8..cfc1979 100644 --- a/README.md +++ b/README.md @@ -83,8 +83,10 @@ DNS-запись без проксирования. Тогда: - агенты, не достучавшись до основного адреса 30 секунд, переходят на прямой и каждые 5 минут проверяют, не ожил ли основной; уже установленные агенты получают прямой адрес с сервера сами (он хранится в `state.json`); -- установщик (`FALLBACK_HOST`) качает агента с прямого, если основной не отвечает; в «Полных вариантах» - есть короткая команда и через прямой адрес; +- установщик берёт адреса с того домена, с которого его скачали: с основного - агент работает через основной + с резервом на прямой, с прямого - наоборот (и бинарник качается с прямого); если первый адрес не отвечает, + бинарник качается со второго. В «Полных вариантах» есть короткая команда и через прямой адрес; +- в «Изменить → Связь с сервером» у клиента, подключённого через прямой адрес, после статуса стоит «(direct)»; - если хост туннеля не задан, агентам отдаётся хост прямого адреса; - ключи доступа основного домена работают и на его поддомене. diff --git a/internal/agent/client.go b/internal/agent/client.go index 74f8448..394e37f 100644 --- a/internal/agent/client.go +++ b/internal/agent/client.go @@ -319,7 +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.learnAddrs(resp.Header.Get(proto.MainHeader), resp.Header.Get(proto.FallbackHeader)) a.applyTransport(resp.Header.Get(proto.TransportHeader)) a.dispatch(ctx, msg) return nil @@ -355,7 +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)) + a.learnAddrs(resp.Header.Get(proto.MainHeader), resp.Header.Get(proto.FallbackHeader)) return a.applyTunnelHeader(ctx, resp.Header) } diff --git a/internal/agent/hosts.go b/internal/agent/hosts.go index 4a23602..5f62422 100644 --- a/internal/agent/hosts.go +++ b/internal/agent/hosts.go @@ -54,10 +54,16 @@ func (a *Agent) fallback() string { 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 != "" { +// learnAddrs keeps the server's other address from the two it tells: the +// direct one, or the main one when the agent was set to the direct one. +func (a *Agent) learnAddrs(main, direct string) { + main = strings.TrimRight(strings.TrimSpace(main), "/") + direct = strings.TrimRight(strings.TrimSpace(direct), "/") + v := direct + if direct != "" && strings.EqualFold(direct, a.cfg.Host) { + v = main + } + if v == "" || strings.EqualFold(v, a.cfg.Host) || a.cfg.Fallback != "" { return } a.stateMu.Lock() @@ -67,7 +73,7 @@ func (a *Agent) learnFallback(v string) { } a.stateMu.Unlock() if !same { - log.Printf("direct address: %s", v) + log.Printf("fallback address: %s", v) _ = a.saveState() } } @@ -163,7 +169,7 @@ func (a *Agent) reachableAt(ctx context.Context, base string) bool { } resp.Body.Close() if resp.StatusCode < 300 { - a.learnFallback(resp.Header.Get(proto.FallbackHeader)) + a.learnAddrs(resp.Header.Get(proto.MainHeader), resp.Header.Get(proto.FallbackHeader)) return true } return false diff --git a/internal/agent/wslink.go b/internal/agent/wslink.go index 9bb1d12..5949c79 100644 --- a/internal/agent/wslink.go +++ b/internal/agent/wslink.go @@ -165,7 +165,7 @@ func (a *Agent) runWS(ctx context.Context) error { } case "cfg": a.applyTunnelAddr(ctx, m.Tunnel) - a.learnFallback(m.Fallback) + a.learnAddrs(m.Main, m.Fallback) a.applyTransport(m.Transport) if a.mode() == proto.TransportLP { return errors.New("set to long polling") diff --git a/internal/proto/proto.go b/internal/proto/proto.go index c401a71..df2bd06 100644 --- a/internal/proto/proto.go +++ b/internal/proto/proto.go @@ -77,6 +77,10 @@ const TransportHeader = "X-Transport" // to when the main one is out of reach. const FallbackHeader = "X-Fallback-Host" +// MainHeader carries the server's main address beside FallbackHeader, for an +// agent set to the direct one to fall back to the main one. +const MainHeader = "X-Main-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" @@ -91,6 +95,7 @@ type WSMsg struct { Tunnel string `json:"tunnel,omitempty"` Transport string `json:"transport,omitempty"` Fallback string `json:"fallback,omitempty"` + Main string `json:"main,omitempty"` } // Terminal / stream frame types on the session "in" channel (server -> agent). diff --git a/internal/server/handlers_agent.go b/internal/server/handlers_agent.go index 9dddd3b..37a7a69 100644 --- a/internal/server/handlers_agent.go +++ b/internal/server/handlers_agent.go @@ -45,6 +45,7 @@ func (s *Server) handlePoll(w http.ResponseWriter, r *http.Request) { } // The agent sends its version and OS user occasionally; record them when // present. Agents older than 0.0.2 send no user, so keep what we have. + s.viaDirectA.Store(a.ID, s.viaDirect(r)) v := r.Header.Get("X-Agent-Version") u := firstNonEmpty(r.Header.Get("X-Agent-User"), a.RunUser) if v != "" && (v != a.Version || u != a.RunUser) { @@ -69,6 +70,7 @@ func (s *Server) setTunnelHeader(w http.ResponseWriter) { } if d := s.db.DirectAddress(); d != "" { w.Header().Set(proto.FallbackHeader, d) + w.Header().Set(proto.MainHeader, s.db.HostAddress()) } } diff --git a/internal/server/handlers_panel.go b/internal/server/handlers_panel.go index 9936a3d..11461ed 100644 --- a/internal/server/handlers_panel.go +++ b/internal/server/handlers_panel.go @@ -11,6 +11,7 @@ import ( "slices" "strconv" "strings" + "sync" "time" "github.com/x-grk/longpolling-remote-access/internal/proto" @@ -227,6 +228,7 @@ func (s *Server) handleAgentEdit(w http.ResponseWriter, r *http.Request) { "UserErr": r.URL.Query().Get("uerr"), "Transport": firstNonEmpty(a.Transport, proto.TransportAuto), "OnWS": s.wsAgents.On(a.ID), + "ViaDirect": viaDirectOf(&s.viaDirectA, a.ID), "Online": s.online(a), } // The user list lives on the machine, so it is only known while it is online. @@ -437,7 +439,7 @@ func (s *Server) handleSettings(w http.ResponseWriter, r *http.Request) { "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), + "DirectCurl": fmt.Sprintf("curl -fsSL %s/install.sh | sudo HOST=%s FALLBACK_HOST=%s ENROLL_SECRET=%s sh", direct, direct, host, secret), "Secret": secret, "CodeLen": s.db.InstallCodeLen(), "Admins": admins, @@ -664,7 +666,7 @@ func (s *Server) handleUfwRevoke(w http.ResponseWriter, r *http.Request) { // handleInstallScript serves the generic installer; the secret is NOT baked in, // so it must be supplied via env or the interactive prompt. func (s *Server) handleInstallScript(w http.ResponseWriter, r *http.Request) { - s.renderInstall(w, "") + s.renderInstall(w, r, "") } // handleShortInstall serves the installer behind the short /i/ link with @@ -686,16 +688,35 @@ func (s *Server) handleShortInstall(w http.ResponseWriter, r *http.Request) { http.NotFound(w, r) return } - s.renderInstall(w, s.db.EnrollSecret()) + s.renderInstall(w, r, s.db.EnrollSecret()) } -// renderInstall writes install.sh with the current host and an optional secret. -func (s *Server) renderInstall(w http.ResponseWriter, secret string) { +// viaDirect reports whether the request came to the direct address. +func (s *Server) viaDirect(r *http.Request) bool { + d := directHost(s.db.DirectAddress()) + if d == "" { + return false + } + host := r.Host + if h, _, err := net.SplitHostPort(host); err == nil { + host = h + } + return strings.EqualFold(host, d) +} + +// renderInstall writes install.sh with the server's addresses and an +// optional secret. Fetched from the direct address, it makes that the +// agent's address and the main one its fallback; otherwise the other way. +func (s *Server) renderInstall(w http.ResponseWriter, r *http.Request, secret string) { + host, fallback := s.db.HostAddress(), s.db.DirectAddress() + if s.viaDirect(r) { + host, fallback = fallback, host + } 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, + "Host": host, + "Fallback": fallback, + "Secret": secret, }); err != nil { log.Printf("install.sh render: %v", err) http.Error(w, "internal error", http.StatusInternalServerError) @@ -707,3 +728,11 @@ func (s *Server) downloadHandler() http.Handler { // install one-liner works before the machine is enrolled. return http.FileServer(http.Dir(s.distDir)) } + +// viaDirectOf tells whether the agent last reached the server at the direct +// address. +func viaDirectOf(m *sync.Map, id string) bool { + v, _ := m.Load(id) + b, _ := v.(bool) + return b +} diff --git a/internal/server/server.go b/internal/server/server.go index 4350e85..43fe816 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -60,6 +60,7 @@ type Server struct { meters speedMeters // byte counts of the speed tests under way xfers xfers // files on their way between a browser and an agent ship shipping // files on their way from one machine to another + viaDirectA sync.Map // agent id -> true while it reaches the server at the direct address challenges challenges // passkey challenges handed out wsAgents wsAgents // agents on their WebSocket now pinMisses pinMisses // wrong PINs per session diff --git a/internal/server/web/install.sh.tmpl b/internal/server/web/install.sh.tmpl index 6424496..e5d076c 100644 --- a/internal/server/web/install.sh.tmpl +++ b/internal/server/web/install.sh.tmpl @@ -7,9 +7,10 @@ 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}}}" +# The server's other address (the direct one, not behind a proxy like +# Cloudflare, or the main one when this script came from the direct one): the +# agent turns to it when HOST is out of reach. +FALLBACK_HOST="${FALLBACK_HOST:-{{.Fallback}}}" {{if .Secret}}ENROLL_SECRET="${ENROLL_SECRET:-{{.Secret}}}" {{end}} BIN_PATH="/usr/local/bin/lpagent" @@ -123,7 +124,7 @@ bold "Скачивание агента ($ARCH) с $HOST…" TMP="$(mktemp)" if ! download "$HOST/download/agent-linux-$ARCH" "$TMP"; then if [ -n "$FALLBACK_HOST" ] && [ "$FALLBACK_HOST" != "$HOST" ]; then - bold "Основной адрес недоступен, скачивание с $FALLBACK_HOST…" + bold "$HOST недоступен, скачивание с $FALLBACK_HOST…" download "$FALLBACK_HOST/download/agent-linux-$ARCH" "$TMP" else red "Не удалось скачать агента."; exit 1 diff --git a/internal/server/web/templates/edit.html b/internal/server/web/templates/edit.html index 845f38b..9c48aae 100644 --- a/internal/server/web/templates/edit.html +++ b/internal/server/web/templates/edit.html @@ -76,7 +76,7 @@

Связь с сервером

- Сейчас: {{if not .Online}}офлайн{{else if .OnWS}}WebSocket{{else}}long polling{{end}} + Сейчас: {{if not .Online}}офлайн{{else if .OnWS}}WebSocket{{else}}long polling{{end}}{{if and .Online .ViaDirect}} (direct){{end}}

diff --git a/internal/server/wsagent.go b/internal/server/wsagent.go index dda2ad8..0914aaf 100644 --- a/internal/server/wsagent.go +++ b/internal/server/wsagent.go @@ -84,6 +84,7 @@ func (s *Server) handleAgentWS(w http.ResponseWriter, r *http.Request) { if err != nil { return } + s.viaDirectA.Store(a.ID, s.viaDirect(r)) conn.SetReadLimit(4 << 20) ctx, cancel := context.WithCancel(r.Context()) defer cancel() @@ -97,7 +98,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), Fallback: s.db.DirectAddress()} + return proto.WSMsg{T: "cfg", Tunnel: s.tunnelAdvert(), Transport: s.transportOf(a.ID), Fallback: s.db.DirectAddress(), Main: s.db.HostAddress()} } if send(cfg()) != nil { return