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.

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 dieip-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
und443
zumtraefik
-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.