SFTP famille auto-hébergé : exposer un NAS sans port forward avec Pangolin + Newt + SFTPGo

SFTP famille auto-hébergé : exposer un NAS sans port forward avec Pangolin + Newt + SFTPGo

·16 min de lecture·Mis à jour le 26 mai 2026
Pour qui

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 :

  1. network_mode: service:gerbil sur Traefik : il partage la stack réseau de Gerbil, qui expose les ports (80, 443, 2022). localhost:80 dans Traefik = port 80 de Gerbil.
  2. Le token Cloudflare est un Docker secret, monté en /run/secrets/cf_dns_api_token, fichier source en chmod 600 root.
  3. Le port 2022:2022 s'ajoute à Gerbil pour exposer une raw resource TCP. Pour de l'UDP raw (serveur de jeu), c'est XXXX: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'
Le naming des entryPoints raw

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 :

NomTypeValeurProxy
pangolin.example.orgA203.0.113.10DNS only
auth.example.orgA203.0.113.10DNS only
files.example.orgA203.0.113.10DNS 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

  1. Menu Sites+ Ajouter un site → type Newt, nom nas
  2. Activer le toggle « Accepter les connexions client »
  3. Créer un site, puis copier ID et Secret (affichés une seule fois)
  4. Les coller dans .env côté NAS :
NEWT_ID=xxxxxxxxxxxxxxxx
NEWT_SECRET=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
  1. Sur le NAS : docker compose up -d

Création de la raw resource TCP

Menu RessourcesPublique+ 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éseau pangolin-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.

La leçon

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.

Le piège du naming, deux fois

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 authenticationDefault → 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

  1. Cloudflare → API Tokens → Create Token → template « Edit zone DNS » → Zone example.org → TTL 1 an
  2. Sur le VPS, édition manuelle de cf_dns_api_token.txt via sudo nano (pas echo $TOKEN, qui laisse le secret dans l'historique bash)
  3. chmod 600 propriétaire root, puis docker compose restart traefik
  4. 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"}}
  1. 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)'
  1. Une fois un renouvellement de cert validé, révoquer l'ancien token dans Cloudflare.
Pas de secrets dans une conversation avec un LLM

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

SolutionCoût/moisData passe parUX côté utilisateursSelf-hosted
SFTP sshd Internet + fail2ban0 €(rien)SFTP standard
Cloudflare Tunnel (HTTP only)0 €CloudflareApp web acceptable
Cloudflare Tunnel + Spectrum (TCP)5 €+CloudflareOK mais Service Tokens
Tailscale Funnel0 €Tailscale CloudSetup VPN obligatoire
Pangolin + Newt + SFTPGo~3 € (VPS Lite)VPS personnelSFTP standard
Headscale + reverse proxy custom~3 €VPS personnelVariable

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.

PartagerLinkedInXBluesky

Articles similaires