knotctl 0.1.1__py3-none-any.whl → 0.1.2__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.
- knotctl/__init__.py +74 -458
- knotctl/__main__.py +4 -0
- knotctl/config/__init__.py +84 -0
- knotctl/openstack/__init__.py +17 -0
- knotctl/runners/__init__.py +219 -0
- knotctl/utils/__init__.py +282 -0
- {knotctl-0.1.1.dist-info → knotctl-0.1.2.dist-info}/METADATA +9 -3
- knotctl-0.1.2.dist-info/RECORD +11 -0
- {knotctl-0.1.1.dist-info → knotctl-0.1.2.dist-info}/WHEEL +1 -1
- knotctl-0.1.1.dist-info/RECORD +0 -6
- {knotctl-0.1.1.dist-info → knotctl-0.1.2.dist-info}/entry_points.txt +0 -0
- {knotctl-0.1.1.dist-info → knotctl-0.1.2.dist-info/licenses}/LICENSE +0 -0
knotctl/__init__.py
CHANGED
|
@@ -1,128 +1,76 @@
|
|
|
1
1
|
#!/usr/bin/env python3
|
|
2
2
|
|
|
3
|
-
import argparse
|
|
4
3
|
import getpass
|
|
5
|
-
import json
|
|
6
4
|
import os
|
|
7
5
|
import sys
|
|
8
|
-
import urllib.parse
|
|
9
|
-
from os import environ, mkdir
|
|
10
|
-
from os.path import isdir, isfile, join
|
|
11
6
|
from typing import Union
|
|
12
|
-
from urllib.parse import
|
|
7
|
+
from urllib.parse import quote
|
|
13
8
|
|
|
14
|
-
import argcomplete
|
|
15
9
|
import requests
|
|
16
|
-
import yaml
|
|
17
|
-
from requests.models import HTTPBasicAuth
|
|
18
10
|
from simplejson.errors import JSONDecodeError as SimplejsonJSONDecodeError
|
|
19
11
|
|
|
12
|
+
from .config import Config
|
|
13
|
+
from .runners import Run
|
|
14
|
+
from .utils import error, get_parser, output, setup_url
|
|
15
|
+
|
|
20
16
|
try:
|
|
21
17
|
from requests.exceptions import JSONDecodeError as RequestsJSONDecodeError
|
|
22
18
|
except ImportError:
|
|
23
19
|
from requests.exceptions import InvalidJSONError as RequestsJSONDecodeError
|
|
24
20
|
|
|
25
21
|
|
|
26
|
-
|
|
27
|
-
def error(description: str, error: str) -> list[dict]:
|
|
28
|
-
response = []
|
|
29
|
-
reply = {}
|
|
30
|
-
# https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/406
|
|
31
|
-
reply["Code"] = 406
|
|
32
|
-
reply["Description"] = description
|
|
33
|
-
reply["Error"] = error
|
|
34
|
-
response.append(reply)
|
|
35
|
-
return response
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
def get_config(config_filename: str):
|
|
39
|
-
if not isfile(config_filename):
|
|
40
|
-
print("You need to configure knotctl before proceeding")
|
|
41
|
-
run_config(config_filename)
|
|
42
|
-
with open(config_filename, "r") as fh:
|
|
43
|
-
return yaml.safe_load(fh.read())
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
def nested_out(input, tabs="") -> str:
|
|
47
|
-
string = ""
|
|
48
|
-
if isinstance(input, str) or isinstance(input, int):
|
|
49
|
-
string += "{}\n".format(input)
|
|
50
|
-
elif isinstance(input, dict):
|
|
51
|
-
for key, value in input.items():
|
|
52
|
-
string += "{}{}: {}".format(tabs, key,
|
|
53
|
-
nested_out(value, tabs + " "))
|
|
54
|
-
elif isinstance(input, list):
|
|
55
|
-
for entry in input:
|
|
56
|
-
string += "{}\n{}".format(tabs, nested_out(entry, tabs + " "))
|
|
57
|
-
return string
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
def output(response: list[dict], jsonout: bool = False):
|
|
61
|
-
try:
|
|
62
|
-
if jsonout:
|
|
63
|
-
print(json.dumps(response))
|
|
64
|
-
else:
|
|
65
|
-
print(nested_out(response))
|
|
66
|
-
except BrokenPipeError:
|
|
67
|
-
pass
|
|
22
|
+
class Knotctl:
|
|
68
23
|
|
|
24
|
+
def __init__(self):
|
|
25
|
+
self.conf = Config()
|
|
26
|
+
self.config = self.get_config()
|
|
27
|
+
self.config_filename = self.conf.config_filename
|
|
28
|
+
self.runner = Run()
|
|
69
29
|
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
if isinstance(out, list):
|
|
76
|
-
for record in out:
|
|
77
|
-
if (record["data"] == parsed["data"]
|
|
78
|
-
and record["name"] == parsed["name"]
|
|
79
|
-
and record["rtype"] == parsed["rtype"]):
|
|
80
|
-
output(record, jsonout)
|
|
81
|
-
break
|
|
82
|
-
else:
|
|
83
|
-
output(out, jsonout)
|
|
30
|
+
def get_config(self):
|
|
31
|
+
config = self.conf.get_config()
|
|
32
|
+
if not config:
|
|
33
|
+
print("You need to configure knotctl before proceeding")
|
|
34
|
+
run_config()
|
|
84
35
|
|
|
36
|
+
return config
|
|
85
37
|
|
|
86
|
-
def
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
elif
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
# End of multiline
|
|
113
|
-
elif next_has_timestamp:
|
|
114
|
-
text += f"\n{line.strip()}"
|
|
115
|
-
out.append({"timestamp": timestamp, "text": text})
|
|
116
|
-
text = ""
|
|
117
|
-
timestamp = ""
|
|
118
|
-
# Middle of multiline
|
|
38
|
+
def run(self, url: str, args: dict, baseurl: str, parser: dict,
|
|
39
|
+
username: str):
|
|
40
|
+
try:
|
|
41
|
+
if args.command == "add":
|
|
42
|
+
self.runner.add(url, args.json)
|
|
43
|
+
elif args.command == "delete":
|
|
44
|
+
self.runner.delete(url, args.json)
|
|
45
|
+
elif args.command == "list":
|
|
46
|
+
self.runner.lister(url, args.json)
|
|
47
|
+
elif args.command == "update":
|
|
48
|
+
self.runner.update(url, args.json)
|
|
49
|
+
elif args.command == "user":
|
|
50
|
+
url = baseurl + f"/user/info/{username}"
|
|
51
|
+
self.runner.lister(url, args.json)
|
|
52
|
+
elif args.command == "auditlog":
|
|
53
|
+
url = baseurl + "/user/auditlog"
|
|
54
|
+
self.runner.log(url, args.json)
|
|
55
|
+
elif args.command == "changelog":
|
|
56
|
+
url = baseurl + f"/zones/changelog/{args.zone.rstrip('.')}"
|
|
57
|
+
self.runner.log(url, args.json)
|
|
58
|
+
elif args.command == "zone":
|
|
59
|
+
url = baseurl + "/zones"
|
|
60
|
+
self.runner.zone(url, args.json)
|
|
61
|
+
elif args.command == "openstack-sync":
|
|
62
|
+
self.runner.openstack_sync(args.cloud, args.name, args.zone,
|
|
63
|
+
baseurl, args.json)
|
|
119
64
|
else:
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
65
|
+
parser.print_help(sys.stderr)
|
|
66
|
+
return 2
|
|
67
|
+
except requests.exceptions.RequestException as e:
|
|
68
|
+
output(error(e, "Could not connect to server"))
|
|
69
|
+
except (RequestsJSONDecodeError, SimplejsonJSONDecodeError):
|
|
70
|
+
output(
|
|
71
|
+
error("Could not decode api response as JSON",
|
|
72
|
+
"Could not decode"))
|
|
73
|
+
return 0
|
|
126
74
|
|
|
127
75
|
|
|
128
76
|
def run_complete(shell: Union[None, str]):
|
|
@@ -135,33 +83,27 @@ def run_complete(shell: Union[None, str]):
|
|
|
135
83
|
|
|
136
84
|
|
|
137
85
|
def run_config(
|
|
138
|
-
config_filename: str,
|
|
139
86
|
context: Union[None, str] = None,
|
|
140
87
|
baseurl: Union[None, str] = None,
|
|
88
|
+
list_config: bool = False,
|
|
141
89
|
username: Union[None, str] = None,
|
|
142
90
|
password: Union[None, str] = None,
|
|
143
91
|
current: Union[None, str] = None,
|
|
144
92
|
):
|
|
93
|
+
conf = Config()
|
|
145
94
|
if current:
|
|
146
|
-
|
|
147
|
-
actual_path = os.readlink(config_filename)
|
|
148
|
-
print(actual_path.split("-")[-1])
|
|
149
|
-
else:
|
|
150
|
-
print("none")
|
|
95
|
+
print(conf.get_current())
|
|
151
96
|
return
|
|
152
97
|
config = {"baseurl": baseurl, "username": username, "password": password}
|
|
153
98
|
needed = []
|
|
154
99
|
if context:
|
|
155
|
-
|
|
156
|
-
found = os.path.isfile(symlink)
|
|
157
|
-
if os.path.islink(config_filename):
|
|
158
|
-
os.remove(config_filename)
|
|
159
|
-
elif os.path.isfile(config_filename):
|
|
160
|
-
os.rename(config_filename, symlink)
|
|
161
|
-
os.symlink(symlink, config_filename)
|
|
162
|
-
config_filename = symlink
|
|
100
|
+
found = conf.set_context(context)
|
|
163
101
|
if found:
|
|
164
102
|
return
|
|
103
|
+
if list_config:
|
|
104
|
+
config_data = conf.get_config_data()
|
|
105
|
+
output(config_data)
|
|
106
|
+
return
|
|
165
107
|
if not baseurl:
|
|
166
108
|
needed.append("baseurl")
|
|
167
109
|
if not username:
|
|
@@ -183,332 +125,7 @@ def run_config(
|
|
|
183
125
|
output(error("Can not configure without password", "No password"))
|
|
184
126
|
sys.exit(1)
|
|
185
127
|
|
|
186
|
-
|
|
187
|
-
fh.write(yaml.dump(config))
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
def run_delete(url: str, jsonout: bool, headers: dict):
|
|
191
|
-
response = requests.delete(url, headers=headers)
|
|
192
|
-
reply = response.json()
|
|
193
|
-
if not reply and response.status_code == requests.codes.ok:
|
|
194
|
-
reply = [{"Code": 200, "Description": "{} deleted".format(url)}]
|
|
195
|
-
|
|
196
|
-
output(reply, jsonout)
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
def run_list(url: str,
|
|
200
|
-
jsonout: bool,
|
|
201
|
-
headers: dict,
|
|
202
|
-
ret=False) -> Union[None, str]:
|
|
203
|
-
response = requests.get(url, headers=headers)
|
|
204
|
-
string = response.json()
|
|
205
|
-
if ret:
|
|
206
|
-
return string
|
|
207
|
-
else:
|
|
208
|
-
output(string, jsonout)
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
def run_update(url: str, jsonout: bool, headers: dict):
|
|
212
|
-
response = requests.patch(url, headers=headers)
|
|
213
|
-
output(response.json(), jsonout)
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
def run_zone(url: str,
|
|
217
|
-
jsonout: bool,
|
|
218
|
-
headers: dict,
|
|
219
|
-
ret=False) -> Union[None, str]:
|
|
220
|
-
response = requests.get(url, headers=headers)
|
|
221
|
-
zones = response.json()
|
|
222
|
-
for zone in zones:
|
|
223
|
-
del zone["records"]
|
|
224
|
-
string = zones
|
|
225
|
-
|
|
226
|
-
if ret:
|
|
227
|
-
return string
|
|
228
|
-
else:
|
|
229
|
-
output(string, jsonout)
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
# Set up the url
|
|
233
|
-
def setup_url(
|
|
234
|
-
baseurl: str,
|
|
235
|
-
arguments: Union[None, list[str]],
|
|
236
|
-
data: Union[None, str],
|
|
237
|
-
name: Union[None, str],
|
|
238
|
-
rtype: Union[None, str],
|
|
239
|
-
ttl: Union[None, str],
|
|
240
|
-
zone: Union[None, str],
|
|
241
|
-
) -> str:
|
|
242
|
-
url = baseurl + "/zones"
|
|
243
|
-
if zone:
|
|
244
|
-
if not zone.endswith("."):
|
|
245
|
-
zone += "."
|
|
246
|
-
url += "/{}".format(zone)
|
|
247
|
-
if name and zone:
|
|
248
|
-
if name.endswith(zone.rstrip(".")):
|
|
249
|
-
name += "."
|
|
250
|
-
url += "/records/{}".format(name)
|
|
251
|
-
if zone and name and rtype:
|
|
252
|
-
url += "/{}".format(rtype)
|
|
253
|
-
if data and zone and name and rtype:
|
|
254
|
-
url += "/{}".format(data)
|
|
255
|
-
if ttl and data and zone and name and rtype:
|
|
256
|
-
url += "/{}".format(ttl)
|
|
257
|
-
if data and zone and name and rtype and arguments:
|
|
258
|
-
url += "?"
|
|
259
|
-
for arg in arguments:
|
|
260
|
-
if not url.endswith("?"):
|
|
261
|
-
url += "&"
|
|
262
|
-
key, value = arg.split("=")
|
|
263
|
-
url += key + "=" + urllib.parse.quote_plus(value)
|
|
264
|
-
|
|
265
|
-
if ttl and (not rtype or not name or not zone):
|
|
266
|
-
output(
|
|
267
|
-
error(
|
|
268
|
-
"ttl only makes sense with rtype, name and zone",
|
|
269
|
-
"Missing parameter",
|
|
270
|
-
))
|
|
271
|
-
sys.exit(1)
|
|
272
|
-
if rtype and (not name or not zone):
|
|
273
|
-
output(
|
|
274
|
-
error(
|
|
275
|
-
"rtype only makes sense with name and zone",
|
|
276
|
-
"Missing parameter",
|
|
277
|
-
))
|
|
278
|
-
sys.exit(1)
|
|
279
|
-
if name and not zone:
|
|
280
|
-
output(error("name only makes sense with a zone", "Missing parameter"))
|
|
281
|
-
sys.exit(1)
|
|
282
|
-
return url
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
def split_url(url: str) -> dict:
|
|
286
|
-
parsed = urlparse(url, allow_fragments=False)
|
|
287
|
-
path = parsed.path
|
|
288
|
-
query = parsed.query
|
|
289
|
-
arguments: Union[None, list[str]] = query.split("&")
|
|
290
|
-
path_arr = path.split("/")
|
|
291
|
-
data: Union[None, str] = None
|
|
292
|
-
name: Union[None, str] = None
|
|
293
|
-
rtype: Union[None, str] = None
|
|
294
|
-
ttl: Union[None, str] = None
|
|
295
|
-
zone: Union[None, str] = None
|
|
296
|
-
if len(path_arr) > 2:
|
|
297
|
-
zone = path_arr[2]
|
|
298
|
-
if len(path_arr) > 4:
|
|
299
|
-
name = path_arr[4]
|
|
300
|
-
if len(path_arr) > 5:
|
|
301
|
-
rtype = path_arr[5]
|
|
302
|
-
if len(path_arr) > 6:
|
|
303
|
-
data = path_arr[6]
|
|
304
|
-
if len(path_arr) > 7:
|
|
305
|
-
ttl = path_arr[7]
|
|
306
|
-
|
|
307
|
-
return {
|
|
308
|
-
"arguments": arguments,
|
|
309
|
-
"data": data,
|
|
310
|
-
"name": name,
|
|
311
|
-
"rtype": rtype,
|
|
312
|
-
"ttl": ttl,
|
|
313
|
-
"zone": zone,
|
|
314
|
-
}
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
def get_parser() -> dict:
|
|
318
|
-
description = """Manage DNS records with knot dns rest api:
|
|
319
|
-
* https://gitlab.nic.cz/knot/knot-dns-rest"""
|
|
320
|
-
|
|
321
|
-
epilog = """
|
|
322
|
-
The Domain Name System specifies a database of information
|
|
323
|
-
elements for network resources. The types of information
|
|
324
|
-
elements are categorized and organized with a list of DNS
|
|
325
|
-
record types, the resource records (RRs). Each record has a
|
|
326
|
-
name, a type, an expiration time (time to live), and
|
|
327
|
-
type-specific data.
|
|
328
|
-
|
|
329
|
-
The following is a list of terms used in this program:
|
|
330
|
-
----------------------------------------------------------------
|
|
331
|
-
| Vocabulary | Description |
|
|
332
|
-
----------------------------------------------------------------
|
|
333
|
-
| zone | A DNS zone is a specific portion of the DNS |
|
|
334
|
-
| | namespace in the Domain Name System (DNS), |
|
|
335
|
-
| | which a specific organization or administrator |
|
|
336
|
-
| | manages. |
|
|
337
|
-
----------------------------------------------------------------
|
|
338
|
-
| name | In the Internet, a domain name is a string that |
|
|
339
|
-
| | identifies a realm of administrative autonomy, |
|
|
340
|
-
| | authority or control. Domain names are often |
|
|
341
|
-
| | used to identify services provided through the |
|
|
342
|
-
| | Internet, such as websites, email services and |
|
|
343
|
-
| | more. |
|
|
344
|
-
----------------------------------------------------------------
|
|
345
|
-
| rtype | A record type indicates the format of the data |
|
|
346
|
-
| | and it gives a hint of its intended use. For |
|
|
347
|
-
| | example, the A record is used to translate from |
|
|
348
|
-
| | a domain name to an IPv4 address, the NS record |
|
|
349
|
-
| | lists which name servers can answer lookups on |
|
|
350
|
-
| | a DNS zone, and the MX record specifies the |
|
|
351
|
-
| | mail server used to handle mail for a domain |
|
|
352
|
-
| | specified in an e-mail address. |
|
|
353
|
-
----------------------------------------------------------------
|
|
354
|
-
| data | A records data is of type-specific relevance, |
|
|
355
|
-
| | such as the IP address for address records, or |
|
|
356
|
-
| | the priority and hostname for MX records. |
|
|
357
|
-
----------------------------------------------------------------
|
|
358
|
-
|
|
359
|
-
This information was compiled from Wikipedia:
|
|
360
|
-
* https://en.wikipedia.org/wiki/DNS_zone
|
|
361
|
-
* https://en.wikipedia.org/wiki/Domain_Name_System
|
|
362
|
-
* https://en.wikipedia.org/wiki/Zone_file
|
|
363
|
-
"""
|
|
364
|
-
# Grab user input
|
|
365
|
-
parser = argparse.ArgumentParser(
|
|
366
|
-
description=description,
|
|
367
|
-
epilog=epilog,
|
|
368
|
-
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
369
|
-
)
|
|
370
|
-
parser.add_argument("--json", action=argparse.BooleanOptionalAction)
|
|
371
|
-
subparsers = parser.add_subparsers(dest="command")
|
|
372
|
-
|
|
373
|
-
add_description = "Add a new record to the zone."
|
|
374
|
-
addcmd = subparsers.add_parser("add", description=add_description)
|
|
375
|
-
addcmd.add_argument("-d", "--data", required=True)
|
|
376
|
-
addcmd.add_argument("-n", "--name", required=True)
|
|
377
|
-
addcmd.add_argument("-r", "--rtype", required=True)
|
|
378
|
-
addcmd.add_argument("-t", "--ttl")
|
|
379
|
-
addcmd.add_argument("-z", "--zone", required=True)
|
|
380
|
-
|
|
381
|
-
auditlog_description = "Audit the log file for errors."
|
|
382
|
-
subparsers.add_parser("auditlog", description=auditlog_description)
|
|
383
|
-
|
|
384
|
-
changelog_description = "View the changelog of a zone."
|
|
385
|
-
changelogcmd = subparsers.add_parser("changelog",
|
|
386
|
-
description=changelog_description)
|
|
387
|
-
changelogcmd.add_argument("-z", "--zone", required=True)
|
|
388
|
-
|
|
389
|
-
complete_description = "Generate shell completion script."
|
|
390
|
-
completecmd = subparsers.add_parser("completion",
|
|
391
|
-
description=complete_description)
|
|
392
|
-
completecmd.add_argument("-s", "--shell")
|
|
393
|
-
|
|
394
|
-
config_description = "Configure access to knot-dns-rest-api."
|
|
395
|
-
configcmd = subparsers.add_parser("config", description=config_description)
|
|
396
|
-
configcmd.add_argument("-b", "--baseurl")
|
|
397
|
-
configcmd.add_argument("-c", "--context")
|
|
398
|
-
configcmd.add_argument("-C",
|
|
399
|
-
"--current",
|
|
400
|
-
action=argparse.BooleanOptionalAction)
|
|
401
|
-
configcmd.add_argument("-p", "--password")
|
|
402
|
-
configcmd.add_argument("-u", "--username")
|
|
403
|
-
|
|
404
|
-
delete_description = "Delete a record from the zone."
|
|
405
|
-
deletecmd = subparsers.add_parser("delete", description=delete_description)
|
|
406
|
-
deletecmd.add_argument("-d", "--data")
|
|
407
|
-
deletecmd.add_argument("-n", "--name")
|
|
408
|
-
deletecmd.add_argument("-r", "--rtype")
|
|
409
|
-
deletecmd.add_argument("-z", "--zone", required=True)
|
|
410
|
-
|
|
411
|
-
list_description = "List records."
|
|
412
|
-
listcmd = subparsers.add_parser("list", description=list_description)
|
|
413
|
-
listcmd.add_argument("-d", "--data")
|
|
414
|
-
listcmd.add_argument("-n", "--name")
|
|
415
|
-
listcmd.add_argument("-r", "--rtype")
|
|
416
|
-
listcmd.add_argument("-z", "--zone", required=False)
|
|
417
|
-
|
|
418
|
-
user_description = "View user information."
|
|
419
|
-
usercmd = subparsers.add_parser("user", description=user_description)
|
|
420
|
-
usercmd.add_argument("-u", "--username", default=None)
|
|
421
|
-
|
|
422
|
-
update_description = (
|
|
423
|
-
"Update a record in the zone. The record must exist in the zone.\n")
|
|
424
|
-
update_description += (
|
|
425
|
-
"In this case --data, --name, --rtype and --ttl switches are used\n")
|
|
426
|
-
update_description += (
|
|
427
|
-
"for searching for the appropriate record, while the --argument\n")
|
|
428
|
-
update_description += "switches are used for updating the record."
|
|
429
|
-
update_epilog = """Available arguments are:
|
|
430
|
-
data: New record data.
|
|
431
|
-
name: New record domain name.
|
|
432
|
-
rtype: New record type.
|
|
433
|
-
ttl: New record time to live (TTL)."""
|
|
434
|
-
updatecmd = subparsers.add_parser(
|
|
435
|
-
"update",
|
|
436
|
-
description=update_description,
|
|
437
|
-
epilog=update_epilog,
|
|
438
|
-
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
439
|
-
)
|
|
440
|
-
updatecmd.add_argument(
|
|
441
|
-
"-a",
|
|
442
|
-
"--argument",
|
|
443
|
-
action="append",
|
|
444
|
-
metavar="KEY=VALUE",
|
|
445
|
-
help="Specify key - value pairs to be updated: name=dns1.example.com."
|
|
446
|
-
+ " or data=127.0.0.1 for example. --argument can be repeated",
|
|
447
|
-
required=True,
|
|
448
|
-
)
|
|
449
|
-
updatecmd.add_argument("-d", "--data", required=True)
|
|
450
|
-
updatecmd.add_argument("-n", "--name", required=True)
|
|
451
|
-
updatecmd.add_argument("-r", "--rtype", required=True)
|
|
452
|
-
updatecmd.add_argument("-t", "--ttl")
|
|
453
|
-
updatecmd.add_argument("-z", "--zone", required=True)
|
|
454
|
-
|
|
455
|
-
zone_description = "View zones."
|
|
456
|
-
subparsers.add_parser("zone", description=zone_description)
|
|
457
|
-
|
|
458
|
-
argcomplete.autocomplete(parser)
|
|
459
|
-
|
|
460
|
-
return parser
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
def get_token(config) -> str:
|
|
464
|
-
# Authenticate
|
|
465
|
-
baseurl = config["baseurl"]
|
|
466
|
-
username = config["username"]
|
|
467
|
-
password = config["password"]
|
|
468
|
-
basic = HTTPBasicAuth(username, password)
|
|
469
|
-
response = requests.get(baseurl + "/user/login", auth=basic)
|
|
470
|
-
token = ""
|
|
471
|
-
try:
|
|
472
|
-
token = response.json()["token"]
|
|
473
|
-
except KeyError:
|
|
474
|
-
output(response.json())
|
|
475
|
-
except requests.exceptions.JSONDecodeError:
|
|
476
|
-
output(
|
|
477
|
-
error("Could not decode api response as JSON", "Could not decode"))
|
|
478
|
-
return token
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
def run(url, args, headers, baseurl, parser, username):
|
|
482
|
-
try:
|
|
483
|
-
if args.command == "add":
|
|
484
|
-
run_add(url, args.json, headers)
|
|
485
|
-
elif args.command == "delete":
|
|
486
|
-
run_delete(url, args.json, headers)
|
|
487
|
-
elif args.command == "list":
|
|
488
|
-
run_list(url, args.json, headers)
|
|
489
|
-
elif args.command == "update":
|
|
490
|
-
run_update(url, args.json, headers)
|
|
491
|
-
elif args.command == "user":
|
|
492
|
-
url = baseurl + f"/user/info/{username}"
|
|
493
|
-
run_list(url, args.json, headers)
|
|
494
|
-
elif args.command == "auditlog":
|
|
495
|
-
url = baseurl + "/user/auditlog"
|
|
496
|
-
run_log(url, args.json, headers)
|
|
497
|
-
elif args.command == "changelog":
|
|
498
|
-
url = baseurl + f"/zones/changelog/{args.zone.rstrip('.')}"
|
|
499
|
-
run_log(url, args.json, headers)
|
|
500
|
-
elif args.command == "zone":
|
|
501
|
-
url = baseurl + "/zones"
|
|
502
|
-
run_zone(url, args.json, headers)
|
|
503
|
-
else:
|
|
504
|
-
parser.print_help(sys.stderr)
|
|
505
|
-
return 2
|
|
506
|
-
except requests.exceptions.RequestException as e:
|
|
507
|
-
output(error(e, "Could not connect to server"))
|
|
508
|
-
except (RequestsJSONDecodeError, SimplejsonJSONDecodeError):
|
|
509
|
-
output(
|
|
510
|
-
error("Could not decode api response as JSON", "Could not decode"))
|
|
511
|
-
return 0
|
|
128
|
+
conf.set_config(config)
|
|
512
129
|
|
|
513
130
|
|
|
514
131
|
# Entry point to program
|
|
@@ -519,38 +136,35 @@ def main() -> int:
|
|
|
519
136
|
run_complete(args.shell)
|
|
520
137
|
return 0
|
|
521
138
|
|
|
522
|
-
|
|
523
|
-
config_basepath = join(environ["HOME"], ".knot")
|
|
524
|
-
config_filename = join(config_basepath, "config")
|
|
525
|
-
|
|
526
|
-
if not isdir(config_basepath):
|
|
527
|
-
mkdir(config_basepath)
|
|
139
|
+
knotctl = Knotctl()
|
|
528
140
|
|
|
529
141
|
if args.command == "config":
|
|
530
142
|
run_config(
|
|
531
|
-
config_filename,
|
|
532
143
|
args.context,
|
|
533
144
|
args.baseurl,
|
|
145
|
+
args.list_config,
|
|
534
146
|
args.username,
|
|
535
147
|
args.password,
|
|
536
148
|
args.current,
|
|
537
149
|
)
|
|
538
150
|
return 0
|
|
539
151
|
|
|
540
|
-
config = get_config(
|
|
152
|
+
config = knotctl.get_config()
|
|
541
153
|
baseurl = config["baseurl"]
|
|
542
|
-
token = get_token(
|
|
154
|
+
token = knotctl.conf.get_token()
|
|
543
155
|
if token == "":
|
|
544
156
|
print("Could not get token, exiting")
|
|
545
157
|
return 1
|
|
546
|
-
headers = {"Authorization": "Bearer {}".format(token)}
|
|
547
158
|
|
|
548
159
|
# Route based on command
|
|
549
160
|
url = ""
|
|
550
161
|
ttl = None
|
|
162
|
+
quotedata = None
|
|
551
163
|
user = config["username"]
|
|
552
164
|
if "ttl" in args:
|
|
553
165
|
ttl = args.ttl
|
|
166
|
+
if "data" in args:
|
|
167
|
+
quotedata = quote(args.data, safe="")
|
|
554
168
|
if args.command != "update":
|
|
555
169
|
args.argument = None
|
|
556
170
|
if args.command == "add" and not ttl:
|
|
@@ -559,19 +173,21 @@ def main() -> int:
|
|
|
559
173
|
else:
|
|
560
174
|
zname = args.zone + "."
|
|
561
175
|
soa_url = setup_url(baseurl, None, None, zname, "SOA", None, args.zone)
|
|
562
|
-
soa_json =
|
|
176
|
+
soa_json = knotctl.runner.lister(soa_url, True, ret=True)
|
|
563
177
|
ttl = soa_json[0]["ttl"]
|
|
564
178
|
if args.command == "user":
|
|
565
179
|
if args.username:
|
|
566
180
|
user = args.username
|
|
567
|
-
if args.command in [
|
|
181
|
+
if args.command in [
|
|
182
|
+
"auditlog", "changelog", "openstack-sync", "user", "zone"
|
|
183
|
+
]:
|
|
568
184
|
pass
|
|
569
185
|
else:
|
|
570
186
|
try:
|
|
571
187
|
url = setup_url(
|
|
572
188
|
baseurl,
|
|
573
189
|
args.argument,
|
|
574
|
-
|
|
190
|
+
quotedata,
|
|
575
191
|
args.name,
|
|
576
192
|
args.rtype,
|
|
577
193
|
ttl,
|
|
@@ -581,7 +197,7 @@ def main() -> int:
|
|
|
581
197
|
parser.print_help(sys.stderr)
|
|
582
198
|
return 1
|
|
583
199
|
|
|
584
|
-
return run(url, args,
|
|
200
|
+
return knotctl.run(url, args, baseurl, parser, user)
|
|
585
201
|
|
|
586
202
|
|
|
587
203
|
if __name__ == "__main__":
|
knotctl/__main__.py
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
import os
|
|
3
|
+
from os import mkdir
|
|
4
|
+
from os.path import isdir, isfile, join
|
|
5
|
+
from typing import Union
|
|
6
|
+
|
|
7
|
+
import requests
|
|
8
|
+
import yaml
|
|
9
|
+
from requests.models import HTTPBasicAuth
|
|
10
|
+
|
|
11
|
+
from ..utils import error, output
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class Config:
|
|
15
|
+
|
|
16
|
+
def __init__(self):
|
|
17
|
+
# Make sure we have config
|
|
18
|
+
self.config_basepath = join(os.environ["HOME"], ".knot")
|
|
19
|
+
self.config_filename = join(self.config_basepath, "config")
|
|
20
|
+
if not isdir(self.config_basepath):
|
|
21
|
+
mkdir(self.config_basepath)
|
|
22
|
+
|
|
23
|
+
def get_config(self) -> Union[None, dict]:
|
|
24
|
+
if not isfile(self.config_filename):
|
|
25
|
+
return None
|
|
26
|
+
with open(self.config_filename, "r") as fh:
|
|
27
|
+
return yaml.safe_load(fh.read())
|
|
28
|
+
|
|
29
|
+
def get_config_data(self) -> dict:
|
|
30
|
+
config_data = self.get_config()
|
|
31
|
+
config_data.pop("password", None)
|
|
32
|
+
return config_data
|
|
33
|
+
|
|
34
|
+
def get_current(self) -> str:
|
|
35
|
+
if os.path.islink(self.config_filename):
|
|
36
|
+
actual_path = os.readlink(self.config_filename)
|
|
37
|
+
return actual_path.split("-")[-1]
|
|
38
|
+
else:
|
|
39
|
+
return "none"
|
|
40
|
+
|
|
41
|
+
def get_token(self) -> str:
|
|
42
|
+
# Authenticate
|
|
43
|
+
config = self.get_config()
|
|
44
|
+
baseurl = config["baseurl"]
|
|
45
|
+
username = config["username"]
|
|
46
|
+
password = config["password"]
|
|
47
|
+
basic = HTTPBasicAuth(username, password)
|
|
48
|
+
response = requests.get(baseurl + "/user/login", auth=basic)
|
|
49
|
+
token = ""
|
|
50
|
+
try:
|
|
51
|
+
token = response.json()["token"]
|
|
52
|
+
except KeyError:
|
|
53
|
+
output(response.json())
|
|
54
|
+
except requests.exceptions.JSONDecodeError:
|
|
55
|
+
output(
|
|
56
|
+
error("Could not decode api response as JSON",
|
|
57
|
+
"Could not decode"))
|
|
58
|
+
return token
|
|
59
|
+
|
|
60
|
+
def set_context(self, context) -> bool:
|
|
61
|
+
symlink = f"{self.config_filename}-{context}"
|
|
62
|
+
found = os.path.isfile(symlink)
|
|
63
|
+
if os.path.islink(self.config_filename):
|
|
64
|
+
os.remove(self.config_filename)
|
|
65
|
+
elif os.path.isfile(self.config_filename):
|
|
66
|
+
os.rename(self.config_filename, symlink)
|
|
67
|
+
os.symlink(symlink, self.config_filename)
|
|
68
|
+
self.config_filename = symlink
|
|
69
|
+
return found
|
|
70
|
+
|
|
71
|
+
def set_config(
|
|
72
|
+
self,
|
|
73
|
+
baseurl: str,
|
|
74
|
+
username: str,
|
|
75
|
+
password: str,
|
|
76
|
+
):
|
|
77
|
+
config = {
|
|
78
|
+
"baseurl": baseurl,
|
|
79
|
+
"username": username,
|
|
80
|
+
"password": password
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
with open(self.config_filename, "w") as fh:
|
|
84
|
+
fh.write(yaml.dump(config))
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import openstack
|
|
2
|
+
import openstack.config.loader
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
def get_openstack_addresses(cloud: str, name: str):
|
|
6
|
+
conn = openstack.connect(cloud=cloud)
|
|
7
|
+
|
|
8
|
+
# List the servers
|
|
9
|
+
server = conn.compute.find_server(name)
|
|
10
|
+
if server is None:
|
|
11
|
+
print("Server not found")
|
|
12
|
+
exit(1)
|
|
13
|
+
openstack_addresses = []
|
|
14
|
+
for network in server.addresses:
|
|
15
|
+
for address in server.addresses[network]:
|
|
16
|
+
openstack_addresses.append(address)
|
|
17
|
+
return openstack_addresses
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
from typing import Union
|
|
2
|
+
|
|
3
|
+
import requests
|
|
4
|
+
|
|
5
|
+
from ..config import Config
|
|
6
|
+
from ..openstack import get_openstack_addresses
|
|
7
|
+
from ..utils import output, setup_url, split_url
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class Run():
|
|
11
|
+
|
|
12
|
+
def __init__(self):
|
|
13
|
+
conf = Config()
|
|
14
|
+
self.headers = {"Authorization": f"Bearer {conf.get_token()}"}
|
|
15
|
+
|
|
16
|
+
def add(self, url: str, jsonout: bool):
|
|
17
|
+
parsed = split_url(url)
|
|
18
|
+
response = requests.put(url, headers=self.headers)
|
|
19
|
+
out = response.json()
|
|
20
|
+
if isinstance(out, list):
|
|
21
|
+
for record in out:
|
|
22
|
+
if (record["data"] == parsed["data"]
|
|
23
|
+
and record["name"] == parsed["name"]
|
|
24
|
+
and record["rtype"] == parsed["rtype"]):
|
|
25
|
+
output(record, jsonout)
|
|
26
|
+
break
|
|
27
|
+
else:
|
|
28
|
+
output(out, jsonout)
|
|
29
|
+
|
|
30
|
+
def delete(self, url: str, jsonout: bool):
|
|
31
|
+
response = requests.delete(url, headers=self.headers)
|
|
32
|
+
reply = response.json()
|
|
33
|
+
if not reply and response.status_code == requests.codes.ok:
|
|
34
|
+
reply = [{"Code": 200, "Description": "{} deleted".format(url)}]
|
|
35
|
+
|
|
36
|
+
output(reply, jsonout)
|
|
37
|
+
|
|
38
|
+
def log(self, url: str, jsonout: bool):
|
|
39
|
+
response = requests.get(url, headers=self.headers)
|
|
40
|
+
string = response.content.decode("utf-8")
|
|
41
|
+
if jsonout:
|
|
42
|
+
out = []
|
|
43
|
+
lines = string.splitlines()
|
|
44
|
+
index = 0
|
|
45
|
+
text = ""
|
|
46
|
+
timestamp = ""
|
|
47
|
+
while index < len(lines):
|
|
48
|
+
line = lines[index]
|
|
49
|
+
index += 1
|
|
50
|
+
cur_has_timestamp = line.startswith("[")
|
|
51
|
+
next_has_timestamp = index < len(
|
|
52
|
+
lines) and lines[index].startswith("[")
|
|
53
|
+
# Simple case, just one line with timestamp
|
|
54
|
+
if cur_has_timestamp and next_has_timestamp:
|
|
55
|
+
timestamp = line.split("]")[0].split("[")[1]
|
|
56
|
+
text = line.split("]")[1].lstrip(":").strip()
|
|
57
|
+
out.append({"timestamp": timestamp, "text": text})
|
|
58
|
+
text = ""
|
|
59
|
+
timestamp = ""
|
|
60
|
+
# Start of multiline
|
|
61
|
+
elif cur_has_timestamp:
|
|
62
|
+
timestamp = line.split("]")[0].split("[")[1]
|
|
63
|
+
text = line.split("]")[1].lstrip(":").strip()
|
|
64
|
+
# End of multiline
|
|
65
|
+
elif next_has_timestamp:
|
|
66
|
+
text += f"\n{line.strip()}"
|
|
67
|
+
out.append({"timestamp": timestamp, "text": text})
|
|
68
|
+
text = ""
|
|
69
|
+
timestamp = ""
|
|
70
|
+
# Middle of multiline
|
|
71
|
+
else:
|
|
72
|
+
text += f"\n{line.strip()}"
|
|
73
|
+
|
|
74
|
+
else:
|
|
75
|
+
out = string
|
|
76
|
+
|
|
77
|
+
output(out, jsonout)
|
|
78
|
+
|
|
79
|
+
def update(self, url: str, jsonout: bool):
|
|
80
|
+
response = requests.patch(url, headers=self.headers)
|
|
81
|
+
output(response.json(), jsonout)
|
|
82
|
+
|
|
83
|
+
def zone(self, url: str, jsonout: bool, ret=False) -> Union[None, str]:
|
|
84
|
+
response = requests.get(url, headers=self.headers)
|
|
85
|
+
zones = response.json()
|
|
86
|
+
for zone in zones:
|
|
87
|
+
del zone["records"]
|
|
88
|
+
string = zones
|
|
89
|
+
|
|
90
|
+
if ret:
|
|
91
|
+
return string
|
|
92
|
+
else:
|
|
93
|
+
output(string, jsonout)
|
|
94
|
+
|
|
95
|
+
def lister(self, url: str, jsonout: bool, ret=False) -> Union[None, str]:
|
|
96
|
+
response = requests.get(url, headers=self.headers)
|
|
97
|
+
string = response.json()
|
|
98
|
+
if ret:
|
|
99
|
+
return string
|
|
100
|
+
else:
|
|
101
|
+
output(string, jsonout)
|
|
102
|
+
|
|
103
|
+
def add_records(self, openstack_addresses, baseurl, name, zone, url,
|
|
104
|
+
jsonout):
|
|
105
|
+
for address in openstack_addresses:
|
|
106
|
+
rtype = None
|
|
107
|
+
if address["version"] == 4:
|
|
108
|
+
rtype = "A"
|
|
109
|
+
elif address["version"] == 6:
|
|
110
|
+
rtype = "AAAA"
|
|
111
|
+
if rtype:
|
|
112
|
+
url = setup_url(
|
|
113
|
+
baseurl,
|
|
114
|
+
None, # arguments,
|
|
115
|
+
address["addr"], # data,
|
|
116
|
+
name,
|
|
117
|
+
rtype,
|
|
118
|
+
None, # ttl,
|
|
119
|
+
zone,
|
|
120
|
+
)
|
|
121
|
+
self.add(url, jsonout)
|
|
122
|
+
|
|
123
|
+
def update_records(self, openstack_addresses, current_records, baseurl,
|
|
124
|
+
name, zone, url, jsonout):
|
|
125
|
+
previpv4 = False
|
|
126
|
+
previpv6 = False
|
|
127
|
+
curripv4 = False
|
|
128
|
+
curripv6 = False
|
|
129
|
+
for record in current_records:
|
|
130
|
+
if record.type == "A":
|
|
131
|
+
previpv4 = record.data
|
|
132
|
+
elif record.type == "AAAA":
|
|
133
|
+
previpv6 = record.data
|
|
134
|
+
for address in openstack_addresses:
|
|
135
|
+
rtype = None
|
|
136
|
+
if address.version == 4:
|
|
137
|
+
rtype = "A"
|
|
138
|
+
curripv4 = True
|
|
139
|
+
elif address.version == 6:
|
|
140
|
+
rtype = "AAAA"
|
|
141
|
+
curripv6 = True
|
|
142
|
+
if rtype and record.type == rtype:
|
|
143
|
+
if record.data == address.addr:
|
|
144
|
+
continue
|
|
145
|
+
else:
|
|
146
|
+
url = setup_url(
|
|
147
|
+
baseurl,
|
|
148
|
+
None, # arguments,
|
|
149
|
+
address.addr, # data,
|
|
150
|
+
name,
|
|
151
|
+
record.type,
|
|
152
|
+
None, # ttl,
|
|
153
|
+
zone,
|
|
154
|
+
)
|
|
155
|
+
self.update(url, jsonout)
|
|
156
|
+
if previpv4 and not curripv4:
|
|
157
|
+
url = setup_url(
|
|
158
|
+
baseurl,
|
|
159
|
+
None, # arguments,
|
|
160
|
+
previpv4, # data,
|
|
161
|
+
name,
|
|
162
|
+
"A",
|
|
163
|
+
None, # ttl,
|
|
164
|
+
zone,
|
|
165
|
+
)
|
|
166
|
+
self.delete(url, jsonout)
|
|
167
|
+
if previpv6 and not curripv6:
|
|
168
|
+
url = setup_url(
|
|
169
|
+
baseurl,
|
|
170
|
+
None, # arguments,
|
|
171
|
+
previpv6, # data,
|
|
172
|
+
name,
|
|
173
|
+
"AAAA",
|
|
174
|
+
None, # ttl,
|
|
175
|
+
zone,
|
|
176
|
+
)
|
|
177
|
+
self.delete(url, jsonout)
|
|
178
|
+
if curripv4 and not previpv4:
|
|
179
|
+
url = setup_url(
|
|
180
|
+
baseurl,
|
|
181
|
+
None, # arguments,
|
|
182
|
+
curripv4, # data,
|
|
183
|
+
name,
|
|
184
|
+
"A",
|
|
185
|
+
None, # ttl,
|
|
186
|
+
zone,
|
|
187
|
+
)
|
|
188
|
+
self.add(url, jsonout)
|
|
189
|
+
if curripv6 and not previpv6:
|
|
190
|
+
url = setup_url(
|
|
191
|
+
baseurl,
|
|
192
|
+
None, # arguments,
|
|
193
|
+
curripv6, # data,
|
|
194
|
+
name,
|
|
195
|
+
"AAAA",
|
|
196
|
+
None, # ttl,
|
|
197
|
+
zone,
|
|
198
|
+
)
|
|
199
|
+
self.add(url, jsonout)
|
|
200
|
+
|
|
201
|
+
def openstack_sync(self, cloud: str, name: str, zone: str, baseurl: str,
|
|
202
|
+
jsonout: bool):
|
|
203
|
+
url = setup_url(
|
|
204
|
+
baseurl,
|
|
205
|
+
None, # arguments,
|
|
206
|
+
None, # data,
|
|
207
|
+
name,
|
|
208
|
+
None, # rtype,
|
|
209
|
+
None, # ttl,
|
|
210
|
+
zone,
|
|
211
|
+
)
|
|
212
|
+
current_records = self.lister(url, jsonout=True, ret=True)
|
|
213
|
+
openstack_addresses = get_openstack_addresses(cloud, name)
|
|
214
|
+
if current_records["Code"] == 404:
|
|
215
|
+
self.add_records(openstack_addresses, baseurl, name, zone, url,
|
|
216
|
+
jsonout)
|
|
217
|
+
else:
|
|
218
|
+
self.update_records(openstack_addresses, current_records, baseurl,
|
|
219
|
+
name, zone, url, jsonout)
|
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
import argparse
|
|
2
|
+
import argcomplete
|
|
3
|
+
import json
|
|
4
|
+
import sys
|
|
5
|
+
import urllib.parse
|
|
6
|
+
from typing import Union
|
|
7
|
+
from urllib.parse import urlparse
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def get_parser() -> dict:
|
|
11
|
+
description = """Manage DNS records with knot dns rest api:
|
|
12
|
+
* https://gitlab.nic.cz/knot/knot-dns-rest"""
|
|
13
|
+
|
|
14
|
+
epilog = """
|
|
15
|
+
The Domain Name System specifies a database of information
|
|
16
|
+
elements for network resources. The types of information
|
|
17
|
+
elements are categorized and organized with a list of DNS
|
|
18
|
+
record types, the resource records (RRs). Each record has a
|
|
19
|
+
name, a type, an expiration time (time to live), and
|
|
20
|
+
type-specific data.
|
|
21
|
+
|
|
22
|
+
The following is a list of terms used in this program:
|
|
23
|
+
----------------------------------------------------------------
|
|
24
|
+
| Vocabulary | Description |
|
|
25
|
+
----------------------------------------------------------------
|
|
26
|
+
| zone | A DNS zone is a specific portion of the DNS |
|
|
27
|
+
| | namespace in the Domain Name System (DNS), |
|
|
28
|
+
| | which a specific organization or administrator |
|
|
29
|
+
| | manages. |
|
|
30
|
+
----------------------------------------------------------------
|
|
31
|
+
| name | In the Internet, a domain name is a string that |
|
|
32
|
+
| | identifies a realm of administrative autonomy, |
|
|
33
|
+
| | authority or control. Domain names are often |
|
|
34
|
+
| | used to identify services provided through the |
|
|
35
|
+
| | Internet, such as websites, email services and |
|
|
36
|
+
| | more. |
|
|
37
|
+
----------------------------------------------------------------
|
|
38
|
+
| rtype | A record type indicates the format of the data |
|
|
39
|
+
| | and it gives a hint of its intended use. For |
|
|
40
|
+
| | example, the A record is used to translate from |
|
|
41
|
+
| | a domain name to an IPv4 address, the NS record |
|
|
42
|
+
| | lists which name servers can answer lookups on |
|
|
43
|
+
| | a DNS zone, and the MX record specifies the |
|
|
44
|
+
| | mail server used to handle mail for a domain |
|
|
45
|
+
| | specified in an e-mail address. |
|
|
46
|
+
----------------------------------------------------------------
|
|
47
|
+
| data | A records data is of type-specific relevance, |
|
|
48
|
+
| | such as the IP address for address records, or |
|
|
49
|
+
| | the priority and hostname for MX records. |
|
|
50
|
+
----------------------------------------------------------------
|
|
51
|
+
|
|
52
|
+
This information was compiled from Wikipedia:
|
|
53
|
+
* https://en.wikipedia.org/wiki/DNS_zone
|
|
54
|
+
* https://en.wikipedia.org/wiki/Domain_Name_System
|
|
55
|
+
* https://en.wikipedia.org/wiki/Zone_file
|
|
56
|
+
"""
|
|
57
|
+
# Grab user input
|
|
58
|
+
parser = argparse.ArgumentParser(
|
|
59
|
+
description=description,
|
|
60
|
+
epilog=epilog,
|
|
61
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
62
|
+
)
|
|
63
|
+
parser.add_argument("--json", action=argparse.BooleanOptionalAction)
|
|
64
|
+
subparsers = parser.add_subparsers(dest="command")
|
|
65
|
+
|
|
66
|
+
add_description = "Add a new record to the zone."
|
|
67
|
+
addcmd = subparsers.add_parser("add", description=add_description)
|
|
68
|
+
addcmd.add_argument("-d", "--data", required=True)
|
|
69
|
+
addcmd.add_argument("-n", "--name", required=True)
|
|
70
|
+
addcmd.add_argument("-r", "--rtype", required=True)
|
|
71
|
+
addcmd.add_argument("-t", "--ttl")
|
|
72
|
+
addcmd.add_argument("-z", "--zone", required=True)
|
|
73
|
+
|
|
74
|
+
auditlog_description = "Audit the log file for errors."
|
|
75
|
+
subparsers.add_parser("auditlog", description=auditlog_description)
|
|
76
|
+
|
|
77
|
+
changelog_description = "View the changelog of a zone."
|
|
78
|
+
changelogcmd = subparsers.add_parser("changelog",
|
|
79
|
+
description=changelog_description)
|
|
80
|
+
changelogcmd.add_argument("-z", "--zone", required=True)
|
|
81
|
+
|
|
82
|
+
complete_description = "Generate shell completion script."
|
|
83
|
+
completecmd = subparsers.add_parser("completion",
|
|
84
|
+
description=complete_description)
|
|
85
|
+
completecmd.add_argument("-s", "--shell")
|
|
86
|
+
|
|
87
|
+
config_description = "Configure access to knot-dns-rest-api."
|
|
88
|
+
configcmd = subparsers.add_parser("config", description=config_description)
|
|
89
|
+
configcmd.add_argument("-b", "--baseurl")
|
|
90
|
+
configcmd.add_argument("-c", "--context")
|
|
91
|
+
configcmd.add_argument("-C",
|
|
92
|
+
"--current",
|
|
93
|
+
action=argparse.BooleanOptionalAction)
|
|
94
|
+
configcmd.add_argument("-l",
|
|
95
|
+
"--list",
|
|
96
|
+
action=argparse.BooleanOptionalAction,
|
|
97
|
+
dest="list_config")
|
|
98
|
+
configcmd.add_argument("-p", "--password")
|
|
99
|
+
configcmd.add_argument("-u", "--username")
|
|
100
|
+
|
|
101
|
+
delete_description = "Delete a record from the zone."
|
|
102
|
+
deletecmd = subparsers.add_parser("delete", description=delete_description)
|
|
103
|
+
deletecmd.add_argument("-d", "--data")
|
|
104
|
+
deletecmd.add_argument("-n", "--name")
|
|
105
|
+
deletecmd.add_argument("-r", "--rtype")
|
|
106
|
+
deletecmd.add_argument("-z", "--zone", required=True)
|
|
107
|
+
|
|
108
|
+
list_description = "List records."
|
|
109
|
+
listcmd = subparsers.add_parser("list", description=list_description)
|
|
110
|
+
listcmd.add_argument("-d", "--data")
|
|
111
|
+
listcmd.add_argument("-n", "--name")
|
|
112
|
+
listcmd.add_argument("-r", "--rtype")
|
|
113
|
+
listcmd.add_argument("-z", "--zone", required=False)
|
|
114
|
+
|
|
115
|
+
openstack_description = "Sync records with openstack."
|
|
116
|
+
openstackcmd = subparsers.add_parser("openstack-sync",
|
|
117
|
+
description=openstack_description)
|
|
118
|
+
openstackcmd.add_argument("-n", "--name", required=True)
|
|
119
|
+
openstackcmd.add_argument("-c", "--cloud", required=True)
|
|
120
|
+
openstackcmd.add_argument("-z", "--zone", required=True)
|
|
121
|
+
|
|
122
|
+
user_description = "View user information."
|
|
123
|
+
usercmd = subparsers.add_parser("user", description=user_description)
|
|
124
|
+
usercmd.add_argument("-u", "--username", default=None)
|
|
125
|
+
|
|
126
|
+
update_description = (
|
|
127
|
+
"Update a record in the zone. The record must exist in the zone.\n")
|
|
128
|
+
update_description += (
|
|
129
|
+
"In this case --data, --name, --rtype and --ttl switches are used\n")
|
|
130
|
+
update_description += (
|
|
131
|
+
"for searching for the appropriate record, while the --argument\n")
|
|
132
|
+
update_description += "switches are used for updating the record."
|
|
133
|
+
update_epilog = """Available arguments are:
|
|
134
|
+
data: New record data.
|
|
135
|
+
name: New record domain name.
|
|
136
|
+
rtype: New record type.
|
|
137
|
+
ttl: New record time to live (TTL)."""
|
|
138
|
+
updatecmd = subparsers.add_parser(
|
|
139
|
+
"update",
|
|
140
|
+
description=update_description,
|
|
141
|
+
epilog=update_epilog,
|
|
142
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
143
|
+
)
|
|
144
|
+
updatecmd.add_argument(
|
|
145
|
+
"-a",
|
|
146
|
+
"--argument",
|
|
147
|
+
action="append",
|
|
148
|
+
metavar="KEY=VALUE",
|
|
149
|
+
help="Specify key - value pairs to be updated: name=dns1.example.com."
|
|
150
|
+
+ " or data=127.0.0.1 for example. --argument can be repeated",
|
|
151
|
+
required=True,
|
|
152
|
+
)
|
|
153
|
+
updatecmd.add_argument("-d", "--data", required=True)
|
|
154
|
+
updatecmd.add_argument("-n", "--name", required=True)
|
|
155
|
+
updatecmd.add_argument("-r", "--rtype", required=True)
|
|
156
|
+
updatecmd.add_argument("-t", "--ttl")
|
|
157
|
+
updatecmd.add_argument("-z", "--zone", required=True)
|
|
158
|
+
|
|
159
|
+
zone_description = "View zones."
|
|
160
|
+
subparsers.add_parser("zone", description=zone_description)
|
|
161
|
+
|
|
162
|
+
argcomplete.autocomplete(parser)
|
|
163
|
+
|
|
164
|
+
return parser
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def error(description: str, error: str):
|
|
168
|
+
response = []
|
|
169
|
+
reply = {}
|
|
170
|
+
# https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/406
|
|
171
|
+
reply["Code"] = 406
|
|
172
|
+
reply["Description"] = description
|
|
173
|
+
reply["Error"] = error
|
|
174
|
+
response.append(reply)
|
|
175
|
+
return response
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def nested_out(input, tabs="") -> str:
|
|
179
|
+
string = ""
|
|
180
|
+
if isinstance(input, str) or isinstance(input, int):
|
|
181
|
+
string += f"{input}\n"
|
|
182
|
+
elif isinstance(input, dict):
|
|
183
|
+
for key, value in input.items():
|
|
184
|
+
string += f"{tabs}{key}: {nested_out(value, tabs + ' ')}"
|
|
185
|
+
elif isinstance(input, list):
|
|
186
|
+
for entry in input:
|
|
187
|
+
string += f"{tabs}\n{nested_out(entry, tabs + ' ')}"
|
|
188
|
+
return string
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def output(response: list[dict], jsonout: bool = False):
|
|
192
|
+
try:
|
|
193
|
+
if jsonout:
|
|
194
|
+
print(json.dumps(response))
|
|
195
|
+
else:
|
|
196
|
+
print(nested_out(response))
|
|
197
|
+
except BrokenPipeError:
|
|
198
|
+
pass
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def setup_url(
|
|
202
|
+
baseurl: str,
|
|
203
|
+
arguments: Union[None, list[str]],
|
|
204
|
+
data: Union[None, str],
|
|
205
|
+
name: Union[None, str],
|
|
206
|
+
rtype: Union[None, str],
|
|
207
|
+
ttl: Union[None, str],
|
|
208
|
+
zone: Union[None, str],
|
|
209
|
+
) -> str:
|
|
210
|
+
url = baseurl + "/zones"
|
|
211
|
+
if zone:
|
|
212
|
+
if not zone.endswith("."):
|
|
213
|
+
zone += "."
|
|
214
|
+
url += "/{}".format(zone)
|
|
215
|
+
if name and zone:
|
|
216
|
+
if name.endswith(zone.rstrip(".")):
|
|
217
|
+
name += "."
|
|
218
|
+
url += "/records/{}".format(name)
|
|
219
|
+
if zone and name and rtype:
|
|
220
|
+
url += "/{}".format(rtype)
|
|
221
|
+
if data and zone and name and rtype:
|
|
222
|
+
url += "/{}".format(data)
|
|
223
|
+
if ttl and data and zone and name and rtype:
|
|
224
|
+
url += "/{}".format(ttl)
|
|
225
|
+
if data and zone and name and rtype and arguments:
|
|
226
|
+
url += "?"
|
|
227
|
+
for arg in arguments:
|
|
228
|
+
if not url.endswith("?"):
|
|
229
|
+
url += "&"
|
|
230
|
+
key, value = arg.split("=")
|
|
231
|
+
url += key + "=" + urllib.parse.quote_plus(value)
|
|
232
|
+
|
|
233
|
+
if ttl and (not rtype or not name or not zone):
|
|
234
|
+
output(
|
|
235
|
+
error(
|
|
236
|
+
"ttl only makes sense with rtype, name and zone",
|
|
237
|
+
"Missing parameter",
|
|
238
|
+
))
|
|
239
|
+
sys.exit(1)
|
|
240
|
+
if rtype and (not name or not zone):
|
|
241
|
+
output(
|
|
242
|
+
error(
|
|
243
|
+
"rtype only makes sense with name and zone",
|
|
244
|
+
"Missing parameter",
|
|
245
|
+
))
|
|
246
|
+
sys.exit(1)
|
|
247
|
+
if name and not zone:
|
|
248
|
+
output(error("name only makes sense with a zone", "Missing parameter"))
|
|
249
|
+
sys.exit(1)
|
|
250
|
+
return url
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def split_url(url: str) -> dict:
|
|
254
|
+
parsed = urlparse(url, allow_fragments=False)
|
|
255
|
+
path = parsed.path
|
|
256
|
+
query = parsed.query
|
|
257
|
+
arguments: Union[None, list[str]] = query.split("&")
|
|
258
|
+
path_arr = path.split("/")
|
|
259
|
+
data: Union[None, str] = None
|
|
260
|
+
name: Union[None, str] = None
|
|
261
|
+
rtype: Union[None, str] = None
|
|
262
|
+
ttl: Union[None, str] = None
|
|
263
|
+
zone: Union[None, str] = None
|
|
264
|
+
if len(path_arr) > 2:
|
|
265
|
+
zone = path_arr[2]
|
|
266
|
+
if len(path_arr) > 4:
|
|
267
|
+
name = path_arr[4]
|
|
268
|
+
if len(path_arr) > 5:
|
|
269
|
+
rtype = path_arr[5]
|
|
270
|
+
if len(path_arr) > 6:
|
|
271
|
+
data = path_arr[6]
|
|
272
|
+
if len(path_arr) > 7:
|
|
273
|
+
ttl = path_arr[7]
|
|
274
|
+
|
|
275
|
+
return {
|
|
276
|
+
"arguments": arguments,
|
|
277
|
+
"data": data,
|
|
278
|
+
"name": name,
|
|
279
|
+
"rtype": rtype,
|
|
280
|
+
"ttl": ttl,
|
|
281
|
+
"zone": zone,
|
|
282
|
+
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
Metadata-Version: 2.
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
2
|
Name: knotctl
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.2
|
|
4
4
|
Summary: A CLI for knotapi.
|
|
5
5
|
Author-email: Micke Nordin <hej@mic.ke>
|
|
6
6
|
Requires-Python: >=3.9
|
|
@@ -8,10 +8,12 @@ Description-Content-Type: text/markdown
|
|
|
8
8
|
Classifier: Programming Language :: Python :: 3
|
|
9
9
|
Classifier: License :: OSI Approved :: GNU General Public License v3 (GPLv3)
|
|
10
10
|
Classifier: Operating System :: OS Independent
|
|
11
|
+
License-File: LICENSE
|
|
11
12
|
Requires-Dist: argcomplete==2.0.0
|
|
12
13
|
Requires-Dist: pyyaml==6.0.1
|
|
13
14
|
Requires-Dist: requests==2.27.1
|
|
14
15
|
Requires-Dist: simplejson==3.17.6
|
|
16
|
+
Requires-Dist: openstacksdk==4.2.0
|
|
15
17
|
Project-URL: Documentation, https://code.smolnet.org/micke/knotctl
|
|
16
18
|
Project-URL: Source, https://code.smolnet.org/micke/knotctl
|
|
17
19
|
|
|
@@ -20,11 +22,15 @@ Project-URL: Source, https://code.smolnet.org/micke/knotctl
|
|
|
20
22
|
This is a commandline tool for knotapi: https://gitlab.nic.cz/knot/knot-dns-rest
|
|
21
23
|
|
|
22
24
|
## Build and install
|
|
25
|
+
The preffered method of installation is via pipx:
|
|
26
|
+
```
|
|
27
|
+
pipx install knotctl
|
|
28
|
+
```
|
|
23
29
|
|
|
24
30
|
To install using pip, run the following command in a virtual envrionment.
|
|
25
31
|
|
|
26
32
|
```
|
|
27
|
-
python -m pip install
|
|
33
|
+
python -m pip install knotctl
|
|
28
34
|
```
|
|
29
35
|
|
|
30
36
|
To build and install as a deb-package
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
knotctl/__init__.py,sha256=XBYNdvml8P_GiElo6xMi9Lpw1foCiNwzDVQHWgqFpuY,6122
|
|
2
|
+
knotctl/__main__.py,sha256=-Y8KgiSSg0QF7YZRuMCR-vyMxQ-PizDSZ2bqUbQSFSk,64
|
|
3
|
+
knotctl/config/__init__.py,sha256=2CKbH_ZseBDbbzYL_N96o62BNRNvbHWJ0DRK0pvCqpI,2547
|
|
4
|
+
knotctl/openstack/__init__.py,sha256=ywKUSjiwHtqrVUMg1iJvd5wiW4CmycjlTU_DoqlOOhE,480
|
|
5
|
+
knotctl/runners/__init__.py,sha256=dFYhmnS9RyBoAdgGXph2NzUKRiFPpX7z-U9IEpV6XcU,7456
|
|
6
|
+
knotctl/utils/__init__.py,sha256=y8P74G_Ox1Y74iNDs_udYyJtBc-_WW-mtzj9hsxo6Q0,10808
|
|
7
|
+
knotctl-0.1.2.dist-info/entry_points.txt,sha256=oGFZaAfsfqC-KbiCm04W6DTBFCq2f5F_p3KMEgNoY4s,40
|
|
8
|
+
knotctl-0.1.2.dist-info/licenses/LICENSE,sha256=tqi_Y64slbCqJW7ndGgNe9GPIfRX2nVGb3YQs7FqzE4,34670
|
|
9
|
+
knotctl-0.1.2.dist-info/WHEEL,sha256=G2gURzTEtmeR8nrdXUJfNiB3VYVxigPQ-bEQujpNiNs,82
|
|
10
|
+
knotctl-0.1.2.dist-info/METADATA,sha256=1GEPN-B9n81Fwg6CO-RWKNwmjeeDHkalnyaWOBeRcck,7618
|
|
11
|
+
knotctl-0.1.2.dist-info/RECORD,,
|
knotctl-0.1.1.dist-info/RECORD
DELETED
|
@@ -1,6 +0,0 @@
|
|
|
1
|
-
knotctl/__init__.py,sha256=Z2qrtQAzH-V6uPCCaPGwbV2QXhzCO49T5SHGyoFanVI,20018
|
|
2
|
-
knotctl-0.1.1.dist-info/entry_points.txt,sha256=oGFZaAfsfqC-KbiCm04W6DTBFCq2f5F_p3KMEgNoY4s,40
|
|
3
|
-
knotctl-0.1.1.dist-info/LICENSE,sha256=tqi_Y64slbCqJW7ndGgNe9GPIfRX2nVGb3YQs7FqzE4,34670
|
|
4
|
-
knotctl-0.1.1.dist-info/WHEEL,sha256=EZbGkh7Ie4PoZfRQ8I0ZuP9VklN_TvcZ6DSE5Uar4z4,81
|
|
5
|
-
knotctl-0.1.1.dist-info/METADATA,sha256=S1ap70TUQ3ClrEbyz_gylivlIMq1y4QmpPWWOONeoaw,7528
|
|
6
|
-
knotctl-0.1.1.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|