Skip to content

Commit 0298206

Browse files
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 <cursoragent@cursor.com>
1 parent 99746a1 commit 0298206

8 files changed

Lines changed: 177 additions & 11 deletions

File tree

cterasdk/core/devices.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ class Devices(BaseCommand):
1111

1212
name_attr = 'name'
1313
type_attr = 'deviceType'
14-
default = ['name', 'portal', 'deviceType', 'version', 'remoteAccessUrl']
14+
default = ['name', 'portal', 'deviceType', 'version', 'remoteAccessUrl', 'deviceDnsName']
1515

1616
def _create_device_resource_uri(self, device_name, tenant):
1717
session = self._core.session()

cterasdk/core/remote.py

Lines changed: 22 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,30 @@
1+
from urllib.parse import urlparse
12
from .enum import DeviceType
23
from ..objects.synchronous import edge, drive
3-
from ..common import parse_base_object_ref
4+
5+
6+
def _relay_base(Portal, device):
7+
"""
8+
Build the base URL for the HTTP Relay channel.
9+
10+
The portal's RemoteDeviceServlet resolves the tenant from the Host header
11+
using DNS suffix matching (e.g. portal.ctera.me). Accessing the portal by
12+
IP causes Host: <ip> which fails that check. We derive the correct portal
13+
hostname from the device's DDNS name (vGateway-7192.portal.ctera.me) and
14+
substitute it into the base URL so every relay request carries the right
15+
Host header while still connecting to the same portal endpoint.
16+
"""
17+
device_dns = getattr(device, 'deviceDnsName', None)
18+
if device_dns and device.name in device_dns:
19+
portal_hostname = device_dns[len(device.name) + 1:] # strip "vGateway-7192."
20+
parsed = urlparse(Portal.ctera.baseurl)
21+
port = f':{parsed.port}' if parsed.port not in (None, 80, 443) else ''
22+
return f'{parsed.scheme}://{portal_hostname}{port}{parsed.path}/devices/{device.name}'
23+
return f'{Portal.ctera.baseurl}/devices/{device.name}'
424

525

626
def remote_command(Portal, device):
7-
tenant = parse_base_object_ref(device.portal).name
8-
base = f'{Portal.ctera.baseurl}/devicecmdnew/{tenant}/{device.name}'
27+
base = _relay_base(Portal, device)
928

1029
ManagedDevice = None
1130
if device.deviceType in DeviceType.Gateways:

cterasdk/edge/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@
3030
'shares',
3131
'shell',
3232
'smb',
33+
'stats',
3334
'support',
3435
'sync',
3536
'syslog',

cterasdk/edge/stats.py

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
import logging
2+
3+
from .base_command import BaseCommand
4+
5+
6+
logger = logging.getLogger('cterasdk.edge')
7+
8+
9+
VALID_STAT_TYPES = ('cpu', 'memory', 'cache', 'volume', 'connections', 'local_io', 'disk_io', 'cloud_io')
10+
VALID_INTERVALS = ('hour', 'day', 'week', 'month', 'year', 'last')
11+
12+
13+
class Stats(BaseCommand):
14+
"""
15+
Edge Filer statistics retrieved via the HTTP Relay channel.
16+
17+
Valid stat types: cpu, memory, cache, volume, connections, local_io, disk_io, cloud_io
18+
Valid intervals: hour, day, week, month, year, last
19+
"""
20+
21+
def get(self, stat_type, interval='hour'):
22+
"""
23+
Get device statistics
24+
25+
:param str stat_type: Statistic type.
26+
Options: ``cpu``, ``memory``, ``cache``, ``volume``, ``connections``, ``local_io``, ``disk_io``, ``cloud_io``
27+
:param str,optional interval: Time interval, defaults to ``hour``.
28+
Options: ``hour``, ``day``, ``week``, ``month``, ``year``, ``last``
29+
:returns: Statistics data
30+
"""
31+
if stat_type not in VALID_STAT_TYPES:
32+
raise ValueError(f'Invalid stat_type {stat_type!r}. Valid: {VALID_STAT_TYPES}')
33+
if interval not in VALID_INTERVALS:
34+
raise ValueError(f'Invalid interval {interval!r}. Valid: {VALID_INTERVALS}')
35+
return self._edge.api.get(f'/stats/{stat_type}', params={'interval': interval})

cterasdk/objects/synchronous/drive.py

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,42 @@
1+
import logging
2+
13
import cterasdk.settings
24
from ...clients import clients
35
from ..services import Management
46
from ..endpoints import EndpointBuilder
7+
from ...common import parse_base_object_ref
58
from ...lib.session.edge import Session
69
from ...edge import backup, cli, logs, services, support, sync
710

811

12+
logger = logging.getLogger('cterasdk.drive')
13+
14+
915
class Clients:
1016

