twc-cli 2.3.0__py3-none-any.whl → 2.4.1__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.

Potentially problematic release.


This version of twc-cli might be problematic. Click here for more details.

@@ -0,0 +1,654 @@
1
+ """Manage Cloud Firewall rules and groups."""
2
+
3
+ import re
4
+ import sys
5
+ import json
6
+ import textwrap
7
+ from typing import Optional, List, Tuple
8
+ from pathlib import Path
9
+ from uuid import UUID
10
+ from enum import Enum
11
+ from ipaddress import ip_network
12
+ from datetime import datetime
13
+ import logging
14
+
15
+ import typer
16
+ from click import UsageError
17
+ import yaml
18
+ from requests import Response
19
+
20
+ from twc import fmt
21
+ from twc.api import TimewebCloud, FirewallProto
22
+ from twc.typerx import TyperAlias
23
+ from twc.apiwrap import create_client
24
+ from .common import (
25
+ verbose_option,
26
+ config_option,
27
+ profile_option,
28
+ filter_option,
29
+ output_format_option,
30
+ OutputFormat,
31
+ yes_option,
32
+ )
33
+
34
+
35
+ firewall = TyperAlias(help=__doc__)
36
+ firewall_group = TyperAlias(help="Manage firewall groups.")
37
+ firewall_rule = TyperAlias(help="Manage firewall rules.")
38
+ firewall.add_typer(firewall_group, name="group", aliases=["groups"])
39
+ firewall.add_typer(firewall_rule, name="rule", aliases=["rules"])
40
+
41
+
42
+ class _ResourceType(str, Enum):
43
+ SERVER = "server"
44
+ DATABASE = "database"
45
+ BALANCER = "balancer"
46
+
47
+
48
+ class _ResourceType2(str, Enum):
49
+ # Ugly class for 'show' command
50
+ SERVER = "server"
51
+ DATABASE = "database"
52
+ BALANCER = "balancer"
53
+ ALL = "all"
54
+
55
+
56
+ # API issue: inconsistent resource naming: database->dbaas
57
+ RESOURCE_TYPES = {
58
+ "server": "server",
59
+ "database": "dbaas", # fix naming
60
+ "balancer": "balancer",
61
+ }
62
+
63
+
64
+ # ------------------------------------------------------------- #
65
+ # $ twc firewall show #
66
+ # ------------------------------------------------------------- #
67
+
68
+
69
+ def print_firewall_status(data: list):
70
+ print("Groups total:", len(data))
71
+ rules_total = 0
72
+ for group in data:
73
+ rules_total += len(group["rules"])
74
+ print("Rules total:", rules_total)
75
+ for group in data:
76
+ info = f"Group: {group['name']} ({group['id']})"
77
+ for rule in group["rules"]:
78
+ info += "\n" + textwrap.indent(
79
+ textwrap.dedent(
80
+ f"""
81
+ Rule: {rule['id']}
82
+ Direction: {rule['direction']}
83
+ Protocol: {rule['protocol']}
84
+ Port: {rule['port']}
85
+ CIDR: {rule['cidr']}
86
+ """
87
+ ).strip(),
88
+ " ",
89
+ )
90
+ if group["resources"]:
91
+ info += "\n Resources:\n"
92
+ servers = [
93
+ r["id"] for r in group["resources"] if r["type"] == "server"
94
+ ]
95
+ databases = [
96
+ r["id"] for r in group["resources"] if r["type"] == "dbaas"
97
+ ]
98
+ balancers = [
99
+ r["id"] for r in group["resources"] if r["type"] == "balancer"
100
+ ]
101
+ if servers:
102
+ info += textwrap.indent(f"Servers: {servers}\n", " " * 4)
103
+ if databases:
104
+ info += textwrap.indent(f"Databases: {databases}\n", " " * 4)
105
+ if balancers:
106
+ info += textwrap.indent(
107
+ f"Load Balancers: {balancers}\n", " " * 4
108
+ )
109
+ print(info.strip())
110
+
111
+
112
+ def print_rules_by_service(rules, filters):
113
+ if filters:
114
+ rules = fmt.filter_list(rules, filters)
115
+ table = fmt.Table()
116
+ table.header(
117
+ [
118
+ "GROUP ID",
119
+ "RULE ID",
120
+ "DIRECTION",
121
+ "PROTO",
122
+ "PORTS",
123
+ "CIDR",
124
+ ]
125
+ )
126
+ for rule in rules:
127
+ table.row(
128
+ [
129
+ rule["group_id"],
130
+ rule["id"],
131
+ rule["direction"],
132
+ rule["protocol"],
133
+ rule["port"],
134
+ rule["cidr"],
135
+ ]
136
+ )
137
+ table.print()
138
+
139
+
140
+ @firewall.command("show")
141
+ def firewall_status(
142
+ resource_type: _ResourceType2 = typer.Argument(
143
+ ...,
144
+ metavar="(server|database|balancer|all)",
145
+ ),
146
+ resource_id: str = typer.Argument(None),
147
+ verbose: Optional[bool] = verbose_option,
148
+ config: Optional[Path] = config_option,
149
+ profile: Optional[str] = profile_option,
150
+ output_format: Optional[str] = output_format_option,
151
+ filters: Optional[str] = filter_option,
152
+ ):
153
+ """Display firewall status."""
154
+ client = create_client(config, profile)
155
+ data = []
156
+
157
+ if resource_type in [r.value for r in _ResourceType]:
158
+ rules_total = []
159
+ if resource_id is None:
160
+ raise UsageError(
161
+ "Resource ID is required for "
162
+ f"{[r.value for r in _ResourceType]}"
163
+ )
164
+ groups_ = client.get_resource_firewall_groups(
165
+ int(resource_id), RESOURCE_TYPES[resource_type]
166
+ ).json()["groups"]
167
+ for group_ in groups_:
168
+ rules_ = client.get_firewall_rules(group_["id"]).json()["rules"]
169
+ rules_total.extend(rules_)
170
+ print_rules_by_service(rules_total, filters)
171
+
172
+ if resource_type == "all":
173
+ if resource_id:
174
+ groups = [client.get_firewall_group(resource_id).json()["group"]]
175
+ else:
176
+ groups = client.get_firewall_groups().json()["groups"]
177
+
178
+ for group in groups:
179
+ rules = client.get_firewall_rules(group["id"]).json()["rules"]
180
+ resources = client.get_firewall_group_resources(
181
+ group["id"]
182
+ ).json()["resources"]
183
+ data.append(
184
+ {
185
+ "id": group["id"],
186
+ "name": group["name"],
187
+ "rules": rules,
188
+ "resources": resources,
189
+ }
190
+ )
191
+ formats = [f.value for f in OutputFormat]
192
+ if output_format in formats:
193
+ if output_format == "raw":
194
+ print(data)
195
+ else:
196
+ encoders = {
197
+ "yaml": yaml.dump,
198
+ "json": json.dumps,
199
+ }
200
+ fmt.print_colored(
201
+ encoders[output_format](data), lang=output_format
202
+ )
203
+ else:
204
+ print_firewall_status(data)
205
+
206
+
207
+ # ------------------------------------------------------------- #
208
+ # $ twc firewall group list #
209
+ # ------------------------------------------------------------- #
210
+
211
+
212
+ def print_firewall_groups(response: Response):
213
+ groups = response.json()["groups"]
214
+ table = fmt.Table()
215
+ table.header(["ID", "NAME"])
216
+ for group in groups:
217
+ table.row(
218
+ [
219
+ group["id"],
220
+ group["name"],
221
+ ]
222
+ )
223
+ table.print()
224
+
225
+
226
+ @firewall_group.command("list")
227
+ def firewall_group_list(
228
+ verbose: Optional[bool] = verbose_option,
229
+ config: Optional[Path] = config_option,
230
+ profile: Optional[str] = profile_option,
231
+ output_format: Optional[str] = output_format_option,
232
+ ):
233
+ """List groups."""
234
+ client = create_client(config, profile)
235
+ response = client.get_firewall_groups()
236
+ fmt.printer(
237
+ response,
238
+ output_format=output_format,
239
+ func=print_firewall_groups,
240
+ )
241
+
242
+
243
+ # ------------------------------------------------------------- #
244
+ # $ twc firewall group create #
245
+ # ------------------------------------------------------------- #
246
+
247
+
248
+ @firewall_group.command("create")
249
+ def firewall_group_create(
250
+ verbose: Optional[bool] = verbose_option,
251
+ config: Optional[Path] = config_option,
252
+ profile: Optional[str] = profile_option,
253
+ output_format: Optional[str] = output_format_option,
254
+ name: str = typer.Option(..., help="Group display name."),
255
+ desc: Optional[str] = typer.Option(None, help="Description."),
256
+ ):
257
+ """Create new group of firewall rules."""
258
+ client = create_client(config, profile)
259
+ response = client.create_firewall_group(
260
+ name=name,
261
+ description=desc,
262
+ )
263
+ fmt.printer(
264
+ response,
265
+ output_format=output_format,
266
+ func=lambda response: print(response.json()["group"]["id"]),
267
+ )
268
+
269
+
270
+ # ------------------------------------------------------------- #
271
+ # $ twc firewall group remove #
272
+ # ------------------------------------------------------------- #
273
+
274
+
275
+ @firewall_group.command("remove", "rm")
276
+ def firewall_group_remove(
277
+ group_ids: List[UUID] = typer.Argument(..., metavar="GROUP_ID..."),
278
+ verbose: Optional[bool] = verbose_option,
279
+ config: Optional[Path] = config_option,
280
+ profile: Optional[str] = profile_option,
281
+ yes: Optional[bool] = yes_option,
282
+ ):
283
+ """Remove rules group. All rules in group will lost."""
284
+ if not yes:
285
+ typer.confirm("This action cannot be undone. Continue?", abort=True)
286
+ client = create_client(config, profile)
287
+ for group_id in group_ids:
288
+ response = client.delete_firewall_group(group_id)
289
+ if response.status_code == 204:
290
+ print(group_id)
291
+ else:
292
+ sys.exit(fmt.printer(response))
293
+
294
+
295
+ # ------------------------------------------------------------- #
296
+ # $ twc firewall group set #
297
+ # ------------------------------------------------------------- #
298
+
299
+
300
+ @firewall_group.command("set")
301
+ def firewall_group_set(
302
+ group_id: UUID,
303
+ verbose: Optional[bool] = verbose_option,
304
+ config: Optional[Path] = config_option,
305
+ profile: Optional[str] = profile_option,
306
+ output_format: Optional[str] = output_format_option,
307
+ name: Optional[str] = typer.Option(None, help="Group display name"),
308
+ desc: Optional[str] = typer.Option(None, help="Description."),
309
+ ):
310
+ """Set rules group properties."""
311
+ client = create_client(config, profile)
312
+ if not name and not desc:
313
+ raise UsageError(
314
+ "Nothing to do. Set one of options: ['--name', '--desc']"
315
+ )
316
+ if not name:
317
+ # Get old firewall group name, because name is required
318
+ name = client.get_firewall_group(group_id).json()["group"]["name"]
319
+ response = client.update_firewall_group(group_id, name, desc)
320
+ fmt.printer(
321
+ response,
322
+ output_format=output_format,
323
+ func=lambda response: print(response.json()["group"]["id"]),
324
+ )
325
+
326
+
327
+ # ------------------------------------------------------------- #
328
+ # $ twc firewall link #
329
+ # ------------------------------------------------------------- #
330
+
331
+
332
+ @firewall.command("link")
333
+ def firewall_link(
334
+ resource_type: _ResourceType = typer.Argument(
335
+ ...,
336
+ metavar="(server|database|balancer)",
337
+ ),
338
+ resource_id: int = typer.Argument(...),
339
+ group_id: UUID = typer.Argument(...),
340
+ verbose: Optional[bool] = verbose_option,
341
+ config: Optional[Path] = config_option,
342
+ profile: Optional[str] = profile_option,
343
+ output_format: Optional[str] = output_format_option,
344
+ ):
345
+ """Link rules group to service."""
346
+ client = create_client(config, profile)
347
+ response = client.link_resource_to_firewall(
348
+ group_id,
349
+ resource_id,
350
+ RESOURCE_TYPES[resource_type],
351
+ )
352
+ fmt.printer(
353
+ response,
354
+ output_format=output_format,
355
+ func=lambda response: print(response.json()["resource"]["id"]),
356
+ )
357
+
358
+
359
+ # ------------------------------------------------------------- #
360
+ # $ twc firewall unlink #
361
+ # ------------------------------------------------------------- #
362
+
363
+
364
+ @firewall.command("unlink")
365
+ def firewall_unlink(
366
+ resource_type: _ResourceType = typer.Argument(
367
+ ...,
368
+ metavar="(server|database|balancer)",
369
+ ),
370
+ resource_id: int = typer.Argument(...),
371
+ group_id: UUID = typer.Argument(None),
372
+ verbose: Optional[bool] = verbose_option,
373
+ config: Optional[Path] = config_option,
374
+ profile: Optional[str] = profile_option,
375
+ all_groups: bool = typer.Option(
376
+ False,
377
+ "--all",
378
+ "-a",
379
+ help="Unlink all linked firewall groups.",
380
+ ),
381
+ ):
382
+ """Unlink rules group from service."""
383
+ if not all_groups and group_id is None:
384
+ raise UsageError(
385
+ "One of parameters is required: ['--all', 'GROUP_ID']"
386
+ )
387
+ client = create_client(config, profile)
388
+ if all_groups:
389
+ groups_ = client.get_resource_firewall_groups(
390
+ resource_id, RESOURCE_TYPES[resource_type]
391
+ )
392
+ groups = [g["id"] for g in groups_.json()["groups"]]
393
+ else:
394
+ groups = [group_id]
395
+ for group in groups:
396
+ response = client.unlink_resource_from_firewall(
397
+ group,
398
+ resource_id,
399
+ RESOURCE_TYPES[resource_type],
400
+ )
401
+ if response.status_code == 204:
402
+ print("Unlinked:", group)
403
+ else:
404
+ sys.exit(fmt.printer(response))
405
+
406
+
407
+ # ------------------------------------------------------------- #
408
+ # $ twc firewall rule list #
409
+ # ------------------------------------------------------------- #
410
+
411
+
412
+ def print_rules(response: Response):
413
+ rules = response.json()["rules"]
414
+ table = fmt.Table()
415
+ table.header(
416
+ [
417
+ "ID",
418
+ "DIRECTION",
419
+ "PROTO",
420
+ "PORTS",
421
+ "CIDR",
422
+ ]
423
+ )
424
+ for rule in rules:
425
+ table.row(
426
+ [
427
+ rule["id"],
428
+ rule["direction"],
429
+ rule["protocol"],
430
+ rule["port"],
431
+ rule["cidr"],
432
+ ]
433
+ )
434
+ table.print()
435
+
436
+
437
+ @firewall_rule.command("list", "ls")
438
+ def filrewall_rule_list(
439
+ group_id: UUID,
440
+ verbose: Optional[bool] = verbose_option,
441
+ config: Optional[Path] = config_option,
442
+ profile: Optional[str] = profile_option,
443
+ output_format: Optional[str] = output_format_option,
444
+ ):
445
+ """List rules in group."""
446
+ client = create_client(config, profile)
447
+ response = client.get_firewall_rules(group_id)
448
+ fmt.printer(
449
+ response,
450
+ output_format=output_format,
451
+ func=print_rules,
452
+ )
453
+
454
+
455
+ # ------------------------------------------------------------- #
456
+ # $ twc firewall rule add #
457
+ # ------------------------------------------------------------- #
458
+
459
+
460
+ def port_proto_callback(values) -> List[Tuple[Optional[str], str]]:
461
+ new_values = []
462
+ for value in values:
463
+ if not re.match(r"((^\d+(-\d+)?/)?(tcp|udp)$)|(^icmp$)", value, re.I):
464
+ sys.exit(
465
+ f"Error: Malformed argument: '{value}': "
466
+ "correct patterns: '22/TCP', '2000-3000/UDP', 'ICMP', etc."
467
+ )
468
+ if re.match(r"^icmp$", value, re.I):
469
+ new_values.append((None, "icmp"))
470
+ else:
471
+ ports, proto = value.split("/")
472
+ new_values.append((ports, proto.lower()))
473
+ return new_values
474
+
475
+
476
+ def validate_cidr_callback(value):
477
+ if value is not None:
478
+ try:
479
+ assert ip_network(value)
480
+ except ValueError as err:
481
+ sys.exit(f"Error: Invalid CIDR: {err}")
482
+ return value
483
+
484
+
485
+ @firewall_rule.command("add")
486
+ def firewall_allow(
487
+ ports: List[str] = typer.Argument(
488
+ ...,
489
+ metavar="[PORT[-PORT]/]PROTO...",
490
+ callback=port_proto_callback,
491
+ help="List of port/protocol pairs e.g. 22/TCP, 2000-3000/UDP, ICMP",
492
+ ),
493
+ verbose: Optional[bool] = verbose_option,
494
+ config: Optional[Path] = config_option,
495
+ profile: Optional[str] = profile_option,
496
+ output_format: Optional[str] = output_format_option,
497
+ group: Optional[UUID] = typer.Option(
498
+ None,
499
+ "--group",
500
+ "-g",
501
+ help="Firewall rules group UUID.",
502
+ ),
503
+ make_group: Optional[bool] = typer.Option(
504
+ None,
505
+ "--make-group",
506
+ "-G",
507
+ help="Add rules in new rules group.",
508
+ ),
509
+ group_name: Optional[str] = typer.Option(
510
+ None,
511
+ help="Rules group name, can be used with '--make-group'",
512
+ ),
513
+ direction_: bool = typer.Option(
514
+ True, "--ingress/--egress", help="Traffic direction."
515
+ ),
516
+ cidr: Optional[str] = typer.Option(
517
+ "0.0.0.0/0",
518
+ metavar="IP_NETWORK",
519
+ callback=validate_cidr_callback,
520
+ help="IPv4 or IPv6 CIDR.",
521
+ ),
522
+ ):
523
+ """Add new firewall rule."""
524
+ client = create_client(config, profile)
525
+ if make_group is not None and group is not None:
526
+ raise UsageError(
527
+ "'--group' and '--make-group' options is mutually exclusive."
528
+ )
529
+ if make_group is None and group is None:
530
+ raise UsageError(
531
+ "One of options is required: ['--group', '--make-group']"
532
+ )
533
+ if make_group:
534
+ if group_name is None:
535
+ group_name = "Firewall Group " + datetime.now().strftime(
536
+ "%Y.%m.%d-%H:%M:%S"
537
+ )
538
+ group_resp = client.create_firewall_group(group_name)
539
+ group = group_resp.json()["group"]["id"]
540
+ logging.debug("New firewall rules group: %s", group)
541
+ fmt.printer(
542
+ group_resp,
543
+ output_format=output_format,
544
+ func=lambda x: print("Created rules group:", group),
545
+ )
546
+ for port in ports:
547
+ if direction_ is True:
548
+ direction = "ingress"
549
+ else:
550
+ direction = "egress"
551
+ response = client.create_firewall_rule(
552
+ group,
553
+ direction=direction,
554
+ port=port[0], # :str port or port range
555
+ proto=port[1], # :str protocol name
556
+ cidr=cidr,
557
+ )
558
+ fmt.printer(
559
+ response,
560
+ output_format=output_format,
561
+ func=lambda response: print(response.json()["rule"]["id"]),
562
+ )
563
+
564
+
565
+ # ------------------------------------------------------------- #
566
+ # $ twc firewall rule remove #
567
+ # ------------------------------------------------------------- #
568
+
569
+
570
+ def get_group_id_by_rule(client: TimewebCloud, rule_id: UUID) -> str:
571
+ groups = client.get_firewall_groups().json()["groups"]
572
+ for group in groups:
573
+ rules = client.get_firewall_rules(group["id"]).json()["rules"]
574
+ for rule in rules:
575
+ if str(rule_id) == rule["id"]:
576
+ return group["id"]
577
+ sys.exit(f"Error: Rule '{rule_id}' not found")
578
+
579
+
580
+ @firewall_rule.command("remove", "rm")
581
+ def firewall_rule_remove(
582
+ rule_id: UUID,
583
+ verbose: Optional[bool] = verbose_option,
584
+ config: Optional[Path] = config_option,
585
+ profile: Optional[str] = profile_option,
586
+ ):
587
+ """Remove firewall rule."""
588
+ client = create_client(config, profile)
589
+ group_id = get_group_id_by_rule(client, rule_id)
590
+ response = client.delete_firewall_rule(group_id, rule_id)
591
+ if response.status_code == 204:
592
+ print(rule_id)
593
+ else:
594
+ sys.exit(fmt.printer(response))
595
+
596
+
597
+ # ------------------------------------------------------------- #
598
+ # $ twc firewall rule update #
599
+ # ------------------------------------------------------------- #
600
+
601
+
602
+ @firewall_rule.command("update", "upd")
603
+ def filrewa_rule_update(
604
+ rule_id: UUID,
605
+ verbose: Optional[bool] = verbose_option,
606
+ config: Optional[Path] = config_option,
607
+ profile: Optional[str] = profile_option,
608
+ output_format: Optional[str] = output_format_option,
609
+ direction_: Optional[bool] = typer.Option(
610
+ None,
611
+ "--ingress/--egress",
612
+ help="Traffic direction.",
613
+ ),
614
+ cidr: Optional[str] = typer.Option(
615
+ None,
616
+ metavar="IP_NETWORK",
617
+ callback=validate_cidr_callback,
618
+ help="IPv4 or IPv6 CIDR.",
619
+ ),
620
+ port: Optional[str] = typer.Option(
621
+ None,
622
+ metavar="PORT[-PORT]",
623
+ help="Port or ports range e.g. 22, 2000-3000",
624
+ ),
625
+ proto: Optional[FirewallProto] = typer.Option(None, help="Protocol."),
626
+ ):
627
+ """Change firewall rule."""
628
+ client = create_client(config, profile)
629
+ group_id = get_group_id_by_rule(client, rule_id)
630
+ old_state = client.get_firewall_rule(group_id, rule_id).json()["rule"]
631
+ if direction_ is None:
632
+ direction = old_state["direction"]
633
+ elif direction is True:
634
+ direction = ("ingress",)
635
+ else:
636
+ direction = "egress"
637
+ if proto is None:
638
+ proto = old_state["protocol"]
639
+ if cidr is None:
640
+ cidr = old_state["cidr"]
641
+ payload = {
642
+ "group_id": group_id,
643
+ "rule_id": rule_id,
644
+ "direction": direction,
645
+ "port": port,
646
+ "proto": proto,
647
+ "cidr": cidr,
648
+ }
649
+ response = client.update_firewall_rule(**payload)
650
+ fmt.printer(
651
+ response,
652
+ output_format=output_format,
653
+ func=lambda response: print(response.json()["rule"]["id"]),
654
+ )