Jag ville ha ett hemlabb som verkligen är säkert, inte bara "tillräckligt säkert för ett helgprojekt". Målet var en vettig grundnivå för säkerhet och nätverksisolering som jag kunde stå för, använda under lång tid, bygga vidare på och inte behöva skämmas över innerst inne.

Det här är ett hobbyprojekt som jag driver på min fritid, men det är byggt enligt samma standard som jag skulle kunna försvara och förvänta mig på jobbet (varje dag). Det är här jag kör de sidoprojekt som jag gör färdiga efter arbetstid, och jag kör allt på ett operativsystem (eller till och med på hårdvara) som jag själv äger och har full kontroll över. Nätverket är uppdelat i tydliga oktetter så att olika delar hålls åtskilda - ett delnät för varje syfte - och så att mer kapacitet senare bara kräver en ändring i konfigurationen, inte en ombyggnad eller en total förändring av arkitekturen.

Det finns också ett mer vardagligt mål mitt i allt det roliga: jag ville ha ett VPN med en stabil, fast offentlig IP adress som fysiskt finns i Sverige. Jag provade flera stora och välkända kommersiella VPN tjänster för att se om de verkligen framstod som en svensk anslutning, men alltför ofta avslöjade de sig som "ett VPN" i stället för "en vanlig privat internetanslutning". Det kan bero på att de använder IP adresser som byts ut och delas mellan många användare, och som många webbplatser redan känner igen. Hela gruppen av adresser kan helt enkelt ha ett dåligt rykte. Jag testade aldrig något av deras abonnemang med fast IP adress, så det kan mycket väl vara just det som gör skillnaden. Oavsett vilket kan jag, genom att driva mitt eget nav med en fast svensk IP adress, förena nytta med nöje. Om det verkligen får mig att se mer ut som "en svensk" (<utvikning> en liten blinkning till Bettie här 😉 </utvikning>) - än en dyr VPN anslutning med en IP adress som hela tiden byts ut, är en detalj som jag ärligt kommer tillbaka till i slutet. (Den enkla versionen är: det hjälper, men det är förstås ingen magi).

Varför det är värt besväret

När man har arbetat med att leverera programvara i tillräckligt många år slutar ett sådant hemlabb (som inte ens behöver finnas i ens eget hem) att vara något nytt och blir i stället ett verktyg som man själv har full kontroll över. Och det är roligt. Till vardags arbetar jag med hanterade plattformar, och jag tycker verkligen om dem. Här äger och styr jag däremot "varje" abstraktionslager (där "varje" betyder från kernel space på varje maskin och hela vägen upp, och ibland även hårdvaran och de fysiska nätverksenheterna, när en del av maskinerna faktiskt står hemma hos mig). Mer konkret visar det sitt värde genom att ge mig:

  • en plats där jag kan köra riktig infrastrukturkod - Ansible, Terraform och Kubernetes manifest - mot riktiga maskiner, med riktig nätverksisolering och riktiga följder för säkerheten, i stället för i en tillfällig testmiljö;
  • en privat miljö där jag kan bygga och köra de sidoprojekt som jag arbetar med på fritiden - lagring av källkod, CI, kontroller av kodkvalitet, lagring av byggartefakter och objekt, databaser för SQL, NoSQL, dokument och cache, ingress, hemligheter, observability och SRE;
  • en plats där jag kan hålla de praktiska rutinerna i gott skick: säkerhetskopior, byte av nycklar, återställning efter problem, åtkomst med minsta möjliga behörighet samt kontroll och uppdatering av leveranskedjan och sårbarheter - sådant som är dyrt och svårt att öva på i produktion, men enkelt att fortsätta träna på hemma;
  • och enkelt uttryckt: de standarder som håller ihop detta system är samma standarder som alla förväntas följa i sitt professionella arbete.

Man kan med rimlighet se slutläget som ett litet privat moln. Det är en överdrift - jag bygger inte en "hyperscaler i ett skåp" - men det är rätt riktning, och genom att hålla designen på den nivån blir besluten mer ärliga och genomtänkta.

Från början kördes hela lösningen i Google Cloud. Det fungerade, men en hanterad plattform döljer just de lager som jag helst vill ha kontroll över: nätverkets gräns, brandväggen, SSH åtkomsten, systemets tillstånd vid uppstart, delar av kärnan och paket på låg nivå. För ett system som jag själv ansvarar för vill jag hellre kontrollera en vanlig Linuxmiljö från början till slut och ha hand om varje lager, än att lämna över dem till en kontrollplattform som jag inte själv kan granska. Därför gick jag över till en modell där jag själv kontrollerar maskinerna och konfigurerar dem från ett rent operativsystem och uppåt, med hela konfigurationen sparad som kod.

Allt nedan styrs av ett idempotent Ansible repository. Kontrolldatorn är min laptop med macOS (där kommandona körs i zsh). De hanterade servrarna är vanliga Linuxsystem - en populär distribution i en LTS version - som körs som VPS-instanser. Hela lösningen byggs upp i lager: först skapas en administratörsanvändare, sedan säkras SSH, gemensamma systeminställningar läggs till, brandväggen konfigureras och fail2ban installeras. Därefter byggs WireGuard nätet upp, och till sist verifieras hela inventory konfigurationen.

Det här har jag idag

Kärnan består av tre olika typer av maskiner, och varje typ finns i en egen inventory grupp med ett namn som beskriver dess syfte:

  • vpn1 - en WireGuard hubb som alltid är igång. Det här är den väg jag använder till vardags för att administrera hela miljön.
  • vpn2 - en andra WireGuard hubb som fungerar som kall reserv. Den är normalt avstängd och bortkommenterad i inventory filen. Jag startar den för ett test varje månad eller när jag faktiskt behöver den. Det viktiga är att den finns hos en annan leverantör, i ett annat datacenter och i ett annat land än vpn1.
  • bastion1 - SSH bastionen, alltså den server jag använder som mellanliggande hopp. Det är den enda maskin som jag ansluter direkt till via SSH över VPN nätet. Alla andra maskiner når jag genom att först gå via den.

Den grundläggande säkerhetsnivån som används på alla servrar är:

  • SSH inloggning som root är avstängd;
  • lösenordsinloggning via SSH är avstängd - endast publika nycklar tillåts;
  • UFW är aktivt, med all inkommande trafik blockerad som standard och all utgående trafik tillåten som standard;
  • fail2ban övervakar sshd;
  • automatiska uppdateringar och APT dagliga timers är aktiverade;
  • ingen SSH åtkomst är publik - 22/tcp tillåts endast via ett WireGuard gränssnitt och bara från en bestämd adress i mesh-nätet;
  • en övervakningsagent.

Den publika IP adressen på varje server avvisar all SSH trafik. Den enda vägen in går via WireGuard, och därefter endast genom bastionen.

Den gemensamma basnivån

