Mapping Network Access
Using OpnSense and Grafana

Introduction

Your router connects your devices to the world—but do you really know where your network traffic is going? Every device on your network, whether it's your laptop, TV, or smart lighting, is constantly sending and receiving data. Some of these connections are expected, like accessing cloud services or streaming media, but others might raise red flags. What if your laptop is silently exfiltrating your sensitive data to a foreign server? Or an IoT device is part of a botnet attack against a major corporation? By collecting and analyzing the logs from your network router, you can visualize your network’s footprint in real-time, identify suspicious connections, and gain valuable insights into how your devices interact with the internet.

In this guide, we’ll walk through how to collect and process network traffic data from an OpnSense router using Grafana Alloy and Loki, to display an interactive map on a Grafana dashboard. Whether you’re a tech enthusiast looking to safeguard your home, or a cybersecurity professional, this setup will help you uncover the hidden patterns in your network traffic—before they become a problem.

Tools

For this setup, we’ll use the following free and open source tools:

  • OpnSense

    https://opnsense.org

    A firewall and routing platform that includes most of the features available in expensive commercial firewalls.

  • Docker

    https://www.docker.com

    A platform for running applications in a loosely isolated environments called containers. Containers are lightweight and contain everything needed to run the application without needing to rely on what's installed on the host

  • Grafana Alloy

    https://grafana.com/oss/alloy-opentelemetry-collector/

    An OpenTelemetry collector with support for metrics, logs, traces, and profiles compatible with the most popular open source ecosystems

  • Grafana Loki

    https://grafana.com/oss/loki/

    A log aggregation system designed to store and query logs from all your applications and infrastructure

  • Grafana

    https://grafana.com/oss/grafana/

    A data visualization and monitoring solution

Installation

  • OpnSense

    Installing a router/firewall into your existing network and configuring it to route traffic is beyond the scope of this guide, so we'll assume that you have a working OpnSense or pfSense firewall already in place. If you need to set this up yourself, I suggest following the guides at https://opnsense.org/users/get-started/

  • Docker

    The Grafana set of tools will be ran as Docker containers, so if you don't have Docker installed already on your system, head over to https://docs.docker.com/get-started/get-docker/ to find the installation that best suits your system.

  • Grafana Set of Applications

    With Docker installed, each of the Grafana tools can be installed with a single Docker Compose file.

    version: "3.3"
      
    services:
      alloy:
        container_name: alloy
        image: grafana/alloy:latest
        ports:
          - 12345:12345
          - 1514:1514
        command: run /etc/alloy/config.alloy --server.http.listen-addr=0.0.0.0:12345 --storage.path=/var/lib/alloy/data 
        volumes: 
         - alloy_data:/etc/alloy
          
      loki:
        container_name: loki
        image: grafana/loki:latest
        ports:
          - 3100:3100
        command: -config.file=/etc/loki/local-config.yaml
    
      grafana:
        container_name: grafana    
        image: grafana/grafana:latest
        ports:
          - 3000:3000
        volumes:
          - grafana_data:/var/lib/grafana
          
    volumes:
      alloy_config:
      grafana_config:

Configuration

Step 1: Enabling Logging in OpnSense

First, we need to configure OpnSense to log network traffic. The firewall can log connection events, which will later be collected by Grafana Alloy.

Log into your OpnSense dashboard and navigate to System > Settings > Logging.

Go to the Remote tab and click the + to add a new remote destination.

Click on the Remote tab, and create a new destination with the options below. Port 1514 matches the port exposed in the Alloy section of the Docker Compose file earlier.

Click Save, then hit 'Apply' on the Remote tab to start sending logs to Grafana Alloy.

Step 2: transforming Logs in Grafana Alloy

Before Grafana Alloy can collect the logs from OpnSense, the config.alloy file needs to be configured to receive logs, process them, and write them to Loki. Alloy works using a series of code blocks to configure different components by applying processing stages, before sending the results to the list of receivers. A stage is a tool that can parse, transform, or filter log entries. These stages are applied to each log entry in the order added to configuration file. Documentation for each of the stage blocks used can be referenced at:

https://grafana.com/docs/alloy/latest/reference/components/loki/loki.process/

The overall config file needs 3 main blocks: a listener to receive log files, a processor to modify the logs, and a write stage to send the modified logs out.
.
Create a new config.alloy file and add a listener block for the OpnSense syslog messages using port 1514. This port was exposed earlier while installing Alloy through Docker.

loki.source.syslog "listener" {
	listener {
		address = "0.0.0.0:1514"
	}
	forward_to = [loki.process.opnsense.receiver]
}

Now we need a processing block that matches the forward_to line of the listener. All of the various processesing stages will take place in this overall processing block.

loki.process "opnsense" {

}

