netscaler-certbot-hook 1.0.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,29 @@
1
+ #!/usr/bin/env python3
2
+ # -*- coding: utf-8 -*-
3
+
4
+ """NetScaler Certbot Hook - Automated SSL Certificate Management for Citrix NetScaler ADC.
5
+
6
+ This package provides automated installation and renewal of SSL certificates
7
+ (e.g., Let's Encrypt) on Citrix NetScaler ADC appliances using the NITRO API.
8
+
9
+ Main Components:
10
+ - NitroClient: NITRO API client for NetScaler communication
11
+ - CLI: Command-line interface for certificate management
12
+
13
+ Example:
14
+ >>> from netscaler_certbot_hook import NitroClient
15
+ >>> client = NitroClient('https://192.168.1.1', 'nsroot', 'password')
16
+ >>> client.set_verify(True)
17
+
18
+ Author: Simon Lauger <simon@lauger.de>
19
+ License: MIT
20
+ """
21
+
22
+ from .nitro import NitroClient
23
+
24
+ __version__ = "1.0.0"
25
+ __author__ = "Simon Lauger"
26
+ __email__ = "simon@lauger.de"
27
+ __license__ = "MIT"
28
+
29
+ __all__ = ["NitroClient"]
@@ -0,0 +1,13 @@
1
+ #!/usr/bin/env python3
2
+ # -*- coding: utf-8 -*-
3
+
4
+ """Entry point for netscaler-certbot-hook when run as a module.
5
+
6
+ This allows the package to be run with:
7
+ python -m netscaler_certbot_hook --name example.com
8
+ """
9
+
10
+ from .cli import main
11
+
12
+ if __name__ == '__main__':
13
+ main()
@@ -0,0 +1,639 @@
1
+ #!/usr/bin/env python3
2
+ # -*- coding: utf-8 -*-
3
+
4
+ """NetScaler Certbot Hook - Automated SSL Certificate Management for Citrix NetScaler ADC.
5
+
6
+ This script automates the installation and renewal of SSL certificates (e.g., Let's Encrypt)
7
+ on Citrix NetScaler ADC appliances using the NITRO API. It handles certificate uploads,
8
+ installations, updates, and chain certificate linking.
9
+
10
+ The script is designed to work seamlessly with Certbot's DNS-01 challenge for fully
11
+ automated certificate renewal workflows.
12
+
13
+ Usage:
14
+ python3 netscaler-certbot-hook.py --name <cert-name> [options]
15
+
16
+ Environment Variables:
17
+ NS_URL: NetScaler management URL (required, e.g., https://192.168.1.1)
18
+ NS_LOGIN: NetScaler admin username (default: nsroot)
19
+ NS_PASSWORD: NetScaler admin password (default: nsroot)
20
+ NS_VERIFY_SSL: Verify SSL certificate (default: true)
21
+
22
+ Examples:
23
+ # Basic usage with Let's Encrypt default paths
24
+ export NS_URL=https://192.168.10.10
25
+ export NS_LOGIN=nsroot
26
+ export NS_PASSWORD=secretpassword
27
+ python3 netscaler-certbot-hook.py --name mydomain.com
28
+
29
+ # Custom certificate paths
30
+ python3 netscaler-certbot-hook.py --name mydomain.com \\
31
+ --cert /path/to/cert.pem \\
32
+ --privkey /path/to/privkey.pem \\
33
+ --chain-cert /path/to/chain.pem
34
+
35
+ Author: Simon Lauger <simon@lauger.de>
36
+ License: MIT
37
+ Version: 1.0.0
38
+ Copyright: Copyright 2020, IT Consulting Simon Lauger
39
+ """
40
+
41
+ __author__ = "Simon Lauger"
42
+ __email__ = "simon@lauger.de"
43
+ __version__ = "1.0.0"
44
+ __license__ = "MIT"
45
+ __copyright__ = "Copyright 2020, IT Consulting Simon Lauger"
46
+ __maintainer__ = "Simon Lauger"
47
+
48
+ import argparse
49
+ import os
50
+ import sys
51
+ import json
52
+ import time
53
+ import base64
54
+ import urllib.parse
55
+ import logging
56
+ from typing import Dict, Optional, Union, Any
57
+ from OpenSSL import crypto
58
+ from . import nitro
59
+
60
+ # Initialize logger
61
+ logger = logging.getLogger(__name__)
62
+
63
+ add_args = {
64
+ '--name': {
65
+ 'metavar': '<string>',
66
+ 'help': 'object name of the ssl certificate',
67
+ 'type': str,
68
+ 'default': None,
69
+ 'required': True,
70
+ },
71
+ '--chain': {
72
+ 'metavar': '<string>',
73
+ 'help': 'object name of the ssl chain certificate',
74
+ 'type': str,
75
+ 'default': 'letsencrypt',
76
+ 'required': False,
77
+ },
78
+ '--cert': {
79
+ 'metavar': '<file>',
80
+ 'help': 'path to the ssl certificate (default: /etc/letsencrypt/live/name/cert.pem)',
81
+ 'type': str,
82
+ 'default': None,
83
+ 'required': False,
84
+ },
85
+ '--privkey': {
86
+ 'metavar': '<file>',
87
+ 'help': 'path to the ssl private key (default: /etc/letsencrypt/live/name/privkey.pem)',
88
+ 'type': str,
89
+ 'default': None,
90
+ 'required': False,
91
+ },
92
+ '--chain-cert': {
93
+ 'metavar': '<file>',
94
+ 'help': 'path to the ssl chain certificate (default: /etc/letsencrypt/live/name/chain.pem)',
95
+ 'type': str,
96
+ 'default': None,
97
+ 'required': False,
98
+ },
99
+ '--verbose': {
100
+ 'help': 'enable verbose output (DEBUG level)',
101
+ 'action': 'store_true',
102
+ 'default': False,
103
+ 'required': False,
104
+ },
105
+ '--quiet': {
106
+ 'help': 'suppress all output except errors (ERROR level)',
107
+ 'action': 'store_true',
108
+ 'default': False,
109
+ 'required': False,
110
+ },
111
+ }
112
+
113
+ def add_argument(parser: argparse.ArgumentParser, arg: str, params: Dict[str, Any]) -> None:
114
+ """Add an argument to the argument parser.
115
+
116
+ Args:
117
+ parser (argparse.ArgumentParser): The argument parser instance.
118
+ arg (str): The argument name (e.g., '--name').
119
+ params (dict): Argument parameters (metavar, type, help, default, required, action).
120
+ """
121
+ # Build kwargs dynamically based on params
122
+ kwargs = {'help': params['help']}
123
+
124
+ if 'metavar' in params:
125
+ kwargs['metavar'] = params['metavar']
126
+ if 'type' in params:
127
+ kwargs['type'] = params['type']
128
+ if 'default' in params:
129
+ kwargs['default'] = params['default']
130
+ if 'required' in params:
131
+ kwargs['required'] = params['required']
132
+ if 'action' in params:
133
+ kwargs['action'] = params['action']
134
+
135
+ parser.add_argument(arg, **kwargs)
136
+
137
+
138
+ def nitro_check_cert(nitro_client: nitro.NitroClient, objectname: str) -> Union[Dict[str, Any], bool]:
139
+ """Check if a certificate exists on the NetScaler.
140
+
141
+ Args:
142
+ nitro_client (NitroClient): Configured NITRO API client instance.
143
+ objectname (str): Name of the certificate object to check.
144
+
145
+ Returns:
146
+ dict or bool: Certificate information dict if found, False otherwise.
147
+
148
+ Example:
149
+ >>> cert = nitro_check_cert(client, 'mydomain.com')
150
+ >>> if cert:
151
+ ... print(cert['sslcertkey'][0]['serial'])
152
+ """
153
+ try:
154
+ result = nitro_client.request(
155
+ 'get',
156
+ endpoint='config',
157
+ objecttype='sslcertkey',
158
+ objectname=objectname,
159
+ )
160
+ except Exception as e:
161
+ # Certificate not found or other API error
162
+ return False
163
+ return result
164
+
165
+
166
+ def nitro_upload(nitro_client: nitro.NitroClient, source_file: str, target_filename: str) -> Dict[str, Any]:
167
+ """Upload a file to the NetScaler /nsconfig/ssl directory.
168
+
169
+ Args:
170
+ nitro_client (NitroClient): Configured NITRO API client instance.
171
+ source_file (str): Local path to the file to upload.
172
+ target_filename (str): Target filename on the NetScaler.
173
+
174
+ Returns:
175
+ dict: API response from the upload operation.
176
+
177
+ Raises:
178
+ Exception: If the upload fails or file cannot be read.
179
+ """
180
+ return nitro_client.request(
181
+ 'post',
182
+ endpoint='config',
183
+ objecttype='systemfile',
184
+ data=json.dumps({
185
+ 'systemfile': {
186
+ 'filename': target_filename,
187
+ 'filecontent': base64.b64encode(open(source_file, 'rb').read()).decode('utf-8'),
188
+ 'filelocation': '/nsconfig/ssl',
189
+ 'fileencoding': 'BASE64',
190
+ }
191
+ }),
192
+ )
193
+
194
+
195
+ def nitro_delete(nitro_client: nitro.NitroClient, filename: str) -> Dict[str, Any]:
196
+ """Delete a file from the NetScaler /nsconfig/ssl directory.
197
+
198
+ Args:
199
+ nitro_client (NitroClient): Configured NITRO API client instance.
200
+ filename (str): Name of the file to delete.
201
+
202
+ Returns:
203
+ dict: API response from the delete operation.
204
+ """
205
+ return nitro_client.request(
206
+ 'delete',
207
+ endpoint='config',
208
+ objecttype='systemfile',
209
+ objectname=filename,
210
+ params={'args': 'filelocation:%%2Fnsconfig%%2Fssl'},
211
+ )
212
+
213
+
214
+ def nitro_install_cert(nitro_client: nitro.NitroClient, name: str, cert: Optional[str] = None,
215
+ key: Optional[str] = None, update: bool = False) -> Dict[str, Any]:
216
+ """Install or update a certificate on the NetScaler.
217
+
218
+ Args:
219
+ nitro_client (NitroClient): Configured NITRO API client instance.
220
+ name (str): Certificate object name.
221
+ cert (str, optional): Certificate filename on NetScaler. Defaults to None.
222
+ key (str, optional): Private key filename on NetScaler. Defaults to None.
223
+ update (bool, optional): True to update existing cert, False to create new. Defaults to False.
224
+
225
+ Returns:
226
+ dict: API response from the installation/update operation.
227
+
228
+ Example:
229
+ >>> nitro_install_cert(client, 'mydomain.com', cert='mydomain.crt', key='mydomain.key')
230
+ """
231
+ data = {
232
+ 'sslcertkey': {
233
+ 'certkey': name,
234
+ }
235
+ }
236
+
237
+ if update:
238
+ params = {'action': 'update'}
239
+ else:
240
+ params = {}
241
+
242
+ if cert:
243
+ data['sslcertkey']['cert'] = "/nsconfig/ssl/{}".format(cert)
244
+ if key:
245
+ data['sslcertkey']['key'] = "/nsconfig/ssl/{}".format(key)
246
+
247
+ return nitro_client.request(
248
+ 'post',
249
+ endpoint='config',
250
+ objecttype='sslcertkey',
251
+ data=json.dumps(data),
252
+ params=params,
253
+ )
254
+
255
+
256
+ def nitro_link_cert(nitro_client: nitro.NitroClient, name: str, chain: str) -> Dict[str, Any]:
257
+ """Link a certificate to its chain certificate.
258
+
259
+ Args:
260
+ nitro_client (NitroClient): Configured NITRO API client instance.
261
+ name (str): Certificate object name.
262
+ chain (str): Chain certificate object name to link to.
263
+
264
+ Returns:
265
+ dict: API response from the link operation.
266
+
267
+ Raises:
268
+ Exception: If the link operation fails (e.g., link already exists).
269
+ """
270
+ return nitro_client.request(
271
+ 'post',
272
+ endpoint='config',
273
+ objecttype='sslcertkey',
274
+ data=json.dumps({
275
+ 'sslcertkey': {
276
+ 'certkey': name,
277
+ 'linkcertkeyname': chain,
278
+ }
279
+ }),
280
+ params={'action': 'link'},
281
+ )
282
+
283
+
284
+ def nitro_save_config(nitro_client: nitro.NitroClient) -> Dict[str, Any]:
285
+ """Save the NetScaler running configuration to disk.
286
+
287
+ Args:
288
+ nitro_client (NitroClient): Configured NITRO API client instance.
289
+
290
+ Returns:
291
+ dict: API response from the save operation.
292
+
293
+ Note:
294
+ This makes configuration changes persistent across reboots.
295
+ """
296
+ return nitro_client.request(
297
+ 'post',
298
+ endpoint='config',
299
+ objecttype='nsconfig',
300
+ data=json.dumps({
301
+ 'nsconfig': {}
302
+ }),
303
+ params={'action': 'save'},
304
+ )
305
+
306
+ def parse_arguments() -> argparse.Namespace:
307
+ """Parse and validate command line arguments.
308
+
309
+ Returns:
310
+ argparse.Namespace: Parsed command line arguments containing:
311
+ - name: Certificate object name (required)
312
+ - chain: Chain certificate name (default: 'letsencrypt')
313
+ - cert: Path to certificate file (optional)
314
+ - privkey: Path to private key file (optional)
315
+ - chain_cert: Path to chain certificate file (optional)
316
+
317
+ Example:
318
+ >>> args = parse_arguments()
319
+ >>> print(args.name)
320
+ 'mydomain.com'
321
+ """
322
+ parser = argparse.ArgumentParser(
323
+ description='Install and manage SSL certificates on Citrix NetScaler ADC.'
324
+ )
325
+
326
+ for key in add_args:
327
+ add_argument(parser, key, add_args[key])
328
+
329
+ return parser.parse_args()
330
+
331
+
332
+ def get_config(args: argparse.Namespace) -> Dict[str, Union[str, bool, int]]:
333
+ """Build and validate configuration from environment variables and arguments.
334
+
335
+ Reads configuration from environment variables (NS_URL, NS_LOGIN, NS_PASSWORD, NS_VERIFY_SSL)
336
+ and combines them with command line arguments. Validates all required settings and ensures
337
+ certificate files exist.
338
+
339
+ Args:
340
+ args (argparse.Namespace): Parsed command line arguments.
341
+
342
+ Returns:
343
+ dict: Configuration dictionary containing:
344
+ - username (str): NetScaler admin username
345
+ - password (str): NetScaler admin password
346
+ - verify_ssl (bool): Whether to verify SSL certificates
347
+ - url (str): NetScaler management URL
348
+ - cert_file (str): Path to certificate file
349
+ - privkey_file (str): Path to private key file
350
+ - chain_file (str): Path to chain certificate file
351
+ - cert_name (str): Certificate object name
352
+ - chain_name (str): Chain certificate object name
353
+ - timestamp (int): Unix timestamp for file naming
354
+
355
+ Raises:
356
+ ValueError: If required environment variables are missing or invalid.
357
+ FileNotFoundError: If any certificate file does not exist.
358
+
359
+ Example:
360
+ >>> config = get_config(args)
361
+ >>> print(config['url'])
362
+ 'https://192.168.10.10'
363
+ """
364
+ config = {
365
+ 'username': os.getenv('NS_LOGIN', 'nsroot'),
366
+ 'password': os.getenv('NS_PASSWORD', 'nsroot'),
367
+ 'verify_ssl': os.getenv('NS_VERIFY_SSL', 'true').lower() in ('true', '1', 'yes'),
368
+ 'url': os.getenv('NS_URL', None),
369
+ 'cert_file': args.cert or '/etc/letsencrypt/live/{}/cert.pem'.format(args.name),
370
+ 'privkey_file': args.privkey or '/etc/letsencrypt/live/{}/privkey.pem'.format(args.name),
371
+ 'chain_file': args.chain_cert or '/etc/letsencrypt/live/{}/chain.pem'.format(args.name),
372
+ 'cert_name': args.name,
373
+ 'chain_name': args.chain,
374
+ 'timestamp': int(time.time()),
375
+ }
376
+
377
+ # Validate required configuration
378
+ if config['url'] is None:
379
+ raise ValueError('required environment variable NS_URL not set')
380
+
381
+ if not config['url'].startswith('http://') and not config['url'].startswith('https://'):
382
+ raise ValueError('NS_URL must start with http:// or https://')
383
+
384
+ if not config['username']:
385
+ raise ValueError('NS_LOGIN environment variable must not be empty')
386
+
387
+ if not config['password']:
388
+ raise ValueError('NS_PASSWORD environment variable must not be empty')
389
+
390
+ # Validate certificate files exist
391
+ for file_key, file_path in [('cert_file', config['cert_file']),
392
+ ('privkey_file', config['privkey_file']),
393
+ ('chain_file', config['chain_file'])]:
394
+ if not os.path.isfile(file_path):
395
+ raise FileNotFoundError('{} not found: {}'.format(file_key, file_path))
396
+
397
+ return config
398
+
399
+ def get_certificate_serial(cert_file: str) -> int:
400
+ """Extract serial number from a PEM certificate file.
401
+
402
+ Args:
403
+ cert_file (str): Path to the PEM-encoded certificate file.
404
+
405
+ Returns:
406
+ int: Certificate serial number.
407
+
408
+ Raises:
409
+ FileNotFoundError: If certificate file does not exist.
410
+ IOError: If certificate file cannot be read.
411
+ ValueError: If certificate format is invalid.
412
+
413
+ Example:
414
+ >>> serial = get_certificate_serial('/etc/letsencrypt/live/mydomain.com/cert.pem')
415
+ >>> print(serial)
416
+ 409596789458967997345847308430335698529007
417
+ """
418
+ if not os.path.isfile(cert_file):
419
+ raise FileNotFoundError("Certificate file not found: {}".format(cert_file))
420
+
421
+ try:
422
+ with open(cert_file, 'r') as f:
423
+ cert_data = f.read()
424
+ cert = crypto.load_certificate(crypto.FILETYPE_PEM, cert_data)
425
+ return cert.get_serial_number()
426
+ except (IOError, OSError) as e:
427
+ raise IOError("Failed to read certificate file {}: {}".format(cert_file, str(e)))
428
+ except crypto.Error as e:
429
+ raise ValueError("Invalid certificate format in {}: {}".format(cert_file, str(e)))
430
+
431
+
432
+ def process_chain_certificate(nitro_client: nitro.NitroClient, config: Dict[str, Union[str, bool, int]]) -> None:
433
+ """Process and install chain certificate if needed.
434
+
435
+ Checks if the chain certificate exists on the NetScaler. If it exists and matches
436
+ the local serial number, no action is taken. If it doesn't exist, uploads and
437
+ installs the chain certificate.
438
+
439
+ Args:
440
+ nitro_client (NitroClient): Configured NITRO API client instance.
441
+ config (dict): Configuration dictionary from get_config().
442
+
443
+ Raises:
444
+ Exception: If installed chain certificate serial doesn't match local certificate.
445
+ Chain certificates should not be updated for security reasons.
446
+
447
+ Note:
448
+ For security reasons, this function does NOT update existing chain certificates
449
+ if the serial number differs. Manual intervention is required in such cases.
450
+ """
451
+ chain_serial = get_certificate_serial(config['chain_file'])
452
+ check_chain = nitro_check_cert(nitro_client, config['chain_name'])
453
+
454
+ if check_chain:
455
+ installed_serial = int(check_chain['sslcertkey'][0]['serial'], 16)
456
+ logger.info("chain certificate %s found with serial %s", config['chain_name'], installed_serial)
457
+
458
+ if installed_serial == chain_serial:
459
+ logger.info("installed chain certificate matches our serial - nothing to do")
460
+ else:
461
+ raise Exception('serial of installed chain certificate does not match our serial')
462
+ else:
463
+ logger.info("chain certificate %s not found", config['chain_name'])
464
+ chain_filename = '{}-{}.crt'.format(config['chain_name'], config['timestamp'])
465
+ logger.info("uploading chain certificate as %s", chain_filename)
466
+ nitro_upload(nitro_client, config['chain_file'], chain_filename)
467
+ logger.info("installing chain certificate with serial %s", chain_serial)
468
+ nitro_install_cert(nitro_client, config['chain_name'], cert=chain_filename)
469
+
470
+
471
+ def install_or_update_certificate(nitro_client: nitro.NitroClient, config: Dict[str, Union[str, bool, int]],
472
+ cert_serial: int, update: bool = False) -> None:
473
+ """Install or update a certificate on the NetScaler.
474
+
475
+ Uploads the certificate and private key files, installs or updates the certificate
476
+ object, links it to the chain certificate, and saves the configuration.
477
+
478
+ Args:
479
+ nitro_client (NitroClient): Configured NITRO API client instance.
480
+ config (dict): Configuration dictionary from get_config().
481
+ cert_serial (int): Serial number of the certificate being installed.
482
+ update (bool, optional): True to update existing certificate, False to create new.
483
+ Defaults to False.
484
+
485
+ Note:
486
+ This function automatically handles linking to the chain certificate and
487
+ saves the NetScaler configuration after installation.
488
+ """
489
+ cert_filename = '{}-{}.crt'.format(config['cert_name'], config['timestamp'])
490
+ key_filename = '{}-{}.key'.format(config['cert_name'], config['timestamp'])
491
+
492
+ logger.info("uploading certificate as %s", cert_filename)
493
+ nitro_upload(nitro_client, config['cert_file'], cert_filename)
494
+ logger.info("uploading private key as %s", key_filename)
495
+ nitro_upload(nitro_client, config['privkey_file'], key_filename)
496
+
497
+ if update:
498
+ logger.info("update certificate %s", config['cert_name'])
499
+ else:
500
+ logger.info("installing certificate with serial %s", cert_serial)
501
+
502
+ nitro_install_cert(nitro_client, config['cert_name'], cert=cert_filename, key=key_filename, update=update)
503
+
504
+ logger.info("link certificate %s to chain certificate %s", config['cert_name'], config['chain_name'])
505
+ try:
506
+ nitro_link_cert(nitro_client, config['cert_name'], config['chain_name'])
507
+ except Exception as e:
508
+ # Link already exists, which is fine
509
+ logger.info("certificate link was already present - nothing to do")
510
+
511
+ logger.info("saving configuration")
512
+ nitro_save_config(nitro_client)
513
+
514
+
515
+ def process_certificate(nitro_client: nitro.NitroClient, config: Dict[str, Union[str, bool, int]]) -> None:
516
+ """Process and install or update the main certificate.
517
+
518
+ Checks if the certificate exists on the NetScaler. Compares serial numbers and
519
+ decides whether to skip (if matching), update (if different), or install (if not found).
520
+
521
+ Args:
522
+ nitro_client (NitroClient): Configured NITRO API client instance.
523
+ config (dict): Configuration dictionary from get_config().
524
+
525
+ Behavior:
526
+ - If certificate exists and serial matches: No action taken
527
+ - If certificate exists and serial differs: Updates the certificate
528
+ - If certificate doesn't exist: Installs new certificate
529
+ """
530
+ cert_serial = get_certificate_serial(config['cert_file'])
531
+ check_cert = nitro_check_cert(nitro_client, config['cert_name'])
532
+
533
+ if check_cert:
534
+ installed_serial = int(check_cert['sslcertkey'][0]['serial'], 16)
535
+ logger.info("certificate %s found with serial %s", config['cert_name'], installed_serial)
536
+
537
+ if installed_serial == cert_serial:
538
+ logger.info("installed certificate matches our serial - nothing to do")
539
+ else:
540
+ install_or_update_certificate(nitro_client, config, cert_serial, update=True)
541
+ else:
542
+ logger.info("certificate %s not found", config['cert_name'])
543
+ install_or_update_certificate(nitro_client, config, cert_serial, update=False)
544
+
545
+ def setup_logging(verbose: bool = False, quiet: bool = False) -> None:
546
+ """Configure logging based on verbosity flags.
547
+
548
+ Args:
549
+ verbose (bool): Enable DEBUG level logging if True.
550
+ quiet (bool): Suppress all but ERROR level logging if True.
551
+
552
+ Note:
553
+ If both verbose and quiet are True, verbose takes precedence.
554
+ """
555
+ # Determine log level
556
+ if verbose:
557
+ log_level = logging.DEBUG
558
+ elif quiet:
559
+ log_level = logging.ERROR
560
+ else:
561
+ log_level = logging.INFO
562
+
563
+ # Configure logging format
564
+ log_format = '%(message)s'
565
+ if verbose:
566
+ # More detailed format for debug mode
567
+ log_format = '%(levelname)s: %(message)s'
568
+
569
+ logging.basicConfig(
570
+ level=log_level,
571
+ format=log_format,
572
+ handlers=[logging.StreamHandler(sys.stdout)]
573
+ )
574
+
575
+ def main() -> None:
576
+ """Main entry point for certificate installation and management.
577
+
578
+ This function orchestrates the entire certificate installation workflow:
579
+ 1. Parses command line arguments
580
+ 2. Configures logging based on verbosity flags
581
+ 3. Loads and validates configuration
582
+ 4. Connects to NetScaler via NITRO API
583
+ 5. Processes chain certificate (install if needed)
584
+ 6. Processes main certificate (install or update as needed)
585
+
586
+ The function handles the complete lifecycle including validation, upload,
587
+ installation, linking, and configuration persistence.
588
+
589
+ Raises:
590
+ ValueError: If configuration is invalid or missing required values.
591
+ FileNotFoundError: If certificate files are not found.
592
+ Exception: If any NITRO API operation fails.
593
+
594
+ Exit Codes:
595
+ 0: Success - Certificate installed/updated or already up-to-date
596
+ 1: Error - Configuration, validation, or API error occurred
597
+
598
+ Example:
599
+ This function is called when the script is run directly:
600
+ $ python3 netscaler-certbot-hook.py --name mydomain.com
601
+ $ python3 netscaler-certbot-hook.py --name mydomain.com --verbose
602
+ $ python3 netscaler-certbot-hook.py --name mydomain.com --quiet
603
+ """
604
+ args = parse_arguments()
605
+
606
+ # Setup logging
607
+ setup_logging(verbose=args.verbose, quiet=args.quiet)
608
+
609
+ logger.debug("Starting NetScaler Certbot Hook")
610
+ logger.debug("Certificate name: %s", args.name)
611
+
612
+ config = get_config(args)
613
+
614
+ # Initialize NITRO client
615
+ logger.debug("Connecting to NetScaler at %s", config['url'])
616
+ nitro_client = nitro.NitroClient(config['url'], config['username'], config['password'])
617
+ nitro_client.set_verify(config['verify_ssl'])
618
+ nitro_client.on_error('continue')
619
+
620
+ # Process chain certificate
621
+ process_chain_certificate(nitro_client, config)
622
+
623
+ # Process main certificate
624
+ process_certificate(nitro_client, config)
625
+
626
+ logger.debug("NetScaler Certbot Hook completed successfully")
627
+
628
+
629
+ if __name__ == '__main__':
630
+ try:
631
+ main()
632
+ sys.exit(0)
633
+ except Exception as e:
634
+ # Use basic print for error before logging is configured, or logger if available
635
+ if logger.hasHandlers():
636
+ logger.error("Error: %s", e)
637
+ else:
638
+ print("Error: {}".format(e), file=sys.stderr)
639
+ sys.exit(1)