fix: route cloud SSH to reachable router address

This commit is contained in:
benya
2026-07-23 12:52:21 +03:00
parent 9c81954eae
commit bb7221ef8b
11 changed files with 156 additions and 14 deletions
+1 -1
View File
@@ -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, смена/сброс пароля и управление сессиями.
+3 -3
View File
@@ -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
+1 -1
View File
@@ -1,6 +1,6 @@
# OpenWrt RMM — актуальный roadmap
Актуализировано: 2026-07-23. Текущая стабильная линия агента: `0.6.4`.
Актуализировано: 2026-07-23. Текущая стабильная линия агента: `0.6.5`.
## Цель продукта
+1 -1
View File
@@ -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.
+104 -1
View File
@@ -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"}
+40 -1
View File
@@ -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 {
+2 -2
View File
@@ -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
@@ -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
@@ -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.
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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"),