fix: route cloud SSH to reachable router address
This commit is contained in:
+1
-1
@@ -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
@@ -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
@@ -1,6 +1,6 @@
|
||||
# OpenWrt RMM — актуальный roadmap
|
||||
|
||||
Актуализировано: 2026-07-23. Текущая стабильная линия агента: `0.6.4`.
|
||||
Актуализировано: 2026-07-23. Текущая стабильная линия агента: `0.6.5`.
|
||||
|
||||
## Цель продукта
|
||||
|
||||
|
||||
+1
-1
@@ -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.
|
||||
|
||||
@@ -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"}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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"),
|
||||
|
||||
Reference in New Issue
Block a user