В Части 1 мы рассмотрели, как устроена вся система. В этой части мы напишем службу компиляции Golang, которая будет компилировать код C++.
Поток и процесс одинаковы для всех языков, поэтому, как только вы освоите компиляцию C++, вам будет легко реализовать аналогичный сервис для других языков.
Я напишу сам сервер на Golang, но не стесняйтесь использовать любой другой внутренний язык, который вам нравится.
ОБНОВЛЕНИЕ: Часть 3 теперь доступна
Основная идея:
Идея проста. У нас будет сервер Golang, который получает код C++ через запрос POST, компилирует и выполняет код с помощью подкоманд и возвращает результат.
Что не так просто, так это проблемы безопасности, с которыми он сталкивается. Видите ли, поскольку мы позволяем пользователю выполнять любой код C++ на нашем сервере, он вполне может вывести из строя наш сервер через минуту, выполнив код с бесконечным циклом или, что еще хуже, выполнив команду rm -rf
для удаления всех наших файлов.
Вопросы безопасности:
В этом проекте мы позаботимся о 4 основных проблемах безопасности.
- Не позволяйте пользователям выполнять большой фрагмент кода. ( › 1 МБ)
- Не позволяйте пользователям выполнять код с бесконечными циклами.
- Запретить пользователям создавать, читать или записывать файлы в неавторизованных каталогах.
- Запретить пользователям выполнять неавторизованные команды оболочки.
Можно подумать о многих других проблемах, но мы просто позаботимся об этих 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 и ждем. Может случиться одно из двух.
- Код выполняется успешно и выдает вывод/ошибку.
- Выполнение кода пересекает 5 секунд и истекает.
На основании этих случаев мы возвращаем соответствующий ответ.
Итак, это все! Код для компиляции и ответа выводом. Надеюсь, вы узнали что-то новое, читая это. В следующей части мы возьмем этот код и попытаемся его докеризовать, чтобы его можно было запускать на любой машине.