Post

Monitor your external IP with Docker and Gatus

Introduction

Have you ever needed to monitor your external IP address? How about if you have an IPsec VPN that you need to maintain?

Below, I will walk you through how I put together a solution that I use in my home lab. Word of caution: the method I’m using is far from the best or most recommended approach, and there are other options out there for specific use cases like DynDNS or NoIP.com, etc.

Real-World Scenario

My actual use case was for a friend of mine who will be traveling, and I have set up an IPsec VPN to a travel router. The issue is that the travel router, for whatever reason, does not support a DNS name for the IPsec endpoint. This forced me to at least want to know when my IP changed so I could provide them with an update.

Setting Up the Environment

My environment consists of a Ubiquiti UDM Pro with Quantum Fiber (CenturyLink) as my ISP, and each time my CenturyLink ONT restarts or CenturyLink sends an update, my IP changes. In fairness, this really doesn’t happen all that often, but I love to overbuild or create overkill solutions in my home lab :) I mean, why else would I have this blog or spend so much time automating/scripting everything, lol!

Back to my environment… I use Gatus for all my other monitoring within my lab, so I wanted to leverage that and its ability to email me if something changes state.

Choosing the Monitoring Tool - Gatus

I am already a big fan of Gatus, mainly because it is entirely configured based on a YAML configuration file, making it very easy to keep my configurations under source control and push through my CI/CD pipeline. Check it out here: Gatus. They also offer a paid version here: gatus.io.

If you jump over to their GitHub, and scroll down to the Quick Start Guide, you’ll find the example snippet that led me to this approach. Essentially what it does is poll (or check) every 60 seconds to see if there is a response code of 200 and whether the body of the page contains <h1>Example Domain</h1>. This is the logic I wanted for my Docker container: a quick page that displayed my current IP, so that I can check for a state change.

1
2
3
4
5
6
  - name: make-sure-header-is-rendered
    url: "https://example.org/"
    interval: 60s
    conditions:
      - "[STATUS] == 200"                          # Status must be 200
      - "[BODY] == pat(*<h1>Example Domain</h1>*)" # Body must contain the specified header

Developing the Monitoring Solution

Now let’s talk about creating my container. I needed something extremely lightweight, and I wanted to base it on Python, which I have been using more and more these days. After a bit of research, I found Flask to be suitable for my simple website. I haven’t ever written anything with Flask before; much of my experience with Python these days involves manipulating the MS Graph API for my day job, so I’m sure when someone looks at this they may wonder… did a child write this, haha :) Anyway, this was the basis of the Docker container.

Docker Setup

The Dockerfile is fairly basic, using the Python slim Docker image hosted on Docker Hub. Note, in a production environment or for general best practices, you might want to lock in a version for the base Docker image of python:slim. Check out more about the image tags here: Python Docker Hub.

I set the timezone for obvious reasons and then created a non-root user.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
# Use the slim version of the official python image
FROM python:slim

