L'UI de MinIO est-elle vraiment limitée ?

hack 29/05/2023
⚠️
Attention : cet article explique certains points sur la sécurité avec l'utilisation des APIs et des conditions définies côté frontend. Aucune incitation au piratage ou à by-passer certaines fonctions soumises à des licences. Cet article est donc uniquement à but éducatif. Merci de votre compréhension et bonne lecture.

J'utilise MinIO depuis plusieurs années pour mon stockage de type S3 (comprendre Object Storage). Mais dernièrement j'avais mis un peu le truc de côté car j'avais une erreur lorsque je voulais le mettre à jour :

ERROR: Unable to use the drive /data: Drive /data: found backend type fs expected xl or xl-single: Invalid arguments specified

J'ai finalement pu faire ça en repartant d'une base vierge, suivi d'une migration des données, que j'ai fais avec mc, c'était assez rapide. J'en ai profité aussi pour switcher sur l'image disponible chez Quay, pour éviter les limitations (des pulls) du DockerHub. Il y avait beaucoup de changements mais j'étais content car j'avais maintenant un truc tout beau, tout neuf !

Pour la suite de cet article, j'ai monté un environnement local avec Docker. Les détails qui suivent sont donnés uniquement à titre d'information et je déconseille très fortement d'appliquer ça sur vos environnements.

Donc après avoir effectué cette installation, j'aime toujours refaire un tour du propriétaire pour m'assurer que tout est conforme, surtout qu'ici il s'agissait d'un changement important de MinIO, avec une nouvelle UI + Core. Mais très vite une chose particulière attire mon attention :

Une belle erreur 500 lors de l'appel à /api/v1/subnet/info avec comme information "license is not present". Bon à mon sens il faudrait mettre autre chose qu'une 500 mais ce n'est que mon avis. Ceci étant, ça m'amène à regarder la page des licences :

Jusqu'ici tout me semble bon, puisque j'utilise la version" gratuite" de MinIO. Mais tout de même, cette erreur 500 me fait assez chier puisque le truc est juste installé (alors oui je suis assez maniaque sur les bords). Pour le coup j'ai l'avantage d'avoir un Nginx positionné devant le MinIO avec simplement une configuration de type "proxy_pass", pour gérer du filtrage, page de maintenance, ...

A ce stade je vais pas me prendre la tête et faire une location pour retourner simplement une 200 avec un JSON vide :

location /api/v1/subnet/info {
  default_type application/json;
  return 200 '{}';
}

Le résultat est parfait, j'ai bien une 200, c'est "assez" propre mais je remarque aussitôt un changement sur la page des licences (le "registered" en haut) :

