├── main.go ├── renovate.json ├── dns ├── cfg.go ├── md5dns.go └── provider.go ├── cmd ├── root.go ├── genCertSmime.go ├── validation.go └── genCert.go ├── .github └── workflows │ ├── lint.yml │ └── release.yml ├── .gitignore ├── .goreleaser.yaml ├── client ├── helper.go └── client.go ├── go.mod ├── models ├── domain.go ├── organization.go ├── review.go └── certificate.go ├── README.md ├── imap └── validation.go ├── LICENSE └── go.sum /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import "github.com/hm-edu/harica/cmd" 4 | 5 | func main() { 6 | cmd.Execute() 7 | } 8 | -------------------------------------------------------------------------------- /renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://docs.renovatebot.com/renovate-schema.json", 3 | "extends": [ 4 | "config:recommended" 5 | ] 6 | } 7 | -------------------------------------------------------------------------------- /dns/cfg.go: -------------------------------------------------------------------------------- 1 | package dns 2 | 3 | type Provider struct { 4 | Zones []ProviderConfig `yaml:"zones"` 5 | } 6 | 7 | type ProviderConfig struct { 8 | BaseDomain string `yaml:"domain"` 9 | Nameserver string `yaml:"nameserver"` 10 | TsigKeyName string `yaml:"tsig_key_name"` 11 | TsigSecret string `yaml:"tsig_secret"` 12 | TsigSecretAlg string `yaml:"tsig_secret_alg"` 13 | Net string `yaml:"net"` 14 | } 15 | -------------------------------------------------------------------------------- /cmd/root.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "os" 5 | 6 | "github.com/spf13/cobra" 7 | ) 8 | 9 | var ( 10 | debug bool 11 | ) 12 | 13 | // rootCmd represents the base command when called without any subcommands 14 | var rootCmd = &cobra.Command{ 15 | Use: "harica", 16 | } 17 | 18 | func Execute() { 19 | err := rootCmd.Execute() 20 | if err != nil { 21 | os.Exit(1) 22 | } 23 | } 24 | 25 | func init() { 26 | rootCmd.PersistentFlags().BoolVarP(&debug, "debug", "d", false, "Enable debug mode") 27 | } 28 | -------------------------------------------------------------------------------- /.github/workflows/lint.yml: -------------------------------------------------------------------------------- 1 | # Perform linting of the code using golangci-lint 2 | name: golangci-lint 3 | on: 4 | push: 5 | branches: 6 | - main 7 | pull_request: 8 | 9 | permissions: 10 | contents: read 11 | # Optional: allow read access to pull request. Use with `only-new-issues` option. 12 | # pull-requests: read 13 | 14 | jobs: 15 | golangci: 16 | name: lint 17 | runs-on: ubuntu-latest 18 | steps: 19 | - uses: actions/checkout@v6 20 | - uses: actions/setup-go@v6 21 | with: 22 | go-version: stable 23 | - name: golangci-lint 24 | uses: golangci/golangci-lint-action@v9 25 | with: 26 | version: latest -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # If you prefer the allow list template instead of the deny list, see community template: 2 | # https://github.com/github/gitignore/blob/main/community/Golang/Go.AllowList.gitignore 3 | # 4 | # Binaries for programs and plugins 5 | *.exe 6 | *.exe~ 7 | *.dll 8 | *.so 9 | *.dylib 10 | 11 | # Test binary, built with `go test -c` 12 | *.test 13 | 14 | # Output of the go coverage tool, specifically when used with LiteIDE 15 | *.out 16 | 17 | # Dependency directories (remove the comment below to include it) 18 | # vendor/ 19 | 20 | # Go workspace file 21 | go.work 22 | go.work.sum 23 | 24 | # env file 25 | .env 26 | harica 27 | 28 | dist/ 29 | 30 | # Config files for local development 31 | dns_configs 32 | .vscode/launch.json 33 | -------------------------------------------------------------------------------- /.goreleaser.yaml: -------------------------------------------------------------------------------- 1 | version: 2 2 | 3 | before: 4 | hooks: 5 | # You may remove this if you don't use go modules. 6 | - go mod tidy 7 | 8 | builds: 9 | - env: 10 | - CGO_ENABLED=0 11 | goos: 12 | - linux 13 | - windows 14 | - darwin 15 | goarch: 16 | - amd64 17 | - arm64 18 | - arm 19 | binary: harica 20 | ignore: 21 | - goos: windows 22 | goarch: arm64 23 | - goos: windows 24 | goarch: arm 25 | 26 | archives: 27 | - format: tar.gz 28 | # this name template makes the OS and Arch compatible with the results of `uname`. 29 | name_template: >- 30 | {{ .ProjectName }}_ 31 | {{- title .Os }}_ 32 | {{- if eq .Arch "amd64" }}x86_64 33 | {{- else if eq .Arch "386" }}i386 34 | {{- else }}{{ .Arch }}{{ end }} 35 | {{- if .Arm }}v{{ .Arm }}{{ end }} 36 | # use zip for windows archives 37 | format_overrides: 38 | - goos: windows 39 | format: zip 40 | 41 | changelog: 42 | sort: asc 43 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Build Release Package 2 | 3 | on: 4 | pull_request: 5 | push: 6 | tags: 7 | - "*" 8 | 9 | jobs: 10 | goreleaser: 11 | runs-on: ubuntu-latest 12 | permissions: 13 | contents: write 14 | packages: write 15 | steps: 16 | - name: Checkout 17 | uses: actions/checkout@v6 18 | with: 19 | fetch-depth: 0 20 | 21 | - name: Set up Go 22 | uses: actions/setup-go@v6 23 | with: 24 | go-version: "1.25" 25 | 26 | - name: Run GoReleaser 27 | uses: goreleaser/goreleaser-action@v6 28 | if: startsWith(github.ref, 'refs/tags/') 29 | with: 30 | version: latest 31 | args: "release --clean" 32 | env: 33 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 34 | 35 | - name: Run GoReleaser 36 | uses: goreleaser/goreleaser-action@v6 37 | if: startsWith(github.ref, 'refs/pull/') 38 | with: 39 | version: latest 40 | args: "release --clean --snapshot" 41 | env: 42 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} -------------------------------------------------------------------------------- /dns/md5dns.go: -------------------------------------------------------------------------------- 1 | package dns 2 | 3 | import ( 4 | "crypto/hmac" 5 | // Required due to the use in the MWN 6 | "crypto/md5" //#nosec 7 | "encoding/base64" 8 | "encoding/hex" 9 | 10 | "github.com/miekg/dns" 11 | ) 12 | 13 | type Md5provider string 14 | 15 | func fromBase64(s []byte) (buf []byte, err error) { 16 | buflen := base64.StdEncoding.DecodedLen(len(s)) 17 | buf = make([]byte, buflen) 18 | n, err := base64.StdEncoding.Decode(buf, s) 19 | buf = buf[:n] 20 | return 21 | } 22 | 23 | func (key Md5provider) Generate(msg []byte, _ *dns.TSIG) ([]byte, error) { 24 | // If we barf here, the caller is to blame 25 | rawsecret, err := fromBase64([]byte(key)) 26 | if err != nil { 27 | return nil, err 28 | } 29 | h := hmac.New(md5.New, rawsecret) 30 | 31 | h.Write(msg) 32 | return h.Sum(nil), nil 33 | } 34 | 35 | func (key Md5provider) Verify(msg []byte, t *dns.TSIG) error { 36 | b, err := key.Generate(msg, t) 37 | if err != nil { 38 | return err 39 | } 40 | mac, err := hex.DecodeString(t.MAC) 41 | if err != nil { 42 | return err 43 | } 44 | if !hmac.Equal(b, mac) { 45 | return dns.ErrSig 46 | } 47 | return nil 48 | } 49 | -------------------------------------------------------------------------------- /client/helper.go: -------------------------------------------------------------------------------- 1 | package client 2 | 3 | import ( 4 | "strings" 5 | 6 | "github.com/go-resty/resty/v2" 7 | "golang.org/x/net/html" 8 | ) 9 | 10 | func getVerificationToken(r *resty.Client, BaseURL string) (string, error) { 11 | resp, err := r. 12 | R(). 13 | Get(BaseURL) 14 | if err != nil { 15 | return "", err 16 | } 17 | doc, err := html.Parse(strings.NewReader(resp.String())) 18 | if err != nil { 19 | return "", err 20 | } 21 | verificationToken := "" 22 | var processHtml func(*html.Node) 23 | processHtml = func(n *html.Node) { 24 | if verificationToken != "" { 25 | return 26 | } 27 | if n.Type == html.ElementNode && n.Data == "input" { 28 | for _, a := range n.Attr { 29 | if a.Key == "name" && a.Val == "__RequestVerificationToken" { 30 | for _, a := range n.Attr { 31 | if a.Key == "value" { 32 | if verificationToken == "" { 33 | verificationToken = a.Val 34 | return 35 | } 36 | } 37 | } 38 | } 39 | } 40 | } 41 | 42 | for c := n.FirstChild; c != nil; c = c.NextSibling { 43 | if verificationToken != "" { 44 | return 45 | } 46 | processHtml(c) 47 | } 48 | } 49 | 50 | processHtml(doc) 51 | return verificationToken, nil 52 | } 53 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/hm-edu/harica 2 | 3 | go 1.25.0 4 | 5 | require ( 6 | github.com/emersion/go-imap/v2 v2.0.0-beta.7 7 | github.com/emersion/go-message v0.18.2 8 | github.com/go-resty/resty/v2 v2.17.0 9 | github.com/golang-jwt/jwt/v5 v5.3.0 10 | github.com/miekg/dns v1.1.68 11 | github.com/pquerna/otp v1.5.0 12 | github.com/spf13/cobra v1.10.2 13 | github.com/spf13/pflag v1.0.10 14 | github.com/spf13/viper v1.21.0 15 | golang.org/x/net v0.47.0 16 | gopkg.in/yaml.v3 v3.0.1 17 | ) 18 | 19 | require ( 20 | github.com/boombuler/barcode v1.0.2 // indirect 21 | github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6 // indirect 22 | github.com/fsnotify/fsnotify v1.9.0 // indirect 23 | github.com/go-viper/mapstructure/v2 v2.4.0 // indirect 24 | github.com/inconshreveable/mousetrap v1.1.0 // indirect 25 | github.com/pelletier/go-toml/v2 v2.2.4 // indirect 26 | github.com/sagikazarmark/locafero v0.11.0 // indirect 27 | github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect 28 | github.com/spf13/afero v1.15.0 // indirect 29 | github.com/spf13/cast v1.10.0 // indirect 30 | github.com/subosito/gotenv v1.6.0 // indirect 31 | go.uber.org/multierr v1.11.0 // indirect 32 | go.yaml.in/yaml/v3 v3.0.4 // indirect 33 | golang.org/x/mod v0.29.0 // indirect 34 | golang.org/x/sync v0.18.0 // indirect 35 | golang.org/x/sys v0.38.0 // indirect 36 | golang.org/x/text v0.31.0 // indirect 37 | golang.org/x/tools v0.38.0 // indirect 38 | gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect 39 | ) 40 | -------------------------------------------------------------------------------- /models/domain.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | type DomainResponse struct { 4 | Domain string `json:"domain"` 5 | IsValid bool `json:"isValid"` 6 | IncludeWWW bool `json:"includeWWW"` 7 | ErrorMessage string `json:"errorMessage"` 8 | WarningMessage string `json:"warningMessage"` 9 | IsPrevalidated bool `json:"isPrevalidated"` 10 | IsWildcard bool `json:"isWildcard"` 11 | IsFreeDomain bool `json:"isFreeDomain"` 12 | IsFreeDomainDV bool `json:"isFreeDomainDV"` 13 | IsFreeDomainEV bool `json:"isFreeDomainEV"` 14 | CanRequestOV bool `json:"canRequestOV"` 15 | CanRequestEV bool `json:"canRequestEV"` 16 | } 17 | 18 | type OrganizationResponse struct { 19 | ID string `json:"id"` 20 | OrganizationName string `json:"organizationName"` 21 | OrganizationUnitName string `json:"organizationUnitName"` 22 | State string `json:"state"` 23 | Locality string `json:"locality"` 24 | Country string `json:"country"` 25 | Dn string `json:"dn"` 26 | OrganizationNameLocalized string `json:"organizationNameLocalized"` 27 | OrganizationUnitNameLocalized string `json:"organizationUnitNameLocalized"` 28 | StateLocalized string `json:"stateLocalized"` 29 | LocalityLocalized string `json:"localityLocalized"` 30 | OrganizationIdentifier string `json:"organizationIdentifier"` 31 | IsBaseDomain bool `json:"isBaseDomain"` 32 | JurisdictionCountry string `json:"jurisdictionCountry"` 33 | JurisdictionState string `json:"jurisdictionState"` 34 | JurisdictionLocality string `json:"jurisdictionLocality"` 35 | BusinessCategory string `json:"businessCategory"` 36 | Serial string `json:"serial"` 37 | GroupDomains any `json:"groupDomains"` 38 | } 39 | -------------------------------------------------------------------------------- /models/organization.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | type Organization struct { 4 | OrganizationID string `json:"organizationId"` 5 | Domain string `json:"domain"` 6 | Organization string `json:"organization"` 7 | OrganizationLocalized string `json:"organizationLocalized"` 8 | Country string `json:"country"` 9 | State string `json:"state"` 10 | StateLocalized string `json:"stateLocalized"` 11 | Locality string `json:"locality"` 12 | LocalityLocalized string `json:"localityLocalized"` 13 | OrganizationalUnit string `json:"organizationalUnit"` 14 | OrganizationalUnitLocalized any `json:"organizationalUnitLocalized"` 15 | Dn string `json:"dn"` 16 | Validity string `json:"validity"` 17 | GroupID string `json:"groupId"` 18 | ProductListID string `json:"productListId"` 19 | IsBaseDomain bool `json:"isBaseDomain"` 20 | IsRemoteSignatureEnabled bool `json:"isRemoteSignatureEnabled"` 21 | DSAAccounts int `json:"dSAAccounts"` 22 | MaxDSAAccounts int `json:"maxDSAAccounts"` 23 | HistoryOrganizationHierarchyGetDTOs any `json:"historyOrganizationHierarchyGetDTOs"` 24 | SubUnits []any `json:"subUnits"` 25 | DisabledAt string `json:"disabledAt"` 26 | OrganizationIdentifier any `json:"organizationIdentifier"` 27 | ValidityOV string `json:"validityOV"` 28 | ValidityEV string `json:"validityEV"` 29 | DetailsHistory string `json:"detailsHistory"` 30 | JurisdictionCountry any `json:"jurisdictionCountry"` 31 | JurisdictionState any `json:"jurisdictionState"` 32 | JurisdictionLocality any `json:"jurisdictionLocality"` 33 | BusinessCategory any `json:"businessCategory"` 34 | Serial any `json:"serial"` 35 | GroupDomains string `json:"groupDomains"` 36 | GroupName string `json:"groupName"` 37 | CustomTags any `json:"customTags"` 38 | } 39 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Inofficial Client for the HARICA API 2 | 3 | ## Generate Cert with Auto Approval 4 | ```sh 5 | ./harica gen-cert \ 6 | --domains "fancy.domain" \ 7 | --requester-email "requester@fancy.domain" \ 8 | --requester-password "password" \ 9 | --requester-totp-seed "totp-seed" \ 10 | --validator-email "validator@fancy.domain" \ 11 | --validator-password "password" \ 12 | --validator-totp-seed "totp-seed" \ 13 | --csr "-----BEGIN CERTIFICATE REQUEST-----\nfoo-bar\n-----END CERTIFICATE REQUEST-----" 14 | ``` 15 | 16 | Beside using arguments you can also create a config file `cert-generator.yaml`: 17 | 18 | ```yaml 19 | requester_email: "" 20 | validator_email: "" 21 | validator_totp_seed: "" 22 | requester_totp_seed: "" 23 | validator_password: "" 24 | requester_password: "" 25 | ``` 26 | 27 | 28 | ## Automatic Domain Validation using AXFR 29 | 30 | In case you want to (re)validate several domains using DNS Challenges, you may use this module. To use this module, you must have a DNS server/provider that supports standard AXFR Updates to your zones. Right now, we consider all domains to be revalidated that expire in the next 30 days. Domains with a validity of more than 30 days get ignored by the tool. 31 | 32 | ### DNS Configuration 33 | 34 | Please create a new YAML file with the following structure. 35 | 36 | ```yaml 37 | zones: 38 | - domain: "domain.de." 39 | nameserver: "dns-server:53" 40 | tsig_key_name: "hm.edu." 41 | tsig_secret: "tsig_key" 42 | tsig_secret_alg: "hmac-md5.sig-alg.reg.int." 43 | net: "tcp" 44 | - domain: "domain.eu." 45 | nameserver: "dns-server:53" 46 | tsig_key_name: "tsig_key_name." 47 | tsig_secret: "tsig_key" 48 | tsig_secret_alg: "hmac-md5.sig-alg.reg.int." 49 | net: "tcp" 50 | ``` 51 | 52 | Alternative Algorithms are: 53 | 54 | - `hmac-sha1.` 55 | - `hmac-sha224.` 56 | - `hmac-sha256.` 57 | - `hmac-sha384.` 58 | - `hmac-sha512.` 59 | - `hmac-md5.sig-alg.reg.int.` 60 | 61 | 62 | ### Usage 63 | 64 | Afterwards you can trigger the validation flow: 65 | 66 | ```sh 67 | ./harica validation \ 68 | -u "harica-user" \ 69 | -p "harica-password" \ 70 | -t "harica-totp" \ 71 | --imap-host "imap.server.com" \ 72 | --imap-username "fancy-user" \ 73 | --imap-password "fancy-password" \ 74 | --domains "domain.de,domain.eu" \ 75 | --email "fancy-user@server.com" \ 76 | --dns "./path/to/dns-config" 77 | ``` 78 | 79 | 80 | > [!WARNING] 81 | > Please note that we do not recommend validating large batches at once since the code is not that reslient for failures or timeouts. Try to keep the batches smaller than 10 domains and start more batches sequentially. -------------------------------------------------------------------------------- /dns/provider.go: -------------------------------------------------------------------------------- 1 | package dns 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "strings" 7 | "time" 8 | 9 | "github.com/miekg/dns" 10 | "gopkg.in/yaml.v3" 11 | ) 12 | 13 | type DNSProvider struct { 14 | Configs []ProviderConfig 15 | } 16 | 17 | const ( 18 | // maximum time DNS client can be off from server for an update to succeed 19 | clockSkew = 300 20 | 21 | // maximum size of a UDP transport message in DNS protocol 22 | udpMaxMsgSize = 512 23 | ) 24 | 25 | func (d *DNSProvider) LookupTxt(domain string) ([]string, error) { 26 | server := "8.8.8.8" 27 | c := dns.Client{} 28 | m := dns.Msg{} 29 | m.SetQuestion(dns.Fqdn(domain), dns.TypeTXT) 30 | r, _, err := c.Exchange(&m, server+":53") 31 | if err != nil { 32 | return nil, err 33 | } 34 | if len(r.Answer) == 0 { 35 | return []string{}, nil 36 | } 37 | 38 | var txts []string 39 | 40 | for _, ans := range r.Answer { 41 | switch v := ans.(type) { 42 | case *dns.TXT: 43 | txts = append(txts, strings.Join(v.Txt, "")) 44 | } 45 | } 46 | return txts, nil 47 | } 48 | 49 | func NewDNSProvider(config string) (*DNSProvider, error) { 50 | data, err := os.ReadFile(config) 51 | if err != nil { 52 | fmt.Println("Error reading config file: ", err) 53 | return nil, err 54 | } 55 | 56 | providerConfigs := Provider{} 57 | err = yaml.Unmarshal(data, &providerConfigs) 58 | 59 | if err != nil { 60 | fmt.Println("Error unmarshalling config file: ", err) 61 | return nil, err 62 | } 63 | 64 | return &DNSProvider{ 65 | Configs: providerConfigs.Zones, 66 | }, nil 67 | } 68 | 69 | func (r *DNSProvider) Configured(domain string) bool { 70 | _, err := r.matchingProvider(domain) 71 | return err == nil 72 | } 73 | 74 | // Add adds the given records to the zone. 75 | func (r *DNSProvider) Add(domain string, entries []dns.RR) error { 76 | m := new(dns.Msg) 77 | m.SetUpdate(dns.Fqdn(domain)) 78 | m.Insert(entries) 79 | return r.sendMessage(domain, m) 80 | 81 | } 82 | 83 | // Delete removes the given records from the zone. 84 | func (r *DNSProvider) Delete(domain string, entries []dns.RR) error { 85 | m := new(dns.Msg) 86 | m.SetUpdate(dns.Fqdn(domain)) 87 | m.Remove(entries) 88 | return r.sendMessage(domain, m) 89 | } 90 | 91 | func (r *DNSProvider) matchingProvider(domain string) (*ProviderConfig, error) { 92 | 93 | var matchingConfig *ProviderConfig 94 | matchingConfig = nil 95 | for _, config := range r.Configs { 96 | 97 | if config.BaseDomain == "" { 98 | matchingConfig = &config 99 | continue 100 | } 101 | 102 | if strings.HasSuffix(dns.Fqdn(domain), fmt.Sprintf(".%s", dns.Fqdn(config.BaseDomain))) || dns.Fqdn(domain) == dns.Fqdn(config.BaseDomain) { 103 | if matchingConfig == nil { 104 | matchingConfig = &config 105 | continue 106 | } 107 | 108 | if len(strings.Split(config.BaseDomain, ".")) > len(strings.Split(matchingConfig.BaseDomain, ".")) { 109 | matchingConfig = &config 110 | } 111 | } 112 | } 113 | 114 | if matchingConfig == nil { 115 | return nil, fmt.Errorf("no matching DNS provider found for domain %s", domain) 116 | } 117 | return matchingConfig, nil 118 | } 119 | 120 | func (r *DNSProvider) sendMessage(domain string, msg *dns.Msg) error { 121 | 122 | c := new(dns.Client) 123 | 124 | cfg, err := r.matchingProvider(domain) 125 | if err != nil { 126 | return err 127 | } 128 | c.TsigSecret = map[string]string{cfg.TsigKeyName: cfg.TsigSecret} 129 | msg.SetTsig(cfg.TsigKeyName, cfg.TsigSecretAlg, clockSkew, time.Now().Unix()) 130 | if cfg.TsigSecretAlg == dns.HmacMD5 { 131 | c.TsigProvider = Md5provider(cfg.TsigSecret) 132 | } 133 | if msg.Len() > udpMaxMsgSize || cfg.Net == "tcp" { 134 | c.Net = "tcp" 135 | } 136 | resp, _, err := c.Exchange(msg, cfg.Nameserver) 137 | if err != nil { 138 | return err 139 | } 140 | if resp == nil { 141 | return fmt.Errorf("no response received") 142 | } 143 | if resp.Rcode != dns.RcodeSuccess { 144 | return fmt.Errorf("bad return code: %s", dns.RcodeToString[resp.Rcode]) 145 | } 146 | return nil 147 | } 148 | -------------------------------------------------------------------------------- /models/review.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | type ReviewRequest struct { 4 | StartIndex int `json:"startIndex"` 5 | Status string `json:"status"` 6 | FilterPostDTOs []any `json:"filterPostDTOs"` 7 | } 8 | 9 | type ReviewResponse struct { 10 | TransactionID string `json:"transactionId,omitempty"` 11 | ChainedTransactionID any `json:"chainedTransactionId,omitempty"` 12 | TransactionTypeName string `json:"transactionTypeName,omitempty"` 13 | TransactionStatus string `json:"transactionStatus,omitempty"` 14 | TransactionStatusMessage string `json:"transactionStatusMessage,omitempty"` 15 | Notes any `json:"notes,omitempty"` 16 | Organization string `json:"organization,omitempty"` 17 | PurchaseDuration int `json:"purchaseDuration,omitempty"` 18 | AdditionalEmails string `json:"additionalEmails,omitempty"` 19 | UserEmail string `json:"userEmail,omitempty"` 20 | User string `json:"user,omitempty"` 21 | FriendlyName any `json:"friendlyName,omitempty"` 22 | ReviewValue string `json:"reviewValue,omitempty"` 23 | ReviewMessage string `json:"reviewMessage,omitempty"` 24 | ReviewedBy any `json:"reviewedBy,omitempty"` 25 | RequestedAt string `json:"requestedAt,omitempty"` 26 | ReviewedAt any `json:"reviewedAt,omitempty"` 27 | DN string `json:"dN,omitempty"` 28 | HasReview bool `json:"hasReview,omitempty"` 29 | CanRenew bool `json:"canRenew,omitempty"` 30 | IsRevoked any `json:"isRevoked,omitempty"` 31 | IsPaid any `json:"isPaid,omitempty"` 32 | IsEidasValidated any `json:"isEidasValidated,omitempty"` 33 | HasEidasValidation any `json:"hasEidasValidation,omitempty"` 34 | IsHighRisk any `json:"isHighRisk,omitempty"` 35 | IsShortTerm any `json:"isShortTerm,omitempty"` 36 | IsExpired any `json:"isExpired,omitempty"` 37 | IssuedAt string `json:"issuedAt,omitempty"` 38 | CertificateValidTo any `json:"certificateValidTo,omitempty"` 39 | Domains []Domains `json:"domains,omitempty"` 40 | Validations any `json:"validations,omitempty"` 41 | ChainedTransactions any `json:"chainedTransactions,omitempty"` 42 | TokenType any `json:"tokenType,omitempty"` 43 | CsrType any `json:"csrType,omitempty"` 44 | AcceptanceRetrievalAt any `json:"acceptanceRetrievalAt,omitempty"` 45 | ReviewGetDTOs []ReviewGetDTOs `json:"reviewGetDTOs,omitempty"` 46 | UserDescription string `json:"userDescription,omitempty"` 47 | UserOrganization string `json:"userOrganization,omitempty"` 48 | TransactionType string `json:"transactionType,omitempty"` 49 | IsPendingP12 any `json:"isPendingP12,omitempty"` 50 | } 51 | 52 | type Domains struct { 53 | Fqdn string `json:"fqdn,omitempty"` 54 | IncludesWWW bool `json:"includesWWW,omitempty"` 55 | Validations []any `json:"validations,omitempty"` 56 | } 57 | 58 | type ReviewGetDTOs struct { 59 | ReviewID string `json:"reviewId,omitempty"` 60 | IsValidated bool `json:"isValidated,omitempty"` 61 | IsReviewed bool `json:"isReviewed,omitempty"` 62 | CreatedAt string `json:"createdAt,omitempty"` 63 | UserUpdatedAt string `json:"userUpdatedAt,omitempty"` 64 | ReviewedAt string `json:"reviewedAt,omitempty"` 65 | ReviewValue string `json:"reviewValue,omitempty"` 66 | ValidatorReviewGetDTOs []any `json:"validatorReviewGetDTOs,omitempty"` 67 | } 68 | -------------------------------------------------------------------------------- /imap/validation.go: -------------------------------------------------------------------------------- 1 | package imap 2 | 3 | import ( 4 | "bytes" 5 | "fmt" 6 | "io" 7 | "log/slog" 8 | "os" 9 | "regexp" 10 | "slices" 11 | "strings" 12 | "time" 13 | 14 | "github.com/emersion/go-imap/v2" 15 | "github.com/emersion/go-imap/v2/imapclient" 16 | "github.com/emersion/go-message/mail" 17 | ) 18 | 19 | type ValidationCode struct { 20 | Code string 21 | mailDate time.Time 22 | } 23 | 24 | func FetchValidationCodes(imapHost, imapUsername, imapPassword string, imapPort int, validationStart time.Time, domains []string, debug bool, graceDelta int) (map[string]ValidationCode, error) { 25 | // Fetch validation codes from IMAP server 26 | // Create IMAP client 27 | options := imapclient.Options{} 28 | if debug { 29 | options.DebugWriter = os.Stderr 30 | } 31 | imapClient, err := imapclient.DialTLS(fmt.Sprintf("%s:%v", imapHost, imapPort), &options) 32 | if err != nil { 33 | return nil, err 34 | } 35 | imapClient.Login(imapUsername, imapPassword) 36 | defer imapClient.Logout() 37 | 38 | // Select INBOX 39 | _, err = imapClient.Select("INBOX", nil).Wait() 40 | if err != nil { 41 | return nil, err 42 | } 43 | 44 | validationCodes := make(map[string]ValidationCode) 45 | validationRegex := regexp.MustCompile(`(.*) IN TXT "(.*?)"`) 46 | 47 | for { 48 | mails, err := imapClient.Search(&imap.SearchCriteria{ 49 | Header: []imap.SearchCriteriaHeaderField{{Key: "Subject", Value: "HARICA - DNS Change Validation"}}, 50 | }, nil).Wait() 51 | if err != nil { 52 | return nil, err 53 | } 54 | if len(mails.AllSeqNums()) == 0 { 55 | slog.Info("No emails found, waiting for emails") 56 | time.Sleep(5 * time.Second) 57 | continue 58 | } 59 | fetchOptions := &imap.FetchOptions{ 60 | Flags: true, 61 | Envelope: true, 62 | BodySection: []*imap.FetchItemBodySection{{}}, 63 | } 64 | msg, err := imapClient.Fetch(mails.All, fetchOptions).Collect() 65 | if err != nil { 66 | return nil, err 67 | } 68 | 69 | for _, m := range msg { 70 | if m.Envelope.Date.Before(validationStart) && m.Envelope.Date != validationStart { 71 | delta := validationStart.Sub(m.Envelope.Date) 72 | deltaSeconds := delta.Abs().Seconds() 73 | if deltaSeconds > float64(graceDelta) { 74 | slog.Info("Ignoring email", slog.Time("date", m.Envelope.Date), slog.Time("validationStart", validationStart), slog.Float64("deltaSeconds", deltaSeconds), slog.Int("graceDelta", graceDelta)) 75 | continue 76 | } 77 | } 78 | for _, p := range m.BodySection { 79 | body, err := mail.CreateReader(bytes.NewReader(p.Bytes)) 80 | if err != nil { 81 | return nil, err 82 | } 83 | for { 84 | part, err := body.NextPart() 85 | if err != nil { 86 | break 87 | } 88 | switch part.Header.(type) { 89 | case *mail.InlineHeader: 90 | b, _ := io.ReadAll(part.Body) 91 | body := string(b) 92 | // Extract the validation code from the email body 93 | for _, line := range strings.Split(body, "\n") { 94 | line = strings.TrimSpace(line) 95 | if strings.Contains(line, "IN TXT") { 96 | if len(validationRegex.FindStringSubmatch(line)) > 0 { 97 | matches := validationRegex.FindStringSubmatch(line) 98 | domain := strings.TrimSpace(matches[1]) 99 | domain = strings.Trim(domain, ".") 100 | if !slices.Contains(domains, domain) && len(domains) != 0 { 101 | continue 102 | } 103 | if x, ok := validationCodes[domain]; !ok { 104 | validationCodes[domain] = ValidationCode{ 105 | Code: matches[2], 106 | mailDate: m.Envelope.Date, 107 | } 108 | } else if x.mailDate.Before(m.Envelope.Date) { 109 | validationCodes[domain] = ValidationCode{ 110 | Code: matches[2], 111 | mailDate: m.Envelope.Date, 112 | } 113 | } 114 | } 115 | } 116 | } 117 | } 118 | } 119 | } 120 | } 121 | if len(validationCodes) == len(domains) { 122 | break 123 | } else { 124 | time.Sleep(5 * time.Second) 125 | } 126 | } 127 | return validationCodes, nil 128 | } 129 | -------------------------------------------------------------------------------- /models/certificate.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | type CertificateResponse struct { 4 | PKCS7 string `json:"pKCS7"` 5 | Certificate string `json:"certificate"` 6 | PemBundle string `json:"pemBundle"` 7 | DN string `json:"dN"` 8 | SANS string `json:"sANS"` 9 | RevocationCode string `json:"revocationCode"` 10 | Serial string `json:"serial"` 11 | IsRevoked bool `json:"isRevoked"` 12 | RevokedAt any `json:"revokedAt"` 13 | ValidFrom string `json:"validFrom"` 14 | ValidTo string `json:"validTo"` 15 | IssuerDN string `json:"issuerDN"` 16 | AuthorizationDomains string `json:"authorizationDomains"` 17 | KeyType string `json:"keyType"` 18 | FriendlyName any `json:"friendlyName"` 19 | Approver any `json:"approver"` 20 | ApproversAddress any `json:"approversAddress"` 21 | TokenDeviceID any `json:"tokenDeviceId"` 22 | Orders []Orders `json:"orders"` 23 | NeedsImportWithFortify bool `json:"needsImportWithFortify"` 24 | IsTokenCertificate bool `json:"isTokenCertificate"` 25 | IssuerCertificate string `json:"issuerCertificate"` 26 | TransactionID any `json:"transactionId"` 27 | } 28 | type Orders struct { 29 | OrderID string `json:"orderId"` 30 | IsChainedTransaction bool `json:"isChainedTransaction"` 31 | IssuedAt string `json:"issuedAt"` 32 | Duration int `json:"duration"` 33 | } 34 | 35 | type CertificateRequestResponse struct { 36 | TransactionID string `json:"id"` 37 | RequiresConsentKey bool `json:"requiresConsentKey"` 38 | } 39 | 40 | type RevocationReasonsResponse struct { 41 | Name string `json:"name"` 42 | IsClient bool `json:"isClient"` 43 | RevocationMesasge string `json:"revocationMessage"` 44 | RevocationMessageLocalized string `json:"revocationMessageLocalised"` 45 | } 46 | 47 | type TransactionResponse struct { 48 | TransactionID string `json:"transactionId"` 49 | ChainedTransactionID any `json:"chainedTransactionId"` 50 | TransactionTypeName string `json:"transactionTypeName"` 51 | TransactionStatus string `json:"transactionStatus"` 52 | TransactionStatusMessage string `json:"transactionStatusMessage"` 53 | Notes any `json:"notes"` 54 | Organization any `json:"organization"` 55 | PurchaseDuration int `json:"purchaseDuration"` 56 | AdditionalEmails string `json:"additionalEmails"` 57 | UserEmail string `json:"userEmail"` 58 | User any `json:"user"` 59 | FriendlyName any `json:"friendlyName"` 60 | ReviewValue any `json:"reviewValue"` 61 | ReviewMessage any `json:"reviewMessage"` 62 | ReviewedBy any `json:"reviewedBy"` 63 | RequestedAt string `json:"requestedAt"` 64 | ReviewedAt any `json:"reviewedAt"` 65 | DN any `json:"dN"` 66 | HasReview bool `json:"hasReview"` 67 | CanRenew bool `json:"canRenew"` 68 | IsRevoked any `json:"isRevoked"` 69 | IsPaid bool `json:"isPaid"` 70 | IsEidasValidated any `json:"isEidasValidated"` 71 | HasEidasValidation any `json:"hasEidasValidation"` 72 | IsHighRisk bool `json:"isHighRisk"` 73 | IsShortTerm any `json:"isShortTerm"` 74 | IsExpired bool `json:"isExpired"` 75 | IssuedAt any `json:"issuedAt"` 76 | CertificateValidTo any `json:"certificateValidTo"` 77 | Domains []struct { 78 | Fqdn string `json:"fqdn"` 79 | IncludesWWW bool `json:"includesWWW"` 80 | Validations []any `json:"validations"` 81 | } `json:"domains"` 82 | Validations any `json:"validations"` 83 | ChainedTransactions []any `json:"chainedTransactions"` 84 | TokenType any `json:"tokenType"` 85 | CsrType any `json:"csrType"` 86 | AcceptanceRetrievalAt any `json:"acceptanceRetrievalAt"` 87 | ReviewGetDTOs any `json:"reviewGetDTOs"` 88 | UserDescription any `json:"userDescription"` 89 | UserOrganization any `json:"userOrganization"` 90 | TransactionType any `json:"transactionType"` 91 | IsPendingP12 any `json:"isPendingP12"` 92 | } 93 | 94 | type SmimeBulkRequest struct { 95 | FriendlyName string 96 | Email string 97 | Email2 string 98 | Email3 string 99 | GivenName string 100 | Surname string 101 | PickupPassword string 102 | CertType string 103 | CSR string 104 | } 105 | 106 | type SmimeBulkResponse struct { 107 | TransactionID string `json:"id"` 108 | Certificate string `json:"certificate"` 109 | Pkcs7 string `json:"pkcs7"` 110 | } 111 | 112 | type BulkCertificateListEntry struct { 113 | ID string `json:"id"` 114 | EntityID string `json:"entityId"` 115 | CreatedAt string `json:"createdAt"` 116 | CustomTags string `json:"customTags"` 117 | UserEmail string `json:"userEmail"` 118 | } 119 | 120 | type BulkCertificateEntry struct { 121 | ID string `json:"id"` 122 | BulkCertificatesEntryID string `json:"bulkCertificatesEntryId"` 123 | StatusName string `json:"statusName"` 124 | FriendlyName string `json:"friendlyName"` 125 | Duration int `json:"duration"` 126 | CustomTags any `json:"customTags"` 127 | RevocationMessage string `json:"revocationMessage"` 128 | Pkcs12 any `json:"pkcs12"` 129 | IssuedAt string `json:"issuedAt"` 130 | ValidFrom string `json:"validFrom"` 131 | ValidTo string `json:"validTo"` 132 | RevocationCode string `json:"revocationCode"` 133 | IsRevoked bool `json:"isRevoked"` 134 | RevokedAt any `json:"revokedAt"` 135 | CommonName string `json:"commonName"` 136 | OrganizationUnit1 string `json:"organizationUnit1"` 137 | OrganizationUnit2 string `json:"organizationUnit2"` 138 | OrganizationUnit3 string `json:"organizationUnit3"` 139 | KeySpec string `json:"keySpec"` 140 | KeyAlg string `json:"keyAlg"` 141 | Serial string `json:"serial"` 142 | Certificate string `json:"certificate"` 143 | Notes any `json:"notes"` 144 | RevokedByEmail any `json:"revokedByEmail"` 145 | Pkcs7 string `json:"pkcs7"` 146 | } 147 | -------------------------------------------------------------------------------- /cmd/genCertSmime.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "fmt" 5 | "log/slog" 6 | "os" 7 | "strings" 8 | 9 | "github.com/hm-edu/harica/client" 10 | "github.com/hm-edu/harica/models" 11 | "github.com/spf13/cobra" 12 | "github.com/spf13/pflag" 13 | "github.com/spf13/viper" 14 | ) 15 | 16 | type GenCertSmimeConfig struct { 17 | Csr string `mapstructure:"csr"` 18 | CertType string `mapstructure:"cert_type"` 19 | RequesterEmail string `mapstructure:"requester_email"` 20 | RequesterPassword string `mapstructure:"requester_password"` 21 | RequesterTOTPSeed string `mapstructure:"requester_totp_seed"` 22 | //SMIME 23 | Email string `mapstructure:"email"` 24 | FriendlyName string `mapstructure:"friendly_name"` 25 | GivenName string `mapstructure:"given_name"` 26 | SurName string `mapstructure:"sur_name"` 27 | } 28 | 29 | var ( 30 | genCertSmimeConfig GenCertSmimeConfig 31 | configPathSmime string 32 | keyMappingSmime = map[string]string{ 33 | "csr": "csr", 34 | "cert-type": "cert_type", 35 | "requester-email": "requester_email", 36 | "requester-password": "requester_password", 37 | "requester-totp-seed": "requester_totp_seed", 38 | "email": "email", 39 | "friendly-name": "friendly_name", 40 | "given-name": "given_name", 41 | "sur-name": "sur_name", 42 | } 43 | ) 44 | 45 | // genCertCmd represents the genCert command 46 | var genCertSmimeCmd = &cobra.Command{ 47 | Use: "smime", 48 | PreRun: func(cmd *cobra.Command, args []string) { 49 | viper.SetConfigType("yaml") 50 | viper.SetConfigName("cert-generator") 51 | viper.AddConfigPath("/etc/harica/") // path to look for the config file in 52 | viper.AddConfigPath("$HOME/harica/") // call multiple times to add many search paths 53 | viper.AddConfigPath("/opt/harica/") 54 | viper.AddConfigPath(".") // optionally look for config in the working directory 55 | if configPathSmime != "" { 56 | viper.SetConfigFile(configPathSmime) 57 | } 58 | 59 | for k, v := range keyMappingSmime { 60 | err := viper.BindPFlag(v, cmd.Flags().Lookup(k)) 61 | if err != nil { 62 | slog.Error("Failed to bind flag", slog.Any("error", err)) 63 | os.Exit(1) 64 | } 65 | } 66 | 67 | if err := viper.ReadInConfig(); err != nil { 68 | if _, ok := err.(viper.ConfigFileNotFoundError); ok { 69 | slog.Info("No configuration file found") 70 | } else { 71 | slog.Error("Error reading config file", slog.Any("error", err)) 72 | os.Exit(1) 73 | } 74 | } else { 75 | slog.Info("Using config file:", slog.Any("config", viper.ConfigFileUsed())) 76 | } 77 | 78 | // Unmarshal the config into a struct. 79 | err := viper.Unmarshal(&genCertSmimeConfig) 80 | if err != nil { 81 | slog.Error("Error reading config file", slog.Any("error", err)) 82 | os.Exit(1) 83 | } 84 | 85 | cmd.Flags().VisitAll(func(f *pflag.Flag) { 86 | if !f.Changed && viper.IsSet(f.Name) { 87 | val := viper.Get(f.Name) 88 | err = cmd.Flags().Set(f.Name, fmt.Sprintf("%v", val)) 89 | if err != nil { 90 | slog.Error("Failed to set flag", slog.Any("error", err)) 91 | os.Exit(1) 92 | } 93 | } else if v, ok := keyMappingSmime[f.Name]; !f.Changed && ok && viper.IsSet(v) { 94 | val := viper.Get(v) 95 | err = cmd.Flags().Set(f.Name, fmt.Sprintf("%v", val)) 96 | if err != nil { 97 | slog.Error("Failed to set flag", slog.Any("error", err)) 98 | os.Exit(1) 99 | } 100 | } 101 | }) 102 | }, 103 | Run: func(cmd *cobra.Command, args []string) { 104 | // improve 105 | if len(genCertSmimeConfig.Email) == 0 { 106 | slog.Error("'emails' is required at the moment.") 107 | os.Exit(1) 108 | } 109 | 110 | genCertSmimeConfig.Csr = strings.ReplaceAll(genCertSmimeConfig.Csr, "\\n", "\n") 111 | requester, err := client.NewClient(genCertSmimeConfig.RequesterEmail, genCertSmimeConfig.RequesterPassword, genCertSmimeConfig.RequesterTOTPSeed, client.WithDebug(debug)) 112 | if err != nil { 113 | slog.Error("failed to create requester client", slog.Any("error", err)) 114 | os.Exit(1) 115 | } 116 | 117 | // get domain from provided email address 118 | at := strings.LastIndex(genCertSmimeConfig.Email, "@") 119 | var orgs []models.OrganizationResponse 120 | if at >= 0 { 121 | domain := []string{genCertSmimeConfig.Email[at+1:]} 122 | slog.Info("Email Domain:", slog.Any("domain", domain)) 123 | orgs, err = requester.CheckMatchingOrganization(domain) 124 | if err != nil || len(orgs) == 0 { 125 | slog.Error("failed to check matching organization", slog.Any("error", err)) 126 | os.Exit(1) 127 | } 128 | slog.Debug("matching organizations", slog.Any("organizations", orgs)) 129 | } else { 130 | slog.Error("Invalid email address provided.", slog.Any("address", genCertSmimeConfig.Email)) 131 | os.Exit(1) 132 | } 133 | 134 | // build fake bulk request with sinlge user 135 | // it looks like the API still does not fully support requesting a single SMIME certificate 136 | // the request endpoint exists but no review/validation endpoint - atleast missing in API description 137 | var smimeBulk = models.SmimeBulkRequest{ 138 | FriendlyName: genCertSmimeConfig.FriendlyName, 139 | Email: genCertSmimeConfig.Email, 140 | Email2: "", 141 | Email3: "", 142 | GivenName: genCertSmimeConfig.GivenName, 143 | Surname: genCertSmimeConfig.SurName, 144 | PickupPassword: "", 145 | CertType: genCertSmimeConfig.CertType, 146 | CSR: genCertSmimeConfig.Csr, 147 | } 148 | slog.Debug("CSR logged:", slog.Any("csr", smimeBulk.CSR)) 149 | transaction, err := requester.RequestSmimeBulkCertificates(orgs[0].ID, smimeBulk) 150 | if err != nil { 151 | slog.Error("failed to request certificate", slog.Any("error", err)) 152 | os.Exit(1) 153 | } 154 | // regarding the code RequestSmimeBulkCertificates returns: TransactionID: cert.ID, Certificate: cert.Certificate, Pkcs7: cert.Pkcs7 155 | fmt.Print(transaction.Certificate) 156 | }, 157 | } 158 | 159 | func init() { 160 | genCertCmd.AddCommand(genCertSmimeCmd) 161 | genCertSmimeCmd.Flags().String("csr", "", "CSR to request certificate with") 162 | genCertSmimeCmd.Flags().String("requester-email", "", "Email of requester") 163 | genCertSmimeCmd.Flags().String("requester-password", "", "Password of requester") 164 | genCertSmimeCmd.Flags().String("requester-totp-seed", "", "TOTP seed of requester") 165 | genCertSmimeCmd.Flags().StringVar(&configPathSmime, "config", "", "config file (default is cert-generator.yaml)") 166 | // SMIME 167 | genCertSmimeCmd.Flags().StringP("cert-type", "t", "email_only", "Requested certificate cert type.") 168 | genCertSmimeCmd.Flags().String("email", "", "E-Mail Address for the desired certificate") 169 | genCertSmimeCmd.Flags().String("friendly-name", "", "Name to identify the certificate") 170 | genCertSmimeCmd.Flags().String("given-name", "", "Givenname of the certificate requestor") 171 | genCertSmimeCmd.Flags().String("sur-name", "", "Surname of the certificate requestor") 172 | 173 | for _, s := range []string{"requester-email", "requester-password", "requester-totp-seed"} { 174 | err := genCertSmimeCmd.MarkFlagRequired(s) 175 | if err != nil { 176 | slog.Error("Failed to mark flag required", slog.Any("error", err)) 177 | os.Exit(1) 178 | } 179 | } 180 | } 181 | -------------------------------------------------------------------------------- /cmd/validation.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "cmp" 5 | "fmt" 6 | "log/slog" 7 | "slices" 8 | "strings" 9 | "sync" 10 | "time" 11 | 12 | "github.com/miekg/dns" 13 | 14 | "github.com/hm-edu/harica/client" 15 | axfr "github.com/hm-edu/harica/dns" 16 | "github.com/hm-edu/harica/imap" 17 | "github.com/hm-edu/harica/models" 18 | "github.com/spf13/cobra" 19 | ) 20 | 21 | type ValidationConfig struct { 22 | imapHost string 23 | imapPort int 24 | imapUsername string 25 | imapPassword string 26 | username string 27 | password string 28 | totp string 29 | email string 30 | dnsConfig string 31 | domains []string 32 | graceDelta int 33 | } 34 | 35 | var ( 36 | config ValidationConfig 37 | ) 38 | 39 | var validationCmd = &cobra.Command{ 40 | Use: "validation", 41 | Run: func(cmd *cobra.Command, args []string) { 42 | 43 | dnsProvider, err := axfr.NewDNSProvider(config.dnsConfig) 44 | if err != nil { 45 | slog.Error("Failed to create dns provider", slog.Any("error", err)) 46 | return 47 | } 48 | 49 | if len(config.domains) > 10 { 50 | slog.Warn("You are trying to validate more than 10 domains. This is not recommended. You should use smaller batches.") 51 | } 52 | 53 | haricaClient, err := client.NewClient(config.username, config.password, config.totp, client.WithDebug(debug)) 54 | if err != nil { 55 | slog.Error("Failed to create client:", slog.Any("error", err)) 56 | return 57 | } 58 | 59 | orgs, err := haricaClient.GetOrganizations() 60 | if err != nil { 61 | slog.Error("Failed to get organizations:", slog.Any("error", err)) 62 | return 63 | } 64 | 65 | validationStart := time.Now() 66 | validationDomains := []string{} 67 | 68 | slices.SortFunc(orgs, func(i, j models.Organization) int { 69 | return cmp.Compare(i.Domain, j.Domain) 70 | }) 71 | 72 | for _, org := range orgs { 73 | if slices.Contains(config.domains, org.Domain) || len(config.domains) == 0 { 74 | if d, err := time.Parse("2006-01-02T15:04:05", org.Validity); err == nil && d.After(time.Now().Add(30*24*time.Hour)) { 75 | slog.Warn("Domain is already validated", slog.String("domain", org.Domain)) 76 | continue 77 | } 78 | slog.Info("Triggering validation for domain", slog.String("domain", org.Domain)) 79 | err = haricaClient.TriggerValidation(org.OrganizationID, config.email) 80 | if err != nil { 81 | slog.Error("Failed to validate domain:", slog.Any("error", err)) 82 | return 83 | } 84 | validationDomains = append(validationDomains, org.Domain) 85 | } 86 | } 87 | 88 | if len(validationDomains) == 0 { 89 | slog.Info("No domains to validate") 90 | return 91 | } 92 | 93 | for _, domain := range validationDomains { 94 | if !dnsProvider.Configured(domain) { 95 | slog.Error("Domain not configured in dns provider", slog.String("domain", domain)) 96 | return 97 | } 98 | } 99 | 100 | slog.Info("Validation triggered for domains", slog.Any("domains", validationDomains)) 101 | 102 | slog.Info("Waiting for validation codes") 103 | validationCodes, err := imap.FetchValidationCodes(config.imapHost, config.imapUsername, config.imapPassword, config.imapPort, validationStart, validationDomains, debug, config.graceDelta) 104 | if err != nil { 105 | slog.Error("Failed to fetch validation codes:", slog.Any("error", err)) 106 | return 107 | } 108 | 109 | wg := sync.WaitGroup{} 110 | 111 | for domain, code := range validationCodes { 112 | slog.Info("Got validation code", slog.String("domain", domain), slog.String("code", code.Code)) 113 | wg.Add(1) 114 | go func() { 115 | defer wg.Done() 116 | c, err := client.NewClient(config.username, config.password, config.totp, client.WithDebug(debug)) 117 | if err != nil { 118 | slog.Error("Failed to validate domain:", slog.Any("error", err)) 119 | } 120 | err = validate(domain, code, dnsProvider, c) 121 | if err != nil { 122 | slog.Error("Failed to validate domain:", slog.Any("error", err)) 123 | } 124 | }() 125 | } 126 | wg.Wait() 127 | 128 | }, 129 | } 130 | 131 | func validate(domain string, code imap.ValidationCode, dnsProvider *axfr.DNSProvider, client *client.Client) error { 132 | records, err := dnsProvider.LookupTxt(domain) 133 | if err != nil { 134 | slog.Error("Failed to lookup TXT records:", slog.Any("error", err)) 135 | return err 136 | } 137 | for _, record := range records { 138 | if strings.Contains(record, "HARICA-") { 139 | rr, err := dns.NewRR(fmt.Sprintf("%s TXT %s", domain, record)) 140 | if err != nil { 141 | slog.Error("Failed to parse TXT record:", slog.Any("error", err)) 142 | break 143 | } 144 | err = dnsProvider.Delete(domain, []dns.RR{rr}) 145 | if err != nil { 146 | return err 147 | } 148 | } 149 | } 150 | rr, err := dns.NewRR(fmt.Sprintf("%s TXT %s", domain, code.Code)) 151 | if err != nil { 152 | slog.Error("Failed to create TXT record:", slog.Any("error", err)) 153 | return err 154 | } 155 | err = dnsProvider.Add(domain, []dns.RR{rr}) 156 | if err != nil { 157 | slog.Error("Failed to add TXT record:", slog.Any("error", err)) 158 | return err 159 | } 160 | slog.Info("TXT record added. Waiting for validation. This step may take several minutes. (Timeout 5m)", slog.String("domain", domain), slog.String("code", code.Code)) 161 | start := time.Now() 162 | for { 163 | valid := false 164 | orgsTmp, err := client.GetOrganizations() 165 | if err != nil { 166 | slog.Error("Failed to get organizations:", slog.Any("error", err)) 167 | return err 168 | } 169 | for _, org := range orgsTmp { 170 | if org.Domain == domain { 171 | if d, err := time.Parse("2006-01-02T15:04:05", org.Validity); err == nil && d.After(time.Now()) { 172 | slog.Info("Domain is validated. Removing TXT record again.", slog.String("domain", org.Domain)) 173 | err = dnsProvider.Delete(domain, []dns.RR{rr}) 174 | if err != nil { 175 | return err 176 | } 177 | valid = true 178 | } 179 | } 180 | } 181 | if !valid && time.Since(start).Seconds() < 300 { 182 | time.Sleep(5 * time.Second) 183 | } else { 184 | break 185 | } 186 | } 187 | return nil 188 | } 189 | 190 | func init() { 191 | validationCmd.Flags().StringVar(&config.imapHost, "imap-host", "imap.example.com", "IMAP server hostname") 192 | validationCmd.Flags().IntVar(&config.imapPort, "imap-port", 993, "IMAP server port") 193 | validationCmd.Flags().StringVar(&config.imapUsername, "imap-username", "", "IMAP server username") 194 | validationCmd.Flags().StringVar(&config.imapPassword, "imap-password", "", "IMAP server password") 195 | validationCmd.MarkFlagRequired("imap-username") //nolint:errcheck 196 | validationCmd.MarkFlagRequired("imap-password") //nolint:errcheck 197 | validationCmd.MarkFlagRequired("imap-host") //nolint:errcheck 198 | 199 | validationCmd.Flags().StringVarP(&config.username, "username", "u", "", "Harica username") 200 | validationCmd.Flags().StringVarP(&config.password, "password", "p", "", "Harica password") 201 | validationCmd.Flags().StringVarP(&config.totp, "totp-seed", "t", "", "Harica TOTP seed") 202 | 203 | validationCmd.MarkFlagRequired("username") //nolint:errcheck 204 | validationCmd.MarkFlagRequired("password") //nolint:errcheck 205 | validationCmd.MarkFlagRequired("totp-seed") //nolint:errcheck 206 | 207 | validationCmd.Flags().StringSliceVar(&config.domains, "domains", []string{}, "Domains to validate") 208 | validationCmd.Flags().StringVar(&config.email, "email", "", "Email to send validation code to") 209 | validationCmd.MarkFlagRequired("email") //nolint:errcheck 210 | 211 | validationCmd.Flags().StringVar(&config.dnsConfig, "dns", "", "Path to dns config") 212 | validationCmd.MarkFlagRequired("dns") //nolint:errcheck 213 | 214 | validationCmd.Flags().IntVar(&config.graceDelta, "grace-delta", 1, "Grace period delta in seconds") 215 | 216 | rootCmd.AddCommand(validationCmd) 217 | 218 | } 219 | -------------------------------------------------------------------------------- /cmd/genCert.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "fmt" 5 | "io" 6 | "log/slog" 7 | "os" 8 | "strings" 9 | 10 | "github.com/hm-edu/harica/client" 11 | "github.com/spf13/cobra" 12 | "github.com/spf13/pflag" 13 | "github.com/spf13/viper" 14 | ) 15 | 16 | type GenCertConfig struct { 17 | Domains []string `mapstructure:"domains"` 18 | Csr string `mapstructure:"csr"` 19 | Stdin bool `mapstructure:"stdin"` 20 | TransactionType string `mapstructure:"transaction_type"` 21 | RequesterEmail string `mapstructure:"requester_email"` 22 | RequesterPassword string `mapstructure:"requester_password"` 23 | RequesterTOTPSeed string `mapstructure:"requester_totp_seed"` 24 | ValidatorEmail string `mapstructure:"validator_email"` 25 | ValidatorPassword string `mapstructure:"validator_password"` 26 | ValidatorTOTPSeed string `mapstructure:"validator_totp_seed"` 27 | } 28 | 29 | var ( 30 | genCertConfig GenCertConfig 31 | configPath string 32 | keyMapping = map[string]string{ 33 | "domains": "domains", 34 | "csr": "csr", 35 | "stdin": "stdin", 36 | "transaction-type": "transaction_type", 37 | "requester-email": "requester_email", 38 | "requester-password": "requester_password", 39 | "requester-totp-seed": "requester_totp_seed", 40 | "validator-email": "validator_email", 41 | "validator-password": "validator_password", 42 | "validator-totp-seed": "validator_totp_seed", 43 | } 44 | ) 45 | 46 | // genCertCmd represents the genCert command 47 | var genCertCmd = &cobra.Command{ 48 | Use: "gen-cert", 49 | PreRun: func(cmd *cobra.Command, args []string) { 50 | viper.SetConfigType("yaml") 51 | viper.SetConfigName("cert-generator") 52 | viper.AddConfigPath("/etc/harica/") // path to look for the config file in 53 | viper.AddConfigPath("$HOME/harica/") // call multiple times to add many search paths 54 | viper.AddConfigPath("/opt/harica/") 55 | viper.AddConfigPath(".") // optionally look for config in the working directory 56 | if configPath != "" { 57 | viper.SetConfigFile(configPath) 58 | } 59 | 60 | for k, v := range keyMapping { 61 | err := viper.BindPFlag(v, cmd.Flags().Lookup(k)) 62 | if err != nil { 63 | slog.Error("Failed to bind flag", slog.Any("error", err)) 64 | os.Exit(1) 65 | } 66 | } 67 | if err := viper.ReadInConfig(); err != nil { 68 | if _, ok := err.(viper.ConfigFileNotFoundError); ok { 69 | slog.Info("No configuration file found") 70 | } else { 71 | slog.Error("Error reading config file", slog.Any("error", err)) 72 | os.Exit(1) 73 | } 74 | } else { 75 | slog.Info("Using config file:", slog.Any("config", viper.ConfigFileUsed())) 76 | } 77 | 78 | // Unmarshal the config into a struct. 79 | err := viper.Unmarshal(&genCertConfig) 80 | if err != nil { 81 | slog.Error("Error reading config file", slog.Any("error", err)) 82 | os.Exit(1) 83 | } 84 | 85 | cmd.Flags().VisitAll(func(f *pflag.Flag) { 86 | if !f.Changed && viper.IsSet(f.Name) { 87 | val := viper.Get(f.Name) 88 | err = cmd.Flags().Set(f.Name, fmt.Sprintf("%v", val)) 89 | if err != nil { 90 | slog.Error("Failed to set flag", slog.Any("error", err)) 91 | os.Exit(1) 92 | } 93 | } else if v, ok := keyMapping[f.Name]; !f.Changed && ok && viper.IsSet(v) { 94 | val := viper.Get(v) 95 | err = cmd.Flags().Set(f.Name, fmt.Sprintf("%v", val)) 96 | if err != nil { 97 | slog.Error("Failed to set flag", slog.Any("error", err)) 98 | os.Exit(1) 99 | } 100 | } 101 | }) 102 | }, 103 | Run: func(cmd *cobra.Command, args []string) { 104 | if genCertConfig.Stdin { 105 | x, err := io.ReadAll(os.Stdin) 106 | if err != nil { 107 | slog.Error("failed to read CSR from stdin", slog.Any("error", err)) 108 | os.Exit(1) 109 | } 110 | genCertConfig.Csr = string(x) 111 | } 112 | 113 | // Extract domains from CSR 114 | if len(genCertConfig.Domains) == 0 { 115 | slog.Info("--domains empty, reading from CSR") 116 | csr, err := client.ParseCSR([]byte(genCertConfig.Csr)) 117 | if err != nil { 118 | slog.Error("failed to parse CSR to extract domains", slog.Any("error", err)) 119 | os.Exit(1) 120 | } 121 | dnsnames := csr.DNSNames 122 | if csr.Subject.CommonName != "" { 123 | var found bool 124 | for _, x := range dnsnames { 125 | if x == csr.Subject.CommonName { 126 | found = true 127 | break 128 | } 129 | } 130 | if !found { 131 | dnsnames = append(dnsnames, csr.Subject.CommonName) 132 | } 133 | } 134 | genCertConfig.Domains = dnsnames 135 | } 136 | 137 | genCertConfig.Csr = strings.ReplaceAll(genCertConfig.Csr, "\\n", "\n") 138 | requester, err := client.NewClient(genCertConfig.RequesterEmail, genCertConfig.RequesterPassword, genCertConfig.RequesterTOTPSeed, client.WithDebug(debug)) 139 | if err != nil { 140 | slog.Error("failed to create requester client", slog.Any("error", err)) 141 | os.Exit(1) 142 | } 143 | validator, err := client.NewClient(genCertConfig.ValidatorEmail, genCertConfig.ValidatorPassword, genCertConfig.ValidatorTOTPSeed, client.WithDebug(debug)) 144 | if err != nil { 145 | slog.Error("failed to create validator client", slog.Any("error", err)) 146 | os.Exit(1) 147 | } 148 | 149 | orgs, err := requester.CheckMatchingOrganization(genCertConfig.Domains) 150 | if err != nil || len(orgs) == 0 { 151 | slog.Error("failed to check matching organization", slog.Any("error", err)) 152 | os.Exit(1) 153 | } 154 | slog.Info("matching organizations", slog.Any("organizations", orgs)) 155 | 156 | transaction, err := requester.RequestCertificate(genCertConfig.Domains, genCertConfig.Csr, genCertConfig.TransactionType, orgs[0]) 157 | if err != nil { 158 | slog.Error("failed to request certificate", slog.Any("error", err)) 159 | os.Exit(1) 160 | } 161 | 162 | reviews, err := validator.GetPendingReviews() 163 | if err != nil { 164 | slog.Error("failed to get pending reviews", slog.Any("error", err)) 165 | os.Exit(1) 166 | } 167 | 168 | for _, r := range reviews { 169 | if r.TransactionID == transaction.TransactionID { 170 | for _, s := range r.ReviewGetDTOs { 171 | err = validator.ApproveRequest(s.ReviewID, "Auto Approval", s.ReviewValue) 172 | if err != nil { 173 | slog.Error("failed to approve request", slog.Any("error", err)) 174 | os.Exit(1) 175 | } 176 | } 177 | break 178 | } 179 | } 180 | 181 | transactions, err := requester.GetMyTransactions() 182 | if err != nil { 183 | slog.Error("failed to get transactions", slog.Any("error", err)) 184 | os.Exit(1) 185 | } 186 | 187 | for _, t := range transactions { 188 | if t.TransactionID == transaction.TransactionID { 189 | if t.IsHighRisk && strings.EqualFold(t.TransactionStatus, "Pending") { 190 | slog.Error("pending transaction is high risk", slog.String("transaction", t.TransactionID)) 191 | os.Exit(1) 192 | } 193 | } 194 | } 195 | 196 | cert, err := requester.GetCertificate(transaction.TransactionID) 197 | if err != nil { 198 | slog.Error("failed to get certificate", slog.Any("error", err)) 199 | os.Exit(1) 200 | } 201 | fmt.Print(cert.PemBundle) 202 | }, 203 | } 204 | 205 | func init() { 206 | rootCmd.AddCommand(genCertCmd) 207 | genCertCmd.Flags().StringSlice("domains", []string{}, "Domains to request certificate for") 208 | genCertCmd.Flags().String("csr", "", "CSR to request certificate with") 209 | genCertCmd.Flags().Bool("stdin", false, "Read CSR from stdin") 210 | genCertCmd.Flags().StringP("transaction-type", "t", "DV", "Transaction type to request certificate with") 211 | genCertCmd.Flags().String("requester-email", "", "Email of requester") 212 | genCertCmd.Flags().String("requester-password", "", "Password of requester") 213 | genCertCmd.Flags().String("requester-totp-seed", "", "TOTP seed of requester") 214 | genCertCmd.Flags().String("validator-email", "", "Email of validator") 215 | genCertCmd.Flags().String("validator-password", "", "Password of validator") 216 | genCertCmd.Flags().String("validator-totp-seed", "", "TOTP seed of validator") 217 | 218 | for _, s := range []string{"requester-email", "requester-password", "validator-email", "validator-password", "validator-totp-seed"} { 219 | err := genCertCmd.MarkFlagRequired(s) 220 | if err != nil { 221 | slog.Error("Failed to mark flag required", slog.Any("error", err)) 222 | os.Exit(1) 223 | } 224 | } 225 | 226 | genCertCmd.MarkFlagsMutuallyExclusive("csr", "stdin") 227 | genCertCmd.MarkFlagsOneRequired("csr", "stdin") 228 | 229 | genCertCmd.Flags().StringVar(&configPath, "config", "", "config file (default is cert-generator.yaml)") 230 | } 231 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8= 2 | github.com/boombuler/barcode v1.0.2 h1:79yrbttoZrLGkL/oOI8hBrUKucwOL0oOjUgEguGMcJ4= 3 | github.com/boombuler/barcode v1.0.2/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8= 4 | github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= 5 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 6 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 7 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 8 | github.com/emersion/go-imap/v2 v2.0.0-beta.6 h1:3w7QGUcDEoZXr+okRZR75VBjX0yvvJqkfy3gibbH2yY= 9 | github.com/emersion/go-imap/v2 v2.0.0-beta.6/go.mod h1:BZTFHsS1hmgBkFlHqbxGLXk2hnRqTItUgwjSSCsYNAk= 10 | github.com/emersion/go-imap/v2 v2.0.0-beta.7 h1:lNznYWa5uhMrngnSYEklzCeye4DBq9TEJ+pr0K593+8= 11 | github.com/emersion/go-imap/v2 v2.0.0-beta.7/go.mod h1:BZTFHsS1hmgBkFlHqbxGLXk2hnRqTItUgwjSSCsYNAk= 12 | github.com/emersion/go-message v0.18.2 h1:rl55SQdjd9oJcIoQNhubD2Acs1E6IzlZISRTK7x/Lpg= 13 | github.com/emersion/go-message v0.18.2/go.mod h1:XpJyL70LwRvq2a8rVbHXikPgKj8+aI0kGdHlg16ibYA= 14 | github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6 h1:oP4q0fw+fOSWn3DfFi4EXdT+B+gTtzx8GC9xsc26Znk= 15 | github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6/go.mod h1:iL2twTeMvZnrg54ZoPDNfJaJaqy0xIQFuBdrLsmspwQ= 16 | github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= 17 | github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= 18 | github.com/fsnotify/fsnotify v1.8.0 h1:dAwr6QBTBZIkG8roQaJjGof0pp0EeF+tNV7YBP3F/8M= 19 | github.com/fsnotify/fsnotify v1.8.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= 20 | github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= 21 | github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= 22 | github.com/go-resty/resty/v2 v2.16.5 h1:hBKqmWrr7uRc3euHVqmh1HTHcKn99Smr7o5spptdhTM= 23 | github.com/go-resty/resty/v2 v2.16.5/go.mod h1:hkJtXbA2iKHzJheXYvQ8snQES5ZLGKMwQ07xAwp/fiA= 24 | github.com/go-resty/resty/v2 v2.17.0 h1:pW9DeXcaL4Rrym4EZ8v7L19zZiIlWPg5YXAcVmt+gN0= 25 | github.com/go-resty/resty/v2 v2.17.0/go.mod h1:kCKZ3wWmwJaNc7S29BRtUhJwy7iqmn+2mLtQrOyQlVA= 26 | github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs= 27 | github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= 28 | github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo= 29 | github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= 30 | github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= 31 | github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= 32 | github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= 33 | github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= 34 | github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= 35 | github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= 36 | github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= 37 | github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= 38 | github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= 39 | github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= 40 | github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= 41 | github.com/miekg/dns v1.1.68 h1:jsSRkNozw7G/mnmXULynzMNIsgY2dHC8LO6U6Ij2JEA= 42 | github.com/miekg/dns v1.1.68/go.mod h1:fujopn7TB3Pu3JM69XaawiU0wqjpL9/8xGop5UrTPps= 43 | github.com/pelletier/go-toml/v2 v2.2.3 h1:YmeHyLY8mFWbdkNWwpr+qIL2bEqT0o95WSdkNHvL12M= 44 | github.com/pelletier/go-toml/v2 v2.2.3/go.mod h1:MfCQTFTvCcUyyvvwm1+G6H/jORL20Xlb6rzQu9GuUkc= 45 | github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= 46 | github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= 47 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 48 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 49 | github.com/pquerna/otp v1.5.0 h1:NMMR+WrmaqXU4EzdGJEE1aUUI0AMRzsp96fFFWNPwxs= 50 | github.com/pquerna/otp v1.5.0/go.mod h1:dkJfzwRKNiegxyNb54X/3fLwhCynbMspSyWKnvi1AEg= 51 | github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8= 52 | github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= 53 | github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= 54 | github.com/sagikazarmark/locafero v0.7.0 h1:5MqpDsTGNDhY8sGp0Aowyf0qKsPrhewaLSsFaodPcyo= 55 | github.com/sagikazarmark/locafero v0.7.0/go.mod h1:2za3Cg5rMaTMoG/2Ulr9AwtFaIppKXTRYnozin4aB5k= 56 | github.com/sagikazarmark/locafero v0.11.0 h1:1iurJgmM9G3PA/I+wWYIOw/5SyBtxapeHDcg+AAIFXc= 57 | github.com/sagikazarmark/locafero v0.11.0/go.mod h1:nVIGvgyzw595SUSUE6tvCp3YYTeHs15MvlmU87WwIik= 58 | github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo= 59 | github.com/sourcegraph/conc v0.3.0/go.mod h1:Sdozi7LEKbFPqYX2/J+iBAM6HpqSLTASQIKqDmF7Mt0= 60 | github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 h1:+jumHNA0Wrelhe64i8F6HNlS8pkoyMv5sreGx2Ry5Rw= 61 | github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8/go.mod h1:3n1Cwaq1E1/1lhQhtRK2ts/ZwZEhjcQeJQ1RuC6Q/8U= 62 | github.com/spf13/afero v1.12.0 h1:UcOPyRBYczmFn6yvphxkn9ZEOY65cpwGKb5mL36mrqs= 63 | github.com/spf13/afero v1.12.0/go.mod h1:ZTlWwG4/ahT8W7T0WQ5uYmjI9duaLQGy3Q2OAl4sk/4= 64 | github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I= 65 | github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg= 66 | github.com/spf13/cast v1.7.1 h1:cuNEagBQEHWN1FnbGEjCXL2szYEXqfJPbP2HNUaca9Y= 67 | github.com/spf13/cast v1.7.1/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo= 68 | github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY= 69 | github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo= 70 | github.com/spf13/cobra v1.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo= 71 | github.com/spf13/cobra v1.9.1/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wxYW0= 72 | github.com/spf13/cobra v1.10.1 h1:lJeBwCfmrnXthfAupyUTzJ/J4Nc1RsHC/mSRU2dll/s= 73 | github.com/spf13/cobra v1.10.1/go.mod h1:7SmJGaTHFVBY0jW4NXGluQoLvhqFQM+6XSKD+P4XaB0= 74 | github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= 75 | github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= 76 | github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= 77 | github.com/spf13/pflag v1.0.7 h1:vN6T9TfwStFPFM5XzjsvmzZkLuaLX+HS+0SeFLRgU6M= 78 | github.com/spf13/pflag v1.0.7/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= 79 | github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= 80 | github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= 81 | github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= 82 | github.com/spf13/viper v1.20.1 h1:ZMi+z/lvLyPSCoNtFCpqjy0S4kPbirhpTMwl8BkW9X4= 83 | github.com/spf13/viper v1.20.1/go.mod h1:P9Mdzt1zoHIG8m2eZQinpiBjo6kCmZSKBClNNqjJvu4= 84 | github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU= 85 | github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjbTCAY= 86 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 87 | github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= 88 | github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= 89 | github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 90 | github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= 91 | github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= 92 | github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= 93 | go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= 94 | go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= 95 | go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= 96 | go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= 97 | golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= 98 | golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= 99 | golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= 100 | golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= 101 | golang.org/x/mod v0.26.0 h1:EGMPT//Ezu+ylkCijjPc+f4Aih7sZvaAr+O3EHBxvZg= 102 | golang.org/x/mod v0.26.0/go.mod h1:/j6NAhSk8iQ723BGAUyoAcn7SlD7s15Dp9Nd/SfeaFQ= 103 | golang.org/x/mod v0.27.0 h1:kb+q2PyFnEADO2IEF935ehFUXlWiNjJWtRNgBLSfbxQ= 104 | golang.org/x/mod v0.27.0/go.mod h1:rWI627Fq0DEoudcK+MBkNkCe0EetEaDSwJJkCcjpazc= 105 | golang.org/x/mod v0.29.0 h1:HV8lRxZC4l2cr3Zq1LvtOsi/ThTgWnUk/y64QSs8GwA= 106 | golang.org/x/mod v0.29.0/go.mod h1:NyhrlYXJ2H4eJiRy/WDBO6HMqZQ6q9nk4JzS3NuCK+w= 107 | golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= 108 | golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= 109 | golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= 110 | golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= 111 | golang.org/x/net v0.43.0 h1:lat02VYK2j4aLzMzecihNvTlJNQUq316m2Mr9rnM6YE= 112 | golang.org/x/net v0.43.0/go.mod h1:vhO1fvI4dGsIjh73sWfUVjj3N7CA9WkKJNQm2svM6Jg= 113 | golang.org/x/net v0.44.0 h1:evd8IRDyfNBMBTTY5XRF1vaZlD+EmWx6x8PkhR04H/I= 114 | golang.org/x/net v0.44.0/go.mod h1:ECOoLqd5U3Lhyeyo/QDCEVQ4sNgYsqvCZ722XogGieY= 115 | golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY= 116 | golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU= 117 | golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 118 | golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 119 | golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 120 | golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw= 121 | golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= 122 | golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug= 123 | golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= 124 | golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I= 125 | golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= 126 | golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 127 | golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 128 | golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 129 | golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 130 | golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 131 | golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 132 | golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI= 133 | golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= 134 | golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k= 135 | golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= 136 | golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc= 137 | golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= 138 | golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= 139 | golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= 140 | golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= 141 | golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 142 | golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= 143 | golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= 144 | golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= 145 | golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= 146 | golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng= 147 | golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU= 148 | golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk= 149 | golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4= 150 | golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM= 151 | golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM= 152 | golang.org/x/time v0.8.0 h1:9i3RxcPv3PZnitoVGMPDKZSq1xW1gK1Xy3ArNOGZfEg= 153 | golang.org/x/time v0.8.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= 154 | golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 155 | golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= 156 | golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= 157 | golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= 158 | golang.org/x/tools v0.35.0 h1:mBffYraMEf7aa0sB+NuKnuCy8qI/9Bughn8dC2Gu5r0= 159 | golang.org/x/tools v0.35.0/go.mod h1:NKdj5HkL/73byiZSJjqJgKn3ep7KjFkBOkR/Hps3VPw= 160 | golang.org/x/tools v0.36.0 h1:kWS0uv/zsvHEle1LbV5LE8QujrxB3wfQyxHfhOk0Qkg= 161 | golang.org/x/tools v0.36.0/go.mod h1:WBDiHKJK8YgLHlcQPYQzNCkUxUypCaa5ZegCVutKm+s= 162 | golang.org/x/tools v0.38.0 h1:Hx2Xv8hISq8Lm16jvBZ2VQf+RLmbd7wVUsALibYI/IQ= 163 | golang.org/x/tools v0.38.0/go.mod h1:yEsQ/d/YK8cjh0L6rZlY8tgtlKiBNTL14pGDJPJpYQs= 164 | golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 165 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 166 | gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= 167 | gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= 168 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 169 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 170 | -------------------------------------------------------------------------------- /client/client.go: -------------------------------------------------------------------------------- 1 | package client 2 | 3 | import ( 4 | "bytes" 5 | "crypto/x509" 6 | "encoding/csv" 7 | "encoding/json" 8 | "encoding/pem" 9 | "errors" 10 | "fmt" 11 | "strings" 12 | "sync" 13 | "time" 14 | 15 | "log/slog" 16 | 17 | "github.com/go-resty/resty/v2" 18 | "github.com/golang-jwt/jwt/v5" 19 | "github.com/hm-edu/harica/models" 20 | "github.com/pquerna/otp/totp" 21 | ) 22 | 23 | const ( 24 | BaseURLProduction = "https://cm.harica.gr" 25 | BaseURLStaging = "https://cm-stg.harica.gr" 26 | BaseURLDevel = "https://cm-dev.harica.gr" 27 | 28 | LoginPath = "/api/User/Login" 29 | LoginPathTotp = "/api/User/Login2FA" 30 | 31 | CreatePrevalidaitonPath = "/api/OrganizationAdmin/CreatePrevalidatedValidation" 32 | GetOrganizationsPath = "/api/OrganizationAdmin/GetOrganizations" 33 | GetOrganizationsBulkPath = "/api/OrganizationAdmin/GetOrganizationsBulk" 34 | 35 | CreateBulkCertificatesSMIMEPath = "/api/OrganizationAdmin/CreateBulkCertificatesSMIME" 36 | GetBulkCertificateEntriesPath = "/api/OrganizationAdmin/GetBulkCertificateEntries" 37 | GetBulkCertificatesOfAnEntryPath = "/api/OrganizationAdmin/GetBulkCertificatesOfAnEntry" 38 | RevokeBulkCertificatePath = "/api/OrganizationAdmin/RevokeBulkCertificate" 39 | 40 | UpdateReviewsPath = "/api/OrganizationValidatorSSL/UpdateReviews" 41 | GetReviewableTransactionsPath = "/api/OrganizationValidatorSSL/GetSSLReviewableTransactions" 42 | RevokeCertificatePath = "/api/OrganizationValidatorSSL/RevokeCertificate" 43 | 44 | GetCertificatePath = "/api/Certificate/GetCertificate" 45 | RevocationReasonsPath = "/api/Certificate/GetRevocationReasons" 46 | 47 | DomainValidationsPath = "/api/ServerCertificate/GetDomainValidations" 48 | CheckMatchingOrganizationPath = "/api/ServerCertificate/CheckMachingOrganization" 49 | CheckDomainNamesPath = "/api/ServerCertificate/CheckDomainNames" 50 | RequestServerCertificatePath = "/api/ServerCertificate/RequestServerCertificate" 51 | GetMyTransactionsPath = "/api/ServerCertificate/GetMyTransactions" 52 | 53 | ApplicationJson = "application/json" 54 | DnsValidation = "3.2.2.4.7" 55 | ) 56 | 57 | type Client struct { 58 | baseURL string 59 | client *resty.Client 60 | currentToken string 61 | debug bool 62 | user string 63 | password string 64 | totp string 65 | retryEnabled bool 66 | retry int 67 | requestTimeoutEnabled bool 68 | requestTimeout time.Duration 69 | refreshInterval time.Duration 70 | loginLock sync.RWMutex 71 | bulkLock sync.RWMutex 72 | } 73 | 74 | type Domain struct { 75 | Domain string `json:"domain"` 76 | } 77 | 78 | type Option func(*Client) 79 | 80 | type UnexpectedResponseContentTypeError struct { 81 | ContentType string 82 | Body []byte 83 | } 84 | 85 | func (e *UnexpectedResponseContentTypeError) Error() string { 86 | return fmt.Sprintf("unexpected response content type: %s (find request body in this errors `body` field)", e.ContentType) 87 | } 88 | 89 | type UnexpectedResponseCodeError struct { 90 | Code int 91 | Body []byte 92 | } 93 | 94 | func (e *UnexpectedResponseCodeError) Error() string { 95 | return fmt.Sprintf("unexpected response status code %d: %s", e.Code, e.Body) 96 | } 97 | 98 | func NewClient(user, password, totpSeed string, options ...Option) (*Client, error) { 99 | c := Client{ 100 | baseURL: BaseURLProduction, // default to production environment 101 | } 102 | for _, option := range options { 103 | option(&c) 104 | } 105 | err := c.prepareClient(user, password, totpSeed, false) 106 | if err != nil { 107 | return nil, err 108 | } 109 | return &c, nil 110 | } 111 | 112 | func WithProductionEnvironment() Option { 113 | return func(c *Client) { 114 | c.baseURL = BaseURLProduction 115 | } 116 | } 117 | 118 | func WithStagingEnvironment(c *Client) Option { 119 | return func(c *Client) { 120 | c.baseURL = BaseURLStaging 121 | } 122 | } 123 | 124 | func WithDevelEnvironment() Option { 125 | return func(c *Client) { 126 | c.baseURL = BaseURLDevel 127 | } 128 | } 129 | 130 | func WithDebug(debug bool) Option { 131 | return func(c *Client) { 132 | c.debug = debug 133 | } 134 | } 135 | 136 | func WithRefreshInterval(interval time.Duration) Option { 137 | return func(c *Client) { 138 | c.refreshInterval = interval 139 | } 140 | } 141 | 142 | func WithRetry(retry int) Option { 143 | return func(c *Client) { 144 | c.retryEnabled = true 145 | c.retry = retry 146 | } 147 | } 148 | 149 | func WithRequestTimeout(timeout time.Duration) Option { 150 | return func(c *Client) { 151 | c.requestTimeoutEnabled = true 152 | c.requestTimeout = timeout 153 | } 154 | } 155 | 156 | func ParseCSR(csr []byte) (*x509.CertificateRequest, error) { 157 | block, _ := pem.Decode(csr) 158 | if block == nil || block.Type != "CERTIFICATE REQUEST" { 159 | return nil, errors.New("failed to decode PEM block containing CSR") 160 | } 161 | csrParsed, err := x509.ParseCertificateRequest(block.Bytes) 162 | if err != nil { 163 | return nil, fmt.Errorf("failed to parse CSR: %v", err) 164 | } 165 | 166 | if err := csrParsed.CheckSignature(); err != nil { 167 | return nil, fmt.Errorf("CSR signature is invalid: %v", err) 168 | } 169 | 170 | return csrParsed, nil 171 | } 172 | 173 | func (c *Client) SessionRefresh(force bool) error { 174 | return c.prepareClient(c.user, c.password, c.totp, force) 175 | } 176 | 177 | func (c *Client) prepareClient(user, password, totpSeed string, force bool) error { 178 | c.loginLock.Lock() 179 | defer c.loginLock.Unlock() 180 | renew := false 181 | slog.Info("Preparing client") 182 | if c.currentToken != "" { 183 | slog.Info("Token exists, checking expiration") 184 | // Check JWT 185 | token, _, err := jwt.NewParser().ParseUnverified(c.currentToken, jwt.MapClaims{}) 186 | if err != nil { 187 | return err 188 | } 189 | exp, err := token.Claims.GetExpirationTime() 190 | if err != nil { 191 | return err 192 | } 193 | slog.Info("Token expires", slog.Time("exp", exp.Time)) 194 | if exp.Before(time.Now()) || exp.Before(time.Now().Add(c.refreshInterval)) { 195 | renew = true 196 | slog.Info("Token expired or will expire soon, renewing") 197 | } 198 | } 199 | c.user = user 200 | c.password = password 201 | c.totp = totpSeed 202 | if c.client == nil || c.currentToken == "" || renew || force { 203 | if totpSeed != "" { 204 | return c.loginTotp(user, password, totpSeed) 205 | } else { 206 | return c.login(user, password) 207 | } 208 | } 209 | return nil 210 | } 211 | 212 | func (c *Client) loginTotp(user, password, totpSeed string) error { 213 | r := resty.New() 214 | verificationToken, err := getVerificationToken(r, c.baseURL) 215 | if err != nil { 216 | return err 217 | } 218 | otp, err := totp.GenerateCode(totpSeed, time.Now()) 219 | if err != nil { 220 | return err 221 | } 222 | resp, err := r. 223 | R().SetHeaderVerbatim("RequestVerificationToken", verificationToken). 224 | SetHeader("Content-Type", ApplicationJson). 225 | SetBody(map[string]string{"email": user, "password": password, "token": otp}). 226 | Post(c.baseURL + LoginPathTotp) 227 | if err != nil { 228 | return err 229 | } 230 | if resp == nil { 231 | return errors.New("2FA Login response is nil") 232 | } 233 | if resp.IsError() { 234 | return &UnexpectedResponseCodeError{Code: resp.StatusCode()} 235 | } 236 | tokenResp := strings.Trim(resp.String(), "\"") 237 | _, _, err = jwt.NewParser().ParseUnverified(tokenResp, jwt.MapClaims{}) 238 | if err != nil { 239 | return err 240 | } 241 | c.currentToken = tokenResp 242 | r = r.SetHeaders(map[string]string{"Authorization": c.currentToken}) 243 | token, err := getVerificationToken(r, c.baseURL) 244 | if err != nil { 245 | return err 246 | } 247 | r = r.SetHeaderVerbatim("RequestVerificationToken", token).SetDebug(c.debug) 248 | c.client = r 249 | slog.Info("Logged in with TOTP", slog.String("user", user)) 250 | javaWebToken, _, err := jwt.NewParser().ParseUnverified(c.currentToken, jwt.MapClaims{}) 251 | if err != nil { 252 | return err 253 | } 254 | exp, err := javaWebToken.Claims.GetExpirationTime() 255 | if err != nil { 256 | return err 257 | } 258 | if c.retryEnabled { 259 | c.client = c.client.SetRetryCount(c.retry) 260 | } 261 | if c.requestTimeoutEnabled { 262 | c.client = c.client.SetTimeout(c.requestTimeout) 263 | } 264 | slog.Info("Token expires", slog.Time("exp", exp.Time)) 265 | return nil 266 | } 267 | 268 | func (c *Client) login(user, password string) error { 269 | r := resty.New() 270 | verificationToken, err := getVerificationToken(r, c.baseURL) 271 | if err != nil { 272 | return err 273 | } 274 | resp, err := r. 275 | R().SetHeaderVerbatim("RequestVerificationToken", verificationToken). 276 | SetHeader("Content-Type", ApplicationJson). 277 | SetBody(map[string]string{"email": user, "password": password}). 278 | Post(c.baseURL + LoginPath) 279 | if err != nil { 280 | return err 281 | } 282 | if resp == nil { 283 | return errors.New("login response is nil") 284 | } 285 | if resp.IsError() { 286 | return &UnexpectedResponseCodeError{Code: resp.StatusCode()} 287 | } 288 | tokenResp := strings.Trim(resp.String(), "\"") 289 | _, _, err = jwt.NewParser().ParseUnverified(tokenResp, jwt.MapClaims{}) 290 | if err != nil { 291 | return err 292 | } 293 | c.currentToken = tokenResp 294 | r = r.SetHeaders(map[string]string{"Authorization": c.currentToken}) 295 | token, err := getVerificationToken(r, c.baseURL) 296 | if err != nil { 297 | return err 298 | } 299 | r = r.SetHeaderVerbatim("RequestVerificationToken", token).SetDebug(c.debug) 300 | c.client = r 301 | slog.Info("Logged in without TOTP", slog.String("user", user)) 302 | javaWebToken, _, err := jwt.NewParser().ParseUnverified(c.currentToken, jwt.MapClaims{}) 303 | if err != nil { 304 | return err 305 | } 306 | exp, err := javaWebToken.Claims.GetExpirationTime() 307 | if err != nil { 308 | return err 309 | } 310 | if c.retryEnabled { 311 | c.client = c.client.SetRetryCount(c.retry) 312 | } 313 | if c.requestTimeoutEnabled { 314 | c.client = c.client.SetTimeout(c.requestTimeout) 315 | } 316 | slog.Info("Token expires", slog.Time("exp", exp.Time)) 317 | return nil 318 | } 319 | 320 | func (c *Client) GetRevocationReasons() ([]models.RevocationReasonsResponse, error) { 321 | c.loginLock.RLock() 322 | defer c.loginLock.RUnlock() 323 | var response []models.RevocationReasonsResponse 324 | resp, err := c.client.R(). 325 | ExpectContentType(ApplicationJson). 326 | SetResult(&response). 327 | Post(c.baseURL + RevocationReasonsPath) 328 | if err != nil { 329 | return nil, err 330 | } 331 | if resp == nil { 332 | return nil, errors.New("revocation reasons response is nil") 333 | } 334 | if resp.IsError() { 335 | return nil, &UnexpectedResponseCodeError{Code: resp.StatusCode(), Body: resp.Body()} 336 | } 337 | if !strings.Contains(resp.Header().Get("Content-Type"), ApplicationJson) { 338 | return nil, &UnexpectedResponseContentTypeError{ContentType: resp.Header().Get("Content-Type"), Body: resp.Body()} 339 | } 340 | fmt.Printf("Response: %v", resp) 341 | return response, nil 342 | } 343 | 344 | func (c *Client) RevokeCertificate(reason models.RevocationReasonsResponse, comment string, transactionId string) error { 345 | c.loginLock.RLock() 346 | defer c.loginLock.RUnlock() 347 | resp, err := c.client.R(). 348 | SetHeader("Content-Type", ApplicationJson). 349 | SetBody(map[string]interface{}{ 350 | "transactionId": transactionId, 351 | "notes": comment, 352 | "name": reason.Name, 353 | "message": "", 354 | }). 355 | Post(c.baseURL + RevokeCertificatePath) 356 | if err != nil { 357 | return err 358 | } 359 | if resp == nil { 360 | return errors.New("revoke certificate response is nil") 361 | } 362 | if resp.IsError() { 363 | return &UnexpectedResponseCodeError{Code: resp.StatusCode()} 364 | } 365 | return nil 366 | } 367 | 368 | func (c *Client) CheckMatchingOrganization(domains []string) ([]models.OrganizationResponse, error) { 369 | c.loginLock.RLock() 370 | defer c.loginLock.RUnlock() 371 | var domainDto []Domain 372 | for _, domain := range domains { 373 | domainDto = append(domainDto, Domain{Domain: domain}) 374 | } 375 | var response []models.OrganizationResponse 376 | resp, err := c.client.R(). 377 | SetHeader("Content-Type", ApplicationJson). 378 | ExpectContentType(ApplicationJson). 379 | SetResult(&response).SetBody(domainDto). 380 | Post(c.baseURL + CheckMatchingOrganizationPath) 381 | if err != nil { 382 | return nil, err 383 | } 384 | if resp == nil { 385 | return nil, errors.New("check matching organization response is nil") 386 | } 387 | if resp.IsError() { 388 | return nil, &UnexpectedResponseCodeError{Code: resp.StatusCode(), Body: resp.Body()} 389 | } 390 | if !strings.Contains(resp.Header().Get("Content-Type"), ApplicationJson) { 391 | return nil, &UnexpectedResponseContentTypeError{ContentType: resp.Header().Get("Content-Type"), Body: resp.Body()} 392 | } 393 | return response, nil 394 | } 395 | 396 | func (c *Client) GetMyTransactions() ([]models.TransactionResponse, error) { 397 | c.loginLock.RLock() 398 | defer c.loginLock.RUnlock() 399 | var transactions []models.TransactionResponse 400 | resp, err := c.client.R(). 401 | SetResult(&transactions). 402 | SetHeader("Content-Type", ApplicationJson). 403 | ExpectContentType(ApplicationJson). 404 | Post(c.baseURL + GetMyTransactionsPath) 405 | if err != nil { 406 | return nil, err 407 | } 408 | if resp == nil { 409 | return nil, errors.New("my-transactions response is nil") 410 | } 411 | if resp.IsError() { 412 | return nil, &UnexpectedResponseCodeError{Code: resp.StatusCode(), Body: resp.Body()} 413 | } 414 | if !strings.Contains(resp.Header().Get("Content-Type"), ApplicationJson) { 415 | return nil, &UnexpectedResponseContentTypeError{ContentType: resp.Header().Get("Content-Type"), Body: resp.Body()} 416 | } 417 | return transactions, nil 418 | } 419 | 420 | func (c *Client) GetCertificate(id string) (*models.CertificateResponse, error) { 421 | c.loginLock.RLock() 422 | defer c.loginLock.RUnlock() 423 | var cert models.CertificateResponse 424 | resp, err := c.client.R(). 425 | SetResult(&cert). 426 | SetHeader("Content-Type", ApplicationJson). 427 | ExpectContentType(ApplicationJson). 428 | SetBody(map[string]interface{}{"id": id}). 429 | Post(c.baseURL + GetCertificatePath) 430 | if err != nil { 431 | return nil, err 432 | } 433 | if resp == nil { 434 | return nil, errors.New("certificate response is nil") 435 | } 436 | if resp.IsError() { 437 | return nil, &UnexpectedResponseCodeError{Code: resp.StatusCode(), Body: resp.Body()} 438 | } 439 | if !strings.Contains(resp.Header().Get("Content-Type"), ApplicationJson) { 440 | return nil, &UnexpectedResponseContentTypeError{ContentType: resp.Header().Get("Content-Type"), Body: resp.Body()} 441 | } 442 | return &cert, nil 443 | } 444 | 445 | func (c *Client) CheckDomainNames(domains []string) ([]models.DomainResponse, error) { 446 | c.loginLock.RLock() 447 | defer c.loginLock.RUnlock() 448 | domainDto := make([]Domain, 0) 449 | for _, domain := range domains { 450 | domainDto = append(domainDto, Domain{Domain: domain}) 451 | } 452 | domainResp := make([]models.DomainResponse, 0) 453 | resp, err := c.client.R(). 454 | SetResult(&domainResp). 455 | SetHeader("Content-Type", ApplicationJson). 456 | ExpectContentType(ApplicationJson). 457 | SetBody(domainDto). 458 | Post(c.baseURL + CheckDomainNamesPath) 459 | if err != nil { 460 | return nil, err 461 | } 462 | if resp == nil { 463 | return nil, errors.New("check domain names response is nil") 464 | } 465 | if resp.IsError() { 466 | return nil, &UnexpectedResponseCodeError{Code: resp.StatusCode(), Body: resp.Body()} 467 | } 468 | if !strings.Contains(resp.Header().Get("Content-Type"), ApplicationJson) { 469 | return nil, &UnexpectedResponseContentTypeError{ContentType: resp.Header().Get("Content-Type"), Body: resp.Body()} 470 | } 471 | return domainResp, nil 472 | } 473 | 474 | func (c *Client) RequestCertificate(domains []string, csr string, transactionType string, organization models.OrganizationResponse) (*models.CertificateRequestResponse, error) { 475 | c.loginLock.RLock() 476 | defer c.loginLock.RUnlock() 477 | var domainDto []Domain 478 | for _, domain := range domains { 479 | domainDto = append(domainDto, Domain{Domain: domain}) 480 | } 481 | 482 | // Ensure that the CSR is in the correct format so we parse it and transform it again 483 | csrParsed, err := ParseCSR([]byte(csr)) 484 | if err != nil { 485 | return nil, err 486 | } 487 | // Write the CSR as a PEM encoded string again 488 | csr = string(pem.EncodeToMemory(&pem.Block{ 489 | Type: "CERTIFICATE REQUEST", 490 | Bytes: csrParsed.Raw, 491 | })) 492 | 493 | domainJsonBytes, _ := json.Marshal(domainDto) 494 | domainJson := string(domainJsonBytes) 495 | var result models.CertificateRequestResponse 496 | 497 | body := map[string]string{ 498 | "domains": domainJson, 499 | "domainsString": domainJson, 500 | "csr": csr, 501 | "isManualCsr": "true", 502 | "consentSameKey": "true", 503 | "transactionType": transactionType, 504 | "duration": "1", 505 | } 506 | 507 | if transactionType == "OV" { 508 | body["organizationDN"] = fmt.Sprintf("OrganizationId:%s&C:%s&ST:%s&L:%s&O:%s", 509 | organization.ID, 510 | organization.Country, 511 | organization.State, 512 | organization.Locality, 513 | organization.OrganizationName) 514 | } 515 | 516 | resp, err := c.client.R(). 517 | SetHeader("Content-Type", "multipart/form-data"). 518 | SetResult(&result). 519 | ExpectContentType(ApplicationJson). 520 | SetMultipartFormData(body). 521 | Post(c.baseURL + RequestServerCertificatePath) 522 | if err != nil { 523 | return nil, err 524 | } 525 | if resp == nil { 526 | return nil, errors.New("server certificate response is nil") 527 | } 528 | if resp.IsError() { 529 | return nil, &UnexpectedResponseCodeError{Code: resp.StatusCode(), Body: resp.Body()} 530 | } 531 | if !strings.Contains(resp.Header().Get("Content-Type"), ApplicationJson) { 532 | return nil, &UnexpectedResponseContentTypeError{ContentType: resp.Header().Get("Content-Type"), Body: resp.Body()} 533 | } 534 | return &result, nil 535 | } 536 | 537 | func (c *Client) GetPendingReviews() ([]models.ReviewResponse, error) { 538 | c.loginLock.RLock() 539 | defer c.loginLock.RUnlock() 540 | var pending []models.ReviewResponse 541 | resp, err := c.client.R(). 542 | SetResult(&pending). 543 | SetHeader("Content-Type", ApplicationJson). 544 | ExpectContentType(ApplicationJson). 545 | SetBody(models.ReviewRequest{ 546 | StartIndex: 0, 547 | Status: "Pending", 548 | FilterPostDTOs: []any{}, 549 | }). 550 | Post(c.baseURL + GetReviewableTransactionsPath) 551 | if err != nil { 552 | return nil, err 553 | } 554 | if resp == nil { 555 | return nil, errors.New("pending reviews response is nil") 556 | } 557 | if resp.IsError() { 558 | return nil, &UnexpectedResponseCodeError{Code: resp.StatusCode(), Body: resp.Body()} 559 | } 560 | if !strings.Contains(resp.Header().Get("Content-Type"), ApplicationJson) { 561 | return nil, &UnexpectedResponseContentTypeError{ContentType: resp.Header().Get("Content-Type"), Body: resp.Body()} 562 | } 563 | return pending, nil 564 | } 565 | 566 | func (c *Client) ApproveRequest(id, message, value string) error { 567 | c.loginLock.RLock() 568 | defer c.loginLock.RUnlock() 569 | resp, err := c.client.R(). 570 | SetHeader("Content-Type", "multipart/form-data"). 571 | SetMultipartFormData(map[string]string{ 572 | "reviewId": id, 573 | "isValid": "true", 574 | "informApplicant": "true", 575 | "reviewMessage": message, 576 | "reviewValue": value, 577 | }). 578 | Post(c.baseURL + UpdateReviewsPath) 579 | if err != nil { 580 | return err 581 | } 582 | if resp == nil { 583 | return errors.New("approve request response is nil") 584 | } 585 | if resp.IsError() { 586 | return &UnexpectedResponseCodeError{Code: resp.StatusCode()} 587 | } 588 | return nil 589 | } 590 | 591 | func (c *Client) GetOrganizations() ([]models.Organization, error) { 592 | c.loginLock.RLock() 593 | defer c.loginLock.RUnlock() 594 | orgs := []models.Organization{} 595 | resp, err := c.client.R(). 596 | SetResult(&orgs). 597 | SetHeader("Content-Type", ApplicationJson). 598 | ExpectContentType(ApplicationJson). 599 | Post(c.baseURL + GetOrganizationsPath) 600 | if err != nil { 601 | return nil, err 602 | } 603 | if resp == nil { 604 | return nil, errors.New("organizations response is nil") 605 | } 606 | if resp.IsError() { 607 | return nil, &UnexpectedResponseCodeError{Code: resp.StatusCode(), Body: resp.Body()} 608 | } 609 | if !strings.Contains(resp.Header().Get("Content-Type"), ApplicationJson) { 610 | return nil, &UnexpectedResponseContentTypeError{ContentType: resp.Header().Get("Content-Type"), Body: resp.Body()} 611 | } 612 | return orgs, nil 613 | } 614 | 615 | func (c *Client) GetOrganizationsBulk() ([]models.Organization, error) { 616 | c.loginLock.RLock() 617 | defer c.loginLock.RUnlock() 618 | orgs := []models.Organization{} 619 | resp, err := c.client.R(). 620 | SetResult(&orgs). 621 | SetHeader("Content-Type", ApplicationJson). 622 | ExpectContentType(ApplicationJson). 623 | Post(c.baseURL + GetOrganizationsPath) 624 | if err != nil { 625 | return nil, err 626 | } 627 | if resp == nil { 628 | return nil, errors.New("bulk organizations response is nil") 629 | } 630 | if resp.IsError() { 631 | return nil, &UnexpectedResponseCodeError{Code: resp.StatusCode(), Body: resp.Body()} 632 | } 633 | if !strings.Contains(resp.Header().Get("Content-Type"), ApplicationJson) { 634 | return nil, &UnexpectedResponseContentTypeError{ContentType: resp.Header().Get("Content-Type"), Body: resp.Body()} 635 | } 636 | return orgs, nil 637 | } 638 | func (c *Client) TriggerValidation(organizatonId, email string) error { 639 | c.loginLock.RLock() 640 | defer c.loginLock.RUnlock() 641 | resp, err := c.client.R(). 642 | SetHeader("Content-Type", ApplicationJson). 643 | SetBody(map[string]string{ 644 | "organizationId": organizatonId, 645 | "usersEmail": email, 646 | "validationMethodName": DnsValidation, 647 | "whoisEmail": "", 648 | }). 649 | Post(c.baseURL + CreatePrevalidaitonPath) 650 | if err != nil { 651 | return err 652 | } 653 | if resp == nil { 654 | return errors.New("validation response is nil") 655 | } 656 | if resp.IsError() { 657 | return &UnexpectedResponseCodeError{Code: resp.StatusCode()} 658 | } 659 | return nil 660 | } 661 | 662 | func (c *Client) RequestSmimeBulkCertificates(groupId string, request models.SmimeBulkRequest) (*models.SmimeBulkResponse, error) { 663 | 664 | b := new(bytes.Buffer) 665 | data := csv.NewWriter(b) 666 | 667 | err := data.Write([]string{ 668 | "FriendlyName", 669 | "Email", 670 | "Email2", 671 | "Email3", 672 | "GivenName", 673 | "Surname", 674 | "PickupPassword", 675 | "CertType", 676 | "CSR", 677 | }) 678 | if err != nil { 679 | return nil, err 680 | } 681 | 682 | if request.CertType != "email_only" && request.CertType != "natural_legal_lcp" { 683 | return nil, errors.New("invalid certificate type") 684 | } 685 | 686 | err = data.Write([]string{ 687 | request.FriendlyName, 688 | request.Email, 689 | request.Email2, 690 | request.Email3, 691 | request.GivenName, 692 | request.Surname, 693 | request.PickupPassword, 694 | request.CertType, 695 | request.CSR, 696 | }) 697 | if err != nil { 698 | return nil, err 699 | } 700 | data.Flush() 701 | c.bulkLock.Lock() 702 | defer c.bulkLock.Unlock() 703 | c.loginLock.RLock() 704 | defer c.loginLock.RUnlock() 705 | 706 | entriesBefore, err := c.GetSmimeBulkCertificateEntries() 707 | if err != nil { 708 | return nil, err 709 | } 710 | 711 | resp, err := c.client.R(). 712 | SetHeader("Content-Type", "multipart/form-data"). 713 | SetMultipartFormData(map[string]string{ 714 | "groupId": groupId, 715 | }). 716 | SetMultipartField("csv", "bulk.csv", "text/csv", bytes.NewReader(b.Bytes())). 717 | Post(c.baseURL + CreateBulkCertificatesSMIMEPath) 718 | if err != nil { 719 | return nil, err 720 | } 721 | if resp == nil { 722 | return nil, errors.New("bulk smime certificate request response is nil") 723 | } 724 | if resp.IsError() { 725 | return nil, &UnexpectedResponseCodeError{Code: resp.StatusCode(), Body: resp.Body()} 726 | } 727 | // Determine the difference ... 728 | entriesAfter, err := c.GetSmimeBulkCertificateEntries() 729 | if err != nil { 730 | return nil, err 731 | } 732 | var newEntries []models.BulkCertificateListEntry 733 | for _, entry := range *entriesAfter { 734 | found := false 735 | for _, oldEntry := range *entriesBefore { 736 | if entry.ID == oldEntry.ID { 737 | found = true 738 | break 739 | } 740 | } 741 | if !found { 742 | newEntries = append(newEntries, entry) 743 | } 744 | } 745 | if len(newEntries) != 1 { 746 | return nil, errors.New("unexpected number of new entries") 747 | } 748 | // Get the single certificate 749 | cert, err := c.GetSingleSmimeBulkCertificateEntry(newEntries[0].ID) 750 | if err != nil { 751 | return nil, err 752 | } 753 | 754 | return &models.SmimeBulkResponse{TransactionID: cert.ID, Certificate: cert.Certificate, Pkcs7: cert.Pkcs7}, nil 755 | } 756 | 757 | func (c *Client) GetSmimeBulkCertificateEntries() (*[]models.BulkCertificateListEntry, error) { 758 | c.loginLock.RLock() 759 | defer c.loginLock.RUnlock() 760 | var certs []models.BulkCertificateListEntry 761 | resp, err := c.client.R(). 762 | SetResult(&certs). 763 | SetHeader("Content-Type", ApplicationJson). 764 | ExpectContentType(ApplicationJson). 765 | Post(c.baseURL + GetBulkCertificateEntriesPath) 766 | if err != nil { 767 | return nil, err 768 | } 769 | if resp == nil { 770 | return nil, errors.New("bulk smime certificates entries response is nil") 771 | } 772 | if resp.IsError() { 773 | return nil, &UnexpectedResponseCodeError{Code: resp.StatusCode(), Body: resp.Body()} 774 | } 775 | if !strings.Contains(resp.Header().Get("Content-Type"), ApplicationJson) { 776 | return nil, &UnexpectedResponseContentTypeError{ContentType: resp.Header().Get("Content-Type"), Body: resp.Body()} 777 | } 778 | return &certs, nil 779 | } 780 | 781 | func (c *Client) GetSingleSmimeBulkCertificateEntry(id string) (*models.BulkCertificateEntry, error) { 782 | c.loginLock.RLock() 783 | defer c.loginLock.RUnlock() 784 | var cert []models.BulkCertificateEntry 785 | resp, err := c.client.R(). 786 | SetResult(&cert). 787 | SetHeader("Content-Type", ApplicationJson). 788 | ExpectContentType(ApplicationJson). 789 | SetBody(map[string]interface{}{"id": id}). 790 | Post(c.baseURL + GetBulkCertificatesOfAnEntryPath) 791 | if err != nil { 792 | return nil, err 793 | } 794 | if resp == nil { 795 | return nil, errors.New("bulk smime certificates entry response is nil") 796 | } 797 | if resp.IsError() { 798 | return nil, &UnexpectedResponseCodeError{Code: resp.StatusCode(), Body: resp.Body()} 799 | } 800 | if !strings.Contains(resp.Header().Get("Content-Type"), ApplicationJson) { 801 | return nil, &UnexpectedResponseContentTypeError{ContentType: resp.Header().Get("Content-Type"), Body: resp.Body()} 802 | } 803 | if len(cert) == 0 { 804 | return nil, errors.New("no certificate found") 805 | } 806 | if len(cert) > 1 { 807 | return nil, errors.New("multiple certificates found") 808 | } 809 | 810 | return &cert[0], nil 811 | } 812 | 813 | func (c *Client) RevokeSmimeBulkCertificateEntry(transactionId string, comment string, reason string) error { 814 | c.loginLock.RLock() 815 | defer c.loginLock.RUnlock() 816 | resp, err := c.client.R(). 817 | SetHeader("Content-Type", ApplicationJson). 818 | SetBody(map[string]interface{}{ 819 | "transactionId": transactionId, 820 | "name": reason, 821 | "message": comment, 822 | }). 823 | Post(c.baseURL + RevokeBulkCertificatePath) 824 | if err != nil { 825 | return err 826 | } 827 | if resp == nil { 828 | return errors.New("revoke bulk smime certificate response is nil") 829 | } 830 | if resp.IsError() { 831 | return &UnexpectedResponseCodeError{Code: resp.StatusCode()} 832 | } 833 | return nil 834 | } 835 | --------------------------------------------------------------------------------