From ac98a9022292f45b11c126a9fc48ba3fb3f0af6f Mon Sep 17 00:00:00 2001 From: Steven Tracey Date: Mon, 11 Sep 2023 05:08:12 -0400 Subject: [PATCH] lots o progress --- config.go | 37 ++++++++++++ crypto.go | 58 +++++++++++++++++++ example.config.ini | 1 + git.go | 139 +++++++++++++++++++++++++++++++++++++++++++++ go.mod | 6 +- go.sum | 11 ++++ main.go | 132 +++++++----------------------------------- util.go | 53 +++++++++++++++++ 8 files changed, 322 insertions(+), 115 deletions(-) create mode 100644 config.go create mode 100644 crypto.go create mode 100644 git.go create mode 100644 util.go diff --git a/config.go b/config.go new file mode 100644 index 0000000..7340fda --- /dev/null +++ b/config.go @@ -0,0 +1,37 @@ +package main + +import ( + "fmt" + "os" +) + +func makeDirs() { + err := os.MkdirAll("/etc/certman", 0775) + if err != nil { + if !os.IsExist(err) { + fmt.Println("Unable to create config directory") + os.Exit(1) + } + } + + err = os.Mkdir("/etc/certman/conf", 0775) + if err != nil { + if !os.IsExist(err) { + fmt.Println("Unable to create config directory") + os.Exit(1) + } + } +} + +// TODO make domain level configs override global config +func createConfig() { + confPath := "/etc/certman/certman.conf" + configBytes := []byte("[Git]\nhost = github\nserver = https://gitea.instance.com\nusername = user\napi_token = xxxxxxxxxxxxxxxx\norg_name = org\ntemplate_name = template\n\n[Crypto]\ncert_path = /etc/certman/crypto/cert.pem\nkey_path = /etc/certman/crypto/key.pem") + + createFile(confPath, configBytes) +} + +func createNewDomainConfig(domain string) { + data := []byte("[Cloudflare]\ncf_email = email@example.com\ncf_api_token = xxxxxxxxxxxxxxxx\n\n[Certificates]\ncerts_path = ./certs/" + domain + "\nsubdomains =") + createFile("/etc/certman/"+domain+".conf", data) +} diff --git a/crypto.go b/crypto.go new file mode 100644 index 0000000..973ee23 --- /dev/null +++ b/crypto.go @@ -0,0 +1,58 @@ +package main + +import ( + "crypto/rand" + "crypto/rsa" + "crypto/x509" + "fmt" + "os" +) + +var cert *x509.Certificate +var key *rsa.PrivateKey + +func encryptBytes(data []byte) []byte { + if cert == nil || key == nil { + loadCerts() + } + + encrypted, err := rsa.EncryptPKCS1v15(rand.Reader, cert.PublicKey.(*rsa.PublicKey), data) + if err != nil { + fmt.Println("Error encrypting data,", err) + os.Exit(1) + } + return encrypted +} + +func decryptBytes(data []byte) []byte { + if cert == nil || key == nil { + loadCerts() + } + + decrypted, err := rsa.DecryptPKCS1v15(rand.Reader, key, data) + if err != nil { + fmt.Println("Error decrypting data,", err) + os.Exit(1) + } + return decrypted +} + +func loadCerts() { + var err error + certBytes, err := os.ReadFile(config.GetAsString("Crypto.cert_path")) + keyBytes, err := os.ReadFile(config.GetAsString("Crypto.key_path")) + if err != nil { + fmt.Println("Error reading cert or key,", err) + os.Exit(1) + } + + cert, err = x509.ParseCertificate(certBytes) + if err != nil { + fmt.Println("Error parsing certificate,", err) + os.Exit(1) + } + key, err = x509.ParsePKCS1PrivateKey(keyBytes) + if err != nil { + fmt.Println("Error parsing private key,", err) + } +} diff --git a/example.config.ini b/example.config.ini index 409d3df..0ab5551 100644 --- a/example.config.ini +++ b/example.config.ini @@ -1,4 +1,5 @@ [Git] +host = github server = https://gitea.instance.com username = user api_token = xxxxxxxxxxxxxxxx diff --git a/git.go b/git.go new file mode 100644 index 0000000..5662665 --- /dev/null +++ b/git.go @@ -0,0 +1,139 @@ +package main + +import ( + "code.gitea.io/sdk/gitea" + "context" + "fmt" + "github.com/go-git/go-git/v5" + "github.com/go-git/go-git/v5/plumbing/object" + "github.com/google/go-github/v55/github" + "os" + "strings" + "time" +) + +func createGithubClient() *github.Client { + return github.NewClient(nil).WithAuthToken(config.GetAsString("Git.api_token")) +} + +func createGiteaClient() *gitea.Client { + client, err := gitea.NewClient(config.GetAsString("Git.server"), gitea.SetToken(config.GetAsString("Git.api_token"))) + if err != nil { + fmt.Printf("Error connecting to gitea instance: %v\n", err) + os.Exit(1) + } + return client +} + +func createGithubRepo(domain *Domain, client *github.Client) string { + name := domain.name + owner := domain.config.GetAsString("Repo.owner") + description := domain.description + private := true + includeAllBranches := false + + ctx := context.Background() + template := &github.TemplateRepoRequest{ + Name: name, + Owner: &owner, + Description: description, + Private: &private, + IncludeAllBranches: &includeAllBranches, + } + repo, _, err := client.Repositories.CreateFromTemplate(ctx, config.GetAsString("Git.org_name"), config.GetAsString("Git.template_name"), template) + if err != nil { + fmt.Println("Error creating repository from template,", err) + os.Exit(1) + } + return *repo.CloneURL +} + +func createGiteaRepo() string { + options := gitea.CreateRepoFromTemplateOption{ + Avatar: true, + Description: "Certificates storage for " + domain, + GitContent: true, + GitHooks: true, + Labels: true, + Name: domain + "-certificates", + Owner: config.GetAsString("Git.org_name"), + Private: true, + Topics: true, + Webhooks: true, + } + giteaRepo, _, err := giteaClient.CreateRepoFromTemplate(config.GetAsString("Git.org_name"), config.GetAsString("Git.template_name"), options) + if err != nil { + fmt.Printf("Error creating repo: %v\n", err) + os.Exit(1) + } + return giteaRepo.CloneURL +} + +func cloneRepo(url string) (*git.Repository, *git.Worktree) { + repository, err := git.Clone(storage, fs, &git.CloneOptions{URL: url, Auth: creds}) + if err != nil { + fmt.Printf("Error clone git repo: %v\n", err) + os.Exit(1) + } + + workingTree, err := repo.Worktree() + if err != nil { + fmt.Printf("Error getting worktree from repo: %v\n", err) + os.Exit(1) + } + return repository, workingTree +} + +func addAndPushCerts() { + certs, err := os.ReadDir(config.GetAsString("Certificates.certs_path") + "/certificates") + if err != nil { + fmt.Printf("Error reading from directory: %v\n", err) + os.Exit(1) + } + for _, cert := range certs { + if strings.HasPrefix(cert.Name(), domain) { + file, err := fs.Create(cert.Name()) + if err != nil { + fmt.Printf("Error copying cert to memfs: %v\n", err) + os.Exit(1) + } + certFile, err := os.ReadFile(config.GetAsString("Certificates.certs_path") + "/certificates/" + cert.Name()) + certFile = encryptBytes(certFile) + _, err = file.Write(certFile) + err = file.Close() + if err != nil { + fmt.Printf("Error writing to memfs: %v\n", err) + os.Exit(1) + } + _, err = workTree.Add(cert.Name()) + if err != nil { + fmt.Printf("Error adding certificate %v: %v", cert.Name(), err) + os.Exit(1) + } + } + } + + status, err := workTree.Status() + if err != nil { + fmt.Printf("Error getting repo status: %v\n", err) + os.Exit(1) + } + fmt.Println("Work Tree Status:\n" + status.String()) + signature := &object.Signature{ + Name: "Cert Manager", + Email: config.GetAsString("Git.email"), + When: time.Now(), + } + _, err = workTree.Commit("Update "+domain+" @ "+time.Now().Format("Mon Jan _2 2006 15:04:05 MST"), &git.CommitOptions{Author: signature, Committer: signature}) + if err != nil { + fmt.Printf("Error committing certs: %v\n", err) + os.Exit(1) + } + err = repo.Push(&git.PushOptions{Auth: creds, Force: true, RemoteName: "origin"}) + if err != nil { + fmt.Printf("Error pushing to origin: %v\n", err) + os.Exit(1) + } + + fmt.Println("Successfully uploaded to " + config.GetAsString("Git.server") + "/" + config.GetAsString("Git.org_name") + "/" + domain + "-certificates.git") +} diff --git a/go.mod b/go.mod index df1f3ea..8ec5aed 100644 --- a/go.mod +++ b/go.mod @@ -19,6 +19,8 @@ require ( github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect github.com/go-git/go-billy/v5 v5.4.1 // indirect github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect + github.com/google/go-github/v55 v55.0.0 // indirect + github.com/google/go-querystring v1.1.0 // indirect github.com/hashicorp/go-version v1.2.1 // indirect github.com/imdario/mergo v0.3.15 // indirect github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect @@ -28,9 +30,9 @@ require ( github.com/skeema/knownhosts v1.1.1 // indirect github.com/src-d/gcfg v1.4.0 // indirect github.com/xanzy/ssh-agent v0.3.3 // indirect - golang.org/x/crypto v0.9.0 // indirect + golang.org/x/crypto v0.12.0 // indirect golang.org/x/net v0.10.0 // indirect - golang.org/x/sys v0.8.0 // indirect + golang.org/x/sys v0.11.0 // indirect gopkg.in/ini.v1 v1.67.0 // indirect gopkg.in/src-d/go-git.v4 v4.13.1 // indirect gopkg.in/warnings.v0 v0.1.2 // indirect diff --git a/go.sum b/go.sum index dc131bc..6b9c990 100644 --- a/go.sum +++ b/go.sum @@ -43,7 +43,12 @@ github.com/go-git/go-git/v5 v5.7.0/go.mod h1:coJHKEOk5kUClpsNlXrUvPrDxY3w3gjHvhc github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da h1:oI5xCqsCo564l8iNU+DwB5epxmsaqB+rhGL0m5jtYqE= github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= +github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= +github.com/google/go-github/v55 v55.0.0 h1:4pp/1tNMB9X/LuAhs5i0KQAE40NmiR/y6prLNb9x9cg= +github.com/google/go-github/v55 v55.0.0/go.mod h1:JLahOTA1DnXzhxEymmFF5PP2tSS9JVNj68mSZNDwskA= +github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8= +github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU= github.com/hashicorp/go-version v1.2.1 h1:zEfKbn2+PDgroKdiOzqiE8rsmLqU2uwi5PB5pBJ3TkI= github.com/hashicorp/go-version v1.2.1/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA= github.com/imdario/mergo v0.3.15 h1:M8XP7IuFNsqUx6VPK2P9OSmsYsI/YFaGil0uD21V3dM= @@ -102,6 +107,8 @@ golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0 golang.org/x/crypto v0.7.0/go.mod h1:pYwdfH91IfpZVANVyUOhSIPZaFoJGxTFbZhFTx+dXZU= golang.org/x/crypto v0.9.0 h1:LF6fAI+IutBocDJ2OT0Q1g8plpYljMZ4+lty+dsqw3g= golang.org/x/crypto v0.9.0/go.mod h1:yrmDGqONDYtNj3tH8X9dzUun2m2lzPa9ngI6/RUPGR0= +golang.org/x/crypto v0.12.0 h1:tFM/ta59kqch6LlvYnPa0yx5a83cL2nHflFhYKvv9Yk= +golang.org/x/crypto v0.12.0/go.mod h1:NF0Gs7EO5K4qLn+Ylc+fih8BSTeIjAP05siRnAh98yw= golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= @@ -138,11 +145,14 @@ golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.8.0 h1:EBmGv8NaZBZTWvrbjNoL6HVt+IVy3QDQpJs7VRIw3tU= golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.11.0 h1:eG7RXZHdqOJ1i+0lgLgCpSXAp6M3LYlAo6osgSi0xOM= +golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= golang.org/x/term v0.6.0/go.mod h1:m6U89DPEgQRMq3DNkDClhWw02AUbt2daBVO4cn4Hv9U= golang.org/x/term v0.8.0 h1:n5xxQn2i3PC0yLAbjTpNT85q/Kgzcr2gIoX9OrJUols= +golang.org/x/term v0.11.0 h1:F9tnn/DA/Im8nCwm+fX+1/eBwi4qFjRT++MhtVC4ZX0= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= @@ -151,6 +161,7 @@ golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.8.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= golang.org/x/text v0.9.0 h1:2sjJmO8cDvYveuX97RDLsxlyUxLl+GHoLxBiRdHllBE= +golang.org/x/text v0.12.0 h1:k+n5B8goJNdU7hSvEtMUz3d1Q6D/XW4COJSJR6fN0mc= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20190729092621-ff9f1409240a/go.mod h1:jcCCGcm9btYwXyDqrUWc6MKQKKGJCWEQ3AfLSRIbEuI= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= diff --git a/main.go b/main.go index 197aec0..c4cf449 100644 --- a/main.go +++ b/main.go @@ -5,21 +5,20 @@ import ( "code.gitea.io/sdk/gitea" "fmt" "git.nevets.tech/Steven/ezconf" - "github.com/go-git/go-git/v5/plumbing/object" - "io" - "os/exec" - "strings" - "time" - "github.com/go-git/go-billy/v5" "github.com/go-git/go-billy/v5/memfs" "github.com/go-git/go-git/v5" "github.com/go-git/go-git/v5/plumbing/transport/http" "github.com/go-git/go-git/v5/storage/memory" + "github.com/google/go-github/v55/github" + "io" "os" + "os/exec" + "strings" ) var config *ezconf.Configuration +var githubClient *github.Client var giteaClient *gitea.Client var domain string var legoBaseArgs []string @@ -31,19 +30,20 @@ var creds *http.BasicAuth var repo *git.Repository +//TODO create logic for domain based configs +//TODO create logic for gh vs gt repos + func main() { + makeDirs() + createConfig() +} + +func maindis() { + config = ezconf.NewConfiguration("/etc/certman/certman.conf") + var err error args := os.Args - // -c - hasConfig, configIndex := contains(args, "-c") - if hasConfig { - config = ezconf.NewConfiguration(args[configIndex+1]) - } else { - fmt.Printf("Error, no config passed. Please add '-c /path/to/config.ini' to the command\n") - os.Exit(1) - } - // -d hasDomain, domainIndex := contains(args, "-d") if hasDomain { @@ -82,11 +82,7 @@ func main() { Username: config.GetAsString("Git.username"), Password: config.GetAsString("Git.api_token"), } - giteaClient, err = gitea.NewClient(config.GetAsString("Git.server"), gitea.SetToken(config.GetAsString("Git.api_token"))) - if err != nil { - fmt.Printf("Error connecting to gitea instance: %v\n", err) - os.Exit(1) - } + giteaClient = createGiteaClient() storage = memory.NewStorage() fs = memfs.New() @@ -96,13 +92,13 @@ func main() { case "gen": { url := createGiteaRepo() - cloneRepo(url) + repo, workTree = cloneRepo(url) fixUpdateSh() cmd = exec.Command("lego", legoNewSiteArgs...) } case "renew": { - cloneRepo(config.GetAsString("Git.server") + "/" + config.GetAsString("Git.org_name") + "/" + domain + "-certificates.git") + repo, workTree = cloneRepo(config.GetAsString("Git.server") + "/" + config.GetAsString("Git.org_name") + "/" + domain + "-certificates.git") cmd = exec.Command("lego", legoRenewSiteArgs...) } case "gen-cert-only": @@ -116,7 +112,7 @@ func main() { case "git": { url := createGiteaRepo() - cloneRepo(url) + repo, workTree = cloneRepo(url) fixUpdateSh() addAndPushCerts() os.Exit(0) @@ -159,42 +155,6 @@ func main() { addAndPushCerts() } -func createGiteaRepo() string { - options := gitea.CreateRepoFromTemplateOption{ - Avatar: true, - Description: "Certificates storage for " + domain, - GitContent: true, - GitHooks: true, - Labels: true, - Name: domain + "-certificates", - Owner: config.GetAsString("Git.org_name"), - Private: true, - Topics: true, - Webhooks: true, - } - giteaRepo, _, err := giteaClient.CreateRepoFromTemplate(config.GetAsString("Git.org_name"), config.GetAsString("Git.template_name"), options) - if err != nil { - fmt.Printf("Error creating repo: %v\n", err) - os.Exit(1) - } - return giteaRepo.CloneURL -} - -func cloneRepo(url string) { - var err error - repo, err = git.Clone(storage, fs, &git.CloneOptions{URL: url, Auth: creds}) - if err != nil { - fmt.Printf("Error clone git repo: %v\n", err) - os.Exit(1) - } - - workTree, err = repo.Worktree() - if err != nil { - fmt.Printf("Error getting worktree from repo: %v\n", err) - os.Exit(1) - } -} - func fixUpdateSh() { oldUpdateSh, err := fs.Open("update.sh") if err != nil { @@ -222,60 +182,6 @@ func fixUpdateSh() { } } -func addAndPushCerts() { - //TODO integrate SOPS api when stable release - certs, err := os.ReadDir(config.GetAsString("Certificates.certs_path") + "/certificates") - if err != nil { - fmt.Printf("Error reading from directory: %v\n", err) - os.Exit(1) - } - for _, cert := range certs { - if strings.HasPrefix(cert.Name(), domain) { - file, err := fs.Create(cert.Name()) - if err != nil { - fmt.Printf("Error copying cert to memfs: %v\n", err) - os.Exit(1) - } - certFile, err := os.ReadFile(config.GetAsString("Certificates.certs_path") + "/certificates/" + cert.Name()) - _, err = file.Write(certFile) - err = file.Close() - if err != nil { - fmt.Printf("Error writing to memfs: %v\n", err) - os.Exit(1) - } - _, err = workTree.Add(cert.Name()) - if err != nil { - fmt.Printf("Error adding certificate %v: %v", cert.Name(), err) - os.Exit(1) - } - } - } - - status, err := workTree.Status() - if err != nil { - fmt.Printf("Error getting repo status: %v\n", err) - os.Exit(1) - } - fmt.Println("Work Tree Status:\n" + status.String()) - signature := &object.Signature{ - Name: "Cert Manager", - Email: "certs@nevets.tech", - When: time.Now(), - } - _, err = workTree.Commit("Update "+domain+" @ "+time.Now().Format("Mon Jan _2 2006 15:04:05 MST"), &git.CommitOptions{Author: signature, Committer: signature}) - if err != nil { - fmt.Printf("Error committing certs: %v\n", err) - os.Exit(1) - } - err = repo.Push(&git.PushOptions{Auth: creds, Force: true, RemoteName: "origin"}) - if err != nil { - fmt.Printf("Error pushing to origin: %v\n", err) - os.Exit(1) - } - - fmt.Println("Successfully uploaded to " + config.GetAsString("Git.server") + "/" + config.GetAsString("Git.org_name") + "/" + domain + "-certificates.git") -} - func contains(slice []string, value string) (sliceHas bool, index int) { for i, entry := range slice { if entry == value { diff --git a/util.go b/util.go new file mode 100644 index 0000000..480457f --- /dev/null +++ b/util.go @@ -0,0 +1,53 @@ +package main + +import ( + "code.gitea.io/sdk/gitea" + "fmt" + "git.nevets.tech/Steven/ezconf" + "github.com/google/go-github/v55/github" + "os" +) + +type Domain struct { + name *string + config *ezconf.Configuration + description *string + ghClient *github.Client + gtClient *gitea.Client +} + +func createFile(fileName string, data []byte) { + fileInfo, err := os.Stat(fileName) + if err != nil { + if os.IsNotExist(err) { + file, err := os.Create(fileName) + if err != nil { + fmt.Println("Error creating configuration file:", err) + os.Exit(1) + } + + _, err = file.Write(data) + if err != nil { + fmt.Println("Error writing to file;", err) + os.Exit(1) + } + } else { + fmt.Println("Error opening configuration file:", err) + os.Exit(1) + } + } else { + if fileInfo.Size() == 0 { + file, err := os.Create(fileName) + if err != nil { + fmt.Println("Error creating configuration file:", err) + os.Exit(1) + } + + _, err = file.Write(data) + if err != nil { + fmt.Println("Error writing to file:", err) + os.Exit(1) + } + } + } +}