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

·24 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. Bonus : vous avez quatre proches non-techniques à qui il faut donner accès à des dossiers du NAS depuis leur téléphone. Niveau requis : à l'aise avec Docker, WireGuard, Traefik, et le shell.

Le contexte : quatre proches, une app simple, zéro tolérance UX

Quatre personnes - disons Alice, Bob, Claire et David - m'envoient ou récupèrent des photos, vidéos et documents sur mon NAS depuis des années. Ils utilisent tous CX File Explorer sur Android, une app file manager gratuite qui parle nativement SFTP, FTP, WebDAV, SMB. Aucun d'eux ne sait ce qu'est un VPN. Bob me redemande son mot de passe tous les six mois. Claire confond "appuyer sur Enregistrer" et "appuyer sur le triangle play". Le setup doit être : on ouvre l'app, on tape un host, un port, un user, un password, et ça marche.

Pendant longtemps j'avais un SFTP classique exposé sur Internet. Un sshd sur le port 42222 de la box opérateur, avec port forward TCP vers le NAS, fail2ban derrière, un user Linux par personne avec un ChrootDirectory. Ça marchait. Mais :

  • La surface d'attaque est massive : tous les bots du monde tapent en permanence sur les ports SSH non-standard
  • Le sshd qui sert ces user famille est le même que celui que j'utilise en admin. Une faille kernel ou OpenSSH et tout part en fumée
  • Aucun audit centralisé propre des connexions et téléchargements
  • Le NAS doit avoir un port ouvert sur Internet, ce qui implique de la confiance dans le pare-feu de la box, le NAT, et le routeur du FAI

En migrant mon NAS de Synology DSM vers Debian 13 (voir l'article sur la migration), j'ai voulu tout repenser. Cible : le NAS ne doit plus avoir aucun port ouvert sur Internet. Le SFTP doit être servi depuis un VPS petite catégorie qui sert de bastion, et le tunnel doit être sortant depuis le NAS.

Pourquoi pas Cloudflare Tunnel, Tailscale Funnel, ou un reverse proxy classique

Avant de partir sur Pangolin, j'ai sérieusement évalué les alternatives :

Cloudflare Tunnel (ex-Argo Tunnel) - la solution la plus connue. Gratuite, robuste, tunnel sortant via le binaire cloudflared. Le problème : pour exposer du SFTP TCP raw (pas du HTTP), il faut leur Service Tokens, ce qui demande à chaque utilisateur d'installer cloudflared côté client et de configurer un certificat. Inviable pour des proches non-tech. Et surtout : toutes les données passent par Cloudflare. Pour des photos de famille je préfère que ça reste entre Alice et mon NAS.

Tailscale Funnel - exposer un port d'un nœud Tailscale sur l'internet public via les serveurs Tailscale. UX simple côté famille (un host public, un port). Mais : le tunnel sortant utilise Tailscale.com (control plane externe), et les funnels ne fonctionnent que sur Tailscale.com, pas sur Headscale que j'auto-héberge. Or mon tailnet contient des services internes que je ne veux pas exposer aux comptes Tailscale des proches. Mettre Alice dans mon tailnet pour qu'elle puisse uploader trois photos, c'est disproportionné.

Caddy / nginx en reverse proxy TCP - possible avec le module layer4 de Caddy, mais Caddy ne fait pas l'orchestration WireGuard sortante depuis le NAS. Il faudrait monter le tunnel WG manuellement, gérer les peers, ré-allouer les IPs WG à chaque nouveau site. Sympa pour un usage unique, fastidieux quand on veut un dashboard pour ajouter/retirer des services.

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 de management. Open-source (AGPL), API REST, Docker. Depuis la version 1.0.0-beta.9 il supporte le raw TCP/UDP via les "raw resources". C'est exactement le pattern Cloudflare Tunnel mais entièrement chez moi.

L'architecture cible

Le flux côté Alice : son CX File Explorer 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 envoie via le tunnel établi par Newt côté NAS. Newt reçoit, le route vers sftpgo:2022 sur le réseau Docker local du compose pangolin-stack. SFTPGo authentifie Alice et lui sert ses virtual folders.

Ce qui change tout par rapport à un setup classique : le tunnel est monté par Newt sortant depuis le NAS. Le NAS n'a aucun port ouvert sur Internet. La box opérateur n'a aucun port-forward configuré. Tout ce qu'il faut côté maison c'est qu'UDP sortant vers le VPS soit autorisé, ce qui est le cas par défaut sur toute connexion résidentielle non-cellulaire.

Setup côté VPS : Pangolin, Traefik, Cloudflare DNS-01

Le VPS tourne sur Debian 13. Tous les fichiers du déploiement sont dans /home/debian/pangolin/. La structure :

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

Le compose minimal (pangolin, gerbil, traefik, crowdsec) :

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 famille 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 trucs à noter ici :

  1. network_mode: service:gerbil sur Traefik. Traefik partage la stack réseau de Gerbil, donc il n'expose pas ses propres ports, c'est Gerbil qui les expose (80, 443, 2022). Ça simplifie aussi le routage entre les deux : localhost:80 dans Traefik = port 80 de Gerbil.

  2. Le token Cloudflare est un Docker secret, monté en /run/secrets/cf_dns_api_token. Le fichier source est en chmod 600 root sur l'hôte. Traefik lit le contenu via la variable CF_DNS_API_TOKEN_FILE.

  3. Le port 2022:2022 est ajouté à Gerbil quand on veut exposer une raw resource TCP sur ce port. Pour exposer du UDP raw (un serveur de jeu par exemple), c'est XXXX:XXXX/udp à ajouter.

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). Si vous le nommez autrement, Pangolin ne le retrouvera pas dans sa config poussée. Même remarque 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 allow_raw_resources est à false. Tant qu'il y est, le bouton "Raw TCP/UDP resource" n'apparaît pas dans le formulaire de création de ressource du dashboard. J'ai cherché ça pendant dix minutes la première fois en me demandant si j'avais loupé une version. C'est une protection volontaire : tu déclares explicitement que ton instance accepte d'exposer des ports non-HTTP.

