knotctl 0.1.0__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 +87 -415
- 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.0.dist-info → knotctl-0.1.2.dist-info}/METADATA +34 -5
- knotctl-0.1.2.dist-info/RECORD +11 -0
- {knotctl-0.1.0.dist-info → knotctl-0.1.2.dist-info}/WHEEL +1 -1
- knotctl-0.1.0.dist-info/RECORD +0 -6
- {knotctl-0.1.0.dist-info → knotctl-0.1.2.dist-info}/entry_points.txt +0 -0
- {knotctl-0.1.0.dist-info → knotctl-0.1.2.dist-info/licenses}/LICENSE +0 -0
knotctl/__init__.py
CHANGED
|
@@ -1,127 +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, islink, join, split
|
|
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
|
-
with open(config_filename, "r") as fh:
|
|
40
|
-
return yaml.safe_load(fh.read())
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
def nested_out(input, tabs="") -> str:
|
|
44
|
-
string = ""
|
|
45
|
-
if isinstance(input, str) or isinstance(input, int):
|
|
46
|
-
string += "{}\n".format(input)
|
|
47
|
-
elif isinstance(input, dict):
|
|
48
|
-
for key, value in input.items():
|
|
49
|
-
string += "{}{}: {}".format(tabs, key, nested_out(value, tabs + " "))
|
|
50
|
-
elif isinstance(input, list):
|
|
51
|
-
for entry in input:
|
|
52
|
-
string += "{}\n{}".format(tabs, nested_out(entry, tabs + " "))
|
|
53
|
-
return string
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
def output(response: list[dict], jsonout: bool = False):
|
|
57
|
-
try:
|
|
58
|
-
if jsonout:
|
|
59
|
-
print(json.dumps(response))
|
|
60
|
-
else:
|
|
61
|
-
print(nested_out(response))
|
|
62
|
-
except BrokenPipeError:
|
|
63
|
-
pass
|
|
22
|
+
class Knotctl:
|
|
64
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()
|
|
65
29
|
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
if isinstance(out, list):
|
|
72
|
-
for record in out:
|
|
73
|
-
if (
|
|
74
|
-
record["data"] == parsed["data"]
|
|
75
|
-
and record["name"] == parsed["name"]
|
|
76
|
-
and record["rtype"] == parsed["rtype"]
|
|
77
|
-
):
|
|
78
|
-
output(record, jsonout)
|
|
79
|
-
break
|
|
80
|
-
else:
|
|
81
|
-
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()
|
|
82
35
|
|
|
36
|
+
return config
|
|
83
37
|
|
|
84
|
-
def
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
text = line.split(']')[1].lstrip(':').strip()
|
|
111
|
-
# End of multiline
|
|
112
|
-
elif next_has_timestamp:
|
|
113
|
-
text += f'\n{line.strip()}'
|
|
114
|
-
out.append({'timestamp': timestamp, 'text': text})
|
|
115
|
-
text = ""
|
|
116
|
-
timestamp = ""
|
|
117
|
-
# 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)
|
|
118
64
|
else:
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
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
|
|
125
74
|
|
|
126
75
|
|
|
127
76
|
def run_complete(shell: Union[None, str]):
|
|
@@ -134,33 +83,27 @@ def run_complete(shell: Union[None, str]):
|
|
|
134
83
|
|
|
135
84
|
|
|
136
85
|
def run_config(
|
|
137
|
-
config_filename: str,
|
|
138
86
|
context: Union[None, str] = None,
|
|
139
87
|
baseurl: Union[None, str] = None,
|
|
88
|
+
list_config: bool = False,
|
|
140
89
|
username: Union[None, str] = None,
|
|
141
90
|
password: Union[None, str] = None,
|
|
142
91
|
current: Union[None, str] = None,
|
|
143
92
|
):
|
|
93
|
+
conf = Config()
|
|
144
94
|
if current:
|
|
145
|
-
|
|
146
|
-
actual_path = os.readlink(config_filename)
|
|
147
|
-
print(actual_path.split('-')[-1])
|
|
148
|
-
else:
|
|
149
|
-
print("none")
|
|
95
|
+
print(conf.get_current())
|
|
150
96
|
return
|
|
151
97
|
config = {"baseurl": baseurl, "username": username, "password": password}
|
|
152
98
|
needed = []
|
|
153
99
|
if context:
|
|
154
|
-
|
|
155
|
-
found = os.path.isfile(symlink)
|
|
156
|
-
if os.path.islink(config_filename):
|
|
157
|
-
os.remove(config_filename)
|
|
158
|
-
elif os.path.isfile(config_filename):
|
|
159
|
-
os.rename(config_filename, symlink)
|
|
160
|
-
os.symlink(symlink, config_filename)
|
|
161
|
-
config_filename = symlink
|
|
100
|
+
found = conf.set_context(context)
|
|
162
101
|
if found:
|
|
163
102
|
return
|
|
103
|
+
if list_config:
|
|
104
|
+
config_data = conf.get_config_data()
|
|
105
|
+
output(config_data)
|
|
106
|
+
return
|
|
164
107
|
if not baseurl:
|
|
165
108
|
needed.append("baseurl")
|
|
166
109
|
if not username:
|
|
@@ -171,8 +114,7 @@ def run_config(
|
|
|
171
114
|
error(
|
|
172
115
|
"Can not configure without {}".format(need),
|
|
173
116
|
"No {}".format(need),
|
|
174
|
-
)
|
|
175
|
-
)
|
|
117
|
+
))
|
|
176
118
|
sys.exit(1)
|
|
177
119
|
config[need] = input("Enter {}: ".format(need))
|
|
178
120
|
|
|
@@ -183,299 +125,46 @@ 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, jsonout: bool, headers: dict, ret=False) -> Union[None, str]:
|
|
200
|
-
response = requests.get(url, headers=headers)
|
|
201
|
-
string = response.json()
|
|
202
|
-
if ret:
|
|
203
|
-
return string
|
|
204
|
-
else:
|
|
205
|
-
output(string, jsonout)
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
def run_update(url: str, jsonout: bool, headers: dict):
|
|
209
|
-
response = requests.patch(url, headers=headers)
|
|
210
|
-
output(response.json(), jsonout)
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
# Set up the url
|
|
214
|
-
def setup_url(
|
|
215
|
-
baseurl: str,
|
|
216
|
-
arguments: Union[None, list[str]],
|
|
217
|
-
data: Union[None, str],
|
|
218
|
-
name: Union[None, str],
|
|
219
|
-
rtype: Union[None, str],
|
|
220
|
-
ttl: Union[None, str],
|
|
221
|
-
zone: Union[None, str],
|
|
222
|
-
) -> str:
|
|
223
|
-
url = baseurl + "/zones"
|
|
224
|
-
if zone:
|
|
225
|
-
if not zone.endswith("."):
|
|
226
|
-
zone += "."
|
|
227
|
-
url += "/{}".format(zone)
|
|
228
|
-
if name and zone:
|
|
229
|
-
if name.endswith(zone.rstrip(".")):
|
|
230
|
-
name += "."
|
|
231
|
-
url += "/records/{}".format(name)
|
|
232
|
-
if zone and name and rtype:
|
|
233
|
-
url += "/{}".format(rtype)
|
|
234
|
-
if data and zone and name and rtype:
|
|
235
|
-
url += "/{}".format(data)
|
|
236
|
-
if ttl and data and zone and name and rtype:
|
|
237
|
-
url += "/{}".format(ttl)
|
|
238
|
-
if data and zone and name and rtype and arguments:
|
|
239
|
-
url += "?"
|
|
240
|
-
for arg in arguments:
|
|
241
|
-
if not url.endswith("?"):
|
|
242
|
-
url += "&"
|
|
243
|
-
key, value = arg.split("=")
|
|
244
|
-
url += key + "=" + urllib.parse.quote_plus(value)
|
|
245
|
-
|
|
246
|
-
if ttl and (not rtype or not name or not zone):
|
|
247
|
-
output(
|
|
248
|
-
error(
|
|
249
|
-
"ttl only makes sense with rtype, name and zone",
|
|
250
|
-
"Missing parameter",
|
|
251
|
-
)
|
|
252
|
-
)
|
|
253
|
-
sys.exit(1)
|
|
254
|
-
if rtype and (not name or not zone):
|
|
255
|
-
output(
|
|
256
|
-
error(
|
|
257
|
-
"rtype only makes sense with name and zone",
|
|
258
|
-
"Missing parameter",
|
|
259
|
-
)
|
|
260
|
-
)
|
|
261
|
-
sys.exit(1)
|
|
262
|
-
if name and not zone:
|
|
263
|
-
output(error("name only makes sense with a zone", "Missing parameter"))
|
|
264
|
-
sys.exit(1)
|
|
265
|
-
return url
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
def split_url(url: str) -> dict:
|
|
269
|
-
parsed = urlparse(url, allow_fragments=False)
|
|
270
|
-
path = parsed.path
|
|
271
|
-
query = parsed.query
|
|
272
|
-
arguments: Union[None, list[str]] = query.split("&")
|
|
273
|
-
path_arr = path.split("/")
|
|
274
|
-
data: Union[None, str] = None
|
|
275
|
-
name: Union[None, str] = None
|
|
276
|
-
rtype: Union[None, str] = None
|
|
277
|
-
ttl: Union[None, str] = None
|
|
278
|
-
zone: Union[None, str] = None
|
|
279
|
-
if len(path_arr) > 2:
|
|
280
|
-
zone = path_arr[2]
|
|
281
|
-
if len(path_arr) > 4:
|
|
282
|
-
name = path_arr[4]
|
|
283
|
-
if len(path_arr) > 5:
|
|
284
|
-
rtype = path_arr[5]
|
|
285
|
-
if len(path_arr) > 6:
|
|
286
|
-
data = path_arr[6]
|
|
287
|
-
if len(path_arr) > 7:
|
|
288
|
-
ttl = path_arr[7]
|
|
289
|
-
|
|
290
|
-
return {
|
|
291
|
-
"arguments": arguments,
|
|
292
|
-
"data": data,
|
|
293
|
-
"name": name,
|
|
294
|
-
"rtype": rtype,
|
|
295
|
-
"ttl": ttl,
|
|
296
|
-
"zone": zone,
|
|
297
|
-
}
|
|
128
|
+
conf.set_config(config)
|
|
298
129
|
|
|
299
130
|
|
|
300
131
|
# Entry point to program
|
|
301
132
|
def main() -> int:
|
|
302
|
-
|
|
303
|
-
* https://gitlab.nic.cz/knot/knot-dns-rest"""
|
|
304
|
-
|
|
305
|
-
epilog = """
|
|
306
|
-
The Domain Name System specifies a database of information
|
|
307
|
-
elements for network resources. The types of information
|
|
308
|
-
elements are categorized and organized with a list of DNS
|
|
309
|
-
record types, the resource records (RRs). Each record has a
|
|
310
|
-
name, a type, an expiration time (time to live), and
|
|
311
|
-
type-specific data.
|
|
312
|
-
|
|
313
|
-
The following is a list of terms used in this program:
|
|
314
|
-
----------------------------------------------------------------
|
|
315
|
-
| Vocabulary | Description |
|
|
316
|
-
----------------------------------------------------------------
|
|
317
|
-
| zone | A DNS zone is a specific portion of the DNS |
|
|
318
|
-
| | namespace in the Domain Name System (DNS), |
|
|
319
|
-
| | which a specific organization or administrator |
|
|
320
|
-
| | manages. |
|
|
321
|
-
----------------------------------------------------------------
|
|
322
|
-
| name | In the Internet, a domain name is a string that |
|
|
323
|
-
| | identifies a realm of administrative autonomy, |
|
|
324
|
-
| | authority or control. Domain names are often |
|
|
325
|
-
| | used to identify services provided through the |
|
|
326
|
-
| | Internet, such as websites, email services and |
|
|
327
|
-
| | more. |
|
|
328
|
-
----------------------------------------------------------------
|
|
329
|
-
| rtype | A record type indicates the format of the data |
|
|
330
|
-
| | and it gives a hint of its intended use. For |
|
|
331
|
-
| | example, the A record is used to translate from |
|
|
332
|
-
| | a domain name to an IPv4 address, the NS record |
|
|
333
|
-
| | lists which name servers can answer lookups on |
|
|
334
|
-
| | a DNS zone, and the MX record specifies the |
|
|
335
|
-
| | mail server used to handle mail for a domain |
|
|
336
|
-
| | specified in an e-mail address. |
|
|
337
|
-
----------------------------------------------------------------
|
|
338
|
-
| data | A records data is of type-specific relevance, |
|
|
339
|
-
| | such as the IP address for address records, or |
|
|
340
|
-
| | the priority and hostname for MX records. |
|
|
341
|
-
----------------------------------------------------------------
|
|
342
|
-
|
|
343
|
-
This information was compiled from Wikipedia:
|
|
344
|
-
* https://en.wikipedia.org/wiki/DNS_zone
|
|
345
|
-
* https://en.wikipedia.org/wiki/Domain_Name_System
|
|
346
|
-
* https://en.wikipedia.org/wiki/Zone_file
|
|
347
|
-
"""
|
|
348
|
-
# Grab user input
|
|
349
|
-
parser = argparse.ArgumentParser(
|
|
350
|
-
description=description,
|
|
351
|
-
epilog=epilog,
|
|
352
|
-
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
353
|
-
)
|
|
354
|
-
parser.add_argument("--json", action=argparse.BooleanOptionalAction)
|
|
355
|
-
subparsers = parser.add_subparsers(dest="command")
|
|
356
|
-
|
|
357
|
-
add_description = "Add a new record to the zone."
|
|
358
|
-
addcmd = subparsers.add_parser("add", description=add_description)
|
|
359
|
-
addcmd.add_argument("-d", "--data", required=True)
|
|
360
|
-
addcmd.add_argument("-n", "--name", required=True)
|
|
361
|
-
addcmd.add_argument("-r", "--rtype", required=True)
|
|
362
|
-
addcmd.add_argument("-t", "--ttl")
|
|
363
|
-
addcmd.add_argument("-z", "--zone", required=True)
|
|
364
|
-
|
|
365
|
-
auditlog_description = "Audit the log file for errors."
|
|
366
|
-
subparsers.add_parser("auditlog", description=auditlog_description)
|
|
367
|
-
|
|
368
|
-
changelog_description = "View the changelog of a zone."
|
|
369
|
-
changelogcmd = subparsers.add_parser("changelog", description=changelog_description)
|
|
370
|
-
changelogcmd.add_argument("-z", "--zone", required=True)
|
|
371
|
-
|
|
372
|
-
complete_description = "Generate shell completion script."
|
|
373
|
-
completecmd = subparsers.add_parser("completion", description=complete_description)
|
|
374
|
-
completecmd.add_argument("-s", "--shell")
|
|
375
|
-
|
|
376
|
-
config_description = "Configure access to knot-dns-rest-api."
|
|
377
|
-
configcmd = subparsers.add_parser("config", description=config_description)
|
|
378
|
-
configcmd.add_argument("-b", "--baseurl")
|
|
379
|
-
configcmd.add_argument("-c", "--context")
|
|
380
|
-
configcmd.add_argument("-C", "--current", action=argparse.BooleanOptionalAction)
|
|
381
|
-
configcmd.add_argument("-p", "--password")
|
|
382
|
-
configcmd.add_argument("-u", "--username")
|
|
383
|
-
|
|
384
|
-
delete_description = "Delete a record from the zone."
|
|
385
|
-
deletecmd = subparsers.add_parser("delete", description=delete_description)
|
|
386
|
-
deletecmd.add_argument("-d", "--data")
|
|
387
|
-
deletecmd.add_argument("-n", "--name")
|
|
388
|
-
deletecmd.add_argument("-r", "--rtype")
|
|
389
|
-
deletecmd.add_argument("-z", "--zone", required=True)
|
|
390
|
-
|
|
391
|
-
list_description = "List records in the zone."
|
|
392
|
-
listcmd = subparsers.add_parser("list", description=list_description)
|
|
393
|
-
listcmd.add_argument("-d", "--data")
|
|
394
|
-
listcmd.add_argument("-n", "--name")
|
|
395
|
-
listcmd.add_argument("-r", "--rtype")
|
|
396
|
-
listcmd.add_argument("-z", "--zone", required=True)
|
|
397
|
-
|
|
398
|
-
update_description = (
|
|
399
|
-
"Update a record in the zone. The record must exist in the zone.\n"
|
|
400
|
-
)
|
|
401
|
-
update_description += (
|
|
402
|
-
"In this case --data, --name, --rtype and --ttl switches are used\n"
|
|
403
|
-
)
|
|
404
|
-
update_description += (
|
|
405
|
-
"for searching for the appropriate record, while the --argument\n"
|
|
406
|
-
)
|
|
407
|
-
update_description += "switches are used for updating the record."
|
|
408
|
-
update_epilog = """Available arguments are:
|
|
409
|
-
data: New record data.
|
|
410
|
-
name: New record domain name.
|
|
411
|
-
rtype: New record type.
|
|
412
|
-
ttl: New record time to live (TTL)."""
|
|
413
|
-
updatecmd = subparsers.add_parser(
|
|
414
|
-
"update",
|
|
415
|
-
description=update_description,
|
|
416
|
-
epilog=update_epilog,
|
|
417
|
-
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
418
|
-
)
|
|
419
|
-
updatecmd.add_argument(
|
|
420
|
-
"-a",
|
|
421
|
-
"--argument",
|
|
422
|
-
action="append",
|
|
423
|
-
metavar="KEY=VALUE",
|
|
424
|
-
help="Specify key - value pairs to be updated: name=dns1.example.com. or data=127.0.0.1 for example. --argument can be repeated",
|
|
425
|
-
required=True,
|
|
426
|
-
)
|
|
427
|
-
updatecmd.add_argument("-d", "--data", required=True)
|
|
428
|
-
updatecmd.add_argument("-n", "--name", required=True)
|
|
429
|
-
updatecmd.add_argument("-r", "--rtype", required=True)
|
|
430
|
-
updatecmd.add_argument("-t", "--ttl")
|
|
431
|
-
updatecmd.add_argument("-z", "--zone", required=True)
|
|
432
|
-
|
|
433
|
-
argcomplete.autocomplete(parser)
|
|
133
|
+
parser = get_parser()
|
|
434
134
|
args = parser.parse_args()
|
|
435
135
|
if args.command == "completion":
|
|
436
136
|
run_complete(args.shell)
|
|
437
137
|
return 0
|
|
438
138
|
|
|
439
|
-
|
|
440
|
-
config_basepath = join(environ["HOME"], ".knot")
|
|
441
|
-
config_filename = join(config_basepath, "config")
|
|
442
|
-
|
|
443
|
-
if not isdir(config_basepath):
|
|
444
|
-
mkdir(config_basepath)
|
|
139
|
+
knotctl = Knotctl()
|
|
445
140
|
|
|
446
141
|
if args.command == "config":
|
|
447
142
|
run_config(
|
|
448
|
-
|
|
143
|
+
args.context,
|
|
144
|
+
args.baseurl,
|
|
145
|
+
args.list_config,
|
|
146
|
+
args.username,
|
|
147
|
+
args.password,
|
|
148
|
+
args.current,
|
|
449
149
|
)
|
|
450
150
|
return 0
|
|
451
151
|
|
|
452
|
-
|
|
453
|
-
print("You need to configure knotctl before proceeding")
|
|
454
|
-
run_config(config_filename)
|
|
455
|
-
|
|
456
|
-
config = get_config(config_filename)
|
|
152
|
+
config = knotctl.get_config()
|
|
457
153
|
baseurl = config["baseurl"]
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
# Authenticate
|
|
462
|
-
basic = HTTPBasicAuth(username, password)
|
|
463
|
-
response = requests.get(baseurl + "/user/login", auth=basic)
|
|
464
|
-
try:
|
|
465
|
-
token = response.json()["token"]
|
|
466
|
-
except KeyError:
|
|
467
|
-
output(response.json())
|
|
154
|
+
token = knotctl.conf.get_token()
|
|
155
|
+
if token == "":
|
|
156
|
+
print("Could not get token, exiting")
|
|
468
157
|
return 1
|
|
469
|
-
except requests.exceptions.JSONDecodeError:
|
|
470
|
-
output(error("Could not decode api response as JSON", "Could not decode"))
|
|
471
|
-
return 1
|
|
472
|
-
headers = {"Authorization": "Bearer {}".format(token)}
|
|
473
158
|
|
|
474
159
|
# Route based on command
|
|
475
160
|
url = ""
|
|
476
161
|
ttl = None
|
|
162
|
+
quotedata = None
|
|
163
|
+
user = config["username"]
|
|
477
164
|
if "ttl" in args:
|
|
478
165
|
ttl = args.ttl
|
|
166
|
+
if "data" in args:
|
|
167
|
+
quotedata = quote(args.data, safe="")
|
|
479
168
|
if args.command != "update":
|
|
480
169
|
args.argument = None
|
|
481
170
|
if args.command == "add" and not ttl:
|
|
@@ -484,16 +173,21 @@ def main() -> int:
|
|
|
484
173
|
else:
|
|
485
174
|
zname = args.zone + "."
|
|
486
175
|
soa_url = setup_url(baseurl, None, None, zname, "SOA", None, args.zone)
|
|
487
|
-
soa_json =
|
|
176
|
+
soa_json = knotctl.runner.lister(soa_url, True, ret=True)
|
|
488
177
|
ttl = soa_json[0]["ttl"]
|
|
489
|
-
if args.command
|
|
178
|
+
if args.command == "user":
|
|
179
|
+
if args.username:
|
|
180
|
+
user = args.username
|
|
181
|
+
if args.command in [
|
|
182
|
+
"auditlog", "changelog", "openstack-sync", "user", "zone"
|
|
183
|
+
]:
|
|
490
184
|
pass
|
|
491
185
|
else:
|
|
492
186
|
try:
|
|
493
187
|
url = setup_url(
|
|
494
188
|
baseurl,
|
|
495
189
|
args.argument,
|
|
496
|
-
|
|
190
|
+
quotedata,
|
|
497
191
|
args.name,
|
|
498
192
|
args.rtype,
|
|
499
193
|
ttl,
|
|
@@ -503,29 +197,7 @@ def main() -> int:
|
|
|
503
197
|
parser.print_help(sys.stderr)
|
|
504
198
|
return 1
|
|
505
199
|
|
|
506
|
-
|
|
507
|
-
if args.command == "add":
|
|
508
|
-
run_add(url, args.json, headers)
|
|
509
|
-
elif args.command == "delete":
|
|
510
|
-
run_delete(url, args.json, headers)
|
|
511
|
-
elif args.command == "list":
|
|
512
|
-
run_list(url, args.json, headers)
|
|
513
|
-
elif args.command == "update":
|
|
514
|
-
run_update(url, args.json, headers)
|
|
515
|
-
elif args.command == "auditlog":
|
|
516
|
-
url = baseurl + "/user/auditlog"
|
|
517
|
-
run_log(url, args.json, headers)
|
|
518
|
-
elif args.command == "changelog":
|
|
519
|
-
url = baseurl + f"/zones/changelog/{args.zone.rstrip('.')}"
|
|
520
|
-
run_log(url, args.json, headers)
|
|
521
|
-
else:
|
|
522
|
-
parser.print_help(sys.stderr)
|
|
523
|
-
return 2
|
|
524
|
-
except requests.exceptions.RequestException as e:
|
|
525
|
-
output(error(e, "Could not connect to server"))
|
|
526
|
-
except (RequestsJSONDecodeError, SimplejsonJSONDecodeError):
|
|
527
|
-
output(error("Could not decode api response as JSON", "Could not decode"))
|
|
528
|
-
return 0
|
|
200
|
+
return knotctl.run(url, args, baseurl, parser, user)
|
|
529
201
|
|
|
530
202
|
|
|
531
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
|
|
@@ -208,9 +214,9 @@ options:
|
|
|
208
214
|
### LIST
|
|
209
215
|
|
|
210
216
|
```
|
|
211
|
-
usage: knotctl list [-h] [-d DATA] [-n NAME] [-r RTYPE] -z ZONE
|
|
217
|
+
usage: knotctl list [-h] [-d DATA] [-n NAME] [-r RTYPE] [-z ZONE]
|
|
212
218
|
|
|
213
|
-
List records
|
|
219
|
+
List records.
|
|
214
220
|
|
|
215
221
|
options:
|
|
216
222
|
-h, --help show this help message and exit
|
|
@@ -249,3 +255,26 @@ Available arguments are:
|
|
|
249
255
|
ttl: New record time to live (TTL).
|
|
250
256
|
```
|
|
251
257
|
|
|
258
|
+
### USER
|
|
259
|
+
```
|
|
260
|
+
usage: knotctl user [-h] [-u USERNAME]
|
|
261
|
+
|
|
262
|
+
View user information.
|
|
263
|
+
|
|
264
|
+
options:
|
|
265
|
+
-h, --help show this help message and exit
|
|
266
|
+
-u USERNAME, --username USERNAME
|
|
267
|
+
```
|
|
268
|
+
|
|
269
|
+
### ZONE
|
|
270
|
+
|
|
271
|
+
```
|
|
272
|
+
usage: knotctl zone
|
|
273
|
+
|
|
274
|
+
List zones.
|
|
275
|
+
|
|
276
|
+
options:
|
|
277
|
+
-h, --help show this help message and exit
|
|
278
|
+
```
|
|
279
|
+
|
|
280
|
+
|
|
@@ -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.0.dist-info/RECORD
DELETED
|
@@ -1,6 +0,0 @@
|
|
|
1
|
-
knotctl/__init__.py,sha256=kqE3iZEX2pxMSfXTSDyJUCayRanSpHLgdd4EqVR1kV0,18422
|
|
2
|
-
knotctl-0.1.0.dist-info/entry_points.txt,sha256=oGFZaAfsfqC-KbiCm04W6DTBFCq2f5F_p3KMEgNoY4s,40
|
|
3
|
-
knotctl-0.1.0.dist-info/LICENSE,sha256=tqi_Y64slbCqJW7ndGgNe9GPIfRX2nVGb3YQs7FqzE4,34670
|
|
4
|
-
knotctl-0.1.0.dist-info/WHEEL,sha256=EZbGkh7Ie4PoZfRQ8I0ZuP9VklN_TvcZ6DSE5Uar4z4,81
|
|
5
|
-
knotctl-0.1.0.dist-info/METADATA,sha256=yqUFEkuHQ_GgpuV2TJox78ujwdKagC0dYEtrCUGbwBk,7236
|
|
6
|
-
knotctl-0.1.0.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|