En princip gör att hela miljön förblir enhetlig när den växer: varje server får samma säkra grundkonfiguration innan den får någon särskild funktion. En PostgreSQL server, en Kubernetes nod, en Kafka nod, en Redis server eller en e-postserver - ingen är undantagen. Maskinens uppgift är bara ett extra lager ovanpå grunden. Basnivån är inte förhandlingsbar och är likadan överallt. Det är detta som hindrar miljön från att bli en samling individuellt handkonfigurerade servrar, där varje maskin är säkrad på lite olika sätt och aldrig granskas ordentligt.

Mer exakt kör playbooks/site.yml först följande roller på hosts: all, innan några playbooks för särskilda funktioner körs:

  • common - tidszon, språk- och regioninställningar, ett litet antal grundläggande paket samt konfiguration för zsh, vim och värdnamnet. Värdnamnet sätts en gång och låses, så att vendor-init inte kan ändra tillbaka det i bakgrunden. Rollen aktiverar också automatiska uppdateringar och APTs dagliga timers. Säkerhetsuppdateringar installeras utan att jag måste komma ihåg att logga in - precis som i en riktig företagsmiljö.
  • users - mitt personliga administratörskonto, medlemskap i gruppen sudo, lösenordsfri sudo via /etc/sudoers.d/<username>, där filen kontrolleras med visudo innan ändringen sparas, samt SSH nycklar som skyddas av YubiKey. Jag beskriver detta mer i kapitel 1 nedan - fortsättning följer.
  • ssh - en extra konfigurationsfil för säkrare SSH: root inloggning avstängd, lösenord avstängda och endast publika nycklar tillåtna. Konfigurationen kontrolleras med sshd -t innan den får börja gälla.
  • firewall - UFW med all inkommande trafik blockerad som standard och SSH regler som endast tillåter åtkomst via WireGuard. Reglerna skapas från mesh konfigurationen. Ingen server är någonsin tillgänglig på en publik port som den inte uttryckligen behöver.
  • fail2ban - ett skydd för sshd, så att även SSH åtkomsten bakom WireGuard är skyddad mot upprepade försök att gissa inloggningsuppgifter.
  • node_exporter + alloy - varje server skickar mätvärden och loggar från det ögonblick den ansluts till miljön, inte bara när jag råkar komma ihåg att lägga till övervakning. Observability hör hemma i basnivån och ska inte läggas till i efterhand.

Grundprincipen genom hela lösningen är att kontrollera innan en ändring aktiveras och att använda separata konfigurationsfiler i stället för att redigera huvudfilen direkt. För SSH används sshd -t, för sudoers används visudo -cf, och konfiguration läggs under sshd_config.d/ och cloud.cfg.d/ i stället för att skriva om leverantörens filer. En ändring som skulle kunna stoppa en tjänst upptäcks innan den når den körande processen, inte efteråt.

Vad jag skulle lägga till i basnivån härnäst

Basnivån är medvetet ganska enkel idag, men det finns några roller som jag ser som naturliga tillägg när en maskin börjar köra riktiga arbetslaster:

  • auditd - en granskningslogg på kärnnivå över åtgärder med hög behörighet och åtkomst till filer. Det är sådan information som man helst redan vill ha sparad när man senare behöver undersöka något;
  • en CIS liknande säkerhetsgenomgång - strängare inställningar i kärnan via sysctl, rimliga monteringsalternativ och avstängning av tjänster som inte används. Helst bör detta kontrolleras med ett verktyg som Lynis, så att säkerhetsnivån faktiskt mäts och inte bara antas vara bra;
  • rapportering av reboot_required - vid en slumpmässig tid under natten kontrollera om /var/run/reboot-required finns efter automatiska uppdateringar och rapportera det, så att kärnuppdateringar inte väntar på en omstart hur länge som helst;
  • chrony och tidssynkronisering - korrekt tid är ett tyst men viktigt krav för begripliga loggar, korrekt TLS-validering och framtida arbete med certifikat;
  • en agent för säkerhetskopiering, till exempel restic eller borg till MinIO eller annan objektlagring - så att en server kan återställas enligt en bestämd policy, och inte bara med tur, så snart den innehåller data som är värda att spara.

Jag lägger till dessa som separata roller, var och en med en egen tagg och en motsvarande kontroll i verify.yml, så att basnivån och verifieringen alltid växer tillsammans. En funktion som är konfigurerad men inte kontrollerad är, enligt mig, inte en funktion som jag verkligen kan lita på.

Kapitel 1: Börja med YubiKeys

Om du bara tar med dig en sak från det här inlägget: börja med SSH nycklar som skyddas av hårdvara. Allt annat i lösningen bygger på att min SSH identitet är förankrad i fysisk hårdvara, så detta är verkligen steg noll.

Alla mina administrativa SSH-nycklar är av typen sk-ssh-ed25519@openssh.com - FIDO/U2F nycklar där den hemliga delen finns på en YubiKey och aldrig lagras på laptopens disk. På macOS skapar man en sådan med ett enda kommando (jag skapar dem som residenta autentiseringsuppgifter, alltså så att informationen om nyckeln lagras direkt på YubiKey och kan hämtas därifrån senare - se avsnittet om kompromisser nedan):

ssh-keygen -t ed25519-sk -O resident -O application=ssh:homelab \
  -C "<username>@yubikey-1" -f ~/.ssh/id_ed25519_sk_yubikey1

-t ed25519-sk väljer FIDO typen för nyckeln. -O resident lagrar nyckeluppgifterna på själva YubiKey enheten, så att de kan återställas på en ny laptop. -O verify-required kan läggas till för att kräva en PIN kod vid varje användning. Den privata delen finns aldrig på disken - där finns bara ett litet och icke hemligt nyckelhandtag.

Jag har fyra YubiKeys och behandlar dem som vilken oersättlig hemlighet som helst:

  • en har jag alltid med mig till vardags;
  • en förvarar jag hemma som en direkt reserv;
  • ytterligare två finns på två oberoende platser, båda minst flera tiotals kilometer från där jag bor, i säkra och övervakade lokaler.

Alla fyra privata nycklar är registrerade i förväg för SSH åtkomst, så vilken som helst av dem kan användas för att logga in. De skyddas med PIN koder och en lösenfras. Jag låter inte SSH sessioner ligga kvar länge i en agent: cachen för socket anslutningen finns kvar i tre minuter, och därefter försvinner den och jag måste autentisera mig igen. Det är lite irriterande, men helt klart värt det.

De publika delarna av alla fyra nycklarna finns i repot - publika nycklar är säkra att spara där - i inventory/production/files/authorized_keys, med en nyckel per rad. Rollen users skickar dem sedan till varje server. Rollen läser in dem som en lista och lägger till varje nyckel utan att ta bort de befintliga (state: present, utan exclusive). Att registrera en femte nyckel betyder därför bara att lägga till ännu en rad i filen och köra rollen igen:

- name: Ensure admin user exists
  ansible.builtin.user:
    name: "{{ admin_user }}"
    shell: "{{ admin_user_shell }}"
    groups: "{{ admin_user_groups }}"
    append: true
    create_home: true
    state: present