DNS Cloudflare

Trois records A en grey cloud (pas proxy orange - incompatible avec raw TCP) pointant tous 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

Pour le SFTP raw TCP, le proxy Cloudflare orange ne fonctionnerait pas sans un abonnement Cloudflare Spectrum (payant). On laisse en DNS only.

Setup côté NAS : Newt + SFTPGo

Sur le NAS, 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 (l'IP du NAS sur mon réseau WireGuard interne admin, séparé du tunnel Pangolin), pas sur 0.0.0.0. Elle n'est donc accessible que depuis mes machines administratives, pas depuis le LAN, et évidemment jamais depuis Internet. SFTPGo a une UI web complète et puissante - je ne veux pas l'exposer.

Création du site Newt dans Pangolin

Dans le dashboard Pangolin :

  1. Menu Sites+ Ajouter un site → type Newt, nom nas
  2. Activer le toggle "Accepter les connexions client"
  3. Cliquer Créer un site
  4. Copier ID et Secret (affichés une seule fois)
  5. 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

Toujours dans le dashboard, menu RessourcesPublique+ Ajouter une ressource :

  • Type : Ressource TCP/UDP brute (cette option n'apparaît que si allow_raw_resources: true côté config Pangolin)
  • Nom : sftp-famille
  • Protocole : TCP
  • Port public : 2022
  • Site : nas
  • Cible : sftpgo:2022 (hostname Docker du container SFTPGo, résolu via le réseau pangolin-stack)

Cliquer Créer une ressource. Le tunnel WireGuard étant déjà monté, le routage est instantané.

Le détour de deux heures : Free contre Infomaniak

Setup terminé, je suis content de moi. Premier docker compose up -d côté NAS. 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 vers Pangolin marche, mais newt/wg/get-config timeout. Je plonge dans le code Pangolin (/app/dist/server.mjs) et je trouve le handler handleNewtGetConfigMessage :

if (existingSite.lastHolePunch && now - existingSite.lastHolePunch > 5) {
  logger.warn(`Site last hole punch is too old; skipping this register.`)
  return
}

Je vérifie en DB SQLite côté Pangolin :

SELECT siteId, endpoint, publicKey, lastHolePunch FROM sites WHERE siteId=2;
2 | (vide) | (vide) | (vide)

L'endpoint, la public key et le lastHolePunch sont vides. Donc Newt n'arrive jamais à faire le hole-punch UDP vers Gerbil. Je lance un tcpdump côté VPS sur udp port 21820 : zéro paquet en 30 secondes. Côté NAS, le container Newt logue bien Failed to start hole punch: hole punch already running (donc il essaie).

Je teste l'UDP outbound depuis le NAS vers différentes destinations :

import socket
for ip, port in [("1.1.1.1", 53), ("213.251.128.249", 123),
                 ("stun.l.google.com", 19302), ("203.0.113.10", 21820)]:
    s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
    s.sendto(b"X"*32, (ip, port))

Et en parallèle un tcpdump côté VPS : les paquets vers Cloudflare, NTP OVH et STUN Google reçoivent une réponse, mais ceux vers le VPS ne sont jamais vus dans le tcpdump. Et le trafic Tailscale (port 41641 UDP) du NAS arrive au VPS, donc ce n'est pas un blocage généralisé.

Ma théorie du moment, qui m'enchantait : Free fait du DPI signature-based sur l'UDP sortant vers cette destination et drop tout ce qui ne ressemble pas à du Tailscale déjà connu. Le stateful firewall côté Freebox n'autoriserait que les flows initialement établis via STUN/UPnP. Belle théorie. Évidemment fausse.

Je passe deux heures :

  • Connexion à l'API Freebox v15 (joli protocole d'autorisation : app_token + bouton physique sur la box + login HMAC-SHA1)
  • Désactivation de l'adblock Freebox (pensant que c'était un proxy DPI déguisé) - sans effet
  • Activation de la DMZ Freebox pointant sur le NAS pour bypasser le pare-feu de la box - sans effet
  • Mapping UPnP manuel pour le port 21820 entrant - sans effet
  • Lecture de ProxyGuard et de la doc WireGuard sur les NAT symétriques
  • Recherches sur "Free DPI WireGuard outbound 2026" sans rien trouver de probant

