From 70d45f0ea546b6e6318810c6c5f7949fd88e6b5f Mon Sep 17 00:00:00 2001 From: Florian Ritterhoff Date: Wed, 11 Dec 2024 21:01:16 +0100 Subject: [PATCH] Initial code for harica client --- .github/workflows/lint.yml | 26 ++++ client.go | 242 +++++++++++++++++++++++++++++++++++++ go.mod | 19 +++ go.sum | 35 ++++++ helper.go | 52 ++++++++ models/domain.go | 38 ++++++ models/review.go | 66 ++++++++++ 7 files changed, 478 insertions(+) create mode 100644 .github/workflows/lint.yml create mode 100644 client.go create mode 100644 go.mod create mode 100644 go.sum create mode 100644 helper.go create mode 100644 models/domain.go create mode 100644 models/review.go diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml new file mode 100644 index 0000000..70aa8e6 --- /dev/null +++ b/.github/workflows/lint.yml @@ -0,0 +1,26 @@ +# Perform linting of the code using golangci-lint +name: golangci-lint +on: + push: + branches: + - main + pull_request: + +permissions: + contents: read + # Optional: allow read access to pull request. Use with `only-new-issues` option. + # pull-requests: read + +jobs: + golangci: + name: lint + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: + go-version: stable + - name: golangci-lint + uses: golangci/golangci-lint-action@v6 + with: + version: latest \ No newline at end of file diff --git a/client.go b/client.go new file mode 100644 index 0000000..917d12b --- /dev/null +++ b/client.go @@ -0,0 +1,242 @@ +package main + +import ( + "fmt" + "net/http" + "strings" + "time" + + "log/slog" + + "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/pquerna/otp/totp" +) + +const ( + BaseURL = "https://cm.harica.gr" + LoginPath = "/api/User/Login" + LoginPathTotp = "/api/User/Login2FA" + RefreshInterval = 15 * time.Minute +) + +type Client struct { + client *resty.Client + scheduler gocron.Scheduler + currentToken string +} + +func NewClient(user, password, totpSeed string) (*Client, error) { + c := Client{} + 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) + })) + 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 (c *Client) prepareClient(user, password, totpSeed string) error { + renew := false + + if c.currentToken != "" { + // Check JWT + token, _, err := jwt.NewParser().ParseUnverified(c.currentToken, jwt.MapClaims{}) + if err != nil { + return err + } + exp, err := token.Claims.GetExpirationTime() + if err != nil { + return err + } + if exp.Before(time.Now()) || exp.Before(time.Now().Add(RefreshInterval)) { + renew = true + } + } + if c.client == nil || c.currentToken == "" || renew { + if totpSeed != "" { + return c.loginTotp(user, password, totpSeed) + } else { + return c.login(user, password) + } + } + return nil +} + +func (c *Client) loginTotp(user, password, totpSeed string) error { + r := resty.New() + verificationToken, err := getVerificationToken(r) + if err != nil { + return err + } + otp, err := totp.GenerateCode(totpSeed, time.Now()) + if err != nil { + return err + } + resp, err := r. + R().SetHeaderVerbatim("RequestVerificationToken", verificationToken). + SetHeader("Content-Type", "application/json"). + SetBody(map[string]string{"email": user, "password": password, "token": otp}). + Post(BaseURL + LoginPathTotp) + if err != nil { + // handle error + } + 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["RequestVerificationToken"] = []string{verificationToken} + return nil + }) + c.client = r + return nil +} + +func (c *Client) login(user, password string) error { + r := resty.New() + verificationToken, err := getVerificationToken(r) + if err != nil { + return err + } + resp, err := r. + R().SetHeaderVerbatim("RequestVerificationToken", verificationToken). + SetHeader("Content-Type", "application/json"). + SetBody(map[string]string{"email": user, "password": password}). + Post(BaseURL + LoginPath) + if err != nil { + return err + } + c.currentToken = strings.Trim(resp.String(), "\"") + 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 + }) + c.client = r + return nil +} + +func (c *Client) GetRevocationReasons() error { + resp, err := c.client.R().Post(BaseURL + "/api/Certificate/GetRevocationReasons") + 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") + if err != nil { + return err + } + // handle response + fmt.Print(resp.String()) + return nil +} + +type Domain struct { + Domain string `json:"domain"` +} + +func (c *Client) CheckMatchingOrganization(domains []string) ([]models.OrganizationResponse, error) { + domainDto := make([]Domain, 0) + 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") + if err != nil { + // handle error + return nil, err + } + // handle response + return response, 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") + if err != nil { + return nil, err + } + // handle response + return domainResp, nil +} + +func (c *Client) RequestCertificate() { + +} + +func (c *Client) RevokeCertificate() { + +} + +func (c *Client) GetPendingReviews() ([]models.ReviewResponse, error) { + 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{}}). + Post(BaseURL + "/api/OrganizationValidatorSSL/GetSSLReviewableTransactions") + if err != nil { + return nil, err + } + return pending, nil +} + +func (c *Client) ApproveRequest(id, message, value string) { + + c.client.R(). + SetDebug(true). + SetHeader("Content-Type", "multipart/form-data"). + SetBody(map[string]string{"reviewId": id, "isValid": "true", "informApplicant": "true", "reviewMessage": message, "reviewValue": value}). + Post(BaseURL + "/api/OrganizationValidatorSSL/UpdateReviews") + +} + +func (c *Client) Shutdown() { + err := c.scheduler.Shutdown() + if err != nil { + // handle error + } +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..cb3f7a6 --- /dev/null +++ b/go.mod @@ -0,0 +1,19 @@ +module github.com/hm-edu/harica-client + +go 1.23.3 + +require ( + github.com/go-co-op/gocron/v2 v2.12.4 + github.com/go-resty/resty/v2 v2.16.2 + github.com/golang-jwt/jwt/v5 v5.2.1 + github.com/pquerna/otp v1.4.0 + golang.org/x/net v0.27.0 +) + +require ( + github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/jonboulle/clockwork v0.4.0 // indirect + github.com/robfig/cron/v3 v3.0.1 // indirect + golang.org/x/exp v0.0.0-20240613232115-7f521ea00fb8 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..4601eaa --- /dev/null +++ b/go.sum @@ -0,0 +1,35 @@ +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/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= +github.com/go-co-op/gocron/v2 v2.12.4 h1:h1HWApo3T+61UrZqEY2qG1LUpDnB7tkYITxf6YIK354= +github.com/go-co-op/gocron/v2 v2.12.4/go.mod h1:xY7bJxGazKam1cz04EebrlP4S9q4iWdiAylMGP3jY9w= +github.com/go-resty/resty/v2 v2.16.2 h1:CpRqTjIzq/rweXUt9+GxzzQdlkqMdt8Lm/fuK/CAbAg= +github.com/go-resty/resty/v2 v2.16.2/go.mod h1:0fHAoK7JoBy/Ch36N8VFeMsK7xQOHhvWaC3iOktwmIU= +github.com/golang-jwt/jwt/v5 v5.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17wHk= +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/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= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +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/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= +github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= +golang.org/x/exp v0.0.0-20240613232115-7f521ea00fb8 h1:yixxcjnhBmY0nkL253HFVIm0JsFHwrHdT3Yh6szTnfY= +golang.org/x/exp v0.0.0-20240613232115-7f521ea00fb8/go.mod h1:jj3sYF3dwk5D+ghuXyeI3r5MFf+NT2An6/9dOA95KSI= +golang.org/x/net v0.27.0 h1:5K3Njcw06/l2y9vpGCSdcxWOYHOUk3dVNGDXN+FvAys= +golang.org/x/net v0.27.0/go.mod h1:dDi0PyhWNoiUOrAS8uXv/vnScO4wnHQO4mj9fn/RytE= +golang.org/x/time v0.6.0 h1:eTDhh4ZXt5Qf0augr54TN6suAUudPcawVZeIAPU7D4U= +golang.org/x/time v0.6.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= +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/helper.go b/helper.go new file mode 100644 index 0000000..7f79383 --- /dev/null +++ b/helper.go @@ -0,0 +1,52 @@ +package main + +import ( + "strings" + + "github.com/go-resty/resty/v2" + "golang.org/x/net/html" +) + +func getVerificationToken(r *resty.Client) (string, error) { + resp, err := r. + R(). + Get(BaseURL) + if err != nil { + return "", err + } + doc, err := html.Parse(strings.NewReader(resp.String())) + if err != nil { + return "", err + } + verificationToken := "" + var processHtml func(*html.Node) + processHtml = func(n *html.Node) { + if verificationToken != "" { + return + } + if n.Type == html.ElementNode && n.Data == "input" { + for _, a := range n.Attr { + if a.Key == "name" && a.Val == "__RequestVerificationToken" { + for _, a := range n.Attr { + if a.Key == "value" { + if verificationToken == "" { + verificationToken = a.Val + return + } + } + } + } + } + } + + for c := n.FirstChild; c != nil; c = c.NextSibling { + if verificationToken != "" { + return + } + processHtml(c) + } + } + + processHtml(doc) + return verificationToken, nil +} diff --git a/models/domain.go b/models/domain.go new file mode 100644 index 0000000..ec79ee8 --- /dev/null +++ b/models/domain.go @@ -0,0 +1,38 @@ +package models + +type DomainResponse struct { + Domain string `json:"domain"` + IsValid bool `json:"isValid"` + IncludeWWW bool `json:"includeWWW"` + ErrorMessage string `json:"errorMessage"` + WarningMessage string `json:"warningMessage"` + IsPrevalidated bool `json:"isPrevalidated"` + IsWildcard bool `json:"isWildcard"` + IsFreeDomain bool `json:"isFreeDomain"` + IsFreeDomainDV bool `json:"isFreeDomainDV"` + IsFreeDomainEV bool `json:"isFreeDomainEV"` + CanRequestOV bool `json:"canRequestOV"` + CanRequestEV bool `json:"canRequestEV"` +} + +type OrganizationResponse struct { + ID string `json:"id"` + OrganizationName string `json:"organizationName"` + OrganizationUnitName string `json:"organizationUnitName"` + State string `json:"state"` + Locality string `json:"locality"` + Country string `json:"country"` + Dn string `json:"dn"` + OrganizationNameLocalized string `json:"organizationNameLocalized"` + OrganizationUnitNameLocalized string `json:"organizationUnitNameLocalized"` + StateLocalized string `json:"stateLocalized"` + LocalityLocalized string `json:"localityLocalized"` + OrganizationIdentifier string `json:"organizationIdentifier"` + IsBaseDomain bool `json:"isBaseDomain"` + JurisdictionCountry string `json:"jurisdictionCountry"` + JurisdictionState string `json:"jurisdictionState"` + JurisdictionLocality string `json:"jurisdictionLocality"` + BusinessCategory string `json:"businessCategory"` + Serial string `json:"serial"` + GroupDomains any `json:"groupDomains"` +} diff --git a/models/review.go b/models/review.go new file mode 100644 index 0000000..208636b --- /dev/null +++ b/models/review.go @@ -0,0 +1,66 @@ +package models + +type ReviewRequest struct { + StartIndex int `json:"startIndex"` + Status string `json:"status"` + FilterPostDTOs []any `json:"filterPostDTOs"` +} +type ReviewResponse struct { + TransactionID string `json:"transactionId,omitempty"` + ChainedTransactionID any `json:"chainedTransactionId,omitempty"` + TransactionTypeName string `json:"transactionTypeName,omitempty"` + TransactionStatus string `json:"transactionStatus,omitempty"` + TransactionStatusMessage string `json:"transactionStatusMessage,omitempty"` + Notes any `json:"notes,omitempty"` + Organization string `json:"organization,omitempty"` + PurchaseDuration int `json:"purchaseDuration,omitempty"` + AdditionalEmails string `json:"additionalEmails,omitempty"` + UserEmail string `json:"userEmail,omitempty"` + User string `json:"user,omitempty"` + FriendlyName any `json:"friendlyName,omitempty"` + ReviewValue string `json:"reviewValue,omitempty"` + ReviewMessage string `json:"reviewMessage,omitempty"` + ReviewedBy any `json:"reviewedBy,omitempty"` + RequestedAt string `json:"requestedAt,omitempty"` + ReviewedAt any `json:"reviewedAt,omitempty"` + DN string `json:"dN,omitempty"` + HasReview bool `json:"hasReview,omitempty"` + CanRenew bool `json:"canRenew,omitempty"` + IsRevoked any `json:"isRevoked,omitempty"` + IsPaid any `json:"isPaid,omitempty"` + IsEidasValidated any `json:"isEidasValidated,omitempty"` + HasEidasValidation any `json:"hasEidasValidation,omitempty"` + IsHighRisk any `json:"isHighRisk,omitempty"` + IsShortTerm any `json:"isShortTerm,omitempty"` + IsExpired any `json:"isExpired,omitempty"` + IssuedAt string `json:"issuedAt,omitempty"` + CertificateValidTo any `json:"certificateValidTo,omitempty"` + Domains []Domains `json:"domains,omitempty"` + Validations any `json:"validations,omitempty"` + ChainedTransactions any `json:"chainedTransactions,omitempty"` + TokenType any `json:"tokenType,omitempty"` + CsrType any `json:"csrType,omitempty"` + AcceptanceRetrievalAt any `json:"acceptanceRetrievalAt,omitempty"` + ReviewGetDTOs []ReviewGetDTOs `json:"reviewGetDTOs,omitempty"` + UserDescription string `json:"userDescription,omitempty"` + UserOrganization string `json:"userOrganization,omitempty"` + TransactionType string `json:"transactionType,omitempty"` + IsPendingP12 any `json:"isPendingP12,omitempty"` +} + +type Domains struct { + Fqdn string `json:"fqdn,omitempty"` + IncludesWWW bool `json:"includesWWW,omitempty"` + Validations []any `json:"validations,omitempty"` +} + +type ReviewGetDTOs struct { + ReviewID string `json:"reviewId,omitempty"` + IsValidated bool `json:"isValidated,omitempty"` + IsReviewed bool `json:"isReviewed,omitempty"` + CreatedAt string `json:"createdAt,omitempty"` + UserUpdatedAt string `json:"userUpdatedAt,omitempty"` + ReviewedAt string `json:"reviewedAt,omitempty"` + ReviewValue string `json:"reviewValue,omitempty"` + ValidatorReviewGetDTOs []any `json:"validatorReviewGetDTOs,omitempty"` +}