Elmord's Magic Valley

Software, lingüística e rock'n'roll. Às vezes em Português, sometimes in English.

Transferindo file descriptors entre processos

2012-08-04 04:35 -0300. Tags: comp, prog, unix, em-portugues

Nos últimos tempos descobri diversas features interessantes desse tal de Unix, entre elas a possibilidade de transferir um file descriptor entre dois processos. Há mil conceitos envolvidos; basicamente o que você precisa é abrir um Unix domain socket (vide man unix) entre os dois processos e usar um código tipo este para transferir o file descriptor. O objetivo deste post, entretanto, é fazer um passeio pelos mil conceitos em questão. E lá vamos nós!

File descriptions e file descriptors

Quando um processo solicita a abertura de um arquivo, o sistema cria uma descrição de arquivo correspondente: uma entrada em uma tabela global de arquivos abertos que contém a posição do "cursor" dentro do arquivo e as flags com que o arquivo foi aberto, bem como quaisquer informações internas que o sistema operacional precise para manipular o arquivo. Além disso, o processo fica de posse de um descritor de arquivo, uma referência a uma descrição de arquivo. Ao processo está associada uma tabela, acessível apenas pelo kernel, dos descritores de arquivo que o processo possui; o processo em si, ao abrir o arquivo, recebe como resultado um índice nessa tabela. (O índice também é chamado de "descritor de arquivo".) Como o processo trabalha apenas com índices na tabela de descritores de arquivo, mas não tem acesso direto nem à tabela nem ao conteúdo dos descritores/descrições, é impossível falsificar um descritor de arquivo; um arquivo só é acessível a um processo se o sistema operacional lhe entregar um descritor de arquivo correspondente, e o sistema só o fará se o processo tiver permissão para obter o descritor. Assim, um descritor de arquivo tem características de uma capability: a posse de um descritor é prova suficiente de que o processo tem o direito de acessar o arquivo.

A maneira mais básica de se obter um descritor de arquivo é por meio da chamada de sistema open:

int fd = open("/etc/passwd", O_RDWR)

Essa chamada recebe um caminho para um arquivo e um conjunto de flags (unidas por bitwise OR), e retorna um (índice para um) descritor de arquivo correspondente. Caso o arquivo não possa ser aberto, a chamada (como a maior parte das chamadas de sistema do Unix) retorna -1 e indica o erro ocorrido setando a variável global errno. Em um Unix, a função fopen do C padrão chama open internamente, e associa um descritor de arquivo à estrutura FILE * que retorna. Dada uma estrutura FILE *, você pode obter o descritor de arquivo correspondente usando a função fileno(handler). Você também pode criar um FILE * a partir de um descritor de arquivo usando a função fdopen(fd, modo), onde modo é um argumento do mesmo tipo que se passa para a função fopen.

Há diversas outras chamadas que criam e retornam descritores de arquivo. Algumas dessas chamadas associam descritores de arquivos a coisas que não são exatamente arquivos, tais como pipe (que cria dois descritores que não correspondem a nenhum arquivo físico; tudo o que é escrito no primeiro descritor pode ser lido através do segundo) e as chamadas para criação de sockets. Talvez "resource descriptor" fosse um nome mais apropriado do que "file descriptor" para essas criaturas. Enfim, não fui eu que fiz.

(Um ponto interessante dessa história é que como um socket está associado a um descritor de arquivo, você pode usar fdopen para criar um FILE * correspondente, e a partir daí usar as funções comuns para leitura e escrita em arquivos em C (fgets, fprintf, etc.) para enviar e receber dados pelo socket. Genial, não?)

Herança

Um processo pode criar novos processos através da chamada fork(). Essa chamada cria uma cópia do processo atual, apenas com o PID e algumas outras informações modificadas. A chamada retorna 0 para o novo processo (dito processo filho), e o PID do processo recém criado para o processo pai. A execução do código continua do mesmíssimo ponto (o retorno da chamada a fork) em ambos os processos; o valor retornado pode ser usado para determinar quem é quem:

pid_t pid;

// Cria um novo processo.
if (pid = fork()) {
    // Código que será executado pelo processo pai.
    ...
}
else {
    // Código que será executado pelo processo filho.
    ...
}

