En snabb kommentar om namnen i det här inlägget innan vi börjar: min riktiga interna domän är privat och jag vill helst inte publicera den här, så i hela artikeln använder jag example.com (och int.example.com) som ersättning. När du ser monitoring1.int.example.com kan du helt enkelt tänka dig din egen domän i stället. De publika IP-adresserna till mina VPS-servrar är medvetet utelämnade. De privata 10.x.x.x-adresserna som visas är RFC 1918-adresser och är ofarliga.

Problemet jag faktiskt ville lösa

Mitt hemmalabb körs på några VPS-servrar som är kopplade till varandra med WireGuard. Med tiden har det blivit fler servrar: två VPN-hubbar, en bastionserver som används som jump host, två övervakningsservrar med Prometheus, Loki och Grafana, två andra servrar för DNS, ett Kubernetes-kluster, GitLab... Alla dessa servrar har en privat WireGuard-adress, till exempel 10.8.0.5 (vi kan säga att det är min monitoring1-server med observability-stacken).

Det fungerar, men det fungerar inte så bra när miljön växer. Jag vill inte behöva komma ihåg att Grafana på monitoring1 finns på 10.8.0.5, medan monitoring2 (den alternativa observability-stacken) finns på 10.8.0.6, och att bastionservern är 10.8.0.3... Det jag ville ha var något enkelt och tråkigt:

  1. Anslut till VPN (mitt WireGuard).
  2. Skriv https://monitoring1.int.example.com:3000 i webbläsaren.
  3. Få ett giltigt hänglås utan någon certifikatvarning.
  4. Publika webbplatser som https://google.com fortsätter att fungera precis som tidigare (och använder min interna DNS-cache).

Det viktiga kravet bakom den här bekvämligheten är den intressanta delen. DNS är ett lager för bekvämlighet, inte något som hela säkerheten bygger på. Om min interna DNS slutar fungera helt måste jag fortfarande kunna nå varje server direkt via dess interna WireGuard-IP och reparera problemet. Lättlästa namn är till för vardagen, IP-adresser är till för katastrofåterställning, och vägen till återställning ska ha så få beroenden som möjligt. Hela lösningen nedan kommer i princip från att ta den här enda meningen på allvar. 🙂

Startpunkten: två enkla, härdade servrar

Jag hade två servrar som skulle bli DNS-servrar, kallade dns1 och dns2, men de började inte som DNS-servrar. De började som rena Linux-servrar som redan hade gått igenom min vanliga säkerhetskonfiguration:

  • en administrativ användare var skapad och mina SSH-nycklar från YubiKey var installerade (hårdvarubaserade sk-ssh-ed25519@openssh.com-nycklar);
  • SSH-inloggning som root var avstängd, lösenordsinloggning var avstängd och bara publika nycklar var tillåtna;
  • SSH gick bara att nå via WireGuard och bara genom bastionservern (ingen publik SSH);
  • UFW var aktivt, inkommande trafik blockerades som standard och allt var stängt förutom det som öppnades uttryckligen;
  • fail2ban kördes, automatiska uppdateringar och APT:s dagliga timers var aktiverade;
  • WireGuard-tunnlar till båda hubbarna var konfigurerade, och övervakningsagenter skickade metrics och loggar.

Om du vill läsa mer om detta kan du göra det här.

Det här projektet handlade alltså bara om att lägga till en intern DNS-resolver ovanpå en redan härdad grund, utan att göra säkerheten svagare.

Ett medvetet arkitekturval från början var att behandla dns1 och dns2 som två oberoende och likvärdiga instanser. Det är inte ett primary/secondary-par, det finns inga zone transfers, ingen delad runtime-state, ingen VIP och ingen load balancer framför dem. Varje server är en komplett resolver på egen hand. Klienten får helt enkelt båda adresserna och kan byta till den andra om en av dem inte fungerar. Anledningen är förstås att hålla fel isolerade. Jag ville inte lägga till en ny komponent för samordning där ett enda fel skulle kunna slå ut två annars fungerande resolvers samtidigt.

Varför Unbound?

Jag behöver bara en liten och tydligt avgränsad uppsättning privata DNS-poster, i princip: hostname -> WireGuard-IP.

