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
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
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
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
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
Then you can run:
goini --ini ip.service \
--section Service \
--key ExecStart \
--value "/usr/local/bin/ip.ishere.dev" \
--modify-key
goini --ini ip.service \
--section Service \
--key Environment \
--value "IP_CONFIG_FILE=/opt/ip.ishere.dev/cfg.yml" \
--modify-key
goini --ini ip.service \
--section Service \
--key WorkingDirectory \
--value "/opt/ip.ishere.dev" \
--modify-key
goini --ini ip.service \
--section Service \
--key User \
--value "$(whoami)" \
--modify-key
goini --ini ip.service \
--section Service \
--key Group \
--value "$(whoami)" \
--modify-key
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}"
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
}
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
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
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
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
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
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}"
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
}
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
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}"
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
(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.
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
IPv4: 17.17.17.17
IPv6:
Pipe it like a proper Unix citizen:
curl -sL https://ip.ishere.dev | grep IPv4 | awk '{print $2}'
17.17.17.17
Or take it as JSON:
curl -s -L https://ip.ishere.dev/read.json | jq -r '.ipv4'
17.17.17.17
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.