Publié par
Il y a 3 années · 13 minutes · Front

Un « reverse-proxy » http avec Nodejs

Exposer des services internes de notre SI ou bien encore des services partenaires au sein de nos API est un besoin récurrent dans le développement de nos applications. Bien entendu, des outils comme nginx peuvent nous apporter des solutions à cette problématique. Néanmoins des modules node.js directement intégrés à votre application peuvent prendre en charge ce besoin. Plusieurs solutions s’offrent à vous:

Je vous propose de voir quels en sont les avantages et inconvénients respectifs.

Pourquoi un proxy applicatif ?

Avec les outils standards de type proxy, nous obtiendrons les performances et la robustesse exigées pour peu qu’on en maîtrise la configuration. Alors pourquoi un proxy applicatif ?

Nous allons nous placer dans un cas particulier :

  • La fonctionnalité couverte tiendra aussi du reverse-proxy
  • Nous n’avons pas besoin d’un proxy/reverse proxy généraliste mais uniquement du concept pour un périmètre fonctionnel cadré

Dans cette situation, voilà ce que nous reprochons aux proxies ou reverse-proxies :

  • Ils sont plus fait pour modifier/filtrer un accès technique (authentification générale sur le réseau, masquage de l’infrastructure sous-jacente, etc.).
  • Impact fort sur l’infrastructure : c’est un composant à part entière à gérer.
  • Ne protègent pas les composants "cachés derrière" de toutes les attaques car justement ils sont transparents.

En revanche, un proxy applicatif nous permettrait :

  • d’exposer une fonctionnalité d’un composant qu’on ne pourrait pas exposer frontalement en plus des services habituels de notre application
  • de créer nos propres règles de filtrage, de routage, d’authentification et de les garder cohérentes avec le reste de l’application exposée
  • de réaliser tout ça dans le langage de notre application !
  • de le faire dans le même livrable que notre application

Un proxy ou reverse-proxy classique apportera des capacités de configuration fine. À l’inverse, un proxy applicatif évitera l’ajout de nouveaux composants dans l’infrastructure nécessitant des compétences complémentaires. On réalise juste un pont léger, maintenable et escamotable rapidement.

Un cas concret

Prenons deux routes de notre application, qui nécessitent de faire suivre la requête jusqu’à un service tiers, et de renvoyer la réponse au client.

Notre objectif est de protéger un service initialement exposé.

Voici la situation initiale : certains clients accèdent à un serveur local, d’autres à un serveur dans le cloud. Les deux serveurs sont identiques, l’application MyApp est installée des deux côtés.

Nous voyons que la situation de la première catégorie de clients oblige à exposer le service sensible publiquement. Nous souhaitons alors obtenir la situation suivante avec un "pont applicatif" :

De cette manière, il existe une route pour laquelle MyApp doit répondre différemment suivant qu’elle est installée dans le cloud ou localement.

  • Dans le cloud, la route peut appeler le service sensible via une connexion interne, elle possède donc la logique de traitement de la réponse.
  • Sur le serveur local, la même route appelle à la place son homologue sur le serveur dans le cloud : dans ce cas, elle se contente d’implémenter un proxy.

Node http, la pelle et la pioche

Avec ce module, aucun concept fonctionnel qui nous simplifierait la vie ne sera proposé. Pas de middleware bien pratique non plus. Nous devrons tout gérer nous-même.

Nous pourrions donc faire des erreurs ou des omissions involontaires… mais aussi des simplifications volontaires. Ce code peu sembler un peu verbeux car c’est du "Node natif", mais il a l’avantage d’être rapide à mettre en place.

Une autre motivation qui m’a poussé à partager ce code est que la réponse la plus proche à mon problème validée sur stackoverflow ne fonctionnait pas pour nodejs 0.10.24 couplé à Express 3. Attention: Il faut savoir que si vous utilisez encore Express 3, vous pourriez rencontrer des problèmes venant du fait que cette bibliothèque modifie le prototype d’objets natifs dans Nodejs.

La solution exposée ici n’utilise pas la fonction pipe() bien pratique pour économiser du code à cause de ce petit bug : Node.js – pipe() to a http response results in slow response time on ubuntu.

Si on reprend le schéma avec les 2 MyApp Nodejs séparées par Internet, ce code est sur l’instance locale :

