Skip to content

Commit 0e738ad

Browse files
committed
Refactor Cloudflare, Redis, and Webhook modules: improve code readability, centralize configurations, and enhance error handling. Upgrade dependencies and update Docker Redis image to Alpine.
1 parent 7c97971 commit 0e738ad

8 files changed

Lines changed: 245 additions & 341 deletions

File tree

.pylintrc

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,3 +2,4 @@
22
disable=
33
missing-module-docstring,
44
missing-class-docstring,
5+
too-few-public-methods,

compose.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ services:
1010

1111
redis:
1212
container_name: noattack_redis
13-
image: redis
13+
image: redis:alpine
1414
restart: always
1515
ports:
1616
- "6379:6379"

main.py

Lines changed: 79 additions & 93 deletions
Original file line numberDiff line numberDiff line change
@@ -1,127 +1,113 @@
11
import asyncio
2+
import logging
23
import sys
3-
import time
44

5-
import aiohttp
65
import psutil
76
import pyfiglet
87
from colorama import Fore
98

10-
from modules import cloudflare, config, webhook, redis
9+
from modules.config import get_config, ConfigNotFoundError
10+
from modules.cloudflare import Cloudflare
11+
from modules.webhook import Webhook
12+
from modules.redis import RedisCache
1113

12-
config = config.Config()
13-
cloudflare = cloudflare.Cloudflare()
14-
webhook = webhook.Webhook()
15-
redis = redis.RedisCache()
14+
logger = logging.getLogger("noattack")
1615

1716
PREFIX = f"{Fore.RED}[\033[38;5;208mNoAttack{Fore.RED}]{Fore.RESET} "
1817

1918

20-
def get_network_speed(duration=1):
21-
"""
22-
Measure network speed over a given duration.
23-
"""
24-
io_counters_start = psutil.net_io_counters()
19+
async def get_network_speed():
20+
"""Measure incoming and outgoing traffic in MB/s over 1 second."""
21+
snap1 = psutil.net_io_counters()
22+
await asyncio.sleep(1)
23+
snap2 = psutil.net_io_counters()
24+
recv = (snap2.bytes_recv - snap1.bytes_recv) / 1024 / 1024
25+
sent = (snap2.bytes_sent - snap1.bytes_sent) / 1024 / 1024
26+
return recv, sent
2527

26-
time.sleep(duration)
2728

28-
io_counters_end = psutil.net_io_counters()
29+
async def handle_zone(cloudflare, webhook, zone_id, under_attack):
30+
"""Activate or deactivate Under Attack mode for a zone if the state needs to change."""
31+
zone_name = await cloudflare.get_zone(zone_id)
32+
if zone_name is None:
33+
return
2934

30-
bytes_received = io_counters_end.bytes_recv - io_counters_start.bytes_recv
31-
bytes_sent = io_counters_end.bytes_sent - io_counters_start.bytes_sent
35+
currently_attacking = await cloudflare.get_zone_under_attack(zone_id)
36+
if currently_attacking is None:
37+
return
3238

33-
mb_received_per_sec = bytes_received / duration / 1024 / 1024
34-
mb_sent_per_sec = bytes_sent / duration / 1024 / 1024
35-
return mb_received_per_sec, mb_sent_per_sec
39+
if under_attack and not currently_attacking:
40+
logger.info("Activating Under Attack Mode for %s", zone_name)
41+
if await cloudflare.set_zone_under_attack(zone_id, True):
42+
await webhook.send("Under Attack Activated", zone_name, color=0xFF0000)
43+
44+
elif not under_attack and currently_attacking:
45+
logger.info("Deactivating Under Attack Mode for %s", zone_name)
46+
if await cloudflare.set_zone_under_attack(zone_id, False):
47+
await webhook.send("Under Attack Deactivated", zone_name, color=0x00FF00)
3648

3749

