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: -
- {NODE_PROCESS_NOTE} -
-{AUTOMATION_RESUME_NOTE}
-