- name: Ensure SSH public keys are configured for admin user
  ansible.posix.authorized_key:
    user: "{{ admin_user }}"
    key: "{{ item }}"
    state: present
  loop: "{{ admin_user_ssh_public_keys }}"

Själva listan hämtas direkt från filen som är sparad i repot, via en lookup i group_vars/all.yml:

admin_user_ssh_public_keys: "{{ lookup('file', inventory_dir + '/files/authorized_keys').splitlines() }}"

På så sätt är den tillåtna åtkomsten versionshanterad och kan återskapas. Filen är själva policyn. Senare läser verify.yml på nytt filen authorized_keys på servern och kontrollerar att varje nyckel som finns i repot också verkligen finns där. Mer om detta i kapitel 3.

Jag har också en skriftlig återställningsguide med tydliga steg för den dag då min laptop slutar fungera. På så sätt blir återställningen en checklista och inte något jag måste improvisera.

Fördelar

  • Hemligheten lagras aldrig på disken. En stulen, infekterad eller kopierad laptopdisk innehåller därför ingen SSH hemlighet.
  • Man behöver både den fysiska nyckeln och en PIN kod eller lösenfras - riktig tvåfaktorsautentisering, direkt i hårdvaran.
  • Den korta cachen i SSH agenten gör att en obevakad laptop inte förblir en öppen dörr särskilt länge.
  • Fyra nycklar på fyra olika platser gör att en enskild brand, stöld eller förlust inte kan låsa mig ute.

Nackdelar

  • Praktiskt besvär. Att behöva röra vid nyckeln och autentisera sig igen var tredje minut märks verkligen. Det är priset, och jag betalar det medvetet. Kanske borde jag förlänga tiden någon gång.
  • Nyckelhandtag. OpenSSH:s FIDO nycklar behöver också ett litet, icke hemligt nyckelhandtag - delen som hör ihop med .pub filen. Om du inte skapar nycklarna som residenta autentiseringsuppgifter (ssh-keygen -t ed25519-sk -O resident) måste du säkerhetskopiera detta handtag. Om du förlorar det måste det skapas på nytt. Med residenta nycklar kan du hämta tillbaka handtaget direkt från YubiKey med ssh-keygen -K, vilket gör återställningen enklare.
  • Ordning vid registrering. Fyra nycklar betyder fyra registreringar som måste hållas uppdaterade. När du lägger till en ny nyckel måste du uppdatera authorized_keys och köra Ansible igen.

Vad händer om jag förlorar nyckeln eller en laptop?

Om jag förlorar eller förstör en YubiKey händer ingenting direkt. Vilken som helst av de andra tre kan användas för att logga in. Jag kan återkalla och ersätta den förlorade nyckeln i lugn och ro. Eftersom den skyddas av både en PIN kod och en lösenfras kan den som hittar eller stjäl nyckeln inte använda den direkt.

Om min laptop går sönder till exempel efter en brand, stöld eller ett hårdvarufel - men YubiKeys enheterna finns kvar, följer jag min skriftliga återställningsguide. Min SSH identitet har överlevt, eftersom den alltid har funnits på mina YubiKeys och aldrig på laptopen. Det jag faktiskt har förlorat är laptopens privata WireGuard nycklar, vilket jag beskriver mer i nästa kapitel.

Återställningen är enkel: på en ny Mac hämtar jag tillbaka de residenta nyckelhandtagen från en YubiKey, installerar WireGuard igen, importerar de säkerhetskopierade tunnelprofilerna och kontrollerar att en SSH anslutning till bastionen ber mig att röra vid min YubiKey:

ssh-keygen -K

# Kontrollera för säkerhets skull fingeravtrycket som nyckeln nu visar:
ssh-keygen -l -f ~/.ssh/id_ed25519_sk_yubikey1.pub

Om anslutningen via bastionen fungerar är allt återställt: både identiteten och nätverksvägen. Och eftersom laptopens publika nyckel inte har ändrats behöver ingenting ändras på servrarna eller i repot.

Kapitel 2: WireGuard meshnätet - två hubbar, två leverantörer

Nätverket är ett fullständigt mesh nät med åtkomst mellan alla noder och styrs av en enda datakälla. Det finns inga listor över peers som underhålls för hand. Två dictionaries, wg_hubs och wg_members, i group_vars/all.yml beskriver hela topologin, och Ansible rollerna, brandväggsreglerna och verifieringskontrollerna skapas alla utifrån dessa data (publika nycklar och IP visas som platshållare):

wg_hubs:
  vpn1:
    subnet: 10.8.0.0/24
    hub_address: 10.8.0.1
    listen_port: 51820
    endpoint: "<vpn1-public-ip>:51820"
    hub_pub_key: "<vpn1-hub-public-key>"
  vpn2:
    subnet: 10.9.0.0/24
    hub_address: 10.9.0.1
    listen_port: 51820
    endpoint: "<vpn2-public-ip>:51820"
    hub_pub_key: "<vpn2-hub-public-key>"

wg_members:
  vpn1:
    addresses: { vpn1: 10.8.0.1, vpn2: 10.9.0.4 }
    spoke_pub_keys: { vpn2: "<vpn1-wg1-public-key>" }
  vpn2:
    addresses: { vpn1: 10.8.0.4, vpn2: 10.9.0.1 }
    spoke_pub_keys: { vpn1: "<vpn2-wg1-public-key>" }
  bastion1:
    addresses: { vpn1: 10.8.0.3, vpn2: 10.9.0.3 }
    spoke_pub_keys:
      vpn1: "<bastion1-wg0-public-key>"
      vpn2: "<bastion1-wg1-public-key>"
  laptop:
    addresses: { vpn1: 10.8.0.2, vpn2: 10.9.0.2 }
    spoke_pub_keys:
      vpn1: "<laptop-vpn1-profile-public-key>"
      vpn2: "<laptop-vpn2-profile-public-key>"

Det är allt. Allt annat - hubbarnas peer listor, tunnlarna för spoke noderna, brandväggsreglerna och verifieringen - fungerar utifrån dessa två dictionaries. En nyckel som jag ännu inte har skrivs som en vanlig platshållare, och varje roll behandlar en sådan platshållare som "hoppa över, ge inget fel" (jag kommer tillbaka till varför detta är viktigt).

Topologin och rollerna

Hubbar (vpn1, vpn2). Varje hubb har ett eget delnät - vpn1 använder 10.8.0.0/24 och vpn2 använder 10.9.0.0/24, enligt uppdelningen i olika oktetter som jag nämnde i början. Varje hubb fungerar som en WireGuard server på sitt eget virtuella nätverksgränssnitt, som alltid heter wg0. Den aktiverar IPv4 vidarebefordran och använder masquerading för sitt eget delnät, så att spoke noderna får fungerande routing.