3850
async def main():
39-
"""
40-
Main function to monitor network speed and manage Cloudflare zones.
41-
"""
42-
if not config.get("CLOUDFLARE", "ZONE_IDS"):
43-
print(f"{PREFIX}No Cloudflare zones found.")
51+
"""Main monitoring loop."""
52+
config = get_config()
53+
cloudflare = Cloudflare()
54+
webhook = Webhook()
55+
redis_cache = RedisCache()
56+
57+
zone_ids = config.get("CLOUDFLARE", "ZONE_IDS")
58+
if not zone_ids:
59+
logger.critical("No zone IDs configured.")
4460
sys.exit(1)
4561

46-
if await redis.check():
47-
print(f"{PREFIX}Connected to Redis.")
48-
else:
49-
print(f"{PREFIX}Failed to connect to Redis.")
62+
if not await redis_cache.check():
63+
logger.critical("Cannot connect to Redis.")
5064
sys.exit(1)
5165

52-
while True:
53-
try:
54-
if await redis.is_under_attack():
55-
print(f"{PREFIX}Under Attack mode is active.")
56-
else:
57-
mb_received_per_sec, mb_sent_per_sec = get_network_speed()
58-
print(
59-
f"{PREFIX}Received: {mb_received_per_sec:.2f} MB/s, "
60-
f"Sent: {mb_sent_per_sec:.2f} MB/s"
61-
)
62-
63-
if mb_received_per_sec > config.get("SETTINGS", "MAX_INCOMING_TRAFFIC_MB"):
64-
print(
65-
f"{PREFIX}Incoming traffic exceeded "
66-
f"{config.get('SETTINGS', 'MAX_INCOMING_TRAFFIC_MB')} MB/s"
67-
)
68-
69-
for zone_id in config.get("CLOUDFLARE", "ZONE_IDS"):
70-
await handle_zone(zone_id, True)
71-
72-
await redis.set_under_attack()
73-
74-
else:
75-
print(f"{PREFIX}Incoming traffic is normal.")
76-
for zone_id in config.get("CLOUDFLARE", "ZONE_IDS"):
77-
await handle_zone(zone_id, False)
78-
79-
except (aiohttp.ClientError, ValueError) as e:
80-
print(f"{PREFIX}Error encountered in main loop: {e}")
66+
check_interval = config.get("SETTINGS", "CHECK_INTERVAL") or 60
67+
threshold = config.get("SETTINGS", "MAX_INCOMING_TRAFFIC_MB")
68+
logger.info("Started | interval: %ds | threshold: %s MB/s | zones: %d",
69+
check_interval, threshold, len(zone_ids))
8170

82-
await asyncio.sleep(config.get("SETTINGS", "CHECK_INTERVAL") or 60)
83-
84-
85-
async def handle_zone(zone_id, under_attack):
86-
"""
87-
Handle Cloudflare zone based on the traffic condition.
88-
"""
8971
try:
90-
zone = await cloudflare.get_zone(zone_id)
91-
zone_name = zone["result"]["name"]
92-
93-
under_attack_mode = await cloudflare.get_zone_under_attack(zone_id)
94-
if under_attack and under_attack_mode["result"]["value"] != "under_attack":
95-
print(f"{PREFIX}Activating Under Attack Mode for {zone_name}")
96-
await set_zone_under_attack(zone_id, zone_name, True)
97-
elif not under_attack and under_attack_mode["result"]["value"] != "essentially_off":
98-
print(f"{PREFIX}Deactivating Under Attack Mode for {zone_name}")
99-
await set_zone_under_attack(zone_id, zone_name, False)
100-
101-
except (aiohttp.ClientError, ValueError) as e:
102-
print(f"{PREFIX}Error handling Cloudflare zone {zone_id}: {e}")
72+
while True:
73+
try:
74+
if await redis_cache.is_under_attack():
75+
remaining = await redis_cache.ttl()
76+
logger.info("Cooldown active, %ds remaining", remaining)
77+
else:
78+
recv, sent = await get_network_speed()
79+
logger.info("Traffic – recv: %.2f MB/s sent: %.2f MB/s", recv, sent)
10380

