0 Votes

Dashy

Version 4.1 par john le 2026/09/26 16:22

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

services:
 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.

server {
   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 :

  1. 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/.
  2. 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 :

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.
Warning

C'est Authelia qui protège les fichiers, pas Dashy : l'authentification de Dashy se fait dans le navigateur et masque seulement l'éditeur. conf.yml et les pages secondaires contiennent les identifiants des widgets, les clés d'API, les adresses internes et le hash du mot de passe administrateur. Toute voie d'accès qui contourne Authelia (port 4000, autre proxy, règle bypass) les rend lisibles. Vérifier après chaque modification de SWAG ou d'Authelia, en navigation privée : /conf.yml doit renvoyer vers le portail Authelia.

Configuration

Dans <APPDATA>/Dashy/user-data :

ÉlémentRôle
conf.ymlPage d'accueil et configuration commune : appConfig, pageInfo, pages, sections
bookmark.ymlPage secondaire bookmarks : favoris web
cpu.ymlPage secondaire cpu : supervision Glances
news.ymlPage 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.bakCopies 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).

pages:
  - 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 DashyFichierSous-page XWikiWidgets utilisant des identifiants
Accueilconf.ymlAccueiladguard-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.
bookmarksbookmark.ymlBookmarksAucun widget
cpucpu.ymlCPUAucun (Glances sans authentification)
newsnews.ymlNewsflight-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 :

pageInfo:
 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

appConfig:
 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: draculaThème sombre intégré, appliqué à toutes les pages
backgroundImgImage de fond, servie depuis item-icons/
statusCheck: falsePas de vérification d'état globale : elle est activée item par item (statusCheck: true)
faviconApi: allesedvLes icônes favicon sont récupérées via le service externe allesedv
widgetsAlwaysUseProxy: falseChaque widget choisit avec useProxy s'il passe par /cors-proxy
webSearchRecherche web DuckDuckGo depuis la barre de recherche, en nouvel onglet
preventWriteToDisk: falseL'interface peut écrire dans les fichiers YAML du volume
preventLocalSave: falseLes modifications peuvent aussi être gardées dans le stockage local du navigateur
disableConfigurationForNonAdmin: trueSeul 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 :
echo -n '<MOT_DE_PASSE_DASHY>' | sha256sum
  • 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 :

docker exec dashy yarn validate-config

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.

services:
 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 :

CarteAdresse
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 :

git pull
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
NextcloudCré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 HomeReporter 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.
GlancesMettre à jour hostname dans tous les widgets gl-* de cpu.yml si le domaine change.
github-readme-statsReconstruire l'image locale et recréer le jeton PAT_1 s'il a expiré.
Pico WMettre à jour son IP dans news.yml (item et script du widget embed), dans location /pico/ et dans allowed_internal de SWAG.
SWAGRecopier 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.
AutheliaVé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.
DashyRestaurer 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 :

  1. Outils de développement du navigateur (F12), onglets Console et Réseau : repérer la requête du widget et son code de retour.
  2. 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.
  3. Requête directe (sans /cors-proxy) bloquée avec une erreur CORS dans la console : passer le widget en useProxy: true.
  4. Journaux : docker logs swag et <APPDATA>/swag/config/log/nginx/error.log pour le proxy, docker logs dashy pour le serveur de Dashy.
  5. Vérifier la syntaxe du fichier avec yarn validate-config : une erreur d'indentation peut vider toute une section.
  6. Tester l'API cible à la main depuis l'hôte, avec les mêmes identifiants. Exemple pour Nextcloud :
curl -u '<UTILISATEUR_NEXTCLOUD>:<MOT_DE_PASSE_APPLICATION>' -H 'OCS-APIREQUEST: true' \
 '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.