För det skulle BIND, PowerDNS eller en DNS-plattform med databas vara helt onödigt avancerat. Unbound är främst en rekursiv resolver med cache, men kan också hantera en liten mängd lokal DNS-data med local-zone och local-data. Det passar exakt för mitt behov:

  • interna namn under int.example.com besvaras lokalt med ett fåtal local-data-poster;
  • allt annat, till exempel google.com, skickas vidare till betrodda publika DNS-resolvers och cachas lokalt.

Ingen dynamisk DNS, inget GUI, inga zone transfers, inget API, ingen registrering från applikationer och ingen databas. Repositoryt för configuration management är fortfarande den enda källan till sanningen, och DNS-processerna använder bara den konfigurationen när de körs.

Det viktiga för mig är konsekvensen: om dns1 eller dns2 försvinner förlorar jag ingen konfiguration. En ersättningsserver byggs upp igen från repositoryt och konfigureras med Ansible. Repositoryt är den auktoritativa källan, inte någon av DNS-servrarna.

Den enda källan till sanningen: en dictionary, en loop

Hela miljöns WireGuard-mesh är redan beskriven i en enda dictionary i Ansible-inventoryt. Varje medlem har en adress i varje hubbs subnät. Det viktiga för DNS är att jag redan har exakt den hostname -> WireGuard-IP-mappning som jag vill använda, samlad på ett ställe. Jag vill inte underhålla samma mappning en gång till i en manuellt skriven zone-fil, eftersom det då förr eller senare händer att:

  • Ansible-inventoryt säger en IP-adress;
  • DNS-konfigurationen säger en annan IP-adress;
  • ingen märker något förrän något går sönder.

Därför genereras Unbound-posterna från samma mesh-dictionary som resten av miljön redan använder. I praktiken betyder det en liten Jinja-loop i rollen som går igenom varje medlem i meshen och skapar en A-post per host. Posten pekar på hostens adress i subnätet för den hubb som alltid är igång (vpn1, 10.8.0.0/24, eftersom den hubben alltid är aktiv - vpn2 används bara som nödlösning efter ett större fel).

Grundidén är enkel:

{%- for member_name, member_data in wg_members.items() -%}
  {%- if member_name != 'laptop' -%}
    local-data: "{{ member_name }}.int.example.com. IN A {{ member_data.addresses.vpn1 }}"
  {%- endif -%}
{%- endfor -%}

Lägg till en host i mesh-dictionaryn, så skapas dess DNS-post automatiskt vid nästa apply (förutom min localhost-laptop). Det finns ingen andra lista som behöver hållas synkroniserad.

Antaganden för Unbound-konfigurationen

Ansible skriver en enda separat konfigurationsfil, /etc/unbound/unbound.conf.d/00-ansible-int-example.conf, i stället för att ändra paketets vanliga unbound.conf. Filen kontrolleras med unbound-checkconf innan den skrivs, så om den genererade konfigurationen innehåller fel avbryts processen direkt i stället för att slå ut DNS-resolvern.

Det genererade resultatet på dns1 ser ut så här (adresserna är serverns egna):

server:
  # Bind only to WireGuard addresses plus loopback, never 0.0.0.0.
  interface: 10.8.0.7
  interface: 10.9.0.7
  interface: 127.0.0.1
  ip-freebind: yes
  port: 53
  do-ip4: yes
  do-udp: yes
  do-tcp: yes
  do-ip6: no
 
  hide-identity: yes
  hide-version: yes
  harden-glue: yes
  harden-dnssec-stripped: yes
  qname-minimisation: yes
 
  # Who may query: every mesh member, in both hub subnets. Default deny:
  access-control: 0.0.0.0/0 refuse
  access-control: 127.0.0.0/8 allow
  access-control: 10.8.0.2/32 allow
  access-control: 10.8.0.3/32 allow
  # ... one line per mesh member per subnet...

  # Private namespace served locally. 'A' records only:
  local-zone: "int.example.com." static
  local-data: "bastion1.int.example.com. IN A 10.8.0.3"
  local-data: "monitoring1.int.example.com. IN A 10.8.0.5"
  local-data: "monitoring2.int.example.com. IN A 10.8.0.6"
   # ... and so on...

# Everything else goes to three trusted public resolvers:
forward-zone:
  name: "."
  forward-first: no
  forward-addr: 1.1.1.1
  forward-addr: 1.0.0.1
  forward-addr: 8.8.8.8
  forward-addr: 8.8.4.4
  forward-addr: 9.9.9.9
  forward-addr: 149.112.112.112

Bindning till specifika adresser, aldrig 0.0.0.0