Puis, par acquit de conscience, je teste depuis WSL (un réseau totalement différent du mien) :

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}")

Résultat :

TCP VPS:21820: timed out
TCP VPS:9999: timed out
TCP VPS:12345: timed out

Mais TCP 22, 80, 443 vers le VPS marchent depuis WSL. Donc le problème n'est ni Free, ni la box opérateur, ni du DPI exotique. C'est le firewall provider du VPS qui ne laisse passer que les ports explicitement ouverts. Bref, j'ai blâmé mon FAI pendant deux heures pour une case à cocher dans un manager web.

Je vais dans le manager Infomaniak → mon VPS → Pare-feu → règles existantes : TCP 80, TCP 443, TCP 22, ICMP, UDP 3478, UDP 41641 (les ports Tailscale historiques). Tout le reste est bloqué en entrée par défaut.

J'ajoute deux règles :

  • UDP 51820 source Toutes description WireGuard Gerbil
  • UDP 21820 source Toutes description Gerbil hole-punch relay

Restart de Newt côté NAS. Résultat :

INFO  Tunnel connection to server established successfully!
INFO  Client connectivity setup. Ready to accept connections from clients!

Le tunnel monte en trois secondes. Je vérifie en DB Pangolin :

SELECT siteId, endpoint, publicKey, lastHolePunch FROM sites WHERE siteId=2;
2 | 198.51.100.20:51234 | aBcDeFgHiJkLmNoPqRsTuVwXyZ0123456789abcdEFG= | 1779740844
La leçon, gravée dans le marbre

Avant toute théorie sur ton FAI, ton ISP 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, WSL via un autre FAI) avant d'incriminer le tien.

