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,}$`) func validateName(s string) bool { return validDomainName.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 (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) } }