(function(req, res) {
  var err, forwardReq, options;
  try {
    //Options pour le client http : on recopie les paramètres de la requête reçue dans la request qu'on va envoyer
    options = {
      host: config.target.host,
      port: config.target.port, 
      //Chemin seul sans la query string :
      path: req.originalUrl.replace(/\?.*$/, ""), 
      method: req.route.method,
      //La query string est copiée ici
      query: req.query,
      headers: req.headers,
      //Force la création d'un nouveau "client"
      agent: false
    };
    
    //Instance de requête http créée avec les options précédente et une callback qui sera appelée quand la réponse sera reçue
    forwardReq = http.request(options, function(forwardRes) {
      //Ne pas propager les cookies du service réel pour éviter des conflits (exemple : authentification par cookie)
      delete forwardRes.headers["set-cookie"];
      //On recopie des headers de la réponse reçue du serveur distant dans la réponse renvoyée au client
      res.writeHead(forwardRes.statusCode, forwardRes.headers);
      
      //Pour le reste, on enregistre des fonctions qui prendront en charge les divers évènements: 
      //L'évènement "data" correspond à l'arrivée de nouvelles données : 
      forwardRes.on('data', function(chunk) {
        //On recopie simplement les données reçues dans la réponse renvoyée à notre client
        return res.write(chunk);
      });
      
      //"close" correspond à une fermeture brutale de la connexion
      forwardRes.on('close', function() {
        //On met fin proprement à notre réponse vers le client
        return res.end();
      });
      
      //"end" correspond à une fin normale du dialogue HTTP (fin de la réponse)
      return forwardRes.on('end', function() {
        return res.end();
      });

    }).on('error', function(err) {
      //ici on voudra certainement logger
      res.writeHead(503, {
        'Content-Type': 'text/plain'
      });
      res.write("Service currently unvailable");
      return res.end();
    });

    //À ce stade on a une requête prête. Il reste à y mettre un body.
    /*
     Simplification pour éviter de lire la request du client
     avec l'API native de node.
    */
    forwardReq.write(JSON.stringify(req.body));

    /*
     Envoi définitif de la requête. 
     Après cette ligne la callback définie plus haut sera appelée avec la réponse.
    */
    return forwardReq.end();
  } catch (_error) {
    /*
     Si une erreur technique se produit pendant l'envoi de la requête,
     on tombe dans ce catch.
    */
    err = _error;
    logger.error('http request to service raised: ', err, {});
    try {
      return res.send(503, "Service currently unvailable");
    } catch (_error) {
      err = _error;
      return logger.error('Could not send error response: ', err, {});
    }
  }
});

Sur l’instance MyApp distante, qui a le droit d’accéder au backend, la fonction qui sert la route est normale, avec ses middlewares (exemple : authentification), sa logique métier, etc. C’est la fonction métier cible.

Le code ci-dessus nécessite quelques précisions :

  • Il s’agit de code coffeescript traduit en javascript, d’où les return systématiques et les variables intermédiaires.
  • Le logger est winston.
  • http est obtenu par http = require('http').
  • config est supposé être la configuration de l’application.
  • Le service exposé reçoit des POST dont le contenu est du JSON et dont la réponse est également du JSON.
  • L’exemple est tiré d’une application nodejs utilisant le framework Express 3.x, avec le middleware body-parser activé. Vous observerez qu’en sérialisant request.body, on opère au final un aller-retour un peu coûteux : body-parser réalise une désérialisation du corps de la requête JSON, et nous faisons une sérialisation pour remplir le corps de la requête sortante. Normalement, il aurait fallu faire une recopie directe d’une requête à l’autre en lisant le corps de la requête entrante avec l’API Node IncomingMessage.

Ce fonctionnement est simple car il ne gère pas les cookies. Pour gérer les cookies correctement, il aurait fallu faire bien à attention à traduire les valeurs d’hôte pour les cookies qui doivent traverser et à bien garder les valeurs des cookies qui sont spécifiques à un dialogue (comme les cookies de session et d’authentification). Les cookies cryptés avec une clé serveur symétrique exigent que les deux instances de MyApp partagent la même clé secrète pour être modifiés. L’idéal pour proxifier une route spécifique est qu’elle représente un service public où on ne modifie pas les données.

Request, promesse non tenue

Quand on voit le code nécessaire pour jouer les intermédiaires, on cherche un module qui fait la même chose. Request nous promet la simplicité :

request.get('http://mysite.com/doodle.png').pipe(resp)