Quand on a ajouté pour la suite le port TCP 2022 (la raw resource SFTP), il a fallu refaire l'ouverture dans le manager Infomaniak avant que les clients SFTP externes puissent arriver. Le test depuis un réseau autre que celui où l'on développe est devenu un réflexe.

Durcissement de sécurité

À ce stade l'archi tient debout, mais rien n'empêche un attaquant de taper sur 203.0.113.10:2022 toute la journée pour bruteforcer les mots de passe des proches. CrowdSec est bien installé sur le VPS et protège Traefik au niveau HTTP, mais il ne couvre pas le TCP raw : la connexion arrive directement sur Traefik en mode passthrough, sans inspection L7.

Anti-bruteforce : la fausse bonne piste du Defender SFTPGo

SFTPGo embarque un module "Defender" qui ban temporairement les IPs après N tentatives ratées. C'est pile ce qu'il manque ici. Sur le papier, activation par variables d'environnement dans le compose NAS :

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

Quinze tentatives invalides dans une fenêtre de trente secondes (ou cumul de "score" 30+) → IP bannie pour trente minutes. Récidive, soixante minutes. Etc.

Sauf qu'en pratique, derrière le tunnel Newt, toutes les connexions externes apparaissent à SFTPGo avec la même source IP : 172.23.0.1, c'est-à-dire le gateway Docker bridge sur le NAS. Le NAT du tunnel WireGuard a effacé l'IP du vrai client. Conséquence directe : le premier mauvais password de qui que ce soit fait monter le compteur de 172.23.0.1, et au seuil tout le monde est verrouillé dehors, y compris l'admin. On le voit dans les logs :

{"sender":"SSH","message":"connection refused, ip \"172.23.0.1\" is banned"}

La vraie solution architecturale : PROXY protocol — qui ne marche pas (encore)

Le bon fix existe : PROXY protocol (v1 ou v2). Le reverse proxy (ici Traefik côté VPS) préfixe la connexion TCP avec un en-tête contenant la vraie IP source ; le backend (SFTPGo) lit cet en-tête et l'utilise comme source apparente. Le defender reprend tout son sens.

Pangolin supporte ça en natif depuis la PR #1739 : une case "Activer le protocole Proxy" sur la resource raw TCP, avec choix de version (v1 recommandé, v2 supporté). Côté SFTPGo aussi, c'est natif :

- SFTPGO_COMMON__PROXY_PROTOCOL=1 # 1 = optional, 2 = required
- SFTPGO_COMMON__PROXY_ALLOWED=172.23.0.1 # IP autorisée à envoyer le header

Sauf que dans mes tests (Pangolin 1.18, Newt 1.12, SFTPGo 2.7), le forward via Newt casse silencieusement : la connexion TCP s'ouvre côté Traefik:2022, mais zéro byte n'atteint SFTPGo. Aucun banner SSH renvoyé au client. Le PROXY header semble perdu quelque part entre Traefik → tunnel WG → Newt → backend. Aucun log d'erreur explicite côté Traefik/Newt/SFTPGo en niveau INFO. À investiguer plus en profondeur (Traefik en DEBUG + tcpdump côté NAS) et probablement à remonter en issue upstream chez fosrl/pangolin.

Compromis pragmatique : defender off, passwords forts

En attendant que la chaîne PROXY proto soit fiable, j'ai désactivé le defender :

- SFTPGO_COMMON__DEFENDER__ENABLED=false

