Por qué las IPs de Starlink aparecen en la ciudad equivocada (y cómo verificarlo)
El CGNAT de Starlink y el egress en la estación terrena hacen que las bases genéricas de IP ubiquen al usuario en la ciudad del PoP — a veces en el país equivocado. Así detectás tráfico satelital y tratás esa ciudad como aproximada.
Free: 1.000 req/mes · HTTPS · uso personal y no comercial
Por qué la ciudad es el PoP, no la antena
Un terminal Starlink no presenta una IP pública única en la casa del usuario. El tráfico de clientes pasa por CGNAT y sale de la red en una estación terrena / punto de presencia (PoP). La dirección que ve tu origen pertenece a ese egress, no a la antena.
Las bases genéricas de geolocalización por IP (incluida DB-IP City Lite, la capa base de Network Data Labs) mapean prefijos a una ubicación registrada u observada. En Starlink suele ser la ciudad del PoP, un hub cercano o incluso el país equivocado si el feed está desactualizado o el prefijo se movió. Un usuario rural puede aparecer en una metrópolis costera. Probá cuál es mi IP desde una conexión Starlink: la ciudad que ves es el egress de la red, no el GPS del techo.
La precisión de geolocalización de IPs de Starlink a nivel ciudad es una estimación gruesa. País y región son los niveles con los que usualmente podés actuar; la ciudad es una pista.
Qué es un geofeed RFC 8805
Un geofeed RFC 8805 es un CSV que publica el operador de la red: prefijo, país, región, ciudad (y a veces postal). Es la declaración del ISP de dónde deberían ubicarse esos prefijos — no un GPS de cada abonado.
Starlink publica uno en https://geoip.starlinkisp.net/feed.csv. Las filas describen prefijos de clientes (en general el PoP / área de servicio), así que la ciudad del geofeed igual puede quedar a cientos de kilómetros de la antena. Sigue siendo una fuente mejor que una base genérica que nunca se enteró de que el prefijo se movió.
El mismo formato RFC 8805 es el que usa Apple para publicar el egress de iCloud Private Relay. Relay y satélite son problemas distintos; en los dos la “ciudad” de la IP no es la de la persona.
Cómo aplica Network Data Labs los geofeeds
Las consultas arrancan desde DB-IP. Cuando un geofeed RFC 8805 matchea la IP, ese geofeed pisa a DB-IP en país, y en región/ciudad cuando el feed los trae. El match es por prefijo más largo. El feed de Starlink es uno de varios geofeeds de ISP (junto con un feed consolidado y feeds oficiales de proveedores).
Para la red de clientes de Starlink, network.asn 14593, el geofeed de Starlink gana sobre otras fuentes de geofeed de igual longitud de prefijo. Un prefijo más específico de cualquier fuente igual gana. No ponemos el nombre de la fuente del geofeed en el JSON público, y no reivindicamos un porcentaje de precisión de ciudad: el feed igual puede nombrar la ciudad del PoP, y usamos GeoNames para adjuntar coordenadas cuando la ciudad se resuelve.
Detectalo con una sola consulta
La geolocalización, el ASN y traits.is_satellite están en el plan Free: 1.000 requests por mes calendario UTC, 1 request por segundo, uso personal y no comercial. Las señales de riesgo (is_vpn, is_tor, is_relay, is_hosting) son solo de planes pagos.
Llamá a GET https://api.networkdatalabs.com/v0/ip/{ip} con Authorization: Bearer. Los campos que importan acá:
traits.is_satellite— true cuando el ASN de la IP está en la lista satelital. El tráfico de clientes Starlink es ASN 14593.network.asn— confirmá 14593 (red de clientes de SpaceX Starlink).location.accuracy_radius_km— radio estimado en kilómetros. Siempre presente cuando hay geo; un radio amplio significa que la ciudad es una estimación.location.confidence—high|medium|low|unknown.
Ver la API de geolocalización IP para el objeto location completo.
$ curl -s https://api.networkdatalabs.com/v0/ip/$CLIENT_IP \ -H "Authorization: Bearer YOUR_API_KEY" \ | jq '{ip, location: {country_code: .location.country_code, region: .location.region, city: .location.city, accuracy_radius_km: .location.accuracy_radius_km, confidence: .location.confidence}, network: {asn: .network.asn}, traits: {is_satellite: .traits.is_satellite}}' { "ip": "<IP de cliente Starlink>", "location": { "country_code": "US", "region": "Washington", "city": "Seattle", "accuracy_radius_km": 50.0, "confidence": "medium" }, "network": { "asn": 14593 }, "traits": { "is_satellite": true } }
El JSON de arriba es la forma de una consulta cuando la IP está en Starlink (ASN 14593) y se resolvió una ciudad de geofeed. Poné la IP del visitante que realmente ves (la de req.ip, CF-Connecting-IP o la dirección de cliente de tu balanceador — ver cuál es mi IP). La ciudad, cuando viene, es la del feed/PoP, no la de la antena. Leé siempre accuracy_radius_km y confidence antes de usarla. No hay un trait específico de Starlink: el flag público es is_satellite.
Política recomendada
| Señal | Qué significa | Acción sugerida |
|---|---|---|
is_satellite / ASN 14593 | Internet satelital (red de clientes Starlink). Hogares comunes, sitios rurales, marítimo y terminales en movimiento. | Permitir. No bloquees de una. Registralo para tratar la geo como gruesa. |
| Ciudad en una IP satelital | Suele ser la ciudad del PoP / geofeed, no el pueblo del usuario. | Tratá la ciudad como aproximada. No gates contenido, impuestos o envíos con ella. |
| País / región | El geofeed (cuando hay) pisa a DB-IP; igual no es una ubicación a nivel de calle. | Usalo para idioma, moneda y defaults legales. |
accuracy_radius_km amplio / confianza low | La ubicación es gruesa (típico cuando solo se conoce región o país). | Dejá la ciudad de lado. Si necesitás un lugar preciso (entrega, impuesto local, “cerca mío”), pedíselo al usuario. |
Node
Consultá la IP, marcá satélite y sacá la ciudad de cualquier regla dura. Fail-open para que una consulta lenta nunca devuelva 500:
// satellite-geo.js (Node 18+, global fetch)
const API = "https://api.networkdatalabs.com/v0/ip/";
const KEY = process.env.NDL_API_KEY;
const STARLINK_ASN = 14593;
const cache = new Map();
const TTL_MS = 6 * 60 * 60 * 1000;
function normalize(ip) {
return ip && ip.startsWith("::ffff:") ? ip.slice(7) : ip;
}
async function lookup(ip) {
const hit = cache.get(ip);
if (hit && hit.exp > Date.now()) return hit.data;
const res = await fetch(API + encodeURIComponent(ip), {
headers: { Authorization: `Bearer ${KEY}` },
signal: AbortSignal.timeout(800),
});
if (!res.ok) throw new Error(`lookup ${res.status}`);
const data = await res.json();
cache.set(ip, { data, exp: Date.now() + TTL_MS });
return data;
}
function geoPolicy(data) {
const t = data?.traits || {};
const loc = data?.location || {};
const net = data?.network || {};
const satellite = t.is_satellite === true || net.asn === STARLINK_ASN;
const coarse =
satellite ||
loc.confidence === "low" ||
(loc.accuracy_radius_km ?? 0) >= 200;
return {
action: "allow",
satellite,
useCity: !coarse,
country: loc.country_code,
region: loc.region,
city: loc.city,
accuracy_radius_km: loc.accuracy_radius_km,
confidence: loc.confidence,
};
}
function ipGeo() {
return async (req, res, next) => {
try {
const data = await lookup(normalize(req.ip));
req.ipGeo = geoPolicy(data);
} catch (err) {
req.ipGeo = { action: "allow", reason: "lookup_unavailable" };
}
next();
};
}
module.exports = { ipGeo };
const express = require("express");
const { ipGeo } = require("./satellite-geo");
const app = express();
app.set("trust proxy", 1); // un reverse proxy delante de Node
app.use(ipGeo());
app.get("/catalog", (req, res) => {
const geo = req.ipGeo;
// País/región para defaults del catálogo. Nunca 403 a un usuario
// Starlink porque city !== ciudad esperada.
res.json({
country: geo.country,
region: geo.region,
city: geo.useCity ? geo.city : null,
askUserLocation: geo.satellite || !geo.useCity,
});
});
Python
La misma política en un helper chico que podés llamar desde FastAPI o Flask. La key queda en el servidor, en NDL_API_KEY:
import os
import httpx
API = "https://api.networkdatalabs.com/v0/ip/"
HEADERS = {"Authorization": f"Bearer {os.environ['NDL_API_KEY']}"}
STARLINK_ASN = 14593
def geo_for_ip(ip: str) -> dict:
try:
r = httpx.get(API + ip, headers=HEADERS, timeout=1.0)
r.raise_for_status()
data = r.json()
except httpx.HTTPError:
return {"action": "allow", "reason": "lookup_unavailable"}
t = data.get("traits") or {}
loc = data.get("location") or {}
net = data.get("network") or {}
satellite = t.get("is_satellite") is True or net.get("asn") == STARLINK_ASN
coarse = satellite or loc.get("confidence") == "low" or (loc.get("accuracy_radius_km") or 0) >= 200
return {
"action": "allow",
"satellite": satellite,
"use_city": not coarse,
"country": loc.get("country_code"),
"region": loc.get("region"),
"city": loc.get("city"),
"accuracy_radius_km": loc.get("accuracy_radius_km"),
"confidence": loc.get("confidence"),
}
Preguntas frecuentes
¿Puedo bloquear el ASN 14593?
No. Ese ASN es la red de clientes de Starlink — hogares y negocios reales. Bloquearlo de una deja afuera a gente cuya única vía a internet es el satélite. Detectalo, tratá la geo como gruesa y pedí una ubicación cuando de verdad la necesites.
¿is_satellite significa que la ciudad está mal?
Significa que la IP está en un ASN satelital, así que la ciudad es, como mucho, la del PoP / geofeed. Leé accuracy_radius_km y confidence; no asumas que la ciudad es el pueblo del usuario.
¿Esto está en el plan Free?
Sí para geolocalización, ASN e is_satellite. Free es 1.000 requests por mes calendario UTC, 1 request por segundo, uso personal y no comercial. El uso comercial requiere un plan pago. Ver la API de geolocalización IP.
Relacionado
- API de geolocalización IP: país, región, ciudad, radio de precisión y confianza
- Cuál es mi IP: la IP pública y la ciudad que presenta tu conexión
- Detectar iCloud Private Relay sin bloquear a usuarios de Apple
- API Docs