Documents the non-obvious failure mode where /etc/hosts generator scripts using `tailscale status --json | jq '.HostName'` get poisoned by iOS peers, which always report HostName as the literal string "localhost" because iOS doesn't expose the device name to apps. Includes the buggy and fixed jq filter (use .DNSName first label instead), a real-world Postfix outage example, and a verification checklist. Linked from troubleshooting index and SUMMARY. Discovered while diagnosing a 24h Postfix outage on majordiscord. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
116 lines
3.7 KiB
Markdown
116 lines
3.7 KiB
Markdown
---
|
|
title: iOS Tailscale Clients Report HostName="localhost" — Breaks /etc/hosts Generators
|
|
domain: troubleshooting
|
|
category: networking
|
|
tags:
|
|
- tailscale
|
|
- ios
|
|
- postfix
|
|
- etc-hosts
|
|
- jq
|
|
status: published
|
|
created: 2026-04-29
|
|
updated: 2026-04-29
|
|
---
|
|
|
|
# iOS Tailscale Clients Report HostName="localhost" — Breaks /etc/hosts Generators
|
|
|
|
## Problem
|
|
|
|
A homegrown script that builds an `/etc/hosts` block from `tailscale status --json` silently corrupted the file the moment any iOS device joined the tailnet. After the next run, services bound to `localhost` started failing.
|
|
|
|
On the affected host (`majordiscord`), Postfix refused to start with:
|
|
|
|
```
|
|
postfix: fatal: parameter inet_interfaces: no local interface found for 100.127.114.10
|
|
```
|
|
|
|
`/etc/hosts` looked fine at the top — `127.0.0.1 localhost` was still present — but inside the Tailscale-managed block:
|
|
|
|
```
|
|
# TAILSCALE_START
|
|
100.84.42.102 tttpod
|
|
100.110.197.17 majortoot
|
|
100.95.55.40 localhost <-- WRONG (this is an iPhone)
|
|
100.84.165.52 majormail
|
|
...
|
|
100.127.114.10 localhost <-- WRONG (this is an iPad)
|
|
# TAILSCALE_END
|
|
```
|
|
|
|
When Postfix resolved `localhost` (because `inet_interfaces = localhost` in `main.cf`), the **last matching entry** in `/etc/hosts` won — a Tailscale IP that doesn't exist on this host — and the daemon died on bind.
|
|
|
|
## Root Cause
|
|
|
|
The script used `.HostName` from the Tailscale JSON:
|
|
|
|
```bash
|
|
tailscale status --json \
|
|
| jq -r '.Peer[] | "\(.TailscaleIPs[0]) \(.HostName)"' \
|
|
>> "$TEMP_HOSTS"
|
|
```
|
|
|
|
iOS Tailscale clients (iPhone, iPad) **always report `HostName: "localhost"`** in the JSON. iOS doesn't expose the real device name to apps the way macOS/Linux/Windows do, so the Tailscale client falls back to the literal string `localhost`.
|
|
|
|
Inspect it directly:
|
|
|
|
```bash
|
|
$ tailscale status --json | jq '.Peer[] | select(.OS == "iOS") | {DNSName, HostName, OS}'
|
|
{
|
|
"DNSName": "iphone171.tail7f2d9.ts.net.",
|
|
"HostName": "localhost",
|
|
"OS": "iOS"
|
|
}
|
|
{
|
|
"DNSName": "ipad166.tail7f2d9.ts.net.",
|
|
"HostName": "localhost",
|
|
"OS": "iOS"
|
|
}
|
|
```
|
|
|
|
Every iOS device contributes a line `<tailscale-ip> localhost` to `/etc/hosts`, hijacking the `localhost` lookup.
|
|
|
|
## Fix
|
|
|
|
Use `.DNSName` (the unique tailnet DNS name) and take the first dotted component instead of `.HostName`:
|
|
|
|
```bash
|
|
tailscale status --json \
|
|
| jq -r '.Peer[] | "\(.TailscaleIPs[0]) \(.DNSName | rtrimstr(".") | split(".")[0])"' \
|
|
>> "$TEMP_HOSTS"
|
|
```
|
|
|
|
`DNSName` is always set, always unique, and produces clean labels like `iphone171`, `ipad166`, `majorlab`, etc.
|
|
|
|
After patching the script and re-running it:
|
|
|
|
```bash
|
|
$ bash /root/update_tailscale_hosts.sh
|
|
$ systemctl restart postfix
|
|
$ systemctl is-active postfix
|
|
active
|
|
```
|
|
|
|
## Why It's Hard to Spot
|
|
|
|
- The corruption only triggers when an iOS device is in the tailnet — so the script "worked" for months.
|
|
- `/etc/hosts` files are commonly skimmed top-down. The bogus `localhost` line is buried in the Tailscale block, well below the legitimate `127.0.0.1 localhost` line, and looks superficially like a normal Tailscale entry.
|
|
- Postfix's error message names the IP, not `localhost`, so the connection to `/etc/hosts` isn't obvious.
|
|
- `getent hosts localhost` shows the *first* match (`127.0.0.1`), not the one Postfix's resolver actually picks for `inet_interfaces` lookup.
|
|
|
|
## Verification Checklist
|
|
|
|
If you suspect this on any host using a similar generator script:
|
|
|
|
```bash
|
|
# Any non-loopback "localhost" entries are bugs
|
|
grep -nE '^[0-9]+\..* localhost\s*$' /etc/hosts
|
|
|
|
# Look at iOS peers' HostName field
|
|
tailscale status --json | jq '.Peer[] | select(.OS == "iOS") | .HostName'
|
|
```
|
|
|
|
## Related
|
|
|
|
- [[majordiscord]] — affected host (incident logged 2026-04-29)
|
|
- [[Network Overview]] — Tailscale fleet topology
|