O processo filho recebe uma cópia da tabela de descritores de arquivo do processo pai, i.e., ele continua com os mesmos arquivos abertos. Um ponto importante é que embora a tabela de descritores seja uma cópia (i.e., se um dos processos fechar um arquivo, o outro continuará com o mesmo aberto), as entradas dessas tabelas apontam para as mesmas descrições (i.e., se um processo muda a posição do cursor ou as flags de um arquivo, o outro verá as mudanças).

É possível substituir o programa que está sendo executado por um processo, através das funções da família exec* (que são wrappers para a chamada execve). Os detalhes variam para cada função, mas basicamente elas recebem um nome de arquivo correspondente a um programa e os argumentos a serem passados para o programa, e substituem o programa atual pelo novo programa. É como se tivéssemos simplesmente chamado o outro programa, com a diferença de que o programa é executado com o mesmo PID do processo atual. O novo programa também herda os descritores de arquivo abertos (desde que a flag FD_CLOEXEC (close-on-exec) não esteja ativa no descritor; vide open e fcntl).

De fato, "chamar" um novo programa no Unix consiste em duas etapas: criar um subprocesso através de uma chamada a fork, e substituir o executável pelo programa que se deseja chamar. É isso que a famosa função system faz por baixo dos panos, basicamente.

Lembre-se de que a posse de um descritor é suficiente para garantir o acesso do processo ao arquivo correspondente. Um processo que herde um descritor de arquivo carrega com ele as mesmas permissões de acesso ao arquivo, mesmo que o usuário dono do processo seja outro. Por exemplo, um processo rodando como root pode abrir um arquivo A acessível apenas pelo root e criar um subprocesso que passa a executar com um usuário comum; o subprocesso continuará podendo acessar o arquivo A, mesmo que em circunstâncias normais ele não pudesse abrir o arquivo. (Afinal, o que ele não pode é abrir o arquivo; usar um arquivo que ele já recebeu aberto é outra história.) Isso permite, por exemplo, que um processo comece executando como root, obtenha certos recursos que só podem ser obtidos como root (e.g., um socket ouvindo em uma porta menor que 1024, ou acesso ao buffer de uma placa de vídeo), e passe a executar com um usuário com menos privilégios. Assim, o processo só tem acesso aos recursos de que necessita, sem receber mais permissões do que o necessário; isso reduz o potencial de danos caso o programa sofra um ataque.

Pipes e sockets

Um pipe é um canal de comunicação unidirecional orientado a bytes. Um pipe possui duas pontas: o que se escreve em uma das pontas pode ser lido pela outra. Você deve conhecer as pipelines do shell:

ls | grep foo

A função da pipeline é fazer com que a saída de um processo seja alimentada como entrada de outro. Pois bem, o que o shell faz ao se deparar com a pipeline acima é:

  1. Criar um pipe;
  2. Criar um subprocesso, substituir a stdout do processo por uma ponta do pipe, e substituir o programa do processo atual pelo ls;
  3. Criar outro subprocesso, substituir a stdin do processo pela outra ponta do pipe, e substituir o programa do processo atual pelo grep foo;
  4. Deixar a galera executar.

Assim, o ls herda como stdout uma ponta do pipe, e o grep herda a outra ponta como stdin; conseqüentemente, tudo que o ls imprimir para a stdout vai parar na stdin do grep.

Pipes são criados pela chamada pipe: ela recebe um vetor de duas posições, que serão preenchidas com dois descritores de arquivo correspondentes às duas pontas do pipe. Essa chamada cria um pipe anônimo: os descritores retornados não correspondem a nenhum arquivo fisicamente presente no sistema de arquivos. Também é possível criar um pipe nomeado, de modo que é possível se referir ao pipe como um arquivo comum. Um pipe nomeado pode ser criado com a função mkfifo, e pode ser aberto, lido e escrito como um arquivo comum.

Um pipe é um canal unidirecional: se escreve apenas por um lado, e se lê apenas pelo outro. Não há como o processo que recebeu a segunda ponta do pipe transferir informações de volta para o processo que está de posse da primeira ponta. Um socket, por outro lado, é uma criatura bidirecional: pode-se ler e escrever a partir de ambos os lados do socket. Sockets são a API genérica para criação de canais de comunicação no Unix. Existem diversas famílias de sockets (e.g., AF_INET (sockets TCP/IPv4), AF_INET6 (sockets TCP/IPv6), AF_UNIX (Unix domain sockets)), e diversos tipos de socket (e.g., SOCK_STREAM (socket orientado a bytes), SOCK_DGRAM (socket orientado a mensagens com limites bem-definidos)). Os detalhes do funcionamento de um socket variam de família para família.

