Wie ich meinen SSH-Server mit Traefik und einem dynamischen IP-Filter abgesichert habe

Ich bin immer ein wenig paranoid, wenn ich meinen SSH-Port offen im Internet lasse. Es fühlt sich an wie eine offene Einladung für Bots, anzuklopfen. Ich wollte einen Weg finden, ihn so abzusichern, dass nur ich darauf zugreifen kann, selbst wenn sich meine IP-Adresse ändert, weil ich unterwegs bin oder mein ISP beschließt, mir eine neue zu geben.

Sichere SSH-Zugriffe mit Traefik und Whitelist-Middleware
Dieses Diagramm zeigt, wie Traefik als Proxy mit Whitelist-Middleware genutzt wird, um SSH-Verbindungen vom Internet nur für freigegebene IP-Adressen zu erlauben und sicher an den internen SSH-Dienst weiterzuleiten.

Nach einigem Herumprobieren habe ich eine ziemlich coole Lösung mit Traefik als Reverse-Proxy gefunden. So habe ich es gemacht.

Schritt 1: sshd zu einer rein lokalen Angelegenheit machen

Zuerst wollte ich nicht, dass sshd überhaupt von außen erreichbar ist. Also habe ich meine /etc/ssh/sshd_config-Datei bearbeitet und ListenAddress auf Folgendes geändert:

ListenAddress 127.0.0.1

Das weist sshd an, nur auf Verbindungen vom Server selbst zu lauschen. Ein kurzer Neustart des sshd-Dienstes, und er war vom Internet abgeschottet.

Vorsicht! Wenn du das falsch konfigurierst, könntest du dich von deinem Server aussperren. Habe immer eine alternative Zugriffsmethode, wie eine Konsole oder einen Wiederherstellungsmodus, bevor du Änderungen an deiner SSH-Konfiguration vornimmst.

sudo systemctl restart sshd

Schritt 2: Traefik mit Docker Compose einrichten

Als Nächstes habe ich Traefik mit Docker Compose eingerichtet. Die Idee ist, dass Traefik auf einem öffentlichen Port lauscht und den Verkehr dann an meinen nur lokal erreichbaren sshd weiterleitet.

Hier ist meine docker-compose.yml:

version: '3'

services:
  traefik:
    image: "traefik:v2.9"
    container_name: "traefik"
    ports:
      - "2222:2222" # Mein neuer öffentlicher SSH-Port
    volumes:
      - "/var/run/docker.sock:/var/run/docker.sock:ro"
      - "./traefik.yml:/traefik.yml:ro"
      - "./dynamic_conf.yml:/dynamic_conf.yml:ro"
    restart: unless-stopped

Das richtet Traefik ein und macht Port 2222 für die Welt verfügbar. Es bindet auch ein paar Konfigurationsdateien ein, in denen die Magie passiert.

Meine statische Konfiguration in traefik.yml ist super einfach:

entryPoints:
  ssh:
    address: ":2222"

providers:
  file:
    filename: "/dynamic_conf.yml"
    watch: true

Das weist Traefik nur an, einen Eingangspunkt namens ssh auf Port 2222 zu erstellen und dynamic_conf.yml auf Änderungen zu überwachen.

Schritt 3: Die dynamische Konfiguration und das IP-Whitelisting

Das ist der Kern des Setups. In dynamic_conf.yml richte ich eine Middleware ein, die nur Verkehr von meiner IP-Adresse zulässt.

tcp:
  middlewares:
    ip-whitelist:
      ipWhiteList:
        sourceRange:
          - "1.2.3.4" # Hier kommt meine IP rein

  routers:
    ssh-router:
      entryPoints:
        - "ssh"
      rule: "HostSNI(`*`)"
      service: "sshd-service"
      middlewares:
        - "ip-whitelist"
      tls: {}

  services:
    sshd-service:
      loadBalancer:
        servers:
          - address: "172.17.0.1:22" # Die IP des Docker-Hosts

Diese Konfiguration macht ein paar Dinge:

  • Es erstellt eine Middleware namens ip-whitelist, die angibt, welche IPs erlaubt sind.
  • Es richtet einen Router ein, der auf dem ssh-Eingangspunkt lauscht und die ip-whitelist-Middleware anwendet.
  • Es definiert einen Dienst, der auf meinen sshd verweist, der jetzt nur noch von innerhalb von Docker unter 172.17.0.1:22 erreichbar ist.