Ce code serait la seule ligne nécessaire pour répondre à la requête GET "/doodle.png" en faisant suivre la requête à un autre site. Exactement ce que nous voulons.

Le problème est que nous devons répondre en POST. Cela nécessite d’enchaîner avec un appel à form() pour définir les paramètres du formulaire, auquel cas la requête n’est jamais envoyée. Ce point est une inconsistance de l’API. La documentation sur les
formulaires finit par un exemple de requête PUT personnalisée avec un corps JSON, ce qui nous conviendrait. Cependant, m’étant heurté à
ce blocage de requête, j’ai préféré dans mon cas prendre la pelle et la pioche en utilisant
node http comme vu plus haut. On remarquera au passage
dans la doc de Request que dans le cas d’un personnalisation complète, on perd en concision.

Superagent l’omnipotent

Bien qu’étant affiché "moins populaire" que Request selon le nombre d’étoiles et de forks sous Github, Superagent reste néanmoins une solution bien suivie, maintenue et réalisée par la même personne que les célèbres jade et mocha. Superagent se targue de pouvoir faire aussi bien du client que du serveur (nodejs), permettant de réinvestir plus facilement l’effort d’apprentissage.

Cette solution a été mise en œuvre sur un orchestrateur, un serveur nodejs qui sert de "front" à deux applicatifs fournissant des webservices JSON, ces derniers n’étant pas exposés directement au public. Elle pourrait également convenir pour le cas que nous avons exposé au début.

Suivant le service exposé par l’orchestrateur, il peut être traité soit en appelant directement un des deux fournisseurs, soit en appelant les deux pour composer une réponse cohérente en agrégeant deux réponses techniques.

Soit une route simple :

app.route('/product/:id').get(controller.rpGET);

Le contrôleur :

var request = require('superagent');
//...
function copyResponse(res, targetError, targetResponse) {
  if (targetError) {
    res.send(500, targetError); //On peut aussi remplacer 500 par le status code original...
  } else {
    res.status = targetResponse.status;
    res.statusCode = targetResponse.statusCode;
    res.send(targetResponse.body);
  }
}

exports.rpGET = function (req, res) {
  request.get(getRedirectUrl(req))
    .set('MY-HEADER', 'Some value') //simple exemple de définition de header
    .end(function (error, response) {
      copyResponse(res, error, response);
    });
};

Le fonctionnement est simple et le code plus concis.

Dans le cas ci-dessus, nous avons choisi de ne pas traiter sur notre serveur local le GET sur un produit dont l’id est spécifié sur le chemin. À la place, nous confions la requête à une fonction générique qui fait suivre toutes les requêtes GET vers un serveur distant dont l’URL est fournie par la fonction "getRedirectUrl" (non développée ici), puis renvoie la réponse du serveur distant à notre client sans modification.

Nous rajoutons juste un header avant de faire suivre la requête.

La fonction "rpGet" est donc générique et sera appelée par toutes les routes pour lesquelles nous souhaitons faire "passe-plat" et déléguer la requête au serveur distant. On notera qu’il existe des fonctions chaînables pour modifier la requête que l’on recopie. Il est donc facile de traiter à la volée des problématiques nécessitant la modification ou le filtrage des requêtes qu’on fait suivre.

Seul bémol : attention à la cohérence du code, les objets request et response passés aux callbacks par superagent ne sont pas natifs. Comprendre que les attributs ne sont pas forcément standards, d’où la double gestion "status" et "statusCode" (l’un appartenant au module http natif et l’autre à Superagent) afin d’éviter que le développeur non averti aille lire un attribut "undefined" plus loin dans le code…

Node-http-proxy, le couteau-suisse avec pelle et pioche intégrées

Le package npm http-proxy alias node-http-proxy (github), est une solution complète de proxying, supportant même les Websocket. Il fait plus que ce dont nous avons besoin, mais comparons-le aux autres modules pour notre tâche.

Tout d’abord, on observe que l’API est dans le style nodejs, avec des listeners d’évènements qu’on passe par callback. Cette manière de faire ressemble donc au module http, notamment dans sa verbosité. Mais ce n’est pas tout !

