DEV Community

Cover image for Deep dive into PandApache3: Admin endpoint

Deep dive into PandApache3: Admin endpoint

C'est parti, c'est le moment. Bienvenue dans la nouvelle version de PandApache3, version 3.3 ! (Techniquement, c'est la 3.3.1 car une correction mineure a été ajoutée dès le premier jour, similaire aux mises à jour récentes des jeux AAA). J'espère que vous avez apprécié cette série d'articles approfondis sur PandApache3 jusqu'à présent.

Chaque version majeure sera désormais accompagnée d'un article technique discutant des fonctionnalités et de leur mise en œuvre.

Nous vivons des moments si excitants ! Si vous n'avez pas lu ou si vous avez besoin d'un rappel sur ce dont nous avons discuté la dernière fois, vous pouvez consulter nos précédent articles :

Je ferai de mon mieux pour tout expliquer ici, donc aucun prérequis n'est forcement nécessaire.

Prêt ? Allons-y !


La release

Tout d'abord, discutons de ce que nous avons voulu accomplir avec cette version. L'objectif était d'étendre les capacités administratives de PandApache3. Par capacités administratives, nous entendons des fonctions telles que :

  • Changer et mettre à jour la configuration du serveur
  • Récupérer le statut, redémarrer ou arrêter les services
  • Exécuter diverses opérations pour maintenir la santé du service

Habituellement, ces actions sont effectuées directement sur le serveur, nécessitant qu'un administrateur se connecte et exécute des commandes. Bien que cela soit faisable pour quelques services et serveurs, cela devient difficile à grande échelle. Nous ne pouvons pas nous connecter à 100 serveurs pour mettre à jour une configuration.

Pour résoudre ce problème, nous avons ajouté des endpoint administratifs à PandApache3. Ces endpoint vous permettent d'exécuter les mêmes actions sur l'ensemble de votre parc de manière simple. Explorons les différents endpoint disponibles :

  • /admin/status : Obtenez le statut actuel de votre serveur
  • /admin/reload : Rechargez le fichier de configuration
  • /admin/config : Obtenez ou mettez à jour des champs de configuration spécifiques
  • /admin/stop : Arrêtez le service
  • /admin/restart : Redémarrez le service
  • /admin/script : Obtenez, téléchargez ou exécutez des scripts

Prenez un croissant et un café, et plongeons dans la manière dont certains de ces endpoint ont été implémentés !

Entre Nous

Nous avons déjà des outils comme Ansible pour effectuer des actions identiques sur plusieurs serveurs. Alors pourquoi fournir des une API à un service ? Les outils comme Ansible sont en effet puissants, mais avoir des endpoint administratifs disponibles pour effectuer des tâches d'administration est plus simple à intégrer avec d'autres services. C'est aussi plus flexible et léger à intégrer dans des scripts que les playbooks Ansible.
Cette API peut également être utilisée pour construire une interface utilisateur, ce qui n'est pas possible avec Ansible.


Obtenir le statut du service

Nous commencerons par cet endpoint car il est assez simple et nous permettra de nous concentrer sur l'architecture plutôt que sur le résultat de l'endpoint lui-même.

Comme vous l'avez probablement deviné, pour implémenter ce nouvel endpoint d'administration dans notre serveur web, nous utiliserons et nous appuierons à nouveau sur le middleware. Au cas où vous auriez manqué certains articles précédents, un middleware est un composant de notre architecture qui se charge d'exécuter un certain traitement sur une requête.
Toutes les requêtes admin, comme toutes les requêtes, doivent passer par le middleware d'authentification et de répertoire pour accéder à l'endpoint. Ensuite, le middleware admin sera responsable de générer la réponse.

public async Task InvokeAsync(HttpContext context)
{
    Logger.LogDebug("Admin middleware");
    Request request = context.Request;

    if (request.Verb == "GET")
    {
        string adminURL = ServerConfiguration.Instance.AdminDirectory.URL;
        if (request.Path.ToLower().Equals(adminURL + "/status"))
        {
            context.Response = new HttpResponse(200)
            {
                Body = new MemoryStream(Encoding.UTF8.GetBytes(Server.STATUS))
            };
        }
        else
        {
            context.Response = new HttpResponse(404);
        }
    }
    else
    {
        context.Response = new HttpResponse(404);
    }
    await _next(context);
}
Enter fullscreen mode Exit fullscreen mode

Si vous êtes visuel :

Image description


Arrêter ou redémarrer le service

Ces deux endpoints sont plus intéressants à examiner. Arrêter, et surtout redémarrer, le service est une fonctionnalité importante. La plupart des services nécessitent un orchestrateur pour redémarrer. Mais avant d'examiner ce scénario complexe, simplifions-le. Un redémarrage est une action d'arrêt suivie d'un démarrage. Nous avons déjà vu l'action de démarrage de PandApache3 - Deep Dive into PandApache3: Launch Code ; garder cela à l'esprit nous aidera à comprendre la fonction d'arrêt.

Voici la fonction appelée lorsque l'endpoint admin/stop est activé et comment la réponse est générée :

Task.Run(() => Server.StoppingServerAsync(false));

response = new HttpResponse(200)
{
    Body = new MemoryStream(Encoding.UTF8.GetBytes("Stopping...."))
};
Enter fullscreen mode Exit fullscreen mode

Vous pouvez déjà remarquer quelque chose de différent par rapport à l'endpoint status. Arrêter le serveur n'est pas un appel synchrone. Contrairement à l'endpoint status, où le serveur exécute toutes les opérations nécessaires avant de renvoyer la réponse, cette fois une nouvelle tâche indépendante est créée, et la réponse OK est envoyée. Cela a du sens, car si vous arrêtez le serveur, il ne pourra pas répondre aux clients. La réponse ici est une retour disant que la tâche demandée sera effectuée, mais elle n'est pas terminée lorsque la réponse arrive au client.

Une autre partie du code de l'action d'arrêt du serveur est unique à PandApache3. Vérifions directement la fonction StoppingServerAsync appelée par notre nouveau thread :

public static async Task StoppingServerAsync()
{
    if (!Monitor.TryEnter(_lock))
    {
        Logger.LogDebug($"Thread (Thread ID: {Thread.CurrentThread.ManagedThreadId}) could not acquire the lock and will exit.");
        Logger.LogInfo("Server is already restarting");
        return;
    }

    lock (_lock)
    {
        List<CancellationTokenSource> cancellationTokens = new List<CancellationTokenSource>();
        cancellationTokens.Add(_ConnectionManager._cancellationTokenSource);

        StopServerAsync(cancellationTokens).Wait();
    }

    Logger.LogDebug("Get out of the lock");
    Monitor.Exit(_lock);
}
Enter fullscreen mode Exit fullscreen mode

La véritable fonction qui arrêtera le serveur est StopServerAsync, mais vous avez probablement remarqué que cette fonction est à l'intérieur d'un bloc de verrouillage (lock). Un verrouillage garantit que votre code ne sera exécuté que dans un seul thread. Certaines actions, comme l'arrêt du serveur, ne peuvent être exécutées qu'une seule fois. Il n'aurait aucun sens de demander au serveur de s'arrêter deux fois, et cela pourrait générer de nombreux problèmes. En général, les threads peuvent générer de nombreux problèmes de concurrence. Utiliser le verrouillage garantit que la fonction ne sera pas appelée plusieurs fois par différents clients.

Dans cette déclaration de verrouillage, nous n'arrêtons pas seulement le serveur. Vous pouvez également voir que nous avons des jetons d'annulation. Voyons l'objectif de ce jeton que nous avons enregistré dans une liste.

Vous vous souvenez de la façon dont la boucle en charge d'accepter et de gérer les nouvelles connexions a été présentée dans l'article - Deep Dive into PandApache3: Understanding Connection Management and Response Generation

Je vous ai dit que le serveur est exécuté dans une boucle infinie car nous acceptons toujours des connexions. Eh bien, c'était une simplification, car bien sûr, le serveur ne peut pas accepter continuellement des connexions dans certaines situations, comme être arreté. Nous devons être sûrs qu'aucune nouvelle connexion ne sera tentée. Et une façon d'arrêter cette boucle “infinie” à distance est le jeton. Voici la boucle correcte :

do
{
    if (listener.Pending())
    {
        ISocketWrapper client = new SocketWrapper(listener.AcceptSocket());
        await connectionManager.AcceptConnectionsAsync(client);
    }

} while (connectionManager._cancellationTokenSource.IsCancellationRequested == false);
Enter fullscreen mode Exit fullscreen mode

Parlon de l'éléphant au milieu de la pièce. Oui, c'est une boucle do while et non une boucle while. Mais vous pouvez voir dans la condition que pour sortir de la boucle, le cancellationTokenSource, une propriété de notre connectionManager, doit être annulé. C'est ainsi que nous arrêterons d'accepter de nouvelles connexions.

Maintenant, nous pouvons vraiment arrêter le serveur. Voici la dernière fonction pour ce cas d'utilisation, StopServerAsync :

public static async Task StopServerAsync(List<CancellationTokenSource> cancellationTokens = null)
{
    int retry = 5;
    Server.STATUS = "PandApache3 is stopping";
    Logger.LogInfo($"{Server.STATUS}");

    if (cancellationTokens != null)
    {
        foreach (CancellationTokenSource token in cancellationTokens)
        {
            token.Cancel();
        }
    }

    _ConnectionManager.Listener.Stop();

    for (int i = 0; i < retry; retry--)
    {
        if (_ConnectionManager.clients.Count > 0)
        {
            Thread.Sleep(1000);
            Logger.LogDebug("There are still active connections...");
        }
        else
        {
            break;
        }
    }

    if (retry == 0)
    {
        Logger.LogInfo("Force server to stop");
    }
    else
    {
        Logger.LogInfo("Server stopped");
    }

    Server.STATUS = "PandApache3 is stopped";
    Logger.LogInfo($"{Server.STATUS}");
}
Enter fullscreen mode Exit fullscreen mode

Nous commençons par annuler tous les jetons détenus par le connectionManager :

token.Cancel();
Enter fullscreen mode Exit fullscreen mode

Ensuite, nous arrêtons le listener:

_ConnectionManager.Listener.Stop();
Enter fullscreen mode Exit fullscreen mode

À ce stade, même si nous bloquons toutes les nouvelles connexions, nous pourrions encore avoir quelques connexions ouvertes et actives en attente d'une réponse du serveur. Par souci de considération, nous leur donnons 5 secondes avant de les terminer (actuellement, nous ne le faisons pas, mais nous devrions).

Enfin, quand plus aucun thread enfant ne sera en cours d'exécution. Une de nos premières fonctions de serveur, RunServerAsync, se terminera maintenant que la boucle infinie est terminée. Le programme continuera jusqu'à la fin de son code principal et dira au revoir :

Logger.LogInfo("La revedere !");
Enter fullscreen mode Exit fullscreen mode

Between Us

Pourquoi avons-nous une liste de jetons ? Nous avons un seul connectionManager, non ? C'est correct. La liste est pour le moment optionnelle, et le code pourrait fonctionner sans elle en passant simplement notre jeton. Mais, attendez... Nous aborderons ce point plus tard.


Redémarrer le Serveur

Maintenant que nous avons couvert la fonction d'arrêt, examinons le processus de redémarrage. Le redémarrage utilise les mêmes fonctions que l'arrêt, avec un paramètre booléen supplémentaire pour indiquer un redémarrage. Pour redémarrer le serveur, nous devons l'arrêter puis le redémarrer depuis le début. Comment y parvenir ? Avec une boucle et un jeton, bien sûr !

Voici la fonction principale :

while (_cancellationTokenSourceServer.IsCancellationRequested == false)
{
    await StartServerAsync();
    await RunServerAsync();
}

Logger.LogInfo("La revedere !");
Enter fullscreen mode Exit fullscreen mode

Comme discuté dans l'article précédent, nous démarrons le serveur, qui fonctionne ensuite en continu. La fonction RunServerAsync ne se termine jamais jusqu'à ce que nous accédions au endpoint d'administration pour arrêter le serveur. Lorsque le serveur s'arrête, le processus se termine.

Cependant, pour effectuer un redémarrage, nous devons arrêter le serveur puis revenir au début de la fonction principale pour exécuter StartServerAsync et RunServerAsync à nouveau.

Pour y parvenir, nous utilisons un jeton, mais cette fois au niveau du serveur plutôt qu'au niveau du connectionManager. Lorsque nous voulons redémarrer le serveur, nous exécutons la fonction décrite dans la section précédente. Notre fonction StopServer devient une fonction RestartServer. Pour arrêter efficacement le serveur, nous utilisons un indicateur pour déterminer s'il faut annuler le jeton du serveur.

Server.STATUS = "PandApache3 est arrêté";
Logger.LogInfo($"{Server.STATUS}");

if (isRestart == false)
{
    _cancellationTokenSourceServer.Cancel();
}
Enter fullscreen mode Exit fullscreen mode

C'est tout ! Bien que la section précédente ait été détaillée pour expliquer comment le serveur s'arrête, cet effort n'a pas été vain car comprendre comment le serveur redémarre est maintenant beaucoup plus facile. 


Le queryParameters

Nous avons encore un endpoint intéressant à examiner : /admin/script, qui vous permet d'obtenir, de charger ou d'exécuter des scripts. Ce endpoint a quelque chose de différent par rapport aux autres ; il accepte ce que nous appelons un queryParameters .

Voici un exemple d'URL avec un queryParameters : http://pandapache3/admin/script?name=hello_world.ps1 Le queryParameters est ce qui suit le ?. Dans cet exemple, nous avons un paramètre, name, associé à la valeur hello_world.ps1. Cette requête ne contient qu'un paramètre, mais il pourrait y en avoir beaucoup plus.

Comme vous pouvez l'imaginer, cela signifie que notre objet de requête continuera d'évoluer pour stocker ces queryParameters . Notre nouvel attribut sera un dictionnaire de string:

public Dictionary<string, string> queryParameters { get; }
Enter fullscreen mode Exit fullscreen mode

Voici une simple fonction pour analyser et stocker les valeurs :

private Dictionary<string, string> GetQueryParameters()
{
    var parameters = new Dictionary<string, string>();
    if (!string.IsNullOrEmpty(QueryString))
    {
        var keyValuePairs = QueryString.Split('&');
        foreach (var pair in keyValuePairs)
        {
            var keyValue = pair.Split('=');
            Logger.LogDebug($"KeyValue: {keyValue}");

            if (keyValue.Length == 2)
            {
                var key = Uri.UnescapeDataString(keyValue[0]);
                var value = Uri.UnescapeDataString(keyValue[1]);
                parameters[key] = value;
            }
        }
    }

    return parameters;
}
Enter fullscreen mode Exit fullscreen mode

Voyons maintenant ce qui se passe lorsque notre endpoint est atteint. Il y a trois cas d'utilisation.

Vous appelez le point de terminaison avec une requête POST: Cela signifie que vous voulez charger un script sur le serveur. Le serveur gère déjà le chargement depuis la version 3.0, donc rien de nouveau ici.

private HttpResponse postAdmin(HttpContext context)
{
    if (context.Request.Headers["Content-Type"] != null && context.Request.Headers["Content-Type"].StartsWith("multipart/form-data"))
    {
        string adminURL = ServerConfiguration.Instance.AdminDirectory.URL;
        string scriptsDirectory = ServerConfiguration.Instance.AdminDirectory.Path;

        if (context.Request.Path.ToLower().StartsWith(adminURL + "/script"))
        {
            if (ServerConfiguration.Instance.AdminScript)
                return RequestParser.UploadHandler(context.Request, true);
            else
                return new HttpResponse(403);
        }
    }

    return new HttpResponse(404);
}
Enter fullscreen mode Exit fullscreen mode

Vous appelez le endpoint avec une requête GET sans queryParameters: Dans ce cas, votre URL pourrait ressembler à http://pandapache3/admin/script. Nous considérerons que vous voulez savoir quels scripts sont présents sur le serveur que vous pouvez exécuter. Cette requête est simple à gérer :

string bodyScript = "Voici la liste des scripts sur le serveur PandApache3 :\n";
foreach (string script in Directory.GetFiles(scriptsDirectory))
{
    FileInfo fileInfo = new FileInfo(script);
    bodyScript += $"\t- {fileInfo.Name}\n";
}

response = new HttpResponse(200)
{
    Body = new MemoryStream(Encoding.UTF8.GetBytes(bodyScript))
};
Enter fullscreen mode Exit fullscreen mode

Vous utilisez un queryParameters : Cela signifie que vous voulez exécuter un script spécifique sur le serveur. Voyons ce qui se passe :

private HttpResponse RunScript(string scriptDirectory, Dictionary<string, string> queryParameters)
{
    HttpResponse response = null;
    string terminal = string.Empty;
    if (ServerConfiguration.Instance.Platform.Equals("WIN"))
        terminal = "powershell.exe";
    else
        terminal = "/bin/bash";

    string argumentList = $"{Path.Combine(scriptDirectory, queryParameters["name"])}";
    foreach (var item in queryParameters)
    {
        if (item.Key != "name")
        {
            argumentList += $" {item.Value}";
        }
    }
    var processInfo = new ProcessStartInfo
    {
        FileName = terminal,
        Arguments = argumentList,
        RedirectStandardOutput = true,
        RedirectStandardError = true,
        CreateNoWindow = true,
        UseShellExecute = false,
    };

    try
    {
        using (var process = new Process { StartInfo = processInfo })
        {
            process.Start();
            process.WaitForExit();
            string standardOutput = process.StandardOutput.ReadToEnd();
            string standardError = process.StandardError.ReadToEnd();

            ScriptResult scriptResult = new ScriptResult
            {
                ExitCode = process.ExitCode,
                StandardOutput = standardOutput,
                ErrorOutput = standardError
            };

            response = new HttpResponse(200)
            {
                Body = new MemoryStream(Encoding.UTF8.GetBytes(Newtonsoft.Json.JsonConvert.SerializeObject(scriptResult)))
            };
        }
    }
    catch (Exception ex)
    {
        Logger.LogError($"Erreur lors de l'exécution du script {ex.Message}");
        response = new HttpResponse(500);
    }

    return response;
}
Enter fullscreen mode Exit fullscreen mode

Passons en revue cette fonction. Tout d'abord, les paramètres sont le répertoire des scripts (qui est aussi le répertoire d'administration) et la chaîne de requête qui contient le script que nous voulons exécuter.