Unbound lyssnar på serverns två WireGuard-adresser och på 127.0.0.1, och ingen annanstans. Den fungerar aldrig som en öppen resolver på ett publikt nätverksinterface. Att den också lyssnar på adressen i den andra hubben, alltså 10.9.0.x, är ett medvetet val som jag kommer tillbaka till i analysen av olika felsituationer.

Parameter: ip-freebind: yes

WireGuard-adresserna finns först när tunnlarna är uppe. Vid en omstart kan unbound.service starta innan wg-quick@wgN har hunnit aktivera nätverksinterfacet, och en vanlig bind() mot en adress som ännu inte finns misslyckas med EADDRNOTAVAIL. Då startar inte resolver-tjänsten korrekt. ip-freebind aktiverar Linux socket-optionen IP_FREEBIND, så att Unbound kan binda till adressen innan den faktiskt är aktiv. Det ändrar inte vilka adresser Unbound svarar på. Det gör bara startordningen mindre känslig.

Åtkomstkontroll i flera lager

Det finns tre oberoende skyddslager framför den här resolver-tjänsten, och alla tre måste sluta fungera för att den ska bli exponerad:

  1. Den binder bara till WireGuard-adresser (privata nätverksinterface).
  2. UFW öppnar 53/udp och 53/tcp bara på WireGuard-interfacen och bara för de specifika källadresser som tillhör medlemmarna i meshen.
  3. Unbounds egen access-control nekar allt som standard och tillåter bara kända medlemmar i meshen. Unbound väljer den mest specifika regeln, så raden 0.0.0.0/0 refuse skriver inte över /32-reglerna som tillåter trafik - den fungerar bara som en regel som fångar upp allt annat.

Parameter: forward-first: no

Om alla forwarders är otillgängliga försöker Unbound inte automatiskt gå vidare och fråga root-servrarna direkt. I stället misslyckas DNS-frågan för publika namn. Interna namn fungerar fortfarande, eftersom de besvaras lokalt. På så sätt är utgående DNS-trafik strikt begränsad till de konfigurerade forwarders och beteendet vid fel blir förutsägbart.

Flera upstream-leverantörer, failover baserad på RTT

Jag använder 6 publika DNS-serveradresser:

  • 2x Cloudflare (1.1.1.1 + 1.0.0.1)
  • 2x Google (8.8.8.8 + 8.8.4.4)
  • 2x Quad9 (9.9.9.9 + 149.112.112.112)

Unbound följer svarstiden för varje forwarder, väljer helst den snabbaste som fungerar och byter automatiskt till en annan om en slutar svara. Med tre oberoende leverantörer fortsätter den publika DNS-vägen att fungera så länge inte tre separata anycast-nätverk får problem samtidigt.

Mer om systemd-resolved

De flesta Linux-baserade system levereras med systemd-resolved, som lyssnar på 127.0.0.53. Jag låter den medvetet vara orörd. Unbound svarar mesh-klienterna på WireGuard-adresserna och på 127.0.0.1, medan serverns egen lokala namnuppslagning fortsätter att använda systemd-resolved127.0.0.53. Det är olika loopback-adresser, så det blir ingen konflikt på porten, och servern kan fortsätta slå upp namn för wget, curl och andra program helt oberoende av den resolver som den erbjuder till meshen.

Konfiguration - steg för steg

Rollen körs genom en separat play som bara riktar sig mot DNS-gruppen. På varje server görs följande, i den här ordningen:

  1. Installera unbound (ett vanligt APT-paket) och dnsutils. Det senare installeras så att verifierings-playbooken har tillgång till dig utan att vara beroende av något annat.
  2. Se till att katalogen för drop-in-konfigurationen finns, mest för att hålla allt tydligt och ordnat.
  3. Generera drop-in-konfigurationen från mesh-dictionaryn och kontrollera den med unbound-checkconf innan den skrivs.
  4. Aktivera och starta tjänsten unbound.

Hela rollen är medvetet liten. Här är hela task-filen, med några små ändringar för anonymisering:

- name: Ensure unbound and dnsutils are installed
  ansible.builtin.apt:
    name:
      - unbound
      - dnsutils
    state: present
    update_cache: true

- name: Ensure /etc/unbound/unbound.conf.d directory exists
  ansible.builtin.file:
    path: /etc/unbound/unbound.conf.d
    state: directory
    owner: root
    group: root
    mode: "0755"