Je trouve ça assez louche... Alors je décide de jetter un oeil dans les sources Github, pas directement minio/minio (car on va dire que ça concerne le backend) mais plutôt minio/console (qui est le frontend). Ne connaissant pas du tout l'architecture des dossiers et fichiers, j'utilise une méthode de recherche assez radicale avec "grep" (avec le chemin de l'API) mais qui a au moins le mérite de fonctionner :

[~]$ git clone https://github.com/minio/console
Cloning into 'console'...
remote: Enumerating objects: 64306, done.
remote: Counting objects: 100% (2366/2366), done.
remote: Compressing objects: 100% (731/731), done.
remote: Total 64306 (delta 1745), reused 2089 (delta 1600), pack-reused 61940
Receiving objects: 100% (64306/64306), 574.98 MiB | 18.34 MiB/s, done.
Resolving deltas: 100% (45990/45990), done.

[~]$ cd console

[~]$ egrep -rils "api/v1/subnet/info" *
portal-ui/build/static/js/6526.398da3a4.chunk.js
portal-ui/build/static/js/6526.398da3a4.chunk.js.map
portal-ui/build/static/js/4902.8a1d275a.chunk.js.map
portal-ui/build/static/js/main.1379d48c.js
portal-ui/build/static/js/main.1379d48c.js.map
portal-ui/build/static/js/4902.8a1d275a.chunk.js
portal-ui/src/screens/Console/License/License.tsx
portal-ui/src/screens/Console/Support/registerThunks.ts

Bonne nouvelle déjà, il y a plusieurs fichiers. J'ignore les fichiers JS car ils n'auront rien d'intéressant, reste donc seulement 2 fichiers :

  • portal-ui/src/screens/Console/License/License.tsx
  • portal-ui/src/screens/Console/Support/registerThunks.ts

J'ouvre le premier fichier car son nom "License" me semble en phase avec ma page pour faire une inspection, bon à l'aveugle car je ne connais pas ce language et je cherche directement les lignes intéressantes :

const fetchLicenseInfo = useCallback(() => {
  if (loadingLicenseInfo) {
    return;
  }
  setLoadingLicenseInfo(true);
  api
  .invoke("GET", `/api/v1/subnet/info`)
  .then((res: SubnetInfo) => {
    if (res) {
      if (res.plan === "STANDARD") {
        setCurrentPlanID(1);
      } else if (res.plan === "ENTERPRISE") {
        setCurrentPlanID(2);
      } else {
        setCurrentPlanID(1);
      }
      setLicenseInfo(res);
    }
    setClusterRegistered(true);
    setLoadingLicenseInfo(false);
  })
  .catch(() => {
    setClusterRegistered(false);
    setLoadingLicenseInfo(false);
  });
}, [loadingLicenseInfo]);

Finalement sans tout comprendre à ce language, il est simple à comprendre que le "invoke" génère le GET à l'API, et que le "res" est le résultat renvoyé. Tout ça pour déduire que le "res.plan" est le champ "plan" dans le retour JSON. On test ça ?

location /api/v1/subnet/info {
  default_type application/json;
  return 200 '{"plan":"ENTERPRISE"}';
}

Ah c'est cool ! Il est possible de voir qu'on a maintenant le plan enterprise :

Mais il reste encore ce truc en haut "Registered to"... Sans chercher plus loin je ferme le fichier et je regarde le suivant, où la fonction semble être plus complexe :

export const fetchLicenseInfo = createAsyncThunk(
  "register/fetchLicenseInfo",
  async (_, { getState, dispatch }) => {
    const state = getState() as AppState;

    const getSubnetInfo = hasPermission(
      CONSOLE_UI_RESOURCE,
      IAM_PAGES_PERMISSIONS[IAM_PAGES.LICENSE],
      true
    );

    const loadingLicenseInfo = state.register.loadingLicenseInfo;

    if (loadingLicenseInfo) {
      return;
    }
    if (getSubnetInfo) {
      dispatch(setLoadingLicenseInfo(true));
      api
        .invoke("GET", `/api/v1/subnet/info`)
        .then((res: SubnetInfo) => {
          dispatch(setLicenseInfo(res));
          dispatch(setClusterRegistered(true));
          dispatch(setLoadingLicenseInfo(false));
        })
        .catch((err: ErrorResponseHandler) => {
          if (
            err.detailedError.toLowerCase() !==
              "License is not present".toLowerCase() &&
            err.detailedError.toLowerCase() !==
              "license not found".toLowerCase()
          ) {
            dispatch(setErrorSnackMessage(err));
          }
          dispatch(setClusterRegistered(false));
          dispatch(setLoadingLicenseInfo(false));
        });
    } else {
      dispatch(setLoadingLicenseInfo(false));
    }
  }
);

Ici on retrouve effectivement la partie GET qui nous intéresse et une condition qui semble assez basique (en gros si la réponse est bonne ou non), et surtout celle qui m'attire :

.invoke("GET", `/api/v1/subnet/info`)
  .then((res: SubnetInfo) => {
    dispatch(setLicenseInfo(res));
    dispatch(setClusterRegistered(true));
    dispatch(setLoadingLicenseInfo(false));
  })
  .catch((err: ErrorResponseHandler) => {
    if (
      err.detailedError.toLowerCase() !==
      "License is not present".toLowerCase() &&
      err.detailedError.toLowerCase() !==
      "license not found".toLowerCase()
    ) {
      dispatch(setErrorSnackMessage(err));
    }
    dispatch(setClusterRegistered(false));
    dispatch(setLoadingLicenseInfo(false));
  });

Quelques fonctions avec une prise en charge d'un true/false, mais vu qu'on a besoin d'afficher un truc venant de l'API, je retrouve ça dans la première fonction : setLicenseInfo(res)

Je continue mes recherches pour comprendre cette fonction et je retombe que le fichier précédent... Je continue donc de le lire pour comprendre la fonction et l'affichage de la donnée :

{isRegistered && (
    <RegistrationStatusBanner email={licenseInfo?.email} />
)}

Je ne vais volontairement pas tout expliquer mais j'arrive rapidement à cibler le champ "email", qui semble être affiché côté UI. Je test ça :

location /api/v1/subnet/info {
  default_type application/json;
  return 200 '{"email":"hello@xorhak.fr","plan":"ENTERPRISE"}';
}

On fait un refresh et bingo !

Après avoir continuer le tour du propriétaire, j'ai également pu voir que ça avait aussi débloqué certaines fonctions, jusqu'ici limité à un plan en particulier.

Premier exemple dans "Support > Health" :

Avant (Support > Health)
Après (Support > Health)

Second exemple dans "Support > Performance" :

Avant (Support > Performance)
Après (Support > Performance)
🖐️
La console a été mise à jour (v0.23.1) alors que l'article était en cours de relecture, et cela a eu pour effet "bloquer" l'accès aux fonctions supplémentaires, donc petite mise à jour pour prendre en compte ce cas...

Cette mise à jour de la console a donc eu pour effet de bloquer l'accès à ces fonctions par rapport à ma solution trouvée. Mais pourquoi et surtout comment retrouver l'accès à ces fonctions ?

J'ai rapidement regardé les commits passés sur cette dernière release, rien de mieux que d'utiliser le comparateur intégré à Github : https://github.com/minio/console/compare/v0.23.0...v0.23.1. Et dans l'ensemble il y en a un qui m'intéresse particulièrement "Fix UI to access Support tools if registered". Globalement ces changements permettent de regrouper la validation d'enregistrement mais d'une autre manière (la raison du pourquoi ça ne fonctionne plus). Le fichier qui va m'intéresser est le portal-ui/src/config.ts

export const MinIOPlan =
  (
    document.head.querySelector(
      "[name~=minio-license][content]"
    ) as HTMLMetaElement
  )?.content || "AGPL";

type LogoVar = "simple" | "AGPL" | "standard" | "enterprise";

export const getLogoVar = (): LogoVar => {
  let logoVar: LogoVar = "AGPL";
  switch (MinIOPlan) {
    case "enterprise":
      logoVar = "enterprise";
      break;
    case "STANDARD":
      logoVar = "standard";
      break;
    default:
      logoVar = "AGPL";
      break;
  }
  return logoVar;
};

export const registeredCluster = (): boolean => {
  const plan = getLogoVar();
  return plan === "standard" || plan === "enterprise";
};

Ce qui est intéressant de comprendre ici, c'est que la vérification se base seulement sur une métadonnée, que vous pouvez facilement voir dans le code source de la page html du MinIO. Comment cette métadonnée est définie, ça c'est pas vraiment mon soucis, puisque je vais faire encore plus simple via le Nginx, je vais la surcharger. Pour ça, je vais modifier mon location de base (ou il y a mon proxy_pass) avec l'utilisation d'un "sub_filter" :

location / {
  proxy_pass http://localhost:9001;
  sub_filter '<meta name="minio-license" content="agpl" />' '<meta name="minio-license" content="enterprise" />';
  sub_filter_once on;
}

On restart le Nginx et top ! On retrouve l'accès à ces fonctions sans trop de prise de tête... 🙏  

Conclusion

Pour le cas présent, ça vous permettra de pouvoir changer visuellement la licence pour une "enterprise", y compris pour le logo en haut à gauche, mais aussi le déblocage de quelques fonctions. Qui je pense ne seront pas forcément utiles dans 99% des cas. Par contre, ne vous attendez pas à avoir le support 24/7/365 😅

Par le biai de cet article, je voulais surtout mettre en avant le fonctionnement des APIs et des conditions (code) gérées côté frontend et qu'il est facilement possible de by-passer les retours réels de l'API avec un simple proxy. Je n'ai pas continué mes investigations sur les autres appels (mais j'en ai vu d'autres que je devrais tester dès que j'aurai un peu de temps) car dans l'immédiat, j'estime avoir "corrigé" ma fameuse erreur 500 que j'avais d'origine.

Tags

🌱 DJΞRFY 🚀

👨🏻‍💻 Tech Lead SRE. Like #Linux, #Apple, #Kubernetes, #Docker, #Unraid, #Traefik, #Hacking, #Chia. Member of @OpenChia Team 🌱 ¯\_(ツ)_/¯