The first processing stage is a regex block that breaks each log line into separate parts. It takes a single parameter, expression, that describes the RE2 regular expression string to be applied to the log line. Each (?P<name>re) section represents a named capture group, with the name used as the key for the matched value. Because of how Alloy syntax strings work, any backslashes must be escaped with a double backslash.

IPv4 and IPv6 has different formats, as do the TCP, UDP, and ICMP protocols in each IP version. A full description of each type of log line can be found at:  

https://github.com/opnsense/ports/blob/master/opnsense/filterlog/files/description.txt
stage.regex {
	expression = "^(?s)(?P<fw_rule>\\w*),(?P<fw_subrule>\\w*),(?P<fw_anchor>\\w*),(?P<fw_label>\\w*),(?P<fw_interface>\\w*),(?P<fw_reason>\\w*),(?P<fw_action>\\w*),(?P<fw_dir>\\w*),(?P<fw_ipversion>\\w*),(?P<fw_tos>\\w*),(?P<fw_ecn>\\w*),(?P<fw_ttl>\\w*),(?P<fw_id>\\w*),(?P<fw_pckt_flags>\\w*),(?P<fw_pckt_flags>\\w*),(?P<fw_protonum>\\w*),(?P<fw_protoname>\\w*),(?P<fw_length>\\w*),(?P<fw_src>\\d+.\\d+.\\d+.\\d+),(?P<fw_dst>\\d+.\\d+.\\d+.\\d+),(?P<fw_srcport>\\w*),(?P<fw_dstport>\\w*),(?P<fw_datalen>\\w*),(?P<fw_flags>\\w*),(?P<fw_seq>\\w*),(?P<fw_ack>\\w*),(?P<fw_window>\\w*),(?P<fw_urg>\\w*),(?P<options>.*)$"
}

The next two stages assign labels that we can filter on in our dashboard. Loki is limited 15 labels, so we can't choose everything, and we need to leave some available for the location data. Additionally, labels should only be used for items that have a limited number of options, else performance can be degraded rapidly. That means things like Action (pass/block), direction (in/out), and IP Version (4/6) make good labels, while IP addresses and port numbers are best as structured metadata, attached to a log without being indexed or included in the log line content itself.

stage.labels {
	values = {
		action = "fw_action",
		dir = "fw_dir",
		interface = "fw_interface",
		ipversion = "fw_ipversion",
		protocol = "fw_protoname",
	}
}

stage.structured_metadata{ 
	values = {
		src_ip = "fw_src",
        src_port = "fw_srcport",
		dst_ip = "fw_dst",
		dst_port = "fw_dstport",
	}
}

To get a location from the IP addresses, we'll utilize the MaxMind databases. Sign up for an account at https://www.maxmind.com and download both of the databases listed here. Be sure to select the GZIP version, as they contain the mmdb files that the item can use.

  • GeoLite2 ASN
  • GeoLite2 City

Extract the mmdd files from each GZIP and copy them to the same location as your config.alloy file. Then setup the initial geoip stage, followed by stages for labels and structured metadata.

stage.geoip {
	db             = "/etc/alloy/GeoLite2-City.mmdb"
	db_type        = "city"
	source         = "fw_src"
}

stage.labels {
	values = {
		src_location = join(
			[
            	"geoip_city_name",
				"geoip_subdivision_name",
				"geoip_country_name",
				"geoip_continent_name",
			],
			", ",
		),
		src_country  = "geoip_country_name",
	}
}
stage.structured_metadata {
	values = {
		src_zipcode = "geoip_postal_code",
		src_latitude = "geoip_location_latitude",
		src_longitude = "geoip_location_longitude",
	}
}

While location is good, from a network analysis perspective, it's also beneficial to know about the server that owns the target IP. A Windows computer reaching out to a Microsoft server? Probably okay. A smart refrigerator reaching out to a VPN provider in China? Probably not.

So, we'll do a second geoip stage using the ASN database, and capture the organization for the IP.

stage.geoip {
	db             = "/etc/alloy/GeoLite2-ASN.mmdb"
	db_type        = "asn"
	source         = "fw_src"
}

stage.labels {
    values = {
    	src_organization = "geoip_autonomous_system_organization",
    }
}

With all of the data collected for the source IP, we'll repeat it for the destination IP.

Note: It should be possible to use match stages to only look get source information for inbound IP connections and destination information for outbound IP connections, but I've been unable to get that to work consistently. Instead, we'll waste the extra cpu time searching for both sets of information even though only one set will return.

stage.geoip {
	db             = "/etc/alloy/GeoLite2-City.mmdb"
	db_type        = "city"
	source         = "fw_dst"
}

stage.labels {
	values = {
		dst_location = join(
			[
            	"geoip_city_name",
				"geoip_subdivision_name",
				"geoip_country_name",
				"geoip_continent_name",
			],
			", ",
		),
		dst_country  = "geoip_country_name",
	}
}

