From 0eaf6f22591235cac7c13eb1747463a08fadd1ef Mon Sep 17 00:00:00 2001 From: Florian Ritterhoff Date: Thu, 12 Dec 2024 21:58:54 +0100 Subject: [PATCH] feat: provide first cli tool for harica api --- .gitignore | 1 + README.md | 3 +- client.go => client/client.go | 177 ++++++++++++++++++++++------------ helper.go => client/helper.go | 2 +- cmd/genCert.go | 96 ++++++++++++++++++ cmd/root.go | 22 +++++ go.mod | 5 +- go.sum | 9 ++ main.go | 7 ++ models/certificate.go | 38 ++++++++ 10 files changed, 294 insertions(+), 66 deletions(-) rename client.go => client/client.go (52%) rename helper.go => client/helper.go (98%) create mode 100644 cmd/genCert.go create mode 100644 cmd/root.go create mode 100644 main.go create mode 100644 models/certificate.go diff --git a/.gitignore b/.gitignore index 6f72f89..588bdb0 100644 --- a/.gitignore +++ b/.gitignore @@ -23,3 +23,4 @@ go.work.sum # env file .env +harica diff --git a/README.md b/README.md index 6495415..05d2024 100644 --- a/README.md +++ b/README.md @@ -1 +1,2 @@ -# harica-client \ No newline at end of file +# Inofficial Client for the HARICA API + diff --git a/client.go b/client/client.go similarity index 52% rename from client.go rename to client/client.go index 917d12b..45d441b 100644 --- a/client.go +++ b/client/client.go @@ -1,8 +1,8 @@ -package main +package client import ( + "encoding/json" "fmt" - "net/http" "strings" "time" @@ -11,47 +11,62 @@ import ( "github.com/go-co-op/gocron/v2" "github.com/go-resty/resty/v2" "github.com/golang-jwt/jwt/v5" - "github.com/hm-edu/harica-client/models" + "github.com/hm-edu/harica/models" "github.com/pquerna/otp/totp" ) const ( - BaseURL = "https://cm.harica.gr" - LoginPath = "/api/User/Login" - LoginPathTotp = "/api/User/Login2FA" - RefreshInterval = 15 * time.Minute + BaseURL = "https://cm.harica.gr" + LoginPath = "/api/User/Login" + LoginPathTotp = "/api/User/Login2FA" + RevocationReasonsPath = "/api/Certificate/GetRevocationReasons" + DomainValidationsPath = "/api/ServerCertificate/GetDomainValidations" + RefreshInterval = 15 * time.Minute ) type Client struct { client *resty.Client scheduler gocron.Scheduler currentToken string + debug bool } -func NewClient(user, password, totpSeed string) (*Client, error) { +type Option func(*Client) + +func NewClient(user, password, totpSeed string, options ...Option) (*Client, error) { c := Client{} + for _, option := range options { + option(&c) + } err := c.prepareClient(user, password, totpSeed) if err != nil { return nil, err } s, err := gocron.NewScheduler() if err != nil { - slog.Error("failed to create scheduler", slog.Any("error", err)) return nil, err } - job, err := s.NewJob(gocron.DurationJob(RefreshInterval), gocron.NewTask(func() { - c.prepareClient(user, password, totpSeed) + _, err = s.NewJob(gocron.DurationJob(RefreshInterval), gocron.NewTask(func() { + err := c.prepareClient(user, password, totpSeed) + if err != nil { + slog.Error("failed to prepare client", slog.Any("error", err)) + return + } })) if err != nil { - slog.Error("failed to create job", slog.Any("error", err)) return nil, err } - slog.Info("added job", slog.Any("job", job)) s.Start() c.scheduler = s return &c, nil } +func WithDebug(debug bool) Option { + return func(c *Client) { + c.debug = debug + } +} + func (c *Client) prepareClient(user, password, totpSeed string) error { renew := false @@ -95,7 +110,7 @@ func (c *Client) loginTotp(user, password, totpSeed string) error { SetBody(map[string]string{"email": user, "password": password, "token": otp}). Post(BaseURL + LoginPathTotp) if err != nil { - // handle error + return err } tokenResp := strings.Trim(resp.String(), "\"") _, _, err = jwt.NewParser().ParseUnverified(tokenResp, jwt.MapClaims{}) @@ -104,16 +119,11 @@ func (c *Client) loginTotp(user, password, totpSeed string) error { } c.currentToken = tokenResp r = r.SetHeaders(map[string]string{"Authorization": c.currentToken}) - r.SetPreRequestHook(func(c *resty.Client, r *http.Request) error { - cli := resty.New().SetCookieJar(c.GetClient().Jar) - verificationToken, err := getVerificationToken(cli) - if err != nil { - slog.Error("failed to get verification token", slog.Any("error", err)) - return err - } - r.Header["RequestVerificationToken"] = []string{verificationToken} - return nil - }) + token, err := getVerificationToken(r) + if err != nil { + return err + } + r = r.SetHeaderVerbatim("RequestVerificationToken", token).SetDebug(c.debug) c.client = r return nil } @@ -132,39 +142,37 @@ func (c *Client) login(user, password string) error { if err != nil { return err } - c.currentToken = strings.Trim(resp.String(), "\"") + tokenResp := strings.Trim(resp.String(), "\"") + _, _, err = jwt.NewParser().ParseUnverified(tokenResp, jwt.MapClaims{}) + if err != nil { + return err + } + c.currentToken = tokenResp r = r.SetHeaders(map[string]string{"Authorization": c.currentToken}) - r.SetPreRequestHook(func(c *resty.Client, r *http.Request) error { - cli := resty.New().SetCookieJar(c.GetClient().Jar) - verificationToken, err := getVerificationToken(cli) - if err != nil { - slog.Error("failed to get verification token", slog.Any("error", err)) - return err - } - r.Header.Add("RequestVerificationToken", verificationToken) - return nil - }) + token, err := getVerificationToken(r) + if err != nil { + return err + } + r = r.SetHeaderVerbatim("RequestVerificationToken", token).SetDebug(c.debug) c.client = r return nil } func (c *Client) GetRevocationReasons() error { - resp, err := c.client.R().Post(BaseURL + "/api/Certificate/GetRevocationReasons") + resp, err := c.client.R().Post(BaseURL + RevocationReasonsPath) if err != nil { return err } - // handle response data := resp.String() fmt.Print(data) return nil } func (c *Client) GetDomainValidations() error { - resp, err := c.client.R().Post(BaseURL + "/api/ServerCertificate/GetDomainValidations") + resp, err := c.client.R().Post(BaseURL + DomainValidationsPath) if err != nil { return err } - // handle response fmt.Print(resp.String()) return nil } @@ -174,49 +182,84 @@ type Domain struct { } func (c *Client) CheckMatchingOrganization(domains []string) ([]models.OrganizationResponse, error) { - domainDto := make([]Domain, 0) + var domainDto []Domain for _, domain := range domains { domainDto = append(domainDto, Domain{Domain: domain}) } - response := []models.OrganizationResponse{} - _, err := c.client.R().SetHeader("Content-Type", "application/json").SetResult(&response).SetBody(domains).Post(BaseURL + "/api/ServerCertificate/CheckMachingOrganization") + var response []models.OrganizationResponse + _, err := c.client.R(). + SetHeader("Content-Type", "application/json"). + SetResult(&response).SetBody(domainDto). + Post(BaseURL + "/api/ServerCertificate/CheckMachingOrganization") if err != nil { - // handle error return nil, err } - // handle response return response, nil } +func (c *Client) GetCertificate(id string) (models.CertificateResponse, error) { + var cert models.CertificateResponse + _, err := c.client.R(). + SetResult(&cert). + SetHeader("Content-Type", "application/json"). + SetBody(map[string]interface{}{"id": id}). + Post(BaseURL + "/api/Certificate/GetCertificate") + if err != nil { + return cert, err + } + return cert, nil +} + func (c *Client) CheckDomainNames(domains []string) ([]models.DomainResponse, error) { domainDto := make([]Domain, 0) for _, domain := range domains { domainDto = append(domainDto, Domain{Domain: domain}) } domainResp := make([]models.DomainResponse, 0) - _, err := c.client.R().SetResult(&domainResp).SetHeader("Content-Type", "application/json").SetBody(domainDto).Post(BaseURL + "/api/ServerCertificate/CheckDomainNames") + _, err := c.client.R(). + SetResult(&domainResp). + SetHeader("Content-Type", "application/json"). + SetBody(domainDto). + Post(BaseURL + "/api/ServerCertificate/CheckDomainNames") if err != nil { return nil, err } - // handle response return domainResp, nil } -func (c *Client) RequestCertificate() { - -} - -func (c *Client) RevokeCertificate() { - +func (c *Client) RequestCertificate(domains []models.DomainResponse, csr string, transactionType string) (models.CertificateRequestResponse, error) { + domainJsonBytes, _ := json.Marshal(domains) + domainJson := string(domainJsonBytes) + var result models.CertificateRequestResponse + _, err := c.client.R(). + SetHeader("Content-Type", "multipart/form-data"). + SetResult(&result). + SetMultipartFormData(map[string]string{ + "domains": domainJson, + "domainsString": domainJson, + "csr": csr, + "isManualCsr": "true", + "consentSameKey": "true", + "transactionType": transactionType, + "duration": "1", + }). + Post(BaseURL + "/api/ServerCertificate/RequestServerCertificate") + if err != nil { + return result, err + } + return result, nil } func (c *Client) GetPendingReviews() ([]models.ReviewResponse, error) { - pending := []models.ReviewResponse{} + var pending []models.ReviewResponse _, err := c.client.R(). - SetDebug(true). SetResult(&pending). SetHeader("Content-Type", "application/json"). - SetBody(models.ReviewRequest{StartIndex: 0, Status: "Pending", FilterPostDTOs: []any{}}). + SetBody(models.ReviewRequest{ + StartIndex: 0, + Status: "Pending", + FilterPostDTOs: []any{}, + }). Post(BaseURL + "/api/OrganizationValidatorSSL/GetSSLReviewableTransactions") if err != nil { return nil, err @@ -224,19 +267,27 @@ func (c *Client) GetPendingReviews() ([]models.ReviewResponse, error) { return pending, nil } -func (c *Client) ApproveRequest(id, message, value string) { - - c.client.R(). - SetDebug(true). +func (c *Client) ApproveRequest(id, message, value string) error { + _, err := c.client.R(). SetHeader("Content-Type", "multipart/form-data"). - SetBody(map[string]string{"reviewId": id, "isValid": "true", "informApplicant": "true", "reviewMessage": message, "reviewValue": value}). + SetMultipartFormData(map[string]string{ + "reviewId": id, + "isValid": "true", + "informApplicant": "true", + "reviewMessage": message, + "reviewValue": value, + }). Post(BaseURL + "/api/OrganizationValidatorSSL/UpdateReviews") - + if err != nil { + return err + } + return nil } -func (c *Client) Shutdown() { +func (c *Client) Shutdown() error { err := c.scheduler.Shutdown() if err != nil { - // handle error + return err } + return nil } diff --git a/helper.go b/client/helper.go similarity index 98% rename from helper.go rename to client/helper.go index 7f79383..993ef23 100644 --- a/helper.go +++ b/client/helper.go @@ -1,4 +1,4 @@ -package main +package client import ( "strings" diff --git a/cmd/genCert.go b/cmd/genCert.go new file mode 100644 index 0000000..217a7ee --- /dev/null +++ b/cmd/genCert.go @@ -0,0 +1,96 @@ +package cmd + +import ( + "log/slog" + + "github.com/hm-edu/harica/client" + "github.com/spf13/cobra" +) + +var ( + domains []string + csr string + transactionType string + requesterEmail string + requesterPassword string + requesterTOTPSeed string + validatorEmail string + validatorPassword string + validatorTOTPSeed string + debug bool +) + +// genCertCmd represents the genCert command +var genCertCmd = &cobra.Command{ + Use: "gen-cert", + Run: func(cmd *cobra.Command, args []string) { + + requester, err := client.NewClient(requesterEmail, requesterPassword, requesterTOTPSeed, client.WithDebug(debug)) + if err != nil { + slog.Error("failed to create requester client", slog.Any("error", err)) + return + } + validator, err := client.NewClient(validatorEmail, validatorPassword, validatorTOTPSeed, client.WithDebug(debug)) + if err != nil { + slog.Error("failed to create validator client", slog.Any("error", err)) + return + } + + d, err := requester.CheckDomainNames(domains) + if err != nil { + slog.Error("failed to check domain names", slog.Any("error", err)) + return + } + transaction, err := requester.RequestCertificate(d, csr, transactionType) + if err != nil { + slog.Error("failed to request certificate", slog.Any("error", err)) + return + } + + reviews, err := validator.GetPendingReviews() + if err != nil { + slog.Error("failed to get pending reviews", slog.Any("error", err)) + return + } + + for _, r := range reviews { + if r.TransactionID == transaction.TransactionID { + for _, s := range r.ReviewGetDTOs { + err = validator.ApproveRequest(s.ReviewID, "Auto Approval", s.ReviewValue) + if err != nil { + slog.Error("failed to approve request", slog.Any("error", err)) + return + } + } + } + } + cert, err := requester.GetCertificate(transaction.TransactionID) + if err != nil { + slog.Error("failed to get certificate", slog.Any("error", err)) + return + } + slog.Info("got certificate", slog.Any("certificate", cert)) + }, +} + +func init() { + rootCmd.AddCommand(genCertCmd) + genCertCmd.Flags().StringSliceVarP(&domains, "domains", "d", []string{}, "Domains to request certificate for") + genCertCmd.Flags().StringVar(&csr, "csr", "", "CSR to request certificate with") + genCertCmd.Flags().StringVarP(&transactionType, "transaction-type", "t", "DV", "Transaction type to request certificate with") + genCertCmd.Flags().StringVar(&requesterEmail, "requester-email", "", "Email of requester") + genCertCmd.Flags().StringVar(&requesterPassword, "requester-password", "", "Password of requester") + genCertCmd.Flags().StringVar(&requesterTOTPSeed, "requester-totp-seed", "", "TOTP seed of requester") + genCertCmd.Flags().StringVar(&validatorEmail, "validator-email", "", "Email of validator") + genCertCmd.Flags().StringVar(&validatorPassword, "validator-password", "", "Password of validator") + genCertCmd.Flags().StringVar(&validatorTOTPSeed, "validator-totp-seed", "", "TOTP seed of validator") + genCertCmd.Flags().BoolVar(&debug, "debug", false, "Enable debug logging") + genCertCmd.MarkFlagRequired("domains") + genCertCmd.MarkFlagRequired("csr") + genCertCmd.MarkFlagRequired("requester-email") + genCertCmd.MarkFlagRequired("requester-password") + genCertCmd.MarkFlagRequired("requester-totp-seed") + genCertCmd.MarkFlagRequired("validator-email") + genCertCmd.MarkFlagRequired("validator-password") + genCertCmd.MarkFlagRequired("validator-totp-seed") +} diff --git a/cmd/root.go b/cmd/root.go new file mode 100644 index 0000000..82f5a5a --- /dev/null +++ b/cmd/root.go @@ -0,0 +1,22 @@ +package cmd + +import ( + "os" + + "github.com/spf13/cobra" +) + +// rootCmd represents the base command when called without any subcommands +var rootCmd = &cobra.Command{ + Use: "harica", +} + +func Execute() { + err := rootCmd.Execute() + if err != nil { + os.Exit(1) + } +} + +func init() { +} diff --git a/go.mod b/go.mod index efb522a..46eb714 100644 --- a/go.mod +++ b/go.mod @@ -1,4 +1,4 @@ -module github.com/hm-edu/harica-client +module github.com/hm-edu/harica go 1.23.3 @@ -13,7 +13,10 @@ require ( require ( github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc // indirect github.com/google/uuid v1.6.0 // indirect + github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/jonboulle/clockwork v0.4.0 // indirect github.com/robfig/cron/v3 v3.0.1 // indirect + github.com/spf13/cobra v1.8.1 // indirect + github.com/spf13/pflag v1.0.5 // indirect golang.org/x/exp v0.0.0-20240613232115-7f521ea00fb8 // indirect ) diff --git a/go.sum b/go.sum index 1c31e46..3bdefae 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,6 @@ github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc h1:biVzkmvwrH8WK8raXaxBx6fRVTlJILwEwQGL1I/ByEI= github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8= +github.com/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= @@ -13,6 +14,8 @@ github.com/golang-jwt/jwt/v5 v5.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17w github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/jonboulle/clockwork v0.4.0 h1:p4Cf1aMWXnXAUh8lVfewRBx1zaTSYKrKMF2g3ST4RZ4= github.com/jonboulle/clockwork v0.4.0/go.mod h1:xgRqUGwRcjKCO1vbZUEtSLrqKoPSsUpK7fnezOII0kc= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= @@ -21,6 +24,11 @@ github.com/pquerna/otp v1.4.0 h1:wZvl1TIVxKRThZIBiwOOHOGP/1+nZyWBil9Y2XNEDzg= github.com/pquerna/otp v1.4.0/go.mod h1:dkJfzwRKNiegxyNb54X/3fLwhCynbMspSyWKnvi1AEg= github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs= github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/spf13/cobra v1.8.1 h1:e5/vxKd/rZsfSJMUX1agtjeTDf+qv1/JdBF8gg5k9ZM= +github.com/spf13/cobra v1.8.1/go.mod h1:wHxEcudfqmLYa8iTfL+OuZPbBZkmvliBWKIezN3kD9Y= +github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= +github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= @@ -35,5 +43,6 @@ golang.org/x/net v0.32.0 h1:ZqPmj8Kzc+Y6e0+skZsuACbx+wzMgo5MQsJh9Qd6aYI= golang.org/x/net v0.32.0/go.mod h1:CwU0IoeOlnQQWJ6ioyFrfRuomB8GKF6KbYXZVyeXNfs= golang.org/x/time v0.6.0 h1:eTDhh4ZXt5Qf0augr54TN6suAUudPcawVZeIAPU7D4U= golang.org/x/time v0.6.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/main.go b/main.go new file mode 100644 index 0000000..a6d2c77 --- /dev/null +++ b/main.go @@ -0,0 +1,7 @@ +package main + +import "github.com/hm-edu/harica/cmd" + +func main() { + cmd.Execute() +} diff --git a/models/certificate.go b/models/certificate.go new file mode 100644 index 0000000..7ef48e6 --- /dev/null +++ b/models/certificate.go @@ -0,0 +1,38 @@ +package models + +type CertificateResponse struct { + PKCS7 string `json:"pKCS7"` + Certificate string `json:"certificate"` + PemBundle string `json:"pemBundle"` + DN string `json:"dN"` + SANS string `json:"sANS"` + RevocationCode string `json:"revocationCode"` + Serial string `json:"serial"` + IsRevoked bool `json:"isRevoked"` + RevokedAt any `json:"revokedAt"` + ValidFrom string `json:"validFrom"` + ValidTo string `json:"validTo"` + IssuerDN string `json:"issuerDN"` + AuthorizationDomains string `json:"authorizationDomains"` + KeyType string `json:"keyType"` + FriendlyName any `json:"friendlyName"` + Approver any `json:"approver"` + ApproversAddress any `json:"approversAddress"` + TokenDeviceID any `json:"tokenDeviceId"` + Orders []Orders `json:"orders"` + NeedsImportWithFortify bool `json:"needsImportWithFortify"` + IsTokenCertificate bool `json:"isTokenCertificate"` + IssuerCertificate string `json:"issuerCertificate"` + TransactionID any `json:"transactionId"` +} +type Orders struct { + OrderID string `json:"orderId"` + IsChainedTransaction bool `json:"isChainedTransaction"` + IssuedAt string `json:"issuedAt"` + Duration int `json:"duration"` +} + +type CertificateRequestResponse struct { + TransactionID string `json:"id"` + RequiresConsentKey bool `json:"requiresConsentKey"` +}