
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. 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 :
network_mode: service:gerbilsur 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:80dans Traefik = port 80 de Gerbil.Le token Cloudflare est un Docker secret, monté en
/run/secrets/cf_dns_api_token. Le fichier source est enchmod 600root sur l'hôte. Traefik lit le contenu via la variableCF_DNS_API_TOKEN_FILE.Le port
2022:2022est 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'estXXXX: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'
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 :
| 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 |
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 :
- Menu Sites → + Ajouter un site → type Newt, nom
nas - Activer le toggle "Accepter les connexions client"
- Cliquer Créer un site
- 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
Toujours dans le dashboard, menu Ressources → Publique → + Ajouter une ressource :
- Type : Ressource TCP/UDP brute (cette option n'apparaît que si
allow_raw_resources: truecô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éseaupangolin-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 51820sourceToutesdescriptionWireGuard GerbilUDP 21820sourceToutesdescriptionGerbil 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
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.
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 :
- UI admin → avatar haut-droit → Two-factor authentication
- Sélectionner Default dans Configuration → QR code TOTP affiché
- 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.
- Valider avec un code à 6 chiffres
- 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 :
- Sur Cloudflare → API Tokens → Create Token → template "Edit zone DNS" → Zone
example.org→ TTL 1 an - Sur le VPS, en SSH, édition manuelle du fichier
cf_dns_api_token.txtviasudo nano(pas viaecho $TOKEN, qui laisse le secret dans l'historique bash) chmod 600propriétaire rootdocker compose restart traefik- 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"}}
- 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)'
- 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 où (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
| Solution | Coût/mois | Data passe par | UX côté proches | 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 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 :
- Tester depuis un autre réseau (4G, autre VPS, ami). Ça élimine l'hypothèse FAI en trente secondes.
- 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
iptablessur la machine. - Tcpdump des deux côtés simultanément. Ça montre où le paquet meurt.
- 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.
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