Schritt 4: Automatisierung von IP-Updates mit einem Web-Service

Die manuelle Aktualisierung der IP-Adresse ist mühsam. Um dieses Problem zu lösen, habe ich einen kleinen Web-Service entwickelt, der einen sicheren API-Endpunkt bereitstellt, um die IP-Adresse in der Traefik-Konfigurationsdatei automatisch zu aktualisieren.

Um die Dinge organisiert zu halten, erstellen wir ein neues Verzeichnis namens ip-api für unseren neuen Dienst. In diesem Verzeichnis legen wir die folgenden Dateien ab.

Das Dockerfile

Zuerst das Dockerfile, um unsere Node.js-Anwendung in einen Container zu packen. Dieses richtet eine Node.js 20-Umgebung ein, installiert Abhängigkeiten, kopiert den Quellcode und führt den Server aus Sicherheitsgründen als Nicht-Root-Benutzer (node) aus.

FROM node:20-alpine

WORKDIR /app

# Install dependencies
COPY package.json ./
RUN npm install --omit=dev

# Copy application source
COPY server.js ./

ENV NODE_ENV=production \
    PORT=8080

EXPOSE 8080

USER node

CMD ["node", "server.js"]

Die package.json

Als Nächstes benötigen wir eine package.json, um unser Projekt und seine Abhängigkeiten zu definieren. Unser Dienst benötigt express für den Webserver und js-yaml zur Verarbeitung der YAML-Konfigurationsdatei.

{
  "name": "ip-api",
  "version": "1.0.0",
  "description": "API to update home IP in Traefik middleware",
  "main": "server.js",
  "scripts": {
    "start": "node server.js"
  },
  "dependencies": {
    "express": "^4.18.2",
    "js-yaml": "^4.1.0"
  }
}

Die server.js

Dies ist das Herzstück unseres Dienstes. Es ist eine Express.js-Anwendung, die auf eine POST-Anfrage an /update-ip wartet.

  • Authentifizierung: Es wird ein sicheres Token benötigt, das im X-Auth-Token-Header übergeben wird. Es verwendet einen zeitlich konstanten Vergleich, um Timing-Angriffe zu verhindern.
  • IP-Validierung: Es validiert und normalisiert die angegebene IP-Adresse und verarbeitet automatisch sowohl reine IPs als auch CIDR-Bereiche.
  • YAML-Manipulation: Es liest die vorhandene dynamische Konfigurationsdatei von Traefik sicher aus, aktualisiert die IP in unserer ip-whitelist-Middleware und schreibt die Datei zurück, wobei alle anderen Einstellungen erhalten bleiben.

Ich habe den Code so angepasst, dass er perfekt zur in Schritt 3 konfigurierten ip-whitelist-Middleware und zum im Blogbeitrag verwendeten traefik:v2.9-Image passt.

const express = require('express');
const fsp = require('fs/promises');
const path = require('path');
const yaml = require('js-yaml');
const crypto = require('crypto');
const net = require('net');

const PORT = process.env.PORT ? Number(process.env.PORT) : 8080;
const IP_FILE_PATH = process.env.IP_FILE_PATH || '/data/dynamic_conf.yml';
const AUTH_TOKEN = process.env.AUTH_TOKEN || '';

if (!AUTH_TOKEN || AUTH_TOKEN.length < 32) {
  console.error('ERROR: AUTH_TOKEN must be provided and at least 32 characters long.');
  process.exit(1);
}

const app = express();
app.use(express.json());

// Simple request logger
app.use((req, res, next) => {
  if (req.path !== '/healthz') {
    console.log(`${new Date().toISOString()} ${req.method} ${req.path}`);
  }
  next();
});

// Constant-time token validation using hashing to avoid length leaks
function tokenValidFromHeader(req) {
  const headerToken = req.header('X-Auth-Token') || '';
  try {
    const a = crypto.createHash('sha256').update(headerToken, 'utf8').digest();
    const b = crypto.createHash('sha256').update(AUTH_TOKEN, 'utf8').digest();
    return crypto.timingSafeEqual(a, b);
  } catch (e) {
    return false;
  }
}

// Auth middleware except for health endpoint
app.use((req, res, next) => {
  if (req.path === '/healthz') return next();
  if (!tokenValidFromHeader(req)) {
    return res.status(401).json({ error: 'Unauthorized' });
  }
  next();
});