# unbound-checkconf validates the file before it is written, so a bad
# render fails fast instead of being deployed and taking the resolver down:
- name: Configure Unbound (int.example.com local-zone + public forwarders)
  ansible.builtin.template:
    src: 00-ansible-int-example.conf.j2
    dest: /etc/unbound/unbound.conf.d/00-ansible-int-example.conf
    owner: root
    group: root
    mode: "0644"
    validate: "unbound-checkconf %s"
  notify: Restart unbound

- name: Ensure unbound service is enabled and started
  ansible.builtin.systemd:
    name: unbound
    enabled: true
    state: started

Det är faktiskt allt. Två saker är värda att nämna. dnsutils (som innehåller dig) installeras här medvetet och antas inte redan finnas på systemet. På så sätt är verifierings-playbookens DNS-kontroller självständiga och beror inte på att någon annan roll har installerat paketet tidigare. Konfigurationen skrivs också som en drop-in-fil under unbound.conf.d/ i stället för att ändra paketets vanliga unbound.conf, som jag skrev tidigare. Då kan en uppgradering av distributionen som skriver om huvudfilen inte råka ta bort min konfiguration.

handlers/main.yml, som template-tasken anropar, innehåller bara en enkel och ganska tråkig handler:

- name: Restart unbound
  ansible.builtin.systemd:
    name: unbound
    state: restarted

Den intressanta delen av rollen är inte task-filen, utan templaten som den andra tasken genererar. Det är där mesh-dictionaryn omvandlas till rader för local-data, access-control och interface. Listan som visades tidigare i avsnittet "Antaganden för Unbound-konfigurationen" är det färdiga resultatet av den här templaten på dns1. Looparna som skapar den, till exempel för local-data, finns alltså i rollens defaults och template. Därför är det enda som behövs för att ge en ny host en DNS-post att lägga till den i mesh-dictionaryn.

Separat öppnar firewall-rollen 53/udp och 53/tcp på WireGuard-interfacen, exakt för de källadresser som tillhör medlemmarna i meshen. Eftersom både DNS-posterna och brandväggsreglernas källadresser skapas från samma dictionary kan de inte börja skilja sig från varandra. Själva reglerna genereras med en liten Jinja-loop som går igenom varje hubb och varje medlem i meshen och skapar en allow-regel för varje protokoll:

firewall_dns_interface_rules: >-
  {%- set ns = namespace(rules=[]) -%}
  {%- for hub in wg_hubs.keys() -%}
    {%- for name, m in wg_members.items() -%}
      {%- for proto in ['udp', 'tcp'] -%}
        {%- set ns.rules = ns.rules + [{
          'rule': 'allow',
          'port': dns_port,
          'proto': proto,
          'interface': wg_ssh_interface_for_hub[hub],
          'direction': 'in',
          'from_ip': m.addresses[hub],
        }] -%}
      {%- endfor -%}
    {%- endfor -%}
  {%- endfor -%}
  {{ ns.rules }}

Exakt samma wg_members-dictionary används för DNS-posterna, brandväggens källadresser och Unbounds egen access-control-lista. Det finns en enda källa till sanningen och tre delar som använder den.

Verifierings-playbooken kontrollerar sedan kortfattat följande på DNS-servrarna:

  • att tjänsten unbound är aktiv;
  • att den lyssnar på de förväntade WireGuard-adresserna för både UDP och TCP, och inte på en wildcard-adress;
  • att ett internt namn slås upp till den förväntade adressen;
  • att ett publikt namn kan slås upp.

När allt är igång är det väldigt enkelt att kontrollera det manuellt. Från vilken som helst av DNS-servrarna:

dig @127.0.0.1 monitoring1.int.example.com +short
# ... should be 10.8.0.5

dig @127.0.0.1 google.com +short
# ... should be a public address

Och från laptopen, efter att VPN-profilens DNS har ställts in på de två resolver-servrarna (DNS = 10.8.0.7, 10.8.0.8 i WireGuard-klientens konfiguration):

dig monitoring1.int.example.com +short
# ... should be 10.8.0.5

ssh bastion1.int.example.com
# ... should work, using a name instead of an IP

Klienten får adresserna till båda resolver-servrarna direkt. Det finns ingen load balancer som väljer vilken som ska användas. Operativsystemets resolver har helt enkelt två DNS-servrar och går vidare till den andra om den första inte svarar.

Analys av möjliga fel: vad händer när något slutar fungera?

