The Day I Stopped Asking Strangers for My IP

go dev.to

A Story of Go, systemd, and SELinux Left Enforcing

Let me tell you a story about the day I stopped asking strangers on the internet for my own IP address.

Years ago I did what everybody does. I opened a browser, typed what is my ip into a search bar, clicked the first shiny result, and got my answer. Convenient. Free. Instant. Then one afternoon I happened to have tail -f running on my firewall logs when I did it — and I watched the scans arrive quicker than you can say eenie meenie miney moe. You hit their endpoint, your address lands in their logs, and shortly after, the probes come knocking. That's not paranoia, that's just how the watering hole works.

If you're serious about your infrastructure, that lookup should happen on hardware you control. It's a minimal expense — this whole service idles at about 3 MB of RAM — and it means your address never has to volunteer itself to someone else's access logs just so you can read it back.

The first version of this I ever wrote was in PHP, running behind Apache or Nginx or Caddy depending on the year. It worked. But given that Go has the ability to do so much, why not do more? So that's exactly what I did.

Meet the binary

I'd like to introduce you to a Git repository called ip.ishere.dev. Despite looking like a domain name, it's actually a Go application that compiles into a single self-contained web server for the explicit purpose of displaying your current IPv4 and IPv6 addresses. And when the industry finally blesses us with an IPv8 someday (I'm a patient man 😏), support arrives the moment the net package in Go learns the trick.

Inside that one binary lives a Rate Limiter, a Concurrency Limiter, a CORS Handler, CSP Enforcement, and automatic HTTPS Upgrades. Every request is persisted to a SQLite3 database. No Apache. No Nginx. No reverse proxy babysitter. One process, one port pair, done.

Now, about the machine it lives on. I prefer Rocky 9 Linux when I am deploying services, and I like to have SELinux set to enforcing along with fail2ban and firewalld standing guard. A lot of tutorials would tell you to setenforce 0 right about now. We are not going to do that. That's the whole point of this article.

Step 1 — Acquire it

git clone git@github.com:andreimerlescu/ip.ishere.dev.git 
cd ip.ishere.dev
Enter fullscreen mode Exit fullscreen mode

Once we have it, you'll see that alongside the Go source there are a few files that do not belong to the binary itself:

Makefile
ip.service
run-app.sh
Enter fullscreen mode Exit fullscreen mode

These three are the deployment story. The Makefile offers the ability to run:

  • make all — Runs everything below...
  • make mac-intel — Compiles the binary for macOS Intel.
  • make mac-silicon — Compiles for macOS Silicon.
  • make linux-arm — Compiles for Linux ARM.
  • make linux — Compiles for Linux x86.
  • make clean — Removes compiled binaries from the filesystem.
  • make test — Runs the test suite against the binary.

Step 2 — Teach systemd about it

I want the full lifecycle vocabulary on this box:

sudo systemctl start ip.service
sudo systemctl stop ip.service
sudo systemctl restart ip.service
sudo systemctl status ip.service
Enter fullscreen mode Exit fullscreen mode

For that, the system needs the ip.service file installed. The contents:

[Unit]
Description=IP IsHere Service
After=network.target

[Service]
Type=simple
User=admin
Group=admin
WorkingDirectory=/home/admin
ExecStart=/home/admin/ip.ishere.dev-linux-amd64
Environment=IP_CONFIG_FILE=/home/admin/app/config.yaml
Restart=always
RestartSec=5s

[Install]
WantedBy=multi-user.target
Enter fullscreen mode Exit fullscreen mode

Before we go one line further, make a decision and commit to it: where does this binary live? The repo ships with /home/admin defaults. My production box prefers /opt/ip.ishere.dev with the binary in /usr/local/bin. Either is fine — but ExecStart, WorkingDirectory, the Environment=IP_CONFIG_FILE line, and the fallback execution line at the bottom of run-app.sh must all tell the same story. Mismatched paths are the number one reason a service like this "mysteriously" won't start, and I'd rather you hear it from me than from journalctl.

Did You Know: goini can do this rewiring for you!

When I'm programmatically deploying these kinds of applications, I wanted a means to embed .ini file interactions directly into my Bash. So I built one. It exits with code 0 on success and 1 on error, which makes it a first-class citizen inside if statements and set -e scripts — exactly the reason it exists.

go install github.com/andreimerlescu/goini@latest
Enter fullscreen mode Exit fullscreen mode

Then you can run:

goini --ini ip.service \
      --section Service \
      --key ExecStart \
      --value "/usr/local/bin/ip.ishere.dev" \
      --modify-key
Enter fullscreen mode Exit fullscreen mode
goini --ini ip.service \
      --section Service \
      --key Environment \
      --value "IP_CONFIG_FILE=/opt/ip.ishere.dev/cfg.yml" \
      --modify-key
Enter fullscreen mode Exit fullscreen mode
goini --ini ip.service \
      --section Service \
      --key WorkingDirectory \
      --value "/opt/ip.ishere.dev" \
      --modify-key
Enter fullscreen mode Exit fullscreen mode
goini --ini ip.service \
      --section Service \
      --key User \
      --value "$(whoami)" \
      --modify-key
Enter fullscreen mode Exit fullscreen mode
goini --ini ip.service \
      --section Service \
      --key Group \
      --value "$(whoami)" \
      --modify-key
Enter fullscreen mode Exit fullscreen mode

Or if you want to manually edit the file using vi or nano or something else, go for it — I think sed is pretty cool and awk can definitely do it too. 😏

Step 3 — run-app.sh, or: how to befriend SELinux instead of shooting it

Once the unit file tells the truth, run-app.sh installs and launches everything. It opens like this:

#!/bin/bash

set -e

# Colors for output
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
NC='\033[0m' # No Color

EXE=$(realpath $(find . -type f -name 'ip.ishere.dev-linux-amd64'))
APP=$(realpath $(find . -type d -name 'app'))
APP_DATA=$(realpath $(find . -type d -name 'app-data'))

service_app="ip.service"
service_file="/etc/systemd/system/${service_app}"
Enter fullscreen mode Exit fullscreen mode

This defines the executable as EXE, the application directory as APP, and the workspace as APP_DATA. Then we name the service_app (the file we just edited with goini) and its destination service_file.

Next comes check_selinux_status, so we can verify what kind of machine we're standing on before we start labeling files:

# Function to check SELinux status
function check_selinux_status() {
    if command -v getenforce >/dev/null 2>&1; then
        local selinux_status=$(getenforce 2>/dev/null || echo "Disabled")
        case "$selinux_status" in
            "Enforcing"|"Permissive")
                return 0  # SELinux is active
                ;;
            "Disabled"|*)
                return 1  # SELinux is disabled or not available
                ;;
        esac
    esac
    else
        return 1  # SELinux tools not available
    fi
}
Enter fullscreen mode Exit fullscreen mode

