7.7 KiB
Résilience DSH-VPS face aux coupures NAS + fix tailscale-vps-ssh-relay + leçon bifrost-proxy
Date : 24/08/2026 Contexte : coupure STEG (canicule) ayant coupé le NAS pendant plusieurs heures, DSH-VPS (VPS Contabo, indépendant électriquement) s'est retrouvé hors service car il dépendait entièrement de Bifrost (NAS) pour parler aux modèles.
1. Route directe OpenCode pour DSH (indépendante du NAS)
Problème : DSH ne parlait qu'à Bifrost (http://100.86.197.88:3086/v1), lui-même hébergé sur le NAS. Toute coupure NAS = DSH inopérant, même si le VPS tourne.
Solution : ajout d'un second provider dans settings.yaml de DSH (volume dsh_vps_home, fichier /home/dsh-agent/.dsh/settings.yaml), en plus de bifrost (pas en remplacement) :
llm-pi-ai:
providers:
bifrost:
# ... inchangé, reste le défaut (agent-default-model) ...
opencode-direct:
displayName: OpenCode Zen (direct, sans NAS)
apiKeyEnv: OPENCODE_DIRECT_API_KEY
api: openai-completions
baseURL: https://opencode.ai/zen/go/v1
compat:
thinkingFormat: deepseek
models:
- id: deepseek-v4-flash
contextWindow: 131072
maxTokens: 16384
- id: deepseek-v4-pro
contextWindow: 131072
maxTokens: 16384
- id: mimo-v2.5
contextWindow: 1048576
maxTokens: 131000
- id: ox-alpha-free
contextWindow: 1048576
maxTokens: 131072
Clé (OPENCODE_DIRECT_API_KEY) = même clé que celle utilisée par Bifrost pour son provider opencode (BIFROST_OPENCODE_KEY dans /mnt/docker/hermes-platform/.env sur le NAS). Même compte, même forfait — juste un deuxième chemin d'accès. Ajoutée en dur dans .credentials.yaml du conteneur DSH et dans docker-compose.yml (services.dsh-vps.environment).
⚠️ Conséquence à documenter : cette clé existe maintenant sur DEUX hôtes (NAS + VPS). En cas de rotation future, penser aux deux.
Décision produit (Nabil, 24/08/2026) — OpenCode plutôt qu'OpenRouter pour un modèle gratuit-temporaire : Bifrost a aussi un provider openrouter avec accès large ("models": ["*"]). Pour un modèle comme stealth/ox-alpha (gratuit maintenant, potentiellement payant demain), la route OpenRouter a été écartée délibérément : c'est du pay-as-you-go sans plafond — si le modèle bascule en payant, la facturation continue silencieusement jusqu'à ce qu'on s'en aperçoive. La route OpenCode Zen Go tourne sur un forfait plafonné ($10/mois) : le pire cas est soit une consommation du forfait existant, soit un arrêt net du service — jamais une dérive de facturation. Règle générale à retenir : pour tout futur modèle gratuit-temporaire/stealth, préférer systématiquement une route à coût plafonné (forfait) à une route pay-as-you-go pure, même si cette dernière est théoriquement plus permissive.
Validation : testé via dsh --profile headless --patch <override agent-default-model> (pas juste curl brut vers l'API OpenCode — un curl réussi ne prouve pas que le runtime DSH lui-même sait utiliser la config). 3/3 sur mimo-v2.5 et ox-alpha-free après quelques échecs initiaux dus à la flakiness intrinsèque de l'endpoint OpenCode Zen "go" (gratuit/beta) — pas un bug de config. Piège à éviter : ne pas conclure à un bug de config sur un seul test raté sur cet endpoint ; retester plusieurs fois avant de creuser plus loin.
2. Leçon bifrost-proxy — faux positif à ne pas reproduire
Ce qui a été (à tort) diagnostiqué comme une régression : tout appel vers http://100.86.197.88:3086/v1/... (bifrost-proxy, exposition Tailscale de Bifrost pour le VPS) avec un header x-bf-vk: <clé> renvoyait 401 virtual key is required, alors que la même clé fonctionnait parfaitement en LAN direct (172.17.0.1:3085 ou 192.168.100.33:3085).
Cause réelle (pas une panne) : bifrost-proxy est un nginx (openresty) qui ne transmet pas le header x-bf-vk reçu du client. Sa conf (/usr/local/openresty/nginx/conf/nginx.conf dans le conteneur bifrost-proxy) le reconstruit lui-même :
map $http_authorization $bearer_vk { ~*^Bearer\s+(.+)$ $1; default ""; }
map $http_x_api_key $resolved_vk { "" $bearer_vk; default $http_x_api_key; }
...
proxy_set_header x-bf-vk $resolved_vk;
Donc ce endpoint accepte l'auth via Authorization: Bearer <clé> OU X-Api-Key: <clé> — jamais x-bf-vk directement en entrée (ce header n'existe qu'en interne, entre bifrost-proxy et bifrost:8080, et sur le endpoint LAN direct qui lui l'accepte nativement). Tester avec x-bf-vk sur le endpoint Tailscale envoie une VK vide côté proxy → 401 qui ressemble à une vraie panne mais n'en est pas une.
Règle à retenir : pour tester bifrost-proxy (port 3086, Tailscale) → -H "Authorization: Bearer sk-bf-...". Pour tester Bifrost en LAN direct (port 3085) → -H "x-bf-vk: sk-bf-...". Deux endpoints, deux conventions différentes — ne pas les confondre au prochain incident.
3. Fix tailscale-vps-ssh-relay (NAS)
Symptôme : conteneur tailscale-vps-ssh-relay refusait de démarrer après un reboot NAS : cannot join network of a non running container: <id> (network_mode: container:<id>, cible = tailscale-nyora-bridge, pas encore démarrée — simple course au démarrage, résolu par un docker start une fois la cible up).
Vrai problème sous-jacent, découvert en creusant la fiabilité : tailscale-nyora-bridge tourne en Tailscale mode userspace — pas de /dev/net/tun, pas d'interface tailscale0/tun0 dans son netns (confirmé via docker exec tailscale-nyora-bridge ip a). Conséquence : toute connexion TCP directe du noyau vers une IP Tailscale (100.64.0.0/10) depuis ce netns time-out systématiquement — le trafic doit obligatoirement passer par le proxy SOCKS5 local que Tailscale expose en mode userspace, sur 127.0.0.1:1055.
L'ancien relais (while true; do nc -lk -p 22223 -e nc <ip-cible> <port-cible>; sleep 1; done, BusyBox nc) cumulait deux problèmes : tentative de connexion TCP directe (donc timeout garanti, indépendamment du reste) ET un -lk dont le support par BusyBox nc est douteux (races de bind/TIME_WAIT → trous d'écoute intermittents, nc: bind: Address in use en boucle).
Correctif pérenne déployé : service dédié /volume1/docker/tailscale-vps-ssh-relay/ :
FROM alpine:latest
RUN apk add --no-cache socat
ENTRYPOINT ["socat", "TCP-LISTEN:22223,fork,reuseaddr", "SOCKS5-CONNECT:127.0.0.1:<ip-cible>:<port-cible>,socksport=1055"]
services:
tailscale-vps-ssh-relay:
build: .
network_mode: "container:tailscale-nyora-bridge"
restart: unless-stopped
Validation : 20/20 (bannière SSH via IP Tailscale) + 20/20 (bannière via IP interne Docker) + 5/5 sessions SSH authentifiées réelles côté Gemini ; 10/10 rebanniere confirmés indépendamment par Claude en méthode différente (Python socket direct plutôt que curl). Convergence forte, accepté.
Règle générale à retenir : pour tout futur relais/proxy TCP sortant vers une IP Tailscale depuis un conteneur — vérifier d'abord son mode Tailscale (docker exec <ctn> ip a : présence ou non de /dev/net/tun et d'une interface tailscale0/tun0). En mode userspace (le cas courant pour un conteneur sans --cap-add=NET_ADMIN --device=/dev/net/tun), la connexion sortante doit systématiquement passer par SOCKS5-CONNECT:127.0.0.1:<cible>:<port>,socksport=1055 (typiquement via socat), jamais une connexion TCP directe — celle-ci time-out à coup sûr.
Fichiers de référence
brief-gemini-fix-bifrost-proxy-ox-alpha-dsh-20260824.md(historique complet de la décision OpenCode vs OpenRouter, v1→v3)brief-gemini-fix-ssh-relay-20260824.md