From 0298206d8efd11246cf5b38d8ed57441cbc32d1f Mon Sep 17 00:00:00 2001 From: Asaf Nave Date: Mon, 18 May 2026 23:30:08 +0300 Subject: [PATCH 1/7] SDK-363: Switch remote SDK calls from /devicecmdnew/ to HTTP Relay channel Replace the legacy /devicecmdnew/{tenant}/{device}/ binary channel with the /devices/{device}/ HTTP Relay for all remote edge and drive API calls. The relay forwards full HTTP requests (including cookies) to the device, which fixes cookie-dependent endpoints like /stats/*. - Add _relay_base() to derive portal DNS hostname from device DDNS name - Add auto-SSO login on first relay API access (edge and drive) - Append /admingui/api base path for relay requests - Add edge.stats module for device performance metrics - Fetch deviceDnsName in default device field list Co-authored-by: Cursor --- cterasdk/core/devices.py | 2 +- cterasdk/core/remote.py | 25 +++++++++++++++--- cterasdk/edge/__init__.py | 1 + cterasdk/edge/stats.py | 35 +++++++++++++++++++++++++ cterasdk/objects/synchronous/drive.py | 25 ++++++++++++++++-- cterasdk/objects/synchronous/edge.py | 31 ++++++++++++++++++---- tests/ut/core/admin/test_remote.py | 32 +++++++++++++++++++++++ tests/ut/edge/test_stats.py | 37 +++++++++++++++++++++++++++ 8 files changed, 177 insertions(+), 11 deletions(-) create mode 100644 cterasdk/edge/stats.py create mode 100644 tests/ut/edge/test_stats.py diff --git a/cterasdk/core/devices.py b/cterasdk/core/devices.py index a45470fe..b71450ef 100644 --- a/cterasdk/core/devices.py +++ b/cterasdk/core/devices.py @@ -11,7 +11,7 @@ class Devices(BaseCommand): name_attr = 'name' type_attr = 'deviceType' - default = ['name', 'portal', 'deviceType', 'version', 'remoteAccessUrl'] + default = ['name', 'portal', 'deviceType', 'version', 'remoteAccessUrl', 'deviceDnsName'] def _create_device_resource_uri(self, device_name, tenant): session = self._core.session() diff --git a/cterasdk/core/remote.py b/cterasdk/core/remote.py index 432f7c35..514090e9 100644 --- a/cterasdk/core/remote.py +++ b/cterasdk/core/remote.py @@ -1,11 +1,30 @@ +from urllib.parse import urlparse from .enum import DeviceType from ..objects.synchronous import edge, drive -from ..common import parse_base_object_ref + + +def _relay_base(Portal, device): + """ + Build the base URL for the HTTP Relay channel. + + The portal's RemoteDeviceServlet resolves the tenant from the Host header + using DNS suffix matching (e.g. portal.ctera.me). Accessing the portal by + IP causes Host: which fails that check. We derive the correct portal + hostname from the device's DDNS name (vGateway-7192.portal.ctera.me) and + substitute it into the base URL so every relay request carries the right + Host header while still connecting to the same portal endpoint. + """ + device_dns = getattr(device, 'deviceDnsName', None) + if device_dns and device.name in device_dns: + portal_hostname = device_dns[len(device.name) + 1:] # strip "vGateway-7192." + parsed = urlparse(Portal.ctera.baseurl) + port = f':{parsed.port}' if parsed.port not in (None, 80, 443) else '' + return f'{parsed.scheme}://{portal_hostname}{port}{parsed.path}/devices/{device.name}' + return f'{Portal.ctera.baseurl}/devices/{device.name}' def remote_command(Portal, device): - tenant = parse_base_object_ref(device.portal).name - base = f'{Portal.ctera.baseurl}/devicecmdnew/{tenant}/{device.name}' + base = _relay_base(Portal, device) ManagedDevice = None if device.deviceType in DeviceType.Gateways: diff --git a/cterasdk/edge/__init__.py b/cterasdk/edge/__init__.py index be39ec2b..0b2d9c3c 100644 --- a/cterasdk/edge/__init__.py +++ b/cterasdk/edge/__init__.py @@ -30,6 +30,7 @@ 'shares', 'shell', 'smb', + 'stats', 'support', 'sync', 'syslog', diff --git a/cterasdk/edge/stats.py b/cterasdk/edge/stats.py new file mode 100644 index 00000000..10b6d870 --- /dev/null +++ b/cterasdk/edge/stats.py @@ -0,0 +1,35 @@ +import logging + +from .base_command import BaseCommand + + +logger = logging.getLogger('cterasdk.edge') + + +VALID_STAT_TYPES = ('cpu', 'memory', 'cache', 'volume', 'connections', 'local_io', 'disk_io', 'cloud_io') +VALID_INTERVALS = ('hour', 'day', 'week', 'month', 'year', 'last') + + +class Stats(BaseCommand): + """ + Edge Filer statistics retrieved via the HTTP Relay channel. + + Valid stat types: cpu, memory, cache, volume, connections, local_io, disk_io, cloud_io + Valid intervals: hour, day, week, month, year, last + """ + + def get(self, stat_type, interval='hour'): + """ + Get device statistics + + :param str stat_type: Statistic type. + Options: ``cpu``, ``memory``, ``cache``, ``volume``, ``connections``, ``local_io``, ``disk_io``, ``cloud_io`` + :param str,optional interval: Time interval, defaults to ``hour``. + Options: ``hour``, ``day``, ``week``, ``month``, ``year``, ``last`` + :returns: Statistics data + """ + if stat_type not in VALID_STAT_TYPES: + raise ValueError(f'Invalid stat_type {stat_type!r}. Valid: {VALID_STAT_TYPES}') + if interval not in VALID_INTERVALS: + raise ValueError(f'Invalid interval {interval!r}. Valid: {VALID_INTERVALS}') + return self._edge.api.get(f'/stats/{stat_type}', params={'interval': interval}) diff --git a/cterasdk/objects/synchronous/drive.py b/cterasdk/objects/synchronous/drive.py index 2ab59a78..38cdb9f1 100644 --- a/cterasdk/objects/synchronous/drive.py +++ b/cterasdk/objects/synchronous/drive.py @@ -1,21 +1,42 @@ +import logging + import cterasdk.settings from ...clients import clients from ..services import Management from ..endpoints import EndpointBuilder +from ...common import parse_base_object_ref from ...lib.session.edge import Session from ...edge import backup, cli, logs, services, support, sync +logger = logging.getLogger('cterasdk.drive') + + class Clients: def __init__(self, drive, Portal): + self._drive = drive + self._Portal = Portal + self._authenticated = False if Portal: drive._Portal = Portal drive.default.close() drive._ctera_session.start_remote_session(Portal.session()) - self.api = Portal.default.clone(clients.API, EndpointBuilder.new(drive.base), authenticator=lambda *_: True) + self._api = Portal.default.clone(clients.API, EndpointBuilder.new(drive.base, '/admingui/api'), authenticator=lambda *_: True) else: - self.api = drive.default.clone(clients.API, EndpointBuilder.new(drive.base, '/admingui/api')) + self._api = drive.default.clone(clients.API, EndpointBuilder.new(drive.base, '/admingui/api')) + + @property + def api(self): + if self._Portal and not self._authenticated: + tenant = parse_base_object_ref(self._drive.portal).name + device_name = self._drive.name + logger.debug('Auto-SSO login via relay channel. %s', {'tenant': tenant, 'device': device_name}) + token = self._Portal.api.execute(f'/portals/{tenant}/devices/{device_name}', 'singleSignOn') + if token: + self._api.get('/ssologin', params={'ticket': token}) + self._authenticated = True + return self._api class Drive(Management): diff --git a/cterasdk/objects/synchronous/edge.py b/cterasdk/objects/synchronous/edge.py index dbe2c9ba..9ebecccc 100644 --- a/cterasdk/objects/synchronous/edge.py +++ b/cterasdk/objects/synchronous/edge.py @@ -1,9 +1,11 @@ +import logging + import cterasdk.settings from ...clients import clients from ..services import Management from ..endpoints import EndpointBuilder from .. import authenticators -from ...common import modules +from ...common import modules, parse_base_object_ref from ...lib.session.edge import Session @@ -11,24 +13,42 @@ afp, aio, antivirus, array, audit, backup, cache, cli, config, connection, ctera_migrate, dedup, directoryservice, drive, files, firmware, ftp, groups, licenses, login, logs, mail, network, nfs, ntp, power, remote, rsync, ransom_protect, services, - shares, shell, smb, snmp, ssh, ssl, support, sync, syslog, tasks, telnet, + shares, shell, smb, snmp, ssh, ssl, stats, support, sync, syslog, tasks, telnet, timezone, users, volumes, ) +logger = logging.getLogger('cterasdk.edge') + + class Clients: def __init__(self, edge, Portal): + self._edge = edge + self._Portal = Portal + self._authenticated = False if Portal: edge._Portal = Portal edge.default.close() edge._ctera_session.start_remote_session(Portal.session()) - self.api = Portal.default.clone(clients.API, EndpointBuilder.new(edge.base), authenticator=lambda *_: True) + self._api = Portal.default.clone(clients.API, EndpointBuilder.new(edge.base, '/admingui/api'), authenticator=lambda *_: True) else: self.migrate = edge.default.clone(clients.Migrate, EndpointBuilder.new(edge.base, '/migration/rest/v1')) - self.api = edge.default.clone(clients.API, EndpointBuilder.new(edge.base, '/admingui/api')) + self._api = edge.default.clone(clients.API, EndpointBuilder.new(edge.base, '/admingui/api')) self.io = IO(edge) + @property + def api(self): + if self._Portal and not self._authenticated: + tenant = parse_base_object_ref(self._edge.portal).name + device_name = self._edge.name + logger.debug('Auto-SSO login via relay channel. %s', {'tenant': tenant, 'device': device_name}) + token = self._Portal.api.execute(f'/portals/{tenant}/devices/{device_name}', 'singleSignOn') + if token: + self._api.get('/ssologin', params={'ticket': token}) + self._authenticated = True + return self._api + class IO: @@ -106,6 +126,7 @@ def __init__(self, host=None, port=None, https=True, Portal=None, *, base=None): self.shell = shell.Shell(self) self.smb = smb.SMB(self) self.snmp = snmp.SNMP(self) + self.stats = stats.Stats(self) self.ssh = ssh.SSH(self) self.ssl = modules.initialize(ssl.SSLModule, self) self.support = support.Support(self) @@ -164,5 +185,5 @@ def _omit_fields(self): return super()._omit_fields + ['afp', 'aio', 'array', 'audit', 'antivirus', 'backup', 'cache', 'cli', 'config', 'ctera_migrate', 'dedup', 'directoryservice', 'drive', 'files', 'firmware', 'ftp', 'groups', 'licenses', 'logs', 'mail', 'network', 'nfs', 'ntp', 'power', 'ransom_protect', 'rsync', 'services', 'shares', 'shell', - 'smb', 'snmp', 'ssh', 'ssl', 'support', 'sync', 'syslog', 'tasks', 'telnet', 'timezone', + 'smb', 'snmp', 'ssh', 'ssl', 'stats', 'support', 'sync', 'syslog', 'tasks', 'telnet', 'timezone', 'users', 'volumes'] diff --git a/tests/ut/core/admin/test_remote.py b/tests/ut/core/admin/test_remote.py index 12e05d55..8ffcec5f 100644 --- a/tests/ut/core/admin/test_remote.py +++ b/tests/ut/core/admin/test_remote.py @@ -73,6 +73,38 @@ def _create_device_param(name, portal, device_type, remote_access_url): param.remoteAccessUrl = remote_access_url return param + def test_auto_sso_on_first_api_access(self): + """Verify that first access to edge.api triggers SSO login via relay channel.""" + remote_session = self.patch_call("cterasdk.lib.session.edge.Session.start_remote_session") + remote_session.return_value = munch.Munch({'account': munch.Munch({'name': 'mickey', 'tenant': 'tenant'})}) + get_multi_response = TestCoreRemote._create_device_param(self._device_name, self._device_portal, + 'vGateway', self._device_remote_access_url) + self._init_global_admin(get_multi_response=get_multi_response, execute_response=self._sso_ticket) + self._activate_portal_session() + device = devices.Devices(self._global_admin).device(self._device_name) + device._ctera_clients._api = mock.MagicMock() + _ = device.api + self._global_admin.api.execute.assert_called_once_with( + f'/portals/{self._tenant_name}/devices/{self._device_name}', 'singleSignOn') + device._ctera_clients._api.get.assert_called_once_with('/ssologin', params={'ticket': self._sso_ticket}) + + def test_auto_sso_not_repeated_on_subsequent_api_access(self): + """Verify that subsequent api accesses do not re-trigger SSO.""" + remote_session = self.patch_call("cterasdk.lib.session.edge.Session.start_remote_session") + remote_session.return_value = munch.Munch({'account': munch.Munch({'name': 'mickey', 'tenant': 'tenant'})}) + get_multi_response = TestCoreRemote._create_device_param(self._device_name, self._device_portal, + 'vGateway', self._device_remote_access_url) + self._init_global_admin(get_multi_response=get_multi_response, execute_response=self._sso_ticket) + self._activate_portal_session() + device = devices.Devices(self._global_admin).device(self._device_name) + device._ctera_clients._api = mock.MagicMock() + _ = device.api + _ = device.api + _ = device.api + self._global_admin.api.execute.assert_called_once_with( + f'/portals/{self._tenant_name}/devices/{self._device_name}', 'singleSignOn') + device._ctera_clients._api.get.assert_called_once_with('/ssologin', params={'ticket': self._sso_ticket}) + @staticmethod def _create_current_session_object(): session = Object() diff --git a/tests/ut/edge/test_stats.py b/tests/ut/edge/test_stats.py new file mode 100644 index 00000000..73d5f7fa --- /dev/null +++ b/tests/ut/edge/test_stats.py @@ -0,0 +1,37 @@ +from cterasdk.edge import stats +from tests.ut.edge import base_edge + + +class TestEdgeStats(base_edge.BaseEdgeTest): + + def setUp(self): + super().setUp() + self._init_filer() + + def test_get_cpu_default_interval(self): + stats.Stats(self._filer).get('cpu') + self._filer.api.get.assert_called_with('/stats/cpu', params={'interval': 'hour'}) + + def test_get_memory_with_interval(self): + stats.Stats(self._filer).get('memory', interval='day') + self._filer.api.get.assert_called_with('/stats/memory', params={'interval': 'day'}) + + def test_get_all_stat_types(self): + for stat_type in stats.VALID_STAT_TYPES: + self._filer.api.get.reset_mock() + stats.Stats(self._filer).get(stat_type, interval='hour') + self._filer.api.get.assert_called_with(f'/stats/{stat_type}', params={'interval': 'hour'}) + + def test_get_all_intervals(self): + for interval in stats.VALID_INTERVALS: + self._filer.api.get.reset_mock() + stats.Stats(self._filer).get('cpu', interval=interval) + self._filer.api.get.assert_called_with('/stats/cpu', params={'interval': interval}) + + def test_invalid_stat_type_raises_value_error(self): + with self.assertRaises(ValueError): + stats.Stats(self._filer).get('invalid_type') + + def test_invalid_interval_raises_value_error(self): + with self.assertRaises(ValueError): + stats.Stats(self._filer).get('cpu', interval='invalid_interval') From d3d263224b764329adf5c53fcc42d973517abc74 Mon Sep 17 00:00:00 2001 From: Asaf Nave Date: Tue, 19 May 2026 15:55:07 +0300 Subject: [PATCH 2/7] Refactor: deduplicate Clients SSO logic and fix _relay_base edge cases - Extract shared RemoteClients base class to eliminate duplicated auto-SSO login logic between edge.py and drive.py Clients classes - Fix potential bug in _relay_base: use startswith() instead of substring `in` check for device DNS name matching - Fix trailing-slash edge case in _relay_base URL construction - Add comprehensive unit tests for _relay_base (DNS fallback, hostname derivation, port handling, trailing slash) - Extract common test setup into _setup_remote_device_with_sso helper Co-authored-by: Cursor --- cterasdk/core/remote.py | 9 ++- cterasdk/objects/synchronous/drive.py | 29 ++----- cterasdk/objects/synchronous/edge.py | 30 ++----- .../objects/synchronous/remote_clients.py | 32 ++++++++ tests/ut/core/admin/test_relay_base.py | 81 +++++++++++++++++++ tests/ut/core/admin/test_remote.py | 18 ++--- 6 files changed, 137 insertions(+), 62 deletions(-) create mode 100644 cterasdk/objects/synchronous/remote_clients.py create mode 100644 tests/ut/core/admin/test_relay_base.py diff --git a/cterasdk/core/remote.py b/cterasdk/core/remote.py index 514090e9..5bf14b64 100644 --- a/cterasdk/core/remote.py +++ b/cterasdk/core/remote.py @@ -15,12 +15,13 @@ def _relay_base(Portal, device): Host header while still connecting to the same portal endpoint. """ device_dns = getattr(device, 'deviceDnsName', None) - if device_dns and device.name in device_dns: - portal_hostname = device_dns[len(device.name) + 1:] # strip "vGateway-7192." + if device_dns and device_dns.startswith(f'{device.name}.'): + portal_hostname = device_dns[len(device.name) + 1:] parsed = urlparse(Portal.ctera.baseurl) port = f':{parsed.port}' if parsed.port not in (None, 80, 443) else '' - return f'{parsed.scheme}://{portal_hostname}{port}{parsed.path}/devices/{device.name}' - return f'{Portal.ctera.baseurl}/devices/{device.name}' + base_path = parsed.path.rstrip('/') + return f'{parsed.scheme}://{portal_hostname}{port}{base_path}/devices/{device.name}' + return f'{Portal.ctera.baseurl.rstrip("/")}/devices/{device.name}' def remote_command(Portal, device): diff --git a/cterasdk/objects/synchronous/drive.py b/cterasdk/objects/synchronous/drive.py index 38cdb9f1..f39adc4d 100644 --- a/cterasdk/objects/synchronous/drive.py +++ b/cterasdk/objects/synchronous/drive.py @@ -1,42 +1,23 @@ -import logging - import cterasdk.settings from ...clients import clients from ..services import Management from ..endpoints import EndpointBuilder -from ...common import parse_base_object_ref from ...lib.session.edge import Session from ...edge import backup, cli, logs, services, support, sync +from .remote_clients import RemoteClients -logger = logging.getLogger('cterasdk.drive') - - -class Clients: +class Clients(RemoteClients): def __init__(self, drive, Portal): - self._drive = drive - self._Portal = Portal - self._authenticated = False if Portal: drive._Portal = Portal drive.default.close() drive._ctera_session.start_remote_session(Portal.session()) - self._api = Portal.default.clone(clients.API, EndpointBuilder.new(drive.base, '/admingui/api'), authenticator=lambda *_: True) + api_client = Portal.default.clone(clients.API, EndpointBuilder.new(drive.base, '/admingui/api'), authenticator=lambda *_: True) else: - self._api = drive.default.clone(clients.API, EndpointBuilder.new(drive.base, '/admingui/api')) - - @property - def api(self): - if self._Portal and not self._authenticated: - tenant = parse_base_object_ref(self._drive.portal).name - device_name = self._drive.name - logger.debug('Auto-SSO login via relay channel. %s', {'tenant': tenant, 'device': device_name}) - token = self._Portal.api.execute(f'/portals/{tenant}/devices/{device_name}', 'singleSignOn') - if token: - self._api.get('/ssologin', params={'ticket': token}) - self._authenticated = True - return self._api + api_client = drive.default.clone(clients.API, EndpointBuilder.new(drive.base, '/admingui/api')) + super().__init__(drive, Portal, api_client) class Drive(Management): diff --git a/cterasdk/objects/synchronous/edge.py b/cterasdk/objects/synchronous/edge.py index 9ebecccc..1a100e9e 100644 --- a/cterasdk/objects/synchronous/edge.py +++ b/cterasdk/objects/synchronous/edge.py @@ -1,12 +1,11 @@ -import logging - import cterasdk.settings from ...clients import clients from ..services import Management from ..endpoints import EndpointBuilder from .. import authenticators -from ...common import modules, parse_base_object_ref +from ...common import modules from ...lib.session.edge import Session +from .remote_clients import RemoteClients from ...edge import ( @@ -18,36 +17,19 @@ ) -logger = logging.getLogger('cterasdk.edge') - - -class Clients: +class Clients(RemoteClients): def __init__(self, edge, Portal): - self._edge = edge - self._Portal = Portal - self._authenticated = False if Portal: edge._Portal = Portal edge.default.close() edge._ctera_session.start_remote_session(Portal.session()) - self._api = Portal.default.clone(clients.API, EndpointBuilder.new(edge.base, '/admingui/api'), authenticator=lambda *_: True) + api_client = Portal.default.clone(clients.API, EndpointBuilder.new(edge.base, '/admingui/api'), authenticator=lambda *_: True) else: self.migrate = edge.default.clone(clients.Migrate, EndpointBuilder.new(edge.base, '/migration/rest/v1')) - self._api = edge.default.clone(clients.API, EndpointBuilder.new(edge.base, '/admingui/api')) + api_client = edge.default.clone(clients.API, EndpointBuilder.new(edge.base, '/admingui/api')) self.io = IO(edge) - - @property - def api(self): - if self._Portal and not self._authenticated: - tenant = parse_base_object_ref(self._edge.portal).name - device_name = self._edge.name - logger.debug('Auto-SSO login via relay channel. %s', {'tenant': tenant, 'device': device_name}) - token = self._Portal.api.execute(f'/portals/{tenant}/devices/{device_name}', 'singleSignOn') - if token: - self._api.get('/ssologin', params={'ticket': token}) - self._authenticated = True - return self._api + super().__init__(edge, Portal, api_client) class IO: diff --git a/cterasdk/objects/synchronous/remote_clients.py b/cterasdk/objects/synchronous/remote_clients.py new file mode 100644 index 00000000..ccc55f49 --- /dev/null +++ b/cterasdk/objects/synchronous/remote_clients.py @@ -0,0 +1,32 @@ +import logging + +from ...common import parse_base_object_ref + + +logger = logging.getLogger('cterasdk.remote') + + +class RemoteClients: + """ + Base class for remote device client management with lazy SSO authentication. + + Subclasses must set ``self._device`` before calling ``super().__init__``. + """ + + def __init__(self, device, Portal, api_client): + self._device = device + self._Portal = Portal + self._authenticated = False + self._api = api_client + + @property + def api(self): + if self._Portal and not self._authenticated: + tenant = parse_base_object_ref(self._device.portal).name + device_name = self._device.name + logger.debug('Auto-SSO login via relay channel. %s', {'tenant': tenant, 'device': device_name}) + token = self._Portal.api.execute(f'/portals/{tenant}/devices/{device_name}', 'singleSignOn') + if token: + self._api.get('/ssologin', params={'ticket': token}) + self._authenticated = True + return self._api diff --git a/tests/ut/core/admin/test_relay_base.py b/tests/ut/core/admin/test_relay_base.py new file mode 100644 index 00000000..1122825c --- /dev/null +++ b/tests/ut/core/admin/test_relay_base.py @@ -0,0 +1,81 @@ +import unittest +from unittest import mock + +from cterasdk.core.remote import _relay_base + + +class TestRelayBase(unittest.TestCase): + + def _make_portal(self, baseurl): + portal = mock.MagicMock() + portal.ctera.baseurl = baseurl + return portal + + def _make_device(self, name, device_dns_name=None): + device = mock.MagicMock(spec=['name', 'deviceDnsName'] if device_dns_name is not None else ['name']) + device.name = name + if device_dns_name is not None: + device.deviceDnsName = device_dns_name + return device + + def test_fallback_when_device_dns_is_none(self): + portal = self._make_portal('https://portal.ctera.me') + device = self._make_device('vGateway-7192') + result = _relay_base(portal, device) + self.assertEqual(result, 'https://portal.ctera.me/devices/vGateway-7192') + + def test_fallback_when_device_dns_does_not_start_with_name(self): + portal = self._make_portal('https://portal.ctera.me') + device = self._make_device('vGateway-7192', 'other-device.portal.ctera.me') + result = _relay_base(portal, device) + self.assertEqual(result, 'https://portal.ctera.me/devices/vGateway-7192') + + def test_hostname_derivation_from_dns_name(self): + portal = self._make_portal('https://10.0.0.1') + device = self._make_device('vGateway-7192', 'vGateway-7192.portal.ctera.me') + result = _relay_base(portal, device) + self.assertEqual(result, 'https://portal.ctera.me/devices/vGateway-7192') + + def test_non_standard_port_preserved(self): + portal = self._make_portal('https://10.0.0.1:8443') + device = self._make_device('vGateway-7192', 'vGateway-7192.portal.ctera.me') + result = _relay_base(portal, device) + self.assertEqual(result, 'https://portal.ctera.me:8443/devices/vGateway-7192') + + def test_standard_https_port_omitted(self): + portal = self._make_portal('https://10.0.0.1:443') + device = self._make_device('vGateway-7192', 'vGateway-7192.portal.ctera.me') + result = _relay_base(portal, device) + self.assertEqual(result, 'https://portal.ctera.me/devices/vGateway-7192') + + def test_standard_http_port_omitted(self): + portal = self._make_portal('http://10.0.0.1:80') + device = self._make_device('vGateway-7192', 'vGateway-7192.portal.ctera.me') + result = _relay_base(portal, device) + self.assertEqual(result, 'http://portal.ctera.me/devices/vGateway-7192') + + def test_trailing_slash_in_baseurl_no_double_slash(self): + portal = self._make_portal('https://portal.ctera.me/') + device = self._make_device('vGateway-7192', 'vGateway-7192.portal.ctera.me') + result = _relay_base(portal, device) + self.assertNotIn('//', result.split('://')[1]) + self.assertEqual(result, 'https://portal.ctera.me/devices/vGateway-7192') + + def test_baseurl_with_path(self): + portal = self._make_portal('https://10.0.0.1/api/v1') + device = self._make_device('vGateway-7192', 'vGateway-7192.portal.ctera.me') + result = _relay_base(portal, device) + self.assertEqual(result, 'https://portal.ctera.me/api/v1/devices/vGateway-7192') + + def test_substring_device_name_does_not_false_match(self): + """A device named 'gw' should NOT match dns 'other-gw.portal.ctera.me'.""" + portal = self._make_portal('https://portal.ctera.me') + device = self._make_device('gw', 'other-gw.portal.ctera.me') + result = _relay_base(portal, device) + self.assertEqual(result, 'https://portal.ctera.me/devices/gw') + + def test_fallback_strips_trailing_slash(self): + portal = self._make_portal('https://portal.ctera.me/') + device = self._make_device('vGateway-7192') + result = _relay_base(portal, device) + self.assertEqual(result, 'https://portal.ctera.me/devices/vGateway-7192') diff --git a/tests/ut/core/admin/test_remote.py b/tests/ut/core/admin/test_remote.py index 8ffcec5f..fe569c5b 100644 --- a/tests/ut/core/admin/test_remote.py +++ b/tests/ut/core/admin/test_remote.py @@ -73,8 +73,8 @@ def _create_device_param(name, portal, device_type, remote_access_url): param.remoteAccessUrl = remote_access_url return param - def test_auto_sso_on_first_api_access(self): - """Verify that first access to edge.api triggers SSO login via relay channel.""" + def _setup_remote_device_with_sso(self): + """Common setup for SSO auto-login tests. Returns the remote device with a mocked _api.""" remote_session = self.patch_call("cterasdk.lib.session.edge.Session.start_remote_session") remote_session.return_value = munch.Munch({'account': munch.Munch({'name': 'mickey', 'tenant': 'tenant'})}) get_multi_response = TestCoreRemote._create_device_param(self._device_name, self._device_portal, @@ -83,6 +83,11 @@ def test_auto_sso_on_first_api_access(self): self._activate_portal_session() device = devices.Devices(self._global_admin).device(self._device_name) device._ctera_clients._api = mock.MagicMock() + return device + + def test_auto_sso_on_first_api_access(self): + """Verify that first access to edge.api triggers SSO login via relay channel.""" + device = self._setup_remote_device_with_sso() _ = device.api self._global_admin.api.execute.assert_called_once_with( f'/portals/{self._tenant_name}/devices/{self._device_name}', 'singleSignOn') @@ -90,14 +95,7 @@ def test_auto_sso_on_first_api_access(self): def test_auto_sso_not_repeated_on_subsequent_api_access(self): """Verify that subsequent api accesses do not re-trigger SSO.""" - remote_session = self.patch_call("cterasdk.lib.session.edge.Session.start_remote_session") - remote_session.return_value = munch.Munch({'account': munch.Munch({'name': 'mickey', 'tenant': 'tenant'})}) - get_multi_response = TestCoreRemote._create_device_param(self._device_name, self._device_portal, - 'vGateway', self._device_remote_access_url) - self._init_global_admin(get_multi_response=get_multi_response, execute_response=self._sso_ticket) - self._activate_portal_session() - device = devices.Devices(self._global_admin).device(self._device_name) - device._ctera_clients._api = mock.MagicMock() + device = self._setup_remote_device_with_sso() _ = device.api _ = device.api _ = device.api From fb97585931dc59250b619c96886f5591e79f4acc Mon Sep 17 00:00:00 2001 From: Asaf Nave Date: Tue, 19 May 2026 16:12:52 +0300 Subject: [PATCH 3/7] Style: remove verbose docstrings to match repo conventions Co-authored-by: Cursor --- cterasdk/objects/synchronous/remote_clients.py | 5 ----- tests/ut/core/admin/test_relay_base.py | 1 - tests/ut/core/admin/test_remote.py | 1 - 3 files changed, 7 deletions(-) diff --git a/cterasdk/objects/synchronous/remote_clients.py b/cterasdk/objects/synchronous/remote_clients.py index ccc55f49..d00f5bd7 100644 --- a/cterasdk/objects/synchronous/remote_clients.py +++ b/cterasdk/objects/synchronous/remote_clients.py @@ -7,11 +7,6 @@ class RemoteClients: - """ - Base class for remote device client management with lazy SSO authentication. - - Subclasses must set ``self._device`` before calling ``super().__init__``. - """ def __init__(self, device, Portal, api_client): self._device = device diff --git a/tests/ut/core/admin/test_relay_base.py b/tests/ut/core/admin/test_relay_base.py index 1122825c..d1e41ded 100644 --- a/tests/ut/core/admin/test_relay_base.py +++ b/tests/ut/core/admin/test_relay_base.py @@ -68,7 +68,6 @@ def test_baseurl_with_path(self): self.assertEqual(result, 'https://portal.ctera.me/api/v1/devices/vGateway-7192') def test_substring_device_name_does_not_false_match(self): - """A device named 'gw' should NOT match dns 'other-gw.portal.ctera.me'.""" portal = self._make_portal('https://portal.ctera.me') device = self._make_device('gw', 'other-gw.portal.ctera.me') result = _relay_base(portal, device) diff --git a/tests/ut/core/admin/test_remote.py b/tests/ut/core/admin/test_remote.py index fe569c5b..81081fce 100644 --- a/tests/ut/core/admin/test_remote.py +++ b/tests/ut/core/admin/test_remote.py @@ -74,7 +74,6 @@ def _create_device_param(name, portal, device_type, remote_access_url): return param def _setup_remote_device_with_sso(self): - """Common setup for SSO auto-login tests. Returns the remote device with a mocked _api.""" remote_session = self.patch_call("cterasdk.lib.session.edge.Session.start_remote_session") remote_session.return_value = munch.Munch({'account': munch.Munch({'name': 'mickey', 'tenant': 'tenant'})}) get_multi_response = TestCoreRemote._create_device_param(self._device_name, self._device_portal, From 849fef956b08366f28cc70fc83ad21ad485cb5ca Mon Sep 17 00:00:00 2001 From: Asaf Nave Date: Tue, 19 May 2026 17:07:43 +0300 Subject: [PATCH 4/7] Raise CTERAException on SSO ticket failure in relay auth Co-authored-by: Cursor --- cterasdk/objects/synchronous/remote_clients.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/cterasdk/objects/synchronous/remote_clients.py b/cterasdk/objects/synchronous/remote_clients.py index d00f5bd7..dd1ac84c 100644 --- a/cterasdk/objects/synchronous/remote_clients.py +++ b/cterasdk/objects/synchronous/remote_clients.py @@ -1,6 +1,7 @@ import logging from ...common import parse_base_object_ref +from ...exceptions import CTERAException logger = logging.getLogger('cterasdk.remote') @@ -21,7 +22,8 @@ def api(self): device_name = self._device.name logger.debug('Auto-SSO login via relay channel. %s', {'tenant': tenant, 'device': device_name}) token = self._Portal.api.execute(f'/portals/{tenant}/devices/{device_name}', 'singleSignOn') - if token: - self._api.get('/ssologin', params={'ticket': token}) + if not token: + raise CTERAException('Failed to Retrieve SSO Ticket.') + self._api.get('/ssologin', params={'ticket': token}) self._authenticated = True return self._api From 6520b0950c93da1b4433a6eb216e71845b9132dc Mon Sep 17 00:00:00 2001 From: Asaf Nave Date: Tue, 19 May 2026 17:14:34 +0300 Subject: [PATCH 5/7] Remove verbose docstring from _relay_base Co-authored-by: Cursor --- cterasdk/core/remote.py | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/cterasdk/core/remote.py b/cterasdk/core/remote.py index 5bf14b64..5c7ff757 100644 --- a/cterasdk/core/remote.py +++ b/cterasdk/core/remote.py @@ -4,16 +4,6 @@ def _relay_base(Portal, device): - """ - Build the base URL for the HTTP Relay channel. - - The portal's RemoteDeviceServlet resolves the tenant from the Host header - using DNS suffix matching (e.g. portal.ctera.me). Accessing the portal by - IP causes Host: which fails that check. We derive the correct portal - hostname from the device's DDNS name (vGateway-7192.portal.ctera.me) and - substitute it into the base URL so every relay request carries the right - Host header while still connecting to the same portal endpoint. - """ device_dns = getattr(device, 'deviceDnsName', None) if device_dns and device_dns.startswith(f'{device.name}.'): portal_hostname = device_dns[len(device.name) + 1:] From 6876037a2d56e620e529add1105ea251e1505a5a Mon Sep 17 00:00:00 2001 From: Asaf Nave Date: Tue, 19 May 2026 17:16:31 +0300 Subject: [PATCH 6/7] Remove test method docstrings to reduce noise Co-authored-by: Cursor --- cterasdk/edge/stats.py | 15 --------------- tests/ut/core/admin/test_remote.py | 2 -- 2 files changed, 17 deletions(-) diff --git a/cterasdk/edge/stats.py b/cterasdk/edge/stats.py index 10b6d870..b75e739c 100644 --- a/cterasdk/edge/stats.py +++ b/cterasdk/edge/stats.py @@ -11,23 +11,8 @@ class Stats(BaseCommand): - """ - Edge Filer statistics retrieved via the HTTP Relay channel. - - Valid stat types: cpu, memory, cache, volume, connections, local_io, disk_io, cloud_io - Valid intervals: hour, day, week, month, year, last - """ def get(self, stat_type, interval='hour'): - """ - Get device statistics - - :param str stat_type: Statistic type. - Options: ``cpu``, ``memory``, ``cache``, ``volume``, ``connections``, ``local_io``, ``disk_io``, ``cloud_io`` - :param str,optional interval: Time interval, defaults to ``hour``. - Options: ``hour``, ``day``, ``week``, ``month``, ``year``, ``last`` - :returns: Statistics data - """ if stat_type not in VALID_STAT_TYPES: raise ValueError(f'Invalid stat_type {stat_type!r}. Valid: {VALID_STAT_TYPES}') if interval not in VALID_INTERVALS: diff --git a/tests/ut/core/admin/test_remote.py b/tests/ut/core/admin/test_remote.py index 81081fce..a5d28b4c 100644 --- a/tests/ut/core/admin/test_remote.py +++ b/tests/ut/core/admin/test_remote.py @@ -85,7 +85,6 @@ def _setup_remote_device_with_sso(self): return device def test_auto_sso_on_first_api_access(self): - """Verify that first access to edge.api triggers SSO login via relay channel.""" device = self._setup_remote_device_with_sso() _ = device.api self._global_admin.api.execute.assert_called_once_with( @@ -93,7 +92,6 @@ def test_auto_sso_on_first_api_access(self): device._ctera_clients._api.get.assert_called_once_with('/ssologin', params={'ticket': self._sso_ticket}) def test_auto_sso_not_repeated_on_subsequent_api_access(self): - """Verify that subsequent api accesses do not re-trigger SSO.""" device = self._setup_remote_device_with_sso() _ = device.api _ = device.api From 0081a85d485401a5902db9b5028bf08f9d7ea34f Mon Sep 17 00:00:00 2001 From: Asaf Nave Date: Tue, 19 May 2026 17:17:37 +0300 Subject: [PATCH 7/7] Remove extra blank lines between imports Co-authored-by: Cursor --- cterasdk/objects/synchronous/edge.py | 2 -- cterasdk/objects/synchronous/remote_clients.py | 1 - 2 files changed, 3 deletions(-) diff --git a/cterasdk/objects/synchronous/edge.py b/cterasdk/objects/synchronous/edge.py index 1a100e9e..ad84d960 100644 --- a/cterasdk/objects/synchronous/edge.py +++ b/cterasdk/objects/synchronous/edge.py @@ -6,8 +6,6 @@ from ...common import modules from ...lib.session.edge import Session from .remote_clients import RemoteClients - - from ...edge import ( afp, aio, antivirus, array, audit, backup, cache, cli, config, connection, ctera_migrate, dedup, directoryservice, drive, files, firmware, ftp, groups, licenses, login, diff --git a/cterasdk/objects/synchronous/remote_clients.py b/cterasdk/objects/synchronous/remote_clients.py index dd1ac84c..744e8e49 100644 --- a/cterasdk/objects/synchronous/remote_clients.py +++ b/cterasdk/objects/synchronous/remote_clients.py @@ -1,5 +1,4 @@ import logging - from ...common import parse_base_object_ref from ...exceptions import CTERAException