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.
- netscaler_certbot_hook/__init__.py +29 -0
- netscaler_certbot_hook/__main__.py +13 -0
- netscaler_certbot_hook/cli.py +639 -0
- netscaler_certbot_hook/nitro.py +333 -0
- netscaler_certbot_hook-1.0.0.dist-info/METADATA +462 -0
- netscaler_certbot_hook-1.0.0.dist-info/RECORD +10 -0
- netscaler_certbot_hook-1.0.0.dist-info/WHEEL +5 -0
- netscaler_certbot_hook-1.0.0.dist-info/entry_points.txt +2 -0
- netscaler_certbot_hook-1.0.0.dist-info/licenses/LICENSE +21 -0
- netscaler_certbot_hook-1.0.0.dist-info/top_level.txt +1 -0
|
@@ -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)
|