Normalmente quando se fala de sockets, tem-se em mente os sockets da família TCP/IP. Nesse caso, cada ponta do socket é identificada por um IP e uma porta. No caso do TCP (por oposição a UDP), há um lado cliente e um servidor. O servidor:

  1. Cria um socket:
    int sock = socket(AF_INET, SOCK_STREAM, 0);
  2. Associa um endereço e uma porta local ao socket:
    struct sockaddr_in server_addr = {0};
    server_addr.sin_family = AF_INET;
    server_addr.sin_port = htons(5000);
    server_addr.sin_addr = INADDR_ANY;
    bind(sock, &server_addr, sizeof(server_addr));
    
  3. Indica que o socket é passivo, i.e., servirá para esperar por conexões de clientes:
    listen(sock, 8);
  4. Aguarda por conexões:
    while (1) {
        struct sockaddr_in client_addr = {0};
        int len = sizeof(client_addr);
        int clientfd = accept(sock, &client_addr, &len);
    
        // A partir daqui, é possível ler e escrever em clientfd para
        // trocar dados com o cliente, assumindo que não tenha ocorrido
        // nenhum erro.
    
        close(clientfd);
    }
    

O cliente, por sua vez:

  1. Cria um socket:
    int sock = socket(AF_INET, SOCK_STREAM, 0);
  2. Abre uma conexão com o servidor:
    struct sockaddr_in server_addr = {0};
    server_addr.sin_family = AF_INET;
    server_addr.sin_port = htons(5000);
    server_addr.sin_addr = inet_addr("127.0.0.1");
    connect(sock, &server_addr, sizeof(server_addr));
    
    // A partir daqui, é possível escrever em sock para
    // trocar dados com o servidor, assumindo que não tenha ocorrido
    // nenhum erro.
    
    close(sock);
    

Um socket, porém, não é necessariamente da família TCP/IP. Um outro tipo de socket particularmente interessante são os Unix domain sockets, os sockets da família AF_UNIX. Esses sockets foram concebidos para permitir a comunicação entre processos da mesma máquina. Um Unix domain socket basicamente é uma generalização de pipe que permite a comunicação em ambas as direções. Assim como um pipe, um Unix domain socket pode ser anônimo ou nomeado. Um socket anônimo pode ser criado por uma chamada do tipo:

int endpoints[2];
socketpair(AF_UNIX, SOCK_DGRAM, 0, endpoints);

Assim como pipe, socketpair preenche um vetor de duas posições com descritores correspondentes às duas pontas do socket. Esse socket já vem pronto para uso: não é necessário usar nenhuma chamada a listen, connect, etc. para que ele possa ser usado. Como esperado, subprocessos criados posteriormente herdarão os descritores, e assim o processo pai e o filho podem trocar dados por meio do socket, cada um usando uma ponta do mesmo.

Um socket nomeado funciona de maneira similar a um socket TCP/IP: o servidor faz uma chamada a bind, utilizando como endereço uma estrutura sockaddr_un, que contém o nome do arquivo de socket a ser criado/usado, marca o socket como passivo usando listen, e espera conexões com accept. Analogamente, o cliente usa connect para conectar-se ao servidor, usando como endereço uma estrutura sockaddr_un.

Transferindo file descriptors

Sockets possuem uma maluquice que permite que informações de controle sejam enviadas juntamente com os dados propriamente ditos. No caso de Unix domain sockets, um dos tipos de informação de controle que se pode enviar consiste em um vetor de descritores de arquivo que se deseja compartilhar com o processo na outra ponta do socket. Quando o processo recebe a mensagem, o kernel cria uma cópia dos descritores de arquivo do processo de origem no processo destino. Essas mensagens especiais são enviadas e recebidas por meio de chamadas a sendmsg e recvmsg. (No caso de sockets TCP/IP, é possível usar esse mecanismo para enviar pacotes IP com as (nem tão) famosas "opções" que o IP suporta.)

A interface para especificação das mensagens especiais é bastante lamentável; se você pretende transferir descritores por esse meio, o mais prático é roubar o código de alguém ou usar uma biblioteca. Vamos, entretanto, tentar entender como funciona esse caos.

