procustodibus_agent 1.8.0__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.
@@ -0,0 +1,11 @@
1
+ """Pro Custodibus Agent."""
2
+
3
+ __version__ = "1.8.0"
4
+
5
+ DISPLAY_NAME = "Pro Custodibus Agent"
6
+ DESCRIPTION = "Synchronizes your WireGuard settings with Pro Custodibus."
7
+ SERVICE_NAME = "ProCustodibusAgent"
8
+
9
+ DEFAULT_API_URL = "https://api.custodib.us"
10
+ DEFAULT_APP_URL = "https://pro.custodib.us"
11
+ DOCS_URL = "https://docs.procustodibus.com"
@@ -0,0 +1,126 @@
1
+ """Agent logic."""
2
+
3
+ from io import StringIO
4
+ from logging import getLogger
5
+ from time import sleep
6
+
7
+ from procustodibus_agent import __version__ as version
8
+ from procustodibus_agent.api import ping_api
9
+ from procustodibus_agent.cnf import reload_cnf_if_modified
10
+ from procustodibus_agent.connectivity import check_connectivity
11
+ from procustodibus_agent.executor import execute_desired
12
+ from procustodibus_agent.ip_route import (
13
+ annotate_wg_show_with_ip_address_show,
14
+ annotate_wg_show_with_tx,
15
+ annotate_wg_show_with_up,
16
+ )
17
+ from procustodibus_agent.resolve_hostname import apply_endpoint_hostnames
18
+ from procustodibus_agent.wg import (
19
+ filter_wg_show,
20
+ parse_wg_show,
21
+ run_wg_show,
22
+ update_socket_mark,
23
+ )
24
+ from procustodibus_agent.wg_cnf import (
25
+ annotate_wg_show_with_wg_cnf,
26
+ load_all_from_wg_cnf,
27
+ )
28
+
29
+
30
+ def is_connection_good(cnf, message="Checking connectivity", *, expect_error=False):
31
+ """Runs connectivity check and logs result.
32
+
33
+ Arguments:
34
+ cnf (Config): Config object.
35
+ message (str): Message to log (defaults to 'Checking connectivity').
36
+ expect_error (bool): True if error expected (defaults to False).
37
+
38
+ Returns:
39
+ bool: True if connection is good.
40
+ """
41
+ buffer = StringIO()
42
+ print(message, file=buffer)
43
+
44
+ error = check_connectivity(cnf, buffer)
45
+ if error:
46
+ if not expect_error:
47
+ getLogger(__name__).error(buffer.getvalue())
48
+ return False
49
+
50
+ getLogger(__name__).info(buffer.getvalue())
51
+ return True
52
+
53
+
54
+ def ping_loop(cnf, cli_args=None):
55
+ """Pings continuously as specified by looping configuration.
56
+
57
+ Arguments:
58
+ cnf (Config): Config object.
59
+ cli_args (list): List of arguments to pass to Cnf constructor when reloading.
60
+ """
61
+ good = is_connection_good(cnf, message=f"Starting agent {version}")
62
+ hide_error = True
63
+
64
+ while cnf.loop:
65
+ if good:
66
+ try:
67
+ ping(cnf)
68
+ except Exception:
69
+ getLogger(__name__).exception("ping failed")
70
+ good = False
71
+ hide_error = False
72
+ else:
73
+ good = is_connection_good(cnf, expect_error=hide_error)
74
+ hide_error = True
75
+ if good:
76
+ continue
77
+
78
+ sleep(cnf.loop)
79
+
80
+ cnf, modified = reload_cnf_if_modified(cnf, cli_args)
81
+ if modified:
82
+ good = is_connection_good(cnf, message="Reloading configuration")
83
+ hide_error = True
84
+
85
+
86
+ def ping(cnf):
87
+ """Gathers wg info and pings api, executing desired changes from response.
88
+
89
+ Arguments:
90
+ cnf (Config): Config object.
91
+ """
92
+ interfaces = interrogate(cnf)
93
+ response = ping_api(cnf, interfaces)
94
+ executed = execute_desired(cnf, interfaces, response.get("data"))
95
+
96
+ if executed:
97
+ # allow time for changes to propagate
98
+ sleep(2)
99
+ # notify api of changes
100
+ interfaces = interrogate(cnf)
101
+ ping_api(cnf, interfaces, executed)
102
+ else:
103
+ # check for endpoint dns updates
104
+ apply_endpoint_hostnames(cnf, interfaces)
105
+
106
+
107
+ def interrogate(cnf):
108
+ """Extracts interface dict from wg show and wg config.
109
+
110
+ Arguments:
111
+ cnf (Config): Config object.
112
+
113
+ Returns:
114
+ dict: Interface info parsed from wg etc.
115
+ """
116
+ if cnf.wiresock:
117
+ interfaces = filter_wg_show(load_all_from_wg_cnf(cnf), cnf)
118
+ interfaces = annotate_wg_show_with_up(interfaces)
119
+ interfaces = annotate_wg_show_with_tx(interfaces)
120
+ else:
121
+ interfaces = parse_wg_show(run_wg_show(cnf))
122
+ update_socket_mark(interfaces, cnf)
123
+ interfaces = filter_wg_show(interfaces, cnf)
124
+ interfaces = annotate_wg_show_with_wg_cnf(cnf, interfaces)
125
+ interfaces = annotate_wg_show_with_ip_address_show(interfaces)
126
+ return interfaces
@@ -0,0 +1,489 @@
1
+ """API utilities."""
2
+
3
+ import time
4
+ from base64 import urlsafe_b64encode
5
+ from datetime import datetime, timezone
6
+ from json import dumps
7
+ from pathlib import Path
8
+ from random import randint
9
+ from socket import AI_CANONNAME, gaierror, getaddrinfo, gethostname
10
+
11
+ import requests
12
+ from nacl.encoding import Base64Encoder
13
+ from nacl.signing import SigningKey
14
+ from requests import Request, Session
15
+
16
+ from procustodibus_agent import DOCS_URL
17
+ from procustodibus_agent import __version__ as version
18
+ from procustodibus_agent.cnf import load_cnf
19
+ from procustodibus_agent.resolve_hostname import ResolverHTTPAdapter, get_resolver
20
+
21
+ API_TIMEOUT = 16 # seconds
22
+ UTC = timezone.utc
23
+
24
+
25
+ def get_session_transport(cnf):
26
+ """Finds or creates session object cached for the specified config.
27
+
28
+ Arguments:
29
+ cnf (Config): Config object.
30
+
31
+ Returns:
32
+ Session: Requests session object.
33
+ """
34
+ session = cnf.transport
35
+ if not session:
36
+ adapter = ResolverHTTPAdapter(
37
+ resolver=get_resolver(cnf),
38
+ socket_mark=cnf.socket_mark,
39
+ )
40
+ session = Session()
41
+ session.mount("http://", adapter)
42
+ session.mount("https://", adapter)
43
+ cnf.transport = session
44
+ return session
45
+
46
+
47
+ def send_to_api(cnf, request, *, raises=True):
48
+ """Sends request to the API.
49
+
50
+ Arguments:
51
+ cnf (Config): Config object.
52
+ request (Request): Request object to send.
53
+ raises (bool): True to raise on error response (default True).
54
+
55
+ Returns:
56
+ Response: Response object.
57
+ """
58
+ session = get_session_transport(cnf)
59
+
60
+ if not hasattr(request, "body"):
61
+ request = request.prepare()
62
+ response = session.send(request, timeout=API_TIMEOUT)
63
+
64
+ if raises:
65
+ response.raise_for_status()
66
+ return response
67
+
68
+
69
+ def request_from_api(cnf, method, url, **kwargs):
70
+ """Sends request to the API.
71
+
72
+ Arguments:
73
+ cnf (Config): Config object.
74
+ method (str): Request method (eg "GET").
75
+ url (str): Relative request URL (eg "hosts/123").
76
+ **kwargs (dict): Dict of arguments to pass to Request constructor.
77
+
78
+ Returns:
79
+ Response: Response object.
80
+ """
81
+ if not cnf.api:
82
+ _raise_setup_issue("Missing conf for API endpoint")
83
+ raises = kwargs.pop("raises", True)
84
+ request = Request(method, f"{cnf.api}/{url}", **kwargs)
85
+ return send_to_api(cnf, request, raises=raises)
86
+
87
+
88
+ def setup_api(cnf):
89
+ """Generates and saves new credential for configured agent.
90
+
91
+ Arguments:
92
+ cnf (Config): Config object.
93
+ """
94
+ raise_unless_has_cnf(cnf)
95
+ setup = load_setup(cnf)
96
+ code = setup["code"]
97
+
98
+ signing_key = SigningKey.generate()
99
+ public_key = signing_key.verify_key.encode(Base64Encoder).decode("utf-8")
100
+
101
+ url = f"users/{cnf.agent}/credentials/signature"
102
+ headers = {
103
+ "authorization": f"X-Custos user=^{cnf.agent}, agent_setup=^{code}",
104
+ "content-type": "application/json",
105
+ }
106
+ data = dumps(
107
+ {
108
+ "secret": public_key,
109
+ "description": f"on {getfqdn()}",
110
+ "keep_others": setup.get("keep_others") or False,
111
+ },
112
+ separators=(",", ":"),
113
+ )
114
+
115
+ request_from_api(cnf, "POST", url, data=data, headers=headers)
116
+ save_signing_key(cnf, signing_key)
117
+
118
+
119
+ def login_api(cnf):
120
+ """Logs into the api and saves session token on cnf obj.
121
+
122
+ Arguments:
123
+ cnf (Config): Config object.
124
+ """
125
+ raise_unless_has_cnf(cnf)
126
+
127
+ challenge = get_signing_challenge(cnf)["data"][0]
128
+ challenge_id = challenge["id"]
129
+ signature = sign_data(cnf, challenge["attributes"]["value"].encode("utf-8"))
130
+
131
+ headers = {
132
+ "authorization": f"X-Custos user=^{cnf.agent}"
133
+ f", challenge=^{challenge_id}"
134
+ f", signature={signature}",
135
+ }
136
+ response = request_from_api(cnf, "POST", "sessions", headers=headers)
137
+
138
+ # add 1-10 minutes of jitter before real session timeout
139
+ # to prevent repeating stampeding herd on every login
140
+ jitter = randint(60, 600) # noqa: S311
141
+
142
+ session = response.json()["data"][0]
143
+ cnf.session = {
144
+ "token": session["attributes"]["token"],
145
+ "expiration": time.time() + session["meta"]["expiration_timeout"] - jitter,
146
+ }
147
+
148
+
149
+ def ping_api(cnf, interfaces, executed=None):
150
+ """Sends ping request to API.
151
+
152
+ Arguments:
153
+ cnf (Config): Config object.
154
+ interfaces (dict): Interface info parsed from `wg show`.
155
+ executed (list): List of executed desired changes.
156
+
157
+ Returns:
158
+ Response: Response json.
159
+ """
160
+ raise_unless_has_cnf(cnf)
161
+
162
+ data = {"interfaces": interfaces}
163
+ if executed:
164
+ data["executed"] = executed
165
+
166
+ agent = build_agent_info(cnf)
167
+ if agent:
168
+ data["agent"] = agent
169
+
170
+ response = request_with_session(
171
+ cnf,
172
+ "POST",
173
+ f"hosts/{cnf.host}/ping/{version}",
174
+ data=dumps(data, separators=(",", ":")),
175
+ headers={"content-type": "application/json"},
176
+ )
177
+ return response.json()
178
+
179
+
180
+ def get_health_info(cnf):
181
+ """Gets the api's health info.
182
+
183
+ Arguments:
184
+ cnf (Config): Config object.
185
+
186
+ Returns:
187
+ Response: Response json.
188
+ """
189
+ return request_from_api(cnf, "GET", "health").json()
190
+
191
+
192
+ def get_host_info(cnf):
193
+ """Gets the configured host's info from the API.
194
+
195
+ Arguments:
196
+ cnf (Config): Config object.
197
+
198
+ Returns:
199
+ Response: Response json.
200
+ """
201
+ return request_with_session(cnf, "GET", f"hosts/{cnf.host}").json()
202
+
203
+
204
+ def send_with_session(cnf, request):
205
+ """Sends request with session authn header.
206
+
207
+ Arguments:
208
+ cnf (Config): Config object.
209
+ request (Request): Request object to send.
210
+
211
+ Returns:
212
+ Response: Response object.
213
+ """
214
+ create_session_if_expired(cnf)
215
+ prepped = request.prepare()
216
+ prepped.headers["authorization"] = build_authn_header(cnf)
217
+
218
+ response = send_to_api(cnf, prepped, raises=False)
219
+
220
+ if response.status_code == requests.codes.unauthorized:
221
+ login_api(cnf)
222
+ prepped = request.prepare()
223
+ prepped.headers["authorization"] = build_authn_header(cnf)
224
+ response = send_to_api(cnf, prepped, raises=False)
225
+
226
+ response.raise_for_status()
227
+ return response
228
+
229
+
230
+ def request_with_session(cnf, method, url, **kwargs):
231
+ """Sends request with session authn header.
232
+
233
+ Arguments:
234
+ cnf (Config): Config object.
235
+ method (str): Request method (eg "GET").
236
+ url (str): Relative request URL (eg "hosts/123").
237
+ **kwargs (dict): Dict of arguments to pass to Request constructor.
238
+
239
+ Returns:
240
+ Response: Response object.
241
+ """
242
+ request = Request(method, f"{cnf.api}/{url}", **kwargs)
243
+ return send_with_session(cnf, request)
244
+
245
+
246
+ def create_session_if_expired(cnf):
247
+ """Creates a new session if session is missing or has timed-out.
248
+
249
+ Arguments:
250
+ cnf (Config): Config object.
251
+ """
252
+ raise_unless_has_cnf(cnf)
253
+ session = cnf.__dict__.get("session")
254
+ if not session or session["expiration"] < time.time():
255
+ login_api(cnf)
256
+
257
+
258
+ def build_authn_header(cnf):
259
+ """Constructs authn header with session token.
260
+
261
+ Arguments:
262
+ cnf (Config): Config object.
263
+
264
+ Returns:
265
+ str: Authn header
266
+ """
267
+ return f"X-Custos user=^{cnf.agent}, session=^{cnf.session['token']}"
268
+
269
+
270
+ def get_signing_challenge(cnf):
271
+ """Gets the api's current authn challenge data.
272
+
273
+ Arguments:
274
+ cnf (Config): Config object.
275
+
276
+ Returns:
277
+ Response: Response json.
278
+ """
279
+ return request_from_api(cnf, "GET", "sessions/challenge").json()
280
+
281
+
282
+ def sign_data(cnf, data):
283
+ """Signs the specified message.
284
+
285
+ Arguments:
286
+ cnf (Config): Config object.
287
+ data (bytes): Message to sign.
288
+
289
+ Returns:
290
+ str: Base64-web-encoded signature.
291
+ """
292
+ signing_key = load_signing_key(cnf)
293
+ signed_message = signing_key.sign(data)
294
+ return encode_base64_web(signed_message.signature)
295
+
296
+
297
+ def load_signing_key(cnf):
298
+ """Loads the agent's signing key.
299
+
300
+ Arguments:
301
+ cnf (Config): Config object.
302
+
303
+ Returns:
304
+ SingingKey: Agent's signing key.
305
+
306
+ Propagates:
307
+ ValueError: If the signing credentials are missing or invalid.
308
+ """
309
+ try:
310
+ credentials = _load_credentials_dict(cnf.credentials, "credentials")
311
+ private_key = credentials["private_key"]
312
+ except KeyError:
313
+ _raise_setup_issue("Missing private key in credentials file")
314
+
315
+ try:
316
+ return SigningKey(private_key, Base64Encoder)
317
+ except Exception: # noqa: BLE001
318
+ _raise_setup_issue("Invalid private key in credentials file")
319
+
320
+
321
+ def encode_base64_web(message):
322
+ """Base64-web-encodes the specified message.
323
+
324
+ Arguments:
325
+ message (str): Message to encode.
326
+
327
+ Returns:
328
+ str: Base64-web-encoded message.
329
+ """
330
+ return urlsafe_b64encode(message).decode("utf-8").rstrip("=")
331
+
332
+
333
+ def format_datetime(now=None):
334
+ """Formats datetime as an RFC 3339 string.
335
+
336
+ Arguments:
337
+ now (datetime): Datetime to format (defaults to now).
338
+
339
+ Returns:
340
+ str: Formatted datetime.
341
+ """
342
+ if not now:
343
+ now = time.time()
344
+ return datetime.fromtimestamp(now, tz=UTC).strftime("%Y-%m-%dT%H:%M:%SZ")
345
+
346
+
347
+ def build_agent_info(cnf):
348
+ """Builds dict of agent info to send to API.
349
+
350
+ Arguments:
351
+ cnf (Config): Config object.
352
+
353
+ Returns:
354
+ dict: Agent info dict.
355
+ """
356
+ keys = ["read_only", "redact_secrets", "unmanaged_interfaces"]
357
+ if cnf.resolve_hostnames != "auto":
358
+ keys.append("resolve_hostnames")
359
+
360
+ return {key: getattr(cnf, key) for key in keys if getattr(cnf, key)}
361
+
362
+
363
+ def load_setup(cnf):
364
+ """Loads the setup configuration dict, or raises an error.
365
+
366
+ Arguments:
367
+ cnf (Config): Config object.
368
+
369
+ Returns:
370
+ dict: Setup configuration dict.
371
+
372
+ Propagates:
373
+ ValueError: If the setup config is missing the "code" property, or
374
+ the "expires" property indicates it has expired, or
375
+ if generated credentials cannot be saved because there is
376
+ no configured path to the credentials file.
377
+ """
378
+ if type(cnf.credentials) is dict:
379
+ _raise_setup_issue("Missing path to credentials file")
380
+
381
+ try:
382
+ setup = _load_credentials_dict(cnf.setup, "setup")
383
+ setup["code"]
384
+ except KeyError:
385
+ _raise_setup_issue("Missing code in setup file")
386
+
387
+ expires = setup.get("expires")
388
+ if expires:
389
+ expires = datetime.strptime(expires, "%Y-%m-%dT%H:%M:%SZ").replace(tzinfo=UTC)
390
+ now = datetime.now(tz=UTC)
391
+ if expires < now:
392
+ _raise_setup_issue("Setup code has expired")
393
+
394
+ return setup
395
+
396
+
397
+ def save_signing_key(cnf, signing_key):
398
+ """Saves the specified signing key to the configured credentials location.
399
+
400
+ Also deletes the configured setup file (to indicate setup is complete).
401
+
402
+ Arguments:
403
+ cnf (Config): Config object.
404
+ signing_key (SigningKey): Signing key to save.
405
+ """
406
+ private_key = signing_key.encode(Base64Encoder).decode("utf-8")
407
+ public_key = signing_key.verify_key.encode(Base64Encoder).decode("utf-8")
408
+
409
+ credentials = f"""
410
+ # procustodibus-credentials.conf generated {format_datetime()}
411
+ # for agent {cnf.agent} on {getfqdn()}
412
+ [Procustodibus.Credentials]
413
+ PublicKey = {public_key}
414
+ PrivateKey = {private_key}
415
+ """.strip()
416
+
417
+ with open(cnf.credentials, "w") as f:
418
+ for line in credentials.splitlines():
419
+ print(line.strip(), file=f)
420
+
421
+ Path(cnf.credentials).chmod(0o640)
422
+
423
+ if type(cnf.setup) is not dict:
424
+ Path(cnf.setup).unlink()
425
+
426
+
427
+ def getfqdn():
428
+ """Looks up fully-qualified domain name of localhost.
429
+
430
+ Returns:
431
+ str: Fully-qualified domain name (eg 'foo.example.com').
432
+ """
433
+ hostname = gethostname()
434
+ try:
435
+ return getaddrinfo(hostname, 0, flags=AI_CANONNAME)[0][3]
436
+ except gaierror:
437
+ return hostname
438
+
439
+
440
+ def raise_unless_has_cnf(cnf):
441
+ """Raises an error unless the specified cnf has all required settings.
442
+
443
+ Arguments:
444
+ cnf (Config): Config object.
445
+
446
+ Propagates:
447
+ ValueError: If the specified cnf is missing some required settings.
448
+ """
449
+ missing = []
450
+
451
+ if not cnf.api:
452
+ missing.append("API endpoint")
453
+
454
+ if not cnf.agent:
455
+ missing.append("Agent ID")
456
+
457
+ if not cnf.host:
458
+ missing.append("Host ID")
459
+
460
+ if missing:
461
+ _raise_setup_issue(f"Missing conf for {' and '.join(missing)}")
462
+
463
+
464
+ def _raise_setup_issue(problem):
465
+ raise ValueError(f"{problem}; see {DOCS_URL}/guide/agents/troubleshoot/ to fix")
466
+
467
+
468
+ def _load_credentials_dict(path, key):
469
+ """Loads the credentials in the specified ini file if not already a dict.
470
+
471
+ Arguments:
472
+ path: Path to credentials file or dict of credentials.
473
+ key: Key to load in ini (eg 'credentials').
474
+
475
+ Returns:
476
+ dict: Dict containing credentials.
477
+
478
+ Propagates:
479
+ ValueError: If the specified file cannot be read.
480
+ """
481
+ if type(path) is dict:
482
+ return path
483
+
484
+ try:
485
+ loaded = load_cnf(path)
486
+ except OSError:
487
+ _raise_setup_issue(f"Could not read {key} file {path}")
488
+
489
+ return loaded["procustodibus"][key]
@@ -0,0 +1,74 @@
1
+ """Pro Custodibus Agent.
2
+
3
+ Synchronizes your WireGuard settings with Pro Custodibus.
4
+
5
+ Usage:
6
+ procustodibus-agent [--config=CONFIG] [--loop=SECONDS]
7
+ [-v | -vv | --verbosity=LEVEL]
8
+ procustodibus-agent --help
9
+ procustodibus-agent --test [--config=CONFIG] [-v | -vv | --verbosity=LEVEL]
10
+ procustodibus-agent --version
11
+
12
+ Options:
13
+ -h --help Show this help
14
+ --test Run connectivity check
15
+ --version Show agent version
16
+ -c --config=CONFIG Config file
17
+ -l --loop=SECONDS Loop indefinitely, sending ping every SECONDS
18
+ --verbosity=LEVEL Log level (ERROR, WARNING, INFO, DEBUG)
19
+ -v INFO verbosity
20
+ -vv DEBUG verbosity
21
+ """
22
+
23
+ import sys
24
+
25
+ from docopt import docopt
26
+
27
+ from procustodibus_agent import __version__ as version
28
+ from procustodibus_agent.agent import ping, ping_loop
29
+ from procustodibus_agent.cnf import Cnf
30
+ from procustodibus_agent.connectivity import check_connectivity
31
+
32
+
33
+ def main():
34
+ """CLI Entry point."""
35
+ args = docopt(__doc__)
36
+ if args["--version"]:
37
+ # print version to stdout
38
+ print("procustodibus-agent " + version) # noqa: T201
39
+ elif args["--test"]:
40
+ check(args["--config"], args["--verbosity"] or args["-v"])
41
+ else:
42
+ run(
43
+ args["--config"],
44
+ args["--verbosity"] or args["-v"],
45
+ args["--loop"],
46
+ )
47
+
48
+
49
+ def check(*args):
50
+ """Runs connectivity check.
51
+
52
+ Arguments:
53
+ *args (list): List of arguments to pass to Cnf constructor.
54
+ """
55
+ cnf = Cnf(*args)
56
+ sys.exit(check_connectivity(cnf))
57
+
58
+
59
+ def run(*args):
60
+ """Runs CLI.
61
+
62
+ Arguments:
63
+ *args (list): List of arguments to pass to Cnf constructor.
64
+ """
65
+ cnf = Cnf(*args)
66
+
67
+ if cnf.loop:
68
+ ping_loop(cnf, args)
69
+ else:
70
+ ping(cnf)
71
+
72
+
73
+ if __name__ == "__main__":
74
+ main()