zscams 2.0.4__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. zscams/__init__.py +3 -0
  2. zscams/__main__.py +43 -0
  3. zscams/agent/__init__.py +93 -0
  4. zscams/agent/certificates/.gitkeep +0 -0
  5. zscams/agent/config.yaml +103 -0
  6. zscams/agent/configuration/config.j2 +103 -0
  7. zscams/agent/configuration/service.j2 +12 -0
  8. zscams/agent/keys/autoport.key +27 -0
  9. zscams/agent/src/__init__.py +0 -0
  10. zscams/agent/src/core/__init__.py +1 -0
  11. zscams/agent/src/core/backend/bootstrap.py +76 -0
  12. zscams/agent/src/core/backend/client.py +281 -0
  13. zscams/agent/src/core/backend/exceptions.py +10 -0
  14. zscams/agent/src/core/backend/update_machine_info.py +16 -0
  15. zscams/agent/src/core/prerequisites.py +36 -0
  16. zscams/agent/src/core/service_health_check.py +49 -0
  17. zscams/agent/src/core/services.py +86 -0
  18. zscams/agent/src/core/tunnel/__init__.py +144 -0
  19. zscams/agent/src/core/tunnel/tls.py +56 -0
  20. zscams/agent/src/core/tunnels.py +55 -0
  21. zscams/agent/src/services/__init__.py +0 -0
  22. zscams/agent/src/services/reverse_ssh.py +73 -0
  23. zscams/agent/src/services/ssh_forwarder.py +75 -0
  24. zscams/agent/src/services/system_monitor.py +264 -0
  25. zscams/agent/src/support/__init__.py +0 -0
  26. zscams/agent/src/support/cli.py +50 -0
  27. zscams/agent/src/support/configuration.py +86 -0
  28. zscams/agent/src/support/filesystem.py +63 -0
  29. zscams/agent/src/support/logger.py +88 -0
  30. zscams/agent/src/support/mac.py +18 -0
  31. zscams/agent/src/support/network.py +49 -0
  32. zscams/agent/src/support/openssl.py +114 -0
  33. zscams/agent/src/support/os.py +138 -0
  34. zscams/agent/src/support/ssh.py +24 -0
  35. zscams/agent/src/support/yaml.py +37 -0
  36. zscams/deps.py +86 -0
  37. zscams/lib/.gitkeep +0 -0
  38. zscams-2.0.4.dist-info/METADATA +114 -0
  39. zscams-2.0.4.dist-info/RECORD +41 -0
  40. zscams-2.0.4.dist-info/WHEEL +4 -0
  41. zscams-2.0.4.dist-info/entry_points.txt +3 -0
