TL;DR Tailscale zet al m’n apparaten op één WireGuard-mesh, dus ik kom vanuit een koffiebar bij het cluster in de kast zonder één geforwarde poort op de router. Subnet router, exit node, de Kubernetes-operator, ACL’s, en Headscale voor het stuk dat ik liever zelf host, in die volgorde.
Mijn homelab cluster draait in een kast thuis. Ikzelf niet. Ik werk vanuit koffiebars, vanuit het kantoor van een klant, en zo nu en dan vanuit een hotel met wifi die iets tegen me lijkt te hebben. En ik wil dan gewoon bij m’n eigen machines kunnen.
Jarenlang was het standaardantwoord dat je een gat in je eigen voordeur prikt. Poort forwarden op de router, dynamic DNS erbij zodat het wisselende thuis-IP niet alles sloopt, firewallregels schrijven, en dan maar hopen dat niemand die het internet afscant op de SSH-daemon stuit die je net hebt opengezet. Het werkt, je kunt bij je spullen. Het betekent ook dat een stuk van je thuisnetwerk de klok rond vragen van vreemden beantwoordt.
Wat ik wilde was van overal bij m’n homelab kunnen, met niks open naar het internet. Dat doet Tailscale voor me, en deze post is hoe ik het heb opgezet, van één node tot het hele cluster.
Wat Tailscale is
Tailscale is een mesh-VPN op WireGuard. Elk apparaat dat je aanmeldt krijgt een vast adres in de 100.x.y.z-range, en elk apparaat praat rechtstreeks met elk ander. Er zit geen centrale doos tussen waar al het verkeer doorheen moet, en dát is het verschil met de VPN-concentrator die je misschien nog van een kantoorbaan kent.
Werkt het netwerk mee, dan verbinden twee apparaten rechtstreeks met elkaar. Zit NAT in de weg, en dat zit het vaak, dan valt het verkeer terug op een relay via de DERP-servers van Tailscale. Daar regel je zelf niks aan. Beide paden zijn end-to-end versleuteld, dus zelfs de relay kan niet lezen wat erdoorheen gaat.
Eén trade-off voordat ik verder ga. De coördinatielaag is van Tailscale, niet van jou. Zij draaien de control servers die de keys uitdelen en apparaten vertellen waar ze elkaar vinden. Je verkeer blijft peer-to-peer en versleuteld, maar het opzetten van de verbinding leunt op een bedrijf dat je niet zelf host. Voor een blog die op soevereiniteit draait doet dat ertoe, en er is een oplossing. Die komt tegen het eind.
Waarom ik het toch gebruik
Wat me over de streep trok is wat er uit m’n routerconfig verdween. Niks geforward, niks open, geen poort voor een scanner om te vinden. De service die ik vroeger gehard moest houden tegen het hele internet bestaat gewoon niet meer.
Het komt ook door netwerken heen die anders een doodlopende weg zijn. CGNAT van een provider die je geen echt publiek IP geeft, dubbele NAT, hotelwifi die het halve internet blokkeert. Beide kanten bellen naar buiten in plaats van te wachten tot ze gebeld worden, dus de brij aan routers ertussen doet er niet meer toe. Mijn homelab is er gewoon, op m’n tailnet.
Dan de kleinere dingen. Elk apparaat houdt hetzelfde 100.x.y.z-adres, dus ik loop niet achter dynamic-DNS-records aan die achterlopen op de werkelijkheid. MagicDNS geeft elke machine een naam als homelab.tail-abc123.ts.net, dus IP’s onthouden doe ik niet meer. En met ACL’s bepaal ik wie waar bij mag, zodat een geleende laptop niet vanzelf de sleutels van het cluster krijgt.
Aan de praat krijgen
Begin met de homelab-node zelf.
# Ubuntu/Debian
curl -fsSL https://tailscale.com/install.sh | sh
# Start Tailscale
sudo tailscale up
# Check status
tailscale status
tailscale up print een inloglink. Open die, log in, en de node zit op je tailnet. Zet daarna de client op alles wat je meeneemt:
- Je laptop
- Je telefoon
- Wat er verder ook bij moet kunnen
Elk apparaat meldt zich aan bij hetzelfde Tailscale-account, en daarna zien ze elkaar. Check het vanaf je laptop:
# Vanaf laptop
ping homelab.tail-abc123.ts.net
# SSH naar homelab
ssh user@homelab.tail-abc123.ts.net
# Toegang tot services
curl http://homelab.tail-abc123.ts.net:3000 # Grafana
Geen port forwarding, geen firewallregels. De eerste keer dat ik vanuit de trein via SSH op een machine thuis inlogde, zonder iets opengezet te hebben, voelde het een beetje illegaal.
De apparaten bereiken die geen Tailscale kunnen draaien
Genoeg dingen op een thuisnetwerk gaan nooit een Tailscale-client draaien. De NAS-firmware laat het niet toe, en de printer is een printer. Een subnet router lost dat op door van één aangemelde node een doorgang naar de rest van het LAN te maken.
# Op homelab node
sudo tailscale up --advertise-routes=192.168.1.0/24
# Accepteer de route in Tailscale admin console
# Of gebruik --accept-routes op clients
Zodra je de geadverteerde route accepteert, bereikt je laptop de NAS op 192.168.1.50 via de homelab-node, en de printer op dezelfde manier.
Al je verkeer naar huis sturen
Een verwante truc, voor als je het netwerk om je heen helemaal niet vertrouwt. Maak van een node thuis een exit node en je laptop stuurt alles daardoorheen, zodat je verkeer vanaf je thuis-IP naar buiten gaat in plaats van vanaf dat van de koffiebar.
# Op homelab node
sudo tailscale up --advertise-exit-node
# Accepteer in admin console
# Op laptop (wanneer nodig)
sudo tailscale up --exit-node=homelab
Dit zet ik aan zodra ik op wifi zit die ik niet beheer, dan hoeft dat netwerk niet mee te lezen. Het werkt ook om geo-restricted dingen te bereiken alsof je thuis bent, en om je Pi-hole thuis te gebruiken waar je ook zit.
Clusterservices op het tailnet zetten
Hier wordt het leuk voor een Kubernetes-homelab. De Tailscale Kubernetes Operator publiceert services rechtstreeks op je tailnet, zodat ze op naam bereikbaar zijn zonder ingress die naar het internet wijst.
# Installeer operator
helm repo add tailscale https://pkgs.tailscale.com/helmcharts
helm repo update
helm install tailscale-operator tailscale/tailscale-operator \
--namespace tailscale \
--create-namespace \
--set oauth.clientId="<client-id>" \
--set oauth.clientSecret="<client-secret>"
Maak eerst de OAuth-client aan in de Tailscale admin console, en geef die credentials mee aan de install. Draait de operator, dan is een service blootstellen één annotatie:
apiVersion: v1
kind: Service
metadata:
name: grafana
annotations:
tailscale.com/expose: "true"
spec:
selector:
app: grafana
ports:
- port: 3000
Grafana is nu bereikbaar op grafana.tail-abc123.ts.net:3000 vanaf elk apparaat op het tailnet, en nergens anders vandaan. Wil je Tailscale liever als volwaardige ingress controller gebruiken, dan kan dat ook:
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: grafana
spec:
ingressClassName: tailscale
rules:
- host: grafana
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: grafana
port:
number: 3000
Dat geeft je grafana.tail-abc123.ts.net, met HTTPS erbij geregeld.
Bepalen wie waar bij mag
Apparaten aanmelden is de makkelijke helft. Standaard praat alles op je tailnet met alles, en dat is prima tot je een apparaat toevoegt dat je minder vertrouwt. Met ACL’s zet je het strakker.
{
"acls": [
// Admin kan alles benaderen
{
"action": "accept",
"src": ["group:admin"],
"dst": ["*:*"]
},
// Developers kunnen cluster services benaderen
{
"action": "accept",
"src": ["group:dev"],
"dst": ["tag:k8s:80,443,6443"]
},
// Iedereen kan monitoring benaderen
{
"action": "accept",
"src": ["*"],
"dst": ["tag:monitoring:3000,9090"]
}
],
"groups": {
"group:admin": ["user@example.com"],
"group:dev": ["dev1@example.com", "dev2@example.com"]
},
"tagOwners": {
"tag:k8s": ["group:admin"],
"tag:monitoring": ["group:admin"]
}
}
Je tagt apparaten in de admin console, en de ACL bepaalt waar elke tag bij mag. Een dev-box krijgt de cluster-API en de webpoorten, de monitoringstack is leesbaar voor iedereen op het tailnet, en admin krijgt de rest. Om dezelfde reden schrijf ik network policies in het cluster, een kleinere blast radius als er wél iets misgaat.
Namen en certificaten
MagicDNS geeft elk apparaat al een naam als device-name.tail-abc123.ts.net. In de admin console zet je het aan en vervang je de willekeurige tailnet-string door iets wat je wel onthoudt.
Voor TLS geeft Tailscale echte certificaten uit voor tailnet-domeinen:
tailscale cert grafana.tail-abc123.ts.net
Dat is geldige HTTPS voor een interne service zonder Let’s Encrypt of cert-manager erbij te slepen. Wil je je eigen domein ervoor, zet dan een CNAME naar de tailnet-naam en zeg Tailscale dat-ie die moet accepteren:
# In je DNS
grafana.internal.example.com CNAME grafana.tail-abc123.ts.net
Wanneer je wél iets publiek nodig hebt
Zo nu en dan wil je één ding bereikbaar hebben vanaf het open internet. Een webhook-endpoint, een link die je iemand een uurtje geeft, een snelle demo. Tailscale Funnel geeft je daar een publieke URL voor zonder aan je router te komen.
# Stel lokale poort 3000 publiek bloot
tailscale funnel 3000
Je krijgt iets terug als https://homelab.tail-abc123.ts.net/. Funnel zit buiten je ACL’s om, dus wat je funnelt is publiek in de gewone betekenis van het woord. Behandel het als een geforwarde poort, want dat is het, en funnel alleen dingen waarvan je het prima vindt dat de hele wereld eraan zit.
De control plane in eigen hand
Terug naar de trade-off van het begin. Wat me dwarszit aan de coördinatieservers van Tailscale is wat me aan elke black box dwarszit. Liggen ze plat, of verandert het bedrijf van koers, dan verandert m’n toegang mee. Voor een homelab gebouwd op bezitten wat je draait is het uit handen geven van de key-en-discovery-laag een echte concessie.
Headscale is het antwoord. Een open-source herimplementatie van de Tailscale control server die je zelf host, en de gewone Tailscale-clients praten er zonder morren mee.
# headscale deployment
apiVersion: apps/v1
kind: Deployment
metadata:
name: headscale
spec:
replicas: 1
selector:
matchLabels:
app: headscale
template:
spec:
containers:
- name: headscale
image: headscale/headscale:latest
args:
- serve
ports:
- containerPort: 8080
volumeMounts:
- name: config
mountPath: /etc/headscale
- name: data
mountPath: /var/lib/headscale
Zelfde WireGuard eronder, zelfde client-ervaring, alleen draait de coördinatie nu op iets wat jij beheert. De prijs is dat je het zelf draait en bijwerkt. Daar staat tegenover dat de mesh blijft werken op de dag dat Tailscale het bedrijf dat niet doet.
Inhaken op de rest van je tools
Een paar integraties verdienen zich elke dag terug. Tailscale kan de SSH-authenticatie afhandelen, dus je hoeft geen keys meer rond te slepen voor hosts die al op je tailnet zitten:
# Enable Tailscale SSH op server
tailscale up --ssh
# Verbind (geen keys nodig!)
ssh user@homelab.tail-abc123.ts.net
Tailscale authenticeert de verbinding met je tailnet-identiteit, dus er is geen key om rond te kopiëren. Clustertoegang werkt net zo zodra de API-server op het tailnet zit:
# ~/.kube/config
apiVersion: v1
clusters:
- cluster:
server: https://k3s.tail-abc123.ts.net:6443
name: homelab
contexts:
- context:
cluster: homelab
user: admin
name: homelab
current-context: homelab
kubectl vanuit een hotelkamer raakt het cluster alsof je ernaast zit. En om op de machine zelf te werken is één regel SSH-config genoeg voor VS Code Remote:
// .ssh/config
Host homelab
HostName homelab.tail-abc123.ts.net
User youruser
Hoe mijn setup eruitziet
Niks exotisch. Eén node doet het zware werk en m’n handvol apparaten hangt eraan.
De K3s
-node is de subnet router voor 192.168.1.0/24, dus de NAS en de rest van het LAN zijn bereikbaar. Hij is ook m’n exit node voor de dagen dat ik op wifi zit die ik liever niet vertrouw. De Tailscale Operator publiceert Grafana, ArgoCD en Forgejo op het tailnet, en een ACL houdt het geheel beperkt tot m’n eigen apparaten.
Ik check monitoring van overal. Ik heb weleens een ArgoCD-sync vanaf m’n telefoon in de trein afgetrapt. En de router heeft nog nooit één geforwarde poort gehad om dat allemaal te laten werken.
Wanneer het zich misdraagt
Een paar dingen waar ik zelf over gestruikeld ben.
“Tailscale is geblokkeerd.” Sommige bedrijfsnetwerken blokkeren het gewoon. Verplaats het naar een andere DERP-poort, of draai het over TCP/443 zodat het op gewoon HTTPS-verkeer lijkt en erdoorheen komt.
“De verbinding is traag.” Meestal is het directe pad mislukt en ga je via een DERP-relay. Check of een firewall het UDP-verkeer opvreet dat Tailscale voor directe verbindingen gebruikt, en of je NAT-type er een is dat relay afdwingt.
“Ik kan het subnet niet bereiken.” Subnet routes moeten geaccepteerd worden, niet alleen geadverteerd. Adverteer op de router-node met --advertise-routes, accepteer de route in de admin console, en zet --accept-routes aan op de clients die het nodig hebben.
Waarom dit beklijft
In plaats van het netwerk open te zetten en die opening daarna te verdedigen, zet ik niks open. Ergens bij kunnen betekent eerst aanmelden op de mesh, encryptie staat aan of ik eraan denk of niet, en toegang staat in één ACL-bestand dat ik in een minuut doorlees.
Dus het cluster blijft in de kast en ik werk waar ik toevallig zit, en van mijn kant gedraagt het zich alsof ik nooit van huis ben gegaan. Met Headscale erbij draait zelfs het opzetten van de verbindingen niet op andermans servers. De router heeft nog steeds geen enkele poort open, en dat blijft zo.
