Starlink Mini 2 derrière CGNAT : joindre un site qui bouge
En bref (vérifié le 2026-11-11) : SpaceX a ouvert les précommandes sur invitation de la Starlink Mini 2, une antenne portable à batterie amovible. Elle mettra en ligne plus de caméras, de capteurs, de portables de terrain et de bureaux temporaires là où la fibre n'arrive pas. Ce qu'une offre standard ne leur donnera pas, c'est une adresse joignable depuis internet : l'IPv4 de Starlink passe par défaut par un NAT d'opérateur (CGNAT) et les connexions entrantes sont bloquées. Nous avons compté l'espace IPv4 annoncé par le réseau Starlink (990 208 adresses pour plus de 12 millions de clients), écrit un script qui dit si un hôte est derrière un CGNAT et comparé quatre façons de joindre un site qui se déplace.
Sources. La Mini 2 selon Notebookcheck (Daniel Zlatev, 2026-11-10) et DishyTech (Colby Baber, 2026-11-09), discutée sur Hacker News avec 369 points et 380 commentaires au 2026-11-11. Politique d'adressage : articles du Starlink Help Center "What IP address does Starlink provide?" et "IP Address", lus le 2026-11-11. Données de routage : RIPEstat, interrogé par notre script le 2026-11-11.
Ce qui change avec la Mini 2
| Élément | Mini 2, selon les articles du 2026-11-10 |
|---|---|
| Batterie | Amovible, 71 Wh, jusqu'à 4,5 h (chiffre de SpaceX), charge USB-C à 60 W |
| Wi-Fi | Wi-Fi 6 (la première Mini avait le Wi-Fi 5) |
| Construction | IP67, 314 x 263 x 23,1 mm, 1,6 kg avec batterie et béquille |
| Disponibilité | Précommande sur invitation pour certains clients aux États-Unis, d'autres pays ensuite |
| Prix | Non fixé : kit à 249 $ et batterie à 50 $ dans le code du site Starlink (DishyTech), un chiffre de 999 $ ailleurs, sans détail |
Aucun débit n'a été publié. Pour cet article, c'est la batterie qui compte : une antenne qui tient quatre heures sans secteur est une antenne qu'on transporte d'un site à l'autre, et cela change la façon de joindre ce qui se trouve derrière.
Le problème d'adresse : CGNAT par défaut
Les pages d'aide de Starlink décrivent deux politiques IPv4. Celle par défaut est le CGNAT : le routeur reçoit une adresse de 100.64.0.0/10, la plage partagée du RFC 6598, de nombreux clients partagent chaque adresse publique et le trafic IPv4 entrant n'est pas autorisé. Une adresse IPv4 publique n'est proposée qu'en option sur les offres Priority (les deux pages d'aide les nomment différemment : Local et Global Priority dans l'une, Priority, Mobile Priority et Maritime dans l'autre). Chaque ligne reçoit aussi un préfixe IPv6 /56 par DHCPv6-PD, et Starlink indique que l'IPv6 natif fonctionne sur tous ses routeurs, kits et offres.
Deux phrases de plus comptent pour une antenne portable. Les routeurs Wi-Fi de Starlink ne gèrent ni la redirection de ports ni les règles de pare-feu, ni en IPv4 ni en IPv6. Et l'adresse IPv4 réservée et le préfixe IPv6 survivent à un redémarrage, mais peuvent changer quand l'antenne est déplacée ou que son logiciel est mis à jour. Une antenne qui bouge, c'est une adresse qui bouge.
Pourquoi il n'y a pas d'adresse pour tous : notre comptage
Notre script a demandé à RIPEstat ce qu'annonce l'AS14593 (SPACEX-STARLINK) : 2 274 préfixes IPv4 couvrant 990 208 adresses uniques, surtout des /23 et des /24. Starlink a annoncé en juin 2026 plus de 12 millions de clients actifs (Mezha). Cela fait environ une adresse IPv4 publique pour 12 clients. À la médiane sur 90 jours d'ip4.market pour un /24, 25,25 $ par adresse (indice mis à jour le 2026-11-02, voir prix IPv4), une adresse par client coûterait environ 303 millions de dollars ; l'espace annoncé par l'AS14593 vaut environ 25 millions à ce prix. En IPv6, c'est l'inverse : 1 130 préfixes, dont 668 en /40, et un /40 contient 65 536 /56 clients, donc ces seuls /40 en contiennent environ 43,8 millions.
Limites du comptage : RIPEstat écarte les routes vues par moins de 10 pairs RIS, Starlink peut faire passer une partie du trafic par d'autres numéros d'AS, et l'espace annoncé n'est pas l'espace utilisé. Ce rapport explique tout de même pourquoi le CGNAT est le choix par défaut.
Script : cet hôte est-il derrière un CGNAT ?
cgnat_check.js liste les adresses de l'hôte et leur type (publique, RFC 1918, 100.64.0.0/10), demande à un point public quelle adresse il voit, cherche le réseau qui l'annonce et dit si l'IPv4 entrant peut fonctionner. Avec un numéro d'AS en argument, il additionne aussi l'espace annoncé par ce réseau en fusionnant les préfixes qui se chevauchent.
// cgnat_check.js - am I behind CGNAT, and can anything reach me from outside?
// Usage: bun cgnat_check.js [ASN] (Bun or Node 18+, no dependencies)
import os from 'node:os'
const v4n = (ip) => ip.split('.').reduce((a, o) => a * 256 + Number(o), 0)
const inV4 = (ip, cidr) => {
const [net, bits] = cidr.split('/')
const size = 2 ** (32 - Number(bits))
return Math.floor(v4n(ip) / size) === Math.floor(v4n(net) / size)
}
const kindV4 = (ip) =>
inV4(ip, '100.64.0.0/10') ? 'CGNAT (RFC 6598)'
: ['10.0.0.0/8', '172.16.0.0/12', '192.168.0.0/16'].some((c) => inV4(ip, c)) ? 'private (RFC 1918)'
: inV4(ip, '169.254.0.0/16') ? 'link-local'
: inV4(ip, '127.0.0.0/8') ? 'loopback' : 'public'
const kindV6 = (ip) => {
const h = parseInt(ip.split(':')[0] || '0', 16)
return (h & 0xe000) === 0x2000 ? 'global' : (h & 0xfe00) === 0xfc00 ? 'ULA' : (h & 0xffc0) === 0xfe80 ? 'link-local' : 'other'
}
async function trace(url) {
try {
const r = await fetch(url, { signal: AbortSignal.timeout(5000) })
return Object.fromEntries((await r.text()).trim().split('\n').map((l) => l.split('=')))
} catch { return null }
}
async function ripestat(call, res) { // RIPEstat sometimes answers an HTML error page: retry
for (let i = 0; i < 3; i++) {
try {
const r = await fetch(`https://stat.ripe.net/data/${call}/data.json?resource=${res}&sourceapp=cgnat-check`, { signal: AbortSignal.timeout(15000) })
return (await r.json()).data
} catch { await new Promise((ok) => setTimeout(ok, 2000)) }
}
return null
}
async function origin(ip) {
const d = await ripestat('prefix-overview', ip)
return d ? { prefix: d.resource, asn: d.asns?.[0]?.asn, holder: d.asns?.[0]?.holder } : {}
}
const local = Object.entries(os.networkInterfaces()).flatMap(([dev, list]) =>
list.filter((a) => !a.internal).map((a) => ({ dev, ip: a.address, fam: a.family === 'IPv4' || a.family === 4 ? 4 : 6 })))
console.log('Local addresses:')
for (const a of local) console.log(` ${a.dev.padEnd(8)} ${a.ip.padEnd(40)} ${a.fam === 4 ? kindV4(a.ip) : kindV6(a.ip)}`)
const t4 = await trace('https://1.1.1.1/cdn-cgi/trace')
const t6 = await trace('https://[2606:4700:4700::1111]/cdn-cgi/trace')
const pub4 = t4?.ip, pub6 = t6?.ip
console.log(`\nSeen from outside: IPv4 ${pub4 ?? 'none'}, IPv6 ${pub6 ?? 'none'}`)
if (pub4) {
const o = await origin(pub4)
console.log(` ${pub4} in ${o.prefix ?? '?'} originated by AS${o.asn ?? '?'} ${o.holder ?? ''}`)
if (o.asn === 14593) console.log(' AS14593 = SpaceX Starlink')
}
const v4local = local.filter((a) => a.fam === 4)
let verdict
if (pub4 && v4local.some((a) => a.ip === pub4)) verdict = 'IPv4: public address on this host, inbound possible (firewall permitting)'
else if (v4local.some((a) => kindV4(a.ip).startsWith('CGNAT'))) verdict = 'IPv4: CGNAT on this host, no inbound IPv4'
else if (pub4) verdict = 'IPv4: behind NAT (home router and maybe CGNAT upstream): inbound only via port forward on every NAT layer'
else verdict = 'IPv4: no IPv4 path out'
console.log(`\n${verdict}`)
if (pub6 && local.some((a) => a.ip === pub6)) console.log('IPv6: global address on this host, inbound depends on the router firewall')
else console.log('IPv6: no global IPv6 path seen')
// Optional: bun cgnat_check.js AS14593 - how much address space does a network announce?
const asArg = process.argv[2]
if (asArg) {
const ps = (await ripestat('announced-prefixes', asArg)).prefixes.map((p) => p.prefix)
const v4 = ps.filter((p) => !p.includes(':')).map((p) => { const [n, b] = p.split('/'); return { s: v4n(n), e: v4n(n) + 2 ** (32 - b) } })
.sort((a, b) => a.s - b.s || b.e - a.e)
let total = 0, end = -1
for (const p of v4) { if (p.e > end) { total += p.e - Math.max(p.s, end); end = p.e } }
const v6 = ps.filter((p) => p.includes(':'))
const by = (l) => Object.entries(l.reduce((m, p) => ((m[p.split('/')[1]] = (m[p.split('/')[1]] || 0) + 1), m), {})).sort((a, b) => a[0] - b[0]).map(([k, n]) => `/${k} x${n}`).join(', ')
console.log(`\n${asArg}: ${v4.length} IPv4 prefixes, ${total.toLocaleString('en')} unique addresses; ${v6.length} IPv6 prefixes`)
console.log(` IPv4 sizes: ${by(ps.filter((p) => !p.includes(':')))}`)
console.log(` IPv6 sizes: ${by(v6)}`)
}
bun cgnat_check.js # this host only
bun cgnat_check.js AS14593 # plus the space a network announces
Sortie, exécuté sur un serveur DCXV Cloud avec Bun 1.4.4 le 2026-11-11 (octets d'hôte masqués) :
Local addresses:
eth0 178.23.189.x public
eth0 fe80::x link-local
int0 10.10.x.x private (RFC 1918)
int0 fe80::x link-local
Seen from outside: IPv4 178.23.189.x, IPv6 none
178.23.189.x in 178.23.189.0/24 originated by AS204057 DCXV-AS DCXV INTERNATIONAL LTD
IPv4: public address on this host, inbound possible (firewall permitting)
IPv6: no global IPv6 path seen
AS14593: 2274 IPv4 prefixes, 990,208 unique addresses; 1130 IPv6 prefixes
IPv4 sizes: /20 x2, /21 x28, /22 x58, /23 x1434, /24 x752
IPv6 sizes: /36 x1, /38 x67, /40 x668, /41 x173, /42 x11, /44 x4, /45 x4, /46 x11, /47 x20, /48 x171
Notre serveur porte son adresse publique sur eth0, donc les connexions entrantes l'atteignent dès que le pare-feu les autorise ; l'IPv6 n'est tout simplement pas configuré sur cette machine. Nous n'avons pas de ligne Starlink : nous ne montrons donc aucune sortie Starlink et n'affirmons rien à son sujet. Ce que fait le script là-bas : sur un portable derrière le routeur Starlink, l'adresse locale est privée et la publique se trouve dans un préfixe de l'AS14593, il signale donc un NAT et affiche la ligne Starlink ; sur le port WAN d'un routeur tiers, il voit directement l'adresse en 100.64.0.0/10 et signale un CGNAT.
Quatre façons de joindre un site derrière Starlink
| Méthode | Entrant pour tous ? | Ce qu'il faut | Ce qui la casse |
|---|---|---|---|
| IPv6 direct vers le site | Visiteurs IPv6 seulement | Votre propre routeur (celui de Starlink n'a pas de règles de pare-feu) | Le /56 peut changer après un déplacement ; beaucoup de réseaux restent en IPv4 seul |
| Offre Priority, IPv4 publique | Oui | Une offre Priority | L'adresse peut changer après un déplacement ou une mise à jour |
| VPN maillé (Tailscale, ZeroTier) | Vos appareils seulement | Un agent sur chaque appareil | Inutilisable pour le public ou des appareils sans agent |
| Relais WireGuard sur un serveur cloud | Oui, IPv4 fixe | Un petit serveur avec IPv4 statique | Un saut de plus et une machine de plus à faire tourner |
Le relais fonctionne parce que le CGNAT autorise ce pour quoi il a été conçu : les connexions sortantes. Le site appelle le relais, et PersistentKeepalive = 25 envoie un paquet toutes les 25 secondes pour garder l'entrée NAT ouverte (le quick start de WireGuard donne 25 s comme intervalle compatible avec une grande variété de pare-feu). Les visiteurs se connectent à l'adresse du relais, qui vit dans un centre de données et ne bouge pas avec l'antenne.
Le relais : deux fichiers de configuration
Non exécuté ici : il faut root sur deux machines et un site distant. Ce sont les formes standard de wg-quick ; 192.0.2.10 est une adresse de documentation, mettez celle de votre relais à sa place.
# /etc/wireguard/wg0.conf on the cloud server (public IPv4 192.0.2.10)
[Interface]
Address = 10.99.0.1/24
ListenPort = 51820
PrivateKey = <relay-private-key>
# forward TCP 443 that arrives on the public address to the site
PostUp = sysctl -w net.ipv4.ip_forward=1
PostUp = iptables -t nat -A PREROUTING -i eth0 -p tcp --dport 443 -j DNAT --to-destination 10.99.0.2:443
PostUp = iptables -t nat -A POSTROUTING -o wg0 -j MASQUERADE
PostUp = iptables -A FORWARD -i eth0 -o wg0 -p tcp --dport 443 -j ACCEPT
PostUp = iptables -A FORWARD -i wg0 -o eth0 -m conntrack --ctstate ESTABLISHED,RELATED -j ACCEPT
PostDown = iptables -t nat -D PREROUTING -i eth0 -p tcp --dport 443 -j DNAT --to-destination 10.99.0.2:443
PostDown = iptables -t nat -D POSTROUTING -o wg0 -j MASQUERADE
PostDown = iptables -D FORWARD -i eth0 -o wg0 -p tcp --dport 443 -j ACCEPT
PostDown = iptables -D FORWARD -i wg0 -o eth0 -m conntrack --ctstate ESTABLISHED,RELATED -j ACCEPT
[Peer]
# the site behind Starlink
PublicKey = <site-public-key>
AllowedIPs = 10.99.0.2/32
# /etc/wireguard/wg0.conf at the site (behind CGNAT)
[Interface]
Address = 10.99.0.2/24
PrivateKey = <site-private-key>
[Peer]
PublicKey = <relay-public-key>
Endpoint = 192.0.2.10:51820
AllowedIPs = 10.99.0.1/32
PersistentKeepalive = 25
wg genkey | tee private.key | wg pubkey > public.key
wg-quick up wg0
Trois remarques. MASQUERADE fait arriver chaque connexion sur le site depuis 10.99.0.1, ses journaux perdent donc l'adresse du visiteur ; si vous en avez besoin, retirez cette règle et routez les réponses du site via wg0. Chaque paquet traverse désormais la liaison satellite et le relais : mesurez la latence avant d'y faire passer de la voix ou une boucle de contrôle. Et prévoyez la panne de la liaison elle-même : la panne mondiale de Starlink de juillet 2025 a duré plus de deux heures.
Taille et prix du relais
Un relais transmet des paquets et fait peu d'autre chose. Notre offre cloud Small (2 vCPU, 4 Go de RAM, 40 Go, 15,00 EUR par mois, une IPv4 statique incluse) est prévue dans notre propre liste de tâches pour WireGuard jusqu'à 25 tunnels ; la page serveur VPN couvre le même montage pour les équipes. Pour des parcs d'appareils qui ont chacun besoin d'une adresse, voir l'IoT et la pénurie d'IPv4.
Lancez un relais chez nous
Nos centres de données Tier III à Prague et Covilha hébergent des serveurs cloud, chacun avec une IPv4 statique. Lancez-en un, exécutez cgnat_check.js sur le site distant et envoyez la sortie à sales@dcxv.com : nous vous aiderons à monter le relais.
