Files
nas-runbooks/common/dsh-resilience-opencode-direct-bifrost-proxy-tailscale-relay-20260824.md
T

91 lines
7.7 KiB
Markdown

# 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) :
```yaml
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 :
```nginx
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/` :
```dockerfile
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"]
```
```yaml
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`