Notice that both Enforcing and Permissive return 0. That's deliberate. Permissive mode still labels and still logs — it just doesn't block — so we want our contexts in place either way. The function returns 0 or 1 precisely so it can be consumed as if check_selinux_status; then.

Let's make sure the workspace exists:

if ! [ -d "${APP}" ]; then
    mkdir "${APP}"
fi
if ! [ -d "${APP_DATA}" ]; then
    mkdir "${APP_DATA}"
fi
Enter fullscreen mode Exit fullscreen mode

And now the main event:

# Check if SELinux is enabled
if check_selinux_status; then
    echo -e "${GREEN}SELinux is active. Setting up SELinux contexts for ip application...${NC}"

    # Remove any existing file contexts to avoid conflicts
    echo -e "${YELLOW}Cleaning up existing file contexts...${NC}"
    sudo semanage fcontext -d "${APP}(/.*)?" 2>/dev/null || true
    sudo semanage fcontext -d "${APP_DATA}(/.*)?" 2>/dev/null || true
    sudo semanage fcontext -d "${EXE}" 2>/dev/null || true

    # Set file contexts using httpd_sys_content_t
    echo -e "${YELLOW}Setting file contexts...${NC}"
    sudo semanage fcontext -a -t bin_t "${EXE}"
    sudo semanage fcontext -a -t httpd_sys_content_t "${APP}(/.*)?"
    sudo semanage fcontext -a -t httpd_sys_rw_content_t "${APP_DATA}(/.*)?"

    # Apply the contexts
    echo -e "${YELLOW}Applying file contexts...${NC}"
    sudo restorecon -v "${EXE}"
    sudo restorecon -Rv "${APP}"
    sudo restorecon -Rv "${APP_DATA}"

    echo -e "${GREEN}SELinux context setup complete!${NC}"
else
    echo -e "${YELLOW}SELinux is disabled or not available. Skipping SELinux context setup.${NC}"
fi
Enter fullscreen mode Exit fullscreen mode

In the above block we're running semanage to declare the labels our resources should carry, and then applying them with restorecon. The -e on the echo allows the color output through ${GREEN}, ${YELLOW}, and ${NC}.

The sermon

I want to break down the SELinux stuff for you plainly, because this is where most people bail.

I have watched brilliant engineers — including folks at studios protecting billions in assets — reach for setenforce 0 the moment an AVC denial appears in their logs. They're not lazy; they're tired. SELinux answers questions nobody remembers asking, and the denials feel like the system fighting you. So they disable the guard because the guard asked a question. We don't do that here.