Jag går igenom fallen från de enklaste till de mest allvarliga.

En DNS-server slutar fungera (låt oss säga dns1)

dns2 fortsätter att svara på både interna och publika namn, med sina egna lokala poster, sin egen cache och sina egna forwarders. Klienten har både 10.8.0.7 och 10.8.0.8 konfigurerade som DNS-servrar och går därför över till dns2 om dns1 inte svarar. Det vanliga arbetet kan fortsätta. Jag verifierade även detta: när unbound var stoppad på dns1 svarade en fråga som skickades direkt till dns2 fortfarande korrekt, medan en fråga till dns1 gav timeout, precis som förväntat.

# on dns1
systemctl stop unbound

# from the client, still fine because dns2 answers
dig @10.8.0.8 bastion1.int.example.com +short

10.8.0.3

# and dns1 is genuinely down, not cached
dig @10.8.0.7 bastion1.int.example.com +short

... (timeout)

En viktig detalj om failover på klientsidan. dig pratar direkt med en DNS-server och är därför ett bra sätt att kontrollera att servrarna fungerar, men det går förbi operativsystemets egen resolver-stack. Vanliga applikationer gör inte det. På macOS finns det flera lager av cache framför de konfigurerade DNS-servrarna: längst ner finns mDNSResponder (systemets DNS-klient och cache för vanlig Unicast DNS, som också hanterar multicast DNS och service discovery), och ovanpå det har enskilda applikationer, särskilt webbläsare, sina egna DNS-cachar i processen. macOS använder inte heller enkel round-robin mellan de två konfigurerade DNS-servrarna. mDNSResponder följer hur varje server beter sig och fortsätter att skicka frågor till den första DNS-servern tills den bedömer att servern inte fungerar som den ska. Den använder också egna timeouts och negativ caching innan den ger upp och försöker med den andra servern.

I praktiken betyder det att när dns1 går ner visar dig @10.8.0.8 direkt att dns2 fungerar, men vanliga applikationer på laptopen kan under några sekunder få långsamma eller misslyckade DNS-uppslagningar. Det varar tills mDNSResponder har fått timeout mot den döda servern och gått över till den fungerande, och tills gamla negativa poster har försvunnit ur de olika cacharna. Det här är inte ett fel i DNS-lagret. Båda resolver-servrarna fungerar och svarar korrekt. Det är helt enkelt så macOS bygger upp och hanterar sina DNS-cachar. Om det någon gång blir störande under ett riktigt avbrott:

sudo dscacheutil -flushcache
sudo killall -HUP mDNSResponder

Det rensar systemets DNS-cache och tvingar fram en ny start.

En liten utvikning: på Linux på klientsidan fungerar det på ungefär samma sätt. Applikationer slår normalt upp namn via getaddrinfo()/NSS i stället för att fråga DNS-servrar direkt. Den faktiska resolver-funktionen kan vara glibc som använder /etc/resolv.conf, systemd-resolved, dnsmasq eller någon annan lokal stub resolver. Flera konfigurerade DNS-servrar fungerar i stort sett som man förväntar sig - den aktiva resolvern kan fortsätta använda en server tills en fråga får timeout eller misslyckas, och sedan prova en annan. Lokala positiva och negativa cacher, samt DNS-cacher i själva applikationerna, kan ge ytterligare fördröjning. dig @<server> går förbi den här resolver-vägen och kontrollerar bara att den angivna DNS-servern går att nå och svarar.

Tillbaka till ämnet: det symmetriska fallet (dns2 slutar fungera och dns1 fortsätter svara) är exakt likadant. Vilken som helst av resolver-servrarna kan försvinna, startas om, uppgraderas eller byggas om utan att det vanliga arbetet avbryts. Det är också viktigt vid normalt underhåll och vid mänskliga misstag. Jag kan lägga ut en ändring på en resolver, kontrollera att allt fungerar och först därefter ändra den andra. På så sätt kan en dålig konfiguration inte slå ut båda samtidigt.

Båda DNS-servrarna slutar fungera