function normalizeIpCidr(input) {
  if (!input || typeof input !== 'string') {
    throw new Error('IP value must be a non-empty string');
  }
  const trimmed = input.trim();
  const hasSlash = trimmed.includes('/');
  let ip = trimmed;
  let cidrStr;
  if (hasSlash) {
    const parts = trimmed.split('/');
    if (parts.length !== 2) throw new Error('Invalid IP/CIDR format');
    ip = parts[0];
    cidrStr = parts[1];
  }
  const ipVersion = net.isIP(ip);
  if (ipVersion === 0) {
    throw new Error('Invalid IP address');
  }
  let cidr;
  if (cidrStr === undefined || cidrStr === '') {
    cidr = ipVersion === 6 ? 128 : 32;
  } else {
    const n = Number(cidrStr);
    if (!Number.isInteger(n)) throw new Error('CIDR must be an integer');
    if (ipVersion === 4 && (n < 0 || n > 32)) throw new Error('CIDR for IPv4 must be between 0 and 32');
    if (ipVersion === 6 && (n < 0 || n > 128)) throw new Error('CIDR for IPv6 must be between 0 and 128');
    cidr = n;
  }
  return { ip, cidr, ipCidr: `${ip}/${cidr}` };
}

function ensureStructure(obj) {
  if (!obj || typeof obj !== 'object') obj = {};
  if (!obj.tcp) obj.tcp = {};
  if (!obj.tcp.middlewares) obj.tcp.middlewares = {};
  if (!obj.tcp.middlewares['ip-whitelist']) obj.tcp.middlewares['ip-whitelist'] = {};
  if (!obj.tcp.middlewares['ip-whitelist'].ipWhiteList) obj.tcp.middlewares['ip-whitelist'].ipWhiteList = {};
  if (!Array.isArray(obj.tcp.middlewares['ip-whitelist'].ipWhiteList.sourceRange)) {
    obj.tcp.middlewares['ip-whitelist'].ipWhiteList.sourceRange = [];
  }
  return obj;
}

async function readYamlConfig(filePath) {
  try {
    const content = await fsp.readFile(filePath, 'utf8');
    const data = yaml.load(content) || {};
    return ensureStructure(data);
  } catch (err) {
    if (err.code === 'ENOENT') {
      return ensureStructure({});
    }
    throw err;
  }
}

async function writeYamlConfig(filePath, data) {
  const dir = path.dirname(filePath);
  await fsp.mkdir(dir, { recursive: true });
  const yamlStr = yaml.dump(data, { lineWidth: 120 });
  await fsp.writeFile(filePath, yamlStr, 'utf8');
}

function getCurrentSourceRanges(data) {
  const tcpRanges = data?.tcp?.middlewares?.['ip-whitelist']?.ipWhiteList?.sourceRange || [];
  return { tcpRanges };
}

function updateRangesIfChanged(data, ipCidr) {
  const { tcpRanges } = getCurrentSourceRanges(data);
  const currentTcp = tcpRanges[0];
  const needsUpdate = currentTcp !== ipCidr || tcpRanges.length !== 1;
  if (needsUpdate) {
    data.tcp.middlewares['ip-whitelist'].ipWhiteList.sourceRange = [ipCidr];
  }
  return { updated: needsUpdate };
}

app.get('/healthz', (req, res) => {
  res.status(200).send('ok');
});

app.post('/update-ip', async (req, res) => {
  try {
    const provided = req.body?.ip || req.body?.ipCidr;
    if (!provided) {
      return res.status(400).json({ error: 'Missing ip (optionally with CIDR) in request body' });
    }
    const { ip, cidr, ipCidr } = normalizeIpCidr(provided);

    let data = await readYamlConfig(IP_FILE_PATH);
    const { updated } = updateRangesIfChanged(data, ipCidr);
    if (updated) {
      await writeYamlConfig(IP_FILE_PATH, data);
    }
    data = await readYamlConfig(IP_FILE_PATH);
    const { tcpRanges } = getCurrentSourceRanges(data);

    return res.json({
      updated,
      ip,
      cidr,
      ipCidr,
      tcp: tcpRanges,
      filePath: IP_FILE_PATH
    });
  } catch (err) {
    console.error('Error in /update-ip:', err);
    return res.status(400).json({ error: err.message || 'Invalid request' });
  }
});

app.listen(PORT, () => {
  console.log(`ip-api listening on port ${PORT}`);
  console.log(`Using file: ${IP_FILE_PATH}`);
});

