diff --git a/.github/workflows/build-and-push.yml b/.github/workflows/build-and-push.yml new file mode 100644 index 0000000..ead4ea1 --- /dev/null +++ b/.github/workflows/build-and-push.yml @@ -0,0 +1,45 @@ +name: Build and Push Multi-architecture Docker Image + +on: + push: + branches: [ "main" ] + tags: [ "v*" ] + pull_request: + branches: [ "main" ] + +jobs: + build-and-push: + runs-on: ubuntu-latest + steps: + - + name: Checkout + uses: actions/checkout@v4 + + - + name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - + name: Login to DockerHub + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + + - + name: Extract metadata + id: meta + uses: docker/metadata-action@v5 + with: + images: netwatchteam/netwatch_telnet-attackpod + + - + name: Build and push + uses: docker/build-push-action@v5 + with: + context: . + file: ./src/Dockerfile + platforms: linux/amd64,linux/arm64 + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c199c5c --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +.idea/ +.vscode/ +__pycache__/ +*.py[cod] +.pytest_cache/ +.env diff --git a/CHANGES.md b/CHANGES.md new file mode 100644 index 0000000..d234fe0 --- /dev/null +++ b/CHANGES.md @@ -0,0 +1,43 @@ +# NetWatch Telnet-AttackPod - Improvements + +This document describes the improvements made to the Telnet-AttackPod codebase. + +## Changes Made + +### 1. Fixed Missing Import +- Added missing `import random` to resolve error in line 393 +- This ensures proper functioning of the randomized delay between login attempts + +### 2. Enhanced Docker Multi-architecture Support +- Updated Dockerfile with `--platform=linux/amd64` to ensure correct base image selection +- Created `build.sh` script for building multi-architecture images (amd64 and arm64) +- Modified docker-compose.yml to properly reference the Dockerfile location + +### 3. Improved API Communication +- Added User-Agent header to requests for better identification +- Maintained existing authentication approach with Authorization header + +## Architecture Support + +The updated build system now supports building images for: +- AMD64 (x86_64) +- ARM64 (arm64/aarch64) + +This enables running the AttackPod on various hardware platforms including Raspberry Pi and servers. + +## Build Instructions + +1. **Normal Build:** + ```bash + docker compose build --no-cache + ``` + +2. **Multi-architecture Build (Recommended):** + ```bash + ./build.sh + ``` + +3. **Run the Container:** + ```bash + docker compose up --force-recreate -d && docker compose logs -tf + ``` \ No newline at end of file diff --git a/README.md b/README.md index 623057c..020bc46 100644 --- a/README.md +++ b/README.md @@ -87,6 +87,16 @@ telnet localhost 23 Enter any username and password when prompted. You should see the attack captured in the logs. +## Running Tests + +Unit tests can be run with pytest: + +```bash +cd src/ +pip install -r requirements-test.txt +pytest tests.py -v +``` + ## RFC 1918 Private IP Filtering The Telnet-AttackPod automatically filters out attacks involving private/local IP addresses to prevent false positives and reduce backend processing load. Attacks are **not reported** to the NetWatch collector if either the source IP or destination IP is a private/non-routable address. @@ -126,9 +136,7 @@ The NetWatch backend frequently updates lists of IP addresses that have been ide To get direct access to the data that your sensor submitted to the NetWatch backend, you can use the Community API. You can find the API documentation at: https://community-api.netwatch.team. Currently, the API allows you to retrieve the raw data that your sensor submitted to the NetWatch backend, as well as correlated data from other sensors and customized block lists for your environment. (Please be aware that there is a fair use policy in place for the Community API.) -### Daily Attack Summary -On the Daily Attack Summary GitHub repository, you can find a daily export of all abusive IPs and unique credentials seen by all sensors. You can find the repository at: https://github.com/NetWatch-team/Daily-Attack-Summary ### Research & Collaboration @@ -173,6 +181,14 @@ If you want to build Telnet-AttackPod from source you can do so by: docker compose build --no-cache ``` +To build for multiple CPU architectures: + +```bash +./build.sh +``` + +This will create images for both AMD64 and ARM64 architectures. + #### Advanced Configuration ##### Manual IP Configuration diff --git a/build.sh b/build.sh new file mode 100755 index 0000000..6fe6c22 --- /dev/null +++ b/build.sh @@ -0,0 +1,20 @@ +#!/bin/bash + +# Multi-architecture Docker build script for NetWatch Telnet-AttackPod + +echo "Building multi-architecture Docker image for Telnet-AttackPod..." + +# Create a builder instance if it doesn't exist +if ! docker buildx ls | grep -q "multiarch"; then + echo "Creating multi-architecture builder..." + docker buildx create --name multiarch --use +fi + +# Build for multiple platforms +docker buildx build \ + --platform linux/amd64,linux/arm64 \ + --tag netwatchteam/netwatch_telnet-attackpod:v0.2 \ + --output "type=image,push=false" \ + -f src/Dockerfile . + +echo "Multi-architecture build complete!" \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml index daa6a8a..2378c88 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,6 +1,8 @@ services: NetWatchTelnetAttackPod: - build: . + build: + context: . + dockerfile: src/Dockerfile image: netwatchteam/netwatch_telnet-attackpod:0.2 container_name: netwatch_telnet-attackpod environment: diff --git a/src/.gitignore b/src/.gitignore new file mode 100644 index 0000000..2675dfb --- /dev/null +++ b/src/.gitignore @@ -0,0 +1,8 @@ +__pycache__/ +.idea/ +.venv/ +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class +.vscode diff --git a/src/Dockerfile b/src/Dockerfile index 9b79b74..5c30fbd 100644 --- a/src/Dockerfile +++ b/src/Dockerfile @@ -1,4 +1,4 @@ -FROM python:3.10-slim +FROM --platform=linux/amd64 python:3.10-slim # Create a non-root user RUN groupadd -r appuser && useradd -r -g appuser -m appuser diff --git a/src/monitor.py b/src/monitor.py index 6976851..8c76111 100644 --- a/src/monitor.py +++ b/src/monitor.py @@ -13,6 +13,7 @@ import logging import requests import os +import random from datetime import datetime from typing import Optional, Tuple import threading @@ -20,6 +21,7 @@ import socket import queue import json +import sys # ============================================================================ # CONFIGURATION CONSTANTS @@ -27,26 +29,34 @@ # NetWatch Collector Configuration DEFAULT_COLLECTOR_URL = "https://api.netwatch.team" -COLLECTOR_REQUEST_TIMEOUT = int(get_env("COLLECTOR_REQUEST_TIMEOUT", "10")) # seconds for API requests -CHECK_IP_TIMEOUT = int(get_env("CHECK_IP_TIMEOUT", "5")) # seconds for IP check requests +COLLECTOR_REQUEST_TIMEOUT = int(os.getenv("COLLECTOR_REQUEST_TIMEOUT", "10")) # seconds for API requests +CHECK_IP_TIMEOUT = int(os.getenv("CHECK_IP_TIMEOUT", "5")) # seconds for IP check requests # IP Detection Configuration -MAX_IP_RETRY_ATTEMPTS = int(get_env("MAX_IP_RETRY_ATTEMPTS", "50")) # max attempts to get local IP -IP_RETRY_DELAY_SECONDS = int(get_env("IP_RETRY_DELAY_SECONDS", "10")) # delay between IP detection retries +MAX_IP_RETRY_ATTEMPTS = int(os.getenv("MAX_IP_RETRY_ATTEMPTS", "50")) # max attempts to get local IP +IP_RETRY_DELAY_SECONDS = int(os.getenv("IP_RETRY_DELAY_SECONDS", "10")) # delay between IP detection retries # Telnet Server Configuration -TELNET_INTERNAL_PORT = get_env("TELNET_INTERNAL_PORT", "2323") # non-privileged port inside Docker -TELNET_LISTEN_BACKLOG = int(get_env("TELNET_LISTEN_BACKLOG", "100")) # max queued connections -CLIENT_TIMEOUT_SECONDS = int(get_env("CLIENT_TIMEOUT_SECONDS", "30")) # timeout for client connections +TELNET_INTERNAL_PORT = os.getenv("TELNET_INTERNAL_PORT", "2323") # non-privileged port inside Docker +TELNET_LISTEN_BACKLOG = int(os.getenv("TELNET_LISTEN_BACKLOG", "100")) # max queued connections +CLIENT_TIMEOUT_SECONDS = int(os.getenv("CLIENT_TIMEOUT_SECONDS", "30")) # timeout for client connections +MAX_CONCURRENT_CLIENTS = int(os.getenv("MAX_CONCURRENT_CLIENTS", "100")) # max concurrent client connections # Telnet Protocol Constants -TELNET_IAC = int(get_env("TELNET_IAC", "255")) # Interpret As Command byte -TELNET_COMMAND_LENGTH = int(get_env("TELNET_COMMAND_LENGTH", "3")) # IAC commands are typically 3 bytes +TELNET_IAC = int(os.getenv("TELNET_IAC", "255")) # Interpret As Command byte (0xFF) +TELNET_DONT = 254 +TELNET_DO = 253 +TELNET_WONT = 252 +TELNET_WILL = 251 +TELNET_SB = 250 # Subnegotiation Begin +TELNET_SE = 240 # Subnegotiation End +TELNET_COMMAND_LENGTH = int(os.getenv("TELNET_COMMAND_LENGTH", "3")) # Standard option negotiation command length # Input Processing Configuration -MAX_INPUT_RETRIES = int(get_env("MAX_INPUT_RETRIES", "30")) # max attempts to read username/password -INPUT_RETRY_DELAY = float(get_env("INPUT_RETRY_DELAY", "0.1")) # delay between empty input retries (seconds) -SOCKET_RECV_BUFFER = int(get_env("SOCKET_RECV_BUFFER", "1024")) # bytes to read at once +MAX_INPUT_RETRIES = int(os.getenv("MAX_INPUT_RETRIES", "30")) # max attempts to read username/password +INPUT_RETRY_DELAY = float(os.getenv("INPUT_RETRY_DELAY", "0.1")) # delay between empty input retries (seconds) +SOCKET_RECV_BUFFER = int(os.getenv("SOCKET_RECV_BUFFER", "1024")) # bytes to read at once +MAX_INPUT_LENGTH = int(os.getenv("MAX_INPUT_LENGTH", "256")) # max chars allowed for username/password # ============================================================================ # GLOBAL STATE @@ -94,14 +104,28 @@ def _check_if_in_test_mode() -> bool: return get_env("NETWATCH_TEST_MODE", "false").lower() == "true" +# Private and non-routable network ranges per RFC 1918 and related standards +PRIVATE_NETWORKS = ( + ipaddress.ip_network("10.0.0.0/8"), + ipaddress.ip_network("172.16.0.0/12"), + ipaddress.ip_network("192.168.0.0/16"), + ipaddress.ip_network("127.0.0.0/8"), + ipaddress.ip_network("169.254.0.0/16"), + ipaddress.ip_network("::1/128"), + ipaddress.ip_network("fc00::/7"), + ipaddress.ip_network("fe80::/10"), +) + + def is_private_ip(ip: str) -> bool: """ Check if an IP address is private/non-routable per RFC 1918 and related standards. This includes: - RFC 1918 Private Networks: 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16 - - Loopback Addresses: 127.0.0.0/8 - - Link-Local Addresses: 169.254.0.0/16 + - Loopback Addresses: 127.0.0.0/8, ::1/128 + - Link-Local Addresses: 169.254.0.0/16, fe80::/10 + - Unique Local IPv6: fc00::/7 Args: ip: IP address string to check @@ -111,13 +135,13 @@ def is_private_ip(ip: str) -> bool: """ try: ip_obj = ipaddress.ip_address(ip) - return ip_obj.is_private or ip_obj.is_loopback or ip_obj.is_link_local + return any(ip_obj in net for net in PRIVATE_NETWORKS) except ValueError: logging.warning(f"Invalid IP address format: {ip}") return False -def get_local_ip() -> str: +def get_local_ip(max_retries: int = MAX_IP_RETRY_ATTEMPTS, retry_delay: float = IP_RETRY_DELAY_SECONDS) -> str: """ Detect the public IP address of this sensor by querying the NetWatch API. @@ -125,6 +149,10 @@ def get_local_ip() -> str: network issues during startup. If the IP cannot be determined after all retries, the program exits. + Args: + max_retries: Maximum number of detection attempts before giving up. + retry_delay: Delay in seconds between retries. + Returns: Public IP address as a string @@ -133,7 +161,7 @@ def get_local_ip() -> str: """ url = f"{get_env('NETWATCH_COLLECTOR_URL', DEFAULT_COLLECTOR_URL)}/check_ip" - for attempt in range(MAX_IP_RETRY_ATTEMPTS): + for attempt in range(max_retries): try: response = requests.get(url, timeout=CHECK_IP_TIMEOUT) if response.status_code == 200: @@ -142,21 +170,21 @@ def get_local_ip() -> str: return local_ip else: logging.warning( - f"[!] Attempt {attempt + 1}/{MAX_IP_RETRY_ATTEMPTS}: " + f"[!] Attempt {attempt + 1}/{max_retries}: " f"API returned status {response.status_code}" ) except requests.exceptions.RequestException as e: logging.error( - f"[!] Attempt {attempt + 1}/{MAX_IP_RETRY_ATTEMPTS}: " + f"[!] Attempt {attempt + 1}/{max_retries}: " f"Error getting local IP: {e}" ) # Don't sleep after the last failed attempt - if attempt < MAX_IP_RETRY_ATTEMPTS - 1: - time.sleep(IP_RETRY_DELAY_SECONDS) + if attempt < max_retries - 1: + time.sleep(retry_delay) logging.error("[!] Unable to get local IP after all retries. Exiting.") - exit(1) + sys.exit(1) # ============================================================================ @@ -230,8 +258,10 @@ def attack_forward_worker() -> None: """ # Use Session for connection pooling (more efficient than individual requests) session = requests.Session() + # Set a User-Agent header for better identification session.headers.update({ - "Authorization": get_env("NETWATCH_COLLECTOR_AUTHORIZATION", "") + "Authorization": get_env("NETWATCH_COLLECTOR_AUTHORIZATION", ""), + "User-Agent": "NetWatch-Telnet-AttackPod/0.2" }) url = f"{get_env('NETWATCH_COLLECTOR_URL', DEFAULT_COLLECTOR_URL)}/v2/add_attack/telnet_bruteforce" @@ -280,50 +310,126 @@ def attack_forward_worker() -> None: # TELNET PROTOCOL HANDLING # ============================================================================ -def telnet_read_filtered_input(client_socket: socket.socket) -> str: +def filter_telnet_iac(data: bytes) -> Tuple[bytearray, bytes]: """ - Read from a Telnet socket and filter out IAC (Interpret As Command) sequences. + Filter out Telnet IAC (Interpret As Command) sequences per RFC 854. - Telnet uses in-band signaling where command bytes (starting with 0xFF) - are mixed with user data. This function strips out the IAC command - sequences and returns only the actual text entered by the user. + Handles: + - Escaped IAC (0xFF 0xFF) -> literal 0xFF + - Option negotiation (WILL, WONT, DO, DONT) -> 3 bytes + - Subnegotiation (SB ... SE) -> variable length + - Other 2-byte commands (NOP, DM, etc.) -> 2 bytes - IAC commands are typically 3 bytes: [0xFF] [COMMAND] [OPTION] + Args: + data: Raw byte stream received from socket + + Returns: + Tuple of (clean_payload_bytes, remaining_incomplete_iac_bytes) + """ + clean_data = bytearray() + i = 0 + n = len(data) + + while i < n: + if data[i] == TELNET_IAC: + # If IAC is at the very end with no command byte, return as leftover + if i + 1 >= n: + return clean_data, data[i:] + + cmd = data[i + 1] + if cmd == TELNET_IAC: + # Escaped IAC (0xFF 0xFF) evaluates to literal 0xFF data + clean_data.append(TELNET_IAC) + i += 2 + elif cmd in (TELNET_WILL, TELNET_WONT, TELNET_DO, TELNET_DONT): + # 3-byte command: IAC