Varje server är en spoke till alla andra hubbar. Det är hela poängen. bastion1 har en tunnel till båda hubbarna (wg0 går mot vpn1 och wg1 går mot vpn2). Varje hubb är också en spoke till den andra hubben genom en enda extra wg1 tunnel. Därför har mesh nätet ingen enskild punkt där ett nätverksfel kan stoppa allt. Om WireGuard konfigurationen på en hubb försvinner tappar jag inte åtkomsten till resten av miljön, eftersom den andra hubben fortfarande har vägar till alla maskiner.

Bastionen är den enda ingången. Ingen server tillåter publik SSH åtkomst. Alla servrar utom bastionen accepterar 22/tcp endast på sitt WireGuard gränssnitt och endast från bastionens adress i det aktuella delnätet. Bastionen accepterar i sin tur SSH endast från min laptops WireGuard adress. Vägen är därför alltid laptop -> (WireGuard) -> bastion1 -> (ssh -J) -> target.

Namnen på gränssnitten är medvetet fasta och tråkiga: wg0 går alltid mot vpn1 och wg1 går alltid mot vpn2. En hubb är aldrig en spoke till sig själv. Därför går hubbens enda extra spoke tunnel alltid via wg1, medan dess eget wg0 är reserverat för hubbservern. Den här regeln kontrolleras med en assertion i rollen wireguard_client, så att ett fel i konfigurationen ger ett tydligt felmeddelande i stället för att i tysthet skriva över hubbens egen nyckel:

- name: Verify no spoke tunnel on a hub host resolves to wg0 (RF6)
  ansible.builtin.assert:
    that:
      - item.interface != 'wg0'
    fail_msg: >-
      Hub host {{ inventory_hostname }} must never run a spoke tunnel on wg0 -
      that interface is reserved for its own hub server.
  loop: "{{ wireguard_client_tunnels }}"
  when: "'vpn' in group_names"

SSH hardening

Innan något arbete med nätverket görs säkras varje server. Jag lägger konfigurationen i en separat drop-in-fil i stället för att redigera den huvudsakliga sshd_config, och jag kontrollerar den med sshd -t innan den får börja gälla. En felaktig konfiguration når därför aldrig den aktiva SSH-tjänsten:

- name: Configure SSH hardening
  ansible.builtin.copy:
    dest: "{{ ssh_hardening_config_file }}"
    content: |
      PermitRootLogin {{ ssh_permit_root_login }}
      PasswordAuthentication {{ ssh_password_authentication }}
      KbdInteractiveAuthentication {{ ssh_kbd_interactive_authentication }}
      ChallengeResponseAuthentication {{ ssh_challenge_response_authentication }}
      PubkeyAuthentication {{ ssh_pubkey_authentication }}
      PermitEmptyPasswords {{ ssh_permit_empty_passwords }}
    validate: /usr/sbin/sshd -t -f %s
  notify: Restart ssh

En liten detalj från praktiken är värd att nämna: filen heter 00-ansible-hardening.conf, inte 99-.... Leverantörens systemavbildning innehåller en egen drop-in-fil med namnet 00-<provider_name>-auth.conf, och sshd använder den första förekomsten av varje inställning i alfabetisk ordning.

En fil med namnet 99-* skulle hamna efter leverantörens fil och förlora utan något tydligt felmeddelande. Genom att kalla min fil 00-ansible-* hamnar den först och får företräde. Inte särskilt elegant, men det fungerar.

Bastion är den enda vägen in

Ingen server tillåter publik SSH åtkomst. Regeln som ser till att detta följs skrivs inte för hand för varje server, utan skapas utifrån mesh konfigurationen. För varje hubb skapar jag en UFW regel som tillåter 22/tcp på gränssnittet som leder till den hubben, och endast från bastionens adress i hubbens delnät:

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

firewall_removed_tcp_ports:
  - 22

(Jinja har inget stöd för list comprehensions, därför använder jag mönstret med en namespace() ackumulator.)

Bastionen är det enda undantaget - group_vars/bastion.yml skriver över samma struktur så att SSH tillåts från laptopens adress i stället för från bastionens egen, eftersom bastionen fungerar som jump host. Vägen är därför alltid laptop -> (WireGuard) -> bastion1 -> (ssh -J) -> target:

# Till vardags når jag vpn1 genom att gå via bastionen över WireGuard:
ssh -J <username>@10.8.0.3 <username>@10.8.0.1

# Den publika IP-adressen avvisar däremot helt enkelt anslutningen:
ssh <username>@<vpn1-public-ip>

Varför två hubs hos två olika leverantörer?

Redundans som klarar ett avbrott hos leverantören, inte bara ett fel på en enskild maskin. Om ett datacenter får problem finns den andra hubben i ett helt separat nätverk med ett eget påverkansområde. Att vpn2 är en kall reserv håller driftkostnaden nära noll, samtidigt som jag har en beprövad och testad reservlösning. Jag startar den varje månad, kontrollerar att hela mesh nätet "blir grönt" och stänger sedan av den igen.

De två leverantörerna är inte heller konfigurerade på samma sätt. vpn1 ger direkt SSH åtkomst som root vid den första konfigurationen. Molnleverantören som kör vpn2 tillåter däremot inte root via SSH. Den skapar ett konto utan särskilda behörigheter och kräver att jag först ger det sudo åtkomst genom leverantörens konsol. Den kontrollerar också inkommande trafik med en VPC brandvägg som ligger framför serverns egen UFW brandvägg. WireGuard kunde inte ens skapa en anslutning förrän jag öppnade udp:51820 i det molnlagret, helt separat från UFW. På den servern består SSH skyddet därför av två lager: UFW på själva maskinen och leverantörens brandvägg framför den. Att öppna WireGuards UDP port mot hela internet är för övrigt inget problem - WireGuard svarar inte alls på trafik från peers som inte kan autentisera sig.

Hur en hub skapar sin peer

Hubbrollen (roles/wireguard) läser inte någon manuellt skriven lista över peers. Den går igenom wg_members, tar med varje medlem som har en riktig nyckel för den aktuella hubben och hoppar över platshållare med ett tydligt meddelande i stället för att ge ett fel:

- name: Collect hub peers from the mesh dictionary
  ansible.builtin.set_fact:
    wireguard_hub_peers: "{{ wireguard_hub_peers + [wireguard_hub_peer_candidate] }}"
  vars:
    wireguard_hub_peer_candidate:
      name: "{{ wireguard_hub_peer_display_names[item.key] | default(item.key) }}"
      public_key: "{{ item.value.spoke_pub_keys[inventory_hostname] }}"
      allowed_ips: "{{ item.value.addresses[inventory_hostname] }}/32"
      sort_key: "{{ item.value.addresses[inventory_hostname].split('.') | map('int') | list }}"
  loop: "{{ wg_members | dict2items }}"
  when:
    - item.key != inventory_hostname
    - item.value.addresses[inventory_hostname] is defined
    - (item.value.spoke_pub_keys[inventory_hostname] | default('')) | length > 0
    - not (item.value.spoke_pub_keys[inventory_hostname] | default('')).startswith('REPLACE_')

