Dashy
Tableau de bord d'accueil du serveur principal : liens vers les services auto-hébergés avec leur état, et widgets. Il compte quatre pages : l'accueil (conf.yml) et trois pages secondaires.
Les valeurs propres à l'infrastructure sont remplacées par des marqueurs <ENTRE_CHEVRONS> : <DOMAINE>, adresses IP, comptes, mots de passe et clés.
Déploiement
Stack Portainer
dashy:
image: lissy93/dashy:latest
container_name: dashy
environment:
- NODE_ENV=production
- UID=1000
- GID=1000
- NODE_TLS_REJECT_UNAUTHORIZED=0
ports:
- "4000:8080"
volumes:
- <APPDATA>/Dashy/user-data:/app/user-data
restart: unless-stopped
healthcheck:
test: ['CMD', 'node', '/app/services/healthcheck']
interval: 90s
timeout: 10s
retries: 3
start_period: 40s
networks:
- proxy
networks:
proxy:
external: true
- NODE_TLS_REJECT_UNAUTHORIZED=0 : Dashy accepte les certificats auto-signés des services locaux (vérifications d'état, proxy CORS).
- user-data contient toute la configuration. Dashy sert ce dossier à la racine du site.
- Le port 4000 donne un accès direct depuis le LAN, sans SWAG ni Authelia. SWAG n'en a pas besoin : il joint le conteneur par le réseau proxy.
SWAG
<APPDATA>/swag/config/nginx/proxy-confs/30-dashy.subdomain.conf :
listen 443 ssl;
listen [::]:443 ssl;
server_name dash.<DOMAINE>;
include /config/nginx/ssl.conf;
include /config/nginx/snippets/robots-default.conf;
client_max_body_size 25m;
# Authelia devant tout le domaine
include /config/nginx/authelia-server.conf;
# Upstream Dashy
set $upstream_app dashy;
set $upstream_port 8080;
set $upstream_proto http;
# (facultatif) proxy vers /pico/
location /pico/ {
include /config/nginx/authelia-location.conf;
proxy_pass http://<IP_PICO>/;
include /config/nginx/proxy.conf;
proxy_set_header Connection close;
}
# API de Dashy (sauvegarde de la config, vérifications d'état)
location ^~ /api/ {
include /config/nginx/authelia-location.conf;
include /config/nginx/proxy.conf;
include /config/nginx/resolver.conf;
proxy_pass $upstream_proto://$upstream_app:$upstream_port;
}
location = /cors-proxy {
include /config/nginx/authelia-location.conf;
# Dashy envoie la cible dans ce header
set $target $http_target_url;
if ($target = "") { return 400; }
add_header Access-Control-Allow-Origin "*" always;
add_header Access-Control-Allow-Methods "GET, POST, OPTIONS" always;
add_header Access-Control-Allow-Headers "*" always;
if ($request_method = OPTIONS) { return 204; }
# TLS (certs auto-signés possibles sur les cibles LAN, ex: Nextcloud)
proxy_ssl_server_name on;
proxy_ssl_verify off;
# Par défaut, on ne fait pas confiance à l'input
set $auth_header "";
set $accept_header "";
set $ocs_header "";
set $host_override "";
access_by_lua_block {
local target = ngx.var.target
local host, port = target:match("^https?://([^/:]+):?(%d*)")
if not host then
ngx.status = 400
ngx.header["Content-Type"] = "application/json"
ngx.say('{"error":"Target-URL invalide"}')
return ngx.exit(400)
end
-- Cibles LAN explicitement autorisées (widgets Dashy internes)
local allowed_internal = { ["<IP_SERVEUR>"] = true, ["<IP_ADGUARD>"] = true, ["<IP_PICO>"] = true }
local function is_private_ip(h)
if h == "localhost" then return true end
local a, b = h:match("^(%d+)%.(%d+)%.%d+%.%d+$")
if not a then return false end -- pas une IPv4 littérale -> traité comme hôte public
a, b = tonumber(a), tonumber(b)
if a == 127 or a == 10 or a == 0 then return true end
if a == 172 and b >= 16 and b <= 31 then return true end -- 172.16.0.0/12 (docker inclus)
if a == 192 and b == 168 then return true end
if a == 169 and b == 254 then return true end -- link-local / metadata cloud
return false
end
-- Anti-SSRF : on bloque tout le LAN/loopback/metadata, sauf les cibles explicitement whitelistées
if is_private_ip(host) and not allowed_internal[host] then
ngx.status = 403
ngx.header["Content-Type"] = "application/json"
ngx.say('{"error":"Cible interne non autorisée"}')
return ngx.exit(403)
end
ngx.var.host_override = (port ~= "" and port ~= nil) and (host .. ":" .. port) or host
-- Parse le JSON envoyé dans customheaders
local cjson = require "cjson.safe"
local hdrs = ngx.req.get_headers()
local ch = hdrs["customheaders"]
if ch then
local obj = cjson.decode(ch)
if obj then
if obj["Authorization"] then ngx.var.auth_header = obj["Authorization"] end
if obj["Accept"] then ngx.var.accept_header = obj["Accept"] end
if obj["OCS-APIREQUEST"] ~= nil then
ngx.var.ocs_header = tostring(obj["OCS-APIREQUEST"])
end
end
end
}
# Injecte les vrais headers vers la cible
proxy_set_header Authorization $auth_header;
proxy_set_header Accept $accept_header;
proxy_set_header OCS-APIREQUEST $ocs_header;
proxy_set_header Host $host_override;
proxy_set_header X-Forwarded-Proto https;
proxy_pass $target;
}
# Application (SPA Vue en history mode)
location / {
include /config/nginx/authelia-location.conf;
include /config/nginx/proxy.conf;
include /config/nginx/resolver.conf;
proxy_pass $upstream_proto://$upstream_app:$upstream_port;
# --- Fallback SPA : si l’upstream renvoie 404 sur /login, /settings, etc.,
# on renvoie l'index de l'app pour laisser Vue router côté client.
proxy_intercept_errors on;
error_page 404 = @dashy_fallback;
}
# Cible du fallback : renvoyer l'index de l'app en conservant les en-têtes
location @dashy_fallback {
proxy_set_header X-Original-URI $request_uri;
proxy_pass $upstream_proto://$upstream_app:$upstream_port/;
}
}
- Authelia protège tout le domaine, y compris les fichiers YAML, qui contiennent les identifiants des widgets.
- /cors-proxy remplace le proxy CORS de Dashy pour les widgets en useProxy: true. Il ne relaie que les en-têtes Authorization, Accept et OCS-APIREQUEST, et refuse les IP privées absentes de allowed_internal : tout nouveau widget qui vise une IP du LAN doit y être ajouté.
- /pico/ relaie l'interface web du Pico W.
- @dashy_fallback renvoie l'application sur les routes internes de Vue (/login...), que le serveur de Dashy renvoie en 404.
Authelia
Règle dans la section access_control de la configuration d'Authelia :
default_policy: deny
rules:
- domain: "dash.<DOMAINE>"
policy: one_factor
Aucune règle bypass pour ce domaine ne doit la précéder : Authelia applique la première règle qui correspond.
Fichiers de configuration
Dans <APPDATA>/Dashy/user-data :
| Page Dashy | Fichier | Sous-page | Widgets avec identifiants |
|---|---|---|---|
| Accueil | conf.yml | Accueil | adguard-stats, adguard-filter-status, adguard-top-domains (compte AdGuard Home) ; nextcloud-system (mot de passe d'application Nextcloud) |
| bookmarks | bookmark.yml | Bookmarks | — |
| cpu | cpu.yml | CPU | — |
| news | news.yml | News | flight-data (clé RapidAPI) ; news-headlines (clé Currents API) |
item-icons/ contient les icônes locales et l'image de fond.
Multi-pages
La page d'accueil est conf.yml. Elle déclare les pages secondaires dans pages: : nom affiché dans la barre de navigation et chemin relatif à user-data.
- name: bookmarks
path: bookmark.yml
- name: cpu
path: cpu.yml
- name: news
path: news.yml
Chaque page secondaire ne contient que pageInfo, sections et la même liste pages:, pour garder la navigation. appConfig n'est lu que dans conf.yml et s'applique à toutes les pages.
Fichiers présents mais non référencés : cpu2.yml (ancienne copie de cpu.yml à l'indentation cassée), conf.yml.bak et bookmark.yml.bak.
Éléments communs
appConfig est dans conf.yml, dont le fichier complet est sur la sous-page Accueil.
- Thème dracula, image de fond servie depuis item-icons/.
- statusCheck: false au niveau global : la vérification d'état est activée item par item.
- faviconApi: allesedv pour les items en icon: favicon. enableFontAwesome: true pour les icônes fas, fab et fad. Les icônes hl-... viennent du jeu homelab intégré.
- widgetsAlwaysUseProxy: false : chaque widget choisit avec useProxy s'il passe par /cors-proxy. Le réglage se met sur le widget : au niveau de la section, il est ignoré.
- Authentification : un compte admin défini par son nom et le hash SHA-256 de son mot de passe (echo -n '<MOT_DE_PASSE>' | sha256sum). enableGuestAccess: true : une fois passé Authelia, on voit les pages sans se connecter à Dashy. disableConfigurationForNonAdmin: true : seul l'admin modifie la configuration.
Vérifier la syntaxe après une modification à la main :
Statistiques GitHub : github-readme-stats
Instance locale de github-readme-stats, qui génère les cartes SVG de statistiques GitHub affichées par un widget de Dashy : statistiques du profil, langages les plus utilisés, dépôts épinglés.
github-readme-stats:
image: github-readme-stats:local
container_name: github-readme-stats
restart: unless-stopped
ports:
- "9000:9000"
environment:
- PAT_1=${PAT_1}
- CACHE_SECONDS=14400
- PAT_1 : jeton d'accès personnel GitHub, défini dans les variables de la stack Portainer. Jeton classique avec les permissions repo pour compter les dépôts privés.
- CACHE_SECONDS : durée de mise en cache des cartes (4 heures), pour limiter les appels à l'API GitHub.
- Accessible uniquement en local, sur le port 9000.
Principales adresses :
| Carte | Adresse |
|---|---|
| Statistiques du profil | /api?username=<UTILISATEUR> |
| Langages les plus utilisés | /api/top-langs?username=<UTILISATEUR> |
| Dépôt épinglé | /api/pin?username=<UTILISATEUR>&repo=<DEPOT> |
Dans Dashy, les cartes s'affichent avec un widget de type image qui pointe vers l'instance locale.
Image locale
Portainer ne sait pas construire une image dans une stack : l'image github-readme-stats:local est construite sur l'hôte, depuis le dépôt cloné. Elle n'est donc pas mise à jour par Watchtower. Pour la mettre à jour, depuis le dossier du dépôt :
docker build -t github-readme-stats:local .
Puis redéployer la stack dans Portainer.
Maintenance
Après une migration ou une réinstallation
| Service | À refaire |
|---|---|
| Nextcloud | Créer un nouveau mot de passe d'application et le reporter dans nextcloud-system : les mots de passe d'application ne suivent pas une migration. |
| AdGuard Home | Reporter le compte de l'interface web dans les trois widgets adguard-*. Si l'IP change : hostname et allowed_internal de SWAG. |
| Pico W | Si l'IP change : news.yml (item et script), location /pico/ et allowed_internal de SWAG. |
| SWAG, Authelia | Remettre 30-dashy.subdomain.conf complet et la règle one_factor, puis vérifier en navigation privée que /conf.yml redirige vers Authelia. |
| Dashy | Restaurer user-data. |
Recharger la configuration
- Dashy relit les fichiers YAML à chaque chargement : après une modification à la main, rafraîchir la page. Si l'ancienne version reste affichée, elle vient du stockage local du navigateur : réinitialiser les paramètres locaux depuis le menu de configuration.
- SWAG : docker exec swag nginx -t puis docker exec swag nginx -s reload. Authelia : docker restart authelia.
Un widget reste vide
Dans les outils de développement du navigateur (F12, onglet Réseau), regarder la requête du widget :
| Symptôme | Cause |
|---|---|
| Erreur CORS sur un appel direct | Passer le widget en useProxy: true |
| /cors-proxy : 302 ou 401 | Session Authelia expirée : recharger la page |
| /cors-proxy : 403 Cible interne non autorisée | IP absente de allowed_internal |
| /cors-proxy : 401 ou 403 de la cible | Identifiants faux ou révoqués, compte sans droits, ou en-tête non relayé par le bloc Lua |
| /cors-proxy : 502 ou 504 | Service cible arrêté ou injoignable depuis SWAG |
Journaux : docker logs dashy et <APPDATA>/swag/config/log/nginx/error.log.
Sauvegarde
<APPDATA>/Dashy/user-data est sauvegardé par le script de Sauvegarde, identifiants des widgets compris. Le fichier SWAG est aussi dans <APPDATA>.