Compensé par :

  • mots de passe de 16 à 20 caractères générés par le password manager (entropie ~104 bits) — non bruteforçables au débit d'un handshake SSH
  • monitoring des logs SFTPGo (les tentatives échouées restent loggées même defender off)
  • audit log SFTPGo qui trace toutes les actions (téléchargements, uploads, modifs)
  • fail2ban sur le VPS sur le port 2022 comme chantier de durcissement futur (parsing des logs d'access Traefik TCP)

Dans ce contexte (4 utilisateurs connus, passwords longs, pas de service ouvert au public anonyme), c'est un compromis acceptable. Le defender SFTPGo est conçu pour une exposition directe (port forward home, ou serveur public), pas pour un setup reverse-proxy-tunnel où l'IP source disparaît dans le NAT.

Le piège du naming, deux fois

Mon premier essai du defender utilisait SFTPGO_COMMON__DEFENDER_CONFIG__* comme nom de variable d'env, parce que dans le log debug SFTPGo on voit DefenderConfig:{Enabled:false ...}. C'est le nom du champ Go interne, pas la clé de config. La bonne clé JSON/YAML est common.defender (sans _config), donc l'env var est SFTPGO_COMMON__DEFENDER__ENABLED.

Idem pour le PROXY protocol : à première lecture rapide on peut croire que proxy_protocol est un setting de binding (SFTPGO_SFTPD__BINDINGS__0__PROXY_PROTOCOL), vu qu'il existe un flag apply_proxy_config au niveau binding. Mais en réalité c'est un setting global dans common, partagé entre tous les bindings. La distinction n'est claire qu'en lisant le dump de config dans les logs au démarrage : Common:{... ProxyProtocol:1 ProxyAllowed:[...]} SFTPD:{Bindings:[{... ApplyProxyConfig:true}]}. Toujours valider depuis la doc officielle ET le dump de config réel, jamais depuis l'intuition.

Virtual folders : ACL granulaire par user

La fonctionnalité qui m'a fait choisir SFTPGo, ce sont les virtual folders. 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. Au lieu de bricoler un sshd avec ChrootDirectory + bind mounts manuels (j'ai vécu, c'est pénible), tout se déclare via l'API REST :

# Création de Alice 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"},
    {"name": "alice",   "mapped_path": "/data/Alice"},
]
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 Alice 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ée, Alice voit à la racine :

sftp> pwd
Remote working directory: /
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

Pas de dossier de Bob, pas de NAS root, rien d'autre. Le permissions: {"/": ["list"]} autorise seulement le ls à la racine (elle voit l'existence de ses virtual folders), pas de création de fichier à la racine. À l'intérieur de chaque folder, les permissions complètes s'appliquent.

2FA admin SFTPGo (TOTP)

L'UI web admin SFTPGo héberge la gestion des users, des folders, l'audit log, et tout le reste sensible. Activation du 2FA :

  1. UI admin → avatar haut-droit → Two-factor authentication
  2. Sélectionner Default dans Configuration → QR code TOTP affiché
  3. Scanner avec une app TOTP. En 2026, je recommande Aegis sur Android (open-source, vault chiffré local, biométrie, exports). Sur iOS, Raivo OTP. Pour du cross-platform open-source, 2FAS ou Ente Auth.
  4. Valider avec un code à 6 chiffres
  5. Sauvegarder les recovery codes dans un password manager. Sans ça, perdre son téléphone = se résoudre à recréer un admin via la CLI SFTPGo en bypass de l'auth.

Côté Aegis, deuxième piège : activer un backup chiffré (menu → Settings → Backups → Active → choisir un dossier sur cloud personnel). Trop de gens perdent leurs codes TOTP juste parce qu'ils n'ont jamais exporté Aegis avant que leur téléphone ne casse.

Rotation du token Cloudflare DNS-01