(startswith('REPLACE_') - om jag ännu inte har någon nyckel - är en "kallstart" för en ny host).

Peer posterna sorteras sedan efter adress. IP adressens fyra sifferdelar omvandlas till heltal (så att 10.8.0.10 hamnar efter 10.8.0.2, inte före den). Sorteringen är inte bara kosmetisk - den gör att den skapade konfigurationen blir identisk byte för byte mellan körningarna. När en ny medlem läggs till senare ändras därför inte ordningen på de befintliga raderna, och hanteraren för att starta om WireGuard utlöses inte i onödan på en hubb som annars inte har ändrats. Själva mallen är mycket liten:

{% raw %}[Interface]
Address = {{ wireguard_address }}
ListenPort = {{ wireguard_listen_port }}
PrivateKey = {{ wireguard_server_private_key }}

{% for peer in wireguard_hub_peers_sorted %}
[Peer]
# {{ peer.name }}
PublicKey = {{ peer.public_key }}
AllowedIPs = {{ peer.allowed_ips }}
{% endfor %}{% endraw %}

Hur en spoke skapar sina tunnlar

Spoke rollen (roles/wireguard_client) körs på varje server och skapar en tunnel till varje annan hubb. Den väljer det fasta gränssnittet och markerar tunneln som active endast om hubbens nyckel är riktig:

- name: Derive one spoke tunnel per other hub in the mesh
  ansible.builtin.set_fact:
    wireguard_client_tunnels: "{{ wireguard_client_tunnels + [wireguard_client_tunnel_entry] }}"
  vars:
    wireguard_client_tunnel_entry:
      hub: "{{ item.key }}"
      interface: "{{ 'wg1' if inventory_hostname in wg_hubs else wg_hub_interfaces[item.key] }}"
      address: "{{ wg_members[inventory_hostname].addresses[item.key] }}/32"
      hub_pub_key: "{{ item.value.hub_pub_key }}"
      endpoint: "{{ item.value.endpoint }}"
      allowed_ips: "{{ item.value.subnet }}"
      active: >-
        {{ (item.value.hub_pub_key | default('')) | length > 0
           and not (item.value.hub_pub_key | default('')).startswith('REPLACE_') }}
  loop: "{{ wg_hubs | dict2items }}"
  when: item.key != inventory_hostname

Varje tunnel använder split tunneling (AllowedIPs innehåller endast den aktuella hubbens delnät) och har PersistentKeepalive = 25, så att den förblir nåbar genom NAT. Varje tunnel meddelar också en handler för sitt eget gränssnitt (Restart wireguard client wg1). Därför kan en ändring i wg1 aldrig starta om wg0 och bryta den anslutning jag använder för den löpande administrationen.

Fördelar

  • Ingen enskild felpunkt i nätverkslagret. Två hubbar, två leverantörer och ett fullständigt mesh nät. Om vpn1 försvinner byter jag min laptop till profilen för vpn2 och kan fortfarande nå bastionen.
  • Ingen publik angreppsyta för SSH. De publika IP adresserna avvisar helt enkelt trafik till 22/tcp. Portskannrar hittar ingenting som svarar.
  • En enda källa till sanningen. För att lägga till en ny server i mesh nätet behövs bara en ny post i en dictionary. Roller, brandväggsregler och verifiering tar automatiskt hänsyn till den - inga ändringar i koden behövs.
  • Säkra mellanlägen. En peer som ännu inte har skapats representeras av en platshållarnyckel (REPLACE_). Roller hoppar helt enkelt över den i stället för att misslyckas. Mesh nätet förblir "grönt" medan en ny server bara är delvis färdig, och börjar verifieras fullt ut så snart den riktiga nyckeln läggs till.
  • Idempotent och möjlig att granska. En andra körning rapporterar inga ändringar. Allt finns i Git - endast publika nycklar. Privata nycklar skapas på varje server och lämnar den aldrig.

Nackdelar

  • Laptopens två VPN profiler kan inte användas samtidigt. Varje profil når bara sin egen hubbs delnät. I praktiken betyder det att en enda Ansible körning inte kan nå båda delnäten samtidigt - jag kör mot den ena hubbens servrar med den ena profilen och mot den andra hubben med den andra. Det fungerar bra idag. Om miljön växer över båda hubbarna kommer jag troligen att köra allt via bastionen, eftersom det är den enda servern som når båda delnäten.
  • Ett chicken-egg problem när åtkomsten låses ned. Eftersom SSH bara kan nås via WireGuard kan jag låsa ute mig själv mitt under en körning om jag aktiverar brandväggen innan tunneln och vägen genom bastion har testats. Därför är hela processen noggrant ordnad: starta tunneln, kontrollera från en annan terminal att ssh -J fungerar och aktivera först DÄREFTER brandväggen. Bastion tas sist, eftersom all åtkomst går genom den.
  • En hubb är inte helt färdig förrän dess brandväggsroll har körts. Det är den rollen som installerar regeln för NAT/masquerading. Innan den körs har en nykonfigurerad hubb IP vidarebefordran aktiverad men ingen NAT, så spoke noderna får en tunnel men ingen internetåtkomst. Det går att felsöka, men första gången är det inte helt självklart.
  • Skillnader mellan leverantörerna skapar extra mental belastning. Root eller inget root, bara UFW eller dessutom en VPC-brandvägg - jag måste hålla båda varianterna uppdaterade i dokumentationen.

Vad händer om jag förlorar en hub eller bastion?

Konfigurationen på en hubb går sönder (till exempel WireGuard konfigurationen). Det är inget större problem. Den andra hubben fortsätter att routa trafiken. Jag byter laptopens VPN profil till den andra hubben, ansluter till bastionen, hoppar vidare till den trasiga hubben och reparerar den genom att köra Ansible igen, eftersom Ansible repot är källan till den riktiga konfigurationen.

En hel hubb försvinner från nätverket, antingen för att den är trasig eller inte går att nå. Ur åtkomstsynpunkt är situationen densamma - den hubb som fortfarande fungerar ger mig fortsatt åtkomst. Om det är reservhubben vpn2 har jag inte förlorat något som var aktivt. Om det är vpn1 startar jag vpn2 enligt en dokumenterad, enkel process i flera steg och bygger sedan upp vpn1 från grunden igen. Hela serverkonfigurationen finns i repot, så en ny installation består av en bootstrap följd av en playbook körning, inte komplicerat detektivarbete.

Bastionen går sönder. Bastionen är den maskin i den aktiva åtkomstvägen som jag behandlar mest försiktigt, just eftersom all åtkomst går genom den. Därför är den alltid den sista servern jag ändrar vid en brandväggsändring, och jag stänger aldrig en fungerande session förrän jag har bekräftat från en andra terminal att en ny anslutning fungerar. Om bastionen ändå går sönder är leverantörens räddningskonsol reservlösningen, och repot används för att bygga upp den igen.

Kapitel 3: En verify.yml playbook