A função sendmsg recebe um socket, uma estrutura do tipo msghdr representando a mensagem a ser enviada, e um conjunto de flags. Essa estrutura msghdr contém os seguintes campos:

O buffer apontado por msg_control contém uma seqüência de "mensagens de controle". Cada uma das mensagens de controle é uma estrutura do tipo cmsghdr, que contém os seguintes campos:

Após esses campos vai o conteúdo da mensagem de controle, no nosso caso os números de um ou mais descritores de arquivo que desejamos transferir. A questão toda é como inicializar esses campos:

  1. Criamos um buffer capaz de conter todas as mensagens de controle (no caso só queremos enviar uma). O tamanho de cada entrada desse buffer pode ser calculado com a macro CMSG_SPACE: ela recebe o tamanho do dado que desejamos transferir e devolve o tamanho total da mensagem, headers inclusos. No caso, queremos enviar um inteiro (o descritor de arquivo):
    char buf[CMSG_SPACE(sizeof(int))];
  2. Inicializamos a mensagem (estrutura msghdr) para apontar para o buffer:
    msg.msg_control = buf;
    msg.msg_controllen = sizeof(buf);
    
  3. Obtemos um ponteiro para a primeira mensagem no buffer, e inicializamos os campos do header:
    cmsghdr *cmsg = CMSG_FIRSTHDR(msg);
    cmsg->cmsg_level = SOL_SOCKET;
    cmsg->cmsg_type = SCM_RIGHTS;
    
  4. Copiamos os dados (no caso o nosso inteiro) para dentro do conteúdo da mensagem:
    cmsg->cmsg_len = CMSG_LEN(sizeof(int));
    memmove(CMSG_DATA(cmsg), &fd, sizeof(int));
    
  5. Atribuimos a msg.msg_controllen a soma do CMSG_SPACE de todas as mensagens contidas no buffer. Isso seria necessário se tivéssemos usado um buffer maior do que o necessário para armazenar as mensagens. No nosso caso, o buffer já tinha o tamanho correspondente ao CMSG_SPACE de todas as (uma) mensagens, e msg.msg_controllen foi inicializado com esse valor no passo 2.

No recebimento, os passos 1 e 2 são idênticos. Após a chamada a recvmsg, o valor de msg.msg_controllen terá sido atualizado com o tamanho real do buffer de controle da mensagem.

Falta inicializar a mensagem com os buffers de dados não-controle. No nosso caso, só queremos transferir o descritor de arquivo, que vai nos headers de controle, mas o Unix exige que seja transferido pelo menos um byte de dados junto com os headers de controle. You know, worse is better. Sendo assim, devemos:

  1. Criar um buffer de envio/recebimento de pelo menos um byte:
    char dummy[1] = {0};
  2. Criar um par iovec descrevendo esse buffer:
    struct iovec iov;
    iov.iov_base = dummy;
    iov.iov_len = sizeof(dummy);
    
  3. Fazer a mensagem apontar para o par:
    msg.msg_iov = &iov;
    msg.msg_iovlen = 1;
    

E graças a Odin, acabou.

Comentários / Comments (0)

Deixe um comentário / Leave a comment

Main menu

Posts recentes

Comentários recentes

Tags

em-portugues (213) comp (138) prog (68) in-english (51) life (47) unix (35) pldesign (35) lang (32) random (28) about (27) mind (25) lisp (23) mundane (22) fenius (20) web (18) ramble (17) img (13) rant (12) hel (12) privacy (10) scheme (10) freedom (8) bash (7) copyright (7) music (7) academia (7) lash (7) esperanto (7) home (6) mestrado (6) shell (6) conlang (5) emacs (5) misc (5) latex (4) editor (4) book (4) php (4) worldly (4) politics (4) android (4) etymology (4) wrong (3) security (3) tour-de-scheme (3) kbd (3) c (3) film (3) network (3) cook (2) poem (2) physics (2) wm (2) treta (2) philosophy (2) comic (2) lows (2) llvm (2) perl (1) en-esperanto (1) audio (1) german (1) kindle (1) old-chinese (1) pointless (1) translation (1)

Elsewhere

Quod vide


Copyright © 2010-2020 Vítor De Araújo
O conteúdo deste blog, a menos que de outra forma especificado, pode ser utilizado segundo os termos da licença Creative Commons Attribution-NonCommercial-ShareAlike 4.0 International.

Powered by Blognir.