diff --git a/README.md b/README.md
index 7565dc8..8594f84 100644
--- a/README.md
+++ b/README.md
@@ -7,17 +7,67 @@
use it to monitor your enterprise's ports over time
+[About](#about) •
[Installation](#installation) •
-[Examples](#examples) •
+[Example](#example) •
[Contributing](#contributing) •
[License](#license)
+## About
+
+*bronzeburner* words
+
## Installation
-TBD
+This project was authored for use with Pypy3.10 for performance reasons, but will likely run fine with any full Python implementation.
+Unfortunately, this means several useful libraries are yet incompatible (e.g., uvloop).
-## Examples
+### Requirements & Recommendations
+
+- [RustScan](https://github.com/RustScan/RustScan) (required, in $PATH)
+- [InfluxDB](https://github.com/influxdata/influxdb) (required)
+- [Grafana](https://github.com/grafana/grafana) (optional, recommended)
+- Docker (recommended)
+
+### Instructions
+
+These instructions assume you're running a Linux or macOS system. If you aren't, the instructions can easily be adapted.
+If you don't already use [pyenv](https://github.com/pyenv/pyenv), look into using it to manage your Python versions. Use it to install
+Pypy3.10 or install it manually. For macOS users, Pypy3.10 can be installed with `brew install pypy3.10`.
+
+Clone this repository with `git clone ...`. Browse to the newly created project directory with `cd bronzeburner`. Create a new virtual
+Python environment with `pypy3.10 -m venv venv` and activate it with `source venv/bin/activate`. Install bronzeburner and its dependencies
+with `pip install .`.
+
+Install Docker if you don't already use it. Create a persistent directory to store your data (i.e., `/opt/influxdb`). To run an InfluxDB instance,
+run `docker run -v /opt/influxdb:/var/lib/influxdb2 -p 8086:8086 influxdb:2.7.1-alpine`. Browse to [http://127.0.0.1:8086/](http://127.0.0.1:8086/) and
+set up your instance. Create a new API key with write access to your new org's new bucket and note it down.
+
+You're ready to run bronzeburner.
+
+```bash
+❯ bronzeburner -h
+usage: bronzeburner [-h] -a ADDRESS -u URL -o ORG -b BUCKET -t TOKEN
+
+A humble network scanner
+
+options:
+ -h, --help show this help message and exit
+ -a ADDRESS, --address ADDRESS
+ IP address or CIDR range to scan
+ -u URL, --url URL InfluxDB server URL
+ -o ORG, --org ORG InfluxDB organization
+ -b BUCKET, --bucket BUCKET
+ InfluxDB bucket
+ -t TOKEN, --token TOKEN
+ InfluxDB token
+```
+
+Decide on a target. bronzeburner accepts IPv4 addresses and CIDR ranges as address targets but can be extended to include additional options. See the
+example execution below.
+
+## Example
TBD
diff --git a/bronzeburner.png b/bronzeburner.png
index 3de026e..ca1c62e 100644
Binary files a/bronzeburner.png and b/bronzeburner.png differ
diff --git a/bronzeburner/cli.py b/bronzeburner/cli.py
index d8854bd..c7e88a4 100644
--- a/bronzeburner/cli.py
+++ b/bronzeburner/cli.py
@@ -1,16 +1,58 @@
import argparse
+import asyncio
+
+from .logs import logger
+from .scan import run_rustscan
+from .validation import validate_cidr_or_ipv4
+from bronzeburner.influx import InfluxDB
-def main():
- pass
+async def main(args: argparse.Namespace) -> None:
+ """
+ Invoked after parsing args. Checks InfluxDB state and initiates network scan.
+
+ Args:
+ args (argparse.Namespace): All the arguments passed to the command line
+
+ Returns:
+ int: Exit code from invoking rustscan
+
+ Raises:
+ SystemExit: When there is an error connecting to InfluxDB
+ """
+ if not await args.db.check_settings():
+ logger.error("There was an error connecting to InfluxDB. Check your settings!")
+ exit(1)
+ return await run_rustscan(args)
-def run():
+def run() -> None:
+ """
+ This function creates and configures an ArgumentParser object for command line input,
+ instantiates the InfluxDB class with the provided arguments, and asynchronously runs the main function.
+
+ Command Line Arguments:
+ -a, --address: IP address or CIDR range to scan. Required.
+ -u, --url: InfluxDB server URL. Required.
+ -o, --org: InfluxDB organization. Required.
+ -b, --bucket: InfluxDB bucket. Required.
+ -t, --token: InfluxDB token. Required.
+
+ Raises:
+ argparse.ArgumentError: If any of the required arguments are not provided
+ """
parser = argparse.ArgumentParser(description="A humble network scanner")
-
+ parser.add_argument(
+ "-a", "--address", help="IP address or CIDR range to scan", type=validate_cidr_or_ipv4, required=True
+ )
+ parser.add_argument("-u", "--url", help="InfluxDB server URL", type=str, required=True)
+ parser.add_argument("-o", "--org", help="InfluxDB organization", required=True)
+ parser.add_argument("-b", "--bucket", help="InfluxDB bucket", required=True)
+ parser.add_argument("-t", "--token", help="InfluxDB token", required=True)
args = parser.parse_args()
- main(args)
+ args.db = InfluxDB(args.url, args.org, args.bucket, args.token)
+ asyncio.run(main(args))
if __name__ == "__main__":
diff --git a/bronzeburner/influx.py b/bronzeburner/influx.py
new file mode 100644
index 0000000..841675d
--- /dev/null
+++ b/bronzeburner/influx.py
@@ -0,0 +1,36 @@
+from collections import namedtuple
+
+import aiohttp
+
+from .logs import logger
+
+
+class InfluxDB:
+ def __init__(self, url: str, org: str, bucket: str, token: str) -> "InfluxDB":
+ self.url = url
+ self.org = org
+ self.bucket = bucket
+ self.headers = {"Authorization": f"Token {token}", "Content-Type": "text/plain"}
+
+ async def check_settings(self) -> bool:
+ async with aiohttp.ClientSession() as session:
+ async with session.get(f"{self.url}/api/v2/orgs", headers=self.headers) as response:
+ return response.status == 200
+
+ async def insert(self, host: str, ports: namedtuple) -> None:
+ payload = "\n".join(
+ f'port_scan,host={host},port={p.port} state="{p.state}",protocol="{p.protocol}",service="{p.service}"'
+ for p in ports
+ )
+
+ async with aiohttp.ClientSession() as session:
+ async with session.post(
+ f"{self.url}/api/v2/write?org={self.org}&bucket={self.bucket}&precision=s",
+ headers=self.headers,
+ data=payload,
+ ) as response:
+ if response.status != 204:
+ logger.error(f"Failed write to InfluxDB (HTTP {response.status}): {await response.text()}")
+ return False
+ logger.info(f"Received HTTP {response.status} from InfluxDB")
+ return True
diff --git a/bronzeburner/logs.py b/bronzeburner/logs.py
new file mode 100644
index 0000000..088de0d
--- /dev/null
+++ b/bronzeburner/logs.py
@@ -0,0 +1,34 @@
+from loguru import logger
+
+logger.add(
+ "app.log",
+ format="{time:YYYY-MM-DD HH:mm:ss} | {message}",
+ level="INFO",
+ rotation="1 day",
+ retention="30 days",
+)
+
+logger.add(
+ "errors.log",
+ format="ℹ️ {time:YYYY-MM-DD HH:mm:ss} | {message}",
+ level="WARNING",
+ rotation="1 day",
+ retention="30 days",
+)
+
+logger.add(
+ "error.log",
+ format="⛔️ {time:YYYY-MM-DD HH:mm:ss} | {message}",
+ level="ERROR",
+ rotation="1 day",
+ retention="30 days",
+)
+
+
+logger.add(
+ "critical.log",
+ format="🚨 {time:YYYY-MM-DD HH:mm:ss} | {message}",
+ level="CRITICAL",
+ rotation="1 day",
+ retention="30 days",
+)
diff --git a/bronzeburner/scan.py b/bronzeburner/scan.py
new file mode 100644
index 0000000..e35c3cc
--- /dev/null
+++ b/bronzeburner/scan.py
@@ -0,0 +1,76 @@
+import argparse
+import asyncio
+import re
+from collections import namedtuple
+
+from .influx import InfluxDB
+from .logs import logger
+
+PORTS_LINE_RE = re.compile(r"Host: (\d+\.\d+\.\d+\.\d+).*Ports: (.+)$")
+PORT_ENTRY_RE = re.compile(r"(\d+)/([^/]*)/([^/]*)/([^/]*)/([^/]*)/([^/]*)/([^/]*)/")
+PortEntry = namedtuple("PortEntry", "port state protocol owner service rpc_info version")
+
+
+async def parse_output_line(db: InfluxDB, line: str) -> None:
+ """
+ This function parses a line of output from RustScan and stores port information in an InfluxDB database.
+
+ Args:
+ db (InfluxDB): The InfluxDB instance where the data will be stored.
+ line (str): A single line of output from RustScan.
+
+ Returns:
+ None
+ """
+ if match := PORTS_LINE_RE.match(line):
+ host_ip, port_findings = match.groups()
+ port_entries = PORT_ENTRY_RE.findall(port_findings)
+ parsed_ports = [PortEntry(*p) for p in port_entries]
+
+ logger.info(f"Found {len(port_entries)} ports for {host_ip}: {','.join(str(p.port) for p in parsed_ports)}")
+
+ if await db.insert(host_ip, parsed_ports):
+ logger.info(f"Successfully wrote {len(parsed_ports)} ports to InfluxDB")
+ else:
+ logger.error(f"Failed to write {len(parsed_ports)} ports to InfluxDB")
+
+
+async def run_rustscan(args: argparse.Namespace) -> int:
+ """
+ Runs rustscan with specified arguments and processes its output line by line using parse_output_line().
+
+ Args:
+ args (argparse.Namespace): Parsed command line arguments containing the address to scan
+
+ Returns:
+ int: The exit code of the rustscan process
+
+ Raises:
+ May raise various exceptions depending on the rustscan output and parsing process
+ """
+ rustscan_args = [
+ "rustscan",
+ "-t",
+ "500",
+ "-b",
+ "1500",
+ "--ulimit",
+ "5500",
+ "-a",
+ str(args.address),
+ "--",
+ "-oG",
+ "-",
+ ]
+ logger.info(f"Invoking rustscan: {' '.join(rustscan_args)}")
+
+ process = await asyncio.create_subprocess_exec(*rustscan_args, stdout=asyncio.subprocess.PIPE)
+
+ async for line in process.stdout:
+ await parse_output_line(args.db, line.decode().strip())
+
+ returncode = await process.wait()
+ if returncode != 0:
+ logger.critical(f"rustscan exited with code {returncode}")
+
+ return returncode
diff --git a/bronzeburner/validation.py b/bronzeburner/validation.py
new file mode 100644
index 0000000..1b527a7
--- /dev/null
+++ b/bronzeburner/validation.py
@@ -0,0 +1,23 @@
+import argparse
+from ipaddress import AddressValueError
+from ipaddress import IPv4Network
+
+
+def validate_cidr_or_ipv4(value: str) -> IPv4Network:
+ """
+ Validates whether a given value is a valid CIDR or IPv4 address
+ by attempting return a newly constructed IPv4Network object.
+
+ Args:
+ value (str): The string value to be validated as CIDR or IPv4 address
+
+ Returns:
+ IPv4Network: An IPv4Network instance constructed with the input value
+
+ Raises:
+ argparse.ArgumentTypeError: If the input value is not a valid CIDR or IPv4 address
+ """
+ try:
+ return IPv4Network(value)
+ except AddressValueError as err:
+ raise argparse.ArgumentTypeError(str(err))
diff --git a/pyproject.toml b/pyproject.toml
index a10883e..f50c81d 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -9,7 +9,7 @@ authors = [{ name = "Darryl Nixon", email = "git@nixon.mozmail.com" }]
description = "A humble network scanner"
requires-python = ">=3.9"
license = { text = "AGPL 3.0" }
-dependencies = ["sh>=2.0.6", "rocketry>=2.5.1"]
+dependencies = ["aiohttp>=3.8.5", "loguru>=0.7.1"]
[project.scripts]
bronzeburner = "bronzeburner.cli:run"