1117
def __init__(self, drive, Portal):
18+
self._drive = drive
19+
self._Portal = Portal
20+
self._authenticated = False
1221
if Portal:
1322
drive._Portal = Portal
1423
drive.default.close()
1524
drive._ctera_session.start_remote_session(Portal.session())
16-
self.api = Portal.default.clone(clients.API, EndpointBuilder.new(drive.base), authenticator=lambda *_: True)
25+
self._api = Portal.default.clone(clients.API, EndpointBuilder.new(drive.base, '/admingui/api'), authenticator=lambda *_: True)
1726
else:
18-
self.api = drive.default.clone(clients.API, EndpointBuilder.new(drive.base, '/admingui/api'))
27+
self._api = drive.default.clone(clients.API, EndpointBuilder.new(drive.base, '/admingui/api'))
28+
29+
@property
30+
def api(self):
31+
if self._Portal and not self._authenticated:
32+
tenant = parse_base_object_ref(self._drive.portal).name
33+
device_name = self._drive.name
34+
logger.debug('Auto-SSO login via relay channel. %s', {'tenant': tenant, 'device': device_name})
35+
token = self._Portal.api.execute(f'/portals/{tenant}/devices/{device_name}', 'singleSignOn')
36+
if token:
37+
self._api.get('/ssologin', params={'ticket': token})
38+
self._authenticated = True
39+
return self._api
1940

2041

2142
class Drive(Management):

cterasdk/objects/synchronous/edge.py

Lines changed: 26 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,34 +1,54 @@
1+
import logging
2+
13
import cterasdk.settings
24
from ...clients import clients
35
from ..services import Management
46
from ..endpoints import EndpointBuilder
57
from .. import authenticators
6-
from ...common import modules
8+
from ...common import modules, parse_base_object_ref
79
from ...lib.session.edge import Session
810

911

1012
from ...edge import (
1113
afp, aio, antivirus, array, audit, backup, cache, cli, config, connection, ctera_migrate,
1214
dedup, directoryservice, drive, files, firmware, ftp, groups, licenses, login,
1315
logs, mail, network, nfs, ntp, power, remote, rsync, ransom_protect, services,
14-
shares, shell, smb, snmp, ssh, ssl, support, sync, syslog, tasks, telnet,
16+
shares, shell, smb, snmp, ssh, ssl, stats, support, sync, syslog, tasks, telnet,
1517
timezone, users, volumes,
1618
)
1719

1820

21+
logger = logging.getLogger('cterasdk.edge')
22+
23+
1924
class Clients:
2025

2126
def __init__(self, edge, Portal):
27+
self._edge = edge
28+
self._Portal = Portal
29+
self._authenticated = False
2230
if Portal:
2331
edge._Portal = Portal
2432
edge.default.close()
2533
edge._ctera_session.start_remote_session(Portal.session())
26-
self.api = Portal.default.clone(clients.API, EndpointBuilder.new(edge.base), authenticator=lambda *_: True)
34+
self._api = Portal.default.clone(clients.API, EndpointBuilder.new(edge.base, '/admingui/api'), authenticator=lambda *_: True)
2735
else:
2836
self.migrate = edge.default.clone(clients.Migrate, EndpointBuilder.new(edge.base, '/migration/rest/v1'))
29-
self.api = edge.default.clone(clients.API, EndpointBuilder.new(edge.base, '/admingui/api'))
37+
self._api = edge.default.clone(clients.API, EndpointBuilder.new(edge.base, '/admingui/api'))
3038
self.io = IO(edge)
3139

40+
@property
41+
def api(self):
42+
if self._Portal and not self._authenticated:
43+
tenant = parse_base_object_ref(self._edge.portal).name
44+
device_name = self._edge.name
45+
logger.debug('Auto-SSO login via relay channel. %s', {'tenant': tenant, 'device': device_name})
46+
token = self._Portal.api.execute(f'/portals/{tenant}/devices/{device_name}', 'singleSignOn')
47+
if token:
48+
self._api.get('/ssologin', params={'ticket': token})
49+
self._authenticated = True
50+
return self._api
51+
3252

3353
class IO:
3454

@@ -106,6 +126,7 @@ def __init__(self, host=None, port=None, https=True, Portal=None, *, base=None):
106126
self.shell = shell.Shell(self)
107127
self.smb = smb.SMB(self)
108128
self.snmp = snmp.SNMP(self)
129+
self.stats = stats.Stats(self)
109130
self.ssh = ssh.SSH(self)
110131
self.ssl = modules.initialize(ssl.SSLModule, self)
111132
self.support = support.Support(self)
@@ -164,5 +185,5 @@ def _omit_fields(self):
164185
return super()._omit_fields + ['afp', 'aio', 'array', 'audit', 'antivirus', 'backup', 'cache', 'cli', 'config', 'ctera_migrate',
165186
'dedup', 'directoryservice', 'drive', 'files', 'firmware', 'ftp', 'groups', 'licenses', 'logs',
166187
'mail', 'network', 'nfs', 'ntp', 'power', 'ransom_protect', 'rsync', 'services', 'shares', 'shell',
167-
'smb', 'snmp', 'ssh', 'ssl', 'support', 'sync', 'syslog', 'tasks', 'telnet', 'timezone',
188+
'smb', 'snmp', 'ssh', 'ssl', 'stats', 'support', 'sync', 'syslog', 'tasks', 'telnet', 'timezone',
168189
'users', 'volumes']