När båda DNS-servrarna är nere fungerar namn-baserad åtkomst över den vanliga VPN-anslutningen sämre. Det är förväntat och, viktigast av allt, går att hantera. Själva infrastrukturen går fortfarande att nå via de kända WireGuard-IP-adresserna. Allt försämras på ett kontrollerat sätt:

  • båda DNS-servrarna är nere;
  • gå över till de vanliga WireGuard-IP-adresserna;
  • ssh bastion1 (från min laptop skapas då en SSH-anslutning till 10.8.0.3 baserat på min lokala ~/.ssh/config), och därifrån kan jag nå vilken server som helst och reparera DNS. Om min lokala SSH-konfiguration skulle försvinna har jag också vanlig dokumentation i Markdown med hela topologin. Den genereras automatiskt, men det är ett annat ämne, kanske för ett annat blogginlägg.

Huvudmålet är att DNS alltid ska vara ett lager för bekvämlighet. Om det försvinner kostar det lite komfort, men inte åtkomst till infrastrukturen.

Den primära VPN-hubben (vpn1) slutar fungera, och samtidigt även dns1

Här visar det sig varför det är bra att lyssna på adresserna i båda hubbarna. Den vanliga vägen går via vpn1. Om den går ner används den andra hubben, vpn2, som en break-glass-väg och ger åtkomst till allt i subnätet 10.9.0.0/24. Eftersom varje DNS-server också lyssnar på sin adress i vpn2-subnätet (dns2 till exempel på 10.9.0.8) kan jag fortfarande nå och även fråga den resolver som fortfarande fungerar via nödvägen:

# over the vpn2 break-glass path, dns1 is gone but dns2 answers
dig @10.9.0.8 monitoring1.int.example.com +short

10.8.0.5

Men - och det här är en viktig skillnad - jag är inte beroende av det. Att kunna nå DNS via vpn2 är praktiskt för felsökning, men det är inget krav. Break-glass-proceduren bygger helt på vanliga IP-adresser, så den fungerar oavsett om DNS är uppe eller inte.

Båda DNS-servrarna slutar fungera och vpn1 går ner, så bara vpn2 återstår

Det här är det allvarligaste felet som fortfarande går att återhämta sig från. Allt bekvämt är borta just nu: ingen fungerande intern DNS och ingen primär VPN-hubb. Det som finns kvar är nödvägen med så få beroenden som möjligt:

  • starta och anslut till vpn2;
  • anslut med SSH till bastionservern via dess 10.9.0.x-adress;
  • anslut därifrån med SSH till valfri server via dess 10.9.0.x-adress;
  • felsök och återställ med vanliga IP-adresser.

Ingen namnuppslagning behövs någonstans i den här processen. Återställningsvägen har medvetet hållits så enkel som möjligt. Den viktigaste egenskapen i hela systemet är att ett totalt DNS-fel aldrig i sig kan stänga ute mig från infrastrukturen.

Låt oss gå ett steg längre - en publik upstream slutar fungera

Interna namn under int.example.com fortsätter att fungera, eftersom de besvaras lokalt och aldrig lämnar det privata nätverket. Publika namn kan vara otillgängliga en kort stund tills Unbounds RTT-baserade failover går över till en annan forwarder. Med tre oberoende leverantörer är ett totalt avbrott för publik DNS mycket osannolikt. Om det ändå händer betyder det nog att mänskligheten, inklusive jag själv, har betydligt större problem... 🥲

Publikt betrodda certifikat för privata, interna tjänster

Att låta monitoring1.int.example.com peka på 10.8.0.5 är bara halva bekvämligheten. Den andra halvan är att kunna öppna https://monitoring1.int.example.com:3000 och få ett grönt hänglås utan någon varning, på macOS, iOS och på alla enheter, i alla webbläsare och skript, utan att behöva installera en privat root CA på någon enhet.

Några ord om varför jag över huvud taget använder en subdomän till en riktig domän i stället för ett påhittat suffix. Det finns faktiskt ett korrekt reserverat namn för just detta: home.arpa. Det definieras i RFC 8375 som standarddomänen för privata hemnätverk. Historiskt kommer det från Homenet, RFC 7788, för ungefär tio år sedan. Det är det officiella sättet att namnge saker i ett hemnätverk utan att använda någon annans namespace eller chansa på att ett påhittat namn blir en riktig TLD i framtiden. Om jag bara behöver intern namnuppslagning är det ett mycket bra val. Men eftersom jag äger min egen domän kan jag bevisa ägarskapet för en publik certifikatutfärdare, något som aldrig går att göra med home.arpa eftersom ingen CA utfärdar publikt betrodda certifikat för ett reserverat privat namn. Just den egenskapen - att jag kan bevisa kontroll över domänen via DNS - gör det möjligt att använda publikt betrodd TLS för privata tjänster.