Att konfigurera en server är bara halva arbetet. Den andra halvan är att bevisa att den fortfarande är konfigurerad på det sätt jag tror. playbooks/verify.yml körs mot varje server, ändrar ingenting och kontrollerar hela basnivån: root inloggning är avstängd, lösenordsinloggning är avstängd, publika nycklar är aktiverade, UFW är aktivt, fail2ban är aktivt, timern för automatiska uppdateringar är aktiverad, värdnamnet är korrekt och - särskilt viktigt - det finns ingen generell SSH regel någonstans och varje administratörsnyckel som finns i repot också verkligen finns på servern.

Mönstret är hela tiden: kontrollera först, gör sedan en assertion. Ett skrivskyddat kommando körs och dess resultat sparas (changed_when: false), och därefter kontrolleras resultatet med assert:

- name: Check effective SSH configuration
  ansible.builtin.command: /usr/sbin/sshd -T
  register: verify_sshd_config
  changed_when: false

- name: Verify root SSH login is disabled
  ansible.builtin.assert:
    that:
      - "'permitrootlogin no' in verify_sshd_config.stdout"
    fail_msg: "Root SSH login is not disabled."

Den kontroll jag värderar högst är den negativa - att bevisa att det som inte får finnas verkligen inte finns:

- name: Verify global SSH access is absent
ansible.builtin.assert:
  that:
    - >-
      verify_ufw_ssh.stdout_lines
      | select('match', '^22/tcp\s')
      | reject('search', 'wg0')
      | reject('search', 'wg1')
      | list | length == 0
  fail_msg: "UFW still has a global 22/tcp rule; SSH must only be reachable over WireGuard."

Och eftersom nycklarna skyddas av hårdvara och det finns flera av dem, läser verify.yml tillbaka authorized_keys från servern och kontrollerar att varje publik nyckel som finns i repot också finns där:

- name: Read admin user authorized_keys file
  ansible.builtin.slurp:
    src: "/home/{{ admin_user }}/.ssh/authorized_keys"
  register: verify_authorized_keys_raw
  changed_when: false

- name: Verify all admin SSH public keys are present
  ansible.builtin.assert:
    that:
      - item in (verify_authorized_keys_raw.content | b64decode)
  loop: "{{ admin_user_ssh_public_keys }}"

Det fina är att verify.yml styrs av samma mesh dictionary som rollerna. Tidigt i playbooken skapar den faktalistor för varje server och märker varje hubb och peer med två flaggor: key_real - nyckeln är inte en platshållare - och in_inventory - hubben finns för närvarande i inventory. En strikt kontroll görs bara när båda är sanna. Annars visas en varning utan att körningen avbryts:

- name: Verify SSH is allowed on each verified interface from the expected source
  ansible.builtin.assert:
    that:
      - >-
        verify_ufw_ssh.stdout_lines
        | select('search', '22/tcp on ' + item.interface)
        | select('search', item.source)
        | select('search', 'ALLOW')
        | list | length > 0
  loop: "{{ verify_wg_interfaces }}"
  when: item.verified | bool

Det enda villkoret med when: gör att hela verifieringen kan fortsätta vara grön även när reservhubben vpn2 är bortkommenterad i inventory. Så snart dess nycklar är riktiga och den tas med i inventory igen börjar verify.yml automatiskt göra strikta kontroller av vpn2 - utan någon ändring i själva playbooken.

Det förväntade resultatet av en körning är failed=0, unreachable=0 och helst även changed=0 från site.yml. Det sista fungerar som min varningssignal för att idempotensen fortfarande fungerar.

Som jag skrev tidigare utvecklas verifieringen och konfigurationen från samma källa till sanningen, så de kan inte långsamt glida isär utan att det märks. Nackdelen är att vissa assertions är beroende av hur ufw status visar sina kolumner. Om UFW ändrar formatet i en framtida version kan ett filter som använder select('search', ...) behöva justeras.

Kapitel 4: Att lägga till en ny server

Det här är vinsten med att styra allt genom wg_hubs och wg_members. Att lägga till en ny maskin i mesh nätet är i normala fall bara en ändring i konfigurationen och datan. Arbetsflödet är:

1. Lägg till servern i inventory (host_vars/<host>.yml med common_hostname, samt en rad i hosts.ini under rätt grupp).

2. Lägg till en post i wg_members och även i wg_hubs om det är en ny hubb. Använd platshållare som börjar med REPLACE_* för nycklar som jag ännu inte har.

3. Kör bootstrap och lägg på basnivån. Så länge nycklarna fortfarande är platshållare hoppas tunnlarna över utan att körningen misslyckas. De lokala nyckelparen skapas ändå, så att jag kan läsa ut de publika delarna:

ansible-playbook playbooks/bootstrap.yml -l <host> -e ansible_user=root
ansible-playbook playbooks/site.yml -l <host>

ansible <host> -m command -a 'cat /etc/wireguard/publickey-wg0' --become
ansible <host> -m command -a 'cat /etc/wireguard/publickey-wg1' --become

4. Klistra in de riktiga publika nycklarna i stället för platshållarna REPLACE_* och kör sedan de två WireGuard rollerna igen, så att den nya peeren och tunnlarna aktiveras i hela mesh nätet:

ansible-playbook playbooks/site.yml --tags wireguard,wireguard_client

5. Kontrollera från en separat terminal att tunneln och anslutningen via ssh -J fungerar, och lås först därefter brandväggen - alltid med --limit, aldrig genom att köra allt blint, alltså:

ansible-playbook playbooks/site.yml --tags firewall --limit <host>

6. Kör ansible-playbook playbooks/verify.yml som enligt beskrivningen ovan nu automatiskt gör strikta kontroller av den nya servern.

Det är konventionen med REPLACE_* som gör steg 3 säkert. En server som bara är delvis färdig är ett stabilt och giltigt läge i datan, inte ett fel. Mesh nätet förblir grönt under hela införandet, och jag behöver aldrig kommentera bort eller aktivera kod för att lägga till en ny maskin.

Kompromissen är att den här elegansen bygger på ganska kompakt och avancerad Jinja kod: namespace() ackumulatorer, loopar med dict2items och kontroller av platshållare. Det kräver mer läsning än en hårdkodad konfiguration, och läsaren måste till en början lita på att "allt skapas från datan" innan varje filter har följts och förståtts.

Det är ett medvetet val. För en miljö som bara kommer att växa betalar den datadrivna modellen tillbaka sin extra komplexitet många gånger om. För tre eller fem statiska servrar hade det varit för tidigt. Jag bygger för den utveckling jag räknar med, inte för det nuvarande antalet servrar. Det är samma beslut som jag skulle fatta för vilket system som helst som jag förväntar mig ska leva längre än sin första design.

Kapitel 5: Att driftsätta en riktig app

Hela poängen med den säkra basnivån är att riktiga arbetslaster nu kan byggas ovanpå den, så att resultatet i stora drag fungerar som ett privat cloud. Jag visar det genom att gå igenom hur jag lägger till en enkel PostgreSQL databas och en FastAPI tjänst, med säkerhetstester som en del av processen och driftsättning i Kubernetes - för ett homelab som inte kan driftsätta en app är trots allt bara ett labb.