# Install tzdata package and setup MST timezone
RUN apt-get update && \
    apt-get install -y --no-install-recommends tzdata && \
    ln -fs /usr/share/zoneinfo/America/Denver /etc/localtime && \
    dpkg-reconfigure -f noninteractive tzdata && \
    apt-get clean && \
    rm -rf /var/lib/apt/lists/* 

# Set Time Zone environment variable to America/Denver
ENV TZ=America/Denver

# Set the working directory in the Docker container
WORKDIR /app

# Copy the requirements.txt file from local machine to the Docker container
COPY requirements.txt .

# Install the Python dependencies from the requirements.txt file
RUN pip install --no-cache-dir -r requirements.txt

# Copy all files from the local project directory to the Docker container
COPY . .

# Create a non-root user 'nonroot' for running the application
RUN adduser --disabled-password --gecos "" nonroot

# Switch to the nonroot user for security
USER nonroot

# Define the default command to be executed when the container starts
CMD [ "python", "./app.py" ]

Flask Application

The application, as mentioned, uses Python and Flask. It runs at boot, as you can see from this line above: CMD ["python", "./app.py"]. My Python script utilizes a Flask-based web application that’s designed to continuously monitor and display the public IP address of the server (Docker container). Every 60 seconds, it fetches the container’s public IP via the url https://api.ipify.org and updates this information on the generated website. It logs each successful IP fetch attempt, keeps track of the last successful check time, and maintains a counter of how many fetches have been executed. Additionally, it calculates and displays the container’s uptime since the script’s initiation.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
from flask import Flask
import requests
from apscheduler.schedulers.background import BackgroundScheduler
from pytz import timezone
from datetime import datetime, timedelta

app = Flask(__name__)

# Initialize global variables for tracking state across scheduled fetches
last_successful_check = None  # Time of the last successful IP fetch
current_ip = None  # Most recently fetched public IP address
run_count = 0  # Counts how many IP fetches have been attempted
start_time = datetime.now(timezone('MST'))  # Store the script start time with timezone

def fetch_public_ip():
    global last_successful_check, current_ip, run_count  # Indicates modification of global variables
    try:
        # Request to get the current public IP from 'ipify' API
        ip = requests.get('https://api.ipify.org').text
        current_ip = ip  # Update the global variable with the new IP
        last_successful_check = datetime.now(timezone('MST'))  # Update the last successful fetch time
        run_count += 1  # Increment the counter for each fetch attempt
        # Log the successful fetch and the current run count
        print(f'Successful IP check at {last_successful_check.strftime("%I:%M:%S %p %Z")}: {ip}')
        print(f'Run count: {run_count}')
    except Exception as e:
        # Output error message if the IP fetch fails
        print(f'Failed to fetch IP: {e}')

@app.route('/')
def hello_world():
    current_time = datetime.now(timezone('MST'))  # Get the current time with timezone
    uptime = current_time - start_time  # Calculate the script uptime
    # Format the uptime into a more readable string format
    uptime_str = str(timedelta(seconds=int(uptime.total_seconds())))
    
    # Provide different responses based on whether an IP has been fetched successfully
    if last_successful_check and current_ip:
        return (f'<h1>Your current public IP address is: {current_ip}</h1>'
                f'<h2>Last checked successfully at: {last_successful_check.strftime("%I:%M:%S %p %Z")}</h2>'
                f'<h2>Number of checks run: {run_count}</h2>'
                f'<h2>Uptime: {uptime_str}</h2>'
                f'<h2>Start Time: {start_time.strftime("%I:%M:%S %p %Z")}</h2>')
    else:
        return '<h1>The IP has not been checked yet.</h1>'

if __name__ == '__main__':
    scheduler = BackgroundScheduler()  # Set up a background scheduler
    scheduler.add_job(fetch_public_ip, 'interval', seconds=60)  # Schedule the IP fetch to run every 60 seconds
    scheduler.start()  # Start the scheduler
    app.run(host='0.0.0.0', port=80, use_reloader=False)  # Run the Flask app accessible over the network

Here is a screenshot of the application running:
application running

You can access the application via a web browser, on the IP and Port set when launching the Docker container.

Ansible Deployment

Below, I will also share my Ansible script used to build and deploy this container. This playbook copies the necessary files, dockerfile.j2 & app.py.j2, which must be in the same directory from where the playbook is being executed. One note on the requirements.txt: it is currently being created without specifying specific versions. This is not recommended, as when the Docker build step occurs, it will just pull the latest version, which in the future could cause incompatibilities or other issues that might prevent this script from functioning correctly. I have noted the versions that were installed when this blog post was created in the code comments below. Using the latest versions may work, but if you encounter issues, set specific versions and update based on each package maintainer’s recommendation.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
---
# The playbook configures and deploys a Flask application on a Docker container hosted on the 'dockerhost' server.
- hosts: dockerhost   # Target host where operations will be performed
  become: yes    # Gain higher privileges

  vars:   # Define variables used within the playbook
    container_name: whatismyip  # Name of the Docker container
    container_image_name: what-is-my-ip  # Name of the Docker image to be built
    path: /mnt/shared/what-is-my-ip  # Path on the server where files will be stored

  tasks:  # Tasks to be executed on the target host
  - name: Create a directory for the IP app
    # Task to create the necessary directory
    file:
      path: "{{ path }}"  # Path variable defined above
      state: directory  # Ensure the path is a directory
      mode: '0755'  # Set permissions

  - name: Add dockerfile
    # Task to create the Dockerfile from a template
    ansible.builtin.template:
      src: dockerfile.j2  # Source template file
      dest: "{{ path }}/Dockerfile"  # Destination path on the server

  - name: Add app.py
    # Task to create app.py from a template
    ansible.builtin.template:
      src: app.py.j2  # Source template file
      dest: "{{ path }}/app.py"  # Destination path on the server

  - name: Add requirements.txt
    # Task to create requirements.txt with specified content
    copy:
      dest: "{{ path }}/requirements.txt"  # Destination path on the server
      content: |  # Content of the requirements.txt file
        flask # 3.0.3
        requests # 2.31.0
        APScheduler # 3.10.4
        pytz # 2024.1
        Werkzeug # 3.0.3

  - name: Remove existing Docker container
    # Task to remove any existing container with the same name
    community.docker.docker_container:
      name: "{{ container_name }}"  # Container name variable
      state: absent  # Ensure the container is absent

  - name: Remove existing Docker image
    # Task to remove any existing image with the same name
    community.docker.docker_image:
      name: "{{ container_image_name }}"  # Image name variable
      state: absent  # Ensure the image is absent

  - name: Build Docker Image
    # Task to build a Docker image from the Dockerfile
    community.docker.docker_image:
      source: build  # Build from Dockerfile
      build:
        path: "{{ path }}"  # Path to the directory containing Dockerfile
      name: "{{ container_image_name }}"  # Name of the image
      tag: latest  # Tag the image as latest

  - name: Run Docker container
    # Task to start a Docker container from the image
    community.docker.docker_container:
      name: "{{ container_name }}"  # Name of the container
      image: "{{ container_image_name }}:latest"  # Image name with tag
      ports:
        - "8111:80"  # Port mapping from host to container
      state: started  # Ensure the container is running
      restart_policy: unless-stopped  # Auto-restart policy

Gatus configuration

Below is the code snippet of my Gatus configuration. If you need help setting up Gatus, please refer to the links above for self-hosting Gatus and using the code below. Ensure you update it based on your IP and requirements.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
endpoints:
  - name: Home - Public IP
    url: "http://192.168.x.x:8111" # This is the URL of your newly created Docker container. Update it to the correct DNS name or IP.
    interval: 60s
    conditions:
      - "[STATUS] == 200"
      - "[BODY] == pat(*<h1>Your current public IP address is: 71.33.x.x</h1>*)" # The string to check for. Update the IP address based on your current IP.
    client:
      insecure: true
      ignore-redirect: false
      timeout: 10s      
    alerts:
      - type: email
        description: "healthcheck failed"
        send-on-resolved: true   

Conclusion and Feedback

I’d love to hear in the comments section below if this helped you or how you might be monitoring your public IP and for what purpose. And as always, I’m open to feedback on my scripts and approach, always looking to improve!

This post is licensed under CC BY 4.0 by the author.