From 0bc1ac19345d3e02a7c90a6cfcb1d460f971c575 Mon Sep 17 00:00:00 2001 From: "tci@tcinet.ru" Date: Mon, 10 Apr 2023 13:06:15 +0300 Subject: [PATCH] add dcv-filter --- .gitignore | 2 + README | 62 ++++ cmd/certrobot.conf | 6 + cmd/main.go | 653 +++++++++++++++++++++++++++++++++++++++++ go.mod | 3 + req/req.go | 71 +++++ responder/responder.go | 84 ++++++ tciapi/tciapi.go | 612 ++++++++++++++++++++++++++++++++++++++ 8 files changed, 1493 insertions(+) create mode 100644 .gitignore create mode 100644 README create mode 100644 cmd/certrobot.conf create mode 100644 cmd/main.go create mode 100644 go.mod create mode 100644 req/req.go create mode 100644 responder/responder.go create mode 100644 tciapi/tciapi.go diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d9027ac --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +cmd/certrobot.conf + diff --git a/README b/README new file mode 100644 index 0000000..39aa081 --- /dev/null +++ b/README @@ -0,0 +1,62 @@ +TCI CertRobot +Клиентская утилита, автоматизирующая запрос/получение сертификатов и прохождение DCV. + +Требуется аккаунт на tlscc.ru. То есть, пользователь регистрируется в ЦС TLS, а реквизиты акканута (логин/пароль) +передаёт данной утилите (в конфигурационном файле, либо пароль можно указать в командной строке). + +Для проверки права управления доменным именем используется HTTP-подтверждение, которое реализуется двумя способами: +1) запуск собственного HTTP-респондера, встроенного в утилиту (требуется возможность приёма соединений 80/tcp); +2) размещение файла с кодом подтверждения в директории независимого (от утилиты) веб-сервера (требуется указать путь к директории web root). + +Конфигурационный файл + +По умолчанию - tcibot.conf (см. ниже). +Файл в формате JSON, описание полей: + +"backend_hostname" - строка; имя хоста, под которым доступен сервис ЦС ("tlscc.ru"); + +"api_path" - строка; часть URL, обозначающая корневую директорию API ("api/1.0"); слева не должно быть косой черты ("/"); + +"login" - строка; логин пользователя в ЦС TLS ТЦИ ("user@test.ru"); + +"password" - строка; опциональный пароль к логину (обратите внимание: в данной версии пароль сохраняется в открытом виде; +пароль можно указывать в параметрах командной строки, а не только в конфигурационном файле); + +"working_dir" - строка; рабочая директория утилиты, без завершающей косой черты, - в эту директорию записываются полученные сертификаты +и СЕКРЕТНЫЕ ключи ("/etc/pki/private/operations"); + +"root_cert_file" - строка; ОПЦИОНАЛЬНЫЙ СЛУЖЕБНЫЙ параметр - позволяет использовать заданный сертификат в качестве +доверенного корневого сертификата при доступе к API; указывает на путь к PEM-файлу сертификата; при отсутствии данного +параметра (или если значением является пустая строка) используется системный набор корневых сертификатов; + +"debug" - true/false, флаг; включает режим отладки - в данном режиме утилита выводит дополнительные сообщения +о статусе во время работы (false). + +Пример файла конфигурации: +{ + "backend_hostname" : "cs-tls.ru", + "api_path" : "api/1.0/", + "login" : "user@test.ru", + "password" : "your_password", + "working_dir" : "/etc/tcibot/private", + "root_cert_file" : "path_to_cert's_dir", + "debug" : false +} + +Вызов + +./tcibot -d domain.tld [-c config.conf] [-p pwd123] [-r /var/www/site/] +где: +-d - доменное имя, для которого заказывается сертификат; +-c - конфигурационный файл (по умолчанию - tcibot.conf в текущей директории); +-p - пароль для аккаунта на tlscc.ru (ЦС TLS ТЦИ); пароль также может быть указан в конфигурационном файле, приоритет имеет параметр командной строки; +-r - корневая директория веб-сервера, означает, что для подтверждения права управления используется независимый веб-сервер. + +Основной вывод производится в STDERR, имена файлов сертификатов и ключей - выводятся в STDOUT. + +Сборка + +cd cmd/ + +go build -o /path/to/utils/tcibot main.go + diff --git a/cmd/certrobot.conf b/cmd/certrobot.conf new file mode 100644 index 0000000..26ddfac --- /dev/null +++ b/cmd/certrobot.conf @@ -0,0 +1,6 @@ +{ + "backend_hostname" : "cs-tls.ru", + "login" : "login@test.ru", + "password" : "pwdpwdpwd", + "debug" : true +} diff --git a/cmd/main.go b/cmd/main.go new file mode 100644 index 0000000..4a7c71a --- /dev/null +++ b/cmd/main.go @@ -0,0 +1,653 @@ +package main + + +/* + * Общий ход процесса: + * 1) выполнение логина (имя пользователя и пароль из параметров) и получение авторизационного токена; + * 2) подготовка ключа и CSR; вызов метода ЦС, создающего новый заказ, получение идентификатора заказа, идентификатора пользователя; + * 3) вызов метода ЦС, формирующего параметры проверки (DCV) домена, получение кода валидации, адреса документа; + * 4) запуск собственного HTTP-респондера или размещение файла с кодом подтверждения в директории веб-сервера (web root); + * 5) вызов метода ЦС, запускающего проверку домена на стороне ЦС; + * 6) ожидание валидации (см. следующие шаги); + * 7) периодический вызов метода ЦС, который возвращает статус заказов, ожидание изменения статуса для заказа с текущим идентификатором; + * 8) в случае, если получен статус заказа, соответствующий выпуску сертификата, - извлечение файла сертификата, вывод файлов, успешное завершение; + * 9) в случае, если сертификат не выпущен (то есть, закончилось время ожидания, так как статус отказа не проверяется) - завершение с ошибкой. + * + * Особенности: + * для использования необходим аккаунт в ЦС TLS ТЦИ (https://tlscc.ru/); + * + * данная версия ожидает изменение значения поля статуса размещённого заказа, при этом успешным считается + * только один из статусов ("validated"); это означает, что выход из цикла ожидания возможен только + * в двух случаях: когда заказ успешно обработан, а сертификат выпущен, и когда превышено максимальное + * время ожидания (при этом на стороне ЦС возможны различные статусы заказов, в том числе, отмена заказа, + * однако эти статусы игнорируются); + * + * возможны ограничения на строне API сервера по количеству запросов, количеству выпущенных сертификатов + * и др. + * + * + */ + +import ( + "io" + "os" + "fmt" + "time" + "net" + "net/http" + "bufio" + "errors" + "regexp" + "strings" + "crypto/rand" + "crypto/x509" + "crypto/sha256" + "encoding/pem" + "encoding/json" + "certrobot/req" + "certrobot/tciapi" + "certrobot/responder" +) + +const MAXOPTIONS int = 8 + +const helpString = ` +tcibot -d domain.tld [-c config.conf] [-p pwd123] [-r /var/www/site/] +Automation for TCI CA certificate issuance. +Options: +-d - domain name; +-c - config file name (defaults to tcibot.conf); +-p - password (overwrites password from config); +-r - web root, path to (for static file validation method). + +` +const errorMsgConfigEmpty = "Config: %s parameter is not set!\n" +const wellKnownSubPath = ".well-known" +const pkiValidationSubPath = "pki-validation" + +type Config struct{ + Backend string `json:"backend_hostname"` + APIPath string `json:"api_path"` + Login string `json:"login"` + Password string `json:"password"` + WorkingDir string `json:"working_dir"` + RootCertFile string `json:"root_cert_file"` + DebugMode bool `json:"debug"` +} + +type Options struct{ + DomainName string + ConfigFile string + WebRoot string + Password string +} + +type StaticChallenge struct{ + IsSet bool + WithWellKnowDir bool + WellKnownPath string + WithPkiValidationDir bool + PkiValidationPath string + DocFilePath string +} + +func wrapper(in []byte) string { +var res []byte + counter := 0 + for _, c := range(in) { + res = append(res, c) + counter = counter + 1 + if counter >= 50 { + res = append(res, '\n') + counter = 0 + } + } + if (counter > 0) && (counter < 50) { + res = append(res, '\n') + } + return "-----BEGIN CERTIFICATE-----\n" + string(res) + "-----END CERTIFICATE-----" +} + +var validDomainName = regexp.MustCompile(`^[a-zA-Z0-9]{1}[a-zA-Z0-9\-\.]{2,}\.[a-zA-Z0-9]{1,}[a-zA-Z0-9\-]{1,}$`) +var validChallengeName = regexp.MustCompile(`^[a-fA-F0-9]{16,64}\.[a-zA-Z]{3,5}$`) +func validateName(s string) bool { + return validDomainName.MatchString(s) +} +func validateChallengeName(s string) bool { + return validChallengeName.MatchString(s) +} +func placeStaticChallenge(webRoot, docName, docValue string) (s StaticChallenge, e error) { +var res StaticChallenge + res.IsSet = false + res.WithWellKnowDir = false + res.WithPkiValidationDir = false + + if (!validateChallengeName(docName)) || (len(webRoot) < 1) || (len(docName) < 7) || (len(docValue) < 16) { + return res, errors.New("Bad challenge!") + } + if !strings.HasSuffix(webRoot, "/") { + webRoot = webRoot + "/" + } + + webDir, err := os.Stat(webRoot) + if os.IsNotExist(err) { + return res, err + } + if !webDir.IsDir() { + return res, errors.New("Web root is not a directory!") + } + + constructedPath := webRoot + wellKnownSubPath + "/" + res.WellKnownPath = constructedPath + _, err = os.Stat(constructedPath) + if os.IsNotExist(err) { + status := os.Mkdir(constructedPath, 0755) + if status != nil { + return res, status + } + res.WithWellKnowDir = true + } + cPath, err := os.Stat(constructedPath) + if os.IsNotExist(err) { + return res, errors.New("Unable to create " + constructedPath + " subdirectory!") + } + if !cPath.IsDir() { + return res, errors.New("Unexpected directory options!") + } + + constructedPath = constructedPath + pkiValidationSubPath + "/" + res.PkiValidationPath = constructedPath + _, err = os.Stat(constructedPath) + if os.IsNotExist(err) { + status := os.Mkdir(constructedPath, 0755) + if status != nil { + return res, status + } + res.WithPkiValidationDir = true + } + cPath, err = os.Stat(constructedPath) + if os.IsNotExist(err) { + return res, errors.New("Unable to create " + constructedPath + " subdirectory!") + } + if !cPath.IsDir() { + return res, errors.New("Unexpected directory options!") + } + + docPath := constructedPath + docName + _, err = os.Stat(docPath) + if !os.IsNotExist(err) { + return res, errors.New("Challenge file exists!") + } + codeFile, err := os.OpenFile(docPath, os.O_RDWR|os.O_CREATE, 0644) + if err != nil { + return res, err + } + _, err = os.Stat(docPath) + if os.IsNotExist(err) { + return res, err + } + codeFile.WriteString(docValue) + codeFile.Close() + res.DocFilePath = docPath + res.IsSet = true + return res, nil +} + +func cleanStaticChallenge(c StaticChallenge) error { + if c.IsSet { + err := os.Remove(c.DocFilePath) + if err != nil { + return err + } + } + if c.WithPkiValidationDir { + err := os.Remove(c.PkiValidationPath) + if err != nil { + return err + } + if c.WithWellKnowDir { + err := os.Remove(c.WellKnownPath) + if err != nil { + return err + } + } + } + return nil +} + +func parseOptions(s []string) (Options, bool){ +var res Options + if len(s) > MAXOPTIONS { + return res, false + } + i := 0; + for{ + if i >= len(s){ + return res, true + } + switch(s[i]){ + case "-d": + if (i + 1) < len(s) { + res.DomainName = s[i+1] // TODO: Validate name. + if !validateName(res.DomainName) { + return res, false + } + i = i + 2 + }else{ + return res, false + } + case "-c": + if (i + 1) < len(s) { + res.ConfigFile = s[i+1] + i = i + 2 + }else{ + return res, false + } + case "-r": + if (i + 1) < len(s) { + res.WebRoot = s[i+1] + i = i + 2 + }else{ + return res, false + } + case "-p": + if (i + 1) < len(s) { + res.Password = s[i+1] + i = i + 2 + }else{ + return res, false + } + default: + return res, false + } + } +} + +func getCertViaHttp(url string) (result []byte, status bool){ +var ourUserAgent = "tcibot-client/0.1" +var backTransport = &http.Transport{ + Dial: (&net.Dialer{ + Timeout: 55 * time.Second, + }).Dial, + DisableKeepAlives : true, // HTTP Keep-alives. + DisableCompression : true, + MaxIdleConns : 1, + MaxIdleConnsPerHost : 1, + IdleConnTimeout : 7 * time.Second, + ExpectContinueTimeout : 7 * time.Second, + MaxResponseHeaderBytes : 4096, +} +var httpC = &http.Client{ + Timeout: time.Second * 60, + Transport: backTransport, + CheckRedirect: func(req *http.Request, via []*http.Request) (error) { + return http.ErrUseLastResponse + }, +} +var ret []byte + + req, err := http.NewRequest("GET", url, nil) + if err != nil { + return nil, false + } + req.Header.Set("User-Agent", ourUserAgent) + + resp, err := httpC.Do(req) + if err != nil { + return nil, false + } + + if resp.StatusCode != 200 { + return nil, false + } + + //body, err := ioutil.ReadAll(resp.Body) + body, err := io.ReadAll(resp.Body) + resp.Body.Close() + if err != nil { + return nil, false + } + + if (len(body) < 128) || (len(body) > 16384) { + return nil, false + } + + ret = append(ret, body...) + return ret, true +} + +func loadIntermCert(eeCert string) (string, bool) { + block, _ := pem.Decode([]byte(eeCert)) + if block == nil { + return "", false + } + cert, err := x509.ParseCertificate(block.Bytes) + if err != nil { + return "", false + } + if len(cert.IssuingCertificateURL) != 1 { + return "", false + } + intermPEM, _ := getCertViaHttp(cert.IssuingCertificateURL[0]) + if intermPEM == nil { + return "", false + } + block, _ = pem.Decode(intermPEM) + if block == nil { + return "", false + } + return string(intermPEM), true +} + +func main() { +var config Config +var DCVtype int + if len(os.Args) < 3 { + fmt.Fprintf(os.Stderr, "Not enough arguments!\n") + fmt.Fprintf(os.Stderr, helpString) + os.Exit(1) + } + + opt, status := parseOptions(os.Args[1:]) + if (!status) || (opt.DomainName == "") { + fmt.Fprintf(os.Stderr, "Bad options!\n") + fmt.Fprintf(os.Stderr, helpString) + os.Exit(2) + } + + configFile := "" + if opt.ConfigFile == "" { + configFile = "tcibot.conf" + }else{ + configFile = opt.ConfigFile + } + file, err := os.Open(configFile) + if err != nil { + panic(err) + } + defer file.Close() + + config.Backend = "tlscc.ru" + config.APIPath = "api/1.0/" + config.WorkingDir = "certificates" + config.Login = "demo" + config.Password = "demo" + config.DebugMode = true + decoder := json.NewDecoder(file) + err = decoder.Decode(&config) + if err != nil { + panic(err) + } + + if config.Backend == "" { + fmt.Fprintf(os.Stderr, errorMsgConfigEmpty, "backend_hostname") + os.Exit(1) + } + if config.Login == "" { + fmt.Fprintf(os.Stderr, errorMsgConfigEmpty, "login") + os.Exit(1) + } + if (config.Password == "") && (opt.Password == "") { + fmt.Fprintf(os.Stderr, errorMsgConfigEmpty, "password") + os.Exit(1) + } + + if opt.Password != "" { + config.Password = opt.Password + fmt.Fprintf(os.Stderr, "Using password from command line.\n") + } + + if config.WorkingDir == "" { + fmt.Fprintf(os.Stderr, errorMsgConfigEmpty, "working_dir") + os.Exit(1) + } + + var rootCertExt []byte + if config.RootCertFile != "" { + var err error + rootCertExt, err = os.ReadFile(config.RootCertFile) + if err != nil { + fmt.Fprintf(os.Stderr, "Unable to load root certificate! (%s)\n", err.Error()) + fmt.Fprintf(os.Stderr, errorMsgConfigEmpty, "working_dir") + os.Exit(1) + } + } + + if opt.WebRoot != "" { + DCVtype = 2 // Static file. + }else{ + l, err := net.Listen("tcp", ":80") + if err != nil { + fmt.Fprintf(os.Stderr, "Error listening: %s\n\n", err.Error()) + os.Exit(1) + } + l.Close() + DCVtype = 1 // Web server. + } + + if config.DebugMode { + fmt.Fprintf(os.Stderr, "DEBUG\nAPI:\n\t%s\n\t%s\n", config.Backend, config.Login) + fmt.Fprintf(os.Stderr, "\t%s\t%s\n", opt.DomainName, opt.WebRoot) + switch DCVtype { + case 2: + fmt.Fprintf(os.Stderr, "\t%s\n", "DCV: place challenge in web root") + case 1: + fmt.Fprintf(os.Stderr, "\t%s\n", "DCV: spin web server") + default: + } + } + + fmt.Fprintf(os.Stderr, "TCI BOT beta\nLogging in...") + backend, status := tciapi.NewTciApi(config.Backend, config.APIPath, config.Login, config.Password, string(rootCertExt), config.DebugMode) + if status { + fmt.Fprintf(os.Stderr, "\033[1;32mOK\033[0m\nBackend: %s, %s \033[1;32mOK\033[0m\n", backend.Hostname, backend.Login) + fmt.Fprintf(os.Stderr, "Generating key and CSR...") + // TODO: Нужно проверять формат параметра - имени домена. + reqData, what := req.CraftCSRandKey(opt.DomainName) + if !what { + fmt.Fprintf(os.Stderr,"ERROR: Unable to create CSR and key!\n\n") + os.Exit(3) + } + fmt.Fprintf(os.Stderr, "\033[1;32mOK\033[0m\n") + fmt.Fprintf(os.Stderr, "Placing order...") + what = backend.RequestCertificate(opt.DomainName, reqData.CSR) + if !what { + fmt.Fprintf(os.Stderr, "ERROR: Unable to place order!\n\n") + os.Exit(3) + } + fmt.Fprintf(os.Stderr, "\033[1;32mOK\033[0m\n") + fmt.Fprintf(os.Stderr, "Requesting validation...\n") + if !backend.RequestCode() { + fmt.Fprintf(os.Stderr,"ERROR: Failed to get verification code and url!\n\n") + os.Exit(3) + } + fmt.Fprintf(os.Stderr, "\033[1;32mOK\033[0m\n") + + var serverCh chan int + var challengePlaced StaticChallenge + switch DCVtype { + case 1: + serverCh = make(chan int) + fmt.Fprintf(os.Stderr, "Spinning web server...") + var portNum string + portNum = ":80" + go responder.SpinServer(serverCh, portNum, "/" + wellKnownSubPath + "/" + pkiValidationSubPath + "/" + backend.ValidationURL, backend.ValidationCode) + fmt.Fprintf(os.Stderr, "\033[1;32mOK\033[0m\n") + case 2: + fmt.Fprintf(os.Stderr, "Placing static challenge...") + var err error + challengePlaced, err = placeStaticChallenge(opt.WebRoot, backend.ValidationURL, backend.ValidationCode) + if err != nil { + fmt.Fprintf(os.Stderr, "\nERROR: %s\n\n", err.Error()) + os.Exit(11) + } + fmt.Fprintf(os.Stderr, "\033[1;32mOK\033[0m\n") + default: + fmt.Fprintf(os.Stderr, "ERROR: Internal error (unexpected DCV method)!\n\n") + os.Exit(3) + } + + fmt.Fprintf(os.Stderr, "Requesting verification...\n\n") + if !backend.StartDCV() { + if DCVtype == 2 { + err := cleanStaticChallenge(challengePlaced) + if err != nil { + fmt.Fprintf(os.Stderr, "\nWARNING: %s\n\n", err.Error()) + } + } + fmt.Fprintf(os.Stderr, "ERROR: Unable to start DCV process!\n\n") + os.Exit(3) + } + fmt.Fprintf(os.Stderr, "\033[1;32mOK\033[0m\n") + + start := time.Now() + fmt.Fprintf(os.Stderr, "Waiting for certificate...") + for j := 0; j < 35; j++ { + result, e := backend.RequestStatus() + if !e { + switch DCVtype { + case 1: + serverCh <- 1 + case 2: + fmt.Fprintf(os.Stderr, "Cleaning static challenge...") + errStatus := cleanStaticChallenge(challengePlaced) + if errStatus != nil { + fmt.Fprintf(os.Stderr, "\nWARNING: %s \n\n", errStatus.Error()) + }else{ + fmt.Fprintf(os.Stderr, "\033[1;32mOK\033[0m\n") + } + default: + fmt.Fprintf(os.Stderr, "\nInternal error\n\n") + } + fmt.Fprintf(os.Stderr, "ERROR: Unexpected result - missing OrderId!\n\n") + os.Exit(3) + } + if result { + switch DCVtype { + case 1: + serverCh <- 1 + case 2: + fmt.Fprintf(os.Stderr, "Cleaning static challenge...") + errStatus := cleanStaticChallenge(challengePlaced) + if errStatus != nil { + fmt.Fprintf(os.Stderr, "\nWARNING: %s \n\n", errStatus.Error()) + }else{ + fmt.Fprintf(os.Stderr, "\033[1;32mOK\033[0m\n") + } + default: + fmt.Fprintf(os.Stderr, "\nInternal error\n\n") + } + fmt.Fprintf(os.Stderr, "\033[1;32mOK\033[0m\n") + fmt.Fprintf(os.Stderr, "Elapsed time: %s\n\n", time.Since(start)) + // TODO: добавить проверку того, что файлы с заданными именами уже существуют. + // TODO: переделать шаблон для имён файлов - на более понятный, но со счётчиком. + + var certFileName, intermFileName, keyFileName string + gotNames := false + for nameCounter := 0; nameCounter < 5; nameCounter++ { + timeStr := time.Now().Format("2006-01-02") + buf := make([]byte, 16) + _, err := rand.Read(buf) + if err != nil { + panic(err.Error()) + } + h := sha256.New() + h.Write(buf) + nameS := h.Sum(nil)[0:2] + + certFileName = config.WorkingDir + "/" + timeStr + "-" + backend.DomainName + fmt.Sprintf("-%x%d", nameS, nameCounter) + ".cert.pem" + intermFileName = config.WorkingDir + "/" + timeStr + "-" + backend.DomainName + fmt.Sprintf("-%x%d", nameS, nameCounter) + ".bundle.pem" + keyFileName = config.WorkingDir + "/" + timeStr + "-" + backend.DomainName + fmt.Sprintf("-%x%d", nameS, nameCounter) + ".private.key.pem" + _, errCert := os.Stat(certFileName) + _, errInterm := os.Stat(intermFileName) + _, errKey := os.Stat(keyFileName) + if os.IsNotExist(errCert) && os.IsNotExist(errInterm) && os.IsNotExist(errKey) { + gotNames = true + break + } + } + certString := wrapper([]byte(backend.CertData)) + keyString := reqData.PrivateKey + if gotNames { + certFile, err := os.Create(certFileName) + if err != nil { + fmt.Printf("Certificate:\n%s\n\nKey:\n%s\n\n", certString, keyString) + panic("Could not create output file (certificate)! " + err.Error()) + } + keyFile, err := os.Create(keyFileName) + if err != nil { + fmt.Printf("Certificate:\n%s\n\nKey:\n%s\n\n", certString, keyString) + certFile.Close() + panic("Could not create output file (key)! " + err.Error()) + } + wKey := bufio.NewWriter(keyFile) + _, err = wKey.WriteString(reqData.PrivateKey) + if err != nil { + fmt.Printf("Certificate:\n%s\n\nKey:\n%s\n\n", certString, keyString) + keyFile.Close() + certFile.Close() + panic("Error writing key! " + err.Error()) + } + wCert := bufio.NewWriter(certFile) + _, err = wCert.WriteString(wrapper([]byte(backend.CertData))) + if err != nil { + fmt.Printf("Certificate:\n%s\n\nKey:\n%s\n\n", certString, keyString) + keyFile.Close() + certFile.Close() + panic("Error writing certificate! " + err.Error()) + } + wKey.Flush() + wCert.Flush() + keyFile.Close() + certFile.Close() + intermCert, loadSt := loadIntermCert(certString) + if loadSt { + intermFile, err := os.Create(intermFileName) + if err != nil { + fmt.Fprintf(os.Stderr, "Warning: could not create file for intermediate certificate!\n") + } + wInterm := bufio.NewWriter(intermFile) + _, err = wInterm.WriteString(intermCert) + if err != nil { + fmt.Fprintf(os.Stderr, "Warning: could not write intermediate certificate to file!\n") + } + wInterm.Flush() + intermFile.Close() + }else{ + fmt.Fprintf(os.Stderr, "Warning: could not load intermediate certificate!\n") + } + fmt.Printf("Certificate file: %s\nKey file: %s\n", certFileName, keyFileName) + if loadSt { + fmt.Printf("CA bundle file: %s\n", intermFileName) + } + fmt.Fprintf(os.Stderr, "\n\033[1;32mDONE\033[0m\n\n") + os.Exit(0) + }else{ + fmt.Printf("ERROR writing certificate and key!\nCertificate:\n%s\n\nKey:\n%s\n\n", certString, keyString) + os.Exit(3) + } + } + time.Sleep(7 * time.Second) + fmt.Fprintf(os.Stderr, ".") + } + switch DCVtype { + case 1: + serverCh <- 1 + case 2: + fmt.Fprintf(os.Stderr, "Cleaning static challenge...") + errStatus := cleanStaticChallenge(challengePlaced) + if errStatus != nil { + fmt.Fprintf(os.Stderr, "\nWARNING: %s \n\n", errStatus.Error()) + }else{ + fmt.Fprintf(os.Stderr, "\033[1;32mOK\033[0m\n") + } + default: + fmt.Fprintf(os.Stderr, "\nInternal error\n\n") + } + fmt.Fprintf(os.Stderr,"ERROR: Unable to get certificate!\n\n") + os.Exit(3) + }else{ + fmt.Fprintf(os.Stderr, "\nUnable to connect to backend!\n\n") + os.Exit(3) + } +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..2abe2c2 --- /dev/null +++ b/go.mod @@ -0,0 +1,3 @@ +module certrobot + +go 1.18 diff --git a/req/req.go b/req/req.go new file mode 100644 index 0000000..649cd99 --- /dev/null +++ b/req/req.go @@ -0,0 +1,71 @@ +package req + +import( + "os" + "fmt" + "crypto/x509" + "crypto/ecdsa" + "crypto/rand" + "crypto/elliptic" + "encoding/base64" + "encoding/pem" + "encoding/asn1" +) +type CSRContainer struct{ + CSR string // Данные CSR в виде строки (PEM-блок и base64). + PrivateKey string // Секретный ключ (PEM-блок и base64). +} +type subjectTemplate struct{ + Subject subjectSet `asn1:"set"` +} +type subjectSet struct{ + CN subjectCN +} +type subjectCN struct{ + OID asn1.ObjectIdentifier + Value string `asn1:"utf8"` +} +func CraftCSRandKey(name string) (CSRContainer, bool){ +var res CSRContainer +var sT subjectTemplate + privKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + fmt.Fprintf(os.Stderr, "Unable to generate private key: %s\n\n", err.Error()) + return res, false + } + sT.Subject.CN = subjectCN{ + OID : []int{ 0x02, 0x05, 0x04, 0x03 }, + Value : name, + } + nameRaw, err := asn1.Marshal(sT) + if err != nil { + fmt.Fprintf(os.Stderr, "Unable to generate private key: %s\n\n", err.Error()) + return res, false + } + var csrTemplate = x509.CertificateRequest { + RawSubject : nameRaw, + SignatureAlgorithm : x509.ECDSAWithSHA256, + } + csr, err := x509.CreateCertificateRequest(rand.Reader, &csrTemplate, privKey) + if err != nil { + fmt.Fprintf(os.Stderr, "Unable to generate CSR: %s\n\n", err.Error()) + return res, false + } + exportKey, err := x509.MarshalECPrivateKey(privKey) + if err != nil { + fmt.Fprintf(os.Stderr, "Unable to marshal private key: %s\n\n", err.Error()) + return res, false + } + block := &pem.Block{ + Type: "EC PRIVATE KEY", + Bytes: exportKey, + } + pemEncodedKey := pem.EncodeToMemory(block) + if pemEncodedKey == nil { + fmt.Fprintf(os.Stderr, "Unable to generate PEM for key: %s\n\n", err.Error()) + return res, false + } + res.CSR = "-----BEGIN CERTIFICATE REQUEST-----" + base64.StdEncoding.EncodeToString(csr) + "-----END CERTIFICATE REQUEST-----" + res.PrivateKey = string(pemEncodedKey) + return res, true +} diff --git a/responder/responder.go b/responder/responder.go new file mode 100644 index 0000000..6c8d430 --- /dev/null +++ b/responder/responder.go @@ -0,0 +1,84 @@ +package responder + +import( + "strings" + "strconv" + "errors" + "bufio" + "time" + "net" + "fmt" + "os" +) + +const error404StatusEnd = "HTTP/1.1 404 Not Found\r\n\r\n" +const error400StatusEnd = "HTTP/1.1 400 Bad Request\r\n\r\n" + +func handler(conn net.Conn, docUrl string, codeVal string) (ret bool) { +defer func() { + if r := recover(); r != nil { + conn.Close() + ret = false + return + } +}() + defer conn.Close() + contentLen := strconv.Itoa(len(codeVal)) + timeout := 11 * time.Second + conn.SetReadDeadline(time.Now().Add(timeout)) + c := bufio.NewReader(conn) + line, _, _ := c.ReadLine() + + srcFields := strings.Split(string(line), " ") + if len(srcFields) < 3 { + conn.Write([]byte(error400StatusEnd)) + return false + } + if srcFields[0] != "GET" { + conn.Write([]byte(error400StatusEnd)) + return false + } + if srcFields[2] != "HTTP/1.1" { + conn.Write([]byte(error400StatusEnd)) + return false + } + if srcFields[1] == docUrl { + retData := []byte("HTTP/1.1 200 OK\r\n") + retData = append(retData, []byte("Server: tci-certrobot\r\n")[0:]...) + retData = append(retData, []byte("Content-Type: text/html\r\n")[0:]...) + retData = append(retData, []byte("Content-Length: " + contentLen)[0:]...) + retData = append(retData, []byte("\r\nConnection: close\r\n\r\n")[0:]...) + conn.Write(retData) + conn.Write([]byte(codeVal)) + return true + } + conn.Write([]byte(error404StatusEnd)) + return false +} + +func SpinServer(c chan int, portnum string, documentUrl string, code string) bool{ + l, err := net.Listen("tcp", portnum) + if err != nil { + fmt.Fprintf(os.Stderr, "Error listening:", err.Error()) + return false + } + defer l.Close() + go func(){ + <- c + fmt.Fprintf(os.Stderr, "Closing!\n") + l.Close() + }() + for { + conn, err := l.Accept() + if err != nil { // TODO: нужна обработка закрытия "сокета". + if errors.Is(err, net.ErrClosed) { + return true + } + fmt.Fprintf(os.Stderr, "FATAL: Accept error!\n", err.Error()) + return false + } + go handler(conn, documentUrl, code) + } + + return true +} diff --git a/tciapi/tciapi.go b/tciapi/tciapi.go new file mode 100644 index 0000000..888b247 --- /dev/null +++ b/tciapi/tciapi.go @@ -0,0 +1,612 @@ +package tciapi + +import( + "io" + "os" + "fmt" + "bytes" + "net/url" + "net/http" + "crypto/tls" + "crypto/x509" + "encoding/json" +) + +const apiUrlAuth = "auth" +const apiUrlOrder = "order" +const apiUrlCode = "proxy/csr" +const apiUrlValidate = "order/check-domain-ownership" + +type stUserAuth struct { + Status string `json:"status"` + StatusCode int `json:"statusCode"` + Message string `json:"message"` + Result stUserAuthResult `json:"result"` +} + +type stUserAuthResult struct { + AccessToken string `json:"access_token"` +} + +type stUnauthorized struct { + StatusCode int `json:"statusCode"` + ErrorDesc string `json:"error"` + Message string `json:"message"` +} + +type stErrorResp struct { + StatusCode int `json:"statusCode"` + ErrorDesc string `json:"error"` + Message []string `json:"message"` +} + +type stErrorLimit struct { + StatusCode int `json:"statusCode"` + ErrorDesc string `json:"error"` + Message string `json:"message"` +} + +type stCode struct { + Status string `json:"status"` + StatusCode int `json:"statusCode"` + Message string `json:"message"` + Result stCodeResult `json:"result"` +} + +type stCodeResult struct { + Code string `json:"code"` + DocumentName string `json:"filename"` +} + +type stOrderResponse struct { + Status string `json:"status"` + StatusCode int `json:"statusCode"` + Message string `json:"message"` + Result stOrderResult `json:"result"` +} + +type stOrderResult struct { + DomainName string `json:"domain"` + ValidityDays int `json:"period"` + Method string `json:"method"` + Wildcard bool `json:"wildcard"` + CSR string `json:"csr"` + Cryptosystem string `json:"certificate_type"` + Type string `json:"certificate_kind"` + DCVMethod string `json:"confirmation_type"` + CustomerId string `json:"customerId"` + Issued string `json:"issued"` + LastCodeDate string `json:"last_new_code_date"` + Id string `json:"id"` + OrderNumber int `json:"number"` + Created string `json:"created"` + Updated string `json:"updated"` + State string `json:"state"` + AttemptsCounter int `json:"confirm_attempts"` + UserInfo stUserInfo `json:"user"` + CertificateInfo stCertificateInfo `json:"certificate"` +} + +type stCertificateInfo struct { + Id string `json:"id"` + Created string `json:"created"` + Updated string `json:"updated"` + DomainName string `json:"domain"` + NotBeforeDate string `json:"start"` + NotAfterDate string `json:"finish"` + State string `json:"state"` + SerialNumber string `json:"serial"` + IssuerName string `json:"ca_name"` + Data string `json:"body"` +} + +type stUserInfo struct { + Id string `json:"id"` + Created string `json:"created"` + Updated string `json:"updated"` + Number int `json:"number"` + FirstName string `json:"firstName"` + LastName string `json:"lastName"` + MiddleName string `json:"middleName"` + UserName string `json:"username"` + Password string `json:"password"` + Agreement bool `json:"agreement"` + Convention bool `json:"convention"` +} + +type rtOrder struct { + DomainName string `json:"domain"` + ValidityDays int `json:"period"` + OrderMethod string `json:"method"` + Wildcard bool `json:"wildcard"` + CSR string `json:"csr"` + Cryptosystem string `json:"certificate_type"` + Type string `json:"certificate_kind"` + DCVMethod string `json:"confirmation_type"` +} + +type rtCode struct { + OrderId string `json:"order_id"` + UserId string `json:"user_id"` + Cryptosystem string `json:"type"` + DomainName string `json:"name"` + DCVMethod string `json:"check_type"` + CSR string `json:"csr"` +} + +type rtValidate struct { + OrderId string `json:"id"` +} + +type TciApi struct { + Connected bool // Состояние "подключения"; true - был успешный логин и есть активный токен (JWT). + Complete bool + Login string // Логин - имя пользователя на сервисе ЦС. + AuthToken string // Токен для аутентификации (получен с сервера на этапе "подключения"). + Hostname string // Имя хоста сервиса ЦС. + APIPath string // Путь к API на сервере. + CurrentOrderId string // Используемый идентификатор заказа. + CurrentUserId string // Используемый идентификатор пользователя. + CurrentCSR string + CertData string + ValidationCode string // Код валидации. + ValidationURL string // Адрес документа для размещения кода валидации. + DebugMode bool + DomainName string // Используемое в заказе имя домена. + C *http.Client +} + +func NewTciApi(hostname, api, login, passwd string, rootCert string, debugFlag bool) (instance *TciApi, status bool) { +var res TciApi +defer func() { + if boo := recover(); boo != nil { + instance = nil + status = false + return + } +}() + var tlsConfig tls.Config + res.DebugMode = debugFlag + if rootCert != "" { + caCertPool := x509.NewCertPool() + if !caCertPool.AppendCertsFromPEM([]byte(rootCert)) { + fmt.Fprintf(os.Stderr, "ERROR: %s\n\n", "Unable to load root certificate!") + return &res, false + } + tlsConfig.RootCAs = caCertPool + } + res.C = &http.Client{ // TODO: добавить (здесь) параметры в конфигурацию клиента. + Transport: &http.Transport{ + TLSClientConfig: &tlsConfig, + }, + } + + res.Login = login + res.Hostname = "https://" + hostname + "/" + res.APIPath = api + + response, err := res.C.PostForm(res.Hostname + res.APIPath + apiUrlAuth, + url.Values{ + "username" : { login }, + "password" : { passwd }, + }) + if err != nil { + fmt.Fprintf(os.Stderr, "ERROR: %s\n\n", err.Error()) + return &res, false + } + defer response.Body.Close() + body, err := io.ReadAll(response.Body) + if err != nil { + fmt.Fprintf(os.Stderr, "ERROR: %s\n\n", err.Error()) + return &res, false + } + switch response.StatusCode { + case 200: // 201 + { + s := new(stUserAuth) + e := json.Unmarshal([]byte(body), s) + if e != nil { + fmt.Fprintf(os.Stderr, "ERROR: %s\n\n", e.Error()) + return &res, false + } + res.AuthToken = s.Result.AccessToken + if debugFlag { + fmt.Fprintf(os.Stderr, "Connected (%s)\n", res.Hostname) + fmt.Fprintf(os.Stderr, "%s\n", res.AuthToken) + } + return &res, true + } + case 401: + { + s := new(stUnauthorized) + e := json.Unmarshal([]byte(body), s) + if e != nil { + fmt.Fprintf(os.Stderr, "ERROR: %s\n\n", e.Error()) + return &res, false + } + fmt.Fprintf(os.Stderr, "\t%s\n\t%s\n\n", s.ErrorDesc, s.Message) + return &res, false + } + default: + { + fmt.Fprintf(os.Stderr, "Unexpected response (%s)!\n", res.Hostname) + return &res, false + } + } + + return &res, true +} + +func (r *TciApi) RequestCertificate(name, csr string) (status bool){ +defer func() { + if boo := recover(); boo != nil { + status = false + return + } +}() + + s := new(rtOrder) + s.DomainName = name + + s.Cryptosystem = "ecdsa" + s.CSR = csr + s.ValidityDays = 90 + s.OrderMethod = "auto" + s.Type = "domain" + s.DCVMethod = "http" + s.Wildcard = false + + reqBody, err := json.Marshal(s) + if err != nil { + fmt.Fprintf(os.Stderr, "ERROR: %s\n", err.Error()) + return false + } + + requestData := bytes.NewBuffer(reqBody) + req, err := http.NewRequest("POST", r.Hostname + r.APIPath + apiUrlOrder, requestData) + if err != nil { + fmt.Fprintf(os.Stderr, "ERROR: %s\n", err.Error()) + return false + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer " + r.AuthToken) + req.Header.Set("Cookie", "access_token=" + r.AuthToken) + + if r.DebugMode { + fmt.Fprintf(os.Stderr, "\nSending order (%s)\n", r.Hostname) + } + + response, err := r.C.Do(req) + if err != nil { + fmt.Fprintf(os.Stderr, "ERROR: %s\n\n", err.Error()) + return false + } + defer response.Body.Close() + respBody, err := io.ReadAll(response.Body) + if err != nil { + fmt.Fprintf(os.Stderr, "ERROR: %s\n\n", err.Error()) + return false + } + if r.DebugMode { + fmt.Fprintf(os.Stderr, "Response code: %d\n", response.StatusCode) + } + switch response.StatusCode { + case 201: + { + orderData := new(stOrderResponse) + e := json.Unmarshal([]byte(respBody), orderData) + if e != nil { + fmt.Fprintf(os.Stderr, "ERROR: %s\n\n", e.Error()) + return false + } + if r.DebugMode { + fmt.Fprintf(os.Stderr, "OrderId: %s\n\n", orderData.Result.Id) + } + r.DomainName = name + r.CurrentOrderId = orderData.Result.Id + r.CurrentUserId = orderData.Result.CustomerId + r.CurrentCSR = csr + return true + } + case 400: + { + s := new(stErrorResp) + e := json.Unmarshal([]byte(respBody), s) + if e != nil { + fmt.Fprintf(os.Stderr, "ERROR: %s\n\n", e.Error()) + return false + } + fmt.Fprintf(os.Stderr, "\t%s\n\t%s\n\n", s.ErrorDesc, s.Message) + return false + } + case 401: + { + s := new(stUnauthorized) + e := json.Unmarshal([]byte(respBody), s) + if e != nil { + fmt.Fprintf(os.Stderr, "ERROR: %s\n\n", e.Error()) + return false + } + fmt.Fprintf(os.Stderr, "\t%s\n\t%s\n\n", s.ErrorDesc, s.Message) + return false + } + case 429: + { + s := new(stErrorLimit) + e := json.Unmarshal([]byte(respBody), s) + if e != nil { + fmt.Fprintf(os.Stderr, "ERROR: %s\n\n", e.Error()) + return false + } + fmt.Fprintf(os.Stderr, "\t%s\n\t%s\n\n", s.ErrorDesc, s.Message) + return false + } + + default: + { + fmt.Fprintf(os.Stderr, "Unexpected response (%s)!\n", r.Hostname) + return false + } + } + + return true +} + +func (r *TciApi) RequestCode() (status bool){ +defer func() { + if boo := recover(); boo != nil { + status = false + return + } +}() + + if r.DebugMode { + fmt.Fprintf(os.Stderr, "Requesting code for DCV\n") + } + + req, err := http.NewRequest("GET", r.Hostname + r.APIPath + apiUrlCode + "/" + r.CurrentOrderId, nil) + if err != nil { + fmt.Fprintf(os.Stderr, "ERROR: %s\n", err.Error()) + return false + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer " + r.AuthToken) + + response, err := r.C.Do(req) + if err != nil { + fmt.Fprintf(os.Stderr, "ERROR: %s\n\n", err.Error()) + return false + } + defer response.Body.Close() + respBody, err := io.ReadAll(response.Body) + if err != nil { + fmt.Fprintf(os.Stderr, "ERROR: %s\n\n", err.Error()) + return false + } + + switch response.StatusCode { + case 200: //201 + { + codeData := new(stCode) + e := json.Unmarshal([]byte(respBody), codeData) + if e != nil { + fmt.Fprintf(os.Stderr, "ERROR: %s\n\n", e.Error()) + return false + } + r.ValidationCode = codeData.Result.Code + r.ValidationURL = codeData.Result.DocumentName + if r.DebugMode { + fmt.Fprintf(os.Stderr, "Code: %s\nDocument: %s\n", codeData.Result.Code, codeData.Result.DocumentName) + } + return true + } + case 400: + { + s := new(stErrorResp) + e := json.Unmarshal([]byte(respBody), s) + if e != nil { + fmt.Fprintf(os.Stderr, "ERROR: %s\n\n", e.Error()) + return false + } + fmt.Fprintf(os.Stderr, "\t%s\n\t%s\n\n", s.ErrorDesc, s.Message) + return false + } + case 401: + { + s := new(stUnauthorized) + e := json.Unmarshal([]byte(respBody), s) + if e != nil { + fmt.Fprintf(os.Stderr, "ERROR: %s\n\n", e.Error()) + return false + } + fmt.Fprintf(os.Stderr, "\t%s\n\t%s\n\n", s.ErrorDesc, s.Message) + return false + } + default: + { + fmt.Fprintf(os.Stderr, "Unexpected response (%s)!\n", r.Hostname) + return false + } + } + + return true +} + +func (r *TciApi) StartDCV() (status bool){ +defer func() { + if boo := recover(); boo != nil { + status = false + return + } +}() + + s := new(rtValidate) + s.OrderId = r.CurrentOrderId + + reqBody, err := json.Marshal(s) + if err != nil { + fmt.Fprintf(os.Stderr, "ERROR: %s\n", err.Error()) + return false + } + requestData := bytes.NewBuffer(reqBody) + req, err := http.NewRequest("POST", r.Hostname + r.APIPath + apiUrlValidate, requestData) + if err != nil { + fmt.Fprintf(os.Stderr, "ERROR: %s\n", err.Error()) + return false + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer " + r.AuthToken) + + if r.DebugMode { + fmt.Fprintf(os.Stderr, "Start DCV\n") + } + + response, err := r.C.Do(req) + if err != nil { + fmt.Fprintf(os.Stderr, "ERROR: %s\n\n", err.Error()) + return false + } + defer response.Body.Close() + respBody, err := io.ReadAll(response.Body) + if err != nil { + fmt.Fprintf(os.Stderr, "ERROR: %s\n\n", err.Error()) + return false + } + fmt.Printf("Status Code: %d\n", response.StatusCode) + + switch response.StatusCode { + case 200: + { + if r.DebugMode { + fmt.Fprintf(os.Stderr, "DCV process started\n") + } + return true + } + case 400: + { + s := new(stErrorResp) + e := json.Unmarshal([]byte(respBody), s) + if e != nil { + fmt.Fprintf(os.Stderr, "ERROR: %s\n\n", e.Error()) + return false + } + fmt.Fprintf(os.Stderr, "\t%s\n\t%s\n\n", s.ErrorDesc, s.Message) + return false + } + case 401: + { + s := new(stUnauthorized) + e := json.Unmarshal([]byte(respBody), s) + if e != nil { + fmt.Fprintf(os.Stderr, "ERROR: %s\n\n", e.Error()) + return false + } + fmt.Fprintf(os.Stderr, "\t%s\n\t%s\n\n", s.ErrorDesc, s.Message) + return false + } + default: + { + fmt.Fprintf(os.Stderr, "Unexpected response (%s)!\n", r.Hostname) + return false + } + } + + return true +} + +func (r *TciApi) RequestStatus() (res, status bool){ +defer func() { + if boo := recover(); boo != nil { + res = false + status = false + return + } +}() + + req, err := http.NewRequest("GET", r.Hostname + r.APIPath + apiUrlOrder, nil) + if err != nil { + fmt.Fprintf(os.Stderr, "ERROR: %s\n", err.Error()) + return false, false + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer " + r.AuthToken) + + if r.DebugMode { + fmt.Fprintf(os.Stderr, "Status check\n") + } + + response, err := r.C.Do(req) + if err != nil { + fmt.Fprintf(os.Stderr, "ERROR: %s\n\n", err.Error()) + return false, false + } + defer response.Body.Close() + respBody, err := io.ReadAll(response.Body) + if err != nil { + fmt.Fprintf(os.Stderr, "ERROR: %s\n\n", err.Error()) + return false, false + } + + switch response.StatusCode { + case 200: + { + s := new([]stOrderResult) + e := json.Unmarshal([]byte(respBody), s) + if e != nil { + fmt.Fprintf(os.Stderr, "ERROR: %s\n\n", e.Error()) + return false, false + } + + found := false + var order stOrderResult + for _, o := range(*s) { + if r.CurrentOrderId == o.Id { + found = true + order = o + } + } + if found { + if (order.State == "validated") && (len(order.CertificateInfo.Data) > 0) { + r.CertData = order.CertificateInfo.Data + if r.DebugMode { + fmt.Fprintf(os.Stderr, "DCV confirmed!\n") + } + return true, true + }else{ + return false, true + } + }else{ + return false, false + } + } + case 400: + { + s := new(stErrorResp) + e := json.Unmarshal([]byte(respBody), s) + if e != nil { + fmt.Fprintf(os.Stderr, "ERROR: %s\n\n", e.Error()) + return false, false + } + fmt.Fprintf(os.Stderr, "\t%s\n\t%s\n\n", s.ErrorDesc, s.Message) + return false, false + } + case 401: + { + s := new(stUnauthorized) + e := json.Unmarshal([]byte(respBody), s) + if e != nil { + fmt.Fprintf(os.Stderr, "ERROR: %s\n\n", e.Error()) + return false, false + } + fmt.Fprintf(os.Stderr, "\t%s\n\t%s\n\n", s.ErrorDesc, s.Message) + return false, false + } + default: + { + fmt.Fprintf(os.Stderr, "Unexpected response (%s)!\n", r.Hostname) + return false, false + } + } + + return false, false +}