L'étape suivante consiste à déterminer la plateforme actuelle pour exécuter notre script avec le programme approprié (PowerShell pour Windows et Bash pour Linux).

string terminal = string.Empty;
if (ServerConfiguration.Instance.Platform.Equals("WIN"))
    terminal = "powershell.exe";
else
    terminal = "/bin/bash";
Enter fullscreen mode Exit fullscreen mode

Avec toutes les informations, nous construisons la commande que nous voulons exécuter, y compris le chemin complet du script et les arguments potentiels :

string argumentList = $"{Path.Combine(scriptDirectory, queryParameters["name"])}";
foreach (var item in queryParameters)
{
    if (item.Key != "name")
    {
        argumentList += $" {item.Value}";
    }
}
Enter fullscreen mode Exit fullscreen mode

Pour exécuter le script, nous avons besoin d'un ProcessStartInfo pour spécifier les options pour l'exécution :

var processInfo = new ProcessStartInfo
{
    FileName = terminal,
    Arguments = argumentList,
    RedirectStandardOutput = true,
    RedirectStandardError = true,
    CreateNoWindow = true,
    UseShellExecute = false,
};
Enter fullscreen mode Exit fullscreen mode

Nous pouvons maintenant l'exécuter et enregistrer sa sortie pour générer une réponse pour notre client :