Steg 1: PostgreSQL som ett hanterat byggblock

Jag kör PostgreSQL i klustret, eller på en särskild server i mesh nätet om jag vill hålla databasen utanför den kritiska vägen. Jag skriver aldrig lösenordet manuellt någonstans. Vault skapar kortlivade inloggningsuppgifter till databasen när de behövs. Här är ett enkelt Kubernetes StatefulSet för PostgreSQL, där lösenordet hämtas från en Secret som fylls i av Vault:

apiVersion: apps/v1
kind: StatefulSet
metadata:
  name: postgres
spec:
  serviceName: postgres
  replicas: 1
  selector:
    matchLabels: { app: postgres }
  template:
    metadata:
      labels: { app: postgres }
    spec:
      containers:
        - name: postgres
          image: postgres:18
          ports:
            - containerPort: 5432
          env:
            - name: POSTGRES_DB
              value: appdb
            - name: POSTGRES_USER
              value: app
            - name: POSTGRES_PASSWORD
              valueFrom:
                secretKeyRef:
                  name: postgres-credentials
                  key: password
          volumeMounts:
            - name: data
              mountPath: /var/lib/postgresql/data
  volumeClaimTemplates:
    - metadata: { name: data }
      spec:
        accessModes: ["ReadWriteOnce"]
        resources:
          requests: { storage: 10Gi }

Observera att samma princip gäller här som i mesh nätet - hemligheter sparas aldrig i repot. Manifestet hänvisar till en Secret (som kommer från Vault).

Steg 2: En FastAPI app

Själva appen är medvetet liten - en endpoint för hälsokontroll och en route som kommunicerar med PostgreSQL:

import os
import asyncpg
from fastapi import FastAPI

app = FastAPI()
DB_DSN = os.environ["DB_DSN"]

@app.get("/health")
async def health():
  return {"status": "ok"}

@app.get("/widgets/{widget_id}")
async def get_widget(widget_id: int):
  connection = await asyncpg.connect(DB_DSN)
  try:
    row = await connection.fetchrow(
      "SELECT id, name FROM widgets WHERE id = $1", widget_id
    )
       
    if row:
      return dict(row) 
               
    return {"status": "error", "details": "Object not found in database"}

  finally:
    await connection.close()

Containeriserad med en liten, versionslåst basavbildning (versionslåsningen är viktig för skanningen nedan):

FROM python:3.14-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY app/ ./app/
EXPOSE 8000
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]

Steg 3: GitLab pipeline

Det här är den del som jag verkligen rekommenderar att inte hoppa över, eftersom idén om ett "privat cloud" bara är trovärdig om även leveranskedjan övervakas. Min GitLab pipeline körs vid varje push, ungefär i följande ordning:

  • Kontroll av beroenden och gamla paket - hittar föråldrade och sårbara Python-beroenden innan de når driftmiljön.
  • Sårbarhetsskanning av den byggda avbildningen - skannar containern efter kända CVE sårbarheter i operativsystemets paket och bibliotek, och stoppar bygget vid sårbarheter med hög allvarlighetsgrad.
  • Statisk analys - en kvalitetskontroll i SonarQube tillsammans med en Python linter.
  • Driftsättning i Kubernetes - endast om alla kontroller ovan är godkända.

Här är en förkortad .gitlab-ci.yml fil som visar hur det är uppbyggt:

stages: [test, security, deploy]

dependency-audit:
 stage: security
 script:
   - pip install pip-audit
   - pip-audit -r requirements.txt
   - pip list --outdated

sast:
 stage: security
 script:
   - pip install bandit
   - bandit -r app/
   - sonar-scanner

image-scan:
 stage: security
 script:
   - docker build -t "$IMAGE" .
   - trivy image --exit-code 1 --severity HIGH,CRITICAL "$IMAGE"

deploy:
 stage: deploy
 needs: [dependency-audit, sast, image-scan]
 script:
   - kubectl apply -f k8s/postgres.yaml
   - kubectl apply -f k8s/fastapi.yaml
   - kubectl rollout status deployment/fastapi
 environment: homelab

Verktygen kan bytas ut mot andra alternativ (pip-audit/safety, bandit, trivy/grype, SonarQube) - det viktiga är principen: kontroll av föråldrade beroenden, sårbarhetsskanning och statisk analys fungerar alla som obligatoriska steg, och driftsättningen sker först när samtliga är godkända. Det är den säkerhetsnivå jag skulle kräva av en produktionsplattform... Det finns ingen anledning att ställa lägre krav på en personlig plattform.

Steg 4: Gör tjänsten tillgänglig och övervaka den

nginx, eller en ingress controller, står framför tjänsten. Eftersom hela klustret finns inne i WireGuard meshnätet kan jag hålla det helt privat. Jag kan också göra en enda noggrant kontrollerad route tillgänglig genom den svenska hubben om jag vill att tjänsten ska nås från en svensk IP adress. Prometheus hämtar data från /metrics, Loki samlar in loggarna via alloy och Grafana visar båda. Om något börjar fungera fel klockan tre på natten har jag därför samma övervakning och insyn som jag skulle kräva av mig själv på jobbet.

Fördelar med att tänka på det som ett "privat cloud"

  • Kontroll från början till slut. Källkod, CI, skanning, hemligheter, körmiljö, lagring, ingress och övervakning är allt mitt, på hårdvara som jag kontrollerar (vissa servrar står bokstavligen "under mitt skrivbord"), inne i ett nätverk utan någon publik angreppsyta.
  • Realistisk träning. Arbetsflödet liknar en produktionsplattform tillräckligt mycket för att kunskaperna ska kunna användas direkt i det dagliga arbetet.
  • Kostnad och integritet. Ingen betalning per anrop, inga prisnivåer, ingen tredje part i datavägen, och sidoprojekt kan utvecklas privat så länge jag vill.

Nackdelar - de ärliga förbehållen

  • Nu är det jag som är plattformsteamet. Varje uppgradering, CVE, certifikatbyte och säkerhetskopia är mitt ansvar. En hanterad molnplattform tar hand om en enorm mängd löpande driftarbete som jag medvetet väljer att ta tillbaka.
  • "Privat cloud" är en överdrift. Jag har inget kontrollplan i flera regioner, ingen hanterad automatisk skalning, och min "höga tillgänglighet" består av två hubbar och en reservlösning. Det är fullt tillräckligt för ett hobbyprojekt, men inte för ett kontrollsystem för rymdfärder.
  • Konsekvenserna av mina egna misstag. Samma kontroll som lär mig mest är också det som kan slå ut allt på en gång om jag är oförsiktig. Det är därför avsnitten om återställning nedan finns.

Kapitel 6: Säkerhetskopiering av WireGuard-nycklarna