81+
if recv > threshold:
82+
logger.warning("Traffic %.2f MB/s exceeded threshold %s MB/s",
83+
recv, threshold)
84+
for zone_id in zone_ids:
85+
await handle_zone(cloudflare, webhook, zone_id, True)
86+
await redis_cache.set_under_attack()
87+
else:
88+
for zone_id in zone_ids:
89+
await handle_zone(cloudflare, webhook, zone_id, False)
10490

105-
async def set_zone_under_attack(zone_id, zone_name, under_attack):
106-
"""
107-
Set the Cloudflare zone under attack mode.
108-
"""
109-
if config.get("SETTINGS", "LOGGING") and config.get("SETTINGS", "WEBHOOK"):
110-
await webhook.send(
111-
message=f"{'Activating' if under_attack else 'Deactivating'} "
112-
f"Under Attack Mode for {zone_name}",
113-
color=0xFF0000 if under_attack else 0x00FF00
114-
)
91+
except Exception as exc: # pylint: disable=broad-except
92+
logger.error("Error in main loop: %s", exc)
11593

116-
await cloudflare.set_zone_under_attack(zone_id, under_attack)
94+
await asyncio.sleep(max(0, check_interval - 1))
95+
finally:
96+
await cloudflare.close()
11797

11898

11999
if __name__ == "__main__":
120-
print("\033[38;5;208m" + pyfiglet.figlet_format('NoAttack', font='doom') + Fore.RESET)
100+
print("\033[38;5;208m" + pyfiglet.figlet_format("NoAttack", font="doom") + Fore.RESET)
121101

122-
if not config.config_exists():
123-
config.create_config()
124-
print(f"{PREFIX}Config file created.")
102+
try:
103+
get_config()
104+
except ConfigNotFoundError as config_error:
105+
print(f"{PREFIX}{config_error}")
106+
sys.exit(1)
125107

126-
print(f"{PREFIX}Config file loaded.")
108+
logging.basicConfig(
109+
level=logging.INFO,
110+
format="[%(asctime)s] [%(levelname)s] %(name)s: %(message)s",
111+
datefmt="%Y-%m-%d %H:%M:%S",
112+
)
127113
asyncio.run(main())

modules/cloudflare.py

Lines changed: 52 additions & 70 deletions
Original file line numberDiff line numberDiff line change
@@ -1,88 +1,70 @@
1+
import logging
2+
13
import aiohttp
24

3-
from modules import config
5+
from modules.config import get_config
6+
7+
logger = logging.getLogger("noattack.cloudflare")
48

59

610
class Cloudflare:
711
def __init__(self):
8-
self.config = config.Config()
12+
self._cfg = get_config()
13+
self._session = None
914

10-
async def get_header(self) -> dict:
11-
"""
12-
Get headers for Cloudflare API.
13-
:return: A dictionary containing the headers.
14-
"""
15+
def _headers(self):
16+
"""Build the Cloudflare API auth headers."""
1517
return {
16-
"Authorization": f"Bearer {self.config.get('CLOUDFLARE', 'API_KEY')}",
17-
"Content-Type": "application/json"
18+
"Authorization": f"Bearer {self._cfg.get('CLOUDFLARE', 'API_KEY')}",
19+
"Content-Type": "application/json",
1820
}
1921

20-
async def get_zone(self, zone_id: str) -> dict:
21-
"""
22-
Get a zone from Cloudflare.
23-
:param zone_id: The ID of the zone.
24-
:return: A dictionary containing the zone information.
25-
"""
26-
url = f"https://api.cloudflare.com/client/v4/zones/{zone_id}"
22+
async def _get_session(self):
23+
"""Return the shared aiohttp session, creating it if needed."""
24+
if self._session is None or self._session.closed:
25+
self._session = aiohttp.ClientSession()
26+
return self._session
2727