using (var process = new Process { StartInfo = processInfo })
{
    process.Start();
    process.WaitForExit();
    string standardOutput = process.StandardOutput.ReadToEnd();
    string standardError = process.StandardError.ReadToEnd();

    ScriptResult scriptResult = new ScriptResult
    {
        ExitCode = process.ExitCode,
        StandardOutput = standardOutput,
        ErrorOutput = standardError
    };

    response = new HttpResponse(200)
    {
        Body = new MemoryStream(Encoding.UTF8.GetBytes(Newtonsoft.Json.JsonConvert.SerializeObject(scriptResult)))
    };
}
Enter fullscreen mode Exit fullscreen mode

La réponse sera structurée en format JSON avec le code de sortie, la sortie standard et la sortie d'erreur. Ce format est conçu pour être facilement utilisé par d'autres programmes plutôt que par des humains.

Entre Nous

Le endpoint /admin/config peut également fonctionner avec des paramètres de requête pour modifier un paramètre spécifique dans la configuration. Cette partie fonctionne exactement comme le point de terminaison de script à ce niveau, donc nous ne le couvrirons pas dans cet article. Cependant, vous avez maintenant toutes les connaissances nécessaires pour explorer le code vous-même !

Toujours Entre Nous

Vous pourriez remarquer que cette fois, la requête HTTP est synchrone pour le client. C'est assez différent par rapport à l'action d'arrêter le serveur, qui renvoie une réponse avant la fin de l'action. Ici, nous avons choisi de bloquer le client jusqu'à ce qu'il reçoive une réponse. Cela pourrait poser un problème si votre script effectue un traitement lourd. Nous aborderons ce point à l'avenir en proposant des options d'exécution de script synchrones et asynchrones sur le serveur.


