Tableau de bord d'accueil : liens vers tous les services auto-hébergés, avec leur état en direct et des widgets, répartis sur une page d'accueil et trois pages secondaires.
Fonctionnalités utilisées
- Sections et liens vers les services, avec icônes stockées en local ou issues des jeux d'icônes intégrés.
- Indicateur d'état de chaque service (vérification de disponibilité).
- Configuration multi-pages : une page d'accueil (conf.yml) et trois pages secondaires déclarées dans sa section pages:.
- Widgets de contenu dynamique, dont certains interrogent des services locaux ou des API externes avec des identifiants (AdGuard Home, Nextcloud, API de vols et d'actualités).
- Cartes de statistiques GitHub fournies par une instance locale de github-readme-stats.
- Accès protégé par Authelia au niveau de SWAG : aucune page ni aucun fichier de configuration n'est servi sans connexion.
- Proxy CORS personnalisé dans SWAG, qui remplace celui de Dashy pour les widgets en useProxy: true.
- Configuration modifiable depuis l'interface, enregistrée dans les fichiers YAML.
Stack
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 : le serveur de Dashy accepte les certificats non reconnus lors des vérifications d'état et dans son proxy CORS, nécessaire pour les services locaux en https avec un certificat auto-signé.
- UID / GID : propriétaire des fichiers écrits dans user-data (sauvegarde depuis l'interface).
- Port 4000 : accès direct depuis le LAN, sans SWAG ni Authelia. L'accès normal passe par SWAG, qui joint le conteneur par son nom sur le réseau proxy, port interne 8080. Pour que tout accès passe par Authelia, supprimer cette publication de port : SWAG n'en a pas besoin.
- Volume : tout le dossier user-data est monté dans /app/user-data. Dashy sert ce dossier à la racine du site : conf.yml, les pages secondaires et item-icons/ sont accessibles par leur nom.
- Réseau proxy : réseau Docker externe partagé avec SWAG, à créer avant le premier déploiement (docker network create proxy).
- Mises à jour de l'image : Watchtower.
Exposition derrière SWAG
Fichier <APPDATA>/swag/config/nginx/proxy-confs/30-dashy.subdomain.conf. Chaque location inclut authelia-location.conf, selon le même schéma que les autres services protégés. Voir aussi SWAG.
listen 443 ssl;
listen [::]:443 ssl;
server_name <DOMAINE_DASHY>;
include /config/nginx/ssl.conf;
include /config/nginx/snippets/robots-default.conf;
client_max_body_size 25m;
# Authelia : tout le domaine exige une connexion
include /config/nginx/authelia-server.conf;
set $upstream_app dashy;
set $upstream_port 8080;
set $upstream_proto http;
# Interface web du Pico W, relayée sous /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;
}
# Proxy CORS des widgets, traité par SWAG et non par Dashy
location = /cors-proxy {
include /config/nginx/authelia-location.conf;
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; }
proxy_ssl_server_name on;
proxy_ssl_verify off;
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 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
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
if a == 192 and b == 168 then return true end
if a == 169 and b == 254 then return true end
return false
end
-- Anti-SSRF : LAN, loopback et metadata bloqués sauf liste blanche
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
-- En-têtes envoyés par Dashy en JSON dans CustomHeaders
local cjson = require "cjson.safe"
local ch = ngx.req.get_headers()["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
}
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 mode history)
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;
# Une 404 de l'upstream sur /login, /settings... renvoie l'index de l'app
proxy_intercept_errors on;
error_page 404 = @dashy_fallback;
}
location @dashy_fallback {
proxy_set_header X-Original-URI $request_uri;
proxy_pass $upstream_proto://$upstream_app:$upstream_port/;
}
}
Fonctionnement du proxy CORS :
- Un widget en useProxy: true n'appelle pas l'API cible depuis le navigateur : il envoie la requête à /cors-proxy sur le domaine de Dashy, avec l'adresse cible dans l'en-tête Target-URL et ses propres en-têtes (authentification comprise) en JSON dans CustomHeaders.
- Derrière SWAG, c'est ce bloc location = /cors-proxy qui répond, pas le proxy intégré de Dashy. Il ne transmet que Authorization, Accept et OCS-APIREQUEST : un widget qui a besoin d'un autre en-tête doit être ajouté dans le bloc Lua.
- Les adresses IP privées sont refusées (403), sauf celles de allowed_internal. Tout nouveau widget qui vise une IP du LAN doit y être ajouté, puis SWAG rechargé. Un nom de domaine public passe sans liste blanche.
- Les appels à /cors-proxy partent de la page Dashy, sur le même domaine : le navigateur y joint le cookie de session Authelia, et /cors-proxy n'est pas utilisable sans connexion.
- Les requêtes OPTIONS (pré-vol CORS) reçoivent leur 204 avant le contrôle Authelia, car les if de nginx sont évalués avant auth_request.
- En accès direct sur le port 4000, c'est le proxy intégré de Dashy qui répond, sans liste blanche ni Authelia.
- Nécessite le module Lua de nginx, présent dans l'image SWAG.
Accès
L'accès se fait en deux étapes :
- Authelia, devant tout le domaine : sans session Authelia, SWAG redirige vers le portail Authelia. Cela couvre l'interface, conf.yml et les pages secondaires, l'API de Dashy, /cors-proxy et /pico/.
- Dashy, une fois Authelia passé : son écran de connexion donne accès à l'éditeur de configuration au compte administrateur défini dans appConfig.auth.users (disableConfigurationForNonAdmin: true). Voir Authentification.
Règle dans la configuration d'Authelia, section access_control :
default_policy: deny
rules:
- domain: "<DOMAINE_DASHY>"
policy: one_factor
- La règle ne sert que si SWAG interroge Authelia : sans les include d'Authelia dans le fichier SWAG, elle n'est jamais évaluée, quelle que soit sa policy. Il faut les deux.
- Une règle bypass pour ce domaine, placée avant, annulerait la protection : Authelia applique la première règle qui correspond.
- enableGuestAccess: true ne concerne que les personnes déjà passées par Authelia : elles voient les pages sans se connecter à Dashy, mais ne peuvent pas modifier la configuration.
- Le fichier Authelia ouvre aussi le CORS de /.well-known/openid-configuration et /jwks.json au domaine de Dashy, en prévision d'une connexion OIDC. Celle-ci n'est pas activée dans conf.yml.
Configuration
Dans <APPDATA>/Dashy/user-data :
| Élément | Rôle |
|---|---|
| conf.yml | Page d'accueil et configuration commune : appConfig, pageInfo, pages, sections |
| bookmark.yml | Page secondaire bookmarks : favoris web |
| cpu.yml | Page secondaire cpu : supervision Glances |
| news.yml | Page secondaire news : capteurs, flux, actualités, crypto |
| item-icons/ | Icônes des services et image de fond, stockées en local |
| conf.yml.bak, bookmark.yml.bak | Copies manuelles, non utilisées par Dashy |
| config-backups/ | Dossier de sauvegardes de Dashy (vide actuellement) |
Fonctionnement multi-pages
La page d'accueil est toujours conf.yml. Les autres pages sont déclarées dans sa section pages: : chaque entrée donne le nom affiché dans la barre de navigation et le chemin du fichier, relatif à user-data (une URL distante est aussi acceptée).
- name: bookmarks
path: bookmark.yml
- name: cpu
path: cpu.yml
- name: news
path: news.yml
- Une page secondaire ne contient que pageInfo et sections : appConfig (thème, authentification, recherche...) est lu uniquement dans conf.yml et s'applique à toutes les pages.
- Chaque page secondaire répète la même liste pages:, pour garder la navigation identique d'une page à l'autre. Aucune ne déclare de fichier supplémentaire.
- Dans pageInfo.navLinks des pages secondaires, un lien Home vers / ramène à l'accueil.
- Ajouter une page : créer le fichier dans user-data, l'ajouter dans pages: de conf.yml et des autres pages, puis recharger.
Récapitulatif des pages
| Page Dashy | Fichier | Sous-page XWiki | Widgets utilisant des identifiants |
|---|---|---|---|
| Accueil | conf.yml | Accueil | adguard-stats, adguard-filter-status, adguard-top-domains (compte AdGuard Home), nextcloud-system (mot de passe d'application Nextcloud). Les widgets image dépendent du jeton GitHub de github-readme-stats, stocké côté serveur. |
| bookmarks | bookmark.yml | Bookmarks | Aucun widget |
| cpu | cpu.yml | CPU | Aucun (Glances sans authentification) |
| news | news.yml | News | flight-data (clé RapidAPI), news-headlines (clé Currents API) |
Fichiers non référencés
- cpu2.yml : ancienne copie de cpu.yml avec une indentation à un espace qui casse la structure YAML. Déclarée nulle part, mais servie comme les autres fichiers. À supprimer.
- conf.yml.bak et bookmark.yml.bak : copies de sauvegarde, non déclarées.
Structure d'un fichier
Un fichier de page contient trois blocs de premier niveau, plus appConfig et pages pour conf.yml :
title: <TITRE>
description: <SOUS_TITRE>
navLinks:
- title: <LIBELLE>
target: newtab
path: https://<SITE_PERSO>
appConfig:
# conf.yml uniquement, voir ci-dessous
pages:
# conf.yml, recopié dans chaque page secondaire
sections:
- name: <NOM_SECTION>
icon: <ICONE>
displayData:
sortBy: default # default | alphabetical | random
rows: 1
cols: 1
collapsed: false
hideForGuests: false
items:
- title: <NOM_SERVICE>
description: <DESCRIPTION>
icon: <ICONE>
url: https://<DOMAINE_SERVICE>/
target: newtab
statusCheck: true
statusCheckAllowInsecure: true
id: <ID>
widgets:
- type: <TYPE_WIDGET>
useProxy: true
options:
<OPTION>: <VALEUR>
id: <ID>
- id : identifiant unique généré par l'interface. Facultatif en écriture manuelle : Dashy en génère un s'il manque.
- icon : formes utilisées dans cette configuration :
- nom de fichier présent dans item-icons/ (portainer.svg) ;
- hl-<nom> : jeu d'icônes homelab intégré (hl-jellyfin) ;
- classes Font Awesome (fas fa-terminal, fab fa-github, fad fa-clock), actif via enableFontAwesome: true ;
- favicon : favicon du site récupéré par le service défini dans faviconApi ;
- emoji (🛡️) ou URL complète d'une image.
- useProxy se place sur le widget, pas sur la section : placé au niveau de la section, il est ignoré.
appConfig et thème
theme: dracula
layout: auto
iconSize: small
backgroundImg: /item-icons/<IMAGE_FOND>
startingView: default
defaultOpeningMethod: newtab
statusCheck: false
statusCheckInterval: 0
faviconApi: allesedv
enableFontAwesome: true
enableMaterialDesignIcons: false
widgetsAlwaysUseProxy: false
webSearch:
searchEngine: duckduckgo
openingMethod: newtab
searchBangs: {}
disableWebSearch: false
auth:
enableGuestAccess: true
users:
- user: <UTILISATEUR_DASHY>
hash: <HASH_SHA256_DU_MOT_DE_PASSE>
type: admin
enableHeaderAuth: false
headerAuth:
userHeader: REMOTE_USER
proxyWhitelist: []
enableKeycloak: false
hideComponents:
hideHeading: false
hideNav: false
hideSearch: false
hideSettings: false
hideFooter: false
showSplashScreen: false
preventWriteToDisk: false
preventLocalSave: false
disableConfiguration: false
disableConfigurationForNonAdmin: true
allowConfigEdit: true
enableServiceWorker: false
disableContextMenu: false
disableUpdateChecks: false
disableSmartSort: false
enableErrorReporting: false
| Clé | Effet |
|---|---|
| theme: dracula | Thème sombre intégré, appliqué à toutes les pages |
| backgroundImg | Image de fond, servie depuis item-icons/ |
| statusCheck: false | Pas de vérification d'état globale : elle est activée item par item (statusCheck: true) |
| faviconApi: allesedv | Les icônes favicon sont récupérées via le service externe allesedv |
| widgetsAlwaysUseProxy: false | Chaque widget choisit avec useProxy s'il passe par /cors-proxy |
| webSearch | Recherche web DuckDuckGo depuis la barre de recherche, en nouvel onglet |
| preventWriteToDisk: false | L'interface peut écrire dans les fichiers YAML du volume |
| preventLocalSave: false | Les modifications peuvent aussi être gardées dans le stockage local du navigateur |
| disableConfigurationForNonAdmin: true | Seul un compte type: admin voit l'éditeur de configuration |
Authentification
- Un seul compte, administrateur, défini par son nom et le hash SHA-256 de son mot de passe. Générer le hash :
- enableGuestAccess: true : les visiteurs authentifiés par Authelia mais pas par Dashy voient toutes les pages. hideForGuests: true sur une section la masque aux invités, sans la retirer des fichiers servis.
- enableHeaderAuth (utilisateur transmis par le reverse proxy dans REMOTE_USER) et enableKeycloak sont désactivés.
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_GITHUB> |
| Langages les plus utilisés | /api/top-langs?username=<UTILISATEUR_GITHUB> |
| Dépôt épinglé | /api/pin?username=<UTILISATEUR_GITHUB>&repo=<DEPOT> |
Dans Dashy, les cartes s'affichent avec un widget de type image qui pointe vers l'instance locale (voir la sous-page Accueil).
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 d'un service
| Service migré ou réinstallé | À refaire dans Dashy |
|---|---|
| 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 (sauvegarde restaurée, passage à AIO, nouvelle instance). Vérifier que le compte est toujours administrateur et que le nom d'utilisateur a gardé la même casse. |
| AdGuard Home | Reporter le nom d'utilisateur et le mot de passe de l'interface web dans les trois widgets adguard-*. Si l'adresse IP change : mettre à jour hostname et la liste allowed_internal de SWAG. |
| Glances | Mettre à jour hostname dans tous les widgets gl-* de cpu.yml si le domaine change. |
| github-readme-stats | Reconstruire l'image locale et recréer le jeton PAT_1 s'il a expiré. |
| Pico W | Mettre à jour son IP dans news.yml (item et script du widget embed), dans location /pico/ et dans allowed_internal de SWAG. |
| SWAG | Recopier 30-dashy.subdomain.conf complet. Sans les include d'Authelia, Dashy et ses fichiers de configuration redeviennent publics. Sans le bloc /cors-proxy, les widgets en useProxy: true passent par le proxy de Dashy, sans liste blanche. |
| Authelia | Vérifier que la règle one_factor du domaine de Dashy est toujours présente et qu'aucune règle bypass ne la précède. |
| Dashy | Restaurer user-data (fichiers YAML et item-icons/). Aucun identifiant n'est stocké ailleurs. |
| API externes (RapidAPI, Currents) | Si la clé est régénérée ou le compte recréé, reporter la nouvelle clé dans news.yml. |
Recharger la configuration
- Modification depuis l'interface : l'enregistrement sur le disque, depuis l'éditeur de configuration, écrit le fichier YAML de la page affichée.
- Modification à la main : Dashy relit les fichiers YAML à chaque chargement, un rafraîchissement du navigateur suffit. Si l'ancienne configuration reste affichée, elle vient du stockage local du navigateur : réinitialiser les paramètres locaux depuis le menu de configuration, ou vider les données du site.
- Changement dans la stack (variables d'environnement, image) : redéployer la stack dans Portainer.
- Changement dans le fichier SWAG : docker exec swag nginx -t puis docker exec swag nginx -s reload.
- Changement dans la configuration d'Authelia : docker restart authelia.
Un widget reste vide
Dans l'ordre :
- Outils de développement du navigateur (F12), onglets Console et Réseau : repérer la requête du widget et son code de retour.
- Requête vers /cors-proxy :
- 302 vers le portail Authelia, ou 401 : session Authelia expirée ; recharger la page et se reconnecter ;
- 400 Target-URL invalide : hostname mal formé (schéma http:// ou https:// obligatoire) ;
- 403 Cible interne non autorisée : IP du LAN absente de allowed_internal dans SWAG ;
- 401 ou 403 renvoyé par la cible : identifiants faux, mot de passe d'application révoqué, compte sans droits suffisants, ou en-tête d'authentification non transmis par le bloc Lua ;
- 502 ou 504 : service cible arrêté ou injoignable depuis le conteneur SWAG.
- Requête directe (sans /cors-proxy) bloquée avec une erreur CORS dans la console : passer le widget en useProxy: true.
- Journaux : docker logs swag et <APPDATA>/swag/config/log/nginx/error.log pour le proxy, docker logs dashy pour le serveur de Dashy.
- Vérifier la syntaxe du fichier avec yarn validate-config : une erreur d'indentation peut vider toute une section.
- Tester l'API cible à la main depuis l'hôte, avec les mêmes identifiants. Exemple pour Nextcloud :
'https://<DOMAINE_NEXTCLOUD>/ocs/v2.php/apps/serverinfo/api/v1/info?format=json'
Sauvegarde
<APPDATA>/Dashy/user-data est sauvegardé par le script de Sauvegarde. Il contient toute la configuration, identifiants des widgets compris : la sauvegarde doit être protégée en conséquence. Le fichier SWAG 30-dashy.subdomain.conf est aussi dans <APPDATA>, couvert par la même sauvegarde.