Här är fällan som hela lösningen skapar. Eftersom SSH bara kan nås via WireGuard, och WireGuard behöver laptopens privata nyckel, skapar en trasig laptop följande cirkel:

  • för att logga in via SSH behöver jag WireGuard;
  • för att använda WireGuard behöver jag laptopens privata nyckel;
  • den privata nyckeln fanns bara på den trasiga laptopen.

YubiKeys skyddar min SSH-identitet, men de hjälper inte med WireGuards privata nyckel, som bara finns på laptopen (det är alltså en SPOF). Därför sparar jag en offline säkerhetskopia av båda tunnelprofilerna - hela .conf filen för varje tunnel, inklusive den privata nyckeln - på samma krypterade USB minnen och på samma säkra platser som mina YubiKeys. Fyra kopior på flera platser, alltså samma modell för redundans.

En profil för laptopen är en vanlig WireGuard .conf fil. Den har samma struktur som rollen wireguard_client skapar för servrarna, men använder en full tunnel:

[Interface]
Address = 10.8.0.2/32
PrivateKey = <laptop-vpn1-private-key>

[Peer]
PublicKey = <vpn1-hub-public-key>
Endpoint = <vpn1-public-ip>:51820
AllowedIPs = 0.0.0.0/0
PersistentKeepalive = 25

Allt annat i filen är redan offentligt och finns i repot - adressen, hubbens publika nyckel och endpointen. Det är bara den privata nyckeln som inte går att ersätta, och det är precis den som säkerhetskopian skyddar. När jag gör säkerhetskopian kontrollerar jag att jag har fått med rätt nyckel genom att skapa den publika nyckeln från den privata igen och jämföra den med den som finns i repot:

grep PrivateKey ~/wg-backup/vpn1.conf | awk '{print $3}' | wg pubkey

(måste vara samma som wg_members.laptop.spoke_pub_keys.vpn1 i group_vars/all.yml)

Säkerhetskopian är statisk - laptopens nycklar roteras inte automatiskt - så jag behöver bara uppdatera den om jag någon gång skapar en ny nyckel för laptopen eller om en hubb får nya nycklar.

Den fasta regeln är att dessa .conf filer aldrig får hamna i repot. Repot innehåller endast publika nycklar. De privata nycklarna stannar självklart på de krypterade USB minnena.

Med den säkerhetskopian på plats blir "laptopen dog" en checklista på femton minuter: återställ SSH nyckelhandtaget från en YubiKey, installera WireGuard igen, importera de två .conf filerna, aktivera en profil och anslut till bastionen. Utan säkerhetskopian återstår bara den medvetet besvärliga sista utvägen - att använda leverantörens separata konsol för att öppna ett skal på en hubb, skapa en ny nyckel manuellt och ändra mesh konfigurationen. Det är precis den situation som offline säkerhetskopian finns för att undvika.

Epilog

Det genomgående temat i allt detta är: gör återställningsplanen tydlig för varje resurs innan du behöver använda den. För varje sak jag kan förlora - en YubiKey, laptopen, konfigurationen på en hubb, en hel hubbserver eller bastionen - vet jag exakt vad som slutar fungera, vad som finns kvar och vilka steg som krävs för att återställa allt. De flesta återställningar är medvetet ganska odramatiska, just eftersom jag planerade redundansen från början (regelbundet övar på olika felsituationer och har skrivit ned hela processen).

Det är mer noggrannhet än vad ett hobbyprojekt ofta får - och det är precis poängen. Det extra besväret är en del av lösningen: en identitet som är förankrad i hårdvara, ingen publik SSH åtkomst, ett mesh nät hos två leverantörer utan någon enskild felpunkt, all konfiguration i idempotent kod och en dokumenterad reservplan för den riktigt dåliga dagen.

Inget av detta är särskilt exotiskt. Det är helt enkelt den standard jag skulle använda för allt som jag själv ansvarar för. Det är grunden. Ovanpå något som jag faktiskt litar på kan jag sedan bygga den intressanta delen - en plattform som liknar ett privat moln för mina projekt på fritiden, med ett PostgreSQL kluster, FastAPI, Kubernetes, Kafka, CI/CD med obligatoriska säkerhetskontroller, övervakning och en lösning för hantering av hemligheter.

Bilaga: Får den svenska hubben mig verkligen att verka 🇸🇪 svensk?

Jag vill återkomma till detta en stund. Att köra min egen WireGuard hubb på en fast offentlig IP adress i Sverige, antingen från ett privat bredbandsabonnemang eller ett datacenter, hjälper verkligen jämfört med en stor kommersiell VPN tjänst. Men det är ingen magisk lösning, och det är värt att vara tydlig med varför:

  • IP-adressen är stabil och delas inte med andra. Kommersiella VPN tjänster delar ut adresser från pooler som tusentals personer växlar mellan. Dessa adressområden är välkända och markeras ofta som misstänkta. Min hubb har en enda IP adress som inte ändras och som inte har med sig något sådant dåligt rykte. Den beter sig som en vanlig internetanslutning, eftersom det faktiskt är en enda anslutning.
  • Men det spelar fortfarande roll hur IP adressen klassificeras. Det som oftast avslöjar en kommersiell VPN är att adressen tillhör ett ASN eller adressområde som offentligt är märkt som "hosting", "VPN" eller "proxy". Om min svenska hubb använder en IP adress från ett datacenter kan en noggrann webbplats fortfarande se att den tillhör en hostingleverantör och inte ett vanligt bredbandsabonnemang. Jag skulle därför se ut som "en server i Sverige", inte nödvändigtvis som "en svensk person hemma i soffan". En fast privat IP adress från en svensk internetleverantör skulle se mycket mer ut som en lokal användare än vad en kommersiell VPN vanligtvis kan göra.
  • Resten av fingeravtrycket måste också stämma. Att uppfattas som svensk handlar förstås om mer än IP adressen. Tidszon, webbläsarens språk och locale, språkhuvuden, DNS-serverns plats, WebRTC läckor, operativsystemets inställningar och andra enheter i nätverket kan också granskas. Eftersom min hubb kan fungera som en riktig full tunnel, med DNS låst till den svenska sidan, kan jag få alla dessa delar att stämma överens. Det är ofta just här kommersiella VPN tjänster misslyckas i bakgrunden, till exempel genom att använda en DNS server i ett annat land eller läcka en lokal IP adress.

Så ja: det är tydligt mer övertygande än en dyr VPN endpoint med en IP adress som hela tiden byts ut. Det beror främst på att jag har en egen, fast IP adress utan dåligt rykte och ett konsekvent tekniskt fingeravtryck. Skillnaden skulle bli ännu större med en privat bredbandsadress i stället för en adress från ett datacenter.

Det är definitivt inte ett verktyg för att kringgå någon tjänsts användarvillkor. Det handlar om att ha en legitim och konsekvent närvaro på en marknad som jag faktiskt använder. Och det är ett bra exempel på temat för hela det här inlägget: när du själv kontrollerar infrastrukturen från början till slut är det också du som kan se till att de tråkiga detaljerna blir rätt - vilken IP-adress, vilken DNS-server och vilken region.