@sensolus/create-snt-agent-app 0.3.11 → 0.3.14
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.
package/package.json
CHANGED
package/template/Dockerfile
CHANGED
|
@@ -25,6 +25,12 @@ RUN pip install --no-cache-dir -r requirements.txt
|
|
|
25
25
|
COPY backend/ backend/
|
|
26
26
|
COPY --from=builder /app/dist dist/
|
|
27
27
|
|
|
28
|
+
# Run as non-root user
|
|
29
|
+
RUN groupadd --system --gid 1001 app \
|
|
30
|
+
&& useradd --system --uid 1001 --gid app --no-create-home app \
|
|
31
|
+
&& chown -R app:app /app
|
|
32
|
+
USER app
|
|
33
|
+
|
|
28
34
|
EXPOSE 5000
|
|
29
35
|
|
|
30
36
|
# Run with socketio.run() for proper WebSocket support (not gunicorn)
|
package/template/backend/app.py
CHANGED
|
@@ -11,6 +11,11 @@ from dotenv import load_dotenv
|
|
|
11
11
|
|
|
12
12
|
from db_config import get_database_uri
|
|
13
13
|
from extensions import db
|
|
14
|
+
from sensolus_client_api import (
|
|
15
|
+
SENSOLUS_BASE_URL,
|
|
16
|
+
SENSOLUS_COOKIE_NAME,
|
|
17
|
+
make_sensolus_request,
|
|
18
|
+
)
|
|
14
19
|
|
|
15
20
|
# Get the project root (parent of backend folder)
|
|
16
21
|
PROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
|
@@ -46,10 +51,6 @@ import models # noqa: F401
|
|
|
46
51
|
migrations_dir = os.path.join(os.path.dirname(__file__), 'migrations')
|
|
47
52
|
Migrate(app, db, directory=migrations_dir)
|
|
48
53
|
|
|
49
|
-
SENSOLUS_DOMAIN = os.environ.get("SENSOLUS_DOMAIN", "cloud.sensolus.com")
|
|
50
|
-
SENSOLUS_BASE_URL = f"https://{SENSOLUS_DOMAIN}/rest/api/v2"
|
|
51
|
-
SENSOLUS_COOKIE_NAME = os.environ.get("SENSOLUS_COOKIE_NAME", "prod-sensolus-token")
|
|
52
|
-
|
|
53
54
|
MANAGER_AUTH_KEY = os.environ.get("MANAGER_AUTH_KEY")
|
|
54
55
|
|
|
55
56
|
# Manager-provided headers — typos in string literals silently 401, so route everything through these.
|
|
@@ -163,127 +164,6 @@ def heartbeat():
|
|
|
163
164
|
logger.info("Scheduled heartbeat: the background scheduler is alive and well.")
|
|
164
165
|
|
|
165
166
|
|
|
166
|
-
def get_auth_headers_and_params():
|
|
167
|
-
"""
|
|
168
|
-
Get authentication headers and params for Sensolus API calls.
|
|
169
|
-
|
|
170
|
-
Priority:
|
|
171
|
-
1. Session cookie (SENSOLUS_COOKIE_NAME) -> passed as Bearer token
|
|
172
|
-
2. API key from query parameter -> passed as apiKey query param
|
|
173
|
-
3. Stored API key from session -> passed as apiKey query param
|
|
174
|
-
|
|
175
|
-
Returns:
|
|
176
|
-
tuple: (headers dict, params dict, error message or None)
|
|
177
|
-
"""
|
|
178
|
-
headers = {}
|
|
179
|
-
params = {}
|
|
180
|
-
|
|
181
|
-
# Check for session cookie first - pass as Bearer token
|
|
182
|
-
token = request.cookies.get(SENSOLUS_COOKIE_NAME)
|
|
183
|
-
if token:
|
|
184
|
-
headers['Authorization'] = f'Bearer {token}'
|
|
185
|
-
logger.info("Using session cookie as Bearer token for authentication")
|
|
186
|
-
return headers, params, None
|
|
187
|
-
|
|
188
|
-
# Check for API key in query parameter
|
|
189
|
-
api_key = request.args.get('apiKey')
|
|
190
|
-
if api_key:
|
|
191
|
-
# Store API key in session for future requests
|
|
192
|
-
session['api_key'] = api_key
|
|
193
|
-
params['apiKey'] = api_key
|
|
194
|
-
logger.info("Using API key from query parameter")
|
|
195
|
-
return headers, params, None
|
|
196
|
-
|
|
197
|
-
# Check for useCookie flag without actual cookie
|
|
198
|
-
if request.args.get('useCookie') == 'true':
|
|
199
|
-
logger.warning("useCookie=true but no session token found")
|
|
200
|
-
return None, None, "No session token found in cookies"
|
|
201
|
-
|
|
202
|
-
# Try to use stored API key from session
|
|
203
|
-
stored_api_key = session.get('api_key')
|
|
204
|
-
if stored_api_key:
|
|
205
|
-
params['apiKey'] = stored_api_key
|
|
206
|
-
logger.info("Using stored API key from session")
|
|
207
|
-
return headers, params, None
|
|
208
|
-
|
|
209
|
-
logger.warning("No authentication method available")
|
|
210
|
-
return None, None, "API key is required"
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
def make_sensolus_request(endpoint, extra_params=None, method='GET', json_body=None):
|
|
214
|
-
"""
|
|
215
|
-
Make an authenticated request to the Sensolus API.
|
|
216
|
-
|
|
217
|
-
Args:
|
|
218
|
-
endpoint: API endpoint (e.g., '/organisations')
|
|
219
|
-
extra_params: Additional query parameters
|
|
220
|
-
method: HTTP method ('GET' or 'POST')
|
|
221
|
-
json_body: JSON body for POST requests
|
|
222
|
-
|
|
223
|
-
Returns:
|
|
224
|
-
tuple: (response_data, status_code)
|
|
225
|
-
"""
|
|
226
|
-
headers, params, error = get_auth_headers_and_params()
|
|
227
|
-
|
|
228
|
-
if error:
|
|
229
|
-
return {"error": error}, 401 if "session token" in error else 400
|
|
230
|
-
|
|
231
|
-
if extra_params:
|
|
232
|
-
params.update(extra_params)
|
|
233
|
-
|
|
234
|
-
url = f"{SENSOLUS_BASE_URL}{endpoint}"
|
|
235
|
-
|
|
236
|
-
# Log the request (mask sensitive data)
|
|
237
|
-
log_params = {k: '***' if k == 'apiKey' else v for k, v in params.items()}
|
|
238
|
-
log_headers = {k: '***' if k == 'Authorization' else v for k, v in headers.items()}
|
|
239
|
-
logger.info(f"API Request: {method} {url}")
|
|
240
|
-
logger.info(f" Headers: {log_headers}")
|
|
241
|
-
logger.info(f" Params: {log_params}")
|
|
242
|
-
if json_body:
|
|
243
|
-
logger.info(f" Body: {json_body}")
|
|
244
|
-
|
|
245
|
-
try:
|
|
246
|
-
if method == 'POST':
|
|
247
|
-
headers['Content-Type'] = 'application/json'
|
|
248
|
-
response = requests.post(
|
|
249
|
-
url,
|
|
250
|
-
params=params,
|
|
251
|
-
headers=headers,
|
|
252
|
-
json=json_body,
|
|
253
|
-
timeout=30
|
|
254
|
-
)
|
|
255
|
-
else:
|
|
256
|
-
response = requests.get(
|
|
257
|
-
url,
|
|
258
|
-
params=params,
|
|
259
|
-
headers=headers,
|
|
260
|
-
timeout=30
|
|
261
|
-
)
|
|
262
|
-
|
|
263
|
-
logger.info(f"API Response: {response.status_code} from {endpoint}")
|
|
264
|
-
|
|
265
|
-
if response.status_code >= 400:
|
|
266
|
-
logger.error(f"API Error: {response.status_code} - {response.text[:500]}")
|
|
267
|
-
|
|
268
|
-
response.raise_for_status()
|
|
269
|
-
return response.json(), 200
|
|
270
|
-
|
|
271
|
-
except requests.exceptions.HTTPError as e:
|
|
272
|
-
error_msg = f"API error: {e.response.status_code}"
|
|
273
|
-
if e.response.text:
|
|
274
|
-
try:
|
|
275
|
-
error_detail = e.response.json()
|
|
276
|
-
error_msg = f"{error_msg} - {error_detail.get('message', e.response.text[:200])}"
|
|
277
|
-
except Exception:
|
|
278
|
-
error_msg = f"{error_msg} - {e.response.text[:200]}"
|
|
279
|
-
logger.error(f"HTTP Error on {endpoint}: {error_msg}")
|
|
280
|
-
return {"error": error_msg}, e.response.status_code
|
|
281
|
-
|
|
282
|
-
except requests.exceptions.RequestException as e:
|
|
283
|
-
logger.error(f"Request Exception on {endpoint}: {str(e)}")
|
|
284
|
-
return {"error": str(e)}, 500
|
|
285
|
-
|
|
286
|
-
|
|
287
167
|
@app.route('/')
|
|
288
168
|
def index():
|
|
289
169
|
return send_from_directory(app.static_folder, 'index.html')
|
|
@@ -658,6 +538,7 @@ if __name__ == '__main__':
|
|
|
658
538
|
logger.warning(f"DB init skipped: {e}")
|
|
659
539
|
|
|
660
540
|
scheduler.start()
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
541
|
+
debug_mode = os.getenv('FLASK_DEBUG', '0').lower() in ('1', 'true', 'yes')
|
|
542
|
+
logger.info(f"Starting server on port 5000 (scheduler active, debug={debug_mode})")
|
|
543
|
+
socketio.run(app, debug=debug_mode, port=5000, host='0.0.0.0',
|
|
544
|
+
allow_unsafe_werkzeug=debug_mode, use_reloader=False)
|
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
flask
|
|
2
|
-
flask-apscheduler
|
|
3
|
-
flask-socketio
|
|
4
|
-
flask-sqlalchemy
|
|
5
|
-
flask-migrate
|
|
6
|
-
requests
|
|
7
|
-
python-dotenv
|
|
8
|
-
psycopg2-binary
|
|
1
|
+
flask==3.1.3
|
|
2
|
+
flask-apscheduler==1.13.1
|
|
3
|
+
flask-socketio==5.6.1
|
|
4
|
+
flask-sqlalchemy==3.1.1
|
|
5
|
+
flask-migrate==4.1.0
|
|
6
|
+
requests==2.34.2
|
|
7
|
+
python-dotenv==1.2.2
|
|
8
|
+
psycopg2-binary==2.9.12
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
"""Sensolus public REST API client.
|
|
2
|
+
|
|
3
|
+
Wraps authenticated requests to the Sensolus API for the Flask backend.
|
|
4
|
+
Endpoint inputs are validated so they cannot escape the configured base URL
|
|
5
|
+
(no absolute URLs, protocol-relative URLs, path traversal, or query/fragment
|
|
6
|
+
injection). Cross-host redirects are also disabled.
|
|
7
|
+
"""
|
|
8
|
+
import logging
|
|
9
|
+
import os
|
|
10
|
+
import re
|
|
11
|
+
|
|
12
|
+
import requests
|
|
13
|
+
from flask import request, session
|
|
14
|
+
from urllib.parse import urlparse
|
|
15
|
+
|
|
16
|
+
logger = logging.getLogger(__name__)
|
|
17
|
+
|
|
18
|
+
SENSOLUS_DOMAIN = os.environ.get("SENSOLUS_DOMAIN", "cloud.sensolus.com")
|
|
19
|
+
SENSOLUS_BASE_URL = f"https://{SENSOLUS_DOMAIN}/rest/api/v2"
|
|
20
|
+
SENSOLUS_COOKIE_NAME = os.environ.get("SENSOLUS_COOKIE_NAME", "prod-sensolus-token")
|
|
21
|
+
|
|
22
|
+
# Endpoints are conservative relative paths: alphanumerics plus '/', '-', '_', '.'.
|
|
23
|
+
# Query strings are built separately from the params dict — not part of endpoint.
|
|
24
|
+
_ALLOWED_ENDPOINT_RE = re.compile(r'^/[A-Za-z0-9/_\-.]*$')
|
|
25
|
+
|
|
26
|
+
_EXPECTED_HOST = urlparse(SENSOLUS_BASE_URL).hostname
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _sanitize_endpoint(endpoint):
|
|
30
|
+
"""Validate the endpoint is a safe relative path under the Sensolus API base.
|
|
31
|
+
|
|
32
|
+
Rejects absolute URLs, protocol-relative URLs, path traversal, query/fragment
|
|
33
|
+
in the endpoint, and any characters outside a conservative allowlist. This
|
|
34
|
+
guarantees the final URL stays on the configured Sensolus host.
|
|
35
|
+
"""
|
|
36
|
+
if not isinstance(endpoint, str) or not endpoint:
|
|
37
|
+
raise ValueError("endpoint must be a non-empty string")
|
|
38
|
+
if not endpoint.startswith('/') or endpoint.startswith('//'):
|
|
39
|
+
raise ValueError(f"endpoint must be a single-slash relative path: {endpoint!r}")
|
|
40
|
+
if '..' in endpoint:
|
|
41
|
+
raise ValueError(f"endpoint must not contain '..': {endpoint!r}")
|
|
42
|
+
if '?' in endpoint or '#' in endpoint or '\\' in endpoint:
|
|
43
|
+
raise ValueError("endpoint must not contain '?', '#' or '\\'; use params for query strings")
|
|
44
|
+
if not _ALLOWED_ENDPOINT_RE.match(endpoint):
|
|
45
|
+
raise ValueError(f"endpoint contains invalid characters: {endpoint!r}")
|
|
46
|
+
return endpoint
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def get_auth_headers_and_params():
|
|
50
|
+
"""
|
|
51
|
+
Get authentication headers and params for Sensolus API calls.
|
|
52
|
+
|
|
53
|
+
Priority:
|
|
54
|
+
1. Session cookie (SENSOLUS_COOKIE_NAME) -> passed as Bearer token
|
|
55
|
+
2. API key from query parameter -> passed as apiKey query param
|
|
56
|
+
3. Stored API key from session -> passed as apiKey query param
|
|
57
|
+
|
|
58
|
+
Returns:
|
|
59
|
+
tuple: (headers dict, params dict, error message or None)
|
|
60
|
+
"""
|
|
61
|
+
headers = {}
|
|
62
|
+
params = {}
|
|
63
|
+
|
|
64
|
+
# Check for session cookie first - pass as Bearer token
|
|
65
|
+
token = request.cookies.get(SENSOLUS_COOKIE_NAME)
|
|
66
|
+
if token:
|
|
67
|
+
headers['Authorization'] = f'Bearer {token}'
|
|
68
|
+
logger.info("Using session cookie as Bearer token for authentication")
|
|
69
|
+
return headers, params, None
|
|
70
|
+
|
|
71
|
+
# Check for API key in query parameter
|
|
72
|
+
api_key = request.args.get('apiKey')
|
|
73
|
+
if api_key:
|
|
74
|
+
# Store API key in session for future requests
|
|
75
|
+
session['api_key'] = api_key
|
|
76
|
+
params['apiKey'] = api_key
|
|
77
|
+
logger.info("Using API key from query parameter")
|
|
78
|
+
return headers, params, None
|
|
79
|
+
|
|
80
|
+
# Check for useCookie flag without actual cookie
|
|
81
|
+
if request.args.get('useCookie') == 'true':
|
|
82
|
+
logger.warning("useCookie=true but no session token found")
|
|
83
|
+
return None, None, "No session token found in cookies"
|
|
84
|
+
|
|
85
|
+
# Try to use stored API key from session
|
|
86
|
+
stored_api_key = session.get('api_key')
|
|
87
|
+
if stored_api_key:
|
|
88
|
+
params['apiKey'] = stored_api_key
|
|
89
|
+
logger.info("Using stored API key from session")
|
|
90
|
+
return headers, params, None
|
|
91
|
+
|
|
92
|
+
logger.warning("No authentication method available")
|
|
93
|
+
return None, None, "API key is required"
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def make_sensolus_request(endpoint, extra_params=None, method='GET', json_body=None):
|
|
97
|
+
"""
|
|
98
|
+
Make an authenticated request to the Sensolus API.
|
|
99
|
+
|
|
100
|
+
Args:
|
|
101
|
+
endpoint: API endpoint relative to SENSOLUS_BASE_URL (e.g., '/organisations').
|
|
102
|
+
Validated against a strict allowlist — absolute URLs, protocol-relative
|
|
103
|
+
URLs, path traversal, and query/fragment characters are rejected.
|
|
104
|
+
extra_params: Additional query parameters
|
|
105
|
+
method: HTTP method ('GET' or 'POST')
|
|
106
|
+
json_body: JSON body for POST requests
|
|
107
|
+
|
|
108
|
+
Returns:
|
|
109
|
+
tuple: (response_data, status_code)
|
|
110
|
+
"""
|
|
111
|
+
try:
|
|
112
|
+
endpoint = _sanitize_endpoint(endpoint)
|
|
113
|
+
except ValueError as e:
|
|
114
|
+
logger.error(f"Rejected unsafe endpoint: {e}")
|
|
115
|
+
return {"error": "Invalid API endpoint"}, 400
|
|
116
|
+
|
|
117
|
+
headers, params, error = get_auth_headers_and_params()
|
|
118
|
+
|
|
119
|
+
if error:
|
|
120
|
+
return {"error": error}, 401 if "session token" in error else 400
|
|
121
|
+
|
|
122
|
+
if extra_params:
|
|
123
|
+
params.update(extra_params)
|
|
124
|
+
|
|
125
|
+
url = f"{SENSOLUS_BASE_URL}{endpoint}"
|
|
126
|
+
|
|
127
|
+
# Defence in depth: even though endpoint is sanitized, re-parse and verify
|
|
128
|
+
# the final URL still points at the expected Sensolus host before sending.
|
|
129
|
+
parsed = urlparse(url)
|
|
130
|
+
if parsed.scheme != 'https' or parsed.hostname != _EXPECTED_HOST:
|
|
131
|
+
logger.error(f"Refusing to send request to unexpected host: {parsed.hostname}")
|
|
132
|
+
return {"error": "Invalid API endpoint"}, 400
|
|
133
|
+
|
|
134
|
+
# Log the request (mask sensitive data)
|
|
135
|
+
log_params = {k: '***' if k == 'apiKey' else v for k, v in params.items()}
|
|
136
|
+
log_headers = {k: '***' if k == 'Authorization' else v for k, v in headers.items()}
|
|
137
|
+
logger.info(f"API Request: {method} {url}")
|
|
138
|
+
logger.info(f" Headers: {log_headers}")
|
|
139
|
+
logger.info(f" Params: {log_params}")
|
|
140
|
+
if json_body:
|
|
141
|
+
logger.info(f" Body: {json_body}")
|
|
142
|
+
|
|
143
|
+
try:
|
|
144
|
+
if method == 'POST':
|
|
145
|
+
headers['Content-Type'] = 'application/json'
|
|
146
|
+
response = requests.post(
|
|
147
|
+
url,
|
|
148
|
+
params=params,
|
|
149
|
+
headers=headers,
|
|
150
|
+
json=json_body,
|
|
151
|
+
timeout=30,
|
|
152
|
+
allow_redirects=False,
|
|
153
|
+
)
|
|
154
|
+
else:
|
|
155
|
+
response = requests.get(
|
|
156
|
+
url,
|
|
157
|
+
params=params,
|
|
158
|
+
headers=headers,
|
|
159
|
+
timeout=30,
|
|
160
|
+
allow_redirects=False,
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
logger.info(f"API Response: {response.status_code} from {endpoint}")
|
|
164
|
+
|
|
165
|
+
# Block server-initiated redirects — we will not chase the Sensolus API
|
|
166
|
+
# off to another host or path under any circumstances.
|
|
167
|
+
if 300 <= response.status_code < 400:
|
|
168
|
+
logger.error(f"Refusing to follow redirect from {endpoint} to {response.headers.get('Location')!r}")
|
|
169
|
+
return {"error": "Upstream redirect refused"}, 502
|
|
170
|
+
|
|
171
|
+
if response.status_code >= 400:
|
|
172
|
+
logger.error(f"API Error: {response.status_code} - {response.text[:500]}")
|
|
173
|
+
|
|
174
|
+
response.raise_for_status()
|
|
175
|
+
return response.json(), 200
|
|
176
|
+
|
|
177
|
+
except requests.exceptions.HTTPError as e:
|
|
178
|
+
error_msg = f"API error: {e.response.status_code}"
|
|
179
|
+
if e.response.text:
|
|
180
|
+
try:
|
|
181
|
+
error_detail = e.response.json()
|
|
182
|
+
error_msg = f"{error_msg} - {error_detail.get('message', e.response.text[:200])}"
|
|
183
|
+
except Exception:
|
|
184
|
+
error_msg = f"{error_msg} - {e.response.text[:200]}"
|
|
185
|
+
logger.error(f"HTTP Error on {endpoint}: {error_msg}")
|
|
186
|
+
return {"error": error_msg}, e.response.status_code
|
|
187
|
+
|
|
188
|
+
except requests.exceptions.RequestException as e:
|
|
189
|
+
logger.error(f"Request Exception on {endpoint}: {str(e)}")
|
|
190
|
+
return {"error": str(e)}, 500
|