Protéger Notre Administration

Il est temps de discuter du dernier aspect de cette version. Nous avons vu que les points de terminaison d'administration sont assez puissants et peuvent être utilisés pour créer des outils CLI ou une console d'administration web. Leur puissance est telle que, si une action d'administration n'existe pas, vous pouvez la créer vous-même via des scripts.

Cependant, si vous êtes un bon administrateur système, vous reconnaîtrez que la possibilité de charger et d'exécuter du code à distance sur un serveur peut être extrêmement dangereuse ! Et vous avez raison !

Cette fonctionnalité, bien que très utile pour la gestion d'un PaaS, est également risquée si elle n'est pas configurée correctement. C'est pourquoi vos endpoint d'administration ne fonctionnent pas sur le même site que votre site PandApache !

Actuellement, mon site fonctionne sur http://127.0.0.1:8080/. Lorsque j'accède à cette adresse, mon gestionnaire de connexions gère la connexion, passe la requête à travers le pipeline de middleware et affiche mes ressources. Cependant, mon endpoint d'administration n'est pas situé à cette adresse. Vous pouvez le configurer pour qu'il fonctionne sur un port différent ou même sur une autre interface réseau ! Ce site a son propre gestionnaire de connexions, ce qui signifie qu'il a aussi son propre pipeline de middleware.