The mental model is simple: every file wears a name tag, and every process only trusts certain name tags. Your job when enforcing is on is not to argue with the doorman — it's to hand out the right name tags, deliberately, one binary at a time.

Execution via bin_t — this tag says "I am a legitimate executable":

# bin_t permits binary execution
sudo semanage fcontext -a \ # apply
                       -t bin_t \ # context to
                       "${EXE}" # path
Enter fullscreen mode Exit fullscreen mode

Since this application services web traffic on ports like :443 and :80 (customizable), we reuse the well-worn httpd_sys_* family of contexts for the paths it touches — the same tags the OS already understands to mean "web content lives here."

Read-Only access via httpd_sys_content_t:

# httpd_sys_content_t permits read access to path
sudo semanage fcontext -a \ # apply
                       -t httpd_sys_content_t \ # context to
                       "${APP}(/.*)?" # path
Enter fullscreen mode Exit fullscreen mode

Read-Write access via httpd_sys_rw_content_t — this is where the SQLite3 database breathes:

# httpd_sys_rw_content_t permits write access to path
sudo semanage fcontext -a \ # apply
                       -t httpd_sys_rw_content_t \ # context to
                       "${APP_DATA}(/.*)?" # path
Enter fullscreen mode Exit fullscreen mode

Declare, then apply with restorecon. That's the whole ritual. No policy modules to compile, no enforcing disabled, no shame.

Low ports without root

One more gate: ports below 1024 are privileged on Linux. The old answer was "run it as root," which is a terrible trade — all of root's power for one tiny permission. The better answer is setcap, which grants exactly one capability and nothing else:

# Set capabilities for the binary (this works regardless of SELinux status)
echo -e "${YELLOW}Setting capabilities for ip binary...${NC}"
sudo setcap cap_net_bind_service=+ep "${EXE}"

echo -e "${YELLOW}Starting application...${NC}"
Enter fullscreen mode Exit fullscreen mode

Now the admin user's binary can bind :80 and :443, and none of root's other privileges came along for the ride.

Step 4 — Let systemd take the wheel

Next we need check_systemd_service, which ensures the ip.service file is installed where systemd can see it, that systemctl exists, and that the daemon reload succeeds. Same return 0 / return 1 pattern as before:

check_systemd_service() {

    # Check if service file exists
    if [[ ! -f "$service_file" ]]; then
        if [[ -f ip.service ]]; then
            sudo cp "${service\_app}" "${service_file}"
        else
            echo -e "${YELLOW}${service_app} not found at $service\_file${NC}"
            return 1
        fi
    fi

    # Check if systemctl is available
    if ! command -v systemctl >/dev/null 2>&1; then
        echo -e "${YELLOW}systemctl command not available${NC}"
        return 1
    fi

    # Try to reload systemd daemon
    if ! sudo systemctl daemon-reload 2>/dev/null; then
        echo -e "${YELLOW}Failed to reload systemd daemon${NC}"
        return 1
    fi

    return 0
}
Enter fullscreen mode Exit fullscreen mode

Now we consume it. Once systemd is happy, the service is started, enabled, and its status printed to STDOUT. Enabled means: when your machine reboots at 3 AM, this binary comes back without you.

# Try to use systemd service first
if check_systemd_service; then
    echo -e "${GREEN}Found ${service\_app}. Starting via systemd...${NC}"

    # Start the service
    if sudo systemctl start "${service_app}"; then
        echo -e "${GREEN}Service ${service\_app} started successfully${NC}"

        # Enable the service for auto-start
        if sudo systemctl enable "${service_app}"; then
            echo -e "${GREEN}Service ${service\_app} enabled for auto-start${NC}"
        else
            echo -e "${YELLOW}Warning: Failed to enable service for auto-start${NC}"
        fi

        # Show service status
        echo -e "${GREEN}Service status:${NC}"
        sudo systemctl status "${service_app}" --no-pager
    else
        echo -e "${RED}Failed to start service via systemd. Falling back to direct execution...${NC}"
        # Fallback to direct execution
        IP_CONFIG_FILE=/opt/ip.ishere.dev/cfg.yml "${EXE}"
    fi
else
    echo -e "${YELLOW}Using direct execution method...${NC}"
    # Fallback to direct execution
    IP_CONFIG_FILE=/opt/ip.ishere.dev/cfg.yml "${EXE}"
fi
Enter fullscreen mode Exit fullscreen mode

Of course, you don't actually have to write run-app.sh — it ships with the repository. The one line you must review is that fallback execution appearing in both the if and else branches:

IP_CONFIG_FILE=/opt/ip.ishere.dev/cfg.yml "${EXE}"
Enter fullscreen mode Exit fullscreen mode