@@ -0,0 +1,281 @@
1
+ import json
2
+ import os
3
+ from pathlib import Path
4
+ import requests
5
+
6
+ from typing import Optional, cast
7
+
8
+ from .exceptions import AgentBootstrapError
9
+
10
+ from zscams.agent.src.support.configuration import (
11
+ CONFIG_PATH,
12
+ ROOT_PATH,
13
+ get_config,
14
+ override_config,
15
+ )
16
+ from zscams.agent.src.support.yaml import resolve_placeholders, assert_no_placeholders_left
17
+ from zscams.agent.src.support.mac import get_mac_address
18
+ from zscams.agent.src.support.filesystem import resolve_path, ensure_dir, is_file_exists
19
+ from zscams.agent.src.support.network import get_local_hostname, get_local_ip_address
20
+ from zscams.agent.src.support.openssl import (
21
+ generate_csr_from_private_key,
22
+ generate_private_key,
23
+ )
24
+ from zscams.agent.src.support.logger import get_logger
25
+
26
+
27
+ class BackendClient:
28
+ """Client to interact with the backend service."""
29
+
30
+ MACHINE_INFO_FILE_NAME = "machine_info.json"
31
+
32
+ def __init__(self):
33
+ config = get_config()
34
+ self.services_config = config.get("services", [])
35
+ self.backend_config = config.get("backend", {})
36
+ self.bootstrap_info = config.get("bootstrap_info", {})
37
+ self.remote_config = config.get("remote", {})
38
+ self.url = self.backend_config.get("base_url")
39
+ self.session = requests.session()
40
+ self.agent_id = get_mac_address()
41
+ self.logger = get_logger("backend_client")
42
+
43
+ self.__machine_cache_path = Path(
44
+ os.path.join(
45
+ ROOT_PATH.parent,
46
+ self.backend_config.get("cache_dir"),
47
+ self.MACHINE_INFO_FILE_NAME,
48
+ )
49
+ )
50
+
51
+ def bootstrap(self, equipment_name: str, enforced_id: Optional[str] = None):
52
+ """Bootstrap the agent with the backend."""
53
+
54
+ if not self.is_authenticated():
55
+ self.logger.error("Couldn't authenticate")
56
+ raise AgentBootstrapError(
57
+ "Client is not authenticated to bootstrap. Please login first."
58
+ )
59
+
60
+ self.logger.info("bootstrapping agent ID %s", self.agent_id)
61
+
62
+ csr_info = self.bootstrap_info.get("csr", {})
63
+ remote_configs = get_config().get("remote", {})
64
+
65
+ private_key_path = cast(
66
+ str,
67
+ resolve_path(
68
+ remote_configs.get("client_key"), os.path.dirname(CONFIG_PATH)
69
+ ),
70
+ )
71
+
72
+ self.logger.info("Generating the private key...")
73
+ private_key_content = generate_private_key(private_key_path)
74
+
75
+ self.logger.info("Generated the private key at %s", private_key_path)
76
+
77
+ common_name = (
78
+ f"{equipment_name}.orangecyberdefense.com"
79
+ if equipment_name
80
+ else csr_info.get("default_common_name")
81
+ )
82
+
83
+ ip = get_local_ip_address()
84
+ csr = generate_csr_from_private_key(
85
+ private_key_content,
86
+ csr_info.get("country"),
87
+ csr_info.get("state"),
88
+ csr_info.get("locality"),
89
+ csr_info.get("organization"),
90
+ common_name,
91
+ )
92
+
93
+ payload = {
94
+ "private_ip": ip,
95
+ "lan_ip": ip,
96
+ "hostname": get_local_hostname(),
97
+ "equipment_name": equipment_name,
98
+ "csr": csr,
99
+ "enforced_id": enforced_id,
100
+ "services": [service.get("name") for service in self.services_config if service.get("custom_port_name", None)],
101
+ "blacklist_ports": self.bootstrap_info.get("blacklist_ports", []),
102
+ "cert_issuer": self.bootstrap_info.get("cert_issuer", None),
103
+ }
104
+
105
+ self.logger.debug(
106
+ "bootstrapping the agent with payload %s", json.dumps(payload, indent=2)
107
+ )
108
+
109
+ res = self._post(f"agent-bootstrap/{self.agent_id}", payload)
110
+ res_json = res.json()
111
+
112
+ if not res.ok or res_json.get("success") is False:
113
+ message = (
114
+ res_json.get("message", None)
115
+ or res_json.get("error", None)
116
+ or res_json.get("errors", None)
117
+ or "Unknown error"
118
+ )
119
+ raise AgentBootstrapError(
120
+ f"Bootstrapping failed on backend request: {message}"
121
+ )
122
+
123
+ self.logger.info("Signed the cert")
124
+ self.logger.debug("Signed cert response: %s", json.dumps(res_json, indent=2))
125
+
126
+ customer_machine = res_json.get("response", {})
127
+ ca_chain: list[str] = customer_machine.get("ca_chain", [])
128
+ signed_cert: str = customer_machine.get("signed_certificate", [])
129
+ self._write_certificates(ca_chain, signed_cert)
130
+
131
+ # To cache the machine info
132
+ self.get_machine_info(ignore_cache=True)
133
+
134
+ self.__override_config_services_ports(customer_machine.get("services", []))
135
+
136
+ return customer_machine
137
+
138
+ def get_machine_info(self, ignore_cache: bool = False):
139
+ """Get machine information from the backend."""
140
+
141
+ if not ignore_cache and is_file_exists(self.__machine_cache_path, self.logger):
142
+ return self.__load_machine_info_from_cache()
143
+
144
+ res = self._get(f"customers-machines/machine-id/{self.agent_id}")
145
+ res.raise_for_status()
146
+
147
+ machine_info = res.json().get("response", {})
148
+ self.logger.debug(
149
+ "Fetched machine info: %s", json.dumps(machine_info, indent=2)
150
+ )
151
+ self.__cache_machine_info(machine_info)
152
+ return machine_info
153
+
154
+ def __load_machine_info_from_cache(self):
155
+ """Cache machine information to a local file."""
156
+
157
+ self.logger.info(
158
+ "Loading machine info from cache at %s", self.__machine_cache_path
159
+ )
160
+ with open(self.__machine_cache_path, "r", encoding="utf-8") as cache_file:
161
+ return json.load(cache_file)
162
+
163
+ def __cache_machine_info(self, machine_info: dict):
164
+ """Cache machine information to a local file."""
165
+
166
+ ensure_dir(str(self.__machine_cache_path.parent.absolute()))
167
+ self.logger.info("Caching machine info to %s", self.__machine_cache_path)
168
+ with open(self.__machine_cache_path, "w", encoding="utf-8") as cache_file:
169
+ json.dump(machine_info, cache_file, indent=2)
170
+
171
+ def _write_certificates(self, ca_chain: list[str], cert: str):
172
+ if cert:
173
+ cert_path = os.path.join(ROOT_PATH, self.remote_config.get("client_cert"))
174
+
175
+ self.logger.info("Writing signed certificate to %s", cert_path)
176
+ with open(cert_path, "w", encoding="utf-8") as cert_file:
177
+ cert_file.write(cert)
178
+
179
+ if ca_chain:
180
+ ca_chain_path = os.path.join(ROOT_PATH, self.remote_config.get("ca_chain"))
181
+
182
+ self.logger.info("Writing CA chain to %s", ca_chain_path)
183
+ with open(ca_chain_path, "w", encoding="utf-8") as ca_chain_file:
184
+ ca_chain_file.write("\n\n".join(ca_chain))
185
+
186
+ def is_bootstrapped(self) -> bool:
187
+ """Check if the agent is bootstrapped."""
188
+
189
+ is_bootstrapped_res = self._get(
190
+ f"agent-bootstrap/{self.agent_id}/is-bootstrapped"
191
+ )
192
+ is_bootstrapped_res.raise_for_status()
193
+
194
+ return (
195
+ is_bootstrapped_res.json().get("response", {}).get("is-bootstrapped", False)
196
+ )
197
+
198
+ def _get(self, path: str):
199
+ """Perform a GET request to the backend."""
200
+
201
+ return self.session.get(self.__prepare_path(path))
202
+
203
+ def _post(self, path: str, body: dict):
204
+ """Perform a POST request to the backend."""
205
+
206
+ return self.session.post(self.__prepare_path(path), json=body)
207
+
208
+ def _put(self, path: str, body: dict):
209
+ """Perform a PUT request to the backend."""
210
+
211
+ return self.session.put(self.__prepare_path(path), json=body)
212
+
213
+ def _delete(self, path: str):
214
+ """Perform a DELETE request to the backend."""
215
+
216
+ return self.session.delete(self.__prepare_path(path))
217
+
218
+ def login(self, username: str, totp: str):
219
+ """Perform login to the backend using username and TOTP."""
220
+
221
+ payload = {
222
+ "username": username,
223
+ "totp": totp,
224
+ "client_id": "orchestrator-client",
225
+ "grant_type": "password",
226
+ }
227
+
228
+ self.logger.info("Logging in to backend as %s", username)
229
+
230
+ response = self.session.post(
231
+ self.__prepare_path("authentication/temp/login"), json=payload
232
+ )
233
+ if not response.ok:
234
+ self.logger.error(
235
+ "Login failed with status code %d: %s",
236
+ response.status_code,
237
+ response.text,
238
+ )
239
+ response.raise_for_status()
240
+
241
+ token = response.json().get("access_token")
242
+ if token:
243
+ self.session.headers.update({"Authorization": f"Bearer {token}"})
244
+
245
+ self.logger.info("Login successful")
246
+
247
+ return response.json()
248
+
249
+ def is_authenticated(self) -> bool:
250
+ """Check if the client is authenticated."""
251
+ return self.session.headers.get("Authorization") is not None
252
+
253
+ def __prepare_path(self, path):
254
+ return f"{self.url}/{path.lstrip('/')}"
255
+
256
+ def __override_config_services_ports(self, cm_services: list[dict]):
257
+ self.logger.debug("Overriding configuration services ports...")
258
+
259
+ config = get_config()
260
+ custom_ports = {}
261
+ for cm_service in cm_services:
262
+ for cfg_service_idx, cfg_service in enumerate(config.get("services", [])):
263
+ if cm_service.get("name") == cfg_service.get("name"):
264
+
265
+ if not config["services"][cfg_service_idx]["params"]:
266
+ config["services"][cfg_service_idx]["params"] = {}
267
+
268
+ port_field_name = cfg_service.get("custom_port_name", None)
269
+ if port_field_name:
270
+ custom_ports[port_field_name] = cm_service.get("port")
271
+ config["services"][cfg_service_idx]["params"][port_field_name] = (
272
+ cm_service.get("port")
273
+ )
274
+ resolve_placeholders(config, custom_ports)
275
+ assert_no_placeholders_left(config)
276
+ self.logger.debug("Done overriding the configurations")
277
+
278
+ return override_config(config)
279
+
280
+
281
+ backend_client = BackendClient()
@@ -0,0 +1,10 @@
1
+ class AgentBootstrapError(Exception):
2
+ """Base exception for agent bootstrap-related errors."""
3
+
4
+
5
+ class AgentNotBootstrapped(AgentBootstrapError):
6
+ """Raised when an operation requires a bootstrapped agent, but the agent is not bootstrapped."""
7
+
8
+
9
+ class AgentAlreadyBootstrapped(AgentBootstrapError):
10
+ """Raised when attempting to bootstrap an already bootstrapped agent."""
@@ -0,0 +1,16 @@
1
+ from zscams.agent.src.core.backend.client import backend_client
2
+ from zscams.agent.src.support.logger import get_logger
3
+
4
+ logger = get_logger("agent_machine_info")
5
+
6
+
7
+ def update_machine_info():
8
+ """Ensure the agent is bootstrapped with the backend."""
9
+
10
+ username = input("LDAP Username: ")
11
+ totp = input("TOTP: ")
12
+
13
+ backend_client.login(username, totp)
14
+
15
+ logger.info("Getting machine info from backend...")
16
+ backend_client.get_machine_info(ignore_cache=True)
@@ -0,0 +1,36 @@
1
+ from zscams.agent.src.support.logger import get_logger
2
+ from zscams.agent.src.support.filesystem import is_file_exists
3
+ from zscams.agent.src.core import running_services
4
+ from zscams.agent.src.support.network import is_port_open, is_service_running
5
+
6
+ logger = get_logger("service_prerequisites")
7
+
8
+
9
+ def check_prerequisites(prereqs: dict) -> bool:
10
+ """
11
+ Perform a one-shot prerequisites check (no waiting or retry).
12
+ Returns True only if all listed conditions are satisfied.
13
+ """
14
+ if not prereqs:
15
+ return True
16
+
17
+ results = []
18
+
19
+ # Check file prerequisites
20
+ for path in prereqs.get("files", []):
21
+ results.append(is_file_exists(path, logger))
22
+
23
+ # Check port prerequisites
24
+ for port in prereqs.get("ports", []):
25
+ results.append(is_port_open("localhost", port, logger))
26
+
27
+ # Check service prerequisites
28
+ for svc in prereqs.get("services", []):
29
+ results.append(is_service_running(svc, running_services, logger))
30
+
31
+ all_ok = all(results)
32
+ if not all_ok:
33
+ logger.warning("Some prerequisites failed for this service.")
34
+ else:
35
+ logger.info("All prerequisites satisfied.")
36
+ return all_ok
@@ -0,0 +1,49 @@
1
+ """
2
+ Health monitor for agent services.
3
+
4
+ This module periodically checks the defined health ports of all services.
5
+ If a service is not responding on its port, it will be restarted.
6
+ """
7
+
8
+ import asyncio
9
+ from zscams.agent.src.support.logger import get_logger
10
+ from zscams.agent.src.core.services import start_service
11
+ from zscams.agent.src.core import running_services
12
+ from zscams.agent.src.support.network import is_port_open
13
+
14
+ logger = get_logger("health_monitor")
15
+
16
+
17
+ async def monitor_services(services: list, config_dir: str, interval: int = 60):
18
+ """
19
+ Periodically check service health by port.
20
+
21
+ If a service's health port is closed, attempt to restart it.
22
+ """
23
+ logger.info(f"Health monitor started (interval={interval}s)")
24
+ await asyncio.sleep(30) # Initial delay to allow services to start
25
+ while True:
26
+ for svc in services:
27
+ name = svc["name"]
28
+ health = svc.get("health", {})
29
+
30
+ host = health.get("host", "localhost")
31
+ port = health.get("port")
32
+ if not port:
33
+ logger.debug(f"Skipping {name}: no health port defined.")
34
+ continue
35
+
36
+ if name in running_services and is_port_open(host, port, logger):
37
+ logger.debug(f"{name} is healthy on {host}:{port}")
38
+ continue
39
+
40
+ logger.warning(f"{name} not healthy, restarting...")
41
+ if name in running_services:
42
+ running_services.remove(name)
43
+ try:
44
+ await start_service(svc, config_dir)
45
+ logger.info(f"Restarted service: {name}")
46
+ except Exception as e:
47
+ logger.error(f"Failed to restart {name}: {e}")
48
+
49
+ await asyncio.sleep(interval)
@@ -0,0 +1,86 @@
1
+ """
2
+ Service launcher utilities for TLS Tunnel Client
3
+
4
+ - Generic solution: any service can receive parameters via `SERVICE_PARAMS` env variable
5
+ - Starts services asynchronously using asyncio
6
+ - Supports both Python scripts and executables
7
+ """
8
+
9
+ import asyncio
10
+ import json
11
+ import os
12
+ from zscams.agent.src.support.logger import get_logger
13
+ from zscams.agent.src.core.prerequisites import check_prerequisites
14
+ from zscams.agent.src.core import running_services
15
+
16
+ logger = get_logger("service_launcher")
17
+
18
+
19
+ async def start_service(service_cfg, config_dir=None):
20
+ """
21
+ Launch an external script/service asynchronously.
22
+
23
+ Args:
24
+ service_cfg (dict): Service configuration from config.yaml
25
+ - name: service name
26
+ - script: path to script/executable
27
+ - port: associated port (optional)
28
+ - args: list of extra arguments
29
+ - params: dict of arbitrary parameters (SERVICE_PARAMS)
30
+ config_dir (str, optional): Base directory to resolve relative script paths
31
+
32
+ Returns:
33
+ asyncio.subprocess.Process or None
34
+ """
35
+ base_dir = config_dir or os.getcwd()
36
+ script_path = os.path.join(base_dir, service_cfg["script"])
37
+ prereqs = service_cfg.get("prerequisites")
38
+ name = service_cfg["name"]
39
+ # Wait for prerequisites before starting
40
+ logger.info("Checking prerequisites for %s...", name)
41
+ ok = check_prerequisites(prereqs)
42
+ if not ok:
43
+ logger.error("Prerequisites not met for %s. Skipping start.", name)
44
+ return
45
+
46
+ if not os.path.exists(script_path):
47
+ logger.error("Service script not found: %s", script_path)
48
+ return None
49
+
50
+ env = os.environ.copy()
51
+ params = service_cfg.get("params")
52
+ if params:
53
+ # Pass generic parameters to the service via JSON environment variable
54
+ env["SERVICE_PARAMS"] = json.dumps(params)
55
+
56
+ cmd = ["python", script_path] + service_cfg.get("args", [])
57
+ logger.info(
58
+ "Starting service %s on port %d: %s",
59
+ service_cfg.get("name"),
60
+ service_cfg.get("port", 0),
61
+ cmd,
62
+ )
63
+
64
+ try:
65
+ process = await asyncio.create_subprocess_exec(*cmd, env=env)
66
+ running_services.add(name)
67
+ return process
68
+ except Exception as e:
69
+ logger.error("Failed to start service %s: %s", service_cfg.get("name"), e)
70
+ return None
71
+
72
+
73
+ async def start_all_services(services_cfg_list, config_dir=None):
74
+ """
75
+ Start all services defined in the configuration concurrently.
76
+
77
+ Args:
78
+ services_cfg_list (list): List of service configuration dictionaries
79
+ config_dir (str, optional): Base directory to resolve relative script paths
80
+ """
81
+ tasks = []
82
+ for svc in services_cfg_list:
83
+ tasks.append(start_service(svc, config_dir=config_dir))
84
+
85
+ # Wait for all services to start
86
+ await asyncio.gather(*tasks)
@@ -0,0 +1,144 @@
1
+ """
2
+ Forwarding utilities for TLS Tunnel Client with:
3
+ - Auto-reconnect with exponential backoff
4
+ - Health checks
5
+ """
6
+
7
+ import asyncio
8
+ from zscams.agent.src.support.logger import get_logger
9
+
10
+ READ_BUFFER_SIZE = 4096
11
+
12
+ logger = get_logger("tls_forward")
13
+
14
+
15
+ async def forward(reader: asyncio.StreamReader, writer: asyncio.StreamWriter):
16
+ """
17
+ Forward data from reader to writer until connection closes.
18
+ Handles Windows-specific connection resets gracefully.
19
+ """
20
+ try:
21
+ while True:
22
+ data = await reader.read(READ_BUFFER_SIZE)
23
+ if not data:
24
+ break
25
+ writer.write(data)
26
+ await writer.drain()
27
+ except (ConnectionResetError, OSError) as err:
28
+ # Handle Windows WinError 64 and other disconnects gracefully
29
+ logger.debug("Connection closed by peer (expected): %s", err)
30
+ except Exception as err:
31
+ logger.error("Unexpected forwarding error: %s", err)
32
+ finally:
33
+ try:
34
+ writer.close()
35
+ await writer.wait_closed()
36
+ except Exception:
37
+ pass # ignore errors on close
38
+
39
+
40
+ async def handle_client(
41
+ local_reader, local_writer, remote_host, remote_port, sni_hostname, ssl_context
42
+ ): # pylint: disable=too-many-arguments,too-many-positional-arguments
43
+ """
44
+ Forward a single local connection to the remote TLS server with SNI.
45
+ Handles abrupt disconnects gracefully.
46
+ """
47
+ try:
48
+ remote_reader, remote_writer = await asyncio.open_connection(
49
+ host=remote_host,
50
+ port=remote_port,
51
+ ssl=ssl_context,
52
+ server_hostname=sni_hostname,
53
+ )
54
+ logger.debug(
55
+ "New connection: local -> %s:%s (SNI=%s)",
56
+ remote_host,
57
+ remote_port,
58
+ sni_hostname,
59
+ )
60
+
61
+ await asyncio.gather(
62
+ forward(local_reader, remote_writer), forward(remote_reader, local_writer)
63
+ )
64
+
65
+ except (ConnectionResetError, OSError) as e:
66
+ logger.debug("Connection closed unexpectedly: %s", e)
67
+ except Exception as e:
68
+ logger.error("Unexpected client handling error: %s", e)
69
+ finally:
70
+ try:
71
+ local_writer.close()
72
+ await local_writer.wait_closed()
73
+ logger.debug(
74
+ f"Connection closed: local -> {remote_host}:{remote_port} (SNI={sni_hostname})"
75
+ )
76
+ except Exception as e:
77
+ logger.error("Unexpected client handling error: %s", e)
78
+
79
+
80
+ async def start_tunnel(
81
+ local_port,
82
+ remote_host,
83
+ remote_port,
84
+ sni_hostname,
85
+ ssl_context,
86
+ reconnect_max_delay=60,
87
+ ready_event=None,
88
+ ): # pylint: disable=too-many-arguments,too-many-positional-arguments
89
+ """
90
+ Start a TCP listener on local_port and forward connections to the remote TLS server.
91
+ Automatically reconnects on failure with exponential backoff and performs periodic health checks.
92
+
93
+ Args:
94
+ local_port (int)
95
+ remote_host (str)
96
+ remote_port (int)
97
+ sni_hostname (str)
98
+ ssl_context (ssl.SSLContext)
99
+ reconnect_max_delay (int): Maximum delay between reconnect attempts
100
+ """
101
+ backoff = 1 # initial delay in seconds
102
+
103
+ while True:
104
+ try:
105
+ server = await asyncio.start_server(
106
+ lambda r, w: handle_client(
107
+ r, w, remote_host, remote_port, sni_hostname, ssl_context
108
+ ),
109
+ "127.0.0.1",
110
+ local_port,
111
+ )
112
+ addr = server.sockets[0].getsockname()
113
+ logger.info(
114
+ "Listening on :%s -> %s:%s (SNI=%s)",
115
+ addr[1],
116
+ remote_host,
117
+ remote_port,
118
+ sni_hostname,
119
+ )
120
+
121
+ # Health check task: logs tunnel is alive every 30 seconds
122
+ async def health_check():
123
+ while True:
124
+ await asyncio.sleep(600)
125
+ logger.info(
126
+ "Tunnel on local port %s is healthy and running", local_port
127
+ )
128
+
129
+ asyncio.create_task(health_check())
130
+
131
+ # Signal ready if event is provided
132
+ if ready_event:
133
+ ready_event.set()
134
+
135
+ async with server:
136
+ await server.serve_forever()
137
+
138
+ except Exception as e:
139
+ logger.error("Tunnel error on local port %s: %s", local_port, e)
140
+ logger.info("Reconnecting in %s seconds...", backoff)
141
+ await asyncio.sleep(backoff)
142
+ backoff = min(backoff * 2, reconnect_max_delay) # exponential backoff
143
+ else:
144
+ backoff = 1 # reset backoff if successful
@@ -0,0 +1,56 @@
1
+ import ssl
2
+ import os
3
+ from typing import Optional
4
+ from zscams.agent.src.support.configuration import RemoteConfig
5
+ from zscams.agent.src.support.filesystem import resolve_path
6
+ from zscams.agent.src.support.logger import get_logger
7
+
8
+ logger = get_logger("tls_utils")
9
+
10
+
11
+ def create_ssl_context(
12
+ remote_cfg: RemoteConfig, config_dir: Optional[str] = None
13
+ ) -> ssl.SSLContext:
14
+ """
15
+ Create an SSL context for TLS connections based on configuration.
16
+
17
+ Args:
18
+ remote_cfg (dict): Remote server configuration including:
19
+ - verify_cert (bool)
20
+ - ca_cert (str)
21
+ - client_cert (str)
22
+ - client_key (str)
23
+ config_dir (str, optional): Directory to resolve relative paths from.
24
+
25
+ Returns:
26
+ ssl.SSLContext: Configured SSL context
27
+ """
28
+ context = ssl.create_default_context()
29
+
30
+ ca_cert = resolve_path(remote_cfg.get("ca_cert"), config_dir)
31
+ client_cert = resolve_path(remote_cfg.get("client_cert"), config_dir)
32
+ client_key = resolve_path(remote_cfg.get("client_key"), config_dir)
33
+
34
+ if not remote_cfg.get("verify_cert", True):
35
+ context.check_hostname = False
36
+ context.verify_mode = ssl.CERT_NONE
37
+ logger.warning("Certificate verification is disabled!")
38
+ else:
39
+ if ca_cert and os.path.exists(ca_cert):
40
+ context.load_verify_locations(cafile=ca_cert)
41
+ logger.info("Loaded CA certificate from %s", ca_cert)
42
+ else:
43
+ logger.warning("CA certificate not found or not provided: %s", ca_cert)
44
+
45
+ if client_cert and client_key:
46
+ if os.path.exists(client_cert) and os.path.exists(client_key):
47
+ context.load_cert_chain(certfile=client_cert, keyfile=client_key)
48
+ logger.info(
49
+ "Loaded client certificate/key: %s / %s", client_cert, client_key
50
+ )
51
+ else:
52
+ raise FileNotFoundError(
53
+ f"Client certificate or key not found: {client_cert}, {client_key}"
54
+ )
55
+
56
+ return context