В Части 1 мы рассмотрели, как устроена вся система. В этой части мы напишем службу компиляции Golang, которая будет компилировать код C++.

Поток и процесс одинаковы для всех языков, поэтому, как только вы освоите компиляцию C++, вам будет легко реализовать аналогичный сервис для других языков.

Я напишу сам сервер на Golang, но не стесняйтесь использовать любой другой внутренний язык, который вам нравится.

ОБНОВЛЕНИЕ: Часть 3 теперь доступна

Основная идея:

Идея проста. У нас будет сервер Golang, который получает код C++ через запрос POST, компилирует и выполняет код с помощью подкоманд и возвращает результат.

Что не так просто, так это проблемы безопасности, с которыми он сталкивается. Видите ли, поскольку мы позволяем пользователю выполнять любой код C++ на нашем сервере, он вполне может вывести из строя наш сервер через минуту, выполнив код с бесконечным циклом или, что еще хуже, выполнив команду rm -rf для удаления всех наших файлов.

Вопросы безопасности:

В этом проекте мы позаботимся о 4 основных проблемах безопасности.

  1. Не позволяйте пользователям выполнять большой фрагмент кода. ( › 1 МБ)
  2. Не позволяйте пользователям выполнять код с бесконечными циклами.
  3. Запретить пользователям создавать, читать или записывать файлы в неавторизованных каталогах.
  4. Запретить пользователям выполнять неавторизованные команды оболочки.

Можно подумать о многих других проблемах, но мы просто позаботимся об этих 4, чтобы все было просто.

Для первых двух мы можем сделать некоторые проверки в Golang, чтобы смягчить их. Но для 3 и 4 у нас есть 2 варианта.

1. Песочница:

Наиболее распространенным способом избежать всех этих проблем является метод, называемый песочницей. Вы создаете что-то вроде контейнера докеров со своей файловой системой и библиотеками и выполняете пользовательский код внутри него. Таким образом, вы можете изолировать файловую систему вашего сервера от места, где может выполняться непроверенный пользовательский код.

Я не стал использовать этот подход, потому что, хотя он и безопасен, его также немного сложно реализовать, и я хотел, чтобы все было просто.

2. Тюремное заключение:

Другой подход — тюремное заключение. Здесь мы создаем ограниченного пользователя ОС с ограниченными разрешениями и выполняем код от имени этого пользователя, а не от имени пользователя root.

Мы подробно рассмотрим этот подход в части 3.

Это называется тюрьмой, потому что мы строим стену вокруг кода, ограничивая права доступа к нему и ограничивая его доступом только к необходимым файлам и папкам.

Выполнение:

Примечание. Реализация будет длинной и подробной, поэтому, если вам нужен только полный код, ознакомьтесь с ним здесь.

Хорошо, теперь, когда мы немного разобрались с проблемами безопасности, давайте начнем с создания простого сервера Golang с использованием встроенного пакета net/http.

package main

import (
 "log"
 "net/http"
)

func main() {
   http.HandleFunc("/compile", handleCompile)
   log.Println("C++ Server listening on port 8080...")
   log.Fatal(http.ListenAndServe(":8080", nil))
}

Это просто создает простой http-сервер, который прослушивает запросы на маршруте /compile через ПОРТ 8080 и вызывает функцию handleCompile. Но прежде чем мы напишем функцию handleCompile, давайте определим некоторые структуры и константы, чтобы сделать все читабельным.

// Maximum allowed code size in bytes
const MaxCodeSize = 1024 * 1024 // 1 MB

// Restricted user and group ID
const RestrictedUserID = 1000
const RestrictedGroupID = 1000

type CompileRequest struct {
   Code     string `json:"code"`
   Input    string `json:"input"`
   Language string `json:"language,omitempty"`
}

Определите их перед основной функцией.

MaxCodeSize сообщает, насколько большим может быть код C++ во входящем запросе. Вот это 1024 bytes (which is 1 KB) x 1024 = 1 MB.

RestrictedUserID и RestrictedGroupID определяют идентификаторы пользователя и группы непривилегированного пользователя, который будет выполнять код. По умолчанию 1000 для обоих контейнеров докеров.

Наконец, структура CompileRequest определяет формат JSON во входящем запросе POST.

Функция обработчика:

Когда запрос попадает в функцию handleCompile, первым шагом является декодирование тела в экземпляр структуры compileRequest.

func handleCompile(w http.ResponseWriter, r *http.Request) {
   // Read the request body
   body, err := ioutil.ReadAll(r.Body)
   if err != nil {
      w.WriteHeader(http.StatusBadRequest)
      fmt.Fprint(w, "Error occurred during parsing of the request body", err)
      log.Printf("Failed to read request body: %v", err)
      return
   }
  
   // Parse the JSON request body
   var compileReq CompileRequest
   err = json.Unmarshal(body, &compileReq)
   if err != nil {
      w.WriteHeader(http.StatusBadRequest)
      fmt.Fprint(w, "Error occurred during parsing of the JSON request body", err)
      log.Printf("Failed to parse JSON request body: %v", err)
      return
   }
}