tests/ut/core/admin/test_remote.py

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,38 @@ def _create_device_param(name, portal, device_type, remote_access_url):
7373
param.remoteAccessUrl = remote_access_url
7474
return param
7575

76+
def test_auto_sso_on_first_api_access(self):
77+
"""Verify that first access to edge.api triggers SSO login via relay channel."""
78+
remote_session = self.patch_call("cterasdk.lib.session.edge.Session.start_remote_session")
79+
remote_session.return_value = munch.Munch({'account': munch.Munch({'name': 'mickey', 'tenant': 'tenant'})})
80+
get_multi_response = TestCoreRemote._create_device_param(self._device_name, self._device_portal,
81+
'vGateway', self._device_remote_access_url)
82+
self._init_global_admin(get_multi_response=get_multi_response, execute_response=self._sso_ticket)
83+
self._activate_portal_session()
84+
device = devices.Devices(self._global_admin).device(self._device_name)
85+
device._ctera_clients._api = mock.MagicMock()
86+
_ = device.api
87+
self._global_admin.api.execute.assert_called_once_with(
88+
f'/portals/{self._tenant_name}/devices/{self._device_name}', 'singleSignOn')
89+
device._ctera_clients._api.get.assert_called_once_with('/ssologin', params={'ticket': self._sso_ticket})
90+
91+
def test_auto_sso_not_repeated_on_subsequent_api_access(self):
92+
"""Verify that subsequent api accesses do not re-trigger SSO."""
93+
remote_session = self.patch_call("cterasdk.lib.session.edge.Session.start_remote_session")
94+
remote_session.return_value = munch.Munch({'account': munch.Munch({'name': 'mickey', 'tenant': 'tenant'})})
95+
get_multi_response = TestCoreRemote._create_device_param(self._device_name, self._device_portal,
96+
'vGateway', self._device_remote_access_url)
97+
self._init_global_admin(get_multi_response=get_multi_response, execute_response=self._sso_ticket)
98+
self._activate_portal_session()
99+
device = devices.Devices(self._global_admin).device(self._device_name)
100+
device._ctera_clients._api = mock.MagicMock()
101+
_ = device.api
102+
_ = device.api
103+
_ = device.api
104+
self._global_admin.api.execute.assert_called_once_with(
105+
f'/portals/{self._tenant_name}/devices/{self._device_name}', 'singleSignOn')
106+
device._ctera_clients._api.get.assert_called_once_with('/ssologin', params={'ticket': self._sso_ticket})
107+
76108
@staticmethod
77109
def _create_current_session_object():
78110
session = Object()

tests/ut/edge/test_stats.py

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
from cterasdk.edge import stats
2+
from tests.ut.edge import base_edge
3+
4+
5+
class TestEdgeStats(base_edge.BaseEdgeTest):
6+
7+
def setUp(self):
8+
super().setUp()
9+
self._init_filer()
10+
11+
def test_get_cpu_default_interval(self):
12+
stats.Stats(self._filer).get('cpu')
13+
self._filer.api.get.assert_called_with('/stats/cpu', params={'interval': 'hour'})
14+
15+
def test_get_memory_with_interval(self):
16+
stats.Stats(self._filer).get('memory', interval='day')
17+
self._filer.api.get.assert_called_with('/stats/memory', params={'interval': 'day'})
18+
19+
def test_get_all_stat_types(self):
20+
for stat_type in stats.VALID_STAT_TYPES:
21+
self._filer.api.get.reset_mock()
22+
stats.Stats(self._filer).get(stat_type, interval='hour')
23+
self._filer.api.get.assert_called_with(f'/stats/{stat_type}', params={'interval': 'hour'})
24+
25+
def test_get_all_intervals(self):
26+
for interval in stats.VALID_INTERVALS:
27+
self._filer.api.get.reset_mock()
28+
stats.Stats(self._filer).get('cpu', interval=interval)
29+
self._filer.api.get.assert_called_with('/stats/cpu', params={'interval': interval})
30+
31+
def test_invalid_stat_type_raises_value_error(self):
32+
with self.assertRaises(ValueError):
33+
stats.Stats(self._filer).get('invalid_type')
34+
35+
def test_invalid_interval_raises_value_error(self):
36+
with self.assertRaises(ValueError):
37+
stats.Stats(self._filer).get('cpu', interval='invalid_interval')

0 commit comments

Comments
 (0)