Tricket är att int.example.com ligger under en riktig domän som jag äger. Det betyder att jag kan få ett vanligt, publikt betrott Let's Encrypt-certifikat för monitoring1.int.example.com, även om namnet bara pekar på en privat WireGuard-adress som inte går att nå från internet. Certifikatutfärdaren behöver inte kunna nå själva tjänsten. Den behöver bara ett bevis via DNS på att jag kontrollerar domänen. Det är ACME DNS-01-validering. Jag publicerar alltså en TXT-post under _acme-challenge.monitoring1.int.example.com. i den publika DNS-zonen för example.com, och Let's Encrypt kontrollerar den.

Arkitekturen ger alltså tre saker samtidigt:

  1. Privat åtkomst över nätverket.
  2. PLUS privata DNS-poster.
  3. PLUS publikt betrodda TLS-certifikat.

Allt detta utan att tjänsten någonsin exponeras publikt, förstås.

Designval för SSL-certifikatet

Jag tog två medvetna beslut här.

Först: certifikat per host eller wildcard-certifikat. Varje monitoring-host skapar och har bara sitt eget certifikat för sitt eget namn. Den privata nyckeln skapas på hosten av certbot och lämnar aldrig maskinen. Det är samma princip som jag redan använder för privata WireGuard-nycklar. Ett wildcard-certifikat för *.int.example.com skulle innebära att samma privata nyckel delas mellan alla hostar. Resultatet? Om någon lyckas kompromettera en enda host får angriparen en nyckel som är giltig för alla namn i hela namespace. Med certifikat per host begränsas skadan till en enda maskin.

Det andra beslutet är att Grafana ska köra HTTPS direkt, utan någon reverse proxy, alltså med port :3000 i slutet av URL:en. Grafana kan läsa cert_file och cert_key direkt och använda TLS på sin egen port. Det håller hela lösningen enkel. Nackdelen är att porten finns kvar i URL:en, som jag skrev tidigare: https://monitoring1.int.example.com:3000. Det accepterar jag gärna. Ingen nginx, ingen Caddy, inget nytt som måste köras och säkras.

Den relevanta delen av grafana.ini, som bara genereras när TLS är aktiverat för den hosten:

[server]
protocol = https
domain = monitoring1.int.example.com
cert_file = /etc/grafana/tls/monitoring1.int.example.com.fullchain.pem
cert_key = /etc/grafana/tls/monitoring1.int.example.com.privkey.pem
http_addr = 10.8.0.5
http_port = 3000

Ansible hanterar allt runt certifikatet. Det installerar alltså certbot, skapar /etc/grafana/tls, installerar ett deploy-hook-skript som kopierar det utfärdade fullchain.pem och privkey.pem till den katalogen med ägare root:grafana och rättigheter 0640, och startar om Grafana. Det ser också till att HTTPS inte aktiveras innan certifikatfilerna faktiskt finns, eftersom Grafana annars skulle vägra att starta.

Det Ansible inte gör är att köra själva ACME-challengen, eftersom det steget kräver att en människa publicerar en DNS-post.

Konfigurationsflödet

På varje monitoring-host, som nås via bastionservern:

sudo certbot certonly --manual --preferred-challenges dns \
  -d monitoring1.int.example.com

