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.
- zscams/__init__.py +3 -0
- zscams/__main__.py +43 -0
- zscams/agent/__init__.py +93 -0
- zscams/agent/certificates/.gitkeep +0 -0
- zscams/agent/config.yaml +103 -0
- zscams/agent/configuration/config.j2 +103 -0
- zscams/agent/configuration/service.j2 +12 -0
- zscams/agent/keys/autoport.key +27 -0
- zscams/agent/src/__init__.py +0 -0
- zscams/agent/src/core/__init__.py +1 -0
- zscams/agent/src/core/backend/bootstrap.py +76 -0
- zscams/agent/src/core/backend/client.py +281 -0
- zscams/agent/src/core/backend/exceptions.py +10 -0
- zscams/agent/src/core/backend/update_machine_info.py +16 -0
- zscams/agent/src/core/prerequisites.py +36 -0
- zscams/agent/src/core/service_health_check.py +49 -0
- zscams/agent/src/core/services.py +86 -0
- zscams/agent/src/core/tunnel/__init__.py +144 -0
- zscams/agent/src/core/tunnel/tls.py +56 -0
- zscams/agent/src/core/tunnels.py +55 -0
- zscams/agent/src/services/__init__.py +0 -0
- zscams/agent/src/services/reverse_ssh.py +73 -0
- zscams/agent/src/services/ssh_forwarder.py +75 -0
- zscams/agent/src/services/system_monitor.py +264 -0
- zscams/agent/src/support/__init__.py +0 -0
- zscams/agent/src/support/cli.py +50 -0
- zscams/agent/src/support/configuration.py +86 -0
- zscams/agent/src/support/filesystem.py +63 -0
- zscams/agent/src/support/logger.py +88 -0
- zscams/agent/src/support/mac.py +18 -0
- zscams/agent/src/support/network.py +49 -0
- zscams/agent/src/support/openssl.py +114 -0
- zscams/agent/src/support/os.py +138 -0
- zscams/agent/src/support/ssh.py +24 -0
- zscams/agent/src/support/yaml.py +37 -0
- zscams/deps.py +86 -0
- zscams/lib/.gitkeep +0 -0
- zscams-2.0.4.dist-info/METADATA +114 -0
- zscams-2.0.4.dist-info/RECORD +41 -0
- zscams-2.0.4.dist-info/WHEEL +4 -0
- 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
|