# txtoverssh.c 2023-11-13T08:58:20Z Voici comment j'ai mis en place un service de lecture via ssh. La principale source d'inspiration, c'est z3bra : => gopher://phlog.z3bra.org/0/ssh-as-a-public-service.txt En effet, ssh pourrait permettre tant de choses! J'ai eu envie de faire un système de commentaire, une sorte de grand mur sur lequel chacun pourrait écrire. Il m'est aussi venu à l'idée un système de publication, mais je ne ferai jamais mieux que prose.sh, ou alors il faudrait que j'y consacre un peu de temps => https://prose.sh/ (oui, c'est le genre de trucs dans mes projets) Puis finalement, pour le fun, j'ai décidé de seulement publier mes articles et à l'avenir quelques textes plus personnels que je souhaite garder un peu plus discrets. Déjà, ça sera pas mal. Je suis donc parti sur : * Un chroot ssh * Un utilisateur dédié et l'instruction ForceCommand pour qu'il ne puisse pas faire n'importe quoi * Du C pour profiter d'unveil et pledge, et surtout parce que avec le shell on n'est jamais totalement sûr de bien tout sécuriser pour s'assurer qu'un utilisateur ne va pas s'échapper. Au final, l'outil s'appelle txtoverssh. ## Configuration de SSH On crée une section rien que pour l'utilisateur "lire" ```/etc/ssh/sshd_config Match User lire ChrootDirectory /home/lire Banner /home/lire/banner.txt DisableForwarding yes PubkeyAuthentication no PasswordAuthentication yes MaxAuthTries 2 MaxSessions 2 ChannelTimeout session:*=15m UnusedConnectionTimeout 15m PermitTTY no ForceCommand bin/txtoverssh /dossier1 /dossier2 ``` * ChrootDirectory précise le dossier dans lequel l'utilisateur est enfermé. * Banner permet d'afficher le contenu d'un fichier avant l'invite pour le mot de passe. Il faut préciser le chemin entier, pas relatif au chroot. * DisableForwarding pour plus de tranquilité * PubkeyAuthentication et PasswordAuthentication c'est pourr forcer la demande d'un mot de passe * MaxAuthTries et MaxSessions c'est pour plus de tranquilité. * ChannelTimeout et UnusedConnectionTimeout pour fermer une connexion inactive. Ça ne se comporte pas comme je pense, mais ça fait du nettoyage quand même. * PermitTTT no évite d'avoir à créer des devices avec mknod ou d'avoir des messages d'erreur dans les logs. * ForceCommand appelle le fichier bin/txtoverssh qu'il faudra mettre dans le chroot. Il ira chercher des fichiers dans les dossiers appelés "dossier1" et "dossier2". Ces dossiers sont en réalité "/home/lire/dossier1" et "/home/lire/dossier2". Pour préparer le chroot, il faudra au moins un dossier pour les binaires et pour le device tty: ``` chown root:wheel /home/lire mkdir -p /home/lire/bin cp /bin/sh /home/lire/bin/ ``` Pour savoir si quelqu'un accède au service de lecture, on peut créer un fichier /etc/ssh/sshrc qui enverra un mail à l'admin. Je ne l'ai pas mis en place puisque de toute façon, txtoverssh logge dans /var/log/daemon ce qui est lu. ```/etc/ssh/sshrc #!/bin/sh if [ "$USER" == "lire" ]; then echo "User $USER just logged in from $SSH_CONNECTION" |\ mail -s "$USER connected" root fi ``` ## Commentaire du code txtoverssh Comme indiqué plus haut, txtoverssh est écrit en C. Je le compile avec le flag "-static" puisqu'il est lancé dans un chroot et que ça me prend la tête de copier au bon endroit les bouts de bibliothèque nécessaires. On va commencer avec les options. Rien de bien farfelu: ``` /* parse options */ while ((opt = getopt(argc, argv, "re:")) != -1) { switch (opt) { case 'r': recursive = 1; break; case 'e': esnprintf(ext, sizeof(ext), "%s", optarg); break; default: usage(); } } ``` On peut y voir que txtoverssh va accepter les options "-r" pour scanner récursivement, on change la valeur de la variable recursive le cas échéant, et l'option "-e" pour changer l'extension des fichiers qui seront à proposer à la lecture. À cette occasion, on appelle la fonction esnprintf, une variante de snprintf que je prévère à strlcpy et strlcat car elle permet de faire la même chose que ces 2 fonctions à elle toute seule tout en vérifiant que la chaîne est correctement terminée. ``` /* build string and end it with \0 * usage : esnprintf(str, sizeof(str), "%s ... %s", arg1, arg2); */ size_t esnprintf(char *str, size_t size, const char *format, ...) { va_list ap; size_t ret = 0; va_start(ap, format); ret = vsnprintf(str, size, format, ap); va_end(ap); if (ret < 0 || ret >= size) err(EXIT_FAILURE, "vsnprintf: Output trunkated"); return ret; } ``` Après avoir vérifié les options, on décale argc et argv pour pouvoir lire la liste des dossiers à scanner dans argv. Si aucun dossier n'est précisé, on affiche l'aide. ``` argc -= optind; argv += optind; if (argc == 0) usage(); ``` Au départ, j'avais tout hardcodé dans un config.h, mais les options, c'est quand même nettement plus pratique. Juste après, on sécurise le bouzin: pour chaque dossier précisé, on n'autorise que la lecture de fichiers et des opérations stdin/stdio avec pledge: ``` #ifdef __OpenBSD__ for (size_t i=0; i<argc; i++) { if (unveil(argv[i], "r") == -1) err(EXIT_FAILURE, "unveil"); } if (unveil(NULL, NULL) == -1) err(EXIT_FAILURE, "unveil"); if (pledge("stdio rpath", NULL) == -1) err(EXIT_FAILURE, "pledge"); #endif ``` Tout de suite, on se sent mieux. On en viendrait presque à se demander si le chroot est bien nécessaire ^^ Pour la suite, je présente une structure qui me permettra de stocker les informations sur les fichiers disponibles un peu comme une liste en python. J'aurais pu faire un tableau, mais au lieu de tout réinventer et pour apprendre à m'en servir, autant profiter de queue.h. ``` struct Page { int id; char title[256]; char filename[FILENAME_MAX]; char path[PATH_MAX]; SIMPLEQ_ENTRY(Page) page; }; SIMPLEQ_HEAD(Page_head, Page); ``` Cette structure est donc initialisée comme l'indique la doc: ``` SIMPLEQ_INIT(&Pageh); ``` On va scanner tous les dossiers passés en argument: ``` for (size_t i=0; i<argc; i++) list_entries(argv[i], &Pageh); ``` La fonction list_entries ne fait qu'appeler scandir(): ``` void list_entries(const char *path, struct Page_head *Pagehead) { int n = 0; int id = 0; struct dirent **namelist = NULL; char fullpath[PATH_MAX] = {'\0'}; struct Page *new_page = NULL; struct Page *pp = NULL; if (!SIMPLEQ_EMPTY(Pagehead)) SIMPLEQ_FOREACH(pp, Pagehead, page) if (pp->id > id) id = pp->id; if ((n = scandir(path, &namelist, NULL, alphasort)) < 0) { err(EXIT_FAILURE, "Can't scan %s", path); } else { for (int j = 0; j < n; j++) { /* skip self and parent */ if ((strcmp(namelist[j]->d_name, ".") == 0) || (strcmp(namelist[j]->d_name, "..") == 0)) { continue; } /* create full path */ esnprintf(fullpath, sizeof(fullpath), "%s/%s", path, namelist[j]->d_name); if ((recursive) && (namelist[j]->d_type == DT_DIR)) { /* list sub directory */ list_entries(fullpath, Pagehead); } else if (endswith(namelist[j]->d_name, ext)) { /* create new page entry */ if ((new_page = malloc(sizeof(*new_page))) == NULL) err(EXIT_FAILURE, "malloc"); /* store page data */ id++; new_page->id = id; esnprintf(new_page->filename, sizeof(new_page->filename), "%s", namelist[j]->d_name); esnprintf(new_page->path, sizeof(new_page->path), "%s", fullpath); get_title(fullpath, new_page->title, sizeof(new_page->title)); SIMPLEQ_INSERT_TAIL(Pagehead, new_page, page); } free(namelist[j]); } free(namelist); } } ``` Pas vraiment de choses intéressantes ici, si ce n'est qu'on fait une récursion sur les dossiers si on a activé l'option récursive. esnprint permet d'enregistrer simplement le chemin complet vers les fichiers. Ainsi, ensuite, on peut les enregistrer dans notre liste chainée. On récupère le titre des pages avec la fonction get_title. Cette dernière considère que la première ligne du fichier, c'est le titre. On la récupère avec fgets() et on enlève le retour à la ligne final (sauf si le titre est trop long, et dans ce cas tronqué). ``` void get_title(const char *path, char *title, size_t titlesiz) { size_t nread = 0; FILE *fd = NULL; if ((fd = fopen(path, "r")) == NULL) err(EXIT_FAILURE, "can't open: %s", path); if (fgets(title, titlesiz, fd) == NULL) err(EXIT_FAILURE, "can't read: %s", path); title[strcspn(title, "\r\n")] = '\0'; /* remove newline */ ``` Ensuite, on va virer les "#" et espaces souvent présents au début de mes lignes de titre avec memmove() ``` /* delete leading "#" if any */ /* while ((title[0] == '#') || (title[0] == ' ')) */ while ((strpbrk(title, "# ") == title)) memmove(title, title+1, strlen(title)); ``` J'ai indiqué ici 2 façons de faire la même chose : soit on vérifie que le premier caractère de la chaîne est un "#" ou un " ", soit on appelle strpbrk qui retourne la position du premier caractère trouvé dans la chaîne passée en argument, ici "# ". Le cas échéant, on décale la chaîne d'un cran vers la gauche. Après avoir récupéré ces informations, on les affiche à l'écran avec show_page_list: ``` void show_page_list(struct Page_head *Pageh) { struct Page *pp = NULL; /* display available pages */ SIMPLEQ_FOREACH(pp, Pageh, page) printf("#%d - %s\n", pp->id, pp->title); } ``` On va afficher cette liste tant qu'une variable selection est égale à 0 : ``` while (selection == 0) { show_page_list(&Pageh); ``` On va donc demander à l'utilisateur d'entrer son choix. Pour cela, j'ai un peu cherché. Au départ, je récupérais du texte, ça permettait de faire un strstr() sur chaque page et afficher les résultats correspondants, un peu à la dmenu. C'était finalement peu pratique si on voulait juste quitter avec "q" : est-ce quitter ou bien chercher tous les fichiers avec un titre contenant la lettre "q"? Ensuite, j'ai pensé à récupérer juste l'id du fichier, un nombre, avec scanf. Là encore, mauvaise idée, pas moyen de quitter en entrant une lettre comme "q". Finalement, un fgets est ce qu'il y a de mieux : on peut récupérer n'importe quel texte. ``` if (fgets(buf, sizeof(buf), stdin) != NULL) buf[strcspn(buf, "\n")] = '\0'; else err(EXIT_FAILURE, "can't read input"); ``` On vérifie si c'est une demande pour quitter, et on arrête tout si oui : ``` if (strcasecmp(buf, "q") == 0) { selection = -1; /* != 0 to leave loop */ continue; ``` Sinon, on va transformer l'entrée de l'utilisateur en nombre. Pour ça, strtonum est tout indiqué : ``` selection = (int)strtonum(buf, 1, INT_MAX, NULL); ``` Ici, strtonum tranforme buf en long long. Donc on caste le résultat avec (int). On accepte les valeurs entre 1 et INT_MAX : le maximum possible pour un int, ça devrait être bien suffisant dans mon cas. Notez qu'il ne peut pas y avoir de "0". Si au contraire strtonum retourne 0, c'est qu'il y a eu une erreur de saisie : il faut donc recommencer. Voilà pourquoi je ne récupère pas le message d'erreur et passe à la place NULL à strtonum. Ensuite, on va chercher le fichier correspondant et l'afficher, sans oublier de remettre selection à 0 pour proposer d'afficher une autre page: ``` SIMPLEQ_FOREACH(pp, &Pageh, page) if (pp->id == selection) { syslog(LOG_DAEMON, "Someone read %s", pp->path); clear(); puts(PRINT_INSTRUCTIONS); print_txt_file(pp->path); } selection = 0; /* start again */ } ``` Reste à voir la fonction print_txt_file. Cette dernière va lire le fichier ligne par ligne et l'afficher. Cependant, pour éviter de remplir l'écran d'un coup et obliger à scroller, on va permettre au lecteur de quitter avec "q" ou bien d'afficher la suite avec Entrée : ``` if ((fd = fopen(path, "r")) == NULL) err(EXIT_FAILURE, "can't open: %s", path); while ((c = getc(fd)) != EOF) { if (c == '\n') { if (getchar() == 'q') break; } else { putchar(c); } } fclose(fd); if (ferror(fd)) err(EXIT_FAILURE, "error when reading or closing %s", path); ``` Oui, juste "getc()" sufffit. ## Et pourquoi pas curses.h ? Ça pourrait être bien en effet. C'est pour si un jour je faire quelque chose de plus complet avec davantage de services que la lecture. En attendant, la plupart des terminaux permettent de scroller, et sinon, il y a tmux 👼. ## Où sont les sources? Un petit mail, et hop, je vous les envoie en réponse ;) --- Une réaction? Envoyez votre commentaire par mail (anonyme): => mailto:bla@bla.si3t.ch?subject=txtoverssh.c Voici quelques instructions pour utiliser la liste de diffusion et recevoir les réponses à vos messages: => https://si3t.ch/log/_commentaires_.txt