28-
try:
29-
async with aiohttp.ClientSession() as session:
30-
async with session.get(
31-
url=url,
32-
headers=await self.get_header()
33-
) as response:
34-
return await response.json()
28+
async def close(self):
29+
"""Close the shared HTTP session."""
30+
if self._session and not self._session.closed:
31+
await self._session.close()
3532

36-
except aiohttp.ClientConnectorError as e:
37-
raise ConnectionError(f"Connection error: {e}") from e
38-
except aiohttp.ClientResponseError as e:
39-
raise ValueError(f"Response error: {e}") from e
33+
async def get_zone(self, zone_id):
34+
"""Return the zone name for the given zone ID, or None on error."""
35+
url = f"https://api.cloudflare.com/client/v4/zones/{zone_id}"
36+
try:
37+
session = await self._get_session()
38+
async with session.get(url, headers=self._headers()) as resp:
39+
resp.raise_for_status()
40+
data = await resp.json()
41+
return data["result"]["name"]
42+
except (aiohttp.ClientError, KeyError) as e:
43+
logger.error("Failed to fetch zone %s: %s", zone_id, e)
44+
return None
4045

41-
async def get_zone_under_attack(self, zone_id: str) -> dict:
42-
"""
43-
Get the security level of a zone from Cloudflare.
44-
:param zone_id: The ID of the zone.
45-
:return: A dictionary containing the security level information.
46-
"""
46+
async def get_zone_under_attack(self, zone_id):
47+
"""Return True if the zone is in under_attack mode, False if not, None on error."""
4748
url = f"https://api.cloudflare.com/client/v4/zones/{zone_id}/settings/security_level"
48-
4949
try:
50-
async with aiohttp.ClientSession() as session:
51-
async with session.get(
52-
url=url,
53-
headers=await self.get_header()
54-
) as response:
55-
return await response.json()
50+
session = await self._get_session()
51+
async with session.get(url, headers=self._headers()) as resp:
52+
resp.raise_for_status()
53+
data = await resp.json()
54+
return data["result"]["value"] == "under_attack"
55+
except (aiohttp.ClientError, KeyError) as e:
56+
logger.error("Failed to get security level for zone %s: %s", zone_id, e)
57+
return None
5658

57-
except aiohttp.ClientConnectorError as e:
58-
raise ConnectionError(f"Connection error: {e}") from e
59-
60-
except aiohttp.ClientResponseError as e:
61-
raise ValueError(f"Response error: {e}") from e
62-
63-
async def set_zone_under_attack(self, zone_id: str, mode: bool) -> dict:
64-
"""
65-
Set a zone under attack mode.
66-
:param zone_id: The ID of the zone.
67-
:param mode: True to enable under attack mode, False to disable.
68-
:return: A dictionary containing the response from Cloudflare.
69-
"""
59+
async def set_zone_under_attack(self, zone_id, under_attack):
60+
"""Set the security level for a zone. Returns True on success."""
7061
url = f"https://api.cloudflare.com/client/v4/zones/{zone_id}/settings/security_level"
71-
data = {
72-
"value": "under_attack" if mode else "essentially_off"
73-
}
74-
62+
payload = {"value": "under_attack" if under_attack else "essentially_off"}
7563
try:
76-
async with aiohttp.ClientSession() as session:
77-
async with session.patch(
78-
url=url,
79-
headers=await self.get_header(),
80-
json=data
81-
) as response:
82-
return await response.json()
83-
84-
except aiohttp.ClientConnectorError as e:
85-
raise ConnectionError(f"Connection error: {e}") from e
86-
87-
except aiohttp.ClientResponseError as e:
88-
raise ValueError(f"Response error: {e}") from e
64+
session = await self._get_session()
65+
async with session.patch(url, headers=self._headers(), json=payload) as resp:
66+
resp.raise_for_status()
67+
return True
68+
except aiohttp.ClientError as e:
69+
logger.error("Failed to set security level for zone %s: %s", zone_id, e)
70+
return False

0 commit comments

Comments
 (0)