certbot visar en TXT-post som ska publiceras. Jag lägger till den i DNS-leverantörens panel, väntar tills den har spridits och kontrollerar den direkt mot domänens auktoritativa namnservrar (inte mot en publik resolver, eftersom det är dessa som Let's Encrypt kommer att kontrollera):

dig +short NS example.com
dig +short TXT _acme-challenge.monitoring1.int.example.com @<authoritative-ns>

...och FÖRST DÅ trycker jag på Enter. certbot utfärdar certifikatet och kör automatiskt varje deploy-hook-skript som ligger i katalogen renewal-hooks/deploy/, oavsett vilket delkommando som kördes, inte bara certbot renew. Deploy-hooken jag lade dit tidigare kör alltså igång sig själv, direkt efter detta certonly-anrop:

/etc/letsencrypt/renewal-hooks/deploy/monitoring1.int.example.com.sh

Det kopierar certifikatet till /etc/grafana/tls, startar om Grafana och tjänsten startar med HTTPS - inget manuellt steg behövs.

Om certbot istället skriver "not due for renewal yet" och hoppar över att utfärda ett nytt certifikat, körs inte hooken heller, eftersom inget förnyades. Bara i det fallet kör jag den en gång för hand:

sudo RENEWED_LINEAGE=/etc/letsencrypt/live/monitoring1.int.example.com \
  /etc/letsencrypt/renewal-hooks/deploy/monitoring1.int.example.com.sh

RENEWED_LINEAGE är variabeln som certbot normalt sätter själv när den kör hooken efter en riktig utfärdning - att köra skriptet manuellt innebär att jag sätter den själv.

En snabb kontroll från laptopen, medvetet utan den osäkra flaggan -k, visar att certifikatet som servern använder är publikt betrott och inte någon self-signed reservlösning.

curl -sI https://monitoring1.int.example.com:3000/api/health

HTTP/1.1 200 OK
(...)

Om curl kan verifiera certifikatkedjan mot systemets trust store utan någon extra flagga, kommer macOS och webbläsarna också att göra det. Grönt hänglås, ingen varning och inga fula undantag som "lita på certifikatet och fortsätt ändå"...

Det manuella arbetet: manuell förnyelse, och... vad blir nästa steg?

Det finns ett riktigt irriterande problem, och jag säger det rakt ut. Min domänregistrar är en av de största i världen, och för några år sedan tog de bort sitt publika DNS REST API för alla konton. Utan ett fungerande DNS-API kan jag inte automatisera DNS-01-challengen. Certbots manuella läge kan inte heller förnya certifikatet utan mänsklig hjälp: ett certifikat som skapats med --manual utan en authentication hook kan inte förnyas med certbot renew, eftersom den kommandovägen är gjord för att köras utan interaktion. Så förnyelsen är inte automatiserad än. Jag kör helt enkelt samma certbot certonly-kommando manuellt igen och publicerar en ny TXT-post ungefär var ~90 dag. Den katalog-scannade deploy-hooken kör sedan igång sig själv vid den nya utfärdningen precis som vid den första (certbot 3.2.0+), kopierar det förnyade certifikatet till /etc/grafana/tls och startar om Grafana - inget manuellt hook-steg behövs, såvida inte certbot skrev "not due for renewal yet" och hoppade över utfärdningen.

För att slippa onödiga felmeddelanden från en renewal-timer som ändå aldrig kan lyckas maskerar installationen den medföljande certbot.timer. Annars skulle den försöka köra certbot renew två gånger om dagen och fortsätta logga fel hela tiden.

Det här är en tillfällig situation som jag själv har skapat. Den rena lösningen är antingen att använda en registrar med ett riktigt DNS-API, eller att delegera bara subzonen int.example.com till en DNS-leverantör som har ett sådant API. Då kan DNS-01 automatiseras helt, medan huvuddomänen kan ligga kvar där den är 🎉. Jag kommer nästan säkert att flytta alla mina domäner till en annan operatör inom de närmaste månaderna och därmed bli av med den manuella rutinen. Som problem betraktat är det ändå ett ganska behagligt problem att ha - allt fungerar redan idag, och den enda kostnaden är en kalenderpåminnelse varannan eller var tredje månad.

Epilog

Från två rena, härdade servrar har jag nu byggt ett DNS-lager med två likvärdiga Unbound-servrar. Det hanterar interna namn under int.example.com från en enda källa till sanningen - samma mesh-dictionary som resten av miljön använder, utökad med en Ansible-loop - och cachar och vidarebefordrar allt annat till tre oberoende publika DNS-leverantörer. Unbound binder bara till privata WireGuard-interface och skyddas av tre lager av åtkomstkontroll. Ovanpå detta kör Grafana på monitoring-servrarna ett publikt betrott Let's Encrypt-certifikat med inbyggd HTTPS och kan nås via ett enkelt namn inifrån VPN:et.

Nu kan jag arbeta så här:

  1. Anslut till VPN.
  2. Nu är jag inne i min infrastruktur.
  3. Namn under int.example.com fungerar, med giltig TLS.
  4. Det vanliga internet fungerar också.

Och säkerhetsnätet under allt detta är ännu enklare:

  • något grundläggande har gått sönder;
  • glöm då bekvämlighetslagren och använd de kända WireGuard-IP-adresserna;
  • nå bastionservern och reparera problemet.

Bekvämlighet ovanpå, och en enkel återställningsväg utan onödiga beroenden under. Att hålla de här två delarna tydligt separerade är hela poängen.