tcibot/cmd/main.go

655 lines
20 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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())
}
keyFile.Chmod(os.FileMode(int(0600)))
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)
}
}