C'est pourquoi, lors du redémarrage du serveur, nous utilisons une liste de jetons pour annuler les connexions. En effet, nous arrêtons non pas seulement un ConnectionManager avec son TCPListener, mais deux !

La possibilité de séparer les deux sites au niveau du réseau est un bon moyen de protéger votre serveur contre les attaques. Cela permet à vos clients d'accéder au contenu par un chemin et à votre équipe d'administration de gérer votre service par un autre.

Entre Nous

Si vous n'êtes toujours pas convaincu par les mesures de sécurité, il existe des étapes supplémentaires que vous pouvez suivre. Tout d'abord, vous pouvez désactiver l'exécution de scripts. Votre endpoint d'administration restera disponible, mais vous ne pourrez pas exécuter des actions qui n'ont pas déjà été implémentées via l'API. Vous pouvez également désactiver complètement le site d'administration et continuer à gérer votre service comme vous le faites actuellement. Nous continuerons à améliorer la sécurité à l'avenir, en offrant une isolation encore plus grande entre les parties publique et administrative de PandApache3.


Dans cet article, nous avons exploré les nouvelles fonctionnalités de PandApache3, en détaillant comment les endpoint on été implémenté pour être sécurisé et utile.

Le prochain sujet que nous aborderons dans cette série d'articles est une partie importante pour les administrateurs systèmes : la journalisation, la télémétrie et la surveillance.

Restez à l'écoute !


Merci beaucoup d'avoir exploré les rouages de PandApache3 avec moi ! Vos pensées et votre soutien sont cruciaux pour faire avancer ce projet. 🚀
N'hésitez pas à partager vos idées et impressions dans les commentaires ci-dessous. J'ai hâte d'avoir de vos nouvelles !

Suivez mes aventures sur Twitter @pykpyky pour rester informé de toutes les nouvelles.

Vous pouvez également explorer le projet complet sur GitHub et me rejoindre pour des sessions de codage en direct sur Twitch pour des sessions excitantes et interactives. À bientôt derrière l'écran !

Top comments (0)