Мы просто извлекаем JSON из тела запроса и возвращаем status 500 в случае ошибки.

Теперь код будет присутствовать в поле Code структуры compileReq.

Следующим шагом является проверка проблемы безопасности номер 1. т. е. если размер кода превышает 1 МБ.

 code := []byte(compileReq.Code)

 // Check if the code size exceeds the maximum allowed size
 if len(code) > MaxCodeSize {
    ... status 500 error
 }

Мы приводим строку Code к массиву байтов, а затем проверяем ее длину. Если в массиве более миллиона записей (1 мегабайт равен миллиону байтов), мы возвращаем status 500.

Если размер кода устраивает, следующим шагом будет запись этого кода во временный файл, который можно передать компилятору g++.

// Create a temporary file to store the code
 tmpFile, err := ioutil.TempFile("", "code-*.cpp")
 if err != nil {
    ... status 500 error
 }
 defer os.Remove(tmpFile.Name()) // Clean up the temporary file

 // Write the code to the temporary file
 _, err = tmpFile.Write(code)
 if err != nil {
    ... status 500 error
 }

 // Close the temporary file
 err = tmpFile.Close()
 if err != nil {
    ... status 500 error
 }

ioutil.TempFile() создает временный файл в каталоге /tmp и заменяет ‘*’ в имени файла некоторыми случайными числами. Это гарантирует, что имена файлов будут уникальными для каждого запроса. Убедитесь, что имя файла также заканчивается расширением .cpp.

Мы также должны убедиться, что файл удаляется после отправки ответа. мы делаем это, используя defer os.Remove(tmpFile.Name()).

Затем мы просто пишем код в файл и закрываем файл.

Нам также нужно будет создать еще один файл для хранения вывода аналогичным образом. В этом файле будет храниться скомпилированный байт-код/машинный код.

 // Create a temporary file to store the output
 tmpOpFile, err := ioutil.TempFile("", "output-*")
 if err != nil {
    ... status 500 error
 }

Примечание. Следующий шаг компиляции не требуется для интерпретируемых языков, таких как Python и Javascript. Вы можете сразу перейти к запуску файла.

Теперь мы передаем файл кода компилятору g++, говоря ему, чтобы он записал вывод в созданный нами tempOpFile. Мы используем библиотеку exec для вызова подкоманды bash для g++.

// Compile the code using G++
 outputFile := tmpOpFile.Name()
 cmd := exec.Command("g++", tmpFile.Name(), "-o", outputFile)

 compilerOutput, err := cmd.CombinedOutput()
 if err != nil {
    ... status 500 error
 }

 log.Printf("Compilation successful. Output file: %s", outputFile)

 // Close the temporary output file
 err = tmpOpFile.Close()
 if err != nil {
    ... status 500 error
 }

Теперь у нас есть сгенерированный бинарный файл. Осталось только выполнить этот двоичный файл и зафиксировать вывод из stdout.

При этом нам нужно ограничить время, в течение которого двоичный файл может выполняться, чтобы проверить наличие бесконечных циклов.

// Create a context with a timeout duration
 ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
 defer cancel()

 // Create a channel to receive the output
 outputChannel := make(chan []byte)

 // Run the compiled output in a Goroutine and monitor for timeouts
 go func() {
  cmd := exec.CommandContext(ctx, outputFile)

  // Set the user and group ID of the executed program
  cmd.SysProcAttr = &syscall.SysProcAttr{
     Credential: &syscall.Credential{
      Uid: RestrictedUserID,
      Gid: RestrictedGroupID,
     },
  }

  // Set the input for the program
  cmd.Stdin = strings.NewReader(compileReq.Input)

  cmdOutput, err := cmd.CombinedOutput()
  if err != nil {
     log.Printf("Execution error: %s", err)
  }

  // Send the execution output through the channel
  outputChannel <- cmdOutput
 }()

 // Remove the temporary output file after the Goroutine completes
 defer os.Remove(tmpOpFile.Name())

 select {
 case <-ctx.Done():
    // Execution timed out
    w.WriteHeader(http.StatusInternalServerError)
    fmt.Fprint(w, "Execution timed out")
    log.Println("Execution timed out")
 case output := <-outputChannel:
    // Execution completed within the timeout duration
    w.Header().Set("Content-Type", "text/plain")
    w.Write(output)
 }

Мы создаем контекст, время ожидания которого истекает через 5 секунд, и передаем его команде exec, как и раньше. Мы также предоставляем имя файла, который необходимо выполнить, вместе с идентификатором пользователя, который будет запускать файл.

compileReq.Input содержит все входные данные, необходимые для кода C++, отправленного в запросе. Мы поставляем его на stdout.

Мы выполняем код в отдельной процедуре go и ждем. Может случиться одно из двух.

  1. Код выполняется успешно и выдает вывод/ошибку.
  2. Выполнение кода пересекает 5 секунд и истекает.

На основании этих случаев мы возвращаем соответствующий ответ.

Итак, это все! Код для компиляции и ответа выводом. Надеюсь, вы узнали что-то новое, читая это. В следующей части мы возьмем этот код и попытаемся его докеризовать, чтобы его можно было запускать на любой машине.