
SFTP famille auto-hébergé : exposer un NAS sans port forward avec Pangolin + Newt + SFTPGo
Vous voulez exposer un service (SFTP, RDP, jeu, autre TCP/UDP) hébergé sur un réseau résidentiel derrière une box opérateur, sans port forward et sans demander à vos utilisateurs d'installer un client VPN. Niveau requis : à l'aise avec Docker, WireGuard, Traefik, et le shell.
Le problème : un SFTP exposé qu'on ne veut plus
Le point de départ : quatre utilisateurs non-techniques accèdent à des dossiers du NAS depuis un client SFTP Android grand public. La contrainte UX est dure — on ouvre l'app, on tape host / port / user / password, et ça marche. Aucun VPN à installer.
Le setup historique était un sshd exposé sur Internet : port 42222 sur la box, port forward TCP vers le NAS, fail2ban, un user Linux par personne avec ChrootDirectory. Ses défauts :
- Surface d'attaque massive : tous les bots du monde tapent en permanence sur les ports SSH non-standard.
- Le sshd qui sert ces users est le même que celui utilisé en admin. Une faille kernel ou OpenSSH et tout part.
- Aucun audit centralisé des connexions et téléchargements.
- Le NAS doit avoir un port ouvert, ce qui implique de faire confiance au pare-feu de la box, au NAT et au routeur du FAI.
Cible en migrant le NAS de Synology DSM vers Debian 13 (voir l'article sur la migration) : plus aucun port ouvert sur le NAS, SFTP servi depuis un VPS bastion, tunnel sortant depuis le NAS.
Pourquoi pas Cloudflare Tunnel, Tailscale Funnel ou un reverse proxy classique
Cloudflare Tunnel — gratuit et robuste, mais exposer du SFTP TCP raw impose des Service Tokens, donc l'installation de cloudflared et d'un certificat côté client. Inviable pour des non-techs. Et toutes les données transitent par Cloudflare.
Tailscale Funnel — UX simple, mais les funnels ne fonctionnent que sur Tailscale.com, pas sur Headscale auto-hébergé. Et ajouter chaque utilisateur au tailnet pour uploader trois photos est disproportionné.
Caddy / nginx en reverse proxy TCP — faisable avec le module layer4, mais aucune orchestration WireGuard sortante : tunnel à monter à la main, peers et IPs WG à gérer à chaque nouveau service.
Le choix : Pangolin, un control plane self-hosted qui combine Traefik (HTTP + TCP/UDP raw), un serveur WireGuard (Gerbil), un connecteur tunnel sortant (Newt) et un dashboard. Open-source (AGPL), API REST, Docker. Le raw TCP/UDP est supporté depuis la 1.0.0-beta.9 via les « raw resources ». Le pattern Cloudflare Tunnel, entièrement chez soi.
L'architecture cible
Le client ouvre une connexion TCP vers 203.0.113.10:2022. Traefik écoute sur cet entryPoint, Pangolin a configuré une route raw TCP qui pousse le flux dans le tunnel WireGuard. Gerbil l'envoie via le tunnel établi par Newt côté NAS, qui le route vers sftpgo:2022 sur le réseau Docker local.
Ce qui change tout : le tunnel est monté par Newt sortant depuis le NAS. Aucun port ouvert, aucun port-forward. Il suffit que l'UDP sortant vers le VPS soit autorisé, ce qui est le cas par défaut sur toute connexion résidentielle.
Setup côté VPS : Pangolin, Traefik, Cloudflare DNS-01
VPS sous Debian 13, déploiement dans /home/debian/pangolin/ :
pangolin/
├── docker-compose.yml
└── config/
├── config.yml # config serveur Pangolin
├── cf_dns_api_token.txt # secret Cloudflare API token (mode 600)
├── traefik/
│ ├── traefik_config.yml # config statique Traefik
│ └── dynamic_config.yml # routes dynamiques Pangolin override
├── letsencrypt/
│ └── acme.json # certs Let's Encrypt persistés
└── db/db.sqlite # DB SQLite Pangolin
docker-compose.yml
name: pangolin
services:
pangolin:
image: fosrl/pangolin:1.18.4
container_name: pangolin
restart: unless-stopped
volumes:
- ./config:/app/config
healthcheck:
test: ['CMD', 'curl', '-f', 'http://localhost:3001/api/v1/']
interval: 3s
timeout: 3s
retries: 15
gerbil:
image: fosrl/gerbil:1.4.0
container_name: gerbil
restart: unless-stopped
depends_on:
pangolin:
condition: service_healthy
command:
- --reachableAt=http://gerbil:3004
- --generateAndSaveKeyTo=/var/config/key
- --remoteConfig=http://pangolin:3001/api/v1/
volumes:
- ./config/:/var/config
cap_add:
- NET_ADMIN
- SYS_MODULE
ports:
- 51820:51820/udp # WireGuard pour Newt connectors
- 21820:21820/udp # WireGuard hole-punch relay
- 443:443 # Traefik HTTPS (network_mode service:gerbil)
- 80:80 # Traefik HTTP (ACME challenge + redirect)
- 2022:2022 # Raw TCP - SFTPGo via Newt
traefik:
image: traefik:v3.6
container_name: traefik
restart: unless-stopped
network_mode: service:gerbil # partage la stack réseau de gerbil
environment:
- CF_DNS_API_TOKEN_FILE=/run/secrets/cf_dns_api_token
secrets:
- cf_dns_api_token
command:
- --configFile=/etc/traefik/traefik_config.yml
volumes:
- ./config/traefik:/etc/traefik:ro
- ./config/letsencrypt:/letsencrypt
secrets:
cf_dns_api_token:
file: ./config/cf_dns_api_token.txt
Trois points à noter :
network_mode: service:gerbilsur Traefik : il partage la stack réseau de Gerbil, qui expose les ports (80, 443, 2022).localhost:80dans Traefik = port 80 de Gerbil.- Le token Cloudflare est un Docker secret, monté en
/run/secrets/cf_dns_api_token, fichier source enchmod 600root. - Le port
2022:2022s'ajoute à Gerbil pour exposer une raw resource TCP. Pour de l'UDP raw (serveur de jeu), c'estXXXX:XXXX/udp.
Traefik : entry points et certResolver
config/traefik/traefik_config.yml :
api:
insecure: false
dashboard: false
entryPoints:
web:
address: ':80'
http:
redirections:
entryPoint: { to: websecure, scheme: https, permanent: true }
websecure:
address: ':443'
http:
tls:
certResolver: letsencrypt
domains:
- main: 'example.org'
sans: ['*.example.org']
http3:
advertisedPort: 443
tcp-2022:
address: ':2022/tcp'
certificatesResolvers:
letsencrypt:
acme:
email: 'admin@example.org'
storage: /letsencrypt/acme.json
caServer: https://acme-v02.api.letsencrypt.org/directory
dnsChallenge:
provider: cloudflare
resolvers: ['1.1.1.1:53', '1.0.0.1:53']
delayBeforeCheck: 30
providers:
http:
endpoint: 'http://pangolin:3001/api/v1/traefik-config'
pollInterval: '5s'
file:
filename: /etc/traefik/dynamic_config.yml
experimental:
plugins:
badger:
moduleName: 'github.com/fosrl/badger'
version: 'v1.4.0'
crowdsec-bouncer:
moduleName: 'github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin'
version: 'v1.4.4'
Pangolin pousse dynamiquement des routes Traefik via son API HTTP provider. Pour qu'il route une raw resource sur le port 2022, l'entryPoint Traefik doit s'appeler tcp-2022 (format protocole-port). Nommé autrement, Pangolin ne le retrouvera pas. Idem pour UDP (udp-XXXX).
Pangolin : flag allow_raw_resources
Dans config/config.yml :
flags:
require_email_verification: true
disable_signup_without_invite: true
disable_user_create_org: true
allow_raw_resources: true # ← critique
gerbil:
start_port: 51820
base_endpoint: 'pangolin.example.org'
app:
dashboard_url: 'https://pangolin.example.org'
Par défaut ce flag est à false, et le bouton « Raw TCP/UDP resource » n'apparaît alors pas dans le dashboard. C'est une protection volontaire : on déclare explicitement que l'instance accepte d'exposer des ports non-HTTP.
DNS Cloudflare
Trois records A en grey cloud (pas de proxy orange — incompatible avec le raw TCP sans Cloudflare Spectrum, payant), pointant vers l'IP du VPS :
| Nom | Type | Valeur | Proxy |
|---|---|---|---|
pangolin.example.org | A | 203.0.113.10 | DNS only |
auth.example.org | A | 203.0.113.10 | DNS only |
files.example.org | A | 203.0.113.10 | DNS only |
Setup côté NAS : Newt + SFTPGo
Dans /mnt/data/apps/pangolin-stack/ :
name: pangolin-stack
services:
newt:
image: fosrl/newt:1.12.5
container_name: newt
restart: unless-stopped
environment:
- PANGOLIN_ENDPOINT=https://pangolin.example.org
- NEWT_ID=${NEWT_ID}
- NEWT_SECRET=${NEWT_SECRET}
networks: [pangolin-stack]
cap_add: [NET_ADMIN]
mem_limit: 128m
sftpgo:
image: drakkan/sftpgo:v2.7-alpine
container_name: sftpgo
restart: unless-stopped
user: '1000:1000'
environment:
- TZ=Europe/Paris
- SFTPGO_HTTPD__BINDINGS__0__PORT=8080
- SFTPGO_HTTPD__BINDINGS__0__ADDRESS=0.0.0.0
- SFTPGO_HTTPD__BINDINGS__0__ENABLE_WEB_ADMIN=true
- SFTPGO_HTTPD__BINDINGS__0__ENABLE_WEB_CLIENT=true
- SFTPGO_SFTPD__BINDINGS__0__PORT=2022
- SFTPGO_SFTPD__BINDINGS__0__ADDRESS=0.0.0.0
ports:
- '100.64.0.10:8080:8080' # UI admin accessible uniquement via WireGuard interne
volumes:
- ./config/sftpgo:/var/lib/sftpgo
- /mnt/data/Famille:/data/Famille
- /mnt/data/Vidéos:/data/Vidéos
- /mnt/data/Alice:/data/Alice
- /mnt/data/Bob:/data/Bob
networks: [pangolin-stack]
mem_limit: 256m
networks:
pangolin-stack:
driver: bridge
L'UI admin SFTPGo est bindée sur 100.64.0.10:8080 (IP du NAS sur le réseau WireGuard interne d'admin, distinct du tunnel Pangolin), pas sur 0.0.0.0. Elle n'est accessible ni depuis le LAN, ni depuis Internet.
Création du site Newt dans Pangolin
- Menu Sites → + Ajouter un site → type Newt, nom
nas - Activer le toggle « Accepter les connexions client »
- Créer un site, puis copier
IDetSecret(affichés une seule fois) - Les coller dans
.envcôté NAS :
NEWT_ID=xxxxxxxxxxxxxxxx
NEWT_SECRET=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
- Sur le NAS :
docker compose up -d
Création de la raw resource TCP
Menu Ressources → Publique → + Ajouter une ressource :
- Type : Ressource TCP/UDP brute (n'apparaît que si
allow_raw_resources: true) - Nom :
sftp-famille, Protocole : TCP, Port public :2022 - Site :
nas, Cible :sftpgo:2022(hostname Docker résolu via le réseaupangolin-stack)
Le tunnel WireGuard étant déjà monté, le routage est instantané.
Le piège du firewall provider
Premier démarrage : Newt n'arrive pas à monter son tunnel, les logs bouclent sur :
INFO Websocket connected
INFO Connecting to endpoint: pangolin.example.org
INFO SendMessageInterval timed out after 16 attempts for message type: newt/wg/get-config
WARN Ping attempt 1 failed: failed to read ICMP packet: i/o timeout
Le websocket passe, mais newt/wg/get-config timeout. Côté Pangolin, le handler handleNewtGetConfigMessage explique pourquoi :
if (existingSite.lastHolePunch && now - existingSite.lastHolePunch > 5) {
logger.warn(`Site last hole punch is too old; skipping this register.`)
return
}
En DB SQLite, endpoint, publicKey et lastHolePunch sont vides : le hole-punch UDP n'aboutit jamais. Un tcpdump côté VPS sur udp port 21820 ne voit aucun paquet, alors que l'UDP sortant du NAS vers Cloudflare, NTP et STUN Google reçoit bien des réponses.
Le test décisif se fait depuis un autre réseau (ici WSL sur un autre FAI) :
import socket
for port in [21820, 9999, 12345]:
try:
s = socket.create_connection(("203.0.113.10", port), timeout=3)
print(f"TCP VPS:{port}: OK")
except Exception as e:
print(f"TCP VPS:{port}: {e}")
Tous les ports timeout, alors que 22, 80 et 443 répondent. Le coupable n'est donc pas le FAI, mais le firewall provider du VPS. Dans le manager Infomaniak → VPS → Pare-feu, les règles existantes ne couvrent que TCP 80, 443, 22, ICMP, UDP 3478, 41641. Tout le reste est bloqué en entrée par défaut.
Deux règles à ajouter (UDP 51820 pour WireGuard Gerbil, UDP 21820 pour le hole-punch relay), puis restart de Newt :
INFO Tunnel connection to server established successfully!
INFO Client connectivity setup. Ready to accept connections from clients!
Le port TCP 2022 de la raw resource demande la même ouverture avant que les clients SFTP externes puissent arriver.
Avant toute théorie sur ton FAI ou du DPI exotique, vérifie le firewall provider de ton VPS. Tous les hébergeurs cloud modernes (Infomaniak, OVH Cloud, Scaleway Stardust, Hetzner Firewall, AWS Security Groups) appliquent une politique deny-by-default sur l'inbound, même quand le système Linux n'a aucune règle iptables. Et toujours tester depuis un réseau différent (4G, autre VPS) avant d'incriminer le tien.
Durcissement de sécurité
Rien n'empêche un attaquant de bruteforcer 203.0.113.10:2022 toute la journée. CrowdSec protège Traefik au niveau HTTP, mais pas le TCP raw : la connexion arrive en passthrough, sans inspection L7.
Le defender SFTPGo : fausse bonne piste
SFTPGo embarque un module « Defender » qui ban temporairement les IPs après N échecs. Sur le papier :
environment:
- SFTPGO_COMMON__DEFENDER__ENABLED=true
- SFTPGO_COMMON__DEFENDER__DRIVER=memory
- SFTPGO_COMMON__DEFENDER__BAN_TIME=30 # 30 minutes
- SFTPGO_COMMON__DEFENDER__THRESHOLD=15
- SFTPGO_COMMON__DEFENDER__SCORE_INVALID=2
- SFTPGO_COMMON__DEFENDER__SCORE_NO_AUTH=2
En pratique, derrière le tunnel Newt, toutes les connexions externes arrivent à SFTPGo avec la même source IP : 172.23.0.1, le gateway Docker bridge du NAS. Le NAT du tunnel WireGuard a effacé l'IP réelle. Le premier mauvais password fait monter le compteur de 172.23.0.1, et au seuil tout le monde est verrouillé dehors, admin compris :
{"sender":"SSH","message":"connection refused, ip \"172.23.0.1\" is banned"}
PROXY protocol : le bon fix, qui ne marche pas encore
Le reverse proxy préfixe la connexion TCP avec un en-tête contenant la vraie IP source, que le backend utilise comme source apparente. Pangolin le supporte depuis la PR #1739 (case « Activer le protocole Proxy », v1 recommandé), et SFTPGo aussi :
- SFTPGO_COMMON__PROXY_PROTOCOL=1 # 1 = optional, 2 = required
- SFTPGO_COMMON__PROXY_ALLOWED=172.23.0.1 # IP autorisée à envoyer le header
Mais avec Pangolin 1.18 / Newt 1.12 / SFTPGo 2.7, le forward via Newt casse silencieusement : la connexion TCP s'ouvre côté Traefik:2022, zéro byte n'atteint SFTPGo, aucun banner SSH renvoyé, aucun log d'erreur en niveau INFO. À creuser en Traefik DEBUG + tcpdump, et probablement à remonter en issue upstream.
Compromis retenu
Defender désactivé (SFTPGO_COMMON__DEFENDER__ENABLED=false), compensé par :
- mots de passe de 16 à 20 caractères générés par password manager (~104 bits d'entropie), non bruteforçables au débit d'un handshake SSH
- monitoring des logs SFTPGo (les échecs restent loggés même defender off)
- audit log SFTPGo (téléchargements, uploads, modifications)
- fail2ban sur le VPS pour le port 2022 comme chantier futur (parsing des logs d'access Traefik TCP)
Acceptable avec quatre utilisateurs connus et des passwords longs. Le defender SFTPGo est conçu pour une exposition directe, pas pour un setup reverse-proxy-tunnel où l'IP source disparaît dans le NAT.
SFTPGO_COMMON__DEFENDER_CONFIG__* ne fonctionne pas, même si le log debug affiche DefenderConfig:{Enabled:false ...} : c'est le nom du champ Go interne, pas la clé de config. La bonne clé est common.defender, donc SFTPGO_COMMON__DEFENDER__ENABLED.
Idem pour le PROXY protocol : ce n'est pas un setting de binding (SFTPGO_SFTPD__BINDINGS__0__PROXY_PROTOCOL) malgré l'existence d'un flag apply_proxy_config au niveau binding, mais un setting global dans common, partagé entre tous les bindings. La distinction n'apparaît que dans le dump de config au démarrage.
Virtual folders : ACL granulaire par user
La fonctionnalité qui fait choisir SFTPGo. Chaque user a un home_dir chrooté (/srv/sftpgo/users/<username>), et on lui monte des dossiers du host à des paths virtuels avec des permissions distinctes par folder. Tout se déclare via l'API REST, au lieu de bricoler un sshd avec ChrootDirectory + bind mounts :
# Création d'un user qui voit Famille + Vidéos en lecture/écriture
import urllib.request, json, base64
auth = base64.b64encode(b"homeadmin:xxx").decode()
req = urllib.request.Request("http://100.64.0.10:8080/api/v2/token",
headers={"Authorization": f"Basic {auth}"})
TOKEN = json.loads(urllib.request.urlopen(req).read())["access_token"]
FULL = ["list","download","upload","overwrite","delete","rename",
"create_dirs","create_symlinks","chmod","chtimes"]
# 1) Déclaration des virtual folders globaux
folders = [
{"name": "famille", "mapped_path": "/data/Famille"},
{"name": "videos", "mapped_path": "/data/Vidéos"},
]
for f in folders:
req = urllib.request.Request("http://100.64.0.10:8080/api/v2/folders",
data=json.dumps(f).encode(),
headers={"Authorization": f"Bearer {TOKEN}",
"Content-Type": "application/json"})
urllib.request.urlopen(req)
# 2) Création de l'user avec ses virtual folders
payload = {
"username": "alice", "password": "xxx", "status": 1,
"home_dir": "/srv/sftpgo/users/alice",
"permissions": {"/": ["list"], "/Famille": FULL, "/Vidéos": FULL},
"virtual_folders": [
{"name": "famille", "virtual_path": "/Famille"},
{"name": "videos", "virtual_path": "/Vidéos"},
],
}
req = urllib.request.Request("http://100.64.0.10:8080/api/v2/users",
data=json.dumps(payload).encode(),
headers={"Authorization": f"Bearer {TOKEN}",
"Content-Type": "application/json"})
urllib.request.urlopen(req)
Une fois loggé, l'user ne voit à la racine que ses virtual folders :
sftp> ls -la
drwxr-xr-x 1 0 0 0 Jan 1 1970 Famille
drwxr-xr-x 1 0 0 0 Jan 1 1970 Vidéos
Le permissions: {"/": ["list"]} autorise seulement le ls à la racine, pas la création de fichier. À l'intérieur de chaque folder, les permissions complètes s'appliquent.
2FA admin SFTPGo (TOTP)
L'UI web admin héberge la gestion des users, des folders et l'audit log. Activation : avatar haut-droit → Two-factor authentication → Default → scan du QR code TOTP → validation par un code à 6 chiffres.
Sauvegarder les recovery codes dans un password manager : sans eux, perdre son téléphone impose de recréer un admin via la CLI SFTPGo en bypass de l'auth. Côté app TOTP (Aegis, 2FAS, Ente Auth), activer aussi un backup chiffré.
Rotation du token Cloudflare DNS-01
- Cloudflare → API Tokens → Create Token → template « Edit zone DNS » → Zone
example.org→ TTL 1 an - Sur le VPS, édition manuelle de
cf_dns_api_token.txtviasudo nano(pasecho $TOKEN, qui laisse le secret dans l'historique bash) chmod 600propriétaire root, puisdocker compose restart traefik- Vérification de la validité du token :
sudo sh -c 'TOKEN=$(cat /home/debian/pangolin/config/cf_dns_api_token.txt); \
curl -s https://api.cloudflare.com/client/v4/user/tokens/verify \
-H "Authorization: Bearer $TOKEN"'
# → {"success":true,"result":{"id":"...","status":"active"}}
- Vérification que le container voit le nouveau token :
sudo sh -c 'echo Host: $(sha256sum /home/debian/pangolin/config/cf_dns_api_token.txt | cut -c1-16)
echo Container: $(docker exec traefik sha256sum /run/secrets/cf_dns_api_token | cut -c1-16)'
- Une fois un renouvellement de cert validé, révoquer l'ancien token dans Cloudflare.
Un secret ne doit jamais transiter dans un chat avec un assistant IA, ni dans les logs de session, ni dans sa mémoire persistante. L'assistant guide les commandes, l'humain les exécute lui-même. Vrai aussi pour les clés privées SSH et tout ce qui finit en .env.
Coûts et bilan comparatif
| Solution | Coût/mois | Data passe par | UX côté utilisateurs | Self-hosted |
|---|---|---|---|---|
| SFTP sshd Internet + fail2ban | 0 € | (rien) | SFTP standard | ✅ |
| Cloudflare Tunnel (HTTP only) | 0 € | Cloudflare | App web acceptable | ❌ |
| Cloudflare Tunnel + Spectrum (TCP) | 5 €+ | Cloudflare | OK mais Service Tokens | ❌ |
| Tailscale Funnel | 0 € | Tailscale Cloud | Setup VPN obligatoire | ❌ |
| Pangolin + Newt + SFTPGo | ~3 € (VPS Lite) | VPS personnel | SFTP standard | ✅ |
| Headscale + reverse proxy custom | ~3 € | VPS personnel | Variable | ✅ |
Le surcoût par rapport à un SFTP exposé direct se limite au VPS bastion (Infomaniak VPS Lite à 3,12 € HT/mois, qui héberge aussi Pangolin, Headscale et d'autres services). En échange : zéro port ouvert sur la box, NAS isolé, dashboard de gestion, audit log centralisé, 2FA admin, certificats wildcard automatiques, et la possibilité d'exposer d'autres services (RDP, Minecraft) sans toucher au réseau home.
Le réflexe à garder quand un paquet réseau disparaît dans le vide : tester depuis un autre réseau, lister les règles firewall du provider VPS, tcpdump des deux côtés simultanément, lire les logs du service avant de théoriser sur la couche réseau. Le reste — Pangolin, Newt, SFTPGo, l'archi WireGuard sortante — monte en moins de cinq minutes une fois le firewall provider sorti du chemin.
Articles similaires
Kernel panic sur un NAS Debian : récupérer une libc tronquée sans rebooter sur du vide
nas · linux · debian
Mon VPS comme atelier de dev nomade : faire tourner mes dev servers et les ouvrir au mobile en HTTPS valide (sans nouveau certificat)
dev · infra · vps
Accès distant sécurisé à son NAS avec Tailscale
nas · linux · vpn