Aktualisierung der Traefik-Konfiguration

Um unseren neuen API-Dienst über Traefik bereitzustellen, müssen wir einen HTTPS-Entrypoint aktivieren. Wir aktualisieren die traefik.yml, um web- und websecure-Entrypoints hinzuzufügen und einen Let's Encrypt-Zertifikats-Resolver zu konfigurieren. Stell sicher, dass du your-email@example.com durch deine tatsächliche E-Mail-Adresse ersetzt.

entryPoints:
  ssh:
    address: ":2222"
  web:
    address: ":80"
  websecure:
    address: ":443"

providers:
  docker:
    exposedByDefault: false
  file:
    filename: "/dynamic_conf.yml"
    watch: true

certificatesResolvers:
  letsencrypt:
    acme:
      email: "your-email@example.com"
      storage: "acme.json"
      httpChallenge:
        entryPoint: "web"

Aktualisierung der Docker-Compose-Datei

Zuletzt aktualisieren wir unsere docker-compose.yml, um alles zusammenzufügen.

  • Wir fügen den Dienst ip-api hinzu und weisen ihn an, aus dem Verzeichnis ./ip-api zu bauen.
  • Wir übergeben ihm ein sicheres AUTH_TOKEN als Umgebungsvariable. Denk daran, dieses zu ändern!
  • Wir fügen die Ports 80 und 443 zum traefik-Dienst für die Let's Encrypt-Challenge und unsere API hinzu.
  • Wir fügen ein Volume für acme.json hinzu, um Zertifikate zu persistieren. Du musst diese Datei vor dem Start erstellen: touch acme.json && chmod 600 acme.json.
version: '3'

services:
  traefik:
    image: "traefik:v2.9"
    container_name: "traefik"
    ports:
      - "80:80"
      - "443:443"
      - "2222:2222" # Mein neuer öffentlicher SSH-Port
    volumes:
      - "/var/run/docker.sock:/var/run/docker.sock:ro"
      - "./traefik.yml:/traefik.yml:ro"
      - "./dynamic_conf.yml:/dynamic_conf.yml:ro" # Traefik benötigt nur Lesezugriff
      - "./acme.json:/acme.json" # Zertifikate persistieren
    restart: unless-stopped

  ip-api:
    build: ./ip-api
    environment:
      # ÄNDERN SIE DIES zu einer langen, zufälligen, sicheren Zeichenfolge
      - AUTH_TOKEN=a_very_long_and_secure_secret_token_here
      - IP_FILE_PATH=/data/dynamic_conf.yml
    labels:
      - "traefik.enable=true"
      # ÄNDERN SIE DIES zu Ihrer tatsächlichen Domain
      - "traefik.http.routers.homeip-api.rule=Host(`api.your-domain.com`)"
      - "traefik.http.routers.homeip-api.entrypoints=websecure"
      - "traefik.http.services.homeip-api.loadbalancer.server.port=8080"
      - "traefik.http.routers.homeip-api.tls=true"
      - "traefik.http.routers.homeip-api.tls.certResolver=letsencrypt"
    volumes:
      - ./dynamic_conf.yml:/data/dynamic_conf.yml # Die zu ändernde Datei einbinden
    restart: unless-stopped

Schritt 5: Ein einfaches Skript, um meine IP auf dem neuesten Stand zu halten

Das letzte Teil des Puzzles ist ein Skript auf meinem lokalen Rechner, das alle paar Minuten läuft. Es holt meine aktuelle öffentliche IP und sendet sie an meinen Web-Service.

Hier ist das Bash-Skript, das ich verwende:

#!/bin/bash
IP=$(curl -s ifconfig.me)
SECRET_TOKEN="mein-super-geheimes-token"
API_ENDPOINT="https://mein-server.com/update-ip"

curl -X POST -H "Authorization: Bearer $SECRET_TOKEN" -H "Content-Type: application/json" -d "{\"ip\": \"$IP\"}" $API_ENDPOINT

Das lasse ich als Cron-Job laufen, also muss ich nie darüber nachdenken.

Hat es sich gelohnt?

Absolut. Es war ein bisschen Arbeit, es einzurichten, aber jetzt ist mein SSH-Port für das große weite Internet praktisch unsichtbar. Ich kann reisen und auf meinen Server zugreifen, ohne mir Sorgen machen zu müssen, dass sich meine IP ändert. Das ist eine enorme Beruhigung.

Share this post: