From 7e194e9f330d2ee9a7b41ca419db9e32c175bdfe Mon Sep 17 00:00:00 2001 From: Guillaume De Saint Martin Date: Mon, 27 Jul 2026 20:14:14 +0200 Subject: [PATCH] [Onboarding] simplify and make tailscale ip more flexible --- packages/commons/octobot_commons/network.py | 13 +++++ packages/commons/tests/test_network.py | 53 +++++++++++++++++++ .../node_api_interface/core/network.py | 6 +-- .../tests/core/test_network.py | 37 ++++++++++--- .../Setup/StartAutomationDialog.tsx | 18 +------ 5 files changed, 102 insertions(+), 25 deletions(-) diff --git a/packages/commons/octobot_commons/network.py b/packages/commons/octobot_commons/network.py index 2f0d73590..754cc0c67 100644 --- a/packages/commons/octobot_commons/network.py +++ b/packages/commons/octobot_commons/network.py @@ -104,6 +104,19 @@ def get_interface_ipv4_by_name_substring(interface_name_substring: str) -> str | return None +def get_interface_ipv4_by_prefix(ipv4_prefix: str) -> str | None: + """ + Return the first IPv4 address on any network interface that starts with the given prefix. + :param ipv4_prefix: prefix to match against IPv4 address strings (e.g. "100.") + :return: the IPv4 address string, or None if none is found + """ + for addresses in psutil.net_if_addrs().values(): + for address in addresses: + if address.family == socket.AF_INET and address.address.startswith(ipv4_prefix): + return address.address + return None + + def get_local_network_ip() -> str | None: """ Return the preferred private IPv4 address for local network access. diff --git a/packages/commons/tests/test_network.py b/packages/commons/tests/test_network.py index 4017bafcf..dda25df45 100644 --- a/packages/commons/tests/test_network.py +++ b/packages/commons/tests/test_network.py @@ -100,6 +100,59 @@ def test_returns_first_af_inet_on_matching_interface(self): assert network.get_interface_ipv4_by_name_substring("tailscale") == "100.64.0.4" +class TestGetInterfaceIpv4ByPrefix: + def test_returns_first_ipv4_matching_prefix_across_interfaces(self): + ethernet_address = mock.Mock() + ethernet_address.family = socket.AF_INET + ethernet_address.address = "192.168.0.5" + tailscale_address = mock.Mock() + tailscale_address.family = socket.AF_INET + tailscale_address.address = "100.115.92.45" + with mock.patch( + f"{NETWORK_MODULE}.psutil.net_if_addrs", + return_value={ + "eth0": [ethernet_address], + "utun3": [tailscale_address], + }, + ): + assert network.get_interface_ipv4_by_prefix("100.") == "100.115.92.45" + + def test_returns_none_when_no_address_matches_prefix(self): + ethernet_address = mock.Mock() + ethernet_address.family = socket.AF_INET + ethernet_address.address = "192.168.0.5" + with mock.patch( + f"{NETWORK_MODULE}.psutil.net_if_addrs", + return_value={"eth0": [ethernet_address]}, + ): + assert network.get_interface_ipv4_by_prefix("100.") is None + + def test_skips_non_af_inet_addresses(self): + ipv6_address = mock.Mock() + ipv6_address.family = socket.AF_INET6 + ipv6_address.address = "fe80::1" + with mock.patch( + f"{NETWORK_MODULE}.psutil.net_if_addrs", + return_value={"utun3": [ipv6_address]}, + ): + assert network.get_interface_ipv4_by_prefix("100.") is None + + def test_returns_first_matching_ipv4_when_multiple_exist(self): + first_address = mock.Mock() + first_address.family = socket.AF_INET + first_address.address = "100.64.0.4" + second_address = mock.Mock() + second_address.family = socket.AF_INET + second_address.address = "100.64.0.5" + with mock.patch( + f"{NETWORK_MODULE}.psutil.net_if_addrs", + return_value={ + "utun3": [first_address, second_address], + }, + ): + assert network.get_interface_ipv4_by_prefix("100.") == "100.64.0.4" + + class TestGetInterfaceIpv4ByNameSubstringPsutilIntegration: def test_psutil_net_if_addrs_runs_without_error(self): interface_addresses = psutil.net_if_addrs() diff --git a/packages/tentacles/Services/Interfaces/node_api_interface/core/network.py b/packages/tentacles/Services/Interfaces/node_api_interface/core/network.py index d986e773c..12f481b77 100644 --- a/packages/tentacles/Services/Interfaces/node_api_interface/core/network.py +++ b/packages/tentacles/Services/Interfaces/node_api_interface/core/network.py @@ -24,9 +24,9 @@ def get_vpn_network_ip() -> str | None: interface_ipv4 = commons_network.get_interface_ipv4_by_name_substring( TAILSCALE_INTERFACE_NAME_SUBSTRING, ) - if interface_ipv4 is None or not interface_ipv4.startswith(TAILSCALE_IPV4_PREFIX): - return None - return interface_ipv4 + if interface_ipv4 is not None and interface_ipv4.startswith(TAILSCALE_IPV4_PREFIX): + return interface_ipv4 + return commons_network.get_interface_ipv4_by_prefix(TAILSCALE_IPV4_PREFIX) def get_local_network_ip() -> str | None: diff --git a/packages/tentacles/Services/Interfaces/node_api_interface/tests/core/test_network.py b/packages/tentacles/Services/Interfaces/node_api_interface/tests/core/test_network.py index 598f81ccb..8182b41dc 100644 --- a/packages/tentacles/Services/Interfaces/node_api_interface/tests/core/test_network.py +++ b/packages/tentacles/Services/Interfaces/node_api_interface/tests/core/test_network.py @@ -31,22 +31,47 @@ def test_get_vpn_network_ip_delegates_with_tailscale_substring(self): f"{NETWORK_MODULE}.commons_network.get_interface_ipv4_by_name_substring", return_value="100.64.0.1", ) as get_interface_ipv4: - assert network.get_vpn_network_ip() == "100.64.0.1" + with mock.patch( + f"{NETWORK_MODULE}.commons_network.get_interface_ipv4_by_prefix", + ) as get_interface_ipv4_by_prefix: + assert network.get_vpn_network_ip() == "100.64.0.1" get_interface_ipv4.assert_called_once_with(network.TAILSCALE_INTERFACE_NAME_SUBSTRING) + get_interface_ipv4_by_prefix.assert_not_called() - def test_get_vpn_network_ip_returns_none_when_ip_not_in_tailscale_range(self): + def test_get_vpn_network_ip_falls_back_to_prefix_when_name_scan_returns_none(self): with mock.patch( f"{NETWORK_MODULE}.commons_network.get_interface_ipv4_by_name_substring", - return_value="192.168.0.5", + return_value=None, ): - assert network.get_vpn_network_ip() is None + with mock.patch( + f"{NETWORK_MODULE}.commons_network.get_interface_ipv4_by_prefix", + return_value="100.64.0.9", + ) as get_interface_ipv4_by_prefix: + assert network.get_vpn_network_ip() == "100.64.0.9" + get_interface_ipv4_by_prefix.assert_called_once_with(network.TAILSCALE_IPV4_PREFIX) - def test_get_vpn_network_ip_returns_none_when_no_interface_found(self): + def test_get_vpn_network_ip_returns_none_when_name_and_prefix_scans_find_nothing(self): with mock.patch( f"{NETWORK_MODULE}.commons_network.get_interface_ipv4_by_name_substring", return_value=None, ): - assert network.get_vpn_network_ip() is None + with mock.patch( + f"{NETWORK_MODULE}.commons_network.get_interface_ipv4_by_prefix", + return_value=None, + ): + assert network.get_vpn_network_ip() is None + + def test_get_vpn_network_ip_falls_back_to_prefix_when_name_scan_returns_non_tailscale_ip(self): + with mock.patch( + f"{NETWORK_MODULE}.commons_network.get_interface_ipv4_by_name_substring", + return_value="192.168.0.5", + ): + with mock.patch( + f"{NETWORK_MODULE}.commons_network.get_interface_ipv4_by_prefix", + return_value="100.64.0.2", + ) as get_interface_ipv4_by_prefix: + assert network.get_vpn_network_ip() == "100.64.0.2" + get_interface_ipv4_by_prefix.assert_called_once_with(network.TAILSCALE_IPV4_PREFIX) class TestGetLocalNetworkIp: diff --git a/packages/tentacles/Services/Interfaces/node_web_interface/src/components/Setup/StartAutomationDialog.tsx b/packages/tentacles/Services/Interfaces/node_web_interface/src/components/Setup/StartAutomationDialog.tsx index fc9ca419b..9d7256414 100644 --- a/packages/tentacles/Services/Interfaces/node_web_interface/src/components/Setup/StartAutomationDialog.tsx +++ b/packages/tentacles/Services/Interfaces/node_web_interface/src/components/Setup/StartAutomationDialog.tsx @@ -10,12 +10,6 @@ import { import { Button } from "@/components/ui/button" import { OCTOBOT_WEB_INTERFACE_URL } from "@/lib/external-links" -const NODE_PROCESS_NOTE = - "This node runs the strategy (you will need the node process to run)." - -const AUTOMATION_RESUME_NOTE = - "Started automations will show up in this node's dashboard and will automatically resume if you stop and restart your OctoBot node." - type StartAutomationDialogProps = { open: boolean onOpenChange: (open: boolean) => void @@ -40,15 +34,13 @@ export function StartAutomationDialog({

- Create a new automation in the OctoBot web or mobile app. + Create and start your automation in the OctoBot web or mobile app. + It will appear on this node's dashboard once started.

-

- Use the OctoBot interface to create your automation: -

Browser @@ -82,12 +74,6 @@ export function StartAutomationDialog({ .

-

- {NODE_PROCESS_NOTE} -

-
-

{AUTOMATION_RESUME_NOTE}

-