From bb7221ef8b855313590814590cebdab15eae25da Mon Sep 17 00:00:00 2001 From: benya Date: Thu, 23 Jul 2026 12:52:21 +0300 Subject: [PATCH] fix: route cloud SSH to reachable router address --- CHECKLIST.md | 2 +- RELEASES.md | 6 +- ROADMAP.md | 2 +- agent/README.md | 2 +- agent/go/cmd/rmm-agent/main.go | 105 +++++++++++++++++- agent/go/cmd/rmm-agent/main_test.go | 41 ++++++- agent/package/luci-app-rmm-agent/README.md | 4 +- .../package/rmm-agent-go-production/Makefile | 2 +- .../package/rmm-agent-go-production/README.md | 2 +- agent/package/rmm-agent-go/Makefile | 2 +- web/app.js | 2 +- 11 files changed, 156 insertions(+), 14 deletions(-) diff --git a/CHECKLIST.md b/CHECKLIST.md index 85d8c18..c74ad4d 100644 --- a/CHECKLIST.md +++ b/CHECKLIST.md @@ -5,7 +5,7 @@ ## Работает сейчас - [x] Go server, SQLite/WAL, Docker/Compose и `/healthz`. -- [x] Go agent 0.6.4, heartbeat, команды, backoff и OpenWrt init integration. +- [x] Go agent 0.6.5, heartbeat, команды, backoff и OpenWrt init integration. - [x] APK/IPK и LuCI-пакет. - [x] Multi-user, роли admin/user, владение и передача роутеров. - [x] Профиль, e-mail, смена/сброс пароля и управление сессиями. diff --git a/RELEASES.md b/RELEASES.md index 3c49e1b..8568202 100644 --- a/RELEASES.md +++ b/RELEASES.md @@ -29,7 +29,7 @@ production upgrade even when the release is marked compatible. ## Agent releases -Tags use `agent-vMAJOR.MINOR.PATCH`, for example `agent-v0.6.4`. +Tags use `agent-vMAJOR.MINOR.PATCH`, for example `agent-v0.6.5`. An agent release contains the Go runtime, LuCI application and OpenWrt IPK/APK packages. Before tagging, the tag version must match `agentVersion` in the Go source and @@ -58,8 +58,8 @@ transition period; it should not silently reuse `v1`. git tag -a server-v0.8.0 -m "OpenWrt RMM Server 0.8.0" git push origin server-v0.8.0 -git tag -a agent-v0.6.4 -m "OpenWrt RMM Agent 0.6.4" -git push origin agent-v0.6.4 +git tag -a agent-v0.6.5 -m "OpenWrt RMM Agent 0.6.5" +git push origin agent-v0.6.5 ``` Pushing a server tag publishes the container image and creates a GitHub Release. Pushing diff --git a/ROADMAP.md b/ROADMAP.md index b8023e9..4ff9d61 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1,6 +1,6 @@ # OpenWrt RMM — актуальный roadmap -Актуализировано: 2026-07-23. Текущая стабильная линия агента: `0.6.4`. +Актуализировано: 2026-07-23. Текущая стабильная линия агента: `0.6.5`. ## Цель продукта diff --git a/agent/README.md b/agent/README.md index 428690b..de9141e 100644 --- a/agent/README.md +++ b/agent/README.md @@ -1,6 +1,6 @@ # OpenWrt RMM Agent -Current stable Go agent: `0.6.4`. It reports runtime health, pending command results, +Current stable Go agent: `0.6.5`. It reports runtime health, pending command results, and the last heartbeat transport error after connectivity is restored. Production Go agent for OpenWrt, with the shell implementation retained as a fallback runtime. diff --git a/agent/go/cmd/rmm-agent/main.go b/agent/go/cmd/rmm-agent/main.go index de41c5e..0c300d9 100644 --- a/agent/go/cmd/rmm-agent/main.go +++ b/agent/go/cmd/rmm-agent/main.go @@ -23,7 +23,7 @@ import ( "time" ) -const agentVersion = "0.6.4" +const agentVersion = "0.6.5" type agentRuntimeHealth struct { StartedAt time.Time @@ -922,6 +922,16 @@ func remoteSSHReverseOutput(ctx context.Context, cfg config, args map[string]str if !ok { return validationOutput, 2 } + localHost, err := selectRemoteSSHLocalHost( + remoteArgs.LocalHost, + remoteArgs.LocalPort, + localInterfaceIPv4Candidates(), + remoteSSHTargetReachable, + ) + if err != nil { + return err.Error() + "\n", 1 + } + remoteArgs.LocalHost = localHost _, _ = remoteSSHStopSession(cfg, remoteArgs.SessionID, remoteArgs.RemotePort) if err := os.MkdirAll(cfg.TunnelStateDir, 0o700); err != nil { return err.Error() + "\n", 1 @@ -973,6 +983,99 @@ func remoteSSHReverseOutput(ctx context.Context, cfg config, args map[string]str return b.String(), 0 } +func selectRemoteSSHLocalHost(requestedHost string, port int, candidates []string, reachable func(string, int) bool) (string, error) { + hosts := []string{requestedHost} + requestedIP := net.ParseIP(requestedHost) + if requestedIP != nil && requestedIP.IsLoopback() { + hosts = append(hosts, candidates...) + } + seen := make(map[string]struct{}, len(hosts)) + for _, host := range hosts { + host = strings.TrimSpace(host) + if host == "" { + continue + } + if _, ok := seen[host]; ok { + continue + } + seen[host] = struct{}{} + if reachable(host, port) { + return host, nil + } + } + return "", fmt.Errorf("local SSH service is unavailable on port %d (checked %s)", port, strings.Join(uniqueHostsInOrder(hosts), ", ")) +} + +func uniqueHostsInOrder(values []string) []string { + result := make([]string, 0, len(values)) + seen := make(map[string]struct{}, len(values)) + for _, value := range values { + value = strings.TrimSpace(value) + if value == "" { + continue + } + if _, ok := seen[value]; ok { + continue + } + seen[value] = struct{}{} + result = append(result, value) + } + return result +} + +func remoteSSHTargetReachable(host string, port int) bool { + connection, err := net.DialTimeout("tcp", net.JoinHostPort(host, strconv.Itoa(port)), 750*time.Millisecond) + if err != nil { + return false + } + _ = connection.Close() + return true +} + +func localInterfaceIPv4Candidates() []string { + return parseLocalInterfaceIPv4Candidates(commandOutput("ip", "-4", "-o", "addr", "show")) +} + +func parseLocalInterfaceIPv4Candidates(output string) []string { + type candidate struct { + host string + priority int + } + var candidates []candidate + seen := map[string]struct{}{} + for _, line := range strings.Split(output, "\n") { + fields := strings.Fields(line) + if len(fields) < 4 || fields[2] != "inet" { + continue + } + interfaceName := strings.TrimSuffix(fields[1], ":") + ip, _, err := net.ParseCIDR(fields[3]) + if err != nil || ip == nil || ip.IsLoopback() || ip.IsUnspecified() || ip.IsLinkLocalUnicast() { + continue + } + host := ip.String() + if _, ok := seen[host]; ok { + continue + } + seen[host] = struct{}{} + priority := 10 + if interfaceName == "br-lan" || interfaceName == "lan" { + priority = 0 + } + candidates = append(candidates, candidate{host: host, priority: priority}) + } + for i := 1; i < len(candidates); i++ { + for j := i; j > 0 && candidates[j].priority < candidates[j-1].priority; j-- { + candidates[j], candidates[j-1] = candidates[j-1], candidates[j] + } + } + result := make([]string, 0, len(candidates)) + for _, item := range candidates { + result = append(result, item.host) + } + return result +} + func remoteSSHCommand(cfg config, args remoteSSHArgs) (string, []string, error) { if sshPath, err := exec.LookPath("ssh"); err == nil { cmdArgs := []string{"-N", "-o", "StrictHostKeyChecking=accept-new", "-o", "ExitOnForwardFailure=yes", "-o", "ServerAliveInterval=15", "-o", "ServerAliveCountMax=2"} diff --git a/agent/go/cmd/rmm-agent/main_test.go b/agent/go/cmd/rmm-agent/main_test.go index d9e0f98..5e16327 100644 --- a/agent/go/cmd/rmm-agent/main_test.go +++ b/agent/go/cmd/rmm-agent/main_test.go @@ -9,11 +9,50 @@ import ( ) func TestAgentVersionIsStable(t *testing.T) { - if agentVersion != "0.6.4" { + if agentVersion != "0.6.5" { t.Fatalf("unexpected agent version %q", agentVersion) } } +func TestParseLocalInterfaceIPv4CandidatesPrefersLAN(t *testing.T) { + output := `1: lo inet 127.0.0.1/8 scope host lo +2: eth0 inet 203.0.113.10/24 brd 203.0.113.255 scope global eth0 +5: br-lan inet 10.10.10.1/24 brd 10.10.10.255 scope global br-lan +6: guest inet 192.168.50.1/24 brd 192.168.50.255 scope global guest` + got := parseLocalInterfaceIPv4Candidates(output) + want := []string{"10.10.10.1", "203.0.113.10", "192.168.50.1"} + if len(got) != len(want) { + t.Fatalf("unexpected candidates: %#v", got) + } + for i := range want { + if got[i] != want[i] { + t.Fatalf("candidate %d = %q, want %q", i, got[i], want[i]) + } + } +} + +func TestSelectRemoteSSHLocalHostFallsBackFromLoopbackToLAN(t *testing.T) { + reachable := func(host string, port int) bool { + return host == "10.10.10.1" && port == 22 + } + got, err := selectRemoteSSHLocalHost("127.0.0.1", 22, []string{"10.10.10.1", "192.168.50.1"}, reachable) + if err != nil { + t.Fatalf("selectRemoteSSHLocalHost() error: %v", err) + } + if got != "10.10.10.1" { + t.Fatalf("selected host %q, want LAN address", got) + } +} + +func TestSelectRemoteSSHLocalHostDoesNotReplaceExplicitHost(t *testing.T) { + reachable := func(host string, port int) bool { + return host == "10.10.10.1" + } + if _, err := selectRemoteSSHLocalHost("192.0.2.10", 22, []string{"10.10.10.1"}, reachable); err == nil { + t.Fatal("explicit unreachable host unexpectedly fell back to another interface") + } +} + func TestAgentRuntimeHealthSnapshot(t *testing.T) { spoolDir := t.TempDir() if err := os.WriteFile(filepath.Join(spoolDir, "pending.json"), []byte(`{}`), 0o600); err != nil { diff --git a/agent/package/luci-app-rmm-agent/README.md b/agent/package/luci-app-rmm-agent/README.md index 6e9d2b9..a96e508 100644 --- a/agent/package/luci-app-rmm-agent/README.md +++ b/agent/package/luci-app-rmm-agent/README.md @@ -67,7 +67,7 @@ application to the router. Do not copy or install the shell runtime at the same cd dist/rmm-openwrt-25.12.4-ramips-mt7621 sha256sum -c SHA256SUMS scp \ - rmm-agent-go-production-0.6.4-r1.apk \ + rmm-agent-go-production-0.6.5-r1.apk \ luci-app-rmm-agent-0.2.1-r2.apk \ root@ROUTER_IP:/tmp/ ``` @@ -76,7 +76,7 @@ Then install the locally built, unsigned packages over SSH: ```sh apk add --allow-untrusted \ - /tmp/rmm-agent-go-production-0.6.4-r1.apk \ + /tmp/rmm-agent-go-production-0.6.5-r1.apk \ /tmp/luci-app-rmm-agent-0.2.1-r2.apk /etc/init.d/rpcd restart /etc/init.d/uhttpd restart diff --git a/agent/package/rmm-agent-go-production/Makefile b/agent/package/rmm-agent-go-production/Makefile index 18bc769..d8b2758 100644 --- a/agent/package/rmm-agent-go-production/Makefile +++ b/agent/package/rmm-agent-go-production/Makefile @@ -2,7 +2,7 @@ include $(TOPDIR)/rules.mk PKG_NAME:=rmm-agent-go-production -PKG_VERSION:=0.6.4 +PKG_VERSION:=0.6.5 PKG_RELEASE:=1 PKG_MAINTAINER:=RMM OpenWrt diff --git a/agent/package/rmm-agent-go-production/README.md b/agent/package/rmm-agent-go-production/README.md index 1d6e86a..3d6c7e2 100644 --- a/agent/package/rmm-agent-go-production/README.md +++ b/agent/package/rmm-agent-go-production/README.md @@ -34,5 +34,5 @@ opkg install /tmp/rmm-agent-go-production_*.ipk This package is intended for the final shell-to-Go migration when the router should keep the same RMM object identity. -Version `0.6.4` uses the cloud tunnel exclusively and no longer discovers or publishes the +Version `0.6.5` uses the cloud tunnel exclusively and no longer discovers or publishes the router's public WAN addresses. diff --git a/agent/package/rmm-agent-go/Makefile b/agent/package/rmm-agent-go/Makefile index 3ee2d84..0e1b5d8 100644 --- a/agent/package/rmm-agent-go/Makefile +++ b/agent/package/rmm-agent-go/Makefile @@ -2,7 +2,7 @@ include $(TOPDIR)/rules.mk PKG_NAME:=rmm-agent-go -PKG_VERSION:=0.6.4 +PKG_VERSION:=0.6.5 PKG_RELEASE:=1 PKG_MAINTAINER:=RMM OpenWrt diff --git a/web/app.js b/web/app.js index ef8e245..9fca27f 100644 --- a/web/app.js +++ b/web/app.js @@ -38,7 +38,7 @@ const state = { let eventSource = null; let liveRefreshTimer = null; -const EXPECTED_AGENT_VERSION = "0.6.4"; +const EXPECTED_AGENT_VERSION = "0.6.5"; const els = { loginView: document.querySelector("#loginView"),