Pour le challenge ACME DNS-01, Traefik utilise un token API Cloudflare scopé sur ma zone. Ce token est un secret persistant à rotater régulièrement. La procédure que j'applique :

  1. Sur Cloudflare → API Tokens → Create Token → template "Edit zone DNS" → Zone example.org → TTL 1 an
  2. Sur le VPS, en SSH, édition manuelle du fichier cf_dns_api_token.txt via sudo nano (pas via echo $TOKEN, qui laisse le secret dans l'historique bash)
  3. chmod 600 propriétaire root
  4. docker compose restart traefik
  5. Vérification que le token est valide via l'API Cloudflare :
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 Traefik voit bien le nouveau token (les Docker secrets en mode compose sont des bind mounts, le restart suffit, mais on peut comparer les sha256) :
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 validé en prod (un cert se renouvelle bien, ou je force une re-emission en supprimant un cert d'acme.json), révocation de l'ancien token dans Cloudflare.

Pas de secrets dans une conversation avec un assistant IA

Pendant cette session j'ai failli coller le nouveau token Cloudflare dans le chat avec mon assistant. Réflexe à acquérir : un secret ne doit jamais transiter dans une conversation avec un LLM, ni dans les logs locaux de session, ni dans la mémoire persistante de l'assistant. Procédure self-service obligatoire, l'assistant guide les commandes, l'humain les exécute lui-même. C'est aussi vrai pour les password managers self-hosted, pour les clés privées SSH, et pour à peu près tout ce qui finit en .env.

Côté famille : le message qui ne demande aucune compétence

Une fois la stack en place, le message envoyé à Alice ressemble à ça :

Salut Alice ! Le partage de fichiers a déménagé. Voici comment t'y reconnecter :

1. Installe l'app "CX File Explorer" depuis le Play Store (gratuite, ~5 Mo)
2. Ouvre-la, va dans l'onglet "Réseau" (en bas)
3. Bouton "+" en haut à droite → choisis "SFTP"
4. Remplis :
   - Host (serveur) : files.example.org
   - Port : 2022
   - Username : alice
   - Password : (le mot de passe que je t'envoie en parallèle sur Signal)
5. Coche "Mémoriser le mot de passe", clique OK

Tu verras tes dossiers Famille et Vidéos comme d'habitude.
Aucun VPN à installer, ça marche depuis n'importe  (maison, 4G, hôtel).

Si tu ne peux plus accéder, dis-moi.

Aucun client à installer en plus, aucun certificat à approuver, aucun service tiers. Alice peut être chez sa fille, en vacances, sur une box opérateur capricieuse : tant qu'elle a Internet, ça marche.

CX File Explorer mémorise le profil, donc à partir de la deuxième fois c'est : ouvrir l'app, tap sur "Famille", et c'est en ligne.

Coûts et bilan comparatif

SolutionCoût/moisData passe parUX côté prochesSelf-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 mensuel par rapport à un SFTP exposé direct est juste le VPS bastion (Infomaniak VPS Lite à 3,12 € HT/mois dans mon cas, qui héberge aussi Pangolin, Headscale et quelques autres services). Pour ça j'obtiens : zéro port ouvert sur la box opérateur, NAS isolé, dashboard de gestion, audit log centralisé, 2FA admin, certificats Let's Encrypt wildcard automatiques, et la possibilité d'ajouter d'autres services exposés (RDP, Minecraft, autre) sans toucher au réseau home.

La leçon, encore

Ce que je retiens de cette session, ce n'est pas la pile technique. C'est deux heures perdues à blâmer mon FAI alors que le coupable était dans le panneau d'à côté, à trois clics et un nouveau formulaire. Mon réflexe pour la prochaine fois qu'un paquet réseau disparaît dans le vide :

  1. Tester depuis un autre réseau (4G, autre VPS, ami). Ça élimine l'hypothèse FAI en trente secondes.
  2. Lister explicitement les règles firewall du provider VPS (Infomaniak, OVH, Scaleway, Hetzner, AWS Security Group). C'est souvent la cause, et c'est invisible depuis iptables sur la machine.
  3. Tcpdump des deux côtés simultanément. Ça montre où le paquet meurt.
  4. Lire les logs du service plutôt que de tester des hypothèses depuis la couche réseau.

Le reste, Pangolin, Newt, SFTPGo, l'archi WireGuard sortant, c'est de l'assemblage de briques solides. Une fois le firewall provider sorti du chemin, tout monte en moins de cinq minutes.

Le NAS dort tranquille derrière sa box. Alice envoie ses photos. Et moi je peux enfin clôturer ce ticket "remplacer le SFTP exposé" qui traînait depuis beaucoup trop longtemps.

PartagerLinkedInXBluesky

Articles similaires