On remarque en lisant la documentation que node-http-proxy est un serveur indépendant qui va écouter sur un port particulier. Ceci peut être une contrainte : pour que toutes les requêtes de notre application passent par le même port, il faut nécessairement mettre un serveur http devant. Ce cas nous intéresse beaucoup moins et nous ne le détaillerons pas ici. Nous retiendrons cependant certains éléments intéressants :

  • Ce module est un proxy complet, pas un "re-routeur".
  • Il crée un service http sur un port donné, indépendant du reste de l’application.
  • On peut lui adjoindre des routes express, ce qui en pratique s’avère nécessaire car l’API évènementielle est de bas niveau, comme node-http.
  • Par rapport à un vrai reverse-proxy avec un fichier de configuration, ici le code peut rester avec le reste du code applicatif, ce qui facilite la maintenance et élargit la population qui comprend ce qu’il fait au delà des "ops" rompus à nginx ou apache.

Contrairement aux autres modules, node-http-proxy n’est pas un module qui permet d’ajouter une simple route proxyfiée sur un conteneur applicatif. C’est au contraire un conteneur au même titre que node-http, et on ne coupera pas à l’adjonction d’express pour faciliter la définition des routes comme pour un serveur http classique.

Conclusion

Nous avons vu que si Nodejs était capable, de base, de faire tout ce que le protocole http permet (client, serveur, proxyfying…), en revanche il vaut mieux utiliser un module spécifique pour permettre un code de plus haut niveau.

On tombe alors dans la jungle du monde javascript où même un artefact avec des milliers d’étoiles sous github peut être assez aléatoire.

  • Bien que certains développeurs aient rapporté des astuces avec request, d’autres ont soumis des bugs… Mon expérience personnelle m’a forcé à abandonner très tôt ce module.
  • Superagent a fait ses preuves, tout en ayant une syntaxe pratique.
  • node-http-proxy nous permettra d’écrire une "application proxy" complète avec les même outils qu’une application classique. Son usage est à comparer à celui d’un vrai reverse proxy et peut être une alternative viable.

Contrairement aux serveurs http classiques qui mettent l’accent sur les performances, une solution intégrée à Nodejs offre l’avantage de rendre lisible et maintenable par des développeurs js ou Coffee le livrable "proxy". Cette solution est donc envisageable quand la configuration du serveur HTTP devant les serveurs applicatifs serait trop complexe et/ou avec des règles amenées à évoluer au gré du cycle de vie de la webapp.

 

2 thoughts on “Un « reverse-proxy » http avec Nodejs”

  1. Publié par Anas, Il y a 3 années

    Quid de comment gérer la génération d’un identifiant unique permettant de gérer la traçabilité de bout en bout d’un appel de service (une requête qui traverse plusieurs couches et une réponse qui se recoltine les coouche pour sortir). La fonction est supportée par un module apache qui permet de générer un identifiant unique et qui simplifie ennormément l’analyse de bout en bout des appels.

  2. Publié par Joachim Rousseau, Il y a 3 années

    Bonjour,

    les modules cités dans cet article ne gèrent pas d’id unique, ce n’est pas leur but car cela correspond à un besoin spécifique. Même le proxying ou le reverse-proxying évoqués dans cette article ne sont que des cas d’utilisation émergents des fonctionnalités de base, pas des fonctionnalités prévues dès leur conception. Le traçage des requêtes de bout en bout reste donc à rajouter par-dessus.
    Je n’ai pas la connaissance d’un module qui ferait déjà ça.

    La gestion d’un id unique pourra donc se faire via un middleware que vous pouvez écrire vous-même. La génération d’un id unique peut se faire en utilisant des modules existants s’ils sont conformes à vos exigences (notamment en termes de sécurité).

    Enfin ceci était un exemple simple sur un cas particulier. Si vraiment vous souhaitez une application node.js qui ferait l’équivalent d’Apache HTTP ou nginx, il y a beaucoup d’autres choses à vérifier, à commencer par les performances. Dans l’absolu le cas exposé ici est suffisamment performant dans le cadre dans lequel il a été prévu : « proxifier » des fonctionnalités particulières, protéger un service interne, mais pas remplacer un proxy ou reverse-proxy présent dans l’infrastructure.

  3. Publié par Nicolas, Il y a 3 années

    dans l’implem supergant le copy response sert à rien, tout comme request le .pipe() existe aussi pour superagent, du coup fonctionnement en stream, beaucoup plus optimisé.

Laisser un commentaire

Votre adresse de messagerie ne sera pas publiée. Les champs obligatoires sont indiqués avec *