stage.structured_metadata {
	values = {
		dst_zipcode = "geoip_postal_code",
		dst_latitude = "geoip_location_latitude",
		dst_longitude = "geoip_location_longitude",
	}
}

stage.geoip {
	db             = "/etc/alloy/GeoLite2-ASN.mmdb"
	db_type        = "asn"
	source         = "fw_dst"
}
	
stage.labels {
	values = {
    	dst_organization = "geoip_autonomous_system_organization",
   	}
}

Lastly, add a line pointing to the writer block that will send all of our data to Loki.

forward_to = [loki.write.default.receiver]

With the processing done, the logs need to be sent somewhere. For this, we direct them to our Loki install as shown below. Just make sure to change the IP address to match that your Docker host.

loki.write "default" {
	endpoint {
		url = "http://<dockerhost_ip_address>:3100/loki/api/v1/push"
	}
}

Step 3: Aggregating Logs with Loki

Once Alloy is receiving logs, we need to store them using Loki. Loki, like Alloy, uses a configuration file to compose its settings. These first few settings below control the overall server as a whole. Copy them into a new yaml file.

auth_enabled: false

server:
  http_listen_port: 3100
  grpc_listen_port: 9096
  log_level: debug
  grpc_server_max_concurrent_streams: 1000

common:
  instance_addr: 127.0.0.1
  path_prefix: /tmp/loki
  storage:
    filesystem:
      chunks_directory: /tmp/loki/chunks
      rules_directory: /tmp/loki/rules
  replication_factor: 1
  ring:
    kvstore:
      store: inmemory

The next group of settings are more general purpose configurations. Since we're using structured metadata in our Alloy configuration, make sure that allow_structured_metadata is set to true.

query_range:
  results_cache:
    cache:
      embedded_cache:
        enabled: true
        max_size_mb: 100

limits_config:
  metric_aggregation_enabled: true
  allow_structured_metadata: true

ruler:
  alertmanager_url: http://localhost:9093

frontend:
  encoding: protobuf

analytics:
   reporting_enabled: false

Lastly, add a set of configurations to manage our specific schema. All logs after the from date will use these settings. Additionally, structured metadata was included in v13, so if you're modifying an existing configuration, make sure to change the schema version. Finally, ensure that the port listed in the loki_address matches the http_listen_port earlier, and the endpoint used in the Alloy configuration.

schema_config:
  configs:
    - from: 2020-10-24
      store: tsdb
      object_store: filesystem
      schema: v13
      index:
        prefix: index_
        period: 24h

pattern_ingester:
  enabled: true
  metric_aggregation:
    loki_address: localhost:3100

Step 4: Visualizing Data with Grafana

Now that all of the heavy work is done, we can do the fun part of creating a map to display the geographic locations of each IP connection.

In Grafana, open up the Menu and click on the Add New Datasource button near the bottom. In the list of available sources, search for Loki. You can give the source a name, or keep it the default. Add the URL to your Loki instance, and then scroll all the way to the bottom and click the Save & Test

Click on the Build a dashboard button, and we can finally start visualizing our data. For me, I want to know two things:

  • Who is reaching into my network
  • Who is being blocked

This means I need to come up with two queries. Once Loki is selected as the data source, the dropdown in the label filters will populate with the labels we created in our Alloy config file. Creating a query that filters on two labels, direction = in and action = pass, will only show inbound connections. Creating a second query that filters action = pass, will show blocked connections, regardless of direction.

In the top right corner of the screen is the option for which type of visualization is shown. It defaults to Time but, near the bottom of the list, is an option for a Geomap, which will automatically place markers onto a map for any latitude and longitude field it finds. Since we have two sets of latitudes and longitudes, we have to help the software out a little by transforming our data.

We need two transformations added to our data. The first is to extract the structured metadata from our log. Add an Extract Fields transform, set the source to Labels and toggle the option to replace all fields. Now all of the data in our log will be visible for our use, not just the regular labels. However, each of these fields are formatted as strings. The Geomap expects latitude and longitude fields to be numbers. Add a Convert Field Type transform, select each latitude and longitude field, and set them each as Number.

Now, we can set the Geomap to use these fields as our latitude and longitude source. In the Geomap configuration panel on the right, Configure the markers layer to use coordinates, then set the latitude and longitude fields appropriately.

Since each marker is partially opaque, locations that have more connections in the log appear more solid. However, it's even easier to see which locations are more active by changing the layer type to Heatmap. Just reselect the latitude and longitude fields, and leave the other settings alone, and you can get a result like below.

These maps are only displaying the data from Query A. By adding a second layer, the data from the second query can be displayed. In the map below, both blocked and allowed IP addresses are shown, with a separate color for each group.

Conlcusion

Whether you’re monitoring security threats or just curious about where your network traffic is going, mapping the IP addresses found in your router's logs instantly transforms meaningless numerical data into geographical information that provides powerful insights into your network activity and helps to shape the decisions on how best to secure it, all through the use of free and open-source software.