Point IP_CONFIG_FILE at your config path from Step 2. If systemd ever fails and the fallback fires with a path that doesn't exist on your box, you'll see an error — and now you know exactly why. 🤫 No more mystery.

Step 5 — The config file

But Andrei! What is inside /opt/ip.ishere.dev/cfg.yml? What a wonderful question you ask!

domain: ip.ishere.dev
http: 80
https: 443
key: key.pem
cert: cert.pem
database: /home/admin/app-data/ip_requests.db
connections: 369
advanced: true
Enter fullscreen mode Exit fullscreen mode

(And yes — connections: 369. Tesla knew things.)

There are 42 additional configurables available for your consideration — the answer to life, the universe, and your config file. Some include enable_csp: true, enable_cors: true, enable_prometheus: true, enable_health: true, and enable_stats: true for route protections and observability into your binary. You can customize the endpoints themselves with endpoint_health: "/health", endpoint_stats: "/stats", endpoint_read: "/read", and endpoint_metrics: "/metrics".

Step 6 — Light it up

Once you have this all tied up, run the script and you should see:

[admin@ip ~]$ ./run-app.sh 
SELinux is active. Setting up SELinux contexts for ip application...
Cleaning up existing file contexts...
Setting file contexts...
Applying file contexts...
SELinux context setup complete!
Setting capabilities for ip binary...
Starting application...
Found ip.service. Starting via systemd...
Service ip.service started successfully
Service ip.service enabled for auto-start
Service status:
● ip.service - IP IsHere Service
     Loaded: loaded (/etc/systemd/system/ip.service; enabled; preset: disabled)
     Active: active (running) since Sat 2025-07-12 13:23:39 UTC; 226ms ago
   Main PID: 4856 (ip.ishere.dev-l)
      Tasks: 6 (limit: 4424)
     Memory: 2.9M
        CPU: 8ms
     CGroup: /system.slice/ip.service
             └─4856 /home/admin/ip.ishere.dev-linux-amd64

Jul 12 13:23:39 ip.ishere.dev systemd[1]: Started IP IsHere Service.
Jul 12 13:23:39 ip.ishere.dev ip.ishere.dev-linux-amd64[4856]: 2025/07/12 13:23:39 Starting HT…443
Jul 12 13:23:39 ip.ishere.dev ip.ishere.dev-linux-amd64[4856]: 2025/07/12 13:23:39 Starting HT…:80
Hint: Some lines were hidden, use -l to show in full.
Enter fullscreen mode Exit fullscreen mode

2.9 MB of memory. 8 ms of CPU. SELinux still enforcing. That's the mark.

Now hit the endpoint you defined as domain: in the config:

curl -s -L https://ip.ishere.dev      
Enter fullscreen mode Exit fullscreen mode
IPv4: 17.17.17.17
IPv6: 
Enter fullscreen mode Exit fullscreen mode

Pipe it like a proper Unix citizen:

curl -sL https://ip.ishere.dev | grep IPv4 | awk '{print $2}'
Enter fullscreen mode Exit fullscreen mode
17.17.17.17
Enter fullscreen mode Exit fullscreen mode

Or take it as JSON:

curl -s -L https://ip.ishere.dev/read.json | jq -r '.ipv4'
Enter fullscreen mode Exit fullscreen mode
17.17.17.17
Enter fullscreen mode Exit fullscreen mode

Why this matters beyond your homelab

Here's the enterprise story hiding inside this little binary. Stand it up on your internal network, point your internal DNS at it — imagine an ip.cisco.com that resolves inside the walls — and voilà: any machine in the fleet can ask for its own 10.x.x.x address plus its WAN, which tells you exactly which root-level gateway that machine egressed through. When you're juggling cloud resources across closed networks, that answer, obtained without touching a public endpoint, is worth its weight.

And if you've used services like What's My IP? before — you already know why keeping this lookup in-house keeps your instances free from the instant scans that follow.

Conclusion

I had an old version of this written in PHP that ran behind Apache or Nginx or Caddy — but given that Go can do so much, why not do more? One binary now carries the rate limiting, the concurrency limits, CORS, CSP, HTTPS upgrades, and SQLite persistence that used to take a whole stack.

Best part: it's entirely free and open source, including the SELinux context installation and the systemd service for Rocky 9 Linux. This binary is one of many in my open source collection, an ecosystem I document in public — production-quality binaries following strong security practices, written down for myself and for you.

I welcome security audits of the binary, testing, and expansion of its functionality.

So tell me: is your fleet running SELinux enforcing, or has somebody quietly setenforce 0'd it when you weren't looking? Drop it in the comments — I'll respond to every single one.

Source: dev.to

arrow_back Back to Tutorials